From 6634d3ef873e086b3d423b705415378a4dbad5f1 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Mon, 3 Aug 2026 13:44:30 -0400 Subject: [PATCH] The Beast Roofer Edition v1.2.4 Removes a bug introduced in v1.2.3: the DLSS clip-space jitter block was emitted into every vertex shader and read the Position output before writing it. Reading an unwritten output is undefined, so vertex positions could come out as garbage - whole 3D passes landing nowhere (the green screen reported on GYLT, Ghostbusters, Blair Witch and others) and the black block artifacts in Xenoblade Chronicles 2's motion-blur pass, which had been mistaken for a long-standing emulation defect and worked around by disabling the effect. With jitter off - how this fork ships - the vertex epilogue is now identical to the base emulator again, and the motion blur no longer needs to be disabled. CodeGenVersion bumped so existing shader caches are rebuilt. Also adds a DLSS sharpening slider (0-100, off by default) and an experimental advanced motion stack checkbox (off by default). --- docs/RELEASE-NOTES-v1.2.3.md | 202 + docs/RELEASE-NOTES-v1.2.4.md | 139 + src/Ryujinx.Graphics.GAL/DlssCameraState.cs | 73 +- .../Multithreading/CommandHelper.cs | 1 + .../Multithreading/CommandType.cs | 1 + .../Buffer/BufferSetDataBatchCommand.cs | 66 + .../Multithreading/ThreadedRenderer.cs | 94 + .../Engine/Compute/ComputeClass.cs | 1 + .../Engine/Dma/DmaClass.cs | 4 + .../Engine/Threed/ConstantBufferUpdater.cs | 32 +- .../Engine/Threed/DrawManager.cs | 49 +- .../Engine/Threed/MvppCamTrace.cs | 136 + .../Engine/Threed/MvppCameraCapture.cs | 397 ++ .../Engine/Threed/MvppCompInputsProbe.cs | 92 + .../Engine/Threed/MvppCtxProbe.cs | 597 +++ .../Engine/Threed/MvppDofSkip.cs | 215 + .../Engine/Threed/MvppDrawStepProbe.cs | 378 ++ .../Engine/Threed/MvppGlowProbe.cs | 181 + .../Engine/Threed/MvppHangWatch.cs | 152 + .../Engine/Threed/MvppHazardProbe.cs | 167 + .../Engine/Threed/MvppLayoutProbe.cs | 79 + .../Engine/Threed/MvppLdrSkip.cs | 550 +++ .../Engine/Threed/MvppPreSyncProbe.cs | 208 + .../Engine/Threed/MvppProjAudit.cs | 165 + .../Engine/Threed/MvppRtDumpProbe.cs | 371 ++ .../Engine/Threed/MvppScanProbe.cs | 1733 ++++++++ .../Engine/Threed/MvppScenePass.cs | 204 + .../Engine/Threed/MvppSoloCamera.cs | 3504 +++++++++++++++++ .../Engine/Threed/MvppUiProbe.cs | 378 +- .../Engine/Threed/MvppViewportProbe.cs | 441 +++ .../Engine/Threed/SemaphoreUpdater.cs | 9 +- .../Engine/Threed/StateUpdater.cs | 178 +- .../Engine/Twod/MvppTwodProbe.cs | 131 + .../Engine/Twod/TwodClass.cs | 9 + .../Image/AutoDeleteCache.cs | 42 + .../Image/MvppBuilderInProbe.cs | 531 +++ .../Image/MvppCacheProbe.cs | 150 + .../Image/MvppConstDiffProbe.cs | 144 + .../Image/MvppContentProbe.cs | 152 + .../Image/MvppDofProbe.cs | 166 + .../Image/MvppFeedbackProbe.cs | 199 + .../Image/MvppFullSyncProbe.cs | 129 + .../Image/MvppGobProbe.cs | 96 + .../Image/MvppMap64Probe.cs | 269 ++ .../Image/MvppMvBufProbe.cs | 255 ++ .../Image/MvppMvSyncProbe.cs | 120 + .../Image/MvppScenePassProbe.cs | 184 + .../Image/MvppTaaProbe.cs | 170 + .../Image/MvppTraceProbe.cs | 176 + .../Image/MvppTwinFixProbe.cs | 105 + .../Image/MvppTwinMapProbe.cs | 396 ++ .../Image/MvppTwinXferProbe.cs | 182 + .../Image/MvppViewAliasProbe.cs | 115 + .../Image/MvppWriterCensusProbe.cs | 147 + src/Ryujinx.Graphics.Gpu/Image/Sampler.cs | 14 + src/Ryujinx.Graphics.Gpu/Image/Texture.cs | 25 +- .../Image/TextureBindingsManager.cs | 109 +- .../Image/TextureCache.cs | 9 + .../Image/TextureGroup.cs | 7 +- .../Image/TextureManager.cs | 23 + src/Ryujinx.Graphics.Gpu/Image/TexturePool.cs | 20 +- src/Ryujinx.Graphics.Gpu/Memory/Buffer.cs | 131 +- .../Memory/BufferCache.cs | 3 + .../Memory/BufferManager.cs | 9 + .../Memory/MvppPalProbe.cs | 300 ++ .../Memory/MvppUpVolProbe.cs | 186 + .../Memory/SupportBufferUpdater.cs | 10 + .../Shader/DiskCache/DiskCacheHostStorage.cs | 23 +- .../Shader/ShaderCache.cs | 15 + src/Ryujinx.Graphics.Gpu/Window.cs | 110 + src/Ryujinx.Graphics.OpenGL/Framebuffer.cs | 6 + .../MvppGlDumpProbe.cs | 129 + src/Ryujinx.Graphics.OpenGL/Pipeline.cs | 1 + .../CodeGen/Spirv/Instructions.cs | 29 + .../CodeGen/Spirv/SpirvDelegates.cs | 6 + .../Instructions/InstEmitConversion.cs | 10 + .../Instructions/InstEmitFloatArithmetic.cs | 92 + .../Instructions/InstEmitMultifunction.cs | 96 +- .../Instructions/InstEmitTexture.cs | 378 ++ .../ShaderProgramInfo.cs | 9 +- .../Translation/CocConstProbe.cs | 58 + .../Translation/EmitterContext.cs | 61 + .../Translation/HashTestProbe.cs | 46 + .../Translation/MufuPrecProbe.cs | 77 + .../Translation/MvKillProbe.cs | 53 + .../Translation/MvResolveMvProbe.cs | 45 + .../Translation/MvSignProbe.cs | 50 + .../Translation/MvppForceLod0Probe.cs | 42 + .../Translation/MvppJitterEmit.cs | 30 + .../Translation/MvppNClampProbe.cs | 47 + .../Translation/MvppNMinMaxProbe.cs | 44 + .../Translation/MvppTileCapProbe.cs | 52 + .../Translation/MvppTruncEpsProbe.cs | 56 + .../Translation/NanScrubProbe.cs | 109 + .../Optimizations/BindlessElimination.cs | 17 + .../Translation/PeriscopeProbe.cs | 38 + .../Translation/RroReduceProbe.cs | 27 + .../Translation/SkyMvProbe.cs | 55 + .../Translation/TileMapConstProbe.cs | 58 + .../Translation/TranslatorContext.cs | 18 +- .../DescriptorSetUpdater.cs | 31 + .../Dlss/DlssIntegration.cs | 21 + .../Dlss/DlssSharpenPass.cs | 125 + .../Dlss/DlssUpscaler.cs | 826 +++- .../Dlss/StreamlineFrameGen.cs | 28 +- .../Effects/Shaders/DlssSharpenLinear.spv | Bin 0 -> 20372 bytes .../Effects/Shaders/FsrSharpening.glsl | 4 + .../Effects/Shaders/MvppReproject.glsl | 132 +- .../Effects/Shaders/MvppReproject.spv | Bin 44832 -> 49896 bytes .../MvppReproject.spv.bak_avant_depthsonde | Bin 0 -> 41920 bytes .../MvppReproject.spv.bak_avant_dynsonde | Bin 0 -> 38064 bytes .../MvppReproject.spv.bak_avant_flowgate | Bin 0 -> 29412 bytes .../MvppReproject.spv.bak_avant_skydepth | Bin 0 -> 44832 bytes .../MvppReproject.spv.bak_avant_skyflow | Bin 0 -> 26776 bytes .../MvppReproject.spv.bak_avant_subgroup | Bin 0 -> 35104 bytes .../MvppReproject.spv.bak_depthsonde_v1 | Bin 0 -> 43844 bytes .../FramebufferParams.cs | 21 +- .../MemoryAllocation.cs | 6 + .../MvppClearRectProbe.cs | 77 + .../MvppDecompProbe.cs | 170 + .../MvppDescTruthProbe.cs | 82 + .../MvppDofStateProbe.cs | 83 + .../MvppDrawClearProbe.cs | 77 + .../MvppHistWatchProbe.cs | 137 + .../MvppMemAliasProbe.cs | 168 + .../MvppMvDumpProbe.cs | 230 ++ src/Ryujinx.Graphics.Vulkan/MvppNoFblProbe.cs | 48 + .../MvppNoFragMask0Probe.cs | 54 + src/Ryujinx.Graphics.Vulkan/MvppOmapMaskVk.cs | 65 + .../MvppOmapVentProbe.cs | 183 + .../MvppReadBarProbe.cs | 51 + .../MvppSampStoreProbe.cs | 99 + .../MvppStorageBarrierProbe.cs | 138 + .../MvppVkDropProbe.cs | 101 + src/Ryujinx.Graphics.Vulkan/MvppVkImgProbe.cs | 242 ++ src/Ryujinx.Graphics.Vulkan/PipelineBase.cs | 150 +- src/Ryujinx.Graphics.Vulkan/PipelineFull.cs | 17 +- src/Ryujinx.Graphics.Vulkan/PipelineState.cs | 88 +- .../RenderSyncSwitch.cs | 74 + .../Ryujinx.Graphics.Vulkan.csproj | 1 + .../ShaderCollection.cs | 5 + src/Ryujinx.Graphics.Vulkan/TextureStorage.cs | 42 +- src/Ryujinx.Graphics.Vulkan/TextureView.cs | 17 +- .../UnsafeBlitProbe.cs | 156 + .../VulkanInitialization.cs | 53 + src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs | 28 +- src/Ryujinx.Graphics.Vulkan/Window.cs | 268 +- src/Ryujinx.HLE/FileSystem/ContentManager.cs | 14 +- .../HOS/Services/Fs/IFileSystemProxy.cs | 9 +- .../Systems/AppLibrary/ApplicationLibrary.cs | 16 + src/Ryujinx/Systems/DlssUiSettings.cs | 187 +- .../UI/ViewModels/SettingsViewModel.cs | 38 +- .../Views/Settings/SettingsGraphicsView.axaml | 21 + 153 files changed, 21681 insertions(+), 100 deletions(-) create mode 100644 docs/RELEASE-NOTES-v1.2.3.md create mode 100644 docs/RELEASE-NOTES-v1.2.4.md create mode 100644 src/Ryujinx.Graphics.GAL/Multithreading/Commands/Buffer/BufferSetDataBatchCommand.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCamTrace.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCompInputsProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCtxProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDofSkip.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDrawStepProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppGlowProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHangWatch.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHazardProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLayoutProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLdrSkip.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppPreSyncProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppProjAudit.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppRtDumpProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScanProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScenePass.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppSoloCamera.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppViewportProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Engine/Twod/MvppTwodProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppBuilderInProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppCacheProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppConstDiffProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppContentProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppDofProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppFeedbackProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppFullSyncProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppGobProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppMap64Probe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppMvBufProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppMvSyncProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppScenePassProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppTaaProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppTraceProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppTwinFixProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppTwinMapProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppTwinXferProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppViewAliasProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Image/MvppWriterCensusProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Memory/MvppPalProbe.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Memory/MvppUpVolProbe.cs create mode 100644 src/Ryujinx.Graphics.OpenGL/MvppGlDumpProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/CocConstProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/HashTestProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MufuPrecProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvKillProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvResolveMvProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvSignProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvppForceLod0Probe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvppJitterEmit.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvppNClampProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvppNMinMaxProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvppTileCapProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/MvppTruncEpsProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/NanScrubProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/PeriscopeProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/RroReduceProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/SkyMvProbe.cs create mode 100644 src/Ryujinx.Graphics.Shader/Translation/TileMapConstProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/DlssSharpenPass.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/DlssSharpenLinear.spv create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_depthsonde create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_dynsonde create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_flowgate create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skydepth create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skyflow create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_subgroup create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_depthsonde_v1 create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppClearRectProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppDecompProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppDescTruthProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppDofStateProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppDrawClearProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppHistWatchProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppMemAliasProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppMvDumpProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppNoFblProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppNoFragMask0Probe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppOmapMaskVk.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppOmapVentProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppReadBarProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppSampStoreProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppStorageBarrierProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppVkDropProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/MvppVkImgProbe.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/RenderSyncSwitch.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/UnsafeBlitProbe.cs diff --git a/docs/RELEASE-NOTES-v1.2.3.md b/docs/RELEASE-NOTES-v1.2.3.md new file mode 100644 index 000000000..4b79adab7 --- /dev/null +++ b/docs/RELEASE-NOTES-v1.2.3.md @@ -0,0 +1,202 @@ +# Release notes v1.2.3 — The Beast Roofer Edition (EN + FR) + +_Draft for the Gitea release page. Bilingual per fork rule. No internal jargon._ + +--- + +## English + +### Improved: cloud and sky ghosting with DLSS — now also with native-resolution 4K mods + +Two improvements in one: + +- **Native-resolution configurations** (emulator resolution 1x + an in-engine 4K render + mod, e.g. TOTK Optimizer at 3840x2160): the DLSS motion-quality system was **silently + inactive** in this configuration — clouds and sky ghosted exactly as raw DLSS. It now + engages in every resolution configuration, scaled or native. +- **Camera zooms** (aiming a bow, spyglass): the anti-transition protection used to mute + the sky handling during every zoom, letting clouds ghost briefly in broad daylight. It + now only triggers on real scene cuts (teleports, shrine transitions), never on zooms. + +**Known behavior:** during roughly the first minute after loading a save, some cloud +ghosting can appear, fade, and come back while the system arms itself (it has to find the +game's camera, its depth buffer, and build up motion history). It then settles for the +rest of the session. This release is an **improvement, not perfection** — brief cloud +ghosting can still occur in specific moments. + +### Improved: black lines / dark frame echo during camera movement + +The recurring "thin black frame" some players saw while panning the camera had a real +mechanism behind it: the game leaves a 2-pixel black border on its rendered image at +scaled resolutions, and the DLSS history smears that border inward during camera motion — +a dotted echo proportional to pan speed. The border is now repaired before DLSS ever sees +the image. As a side effect, cloud trails that were fed by the same polluted history are +also reduced. + +### Fixed: crash when toggling fullscreen (F11) or resizing the window with DLSS active + +Toggling fullscreen or resizing while DLSS ran could crash the emulator (GPU errors on the +swapchain recreation path). The recreation sequence was rebuilt — the old chain and its +views now retire only once the display is done with them, and the acquire loop follows the +Vulkan rules to the letter. Validated with hundreds of consecutive fullscreen toggles. + +### New: Frame Generation self-recovery + +Frame Generation could silently stop injecting frames (display back to 1x, no error +anywhere) until a fullscreen toggle happened to revive it. A watchdog now detects that +state and applies the same remedy automatically within ~8 seconds. If you ever need to +disable it: set the environment variable `RYUJINX_DLSS_FG_WATCHDOG=0`. + +### Fixed: game list could come up empty after a crash + +If the emulator was killed or crashed at the exact moment it was saving its configuration, +the config file could be truncated — and your game directories list came up empty on the +next launch. Configuration files are now written atomically: a crash can no longer damage +them. + +### Fixed: changing DLSS settings could restart with a partial state + +Changing certain DLSS settings restarts the emulator; the restart could come back up with +only part of the motion-quality profile applied (cloud ghosting returning, border fix +lost). The full profile is now re-derived from scratch on every restart. + +### Fixed: texture corruption with "Force mipmaps" + native-resolution 4K mods + +With the (off by default) *Force mipmaps* option enabled **and** an in-engine 4K render +mod at native emulator resolution, procedural textures (shrine portals, some terrain) +could show purple/rainbow corruption, most often around shrine transitions. Cause: mip +levels were being generated behind small render targets the game redraws every frame. Such +textures are now excluded automatically the moment the game renders into them. Art +textures keep the full benefit of the option. + +### Better diagnostics + +When the DLSS motion-quality system cannot run, it now says why in the log ("camera not +paired", "depth not paired"...) instead of staying silent. If you report an issue, your +log will tell us much more than before. + +### Reminder — hardware requirements for DLSS + +A lot of reports boil down to this, so let's be explicit: + +- **DLSS and DLAA require an NVIDIA RTX GPU** (RTX 20 series or newer). They **cannot** + work on GTX cards (10/16 series) — the hardware simply lacks the units DLSS runs on. + This is an NVIDIA limitation, not something the fork can patch around. +- **Frame Generation requires an RTX 40 series or newer.** +- **No RTX card? Use NIS** (NVIDIA Image Scaling, included in this fork): it runs on any + GPU and remains the recommended upscaler for GTX and non-NVIDIA cards. +- A small number of RTX configurations still crash when DLSS activates; this is under + active investigation — see the pinned diagnostic-build ticket if you are affected. + +### Testing scope + +This release was built and validated on a single machine — Zelda TOTK and BOTW as main +test games, both scaled and native-4K-mod configurations, plus a package-level test of +the exact archives published here. Other +GPUs, drivers and games may behave differently: if something regresses for you, open an +issue with your log and it becomes tomorrow's fix. + +--- + +## Français + +### Amélioré : ghosting des nuages et du ciel avec DLSS — maintenant aussi avec les mods 4K en résolution native + +Deux améliorations en une : + +- **Configurations en résolution native** (résolution émulateur 1x + un mod de rendu 4K + in-engine, ex. TOTK Optimizer en 3840x2160) : le système de qualité de mouvement DLSS + était **silencieusement inactif** dans cette configuration — les nuages et le ciel + ghostaient comme avec le DLSS brut. Il s'active désormais dans toutes les configurations + de résolution, scalées ou natives. +- **Zooms de caméra** (visée à l'arc, longue-vue) : la protection anti-transitions coupait + le traitement du ciel pendant chaque zoom, laissant les nuages ghoster brièvement en + plein jour. Elle ne se déclenche plus que sur les vraies coupures de scène (téléportations, + transitions de sanctuaire), jamais sur les zooms. + +**Comportement connu :** pendant environ la première minute après le chargement d'une +partie, un peu de ghosting de nuages peut apparaître, s'estomper et revenir pendant que le +système s'arme (il doit trouver la caméra du jeu, son tampon de profondeur, et bâtir son +historique de mouvement). Ensuite ça se stabilise pour le reste de la session. Cette +version est une **amélioration, pas la perfection** — un bref ghosting de nuages reste +possible dans certains moments précis. + +### Amélioré : lignes noires / écho de cadre sombre pendant les mouvements de caméra + +Le fameux « cadre noir fin » que certains joueurs voyaient en tournant la caméra avait un +vrai mécanisme derrière : le jeu laisse une bordure noire de 2 pixels sur son image aux +résolutions scalées, et l'historique DLSS étale cette bordure vers l'intérieur pendant le +mouvement — un écho pointillé proportionnel à la vitesse du pan. La bordure est désormais +réparée avant que DLSS ne voie l'image. Effet de bord bienvenu : les traînées de nuages +nourries par ce même historique pollué sont réduites elles aussi. + +### Corrigé : plantage au passage plein écran (F11) ou au redimensionnement avec DLSS actif + +Basculer en plein écran ou redimensionner la fenêtre pendant que DLSS tournait pouvait +faire planter l'émulateur (erreurs GPU sur le chemin de recréation du swapchain). La +séquence de recréation a été rebâtie — l'ancienne chaîne et ses vues ne partent que quand +l'affichage en a fini avec elles, et la boucle d'acquisition suit les règles Vulkan à la +lettre. Validé avec des centaines de bascules plein écran consécutives. + +### Nouveau : auto-récupération de la génération d'images (Frame Generation) + +La génération d'images pouvait s'arrêter en silence (affichage revenu à 1x, aucune erreur +nulle part) jusqu'à ce qu'un passage plein écran la ressuscite par hasard. Un chien de +garde détecte maintenant cet état et applique le même remède automatiquement en ~8 +secondes. Pour le désactiver au besoin : variable d'environnement +`RYUJINX_DLSS_FG_WATCHDOG=0`. + +### Corrigé : liste de jeux vide après un plantage + +Si l'émulateur était tué ou plantait au moment exact où il sauvegardait sa configuration, +le fichier de config pouvait être tronqué — et votre liste de dossiers de jeux revenait +vide au lancement suivant. Les fichiers de configuration sont désormais écrits de façon +atomique : un plantage ne peut plus les abîmer. + +### Corrigé : changer les réglages DLSS pouvait redémarrer avec un état partiel + +Changer certains réglages DLSS redémarre l'émulateur ; le redémarrage pouvait revenir avec +seulement une partie du profil de qualité de mouvement appliquée (ghosting de nuages de +retour, correctif de bordure perdu). Le profil complet est maintenant reconstruit de zéro +à chaque redémarrage. + +### Corrigé : corruption de textures avec « Forcer les mipmaps » + mods 4K en résolution native + +Avec l'option *Forcer les mipmaps* (désactivée par défaut) **et** un mod de rendu 4K +in-engine en résolution émulateur native, des textures procédurales (portails de +sanctuaire, certains terrains) pouvaient montrer une corruption mauve/arc-en-ciel, surtout +autour des transitions de sanctuaire. Cause : des niveaux de mip étaient générés derrière +de petites cibles de rendu que le jeu redessine chaque frame. Ces textures sont désormais +exclues automatiquement dès que le jeu dessine dedans. Les textures d'art gardent tout le +bénéfice de l'option. + +### Meilleurs diagnostics + +Quand le système de qualité de mouvement DLSS ne peut pas tourner, il dit maintenant +pourquoi dans le journal (« caméra non appariée », « profondeur non appariée »...) au lieu +de rester muet. Si vous rapportez un problème, votre journal nous en dira beaucoup plus +qu'avant. + +### Rappel — matériel requis pour le DLSS + +Beaucoup de rapports se résument à ceci, alors soyons explicites : + +- **Le DLSS et le DLAA exigent une carte NVIDIA RTX** (série RTX 20 ou plus récente). Ils + **ne peuvent pas** fonctionner sur les cartes GTX (séries 10/16) — le matériel n'a tout + simplement pas les unités sur lesquelles le DLSS tourne. C'est une limite NVIDIA, pas + quelque chose que le fork peut contourner. +- **La génération d'images (Frame Generation) exige une RTX série 40 ou plus récente.** +- **Pas de carte RTX ? Utilisez NIS** (NVIDIA Image Scaling, inclus dans ce fork) : il + fonctionne sur n'importe quel GPU et reste l'upscaler recommandé pour les cartes GTX et + non-NVIDIA. +- Un petit nombre de configurations RTX plantent encore à l'activation du DLSS ; c'est en + cours d'investigation active — voyez le ticket épinglé de build diagnostic si vous êtes + concerné. + +### Portée des tests + +Cette version a été bâtie et validée sur une seule machine — Zelda TOTK et BOTW comme +jeux de test principaux, en configurations scalée et 4K natif avec mod, plus un test au +niveau du paquet sur les archives exactes publiées ici. D'autres GPU, pilotes et jeux peuvent se comporter différemment : si quelque chose +régresse chez vous, ouvrez un ticket avec votre journal et ça devient le correctif de +demain. diff --git a/docs/RELEASE-NOTES-v1.2.4.md b/docs/RELEASE-NOTES-v1.2.4.md new file mode 100644 index 000000000..30d367651 --- /dev/null +++ b/docs/RELEASE-NOTES-v1.2.4.md @@ -0,0 +1,139 @@ +# Release notes v1.2.4 — The Beast Roofer Edition (EN + FR) + +_Pour la page de release Gitea. Bilingue (règle du fork). Sans jargon interne._ + +--- + +## English + +I am shipping this update because I caught a bug I had introduced myself in v1.2.3, the +one that turns the screen green in some games. It could in principle affect any game, so +please take this update even if yours looked fine. + +One correction while I am at it: the black block artifacts in Xenoblade Chronicles 2, the +ones I had described as a long-standing emulation defect and worked around by skipping the +motion-blur pass, were caused by that same bug of mine. They were never an old emulator +problem, they were mine. They are gone now, and the motion blur stays on. + +### Fixed: green screen in several games (GYLT, Ghostbusters: Spirits Unleashed, and others) + +Some games — several Unreal Engine titles among them — rendered their menus and videos +correctly but showed a green (or corrupted) screen the moment 3D gameplay started. The +cause was a real bug in this fork, introduced in v1.2.3: a constant used by the DLSS +system was never delivered to the GPU when DLSS jitter was off, and every 3D shader ended +up reading uninitialized memory. Depending on what the graphics driver had left in that +memory, a game could look perfectly fine, glitch occasionally, or go fully green. + +The offending code has been removed: with jitter off — which is how this fork ships — the +vertex shaders are now translated exactly as the base emulator does, so there is nothing +left to corrupt. To be clear about what this is: we broke it, and we undid it. + +The same bug also produced black block artifacts in motion-blur scenes on this fork (that +pass expands points into quads; hand it undefined vertex positions and it draws blocks). +Those are gone too, and the motion blur no longer has to be disabled to avoid them. +Verified on the previously-broken games. If a specific title still shows issues, it is a +separate cause — please report it on the community. + +**Every v1.2.3 user should take this update**: the bug could in principle touch any +game, even ones that appeared to work. + +### New: DLSS Sharpening slider + +A **DLSS Sharpening** slider (0–100) in Settings → Graphics, right under Frame Generation. +It applies the same contrast-adaptive sharpening used by the FSR filter, directly on the +DLSS output — HDR is handled correctly and it costs almost nothing. 0 = off (the default; +existing setups are unchanged). Around 25–40 is subtle; higher is noticeably crisper but +can make halos and shimmer more visible in motion. Applied on the next launch, like the +other DLSS settings. + +An experimental `Advanced motion stack` checkbox is also present, **off by default**. It +is validated on one engine family only and can add trembling in motion elsewhere — leave +it off unless you want to experiment. + +### Known issue: Frame Generation still produces ghost images + +With Frame Generation enabled, ghost or "temporal" images can still appear — doubled +edges and trails on moving content. **Turning Frame Generation off removes them.** This +is the number one thing being worked on right now: the generated frames are built from +motion vectors we reconstruct, and those describe the camera better than they describe +individual moving objects. If ghosting bothers you more than the extra smoothness helps, +set Frame Generation to Off in the graphics settings. + +### Known issue: Xenoblade Chronicles 2 can hang while loading + +On Xenoblade Chronicles 2, loading (or the menu) occasionally stops progressing — the +picture keeps drawing, but the game stops advancing. Closing and reopening the emulator +clears it. We captured this live several times and it is **not solved yet**: the freeze +happens on the game's own side, and it predates the changes in this release. If you hit +it, closing and relaunching is the workaround for now. + +--- + +## Français + +J'envoie cette mise à jour parce que j'ai attrapé un bug que j'avais moi-même introduit en +v1.2.3, celui qui rend l'écran vert dans certains jeux. Il pouvait en principe toucher +n'importe quel jeu, alors prenez la mise à jour même si le vôtre semblait correct. + +Une correction au passage : les blocs noirs dans Xenoblade Chronicles 2, ceux que j'avais +décrits comme un vieux défaut d'émulation et que je contournais en désactivant la passe de +flou de mouvement, venaient du même bug — le mien. Ça n'a jamais été un problème ancien de +l'émulateur, c'était moi. Ils sont partis, et le flou de mouvement reste actif. + +### Corrigé : écran vert dans plusieurs jeux (GYLT, Ghostbusters: Spirits Unleashed, et d'autres) + +Certains jeux — plusieurs titres Unreal Engine notamment — affichaient correctement leurs +menus et vidéos, mais montraient un écran vert (ou corrompu) dès que la 3D commençait. +La cause était un vrai bug de ce fork, introduit en v1.2.3 : une constante du système +DLSS n'était jamais livrée au GPU quand le jitter DLSS était éteint, et tous les shaders +3D lisaient de la mémoire non initialisée. Selon ce que le pilote graphique avait laissé +dans cette mémoire, un jeu pouvait sembler parfait, glitcher par moments, ou devenir +entièrement vert. + +Le code fautif a été retiré : avec le jitter éteint — c'est ainsi que ce fork est livré — +les vertex shaders sont désormais traduits exactement comme le fait l'émulateur de base, +il n'y a donc plus rien à corrompre. Pour être clair sur ce que c'est : on l'avait cassé, +on l'a défait. + +Le même bug produisait aussi les blocs noirs dans les scènes avec flou de mouvement sur ce +fork (cette passe dilate des points en carrés ; donnez-lui des positions indéfinies, elle +dessine des blocs). Ils disparaissent également, et il n'est plus nécessaire de désactiver +le flou de mouvement pour les éviter. Vérifié sur les jeux qui étaient cassés. Si un titre +précis montre encore des problèmes, c'est une cause distincte — signalez-le sur la +communauté. + +**Tous les utilisateurs de la v1.2.3 devraient prendre cette mise à jour** : le bug +pouvait en principe toucher n'importe quel jeu, même ceux qui semblaient fonctionner. + +### Nouveau : curseur de netteté DLSS + +Un curseur **DLSS Sharpening** (0–100) dans Paramètres → Graphismes, juste sous la Frame +Generation. Il applique la même netteté adaptative que le filtre FSR, directement sur la +sortie DLSS — le HDR est géré correctement et le coût est négligeable. 0 = désactivé (le +défaut ; les configurations existantes ne changent pas). Autour de 25–40 c'est subtil ; +plus haut, c'est nettement plus croustillant mais les halos et le scintillement en +mouvement deviennent plus visibles. Appliqué au prochain lancement, comme les autres +réglages DLSS. + +Une case `Advanced motion stack` expérimentale est également présente, **désactivée par +défaut**. Elle n'est validée que sur une famille de moteur et peut au contraire ajouter du +tremblement en mouvement ailleurs — laissez-la éteinte sauf si vous voulez expérimenter. + +### Limite connue : la Frame Generation produit encore des images fantômes + +Avec la Frame Generation activée, des images fantômes ou « temporelles » peuvent encore +apparaître — contours dédoublés et traînées sur ce qui bouge. **Désactiver la Frame +Generation les fait disparaître.** C'est le chantier numéro un en ce moment : les images +générées sont construites à partir de vecteurs de mouvement que nous reconstruisons, et +ceux-ci décrivent mieux la caméra que les objets qui bougent individuellement. Si les +fantômes vous gênent plus que la fluidité supplémentaire ne vous apporte, mettez la Frame +Generation sur Off dans les paramètres graphiques. + +### Limite connue : Xenoblade Chronicles 2 peut se bloquer au chargement + +Sur Xenoblade Chronicles 2, le chargement (ou le menu) cesse parfois d'avancer — l'image +continue de s'afficher, mais le jeu n'avance plus. Fermer et rouvrir l'émulateur règle le +problème. Nous l'avons capturé en direct à plusieurs reprises et il **n'est pas encore +résolu** : le blocage se produit du côté du jeu lui-même, et il est antérieur aux +changements de cette version. Si ça vous arrive, fermer et relancer reste le contournement +pour l'instant. diff --git a/src/Ryujinx.Graphics.GAL/DlssCameraState.cs b/src/Ryujinx.Graphics.GAL/DlssCameraState.cs index d9e21c03f..02165c79f 100644 --- a/src/Ryujinx.Graphics.GAL/DlssCameraState.cs +++ b/src/Ryujinx.Graphics.GAL/DlssCameraState.cs @@ -38,6 +38,14 @@ namespace Ryujinx.Graphics.GAL public static Matrix4x4 PresentVp; public static bool PresentVpValid; + /// + /// [GAMEJITTER 28/07] Le decalage sous-pixel que LE JEU appliquait a l'image presentee, en + /// NDC (x_ndc += Jx). Republie au dequeue, comme , donc apparie a la + /// meme image par le meme ordre. En pixels : Jx * largeurRendu / 2. + /// + public static float PresentJitterX; + public static float PresentJitterY; + // Present hand-off (GPU thread -> render thread). TryConsumeOrdered below pairs the game // camera to the presented frame ON the GPU thread; this ring carries that paired result // across to the render thread. A single static slot raced here: the GPU thread overwrote @@ -46,14 +54,18 @@ namespace Ryujinx.Graphics.GAL // exactly the heavy scenes where DLSS matters most). Order does the pairing instead: one // entry enqueued per GAL present (GPU thread), one dequeued per executed present (render // thread), so a lagging consumer still reads the camera of the frame it is presenting. - private static readonly ConcurrentQueue<(Matrix4x4 Vp, bool Valid)> _presentRing = new(); + private static readonly ConcurrentQueue<(Matrix4x4 Vp, bool Valid, float Jx, float Jy)> _presentRing = new(); + + public static void PublishPresent(in Matrix4x4 vp, bool valid) => PublishPresent(in vp, valid, 0f, 0f); /// /// Enqueues the camera paired with the frame being enqueued for presentation (GPU thread). + /// [GAMEJITTER] Le decalage sous-pixel voyage dans le MEME anneau, donc il ne peut pas se + /// desynchroniser de la camera ni de l'image. La surcharge sans decalage pousse zero. /// - public static void PublishPresent(in Matrix4x4 vp, bool valid) + public static void PublishPresent(in Matrix4x4 vp, bool valid, float jx, float jy) { - _presentRing.Enqueue((vp, valid)); + _presentRing.Enqueue((vp, valid, jx, jy)); // A consumer-less backend (OpenGL, or presents executed before the consumer exists) // must not grow the ring unbounded: keep the same short resync window as the ordered @@ -72,10 +84,12 @@ namespace Ryujinx.Graphics.GAL /// public static void ConsumePresent() { - if (_presentRing.TryDequeue(out (Matrix4x4 Vp, bool Valid) entry)) + if (_presentRing.TryDequeue(out (Matrix4x4 Vp, bool Valid, float Jx, float Jy) entry)) { PresentVp = entry.Vp; PresentVpValid = entry.Valid; + PresentJitterX = entry.Jx; + PresentJitterY = entry.Jy; } } @@ -120,6 +134,46 @@ namespace Ryujinx.Graphics.GAL /// public static bool GuestDepthMinusOneToOne; + /// + /// [CUTONJUMP 27/07] Incremented by the camera source whenever the guest's camera makes a + /// jump no continuous movement can explain - a warp, a zone load, a cutscene taking over. + /// The upscaler compares it against the value it last saw and, on a change, resets its + /// history for that frame. + /// + /// It is a SEQUENCE and not a flag on purpose: a flag can be set and cleared between two + /// reads and vanish, and it also makes "how many jumps happened" unanswerable. A counter + /// only ever moves forward, so the consumer cannot miss one and the count is a diagnostic + /// on its own. + /// + /// This carries no world units and no threshold: the decision is taken at the source, + /// where the camera's own recent motion provides the scale. See MvppSoloCamera. + /// + public static int TeleportSeq; + + /// + /// [DUPSKIP 02/08] Séquence des présentations invitées portant AU MOINS UN dessin (une + /// « vraie » image rendue). Producteur : MvppCameraCapture à la frontière de present + /// (Interlocked, gaté RYUJINX_DLSS_DUPSKIP). Consommateur : DlssUpscaler.TryRun — si la + /// séquence n'a pas bougé depuis son dernier Evaluate, la présentation courante est un + /// DOUBLON (le jeu re-présente sans avoir rendu) : on re-blitte la sortie précédente au + /// lieu de ré-accumuler le temporel dessus (journal (348)-(350) : BOTW ~30 vraies + /// images/s en rotation caméra, présentées ~60 → « images fantômes »). Même patron que + /// TeleportSeq — une séquence, pas un drapeau (et PAS la FIFO : voir l'avertissement + /// du 28/07 ci-dessous). 0 = producteur jamais armé → le consommateur reste inerte + /// (garde-fou anti-gel). + /// + public static long RenderedFrameSeq; + + // ⛔ NE PAS CHANGER LE TYPE D'ELEMENT DE CETTE FILE. Essaye le 28/07 : j'y avais ajoute deux + // flottants pour faire voyager le decalage sous-pixel avec la matrice. Resultat mesure a la + // trace : la capture poussait bien (etape A non nulle) et le present trouvait la file VIDE + // (etape B, valide=False, frais=False), alors que la meme file donnait pushes=30 fresh=30 + // holds=0 quelques heures plus tot. `Queue` n'est pas protegee contre les acces + // simultanes -- le commentaire ci-dessus affirme un seul thread, mais les journaux montrent + // OnDrawImpl d'un cote et Present de l'autre. Grossir l'element d'une structure deja limite + // suffit a la faire rendre du vide, et l'appariement camera s'effondre avec. + // + // Le decalage passe donc par l'ANNEAU DE PRESENTATION, qui est une ConcurrentQueue. private static readonly Queue _fifo = new(); private static Matrix4x4 _fifoLast; private static bool _fifoHasLast; @@ -133,6 +187,12 @@ namespace Ryujinx.Graphics.GAL public static int StatDrops; public static int StatMaxDepth; + // [28/07] NE RIEN AJOUTER ICI SANS Y PENSER A DEUX FOIS. Ce fichier vit dans + // Ryujinx.Graphics.GAL, et la copie de test tourne sur un GAL.dll du 10/07 : y ajouter un + // champ oblige a redeployer cette DLL, donc a injecter d'un coup tous les changements de + // GAL accumules depuis. Fait le 28/07 a 10h07 pour une simple sonde de journal, suivi d'un + // defaut neuf en jeu. Faire transiter les sondes autrement. + /// [JITTERVAL temporal probe, 10/07] Result of the most recent /// : true = fresh dequeue, false = hold of the last camera. /// Log-only (read by the gated JITTERVAL-PRES/INJ lines); nothing in the render path reads it. @@ -141,6 +201,10 @@ namespace Ryujinx.Graphics.GAL /// /// Pushes a newly observed distinct camera view-projection, in draw order (GPU thread). /// + // [28/07] La surcharge a decalage est SUPPRIMEE : voir le commentaire de _fifo. Elle reste + // acceptee pour ne pas casser les appelants, mais elle ignore le decalage. + public static void PushOrdered(in Matrix4x4 vp, float jx, float jy) => PushOrdered(in vp); + public static void PushOrdered(in Matrix4x4 vp) { _fifo.Enqueue(vp); @@ -165,6 +229,7 @@ namespace Ryujinx.Graphics.GAL /// static camera no new value was written, so the last consumed one still describes /// this frame. /// + // [28/07] Restauree A L'IDENTIQUE de la version qui donnait pushes=30 fresh=30 holds=0. public static bool TryConsumeOrdered(out Matrix4x4 vp) { if (_fifo.TryDequeue(out vp)) diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/CommandHelper.cs b/src/Ryujinx.Graphics.GAL/Multithreading/CommandHelper.cs index d300a48d8..2b3ade145 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/CommandHelper.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/CommandHelper.cs @@ -62,6 +62,7 @@ namespace Ryujinx.Graphics.GAL.Multithreading Register(CommandType.BufferDispose); Register(CommandType.BufferGetData); Register(CommandType.BufferSetData); + Register(CommandType.BufferSetDataBatch); // [BUFBATCH] Register(CommandType.CounterEventDispose); Register(CommandType.CounterEventFlush); diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/CommandType.cs b/src/Ryujinx.Graphics.GAL/Multithreading/CommandType.cs index e41560074..ccf21f25a 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/CommandType.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/CommandType.cs @@ -113,5 +113,6 @@ namespace Ryujinx.Graphics.GAL.Multithreading TryHostConditionalRendering, TryHostConditionalRenderingFlush, MvppInjectDlss, + BufferSetDataBatch, } } diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Buffer/BufferSetDataBatchCommand.cs b/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Buffer/BufferSetDataBatchCommand.cs new file mode 100644 index 000000000..23a93647c --- /dev/null +++ b/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Buffer/BufferSetDataBatchCommand.cs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Graphics.GAL.Multithreading.Model; +using System; + +namespace Ryujinx.Graphics.GAL.Multithreading.Commands.Buffer +{ + /// + /// [BUFBATCH 02/08, journal (368)-(369)] Une entree du lot : ou ecrire (buffer, offset) et + /// combien d'octets consommer dans l'arene du lot (les donnees sont concatenees dans l'ordre). + /// + struct BufferSetDataBatchEntry + { + public BufferHandle Buffer; + public int Offset; + public int Size; + } + + /// + /// [BUFBATCH] Lot de SetBufferData consecutifs : K entrees + une arene de donnees concatenees, + /// portees par DEUX SpanRef (l'element de queue ne grossit pas — voir l'avertissement + /// « grossir l'element de la Queue la brise »). Ordre pool FIFO : le producteur insere les + /// ENTREES puis l'ARENE ; le consommateur consomme dans le meme ordre (copie des entrees en + /// pile avant Dispose, car Get/Dispose du pool sont strictement sequentiels). + /// + struct BufferSetDataBatchCommand : IGALCommand, IGALCommand + { + public readonly CommandType CommandType => CommandType.BufferSetDataBatch; + private SpanRef _entries; + private SpanRef _data; + private int _count; + + public void Set(SpanRef entries, SpanRef data, int count) + { + _entries = entries; + _data = data; + _count = count; + } + + public static void Run(ref BufferSetDataBatchCommand command, ThreadedRenderer threaded, IRenderer renderer) + { + // Copie des entrees AVANT Dispose : le pool est un anneau, Dispose libere la zone + // pour le producteur — on ne garde jamais un Span pool au-dela de son Dispose. + Span entries = command._count <= 64 + ? stackalloc BufferSetDataBatchEntry[64] + : new BufferSetDataBatchEntry[command._count]; + + command._entries.Get(threaded)[..command._count].CopyTo(entries); + command._entries.Dispose(threaded); + entries = entries[..command._count]; + + ReadOnlySpan data = command._data.Get(threaded); + + int dataOffset = 0; + foreach (BufferSetDataBatchEntry entry in entries) + { + renderer.SetBufferData(threaded.Buffers.MapBuffer(entry.Buffer), entry.Offset, data.Slice(dataOffset, entry.Size)); + dataOffset += entry.Size; + } + + command._data.Dispose(threaded); + } + } +} diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedRenderer.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedRenderer.cs index 66ac31ab4..5e3bc9d92 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedRenderer.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedRenderer.cs @@ -162,6 +162,85 @@ namespace Ryujinx.Graphics.GAL.Multithreading return _spanPool.Insert(data); } + // [BUFBATCH 02/08, journal (368)-(369)] Lots de SetBufferData consecutifs + // (RYUJINX_BUFBATCH=1, OFF par defaut = chemin stock). Mesure (362)-(368) : le jeu ecrit + // ses buffers au tick invite (~40-50 000 uploads/s CONSTANTS) ; a ~6 us de machinerie par + // commande, le cout par IMAGE RENDUE explose quand la cadence chute (boucle de + // retroaction, marches 12-20 img/s). Ce gate accumule les SetBufferData CONSECUTIFS dans + // une arene productrice privee et n'emet qu'UNE commande par lot. L'ordre global est + // preserve : TOUTE commande d'un autre type flushe d'abord le lot en attente (crochet + // unique dans New). Le lot ne detient AUCUNE ressource du pool avant son flush. + // Serialisation : memes hypotheses que le producteur stock (New/_producerPtr non + // verrouilles) — quiconque a le droit d'enqueue a le droit d'accumuler. + private static readonly bool _bufBatchEnabled = + Environment.GetEnvironmentVariable("RYUJINX_BUFBATCH") == "1"; + + private const int BufBatchMaxEntries = 256; + private const int BufBatchArenaBytes = 512 * 1024; + + private BufferSetDataBatchEntry[] _bufBatchEntries; + private byte[] _bufBatchArena; + private int _bufBatchCount; + private int _bufBatchArenaUsed; + private bool _bufBatchArmedLogged; + + private unsafe void BatchBufferSetData(BufferHandle buffer, int offset, ReadOnlySpan data) + { + if (!_bufBatchArmedLogged) + { + _bufBatchArmedLogged = true; + _bufBatchEntries = new BufferSetDataBatchEntry[BufBatchMaxEntries]; + _bufBatchArena = new byte[BufBatchArenaBytes]; + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"[BUFBATCH] ARME (RYUJINX_BUFBATCH=1) - lots de SetBufferData (max {BufBatchMaxEntries} entrees / {BufBatchArenaBytes / 1024} Ko)."); + } + + // Trop gros pour l'arene : flush du lot puis chemin stock (l'ordre reste correct). + if (data.Length > BufBatchArenaBytes) + { + FlushBufferBatch(); + New()->Set(buffer, offset, CopySpan(data)); + QueueCommand(); + + return; + } + + if (_bufBatchCount == BufBatchMaxEntries || _bufBatchArenaUsed + data.Length > BufBatchArenaBytes) + { + FlushBufferBatch(); + } + + data.CopyTo(_bufBatchArena.AsSpan(_bufBatchArenaUsed, data.Length)); + _bufBatchEntries[_bufBatchCount++] = new BufferSetDataBatchEntry + { + Buffer = buffer, + Offset = offset, + Size = data.Length, + }; + _bufBatchArenaUsed += data.Length; + } + + /// [BUFBATCH] Emet le lot en attente : les ENTREES puis l'ARENE dans le pool + /// (ordre FIFO consomme a l'identique par la commande), une seule commande. + private unsafe void FlushBufferBatch() + { + if (_bufBatchCount == 0) + { + return; + } + + int count = _bufBatchCount; + int used = _bufBatchArenaUsed; + _bufBatchCount = 0; + _bufBatchArenaUsed = 0; + + SpanRef entries = CopySpan(_bufBatchEntries.AsSpan(0, count)); + SpanRef data = CopySpan(_bufBatchArena.AsSpan(0, used)); + + New()->Set(entries, data, count); + QueueCommand(); + } + private TableRef Ref(T reference) { return new TableRef(this, reference); @@ -169,6 +248,14 @@ namespace Ryujinx.Graphics.GAL.Multithreading internal unsafe T* New() where T : unmanaged, IGALCommand { + // [BUFBATCH] point d'ordre unique : toute commande d'un AUTRE type vide d'abord le + // lot de SetBufferData en attente. typeof(T) est resolu par le JIT par + // instanciation ; gate OFF => _bufBatchCount reste 0 => branche morte. + if (_bufBatchEnabled && _bufBatchCount != 0 && typeof(T) != typeof(BufferSetDataBatchCommand)) + { + FlushBufferBatch(); + } + while (_producerPtr == (Volatile.Read(ref _consumerPtr) + QueueCount - 1) % QueueCount) { // If incrementing the producer pointer would overflow, we need to wait. @@ -460,6 +547,13 @@ namespace Ryujinx.Graphics.GAL.Multithreading public unsafe void SetBufferData(BufferHandle buffer, int offset, ReadOnlySpan data) { + if (_bufBatchEnabled) + { + BatchBufferSetData(buffer, offset, data); + + return; + } + New()->Set(buffer, offset, CopySpan(data)); QueueCommand(); } diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Compute/ComputeClass.cs b/src/Ryujinx.Graphics.Gpu/Engine/Compute/ComputeClass.cs index 342269cdd..4b77ae497 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Compute/ComputeClass.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Compute/ComputeClass.cs @@ -204,6 +204,7 @@ namespace Ryujinx.Graphics.Gpu.Engine.Compute _context.Renderer.Pipeline.DispatchCompute(qmd.CtaRasterWidth, qmd.CtaRasterHeight, qmd.CtaRasterDepth); MvppBlitProbe.OnDispatch(_channel.TextureManager, (int)qmd.CtaRasterWidth, (int)qmd.CtaRasterHeight, (int)qmd.CtaRasterDepth, shaderGpuVa); // read-only (gated) + Threed.MvppDrawStepProbe.OnDispatch(_channel.TextureManager, (int)qmd.CtaRasterWidth, (int)qmd.CtaRasterHeight, (int)qmd.CtaRasterDepth, shaderGpuVa); // read-only (gated) _3dEngine.ForceShaderUpdate(); } diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Dma/DmaClass.cs b/src/Ryujinx.Graphics.Gpu/Engine/Dma/DmaClass.cs index 19b90c59a..29f61be37 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Dma/DmaClass.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Dma/DmaClass.cs @@ -216,6 +216,9 @@ namespace Ryujinx.Graphics.Gpu.Engine.Dma _3dEngine.CreatePendingSyncs(); _3dEngine.FlushUboDirty(); + // [TWINXFER] Raw copy-engine tap, BEFORE any branch (buffer-domain blind spot). Self-gated. + Image.MvppTwinXferProbe.OnDma(srcGpuVa, dstGpuVa, xCount, yCount, copy2D, srcLinear, dstLinear); + if (copy2D) { // Buffer to texture copy. @@ -325,6 +328,7 @@ namespace Ryujinx.Graphics.Gpu.Engine.Dma target.PropagateScale(source); } + Image.MvppTwinXferProbe.OnCopy("DMA", source, target); // [TWINXFER] read-only, self-gated source.HostTexture.CopyTo(target.HostTexture, 0, 0); target.SignalModified(); return; diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/ConstantBufferUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/ConstantBufferUpdater.cs index 6fc49fc8d..012aeb863 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/ConstantBufferUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/ConstantBufferUpdater.cs @@ -105,6 +105,20 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed } } + // [Beast Roofer diag] RYUJINX_UBO_FORCEDIRTY=1 (EXP 11, gated OFF by default): always mark + // the inline-updated UBO range dirty, ignoring the redundancy check's verdict. The XC2 + // velocity chain's only per-frame data path is the constant buffers (journal 141: every + // vertex SSBO is static); if the redundancy check ever wrongly reports "unchanged" (e.g. a + // game double-write making guest memory match while the HOST copy is still old), the GPU + // reads one-frame-stale matrices -- garbage velocities, only in motion, per draw. Forcing + // the dirty flag costs a redundant upload but cannot be wrong. Artifact gone with this on + // => the redundancy-check family is the root; see journal (142). + private static readonly bool _uboForceDirty = + Environment.GetEnvironmentVariable("RYUJINX_UBO_FORCEDIRTY") == "1"; + + private static long _uboForceDirtyHits; + private static long _uboForceDirtyLogMs; + /// /// Flushes any queued UBO updates. /// @@ -116,9 +130,25 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed Span data = MemoryMarshal.Cast(_ubData.AsSpan(0, (int)(_ubByteCount / 4))); - if (memoryManager.Physical.WriteWithRedundancyCheck(_ubBeginCpuAddress, data)) + bool changed = memoryManager.Physical.WriteWithRedundancyCheck(_ubBeginCpuAddress, data); + + if (changed || _uboForceDirty) { memoryManager.Physical.BufferCache.ForceDirty(memoryManager, _ubFollowUpAddress - _ubByteCount, _ubByteCount); + + if (!changed) + { + // Only reached with the experiment on: these are exactly the flushes the + // redundancy check would have skipped. Their count is the witness. + _uboForceDirtyHits++; + long now = Environment.TickCount64; + if (now - _uboForceDirtyLogMs >= 3000) + { + _uboForceDirtyLogMs = now; + Ryujinx.Common.Logging.Logger.Warning?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"[UBOFD] redundancy-skipped flushes forced dirty so far: {_uboForceDirtyHits}"); + } + } } _ubFollowUpAddress = 0; diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs index 2189b0cc7..57e4ff9f1 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs @@ -125,12 +125,29 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private void DrawEnd(ThreedClass engine, int firstIndex, int indexCount, int drawFirstVertex, int drawVertexCount) { MvppProbe.OnDraw(_channel); + MvppScanProbe.OnDraw(_channel); + MvppHazardProbe.OnDraw(_channel); + MvppRtDumpProbe.OnDraw(_channel); + MvppGlowProbe.OnDraw(_channel, ref _state.State); + MvppViewportProbe.OnDraw(_channel, ref _state.State); + MvppDrawStepProbe.OnDraw(_channel); + MvppCompInputsProbe.OnDraw(_channel, ref _state.State); MvppCameraCapture.OnDraw(_channel); MvppP2Probe.OnDraw(_channel, ref _state.State, firstIndex, indexCount, drawFirstVertex, drawVertexCount); MvppP2BProbe.OnDraw(_channel); - MvppUiProbe.OnDraw(_channel, ref _state.State); + MvppUiProbe.OnDraw(_channel, ref _state.State, _context); MvppCompositeProbe.OnDraw(_channel, ref _state.State); + // [DOFSKIP] Compatibility option, inert unless RYUJINX_DOF_SCATTER_SKIP=1. Matched by + // pipeline shape, so it survives game versions AND a warm shader cache. + if (MvppDofSkip.ShouldSkip(_channel)) + { + _drawState.DrawIndexed = false; + _instancedDrawPending = false; + + return; + } + ConditionalRenderEnabled renderEnable = ConditionalRendering.GetRenderEnable( _context, _channel.MemoryManager, @@ -223,6 +240,11 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed { _context.Renderer.Pipeline.EndHostConditionalRendering(); } + + // [POST 21/07] Capture la cible APRES l'ecriture de ce draw (chemin normal non-instancie). + // La sonde pre-draw (l.133) montre le contenu AVANT ; comparer les deux dit si ce draw + // ECRIT la corruption ou la trouve deja la. + MvppDrawStepProbe.OnDrawPost(_channel); } /// @@ -938,6 +960,8 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed engine.UpdateState(updateMask); + int probeScX = -1, probeScY = -1, probeScW = -1, probeScH = -1; // [MVBUF] read-only capture of the effective clear scissor + if (needsCustomScissor) { int scissorX = screenScissorState.X; @@ -970,6 +994,11 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed new(scissorX, scissorY, scissorW, scissorH) ]; + probeScX = scissorX; // [MVBUF] + probeScY = scissorY; + probeScW = scissorW; + probeScH = scissorH; + _context.Renderer.Pipeline.SetScissors(scissors); } @@ -981,6 +1010,24 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed ColorF color = new(clearColor.Red, clearColor.Green, clearColor.Blue, clearColor.Alpha); + // [MVBUF] Read-only (gated): record engine clears hitting the object-MV buffer. + Image.MvppMvBufProbe.OnClear( + _channel.TextureManager.GetColorTarget(index), + index, + componentMask, + color, + needsCustomScissor, + probeScX, + probeScY, + probeScW, + probeScH); + + // [TWINMAP v5] per-twin clear attribution (self-gated). + Image.MvppTwinMapProbe.OnClear( + _channel.TextureManager.GetColorTarget(index), + componentMask, + clearColor.Red, clearColor.Green, clearColor.Blue, clearColor.Alpha); + _context.Renderer.Pipeline.ClearRenderTargetColor(index, layer, layerCount, componentMask, color); } diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCamTrace.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCamTrace.cs new file mode 100644 index 000000000..8ff223bc0 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCamTrace.cs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// IDENTITY TRACE of the camera actually read each frame. Gate: RYUJINX_MVPP_CAMTRACE=1. + /// LOGGING ONLY - one line per accepted read, nothing is altered, nothing is fed downstream. + /// + /// WHY IT EXISTS (27/07, XC2). Alex reports the geometry sliding even when NOTHING moves. + /// Measured on his run: at the screen centre the motion vector is ~0.5 to 1.2 px and FLIPS + /// SIGN frame to frame while the scene is still, and the projective audit shows the camera + /// position returning, in a loop, to five values that repeat EXACTLY to three decimals on all + /// three axes, spread over 0.14 world units. Noise never repeats exactly, so those are not + /// measurement error: they are DISTINCT cameras, and the per-frame read lands on a different + /// one from frame to frame. + /// + /// That was already anticipated in MvppSoloCamera's own notes - the game runs its adjustable + /// gameplay camera and renders the minimap from another - and CAMGUARD was written for it. + /// But CAMGUARD tests a TELEPORT budget (150 world units per second, floor 5), sized on the + /// intruders seen so far (150 and 34699 units). A rival camera 0.14 units away passes that + /// test untouched, and a genuine camera moving at the measured median (5.8 units per second = + /// ~0.1 per frame) is the SAME order of magnitude as the gap between the rivals. So distance + /// alone can never separate them, whatever the threshold: the discriminator has to be + /// IDENTITY, not metric. + /// + /// WHAT THIS MEASURES. The per-frame read is keyed on the SLOT, deliberately, because the + /// camera's ADDRESS rotates through a small ring buffer. Nothing checks that the address + /// behind the slot still belongs to THAT ring. This trace prints, for every accepted read, + /// the address it came from and the position it yielded. + /// + /// HOW TO READ IT. Group the lines by address: + /// - a handful of addresses, each always giving the SAME position, cycling => the slot is + /// shared by several cameras and the fix is to pin the read to the elected ring; + /// - one address giving positions that jitter => the ring holds different FRAMES of one + /// camera and the fix is a freshness rule, not an identity one; + /// - addresses and positions both stable => this whole theory is wrong and the false motion + /// is produced downstream of the camera. + /// The three outcomes ask for three different fixes, which is the point of measuring first. + /// + static class MvppCamTrace + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAMTRACE") == "1"; + + // ~10 seconds at 60 fps. Long enough to see a cycle repeat many times, short enough that + // the log stays readable and the run stays fast. + private const int MaxLines = 600; + + // Ordinary reads measured at 4.8 max once the three fixes are armed; the intruders they + // removed were 250. Ten sits between the two by two orders of magnitude on either side. + private const float JumpThreshold = 50f; + + private static int _lines; + private static int _jumps; + private static ulong _prevAddress; + private static float _prevX, _prevY, _prevZ; + private static bool _hasPrev; + + /// + /// Called on the success path of the per-frame read, with the address currently behind the + /// elected slot and the position that read produced. + /// + public static void Note(ulong address, float px, float py, float pz, float jx, float jy, bool deJittered) + { + if (!Enabled) + { + return; + } + + // [27/07] The first version stopped after MaxLines and went silent for the rest of the + // session - so Alex's one remaining flick, which happened during a fight nine minutes + // in, left no trace at all. Past the opening window the probe keeps watching but only + // speaks when a read jumps: measured on the run that followed the three fixes, ordinary + // reads peak at 4.8 while the intruders were 250, so a jump this size is an event and + // not a busy log. Cost outside an event: three subtractions. + bool opening = _lines < MaxLines; + + if (!opening) + { + float ddx = px - _prevX; + float ddy = py - _prevY; + float ddz = pz - _prevZ; + float jump = MathF.Sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + + _prevAddress = address; + _prevX = px; + _prevY = py; + _prevZ = pz; + + if (jump > JumpThreshold) + { + _jumps++; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP camtrace JUMP #{_jumps}: addr={address:X10} " + + $"pos=[{px:0.####} {py:0.####} {pz:0.####}] step={jump:0.##}."); + } + + return; + } + + _lines++; + + // The step since the previous accepted read is what the reprojection turns into motion + // vectors, so it is printed rather than left to be recomputed by hand afterwards. + float step = 0f; + bool addrChanged = false; + + if (_hasPrev) + { + float dx = px - _prevX; + float dy = py - _prevY; + float dz = pz - _prevZ; + step = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + addrChanged = address != _prevAddress; + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP camtrace {_lines,4}: addr={address:X10}{(addrChanged ? " *NEW*" : " ")} " + + $"pos=[{px:0.####} {py:0.####} {pz:0.####}] step={step:0.#####} " + + $"jitter=({jx:0.######} {jy:0.######}){(deJittered ? " REMOVED" : " kept")}" + + (_lines == MaxLines ? " (trace complete, no further lines)" : "")); + + _prevAddress = address; + _prevX = px; + _prevY = py; + _prevZ = pz; + _hasPrev = true; + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs index c07134de6..05a0e8957 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs @@ -32,6 +32,48 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private static bool _enabled = Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAP") == "1"; + // [CAPWHY 02/08] Compteurs de branches du chemin de capture, lecture seule, gate OFF par + // defaut. Question (journal (347)) : sur les images AFFAMEES du village BOTW, ou meurt le + // chemin — OnDrawImpl jamais appelee, cache reussi (?!), relocalisation, ou repli solo ? + // Meme patron que les stats de fenetre : ecrits sur le fil GPU, vides au meme point de + // flush que la ligne "MVPP capture window" (approximation identique a l'existant). + private static readonly bool _capWhy = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAPWHY") == "1"; + + // [DUPSKIP 02/08] Producteur du signal anti-doublons : a chaque present invite portant au + // moins un dessin, incremente GAL.DlssCameraState.RenderedFrameSeq (voir sa doc). Reutilise + // le verrou par image de CAPWHY v2 ; ferme = aucun comportement. + private static readonly bool _dupSkip = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_DUPSKIP") == "1"; + + // [FGFEED 02/08] Meme signal, autre consommateur : Vulkan Window.Present AVALE les + // presentations-doublons pour que Streamline/FG ne voie que les vraies images et + // interpole entre elles (journal (353)). Le producteur est strictement identique a + // DUPSKIP ; seul le consommateur change. Ferme = aucun comportement. + private static readonly bool _fgFeed = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_FGFEED") == "1"; + + private static int _cwQual; // dessins ayant passe le prefiltre (OnDraw) + private static int _cwNeed; // ... dont l'image n'avait pas encore capture + private static int _cwImpl; // entrees dans OnDrawImpl + private static int _cwCachedOk; // lecture au cache reussie (publie) + private static int _cwHeld; // quarantaine gate (slot tenu ouvert) + private static int _cwRescanOk; // relocalisation reussie (publie) + private static int _cwSoloCall; // repli solo atteint + private static int _cwSoloOk; // repli solo a publie + // "rien" = impl - cachedOk - held - rescanOk - soloOk, derivable, pas de compteur dedie. + + // [CAPWHY v2 02/08] Discriminant demande par la correction d'Alex (« les FPS ne descendent + // pas ») : par PRESENTATION, l'image avait-elle des dessins DU TOUT (avant prefiltre), et + // en avait-elle apres ? Separe « le jeu n'a pas rendu » (monde 1) de « tout est mort au + // prefiltre » (monde 2). Verrous par image poses sur le fil GPU, releves au present. + private static int _cwRaw; // dessins vus a l'entree de OnDraw (avant prefiltre) + private static int _cwFrameHadRaw; // verrou : cette image a vu >= 1 dessin brut + private static int _cwFrameHadQual; // verrou : cette image a vu >= 1 dessin qualifie + private static int _cwFramesNoDraw; // presentations sans AUCUN dessin brut + private static int _cwFramesPrefiltred; // presentations avec des bruts mais 0 qualifie + private static int _cwFramesOk; // presentations avec >= 1 dessin qualifie + private static int _capturedThisFrame; private static int _cachedSlot = -1; private static int _cachedOffset; @@ -136,6 +178,12 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private static readonly bool _fifoMode = Environment.GetEnvironmentVariable("RYUJINX_MVPP_VPFIFO") == "1"; + // [VPSOLO] Last matrix pushed into the ordered FIFO by the solo fallback, so it pushes + // only on change (the triplet producer gates on its block changing; without an + // equivalent here the queue would be fed an identical matrix every single frame). + private static bool _hasSoloPushed; + private static Matrix4x4 _lastSoloPushed; + static MvppCameraCapture() { DlssCameraState.Enabled = _enabled; @@ -149,6 +197,30 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed return; } + if (_capWhy || _dupSkip || _fgFeed) + { + if (_capWhy) + { + _cwRaw++; + } + + _cwFrameHadRaw = 1; + } + + // [VPSOLO] Aspect reference for the solo fallback, fed on EVERY draw with a bound + // depth -- both regimes, before any filtering. Feeding it only from the native arm + // left a scaled config (res_scale > 1) with no reference at all, which silently + // blocks the election for ever. Guarded: nothing runs unless the fallback is on. + if (MvppSoloCamera.Enabled) + { + Image.Texture aspectDs = channel.TextureManager.RenderTargetDepthStencil; + + if (aspectDs != null) + { + MvppSoloCamera.NoteDepth(aspectDs.Info.Width, aspectDs.Info.Height); + } + } + // Two regimes for recognizing the main 3D scene pass (everything BEHIND this // pre-filter -- square guard, largest-wins vote, structural camera validation -- // is scale-agnostic and unchanged): @@ -181,17 +253,75 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed } } + // [CTXPROBE 29/07 SOIR] Lecture seule. Recense TOUTES les passes qui arrivent jusqu'ici, pas + // seulement la premiere : c'est ce chiffre qui dit s'il y a un choix a faire, puisque la + // capture ci-dessous prend la premiere venue et se tait ensuite. Voir MvppCtxProbe. + if (MvppCtxProbe.Enabled) + { + MvppCtxProbe.NoteQualifyingDraw(channel); + } + bool needCapture = Volatile.Read(ref _capturedThisFrame) == 0; bool probing = (_pairProbe || _fifoMode) && _cachedSlot >= 0; + if (_capWhy) + { + _cwQual++; + _cwFrameHadQual = 1; + + if (needCapture) + { + _cwNeed++; + } + } + + // [SCENEPASS 29/07 SOIR] Le pre-filtre ci-dessus reconnait "une passe 3D", pas "LA passe + // de la scene", et la capture prend la premiere venue. Mesure CTXPROBE du soir : la passe + // 8 bits porte 67 % des intruses, la couleur HDR de scene 1,7 %. On saute donc la + // tentative sur les passes qui ne sont pas la scene -- la capture se fera plus loin dans + // la MEME image. Filet de securite et auto-desarmement : voir MvppScenePass. + if (needCapture && MvppScenePass.Enabled && !MvppScenePass.Allows(channel)) + { + needCapture = false; + } + + // [NOLDR 29/07 SOIR] L'inverse de SCENEPASS, et c'est la mesure qui a impose l'inversion : + // la passe de scene HDR existe dans 98,9 % des images mais la camera n'y est lisible que + // dans 36,5 % des cas, donc n'autoriser QUE celle-la affame la camera. On ECARTE plutot + // la pire passe -- la 8 bits, 19,6 % d'intruses, quatre intruses sur cinq -- et on garde + // toutes les autres comme occasions de capture. Voir MvppLdrSkip. + if (needCapture && MvppLdrSkip.Active && !MvppLdrSkip.Allows(channel)) + { + needCapture = false; + } + try { StashSceneDepth(channel); + // [CAPTIME_FIX 31/07] Mesure du 31/07 : sur 55 % des images le jeu reecrit sa camera + // APRES notre capture, vers le 39e dessin sur ~160. On laisse donc passer les + // dessins tant que le lieu elu porte encore la valeur deja publiee, et la capture + // normale se fait des qu'une valeur NOUVELLE apparait. Une seule capture par image, + // par le chemin normal : SNAPGUARD, la file et les gardes gardent leur hypothese. + // Eteint par defaut ; le doute profite toujours au comportement existant. + if (needCapture && MvppSoloCamera.StaleAtElectedLocation(channel)) + { + needCapture = false; + } + if (needCapture) { OnDrawImpl(channel); } + else if (MvppSoloCamera.CapTime && Volatile.Read(ref _capturedThisFrame) == 1) + { + // [CAPTIME 31/07] LA SEULE ligne ajoutee hors de MvppSoloCamera, et elle est + // inevitable : le chemin solo n'est appele qu'au-dessus, sous `needCapture`, + // donc une fois l'image capturee MvppSoloCamera ne serait plus jamais sollicite. + // Lecture pure, eteinte par defaut, aucun effet sur la capture ni sur l'election. + MvppSoloCamera.NoteLateDraw(channel); + } if (probing) { @@ -671,6 +801,7 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed vp[12], vp[13], vp[14], vp[15]); DlssCameraState.PushOrdered(in m); + MvppFamHold.NotePush(); } } } @@ -683,6 +814,19 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed { _colorCapFrameId++; // [COLORCAP probe] one id per presented frame, for the capture prototype log + // [HANGWATCH 29/07] Une ecriture d'entier, sur un fil de fond independant qui, lui, + // survivra au gel du fil graphique. Voir MvppHangWatch. + if (MvppHangWatch.Enabled) + { + MvppHangWatch.Ping(); + } + + // [CTXPROBE 29/07 SOIR] Re-arme le rang de dessin, au meme endroit que la capture elle-meme. + if (MvppCtxProbe.Enabled) + { + MvppCtxProbe.OnFrame(); + } + if (_gateEnabled) { if (Volatile.Read(ref _capturedThisFrame) == 1) @@ -700,8 +844,56 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed _rivalSeenThisFrame = false; } + // [SCENEPASS 29/07 SOIR] ICI, avant le re-armement : c'est le seul endroit ou l'on sait + // si l'image a fini par obtenir une camera. Une image restee sans camera alors qu'elle + // avait des passes 3D est une image potentiellement perdue -- le filet la compte. + if (MvppScenePass.Enabled) + { + MvppScenePass.OnFrame(Volatile.Read(ref _capturedThisFrame) == 1); + } + + if (MvppLdrSkip.Active) + { + MvppLdrSkip.OnFrame(Volatile.Read(ref _capturedThisFrame) == 1); + } + + // [CAPTIME_AB 31/07] Meme endroit, meme raison que les deux au-dessus : c'est le seul + // point ou l'on sait si l'image a fini par obtenir une camera. Inerte tant que + // RYUJINX_MVPP_CAPTIME_AB n'est pas renseigne. + MvppSoloCamera.OnPresentBoundary(Volatile.Read(ref _capturedThisFrame) == 1); + MvppSoloCamera.LogWhy(); + Volatile.Write(ref _capturedThisFrame, 0); + // [CAPWHY v2] Classement de la presentation qui se termine, puis reset des verrous. + // [DUPSKIP] La meme frontiere nourrit la sequence des vraies images rendues. + if (_capWhy || _dupSkip || _fgFeed) + { + if ((_dupSkip || _fgFeed) && _cwFrameHadRaw == 1) + { + System.Threading.Interlocked.Increment(ref DlssCameraState.RenderedFrameSeq); + } + + if (_capWhy) + { + if (_cwFrameHadRaw == 0) + { + _cwFramesNoDraw++; + } + else if (_cwFrameHadQual == 0) + { + _cwFramesPrefiltred++; + } + else + { + _cwFramesOk++; + } + } + + _cwFrameHadRaw = 0; + _cwFrameHadQual = 0; + } + // Publisher window: ticks on EVERY present so a zero-publish phase still logs // (the in-capture heartbeat is silent exactly when things go wrong). _statEnqueues++; @@ -717,6 +909,16 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed $"vp-jumps {_statVpJumps}, blk-changes {_statBlockChanges}, " + $"sq-rejects {StatSquareRejects}, rescans {_rescans}, " + $"rivals {_statRivals}, late {_statLineageLate}, resyncs {_statResyncs}."); + + if (_capWhy) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP capture why: raw {_cwRaw}, qual {_cwQual}, need {_cwNeed}, impl {_cwImpl}, " + + $"cachedOk {_cwCachedOk}, held {_cwHeld}, rescanOk {_cwRescanOk}, " + + $"soloCall {_cwSoloCall}, soloOk {_cwSoloOk}, " + + $"rien {_cwImpl - _cwCachedOk - _cwHeld - _cwRescanOk - _cwSoloOk} | " + + $"img: sansDessin {_cwFramesNoDraw}, toutPrefiltre {_cwFramesPrefiltred}, ok {_cwFramesOk}."); + } } _pubWindowMs = nowPub; @@ -729,6 +931,18 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed _statBlockChanges = 0; _statRivals = 0; _statLineageLate = 0; + _cwQual = 0; + _cwNeed = 0; + _cwImpl = 0; + _cwCachedOk = 0; + _cwHeld = 0; + _cwRescanOk = 0; + _cwSoloCall = 0; + _cwSoloOk = 0; + _cwRaw = 0; + _cwFramesNoDraw = 0; + _cwFramesPrefiltred = 0; + _cwFramesOk = 0; } if (_pairProbe) @@ -750,13 +964,95 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed } } + // [DUPPAIR 28/07] Sonde du POINT DE PRODUCTION de la camera, cadencee a 1 Hz et sans + // dependance au mode dev (qui coute des images). Mesure du 28/07 cote consommation : + // pendant les pans, 6 a 15 valeurs distinctes par seconde pour 30 images -- et le + // manque est INVERSEMENT correle a la vitesse de la camera. Rien n'est perdu en route + // (drops=0), donc la valeur n'est jamais produite. Reste a savoir si on lit et que la + // valeur ne bouge pas, ou si la lecture echoue -- et alors pour quel motif. + private static readonly bool _dupPair = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_DUPPAIR") == "1"; + + // [JITTRACE 28/07] Voir les etapes A / B / C. Journal seul, une ligne par seconde par etape. + internal static readonly bool JitTrace = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_JITTRACE") == "1"; + + private static readonly bool _jitTrace = JitTrace; + private static long _jitTraceMs; + + private static int _soloDistinct; + private static int _soloSame; + private static long _dupLogMs; + private static readonly int[] _dupFailBase = new int[16]; + private static readonly int[] _dupFailNow = new int[16]; + + private static void DupPairLog() + { + long now = Environment.TickCount64; + + if (now - _dupLogMs < 1000) + { + return; + } + + _dupLogMs = now; + + MvppSoloCamera.CopyFailCounts(_dupFailNow); + + var sb = new System.Text.StringBuilder(); + sb.Append($"MVPP PRODUCTION: lectures distinctes={_soloDistinct} identiques={_soloSame} | echecs: "); + + int n = Math.Min(MvppSoloCamera.FailReasonCount, _dupFailNow.Length); + int total = 0; + + for (int i = 0; i < n; i++) + { + total += _dupFailNow[i] - _dupFailBase[i]; + } + + for (int i = 0; i < n; i++) + { + int d = _dupFailNow[i] - _dupFailBase[i]; + + if (d > 0) + { + sb.Append($"{MvppSoloCamera.FailNames[i]} {d} ({(total > 0 ? 100f * d / total : 0f):0.#} %) · "); + } + + _dupFailBase[i] = _dupFailNow[i]; + } + + sb.Append($"total {total}"); + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + Logger.Info?.Print(LogClass.Gpu, MvppSoloCamera.AltSummaryAndReset()); + + _soloDistinct = 0; + _soloSame = 0; + } + private static void OnDrawImpl(GpuChannel channel) { + if (_dupPair) + { + DupPairLog(); + } + + if (_capWhy) + { + _cwImpl++; + } + _gateHeldSlot = false; // Cached location first: one read + three cheap checks per frame in steady state. if (_cachedSlot >= 0 && TryCapture(channel, _cachedSlot, _cachedOffset)) { + if (_capWhy) + { + _cwCachedOk++; + } + return; } @@ -765,6 +1061,11 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed // The cached block validated but the gate quarantined it (rival camera): // the lineage lives at the SAME location on a LATER draw -- a full rescan // would only re-find the rival. Keep the slot open, skip the scan. + if (_capWhy) + { + _cwHeld++; + } + return; } @@ -791,10 +1092,106 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed Logger.Info?.Print(LogClass.Gpu, $"MVPP capture: camera block found at stage0 cbuf{slot} +0x{offset:X3} (scan #{_rescans})."); + if (_capWhy) + { + _cwRescanOk++; + } + return; } } } + + // [VPSOLO fallback, 21/07 -- RYUJINX_MVPP_VPSOLO=1, off by default] + // Reached ONLY when the triplet scan above found nothing, so a game whose camera the + // historic contract recognises never gets here and its behaviour is unchanged by + // construction rather than by testing. Games that store their view-projection ALONE + // (Xenoblade 2: view and proj in separate buffers, column-major, canonical triplet + // count 0 across every measured run) had MV++ silently disarmed for the whole + // session; this gives them a camera. See MvppSoloCamera for how the matrix is + // recognised and how the location is elected. + if (_capWhy && MvppSoloCamera.Enabled) + { + _cwSoloCall++; + } + + if (MvppSoloCamera.Enabled && MvppSoloCamera.TryGetViewProjection(channel, out Matrix4x4 soloVp)) + { + if (_capWhy) + { + _cwSoloOk++; + } + + DlssCameraState.PublishCurrent(in soloVp); + Volatile.Write(ref _capturedThisFrame, 1); + _captures++; + _statPubFrames++; + + // Publishing is only half the pipe. In VPFIFO mode the present side pairs the + // camera to the presented frame by CONSUMING an ordered queue, and the only + // producer for that queue is ProbeDistinctValues -- which requires _cachedSlot, + // i.e. a triplet that was found. On a game where the triplet does not exist the + // queue therefore stayed empty for ever: measured 21/07 on XC2, pub 150/151 on + // the GPU side while the consumer logged "pairOk=0" every 5 s for a whole + // 3-minute run, so the reprojection pass never once ran. Push here too, and only + // on a CHANGED value, exactly like the triplet producer does -- a queue fed the + // same matrix every frame would just grow. + if (_fifoMode && (!_hasSoloPushed || soloVp != _lastSoloPushed)) + { + _hasSoloPushed = true; + _lastSoloPushed = soloVp; + + // [GAMEJITTER 28/07] Le decalage sous-pixel MESURE SUR CETTE LECTURE part avec + // la matrice : c'est la seule facon qu'il reste apparie a la bonne image. + float pushJx = MvppSoloCamera.LastJitterX; + float pushJy = MvppSoloCamera.LastJitterY; + + // ⛔ [28/07 17h40] Surcharge a 3 parametres NEUTRALISEE, meme dette que dans + // Window.cs : PushOrdered(vp, jx, jy) a ete ajoutee au GAL a 11:46, le GAL.dll + // de l'installation date de 10:07 et ne la contient pas -> MissingMethodException + // des que le projet Gpu est recompile. La surcharge a 1 parametre existe des + // deux cotes. Zero perte : GAMEJITTER ne transporte rien (mesure du 28/07, + // `brut (0,000;0,000)` cote DLSS). A retablir avec la reconstruction du GAL. + _ = pushJx; + _ = pushJy; + DlssCameraState.PushOrdered(in soloVp); + MvppFamHold.NotePush(); + _soloDistinct++; + + // [JITTRACE 28/07] ETAPE A. La valeur est mesuree d'un cote et vaut zero de + // l'autre ; deux explications de ma part se sont deja revelees fausses. On + // trace donc a CHAQUE saut au lieu de raisonner. Ici : ce qu'on POUSSE. + if (_jitTrace && Environment.TickCount64 - _jitTraceMs >= 1000) + { + _jitTraceMs = Environment.TickCount64; + Logger.Info?.Print(LogClass.Gpu, + $"JITTRACE A (pousse) : jx={pushJx:0.000000} jy={pushJy:0.000000} " + + $"soit ({pushJx * 960f:0.000};{pushJy * 540f:0.000}) px a 1920x1080"); + } + } + else if (_fifoMode) + { + // [DUPPAIR 28/07] Lecture reussie qui rend la MEME matrice que la derniere + // poussee. C'est la moitie manquante du diagnostic : une image sans nouvelle + // camera vient soit d'ici (on lit, mais la valeur ne bouge pas), soit d'un + // echec de lecture (compte par motif dans MvppSoloCamera). Les deux ne se + // corrigent pas au meme endroit. + _soloSame++; + } + + // Same scene/aux attribution as the triplet path: did the draw that carried this + // camera have the pinned scene depth bound? + GAL.ITexture soloDs = channel.TextureManager.RenderTargetDepthStencil?.HostTexture; + + if (soloDs != null && soloDs.Width == SceneDepthHostWidth && soloDs.Height == SceneDepthHostHeight) + { + _statCapScene++; + } + else + { + _statCapAux++; + } + } } // Center-pixel MV spikes of +-40 px at 1-3 Hz with a near-still camera can only come diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCompInputsProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCompInputsProbe.cs new file mode 100644 index 000000000..668a7c419 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCompInputsProbe.cs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Recensement du tuilage GOB de toutes les textures pleine résolution (RYUJINX_COMPIN=1). READ-ONLY. + /// + /// La corruption XC2 a une période de 8 lignes = hauteur d'un GOB ⇒ erreur de tuilage block-linear. + /// Plutôt que de traquer la passe exacte (mes filtres étaient tour à tour trop larges puis trop serrés), + /// on recense directement le gobBlocksInY de CHAQUE texture aux dimensions d'un G-buffer (1280x720, + /// 640x360, 512x288), cibles de rendu ET entrées échantillonnées. Une texture dont le gobBlocksInY + /// détonne de ses voisines de MÊME taille est lue/écrite de travers : c'est le bug. + /// Chaque forme distincte (dims + format + gobY + rôle) est logguée une seule fois. + /// + static class MvppCompInputsProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_COMPIN") == "1"; + + private static bool _announced; + private static readonly HashSet _seen = new(); + + private static bool RtSized(Image.TextureInfo i) + { + bool sized = (i.Width == 1280 && i.Height == 720) || + (i.Width == 640 && i.Height == 360) || + (i.Width == 512 && i.Height == 288); + + string fmt = i.FormatInfo.Format.ToString(); + bool uncompressed = !fmt.StartsWith("Bc", StringComparison.Ordinal) && + !fmt.StartsWith("Astc", StringComparison.Ordinal); + + return sized && uncompressed; + } + + private static void Note(Image.Texture tex, string role) + { + if (tex == null) + { + return; + } + + Image.TextureInfo i = tex.Info; + + if (!RtSized(i)) + { + return; + } + + // On logue le gobBlocksInY brut : l'anomalie = deux textures de MÊME taille avec des gobY + // différents. Le stride est ajouté car un pitch block-linear faux est l'autre forme du bug. + string sig = $"{role} {i.Width}x{i.Height} {i.FormatInfo.Format} gobY={i.GobBlocksInY} lin={i.IsLinear} stride={i.Stride}"; + + if (_seen.Add(sig)) + { + Logger.Info?.Print(LogClass.Gpu, $"MVPP COMPIN: {sig}"); + } + } + + public static void OnDraw(GpuChannel channel, ref ThreedClassState state) + { + if (!_enabled) + { + return; + } + + try + { + if (!_announced) + { + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP COMPIN: ON -- recensement gobBlocksInY des textures pleine res (cibles + entrees)."); + } + + channel.TextureManager.MvppEnumerateRenderTargets((slot, rt) => Note(rt, $"CIBLE slot{slot}")); + channel.TextureManager.MvppEnumerateGraphicsInputs(tex => Note(tex, "ENTREE")); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP COMPIN: desactive apres erreur: {e.Message}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCtxProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCtxProbe.cs new file mode 100644 index 000000000..be3678cc4 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCtxProbe.cs @@ -0,0 +1,597 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// [CTXPROBE 29/07 SOIR] Sonde de CONTEXTE DE BRANCHEMENT (RYUJINX_MVPP_CTXPROBE=1, coupee par + /// defaut). LECTURE SEULE : ne touche ni la matrice rendue, ni l'election, ni un garde-fou. + /// Aucune ecriture disque. Deux appels d'instrumentation, rien d'autre. + /// + /// LA QUESTION QU'ELLE POSE. Identifier l'intruse par son CONTENU est ferme depuis le 27/07 + /// (commentaire de SNAPGUARD) : elle n'est pas a l'origine, elle a le bon rapport d'image, et + /// c'est une vue-projection structurellement PARFAITE. Rien dans ses chiffres ne la distingue + /// d'une vraie camera. Reste une chose qu'on n'a jamais regardee : non pas a quoi elle + /// ressemble, mais OU et QUAND le jeu la branche. + /// + /// LE MECANISME SOUPCONNE. capture la camera au PREMIER + /// dessin de l'image qui passe le pre-filtre (profondeur non carree / cible mise a l'echelle), + /// puis se tait jusqu'a l'image suivante (_capturedThisFrame). Ce pre-filtre reconnait "une + /// passe 3D", pas "LA passe de la scene principale". Si le jeu dessine une passe secondaire + /// (reflet, miroir, carte, vue fixe) AVANT la scene, on lit SA camera -- une vraie camera, a un + /// siege fixe du monde, structurellement parfaite. Ce qui collerait a tout le dossier : siege + /// fixe, matrice parfaite, entree par le slot ELU (meme slot, autre passe), salves de plus de + /// 20 images, dependance a l'angle et a l'endroit, et le fait qu'un filtre d'amplitude attenue + /// sans jamais tuer -- on filtre apres coup un choix fait trop tot. + /// + /// CE QU'ELLE MESURE. Deux recensements, tous deux a GROS VOLUME (lecon du 29/07 : ne jamais + /// juger sur des evenements rares) : + /// 1. CONTEXTE DE LECTURE -- pour chaque lecture de la camera, la cible couleur et la + /// profondeur branchees a cet instant, plus le rang du dessin dans l'image. Chaque + /// contexte compte ses lectures ET ses intruses. + /// 2. RECENSEMENT DES PASSES QUALIFIANTES -- toutes les passes qui passent le pre-filtre dans + /// l'image, pas seulement la premiere. Ce chiffre decide si un correctif est meme + /// POSSIBLE : s'il n'y a qu'une passe qualifiante par image, il n'y a rien a choisir et + /// l'hypothese meurt ici. + /// + /// COMMENT LA LIRE. Si les intruses se concentrent sur un contexte distinct des lectures + /// saines, on tient un discriminant que le contenu ne donnait pas. Si tous les contextes sont + /// identiques, l'hypothese est morte -- et c'est un resultat, pas un echec. + /// + /// Minuteur PROPRE, delibere : celui d'ALTPROBE etait enferme dans le bloc d'une autre sonde et + /// n'a jamais ete emis une seule fois de tout le dossier. + /// + static class MvppCtxProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_CTXPROBE") == "1"; + + /// + /// [29/07 soir] La lecture d'ESSAI sur la passe HDR, derriere SA PROPRE variable + /// (RYUJINX_MVPP_HDRTRY=1). Pourquoi separee : le run qui l'a introduite a rendu une + /// publication de 60,4 % contre 83,2 % de reference, alors que rien d'autre n'avait change + /// -- une sonde en lecture seule ne devrait pas deplacer ce chiffre. Soit c'est l'endroit + /// (la reference etait mesuree ailleurs), soit CETTE lecture perturbe le chemin de lecture. + /// Un interrupteur separe permet de trancher au meme endroit, sans perdre le recensement. + /// 📌 Dans ce dossier, un ecart spectaculaire est d'abord un bug d'instrument. + /// + public static readonly bool TrialRead = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_HDRTRY") == "1"; + + /// + /// Seuil de separation, pris des mesures du 29/07 et non d'un choix : le plus grand pas + /// LEGITIME observe vaut 4,9 u (pas ordinaire 0,2, 99e centile 0,7), la plus petite + /// INTRUSE 415 u. 100 u tombe entre deux ordres de grandeur ou aucune lecture n'a jamais + /// ete mesuree. Ce seuil ne SERT a rien d'autre qu'a etiqueter une ligne de journal. + /// + private const float IntruderDist = 100f; + + /// + /// Au-dela de cet age, la derniere position acceptee ne peut plus servir de reference : le + /// joueur a eu le temps de se deplacer, donc l'ecart mesure n'est plus un saut de camera. + /// 60 ms ≈ deux images. Mesure du 29/07 au soir : les lectures saines ont un age moyen de + /// 37 ms, les faux positifs 2 443 ms -- les deux populations sont separees par presque deux + /// ordres de grandeur, donc la valeur exacte du seuil n'est pas critique. + /// + private const long FreshRefMs = 60; + + // Releve a 32 le 29/07 au soir : l'adresse de la profondeur est entree dans la cle, donc une + // meme forme peut desormais occuper plusieurs lignes. Le debordement reste journalise. + private const int MaxCtx = 32; + private const int ReportMs = 5000; + + private struct Ctx + { + public bool Used; + public int Fmt; + public int CW; + public int CH; + public int DW; + public int DH; + public ulong ColorAddr; + public ulong DepthAddr; + public int Reads; + public int Intruders; + public int MinRank; + public int MaxRank; + public float MaxDist; + } + + private static readonly Ctx[] _readCtx = new Ctx[MaxCtx]; + private static readonly Ctx[] _qualCtx = new Ctx[MaxCtx]; + + // [RANG REPARE 29/07 soir] La v1 remettait _rank a zero depuis OnFrame, c'est-a-dire depuis + // le fil de PRESENTATION, pendant que OnDraw l'incrementait depuis le fil GPU -- d'ou des + // "rang 0" impossibles et une colonne inutilisable. Ici OnFrame ne touche plus qu'un JETON, + // et la remise a zero se fait paresseusement sur le fil GPU, seul ecrivain de _rank. Plus de + // course : le seul echange entre fils est un int, indechirable. + private static int _rank; + private static int _frameMark; + private static int _seenMark; + + private static int _frames; + private static int _qualSum; + private static int _qualMin = int.MaxValue; + private static int _qualMax; + + private static int _reads; + private static int _intruders; + private static int _ctxOverflow; + + // [VERDICT PASSE HDR, 29/07 soir] La question qui decide du chantier SCENEPASS. Le + // recensement a montre OU sont les intruses (8,1 % sur la passe 8 bits, 0,08 % sur la + // couleur HDR) ; deplacer la capture n'a de sens que si la camera est LISIBLE au moment de + // la passe HDR. Une lecture d'essai par image, a la premiere passe HDR rencontree. + // [IDENTITE DES INTRUSES, 29/07 soir, 3e passe] La question qui peut FERMER le dossier. Le + // residu vaut 20 intruses par run, et toutes plafonnent a 415,2 u -- or (272) a etabli que + // "415,2 depuis [403,3 -28 436,4]" est la transition de camera au CHARGEMENT : deterministe, + // une fois par lancement, PAS un symptome. Si les 20 sont toutes de cette famille, il ne + // reste rien a corriger. On les recense donc une par une, dedupliquees par distance arrondie + // au dixieme, avec la position d'ou elles viennent : une valeur ET une position identiques + // au chiffre pres d'un lancement a l'autre = evenement deterministe (regle de (272)). + private const int MaxIntr = 16; + private static readonly float[] _intrDist = new float[MaxIntr]; + private static readonly int[] _intrCount = new int[MaxIntr]; + private static readonly float[] _intrPx = new float[MaxIntr]; + private static readonly float[] _intrPy = new float[MaxIntr]; + private static readonly float[] _intrPz = new float[MaxIntr]; + private static int _intrN; + private static int _intrOverflow; + + // [RANG DES INTRUSES, 29/07 soir, 4e passe] Le seul axe jamais regarde, et disponible + // uniquement depuis que la colonne "rang" est reparee. La question : les intruses arrivent- + // elles a un rang systematiquement PLUS TOT que les lectures saines ? Si oui, "ne pas + // capturer dans les N premiers dessins qualifiants" est un correctif generique, simple et + // testable -- et ce serait le meme genre de discriminant que celui qui a tue la passe 8 bits. + // Moyennes + histogramme grossier : on cherche un ecart franc, pas une decimale. + private static long _rankSumClean; + private static int _rankNClean; + private static long _rankSumIntr; + private static int _rankNIntr; + private static readonly int[] _rankHistClean = new int[5]; + private static readonly int[] _rankHistIntr = new int[5]; + + private static int _staleJumps; + private static long _ageSumClean; + private static int _ageNClean; + private static long _ageSumIntr; + private static int _ageNIntr; + private static long _ageMaxIntr; + + private static bool _probedThisFrame; + private static int _hdrFrames; + private static int _hdrReadable; + private static int _hdrUnreadable; + private static int _hdrIntruders; + + private static long _lastReportMs; + + /// + /// Appelee pour CHAQUE dessin qui passe le pre-filtre de , + /// que la camera y soit lue ou non. C'est ce recensement qui dit combien de passes + /// qualifiantes existent par image, donc s'il y a un choix a faire. + /// + public static void NoteQualifyingDraw(GpuChannel channel) + { + int mark = _frameMark; + + if (_seenMark != mark) + { + _seenMark = mark; + _rank = 0; + } + + _rank++; + + // [VERDICT PASSE HDR] Une seule lecture d'essai par image, a la PREMIERE passe de scene + // HDR rencontree -- c'est exactement l'instant ou SCENEPASS voudrait capturer. Lecture + // seule et sans effet de bord (voir MvppSoloCamera.TryProbeRead) : rien n'est publie, + // aucun garde n'est traverse, la paire de jitter est restauree. + if (TrialRead && !_probedThisFrame && MvppScenePass.IsSceneColorPass(channel)) + { + _probedThisFrame = true; + _hdrFrames++; + + if (MvppSoloCamera.TryProbeRead(channel, out float pdist)) + { + _hdrReadable++; + + if (pdist >= IntruderDist) + { + _hdrIntruders++; + } + } + else + { + _hdrUnreadable++; + } + } + + Describe(channel, out int fmt, out int cw, out int ch, out int dw, out int dh, + out ulong cAddr, out ulong dAddr); + + Record(_qualCtx, fmt, cw, ch, dw, dh, cAddr, dAddr, _rank, 0f, false); + } + + /// + /// Appelee sur la lecture principale de la camera, juste apres qu'elle a reussi + /// structurellement et AVANT que les gardes ne tranchent -- sinon les intruses, que + /// SNAPGUARD refuse, seraient invisibles et la sonde ne verrait que les lectures saines. + /// est le pas depuis la derniere position ACCEPTEE, c'est-a-dire + /// exactement la distance que mesure SNAPGUARD : les chiffres des deux se comparent. + /// + public static void NoteRead(GpuChannel channel, float dist, float px, float py, float pz, long ageMs) + { + _reads++; + + // [DEFINITION CORRIGEE, 29/07 soir, 7e passe] MESURE : les "intruses" avaient un age de + // reference de 2 443 ms de moyenne (max 2 875) contre 37 ms pour les lectures saines. + // 🐛 Donc elles n'etaient PAS des cameras rivales : c'etait ma definition qui se + // declenchait sur une reference PERIMEE. Pendant 2,4 s le joueur se deplace, et la + // lecture legitime suivante ressemble forcement a un saut de 400 u. Ca explique tout ce + // qui m'intriguait : distances toujours vers 415 (ce qu'on parcourt en 2,4 s), "familles" + // changeant de run en run, "position fixe" revue 19 fois (l'endroit ou les lectures + // reprennent). + // + // Une intruse n'est donc comptee que si le saut est mesure contre une reference FRAICHE. + // Les sauts a reference perimee sont comptes A PART : ils ne disparaissent pas du + // journal, ils changent de nom -- ils mesurent les TROUS d'acceptation, ce qui est une + // vraie grandeur, mais pas celle qu'on croyait. + bool freshRef = ageMs <= FreshRefMs; + bool bigJump = dist >= IntruderDist; + bool intruder = bigJump && freshRef; + + if (bigJump && !freshRef) + { + _staleJumps++; + } + + // [AGE DE LA REFERENCE, 29/07 soir, 6e passe] LE DOUTE SUR MA PROPRE DEFINITION. + // "Intruse" = pas de 100 u ou plus depuis la DERNIERE POSITION ACCEPTEE. Or quand + // SNAPGUARD refuse un moment, cette reference VIEILLIT pendant que le joueur avance : + // une lecture parfaitement legitime peut alors ressembler a un saut de 400 u. Ce qui + // collerait avec la famille qui DEFILAIT au plan Y du joueur ([115,1 -11,5 142,1] -> + // [117,0 -11,5 143,7]) : peut-etre sa propre camera, lue apres une tenue longue. + // Si les intruses ont un age de reference bien plus eleve que les lectures saines, une + // partie du "residu" est un artefact de MA sonde, pas un defaut -- et il ne faut surtout + // pas coder un correctif contre ca. + if (intruder) + { + _ageSumIntr += ageMs; + _ageNIntr++; + + if (ageMs > _ageMaxIntr) + { + _ageMaxIntr = ageMs; + } + } + else + { + _ageSumClean += ageMs; + _ageNClean++; + } + + if (intruder) + { + _intruders++; + NoteIntruder(dist, px, py, pz); + } + + int bucket = _rank <= 1 ? 0 : _rank <= 3 ? 1 : _rank <= 10 ? 2 : _rank <= 50 ? 3 : 4; + + if (intruder) + { + _rankSumIntr += _rank; + _rankNIntr++; + _rankHistIntr[bucket]++; + } + else + { + _rankSumClean += _rank; + _rankNClean++; + _rankHistClean[bucket]++; + } + + // [NOLDR A/B] Etiquette la lecture avec la moitie d'experience en cours. C'est le seul + // endroit qui sait si une lecture est une intruse, et MvppLdrSkip le seul qui sache + // quelle moitie tourne : les deux moities se comparent alors sur la MEME scene, ce qui + // supprime la variable qui a fait juger trois correctifs sur du hasard ce matin. + MvppLdrSkip.NoteRead(intruder); + + Describe(channel, out int fmt, out int cw, out int ch, out int dw, out int dh, + out ulong cAddr, out ulong dAddr); + + Record(_readCtx, fmt, cw, ch, dw, dh, cAddr, dAddr, _rank, dist, intruder); + + long now = Environment.TickCount64; + + if (now - _lastReportMs >= ReportMs) + { + _lastReportMs = now; + Report(); + } + } + + /// Re-arme le rang de dessin, une fois par image presentee. + public static void OnFrame() + { + if (_rank > 0) + { + _frames++; + _qualSum += _rank; + + if (_rank < _qualMin) + { + _qualMin = _rank; + } + + if (_rank > _qualMax) + { + _qualMax = _rank; + } + } + + // On ne touche PLUS a _rank ici (fil de presentation) : on avance un jeton, et le fil GPU + // fera la remise a zero lui-meme. C'est la reparation de la colonne "rang". + _frameMark++; + _probedThisFrame = false; + } + + /// + /// Recense une intruse, dedupliquee par sa POSITION arrondie a l'unite. + /// + /// 🐛 [Correction 29/07 soir] La v1 dedupliquait par DISTANCE, et c'etait faux : quand le + /// joueur bouge, chaque image donne une distance neuve pour une intruse pourtant immobile + /// (mesure : 412,1 · 411,9 · 411,7 · 411,4 … depuis des positions qui defilent). La table de + /// 16 lignes debordait donc en quelques secondes -- 23 valeurs perdues -- et le compteur + /// "vue N fois" ne voulait rien dire. + /// + /// La position est la bonne cle, et elle repond a LA question qui reste : combien du residu + /// est-il STATIONNAIRE ? Un siege immobile pendant que la camera acceptee bouge n'est pas la + /// camera de scene, et ca serait un critere GENERIQUE -- contrairement a un seuil sur une + /// hauteur du monde. Les mesures disent que les deux especes coexistent : la famille a 550 u + /// tient une position quasi fixe ([53,8 −1,0 −337,6]) alors que celle a 410 u DEFILE avec le + /// joueur, au meme plan Y que lui. Le compte par position dira laquelle domine, donc ce qu'un + /// tel critere pourrait gagner -- AVANT d'ecrire la moindre ligne de correctif. + /// + private static void NoteIntruder(float dist, float px, float py, float pz) + { + for (int i = 0; i < _intrN; i++) + { + if (MathF.Abs(_intrPx[i] - px) < 1f && + MathF.Abs(_intrPy[i] - py) < 1f && + MathF.Abs(_intrPz[i] - pz) < 1f) + { + _intrCount[i]++; + + return; + } + } + + if (_intrN >= MaxIntr) + { + _intrOverflow++; + + return; + } + + _intrDist[_intrN] = dist; + _intrPx[_intrN] = px; + _intrPy[_intrN] = py; + _intrPz[_intrN] = pz; + _intrCount[_intrN] = 1; + _intrN++; + } + + private static void Describe( + GpuChannel channel, + out int fmt, + out int cw, + out int ch, + out int dw, + out int dh, + out ulong cAddr, + out ulong dAddr) + { + Image.Texture col0 = channel.TextureManager.RenderTargetColor0; + Image.Texture ds = channel.TextureManager.RenderTargetDepthStencil; + + // Dimensions HOTES : c'est ce que le rendu occupe vraiment, mise a l'echelle comprise. + GAL.ITexture colHost = col0?.HostTexture; + GAL.ITexture dsHost = ds?.HostTexture; + + fmt = col0 != null ? (int)col0.Info.FormatInfo.Format : -1; + cw = colHost?.Width ?? 0; + ch = colHost?.Height ?? 0; + dw = dsHost?.Width ?? 0; + dh = dsHost?.Height ?? 0; + cAddr = col0 != null ? col0.Range.GetSubRange(0).Address : 0UL; + dAddr = ds != null ? ds.Range.GetSubRange(0).Address : 0UL; + } + + /// + /// Regroupe par (format couleur, dimensions couleur, dimensions profondeur, ADRESSE de la + /// profondeur). + /// + /// [29/07 soir, 2e passe] L'adresse de la PROFONDEUR est entree dans la cle, et elle seule. + /// Pourquoi : une fois `NOLDR` arme, le residu d'intruses s'est concentre dans le contexte + /// "aucune couleur attachee" -- 21 intruses sur 5 146 lectures -- devenu le contexte + /// DOMINANT. Or les journaux montrent DEUX cibles de profondeur stables qui reviennent run + /// apres run (…52C0000 et …5680000) : si les intruses se concentrent sur l'une des deux, on + /// tient le meme genre de discriminant que celui qui a tue la passe 8 bits. C'est la seule + /// facon de subdiviser ce contexte, puisqu'il n'a pas de couleur a montrer. + /// + /// L'adresse de la COULEUR reste hors de la cle : le jeu fait tourner ses tampons (XC2 : + /// …ED00/EE00/EF00, une seule trajectoire), donc l'y mettre eclaterait un meme contexte en + /// plusieurs lignes. Elle est conservee a titre indicatif (derniere vue). Si la profondeur + /// tourne elle aussi, la table debordera -- et le compteur de debordement le DIRA, il est + /// journalise : une sonde qui tronque en silence se lit comme une sonde qui a tout vu. + /// + private static void Record( + Ctx[] table, + int fmt, + int cw, + int ch, + int dw, + int dh, + ulong cAddr, + ulong dAddr, + int rank, + float dist, + bool intruder) + { + for (int i = 0; i < MaxCtx; i++) + { + ref Ctx c = ref table[i]; + + if (!c.Used) + { + c.Used = true; + c.Fmt = fmt; + c.CW = cw; + c.CH = ch; + c.DW = dw; + c.DH = dh; + c.DepthAddr = dAddr; + c.MinRank = rank; + c.MaxRank = rank; + } + else if (c.Fmt != fmt || c.CW != cw || c.CH != ch || c.DW != dw || c.DH != dh || + c.DepthAddr != dAddr) + { + continue; + } + + c.ColorAddr = cAddr; + c.Reads++; + + if (rank < c.MinRank) + { + c.MinRank = rank; + } + + if (rank > c.MaxRank) + { + c.MaxRank = rank; + } + + if (intruder) + { + c.Intruders++; + } + + if (dist > c.MaxDist) + { + c.MaxDist = dist; + } + + return; + } + + // Table pleine : compte l'oubli plutot que de le taire. Une sonde qui tronque en + // silence se lit comme une sonde qui a tout vu. + _ctxOverflow++; + } + + private static void Report() + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE : {_reads} lectures · {_intruders} INTRUSES (pas >= {IntruderDist:0} u " + + $"contre une reference FRAICHE) · {_staleJumps} gros sauts ecartes (reference perimee, " + + $"pas des intruses) · " + + $"passes qualifiantes par image : min {(_qualMin == int.MaxValue ? 0 : _qualMin)} / " + + $"max {_qualMax} / moyenne {(_frames > 0 ? (float)_qualSum / _frames : 0f):0.##} sur {_frames} images" + + $"{(_ctxOverflow > 0 ? $" · TABLE PLEINE, {_ctxOverflow} contextes non comptes" : "")}"); + + // LA LIGNE QUI DECIDE. Si "passe HDR" est proche de 100 % des images 3D ET que la camera + // y est lisible presque toujours, alors deplacer la capture est jouable et c'est mon + // garde-fou qui etait mal regle. Si l'un des deux s'effondre, l'approche est morte -- et + // c'est un resultat, pas un echec. + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE VERDICT PASSE HDR : {_frames} images 3D · " + + $"{_hdrFrames} avec une passe HDR ({(_frames > 0 ? 100f * _hdrFrames / _frames : 0f):0.#} %) · " + + $"camera LISIBLE sur {_hdrReadable} " + + $"({(_hdrFrames > 0 ? 100f * _hdrReadable / _hdrFrames : 0f):0.#} % de celles-la) · " + + $"illisible sur {_hdrUnreadable} · " + + $"intruses parmi les lisibles : {_hdrIntruders} " + + $"({(_hdrReadable > 0 ? 100f * _hdrIntruders / _hdrReadable : 0f):0.##} %)"); + + // L'AGE DE LA REFERENCE : le test qui dit si mes "intruses" sont reelles ou si c'est ma + // definition qui les fabrique. Ages comparables => intruses reelles. Age des intruses + // bien plus eleve => une partie du residu est un artefact de la sonde. + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE AGE DE LA REFERENCE · saines : " + + $"{(_ageNClean > 0 ? (float)_ageSumClean / _ageNClean : 0f):0.#} ms de moyenne sur {_ageNClean} · " + + $"intruses : {(_ageNIntr > 0 ? (float)_ageSumIntr / _ageNIntr : 0f):0.#} ms sur {_ageNIntr} " + + $"(max {_ageMaxIntr} ms)"); + + // LE RANG : intruses contre lectures saines. Un ecart franc ici = un correctif generique + // ("ne pas capturer trop tot dans l'image"). Des profils identiques = axe mort, et c'est + // un resultat aussi. + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE RANG · saines : moyenne " + + $"{(_rankNClean > 0 ? (float)_rankSumClean / _rankNClean : 0f):0.#} sur {_rankNClean} " + + $"[rang1 {Pct(_rankHistClean[0], _rankNClean)} · 2-3 {Pct(_rankHistClean[1], _rankNClean)} · " + + $"4-10 {Pct(_rankHistClean[2], _rankNClean)} · 11-50 {Pct(_rankHistClean[3], _rankNClean)} · " + + $"51+ {Pct(_rankHistClean[4], _rankNClean)}]"); + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE RANG · intruses : moyenne " + + $"{(_rankNIntr > 0 ? (float)_rankSumIntr / _rankNIntr : 0f):0.#} sur {_rankNIntr} " + + $"[rang1 {Pct(_rankHistIntr[0], _rankNIntr)} · 2-3 {Pct(_rankHistIntr[1], _rankNIntr)} · " + + $"4-10 {Pct(_rankHistIntr[2], _rankNIntr)} · 11-50 {Pct(_rankHistIntr[3], _rankNIntr)} · " + + $"51+ {Pct(_rankHistIntr[4], _rankNIntr)}]"); + + // LA LIGNE QUI PEUT FERMER LE DOSSIER : si toutes les intruses tiennent sur une ou deux + // valeurs, depuis une position fixe, ce sont des evenements DETERMINISTES (transition de + // chargement) et non le symptome qu'on chasse. + for (int i = 0; i < _intrN; i++) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE INTRUSE #{i} : pas {_intrDist[i]:0.0} u · " + + $"depuis [{_intrPx[i]:0.0} {_intrPy[i]:0.0} {_intrPz[i]:0.0}] · " + + $"vue {_intrCount[i]} fois"); + } + + if (_intrOverflow > 0) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE INTRUSES : {_intrOverflow} valeurs distinctes non comptees (table pleine)"); + } + + Dump("CONTEXTE DE LECTURE", _readCtx, true); + Dump("PASSES QUALIFIANTES", _qualCtx, false); + } + + private static string Pct(int n, int total) + { + return total > 0 ? $"{100f * n / total:0}%" : "-"; + } + + private static void Dump(string title, Ctx[] table, bool withIntruders) + { + for (int i = 0; i < MaxCtx; i++) + { + ref Ctx c = ref table[i]; + + if (!c.Used) + { + break; + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CTXPROBE {title} #{i} : " + + $"COLOR {(c.Fmt < 0 ? "aucune" : $"{(GAL.Format)c.Fmt} {c.CW}x{c.CH} @0x{c.ColorAddr:X}")} · " + + $"DEPTH {c.DW}x{c.DH} @0x{c.DepthAddr:X} · " + + $"rang {c.MinRank}-{c.MaxRank} · {c.Reads} vues" + + (withIntruders + ? $" · {c.Intruders} intruses · pas max {c.MaxDist:0.#} u" + : "")); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDofSkip.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDofSkip.cs new file mode 100644 index 000000000..2d5ae2f9d --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDofSkip.cs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Gpu.Image; +using System; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// [DOFSKIP] (RYUJINX_MVPP_DOF_SCATTER_SKIP=1, inert unless set). Compatibility option. + /// + /// The name MUST keep the RYUJINX_MVPP_ prefix: DlssRestart only carries variables matching + /// RYUJINX_DLSS* / RYUJINX_MVPP_* / RYUJINX_HDR_PQ across a cold restart, so a gate named + /// outside those prefixes is silently dropped the moment a DLSS setting changes in the UI - + /// the same partial-state bug already caught once on SKYDRIFT/SKYGRID/EDGE/BORDERFIX. + /// + /// Skips the scatter/bokeh accumulation pass of the Xenoblade engine's motion blur. On + /// Vulkan that pass renders a block artifact this fork has not been able to explain - + /// journal 226..246 eliminated, by measurement, every shader of the chain (all bit-identical + /// to OpenGL on an offline bench), its inputs, its constants, the sin precision, the LOD, + /// the blit path and the synchronisation. The effect it produces exists to hide the console's + /// 30 fps; at emulated framerates it buys nothing, so switching it off is a defensible + /// trade rather than a mutilation. It IS a trade: the blur is gone, not fixed. + /// + /// Identified by PIPELINE SHAPE, never by a hardcoded guest address or shader hash: + /// the pass is the only one in the frame whose colour target is a 512x288-equivalent + /// RGBA16F surface. Two consequences matter: + /// - it keeps working across game versions and regions, where an address does not; + /// - it works with the shader cache ENABLED, unlike the translation-time gate, which + /// never matches on a cached shader (the guest address comes back as 0) and therefore + /// required playing with the cache off - unusable in practice. + /// + /// Matched by PROPORTION of the render width, so it survives DLSS and ResScale alike. + /// + static class MvppDofSkip + { + /// + /// 0 = off (default), 1 = skip the pass, 2 = DRY RUN: detect and report, skip NOTHING. + /// + /// Mode 2 exists because the shape rule was validated against a 400-target census taken in + /// GAMEPLAY only - cutscenes were never censused. If a cutscene pass falls inside the same + /// proportions it would be skipped while legitimate, leaving its surface untouched, which + /// reads on screen as coloured blocks. Mode 2 answers "which targets would I have skipped" + /// during a single cutscene without altering a single pixel, so the visual verdict and the + /// measurement come from the SAME run. + /// + public static readonly int Mode = ParseMode(); + + public static bool Enabled => Mode != 0; + + private static int ParseMode() + { + return Environment.GetEnvironmentVariable("RYUJINX_MVPP_DOF_SCATTER_SKIP") switch + { + "1" => 1, + "2" => 2, + _ => 0, + }; + } + + // The pass renders at 512x288 when the game renders at 1280x720 - that is 40% of the + // render width, in 16:9. Matching those PROPORTIONS instead of an absolute pixel size is + // what makes the option survive every scaling path: DLSS multiplies the whole frame + // (quality made 512x288 come through as 768x432, x1.5, which an absolute match missed + // entirely) and ResScale multiplies it again, neither of them reported by + // RenderTargetScale alone. + private const float WidthRatio = 0.4f; // 512 / 1280 + private const float AspectRatio = 16f / 9f; + private const float Tolerance = 0.02f; + + private static bool _announced; + private static long _skipped; + private static long _lastLogMs; + private static int _renderWidth; + + // Census of the DISTINCT target shapes this rule matches, and of every render-width change. + // Both are silent in mode 1 beyond the first announcement; mode 2 reports them. A second + // distinct shape appearing only during cutscenes IS the false positive we are hunting: the + // rule cannot tell it apart from the bokeh pass, so it skips a legitimate draw and leaves + // its surface untouched. + private const int CensusCap = 16; + private static readonly long[] _shapes = new long[CensusCap]; + private static int _shapeCount; + private static readonly object _censusLock = new(); + + /// + /// True when this draw is the scatter/bokeh accumulation and should not be issued. + /// + public static bool ShouldSkip(GpuChannel channel) + { + if (!Enabled || channel == null) + { + return false; + } + + Image.Texture target; + + try + { + target = channel.TextureManager.GetColorTarget(0); + } + catch + { + return false; + } + + if (target == null) + { + return false; + } + + int rawW = target.Info.Width; + int rawH = target.Info.Height; + + // Track the widest colour target seen: that is the frame's render width, whatever + // DLSS and ResScale multiplied it by. Everything else is judged relative to it. + if (rawW > _renderWidth) + { + int previous = _renderWidth; + _renderWidth = rawW; + + // The reference only ever grows, so a wider target appearing in a cutscene silently + // moves the 40% goalpost for every later frame. Worth seeing, not just inferring. + if (Mode == 2 && previous != 0) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[DOFSKIP/DRY] render width reference moved: {previous} -> {rawW}. " + + $"The 40% target is now {rawW * WidthRatio:F0}px wide."); + } + } + + if (target.Info.FormatInfo.Format != GAL.Format.R16G16B16A16Float || _renderWidth == 0) + { + return false; + } + + float widthRatio = rawW / (float)_renderWidth; + float aspect = rawH == 0 ? 0f : rawW / (float)rawH; + + if (MathF.Abs(widthRatio - WidthRatio) > Tolerance || + MathF.Abs(aspect - AspectRatio) > Tolerance) + { + return false; + } + + _skipped++; + + // Report every DISTINCT shape the rule claims, not just the first one. The original + // code announced once and stayed silent forever, so a second matching shape appearing + // later in the session - exactly the cutscene case - was invisible in the log. + ReportShape(rawW, rawH, widthRatio); + + if (!_announced) + { + _announced = true; + Logger.Warning?.Print(LogClass.Gpu, + $"[DOFSKIP] armed: skipping the scatter/bokeh pass (target {rawW}x{rawH} RGBA16F = " + + $"{widthRatio * 100f:F0}% of a {_renderWidth}px render, 16:9). " + + "Motion blur will be absent - this is the option's purpose."); + } + + long now = Environment.TickCount64; + + if (now - _lastLogMs >= 10000) + { + _lastLogMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[DOFSKIP{(Mode == 2 ? "/DRY" : "")}] draws matched so far: {_skipped}"); + } + + // Dry run: everything above is measurement, nothing is skipped. The frame renders exactly + // as it would with the option off, so the eye verdict and the census come from one run. + return Mode != 2; + } + + /// + /// Logs a matched target shape the first time it is seen. Deduplicated and capped, so a + /// pass running once per frame costs one line for the whole session. + /// + private static void ReportShape(int width, int height, float widthRatio) + { + long key = ((long)width << 32) | (uint)height; + + lock (_censusLock) + { + for (int i = 0; i < _shapeCount; i++) + { + if (_shapes[i] == key) + { + return; + } + } + + if (_shapeCount >= CensusCap) + { + return; + } + + _shapes[_shapeCount++] = key; + + string tag = Mode == 2 ? "DOFSKIP/DRY" : "DOFSKIP"; + string verb = Mode == 2 ? "WOULD MATCH" : "MATCH"; + + Logger.Warning?.Print(LogClass.Gpu, + $"[{tag}] {verb} #{_shapeCount}: {width}x{height} RGBA16F = " + + $"{widthRatio * 100f:F1}% of a {_renderWidth}px render. " + + (_shapeCount > 1 + ? "SECOND DISTINCT SHAPE - the rule is claiming more than the bokeh pass." + : "This is the expected bokeh target.")); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDrawStepProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDrawStepProbe.cs new file mode 100644 index 000000000..51e0d69d1 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppDrawStepProbe.cs @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.IO; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Capture PAS À PAS d'une seule image (RYUJINX_DRAWSTEP=1). READ-ONLY, off par défaut. + /// + /// POURQUOI. RenderDoc refuse de tourner sur cet émulateur (deux tentatives, deux plantages). + /// Or ce qu'il apporte tient en une chose : voir le contenu de la cible APRÈS CHAQUE DRAW, pour + /// repérer l'instant exact où les pixels deviennent faux. Cette sonde fait la même chose en plus + /// rustique : sur UNE image et une seule, elle écrit la cible couleur principale tous les N draws. + /// On obtient une séquence qui montre la scène se construire, et on lit à quel moment le + /// quadrillage ou les traînées apparaissent -- puis on remonte au draw responsable. + /// + /// Le dossier XC2 au 21/07 : le défaut est DANS l'image que le jeu produit (établi sur une paire + /// même-image), et aucun état de rastérisation n'est en cause (viewport, scissor, screen scissor, + /// clip, miroir, swizzle : six mesures, zéro anomalie sur ~90 000 draws). Restent les draws + /// eux-mêmes, et personne ne les a jamais regardés un par un. + /// + /// COÛT ASSUMÉ : chaque capture est une lecture GPU->CPU synchrone qui vide le pipeline. Une + /// trentaine d'affilée fige le jeu quelques secondes. C'est pour ça que ça ne tourne QUE sur une + /// image, et jamais par défaut. + /// + static class MvppDrawStepProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP") == "1"; + + /// Secondes avant de capturer l'image (RYUJINX_DRAWSTEP_START, défaut 45). + private static readonly int _startSeconds = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_START"), out int st) && st > 0 ? st : 45; + + /// Un cliché tous les N draws (RYUJINX_DRAWSTEP_EVERY, défaut 50). + private static readonly int _everyDraws = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_EVERY"), out int ev) && ev > 0 ? ev : 50; + + /// + /// Ne capturer qu'à partir de ce numéro de draw (RYUJINX_DRAWSTEP_FROM, défaut 0 = dès le début). + /// [21/07] La frame artefactée a ~54 draws et le budget s'épuisait au draw 33, ratant la + /// composition finale (34-54) où la corruption naît. Ce gate saute le G-buffer du début pour + /// dépenser tout le budget DENSÉMENT sur la fin de frame. + /// + private static readonly int _fromDraw = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_FROM"), out int fdr) && fdr > 0 ? fdr : 0; + + /// Plafond de clichés PAR image (RYUJINX_DRAWSTEP_MAX, défaut 25). + private static readonly int _maxSteps = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_MAX"), out int mx) && mx > 0 ? mx : 25; + + /// + /// Nombre d'images instrumentées (RYUJINX_DRAWSTEP_FRAMES, défaut 5), espacées de _gapMs. + /// Une seule image obligerait le joueur à tomber pile sur l'instant où l'artefact est là ; + /// avec plusieurs tentatives espacées, il lui suffit de tourner en continu. + /// + private static readonly int _frames = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_FRAMES"), out int fr) && fr > 0 ? fr : 5; + + /// Pause entre deux images instrumentées (RYUJINX_DRAWSTEP_GAP, défaut 3000 ms). + private static readonly int _gapMs = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_GAP"), out int gp) && gp > 0 ? gp : 3000; + + /// + /// Déclenchement AU CLAVIER (F10 par défaut, RYUJINX_DRAWSTEP_VKEY pour changer le code). + /// + /// La version à minuterie était injouable : elle demandait d'avoir chargé la partie, d'être au + /// bon endroit ET que l'artefact soit visible à la seconde près. Ici c'est l'inverse -- Alex joue + /// normalement, et quand il VOIT le défaut il appuie. Même principe que le F12 de RenderDoc. + /// + private static readonly int _vkey = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DRAWSTEP_VKEY"), out int vk) && vk > 0 ? vk : 0x79; + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int vKey); + + private static bool TriggerHeld() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + return (GetAsyncKeyState(_vkey) & 0x8000) != 0; + } + catch + { + return false; + } + } + + private static long _armMs; + private static long _nextMs; + private static bool _announced; + private static bool _capturing; + private static bool _done; + private static int _frameNo; + private static int _csDispatches, _csBindings, _csImages, _csStores; + private static int _drawNo; + private static int _stepNo; + private static string _dir; + + /// + /// Frontière d'image, appelée depuis Gpu/Window.Present. Ouvre la capture sur l'image suivante, + /// puis la referme définitivement : une seule image est instrumentée par session. + /// + public static void OnPresent(Image.Texture presented) + { + if (!_enabled || _done) + { + return; + } + + try + { + long now = Environment.TickCount64; + + if (_armMs == 0) + { + _armMs = now; + } + + if (!_announced) + { + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP DRAWSTEP: armed -- APPUIE SUR F10 quand tu VOIS l'artefact. Chaque appui capture une " + + $"image entiere ({_maxSteps} cliches, un tous les {_everyDraws} draws). {_frames} appuis au " + + "maximum. Le jeu figera brievement a chaque fois : c'est la capture."); + } + + if (_capturing) + { + // L'image instrumentée vient de se terminer. On écrit d'abord CE QUE LE JOUEUR VOIT : + // sans ça la séquence s'arrête sur un buffer HDR intermédiaire, qu'Alex ne peut pas + // reconnaître -- c'est ce qui a rendu les 5 premières séquences inexploitables. + if (presented?.HostTexture != null) + { + try + { + using GAL.PinnedSpan pdata = presented.HostTexture.GetData(); + ReadOnlySpan pbytes = pdata.Get(); + string pname = $"ZFINAL_presente_{presented.Info.Width}x{presented.Info.Height}_{presented.Info.FormatInfo.Format}.bin"; + File.WriteAllBytes(Path.Combine(_dir, pname), pbytes.ToArray()); + + Logger.Info?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: {pname} ({pbytes.Length} octets) -- image telle que presentee."); + } + catch (Exception pe) + { + Logger.Warning?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: image presentee illisible: {pe.Message}"); + } + } + + _capturing = false; + _nextMs = now + _gapMs; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP DRAWSTEP: image {_frameNo}/{_frames} terminee -- {_stepNo} cliches sur {_drawNo} draws, dans {_dir}. " + + $"COMPUTE: {_csDispatches} dispatchs, {_csBindings} bindings, {_csImages} images, {_csStores} en ecriture."); + + if (_frameNo >= _frames) + { + _done = true; + Logger.Info?.Print(LogClass.Gpu, "MVPP DRAWSTEP: toutes les images sont capturees."); + } + + return; + } + + // Anti-rebond seulement : la décision appartient entièrement au joueur. + if (now < _nextMs || !TriggerHeld()) + { + return; + } + + _frameNo++; + _dir = Path.Combine("drawstep", $"image{_frameNo:D2}"); + Directory.CreateDirectory(_dir); + _drawNo = 0; + _stepNo = 0; + _csDispatches = _csBindings = _csImages = _csStores = 0; + _capturing = true; + + Logger.Info?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: capture de l'image {_frameNo}/{_frames} -> {_dir}"); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: desactive apres erreur: {e.Message}"); + } + } + + /// + /// Dispatch COMPUTE, appelé depuis ComputeClass. Mesuré le 21/07 : au dernier DRAW de l'image la + /// scène est complète et propre, et dans l'image présentée la moitié du décor a disparu sous une + /// zone verte à bords droits. Comme cette sonde voit TOUS les draws et rien d'autre, ce qui reste + /// entre les deux, c'est le compute et les copies -- un étage jamais instrumenté dans ce dossier. + /// + /// On capture ici les IMAGES DE SORTIE du dispatch (bindings image en écriture), puisqu'un compute + /// n'écrit pas dans une cible de rendu. Le nom porte le rang du dispatch DANS la séquence de draws, + /// pour qu'on puisse replacer l'événement au bon endroit de la frame. + /// + public static void OnDispatch(Image.TextureManager tm, int gridX, int gridY, int gridZ, ulong shaderVa) + { + if (!_enabled || !_capturing || _stepNo >= _maxSteps || tm == null) + { + return; + } + + try + { + int idx = 0; + + // Compteurs VISIBLES : sans eux, "aucune capture" ne distingue pas "aucun dispatch" de + // "dispatch sans image en ecriture". C'est la regle du projet, et je l'ai deja violee une fois. + _csDispatches++; + + tm.MvppEnumerateComputeBindings((isImage, isStore, tex) => + { + _csBindings++; + + if (isImage) + { + _csImages++; + } + + if (isImage && isStore) + { + _csStores++; + } + }); + + tm.MvppEnumerateComputeBindings((isImage, isStore, tex) => + { + if (!isImage || !isStore || tex?.HostTexture == null || _stepNo >= _maxSteps) + { + return; + } + + using GAL.PinnedSpan data = tex.HostTexture.GetData(); + ReadOnlySpan bytes = data.Get(); + + string name = $"step{_stepNo:D3}_CS{idx}_apresDraw{_drawNo:D5}_grid{gridX}x{gridY}x{gridZ}_" + + $"{tex.Info.Width}x{tex.Info.Height}_{tex.Info.FormatInfo.Format}.bin"; + File.WriteAllBytes(Path.Combine(_dir, name), bytes.ToArray()); + _stepNo++; + idx++; + + Logger.Info?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: {name} (shader 0x{shaderVa:X})."); + }); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: desactive apres erreur compute: {e.Message}"); + } + } + + public static void OnDraw(GpuChannel channel) + { + if (!_enabled || !_capturing || _stepNo >= _maxSteps) + { + return; + } + + try + { + ++_drawNo; + + if (_drawNo < _fromDraw || (_drawNo % _everyDraws) != 0) + { + return; + } + + // [21/07] TOUS les slots couleur, plus seulement le 0 : la texture PRÉSENTÉE est un + // R8G8B8A8 alors que les derniers draws visaient le HDR R11G11B10 en slot 0. En ne + // regardant que le slot 0 on capturait la mauvaise cible et on croyait la scène propre. + int localDraw = _drawNo; + + channel.TextureManager.MvppEnumerateRenderTargets((slot, rt) => + { + if (rt?.HostTexture == null || _stepNo >= _maxSteps) + { + return; + } + + using GAL.PinnedSpan data = rt.HostTexture.GetData(); + ReadOnlySpan bytes = data.Get(); + + string name = $"step{_stepNo:D3}_draw{localDraw:D5}_slot{slot}_{rt.Info.Width}x{rt.Info.Height}_{rt.Info.FormatInfo.Format}.bin"; + File.WriteAllBytes(Path.Combine(_dir, name), bytes.ToArray()); + _stepNo++; + + Logger.Info?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: {name} ({bytes.Length} octets)."); + }); + + // [DRAWSTEP-INPUTS 21/07] Dump aussi CE QUE CE DRAW ECHANTILLONNE. Trancher hérité vs né : + // si une sortie capturée est striée ALORS QUE ses entrées le sont déjà -> corruption + // HERITEE (remonter à qui a écrit cette entrée). Si les entrées sont propres et la sortie + // striée -> corruption NEE à ce draw (shader / mise en place de la cible). C'est la + // bifurcation que la capture par-slot du 21/07 ne pouvait pas résoudre : elle ne voyait + // que les sorties. + channel.TextureManager.MvppEnumerateGraphicsInputsStage((stage, tex) => + { + if (tex?.HostTexture == null || _stepNo >= _maxSteps) + { + return; + } + + using GAL.PinnedSpan idata = tex.HostTexture.GetData(); + ReadOnlySpan ibytes = idata.Get(); + + string iname = $"step{_stepNo:D3}_draw{localDraw:D5}_INPUT_s{stage}_{tex.Info.Width}x{tex.Info.Height}_{tex.Info.FormatInfo.Format}.bin"; + File.WriteAllBytes(Path.Combine(_dir, iname), ibytes.ToArray()); + _stepNo++; + + Logger.Info?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: {iname} (entree echantillonnee)."); + }); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: desactive apres erreur de lecture: {e.Message}"); + } + } + + /// + /// Capture APRÈS l'écriture du draw (appelée en fin de DrawEnd, chemin normal). Même grille que + /// OnDraw mais SANS ré-incrémenter _drawNo (déjà fait au pré). Nom suffixé _POST pour apparier + /// pré et post et dire si CE draw écrit la corruption ou la trouve déjà présente dans la cible. + /// + public static void OnDrawPost(GpuChannel channel) + { + if (!_enabled || !_capturing || _stepNo >= _maxSteps) + { + return; + } + + if (_drawNo < _fromDraw || (_drawNo % _everyDraws) != 0) + { + return; + } + + try + { + int localDraw = _drawNo; + + channel.TextureManager.MvppEnumerateRenderTargets((slot, rt) => + { + if (rt?.HostTexture == null || _stepNo >= _maxSteps) + { + return; + } + + using GAL.PinnedSpan data = rt.HostTexture.GetData(); + ReadOnlySpan bytes = data.Get(); + + string name = $"step{_stepNo:D3}_draw{localDraw:D5}_slot{slot}_POST_{rt.Info.Width}x{rt.Info.Height}_{rt.Info.FormatInfo.Format}.bin"; + File.WriteAllBytes(Path.Combine(_dir, name), bytes.ToArray()); + _stepNo++; + + Logger.Info?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: {name} (apres ecriture)."); + }); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP DRAWSTEP: desactive apres erreur POST: {e.Message}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppGlowProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppGlowProbe.cs new file mode 100644 index 000000000..d66e7c599 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppGlowProbe.cs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Light-glow clipping probe (RYUJINX_GLOW_PROBE=1, OFF by default, read-only). + /// + /// WHERE THIS COMES FROM. Ten suspects were eliminated by measurement on the Xenoblade 2 + /// artefact (shader cache, DLSS/MV++/FG, runtime mipmaps, recycled memory, history resets, the + /// fork's gobBlocksInZ clamp, sampling a bound target, the 1080p mod, dynamic resolution, and + /// the scene buffer itself which dumped perfectly clean). Capturing the actual render targets + /// then found it: one 1280x720 R11G11B10Float buffer holds the game's LIGHT HALOS, and every + /// halo sits inside a HARD-EDGED RECTANGLE -- the glow is sliced off at the box border instead + /// of fading out. Detected on six independent captures: ~450 columns and ~300 rows of abrupt + /// edges, where a normal scene image gives 0 to 3. + /// + /// That matches every observation: the boxes follow the lights so they move when the camera + /// turns; more lights indoors means more boxes; it is absent when no light is in frame; and it + /// survived every switch because it is the GAME's own rendering, upstream of all of it. + /// + /// WHAT THIS PROBE ANSWERS. Are those rectangles the SCISSOR the game sets for each glow draw? + /// If the logged scissor boxes match the rectangles seen in the dumps, the mechanism is + /// "content drawn larger than the box it is clipped to", and we know exactly what to look at + /// next. If the scissors are full-screen, the rectangles come from the geometry or the texture + /// instead, and that is a different fix. Read-only either way: it records and reports. + /// + static class MvppGlowProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_GLOW_PROBE") == "1"; + + private static readonly Dictionary _boxes = new(); + private static readonly Dictionary _inputs = new(); + private static long _lastLogMs; + private static int _drawsToGlow; + private static int _scissored; + + public static void OnDraw(GpuChannel channel, ref ThreedClassState state) + { + if (!_enabled) + { + return; + } + + try + { + OnDrawImpl(channel, ref state); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"GLOWPROBE: disabled after unexpected error: {e}"); + } + } + + private static void OnDrawImpl(GpuChannel channel, ref ThreedClassState state) + { + // Only the HDR float target the halos live in. Identified from the dumps, by FORMAT and + // shape rather than by any address or game-specific value. + Image.Texture c0 = channel.TextureManager.RenderTargetColor0; + + if (c0 == null || !c0.Info.FormatInfo.Format.ToString().StartsWith("R11G11B10", StringComparison.Ordinal)) + { + return; + } + + _drawsToGlow++; + + ScissorState sc = state.ScissorState[0]; + ScreenScissorState screen = state.ScreenScissorState; + + int w = c0.Info.Width; + int h = c0.Info.Height; + + bool boxed = sc.Enable && (sc.X1 > 0 || sc.Y1 > 0 || sc.X2 < w || sc.Y2 < h); + + if (boxed) + { + _scissored++; + + // Round to 8 px so the same box seen over consecutive frames aggregates instead of + // producing a line per pixel of camera drift. + string key = $"{sc.X1 / 8 * 8},{sc.Y1 / 8 * 8} -> {sc.X2 / 8 * 8},{sc.Y2 / 8 * 8} " + + $"({(sc.X2 - sc.X1) / 8 * 8}x{(sc.Y2 - sc.Y1) / 8 * 8})"; + + _boxes.TryGetValue(key, out int n); + _boxes[key] = n + 1; + } + + // The scissor answered "no" (0 boxes over the whole run), so the rectangles are the + // glow QUADS themselves -- which is normal geometry. What is NOT normal, proven + // against a TOTK control where the same buffer shows zero hard edges, is that the halo + // is still bright AT the quad border instead of having faded to nothing. So look at + // what these draws READ: the halo texture and, above all, its ADDRESSING MODE, which + // is exactly what decides the value returned past the texture edge. ClampToEdge repeats + // the last texel for ever (a bright rim stays bright); ClampToBorder returns the border + // colour (normally transparent black, which fades correctly). + channel.TextureManager.MvppEnumerateGraphicsInputsWithSampler((stage, tex, smp) => + { + if (tex == null || smp == null || _inputs.Count >= 24) + { + return; + } + + string k = $"{tex.Info.Width}x{tex.Info.Height} {tex.Info.FormatInfo.Format} " + + $"U={smp.ProbeAddressU} V={smp.ProbeAddressV} borderA={smp.ProbeBorderA:0.##} " + + $"min={smp.ProbeMinFilter}"; + + _inputs.TryGetValue(k, out int c); + _inputs[k] = c + 1; + }); + + long now = Environment.TickCount64; + + if (now - _lastLogMs < 5000) + { + return; + } + + _lastLogMs = now; + + System.Text.StringBuilder sb = new(); + sb.Append($"GLOWPROBE: {_drawsToGlow} draws vers le buffer HDR {w}x{h}, {_scissored} avec une boite de decoupe"); + sb.Append($" (ecran {screen.X},{screen.Y} {screen.Width}x{screen.Height})"); + + if (_boxes.Count == 0) + { + sb.Append(" -- AUCUNE boite: les rectangles ne viennent PAS du scissor."); + } + else + { + sb.Append($", {_boxes.Count} boites distinctes:"); + int shown = 0; + + foreach (KeyValuePair kv in _boxes) + { + if (shown++ >= 8) + { + sb.Append($" (+{_boxes.Count - 8})"); + + break; + } + + sb.Append($" [{kv.Key} x{kv.Value}]"); + } + } + + if (_inputs.Count > 0) + { + System.Text.StringBuilder ib = new("GLOWPROBE textures lues par ces draws:"); + int k = 0; + + foreach (KeyValuePair kv in _inputs) + { + if (k++ >= 10) + { + ib.Append($" (+{_inputs.Count - 10})"); + + break; + } + + ib.Append($"\n [{kv.Key}] x{kv.Value}"); + } + + Logger.Info?.Print(LogClass.Gpu, ib.ToString()); + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + + _inputs.Clear(); + _drawsToGlow = 0; + _scissored = 0; + _boxes.Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHangWatch.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHangWatch.cs new file mode 100644 index 000000000..edfed778e --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHangWatch.cs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// [HANGWATCH 29/07 fin de soirée] Surveillance qui SURVIT AU GEL + /// (RYUJINX_MVPP_HANGWATCH=1, coupée par défaut). Préparée à la demande d'Alex, à laisser + /// dormir : le chantier du gel n'est PAS ouvert, l'instrument est juste prêt. + /// + /// LE PROBLÈME QU'ELLE RÉSOUT. Alex a un blocage fréquent : après un combat, sur un écran de + /// chargement, **l'image se figeauze mais la musique continue**, le processus reste vivant (214 + /// fils, un seul qui tourne, la fenêtre répond), et fermer/rouvrir suffit à repartir. Vu sans + /// FG comme avec, et bien avant qu'on la rallume ⇒ **la FG est hors de cause**. + /// + /// ⚠️ ET ON EST AVEUGLES À L'INSTANT EXACT : toutes les lignes du journal viennent du fil + /// graphique, donc quand il se coince, le journal se coince avec lui. On voit les dix minutes + /// d'avant, jamais le moment. Aucune sonde posée sur ce fil ne peut répondre -- il faut un + /// observateur EXTÉRIEUR. C'est tout l'objet de ce fichier. + /// + /// CE QU'ELLE MESURE, ET POURQUOI CES DEUX CHIFFRES SUFFISENT À TRANCHER. Un fil de fond, + /// indépendant, compare deux horloges : + /// - la dernière PRÉSENTATION d'image (, appelé depuis OnFrameEnqueued) ; + /// - le compteur de DESSINS de . + /// Deux familles, et elles ne se réparent pas au même endroit : + /// A. les présentations s'arrêtent MAIS les dessins continuent d'avancer ⇒ le fil graphique + /// est vivant, c'est la PRÉSENTATION qui est bloquée (chaîne d'affichage, file de + /// présentation, interposition d'un proxy) ; + /// B. les deux s'arrêtent ensemble ⇒ c'est le fil graphique LUI-MÊME qui est coincé (attente + /// GPU, compilation de shaders, verrou). + /// Un seul run avec cette surveillance armée donnera la lettre. Sans elle, on ne peut que + /// deviner -- et deviner est exactement ce qui a coûté des runs à Alex le 29/07. + /// + /// ⛔ ELLE NE RÉPARE RIEN ET NE TOUCHE À RIEN : aucune écriture d'état de rendu, aucun réveil + /// forcé, aucune tentative de récupération. Elle observe et elle écrit une ligne. Le seul coût + /// sur le chemin chaud est une écriture d'entier par image présentée. + /// + static class MvppHangWatch + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_HANGWATCH") == "1"; + + /// + /// Au-delà de ce silence, on considère l'affichage arrêté. 3 s = une centaine d'images + /// manquées à 30 Hz : bien au-delà d'un simple à-coup ou d'une compilation de shaders + /// ordinaire, et bien en dessous des dizaines de secondes qu'Alex laisse passer avant de + /// fermer. Ni faux positifs de micro-saccade, ni détection trop tardive. + /// + private const long StallMs = 3000; + + /// Rappel pendant que le gel dure, pour voir si les dessins avancent ou non. + private const long RepeatMs = 5000; + + private static long _lastPresentMs; + private static int _started; + + private static long _stallLoggedMs; + private static int _drawsAtStall; + private static bool _inStall; + + /// + /// Appelée à chaque image présentée. Volontairement minuscule : une écriture d'entier, rien + /// d'autre, pas de verrou -- elle est sur le chemin chaud. + /// + public static void Ping() + { + Volatile.Write(ref _lastPresentMs, Environment.TickCount64); + + if (Volatile.Read(ref _started) == 0 && Interlocked.Exchange(ref _started, 1) == 0) + { + Thread t = new(Loop) + { + Name = "MvppHangWatch", + IsBackground = true, + }; + + t.Start(); + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP HANGWATCH : surveillance armee (silence d'affichage de {StallMs} ms = gel). " + + "Elle observe seulement, elle ne repare rien."); + } + } + + private static void Loop() + { + while (true) + { + Thread.Sleep(500); + + long now = Environment.TickCount64; + long last = Volatile.Read(ref _lastPresentMs); + long silence = now - last; + + if (silence < StallMs) + { + if (_inStall) + { + _inStall = false; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP HANGWATCH : l'affichage est REPARTI apres {silence} ms de silence " + + "-- ce n'etait donc pas definitif. Noter ce qui se passait a l'ecran."); + } + + continue; + } + + // Le chiffre qui tranche : les dessins avancent-ils PENDANT le gel ? + int draws = MvppCameraCapture.StatScaledDraws; + + if (!_inStall) + { + _inStall = true; + _stallLoggedMs = now; + _drawsAtStall = draws; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP HANGWATCH : AUCUNE IMAGE PRESENTEE depuis {silence} ms. " + + $"Compteur de dessins a cet instant : {draws}. " + + "Prochaine ligne dans quelques secondes -- si ce compteur a AVANCE, c'est la " + + "PRESENTATION qui est bloquee (le fil graphique vit) ; s'il est FIGE, c'est le " + + "fil graphique lui-meme qui est coince."); + + continue; + } + + if (now - _stallLoggedMs < RepeatMs) + { + continue; + } + + _stallLoggedMs = now; + + int delta = draws - _drawsAtStall; + _drawsAtStall = draws; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP HANGWATCH : gel de {silence / 1000} s. Dessins depuis la derniere ligne : " + + $"{delta} ⇒ " + + (delta > 0 + ? "le fil graphique VIT, c'est la PRESENTATION qui est bloquee (famille A)." + : "le fil graphique est COINCE lui aussi (famille B).")); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHazardProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHazardProbe.cs new file mode 100644 index 000000000..1642d3e81 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppHazardProbe.cs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Read-while-written texture hazard probe (RYUJINX_TEX_HAZARD=1, OFF by default, read-only). + /// + /// TARGET: the Xenoblade 2 artefact -- large rectangles of real-but-wrong content, appearing + /// when the camera TURNS, reportedly worse indoors where more objects are on screen. + /// + /// Everything cheap has already been eliminated BY MEASUREMENT, each with its switch verified + /// live in the log (never assumed): + /// - shader cache purged, artefact unchanged + /// - DLSS / MV++ / FG log proved mode=0, zero "using mode", zero "DLSS: available" + /// - runtime mipmaps never applied when DLSS is off (checked in the code path) + /// - recycled memory 2000+ fresh allocations zeroed, artefact unchanged + /// - history-reset storm 4 resets in a minute (Kameleo20's storm was 85-98%) + /// - gobBlocksInZ clamp probe fired ZERO times on this game + /// + /// What is left that displaces REAL content in RECTANGLES is a synchronisation hazard: the game + /// SAMPLES a texture that is at the same time a bound RENDER TARGET. It then reads a mix of + /// already-written and still-stale tiles -- blocky, made of genuine pixels, and worse when more + /// draws are in flight. Turning the camera is when a streaming game re-renders those buffers. + /// + /// This probe changes NOTHING. Each draw it lists what is being written (colour render targets) + /// and what is being read (sampled textures, every stage), and reports the ones that are the + /// same memory. A hit names the texture: dimensions, format, address. No hit rules the whole + /// mechanism out, and we look elsewhere. + /// + static class MvppHazardProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_TEX_HAZARD") == "1"; + + private const int MaxTargets = 8; + + private static readonly ulong[] _rtAddr = new ulong[MaxTargets]; + private static readonly Image.Texture[] _rtTex = new Image.Texture[MaxTargets]; + private static int _rtCount; + + private static readonly Dictionary _hits = new(); + private static long _lastLogMs; + private static int _draws; + private static int _hitDraws; + + public static void OnDraw(GpuChannel channel) + { + if (!_enabled) + { + return; + } + + // Never take the process down from a diagnostic on the GPU thread. + try + { + OnDrawImpl(channel); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"TEXHAZARD: disabled after unexpected error: {e}"); + } + } + + private static void OnDrawImpl(GpuChannel channel) + { + _draws++; + _rtCount = 0; + + channel.TextureManager.MvppEnumerateRenderTargets((index, tex) => + { + if (tex != null && _rtCount < MaxTargets) + { + _rtTex[_rtCount] = tex; + _rtAddr[_rtCount] = tex.Range.GetSubRange(0).Address; + _rtCount++; + } + }); + + if (_rtCount == 0) + { + return; + } + + bool hitThisDraw = false; + + channel.TextureManager.MvppEnumerateGraphicsInputsStage((stage, tex) => + { + if (tex == null) + { + return; + } + + ulong addr = tex.Range.GetSubRange(0).Address; + + for (int i = 0; i < _rtCount; i++) + { + if (_rtAddr[i] != addr) + { + continue; + } + + hitThisDraw = true; + + // Distinct shapes, not one line per draw: the same few buffers repeat + // thousands of times a second and what matters is WHICH ones. + string key = + $"{tex.Info.Width}x{tex.Info.Height} {tex.Info.FormatInfo.Format} " + + $"{tex.Info.Target} @{addr:X10} (rt{i}, stage{stage})"; + + _hits.TryGetValue(key, out int n); + _hits[key] = n + 1; + } + }); + + if (hitThisDraw) + { + _hitDraws++; + } + + long now = Environment.TickCount64; + + if (now - _lastLogMs < 5000) + { + return; + } + + _lastLogMs = now; + + System.Text.StringBuilder sb = new(); + sb.Append($"TEXHAZARD: {_hitDraws}/{_draws} draws sample a bound render target"); + + if (_hits.Count == 0) + { + sb.Append(" -- AUCUN (mecanisme ecarte pour cette fenetre)."); + } + else + { + sb.Append($", {_hits.Count} textures distinctes:"); + int shown = 0; + + foreach (KeyValuePair kv in _hits) + { + if (shown++ >= 6) + { + sb.Append($" (+{_hits.Count - 6} autres)"); + + break; + } + + sb.Append($" [{kv.Key} x{kv.Value}]"); + } + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + + _draws = 0; + _hitDraws = 0; + _hits.Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLayoutProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLayoutProbe.cs new file mode 100644 index 000000000..a395be0b1 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLayoutProbe.cs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Comparaison tuilage demandé / tuilage fourni pour la texture présentée (RYUJINX_LAYOUT=1). READ-ONLY. + /// + /// Établi le 21/07 : la texture remise au present arrive DÉJÀ corrompue, avec une mosaïque en blocs = + /// signature d'un détuilage block-linear raté. Ici on met face à face ce que le JEU a demandé + /// (pt.Info, construit dans EnqueueFrameThreadSafe à partir de stride/isLinear/gobBlocksInY que le jeu + /// fournit) et ce que le cache a réellement retrouvé/créé (texture.Info). Un écart sur isLinear, + /// gobBlocksInY ou stride est la cause exacte cherchée. + /// + /// Log throttlé + une ligne à CHAQUE changement de signature, pour ne rien rater sans spammer. + /// + static class MvppLayoutProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_LAYOUT") == "1"; + + private static bool _announced; + private static long _nextMs; + private static string _lastSig = ""; + + public static void Compare(Image.TextureInfo want, Image.Texture got) + { + if (!_enabled || got == null) + { + return; + } + + try + { + if (!_announced) + { + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP LAYOUT: ON -- demande (jeu) vs fourni (cache) pour la texture presentee, lecture seule."); + } + + Image.TextureInfo g = got.Info; + + bool linMismatch = want.IsLinear != g.IsLinear; + bool gobMismatch = want.GobBlocksInY != g.GobBlocksInY; + bool strideMismatch = want.Stride != g.Stride; + bool dimMismatch = want.Width != g.Width || want.Height != g.Height; + bool fmtMismatch = want.FormatInfo.Format != g.FormatInfo.Format; + + bool any = linMismatch || gobMismatch || strideMismatch || dimMismatch || fmtMismatch; + + string sig = + $"DEMANDE {want.Width}x{want.Height} lin={want.IsLinear} gobY={want.GobBlocksInY} stride={want.Stride} {want.FormatInfo.Format} " + + $"|| FOURNI {g.Width}x{g.Height} lin={g.IsLinear} gobY={g.GobBlocksInY} stride={g.Stride} {g.FormatInfo.Format}" + + $"{(any ? " <<< DESACCORD:" : " (accord)")}" + + $"{(linMismatch ? " isLinear" : "")}{(gobMismatch ? " gobBlocksInY" : "")}{(strideMismatch ? " stride" : "")}" + + $"{(dimMismatch ? " dimensions" : "")}{(fmtMismatch ? " format" : "")}"; + + long now = Environment.TickCount64; + + if (sig != _lastSig || now >= _nextMs) + { + _lastSig = sig; + _nextMs = now + 2000; + Logger.Info?.Print(LogClass.Gpu, $"MVPP LAYOUT: {sig}"); + } + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP LAYOUT: desactive apres erreur: {e.Message}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLdrSkip.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLdrSkip.cs new file mode 100644 index 000000000..7baf78815 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppLdrSkip.cs @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// [NOLDR 29/07 SOIR] Ne PAS capturer la camera sur une passe a cible couleur ENTIERE (8 bits), + /// tout en laissant passer les autres. Deux interrupteurs : + /// RYUJINX_MVPP_NOLDR=1 -- le correctif, arme apres calibrage (voir le filet plus bas) + /// RYUJINX_MVPP_NOLDR_AB=1 -- le MESUREUR : la restriction s'allume et s'eteint toute seule + /// par tranches de 600 images, dans le MEME run + /// Les deux sont coupes par defaut. + /// + /// POURQUOI CE FICHIER REMPLACE . Ce dernier n'autorisait QUE la + /// passe de scene HDR : la camera y etait mesuree lisible dans trop peu d'images, la publication + /// s'effondrait, la camera etait AFFAMEE -- et une camera manquee fait ecrire zero sur toute + /// l'image. ⛔ Ne pas le re-armer. Ici on n'ELIT pas une passe, on ECARTE la pire. + /// + /// LA MESURE QUI L'A IMPOSE (runs SANS lecture d'essai, donc non contamines) -- la passe 8 bits + /// est la pire dans les TROIS runs propres, et la passe HDR la plus saine dans les trois : + /// run 1 (etalon) : 8 bits 119/1340 = 8,9 % · HDR 3/230 = 1,3 % + /// run SCENEPASS : 8 bits 100/1233 = 8,1 % · HDR 1/1280 = 0,08 % + /// run temoin : 8 bits 8/382 = 2,1 % · HDR 1/130 = 0,8 % + /// ✅ VERIFIE EN JEU : avec NOLDR arme, la passe 8 bits DISPARAIT de la table des lectures + /// (382 -> 0) et les captures se deplacent sur la passe HDR (130 -> 280). Le mecanisme fait ce + /// qu'il annonce ; c'est son COUT qui restait a mesurer. + /// + /// LE CRITERE EST GENERIQUE -- forme de pipeline : une cible couleur ATTACHEE dont le format + /// n'est PAS flottant. Pas de couleur attachee ⇒ passe. Couleur flottante ⇒ passe. Aucune + /// adresse, aucun hash, aucune resolution, aucun nom de jeu. + /// + /// ⚠️⚠️⚠️ MES DEUX FILETS PRECEDENTS ETAIENT FAUX, CHACUN A SA FACON. A RELIRE AVANT D'EN + /// ECRIRE UN TROISIEME. + /// 1. `SCENEPASS` declenchait sur "2 images d'affilee sans camera". Or la reference en rate + /// DEJA 17 % toute seule ⇒ deux rates d'affilee est un evenement a ~3 %, NORMAL au repos : + /// le filet sautait sur une condition de base, desarme en 92 secondes. + /// 2. La v1 de CE fichier se calibrait sur les 600 premieres images 3D -- c'est-a-dire pendant + /// le CHARGEMENT, ou le ratage vaut 90 %. Le seuil devenait 90 + 10 = 100 % : + /// **impossible a atteindre**. La restriction a tourne sans garde-fou tout un run. + /// 📌 La lecon commune : un detecteur qui ne peut pas se declencher est indiscernable d'un + /// detecteur casse. Avant de croire un compteur, verifier ce qu'il compte DANS LE REGIME OU ON + /// L'A MIS -- ici, ne rien calibrer avant que la camera soit chaude. + /// + /// LE MESUREUR (mode A/B), ET POURQUOI IL EXISTE. Quatre runs ont rendu des taux de publication + /// de 83 / 70 / 63 / 60 % selon l'ENDROIT, et des comptes d'intruses variant d'un facteur 10. + /// Comparer deux runs differents ne prouve donc rien -- c'est ce qui a fait juger trois + /// correctifs sur du hasard le 29/07 au matin. Le mode A/B supprime la variable : la restriction + /// alterne DANS le meme run, sur la meme scene et le meme geste, et les deux moities se + /// comparent directement. Chauffe d'abord (200 captures reussies) pour ne jamais mesurer le + /// chargement. + /// + /// Les compteurs sont approximatifs : OnDraw tourne sur le fil GPU, OnFrame sur celui de la + /// presentation. Ordres de grandeur seulement. + /// + static class MvppLdrSkip + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_NOLDR") == "1"; + + /// + /// [NORANK1 29/07 soir, 5e passe] Ne pas capturer sur la PREMIERE tentative de l'image + /// (RYUJINX_MVPP_NORANK1=1, coupe par defaut). + /// + /// LA MESURE. Une fois la colonne "rang" reparee, run de 4 min avec `NOLDR` deja arme (donc + /// les passes 8 bits deja ecartees -- l'effet mesure ici est INDEPENDANT) : + /// lectures saines : rang moyen 33,6 · rang 1 = 48 % · 2-3 = 4 % · 4-10 = 9 % · 51+ = 22 % + /// intruses : rang moyen 3,1 · rang 1 = 95 % · 2-3 = 0 % · 4-10 = 0 % · 51+ = 3 % + /// En taux, la seule lecture qui vaille : au rang 1 une lecture est intruse dans 1,62 % des + /// cas, au-dela du rang 1 dans 0,08 %. **Vingt fois moins.** + /// + /// POURQUOI CA NE DEVRAIT PAS AFFAMER, contrairement a `MvppScenePass` : il y a ~153 passes + /// qualifiantes par image, donc sauter la premiere laisse 152 occasions derriere. SCENEPASS + /// n'avait que la passe HDR pour se rattraper, et la camera n'y etait pas toujours lisible. + /// Le meme filet auto-calibre couvre les deux criteres et tranchera par la mesure. + /// + public static readonly bool SkipRank1 = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_NORANK1") == "1"; + + /// + /// Le MESUREUR : fait ALTERNER par tranches de 600 images le ou les criteres armes, dans le + /// meme run. Il est agnostique au critere -- il alterne ce qui est allume. C'est le seul + /// instrument de la soiree qui ait produit une comparaison fiable, parce qu'il supprime + /// l'endroit comme variable. + /// + public static readonly bool AbMode = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_NOLDR_AB") == "1"; + + /// + /// [MISSWHY 29/07 fin de soirée] Pourquoi une image rate sa capture quand la restriction est + /// armée (RYUJINX_MVPP_MISSWHY=1, coupé par défaut). + /// + /// LE FAIT À EXPLIQUER : sur huit runs, la restriction s'est suspendue DEUX fois, les deux + /// dans des scènes où le ratage calibré était déjà haut. Une suspension coûte 100 s de gain. + /// Deux explications possibles, et elles ne se réparent PAS de la même façon : + /// A. dans ces images il n'existait AUCUNE autre passe pour se rattraper — on a écarté la + /// 8 bits et il n'y avait rien derrière ⇒ la restriction est la cause ; + /// B. il y avait des alternatives, elles ont été tentées, mais la lecture a échoué ⇒ la + /// restriction n'est peut-être pas la cause (l'image aurait raté de toute façon), mais + /// les passes 8 bits écartées auraient peut-être réussi. **Cas AMBIGU, dit comme tel.** + /// C. rien n'a été écarté dans cette image ⇒ la restriction n'y est pour rien. C'est le + /// ratage de fond, celui qui existe sans nous. + /// Le compte des trois dit laquelle domine. ⛔ Je ne devine pas : A et B mènent à des + /// correctifs différents, et C n'en demande aucun. + /// + /// Les deux compteurs par image sont écrits UNIQUEMENT par le fil GPU (dans Allows), remis à + /// zéro par lui aussi via le jeton, et seulement LUS par le fil de présentation à la fin de + /// l'image — donc après le dernier dessin. Pas de course sur une écriture. (Case 1 de + /// `docs/CHECKLIST-SONDE.md`.) + /// + public static readonly bool MissWhy = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_MISSWHY") == "1"; + + /// Le crochet de capture consulte ceci, pas les interrupteurs un par un. + public static bool Active => Enabled || SkipRank1 || AbMode; + + /// + /// Captures reussies a attendre avant de mesurer OU d'armer quoi que ce soit. Sert + /// uniquement a sortir de la phase de chargement, ou le ratage vaut 90 % et ou toute + /// calibration est un mensonge (faute de la v1 de ce fichier). + /// + private const int WarmCaptures = 200; + + private const int CalibrationFrames = 600; + private const int WindowFrames = 600; + private const float MarginPoints = 0.10f; + private const int ReportMs = 5000; + + /// + /// [SUSPENSION 29/07 soir, 4e passe] La v2 coupait la restriction pour TOUTE LA SESSION au + /// premier excès. Mesure du soir : sur cinq runs, un seul a déclenché -- et c'était celui + /// dont le ratage calibré était déjà le plus haut (11,2 %, fenêtre à 25,3 %) ⇒ la famine a + /// l'air LOCALE à une scène. Couper pour la session jetait donc le gain partout ailleurs + /// jusqu'à la fermeture du jeu. + /// + /// Ici l'excès ne coupe plus définitivement : il SUSPEND pour 3 000 images (~100 s), puis on + /// RECALIBRE sur place -- la scène a changé, l'ancien étalon ne vaut plus -- et on réarme. + /// Abandon définitif seulement au 4e excès, avec une ligne de journal explicite. La borne qui + /// protège reste la même (le ratage ne peut pas dépasser le calibré de plus de 10 points + /// pendant plus d'une fenêtre), mais on récupère le gain dès que la scène le permet. + /// + private const int SuspendFrames = 3000; + private const int SuspendsBeforeGiveUp = 4; + + private const int PhaseWarming = 0; + private const int PhaseCalibrating = 1; + private const int PhaseArmed = 2; + private const int PhaseDisarmed = 3; + private const int PhaseSuspended = 4; + + private static int _suspendLeft; + private static int _suspends; + + private static int _phase = PhaseWarming; + private static int _warmCaptured; + private static float _baseMissRate; + private static float _lastWinMissRate = -1f; + + private static int _winFrames; + private static int _winMisses; + + private static bool _qualThisFrame; + private static int _skipsLdr; + private static int _skipsRank; + private static int _attempt; + private static int _frameMark; + private static int _seenMark; + + // Par image, ecrits par le fil GPU seulement (voir MissWhy). + private static int _frSkipped; + private static int _frAllowed; + + // A : rien d'autre n'etait disponible · B : alternatives tentees et echouees (ambigu) + // C : rien n'a ete ecarte, ratage de fond. + private static int _missA; + private static int _missB; + private static int _missC; + private static long _lastReportMs; + + // Mode A/B : indice 1 = restriction ACTIVE (avec), indice 0 = restriction LEVEE (sans). + private static bool _abArmed; + private static int _abWin; + private static bool _abStarted; + private static readonly int[] _abFrames = new int[2]; + private static readonly int[] _abMisses = new int[2]; + private static readonly int[] _abReads = new int[2]; + private static readonly int[] _abIntruders = new int[2]; + + /// Vrai quand la restriction s'applique a cet instant. Sert a etiqueter les mesures. + internal static bool ArmedNow => AbMode ? _abArmed : _phase == PhaseArmed; + + /// + /// Appelee sur un dessin qualifiant tant que l'image n'a pas encore sa camera. Rend false + /// pour SAUTER la tentative sur ce dessin : la capture pourra se faire plus loin dans la + /// MEME image, sur une passe qui n'est pas la passe 8 bits. + /// + public static bool Allows(GpuChannel channel) + { + _qualThisFrame = true; + + // Rang de la TENTATIVE dans l'image. Meme mecanique que la reparation de MvppCtxProbe : + // OnFrame (fil de presentation) n'avance qu'un jeton, la remise a zero se fait ici, sur + // le fil GPU, seul ecrivain de _attempt. Pas de course. + int mark = _frameMark; + + if (_seenMark != mark) + { + _seenMark = mark; + _attempt = 0; + _frSkipped = 0; + _frAllowed = 0; + } + + _attempt++; + + if (!ArmedNow) + { + return true; + } + + if (SkipRank1 && _attempt == 1) + { + _skipsRank++; + _frSkipped++; + + return false; + } + + if (Enabled && IsIntegerColorPass(channel)) + { + _skipsLdr++; + _frSkipped++; + + return false; + } + + // Une tentative AUTORISEE : si l'image finit quand meme sans camera, c'est que la lecture + // a echoue ici, pas que la restriction a ferme la porte. + _frAllowed++; + + return true; + } + + /// + /// La passe a ecarter, reconnue a la FORME de sa cible : une couleur est attachee et son + /// format n'est pas flottant. Une passe sans couleur et une passe flottante restent des + /// occasions de capture -- c'est ce qui evite la famine qui a tue . + /// + private static bool IsIntegerColorPass(GpuChannel channel) + { + Image.Texture col0 = channel.TextureManager.RenderTargetColor0; + + if (col0 == null) + { + return false; + } + + return !IsFloat(col0.Info.FormatInfo.Format); + } + + private static bool IsFloat(GAL.Format f) + { + switch (f) + { + case GAL.Format.R16Float: + case GAL.Format.R32Float: + case GAL.Format.R16G16Float: + case GAL.Format.R32G32Float: + case GAL.Format.R16G16B16Float: + case GAL.Format.R32G32B32Float: + case GAL.Format.R16G16B16A16Float: + case GAL.Format.R32G32B32A32Float: + case GAL.Format.R11G11B10Float: + case GAL.Format.R9G9B9E5Float: + return true; + default: + return false; + } + } + + /// + /// Etiquette une lecture de camera avec la moitie d'experience en cours. Appelee par + /// , qui est le seul a savoir si une lecture est une intruse. + /// + internal static void NoteRead(bool intruder) + { + if (!AbMode || !_abStarted) + { + return; + } + + int p = _abArmed ? 1 : 0; + + _abReads[p]++; + + if (intruder) + { + _abIntruders[p]++; + } + } + + /// Une fois par image presentee, AVANT le re-armement du drapeau de capture. + public static void OnFrame(bool captured) + { + if (!_qualThisFrame) + { + // Image sans passe 3D (menu, chargement) : elle n'avait pas de camera a prendre. + return; + } + + _qualThisFrame = false; + + // [MISSWHY] Classement de l'image ratee, ICI : le dernier dessin est passe, les deux + // compteurs de l'image sont complets, et le jeton n'a pas encore ete avance. + if (MissWhy && !captured && ArmedNow) + { + if (_frSkipped == 0) + { + _missC++; + } + else if (_frAllowed == 0) + { + _missA++; + } + else + { + _missB++; + } + } + + // Jeton pour le rang de tentative : seul echange entre fils, un int, indechirable. + _frameMark++; + + // CHAUFFE, commune aux deux modes : rien n'est mesure ni arme avant que la camera + // produise vraiment. C'est la correction de la faute de la v1, qui calibrait pendant le + // chargement et en tirait un seuil inatteignable. + if (_phase == PhaseWarming) + { + if (captured) + { + _warmCaptured++; + } + + if (_warmCaptured < WarmCaptures) + { + return; + } + + if (AbMode) + { + _abStarted = true; + _abArmed = true; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR A/B : camera chaude ({WarmCaptures} captures), debut de " + + $"l'alternance par tranches de {WindowFrames} images. La restriction " + + "s'allume et s'eteint toute seule ; les deux moities se comparent sur la " + + "MEME scene et le MEME geste."); + } + else + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR : camera chaude ({WarmCaptures} captures), debut du calibrage " + + $"sur {CalibrationFrames} images SANS restriction."); + } + + _phase = AbMode ? PhaseArmed : PhaseCalibrating; + + return; + } + + if (AbMode) + { + int p = _abArmed ? 1 : 0; + + _abFrames[p]++; + + if (!captured) + { + _abMisses[p]++; + } + + if (++_abWin >= WindowFrames) + { + _abWin = 0; + _abArmed = !_abArmed; + } + + ReportAb(); + + return; + } + + // SUSPENSION : la restriction est levee (ArmedNow est faux), on laisse la scene passer, + // puis on RECALIBRE ici meme au lieu de reprendre un etalon perime. + if (_phase == PhaseSuspended) + { + if (--_suspendLeft <= 0) + { + _phase = PhaseCalibrating; + _winFrames = 0; + _winMisses = 0; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR fin de suspension #{_suspends} : recalibrage sur " + + $"{CalibrationFrames} images ICI, puis nouvelle tentative."); + } + + ReportPlain(); + + return; + } + + _winFrames++; + + if (!captured) + { + _winMisses++; + } + + if (_phase == PhaseCalibrating && _winFrames >= CalibrationFrames) + { + _baseMissRate = (float)_winMisses / _winFrames; + _phase = PhaseArmed; + _winFrames = 0; + _winMisses = 0; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR calibrage termine : ratage de reference {100f * _baseMissRate:0.#} % " + + $"sur {CalibrationFrames} images, camera chaude, mesure ICI sans restriction. " + + $"Restriction ARMEE ; coupure si une fenetre de {WindowFrames} images depasse ce " + + $"taux de plus de {100f * MarginPoints:0} points."); + + return; + } + + if (_phase == PhaseArmed && _winFrames >= WindowFrames) + { + float rate = (float)_winMisses / _winFrames; + _lastWinMissRate = rate; + _winFrames = 0; + _winMisses = 0; + + if (rate > _baseMissRate + MarginPoints) + { + _suspends++; + + if (_suspends >= SuspendsBeforeGiveUp) + { + _phase = PhaseDisarmed; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP NOLDR ABANDON : {_suspends}e exces (ratage {100f * rate:0.#} % contre " + + $"{100f * _baseMissRate:0.#} % calibre). Ecarter la passe 8 bits affame la camera " + + "de facon repetee sur ce jeu. Retour au comportement d'avant pour le reste de " + + "la session, et il faut le savoir : le correctif ne tient pas ici."); + } + else + { + _phase = PhaseSuspended; + _suspendLeft = SuspendFrames; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR suspendu #{_suspends} : ratage {100f * rate:0.#} % contre " + + $"{100f * _baseMissRate:0.#} % calibre (+{100f * (rate - _baseMissRate):0.#} points, " + + $"marge {100f * MarginPoints:0}). Restriction levee pour {SuspendFrames} images, " + + "puis RECALIBRAGE sur place et nouvelle tentative -- la famine est peut-etre " + + "propre a cette scene."); + } + } + } + + ReportPlain(); + } + + private static void ReportAb() + { + long now = Environment.TickCount64; + + if (now - _lastReportMs < ReportMs) + { + return; + } + + _lastReportMs = now; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR A/B [tranche en cours : {(_abArmed ? "AVEC" : "SANS")}] · " + + $"ecartes : {_skipsRank} au rang 1, {_skipsLdr} en 8 bits"); + + Dump("AVEC restriction", 1); + Dump("SANS restriction", 0); + } + + private static void Dump(string label, int p) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR A/B {label} : {_abFrames[p]} images · " + + $"ratage {(_abFrames[p] > 0 ? 100f * _abMisses[p] / _abFrames[p] : 0f):0.#} % · " + + $"{_abReads[p]} lectures · {_abIntruders[p]} intruses " + + $"({(_abReads[p] > 0 ? 100f * _abIntruders[p] / _abReads[p] : 0f):0.##} %)"); + } + + private static string Share(int n, int total) + { + return total > 0 ? $"{100f * n / total:0}%" : "-"; + } + + private static void ReportPlain() + { + long now = Environment.TickCount64; + + if (now - _lastReportMs < ReportMs) + { + return; + } + + _lastReportMs = now; + + string phase = _phase switch + { + PhaseWarming => $"CHAUFFE ({_warmCaptured}/{WarmCaptures})", + PhaseCalibrating => $"CALIBRAGE ({_winFrames}/{CalibrationFrames})", + PhaseArmed => "ARME", + PhaseSuspended => $"SUSPENDU ({_suspendLeft} img restantes, exces #{_suspends})", + _ => $"ABANDONNE apres {_suspends} exces", + }; + + if (MissWhy) + { + int tot = _missA + _missB + _missC; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP MISSWHY : {tot} images ratees avec restriction armee · " + + $"A (aucune alternative, la restriction EST la cause) {_missA} " + + $"({Share(_missA, tot)}) · " + + $"B (alternatives tentees et echouees, AMBIGU) {_missB} ({Share(_missB, tot)}) · " + + $"C (rien d'ecarte, ratage de fond) {_missC} ({Share(_missC, tot)})"); + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP NOLDR [{phase}] : ecartes {_skipsRank} au rang 1 / {_skipsLdr} en 8 bits · ratage calibre " + + $"{(_phase <= PhaseCalibrating ? "(en cours)" : $"{100f * _baseMissRate:0.#} %")}" + + $"{(_lastWinMissRate >= 0f ? $" · derniere fenetre {100f * _lastWinMissRate:0.#} %" : "")}"); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppPreSyncProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppPreSyncProbe.cs new file mode 100644 index 000000000..e6ab33360 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppPreSyncProbe.cs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.IO; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Test décisif XC2 (RYUJINX_PRESYNC=1). READ-ONLY, off par défaut. + /// + /// Capture la texture PRÉSENTÉE juste AVANT et juste APRÈS le `texture.SynchronizeMemory()` de + /// `Window.Present` (Gpu/Window.cs ~272). Hypothèse : le render target composé sur GPU est aussi suivi + /// comme mémoire invitée et marqué sale, donc cette resynchronisation recharge la mémoire invitée + /// (périmée) par-dessus l'image rendue propre, juste avant l'affichage. C'est le seul intervalle du + /// pipeline jamais instrumenté, et le seul compatible avec "propre au dernier draw, détruit à l'écran". + /// + /// Déclenchement F10 (comme la sonde pas-à-pas), pour capturer quand l'artefact est visible. Écrit + /// AVANT et APRÈS, plus un booléen "identiques ?". Si before propre + after détruit => cause trouvée, + /// à la ligne près. Si identiques => l'hypothèse meurt et le coupable est ailleurs dans le present. + /// + static class MvppPreSyncProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_PRESYNC") == "1"; + + private static readonly int _vkey = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_PRESYNC_VKEY"), out int vk) && vk > 0 ? vk : 0x79; + + private static readonly int _maxShots = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_PRESYNC_MAX"), out int mx) && mx > 0 ? mx : 4; + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int vKey); + + private static bool _announced; + private static int _shot; + private static long _nextMs; + private static byte[] _before; + private static bool _armedThisFrame; + private static int _guestShots; + private static long _nextGuestMs; + + private static bool KeyHeld() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + return (GetAsyncKeyState(_vkey) & 0x8000) != 0; + } + catch + { + return false; + } + } + + /// + /// Vidage de la mémoire INVITÉE brute de la texture présentée, sur F10. Comparé hors-ligne au + /// détuilage : si la mémoire invitée détuilée est PROPRE alors que la texture hôte est corrompue, + /// le bug est dans la lecture (détuilage) ; si la mémoire invitée est DÉJÀ corrompue, quelque chose + /// a écrit des blocs décalés en amont. Écrit aussi les octets bruts + les paramètres pour rejouer + /// le détuilage à la main. + /// + public static void DumpGuest(Image.Texture texture) + { + if (!_enabled || texture == null) + { + return; + } + + try + { + long now = Environment.TickCount64; + + if (_guestShots >= _maxShots || now < _nextGuestMs || !KeyHeld()) + { + return; + } + + ReadOnlySpan guest = texture.PhysicalMemory.GetSpan(texture.Range); + + Image.TextureInfo gi = texture.Info; + string dir = Path.Combine("presync", $"guest{_guestShots:D2}"); + Directory.CreateDirectory(dir); + + File.WriteAllBytes( + Path.Combine(dir, $"MEMOIRE_{gi.Width}x{gi.Height}_lin{gi.IsLinear}_gobY{gi.GobBlocksInY}_stride{gi.Stride}_{gi.FormatInfo.Format}.bin"), + guest.ToArray()); + + using GAL.PinnedSpan host = texture.HostTexture.GetData(); + File.WriteAllBytes( + Path.Combine(dir, $"HOTE_{gi.Width}x{gi.Height}_{gi.FormatInfo.Format}.bin"), + host.Get().ToArray()); + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP PRESYNC GUEST shot {_guestShots}: memoire invitee {guest.Length} octets + texture hote, " + + $"{gi.Width}x{gi.Height} lin={gi.IsLinear} gobY={gi.GobBlocksInY} stride={gi.Stride} {gi.FormatInfo.Format} -> {dir}"); + + _guestShots++; + _nextGuestMs = now + 2000; + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP PRESYNC GUEST: desactive apres erreur: {e.Message}"); + } + } + + public static void Before(Image.Texture texture) + { + if (!_enabled || texture?.HostTexture == null) + { + return; + } + + try + { + if (!_announced) + { + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP PRESYNC: ON -- APPUIE SUR F10 quand tu VOIS l'artefact. Capture l'image presentee " + + "AVANT et APRES la resynchronisation, pour voir si c'est ELLE qui la detruit."); + } + + _armedThisFrame = false; + + if (_shot >= _maxShots) + { + return; + } + + long now = Environment.TickCount64; + + if (now < _nextMs || !KeyHeld()) + { + return; + } + + using GAL.PinnedSpan data = texture.HostTexture.GetData(); + _before = data.Get().ToArray(); + _armedThisFrame = true; + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP PRESYNC: desactive apres erreur (before): {e.Message}"); + } + } + + public static void After(Image.Texture texture) + { + if (!_enabled || !_armedThisFrame || _before == null || texture?.HostTexture == null) + { + return; + } + + _armedThisFrame = false; + + try + { + using GAL.PinnedSpan data = texture.HostTexture.GetData(); + byte[] after = data.Get().ToArray(); + + bool identical = _before.Length == after.Length && _before.AsSpan().SequenceEqual(after); + + long diffBytes = 0; + + if (!identical && _before.Length == after.Length) + { + for (int i = 0; i < after.Length; i++) + { + if (_before[i] != after[i]) + { + diffBytes++; + } + } + } + + string dir = Path.Combine("presync", $"shot{_shot:D2}"); + Directory.CreateDirectory(dir); + string tag = $"{texture.Info.Width}x{texture.Info.Height}_{texture.Info.FormatInfo.Format}"; + File.WriteAllBytes(Path.Combine(dir, $"AVANT_{tag}.bin"), _before); + File.WriteAllBytes(Path.Combine(dir, $"APRES_{tag}.bin"), after); + + double pct = after.Length > 0 ? 100.0 * diffBytes / after.Length : 0; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP PRESYNC shot {_shot}: {tag} -- {(identical ? "IDENTIQUES (la resync ne touche rien)" : $"DIFFERENTS, {pct:F1}% des octets changes par la resync")} -> {dir}"); + + _shot++; + _nextMs = Environment.TickCount64 + 2000; + _before = null; + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP PRESYNC: desactive apres erreur (after): {e.Message}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppProjAudit.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppProjAudit.cs new file mode 100644 index 000000000..c8f6ea246 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppProjAudit.cs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.Numerics; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// PROJECTIVE AUDIT of the view-projection elected by . + /// Gate: RYUJINX_MVPP_PROJAUDIT=1. LOGGING ONLY - reads the matrix that was already + /// accepted, computes scalars, prints one line a second. It changes no state, feeds no + /// shader and returns nothing, so with the gate off it is two boolean tests per frame and + /// with the gate on it cannot alter a single pixel. + /// + /// WHY IT EXISTS (27/07, XC2). VPSOLO finally armed MV++ on Xenoblade 2 and the split in + /// Alex's verdict is the whole clue: the SKY is perfect, the GEOMETRY slides when the camera + /// moves. SKYROT reprojects the far plane with ROTATION ONLY - it needs neither depth nor the + /// projective part. Geometry needs the full matrix AND depth. Sky right + geometry wrong + /// therefore points at a matrix that is correct as a CAMERA and wrong as a PROJECTION. + /// + /// And that is exactly what the election allows. MvppSoloCamera.IsViewProj checks the norm of + /// row3, the norms fx/fy, the mutual orthogonality of rows 0/1/3, the collinearity of row2 + /// with row3, and rejects bare projections. NONE of those constrain the DEPTH MAPPING: the + /// terms usually written A and B (row2.xyz magnitude and row2.w) are never validated, and + /// ViewProjPos rebuilds the camera position from m[3], m[7] and m[15] without ever reading + /// m[11]. A matrix can pass every rule while mapping depth in a convention the reprojection + /// does not expect. + /// + /// THE SUSPECT NUMBER. The in-game "MVPP center" probe reports worldW between 0.0002 and + /// 0.077 for real geometry - a ship and a building. If worldW is the view-space distance in + /// world units, those objects would sit four centimetres from the camera. The reprojection + /// divides by that w, so a w that is wrong by three orders of magnitude produces exactly what + /// was measured: vectors that flip sign frame to frame during a single continuous pan and + /// that peg to whatever the clamp is (128 with MAXMOTION=128, 512 with 512). + /// + /// WHAT THIS PRINTS, AND HOW TO READ IT. + /// fx/fy : the focal scales. Their ratio must equal the render aspect (~1.778). + /// n3 : |row3.xyz|. IsViewProj FORCES this to 1 +/- 2%, so it is printed to confirm + /// the constraint is what makes the scale of w what it is. + /// A : |row2.xyz|. For a standard perspective this is the depth compression. A + /// near-zero A means an INFINITE far plane (or reverse-Z), and note that + /// IsViewProj SKIPS its collinearity test entirely when A < 1e-4 - such a + /// matrix is accepted without that check ever running. + /// B : row2.w - A*tz, the depth offset. + /// near/far : recovered from A and B under the OpenGL convention. If they come out + /// absurd (negative, inverted, astronomically large) the matrix does not map + /// depth the way the reprojection assumes, and that is the defect. + /// wCenter : the perspective w for a point at the screen centre at the depth given, in + /// the SAME units as the camera position. Compare it against camDist: they + /// describe the same distance and must agree in order of magnitude. + /// rt : round-trip error in pixels - unproject the screen centre with VP^-1 then + /// project it back with VP. This tests CONDITIONING only, never correctness: + /// a badly scaled matrix round-trips perfectly. A large rt means the matrix is + /// near-singular, which is a separate and worse problem. + /// + /// HONEST LIMIT. This audit cannot say what the TRUE matrix is - there is no ground truth + /// available at this level. It can only say whether the accepted one is internally coherent + /// and whether its depth mapping is plausible. That is enough to decide where to look next: + /// a coherent matrix moves the search to the consumer (the reprojection shader), an incoherent + /// one keeps it here, in the election. + /// + static class MvppProjAudit + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_PROJAUDIT") == "1"; + + private const long IntervalMs = 1000; + + private static long _lastMs; + + /// + /// Called on the success path of MvppSoloCamera.TryGetViewProjection, with the matrix it + /// is about to hand out and the focal scales it recovered. Throttled to one line a second. + /// + public static void Audit(in Matrix4x4 vp, float camX, float camY, float camZ) + { + if (!Enabled) + { + return; + } + + long now = Environment.TickCount64; + + if (now - _lastMs < IntervalMs) + { + return; + } + + _lastMs = now; + + // Recovered here rather than passed in, so the hook at the call site stays a single + // line and TryReadAt keeps its signature untouched. Same definition as IsViewProj. + float fx = MathF.Sqrt(vp.M11 * vp.M11 + vp.M12 * vp.M12 + vp.M13 * vp.M13); + float fy = MathF.Sqrt(vp.M21 * vp.M21 + vp.M22 * vp.M22 + vp.M23 * vp.M23); + + // Row layout, matching the constructor in MvppSoloCamera.TryReadAt: + // row0 = M11 M12 M13 M14 row2 = M31 M32 M33 M34 + // row1 = M21 M22 M23 M24 row3 = M41 M42 M43 M44 + float n3 = MathF.Sqrt(vp.M41 * vp.M41 + vp.M42 * vp.M42 + vp.M43 * vp.M43); + float a = MathF.Sqrt(vp.M31 * vp.M31 + vp.M32 * vp.M32 + vp.M33 * vp.M33); + + // Documented shape: row2.xyz = A*R.row2 and row3.xyz = s*R.row2 with s = +/-1, so the + // sign is read off the dot product rather than assumed. + float dot23 = vp.M31 * vp.M41 + vp.M32 * vp.M42 + vp.M33 * vp.M43; + float s = dot23 >= 0f ? 1f : -1f; + + // row3.w = s*tz => tz = row3.w / s ; row2.w = A*tz + B => B = row2.w - A*tz. + float tz = s != 0f ? vp.M44 / s : 0f; + float b = vp.M34 - a * tz; + + // OpenGL perspective: A' = -(f+n)/(f-n), B' = -2fn/(f-n) with the sign carried by s. + // Inverting gives n = B/(A-1) and f = B/(A+1); both are printed raw so an absurd pair + // is visible rather than silently normalised into something plausible. + float aSigned = a * s; + float near = MathF.Abs(aSigned - 1f) > 1e-6f ? b / (aSigned - 1f) : float.NaN; + float far = MathF.Abs(aSigned + 1f) > 1e-6f ? b / (aSigned + 1f) : float.NaN; + + // The perspective w at the screen centre for a point on the camera axis: w is row3 + // dotted with the world point plus row3.w. Taking the camera itself gives the offset, + // so the magnitude that matters is how w grows per world unit along the view axis -- + // with n3 forced to 1 that rate is 1, and w IS the distance in world units. + float wAtCam = vp.M41 * camX + vp.M42 * camY + vp.M43 * camZ + vp.M44; + float camDist = MathF.Sqrt(camX * camX + camY * camY + camZ * camZ); + + // Conditioning: unproject the centre of the screen at mid depth, project it back. + float rt = float.NaN; + + if (Matrix4x4.Invert(vp, out Matrix4x4 inv)) + { + Vector4 clip = new Vector4(0f, 0f, 0f, 1f); + Vector4 world = Vector4.Transform(clip, inv); + + if (MathF.Abs(world.W) > 1e-20f) + { + world /= world.W; + world.W = 1f; + + Vector4 back = Vector4.Transform(world, vp); + + if (MathF.Abs(back.W) > 1e-20f) + { + // Half the render width is the worst case for a normalised device unit, + // 1920 is only a scale for readability - the verdict is "near zero or not". + rt = MathF.Sqrt(back.X / back.W * (back.X / back.W) + + back.Y / back.W * (back.Y / back.W)) * 1920f; + } + } + } + else + { + rt = -1f; // not invertible at all + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP proj audit: fx={fx:0.####} fy={fy:0.####} ratio={(fy > 1e-9f ? fx / fy : 0f):0.####} " + + $"n3={n3:0.#####} A={a:0.######} s={s:+0;-0} B={b:0.####} " + + $"near={near:0.#####} far={far:0.##} tz={tz:0.####} " + + $"campos=[{camX:0.##} {camY:0.##} {camZ:0.##}] camDist={camDist:0.##} wAtCam={wAtCam:0.######} " + + $"rt={rt:0.####} px."); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppRtDumpProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppRtDumpProbe.cs new file mode 100644 index 000000000..91ab76c07 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppRtDumpProbe.cs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Render-target snapshot probe (RYUJINX_RT_DUMP=1, OFF by default). + /// + /// WHY WE ARE HERE. The Xenoblade 2 artefact - large, SOFT rectangles of real-but-averaged + /// content, when the camera turns, worse indoors - has survived nine eliminations, each one + /// measured with its switch verified live in the log, never assumed: + /// shader cache · DLSS/MV++/FG (mode=0 proven) · runtime mipmaps · recycled device memory + /// (2000+ allocations zeroed) · history-reset storm · the fork's gobBlocksInZ clamp (zero + /// firings) · sampling a bound render target (0 hits in 16731 draws) · the 1080p scene mod + /// (patch proven applied) · dynamic resolution (depth AND colour sizes rock-steady). + /// + /// Counters have run out of road, so we stop guessing at mechanisms and LOOK AT THE PIXELS. + /// + /// WHAT THE LAST MEASUREMENT REVEALED. XC2 renders through several LOW-RESOLUTION buffers that + /// are enlarged and composited back over the frame - 640x360 R11G11B10Float and 512x288 + /// R16G16B16A16Float, ~300 draws each per 5 s window, permanently. That matches the LOOK of the + /// artefact: the blocks are big and soft, i.e. averaged content stretched up, not the crisp + /// blocks a full-resolution buffer would give. It would also explain "worse indoors", where + /// more lights and volumetrics are in play. + /// + /// WHAT THIS PROBE DOES. On a trigger it captures ONE image per distinct colour render-target + /// shape (plus the depth), straight off the GPU, to raw files named with their dimensions and + /// format. Then we look at them one by one and SEE which buffer carries the blocks. No + /// hypothesis survives contact with the actual pixels. + /// + /// COST. GetData() is a full GPU-to-CPU readback and it stalls the pipeline, so this is + /// strictly one short burst per session, capped, and off by default. + /// + static class MvppRtDumpProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP") == "1"; + + /// + /// Seconds to wait after boot before the FIRST capture (RYUJINX_RT_DUMP_START, default 90). + /// Measured the hard way 21/07: with captures starting 10 s after boot, all four bursts + /// landed during loading and the intro, so the "clean" buffer they showed said nothing + /// about the moment the artefact is on screen. The captures have to happen when the player + /// is IN the scene, looking at the defect. + /// + private static readonly int _startSeconds = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP_START"), out int st) && st > 0 ? st : 90; + + /// Seconds between capture bursts (RYUJINX_RT_DUMP_EVERY, default 10). + private static readonly int _everySeconds = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP_EVERY"), out int s) && s > 0 ? s : 10; + + /// How many bursts before the probe stops for good (RYUJINX_RT_DUMP_MAX, default 4). + private static readonly int _maxBursts = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP_MAX"), out int m) && m > 0 ? m : 4; + + /// RYUJINX_RT_DUMP_ALL=1: capture every bound target, not just the scene buffer. + private static readonly bool _dumpAll = + Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP_ALL") == "1"; + + /// RYUJINX_RT_DUMP_INPUTS=1: also capture the textures the draws SAMPLE. + private static readonly bool _dumpInputs = + Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP_INPUTS") == "1"; + + /// + /// RYUJINX_RT_DUMP_FRAME=1 : une rafale == UNE IMAGE EXACTEMENT, bornée par les presents, + /// au lieu d'une fenêtre en millisecondes. + /// + /// Pourquoi (mesuré deux fois le 21/07) : avec un découpage temporel, les cibles intermédiaires + /// et l'image présentée tombent sur des images DIFFÉRENTES. Pendant une rotation caméra la vue + /// change complètement en une seconde — un burst montrait une grue pendant que l'image finale + /// montrait des caisses. On comparait deux scènes sans rapport, et aucune conclusion n'était + /// possible sur « à quel étage les premiers pixels faux apparaissent ». + /// + /// Ici la rafale est ouverte par un present et fermée par le SUIVANT : tous les draws capturés + /// entre les deux appartiennent à l'image dont on capture ensuite le résultat présenté. Les deux + /// sondes vivent du même côté du code (couche Gpu), donc ce découpage suffit — aucune + /// synchronisation inter-assembly n'est nécessaire. + /// + private static readonly bool _frameMode = + Environment.GetEnvironmentVariable("RYUJINX_RT_DUMP_FRAME") == "1"; + + private const int MaxShapesPerBurst = 26; + + private static long _nextBurstMs; + private static bool _bursting; + private static long _burstEndsMs; + private static int _burstIndex; + private static string _burstDir; + private static bool _frameArmLogged; + private static readonly HashSet _doneThisBurst = new(); + + /// + /// Called from Window.Present with the texture that is about to reach the screen. Captured + /// only while a burst is running, so the presented frame lands in the SAME folder as the + /// intermediate buffers of that moment and the two can be compared directly. + /// + public static void NotePresented(Image.Texture texture) + { + if (!_enabled) + { + return; + } + + if (!_frameMode) + { + if (!_bursting || texture == null) + { + return; + } + + try + { + TryDump(texture, "PRESENTED"); + } + catch (Exception e) + { + Logger.Warning?.Print(LogClass.Gpu, $"RTDUMP: presented illisible: {e.GetType().Name} {e.Message}"); + } + + return; + } + + // --- Mode IMAGE : ce present est la frontière entre deux images. --- + try + { + if (_bursting) + { + // Fin de l'image capturée : son résultat présenté ferme le dossier, à côté des + // cibles intermédiaires des draws de CETTE image, et d'aucune autre. + if (texture != null) + { + TryDump(texture, "PRESENTED"); + } + + _bursting = false; + _nextBurstMs = Environment.TickCount64 + _everySeconds * 1000L; + + Logger.Info?.Print(LogClass.Gpu, + $"RTDUMP: image {_burstIndex}/{_maxBursts} complete, {_doneThisBurst.Count} tampons."); + + return; + } + + if (_burstIndex >= _maxBursts) + { + return; + } + + long now = Environment.TickCount64; + + if (_nextBurstMs == 0) + { + _nextBurstMs = now + _startSeconds * 1000L; + } + + if (!_frameArmLogged) + { + _frameArmLogged = true; + Logger.Info?.Print(LogClass.Gpu, + $"RTDUMP: armed en MODE IMAGE -- premiere image dans {_startSeconds}s, puis toutes les " + + $"{_everySeconds}s, {_maxBursts} images. Une rafale == UNE image : cibles et resultat presente " + + "appartiennent a la MEME image."); + } + + if (now < _nextBurstMs) + { + return; + } + + // Ouverture : les draws de l'image SUIVANTE seront capturés, puis le present suivant + // fermera le dossier avec le résultat de cette même image. + _burstIndex++; + _doneThisBurst.Clear(); + _burstDir = Path.Combine("rtdump", $"image{_burstIndex:D2}"); + Directory.CreateDirectory(_burstDir); + _bursting = true; + + Logger.Info?.Print(LogClass.Gpu, $"RTDUMP: image {_burstIndex}/{_maxBursts} ouverte -> {_burstDir}"); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"RTDUMP: mode image desactive apres erreur: {e.Message}"); + } + } + + public static void OnDraw(GpuChannel channel) + { + if (!_enabled) + { + return; + } + + try + { + OnDrawImpl(channel); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"RTDUMP: disabled after unexpected error: {e}"); + } + } + + private static void OnDrawImpl(GpuChannel channel) + { + long now = Environment.TickCount64; + + // En mode IMAGE, l'ouverture et la fermeture de la rafale appartiennent à NotePresented : + // ici on ne fait que capturer les cibles des draws de l'image en cours, sans toucher au + // découpage. Toute logique de temps ici casserait l'appariement. + if (_frameMode) + { + if (!_bursting) + { + return; + } + } + else if (!_bursting) + { + if (_burstIndex >= _maxBursts) + { + return; + } + + if (_nextBurstMs == 0) + { + _nextBurstMs = now + _startSeconds * 1000L; + + Logger.Info?.Print(LogClass.Gpu, + $"RTDUMP: armed -- first capture in {_startSeconds}s, then every {_everySeconds}s, " + + $"{_maxBursts} bursts. Be in the scene with the defect visible by then."); + + return; + } + + if (now < _nextBurstMs) + { + return; + } + + // A burst spans a full second: the low-resolution buffers are bound on their own + // draws, so capturing a single draw would only ever catch the main target. + _bursting = true; + _burstEndsMs = now + 1000; + _burstIndex++; + _doneThisBurst.Clear(); + _burstDir = Path.Combine("rtdump", $"burst{_burstIndex:D2}"); + + try + { + Directory.CreateDirectory(_burstDir); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"RTDUMP: cannot create {_burstDir}: {e.Message}"); + + return; + } + + Logger.Info?.Print(LogClass.Gpu, $"RTDUMP: burst {_burstIndex}/{_maxBursts} started -> {_burstDir}"); + } + + if (!_frameMode && now > _burstEndsMs) + { + _bursting = false; + _nextBurstMs = now + _everySeconds * 1000L; + + Logger.Info?.Print(LogClass.Gpu, + $"RTDUMP: burst {_burstIndex} done, {_doneThisBurst.Count} buffers captured."); + + return; + } + + channel.TextureManager.MvppEnumerateRenderTargets((index, tex) => TryDump(tex, $"color{index}")); + + Image.Texture ds = channel.TextureManager.RenderTargetDepthStencil; + + if (ds != null) + { + TryDump(ds, "depth"); + } + + // Also capture what the draws READ. Measured 21/07: the 640x360 R32G32Uint buffer that + // the glow draws sample never showed up among the render targets, because nothing + // DRAWS into it -- it is written by a COMPUTE shader. That is exactly how tiled/ + // clustered lighting builds its per-tile light lists, so the buffer has to be captured + // from the read side or not at all. + if (_dumpInputs) + { + channel.TextureManager.MvppEnumerateGraphicsInputsStage((stage, tex) => TryDump(tex, $"input{stage}")); + } + } + + private static void TryDump(Image.Texture tex, string role) + { + if (tex == null || (role != "PRESENTED" && _doneThisBurst.Count >= MaxShapesPerBurst)) + { + return; + } + + GAL.ITexture host = tex.HostTexture; + + if (host == null) + { + return; + } + + // One capture per distinct SHAPE, not per texture: the same few buffers are rebound + // thousands of times a second and what we need is one picture of each kind. + string name = $"{role}_{tex.Info.Width}x{tex.Info.Height}_{tex.Info.FormatInfo.Format}"; + + // The artefact is INTERMITTENT and only shows while the camera moves, so a handful of + // snapshots will miss it. We need many captures over a long window -- which means the + // disk budget has to be spent where the answer is. The 21/07 dumps showed the composed + // scene lives in the 1280x720 R11G11B10Float target; that is the one that answers the + // bisection question "do the blocks exist in the game's own image?". Everything else + // is skipped unless RYUJINX_RT_DUMP_ALL=1. + if (role != "PRESENTED" && !_dumpAll && !_dumpInputs && + !tex.Info.FormatInfo.Format.ToString().StartsWith("R11G11B10", StringComparison.Ordinal)) + { + return; + } + + // [28/07] L'ADRESSE GPU DANS LE NOM DU FICHIER. Sans elle, les dumps sont nommes par + // SLOT (color0, color5...) alors que MvppUiProbe raisonne en ADRESSES : impossible de + // croiser les deux, et un meme buffer lie a trois slots ressort en triple exemplaire + // (color0/5/6 du 28/07 avaient un decor identique a +0,957 -- c'etait le meme). + // Deduplication par ADRESSE et plus par nom : un buffer par contenu reel. + ulong gpuAddr = 0; + + try + { + gpuAddr = tex.Range.GetSubRange(0).Address; + } + catch + { + // pas d'adresse exploitable : on garde 0, le nom reste unique par le slot + } + + if (!_doneThisBurst.Add(gpuAddr != 0 ? $"@{gpuAddr:X}" : name)) + { + return; + } + + try + { + using GAL.PinnedSpan data = host.GetData(); + ReadOnlySpan bytes = data.Get(); + + string path = Path.Combine(_burstDir, $"{name}_at{gpuAddr:X}" + ".bin"); + File.WriteAllBytes(path, bytes.ToArray()); + + Logger.Info?.Print(LogClass.Gpu, + $"RTDUMP: {name}.bin ({bytes.Length} octets, {tex.Info.Width}x{tex.Info.Height} " + + $"{tex.Info.FormatInfo.Format}, {tex.Info.Target})."); + } + catch (Exception e) + { + Logger.Warning?.Print(LogClass.Gpu, $"RTDUMP: {name} illisible: {e.GetType().Name} {e.Message}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScanProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScanProbe.cs new file mode 100644 index 000000000..57165f62a --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScanProbe.cs @@ -0,0 +1,1733 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.Runtime.InteropServices; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// MV++ pipeline-shape inventory probe (RYUJINX_MVPP_SCAN=1). Purpose: games whose camera the + /// capture never arms on (XC2: pub 0 across three configs, native and modded alike - the + /// existing counters only tick AFTER a successful publication, so they cannot say WHICH stage + /// of the chain fails). + /// + /// v1 (21/07 morning) answered the first question and killed the working hypothesis: XC2 DOES + /// have non-square depth passes (1280x720 D24S8, ~9k draws / 5 s) so the scene-pass filter is + /// not the blocker; the camera IS present (PROJ fx=1.358 aspect=1.778, exact 16:9); and the + /// canonical [view][proj][viewproj] contiguous row-major triplet the capture requires exists + /// NOWHERE (canonical 0 over the whole run) because XC2 stores view and proj in SEPARATE + /// constant buffers, column-major. + /// + /// v2 fixes v1's own defect and lifts the VIEW noise: + /// - v1 only paired proj x view WITHIN one buffer, so on a game that splits them across + /// buffers the product search could never fire (the vp-products 0 was the probe's limit, + /// not the game's shape). v2 pairs GLOBALLY, across every bound buffer and stage. + /// - Any proper rotation passes IsOrthonormalView, so bone palettes / normal matrices / + /// identities flooded the candidate list (~400 hits). v2 rejects near-identity matrices + /// and matrices sitting in a stride-64 RUN (the signature of a skinning palette), then + /// ranks what survives by PROOF: a (proj, view) pair whose product is actually STORED in + /// guest memory is the camera - that is evidence, not a heuristic. + /// - Both multiplication conventions are tested (proj x view AND view x proj), and the + /// product is looked for in either storage order. + /// Read-only, completely off without the env var, self-disabling on any error. + /// + static class MvppScanProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SCAN") == "1"; + + private const int PassWindowMs = 5000; + private const int DeepScanIntervalMs = 1000; + private const int DeepScanFallbackMs = 500; + // Sized so one deep scan stays a few milliseconds at 1 Hz: the product search is + // buffers x windows x products, and that product must not become a visible hitch. + // 4 KiB covers everything measured so far (proj at +0x1F0, views at +0x000). + private const int MaxBytesPerBuffer = 4096; + private const int MaxDetailLines = 28; + + private const int MaxBuffers = 48; + private const int MaxCandidates = 512; + private const int MaxProjPaired = 6; + private const int MaxViewPaired = 10; + private const int MaxProducts = MaxProjPaired * MaxViewPaired * 2; + + // ---- Stage A: pass inventory (cheap per-draw counters, 5 s windows) ---- + + private struct DepthForm + { + public int Width; + public int Height; + public Ryujinx.Graphics.GAL.Format Format; + public bool Scaled; + public int Count; + } + + // Aspect of the MAIN render target, i.e. the largest NON-SQUARE depth bound during the + // window. Measured 21/07 on XC2: taking the aspect of whatever draw the 1 Hz tick landed + // on made 56 of 117 verdicts empty, because the tick often lands on a shadow pass + // (1024x1024 -> aspect 1.0) and every 16:9 camera was then rejected as mismatched. The + // reference has to be the scene target, not the current draw. Largest-area wins, promoted + // once per window so a resolution change is picked up without flapping. + private static int _liveMainW; + private static int _liveMainH; + private static long _liveMainArea; + private static float _mainAspect; + + private static long _passWindowStartMs; + private static int _draws; + private static int _drawsWithDepth; + private static int _drawsNonSquareDepth; + private static readonly DepthForm[] _depthForms = new DepthForm[8]; + private static int _depthFormCount; + private static int _depthFormOverflow; + + private static readonly DepthForm[] _colorForms = new DepthForm[12]; + private static int _colorFormCount; + private static int _colorFormOverflow; + + // ---- Stage B/C: deep scan (1 Hz, prefers a draw with a depth-stencil bound) ---- + + private static long _lastDeepScanMs; + private static bool _lastScanOnMainPass; + private static bool _wantDeepScan; + private static long _wantDeepScanSinceMs; + private static ulong _lastCbufLayoutFp; + private static ulong _lastFoundSetFp; + private static int _deepScans; + + private static int _lastProjCount; + private static int _lastViewCount; + private static int _lastViewKept; + private static int _lastVpProductCount; + private static int _lastCanonicalCount; + private static int _lastStandaloneVp; + private static int _lastStandaloneSquare; + + public static void OnDraw(GpuChannel channel) + { + if (!_enabled) + { + return; + } + + // A diagnostic probe must never take the process down: it runs on the GPU thread, + // where an escaped exception is fatal and unlogged (crash of 2026-07-02 08:22, + // WER e0434352 with a silent Ryujinx log). On any error: disable and tell why. + try + { + OnDrawImpl(channel); + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPPSCAN: disabled after unexpected error: {e}"); + } + } + + private static void OnDrawImpl(GpuChannel channel) + { + long now = Environment.TickCount64; + + // ---- Stage A: per-draw counters (int adds + a tiny linear form table) ---- + + _draws++; + + Image.Texture ds = channel.TextureManager.RenderTargetDepthStencil; + + if (ds != null) + { + _drawsWithDepth++; + + int w = ds.Info.Width; + int h = ds.Info.Height; + + if (w != h) + { + _drawsNonSquareDepth++; + + long area = (long)w * h; + + if (area > _liveMainArea) + { + _liveMainArea = area; + _liveMainW = w; + _liveMainH = h; + + // Usable straight away: the first deep scans happen before the first + // window flush, and an empty reference is what caused the 56 blanks. + if (_mainAspect <= 0f) + { + _mainAspect = (float)w / h; + } + } + } + + RecordDepthForm(w, h, ds.Info.FormatInfo.Format, channel.TextureManager.RenderTargetScale > 1f); + } + + // COLOUR targets too. Measured 21/07 indoors on XC2: the depth stayed a rock-steady + // 1280x720 for 20 straight windows while the artefact was on screen -- but a game can + // perfectly well scale its COLOUR target and leave depth alone, which is a common way + // to do dynamic resolution. Reporting only depth would have let me call dynamic + // resolution "eliminated" while never having looked at the buffer it actually resizes. + Image.Texture c0 = channel.TextureManager.RenderTargetColor0; + + if (c0 != null) + { + RecordColorForm(c0.Info.Width, c0.Info.Height, c0.Info.FormatInfo.Format); + } + + if (now - _passWindowStartMs >= PassWindowMs) + { + if (_passWindowStartMs != 0) + { + FlushPassWindow(); + } + + _passWindowStartMs = now; + } + + // ---- Stage B/C: 1 Hz deep scan. Prefer a draw with ANY depth bound (most likely + // scene-adjacent); fall back to any draw if none showed up for 500 ms. ---- + + if (!_wantDeepScan && now - _lastDeepScanMs >= DeepScanIntervalMs) + { + _wantDeepScan = true; + _wantDeepScanSinceMs = now; + } + + // Scan on a draw of the MAIN scene pass whenever possible: the constant buffers bound + // at that instant are the ones the game is drawing the world with. Measured 21/07 on + // XC2 -- two legitimate 16:9 cameras move at once (same fx, different positions), and + // neither the vote nor the motion can separate them; what separates them is WHICH + // pass is being drawn when they are bound. That is the pipeline SHAPE, which is the + // criterion this whole chantier is built on. Falls back to any depth-bound draw, then + // to any draw, so a game that never matches still gets measured. + bool onMainPass = ds != null && ds.Info.Width != ds.Info.Height && + (long)ds.Info.Width * ds.Info.Height >= _liveMainArea; + + if (_wantDeepScan && + (onMainPass || + (ds != null && now - _wantDeepScanSinceMs >= DeepScanFallbackMs) || + now - _wantDeepScanSinceMs >= DeepScanFallbackMs * 2)) + { + _wantDeepScan = false; + _lastDeepScanMs = now; + _lastScanOnMainPass = onMainPass; + DeepScan(channel, ds); + } + } + + private static void RecordDepthForm(int width, int height, Ryujinx.Graphics.GAL.Format format, bool scaled) + { + for (int i = 0; i < _depthFormCount; i++) + { + if (_depthForms[i].Width == width && _depthForms[i].Height == height && + _depthForms[i].Format == format && _depthForms[i].Scaled == scaled) + { + _depthForms[i].Count++; + + return; + } + } + + if (_depthFormCount < _depthForms.Length) + { + _depthForms[_depthFormCount++] = new DepthForm + { + Width = width, + Height = height, + Format = format, + Scaled = scaled, + Count = 1, + }; + } + else + { + _depthFormOverflow++; + } + } + + private static void RecordColorForm(int width, int height, Ryujinx.Graphics.GAL.Format format) + { + for (int i = 0; i < _colorFormCount; i++) + { + if (_colorForms[i].Width == width && _colorForms[i].Height == height && + _colorForms[i].Format == format) + { + _colorForms[i].Count++; + + return; + } + } + + if (_colorFormCount < _colorForms.Length) + { + _colorForms[_colorFormCount++] = new DepthForm + { + Width = width, + Height = height, + Format = format, + Count = 1, + }; + } + else + { + _colorFormOverflow++; + } + } + + private static void FlushPassWindow() + { + System.Text.StringBuilder sb = new(); + sb.Append($"MVPPSCAN passes: draws {_draws}, with-depth {_drawsWithDepth}, non-square-depth {_drawsNonSquareDepth}; forms:"); + + if (_depthFormCount == 0) + { + sb.Append(" (none)"); + } + + for (int i = 0; i < _depthFormCount; i++) + { + ref DepthForm f = ref _depthForms[i]; + sb.Append($" {f.Width}x{f.Height} {f.Format}{(f.Scaled ? " scaled" : "")} x{f.Count};"); + } + + if (_depthFormOverflow > 0) + { + sb.Append($" (+{_depthFormOverflow} draws on overflow forms)"); + } + + sb.Append(" | COULEUR:"); + + if (_colorFormCount == 0) + { + sb.Append(" (none)"); + } + + for (int i = 0; i < _colorFormCount; i++) + { + ref DepthForm f = ref _colorForms[i]; + sb.Append($" {f.Width}x{f.Height} {f.Format} x{f.Count};"); + } + + if (_colorFormOverflow > 0) + { + sb.Append($" (+{_colorFormOverflow})"); + } + + sb.Append($" | deep: proj {_lastProjCount}, view {_lastViewCount} (kept {_lastViewKept}), " + + $"vp-products {_lastVpProductCount}, canonical {_lastCanonicalCount}, " + + $"standalone-vp {_lastStandaloneVp} (+{_lastStandaloneSquare} square-rejected) (scans {_deepScans})."); + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + + // Promote the window's main target as the aspect reference for the next window. + if (_liveMainH > 0) + { + _mainAspect = (float)_liveMainW / _liveMainH; + } + + _liveMainArea = 0; + _liveMainW = 0; + _liveMainH = 0; + + _draws = 0; + _drawsWithDepth = 0; + _drawsNonSquareDepth = 0; + _depthFormCount = 0; + _depthFormOverflow = 0; + _colorFormCount = 0; + _colorFormOverflow = 0; + _deepScans = 0; + } + + // ---- Deep scan ---- + + private enum MatClass + { + Proj, // perspective projection, row-major + ProjT, // perspective projection, column-major (transposed storage) + View, // orthonormal view, row-major + ViewT, // orthonormal view, column-major + } + + private struct Candidate + { + public int Stage; + public int Slot; + public int Offset; // in BYTES from the start of the buffer + public MatClass Class; + public float A; // proj: m00 (fx) | view: camera pos X + public float B; // proj: m11 (fy) | view: camera pos Y + public float C; // view: camera pos Z (unused for proj) + public bool InRun; // sits in a stride-64 chain = skinning palette signature + public bool Identity; // near-identity = structurally meaningless + } + + private struct BufRef + { + public int Stage; + public int Slot; + } + + private static readonly Candidate[] _cands = new Candidate[MaxCandidates]; + private static int _candCount; + + private static readonly BufRef[] _bufs = new BufRef[MaxBuffers]; + private static int _bufCount; + + // Products to look for, built in phase 2 and searched in phase 3. + private static readonly float[] _products = new float[MaxProducts * 16]; + private static readonly int[] _prodProjIdx = new int[MaxProducts]; + private static readonly int[] _prodViewIdx = new int[MaxProducts]; + private static readonly bool[] _prodProjFirst = new bool[MaxProducts]; + private static int _prodCount; + + private static readonly float[] _tmpA = new float[16]; + private static readonly float[] _tmpB = new float[16]; + private static readonly float[] _tmpT = new float[16]; + + // ---- Stage E: consensus verdict ---- + // Measured 21/07 on the TOTK control run (ground truth = the triplet's viewproj at + // stage0 cbuf8 +0x080, the address the current capture publishes): a lone structural + // validator is NOT enough - it named 13 candidates, and picking any of them would have + // put the camera on a wrong matrix ~10% of frames = the ghosting family we spent weeks + // killing. What DOES separate them: the game copies its real view-projection into + // SEVERAL constant buffers (one per shader that needs it), so the true camera is the + // position that independent buffers AGREE on. Scored over 52 scans: + // consensus alone ................ 47/52 (5 misses) + // consensus + degenerate guard ... 52/52 (0 miss) + // The 5 misses were a 3-strong cluster at (2, -0, -0): two coordinates EXACTLY zero, + // the signature of a misaligned window landing on padding rather than a camera. + // Still measurement only - nothing here feeds MvppCameraCapture. + private struct VpSolo + { + public int Stage; + public int Slot; + public int Offset; + public bool Transposed; + public float Fx; + public float Aspect; + public float X; + public float Y; + public float Z; + public bool AspectOk; + public float Motion; + public float Straightness; + + // [DIVERG C 01/08] La matrice elle-meme (transposition resolue), pour mesurer la + // divergence de ROTATION contre la camera publiee — dimension absente de tous les + // journaux jusqu'ici (revue 310). Scratch local au fil GPU, 8 Ko statiques. + public System.Numerics.Matrix4x4 M; + + // Physical address of the buffer this was read from. The SAME buffer is routinely + // bound to several shader stages (TOTK: stage0 cbuf8 and stage4 cbuf8 are the same + // bytes), and counting it once per stage would inflate the consensus with copies of + // a single source. Votes are counted per distinct address. + public ulong Address; + } + + private const int MaxVpSolos = 128; + private static readonly VpSolo[] _vpSolos = new VpSolo[MaxVpSolos]; + private static int _vpSoloCount; + + // Position agreement tolerance: the same camera read from two buffers can differ by a + // float rounding, never by a world unit. + private const float PosAgreeEps = 0.5f; + + // Temporal lock on the elected location (rule 4), keyed on the same stable identity as + // the motion history: address + offset, never the cbuf slot. + private static bool _hasLock; + private static ulong _lockAddress; + private static int _lockOffset; + + // Per-location motion history (rule 3). Measured 21/07 on XC2: the runner-up was not + // junk at all, it was the CAMERA-RELATIVE view-projection (the one engines build for the + // skybox, with the translation removed by construction). It is structurally a perfect + // view-projection, carries the SAME fx as the real camera, and no static test can tell + // them apart -- which is why every static guard I tried failed on one game or the other. + // What separates them is movement: + // real camera .......... 67 world units, and it TRAVELS + // camera-relative ...... ~0.3, pinned to the origin forever + // placeholder sentinel .. 20000, constant forever + // Comparing positions cannot work (the 20000 sentinel would out-scale the real camera + // and get it rejected). Comparing MOTION separates all three, with no threshold and no + // unit: the camera is the one that moves. Motion is remembered per location for the whole + // session, so a single moment of movement is enough to mark it for good. + private struct Tracked + { + // Identity is (physical ADDRESS, offset), NOT (stage, slot, offset). Measured 21/07 + // on XC2 and this settled a whole day of wandering verdicts: stage0 cbuf4 +0x000 was + // bound to 27 DIFFERENT buffers across 113 sightings. A cbuf slot is a drawer the + // game rebinds between draws, so every motion and straightness score computed on a + // slot was averaging unrelated objects -- which is exactly why a real camera looked + // like it "hopped around". Addresses, by contrast, persist (45 distinct in a session, + // the top one seen 91 times), and address+offset separates cleanly: @2297C86100+0x070 + // is the travelling camera while @2297C86100+0x180 is the frozen sky matrix. + public ulong Address; + public int Offset; + public float LastX; + public float LastY; + public float LastZ; + public bool HasLast; + public float MaxStep; + public int Seen; + + // Ring of recent positions, used for the straightness ratio (see TrackMotion). + public int RingHead; + public int RingCount; + public float PathLength; + } + + private const int MaxTracks = 64; + private const int RingSize = 6; + + private static readonly Tracked[] _tracks = new Tracked[MaxTracks]; + private static int _trackCount; + + // Positions ring, flattened: track i occupies [i * RingSize, (i + 1) * RingSize). + private static readonly float[] _ringX = new float[MaxTracks * RingSize]; + private static readonly float[] _ringY = new float[MaxTracks * RingSize]; + private static readonly float[] _ringZ = new float[MaxTracks * RingSize]; + private static readonly float[] _ringStep = new float[MaxTracks * RingSize]; + + private static void DeepScan(GpuChannel channel, Image.Texture drawDs) + { + _deepScans++; + _candCount = 0; + _bufCount = 0; + _prodCount = 0; + _vpSoloCount = 0; + + int canonical = 0; + int standaloneVp = 0; + _lastStandaloneSquare = 0; + + ulong layoutFp = 14695981039346656037UL; + ulong foundFp = 14695981039346656037UL; + + System.Text.StringBuilder detail = null; + int detailLines = 0; + + // ---- Phase 1: collect every matrix-shaped candidate across ALL stages/slots ---- + + for (int stage = 0; stage < Constants.ShaderStages; stage++) + { + uint mask = channel.BufferManager.GetGraphicsUniformBufferUseMask(stage); + + for (int slot = 0; mask != 0; slot++, mask >>= 1) + { + if ((mask & 1) == 0) + { + continue; + } + + if (!TryReadBuffer(channel, stage, slot, out ReadOnlySpan data)) + { + continue; + } + + layoutFp = FnvStep(layoutFp, (ulong)((stage << 24) | (slot << 16) | data.Length)); + + if (_bufCount < _bufs.Length) + { + _bufs[_bufCount++] = new BufRef { Stage = stage, Slot = slot }; + } + + int firstCand = _candCount; + + for (int i = 0; i + 16 <= data.Length && _candCount < MaxCandidates; i += 4) + { + ReadOnlySpan m = data.Slice(i, 16); + + MatClass cls; + float a, b, c; + bool identity = false; + + if (IsPerspectiveProj(m)) + { + cls = MatClass.Proj; a = m[0]; b = m[5]; c = 0f; + } + else if (IsOrthonormalView(m)) + { + cls = MatClass.View; + ViewPos(m, out a, out b, out c); + identity = IsNearIdentity(m); + } + else + { + Transpose(m, _tmpT); + ReadOnlySpan t = _tmpT; + + if (IsPerspectiveProj(t)) + { + cls = MatClass.ProjT; a = t[0]; b = t[5]; c = 0f; + } + else if (IsOrthonormalView(t)) + { + cls = MatClass.ViewT; + ViewPos(t, out a, out b, out c); + identity = IsNearIdentity(t); + } + else + { + continue; + } + } + + _cands[_candCount++] = new Candidate + { + Stage = stage, + Slot = slot, + Offset = i * 4, + Class = cls, + A = a, + B = b, + C = c, + Identity = identity, + }; + + foundFp = FnvStep(foundFp, (uint)((stage << 28) | (slot << 20) | ((i * 4) << 4) | (int)cls)); + } + + MarkRuns(firstCand); + + // ---- Stage D: STANDALONE view-projection recognition ---- + // Measurement only: this is the candidate contract for games that never + // store the [view][proj][viewproj] triplet (XC2 stores the VP alone at + // stage0 cbufN +0x000, row-major, proven 21/07 by cross-buffer pairing). + // Nothing is wired into MvppCameraCapture yet - the point of this pass is + // to show, in a log, that it accepts the RIGHT matrix on XC2 (same campos + // as the pair proof) and does not fire wrongly on TOTK. + for (int i = 0; i + 16 <= data.Length; i += 4) + { + ReadOnlySpan m = data.Slice(i, 16); + bool transposed = false; + + if (!IsViewProj(m, out float fx, out float fy)) + { + Transpose(m, _tmpT); + + if (!IsViewProj(_tmpT, out fx, out fy)) + { + continue; + } + + transposed = true; + } + + ReadOnlySpan vpm = transposed ? _tmpT : m; + + // Same rival guard as the capture: a SQUARE projection is the + // environment-cubemap camera, structurally indistinguishable otherwise. + if (MathF.Abs(fy / fx - 1f) < 0.2f) + { + _lastStandaloneSquare++; + + continue; + } + + standaloneVp++; + foundFp = FnvStep(foundFp, + 0xD00000000000UL | (uint)((stage << 20) | (slot << 16) | (i * 4))); + + ViewProjPos(vpm, fx, fy, out float px, out float py, out float pz); + + // Sentinel/degenerate positions (XC2 showed a [20000 20000 20000] + // placeholder camera): not a place anything is ever rendered from. + if (!float.IsFinite(px) || !float.IsFinite(py) || !float.IsFinite(pz) || + MathF.Abs(px) > 1e6f || MathF.Abs(py) > 1e6f || MathF.Abs(pz) > 1e6f) + { + standaloneVp--; + + continue; + } + + if (_vpSoloCount < MaxVpSolos) + { + _vpSolos[_vpSoloCount++] = new VpSolo + { + Stage = stage, + Slot = slot, + Offset = i * 4, + Transposed = transposed, + Fx = fx, + Aspect = fy / fx, + X = px, + Y = py, + Z = pz, + Address = channel.BufferManager.GetGraphicsUniformBufferAddress(stage, slot), + M = new System.Numerics.Matrix4x4( + vpm[0], vpm[1], vpm[2], vpm[3], + vpm[4], vpm[5], vpm[6], vpm[7], + vpm[8], vpm[9], vpm[10], vpm[11], + vpm[12], vpm[13], vpm[14], vpm[15]), + }; + } + + // The physical address goes in the log because a cbuf SLOT is not an + // identity: the game rebinds slot N to a different buffer between draws, + // so tracking (stage, slot, offset) may be following a drawer rather than + // what is inside it. Hypothesis raised 21/07 after the lock wandered over + // five separately-proven addresses; this line is what will settle it. + AppendDetail(ref detail, ref detailLines, + $"VP-SOLO{(transposed ? "^T" : " ")} stage{stage} cbuf{slot} +0x{i * 4:X3} " + + $"@{channel.BufferManager.GetGraphicsUniformBufferAddress(stage, slot):X10} " + + $"fx={fx:0.###} fy={fy:0.###} aspect={fy / fx:0.###} campos=[{px:0.#} {py:0.#} {pz:0.#}]"); + } + + // Canonical triplet the capture expects TODAY ([view][proj][viewproj], + // row-major, contiguous). Kept so the log can still say outright whether the + // CURRENT contract would match anywhere at all. + for (int i = 0; i + 48 <= data.Length; i += 4) + { + if (IsOrthonormalView(data.Slice(i, 16)) && + IsPerspectiveProj(data.Slice(i + 16, 16)) && + ProductMatches(data.Slice(i + 16, 16), data.Slice(i, 16), data.Slice(i + 32, 16))) + { + canonical++; + foundFp = FnvStep(foundFp, 0xCA0000000000UL | (uint)((stage << 20) | (slot << 16) | (i * 4))); + + AppendDetail(ref detail, ref detailLines, + $"canonical [view][proj][vp] at stage{stage} cbuf{slot} +0x{i * 4:X3}"); + } + } + } + } + + // ---- Phase 2: build the products of every (proj, view) pair, GLOBALLY ---- + + int projCount = 0; + int viewCount = 0; + int viewKept = 0; + + for (int i = 0; i < _candCount; i++) + { + bool isProj = _cands[i].Class == MatClass.Proj || _cands[i].Class == MatClass.ProjT; + + if (isProj) + { + projCount++; + } + else + { + viewCount++; + + if (!_cands[i].Identity && !_cands[i].InRun) + { + viewKept++; + } + } + } + + BuildProducts(channel); + + // ---- Phase 3: is any of those products actually STORED in guest memory? ---- + + int vpProducts = SearchProducts(channel, ref detail, ref detailLines, ref foundFp); + + if (layoutFp != _lastCbufLayoutFp) + { + _lastCbufLayoutFp = layoutFp; + LogCbufLayout(channel); + } + + _lastProjCount = projCount; + _lastViewCount = viewCount; + _lastViewKept = viewKept; + _lastVpProductCount = vpProducts; + _lastCanonicalCount = canonical; + _lastStandaloneVp = standaloneVp; + + // Stage E verdict: emitted on EVERY scan (not gated on the found-set changing) -- + // its whole point is to be readable as a time series, so a wrong pick on a single + // scan cannot hide behind an unchanged fingerprint. + System.Text.StringBuilder verdict = null; + int verdictLines = 0; + LogVerdict(ref verdict, ref verdictLines); + + if (verdict != null) + { + Logger.Info?.Print(LogClass.Gpu, $"MVPPSCAN {verdict.ToString().TrimStart()}"); + } + + if (foundFp != _lastFoundSetFp) + { + _lastFoundSetFp = foundFp; + + // Projections first (the solid signal), then the views that survived the filter. + for (int i = 0; i < _candCount && detailLines < MaxDetailLines; i++) + { + ref Candidate c = ref _cands[i]; + + if (c.Class != MatClass.Proj && c.Class != MatClass.ProjT) + { + continue; + } + + AppendDetail(ref detail, ref detailLines, + $"{(c.Class == MatClass.ProjT ? "PROJ^T" : "PROJ ")} stage{c.Stage} cbuf{c.Slot} +0x{c.Offset:X3} " + + $"fx={c.A:0.###} fy={c.B:0.###} aspect={(c.A > 0 ? c.B / c.A : 0):0.###}"); + } + + for (int i = 0; i < _candCount && detailLines < MaxDetailLines; i++) + { + ref Candidate c = ref _cands[i]; + + if (c.Class != MatClass.View && c.Class != MatClass.ViewT) + { + continue; + } + + if (c.Identity || c.InRun) + { + continue; + } + + AppendDetail(ref detail, ref detailLines, + $"{(c.Class == MatClass.ViewT ? "VIEW^T" : "VIEW ")} stage{c.Stage} cbuf{c.Slot} +0x{c.Offset:X3} " + + $"pos=[{c.A:0.#} {c.B:0.#} {c.C:0.#}]"); + } + + string head = + $"MVPPSCAN deep #{_deepScans}: proj {projCount}, view {viewCount} (kept {viewKept} after " + + $"identity/palette filter), vp-products {vpProducts}, canonical {canonical}, " + + $"draw-ds {(drawDs != null ? $"{drawDs.Info.Width}x{drawDs.Info.Height}" : "none")}"; + + Logger.Info?.Print(LogClass.Gpu, + detail != null ? $"{head}:\n{detail}" : $"{head} -- no candidate survived."); + } + } + + /// + /// Stage E: picks THE camera among the standalone view-projection candidates, by the two + /// rules the 21/07 TOTK control run scored (52/52 together, 47/52 for the vote alone): + /// 1. the projection's aspect must match the render target's (kills the inverse-matrix + /// family at 0.563 and the junk at 0.16 - a ratio, never a resolution, so it stays + /// game-agnostic); + /// 2. among survivors, the winner is the position the MOST INDEPENDENT BUFFERS agree + /// on, after dropping degenerate positions (two coordinates exactly zero = a window + /// straddling two matrices, not a camera). + /// Logged, never published: this is the candidate contract for step 4, not a decision. + /// + /// + /// Records this candidate's displacement since the previous scan and returns the largest + /// single-step displacement ever observed at that exact location. Session-wide, so one + /// moment of movement marks a location as mobile for good - the camera does not stop + /// being the camera when the player stands still. + /// + private static float TrackMotion(in VpSolo c, out float straightness) + { + straightness = 1f; + + int idx = -1; + + for (int i = 0; i < _trackCount; i++) + { + if (_tracks[i].Address == c.Address && _tracks[i].Offset == c.Offset) + { + idx = i; + + break; + } + } + + if (idx < 0) + { + if (_trackCount >= _tracks.Length) + { + return 0f; + } + + idx = _trackCount++; + _tracks[idx] = new Tracked { Address = c.Address, Offset = c.Offset }; + } + + ref Tracked t = ref _tracks[idx]; + t.Seen++; + + // RECENT motion, not the session maximum. Measured 21/07 on XC2: with a session-wide + // maximum, stage0 cbuf6 +0x040 held the lock for 72 scans while reporting the exact + // same [67 -7.8 53.9] every single time -- a constant that had changed value once (a + // load, a spawn) and stayed "mobile" for ever after. Decaying the score makes a + // matrix that stopped moving fade out over ~10 scans while a travelling camera keeps + // its score high. When EVERYTHING is still (player not moving) all scores decay + // together, bestMotion goes to zero, rule 3 stops filtering and the lock simply + // holds -- which is the correct behaviour, not a special case. + // 0.5 chosen by simulation on the measured numbers, not by feel: replaying the run's + // own values (a 163-unit load spike then nothing, against a camera stepping ~12/scan) + // a frozen matrix falls under rule 3's bar after 8 scans at 0.5, versus 22 at 0.8 -- + // and the travelling camera's score is unchanged either way (~13). Faster decay buys + // responsiveness for free here. + t.MaxStep *= 0.5f; + + if (t.HasLast) + { + float dx = c.X - t.LastX; + float dy = c.Y - t.LastY; + float dz = c.Z - t.LastZ; + float step = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + + // A teleport-sized jump is a scene cut or a garbage read, not travel: it must not + // crown a constant matrix that flipped value once. + if (float.IsFinite(step) && step < 1e5f && step > t.MaxStep) + { + t.MaxStep = step; + } + } + + // STRAIGHTNESS: net displacement over the ring divided by the path actually walked. + // Measured 21/07 on XC2, and this is the criterion that finally separates the last + // two survivors -- both 16:9, both genuinely changing every scan: + // camera (cbuf5 +0x000) .......... 0.57 it goes somewhere + // per-object matrix (cbuf4+0x000) 0.02 it hops around a point + // A camera travels; a per-draw object transform jumps to wherever the next object + // is. Raw motion magnitude cannot tell them apart -- it actively favours the jumper, + // which is exactly how the lock landed on it. A ratio of two lengths: no unit, no + // threshold in world units, nothing tuned to a game. + int slot = idx * RingSize; + + if (t.RingCount == RingSize) + { + // Drop the step leaving the window. + t.PathLength -= _ringStep[slot + t.RingHead]; + } + + float lastStep = 0f; + + if (t.HasLast) + { + float ddx = c.X - t.LastX; + float ddy = c.Y - t.LastY; + float ddz = c.Z - t.LastZ; + lastStep = MathF.Sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + + if (!float.IsFinite(lastStep) || lastStep > 1e5f) + { + lastStep = 0f; + } + } + + _ringX[slot + t.RingHead] = c.X; + _ringY[slot + t.RingHead] = c.Y; + _ringZ[slot + t.RingHead] = c.Z; + _ringStep[slot + t.RingHead] = lastStep; + t.PathLength += lastStep; + t.RingHead = (t.RingHead + 1) % RingSize; + + if (t.RingCount < RingSize) + { + t.RingCount++; + } + + if (t.RingCount >= 3 && t.PathLength > 1e-4f) + { + int oldest = (t.RingHead - t.RingCount + RingSize * 2) % RingSize; + float nx = c.X - _ringX[slot + oldest]; + float ny = c.Y - _ringY[slot + oldest]; + float nz = c.Z - _ringZ[slot + oldest]; + float net = MathF.Sqrt(nx * nx + ny * ny + nz * nz); + + straightness = net / t.PathLength; + } + + t.LastX = c.X; + t.LastY = c.Y; + t.LastZ = c.Z; + t.HasLast = true; + + return t.MaxStep; + } + + private static void LogVerdict(ref System.Text.StringBuilder detail, ref int detailLines) + { + if (_vpSoloCount == 0) + { + return; + } + + float targetAspect = _mainAspect; + + // Rule 1 + motion bookkeeping: keep only the aspect-valid candidates, and update + // each one's motion history BEFORE any decision is taken. + float bestMotion = 0f; + float bestStraight = 0f; + int considered = 0; + + for (int i = 0; i < _vpSoloCount; i++) + { + ref VpSolo a = ref _vpSolos[i]; + + a.AspectOk = targetAspect <= 0f || MathF.Abs(a.Aspect / targetAspect - 1f) <= 0.05f; + + if (!a.AspectOk) + { + continue; + } + + considered++; + a.Motion = TrackMotion(in a, out float straight); + a.Straightness = straight; + + if (a.Motion > bestMotion) + { + bestMotion = a.Motion; + } + + if (a.Motion > 0f && straight > bestStraight) + { + bestStraight = straight; + } + } + + int elected = -1; + int electedVotes = 0; + int lockedIdx = -1; + int lockedVotes = 0; + int movers = 0; + + for (int i = 0; i < _vpSoloCount; i++) + { + ref VpSolo a = ref _vpSolos[i]; + + if (!a.AspectOk) + { + continue; + } + + // Rule 3: it has to MOVE like the most mobile candidate does. A location whose + // position never budged while another travelled is the camera-relative matrix + // (or a constant placeholder), not the camera. Relative to the best mover, so + // there is no threshold in world units and nothing tuned to a game. While + // nothing has moved yet (start of session, player standing still) bestMotion is + // 0 and this test lets everything through -- the verdict then says "CHAUFFE". + if (bestMotion > 0f && a.Motion < bestMotion * 0.1f) + { + continue; + } + + // Rule 3b: it has to TRAVEL, not hop. A per-draw object transform changes every + // scan (so it sails through rule 3) but wanders around a point instead of going + // anywhere. Relative to the straightest mover, so still no absolute threshold. + if (bestStraight > 0f && a.Straightness < bestStraight * 0.35f) + { + continue; + } + + movers++; + + // Rule 2: how many DISTINCT buffers report this same world position? Counted per + // physical address, so one buffer bound to several stages is one voice, not two. + int votes = 0; + + for (int j = 0; j < _vpSoloCount; j++) + { + ref VpSolo b = ref _vpSolos[j]; + + if (!b.AspectOk || + MathF.Abs(b.X - a.X) > PosAgreeEps || + MathF.Abs(b.Y - a.Y) > PosAgreeEps || + MathF.Abs(b.Z - a.Z) > PosAgreeEps) + { + continue; + } + + bool alreadyCounted = false; + + for (int k = 0; k < j; k++) + { + ref VpSolo c = ref _vpSolos[k]; + + if (c.AspectOk && c.Address == b.Address && c.Offset == b.Offset && + MathF.Abs(c.X - a.X) <= PosAgreeEps && + MathF.Abs(c.Y - a.Y) <= PosAgreeEps && + MathF.Abs(c.Z - a.Z) <= PosAgreeEps) + { + alreadyCounted = true; + + break; + } + } + + if (!alreadyCounted) + { + votes++; + } + } + + if (votes > electedVotes) + { + electedVotes = votes; + elected = i; + } + + if (_hasLock && a.Address == _lockAddress && a.Offset == _lockOffset) + { + lockedIdx = i; + lockedVotes = votes; + } + } + + // The lock must never survive its own location failing a rule: that is exactly how + // the 21/07 run stayed pinned on the sky matrix for 15 scans while the DIVERGENCE + // line showed the consensus was right all along. + if (_hasLock && lockedIdx < 0) + { + _hasLock = false; + } + + // Rule 3: TEMPORAL LOCK. Consensus alone still lost on XC2, where a 3-strong cluster + // sitting near the origin outvoted the real 2-strong camera on some scans (its + // coordinates were tiny but NOT exactly zero, so the TOTK-tuned degenerate guard -- + // cut against a single game, the very trap the 3-case rule exists to catch -- never + // fired). Once a location has been elected, we keep READING THAT LOCATION as long as + // it still yields a consensus-backed camera, and only re-elect when it stops. Same + // shape as MvppCameraCapture's cached slot + lineage gate, which is proven in the + // field. Divergences are logged, never silently resolved. + int winner; + string mode; + + if (lockedIdx >= 0) + { + winner = lockedIdx; + mode = "VERROU"; + } + else if (elected >= 0) + { + winner = elected; + + // A lock must never be taken on a decision we could not check. Measured 21/07: + // during warm-up the aspect reference is still unknown, EVERY candidate passes + // rule 1 by default, and a lock taken then (on a 0.563 portrait matrix) survived + // long after the reference became available. Only lock on a scan that was both + // aspect-checked AND taken on the main scene pass. + bool trustworthy = targetAspect > 0f && bestMotion > 0f && _lastScanOnMainPass; + + mode = trustworthy ? (_hasLock ? "RE-ELU" : "ELU") : "PROVISOIRE"; + + if (trustworthy) + { + _hasLock = true; + _lockAddress = _vpSolos[elected].Address; + _lockOffset = _vpSolos[elected].Offset; + } + } + else + { + AppendDetail(ref detail, ref detailLines, + $"VERDICT: aucun candidat retenu ({_vpSoloCount} bruts, {considered} au bon aspect, " + + $"{movers} mobiles, cible aspect {targetAspect:0.###}, meilleur mouvement {bestMotion:0.##})"); + + return; + } + + ref VpSolo w = ref _vpSolos[winner]; + + if (bestMotion <= 0f) + { + mode = "CHAUFFE"; + } + + string pass = _lastScanOnMainPass ? "" : " [hors passe scene]"; + + string divergence = lockedIdx >= 0 && elected >= 0 && elected != lockedIdx && electedVotes > lockedVotes + ? $" | DIVERGENCE: le consensus dirait stage{_vpSolos[elected].Stage} cbuf{_vpSolos[elected].Slot} " + + $"+0x{_vpSolos[elected].Offset:X3} campos=[{_vpSolos[elected].X:0.#} {_vpSolos[elected].Y:0.#} " + + $"{_vpSolos[elected].Z:0.#}] ({electedVotes} voix contre {lockedVotes})" + : ""; + + AppendDetail(ref detail, ref detailLines, + $"VERDICT [{mode}]: camera = stage{w.Stage} cbuf{w.Slot} +0x{w.Offset:X3}{(w.Transposed ? "^T" : "")} " + + $"@{w.Address:X10} rect={w.Straightness:0.##} " + + $"campos=[{w.X:0.#} {w.Y:0.#} {w.Z:0.#}] fx={w.Fx:0.###} aspect={w.Aspect:0.###} " + + $"mouvement={w.Motion:0.##} -- {(lockedIdx >= 0 ? lockedVotes : electedVotes)} sources d'accord, " + + $"{movers} mobiles sur {considered} au bon aspect ({_vpSoloCount} bruts){pass}{divergence}"); + + // [DIVERG C 01/08] PHASE C SEULE — calibrage du declencheur consensus-divergence + // (journal 308/310/311). LECTURE PURE : une ligne par verdict, aucune decision, + // aucun chemin de comportement. Compare le VAINQUEUR (par VALEUR, jamais par siege) + // a la camera PUBLIEE. dpos = distance des positions ; drot = ecart max des 9 + // elements du bloc 3x3 (meme metrique que RotGap cote capture). ⚠️ La publiee est + // POST-DEJITTER : les deltas portent ce terme sous-pixel, negligeable devant les + // tolerances visees (~0,1) — dit pour l'interpretation. bornes : OK si dpos < 150 u + // (borne de concordance, tue l'intruse a 546/626 u) ET |pos| >= 5 u (anti-origine). + // n = divergences CONSECUTIVES en bornes (dpos>3 ou drot>0,1) — la matiere premiere + // des distributions demandees (Δpos, Δrot, persistance, corr. fenetres de gel). + if (MvppSoloCamera.TryGetPublishedForProbe(out System.Numerics.Matrix4x4 pubVp, + out float pubX, out float pubY, out float pubZ)) + { + float ddx = w.X - pubX; + float ddy = w.Y - pubY; + float ddz = w.Z - pubZ; + float dpos = MathF.Sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + + float drot = 0f; + drot = MathF.Max(drot, MathF.Abs(w.M.M11 - pubVp.M11)); + drot = MathF.Max(drot, MathF.Abs(w.M.M12 - pubVp.M12)); + drot = MathF.Max(drot, MathF.Abs(w.M.M13 - pubVp.M13)); + drot = MathF.Max(drot, MathF.Abs(w.M.M21 - pubVp.M21)); + drot = MathF.Max(drot, MathF.Abs(w.M.M22 - pubVp.M22)); + drot = MathF.Max(drot, MathF.Abs(w.M.M23 - pubVp.M23)); + drot = MathF.Max(drot, MathF.Abs(w.M.M31 - pubVp.M31)); + drot = MathF.Max(drot, MathF.Abs(w.M.M32 - pubVp.M32)); + drot = MathF.Max(drot, MathF.Abs(w.M.M33 - pubVp.M33)); + + float wNorm = MathF.Sqrt(w.X * w.X + w.Y * w.Y + w.Z * w.Z); + bool bornesOk = dpos < 150f && wNorm >= 5f; + bool diverge = bornesOk && (dpos > 3f || drot > 0.1f); + + _dgConsecutive = diverge ? _dgConsecutive + 1 : 0; + + AppendDetail(ref detail, ref detailLines, + $"DIVERG: dpos={dpos:0.##} drot={drot:0.####} " + + $"pub=[{pubX:0.#} {pubY:0.#} {pubZ:0.#}] " + + $"bornes={(bornesOk ? "OK" : "HORS")} n={_dgConsecutive}"); + } + } + + // [DIVERG C 01/08] Divergences consecutives en bornes (voir la ligne DIVERG). + private static int _dgConsecutive; + + private static bool TryReadBuffer(GpuChannel channel, int stage, int slot, out ReadOnlySpan data) + { + data = default; + + ulong address = channel.BufferManager.GetGraphicsUniformBufferAddress(stage, slot); + int size = Math.Min(channel.BufferManager.GetGraphicsUniformBufferSize(stage, slot), MaxBytesPerBuffer); + + if (address == 0 || address == ulong.MaxValue || size < 64) + { + return false; + } + + try + { + // The bound ranges are already TRANSLATED: physical addresses, not GPU VAs + // (SetGraphicsUniformBuffer runs TranslateAndCreateBuffer). Read physical. + data = MemoryMarshal.Cast(channel.MemoryManager.Physical.GetSpan(address, size)); + } + catch + { + return false; + } + + return true; + } + + /// + /// Flags candidates that sit in a stride-64-byte chain of 3+ orthonormal matrices: the + /// signature of a skinning palette (an array of bone matrices), which is what floods the + /// VIEW candidate list on any character-heavy game. The camera's view matrix lives in a + /// small per-frame header, not in such a run. + /// + private static void MarkRuns(int firstCand) + { + for (int i = firstCand; i < _candCount; i++) + { + if (_cands[i].Class != MatClass.View && _cands[i].Class != MatClass.ViewT) + { + continue; + } + + int chain = 1; + + for (int j = firstCand; j < _candCount; j++) + { + if (j == i) + { + continue; + } + + if (_cands[j].Class != MatClass.View && _cands[j].Class != MatClass.ViewT) + { + continue; + } + + int delta = _cands[j].Offset - _cands[i].Offset; + + if (delta % 64 == 0 && Math.Abs(delta) <= 192) + { + chain++; + } + } + + if (chain >= 3) + { + _cands[i].InRun = true; + } + } + } + + private static void BuildProducts(GpuChannel channel) + { + int projUsed = 0; + + for (int pi = 0; pi < _candCount && projUsed < MaxProjPaired && _prodCount + 2 <= MaxProducts; pi++) + { + if (_cands[pi].Class != MatClass.Proj && _cands[pi].Class != MatClass.ProjT) + { + continue; + } + + if (!LoadCanonical(channel, _cands[pi], _tmpA)) + { + continue; + } + + projUsed++; + int viewUsed = 0; + + for (int vi = 0; vi < _candCount && viewUsed < MaxViewPaired && _prodCount + 2 <= MaxProducts; vi++) + { + if (_cands[vi].Class != MatClass.View && _cands[vi].Class != MatClass.ViewT) + { + continue; + } + + if (_cands[vi].Identity || _cands[vi].InRun) + { + continue; + } + + if (!LoadCanonical(channel, _cands[vi], _tmpB)) + { + continue; + } + + viewUsed++; + + // Both conventions: proj x view (row-vector engines) and view x proj. + Multiply(_tmpA, _tmpB, _products.AsSpan(_prodCount * 16, 16)); + _prodProjIdx[_prodCount] = pi; + _prodViewIdx[_prodCount] = vi; + _prodProjFirst[_prodCount] = true; + _prodCount++; + + Multiply(_tmpB, _tmpA, _products.AsSpan(_prodCount * 16, 16)); + _prodProjIdx[_prodCount] = pi; + _prodViewIdx[_prodCount] = vi; + _prodProjFirst[_prodCount] = false; + _prodCount++; + } + } + } + + private static int SearchProducts( + GpuChannel channel, + ref System.Text.StringBuilder detail, + ref int detailLines, + ref ulong foundFp) + { + if (_prodCount == 0) + { + return 0; + } + + int found = 0; + + for (int b = 0; b < _bufCount; b++) + { + if (!TryReadBuffer(channel, _bufs[b].Stage, _bufs[b].Slot, out ReadOnlySpan data)) + { + continue; + } + + for (int i = 0; i + 16 <= data.Length; i += 4) + { + ReadOnlySpan w = data.Slice(i, 16); + + for (int k = 0; k < _prodCount; k++) + { + ReadOnlySpan exp = _products.AsSpan(k * 16, 16); + + // Two-element prefilter before the full 16-element compare. + bool direct = Close(w[0], exp[0]) && Close(w[5], exp[5]) && MatchesLoose(w, exp, false); + bool transposed = !direct && + Close(w[0], exp[0]) && Close(w[5], exp[5]) && MatchesLoose(w, exp, true); + + if (!direct && !transposed) + { + continue; + } + + found++; + + ref Candidate p = ref _cands[_prodProjIdx[k]]; + ref Candidate v = ref _cands[_prodViewIdx[k]]; + + AppendDetail(ref detail, ref detailLines, + $"*** VP FOUND: {p.Class}(stage{p.Stage} cbuf{p.Slot} +0x{p.Offset:X3}) " + + $"{(_prodProjFirst[k] ? "x" : "<-x-")} {v.Class}(stage{v.Stage} cbuf{v.Slot} +0x{v.Offset:X3}) " + + $"= stage{_bufs[b].Stage} cbuf{_bufs[b].Slot} +0x{i * 4:X3}{(transposed ? " (stored transposed)" : "")} " + + $"| campos=[{v.A:0.#} {v.B:0.#} {v.C:0.#}] fx={p.A:0.###}"); + + foundFp = FnvStep(foundFp, + 0xF00000000000UL | (uint)((_bufs[b].Stage << 20) | (_bufs[b].Slot << 16) | (i * 4))); + + break; + } + } + } + + return found; + } + + private static bool Close(float a, float b) + { + return MathF.Abs(a - b) <= MathF.Max(0.02f, MathF.Abs(b) * 0.01f); + } + + private static bool LoadCanonical(GpuChannel channel, in Candidate c, float[] dst) + { + if (!TryReadBuffer(channel, c.Stage, c.Slot, out ReadOnlySpan data)) + { + return false; + } + + int idx = c.Offset / 4; + + if (idx + 16 > data.Length) + { + return false; + } + + ReadOnlySpan src = data.Slice(idx, 16); + + if (c.Class == MatClass.ProjT || c.Class == MatClass.ViewT) + { + Transpose(src, dst); + } + else + { + src.CopyTo(dst); + } + + return true; + } + + /// + /// Structural recognition of a STANDALONE view-projection matrix, row-major. + /// + /// VP = P x V with P perspective (row 3 = [0,0,+/-1,0]) and V rigid ([R|t], R orthonormal) + /// expands to: + /// row0.xyz = fx * R.row0 row1.xyz = fy * R.row1 + /// row2.xyz = A * R.row2 row3.xyz = +/- R.row2 + /// so the shape is fully constrained without ever seeing P or V: + /// (1) ||row3.xyz|| == 1 (it IS a rotation row) + /// (2) ||row0.xyz|| = fx > 0, ||row1.xyz|| = fy > 0 + /// (3) row0.xyz, row1.xyz, row3.xyz mutually orthogonal (scaled rotation rows) + /// (4) row2.xyz parallel to row3.xyz (both along R.row2) + /// A skinning bone matrix fails (1) outright: its row 3 is [0,0,0,1], xyz norm 0. + /// The caller still has to apply the square-projection rival guard (cubemap cameras). + /// + private static bool IsViewProj(ReadOnlySpan m, out float fx, out float fy) + { + fx = 0f; + fy = 0f; + + // (1) the w-carry row must be a unit direction. + float n3 = MathF.Sqrt(m[12] * m[12] + m[13] * m[13] + m[14] * m[14]); + + if (MathF.Abs(n3 - 1f) > 0.02f) + { + return false; + } + + // (2) focal scales, with sanity bounds. Measured 21/07 on XC2: without an upper + // bound, junk windows sail through (fx=1318930, fy=2.88e18) and poison the position + // maths into NaN. A real focal term lives in a narrow band whatever the game. + float n0 = MathF.Sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]); + float n1 = MathF.Sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]); + + if (!float.IsFinite(n0) || !float.IsFinite(n1) || + n0 < 0.05f || n1 < 0.05f || n0 > 50f || n1 > 50f) + { + return false; + } + + // (3) mutual orthogonality of the three rotation-derived rows. + float d01 = (m[0] * m[4] + m[1] * m[5] + m[2] * m[6]) / (n0 * n1); + float d03 = (m[0] * m[12] + m[1] * m[13] + m[2] * m[14]) / n0; + float d13 = (m[4] * m[12] + m[5] * m[13] + m[6] * m[14]) / n1; + + if (MathF.Abs(d01) > 0.02f || MathF.Abs(d03) > 0.02f || MathF.Abs(d13) > 0.02f) + { + return false; + } + + // (4) row2.xyz must be collinear with row3.xyz (cross product ~ 0). + float n2 = MathF.Sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10]); + + if (n2 > 1e-4f) + { + float cx = m[9] * m[14] - m[10] * m[13]; + float cy = m[10] * m[12] - m[8] * m[14]; + float cz = m[8] * m[13] - m[9] * m[12]; + + if (MathF.Sqrt(cx * cx + cy * cy + cz * cz) / n2 > 0.02f) + { + return false; + } + } + + // (5) a BARE perspective projection satisfies (1)-(4) exactly, because a projection + // IS a view-projection whose view is the identity. Mathematically right, useless to + // us: it carries no camera. Measured 21/07 on XC2 - the pure projections at cbuf3 + // +0x030/+0x180/+0x1F0 all reported campos=[0 0 0]. Reject the identity-view case: + // rotation ~ identity AND translation ~ 0. + bool rotIsIdentity = + MathF.Abs(m[0] / n0 - 1f) < 1e-3f && MathF.Abs(m[1]) < 1e-3f && MathF.Abs(m[2]) < 1e-3f && + MathF.Abs(m[4]) < 1e-3f && MathF.Abs(m[5] / n1 - 1f) < 1e-3f && MathF.Abs(m[6]) < 1e-3f && + MathF.Abs(m[12]) < 1e-3f && MathF.Abs(m[13]) < 1e-3f; + + bool noTranslation = + MathF.Abs(m[3]) < 1e-3f && MathF.Abs(m[7]) < 1e-3f && MathF.Abs(m[15]) < 1e-3f; + + if (rotIsIdentity && noTranslation) + { + return false; + } + + fx = n0; + fy = n1; + + return true; + } + + /// + /// Recovers the camera position from a STANDALONE view-projection matrix, by rebuilding + /// the rigid view rows: R.row0 = row0.xyz / fx, R.row1 = row1.xyz / fy, R.row2 = row3.xyz, + /// and t = (row0.w / fx, row1.w / fy, row3.w). Position = -R^T t, same convention as + /// , so the two can be compared directly - that comparison is the + /// probe's self-test on XC2 (both must land on the same coordinates). + /// + private static void ViewProjPos(ReadOnlySpan m, float fx, float fy, out float x, out float y, out float z) + { + float r00 = m[0] / fx, r01 = m[1] / fx, r02 = m[2] / fx; + float r10 = m[4] / fy, r11 = m[5] / fy, r12 = m[6] / fy; + float r20 = m[12], r21 = m[13], r22 = m[14]; + + float tx = m[3] / fx; + float ty = m[7] / fy; + float tz = m[15]; + + x = -(r00 * tx + r10 * ty + r20 * tz); + y = -(r01 * tx + r11 * ty + r21 * tz); + z = -(r02 * tx + r12 * ty + r22 * tz); + } + + private static void ViewPos(ReadOnlySpan m, out float x, out float y, out float z) + { + // view = [R|t] row-major, camera position = -R^T t. + x = -(m[0] * m[3] + m[4] * m[7] + m[8] * m[11]); + y = -(m[1] * m[3] + m[5] * m[7] + m[9] * m[11]); + z = -(m[2] * m[3] + m[6] * m[7] + m[10] * m[11]); + } + + private static bool IsNearIdentity(ReadOnlySpan m) + { + for (int r = 0; r < 4; r++) + { + for (int c = 0; c < 4; c++) + { + float expected = r == c ? 1f : 0f; + + if (MathF.Abs(m[r * 4 + c] - expected) > 1e-3f) + { + return false; + } + } + } + + return true; + } + + private static void Transpose(ReadOnlySpan m, Span dst) + { + for (int r = 0; r < 4; r++) + { + for (int c = 0; c < 4; c++) + { + dst[r * 4 + c] = m[c * 4 + r]; + } + } + } + + private static void Multiply(ReadOnlySpan a, ReadOnlySpan b, Span dst) + { + for (int r = 0; r < 4; r++) + { + for (int c = 0; c < 4; c++) + { + float sum = 0f; + + for (int k = 0; k < 4; k++) + { + sum += a[r * 4 + k] * b[k * 4 + c]; + } + + dst[r * 4 + c] = sum; + } + } + } + + private static bool MatchesLoose(ReadOnlySpan w, ReadOnlySpan expected, bool transposed) + { + for (int r = 0; r < 4; r++) + { + for (int c = 0; c < 4; c++) + { + float e = expected[r * 4 + c]; + float g = transposed ? w[c * 4 + r] : w[r * 4 + c]; + float tolerance = MathF.Max(0.02f, MathF.Abs(e) * 0.01f); + + if (MathF.Abs(g - e) > tolerance) + { + return false; + } + } + } + + return true; + } + + private static void AppendDetail(ref System.Text.StringBuilder detail, ref int lines, string line) + { + if (lines >= MaxDetailLines) + { + return; + } + + detail ??= new System.Text.StringBuilder(); + + if (detail.Length > 0) + { + detail.Append('\n'); + } + + detail.Append(" ").Append(line); + lines++; + } + + private static void LogCbufLayout(GpuChannel channel) + { + System.Text.StringBuilder sb = new("MVPPSCAN cbufs:"); + + for (int stage = 0; stage < Constants.ShaderStages; stage++) + { + uint mask = channel.BufferManager.GetGraphicsUniformBufferUseMask(stage); + + if (mask == 0) + { + continue; + } + + sb.Append($" stage{stage}["); + bool first = true; + + for (int slot = 0; mask != 0; slot++, mask >>= 1) + { + if ((mask & 1) == 0) + { + continue; + } + + int size = channel.BufferManager.GetGraphicsUniformBufferSize(stage, slot); + sb.Append($"{(first ? "" : " ")}c{slot}:{size}"); + first = false; + } + + sb.Append(']'); + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + } + + private static ulong FnvStep(ulong fp, ulong value) + { + for (int i = 0; i < 8; i++) + { + fp = (fp ^ ((value >> (i * 8)) & 0xFF)) * 1099511628211UL; + } + + return fp; + } + + // ---- Structural validators: exact replicas of MvppCameraCapture's (kept private + // there on purpose - the probe must observe the SAME contract it diagnoses, without + // touching the capture code). ---- + + private static bool IsOrthonormalView(ReadOnlySpan m) + { + if (MathF.Abs(m[12]) > 1e-3f || MathF.Abs(m[13]) > 1e-3f || + MathF.Abs(m[14]) > 1e-3f || MathF.Abs(m[15] - 1f) > 1e-3f) + { + return false; + } + + for (int r = 0; r < 3; r++) + { + float len = m[r * 4] * m[r * 4] + m[r * 4 + 1] * m[r * 4 + 1] + m[r * 4 + 2] * m[r * 4 + 2]; + + if (MathF.Abs(len - 1f) > 0.02f) + { + return false; + } + } + + for (int a = 0; a < 3; a++) + { + for (int b = a + 1; b < 3; b++) + { + float dot = m[a * 4] * m[b * 4] + m[a * 4 + 1] * m[b * 4 + 1] + m[a * 4 + 2] * m[b * 4 + 2]; + + if (MathF.Abs(dot) > 0.02f) + { + return false; + } + } + } + + return true; + } + + private static bool IsPerspectiveProj(ReadOnlySpan m) + { + return m[0] > 0.05f && MathF.Abs(m[1]) < 1e-4f && MathF.Abs(m[2]) < 1e-4f && MathF.Abs(m[3]) < 1e-4f && + MathF.Abs(m[4]) < 1e-4f && m[5] > 0.05f && MathF.Abs(m[6]) < 1e-4f && MathF.Abs(m[7]) < 1e-4f && + MathF.Abs(m[8]) < 1e-4f && MathF.Abs(m[9]) < 1e-4f && + MathF.Abs(m[12]) < 1e-4f && MathF.Abs(m[13]) < 1e-4f && + MathF.Abs(MathF.Abs(m[14]) - 1f) < 0.01f && + MathF.Abs(m[15]) < 1e-3f; + } + + private static bool ProductMatches(ReadOnlySpan p, ReadOnlySpan v, ReadOnlySpan vp) + { + for (int r = 0; r < 4; r++) + { + for (int c = 0; c < 4; c++) + { + float expected = 0f; + + for (int k = 0; k < 4; k++) + { + expected += p[r * 4 + k] * v[k * 4 + c]; + } + + float tolerance = MathF.Max(0.02f, MathF.Abs(expected) * 0.01f); + + if (MathF.Abs(vp[r * 4 + c] - expected) > tolerance) + { + return false; + } + } + } + + return true; + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScenePass.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScenePass.cs new file mode 100644 index 000000000..aa935a8d6 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppScenePass.cs @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// ⛔⛔ MORT PAR LA MESURE, 29/07 AU SOIR -- NE PAS RE-ARMER, NE PAS RE-REGLER. Sonde `HDRTRY` : + /// la passe de scene HDR existe dans **98,9 %** des images 3D, mais la camera n'y est **lisible + /// que dans 36,5 %** des cas. N'autoriser QUE cette passe ferait donc tomber la publication de + /// 83 % a ~37 % : la camera serait AFFAMEE, et une camera manquee fait ecrire zero sur toute + /// l'image. Ce n'est pas un probleme de reglage du filet -- le filet mal dimensionne (voir plus + /// bas) n'etait que le symptome. Le remplacant est , qui ECARTE la pire + /// passe au lieu d'en ELIRE une. Ce fichier est conserve pour la trace, et parce que + /// sert encore a la sonde. + /// + /// [SCENEPASS 29/07 SOIR] Capturer la camera sur LA PASSE DE LA SCENE, et non sur la premiere + /// passe 3D venue (RYUJINX_MVPP_SCENEPASS=1, coupe par defaut). + /// + /// POURQUOI, ET SUR QUELLE MESURE. capture au PREMIER + /// dessin de l'image qui passe le pre-filtre, puis se tait. Ce pre-filtre reconnait "une passe + /// 3D", pas "LA passe de la scene". Recensement CTXPROBE du 29/07 au soir, run etalon de 3 min, + /// 3 633 lectures et 177 intruses -- du GROS volume, pas trois evenements tires au sort : + /// + /// cible couleur branchee a la lecture lectures intruses taux + /// R8G8B8A8Unorm (8 bits, LDR) 1 340 119 8,9 % <- 67 % des intruses + /// aucune (passe profondeur seule) 2 044 52 2,5 % + /// R11G11B10Float (couleur HDR de scene) 230 3 1,3 % <- la vraie passe + /// R32Float 19 3 15,8 % + /// + /// La passe 8 bits donne SEPT FOIS plus d'intruses que la couleur HDR et fournit deux tiers du + /// total. Le discriminant que le dossier croyait inexistant n'est pas dans la MATRICE (fermé le + /// 27/07 : vue-projection structurellement parfaite, rien ne l'identifie) -- il est dans la + /// PASSE. Et il y a de quoi choisir : 125 passes qualifiantes par image en moyenne, dont ~87 + /// sur la couleur HDR. + /// + /// LE CRITERE EST GENERIQUE -- forme de pipeline, rien d'autre : cible couleur attachee, NON + /// CARREE (une cible carree est un atlas d'ombres), format flottant HDR. C'est mot pour mot + /// celui que utilise deja pour epingler la couleur de scene. + /// Aucune adresse, aucun hash, aucune resolution, aucun nom de jeu. + /// + /// ⛔ CE QUE CE FICHIER NE PROMET PAS. Attendu : 4,9 % d'intruses -> 1,3 %, soit environ 73 % + /// de moins. PAS zero : la passe de scene elle-meme en porte 3 sur 230. C'est une attenuation, + /// obtenue a la source au lieu d'un filtre pose apres coup -- ce n'est pas une execution. + /// + /// ⚠️ LE RISQUE EST LA PUBLICATION, PAS L'INTRUSE, et c'est la borne INFERIEURE qui protege + /// (lecon du 29/07 : pour un composant dont le role est de refuser, prouver "il ne refuse + /// jamais plus" est la moitie inutile de la question). Une camera manquee fait ecrire ZERO sur + /// toute l'image. Donc la restriction s'auto-desarme, en deux etages : + /// 1. RATTRAPAGE -- deux images d'affilee avec des passes qualifiantes mais AUCUNE capture, + /// et la restriction se leve pour 300 images (comportement d'avant, a l'identique). Perte + /// bornee : 2 images sur 300, soit 0,7 %. + /// 2. DESARMEMENT -- cinq rattrapages, et la restriction se coupe pour la session, avec une + /// ligne de journal explicite. Si la passe HDR n'est pas fiable sur ce jeu, on le DIT au + /// lieu de saigner 2 images sur 3. + /// Le taux de publication se lit dans l'instrument qui existe deja ("MVPP capture window: + /// enq/pub") : mesure de reference du 29/07 au soir = 6 761 / 8 127 = 83,2 %. En dessous, le + /// correctif est mauvais quel que soit son gain sur les intruses. + /// + /// Les compteurs sont approximatifs : OnDraw tourne sur le fil GPU, OnFrame sur celui de la + /// presentation. On ne batit aucune conclusion fine dessus -- seulement des ordres de grandeur. + /// + static class MvppScenePass + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SCENEPASS") == "1"; + + private const int MissesBeforeCatchUp = 2; + private const int CatchUpFrames = 300; + private const int CatchUpsBeforeDisarm = 5; + private const int ReportMs = 5000; + + private static bool _disarmed; + private static int _missStreak; + private static int _catchUpLeft; + private static int _catchUps; + + private static bool _qualThisFrame; + + private static int _frames; + private static int _captured; + private static int _skips; + private static int _catchUpFramesSeen; + private static long _lastReportMs; + + /// + /// Appelee sur un dessin qualifiant tant que l'image n'a pas encore sa camera. Rend false + /// pour SAUTER la tentative de capture sur ce dessin -- ce qui laisse la capture se faire + /// plus loin dans la MEME image, sur la passe de la scene. + /// + public static bool Allows(GpuChannel channel) + { + _qualThisFrame = true; + + if (_disarmed || _catchUpLeft > 0) + { + return true; + } + + if (IsSceneColorPass(channel)) + { + return true; + } + + _skips++; + + return false; + } + + /// + /// Reconnait la passe de la scene a la FORME de sa cible couleur : attachee, non carree, + /// format flottant HDR. Meme critere que . Une cible 8 bits + /// (interface, composition, passe auxiliaire) et une passe sans couleur du tout echouent + /// ici -- ce sont exactement les deux contextes qui portaient 96 % des intruses. + /// + internal static bool IsSceneColorPass(GpuChannel channel) + { + Image.Texture col0 = channel.TextureManager.RenderTargetColor0; + + if (col0 == null || col0.Info.Width == col0.Info.Height) + { + return false; + } + + GAL.Format f = col0.Info.FormatInfo.Format; + + return f == GAL.Format.R11G11B10Float || f == GAL.Format.R16G16B16A16Float; + } + + /// + /// Une fois par image presentee, AVANT que le drapeau de capture ne soit re-arme. C'est ici + /// que vit le filet de securite : une image qui avait des passes qualifiantes et n'a rien + /// capture est une image potentiellement perdue, et on ne l'accepte pas deux fois de suite. + /// + public static void OnFrame(bool captured) + { + if (!_qualThisFrame) + { + // Image sans passe 3D (menu, chargement) : elle n'a jamais eu de camera a prendre, + // la compter comme un manque ferait declencher le rattrapage pour rien. + return; + } + + _qualThisFrame = false; + _frames++; + + if (_catchUpLeft > 0) + { + _catchUpLeft--; + _catchUpFramesSeen++; + } + + if (captured) + { + _captured++; + _missStreak = 0; + } + else if (!_disarmed && _catchUpLeft == 0) + { + _missStreak++; + + if (_missStreak >= MissesBeforeCatchUp) + { + _missStreak = 0; + _catchUps++; + _catchUpLeft = CatchUpFrames; + + if (_catchUps >= CatchUpsBeforeDisarm) + { + _disarmed = true; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP SCENEPASS DESARME : {_catchUps} rattrapages, la passe de scene HDR " + + "n'est pas assez fiable sur ce jeu pour porter la capture. Retour au " + + "comportement d'avant pour le reste de la session."); + } + else + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP SCENEPASS rattrapage #{_catchUps} : {MissesBeforeCatchUp} images " + + $"d'affilee sans capture, restriction levee pour {CatchUpFrames} images."); + } + } + } + + long now = Environment.TickCount64; + + if (now - _lastReportMs >= ReportMs) + { + _lastReportMs = now; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP SCENEPASS : {_frames} images 3D, {_captured} avec camera " + + $"({(_frames > 0 ? 100f * _captured / _frames : 0f):0.#} %), " + + $"{_skips} dessins sautes, {_catchUps} rattrapages " + + $"({_catchUpFramesSeen} images en rattrapage)" + + $"{(_disarmed ? " · DESARME" : "")}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppSoloCamera.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppSoloCamera.cs new file mode 100644 index 000000000..60eaf824b --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppSoloCamera.cs @@ -0,0 +1,3504 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.Numerics; +using System.Runtime.InteropServices; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Standalone view-projection camera source (RYUJINX_MVPP_VPSOLO=1, OFF by default). + /// + /// WHY IT EXISTS. only recognises a camera stored as the + /// contiguous row-major triplet [view][proj][viewproj] inside one vertex-stage constant + /// buffer. Measured 21/07 on Xenoblade 2: that triplet exists NOWHERE (canonical 0 over + /// every run) because the game keeps view and proj in SEPARATE buffers, column-major -- so + /// MV++ never armed there, in three different configurations. The camera itself is present + /// and findable: what is missing is a contract that accepts a view-projection matrix ALONE. + /// + /// WHERE IT SITS. Strictly a FALLBACK: MvppCameraCapture calls in here only after its own + /// triplet scan has failed. On a game whose triplet is found (TOTK) this file is never + /// reached, so that path stays bit-identical by construction rather than by testing. + /// + /// HOW IT PICKS. Four rules, every one of them RELATIVE - no world units, no resolution, no + /// per-game anything. All four were forced on us by measurement, each after a wrong pick: + /// 1. the projection's aspect must match the MAIN render target's (largest non-square + /// depth), not the current draw's -- the 1 Hz tick often lands on a shadow pass; + /// 2. at least 2 DISTINCT buffers (counted by address) must report the same position; + /// 3. it must MOVE recently (score halved each election) and TRAVEL rather than hop + /// (straightness = net displacement / path walked; camera 0.46 vs per-object 0.065); + /// 4. once elected the location is held, and released the moment it fails a rule. + /// These reject, in order of discovery: NaN/absurd values, bare projections (a projection IS + /// a view-projection with an identity view), the [20000 20000 20000] placeholder, the + /// CAMERA-RELATIVE sky matrix (structurally perfect, same fx, pinned to the origin), a + /// matrix frozen after one load-time spike, and per-object transforms. + /// + /// IDENTITY, the thing that took five failed attempts to see: a cbuf SLOT is not an identity. + /// stage0 cbuf4 +0x000 was bound to 27 DIFFERENT buffers across 113 sightings, so every + /// statistic computed per slot was averaging unrelated objects. Learning is therefore keyed + /// on (physical address + offset). But the per-frame READ must use the slot: the camera sits + /// at a stable (stage, slot, offset) while its ADDRESS rotates through a ring buffer + /// (XC2: ...ED00 / ...EE00 / ...EF00, one continuous trajectory). Learn by address, read by + /// slot -- both halves are needed, and each is wrong on its own. + /// + static class MvppSoloCamera + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_VPSOLO") == "1"; + + private const int ElectionIntervalMs = 1000; + private const int MaxBytesPerBuffer = 4096; + private const int MaxCandidates = 128; + private const int MaxTracks = 64; + private const int RingSize = 6; + + // The same tolerances the probe validated on both games. They are world-unit constants + // and therefore the only part of this file that assumes anything about a game's scale: + // if a title ever measures its world in millimetres these have to become relative too. + private const float PosAgreeEps = 0.5f; + private const float TeleportEps = 1e5f; + private const float AbsurdPos = 1e6f; + + private struct Candidate + { + public int Stage; + public int Slot; + public int Offset; + public bool Transposed; + public ulong Address; + public float Aspect; + public float X; + public float Y; + public float Z; + public float Motion; + public float Straightness; + + // [VPSOLO_ROT 02/08] Direction de visee (rangee 3 du VP, normalisee, meme source que + // le "r3" du gate de capture). Renseignee seulement sous _rotMotion ; sinon zero. + public float DirX; + public float DirY; + public float DirZ; + } + + private struct Tracked + { + public ulong Address; + public int Offset; + public float LastX; + public float LastY; + public float LastZ; + public bool HasLast; + public float Motion; + public int RingHead; + public int RingCount; + public float PathLength; + + // [VPSOLO_ROT 02/08] Derniere direction vue (voir Candidate.Dir*). + public float LastDirX; + public float LastDirY; + public float LastDirZ; + public bool HasLastDir; + } + + private static readonly Candidate[] _cands = new Candidate[MaxCandidates]; + private static int _candCount; + + private static readonly Tracked[] _tracks = new Tracked[MaxTracks]; + private static int _trackCount; + + private static readonly float[] _ringX = new float[MaxTracks * RingSize]; + private static readonly float[] _ringY = new float[MaxTracks * RingSize]; + private static readonly float[] _ringZ = new float[MaxTracks * RingSize]; + private static readonly float[] _ringStep = new float[MaxTracks * RingSize]; + + private static readonly float[] _tmp = new float[16]; + private static readonly float[] _read = new float[16]; + + // Elected location, used for the cheap per-frame read. + private static bool _hasElection; + private static int _elStage; + private static int _elSlot; + private static int _elOffset; + private static bool _elTransposed; + + // ========================================================================================== + // [CAPTIME 31/07] SONDE DE TIMING DE CAPTURE -- LECTURE SEULE, ETEINTE PAR DEFAUT. + // + // LA QUESTION, ET UNE SEULE : la matrice qu'on capture a l'image N+1 etait-elle DEJA + // visible a l'emplacement elu pendant l'image N, apres notre capture ? + // OUI -> la camera existait, on l'a manquee : on capture trop tot (needCapture ne laisse + // passer QUE le premier dessin qualifiant de l'image). + // NON -> le jeu ne l'avait pas encore produite : le moment de capture n'est pas en cause. + // + // POURQUOI CETTE FORME PLUTOT QU'UNE COMPARAISON D'ADRESSE. Le commentaire du chemin + // courant le dit : « the ADDRESS behind (stage, slot) may well have rotated ». Comparer des + // octets a une adresse melangerait « la camera a ete reecrite » et « un autre tampon est + // lie » -- et avec ~125 passes qualifiantes par image, le second domine. Ici la comparaison + // est une EGALITE BIT-EXACTE sur 16 flottants contre une matrice precise : un tampon + // etranger ne peut pas coincider par hasard, donc la rotation d'adresse ne peut pas mentir. + // + // ⛔ N'APPELLE JAMAIS TryReadAt (qui incremente _fail[] et mene a AcceptRead, lequel deplace + // la reference de SNAPGUARD). Ne touche ni DEJITTER, ni MULTIADDR, ni l'election, ni aucun + // compteur de diagnostic. IsViewProj est utilise en FILTRE seulement -- verifie ligne a + // ligne le 31/07 : aucune ecriture d'etat statique dans tout son corps. + public static readonly bool CapTime = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAPTIME") == "1"; + + private const int CtMax = 16; + private static readonly float[] _ctHist = new float[CtMax * 16]; // valeurs BRUTES distinctes vues APRES la capture + private static readonly int[] _ctHistDraw = new int[CtMax]; // rang du dessin ou chacune est apparue + private static int _ctCount; + private static int _ctBound; // dessins qualifiants ou le slot elu ETAIT lie <- la COUVERTURE + private static int _ctMissing; // dessins qualifiants ou il ne l'etait pas + private static int _ctDraw; // rang du dessin dans la fenetre post-capture + private static readonly float[] _ctPending = new float[16]; // brut du dernier TryReadAt, PRE-dejitter + private static readonly float[] _ctCaptured = new float[16]; // brut de la lecture ACCEPTEE de l'image + private static bool _ctHasCapture; + private static long _ctFrameId; + + // ========================================================================================== + // [CAPTIME_FIX 31/07] MODE DE MESURE, ETEINT PAR DEFAUT. Ne corrige rien en aval. + // + // "Capturer la premiere valeur NOUVELLE au lieu de la premiere valeur." Tant que le lieu elu + // porte encore, octet pour octet, la valeur deja publiee, on laisse passer le dessin sans + // rien tenter ; des qu'une valeur differente apparait, la capture normale a lieu par le + // chemin normal, avec toutes ses gardes. + // + // CE QUE CETTE FORME PRESERVE, ET C'EST LA RAISON DE CE CHOIX : + // - UNE seule capture par image => SNAPGUARD voit un seul pas, comme aujourd'hui ; + // - UNE seule entree FIFO => l'appariement camera <-> image est intact ; + // - AUCUN second AcceptRead => aucune interaction DEJITTER/MULTIADDR ; + // - si aucune valeur nouvelle n'apparait de l'image, on ne capture pas : c'est EXACTEMENT + // le comportement d'aujourd'hui, et c'est le cas legitime (45 % des images, mesure du + // 31/07 : le jeu n'avait vraiment rien produit). + // + // MESURE DE REFERENCE A BATTRE, annoncee AVANT le run : A = 55 %, transitions A<->B = 6,2 %, + // couverture 160/0. Reussite = A -> ~0 ET transitions -> ~0 SANS hausse des compteurs + // d'intruses (_fail, refus SNAPGUARD, MULTIADDR). Si A tombe et que les intruses montent, + // c'est SCENEPASS qui recommence et on annule. + public static readonly bool CapTimeFix = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAPTIME_FIX") == "1"; + + // ========================================================================================== + // [CAPTIME_AB 31/07] ALTERNANCE DANS UN SEUL RUN. RYUJINX_MVPP_CAPTIME_AB=, + // 0 = eteint. Le correctif s'allume et s'eteint par blocs de N presents, dans la MEME + // session, au MEME endroit, au MEME geste. + // + // POURQUOI CETTE FORME ET PAS DEUX RUNS. Mesure du 30/07 : le taux de publication vaut + // 83 / 70 / 63 / 60 % selon le seul ENDROIT dans le jeu. Comparer deux runs, c'est comparer + // deux endroits -- la chute de 14 points observee le 31/07 entre les runs 21:06 et 21:29 + // tombe entierement dans ce bruit et ne prouve RIEN, ni dans un sens ni dans l'autre. + // Precedent dans ce depot : NOLDR_AB, qui alternait par tranches de 600 images. + // + // CRITERES DE REUSSITE, ARRETES AVANT LE RUN : + // succes = A baisse cote ON ET transitions baissent ET ni les compteurs d'echec ni + // le taux de capture ne se degradent ; + // compromis/regression = A baisse MAIS la publication baisse. Pas un succes. + private static readonly int _abBlock = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAPTIME_AB"), out int b) && b > 0 ? b : 0; + + private static long _abPresents; + private static bool _abArmed; + // [0] = cote ETEINT, [1] = cote ARME. + private static readonly long[] _abFrames = new long[2]; + private static readonly long[] _abCaptures = new long[2]; + private static readonly long[] _abMoved = new long[2]; + private static readonly long[] _abPreSum = new long[2]; + private static readonly long[] _abA = new long[2]; + private static readonly long[] _abB = new long[2]; + private static readonly long[] _abTrans = new long[2]; + private static readonly long[] _abFailAtSwitch = new long[2]; + private static char _abLastVerdict; + + /// + /// [CAPTIME_AB] Le correctif est-il actif SUR CE PRESENT. En alternance c'est le bloc qui + /// decide ; sinon c'est la variable simple. Un seul point de verite, lu partout. + /// + private static bool FixActive => _abBlock > 0 ? _abArmed : CapTimeFix; + + // [CAPTIME_WHY 31/07] Compteurs de motifs du gate. Instrument seul. + private static long _whyCalled, _whyOff, _whyArmedCall, _whyNoElection, _whyNoRef; + private static long _whyUnbound, _whyReadable, _whyBadShape, _whyDiffers, _whyArmed; + private static long _whyDeadline, _whyNetted; + private static long _whyLogMs; + + // ========================================================================================== + // [CAPTIME_MAX 31/07 nuit] LA BORNE. RYUJINX_MVPP_CAPTIME_MAX=, 0 = gate ETEINT. + // + // La forme SANS limite est morte le 31/07, tuee par alternance dans un seul run : + // publication 68 % cote arme contre 90 % cote eteint, sansCapture 406/10/171/174 contre + // 140/23/20, et tauxA qui ne baissait meme pas. Cause : quand aucune valeur nouvelle + // n'arrivait, on ne capturait JAMAIS. Meme cause que SCENEPASS le 29/07. + // + // Ici on laisse passer AU PLUS K dessins, puis on capture ce qu'il y a. Le nombre de + // captures par image reste donc 1, MATHEMATIQUEMENT et pas experimentalement. + // K vient de deux mesures independantes du 31/07 : la valeur nouvelle apparait au rang + // MEDIAN 39 sur ~160 dessins, et quand la capture s'est reellement deplacee elle l'a fait + // de 36 a 40 dessins. K = 60 laisse donc ~1,5x de marge au-dessus du besoin mesure, tres + // loin sous les ~160 disponibles. + // + // ⛔ Le gate ne s'arme QUE si K > 0 : la forme non bornee est desormais impossible a lancer + // par accident. + private static readonly int _ctMaxSkip = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAPTIME_MAX"), out int k) && k > 0 ? k : 0; + + // FILET : si l'image PRECEDENTE s'est terminee sans capture, le gate ne s'arme pas du tout + // sur celle-ci. Le gate ne peut donc coûter au maximum QU'UNE image ratee d'affilee, meme + // sur une image pauvre en dessins qualifiants -- le trou par lequel la version sans limite + // affamait la camera. + private static bool _ctPrevFrameMissed; + + // [RANKPROBE 01/08] Sonde du RANG D'ARRIVEE, demandee par Alex apres (293). + // RYUJINX_MVPP_RANKPROBE=1, OFF par defaut. Lecture seule, AUCUNE lecture memoire + // nouvelle : tout vient de _ctHist/_ctHistDraw, deja remplis par NoteLateDraw. + // Question binaire : quand l'image n'a pas de valeur neuve dans ses premiers dessins, + // la valeur arrive-t-elle plus tard dans la MEME image (rang 60-160), ou JAMAIS avant + // la fin d'image ? Le rang est compte parmi les dessins qualifiants APRES la capture + // (gate eteint = capture au 1er dessin, donc rang ~= rang absolu). + private static readonly bool _rankProbe = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_RANKPROBE") == "1"; + + private static readonly int[] _rkBuckets = new int[6]; // <=40, 41-60, 61-80, 81-120, 121-160, >160 + private static int _rkNever; // couverture > 0 et aucune valeur differente vue + private static int _rkNoCover; // slot jamais lie sur l'image : rien pu voir, ECARTE + private static int _rkSaturated; // table des distinctes pleine : rang peut-etre manque, compte A PART + private static int _rkFrames; + private static long _rkDrawSum; + private static long _rkLogMs; + + private static int _ctPreDraw; // dessins laisses passer AVANT la capture, cette image + private static int _ctMovedFrames; // images ou la capture a ete deplacee (rang > 1) + private static int _ctTotalFrames; + private static long _ctPreDrawSum; + private static long _ctMovedLogMs; + + private static long _lastElectionMs; + + // ========================================================================================== + // [ELECT_HYST 01/08] HYSTERESIS DE SORTANT. RYUJINX_MVPP_ELECT_HYST=, 0 = COUPE + // (comportement d'aujourd'hui, a l'octet pres sur le chemin de decision). + // + // POURQUOI (verifie le 01/08 sur les 45 logs du 29/07, AUDIT Phase 2 bis) : le scrutin ne + // se tient QUE sur un dessin ou le sortant est illisible -- et chaque raison d'illisibilite + // l'exclut aussi du scrutin. Le sortant ne peut donc jamais gagner sa propre reelection + // (mesure : 35 elections / 35 changements le 01/08). Consequence mesuree : 47 elections de + // cbuf5+0x000 le 29/07, 44 sur les deux points fixes intrus (546/626 u), salve SNAPGUARD + // co-horodatee a +32 ms, une adoption forcee par cycle. On ne repare pas le vote, on + // refuse de le tenir tant que le sortant est vivant. + // + // REGLE : tant que le siege elu a valide STRUCTURELLEMENT il y a moins de ms + // (TryReadAt vrai au chemin commun -- independant du verdict des gardes), l'election ne + // s'ouvre pas. En regime sain le siege revalide plusieurs fois par image : un dessin + // etranger ne l'age pas. Une vraie mort (bloc deplace, chargement, coupure) l'age au-dela + // de la barre et l'election s'ouvre comme aujourd'hui. + // + // FILET ANTI-VERROUILLAGE (ratio fixe 2x, pas une variable de plus) : si RIEN n'a ete + // ACCEPTE depuis 2* ms (_lastAcceptMs -- la publication est affamee pendant que le + // siege pretend vivre), l'election s'ouvre malgre la tenue. Un siege porteur d'une autre + // camera qui ne nourrit jamais la publication tombe donc en 2* ms au plus. + // ⚠️ Ce filet ne couvre PAS le cas « adoption forcee puis intruse acceptee en continu » : + // ce cas est celui de SNAPGUARD aujourd'hui, INCHANGE -- et son declencheur (l'election + // de l'intruse) est precisement ce que la tenue supprime. + private static readonly int _electHystMs = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_ELECT_HYST"), out int hy) && hy > 0 ? hy : 0; + + // [ELECT_HYST_AB 01/08] Alternance DANS le run par tranches de temps fixes (30 s), pilotee + // par l'horloge du fil GPU -- aucun etat partage entre fils, aucun crochet de present. + // Jamais deux runs : la reference varie de 4 a 13 elections/min selon le run (29/07). + // ⚠️ Contamination de bord assumee et CONSERVATRICE : une tranche armee laisse un siege + // sain a la tranche eteinte suivante, ce qui REDUIT l'ecart mesure, jamais ne l'invente. + private static readonly bool _electHystAb = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_ELECT_HYST_AB") == "1"; + + // [ELECT_WARP 01/08] Le bouchon du « flash » : RYUJINX_MVPP_ELECT_WARP=1, OFF par defaut. + // Voir le site d'usage dans TryGetViewProjection, apres l'election. + private static readonly bool _electWarp = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_ELECT_WARP") == "1"; + + // ========================================================================================== + // [VPSOLO_ROT 02/08] LA ROTATION COMPTE COMME MOUVEMENT. RYUJINX_MVPP_VPSOLO_ROT=1, OFF par + // defaut (ferme = comportement a l'octet pres : aucun champ ci-dessous n'est lu). + // + // POURQUOI (journal (345)-(346), campagne BOTW Quality) : Track() mesure la vie d'une + // candidate par le DEPLACEMENT DE SA POSITION. Un jeu en rendu monde-relatif-camera (BOTW : + // vraie camera a cbuf3 +0x070^T, campos ~[0,0,0] MEME EN COURANT, mouvement mesure + // 0,02-1,8/scrutin) reste sous la barre des 10 % de la regle 3 des qu'une candidate + // monde-espace existe (le triplet cbuf8, ~7/scrutin en course) — et celle-ci n'a qu'UNE + // source (votes < 2). Resultat mesure : AUCUNE election en 4 min, zero ligne, famine + // 22-67 % en mouvement, images fantomes. La vie d'une camera, c'est position OU rotation. + // + // CALIBRAGE de RotMotionScale : un pan modere (~30 deg/s) donne un pas de direction + // ~0,5/scrutin (1 s) => x30 = ~15 unites-equivalentes, meme ordre qu'une course (5-7 u/s). + // Un micro-bruit de direction (0,001) => 0,03 : sous tout seuil. Le pas de direction est + // borne par construction (<= ~1,41 signe-insensible) => rotStep <= ~42, loin de tout budget + // teleport. Signe-insensible car la rangee 3 vaut +/-R.row2 selon la convention. + // + // ⚠️ INTERACTION CONNUE, NON TRAITEE ICI : SKYREJECT rejette les candidates epinglees a + // l'origine relativement a la plus lointaine — sur un jeu monde-relatif il rejetterait la + // VRAIE camera (et la matrice ciel tourne aussi, donc pas d'exemption par rotation). Ne pas + // armer SKYREJECT avec ce gate sur un jeu monde-relatif tant que ce dossier n'est pas fait. + private static readonly bool _rotMotion = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_VPSOLO_ROT") == "1"; + + private const float RotMotionScale = 30f; + + private static bool _rotArmedLogged; + private static bool _armWitnessLogged; + + // ========================================================================================== + // [MIRRORS M1 01/08] LE DETOUR DE PEREMPTION. RYUJINX_MVPP_MIRRORS=1, OFF par defaut + // (variable absente => seul delta : la copie brute de TryReadAt s'arme aussi sous _mirrors, + // qui est faux => comportement a l'octet pres). + // + // POURQUOI (journal 291-299) : pendant un gel, MULTIADDR ne tourne PAS — le repli n'existe + // que sur echec STRUCTUREL, or un siege perime lit avec SUCCES (la vieille valeur est une + // camera valide). Sur une image de gel, l'ensemble consulte = le siege seul, pendant que la + // valeur neuve vit dans un autre miroir lisible (~95 % des echantillons de gel, (298)). + // + // REGLE M1 : si la valeur BRUTE du siege est identique octet pour octet a celle vue a la + // derniere image capturee (siege perime), consulter l'historique EXISTANT (TryReadHistory : + // saute le siege, saute les valeurs egales a la derniere retenue, exige dot>0, MULTIROT, + // choisit LA MEILLEURE) et, si un miroir frais existe, accepter CETTE valeur par le chemin + // normal (AcceptRead, tous les gardes). Rien trouve ou refuse => le chemin d'aujourd'hui + // reprend tel quel. UNE acceptation/publication par image, jamais deux. Aucune decouverte + // de miroir neuve (M2 = plus tard), aucune liste d'adresses, election/tenue intouchees. + // + // [MIRRORS_AB] Alternance dans le run par tranches de 30 s (meme patron que ELECT_HYST_AB), + // fil GPU seul. Metriques de verdict : les figes (`camera VP changed`, inconditionnel) par + // cote — NON trichables ici, le detour ne publie que des valeurs LUES — plus la ligne + // `MVPP mirrors:` 5 s ci-dessous. + private static readonly bool _mirrors = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_MIRRORS") == "1"; + + private static readonly bool _mirrorsAb = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_MIRRORS_AB") == "1"; + + private const long MirrorsAbSliceMs = 30000; + + private static readonly float[] _mirSeatNow = new float[16]; + private static readonly float[] _mirSeatLast = new float[16]; + private static bool _mirHasSeatLast; + private static bool _mirAbArmed; + private static bool _mirAbKnown; + private static int _mirStale; // images ou le siege etait perime (octets identiques) + private static int _mirTried; // detours tentes (perime + MULTIADDR arme + cote arme) + private static int _mirFound; // l'historique avait un miroir frais (dot>0, MULTIROT ok) + private static int _mirAccepted; // ... et les gardes l'ont accepte (publie) + private static int _mirRefused; // ... mais un garde l'a refuse (le chemin normal reprend) + private static int _mirNone; // rien de frais dans l'historique + private static long _mirLogMs; + private static int _mirSeatStage, _mirSeatSlot, _mirSeatOffset; // dernier miroir CHOISI (diagnostic) + + private static void MirrorLog(long now) + { + if (now - _mirLogMs < 5000) + { + return; + } + + _mirLogMs = now; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP mirrors: perimees={_mirStale} detours={_mirTried} trouves={_mirFound} " + + $"acceptes={_mirAccepted} refuses={_mirRefused} rien={_mirNone} " + + $"| dernier miroir choisi=stage{_mirSeatStage} cbuf{_mirSeatSlot} +0x{_mirSeatOffset:X3}" + + $"{(_mirrorsAb ? $" | cote={(_mirAbArmed ? "ARME" : "ETEINT")}" : "")}"); + + _mirStale = 0; + _mirTried = 0; + _mirFound = 0; + _mirAccepted = 0; + _mirRefused = 0; + _mirNone = 0; + } + + private const long HystAbSliceMs = 30000; + + private static long _lastSeatOkMs; + private static bool _hystAbArmed; + private static bool _hystAbSideKnown; + private static int _hystBlocked; // elections bloquees par la tenue du siege + private static int _hystExpired; // ouvertes car siege mort depuis >= hyst + private static int _hystStarved; // ouvertes par le filet de famine (>= 2x hyst) + private static long _hystLogMs; + + private static long _liveMainArea; + private static float _mainAspect; + private static int _elections; + private static long _lastLogMs; + private static int _reads; + private static int _readFails; + + // ---- [CAMGUARD 27/07] Continuity guard on the READ path. Gate RYUJINX_MVPP_CAMGUARD=1, + // OFF by default => without the variable this file behaves exactly as before. + // + // WHY. The camera lives at a stable (stage, slot, offset) while its ADDRESS rotates + // through a ring buffer, so the per-frame read is keyed on the SLOT. That read validates + // structure only (IsViewProj + the square-projection guard + absurd-position): it has no + // notion of WHICH camera it just read. Xenoblade 2 legitimately runs more than one - + // Alex points out the game's own adjustable gameplay camera, and the minimap is rendered + // from another - so when the slot happens to hold a different one, a perfectly valid + // matrix for a DIFFERENT viewpoint is handed to the reprojection. + // + // The cost of one such frame is not subtle: the camera delta becomes enormous, every + // motion vector explodes, gets clipped to MAXMOTION and flips sign. Measured 27/07 in the + // proj audit (153 samples at 1 Hz): three distinct focal lengths where one camera has + // one, a position jump of 34699 units, and the [20000 20000 20000] placeholder handed out + // despite the election claiming to reject it. + // + // WHAT IT DOES. Compares each read against the previous accepted one. A jump beyond + // MaxJump world units in a single frame, or a focal change beyond MaxFocalDrift, is a + // teleport rather than movement: the read is refused and the LAST GOOD matrix is returned + // instead. The frame then sees a still camera (zero reprojection) rather than a stranger, + // which is the same conservative fallback the pipeline already uses when a camera is held. + // + // WHY THESE THRESHOLDS. Measured on Alex's own run: median displacement 5.8 units PER + // SECOND, i.e. under 0.1 per frame at 60 fps; only 2 samples out of 152 exceeded 50, and + // both were the placeholder. 25 units in ONE frame is 1500 units/second - far beyond any + // real camera, including pushing the view in and out, so genuine motion cannot trip it. + private static readonly bool _camGuard = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_CAMGUARD") == "1"; + + // [CAMGUARD v2 27/07] The budget is a SPEED, not a fixed distance. A fixed 25-unit step was + // wrong and I caught it before Alex tested it: the same run showed the elected location + // failing for long stretches (5 successful reads against 1278 misses over 5 seconds), and + // after such a gap a perfectly legitimate update carries a whole second of camera travel. + // A fixed threshold would have refused exactly the updates that matter and frozen the + // camera further. So the allowance grows with the time since the last accepted read. + // + // MaxSpeed is 150 world units per second against a measured median of 5.8 - a 25x margin, + // so ordinary play cannot reach it even while pushing the view in and out. The elapsed + // time is capped at CatchUpCapSec so an arbitrarily long gap cannot buy an arbitrarily + // large teleport, and MinBudget keeps a floor for back-to-back reads where dt is ~0. + // The measured intruders are 150 and 34699 units: both stay far outside every budget. + private const float MaxSpeed = 150f; + private const float MinBudget = 5f; + private const float CatchUpCapSec = 2f; + private const float MaxFocalDrift = 0.15f; + + // ---- [DEJITTER 27/07] Gate RYUJINX_MVPP_DEJITTER=1, OFF by default => without the + // variable this file behaves exactly as before, bit for bit. + // + // WHAT WAS MEASURED. Camera immobile, controller down, 600 consecutive reads: the + // reconstructed position takes EXACTLY EIGHT values, each appearing ~75 times out of 600, + // cycling forever in the same order, spread over 0.14 world units, with a step of 0.03 to + // 0.13 units EVERY frame. Eight values in perfect rotation is not movement and not noise: + // it is a periodic sub-pixel sequence. The game runs its own temporal AA and jitters its + // projection on an 8-phase pattern. + // + // WHY IT REACHES US. A jitter is applied as row0 += jx*row3 and row1 += jy*row3 (a shear + // of the projection in x and y). ViewProjPos rebuilds the translation from m[3] and m[7], + // which the shear has just contaminated, so a purely sub-pixel offset is read back as the + // camera having MOVED. The reprojection then writes ~1 px of motion, alternating in sign + // with the phase - which is exactly the flicker Alex sees while standing still, and part + // of the sliding while moving, since the false step adds to the real one. + // + // IsViewProj cannot notice: it accepts |dot(row0,row3)|/n0 up to 0.02, and the measured + // jitter sits inside that tolerance. It was written to reject strangers, not shears. + // + // THE FIX. Remove from rows 0 and 1 whatever component lies along row3 - that component IS + // the jitter, by construction, since an unjittered perspective has rows 0/1 orthogonal to + // row3. Nothing else is touched: rotation, focal lengths and depth mapping are unchanged, + // and a game that does NOT jitter has a zero component removed, so this is a no-op there. + // Motion vectors must describe scene movement, never the sampling pattern; DLSS is told + // about the jitter through its own dedicated input, not through the vectors. + private static readonly bool _deJitter = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_DEJITTER") == "1"; + + // [WHYFAIL 27/07] Why the per-frame read fails, by reason. Measured on Alex's run: 382 + // successful reads against 11 713 failures - a 3.2 % success rate - which means SKYROT is + // handed two camera matrices that are often identical or several frames apart. That alone + // could produce the elastic sky: no rotation for three frames, then three frames' worth at + // once. The code counted a single anonymous "miss" for five very different causes, so the + // number said something was wrong without ever saying what. Counting only; no behaviour + // changes, and the reasons are mutually exclusive by construction (each is a return path). + private const int FailReasons = 7; + private static readonly int[] _fail = new int[FailReasons]; + private static readonly string[] _failNames = + { + "buffer absent", // 0: nothing bound at the elected slot this draw + "hors bornes", // 1: the offset no longer fits the buffer + "pas une camera", // 2: IsViewProj rejects the shape + "projection carree", // 3: cubemap/environment camera guard + "aspect", // 4: ASPECTLOCK - not the main render target's ratio + "position absurde", // 5: NaN or beyond AbsurdPos + "matrice a l'origine",// 6: SKYREJECT - camera-relative (sky) + }; + + private static long _lastFailLogMs; + + // [ALTPROBE 28/07] Vrai pendant que la sonde essaie les adresses PRECEDEMMENT elues. Les + // echecs de ces essais ne doivent pas entrer dans les compteurs de motifs : ils + // gonfleraient « pas une camera » d'un facteur 3 et l'instrument mentirait. + private static bool _probing; + + private static bool Fail(int reason) + { + if (_probing) + { + return false; + } + + _fail[reason]++; + + long now = Environment.TickCount64; + + if (GAL.MvppDev.Enabled && now - _lastFailLogMs >= 5000) + { + _lastFailLogMs = now; + + int total = 0; + + for (int i = 0; i < FailReasons; i++) + { + total += _fail[i]; + } + + if (total > 0) + { + var sb = new System.Text.StringBuilder(); + sb.Append($"MVPP read fails ({total} en 5s) : "); + + for (int i = 0; i < FailReasons; i++) + { + if (_fail[i] > 0) + { + sb.Append($"{_failNames[i]} {_fail[i]} ({100f * _fail[i] / total:0.#} %) · "); + } + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString().TrimEnd(' ', '·')); + } + + Array.Clear(_fail); + } + + return false; + } + + /// + /// [DUPPAIR 28/07] Instantane des motifs d'echec, pour le compte-rendu 1 Hz de la sonde + /// DUPPAIR (cote capture). Lecture seule ; ne remet rien a zero, l'appelant fait ses + /// propres deltas -- le logger 5 s du mode dev, lui, remet a zero, et les deux ne + /// doivent pas se voler leurs compteurs. + /// + // [ALTPROBE 28/07] Historique des dernieres adresses elues, distinctes. SONDE SEULE : quand + // la lecture a l'adresse elue echoue, la camera est-elle a l'une des precedentes ? + // + // Ce qui la motive : on obtient ~1 lecture reussie par image alors que le jeu bouge sa + // camera a CHAQUE image (prouve par le pas double apres chaque trou), et 100 % des echecs + // sont « pas une camera » A L'ADRESSE SURVEILLEE. Si le jeu fait tourner son bloc de + // constantes entre plusieurs tampons, n'en garder qu'un en cache EST le bug -- et alors la + // camera se trouve, au meme instant, a une adresse qu'on connait deja. + // + // Ne change ni la valeur rendue, ni l'election, ni le rendu : elle compte, c'est tout. + private static readonly bool _altProbe = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_DUPPAIR") == "1"; + + // [MULTIADDR 28/07] Le correctif : lire les adresses DEJA ELUES quand l'adresse courante + // ne porte pas la camera. OFF par defaut -> sans la variable, rien n'est lu en plus et le + // comportement est identique a l'octet pres. + private static readonly bool _multiAddr = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_MULTIADDR") == "1"; + + // Trace de trajectoire (matrice aplatie) : les deux dernieres cameras RETENUES. Elles + // donnent la direction dans laquelle la camera evolue, seul moyen de distinguer une + // valeur qui AVANCE d'une valeur PERIMEE dormant dans un autre tampon -- une position + // seule ne le peut pas, puisqu'un pan ne deplace presque pas l'oeil (mesure : moins d'une + // unite d'ecart, y compris pour les valeurs neuves). + private static readonly float[] _goodFlat = new float[16]; + private static readonly float[] _goodFlatPrev = new float[16]; + private static int _goodCount; + private static int _multiTaken; + private static int _multiBack; + + private static void Flatten(in Matrix4x4 m, float[] dst) + { + dst[0] = m.M11; dst[1] = m.M12; dst[2] = m.M13; dst[3] = m.M14; + dst[4] = m.M21; dst[5] = m.M22; dst[6] = m.M23; dst[7] = m.M24; + dst[8] = m.M31; dst[9] = m.M32; dst[10] = m.M33; dst[11] = m.M34; + dst[12] = m.M41; dst[13] = m.M42; dst[14] = m.M43; dst[15] = m.M44; + } + + /// + /// [MULTIADDR] Projection du deplacement propose sur le deplacement recemment observe. + /// Positif = la valeur continue le mouvement, negatif = elle revient en arriere (valeur + /// perimee). Neutre (1) tant que deux cameras n'ont pas encore ete retenues. + /// + private static float TrajectoryDot(in Matrix4x4 cand) + { + if (_goodCount < 2) + { + return 1f; + } + + Flatten(in cand, _candFlat); + + float dot = 0f; + + for (int i = 0; i < 16; i++) + { + dot += (_candFlat[i] - _goodFlat[i]) * (_goodFlat[i] - _goodFlatPrev[i]); + } + + return dot; + } + + private static readonly float[] _candFlat = new float[16]; + + // [MULTIROT 28/07] Garde d'AMPLITUDE sur les candidats de MULTIADDR. Le choix ne reposait + // que sur la DIRECTION (TrajectoryDot > 0, « la valeur continue le mouvement ») et ne + // regardait jamais de COMBIEN. Une matrice appartenant a une AUTRE camera passe donc des + // qu'elle pointe grossierement dans le meme sens. + // + // Mesure du 28/07 (sonde JUMPDIST, cote consommateur) : avec MULTIADDR arme, 10 a 26 sauts + // de rotation par fenetre de 5 s valant tous ~1,13-1,17 ; MULTIADDR coupe, 0 a 3. Aucune + // camera ne tourne de 1,13 en une image : les mouvements legitimes mesures montent au plus + // a ~1,0, l'immense majorite vivant sous 0,1. Un candidat au-dela du seuil n'est pas une + // continuation, c'est une autre camera. + // + // Pourquoi ici et pas cote consommateur : si aucun candidat ne passe, on ne substitue + // RIEN et on retombe sur le comportement d'origine. Pas de camera figee, pas de plafond + // de rejets consecutifs a regler -- les deux faiblesses de la garde cote consommation. + private static readonly float _multiMaxRot = + float.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_MULTIADDR_MAXROT"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float mr) && mr > 0f ? mr : 0f; + private static int _multiFar; + + // [ELECTROT 28/07] Garde d'amplitude sur la lecture PRINCIPALE (voir AcceptRead). + // [ELECTROT] Refus CONSECUTIFS avant acceptation forcee. 1 par defaut : une intruse ne + // dure qu'une image isolee et reste filtree, tandis qu'une VRAIE coupure de plan persiste + // sur l'image suivante et passe aussitot. A 8 (premier essai), chaque changement de plan + // de cinematique figeait la camera assez longtemps pour faire flicker le personnage -- + // regression rapportee par Alex le 28/07 au soir. + private static readonly int ElectRotMaxStreak = + Math.Max(1, (int)(float.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_ELECTROT_STREAK"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float es) ? es : 1f)); + private static readonly float _electRot = + float.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_ELECTROT"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float er) && er > 0f ? er : 0f; + + private static int _electRejectStreak; + private static int _electRejected; + private static int _electForced; + + // [SUBPROBE 29/07] LA question qui decide s'il existe un correctif, posee avant d'en ecrire un. + // + // CE QUI EST MESURE, ET D'OU CA VIENT (run du 29/07 08h14, sonde JUMPDIST cote consommateur) : + // le slot elu adopte par moments une AUTRE camera. Signature = des sauts de rotation dont la + // valeur SE REPETE dans la bande 1,00-1,15 (1,10401x4, 1,06384x4, 1,13291x3, 1,08205x3...), + // ~26 en 85 s, contre 3 singletons de valeurs toutes differentes sur les 3 minutes qui + // precedent. Une valeur repetee a 5 decimales = deux etats FIXES, pas un geste. + // Ces intrusions n'ont PAS pu passer par les adresses de secours : MULTIROT y refuse deja + // tout candidat au-dela de 0,5, et celles-ci valent 1,06. Elles entrent donc par la lecture + // du slot elu lui-meme. Ce point est deductif, il n'a pas besoin d'un test de plus. + // + // POURQUOI UNE SONDE ET PAS UN BOUTON : la voie « refuser la lecture » est FERMEE + // (ELECTROT, 28/07). Refuser laisse la paire (curr, prev) immobile, donc l'image suivante + // voit un vecteur qui couvre DEUX intervalles : le defaut est deplace d'une image, pas + // supprime. Aucun reglage de seuil ni de plafond ne change ca. La seule voie restante est + // de SUBSTITUER -- lire la bonne camera ailleurs -- et elle n'existe que si la bonne camera + // est effectivement disponible ailleurs A CET INSTANT. C'est ce que cette sonde repond : + // secours = 0 -> la voie est morte, chercher ailleurs, ne rien coder + // secours > 0 -> la substitution est possible, et on sait a quel rang aller la chercher + // + // ELLE MESURE AUSSI LE DISCRIMINANT DE POSITION, parce que la mesure du 29/07 dit qu'il + // separe mieux que la rotation : ecart de translation 484-563 unites pour l'intruse contre + // 10-86 pour un vrai grand mouvement de camera (un vide de facteur 6), la ou les bandes de + // ROTATION se touchent (legitime jusqu'a 0,99 contre intruse 1,00-1,15). Le temoin + // « legitime » de la ligne de journal donne le cote sain du vide, sur le meme run : c'est + // la lecon du 28/07, un seuil se calibre sur le regime ou il s'appliquera. + // + // LECTURE SEULE : ne change ni la valeur rendue, ni l'election, ni le rendu. Defaut 0 = coupe. + private static readonly float _subProbe = + float.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_SUBPROBE"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float sp) && sp > 0f ? sp : 0f; + + private static int _subSuspects; + private static int _subRescued; + private static int _subNoAlt; + private static readonly int[] _subByRank = new int[HistMax]; + private static float _subRotMax; + private static float _subDistMax; + private static float _subDistMin = float.MaxValue; + private static float _subAltDistMax; + private static float _okRotMax; + private static float _okDistMax; + private static long _subLogMs; + + /// + /// [ELECTROT] Plus grand ecart element-a-element sur le bloc 3x3 de ROTATION entre deux + /// matrices. Meme mesure que RotGap, mais contre une reference explicite. + /// + private static float RotGapTo(in Matrix4x4 a, in Matrix4x4 b) + { + Flatten(in a, _candFlat); + Flatten(in b, _goodFlatPrev2); + + float m = 0f; + + for (int r = 0; r < 3; r++) + { + for (int c = 0; c < 3; c++) + { + m = Math.Max(m, Math.Abs(_candFlat[r * 4 + c] - _goodFlatPrev2[r * 4 + c])); + } + } + + return m; + } + + private static readonly float[] _goodFlatPrev2 = new float[16]; + + /// + /// [MULTIROT] Plus grand ecart element-a-element sur le bloc 3x3 de ROTATION, entre un + /// candidat et la derniere camera retenue. La rotation est choisie exprès : le jitter + /// sous-pixel du jeu vit dans la projection et la translation, jamais dans ce bloc. + /// + private static float RotGap(in Matrix4x4 cand) + { + if (_goodCount < 1) + { + return 0f; + } + + Flatten(in cand, _candFlat); + + float m = 0f; + + for (int r = 0; r < 3; r++) + { + for (int c = 0; c < 3; c++) + { + m = Math.Max(m, Math.Abs(_candFlat[r * 4 + c] - _goodFlat[r * 4 + c])); + } + } + + return m; + } + + /// + /// [MULTIADDR] Cherche la camera aux adresses deja elues. Retient celle qui va le PLUS + /// LOIN dans le sens du mouvement en cours : les tampons du jeu tournent, plusieurs + /// peuvent etre valides en meme temps et tous ne portent pas la meme fraicheur. Une + /// valeur qui recule est refusee, jamais substituee. + /// + private static bool TryReadHistory(GpuChannel channel, out Matrix4x4 best, out float bx, out float by, out float bz) + { + best = default; + bx = by = bz = 0f; + + float bestDot = 0f; + bool found = false; + + _probing = true; + + try + { + for (int i = 0; i < _histCount; i++) + { + (int stage, int slot, int offset, bool transposed) = _hist[i]; + + if (stage == _elStage && slot == _elSlot && offset == _elOffset && transposed == _elTransposed) + { + continue; + } + + if (!TryReadAt(channel, stage, slot, offset, transposed, out Matrix4x4 cand, + out float cx, out float cy, out float cz)) + { + continue; + } + + if (_hasLastGood && cand.Equals(_lastGoodVp)) + { + continue; + } + + float dot = TrajectoryDot(in cand); + + if (dot <= 0f) + { + _multiBack++; + + continue; + } + + // [MULTIROT] La direction ne suffit pas : verifier de COMBIEN (voir _multiMaxRot). + if (_multiMaxRot > 0f && RotGap(in cand) >= _multiMaxRot) + { + _multiFar++; + + continue; + } + + // [v1] On garde celui qui va le PLUS LOIN dans le sens du mouvement, pas le + // premier acceptable : plusieurs tampons peuvent etre valides en meme temps et + // tous ne portent pas la meme fraicheur. La v2 s'arretait au premier et + // promouvait son adresse ; c'est ce qui a ramene le flick. + if (!found || dot > bestDot) + { + found = true; + bestDot = dot; + best = cand; + bx = cx; + by = cy; + bz = cz; + + // [MIRRORS M1] Diagnostic seul : QUEL siege a fourni la meilleure valeur. + // Ecriture de champ pure, aucune decision ne la lit. + _mirSeatStage = stage; + _mirSeatSlot = slot; + _mirSeatOffset = offset; + } + } + } + finally + { + _probing = false; + } + + if (found) + { + _multiTaken++; + } + + return found; + } + + // [RETOUR A LA v1, 28/07] 4, pas 12. + // + // La v2 elargissait l'historique a 12 adresses (tous les candidats qui s'accordent en + // position avec l'elu) et prenait la PREMIERE acceptable au lieu de la meilleure. Elle + // n'a rien apporte de mesurable -- les recuperations sont restees a ~4 par seconde -- et + // Alex a constate le retour du flick ciel->montagnes que la v1 avait fait disparaitre. + // Une adresse "acceptable" prise en premier peut etre plus vieille que la meilleure. + // + // Regle qui en sort : ne pas elargir un mecanisme valide par l'oeil pour ameliorer un + // chiffre qui, lui, ne bougeait pas. + private const int HistMax = 4; + private static readonly (int Stage, int Slot, int Offset, bool Transposed)[] _hist = + new (int, int, int, bool)[HistMax]; + private static int _histCount; + private static int _altFound; + private static int _altNone; + private static readonly int[] _altByRank = new int[HistMax]; + private static readonly int[] _altDist = new int[4]; + private static float _altDistMax; + private static int _altSame; + private static int _altNew; + + private static void PushHistory(int stage, int slot, int offset, bool transposed) + { + for (int i = 0; i < _histCount; i++) + { + if (_hist[i].Stage == stage && _hist[i].Slot == slot && + _hist[i].Offset == offset && _hist[i].Transposed == transposed) + { + return; + } + } + + if (_histCount < HistMax) + { + _histCount++; + } + + for (int i = _histCount - 1; i > 0; i--) + { + _hist[i] = _hist[i - 1]; + } + + _hist[0] = (stage, slot, offset, transposed); + } + + /// + /// [SUBPROBE 29/07] Lecture seule. Appelee juste avant qu'une matrice devienne la nouvelle + /// camera retenue, l'ancienne etant encore intacte : c'est exactement la paire que JUMPDIST + /// mesure cote consommateur, donc les deux instruments sont comparables chiffre a chiffre. + /// Compte-rendu a 1 Hz, avec son PROPRE minuteur -- volontairement : le compte-rendu + /// d'ALTPROBE, lui, est enferme dans le bloc d'une autre sonde et n'a jamais ete emis une + /// seule fois de tout le dossier. Une sonde dont la sortie depend d'un autre interrupteur + /// est une sonde muette. + /// + private static void SubProbe(GpuChannel channel, in Matrix4x4 vp, float apx, float apy, float apz, long now) + { + float dx = apx - _lastPx; + float dy = apy - _lastPy; + float dz = apz - _lastPz; + float dist = MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + + float rot = RotGapTo(in vp, in _lastGoodVp); + + if (rot < _subProbe) + { + // Adoption ordinaire. Elle sert de TEMOIN : c'est le cote sain du vide, mesure sur + // le meme run et le meme regime que les intrusions. + _okRotMax = Math.Max(_okRotMax, rot); + _okDistMax = Math.Max(_okDistMax, dist); + } + else + { + _subSuspects++; + _subRotMax = Math.Max(_subRotMax, rot); + _subDistMax = Math.Max(_subDistMax, dist); + _subDistMin = Math.Min(_subDistMin, dist); + + bool found = false; + + // Les echecs des essais ci-dessous ne doivent pas entrer dans les compteurs de + // motifs, sinon l'instrument ment (meme precaution qu'ALTPROBE). + _probing = true; + + try + { + for (int i = 0; i < _histCount; i++) + { + (int stage, int slot, int offset, bool transposed) = _hist[i]; + + if (stage == _elStage && slot == _elSlot && offset == _elOffset && transposed == _elTransposed) + { + continue; + } + + if (!TryReadAt(channel, stage, slot, offset, transposed, out Matrix4x4 alt, + out float bpx, out float bpy, out float bpz)) + { + continue; + } + + if (RotGapTo(in alt, in _lastGoodVp) >= _subProbe) + { + continue; + } + + // Candidat proche en rotation. Sa distance compte aussi : substituer une + // rivale eloignee reintroduirait l'intrus que SNAPGUARD a mis une journee + // a chasser. + float ex = bpx - _lastPx; + float ey = bpy - _lastPy; + float ez = bpz - _lastPz; + + _subAltDistMax = Math.Max(_subAltDistMax, MathF.Sqrt((ex * ex) + (ey * ey) + (ez * ez))); + _subByRank[i]++; + found = true; + + break; + } + } + finally + { + _probing = false; + } + + if (found) + { + _subRescued++; + } + else + { + _subNoAlt++; + } + } + + if (now - _subLogMs < 1000) + { + return; + } + + _subLogMs = now; + + var sb = new System.Text.StringBuilder(); + sb.Append($"MVPP SUBPROBE (seuil rot {_subProbe:0.###}) : {_subSuspects} intrusions adoptees"); + + if (_subSuspects > 0) + { + sb.Append($" (rot max {_subRotMax:0.#####}, distance {_subDistMin:0.#}-{_subDistMax:0.#} u)"); + sb.Append($" | SECOURS DISPONIBLE {_subRescued}, AUCUN {_subNoAlt}"); + + for (int i = 0; i < HistMax; i++) + { + if (_subByRank[i] > 0) + { + sb.Append($" | rang {i}: {_subByRank[i]}"); + } + } + + if (_subRescued > 0) + { + sb.Append($" | distance max du secours {_subAltDistMax:0.#} u"); + } + } + + sb.Append($" || temoin legitime : rot max {_okRotMax:0.#####}, distance max {_okDistMax:0.#} u"); + sb.Append($" | {_histCount} adresses connues"); + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + + _subSuspects = 0; + _subRescued = 0; + _subNoAlt = 0; + _subRotMax = 0f; + _subDistMax = 0f; + _subDistMin = float.MaxValue; + _subAltDistMax = 0f; + _okRotMax = 0f; + _okDistMax = 0f; + Array.Clear(_subByRank); + } + + /// + /// [ALTPROBE] Essaie les adresses precedemment elues, sans rien changer. Appelee seulement + /// quand la lecture principale a echoue et que la sonde est armee. + /// + public static void ProbeAlternates(GpuChannel channel) + { + _probing = true; + + try + { + for (int i = 0; i < _histCount; i++) + { + (int stage, int slot, int offset, bool transposed) = _hist[i]; + + if (stage == _elStage && slot == _elSlot && offset == _elOffset && transposed == _elTransposed) + { + continue; + } + + if (TryReadAt(channel, stage, slot, offset, transposed, out Matrix4x4 alt, + out float apx, out float apy, out float apz)) + { + _altFound++; + _altByRank[i]++; + + // [ALTDIST 28/07] LA question avant tout correctif : est-ce LA MEME camera, + // ou une rivale (ombre, reflet, interface) ? Une caisse par ordre de + // grandeur, avec l'echelle deja mesuree le 27/07 : un pas ordinaire vaut + // 0,2 (99e centile 0,7), l'intrus attrape par SNAPGUARD valait 400. Lire une + // rivale reintroduirait exactement ce qu'il a coute une journee a chasser. + if (_hasLastGood) + { + float dx = apx - _lastPx; + float dy = apy - _lastPy; + float dz = apz - _lastPz; + float dist = MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + + int bucket = dist < 1f ? 0 : dist < 10f ? 1 : dist < 100f ? 2 : 3; + _altDist[bucket]++; + + if (dist > _altDistMax) + { + _altDistMax = dist; + } + + // Et surtout : cette valeur apporterait-elle quelque chose ? Si elle est + // identique a la derniere retenue, la lire ne comblerait aucun trou. + if (alt.Equals(_lastGoodVp)) + { + _altSame++; + } + else + { + _altNew++; + } + } + + return; + } + } + } + finally + { + _probing = false; + } + + _altNone++; + } + + /// [ALTPROBE] Compte-rendu 1 Hz, remis a zero a chaque lecture. + public static string AltSummaryAndReset() + { + var sb = new System.Text.StringBuilder(); + sb.Append($"MVPP ALT: camera retrouvee a une adresse deja connue {_altFound} fois, " + + $"introuvable partout {_altNone} fois"); + + for (int i = 0; i < HistMax; i++) + { + if (_altByRank[i] > 0) + { + sb.Append($" | rang {i}: {_altByRank[i]}"); + } + + _altByRank[i] = 0; + } + + sb.Append($" | {_histCount} adresses connues"); + sb.Append($" | distance a la derniere camera retenue : <1u {_altDist[0]}, 1-10u {_altDist[1]}, " + + $"10-100u {_altDist[2]}, >100u {_altDist[3]}, max {_altDistMax:0.##}"); + sb.Append($" | valeur nouvelle {_altNew}, identique {_altSame}"); + sb.Append($" | MULTIADDR {(_multiAddr ? $"arme : {_multiTaken} lectures recuperees, {_multiBack} refusees car en arriere" + (_multiMaxRot > 0f ? $", {_multiFar} refusees car AUTRE CAMERA (rot >= {_multiMaxRot:0.###})" : "") : "coupe")}"); + sb.Append($" | ELECTROT {(_electRot > 0f ? $"seuil {_electRot:0.###} : {_electRejected} lectures refusees (autre camera), {_electForced} acceptees de force" : "coupe")}"); + _electRejected = 0; + _electForced = 0; + + _multiTaken = 0; + _multiBack = 0; + + Array.Clear(_altDist); + _altDistMax = 0f; + _altSame = 0; + _altNew = 0; + _altFound = 0; + _altNone = 0; + + return sb.ToString(); + } + + public static void CopyFailCounts(int[] dst) + { + for (int i = 0; i < FailReasons && i < dst.Length; i++) + { + dst[i] = _fail[i]; + } + } + + public static string[] FailNames => _failNames; + + public static int FailReasonCount => FailReasons; + + private static float _lastJx, _lastJy; + private static int _rejectLogs; + + /// + /// [GAMEJITTER 28/07] Le decalage sous-pixel de la DERNIERE LECTURE ACCEPTEE, en NDC. Lu par + /// la capture au moment ou elle pousse la matrice, pour que les deux voyagent ensemble. + /// + public static float LastJitterX => _lastAcceptedJx; + + public static float LastJitterY => _lastAcceptedJy; + + private static float _lastAcceptedJx; + private static float _lastAcceptedJy; + + // [JITPROBE v2, 28/07] Le decalage sous-pixel que LE JEU applique a sa projection, mesure + // sur la matrice elue et rapporte ICI, dans le fichier qui possede deja la donnee. + // + // ⚠️ La v1 faisait transiter la valeur par un champ AJOUTE dans Ryujinx.Graphics.GAL, ce qui + // a oblige a redeployer cette DLL partagee dans la copie de test -- defaut visuel neuf, et + // le binaire precedent perdu. Regle qui en sort : UNE SONDE NE TOUCHE PAS A UNE + // BIBLIOTHEQUE PARTAGEE. Si elle y oblige, elle ne vaut pas son prix. + // + // Ce qu'on cherche : XC2 decale sa projection sur un motif a 8 PHASES (mesure le 27/07 : + // la position camera prenait exactement huit valeurs). On declare zero decalage a DLSS. Si + // le jeu decale et qu'on annonce zero, DLSS recale chaque echantillon au mauvais endroit + // selon un cycle de 8 images -- environ 3,7 fois par seconde. Ca colle a « ca bouge un peu + // et ca revient », a « seulement avec DLSS », et surtout au fait que le defaut est + // IDENTIQUE entre deux familles de modele : une erreur de geometrie, pas de modele. + // + // Journal seul. Aucune valeur du rendu n'est touchee. + private static readonly bool _jitProbe = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_JITPROBE") == "1"; + + private static int _mainWidth; + private static int _mainHeight; + private static long _jitLogMs; + private static int _jitN; + private static float _jitMinX = float.MaxValue, _jitMaxX = float.MinValue; + private static float _jitMinY = float.MaxValue, _jitMaxY = float.MinValue; + private static readonly float[] _jitSeenX = new float[32]; + private static int _jitSeenN; + + private static void NoteJitter(float jx, float jy) + { + if (!_jitProbe || _mainWidth <= 0) + { + return; + } + + // Le decalage est en NDC (x_ndc += jx) ; en pixels c'est jx * largeur / 2. + float px = jx * _mainWidth * 0.5f; + float py = jy * _mainHeight * 0.5f; + + _jitN++; + _jitMinX = MathF.Min(_jitMinX, px); + _jitMaxX = MathF.Max(_jitMaxX, px); + _jitMinY = MathF.Min(_jitMinY, py); + _jitMaxY = MathF.Max(_jitMaxY, py); + + // Le nombre de valeurs DISTINCTES est le seul chiffre qui prouve un motif : huit + // valeurs qui reviennent, c'est un cycle ; un nuage de valeurs, c'est du bruit. + bool seen = false; + + for (int i = 0; i < _jitSeenN; i++) + { + if (MathF.Abs(_jitSeenX[i] - px) < 0.01f) + { + seen = true; + + break; + } + } + + if (!seen && _jitSeenN < _jitSeenX.Length) + { + _jitSeenX[_jitSeenN++] = px; + } + + long now = Environment.TickCount64; + + if (now - _jitLogMs < 1000) + { + return; + } + + _jitLogMs = now; + + var sb = new System.Text.StringBuilder(); + sb.Append($"MVPP JITPROBE: decalage du JEU x=[{_jitMinX:0.0000};{_jitMaxX:0.0000}] px "); + sb.Append($"y=[{_jitMinY:0.0000};{_jitMaxY:0.0000}] px sur {_jitN} lectures "); + sb.Append($"| valeurs distinctes en x = {_jitSeenN}{(_jitSeenN >= _jitSeenX.Length ? "+" : "")} : "); + + for (int i = 0; i < _jitSeenN && i < 10; i++) + { + sb.Append($"{_jitSeenX[i]:0.000} "); + } + + sb.Append($"| rendu {_mainWidth}x{_mainHeight}"); + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + + _jitN = 0; + _jitMinX = float.MaxValue; + _jitMaxX = float.MinValue; + _jitMinY = float.MaxValue; + _jitMaxY = float.MinValue; + _jitSeenN = 0; + } + + // [ASPECTLOCK 27/07] Gate RYUJINX_MVPP_ASPECTLOCK=1, OFF by default. See the check in + // TryReadAt: it is rule 1 of the election, finally applied to the per-frame read too. + private static readonly bool _aspectLock = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_ASPECTLOCK") == "1"; + + // [SKYREJECT 27/07] Gate RYUJINX_MVPP_SKYREJECT=1, OFF by default. See the check in + // TryReadAt: the election's rejection of camera-relative matrices, applied to the read. + private static readonly bool _skyReject = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SKYREJECT") == "1"; + + // [SNAPGUARD 27/07] Gate RYUJINX_MVPP_SNAPGUARD=1, OFF by default. See AcceptStep. + private static readonly bool _snapGuard = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SNAPGUARD") == "1"; + + // 20x the largest step of the last ~2 s. Ordinary steps measured at 0.2 median / 0.7 at the + // 99th percentile against an intruder at 400: any factor between 5 and 100 gives the same + // verdict, so this is a separation, not a tuning. Three consecutive refusals mean the + // camera genuinely moved and the scale is relearned. + private const float SnapFactor = 20f; + private const int SnapWindow = 120; + private const int SnapConfirm = 3; + + // [SNAPHOLD 29/07] Remplace la REGLE DE REDDITION de SNAPGUARD. Defaut 0 = regle d'origine, + // chemin d'execution inchange. + // + // CE QUI A ETE MESURE (run 08h40, sondes SUBPROBE + le journal de SNAPGUARD lui-meme) : + // le garde VOIT deja les intruses -- 49 refus, avec des sauts de 537,27 / 546,19 / 629,05 + // unites -- et il en laisse quand meme passer 13. Ce n'est donc pas un defaut de detection, + // c'est la reddition qui fuit. Deux causes, distinctes : + // + // 1. IL CEDE APRES 3 REFUS CONSECUTIFS. L'intruse arrive en SALVES de plusieurs images + // d'affilee : atteindre 3 ne lui coute rien. Le critere « ca insiste donc c'est reel » + // ne separe pas les deux, parce que l'intruse insiste aussi. Ce qui les separe est + // ailleurs, et c'est dans les donnees : les positions de reference alternent entre + // DEUX SIEGES FIXES, [117,5 -10,7 205,7] et [58,7 -1,4 -337], distants de ~546 u -- + // la valeur exacte du saut. L'intruse REVIENT. Une vraie teleportation est a SENS + // UNIQUE (fiche CUTONJUMP : apres un vrai saut, les 170 lectures suivantes restent + // immobiles). + // ⇒ Le bon critere est donc : « l'ancien siege n'a plus ete vu depuis longtemps ». + // Et il s'exprime avec le compteur qui existe deja : toute lecture proche de l'ancien + // siege est acceptee et remet _consecutiveRejects a zero. Il suffit d'exiger BEAUCOUP + // PLUS que 3. On peut se le permettre : un refus ne fige rien -- l'appelant rend la + // camera precedente et la publication continue (ce n'est PAS le gel d'ELECTROT, qui + // lui ne publiait plus rien). + // + // 2. EN CEDANT, IL EFFACE SON ECHELLE APPRISE (_accCount = 0), donc les 8 lectures + // suivantes passent SANS AUCUN CONTROLE (`if (_accCount < 8 || ...) return true`). + // C'est par la que rentre le VOYAGE RETOUR de la salve. Et c'est injustifiable en soi : + // que la camera se teleporte ne change pas a quoi ressemble un pas ordinaire (0,2 u, + // 99e centile 0,7). ⇒ avec SNAPHOLD arme, l'echelle n'est plus effacee. + // + // ⚠️ RISQUE ASSUME, A SURVEILLER : sur une VRAIE teleportation, la camera est tenue jusqu'a + // SNAPHOLD images avant d'etre adoptee (~0,6 s a 30 images/s). Le journal sort `salve-max` : + // si elle reste COLLEE a la valeur de SNAPHOLD, le plafond est trop bas et on le voit -- + // c'est exactement le diagnostic qui avait condamne CAMSPLIT. + // ⛔⛔ LES DEUX FUITES SONT SEPAREES EN DEUX INTERRUPTEURS, ET CE N'EST PAS UN DETAIL DE + // CONFORT : elles n'ont PAS le meme risque, donc elles ne se testent pas ensemble. + // SNAPKEEP (fuite 2) : ne change RIEN au moment de la reddition -> aucun effet sur une + // vraie coupure, donc aucun risque sur les cinematiques. + // SNAPHOLD (fuite 1) : retarde la reddition -> sur une vraie coupure de plan, la camera + // est tenue jusqu'a N images (~0,6 s a 30 i/s). RISQUE CINEMATIQUE + // REEL, et Alex a pose les cinematiques en contrainte dure. + // ⇒ On arme SNAPKEEP seul d'abord. SNAPHOLD ne s'arme qu'apres, et seulement s'il reste + // des intrusions ET que le gain justifie de risquer une cinematique. + private static readonly bool _snapKeep = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SNAPKEEP") == "1"; + + // ⛔⛔ [SNAPLEARN 29/07] **REJETE PAR L'OEIL D'ALEX LE 29/07 : BANDE NOIRE SUR LA MOITIE DE + // L'ECRAN.** Ne plus apprendre les pas nuls affame `_accCount`, qui sert AUSSI de compteur + // d'echauffement (`_accCount < 8` = tout passe) : le garde reste alors DESARME (0 refus + // mesures, contre 7 au temoin) et une intruse a 546 u est adoptee sans resistance. + // Laisse en place, desarme par defaut, UNIQUEMENT pour que l'interrupteur reste + // documente -- **ne pas rearmer**. Voir journal (267). + private static readonly bool _snapLearn = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SNAPLEARN") == "1"; + + // [SNAPFLOOR 29/07] LA REPARATION DE LA BARRE A ZERO, faite du bon cote cette fois. + // + // LE DEFAUT, MESURE TROIS FOIS (runs 08h40, 09h23, 09h50) : la fenetre d'apprentissage ne + // retient que 120 valeurs. Camera posee plus de ~4 s, le pas vaut 0 : les zeros remplissent + // la fenetre et EVINCENT les vrais pas, `scale` tombe a 0, donc `step <= scale * SnapFactor` + // devient `step <= 0` et le garde REFUSE TOUT. Releve en plein milieu du run 09h50 : + // `19 refus (saut > 0), derniere = 0,06 | echelle 0 sur 79 pas appris` + // -- 19 refus consecutifs sur un pas de 0,06 unite, un mouvement parfaitement ordinaire. + // ⚠️ Et avec SNAPHOLD=20 chaque episode coute desormais 20 images de camera tenue au lieu + // de 3 : le defaut existait avant, le plafond l'a rendu six fois plus cher. + // + // ⛔ PREMIERE VERSION, REJETEE PAR LA MESURE LE 29/07 -- « se replier sur la derniere echelle + // NON NULLE ». Elle a rendu les choses PIRES (vraies intruses 3 -> 5, refus 26 -> 40/min) et + // le journal disait pourquoi noir sur blanc : + // `50 refus (saut > 0) ... SNAPFLOOR : 2 replis sur la derniere echelle reelle (0)` + // Le maximum de la fenetre peut etre une valeur MICROSCOPIQUE (niveau du jitter, ~0,0004) : + // non nulle, donc memorisee comme reference valable, mais une barre de 0,008 refuse tout + // exactement comme une barre a zero. 🔑 **J'avais corrige « la barre ne doit pas etre NULLE » + // alors que le probleme est « la barre doit etre SIGNIFICATIVE ». Non nul != utilisable.** + // + // LE CORRECTIF, VERSION RETENUE : plancher l'echelle a une valeur MESUREE, pas a « ce qui + // n'est pas zero ». `RYUJINX_MVPP_SNAPFLOOR=` (0 = coupe), teste a 0,5 => la barre + // ne descend jamais sous 10 unites. Le chiffre vient des runs du 29/07 : + // jitter du jeu 0,14 · pas ordinaire 0,2 (99e centile 0,7) · plus grand pas LEGITIME + // observe 4,9 u · plus petite INTRUSE 415 u + // Barre a 10 u = 2x au-dessus du plus gros mouvement legitime vu, 40x sous la plus petite + // intruse. Le vide est mesure des deux cotes, il n'est pas choisi au jugé. + // + // 🔑 CE QUI LE DISTINGUE DE SNAPLEARN, QUI A ECHOUE EXACTEMENT ICI : **les zeros restent + // APPRIS**. `_accCount` grandit normalement, le compteur d'echauffement n'est jamais + // affame, le garde n'est jamais desarme. On ne touche qu'au CALCUL de la barre, pas a ce + // qui est memorise. + // + // 🔒 LES DEUX BORNES, PARCE QUE LA LECON DU JOUR EST QU'IL FAUT ENCADRER LES DEUX (et que je + // ne l'ai fait ni pour SNAPLEARN ni pour la premiere version de SNAPFLOOR) : + // - vers le HAUT : `scale` ne peut qu'AUGMENTER (un plancher) => la barre monte => le + // garde refuse MOINS ou autant, jamais plus. Pas de camera figee, pas de texture qui + // nage (l'echec de SNAPKEEP est structurellement impossible ici). + // - vers le BAS : la barre ne descend JAMAIS sous le plancher, donc le garde ne peut PAS + // devenir absent -- toute intruse au-dela de 10 u est refusee en toutes circonstances, + // y compris au demarrage a froid et juste apres une reddition. Il n'y a AUCUNE branche + // qui accepte sans juger : c'est precisement ce qui manquait a SNAPLEARN (compteur + // d'echauffement affame => `_accCount < 8` => tout passe) et a SNAPFLOOR v1 (branche + // "aucune echelle connue" => tout passe, declenchee 41 fois). + // ⚠️ Les zeros restent APPRIS : `_accCount` grandit normalement, l'echauffement n'est + // jamais affame. On ne touche qu'au CALCUL de la barre, jamais a ce qui est memorise. + private static readonly float _snapFloor = + float.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_SNAPFLOOR"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float sf) && sf > 0f ? sf : 0f; + + private static int _snapFloorUsed; + + private static readonly int _snapHold = + (int)(float.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_SNAPHOLD"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float sh) && sh > 0f ? sh : 0f); + + private static int _snapStreakMax; + private static int _snapForced; + + private static readonly float[] _accSteps = new float[SnapWindow]; + private static int _accHead; + private static int _accCount; + private static int _consecutiveRejects; + private static int _snapRefusals; + private static long _lastSnapLogMs; + + // [CUTONJUMP 27/07] Gate RYUJINX_MVPP_CUTONJUMP=1, OFF by default. See NoteJump. + private static readonly bool _cutOnJump = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_CUTONJUMP") == "1"; + + // The scale is the 90th percentile of the last WindowSize steps - a robust statistic, not a + // record. A record can only go up and, once it is wrong, nothing can bring it back down; a + // percentile over a sliding window follows what the camera is actually doing right now and + // a single outlier cannot move it. WindowSize is ~5 s at 60 fps. + private const float TeleportFactor = 50f; + private const int WindowSize = 300; + private const int ScaleRefresh = 30; + + private static readonly float[] _window = new float[WindowSize]; + private static readonly float[] _sorted = new float[WindowSize]; + private static int _windowHead; + private static int _windowCount; + private static int _scaleCountdown; + private static float _scale; + + private static bool _hasLastGood; + private static float _lastPx, _lastPy, _lastPz, _lastFx; + private static long _lastAcceptMs; + private static Matrix4x4 _lastGoodVp; + private static int _guardRejects; + + /// + /// [DIVERG C 01/08] Instantane en lecture pure de la camera PUBLIEE (la derniere acceptee, + /// celle que MV++ consomme), pour la sonde de divergence de MvppScanProbe. Meme fil GPU, + /// aucun verrou necessaire, aucune ecriture. ⚠️ Valeur POST-DEJITTER (documente cote sonde). + /// + internal static bool TryGetPublishedForProbe(out Matrix4x4 vp, out float px, out float py, out float pz) + { + vp = _lastGoodVp; + px = _lastPx; + py = _lastPy; + pz = _lastPz; + + return _hasLastGood; + } + + /// + /// Refreshes the main render target aspect from the depth bound on this draw. Cheap, and + /// it has to run on every draw so the reference is the scene target rather than whatever + /// pass the election happened to land on. + /// + public static void NoteDepth(int width, int height) + { + // [VPSOLO_ROT 02/08 v2] Temoin d'armement DECOUPLE de l'election : il imprime des le + // premier dessin qualifiant, que l'election tourne ou non. Lecon du run 10:59 : un + // temoin place DANS Elect() ne distingue pas « pas arme » de « jamais appele ». + if (!_armWitnessLogged) + { + _armWitnessLogged = true; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP solo: ARME (VPSOLO=1) — rotation-comme-mouvement {(_rotMotion ? "ON (x" + RotMotionScale + ")" : "OFF")}."); + } + + if (width == height || height <= 0) + { + return; + } + + long area = (long)width * height; + + if (area >= _liveMainArea) + { + _liveMainArea = area; + _mainWidth = width; + _mainHeight = height; + _mainAspect = (float)width / height; + } + } + + /// + /// Fallback camera source. Returns true and fills with the guest's + /// view-projection when a camera could be read. Cheap on the common path: one memory read + /// at the elected location plus a structural check; the full multi-buffer election only + /// runs once a second, or when the elected location stops validating. + /// + public static bool TryGetViewProjection(GpuChannel channel, out Matrix4x4 vp) + { + vp = default; + + if (!Enabled) + { + return false; + } + + // [FAMHOLD 01/08] Echantillonnage des adresses-famille (lecture pure, stride 1/8, + // voir MvppFamHold). Place ICI parce que ce point passe a CHAQUE dessin qualifiant. + if (MvppFamHold.Enabled) + { + MvppFamHold.Sample(channel); + } + + long now = Environment.TickCount64; + + // Common path: read the location elected earlier. The ADDRESS behind (stage, slot) + // may well have rotated since - that is exactly why the read is keyed on the slot. + if (_hasElection && TryReadAt(channel, _elStage, _elSlot, _elOffset, _elTransposed, out vp, out float apx, out float apy, out float apz)) + { + // [ELECT_HYST 01/08] Le siege vient de valider structurellement : il est vivant. + // Simple horodatage, toujours ecrit (aucun comportement) pour que l'alternance + // puisse armer/desarmer la tenue a chaud sans trou de mesure. + _lastSeatOkMs = now; + + // [MIRRORS M1 01/08] Le detour de peremption — voir le bloc de champs. Snapshot du + // brut du siege AVANT toute sonde d'historique (TryReadHistory ecrase _ctPending). + if (_mirrors) + { + _ctPending.AsSpan().CopyTo(_mirSeatNow); + + bool mirArmed = true; + + if (_mirrorsAb) + { + mirArmed = ((now / MirrorsAbSliceMs) & 1) == 0; + + if (!_mirAbKnown || mirArmed != _mirAbArmed) + { + _mirAbKnown = true; + _mirAbArmed = mirArmed; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP mirrors AB: cote={(mirArmed ? "ARME" : "ETEINT")} (tranches de {MirrorsAbSliceMs / 1000} s)."); + } + } + + if (mirArmed && _mirHasSeatLast && _mirSeatNow.AsSpan().SequenceEqual(_mirSeatLast)) + { + _mirStale++; + + if (_multiAddr) + { + _mirTried++; + + if (TryReadHistory(channel, out Matrix4x4 mirVp, out float mpx, out float mpy, out float mpz)) + { + _mirFound++; + + // UNE seule acceptation par image : si le miroir passe les gardes, + // c'est LUI qui est publie et on sort ; s'il est refuse (jamais vu + // en pratique : pas de 0,2-2 u sous une barre de 10-20 u), le + // chemin normal reprend juste en dessous — le doute profite a + // l'existant, la publication ne peut pas baisser. + Matrix4x4 mirCand = mirVp; + + if (AcceptRead(channel, ref mirCand, mpx, mpy, mpz, now)) + { + _mirAccepted++; + vp = mirCand; + _mirSeatNow.AsSpan().CopyTo(_mirSeatLast); + MirrorLog(now); + + if (CapTime) + { + NoteCapture(); + } + + return true; + } + + _mirRefused++; + } + else + { + _mirNone++; + } + } + } + + MirrorLog(now); + } + + // [CTXPROBE 29/07 SOIR] Lecture seule. ICI et pas plus bas : SNAPGUARD refuse les + // intruses, donc placee apres les gardes la sonde ne verrait QUE les lectures + // saines -- exactement l'inverse de ce qu'on cherche. Le pas est mesure depuis la + // derniere position ACCEPTEE, la meme reference que SNAPGUARD : les deux se + // comparent chiffre pour chiffre. Voir MvppCtxProbe. + if (MvppCtxProbe.Enabled && _hasLastGood && !_probing) + { + float cdx = apx - _lastPx; + float cdy = apy - _lastPy; + float cdz = apz - _lastPz; + + // L'age de la reference part avec la mesure : c'est lui qui dira si un "saut de + // 400 u" est une vraie intruse ou une reference perimee par une tenue longue. + MvppCtxProbe.NoteRead(channel, MathF.Sqrt(cdx * cdx + cdy * cdy + cdz * cdz), + apx, apy, apz, now - _lastAcceptMs); + } + + bool accepted = AcceptRead(channel, ref vp, apx, apy, apz, now); + + // [MIRRORS M1] Reference de peremption : le brut du siege tel que vu a la derniere + // image CAPTUREE (chemin normal). Mise a jour aussi cote eteint de l'alternance, + // pour que le cote arme reprenne toujours sur une reference fraiche. + if (_mirrors && accepted) + { + _mirSeatNow.AsSpan().CopyTo(_mirSeatLast); + _mirHasSeatLast = true; + } + + // [CAPTIME 31/07] Apres AcceptRead, jamais avant : on ne veut horodater que la + // lecture qui devient REELLEMENT la camera de l'image. + if (CapTime && accepted) + { + NoteCapture(); + } + + return accepted; + } + + if (_hasElection) + { + _readFails++; + + // [ALTPROBE 28/07] Sonde seule, armee par RYUJINX_MVPP_DUPPAIR. Ne modifie ni la + // valeur rendue, ni l'election. + if (_altProbe) + { + ProbeAlternates(channel); + } + + // [MULTIADDR 28/07] Le correctif. Mesure du 28/07, trois runs : quand la lecture + // echoue a l'adresse elue, la camera est retrouvee a une adresse DEJA ELUE dans + // 100 % des cas (jusqu'a 3 429 fois par seconde), a moins d'une unite de la + // derniere retenue (max 1,05 ; un pas ordinaire vaut 0,2, l'intrus de SNAPGUARD + // valait 400) et avec une valeur NOUVELLE. Le jeu fait tourner son bloc de + // constantes entre plusieurs tampons ; n'en garder qu'un en cache faisait perdre + // une image sur deux pendant les pans, et la reprojection ecrivait alors ZERO sur + // toute l'image. + // + // Ne devine rien : la valeur existe deja, on regardait au mauvais endroit. Elle + // passe par le MEME chemin d'acceptation que la lecture principale (structure, + // aspect, ciel, SNAPGUARD) -- aucun garde-fou n'est contourne. + if (_multiAddr && TryReadHistory(channel, out Matrix4x4 hvp, out float hpx, out float hpy, out float hpz)) + { + vp = hvp; + + return AcceptRead(channel, ref vp, hpx, hpy, hpz, now); + } + } + + // The elected location stopped validating, or there is none yet: re-elect, but never + // more than once a second - the election scans every bound buffer. + if (now - _lastElectionMs < ElectionIntervalMs) + { + return false; + } + + // [ELECT_HYST 01/08] LA DECISION VIT ICI, au declencheur, et nulle part ailleurs : le + // scrutin ne peut pas defendre le sortant (jamais candidat sur le dessin qui echoue). + // Bloquer N'ARME PAS le cooldown (_lastElectionMs inchange) : l'election s'ouvre a + // l'instant exact ou la tenue expire, pas une seconde plus tard. + if (_electHystMs > 0 && _hasElection) + { + bool armed = true; + + if (_electHystAb) + { + armed = ((now / HystAbSliceMs) & 1) == 0; + + if (!_hystAbSideKnown || armed != _hystAbArmed) + { + _hystAbSideKnown = true; + _hystAbArmed = armed; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP elect-hyst AB: cote={(armed ? "ARME" : "ETEINT")} (tranches de {HystAbSliceMs / 1000} s)."); + } + } + + if (armed) + { + bool seatAlive = now - _lastSeatOkMs < _electHystMs; + bool starving = now - _lastAcceptMs >= 2L * _electHystMs; + + if (seatAlive && !starving) + { + _hystBlocked++; + } + else if (seatAlive) + { + _hystStarved++; + } + else + { + _hystExpired++; + } + + if (now - _hystLogMs >= 5000) + { + _hystLogMs = now; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP elect-hyst: bloquees={_hystBlocked} ouvertes-siege-mort={_hystExpired} " + + $"ouvertes-famine={_hystStarved} | siege vu vivant il y a {now - _lastSeatOkMs} ms, " + + $"derniere acceptation il y a {now - _lastAcceptMs} ms."); + + _hystBlocked = 0; + _hystExpired = 0; + _hystStarved = 0; + } + + if (seatAlive && !starving) + { + return false; + } + } + } + + _lastElectionMs = now; + + if (!Elect(channel)) + { + return false; + } + + if (!TryReadAt(channel, _elStage, _elSlot, _elOffset, _elTransposed, out vp, out float epx, out float epy, out float epz)) + { + return false; + } + + // [ELECTPROBE 01/08] Lecture seule, une ligne par election. CE retour est LA SEULE + // image qui contourne AcceptRead (dette notee au journal (283)) : suspect n1 du + // « flash de temps en temps, pas juste les nuages » decrit par Alex le 01/08 + // (13 elections / 11 min dans sa session = le bon ordre de grandeur). On mesure de + // COMBIEN cette image non gardee s'ecarte de la derniere camera retenue -- pas de + // decision, pas de refus : d'abord savoir, ensuite boucher. + if (_hasLastGood) + { + float epdx = epx - _lastPx; + float epdy = epy - _lastPy; + float epdz = epz - _lastPz; + float epStep = MathF.Sqrt(epdx * epdx + epdy * epdy + epdz * epdz); + float epRot = RotGap(in vp); + + // [ELECT_WARP 01/08] LE BOUCHON. Confirme par la sonde sur la session d'Alex du + // 01/08 : l'election de 01:06 a publie CETTE image-ci a ecartRot=2,4457 (5x le + // seuil « autre camera » de MULTIROT) contre une reference vieille de 1 500 ms -- + // DLSS a recu une paire qui condense 1,5 s de mouvement en une seule image : son + // « flash de temps en temps ». Les elections saines mesurent 0,007 (separation + // 350x, pas un reglage fin). + // + // Remede : le tuyau EXISTANT TeleportSeq -> camera-warp -> reset d'historique + // (DlssUpscaler l.1862) : une coupure propre d'une image au lieu d'un champ de + // vecteurs qui condense tout l'intervalle. GAL et Vulkan INTOUCHES. + // + // ⚠️ Ce n'est PAS CUTONJUMP qui ressuscite (461-652 resets/run, mort le 27/07, + // NoteJump reste gate a part) : ici on ne peut tirer QUE sur une election + // (0,7/min sous la tenue), et seulement si l'image s'ecarte VRAIMENT -- + // rot >= 0,5 (seuil etabli de MULTIROT) ou pas >= 10 u (plancher etabli de + // SNAPFLOOR). Attendu : ~1 reset par session, visible ligne par ligne ci-dessous + // et cote DLSS (resetReason "camera-warp" dans ALIGN1). + bool epWarp = false; + + if (_electWarp && (epRot >= 0.5f || epStep >= 10f)) + { + GAL.DlssCameraState.TeleportSeq++; + epWarp = true; + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP election read: pas={epStep:0.##} u, ecartRot={epRot:0.####}, " + + $"age de la reference {Environment.TickCount64 - _lastAcceptMs} ms" + + $"{(epWarp ? $" -> RESET demande (camera-warp #{GAL.DlssCameraState.TeleportSeq})" : "")}."); + } + + return true; + } + + /// + /// Chemin d'acceptation commun a la lecture principale et au repli MULTIADDR : sondes de + /// journal, CAMGUARD, SNAPGUARD, puis memorisation de la derniere camera retenue. Extrait + /// tel quel du corps de le 28/07, sans changement de + /// comportement, pour qu'un repli ne puisse PAS contourner un garde-fou. + /// + private static bool AcceptRead(GpuChannel channel, ref Matrix4x4 vp, float apx, float apy, float apz, long now) + { + { + _reads++; + + // [ELECTROT 28/07] Meme garde que MULTIROT, mais sur la lecture PRINCIPALE. + // + // MULTIROT a supprime les intrusions qui passaient par les adresses de secours + // (valeur 1,14691, constante). Il en reste, mesurees APRES lui a 1,03 · 1,07 · + // 1,09 : elles entrent donc par la lecture du slot elu lui-meme, pas par le + // repli. Verdict d'Alex : « le clignotement des nuages n'est pas parti au + // complet, il y en a encore un peu ». + // + // Une caméra ne tourne pas de plus d'une unite entre deux images. Au-dela, ce + // n'est pas un mouvement, c'est une AUTRE matrice. + // + // Refuser une lecture n'invente rien : TryGetViewProjection rend false, MV++ ne + // publie pas cette image, la paire precedente reste en place -- bien moins nocif + // qu'un vecteur calcule entre deux cameras differentes. Le plafond de refus + // consecutifs empeche le gel qui avait tue CAMGUARD : passe ce plafond, la + // lecture est acceptee de force et une vraie coupure de scene passe toujours. + if (_electRot > 0f && _hasLastGood) + { + if (RotGapTo(in vp, in _lastGoodVp) >= _electRot) + { + if (_electRejectStreak < ElectRotMaxStreak) + { + _electRejectStreak++; + _electRejected++; + + return false; + } + + _electRejectStreak = 0; + _electForced++; + } + else + { + _electRejectStreak = 0; + } + } + + // [PROJAUDIT 27/07] Logging only, gated by RYUJINX_MVPP_PROJAUDIT=1. Reads the + // matrix that was already accepted and prints scalars; changes nothing. + MvppProjAudit.Audit(in vp, apx, apy, apz); + + // [CAMTRACE 27/07] Logging only, gated by RYUJINX_MVPP_CAMTRACE=1. The address is + // fetched only when the gate is armed: the read path stays untouched otherwise. + if (MvppCamTrace.Enabled) + { + MvppCamTrace.Note( + channel.BufferManager.GetGraphicsUniformBufferAddress(_elStage, _elSlot), + apx, apy, apz, _lastJx, _lastJy, _deJitter); + } + + // [CAMGUARD 27/07] The continuity test runs on EVERY read so the rejection rate is + // measured at full frame rate, but the substitution only happens when the gate is + // armed. Gate off => _camGuard is false => vp is untouched and this block is a + // handful of comparisons: the measurement costs nothing and changes nothing. + bool continuous = IsContinuous(in vp, apx, apy, apz); + + if (!continuous) + { + _guardRejects++; + } + + if (_camGuard && !continuous && _hasLastGood) + { + // Hand back the previous camera rather than a stranger. The frame then sees a + // still camera - zero reprojection - which is the conservative fallback the + // pipeline already applies whenever a camera is held. + vp = _lastGoodVp; + + return true; + } + + // [SNAPGUARD 27/07] Last intruder standing, and the only one distance can catch - + // because it is the only one left. Measured after the three identity fixes: two + // addresses (227D8B8800/8900) return a FROZEN position 400 units from the scene, on + // 53 reads out of 600, alternating with the real camera. It is not at the origin + // (sky rejection blind), it has the right aspect ratio (aspect lock blind), and it + // is a structurally perfect view-projection. Nothing identifies it except that the + // camera cannot be in two places at once. + // + // WHY DISTANCE WORKS *NOW* AND FAILED THIS MORNING. CAMGUARD refused 91-98 % of the + // reads because it was fighting the jitter (0.14 units, same order as real motion), + // the interface and the sky all at once, with a threshold sized for none of them. + // Those three are handled at the source now, so ordinary steps sit at 0.2 with a + // 99th percentile of 0.7, while this intruder is 400 - three orders of magnitude of + // clearance instead of none. + // + // AND IT CANNOT SEIZE UP, which is what broke both earlier attempts. A refusal is + // never final: three refusals in a row are read as "the camera really did move" - + // the scale is thrown away and relearned. An intruder alternates one frame in ten + // and is never confirmed; a genuine teleport persists and is accepted on the third + // frame. Persistence, not amplitude, is what separates them. + if (_snapGuard && !AcceptStep(apx, apy, apz)) + { + vp = _lastGoodVp; + + return true; + } + + // [SUBPROBE 29/07] Lecture seule, armee par RYUJINX_MVPP_SUBPROBE (defaut 0 = coupe). + // ICI et pas ailleurs : la matrice a passe TOUS les gardes et va devenir la camera + // retenue, tandis que l'ancienne n'est pas encore ecrasee. C'est la meme paire que + // celle mesuree cote consommateur par JUMPDIST, donc les chiffres des deux sondes + // se comparent directement. Placee plus haut, elle compterait des lectures que + // SNAPGUARD ou CAMGUARD allaient de toute facon refuser. + if (_subProbe > 0f && _hasLastGood && !_probing) + { + SubProbe(channel, in vp, apx, apy, apz, now); + } + + _lastGoodVp = vp; + _lastPx = apx; + _lastPy = apy; + _lastPz = apz; + _lastFx = MathF.Sqrt(vp.M11 * vp.M11 + vp.M12 * vp.M12 + vp.M13 * vp.M13); + _lastAcceptMs = now; + _hasLastGood = true; + + // [JITPROBE v3, 28/07] Mesure du decalage sous-pixel ICI, et pas au moment de la + // lecture brute. La v2 le faisait avant les filtres : elle rapportait donc le + // cisaillement de blocs qui allaient etre REJETES -- d'ou des valeurs de -664 px + // et +2400 px, qui ne sont pas un jitter mais d'autres matrices a la meme adresse. + // A cet endroit, la lecture a passe la structure, l'aspect, le rejet du ciel, le + // guard de saut : c'est la camera que le rendu consomme, et elle seule. + if (!_probing) + { + _lastAcceptedJx = _lastJx; + _lastAcceptedJy = _lastJy; + NoteJitter(_lastJx, _lastJy); + } + + if (GAL.MvppDev.Enabled && now - _lastLogMs >= 5000) + { + _lastLogMs = now; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP solo camera: reading stage{_elStage} cbuf{_elSlot} +0x{_elOffset:X3}" + + $"{(_elTransposed ? "^T" : "")}, {_reads} reads, {_readFails} misses, {_elections} elections, " + + $"CAMGUARD {(_camGuard ? "ARME" : "mesure seule")}: {_guardRejects} lectures etrangeres sur {_reads} " + + $"({(_reads > 0 ? 100f * _guardRejects / _reads : 0f):0.##} %)."); + _reads = 0; + _readFails = 0; + _guardRejects = 0; + } + + // [MULTIADDR] Trace de trajectoire : sert au repli a distinguer une valeur qui + // AVANCE d'une valeur perimee restee dans un autre tampon. Tenue a jour seulement + // quand le repli est arme. + if (_multiAddr) + { + _goodFlat.CopyTo(_goodFlatPrev, 0); + Flatten(in vp, _goodFlat); + + if (_goodCount < 2) + { + _goodCount++; + } + } + + return true; + } + } + + /// Reads and structurally validates the matrix at one exact location. + /// + /// [CTXPROBE 29/07 SOIR] Lecture d'ESSAI a l'emplacement elu, pour la sonde SEULE. Repond a + /// la seule question qui decide du chantier SCENEPASS : quand une passe de scene HDR est + /// branchee, la camera est-elle LISIBLE a cet instant precis ? Le recensement du soir a + /// montre que les intruses se concentrent hors de cette passe (8,1 % contre 0,08 %), mais + /// deplacer la capture n'a de sens que si la lecture y aboutit. + /// + /// NE TOUCHE A RIEN : ne publie pas, ne passe par aucun garde, ne met a jour ni la derniere + /// bonne camera ni la trace de trajectoire. Le SEUL etat que ecrive + /// est la paire de jitter (_lastJx/_lastJy), sauvee et restauree ici -- elle alimente + /// LastJitterX/Y, consomme au present, donc la laisser deriver serait un effet de bord. + /// Restent les compteurs de , purement diagnostiques. + /// + internal static bool TryProbeRead(GpuChannel channel, out float dist) + { + dist = 0f; + + if (!_hasElection) + { + return false; + } + + float savedJx = _lastJx; + float savedJy = _lastJy; + bool savedProbing = _probing; + + _probing = true; + + try + { + if (!TryReadAt(channel, _elStage, _elSlot, _elOffset, _elTransposed, + out _, out float px, out float py, out float pz)) + { + return false; + } + + if (_hasLastGood) + { + float dx = px - _lastPx; + float dy = py - _lastPy; + float dz = pz - _lastPz; + + dist = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + } + + return true; + } + finally + { + _probing = savedProbing; + _lastJx = savedJx; + _lastJy = savedJy; + } + } + + private static bool TryReadAt( + GpuChannel channel, + int stage, + int slot, + int offset, + bool transposed, + out Matrix4x4 vp, + out float px, + out float py, + out float pz) + { + vp = default; + px = py = pz = 0f; + + if (!TryReadBuffer(channel, stage, slot, out ReadOnlySpan data)) + { + return Fail(0); + } + + int idx = offset / 4; + + if (idx < 0 || idx + 16 > data.Length) + { + return Fail(1); + } + + ReadOnlySpan raw = data.Slice(idx, 16); + + if (transposed) + { + Transpose(raw, _tmp); + _tmp.CopyTo(_read.AsSpan()); + } + else + { + raw.CopyTo(_read.AsSpan()); + } + + // [CAPTIME 31/07] Snapshot du BRUT MEMOIRE. Deux raisons de copier `raw` et surtout PAS + // `_read` : (1) trois lignes plus bas RemoveJitter modifie _read EN PLACE, donc la + // matrice acceptee est POST-dejitter ; (2) _read est TRANSPOSE quand _elTransposed est + // vrai. Dans les deux cas la comparaison contre des octets relus en memoire ne + // correspondrait jamais, et la sonde rendrait "B" systematiquement, a tort. + // `raw` est la tranche memoire telle quelle : c'est la seule reference comparable. + if (CapTime || CapTimeFix || _mirrors) + { + raw.CopyTo(_ctPending); + } + + // [DEJITTER 27/07] Applied BEFORE recognition and before the position is rebuilt, so + // every downstream consumer - the structural test, the position, the matrix handed to + // the reprojection - sees one single, coherent, jitter-free camera. The measurement is + // taken either way: with the gate off the amounts are reported and nothing is changed. + float jx = 0f, jy = 0f; + + if (_deJitter || MvppCamTrace.Enabled || _jitProbe) + { + MeasureJitter(_read, out jx, out jy); + _lastJx = jx; + _lastJy = jy; + } + + if (_deJitter) + { + RemoveJitter(_read, jx, jy); + } + + ReadOnlySpan m = _read; + + if (!IsViewProj(m, out float fx, out float fy)) + { + return Fail(2); + } + + // Same rival guard as the triplet path: a SQUARE projection is the environment + // cubemap camera, otherwise structurally indistinguishable from the real one. + if (MathF.Abs(fy / fx - 1f) < 0.2f) + { + return Fail(3); + } + + // [ASPECTLOCK 27/07] Rule 1 of the election, applied to the PER-FRAME read as well. + // + // The election checks that a candidate's projection aspect matches the main render + // target's, and it was written precisely because the 1 Hz tick kept landing on the + // wrong pass. But the per-frame read never repeated that check - it validated shape + // only - so whenever the elected slot happened to hold a different projection that + // frame, it was handed straight to the reprojection. + // + // Measured on Alex's run, 600 consecutive reads: 22 of them (3.7 %, about one frame in + // 27) returned a matrix at [0 0 1.25] with an aspect of 0.5625 where the camera's is + // 1.7778 - the flat 2D interface projection. The distance between the two viewpoints + // is 273 world units, so on those frames every motion vector explodes and the whole + // image jumps. With the jitter now removed, the rest of the trace is stable, which is + // exactly why those isolated jumps became the visible defect. + // + // This is the RIGHT discriminator, and not for XC2: an interface is flat, a camera is + // perspective, and their aspects differ by construction. It uses the same 5 % tolerance + // and the same reference as the election, so it can never disagree with it. Contrast + // with CAMGUARD, which tried to separate them by DISTANCE and refused 91-98 % of the + // reads on the same run: distance cannot tell a rival camera from a moving one, an + // aspect can. + if (_aspectLock && _mainAspect > 0f && MathF.Abs(fy / fx / _mainAspect - 1f) > 0.05f) + { + return Fail(4); + } + + ViewProjPos(m, fx, fy, out px, out py, out pz); + + if (!float.IsFinite(px) || !float.IsFinite(py) || !float.IsFinite(pz) || + MathF.Abs(px) > AbsurdPos || MathF.Abs(py) > AbsurdPos || MathF.Abs(pz) > AbsurdPos) + { + return Fail(5); + } + + // [SKYREJECT 27/07] Refuse a CAMERA-RELATIVE matrix on the per-frame read. + // + // The election already rejects it - the class is named in this file's own header, "the + // CAMERA-RELATIVE sky matrix (structurally perfect, same fx, pinned to the origin)" - + // but the per-frame read never repeated that check either. Same hole as the interface + // projection, and the aspect lock cannot close this one: the sky matrix shares the real + // camera's focal lengths exactly. Only its TRANSLATION gives it away, because a + // camera-relative transform has none by construction. + // + // Measured after the first two fixes, 471 reads: FOUR of them land at the origin and + // back - [0.25 -0.02 0.21] then [-239.9 -5.3 84.0] - a 250-unit round trip in two + // frames. That is the flick that survived, and it is why removing the warp detector + // brought it straight back: the resets were masking these, not fixing them. + // + // The test is RELATIVE, so no world scale is assumed: a position within 1 % of the + // distance the accepted camera sits at is a matrix pinned to the origin, not a camera + // that walked there. If the game's real camera genuinely works near world origin, the + // reference shrinks with it and the test stays silent. + if (_skyReject && _hasLastGood) + { + float here = MathF.Sqrt(px * px + py * py + pz * pz); + float there = MathF.Sqrt(_lastPx * _lastPx + _lastPy * _lastPy + _lastPz * _lastPz); + + if (here < there * 0.01f) + { + return Fail(6); + } + } + + vp = new Matrix4x4( + m[0], m[1], m[2], m[3], + m[4], m[5], m[6], m[7], + m[8], m[9], m[10], m[11], + m[12], m[13], m[14], m[15]); + + return true; + } + + // ---- Election ---- + + private static bool Elect(GpuChannel channel) + { + // [VPSOLO_ROT 02/08] Temoin d'armement, une fois par session. + if (_rotMotion && !_rotArmedLogged) + { + _rotArmedLogged = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP solo: rotation-comme-mouvement arme (VPSOLO_ROT, echelle x" + RotMotionScale + ")."); + } + + _candCount = 0; + + for (int stage = 0; stage < Constants.ShaderStages && _candCount < MaxCandidates; stage++) + { + uint mask = channel.BufferManager.GetGraphicsUniformBufferUseMask(stage); + + for (int slot = 0; mask != 0 && _candCount < MaxCandidates; slot++, mask >>= 1) + { + if ((mask & 1) == 0) + { + continue; + } + + if (!TryReadBuffer(channel, stage, slot, out ReadOnlySpan data)) + { + continue; + } + + ulong address = channel.BufferManager.GetGraphicsUniformBufferAddress(stage, slot); + + for (int i = 0; i + 16 <= data.Length && _candCount < MaxCandidates; i += 4) + { + ReadOnlySpan raw = data.Slice(i, 16); + bool transposed = false; + + if (!IsViewProj(raw, out float fx, out float fy)) + { + Transpose(raw, _tmp); + + if (!IsViewProj(_tmp, out fx, out fy)) + { + continue; + } + + transposed = true; + } + + if (MathF.Abs(fy / fx - 1f) < 0.2f) + { + continue; + } + + ReadOnlySpan m = transposed ? _tmp : raw; + ViewProjPos(m, fx, fy, out float px, out float py, out float pz); + + if (!float.IsFinite(px) || !float.IsFinite(py) || !float.IsFinite(pz) || + MathF.Abs(px) > AbsurdPos || MathF.Abs(py) > AbsurdPos || MathF.Abs(pz) > AbsurdPos) + { + continue; + } + + // [VPSOLO_ROT 02/08] Direction de visee : rangee 3 du VP (+/-R.row2), + // normalisee. Gate ferme => zeros, jamais lus. + float cdx = 0f, cdy = 0f, cdz = 0f; + + if (_rotMotion) + { + float dn = MathF.Sqrt(m[12] * m[12] + m[13] * m[13] + m[14] * m[14]); + + if (float.IsFinite(dn) && dn > 1e-6f) + { + cdx = m[12] / dn; + cdy = m[13] / dn; + cdz = m[14] / dn; + } + } + + _cands[_candCount++] = new Candidate + { + Stage = stage, + Slot = slot, + Offset = i * 4, + Transposed = transposed, + Address = address, + Aspect = fy / fx, + X = px, + Y = py, + Z = pz, + DirX = cdx, + DirY = cdy, + DirZ = cdz, + }; + } + } + } + + if (_candCount == 0) + { + return false; + } + + // [SKYREJECT 27/07] The camera-relative matrix must be thrown out HERE too, not only on + // the per-frame read. Measured on Alex's run: the election picked + // "stage4 cbuf3 +0x180 pos=[-0.1 -0 0.1] motion=297.64" - the sky matrix, elected + // outright. It wins rule 3 for a perverse reason: pinned to the origin while the scene + // camera is 300 units away, it appears to MOVE by that whole distance every time the + // slot alternates, so it scores a huge "motion" and looks like the most active camera + // in the frame. The rule meant to find the camera crowned its impostor. + // + // The discriminator is the same one as on the read path, and just as relative: a + // candidate sitting at a hundredth of the distance of the furthest candidate is pinned + // to the origin, not standing there. If a game's real camera works near world origin, + // every candidate is near it too and the scale collapses with them - the test stays + // silent instead of rejecting everything. + float posScale = 0f; + + if (_skyReject) + { + for (int i = 0; i < _candCount; i++) + { + ref Candidate c = ref _cands[i]; + float n = MathF.Sqrt(c.X * c.X + c.Y * c.Y + c.Z * c.Z); + + if (n > posScale) + { + posScale = n; + } + } + } + + // Rule 1 + motion/straightness bookkeeping. + float bestMotion = 0f; + float bestStraight = 0f; + int considered = 0; + + for (int i = 0; i < _candCount; i++) + { + ref Candidate a = ref _cands[i]; + + if (_mainAspect > 0f && MathF.Abs(a.Aspect / _mainAspect - 1f) > 0.05f) + { + a.Motion = -1f; // marks "rejected by aspect" + + continue; + } + + if (posScale > 0f && + MathF.Sqrt(a.X * a.X + a.Y * a.Y + a.Z * a.Z) < posScale * 0.01f) + { + a.Motion = -1f; // marks "rejected as camera-relative" + + continue; + } + + considered++; + a.Motion = Track(in a, out float straight); + a.Straightness = straight; + + if (a.Motion > bestMotion) + { + bestMotion = a.Motion; + } + + if (a.Motion > 0f && straight > bestStraight) + { + bestStraight = straight; + } + } + + int best = -1; + int bestVotes = 0; + + for (int i = 0; i < _candCount; i++) + { + ref Candidate a = ref _cands[i]; + + if (a.Motion < 0f) + { + continue; + } + + // Rule 3: recent motion, and travel rather than hopping. + if (bestMotion > 0f && a.Motion < bestMotion * 0.1f) + { + continue; + } + + if (bestStraight > 0f && a.Straightness < bestStraight * 0.35f) + { + continue; + } + + // Rule 2: agreement between DISTINCT buffers (by address). + int votes = 0; + + for (int j = 0; j < _candCount; j++) + { + ref Candidate b = ref _cands[j]; + + if (b.Motion < 0f || + MathF.Abs(b.X - a.X) > PosAgreeEps || + MathF.Abs(b.Y - a.Y) > PosAgreeEps || + MathF.Abs(b.Z - a.Z) > PosAgreeEps) + { + continue; + } + + bool duplicate = false; + + for (int k = 0; k < j; k++) + { + if (_cands[k].Motion >= 0f && _cands[k].Address == b.Address && _cands[k].Offset == b.Offset && + MathF.Abs(_cands[k].X - a.X) <= PosAgreeEps && + MathF.Abs(_cands[k].Y - a.Y) <= PosAgreeEps && + MathF.Abs(_cands[k].Z - a.Z) <= PosAgreeEps) + { + duplicate = true; + + break; + } + } + + if (!duplicate) + { + votes++; + } + } + + if (votes < 2) + { + continue; + } + + if (votes > bestVotes) + { + bestVotes = votes; + best = i; + } + } + + // Nothing may be elected before the evidence exists: without an aspect reference or + // without a single moving candidate, every rule above is inert and an election taken + // then survives long after the evidence arrives (measured, and it cost a whole run). + if (best < 0 || _mainAspect <= 0f || bestMotion <= 0f) + { + return false; + } + + ref Candidate w = ref _cands[best]; + + bool changed = !_hasElection || _elStage != w.Stage || _elSlot != w.Slot || + _elOffset != w.Offset || _elTransposed != w.Transposed; + + _hasElection = true; + _elStage = w.Stage; + _elSlot = w.Slot; + _elOffset = w.Offset; + _elTransposed = w.Transposed; + _elections++; + + // [v1] Seul le VAINQUEUR de l'election entre dans l'historique. La v2 y ajoutait aussi + // tous les candidats qui s'accordent en position : retire le 28/07, elle a ramene le + // flick ciel->montagnes sans rien apporter aux mesures. + PushHistory(w.Stage, w.Slot, w.Offset, w.Transposed); + + if (changed) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP solo camera: elected stage{w.Stage} cbuf{w.Slot} +0x{w.Offset:X3}" + + $"{(w.Transposed ? "^T" : "")} @{w.Address:X10} pos=[{w.X:0.#} {w.Y:0.#} {w.Z:0.#}] " + + $"aspect={w.Aspect:0.###} motion={w.Motion:0.##} straight={w.Straightness:0.##} " + + $"({bestVotes} agreeing buffers of {considered} at the right aspect, election #{_elections})."); + } + + return true; + } + + private static float Track(in Candidate c, out float straightness) + { + straightness = 1f; + + int idx = -1; + + for (int i = 0; i < _trackCount; i++) + { + if (_tracks[i].Address == c.Address && _tracks[i].Offset == c.Offset) + { + idx = i; + + break; + } + } + + if (idx < 0) + { + if (_trackCount >= _tracks.Length) + { + return 0f; + } + + idx = _trackCount++; + _tracks[idx] = new Tracked { Address = c.Address, Offset = c.Offset }; + } + + ref Tracked t = ref _tracks[idx]; + + // Halved every election: a matrix that moved once at load time and then froze fades + // under the bar in ~8 elections instead of holding the seat for ever. + t.Motion *= 0.5f; + + float step = 0f; + + if (t.HasLast) + { + float dx = c.X - t.LastX; + float dy = c.Y - t.LastY; + float dz = c.Z - t.LastZ; + step = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + + if (!float.IsFinite(step) || step > TeleportEps) + { + step = 0f; + } + + if (step > t.Motion) + { + t.Motion = step; + } + } + + // [VPSOLO_ROT 02/08] Pas de rotation, signe-insensible (rangee 3 = +/-R.row2). Nourrit + // t.Motion seulement — le ring et PathLength restent nourris par la POSITION (la + // rectitude est un concept de trajectoire ; voir l'exemption plus bas). + float rotStep = 0f; + + if (_rotMotion && t.HasLastDir) + { + float rdx = c.DirX - t.LastDirX; + float rdy = c.DirY - t.LastDirY; + float rdz = c.DirZ - t.LastDirZ; + float sdx = c.DirX + t.LastDirX; + float sdy = c.DirY + t.LastDirY; + float sdz = c.DirZ + t.LastDirZ; + + float dirStep = MathF.Min( + MathF.Sqrt(rdx * rdx + rdy * rdy + rdz * rdz), + MathF.Sqrt(sdx * sdx + sdy * sdy + sdz * sdz)); + + if (float.IsFinite(dirStep)) + { + rotStep = dirStep * RotMotionScale; + + if (rotStep > t.Motion) + { + t.Motion = rotStep; + } + } + } + + int slot = idx * RingSize; + + if (t.RingCount == RingSize) + { + t.PathLength -= _ringStep[slot + t.RingHead]; + } + + _ringX[slot + t.RingHead] = c.X; + _ringY[slot + t.RingHead] = c.Y; + _ringZ[slot + t.RingHead] = c.Z; + _ringStep[slot + t.RingHead] = step; + t.PathLength += step; + t.RingHead = (t.RingHead + 1) % RingSize; + + if (t.RingCount < RingSize) + { + t.RingCount++; + } + + if (t.RingCount >= 3 && t.PathLength > 1e-4f) + { + int oldest = (t.RingHead - t.RingCount + RingSize * 2) % RingSize; + float nx = c.X - _ringX[slot + oldest]; + float ny = c.Y - _ringY[slot + oldest]; + float nz = c.Z - _ringZ[slot + oldest]; + + straightness = MathF.Sqrt(nx * nx + ny * ny + nz * nz) / t.PathLength; + } + + // [VPSOLO_ROT 02/08] Une camera dont la vie est ROTATIONNELLE (position quasi immobile, + // cas monde-relatif) aurait une rectitude de bruit sur un chemin microscopique — la + // regle 3 la veto-erait pour une trajectoire qu'elle n'a pas. Quand la rotation domine, + // la rectitude ne s'applique pas. + if (_rotMotion && rotStep > step) + { + straightness = 1f; + } + + t.LastX = c.X; + t.LastY = c.Y; + t.LastZ = c.Z; + t.HasLast = true; + + if (_rotMotion) + { + t.LastDirX = c.DirX; + t.LastDirY = c.DirY; + t.LastDirZ = c.DirZ; + t.HasLastDir = true; + } + + return t.Motion; + } + + private static bool TryReadBuffer(GpuChannel channel, int stage, int slot, out ReadOnlySpan data) + { + data = default; + + ulong address = channel.BufferManager.GetGraphicsUniformBufferAddress(stage, slot); + int size = Math.Min(channel.BufferManager.GetGraphicsUniformBufferSize(stage, slot), MaxBytesPerBuffer); + + if (address == 0 || address == ulong.MaxValue || size < 64) + { + return false; + } + + try + { + data = MemoryMarshal.Cast(channel.MemoryManager.Physical.GetSpan(address, size)); + } + catch + { + return false; + } + + return true; + } + + // ---- Structural recognition (validated 21/07: 3000/3000 synthetic cameras accepted with + // exact position recovery, 1000/1000 bare projections rejected, 0 false positives over + // 6000 bone/noise/absurd matrices; then confirmed in-game on XC2 and TOTK). ---- + + /// + /// VP = P x V with P perspective (row 3 = [0,0,+/-1,0]) and V rigid expands to + /// row0.xyz = fx*R.row0 row1.xyz = fy*R.row1 row2.xyz = A*R.row2 row3.xyz = +/-R.row2 + /// so the shape is fully constrained without ever seeing P or V: row3 is a unit vector, + /// rows 0/1/3 are mutually orthogonal, and row2 is collinear with row3. A skinning bone + /// matrix fails immediately: its row 3 is [0,0,0,1], xyz norm 0. + /// + /// + /// [CAPTIME_AB 31/07] Frontiere d'image. Appelee au meme endroit que MvppScenePass.OnFrame + /// et MvppLdrSkip.OnFrame -- le seul endroit ou l'on sait si l'image a fini par obtenir une + /// camera, donc le seul endroit ou "images sans capture" est connaissable. + /// N'ecrit que dans les compteurs de l'experience. + /// + public static void OnPresentBoundary(bool captured) + { + // [CAPTIME_MAX] Le filet se renseigne TOUJOURS, meme hors alternance : c'est lui qui + // garantit qu'on ne peut pas rater deux images de suite a cause du gate. + _ctPrevFrameMissed = !captured; + + if (_abBlock <= 0) + { + return; + } + + int side = _abArmed ? 1 : 0; + + _abFrames[side]++; + + if (captured) + { + _abCaptures[side]++; + } + + _abPresents++; + + bool armedNext = (_abPresents / _abBlock) % 2 == 1; + + if (armedNext == _abArmed) + { + return; + } + + // Changement de bloc : on publie le cote qui se termine, puis on repart a zero pour lui. + long f = _abFrames[side]; + long fails = 0; + + for (int i = 0; i < FailReasons; i++) + { + fails += _fail[i]; + } + + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"CAPTIME-AB [{(side == 1 ? "ARME " : "ETEINT")}] presents={f} " + + $"capture={(f > 0 ? 100f * _abCaptures[side] / f : 0f):0.#} % " + + $"sansCapture={f - _abCaptures[side]} " + + $"deplacee={(f > 0 ? 100f * _abMoved[side] / f : 0f):0.#} % " + + $"dessinsMoyens={(f > 0 ? (float)_abPreSum[side] / f : 0f):0.#} " + + $"A={_abA[side]} B={_abB[side]} " + + $"tauxA={(_abA[side] + _abB[side] > 0 ? 100f * _abA[side] / (_abA[side] + _abB[side]) : 0f):0.#} % " + + $"transitions={_abTrans[side]} " + + $"echecs+={fails - _abFailAtSwitch[side]}"); + + _abFailAtSwitch[side] = fails; + _abFrames[side] = 0; + _abCaptures[side] = 0; + _abMoved[side] = 0; + _abPreSum[side] = 0; + _abA[side] = 0; + _abB[side] = 0; + _abTrans[side] = 0; + _abArmed = armedNext; + } + + /// + /// [CAPTIME_FIX 31/07] Vrai si le lieu elu porte encore, BIT POUR BIT, la valeur brute de la + /// derniere lecture acceptee -- donc rien de neuf a capturer sur ce dessin. + /// + /// Lecture pure : n'appelle ni TryReadAt, ni AcceptRead, ni l'election, et n'incremente + /// aucun compteur d'echec. Retourne FAUX des qu'on ne peut pas comparer (pas d'election, + /// slot non lie, forme invalide) : dans ce cas le chemin normal reprend la main et le + /// comportement est celui d'aujourd'hui. Le doute profite toujours au comportement existant. + /// + public static bool StaleAtElectedLocation(GpuChannel channel) + { + // [CAPTIME_WHY 31/07] Compteurs de motifs, AUX POINTS DE DECISION DEJA EXISTANTS. + // Aucune lecture nouvelle, aucun comportement change : ils repondent a la seule question + // "pourquoi le gate ne s'arme-t-il jamais" (run du 31/07 : deplacee = 0 % dans TOUS les + // blocs armes, donc l'alternance n'a rien teste). Sans eux je devinerais, et j'ai deja + // casse trois instruments aujourd'hui. + _whyCalled++; + + // [CAPTIME_MAX] K = 0 => gate eteint, la forme non bornee ne peut pas etre lancee. + if (!FixActive || _ctMaxSkip <= 0) + { + _whyOff++; + + return false; + } + + _whyArmedCall++; + + // [CAPTIME_MAX] La borne. Au-dela on capture ce qu'il y a : 1 capture par image, + // garanti par construction. + if (_ctPreDraw >= _ctMaxSkip) + { + _whyDeadline++; + + return false; + } + + // [CAPTIME_MAX] Le filet : jamais deux images ratees d'affilee a cause du gate. + if (_ctPrevFrameMissed) + { + _whyNetted++; + + return false; + } + + if (!_hasElection) + { + _whyNoElection++; + + return false; + } + + if (!_ctHasCapture) + { + _whyNoRef++; + + return false; + } + + ulong address = channel.BufferManager.GetGraphicsUniformBufferAddress(_elStage, _elSlot); + int size = channel.BufferManager.GetGraphicsUniformBufferSize(_elStage, _elSlot); + + if (address == 0 || address == ulong.MaxValue || _elOffset + 64 > size) + { + _whyUnbound++; + + return false; + } + + _whyReadable++; + + ReadOnlySpan block = channel.MemoryManager.Physical.GetSpan(address + (ulong)_elOffset, 64); + ReadOnlySpan vals = System.Runtime.InteropServices.MemoryMarshal.Cast(block); + + if (!IsViewProj(vals, out _, out _)) + { + _whyBadShape++; + + return false; + } + + // Contre _ctCaptured (la derniere lecture ACCEPTEE) et surtout pas _ctPending, qui est + // ecrase a chaque TryReadAt, accepte ou non. + if (!vals.SequenceEqual(_ctCaptured)) + { + _whyDiffers++; + + return false; + } + + _whyArmed++; + _ctPreDraw++; + + return true; + } + + /// + /// [CAPTIME_WHY 31/07] Table de verite du gate, cadence 5 s. Lecture seule. + /// + public static void LogWhy() + { + if (!CapTime && !CapTimeFix && _abBlock <= 0) + { + return; + } + + if (Environment.TickCount64 - _whyLogMs < 5000) + { + return; + } + + _whyLogMs = Environment.TickCount64; + + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"CAPTIME-WHY appels={_whyCalled} (eteint={_whyOff} arme={_whyArmedCall}) " + + $"| sansElection={_whyNoElection} sansReference={_whyNoRef} " + + $"slotNonLie={_whyUnbound} lisible={_whyReadable} " + + $"formeInvalide={_whyBadShape} valeurDifferente={_whyDiffers} " + + $"borne={_whyDeadline} filet={_whyNetted} " + + $"ARME={_whyArmed} | K={_ctMaxSkip} elu=stage{_elStage} cbuf{_elSlot} +0x{_elOffset:X3} " + + $"transpose={(_elTransposed ? 1 : 0)}"); + + _whyCalled = 0; + _whyOff = 0; + _whyArmedCall = 0; + _whyNoElection = 0; + _whyNoRef = 0; + _whyUnbound = 0; + _whyReadable = 0; + _whyBadShape = 0; + _whyDiffers = 0; + _whyArmed = 0; + _whyDeadline = 0; + _whyNetted = 0; + } + + /// + /// [CAPTIME 31/07] Appelee sur les dessins qualifiants QUI SUIVENT la capture de l'image. + /// Lecture pure : resout l'adresse derriere le slot elu, copie 64 octets, deduplique. + /// N'appelle ni TryReadAt, ni AcceptRead, ni l'election. N'ecrit que dans les champs _ct*. + /// + public static void NoteLateDraw(GpuChannel channel) + { + if (!CapTime || !_hasElection || !_ctHasCapture) + { + return; + } + + _ctDraw++; + + ulong address = channel.BufferManager.GetGraphicsUniformBufferAddress(_elStage, _elSlot); + int size = channel.BufferManager.GetGraphicsUniformBufferSize(_elStage, _elSlot); + + // Le slot elu n'est pas lie a tous les dessins ("buffer absent" est un motif d'echec + // documente). Compte SEPAREMENT : sans ca, "aucune valeur vue" serait indiscernable de + // "on n'a jamais regarde", et le verdict B serait ininterpretable. + if (address == 0 || address == ulong.MaxValue || _elOffset + 64 > size) + { + _ctMissing++; + + return; + } + + _ctBound++; + + ReadOnlySpan block = channel.MemoryManager.Physical.GetSpan(address + (ulong)_elOffset, 64); + ReadOnlySpan vals = System.Runtime.InteropServices.MemoryMarshal.Cast(block); + + // IsViewProj en FILTRE seulement (fonction pure, verifiee sans ecriture d'etat) : evite + // de remplir les 16 emplacements avec les octets d'un tampon etranger. Le verdict, lui, + // ne repose que sur l'egalite bit-exacte plus bas. + if (!IsViewProj(vals, out _, out _)) + { + return; + } + + for (int i = 0; i < _ctCount; i++) + { + if (vals.SequenceEqual(_ctHist.AsSpan(i * 16, 16))) + { + return; + } + } + + if (_ctCount < CtMax) + { + vals.CopyTo(_ctHist.AsSpan(_ctCount * 16, 16)); + _ctHistDraw[_ctCount] = _ctDraw; + _ctCount++; + } + } + + /// + /// [CAPTIME 31/07] Appelee quand une lecture vient d'etre ACCEPTEE comme camera de l'image. + /// L'historique _ctHist contient, a cet instant precis, les valeurs vues APRES la capture de + /// l'image PRECEDENTE -- rien n'y a ete ajoute depuis. Aucune accroche de fin d'image n'est + /// donc necessaire : la rotation se fait ici. + /// + private static void NoteCapture() + { + if (_ctHasCapture) + { + int seenAt = -1; + + for (int i = 0; i < _ctCount; i++) + { + if (_ctPending.AsSpan().SequenceEqual(_ctHist.AsSpan(i * 16, 16))) + { + seenAt = _ctHistDraw[i]; + + break; + } + } + + // A = deja vue pendant l'image precedente => on a capture trop tot. + // B = jamais vue ALORS QU'ON REGARDAIT (bound > 0) => le jeu ne l'avait pas produite. + // Couverture nulle (bound == 0) => echantillon a ECARTER, la sonde n'a rien pu voir. + // [CAPTIME_AB] Le verdict part dans les compteurs DU BLOC EN COURS. Les transitions + // A<->B sont comptees ici, sur la suite des images, cote par cote. + if (_abBlock > 0) + { + int side = _abArmed ? 1 : 0; + char v = seenAt >= 0 ? 'A' : _ctBound > 0 ? 'B' : '?'; + + if (v == 'A') + { + _abA[side]++; + } + else if (v == 'B') + { + _abB[side]++; + } + + if (v != '?') + { + if (_abLastVerdict != '\0' && _abLastVerdict != v) + { + _abTrans[side]++; + } + + _abLastVerdict = v; + } + + if (_ctPreDraw > 0) + { + _abMoved[side]++; + } + + _abPreSum[side] += _ctPreDraw; + } + + string verdict = seenAt >= 0 ? "A (deja vue -> capture trop tot)" + : _ctBound > 0 ? "B (jamais vue -> le jeu ne l'avait pas produite)" + : "ECARTE (couverture nulle)"; + + // En alternance on ne journalise pas image par image : seuls les resumes de bloc + // comptent, et 30 lignes par seconde noieraient le journal. + if (_abBlock == 0) + { + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"CAPTIME frame={_ctFrameId} verdict={verdict} vueAuDessin={seenAt} " + + $"distinctes={_ctCount} slotLie={_ctBound} slotAbsent={_ctMissing} " + + $"dessinsApresCapture={_ctDraw}"); + } + } + + // [CAPTIME_FIX] De combien la capture a-t-elle ete deplacee, et sur quelle proportion + // d'images. Cadence 5 s : c'est un taux, pas un evenement. + if (CapTimeFix) + { + _ctTotalFrames++; + _ctPreDrawSum += _ctPreDraw; + + if (_ctPreDraw > 0) + { + _ctMovedFrames++; + } + + if (Environment.TickCount64 - _ctMovedLogMs >= 5000) + { + _ctMovedLogMs = Environment.TickCount64; + + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"CAPTIME-FIX: capture deplacee sur {_ctMovedFrames}/{_ctTotalFrames} images " + + $"({(_ctTotalFrames > 0 ? 100f * _ctMovedFrames / _ctTotalFrames : 0f):0.#} %), " + + $"dessins laisses passer: moyenne {(_ctTotalFrames > 0 ? (float)_ctPreDrawSum / _ctTotalFrames : 0f):0.#}, " + + $"dernier {_ctPreDraw}."); + + _ctMovedFrames = 0; + _ctTotalFrames = 0; + _ctPreDrawSum = 0; + } + } + + // [RANKPROBE 01/08] Verdict par image, sur l'etat AVANT rotation : _ctCaptured = la + // capture de l'image ecoulee, _ctHist = les distinctes vues apres elle, avec leur rang. + // rang = premier dessin ou une valeur != la capture est apparue ; + // JAMAIS = aucune valeur differente ALORS QU'ON REGARDAIT (couverture > 0) ; + // ECARTE = couverture nulle (slot jamais lie) — indiscernable de « pas produit » ; + // SATURE = table pleine (CtMax) : le rang a pu etre manque, compte a part pour ne + // jamais gonfler JAMAIS en silence. + if (_rankProbe && _ctHasCapture) + { + int firstNew = -1; + + for (int i = 0; i < _ctCount; i++) + { + if (!_ctHist.AsSpan(i * 16, 16).SequenceEqual(_ctCaptured)) + { + firstNew = _ctHistDraw[i]; + + break; + } + } + + _rkFrames++; + _rkDrawSum += _ctDraw; + + if (firstNew >= 0) + { + int b = firstNew <= 40 ? 0 : firstNew <= 60 ? 1 : firstNew <= 80 ? 2 + : firstNew <= 120 ? 3 : firstNew <= 160 ? 4 : 5; + + _rkBuckets[b]++; + } + else if (_ctCount >= CtMax) + { + _rkSaturated++; + } + else if (_ctBound > 0) + { + _rkNever++; + } + else + { + _rkNoCover++; + } + + long rkNow = Environment.TickCount64; + + if (rkNow - _rkLogMs >= 5000) + { + _rkLogMs = rkNow; + + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"MVPP rang-arrivee: images={_rkFrames} | <=40:{_rkBuckets[0]} 41-60:{_rkBuckets[1]} " + + $"61-80:{_rkBuckets[2]} 81-120:{_rkBuckets[3]} 121-160:{_rkBuckets[4]} >160:{_rkBuckets[5]} " + + $"| JAMAIS:{_rkNever} ecartees:{_rkNoCover} saturees:{_rkSaturated} " + + $"| dessins-scannes moyen={(_rkFrames > 0 ? (float)_rkDrawSum / _rkFrames : 0f):0.#} " + + $"| elu=stage{_elStage} cbuf{_elSlot} +0x{_elOffset:X3}"); + + Array.Clear(_rkBuckets); + _rkNever = 0; + _rkNoCover = 0; + _rkSaturated = 0; + _rkFrames = 0; + _rkDrawSum = 0; + } + } + + _ctPending.AsSpan().CopyTo(_ctCaptured); + _ctHasCapture = true; + _ctFrameId++; + _ctCount = 0; + _ctBound = 0; + _ctMissing = 0; + _ctDraw = 0; + _ctPreDraw = 0; + } + + private static bool IsViewProj(ReadOnlySpan m, out float fx, out float fy) + { + fx = 0f; + fy = 0f; + + float n3 = MathF.Sqrt(m[12] * m[12] + m[13] * m[13] + m[14] * m[14]); + + if (MathF.Abs(n3 - 1f) > 0.02f) + { + return false; + } + + float n0 = MathF.Sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]); + float n1 = MathF.Sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]); + + if (!float.IsFinite(n0) || !float.IsFinite(n1) || + n0 < 0.05f || n1 < 0.05f || n0 > 50f || n1 > 50f) + { + return false; + } + + float d01 = (m[0] * m[4] + m[1] * m[5] + m[2] * m[6]) / (n0 * n1); + float d03 = (m[0] * m[12] + m[1] * m[13] + m[2] * m[14]) / n0; + float d13 = (m[4] * m[12] + m[5] * m[13] + m[6] * m[14]) / n1; + + if (MathF.Abs(d01) > 0.02f || MathF.Abs(d03) > 0.02f || MathF.Abs(d13) > 0.02f) + { + return false; + } + + float n2 = MathF.Sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10]); + + if (n2 > 1e-4f) + { + float cx = m[9] * m[14] - m[10] * m[13]; + float cy = m[10] * m[12] - m[8] * m[14]; + float cz = m[8] * m[13] - m[9] * m[12]; + + if (MathF.Sqrt(cx * cx + cy * cy + cz * cz) / n2 > 0.02f) + { + return false; + } + } + + // A BARE perspective projection satisfies everything above, because a projection is a + // view-projection whose view is the identity. Correct, and useless: it carries no + // camera. Measured on XC2, those all reported campos [0 0 0]. + bool rotIsIdentity = + MathF.Abs(m[0] / n0 - 1f) < 1e-3f && MathF.Abs(m[1]) < 1e-3f && MathF.Abs(m[2]) < 1e-3f && + MathF.Abs(m[4]) < 1e-3f && MathF.Abs(m[5] / n1 - 1f) < 1e-3f && MathF.Abs(m[6]) < 1e-3f && + MathF.Abs(m[12]) < 1e-3f && MathF.Abs(m[13]) < 1e-3f; + + bool noTranslation = + MathF.Abs(m[3]) < 1e-3f && MathF.Abs(m[7]) < 1e-3f && MathF.Abs(m[15]) < 1e-3f; + + if (rotIsIdentity && noTranslation) + { + return false; + } + + fx = n0; + fy = n1; + + return true; + } + + /// + /// Camera position from a standalone view-projection: rebuild the rigid rows + /// (R.row0 = row0.xyz/fx, R.row1 = row1.xyz/fy, R.row2 = row3.xyz), take + /// t = (row0.w/fx, row1.w/fy, row3.w) and return -R^T t. The sign ambiguity on R.row2 + /// cancels because it appears twice in the product. + /// + private static void ViewProjPos(ReadOnlySpan m, float fx, float fy, out float x, out float y, out float z) + { + float r00 = m[0] / fx, r01 = m[1] / fx, r02 = m[2] / fx; + float r10 = m[4] / fy, r11 = m[5] / fy, r12 = m[6] / fy; + float r20 = m[12], r21 = m[13], r22 = m[14]; + + float tx = m[3] / fx; + float ty = m[7] / fy; + float tz = m[15]; + + x = -(r00 * tx + r10 * ty + r20 * tz); + y = -(r01 * tx + r11 * ty + r21 * tz); + z = -(r02 * tx + r12 * ty + r22 * tz); + } + + /// + /// [CAMGUARD 27/07] Is this read the SAME camera as the previous one, or a different + /// viewpoint that happens to sit in the same slot this frame? + /// + /// Two tests, both relative, no world units baked in beyond the jump budget itself: + /// - the camera cannot teleport. MaxJump is 25 units in ONE frame = 1500 units/second + /// at 60 fps, against a measured median of 5.8 units per SECOND on Alex's run, so + /// real movement - including pushing the view towards or away from the character - + /// stays two orders of magnitude below the bar; + /// - the focal length cannot step. A zoom is continuous; a different camera is not. + /// + /// The first read of a session has nothing to compare against and is always accepted. + /// + private static bool IsContinuous(in Matrix4x4 vp, float px, float py, float pz) + { + if (!_hasLastGood) + { + return true; + } + + float dx = px - _lastPx; + float dy = py - _lastPy; + float dz = pz - _lastPz; + + // How much travel this read is allowed to carry, given how long ago the last one was + // accepted. Starved periods legitimately deliver a whole second of movement at once. + float dtSec = MathF.Max(0f, (Environment.TickCount64 - _lastAcceptMs) / 1000f); + float budget = MathF.Max(MinBudget, MathF.Min(dtSec, CatchUpCapSec) * MaxSpeed); + float dist = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + float fx = MathF.Sqrt(vp.M11 * vp.M11 + vp.M12 * vp.M12 + vp.M13 * vp.M13); + + if (dist > budget) + { + LogReject("distance", dist, budget, fx, dtSec); + + return false; + } + + if (_lastFx > 1e-6f && MathF.Abs(fx / _lastFx - 1f) > MaxFocalDrift) + { + LogReject("focale", dist, budget, fx, dtSec); + + return false; + } + + return true; + } + + /// + /// [SNAPGUARD 27/07] True when this read continues the camera's trajectory, false when it + /// belongs to somebody else. See the call site for why distance is the right test here and + /// was the wrong one this morning. + /// + private static bool AcceptStep(float px, float py, float pz) + { + if (!_hasLastGood) + { + return true; + } + + float dx = px - _lastPx; + float dy = py - _lastPy; + float dz = pz - _lastPz; + float step = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + + if (!float.IsFinite(step)) + { + return false; + } + + // [SNAPLEARN 29/07] LA REPARATION DE L'ESTIMATEUR. Un pas EXACTEMENT NUL n'apprend rien + // sur l'echelle des mouvements de la camera : il est accepte, et on n'y touche pas. + // + // POURQUOI. Mesure du 29/07 09h : la barre du garde etait tombee a `saut > 0`, et il + // refusait des pas de 0,03 / 0,06 / 0,26 unite -- des mouvements parfaitement ordinaires + // -- ~90 fois par tranche de 5 s. Verdict d'Alex : les textures lointaines se mettent a + // NAGER (camera tenue en permanence => vecteurs perimes). + // Le mecanisme : quand la camera est posee, le pas vaut exactement 0 ; ces zeros sont + // appris et, la fenetre ne faisant que 120 entrees, ils EVINCENT les vrais pas. `scale` + // tombe a 0, donc `step <= scale * SnapFactor` devient `step <= 0` et TOUT est refuse. + // ⚠️ Et ce qui sortait le garde de cet etat, c'etait l'effacement `_accCount = 0` de la + // branche de reddition (8 passages libres, la fenetre se remplit de vrais pas). Cet + // effacement n'etait donc pas SEULEMENT une fuite : c'etait aussi le seul mecanisme de + // RECUPERATION -- le retirer sans reparer l'estimateur (SNAPKEEP) a fige la barre a + // zero pour de bon. C'est pour ca que SNAPKEEP a ete rejete en deux minutes. + // + // 🔒 PROPRIETE DE SURETE, ET C'EST ELLE QUI REND CE CHANGEMENT SUR : ne plus apprendre + // les zeros ne peut qu'AUGMENTER `scale` (un maximum sur une fenetre dont on retire des + // entrees nulles qui evincaient des entrees utiles). Une barre plus haute refuse MOINS. + // ⇒ ce correctif ne peut JAMAIS produire plus de refus qu'aujourd'hui, donc il ne peut + // ni figer la camera, ni faire nager les textures. Le pire cas est « aussi permissif + // qu'avant », jamais plus strict. + // + // Le compteur d'echauffement ne bouge pas non plus : un zero n'est pas une preuve que la + // camera bouge, il ne doit donc pas rapprocher le garde de son etat arme. + if (_snapLearn && step <= 0f) + { + _consecutiveRejects = 0; + + return true; + } + + float scale = 0f; + + for (int i = 0; i < _accCount; i++) + { + if (_accSteps[i] > scale) + { + scale = _accSteps[i]; + } + } + + // [SNAPFLOOR 29/07] Une barre doit etre SIGNIFICATIVE, pas seulement non nulle. Voir le + // champ _snapFloor : la mesure d'ou sort le chiffre, et l'encadrement des DEUX bornes. + // Une seule ligne, aucune branche qui accepte sans juger. + if (_snapFloor > 0f && scale < _snapFloor) + { + scale = _snapFloor; + _snapFloorUsed++; + } + + // Below the bar, or nothing learned yet: this is the camera doing camera things. + if (_accCount < 8 || step <= scale * SnapFactor) + { + _accSteps[_accHead] = step; + _accHead = (_accHead + 1) % SnapWindow; + + if (_accCount < SnapWindow) + { + _accCount++; + } + + _consecutiveRejects = 0; + + return true; + } + + // Over the bar. Refuse - but count, because a real displacement will insist. + // + // [SNAPHOLD 29/07] Le seul changement : COMBIEN de refus avant de ceder. Voir le champ + // _snapHold. Toute lecture proche de l'ancien siege est acceptee plus haut et remet ce + // compteur a zero -- exiger un long compte revient donc a dire « l'ancien siege n'a + // plus ete vu depuis longtemps », ce qui est la definition d'une vraie teleportation. + int confirm = _snapHold > 0 ? _snapHold : SnapConfirm; + + _snapStreakMax = Math.Max(_snapStreakMax, ++_consecutiveRejects); + + if (_consecutiveRejects < confirm) + { + // [27/07] Reported because the trace alone cannot answer the only question that + // matters: it logs BEFORE this filter, so a refused intruder still appears in it. + // Without this line "51 intruders in the log" and "51 intruders on screen" are + // indistinguishable - the same trap as every probe that was too quiet to be + // trusted today. + _snapRefusals++; + + long snapNow = Environment.TickCount64; + + if (snapNow - _lastSnapLogMs >= 5000) + { + _lastSnapLogMs = snapNow; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP snapguard: {_snapRefusals} lectures refusees (saut > {scale * SnapFactor:0.##}), " + + $"derniere = {step:0.##} depuis [{_lastPx:0.#} {_lastPy:0.#} {_lastPz:0.#}]" + + $" | plafond {confirm}, adoptions-forcees {_snapForced}, salve-max {_snapStreakMax}" + + $", echelle {scale:0.###} sur {_accCount} pas appris" + + $"{(_snapFloor > 0f ? $" | SNAPFLOOR {_snapFloor:0.###} (barre plancher {_snapFloor * SnapFactor:0.#} u) : {_snapFloorUsed} fois en vigueur" : "")}" + + $"{(scale <= 0f ? " <-- BARRE A ZERO : le garde refuse tout" : "")}" + + $"{(_snapHold > 0 && _snapStreakMax >= confirm ? " <-- COLLE AU PLAFOND : trop bas" : "")}."); + _snapRefusals = 0; + _snapForced = 0; + _snapStreakMax = 0; + } + + return false; + } + + // It insisted. This is movement, not an impostor: forget the old scale entirely and + // learn again from here. This branch is what makes the guard unable to jam. + _consecutiveRejects = 0; + _snapForced++; + + // [SNAPKEEP 29/07] L'effacement de l'echelle est la seconde fuite mesuree : il ouvre 8 + // lectures sans controle (`if (_accCount < 8 || ...) return true`), par ou rentre le + // VOYAGE RETOUR de la salve. Un pas ordinaire vaut 0,2 u que la camera se soit + // teleportee ou non -- il n'y a rien a reapprendre, et tout a perdre. + if (!_snapKeep) + { + _accCount = 0; + _accHead = 0; + } + + return true; + } + + /// + /// [CUTONJUMP 27/07] Detects a camera warp and tells the upscaler its history is stale. + /// Gate: RYUJINX_MVPP_CUTONJUMP=1, OFF by default. + /// + /// WHY IT EXISTS. With the jitter removed and the interface projection refused, Alex's + /// 14-minute run left EXACTLY ONE aberrant step out of 600 reads - and he saw EXACTLY ONE + /// flick. That step is 829 world units and lands on a position where the next 170 reads sit + /// still: the game genuinely teleported him to another zone. The camera did not lie, so + /// there is nothing to refuse here. What is wrong is reprojecting the new view from the old + /// one: for one frame the vectors describe a journey nobody made. + /// + /// THE SCALE COMES FROM THE CAMERA ITSELF, not from a constant. Anything the camera has + /// ever done under continuous movement is remembered as _maxStep; a warp is declared only + /// beyond FIFTY times that. Measured on the run: ordinary steps peak around 0.3 and the + /// warp is 829, four orders of magnitude apart, so the factor is not a tuned value - any + /// number between 10 and 1000 gives the same verdict. No world units are baked in, which is + /// what CAMGUARD got wrong, and nothing can fire before real movement has been observed: + /// while _maxStep is still zero the test is inert by construction. + /// + /// AND IT IS NOT THE RULE THAT REGRESSED TWICE. SCENECUT_MAX_MOTION thresholds the FRACTION + /// OF THE IMAGE that moves, which is why slow pans fell under it and ghosted. This reads a + /// camera displacement that no controller input can produce. Different quantity, different + /// failure mode. + /// + private static void NoteJump(float px, float py, float pz) + { + if (!_cutOnJump || !_hasLastGood) + { + return; + } + + float dx = px - _lastPx; + float dy = py - _lastPy; + float dz = pz - _lastPz; + float step = MathF.Sqrt(dx * dx + dy * dy + dz * dz); + + if (!float.IsFinite(step)) + { + return; + } + + // EVERY step enters the window, warps included. Excluding them is what broke the first + // version: the reference could only ever be raised by a step that had not been called a + // warp, so once it settled low, every real movement cleared the bar and was called a + // warp too, which in turn kept the reference low. A ratchet that jams shut. Measured on + // Alex's 9-minute run: reference stuck at 0.0001, ordinary walking at 0.19 reported as + // "x3064 over", 461 history resets instead of one. A warp is one sample in thousands, + // so it cannot move a high percentile - letting it in costs nothing and removes the + // only way this can seize up. + _window[_windowHead] = step; + _windowHead = (_windowHead + 1) % WindowSize; + + if (_windowCount < WindowSize) + { + _windowCount++; + + // Inert until the window is full: no verdict before there is something to compare + // against. At 60 fps that is about five seconds. + return; + } + + // Refreshed periodically rather than per read: the scale of a camera's motion does not + // change meaningfully within a few frames, and this keeps the common path cheap. + if (--_scaleCountdown <= 0) + { + _scaleCountdown = ScaleRefresh; + _window.CopyTo(_sorted, 0); + Array.Sort(_sorted); + _scale = _sorted[(int)(WindowSize * 0.9f)]; + } + + if (_scale > 0f && step > _scale * TeleportFactor) + { + GAL.DlssCameraState.TeleportSeq++; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP camera warp: step={step:0.##} vs typical {_scale:0.#####} " + + $"(x{step / _scale:0.} over) -> DLSS history reset #{GAL.DlssCameraState.TeleportSeq}."); + } + } + + /// + /// [DEJITTER 27/07] How much shear rows 0 and 1 carry along row3. For an unjittered + /// perspective both are zero by construction: PxV keeps rows 0/1 orthogonal to row3 + /// (that orthogonality is one of the tests IsViewProj already relies on). Anything + /// non-zero here is the temporal-AA offset the game applied to its projection, expressed + /// in the same units as the NDC shear - typically a fraction of a pixel. + /// + private static void MeasureJitter(ReadOnlySpan m, out float jx, out float jy) + { + float n3Sq = m[12] * m[12] + m[13] * m[13] + m[14] * m[14]; + + if (n3Sq < 1e-12f) + { + jx = 0f; + jy = 0f; + + return; + } + + jx = (m[0] * m[12] + m[1] * m[13] + m[2] * m[14]) / n3Sq; + jy = (m[4] * m[12] + m[5] * m[13] + m[6] * m[14]) / n3Sq; + } + + /// + /// [DEJITTER 27/07] Subtracts the shear measured above, on all FOUR components of each + /// row: the jitter was added as row += j*row3 in full, and m[3]/m[7] are precisely the + /// terms ViewProjPos uses to rebuild the camera position, so leaving them contaminated + /// would defeat the whole point. Rotation, focal lengths and the depth mapping (row2, + /// row3) are never touched. + /// + private static void RemoveJitter(Span m, float jx, float jy) + { + for (int i = 0; i < 4; i++) + { + m[i] -= jx * m[12 + i]; + m[4 + i] -= jy * m[12 + i]; + } + } + + /// + /// [27/07] Why a read was refused, and with which numbers. Written because the armed guard + /// refused 91 to 98 % of the reads on Alex's run while only 24 reads out of 600 carried a + /// step above the 5-unit floor: those two facts cannot both describe a guard rejecting + /// intruders, so the reason has to be read rather than guessed. Gated on the trace, capped, + /// and it never influences the decision it reports. + /// + private static void LogReject(string reason, float dist, float budget, float fx, float dtSec) + { + if (!MvppCamTrace.Enabled || _rejectLogs >= 40) + { + return; + } + + _rejectLogs++; + + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP camguard reject #{_rejectLogs} ({reason}): dist={dist:0.####} budget={budget:0.##} " + + $"dt={dtSec:0.###}s fx={fx:0.#####} lastFx={_lastFx:0.#####} " + + $"drift={(_lastFx > 1e-6f ? MathF.Abs(fx / _lastFx - 1f) : 0f):0.#####}."); + } + + private static void Transpose(ReadOnlySpan m, Span dst) + { + for (int r = 0; r < 4; r++) + { + for (int c = 0; c < 4; c++) + { + dst[r * 4 + c] = m[c * 4 + r]; + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppUiProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppUiProbe.cs index 8eb7150ce..5311a913e 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppUiProbe.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppUiProbe.cs @@ -44,6 +44,8 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed public bool AnyDepth; public bool AnyBlend; public int Draws; + public int NoDepthDraws; // [HUDSPLIT] dessins sans test de profondeur = candidats HUD + public int FirstNoDepthAt; // rang du premier d'entre eux dans l'epoque (1 = des le debut) } private static readonly List _epochs = new(); @@ -51,9 +53,84 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private static int _frameNo; private static int _framesLoggedFull; private static long _lastVerdictMs; + private static long _lastDetailMs; // [28/07] cadence du dump detaille en regime de jeu + + // [EPOCHDUMP 28/07] RYUJINX_MVPP_UIPROBE_DUMP=, 0 = OFF. + // + // POURQUOI CE MODE EXISTE. Les captures de render targets photographient soit un instant + // arbitraire, soit la PRESENTATION -- et a ce moment-la le buffer LDR pleine resolution + // qui recoit 95 a 99 dessins par image est deja RECYCLE : mesure du 28/07, il ressort + // VIDE alors que la sonde d'epoques le voit travailler. C'est exactement pour ca qu'on + // n'a jamais pu regarder ce que le jeu y peint. + // + // Ici on capture le render target AU MOMENT OU SON EPOQUE SE FERME, c'est-a-dire juste + // apres son dernier dessin et avant qu'il serve a autre chose. Une seule image capturee, + // toutes ses epoques, puis la sonde se desarme d'elle-meme. + private static readonly int _dumpAfterSeconds = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_UIPROBE_DUMP"), out int ds) && ds > 0 ? ds : 0; + + private static long _dumpArmedAtMs; + private static bool _dumpDone; + private static int _dumpSeq; + private static Image.Texture _epochTex; // le RT de l'epoque en cours, garde pour la capture + + // [HUDLESS 28/07] RYUJINX_MVPP_HUDLESS=1 : DETECTION SEULE, ne capture rien, ne change rien. + // + // Reconnait, PAR SA FORME, le render target qui porte l'image tonemappee SANS interface -- + // celui que la Frame Generation reclame et qu'on n'a jamais pu lui donner. Signature + // mesuree le 28/07 sur XC2 : format 8 bits par canal (donc MEME espace colorimetrique que + // l'image presentee, ce qui rend la soustraction UI = backbuffer - hudless valide), + // PLEINE largeur de rendu, et une epoque nourrie (~95-99 dessins) -- une passe d'interface + // n'en aurait que quelques-uns, une passe d'effet serait plus petite. + // + // JAMAIS par adresse : 0x2280250000 change d'un lancement a l'autre. + // + // Ce mode existe pour verifier que la regle attrape le bon buffer A TOUS LES COUPS avant + // qu'on branche quoi que ce soit dessus. Le dossier a deja paye une regle non verifiee. + private static readonly bool _hudless = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_HUDLESS") == "1"; + + private const int HudlessMinDraws = 20; + + // [HUDLESSFEED 28/07] RYUJINX_MVPP_HUDLESS_FEED=1 : COPIE le buffer sans interface vers une + // texture persistante, et la publie pour la couche Vulkan. Desarme par defaut. + // + // POURQUOI UNE COPIE ET PAS UNE REFERENCE. Le render target est RECYCLE juste apres son + // epoque -- c'est demontre : toutes les captures faites au present le trouvaient VIDE. + // Publier une simple reference donnerait donc a la Frame Generation un contenu deja + // ecrase. La copie est faite a l'instant exact de la fermeture, seul moment ou le buffer + // contient l'image. + // + // POURQUOI ON COPIE CHAQUE CANDIDAT PLUTOT QUE LE SEUL ELU. L'election ne peut se faire + // qu'a la fin de l'image, quand le buffer n'existe plus. On copie donc tout candidat au + // moins aussi nourri que le meilleur vu dans cette image : le dernier ecrase les + // precedents, et c'est justement lui le bon (29 dessins puis 81 -- mesure du 28/07). + // + // Cout : une copie GPU pleine resolution par candidat, 1 a 2 par image. + private static readonly bool _hudlessFeed = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_HUDLESS_FEED") == "1"; + + private static ITexture _hudlessCopy; + private static int _hudlessCopyW; + private static int _hudlessCopyH; + private static Format _hudlessCopyFmt; + private static long _hudlessFeedId; + private static int _hudlessCopyFails; + + private static int _renderWidthSeen; + private static int _hudlessHits; + private static int _hudlessFrames; + private static int _hudlessMultiple; // images ou PLUSIEURS epoques matchent = regle ambigue + private static long _hudlessLogMs; + private static int _hudlessInFrame; + private static int _hudlessBestDraws; // [v2] meilleur candidat de l'image en cours + private static ulong _hudlessBestAddr; + private static int _hudlessBestRank; + private static int _hudlessRetenus; // images ou un candidat a bien ete elu + private static int _hudlessEchecsLogues; private static string _lastVerdictSig = ""; - public static void OnDraw(GpuChannel channel, ref ThreedClassState state) + public static void OnDraw(GpuChannel channel, ref ThreedClassState state, GpuContext context) { if (!_enabled) { @@ -64,7 +141,7 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed // fatal and unlogged. On any error: disable and say why (same hardening as the other probes). try { - OnDrawImpl(channel, ref state); + OnDrawImpl(channel, ref state, context); } catch (Exception e) { @@ -73,12 +150,44 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed } } - private static void OnDrawImpl(GpuChannel channel, ref ThreedClassState state) + /// + /// Vraie frontière d'image, appelée depuis Gpu/Window.Present. Indépendante de ResScale, donc + /// fonctionne sur les jeux rendus à l'échelle native (XC2) contrairement à la détection par + /// montée du facteur de résolution, qui n'a de sens que si la scène 3D tourne au-dessus de 1x. + /// + public static void OnPresent() + { + if (!_enabled) + { + return; + } + + try + { + if (_epochs.Count > 0) + { + FlushFrame(); + } + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP UIPROBE: disabled after unexpected error: {e}"); + } + } + + private static void OnDrawImpl(GpuChannel channel, ref ThreedClassState state, GpuContext context) { float scale = channel.TextureManager.RenderTargetScale; // Frame boundary = rising edge of the render scale (1x UI/native -> >1x scaled 3D scene = a // new frame started). Flush what we accumulated for the previous frame. + // + // [21/07] /!\ CETTE DETECTION NE MARCHE QUE SI ResScale > 1. Sur XC2 (ResScale = 1) le facteur + // ne bouge jamais, la condition n'est JAMAIS vraie et la sonde reste MUETTE -- un run entier + // perdu à cause de ça. C'est la 3e sonde héritée du chantier TOTK inutilisable telle quelle sur + // XC2 (avec MvppGlowProbe, filtrée R11G11B10, et MvppHdrCensusProbe, filtrée width >= 1500). + // La vraie fin d'image est maintenant donnée par OnPresent(), appelé depuis Gpu/Window.Present. if (_prevScale == 1f && scale > 1f && _epochs.Count > 0) { FlushFrame(); @@ -111,10 +220,68 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed last.Draws++; last.AnyDepth |= depth; last.AnyBlend |= blend; + + // [HUDSPLIT 28/07] Granularite PAR DESSIN. AnyDepth agrege toute l'epoque : + // une epoque qui melange scene et interface ressort "depth=True" et cache + // exactement la frontiere qu'on cherche. Le HUD ne teste jamais la profondeur + // -- compter les dessins SANS test, et retenir le rang du PREMIER, donne le + // point ou l'interface commence a etre posee sur l'image. + if (!depth) + { + if (last.NoDepthDraws == 0) + { + last.FirstNoDepthAt = last.Draws; + } + + last.NoDepthDraws++; + } + return; } } + // [HUDLESS] L'epoque qui vient de se fermer correspond-elle a la signature ? + if (_hudless && _epochs.Count > 0) + { + Epoch fini = _epochs[^1]; + + if (fini.W > _renderWidthSeen) + { + _renderWidthSeen = fini.W; + } + + bool ldr8 = fini.Fmt == Format.R8G8B8A8Unorm || fini.Fmt == Format.B8G8R8A8Unorm; + + if (ldr8 && fini.W == _renderWidthSeen && fini.Draws >= HudlessMinDraws) + { + _hudlessInFrame++; + + // [v2 28/07] LE DERNIER, LE PLUS NOURRI. Mesure : dans certaines situations le + // jeu ecrit DEUX FOIS dans ce buffer par image (29 dessins puis 81) -- la v1 + // comptait les deux (149 % de detections, 74 images ambigues sur 151). L'image + // se construit progressivement : le bon candidat est le dernier ET le plus + // nourri. On ne tranche donc qu'a la FIN de l'image, jamais a la volee. + if (fini.Draws >= _hudlessBestDraws) + { + _hudlessBestDraws = fini.Draws; + _hudlessBestAddr = fini.Addr; + _hudlessBestRank = _epochs.Count; + + // [HUDLESSFEED] C'est ICI, et nulle part ailleurs, que le buffer contient + // encore l'image : son epoque vient de se fermer, il n'a pas encore servi. + if (_hudlessFeed && _epochTex != null && context != null) + { + CopyHudless(context); + } + } + } + } + + // [EPOCHDUMP] L'epoque precedente vient de se fermer : son render target contient + // encore ce que le jeu vient d'y peindre. C'est le SEUL instant ou on peut le voir. + TryDumpEpoch(); + _epochTex = col0; + if (_epochs.Count < MaxEpochsPerFrame) { _epochs.Add(new Epoch @@ -127,10 +294,128 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed AnyDepth = depth, AnyBlend = blend, Draws = 1, + NoDepthDraws = depth ? 0 : 1, + FirstNoDepthAt = depth ? 0 : 1, }); } } + /// + /// [HUDLESSFEED] Copie le render target sans interface vers une texture persistante, puis + /// la publie pour la couche Vulkan via le pont GAL existant (MvppColorSnapshot). Aucune + /// modification du GAL n'est necessaire -- ce pont date du 12/07 et vit deja dans le + /// binaire deploye, contrairement a DlssCameraState qui, lui, a bouge le 28/07 a 11h46. + /// + private static void CopyHudless(GpuContext context) + { + try + { + Image.Texture src = _epochTex; + ITexture host = src.HostTexture; + + if (host == null) + { + return; + } + + int w = src.Info.Width; + int h = src.Info.Height; + Format fmt = src.Info.FormatInfo.Format; + + // Recreation seulement si la FORME change (resolution dynamique, changement de + // cible) : une texture par forme, pas une par image. + if (_hudlessCopy == null || _hudlessCopyW != w || _hudlessCopyH != h || _hudlessCopyFmt != fmt) + { + _hudlessCopy?.Release(); + _hudlessCopy = context.Renderer.CreateTexture(new TextureCreateInfo( + w, h, 1, 1, 1, 1, 1, 1, + fmt, + DepthStencilMode.Depth, + Target.Texture2D, + SwizzleComponent.Red, + SwizzleComponent.Green, + SwizzleComponent.Blue, + SwizzleComponent.Alpha)); + _hudlessCopyW = w; + _hudlessCopyH = h; + _hudlessCopyFmt = fmt; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP-HUDLESSFEED: texture de destination creee {w}x{h} {fmt}."); + } + + host.CopyTo(_hudlessCopy, 0, 0); + + GAL.MvppColorSnapshot.SceneColorHost = _hudlessCopy; + GAL.MvppColorSnapshot.FrameId = ++_hudlessFeedId; + } + catch (Exception e) + { + if (++_hudlessCopyFails <= 3) + { + Logger.Warning?.Print(LogClass.Gpu, $"MVPP-HUDLESSFEED: copie echouee: {e.Message}"); + } + } + } + + /// + /// [EPOCHDUMP] Capture le render target de l'epoque qui vient de se fermer. Lecture seule : + /// aucune ecriture GPU, aucun changement de rendu. Une seule image, puis desarmement. + /// + private static void TryDumpEpoch() + { + if (_dumpAfterSeconds == 0 || _dumpDone || _epochTex == null) + { + return; + } + + long now = Environment.TickCount64; + + if (_dumpArmedAtMs == 0) + { + _dumpArmedAtMs = now; + + return; + } + + if (now - _dumpArmedAtMs < _dumpAfterSeconds * 1000L) + { + return; + } + + try + { + GAL.ITexture host = _epochTex.HostTexture; + + if (host == null) + { + return; + } + + ulong addr = _epochTex.Range.GetSubRange(0).Address; + string dir = System.IO.Path.Combine("rtdump", "epochs"); + System.IO.Directory.CreateDirectory(dir); + + using GAL.PinnedSpan data = host.GetData(); + string nom = $"ep{_dumpSeq:D2}_{_epochTex.Info.Width}x{_epochTex.Info.Height}_" + + $"{_epochTex.Info.FormatInfo.Format}_at{addr:X}.bin"; + System.IO.File.WriteAllBytes(System.IO.Path.Combine(dir, nom), data.Get().ToArray()); + + Logger.Info?.Print(LogClass.Gpu, $"MVPP-EPOCHDUMP: {nom} ecrit a la FERMETURE de son epoque."); + + if (++_dumpSeq >= 12) + { + _dumpDone = true; + Logger.Info?.Print(LogClass.Gpu, "MVPP-EPOCHDUMP: termine (12 epoques capturees), sonde desarmee."); + } + } + catch (Exception e) + { + _dumpDone = true; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP-EPOCHDUMP: abandonne apres erreur: {e.Message}"); + } + } + private static void FlushFrame() { _frameNo++; @@ -215,15 +500,98 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed } // First frames: dump the full epoch list so the verdict is auditable from raw data. - if (_framesLoggedFull < FullDetailFrames) + // + // [28/07] ET AUSSI UNE FOIS TOUTES LES 5 s ENSUITE. Motif : les 8 premieres images + // tombent sur le logo/l'ecran de chargement (mesure du 28/07 17h51 : une seule epoque, + // 768x432), donc le detail n'a JAMAIS decrit une image de jeu reelle. Or c'est + // exactement ce detail qu'il faut pour savoir comment le HUD est compose avant de + // pouvoir le sortir de l'image donnee a la Frame Generation. + bool periodic = now - _lastDetailMs >= 5000; + + if (_framesLoggedFull < FullDetailFrames || periodic) { + if (periodic) + { + _lastDetailMs = now; + } + _framesLoggedFull++; for (int i = 0; i < _epochs.Count; i++) { Epoch e = _epochs[i]; Logger.Info?.Print(LogClass.Gpu, $"MVPP-UIPROBE f{_frameNo} epoch{i}: RT@0x{e.Addr:X} {e.Fmt} {e.W}x{e.H} " + - $"scale={e.Scale:0.##} depth={e.AnyDepth} blend={e.AnyBlend} draws={e.Draws}"); + $"scale={e.Scale:0.##} depth={e.AnyDepth} blend={e.AnyBlend} draws={e.Draws}" + + (e.NoDepthDraws > 0 ? $" >>> SANS-DEPTH {e.NoDepthDraws} (des le n°{e.FirstNoDepthAt}) = CANDIDAT HUD" : "")); + } + } + + // [HUDLESS] Bilan par image, puis resume a 1 Hz. Ce qu'on veut lire : UNE seule + // epoque candidate par image (regle non ambigue) et UNE detection sur CHAQUE image + // (regle qui ne decroche pas). Tout ecart se voit dans ces trois chiffres. + if (_hudless) + { + _hudlessFrames++; + + if (_hudlessInFrame > 1) + { + _hudlessMultiple++; + } + + // [v2] Election de fin d'image : UN candidat retenu, et un seul. + if (_hudlessBestDraws > 0) + { + _hudlessRetenus++; + _hudlessHits++; + + if (_hudlessRetenus <= 6) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP-HUDLESS elu: @0x{_hudlessBestAddr:X} {_hudlessBestDraws} dessins " + + $"(epoque n°{_hudlessBestRank}), {_hudlessInFrame} candidat(s) dans l'image."); + } + } + + // [v2] ECHEC D'ELECTION : dire CE QU'ON A VU plutot que de le contourner par un + // repli. Mesure du 28/07 : la regle tient a 100 % dans la plupart des situations + // mais tombe a 83,9 % par moments -- 24 images sur 149 sans aucun candidat. Un + // HUD-less manquant sur une image = la FG travaille sur du perime, donc exactement + // l'artefact intermittent qu'on veut supprimer. Il faut savoir ce que sont ces + // images avant de decider quoi faire. + else if (_hudlessEchecsLogues < 8) + { + _hudlessEchecsLogues++; + + var vu = new System.Text.StringBuilder(); + + for (int i = 0; i < _epochs.Count; i++) + { + vu.Append($"[{_epochs[i].Fmt} {_epochs[i].W}x{_epochs[i].H} {_epochs[i].Draws}d] "); + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP-HUDLESS AUCUN CANDIDAT sur cette image ({_epochs.Count} epoques) : " + + (vu.Length > 0 ? vu.ToString().TrimEnd() : "aucune epoque")); + } + + _hudlessInFrame = 0; + _hudlessBestDraws = 0; + _hudlessBestAddr = 0; + _hudlessBestRank = 0; + + if (now - _hudlessLogMs >= 5000) + { + _hudlessLogMs = now; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP-HUDLESS: {_hudlessHits} elus sur {_hudlessFrames} images " + + $"({(_hudlessFrames > 0 ? 100f * _hudlessHits / _hudlessFrames : 0f):0.0}% - doit valoir 100), " + + $"images ou plusieurs candidats se presentaient={_hudlessMultiple} " + + $"(l'election en garde UN, c'est desormais informatif), " + + $"largeur de rendu retenue={_renderWidthSeen}."); + _hudlessHits = 0; + _hudlessFrames = 0; + _hudlessMultiple = 0; + _hudlessRetenus = 0; } } diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppViewportProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppViewportProbe.cs new file mode 100644 index 000000000..b87cca6e5 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppViewportProbe.cs @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Engine.Threed +{ + /// + /// Viewport-vs-render-target census (RYUJINX_VP_PROBE=1). READ-ONLY, default off. + /// + /// XC2 lateral-band artefact, 21/07. What is already MEASURED: the presented guest image is 1280x720 + /// and the present blits the FULL rectangle (`src[0,0,1280,720]`, constant over a whole capture run), + /// so the corrupt side bands are already inside the guest image -- it is not a crop or a stretch. + /// What is NOT measured, on this game, ever: the VIEWPORT. Pixels of a render target lying OUTSIDE the + /// current viewport are neither cleared nor defined (the host texture is always allocated at the FULL + /// guest size, and nothing wipes the remainder), so a game that shrinks its viewport -- dynamic + /// resolution, which XC2 is known for -- leaves stale content on the sides of an otherwise fresh frame. + /// The earlier "dynamic resolution eliminated" verdict only ever compared SURFACE sizes, which is + /// precisely what dyn-res keeps constant; it never looked at the viewport. + /// + /// This probe answers one question: does XC2's viewport cover its render target, and if not, is it + /// CENTRED (two bands, matching the observed artefact) or anchored to one edge (a single band)? + /// Stats are kept PER RENDER-TARGET SHAPE -- mixing the scene pass with shadow and low-resolution + /// passes is what produced empty verdicts on the earlier probes. + /// + static class MvppViewportProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_VP_PROBE") == "1"; + + private static bool _announced; + private static long _windowMs; + + private sealed class Stats + { + public int Draws; + public int Narrow; // viewport does not cover the target horizontally + public int Short; // ... vertically + public int TransformOff; // ViewportTransformEnable == 0 (screen-scissor path) + public float MinX, MaxX, MinW, MaxW, MinY, MaxY, MinH, MaxH; + public bool Seeded; + + // [v2, 21/07] The viewport came back covering the target on 91 398 draws, so the OTHER half of + // the same mechanism is what matters now: the SCISSOR, and the SCREEN scissor that feeds the + // texture-width heuristic. Neither has ever been measured on this game's scene passes -- the one + // scissor test on record only ever looked at draws targeting the HDR buffer. + // [v3, 21/07] The three values NOTHING in this emulator ever reads, and that no probe in this + // dossier has ever looked at. All three are structurally symmetric in X, which is the one + // property the artefact demands and that every mechanism eliminated so far fails to provide. + // - ViewportExtents.X/Width : the guest's viewport CLIP rectangle. The struct is documented + // "viewport extents for viewport clipping", but a grep over StateUpdater shows only + // DepthNear/DepthFar are consumed (l.1240-1241, 2117-2119). Vulkan does not clip to the + // viewport -- only scissor and framebuffer do -- so an ignored clip means we rasterise + // fragments the console discards. X=m, Width=W-2m cuts m pixels off BOTH sides. + // - sign of ScaleX : a negative ScaleX is a horizontal MIRROR. StateUpdater.cs:1204 destroys + // it with MathF.Abs, and so did v1/v2 of this very probe -- the measurement copied the bug. + // A mirror leaves the centre invariant and grows outward symmetrically: the exact geography. + // - swizzle X : NegativeX and the X<->Y permutations are never consumed by the Vulkan backend. + public int ClipZero; // register never written (X==0 && Width==0) + public int ClipFull; // clip covers the target + public int ClipCuts; // clip leaves pixels out on the left or the right + public int ClipMinX, ClipMaxX, ClipMinW, ClipMaxW; + public bool ClipSeeded; + public int A2c; // alpha-to-coverage actif sur ce draw + public int A2cDither; // ... ET dither actif => chemin du discard en damier + public int NegScaleX; // ScaleX < 0 -> horizontal mirror the backend cannot express + public int SwizzleXOdd; // swizzle X is not PositiveX + + public void AddClip(int x, int w) + { + if (!ClipSeeded) + { + ClipSeeded = true; + ClipMinX = ClipMaxX = x; + ClipMinW = ClipMaxW = w; + return; + } + + if (x < ClipMinX) { ClipMinX = x; } + if (x > ClipMaxX) { ClipMaxX = x; } + if (w < ClipMinW) { ClipMinW = w; } + if (w > ClipMaxW) { ClipMaxW = w; } + } + + public int ScissorOn; // scissor enabled at all + public int ScissorCuts; // scissor leaves uncovered pixels left or right of the target + public int ScissorSeen; + public int SciMinX1, SciMaxX1, SciMinX2, SciMaxX2; + public int ScrMinW, ScrMaxW, ScrMinH, ScrMaxH; + public bool ScrSeeded; + + public void AddScissor(int x1, int x2) + { + if (ScissorSeen++ == 0) + { + SciMinX1 = SciMaxX1 = x1; + SciMinX2 = SciMaxX2 = x2; + return; + } + + if (x1 < SciMinX1) { SciMinX1 = x1; } + if (x1 > SciMaxX1) { SciMaxX1 = x1; } + if (x2 < SciMinX2) { SciMinX2 = x2; } + if (x2 > SciMaxX2) { SciMaxX2 = x2; } + } + + public void AddScreen(int w, int h) + { + if (!ScrSeeded) + { + ScrSeeded = true; + ScrMinW = ScrMaxW = w; + ScrMinH = ScrMaxH = h; + return; + } + + if (w < ScrMinW) { ScrMinW = w; } + if (w > ScrMaxW) { ScrMaxW = w; } + if (h < ScrMinH) { ScrMinH = h; } + if (h > ScrMaxH) { ScrMaxH = h; } + } + + public void Add(float x, float y, float w, float h) + { + if (!Seeded) + { + Seeded = true; + MinX = MaxX = x; + MinY = MaxY = y; + MinW = MaxW = w; + MinH = MaxH = h; + return; + } + + if (x < MinX) { MinX = x; } + if (x > MaxX) { MaxX = x; } + if (y < MinY) { MinY = y; } + if (y > MaxY) { MaxY = y; } + if (w < MinW) { MinW = w; } + if (w > MaxW) { MaxW = w; } + if (h < MinH) { MinH = h; } + if (h > MaxH) { MaxH = h; } + } + } + + private static readonly Dictionary<(int W, int H), Stats> _byShape = new(); + + // [ADDR, 21/07] Modes d'adressage réellement demandés par le jeu, TOUTES passes confondues. + // Ryujinx substitue quatre modes (EnumConversion.cs:97-110), chacun marqué "TODO: Should be ..." : + // Clamp -> ClampToEdge · MirrorClamp -> ClampToEdge · MirrorClampToBorder -> ClampToBorder + // + tout mode inconnu -> ClampToEdge + // Le vrai Clamp renvoie la COULEUR DE BORDURE hors [0,1] ; ClampToEdge RÉPLIQUE le dernier texel. + // Demander le premier et recevoir le second ne donne pas une bordure : ça donne une TRAÎNÉE -- + // exactement l'étirement vertical photographié par Alex quand il monte/descend la caméra. + // L'élimination du journal ("modes d'adressage cohérents") venait de MvppGlowProbe, filtrée sur les + // seules cibles R11G11B10 : elle ne couvrait ni le 512x288, ni le buffer d'ids, ni le compute. + // Ici AUCUN filtre. Échantillonné 1 draw sur 16 pour que l'énumération ne coûte pas la frame. + private static readonly int[] _addrU = new int[16]; + private static readonly int[] _addrV = new int[16]; + private static int _addrBindings; + private static int _addrSampled; + + public static void OnDraw(GpuChannel channel, ref ThreedClassState state) + { + if (!_enabled) + { + return; + } + + try + { + OnDrawImpl(channel, ref state); + } + catch (Exception e) + { + // A bench instrument must never take the emulator down with it. + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP VPPROBE: disabled after unexpected error: {e}"); + } + } + + private static void OnDrawImpl(GpuChannel channel, ref ThreedClassState state) + { + if (!_announced) + { + // Armed proof before any measurement: a switch you cannot SEE in the log is worth nothing. + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP VPPROBE: ON -- viewport vs render target, read-only, one line per shape per second."); + } + + int rtW = 0, rtH = 0; + + channel.TextureManager.MvppEnumerateRenderTargets((slot, rt) => + { + if (rtW == 0 && slot == 0 && rt != null) + { + rtW = rt.Info.Width; + rtH = rt.Info.Height; + } + }); + + if (rtW <= 0 || rtH <= 0) + { + return; + } + + // Same arithmetic as StateUpdater.UpdateViewportTransform (l.1204-1221), minus the render-target + // scale (inert at ResScale=1) and the swizzle handling, which do not move the rectangle. + ref ViewportTransform transform = ref state.ViewportTransform[0]; + + float scaleX = MathF.Abs(transform.ScaleX); + float scaleY = MathF.Abs(transform.ScaleY); + float vpX = transform.TranslateX - scaleX; + float vpY = transform.TranslateY - scaleY; + float vpW = scaleX * 2f; + float vpH = scaleY * 2f; + + (int, int) key = (rtW, rtH); + + if (!_byShape.TryGetValue(key, out Stats st)) + { + st = new Stats(); + _byShape[key] = st; + } + + st.Draws++; + st.Add(vpX, vpY, vpW, vpH); + + if (state.ViewportTransformEnable == 0) + { + st.TransformOff++; + } + + // "Narrow" = the viewport leaves uncovered pixels on the left or the right of the target. + // One pixel of slack absorbs the float arithmetic; real dyn-res steps are tens of pixels. + if (vpX > 1f || vpX + vpW < rtW - 1f) + { + st.Narrow++; + } + + if (vpY > 1f || vpY + vpH < rtH - 1f) + { + st.Short++; + } + + // Scissor: same question as the viewport, other register. A scissor narrower than the target + // leaves its sides unwritten, which is exactly the observed geography. Disabled or maxed out, + // it becomes (0,0,0xffff,0xffff) and protects nothing -- that case counts as "no cut". + ScissorState sc = state.ScissorState[0]; + bool scEnabled = sc.Enable; + + if (scEnabled) + { + st.ScissorOn++; + st.AddScissor(sc.X1, sc.X2); + + if (sc.X1 > 0 || sc.X2 < rtW) + { + st.ScissorCuts++; + } + } + + // Screen scissor: it is the size hint that decides the created texture WIDTH + // (TextureCache.GetMinimumWidthInGob), so a moving screen scissor can hand back the same guest + // surface at slightly different widths. Worth a number before anyone theorises about it. + ScreenScissorState scr = state.ScreenScissorState; + st.AddScreen(scr.Width, scr.Height); + + // [v3] The clip rectangle the emulator decodes and throws away. + ViewportExtents ext = state.ViewportExtents[0]; + int clipX = ext.X; + int clipW = ext.Width; + + if (clipX == 0 && clipW == 0) + { + st.ClipZero++; + } + else + { + st.AddClip(clipX, clipW); + + if (clipX > 0 || clipX + clipW < rtW) + { + st.ClipCuts++; + } + else + { + st.ClipFull++; + } + } + + // [v3] Raw sign, NOT MathF.Abs -- this is the whole point of the v3 pass. + if (transform.ScaleX < 0f) + { + st.NegScaleX++; + } + + if (transform.UnpackSwizzleX() != ViewportSwizzle.PositiveX) + { + st.SwizzleXOdd++; + } + + // [A2C, 21/07] LA mesure qui décide du dossier. Ryujinx émule le fondu par transparence en + // REJETANT des pixels selon un damier 2x2 (EmitterContext.GenerateAlphaToCoverageDitherDiscard, + // masque 0xfbb99110, discard si le bit est à 0). Sur la console ce fondu est lissé par le + // multi-échantillonnage ; ici les pixels sont jetés et il reste une grille de trous dans la + // géométrie -- exactement le motif régulier qu'Alex photographie. Ce chemin n'est actif que si + // le jeu pose les DEUX drapeaux. Si a2c=0 sur XC2, l'hypothèse meurt en un run. + if ((state.MultisampleControl & 1) != 0) + { + st.A2c++; + + if (state.AlphaToCoverageDitherEnable) + { + st.A2cDither++; + } + } + + // Modes d'adressage demandés, toutes passes. 1 draw sur 16 : l'énumération des bindings est + // le seul point coûteux de cette sonde. + if ((++_addrSampled & 15) == 0) + { + channel.TextureManager.MvppEnumerateGraphicsInputsWithSampler((stage, tex, smp) => + { + if (smp == null) + { + return; + } + + int u = (int)smp.ProbeAddressU; + int v = (int)smp.ProbeAddressV; + + if ((uint)u < 16) { _addrU[u]++; } + if ((uint)v < 16) { _addrV[v]++; } + + _addrBindings++; + }); + } + + long nowMs = Environment.TickCount64; + + if (nowMs < _windowMs) + { + return; + } + + _windowMs = nowMs + 1000; + + foreach (KeyValuePair<(int W, int H), Stats> entry in _byShape) + { + Stats s = entry.Value; + + if (s.Draws == 0 || !s.Seeded) + { + continue; + } + + // Left/right margins of the widest and narrowest viewport seen on this shape. Two roughly + // equal margins = a centred viewport = two lateral bands. One-sided = a single band. + float leftMin = s.MinX; + float rightMin = entry.Key.W - (s.MinX + s.MinW); + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP VPPROBE: rt {entry.Key.W}x{entry.Key.H} draws={s.Draws} narrow={s.Narrow} short={s.Short} " + + $"tfOff={s.TransformOff} vpW=[{s.MinW:F1}..{s.MaxW:F1}] vpH=[{s.MinH:F1}..{s.MaxH:F1}] " + + $"vpX=[{s.MinX:F1}..{s.MaxX:F1}] vpY=[{s.MinY:F1}..{s.MaxY:F1}] " + + $"margeG={leftMin:F1} margeD={rightMin:F1} " + + $"| sciOn={s.ScissorOn} sciCuts={s.ScissorCuts} sciX=[{s.SciMinX1}..{s.SciMaxX1}][{s.SciMinX2}..{s.SciMaxX2}] " + + $"scrW=[{s.ScrMinW}..{s.ScrMaxW}] scrH=[{s.ScrMinH}..{s.ScrMaxH}] " + + $"|| CLIP zero={s.ClipZero} full={s.ClipFull} CUTS={s.ClipCuts} " + + $"clipX=[{s.ClipMinX}..{s.ClipMaxX}] clipW=[{s.ClipMinW}..{s.ClipMaxW}] " + + $"MIRROR negScaleX={s.NegScaleX} swizzleXodd={s.SwizzleXOdd} || A2C={s.A2c} A2C_DITHER={s.A2cDither}"); + s.Draws = 0; + s.Narrow = 0; + s.Short = 0; + s.TransformOff = 0; + s.Seeded = false; + s.ScissorOn = 0; + s.ScissorCuts = 0; + s.ScissorSeen = 0; + s.ScrSeeded = false; + s.ClipZero = 0; + s.ClipFull = 0; + s.ClipCuts = 0; + s.ClipSeeded = false; + s.NegScaleX = 0; + s.SwizzleXOdd = 0; + s.A2c = 0; + s.A2cDither = 0; + } + + if (_addrBindings > 0) + { + // Les modes SUBSTITUES par EnumConversion sont nommes en clair : ce sont les seuls qui + // peuvent transformer une bordure attendue en replication de texel (= trainee). + Logger.Info?.Print(LogClass.Gpu, + $"MVPP ADDRPROBE: {_addrBindings} bindings echantillonnes | U: {DescribeModes(_addrU)} | V: {DescribeModes(_addrV)}"); + + Array.Clear(_addrU); + Array.Clear(_addrV); + _addrBindings = 0; + } + } + + private static string DescribeModes(int[] counts) + { + System.Text.StringBuilder sb = new(); + + for (int i = 0; i < counts.Length; i++) + { + if (counts[i] == 0) + { + continue; + } + + AddressMode m = (AddressMode)i; + bool substitue = m is AddressMode.Clamp or AddressMode.MirrorClamp or AddressMode.MirrorClampToBorder; + + if (sb.Length > 0) + { + sb.Append(' '); + } + + sb.Append(m).Append('=').Append(counts[i]); + + if (substitue) + { + sb.Append("(SUBSTITUE)"); + } + } + + return sb.Length > 0 ? sb.ToString() : "aucun"; + } + + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/SemaphoreUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/SemaphoreUpdater.cs index 51fae81f6..d836132f0 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/SemaphoreUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/SemaphoreUpdater.cs @@ -187,7 +187,14 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed // divisor itself is fractional (1.333^2 = 1.777...), a value regime the // integer daily never exercises. Sampled 1/32 to name the family without // flooding; silent at integer scales. - if (scale != System.MathF.Floor(scale) && (_fractProbeCount++ & 31) == 0) + // [31/07] GATE AJOUTE. Elle etait en release SANS aucun interrupteur : la + // condition « echelle fractionnaire » est remplie par TOUT utilisateur en DLSS + // quality ou performance (DlssIntegration pilote GraphicsConfig.ResScale ; + // 4K + quality = 1440/1080 = 1,334). Mesure du 31/07 sur GYLT : 21 471 lignes + // en 3 minutes -- soit ~690 000 requetes de compteur -- chez quelqu'un qui + // n'avait aucun probleme a diagnostiquer. La sonde rend son service UNE fois + // par rapport de bug ; elle ne doit pas ecrire le reste du temps. + if (GAL.MvppDev.Enabled && scale != System.MathF.Floor(scale) && (_fractProbeCount++ & 31) == 0) { Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, $"MVPP fract-probe: samples query #{_fractProbeCount - 1}, divisor {divisor} (scale {scale}), gpuVa 0x{gpuVa:X}."); diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs index 481a814ee..b29270309 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs @@ -58,6 +58,15 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private static readonly bool _worldResJitter = System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_JITTER_WORLDRES") == "1"; + // [WORLDRES-LOW, 20/07 — natif] At native+4K the July selection window ([In/2, In*0.95], + // designed at 2x) leaves the HALF-render-res world passes (1600x900 for a 3200x1800 render: + // cloth/tents/particles/far LOD) un-jittered inside a de-jittered image => residual world + // trembling in motion (run #7, PASSPROBE proof in docs/JITTER-BANC.md). This knob widens the + // low bound to In/3 for 16:9 passes, depth-bound or not (the camera pass needs >= In/2, so no + // overlap). Same NDC path as WORLDRES. Gated; default OFF => byte-identical. + private static readonly bool _worldResLow = + System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_JITTER_WORLDRES_LOW") == "1"; + // [FULLJIT, 09/07 — kill the inject blur] The camera-pass jitter amplitude denominator is the // present-time InputWidth (published at present, one frame stale = the FINAL DLSS-input width 3200) // instead of THIS frame's real render viewport (2666). So the geometry only receives @@ -98,6 +107,10 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private readonly ShaderProgramInfo[] _currentProgramInfo; + // [SCENEPROBE] guest shader VAs of the current draw (read-only, for the scene-pass probe). + private ulong _probeSceneFsAddr; + private ulong _probeSceneVsAddr; + // [MVPP CUTOUT PROBE] Read-only per-draw tally: draws whose active FRAGMENT shader emits Discard // (alpha cutout), + their host viewport, to PROVE where the cutout foliage draws are. Gated by // RYUJINX_MVPP_CUTOUT_PROBE; default OFF => zero cost, no state touched, no render/DLSS/jitter change. @@ -422,6 +435,134 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed { CutoutDrawProbeTick(); } + + // [CONSTDIFF] Read-only per-draw: trace the builder's prev-frame matrix (journal 221). + if (Image.MvppConstDiffProbe.Enabled) + { + Image.MvppConstDiffProbe.OnDraw(_channel, _probeSceneFsAddr); + } + + // [BUILDERIN] Read-only per-draw: which surfaces are actually bound to the builder's + // samplers and the exact constants it consumes (journal 226). Self-gated; always + // called so its per-draw input list is cleared even on non-builder draws. + Image.MvppBuilderInProbe.OnDraw(_channel, _channel.TextureManager, _probeSceneFsAddr); + + // [MVHASH] Read-only: content occupancy of the MV twin at builder-draw time (242). + Image.MvppContentProbe.OnBuilderDraw(_channel.TextureManager, _probeSceneFsAddr); + + // [SCENEPROBE] Read-only per-draw: identify the pass writing the 720p R11G11B10 scene. + // Same point as the cutout probe (after CommitBindings): RT, inputs and shader VAs are all + // current for this draw. Touches no state. + if (Image.MvppScenePassProbe.Enabled) + { + Image.MvppScenePassProbe.OnDraw( + _channel.TextureManager, + _probeSceneFsAddr, + _probeSceneVsAddr, + Image.MvppFeedbackProbe.Frame); + } + + // [MAP64] Read-only per-draw: who writes the small DoF maps, with the draw's viewport + // extents (a partial viewport leaves stale texels = the staleness suspect). Touches no state. + if (Image.MvppMap64Probe.Enabled) + { + Span vpSpan = _state.State.ViewportTransform.AsSpan(); + ref ViewportTransform vp0 = ref vpSpan[0]; + float vpScale = _channel.TextureManager.RenderTargetScale; + + Image.MvppMap64Probe.OnDraw( + _channel.TextureManager, + _probeSceneFsAddr, + _probeSceneVsAddr, + (int)(MathF.Abs(vp0.ScaleX) * 2f * vpScale), + (int)(MathF.Abs(vp0.ScaleY) * 2f * vpScale)); + } + + // [MVBUF] Read-only per-draw: draws writing the object-MV buffer (R10G10B10A2 720p), + // with the guest per-RT blend enable and colour write mask (v3: state census). + if (Image.MvppMvBufProbe.Enabled) + { + Span vpSpan = _state.State.ViewportTransform.AsSpan(); + ref ViewportTransform vp0 = ref vpSpan[0]; + float vpScale = _channel.TextureManager.RenderTargetScale; + + Span probeBlend = stackalloc bool[Constants.TotalRenderTargets]; + Span probeMask = stackalloc uint[Constants.TotalRenderTargets]; + bool maskShared = _state.State.RtColorMaskShared; + Span blendSpan = _state.State.BlendEnable.AsSpan(); + Span maskSpan = _state.State.RtColorMask.AsSpan(); + + for (int i = 0; i < Constants.TotalRenderTargets; i++) + { + probeBlend[i] = blendSpan[i]; + RtColorMask cm = maskSpan[maskShared ? 0 : i]; + probeMask[i] = (cm.UnpackRed() ? 1u : 0u) | + (cm.UnpackGreen() ? 2u : 0u) | + (cm.UnpackBlue() ? 4u : 0u) | + (cm.UnpackAlpha() ? 8u : 0u); + } + + Image.MvppMvBufProbe.OnDraw( + _channel.TextureManager, + _probeSceneFsAddr, + (int)(MathF.Abs(vp0.ScaleX) * 2f * vpScale), + (int)(MathF.Abs(vp0.ScaleY) * 2f * vpScale), + probeBlend, + probeMask); + } + + // [CENSUS] Read-only per-draw: MV-buffer writers vs the fingerprint-armed registry -- + // flags the writers every value scrub missed. v2: write masks passed so bound-but- + // masked-off draws are counted apart. v3: classification by PROGRAM identity via + // ShaderProgramInfo (VA census defeated by code dedup). Touches no state. + if (Image.MvppWriterCensusProbe.Enabled) + { + Span censusMask = stackalloc uint[Constants.TotalRenderTargets]; + bool censusMaskShared = _state.State.RtColorMaskShared; + Span censusMaskSpan = _state.State.RtColorMask.AsSpan(); + + for (int i = 0; i < Constants.TotalRenderTargets; i++) + { + RtColorMask cm = censusMaskSpan[censusMaskShared ? 0 : i]; + censusMask[i] = (cm.UnpackRed() ? 1u : 0u) | + (cm.UnpackGreen() ? 2u : 0u) | + (cm.UnpackBlue() ? 4u : 0u) | + (cm.UnpackAlpha() ? 8u : 0u); + } + + ShaderProgramInfo censusFragInfo = null; + for (int stage = 0; stage < Constants.ShaderStages; stage++) + { + ShaderProgramInfo info = _currentProgramInfo[stage]; + if (info != null && info.Stage == ShaderStage.Fragment) + { + censusFragInfo = info; + break; + } + } + + Image.MvppWriterCensusProbe.OnDraw(_channel.TextureManager, _probeSceneFsAddr, censusMask, censusFragInfo); + } + + // [TWINMAP] Read-only per-draw: writers per MV twin (by guest VA). v2: + live GMMU + // re-translation. v5: masks passed, all slots scanned (no early return). Touches no state. + if (Image.MvppTwinMapProbe.Enabled) + { + Span twinMask = stackalloc uint[Constants.TotalRenderTargets]; + bool twinMaskShared = _state.State.RtColorMaskShared; + Span twinMaskSpan = _state.State.RtColorMask.AsSpan(); + + for (int i = 0; i < Constants.TotalRenderTargets; i++) + { + RtColorMask cm = twinMaskSpan[twinMaskShared ? 0 : i]; + twinMask[i] = (cm.UnpackRed() ? 1u : 0u) | + (cm.UnpackGreen() ? 2u : 0u) | + (cm.UnpackBlue() ? 4u : 0u) | + (cm.UnpackAlpha() ? 8u : 0u); + } + + Image.MvppTwinMapProbe.OnDraw(_channel.TextureManager, _probeSceneFsAddr, _channel.MemoryManager, twinMask); + } } // [MVPP CUTOUT PROBE] Count this draw + (if its fragment shader is a cutout shader) tally it and its @@ -1051,6 +1192,14 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed vpW >= DlssJitterState.InputWidth / 2f && vpW <= DlssJitterState.InputWidth * 0.95f; + // [WORLDRES-LOW] The half-render-res world passes, below the July window. + bool worldResLowPass = _worldResLow && !cameraPass && + aspect > 1.5f && aspect < 1.95f && + DlssJitterState.InputWidth > 0 && + vpW >= DlssJitterState.InputWidth / 3f && + vpW < DlssJitterState.InputWidth / 2f; + worldResPass |= worldResLowPass; + if (cameraPass || worldResPass) { // Amplitude denominator: default = the final DLSS-input size (present, 1-frame stale). @@ -1058,6 +1207,18 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed // sub-pixel jitter (kills the under-coverage blur). OFF => byte-identical. float denomW = _injectFullJit && vpW > 0f ? vpW : DlssJitterState.InputWidth; float denomH = _injectFullJit && vpH > 0f ? vpH : DlssJitterState.InputHeight; + + // [WORLDRES-LOW v2, 20/07] Sub-window passes are composited with a STRETCH to the + // final image (run #8 proof: vpW denominator => world tremble x2, matching the 2.4x + // overshoot the stretch model predicts). A stretched pass must receive its jitter as + // a fraction of the FINAL input, whatever FULLJIT says, so the on-screen shift equals + // the declared offset exactly. + if (worldResLowPass) + { + denomW = DlssJitterState.InputWidth; + denomH = DlssJitterState.InputHeight; + } + jitterNdcX = DlssJitterState.OffsetX * 2f / denomW; jitterNdcY = DlssJitterState.OffsetY * 2f / denomH; } @@ -1074,12 +1235,22 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed float appliedPxX = inW > 0 ? offX * vpW / inW : 0f; float appliedPxY = inH > 0 ? offY * vpH / inH : 0f; var sc = _state.State.ScreenScissorState; + // [JITTERVAL v2, 20/07] The 07/07 fields above assume the LEGACY denominator (inW) and + // are blind to FULLJIT. truePx = offset scaled by the denominator ACTUALLY used for the + // NDC this frame (same expression as the inject block above), so the applied-vs-declared + // proof measures the real consumption point whatever the knobs. Log-only, same gate. + float jvDenomW = _injectFullJit && vpW > 0f ? vpW : DlssJitterState.InputWidth; + float jvDenomH = _injectFullJit && vpH > 0f ? vpH : DlssJitterState.InputHeight; + float truePxX = jvDenomW > 0f ? offX * vpW / jvDenomW : 0f; + float truePxY = jvDenomH > 0f ? offY * vpH / jvDenomH : 0f; Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, $"JITTERVAL-GEO frame={DlssJitterState.FrameId} vpW={(int)vpW} vpH={(int)vpH} " + $"inW={inW} inH={inH} offX={offX:0.0000} offY={offY:0.0000} " + $"ndcX={ndcX:0.00000} ndcY={ndcY:0.00000} appliedPxX={appliedPxX:0.0000} appliedPxY={appliedPxY:0.0000} " + $"rtColW={_jvColorW} rtColH={_jvColorH} rtDepthW={_jvDepthW} rtDepthH={_jvDepthH} " + - $"scX={sc.X} scY={sc.Y} scW={sc.Width} scH={sc.Height}"); + $"scX={sc.X} scY={sc.Y} scW={sc.Width} scH={sc.Height} " + + $"denomW={(int)jvDenomW} denomH={(int)jvDenomH} fulljit={(_injectFullJit ? 1 : 0)} " + + $"truePxX={truePxX:0.0000} truePxY={truePxY:0.0000}"); } // [JITTER-PASSPROBE, 07/07] One line per unique (width, height, depth-bound) pass geometry. @@ -1879,6 +2050,10 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed addressesSpan[index] = baseAddress + shader.Offset; } + // [SCENEPROBE] stable per-pass identity for the current draw (guest shader VAs). + _probeSceneFsAddr = addressesSpan[5]; // fragment + _probeSceneVsAddr = addressesSpan[1]; // vertex (main) + int samplerPoolMaximumId = _state.State.SamplerIndex == SamplerIndex.ViaHeaderIndex ? _state.State.TexturePoolState.MaximumId : _state.State.SamplerPoolState.MaximumId; @@ -1979,6 +2154,7 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed _currentProgramInfo[stageIndex] = info; } + if (gs.Shaders[5]?.Info.UsesFragCoord == true) { // Make sure we update the viewport size on the support buffer if it will be consumed on the new shader. diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Twod/MvppTwodProbe.cs b/src/Ryujinx.Graphics.Gpu/Engine/Twod/MvppTwodProbe.cs new file mode 100644 index 000000000..81e4a2250 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Engine/Twod/MvppTwodProbe.cs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Engine.Twod +{ + /// + /// Recensement des blits du MOTEUR 2D (RYUJINX_TWOD_PROBE=1). READ-ONLY, off par défaut. + /// + /// POURQUOI ICI, ET PAS AILLEURS. Au 21/07, l'artefact XC2 a été poursuivi étage par étage et chacun + /// est tombé sur mesure : + /// - présentation / blit final / HDR / swapchain : hors de cause (paire même-image guest+swapchain) ; + /// - états de rastérisation : viewport, scissor, screen scissor, clip, miroir, swizzle = 6 mesures, + /// 0 anomalie sur ~90 000 draws ; + /// - appels de dessin : TOUS capturés sur des images entières, la scène est propre au dernier draw + /// alors que l'image présentée est détruite ; + /// - compute : 0 dispatch par image, compteur visible dans le log. + /// Il ne reste que les COPIES. Le moteur 2D est un moteur SÉPARÉ du 3D : aucune sonde de ce dossier ne + /// le regarde, puisqu'elles sont toutes accrochées aux draws. + /// + /// CE QU'ON MESURE. Les rectangles source et destination de chaque blit, et surtout les cas anormaux : + /// coordonnée négative, région qui dépasse la texture, source et destination de tailles différentes. + /// L'analyse forensique avait désigné TextureCopy.Blit comme le seul mécanisme de copie capable de + /// mordre les DEUX bords à la fois (clamps indépendants sur la source et sur la destination, avec des + /// coordonnées qui peuvent devenir négatives) -- c'est exactement la géométrie de l'artefact. + /// + static class MvppTwodProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_TWOD_PROBE") == "1"; + + private static bool _announced; + private static long _nextLogMs; + private static int _blits; + private static int _negatives; // une coordonnée source ou destination est négative + private static int _resized; // la région source n'a pas la même taille que la destination + private static readonly Dictionary _shapes = new(); + + /// + /// Battement branché sur la fin d'image, appelé depuis Gpu/Window.Present. Indispensable : la + /// synthèse ci-dessous ne s'écrivait qu'au premier blit, donc "aucune ligne dans le log" ne + /// distinguait pas "aucun blit 2D" de "sonde jamais armée". Ici la ligne sort même à zéro. + /// + public static void OnPresent() + { + if (!_enabled) + { + return; + } + + long now = Environment.TickCount64; + + if (now < _nextLogMs) + { + return; + } + + _nextLogMs = now + 2000; + _announced = true; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP TWOD: {_blits} blits, {_negatives} hors bornes, {_resized} redimensionnes, {_shapes.Count} formes distinctes."); + + int shown = 0; + + foreach (KeyValuePair kv in _shapes) + { + if (shown++ >= 8) + { + break; + } + + Logger.Info?.Print(LogClass.Gpu, $"MVPP TWOD: x{kv.Value} {kv.Key}"); + } + + _blits = 0; + _negatives = 0; + _resized = 0; + _shapes.Clear(); + } + + public static void OnBlit( + int srcX1, int srcY1, int srcX2, int srcY2, + int dstX1, int dstY1, int dstX2, int dstY2, + int srcW, int srcH, int dstW, int dstH) + { + if (!_enabled) + { + return; + } + + try + { + if (!_announced) + { + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP TWOD: ON -- recensement des blits du moteur 2D, lecture seule, 1 ligne toutes les 2 s."); + } + + _blits++; + + if (srcX1 < 0 || srcY1 < 0 || dstX1 < 0 || dstY1 < 0 || srcX2 > srcW || srcY2 > srcH) + { + _negatives++; + } + + if ((srcX2 - srcX1) != (dstX2 - dstX1) || (srcY2 - srcY1) != (dstY2 - dstY1)) + { + _resized++; + } + + // Une forme = la géométrie complète du blit. C'est elle qui dira si XC2 recopie des bandes + // latérales, et à quelles abscisses exactes. + string shape = $"src[{srcX1},{srcY1}->{srcX2},{srcY2}]/{srcW}x{srcH} dst[{dstX1},{dstY1}->{dstX2},{dstY2}]/{dstW}x{dstH}"; + _shapes[shape] = _shapes.TryGetValue(shape, out int n) ? n + 1 : 1; + + // Le log periodique vit dans OnPresent : il doit sortir meme quand il n'y a aucun blit. + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP TWOD: desactive apres erreur: {e.Message}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs b/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs index c77aee330..f3a6d5541 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs @@ -295,6 +295,13 @@ namespace Ryujinx.Graphics.Gpu.Engine.Twod FormatInfo dstCopyTextureFormat = dstCopyTexture.Format.Convert(); + // Dernier étage jamais instrumenté du dossier XC2 : le moteur 2D. Lecture seule, gated. + MvppTwodProbe.OnBlit( + srcX1, srcY1, srcX2, srcY2, + dstX1, dstY1, dstX2, dstY2, + srcCopyTexture.Width, srcCopyTexture.Height, + dstCopyTexture.Width, dstCopyTexture.Height); + bool canDirectCopy = GraphicsConfig.Fast2DCopy && srcX2 == dstX2 && srcY2 == dstY2 && IsDataCompatible(srcCopyTexture, dstCopyTexture, srcCopyTextureFormat, dstCopyTextureFormat) && @@ -396,6 +403,8 @@ namespace Ryujinx.Graphics.Gpu.Engine.Twod dstRegion.X1, dstRegion.Y1, dstRegion.X2, dstRegion.Y2, linearFilter ? "linear" : "point"); + Image.MvppTwinXferProbe.OnCopy("2D-BLIT", srcTexture, dstTexture); // [TWINXFER] read-only, self-gated + srcTexture.HostTexture.CopyTo(dstTexture.HostTexture, srcRegion, dstRegion, linearFilter); dstTexture.SignalModified(); diff --git a/src/Ryujinx.Graphics.Gpu/Image/AutoDeleteCache.cs b/src/Ryujinx.Graphics.Gpu/Image/AutoDeleteCache.cs index 03c931381..c6b5bcce8 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/AutoDeleteCache.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/AutoDeleteCache.cs @@ -119,6 +119,28 @@ namespace Ryujinx.Graphics.Gpu.Image { _totalSize += texture.Size; + // Report the cache state on a timer, NOT only when an eviction happens. First version + // of this probe logged from inside RemoveLeastUsedTexture, so a run with zero evictions + // produced zero lines, and "no line" meant either "no eviction" or "probe not running". + // Same trap as the birth-clear switch earlier today: a diagnostic you cannot SEE is + // worth nothing. Now the line always comes, and the eviction count is a field in it. + if (CacheProbeEnabled) + { + long tickMs = System.Environment.TickCount64; + + if (tickMs - _probeLastLogMs >= 5000) + { + _probeLastLogMs = tickMs; + + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"CACHEPROBE: cache {_textures.Count}/{MaxCapacity} textures, " + + $"{_totalSize / (1024 * 1024)} Mo / {_maxCacheMemoryUsage / (1024 * 1024)} Mo, " + + $"{_probeEvictions} evictions depuis le dernier point."); + + _probeEvictions = 0; + } + } + texture.IncrementReferenceCount(); texture.CacheNode = _textures.AddLast(texture); @@ -162,10 +184,30 @@ namespace Ryujinx.Graphics.Gpu.Image /// /// Removes the least used texture from the cache. /// + // [CACHEPROBE, 21/07] Alex's decisive observation on the Xenoblade 2 artefact: "it is not + // there at the start, it ACCUMULATES -- the more I turn the camera, the more it comes". + // A defect that grows with time is a different family from the one-off race I had been + // chasing all day, and the obvious thing that grows while turning the camera is the number + // of loaded textures. This cache is bounded (2048 entries, or a memory budget) and starts + // EVICTING the least recently used once full. Empty cache at first, no eviction, no + // artefact; keep turning, cache fills, evictions begin, and if one of them takes a texture + // that is still needed the game draws something real in the wrong place. Read-only: counts + // evictions and reports the cache state, changes nothing. + internal static readonly bool CacheProbeEnabled = + System.Environment.GetEnvironmentVariable("RYUJINX_CACHE_PROBE") == "1"; + + private int _probeEvictions; + private long _probeLastLogMs; + private void RemoveLeastUsedTexture() { Texture oldestTexture = _textures.First.Value; + if (CacheProbeEnabled) + { + _probeEvictions++; + } + _totalSize -= oldestTexture.Size; if (!oldestTexture.CheckModified(false)) diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppBuilderInProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppBuilderInProbe.cs new file mode 100644 index 000000000..3e19c5739 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppBuilderInProbe.cs @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [BUILDERIN] (RYUJINX_BUILDERIN=1, inert unless set). Read-only, BACKEND-NEUTRAL. + /// + /// Why this exists. The offline bench (journal 226) runs the builder's two translations + /// outside the game on byte-identical inputs and finds them equivalent to well under one + /// 10-bit code on the real target. That contradicts the elimination chain of (222), which + /// concluded the divergence must live in the builder's translation because "inputs equal, + /// constants equal, outputs divergent". One of those premises is therefore wrong -- and the + /// two that were established indirectly are the input identity (measured on dump FILES whose + /// mapping to the shader's samplers was inferred, never read) and the output identity. + /// + /// So this probe stops inferring and reads it: for the builder draw only, which surface is + /// actually bound to each sampler (guest VA, format, size, handle -> fp_t_tcb_<handle>), + /// and the exact constant vec4s it consumes. Run it once per backend and the two logs settle, + /// by reading rather than by matching filenames, whether the builder really is fed the same + /// thing on both paths. + /// + /// Touches no GPU state and allocates nothing on the hot path when disabled. + /// + static class MvppBuilderInProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_BUILDERIN") == "1"; + + // The whole DoF/motion chain, not just the builder: (229) showed the builder writes a + // single 320x180 target and does NOT produce the 64x36 maps, so its consumers are now + // the suspects - and they read their OWN constants, which is why the bench found them + // dead when fed the builder's. + private static readonly (ulong Addr, string Name)[] Passes = + { + (0x1000AD730UL, "builder"), + (0x1000B4530UL, "down64"), + (0x1000C2F30UL, "coc64"), + (0x1000AE430UL, "bokeh_fs"), + (0x100071F30UL, "pingpong"), + }; + + private static readonly HashSet _unknown = new(); + private static readonly HashSet _census = new(); + private static int _censusLines; + + /// Cheap identity of the draw's first colour target, for throttling. + private static int TargetKey(TextureManager texMgr) + { + if (texMgr == null) + { + return 0; + } + + try + { + Texture t = texMgr.GetColorTarget(0); + + return t == null ? 0 : (t.Info.Width * 8191) ^ t.Info.Height; + } + catch + { + return 0; + } + } + + private static void CensusTick(TextureManager texMgr, ulong fsAddr) + { + if (texMgr == null || _censusLines >= 400) + { + return; + } + + for (int i = 0; i < 8; i++) + { + Texture t; + + try + { + t = texMgr.GetColorTarget(i); + } + catch + { + break; + } + + if (t == null) + { + continue; + } + + string key = $"{fsAddr:X}|rt{i}|{t.Info.Width}x{t.Info.Height}|{t.Info.FormatInfo.Format}"; + + if (!_census.Add(key)) + { + continue; + } + + _censusLines++; + Logger.Warning?.Print(LogClass.Gpu, + $"[CENSUS] fs=0x{fsAddr:X} rt{i} {t.Info.Width}x{t.Info.Height} {t.Info.FormatInfo.Format}"); + } + } + private static string _shape = "?"; + + /// The 1280x720 twin the builder sampled on its last draw (journal 242). + public static Texture LastTwin { get; private set; } + + /// True when the draw writes one of the small DoF map shapes. + private static bool MatchesUnknownTarget(TextureManager texMgr, out string shape) + { + shape = null; + + if (texMgr == null) + { + return false; + } + + for (int i = 0; i < 8; i++) + { + Texture t; + + try + { + t = texMgr.GetColorTarget(i); + } + catch + { + break; + } + + if (t == null) + { + continue; + } + + int w = t.Info.Width, h = t.Info.Height; + + if ((w == 64 && (h == 36 || h == 180)) || (w == 320 && h == 36)) + { + shape = $"{w}x{h} {t.Info.FormatInfo.Format}"; + return true; + } + } + + return false; + } + + private static string PassName(ulong addr) + { + foreach ((ulong a, string n) in Passes) + { + if (a == addr) + { + return n; + } + } + + return null; + } + private const int FragmentStageIndex = 4; // vertex=0 ... fragment=4 (guest stage order) + private const int CbufSlot = 3; // fp_c3 + + // The vec4 indices the passes read, from its decompiled source: + // [0] output scale / blend / strength / falloff, [5][6][7][8] the reprojection columns, + // [11] the NDC->pixel scale. Anything else in the buffer is not consumed by this shader. + private static readonly int[] WantedC3 = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 13 }; + + private struct Bound + { + public ulong Va; + public string Fmt; + public int W; + public int H; + public int Handle; + public int Binding; + public string Filter; + public int Levels; + public Texture Tex; + } + + private static readonly List _inputs = new(); + private static bool _armed; + private static bool _failLogged; + + // v2. The first cut logged once and fired at ~8 s - the title screen, where the game + // has motion blur off (fp_c3[0].z = 0 kills the whole camera branch). The bench fed + // with those values is structurally dead, so a one-shot log is worthless. Now it + // re-reports whenever the values actually CHANGE, capped so the log stays readable. + private const int MaxReports = 40; + private static int _reports; + // v5. A single global throttle meant one sampled draw every 250 ms for the WHOLE + // chain, so the busiest passes ate every slot and coc64/bokeh/pingpong were never + // sampled at all (23rd trap). The throttle is per pass now. + private static readonly Dictionary _lastBeat = new(); + private static readonly Dictionary _lastSig = new(); + private static readonly Dictionary _perConfig = new(); + private static long _builderDraws; + private static TextureManager _texMgr; + + public static void OnInput(Texture texture, ShaderStage stage, int handle, int binding, Sampler sampler) + { + if (!Enabled || texture == null || stage != ShaderStage.Fragment) + { + return; + } + + lock (_inputs) + { + if (_inputs.Count >= 64) + { + return; + } + + TextureInfo info = texture.Info; + ulong va; + + try + { + va = texture.Range.GetSubRange(0).Address; + } + catch + { + va = 0; + } + + _inputs.Add(new Bound + { + Va = va, + Fmt = info.FormatInfo.Format.ToString(), + W = info.Width, + H = info.Height, + Handle = handle, + Binding = binding, + // down64's second invocation reduces 320 -> 64 horizontally with NO + // horizontal taps: that factor-5 reduction rests entirely on the sampler. + // The address mode was checked long ago and matches the console; the FILTER + // never was, on either backend. + Levels = texture.Info.Levels, + Tex = texture, + Filter = sampler == null + ? "sampler=null" + : $"min={sampler.ProbeMinFilter} mag={sampler.ProbeMagFilter} " + + $"wrap={sampler.ProbeAddressU}/{sampler.ProbeAddressV}", + }); + } + } + + /// + /// Called once per draw after bindings are committed. Logs the builder's full input + /// identity and constants once, then keeps quiet; always clears the per-draw list so a + /// non-builder draw cannot leak its inputs into the next one. + /// + public static void OnDraw(GpuChannel channel, TextureManager texMgr, ulong fsAddr) + { + if (!Enabled) + { + return; + } + + lock (_inputs) + { + try + { + // CENSUS: every (shader, target shape) pair seen, deduplicated. After the + // 2D engine reported ZERO blits, "no draw writes the 64x36" rests entirely + // on my shape detection - so this stops trusting it and enumerates instead. + CensusTick(texMgr, fsAddr); + + string pass = PassName(fsAddr); + + if (pass == null) + { + // The chain read in (232) has a HOLE: down64 emits 64x180 but coc64 and + // the bokeh consume 64x36, so an unlisted pass performs the vertical + // reduction - and that pass produces the very map whose z-channel + // variation Alex proved to be the trigger. Catch it by its target + // shape rather than by an address we do not have yet. + if (!MatchesUnknownTarget(texMgr, out string shape)) + { + return; + } + + if (_unknown.Add(fsAddr)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[BUILDERIN] *** UNLISTED PASS writing {shape} : guest fs = 0x{fsAddr:X} ***"); + } + + pass = $"unlisted_{fsAddr:X}"; + } + + _builderDraws++; + + // Latch the twin from THIS draw's inputs, and only when this draw is the + // builder - otherwise the last unrelated pass wins and the content probe + // ends up measuring a different texture entirely (29th trap). + if (fsAddr == Passes[0].Addr) + { + foreach (Bound bb in _inputs) + { + if (bb.W == 1280 && bb.H == 720) + { + LastTwin = bb.Tex; + } + } + } + + if (!_armed) + { + _armed = true; + Logger.Warning?.Print(LogClass.Gpu, + "[BUILDERIN] armed: tracking the DoF/motion chain (builder, down64, coc64, bokeh_fs, pingpong)"); + } + + // Sample at most ~4x/s: reading guest memory on every builder draw would + // cost far more than the answer is worth. + // The throttle was keyed on the shader alone, so down64's two invocations + // (64x180 then 64x36) shared one 250 ms slot and the vertical one was never + // sampled. Key it on shader + target shape: same shader, two targets, two + // independent slots. + long now = Environment.TickCount64; + ulong beatKey = fsAddr ^ ((ulong)TargetKey(texMgr) << 40); + _lastBeat.TryGetValue(beatKey, out long beat); + + if (beat != 0 && now - beat < 250) + { + return; + } + + _lastBeat[beatKey] = now; + + _texMgr = texMgr; + string signature = BuildSignature(channel, out string[] lines); + + // 24th trap: budgeting per PASS let constant churn eat all eight slots, so a + // second invocation of the same shader with a DIFFERENT target could never be + // reported. The key is the pass plus its I/O shape, so every distinct + // configuration gets its own budget. + string config = pass + "|" + _shape; + + _lastSig.TryGetValue(config, out string prev); + _perConfig.TryGetValue(config, out int seen); + + if (signature == prev || seen >= 4 || _reports >= MaxReports) + { + return; + } + + bool first = prev == null; + _lastSig[config] = signature; + _perConfig[config] = seen + 1; + _reports++; + + Logger.Warning?.Print(LogClass.Gpu, + first + ? $"[BUILDERIN] --- {pass} [{_shape}] state #1 ---" + : $"[BUILDERIN] --- {pass} [{_shape}] state #{seen + 1} CHANGED ---"); + + foreach (string line in lines) + { + Logger.Warning?.Print(LogClass.Gpu, line); + } + + if (_reports == MaxReports) + { + Logger.Warning?.Print(LogClass.Gpu, + "[BUILDERIN] report cap reached - further changes are no longer printed"); + } + } + catch (Exception ex) + { + if (!_failLogged) + { + _failLogged = true; + Logger.Warning?.Print(LogClass.Gpu, $"[BUILDERIN] read FAILED: {ex.Message}"); + } + } + finally + { + _inputs.Clear(); + } + } + } + + /// + /// Renders the draw's full input state as log lines, and returns a signature that + /// changes exactly when any of those values changes. + /// + private static string BuildSignature(GpuChannel channel, out string[] lines) + { + List outLines = new(); + StringBuilder sig = new(); + StringBuilder shape = new(); + + foreach (Bound b in _inputs) + { + outLines.Add( + $"[BUILDERIN] sampler fp_t_tcb_{b.Handle:X} (binding {b.Binding}) " + + $"<- VA 0x{b.Va:X} {b.W}x{b.H} {b.Fmt} mips={b.Levels} [{b.Filter}]"); + sig.Append($"{b.Handle:X}:{b.Va:X}:{b.W}x{b.H}:{b.Levels}:{b.Filter};"); + shape.Append($"{b.W}x{b.H},"); + } + + if (_inputs.Count == 0) + { + outLines.Add("[BUILDERIN] no sampled inputs recorded for this draw"); + sig.Append("noinputs;"); + } + + AppendTargets(_texMgr, outLines, sig, shape); + AppendCbuf(channel, CbufSlot, "fp_c3", WantedC3, outLines, sig); + AppendCbuf(channel, 1, "fp_c1", new[] { 0 }, outLines, sig); + + lines = outLines.ToArray(); + _shape = shape.ToString(); + + return sig.ToString(); + } + + /// + /// The draw's colour targets. (219) attributed a divergence to "the builder's output" + /// but measured it partly on the 64x36 maps, which two later passes produce. Reading the + /// targets here says, without inference, which surface the builder actually writes. + /// + private static void AppendTargets(TextureManager texMgr, List lines, StringBuilder sig, StringBuilder shape) + { + if (texMgr == null) + { + lines.Add("[BUILDERIN] no texture manager for this draw"); + sig.Append("notexmgr;"); + return; + } + + int found = 0; + + for (int i = 0; i < 8; i++) + { + Texture t; + + try + { + t = texMgr.GetColorTarget(i); + } + catch + { + break; + } + + if (t == null) + { + continue; + } + + found++; + TextureInfo info = t.Info; + ulong va; + + try + { + va = t.Range.GetSubRange(0).Address; + } + catch + { + va = 0; + } + + lines.Add($"[BUILDERIN] OUTPUT rt{i} -> VA 0x{va:X} {info.Width}x{info.Height} {info.FormatInfo.Format}"); + sig.Append($"rt{i}:{va:X}:{info.Width}x{info.Height}:{info.FormatInfo.Format};"); + shape.Append($"{info.Width}x{info.Height}>"); + } + + if (found == 0) + { + lines.Add("[BUILDERIN] OUTPUT: no colour target reported"); + sig.Append("nort;"); + } + } + + private static void AppendCbuf( + GpuChannel channel, + int slot, + string name, + int[] indices, + List lines, + StringBuilder sig) + { + uint useMask = channel.BufferManager.GetGraphicsUniformBufferUseMask(FragmentStageIndex); + + if ((useMask & (1u << slot)) == 0) + { + lines.Add($"[BUILDERIN] {name} (slot {slot}) not bound for this draw"); + sig.Append($"{name}:unbound;"); + return; + } + + ulong addr = channel.BufferManager.GetGraphicsUniformBufferAddress(FragmentStageIndex, slot); + + if (addr == 0 || addr == ulong.MaxValue) + { + lines.Add($"[BUILDERIN] {name} (slot {slot}) has no address"); + sig.Append($"{name}:noaddr;"); + return; + } + + foreach (int i in indices) + { + StringBuilder sb = new(); + sb.Append($"[BUILDERIN] {name}.data[{i}] ="); + + for (int c = 0; c < 4; c++) + { + float f = BitConverter.Int32BitsToSingle( + channel.MemoryManager.Physical.Read(addr + (ulong)(i * 16 + c * 4))); + + sb.Append($" {f:G9}"); + sig.Append($"{f:G9},"); + } + + lines.Add(sb.ToString()); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppCacheProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppCacheProbe.cs new file mode 100644 index 000000000..3208a48ab --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppCacheProbe.cs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// Recensement des opérations INTERNES du cache de textures (RYUJINX_CACHEOPS=1). READ-ONLY, off par défaut. + /// + /// DERNIER ÉTAGE DEBOUT du dossier XC2 au 21/07. Tous les autres sont tombés sur mesure : + /// - présentation / blit final / HDR / swapchain : paire MÊME-IMAGE guest+swapchain ; + /// - états de rastérisation : 6 mesures, ~90 000 draws, 0 anomalie ; + /// - appels de dessin : images entières capturées draw par draw, scène PROPRE au dernier draw + /// alors que l'image présentée est détruite ; + /// - compute : 0 dispatch par image ; + /// - moteur 2D : 0 blit sur 61 relevés. + /// Le jeu produit donc une image propre et ne demande AUCUNE copie. Ce qui reste ne peut être que ce + /// que l'émulateur fait de lui-même : recopier entre textures qui se chevauchent en mémoire, + /// resynchroniser depuis la mémoire invitée, créer des vues partielles. + /// + /// /!\ L'ancien verdict du journal « cache de textures : 363/2048 entrées, 0 éviction » ne mesurait QUE + /// les évictions. Les copies de dépendance et SynchronizeMemory n'ont JAMAIS été comptées. + /// + /// Le battement est branché sur la FIN D'IMAGE, jamais sur l'événement mesuré : deux fois ce soir un + /// compteur qui ne s'annonçait qu'en cas de trouvaille a rendu un run inexploitable, parce que + /// « aucune ligne » ne distinguait pas « rien trouvé » de « jamais armé ». + /// + static class MvppCacheProbe + { + private static bool _enabled = + Environment.GetEnvironmentVariable("RYUJINX_CACHEOPS") == "1"; + + private static long _nextLogMs; + private static int _copyDeps; // copies de dépendance créées entre textures qui se chevauchent + private static int _syncs; // APPELS à SynchronizeMemory (la plupart ressortent sans rien faire) + private static int _mismatch; // ... dont les deux textures n'ont PAS la même largeur + private static int _realSyncs; // appels qui rechargent VRAIMENT des données (texture sale) + private static int _realNoData; // ... dont la texture n'avait pas encore de données (SynchronizeFull) + private static readonly Dictionary _realShapes = new(); // formes qui rechargent, par fréquence + private static readonly Dictionary _pairs = new(); + + /// Un appel qui recharge RÉELLEMENT des données depuis la mémoire invitée. + public static void OnRealSync(int w, int h, Format fmt, bool hadData) + { + if (!_enabled) + { + return; + } + + _realSyncs++; + + if (!hadData) + { + _realNoData++; + } + + string key = $"{w}x{h} {fmt}"; + _realShapes[key] = _realShapes.TryGetValue(key, out int n) ? n + 1 : 1; + } + + /// Une copie de dépendance vient d'être créée entre deux textures qui se chevauchent. + public static void OnCopyDependency(Texture a, Texture b) + { + if (!_enabled || a == null || b == null) + { + return; + } + + try + { + _copyDeps++; + + bool diff = a.Info.Width != b.Info.Width || a.Info.Height != b.Info.Height || + a.Info.FormatInfo.Format != b.Info.FormatInfo.Format; + + if (diff) + { + _mismatch++; + } + + // Une largeur ou un format différent entre deux textures qui partagent la MÊME mémoire, + // c'est le mécanisme canonique qui recopie du contenu valide au mauvais endroit : une ligne + // de l'une tombe au milieu d'une ligne de l'autre. + string key = $"{a.Info.Width}x{a.Info.Height} {a.Info.FormatInfo.Format} <-> {b.Info.Width}x{b.Info.Height} {b.Info.FormatInfo.Format}{(diff ? " <<< DIFFERENT" : "")}"; + _pairs[key] = _pairs.TryGetValue(key, out int n) ? n + 1 : 1; + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"MVPP CACHEOPS: desactive apres erreur: {e.Message}"); + } + } + + /// Une texture vient d'être resynchronisée depuis la mémoire invitée. + public static void OnSynchronize() + { + if (_enabled) + { + _syncs++; + } + } + + /// Battement, branché sur la fin d'image. Sort même quand tous les compteurs sont à zéro. + public static void OnPresent() + { + if (!_enabled) + { + return; + } + + long now = Environment.TickCount64; + + if (now < _nextLogMs) + { + return; + } + + _nextLogMs = now + 2000; + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CACHEOPS: {_copyDeps} copies dep ({_mismatch} DIFFERENTES) | {_syncs} appels sync dont " + + $"{_realSyncs} RECHARGENT ({_realNoData} sans donnees prealables) sur {_realShapes.Count} formes."); + + int shown = 0; + + foreach (KeyValuePair kv in _realShapes) + { + if (shown++ >= 10) + { + break; + } + + Logger.Info?.Print(LogClass.Gpu, $"MVPP CACHEOPS: recharge x{kv.Value} {kv.Key}"); + } + + _copyDeps = 0; + _syncs = 0; + _mismatch = 0; + _realSyncs = 0; + _realNoData = 0; + _pairs.Clear(); + _realShapes.Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppConstDiffProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppConstDiffProbe.cs new file mode 100644 index 000000000..5f7a48972 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppConstDiffProbe.cs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [CONSTDIFF] (RYUJINX_CONSTDIFF=1, inert unless set). Journal 221, read-only, + /// BACKEND-NEUTRAL (lives in the Gpu layer: the same probe serves GL and Vulkan). + /// + /// 219 proved the builder transforms comparable texture inputs differently per backend; + /// 220-221 proved its MUFU precision is a lever but the calibration sweep dead-ends + /// (only full quantization is clean). The last ingredient never compared: the builder's + /// CONSTANTS at pass time -- fp_c3[5..8] is the PREVIOUS-FRAME reprojection matrix. A + /// stale matrix is invisible at rest (prev==cur) and wrong exactly in motion: the + /// artifact's oldest signature. Logs, once per builder draw, one row of that matrix: + /// a value REPEATING across consecutive frames during motion = staleness caught. + /// + static class MvppConstDiffProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_CONSTDIFF") == "1"; + + private const ulong BuilderFsAddress = 0x1000AD730UL; + private const int FragmentStageIndex = 4; // vertex=0 ... fragment=4 (guest stage order) + private const int CbufSlot = 3; // fp_c3 + + private static bool _armedLogged; + private static bool _failLogged; + private static float _lastA; + private static float _lastB; + private static int _holdCount; + private static long _lines; + private static long _draws; + private static long _changes; + private static int _maxHold; + private static long _heartbeatMs; + private static readonly float[] _slotLast = new float[8]; + private static readonly long[] _slotChanges = new long[8]; + private static readonly int[] _slotHold = new int[8]; + private static readonly int[] _slotMaxHold = new int[8]; + + public static void OnDraw(GpuChannel channel, ulong fsAddr) + { + if (!Enabled || fsAddr != BuilderFsAddress) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[CONSTDIFF] armed: tracing the builder's fp_c3 prev-frame matrix per draw (repeat across frames in motion = stale)"); + + for (int st = 0; st < 5; st++) + { + uint mask; + + try + { + mask = channel.BufferManager.GetGraphicsUniformBufferUseMask(st); + } + catch + { + mask = 0xDEAD; + } + + Logger.Warning?.Print(LogClass.Gpu, $"[CONSTDIFF] stage {st}: cbuf use mask = 0x{mask:X}"); + } + } + + _draws++; + + try + { + uint useMask = channel.BufferManager.GetGraphicsUniformBufferUseMask(FragmentStageIndex); + + for (int slot = 0; slot < 8; slot++) + { + if ((useMask & (1u << slot)) == 0) + { + continue; + } + + // GetGraphicsUniformBufferAddress renvoie une adresse DEJA physique + // (Range traduite) : lecture directe, PAS de Translate. + ulong addr = channel.BufferManager.GetGraphicsUniformBufferAddress(FragmentStageIndex, slot); + + if (addr == 0 || addr == ulong.MaxValue) + { + continue; + } + + float a = BitConverter.Int32BitsToSingle(channel.MemoryManager.Physical.Read(addr + 5 * 16)); + + if (_slotLast[slot] != a) + { + _slotChanges[slot]++; + _slotLast[slot] = a; + _slotHold[slot] = 0; + } + else + { + _slotHold[slot]++; + if (_slotHold[slot] > _slotMaxHold[slot]) + { + _slotMaxHold[slot] = _slotHold[slot]; + } + } + } + } + catch (Exception ex) + { + if (!_failLogged) + { + _failLogged = true; + Logger.Warning?.Print(LogClass.Gpu, $"[CONSTDIFF] read FAILED: {ex.Message}"); + } + + return; + } + + long now = Environment.TickCount64; + if (now - _heartbeatMs >= 5000) + { + _heartbeatMs = now; + Logger.Warning?.Print(LogClass.Gpu, $"[CONSTDIFF/HB ~5s] builderDraws={_draws}"); + + for (int slot = 0; slot < 8; slot++) + { + if (_slotChanges[slot] > 0 || _slotMaxHold[slot] > 0) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[CONSTDIFF/HB ~5s] slot={slot} draws={_draws} changes={_slotChanges[slot]} maxHold={_slotMaxHold[slot] + 1} last={_slotLast[slot]:G9}"); + } + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppContentProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppContentProbe.cs new file mode 100644 index 000000000..ec85a0569 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppContentProbe.cs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [MVHASH] (RYUJINX_MVHASH=1, inert unless set). Read-only, BACKEND-NEUTRAL. + /// + /// Journal 241 closed the shader family: every stage of the DoF chain - builder, down64, + /// pingpong, bokeh fragment AND the bokeh geometry shader - is bit-identical between OpenGL + /// and Vulkan when fed identical inputs, proven on an offline bench. And 229 proved the + /// builder's inputs are identical in IDENTITY: same guest address, same format, same size, + /// same constants. What was never compared is their CONTENT. + /// + /// By elimination the content must differ, and it is produced upstream by the ~730 draws + /// that write the motion-vector twin. This measures that content directly, at the moment + /// the builder reads it. + /// + /// Comparing two runs pose-for-pose is impossible by hand, so this does NOT rely on a hash + /// matching. It reports pose-robust occupancy statistics - what fraction of the twin carries + /// a written motion vector rather than its clear value - sampled over the whole run. A + /// backend where a class of geometry fails to export its vectors shows a systematically + /// different occupancy, whatever the camera is doing. That is the (217) observation, this + /// time measured over a distribution instead of a single snapshot (the mistake 218 caught). + /// + static class MvppContentProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVHASH") == "1"; + + private const ulong BuilderFsAddress = 0x1000AD730UL; + private const int SampleIntervalMs = 500; + private const int MaxSamples = 60; + + private static long _lastMs; + private static int _samples; + private static bool _failed; + private static readonly List _occupancy = new(); + private static readonly List _meanAbs = new(); + + public static void OnBuilderDraw(TextureManager texMgr, ulong fsAddr) + { + if (!Enabled || fsAddr != BuilderFsAddress || _failed || _samples >= MaxSamples) + { + return; + } + + long now = Environment.TickCount64; + + if (now - _lastMs < SampleIntervalMs) + { + return; + } + + _lastMs = now; + + try + { + Sample(texMgr); + } + catch (Exception ex) + { + _failed = true; + Logger.Warning?.Print(LogClass.Gpu, $"[MVHASH] read FAILED: {ex.Message}"); + } + } + + private static void Sample(TextureManager texMgr) + { + Texture twin = MvppBuilderInProbe.LastTwin; + + if (twin == null) + { + return; + } + + ReadOnlySpan bytes = twin.HostTexture.GetData().Get(); + + if (bytes.Length < 4) + { + return; + } + + int words = bytes.Length / 4; + long written = 0; + double sumAbs = 0; + ulong hash = 14695981039346656037UL; + + // The twin is A2B10G10R10: R and G carry sqrt-encoded magnitude, the 2-bit alpha the + // signs. A texel still at its clear value has R and G at zero, so "occupancy" is the + // share of texels some draw actually wrote a vector into. + for (int i = 0; i < words; i++) + { + uint w = (uint)(bytes[i * 4] | + (bytes[i * 4 + 1] << 8) | + (bytes[i * 4 + 2] << 16) | + (bytes[i * 4 + 3] << 24)); + + uint r = w & 1023; + uint g = (w >> 10) & 1023; + + if (r != 0 || g != 0) + { + written++; + sumAbs += (r + g) / 1023.0; + } + + if ((i & 63) == 0) + { + hash = (hash ^ w) * 1099511628211UL; + } + } + + double occ = 100.0 * written / words; + double mean = written > 0 ? sumAbs / written : 0.0; + + _occupancy.Add(occ); + _meanAbs.Add(mean); + _samples++; + + Logger.Warning?.Print(LogClass.Gpu, + $"[MVHASH] sample {_samples,2}: twin {twin.Info.Width}x{twin.Info.Height} " + + $"occupancy={occ:F3}% meanMag={mean:F5} hash=0x{hash:X16}"); + + if (_samples % 10 == 0) + { + Report(); + } + } + + private static void Report() + { + if (_occupancy.Count == 0) + { + return; + } + + List o = new(_occupancy); + o.Sort(); + + Logger.Warning?.Print(LogClass.Gpu, + $"[MVHASH/SUMMARY] n={o.Count} occupancy min={o[0]:F3}% " + + $"median={o[o.Count / 2]:F3}% max={o[^1]:F3}%"); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppDofProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppDofProbe.cs new file mode 100644 index 000000000..4170b0cbe --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppDofProbe.cs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [DOFPROBE, READ-ONLY, default OFF] Grounding probe for the XC2 depth-of-field bokeh + /// artifact (a temporal history buffer that diverges under camera motion and accumulates). + /// Nsight located the bokeh pass writing a low-res 512x288 target (event 11872) and reading + /// R8G8B8A8 inputs (candidates Image_4615 / Image_4601). This probe reproduces that mapping + /// emulator-side: when a draw's colour render target is the low-res DoF buffer, it logs each + /// distinct input texture (format, size, guest VA) plus how many DoF draws it has fed. The + /// history/feedback buffer is the input that persists on (nearly) every DoF draw; its + /// format+size+VA signature is what the later 8-bit -> 16f experiment must target. + /// A separate census lists every small render-target size seen, so if the real DoF size + /// differs from 512x288 we still learn it in a single run instead of logging nothing. + /// Enable with RYUJINX_DOF_PROBE=1. Never modifies bindings, textures, samplers or targets. + /// + static class MvppDofProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_DOF_PROBE") == "1"; + + // Nsight-confirmed low-res DoF target on XC2 with res-scale 1 (Image_4637, event 11872). + private const int DofWidth = 512; + private const int DofHeight = 288; + + // Anything this small is a candidate small pass (DoF / bloom / blur chain). Census only. + private const int SmallRtMax = 640; + + private static readonly HashSet _smallRtSizes = new(); + private static readonly Dictionary _dofInputs = new(); + + /// Guest VAs of every texture seen as an input to the low-res DoF pass. Read by + /// to flag which temporal-history buffers actually feed the DoF. + public static readonly HashSet DofInputVas = new(); + private static long _lastSummaryMs; + private static long _lastHeartbeatMs; + private static long _totalBinds; + private static bool _armed; + private static int _announced; + + /// + /// Logs the gate state exactly once per process, regardless of , so + /// every run makes it visible whether the probe is armed. Removes the "no lines" ambiguity + /// (probe off vs. probe on but never reached the DoF pass). Cheap: a single interlocked + /// exchange after the first call. Thread-safe (this path runs on multiple GPU threads). + /// + public static void AnnounceOnce() + { + if (System.Threading.Interlocked.Exchange(ref _announced, 1) != 0) + { + return; + } + + string v = Environment.GetEnvironmentVariable("RYUJINX_DOF_PROBE"); + Logger.Info?.Print(LogClass.Gpu, + $"DOFPROBE gate check: RYUJINX_DOF_PROBE={(v ?? "")} -> enabled={Enabled}. " + + (Enabled ? "Probe ACTIVE." : "Probe OFF -- launch via XC2_DOF_PROBE.bat to enable.")); + } + + /// + /// Inspects one input texture bound during a draw, with the draw's active colour target + /// (call site is gated on ). Purely observational. + /// + /// The input texture being bound + /// The draw's active colour render target (may be null) + /// The shader stage this texture is bound to (DoF reads are Fragment) + public static void OnInput(Texture texture, Texture renderTarget, ShaderStage stage) + { + if (texture == null || renderTarget == null) + { + return; + } + + TextureInfo rt = renderTarget.Info; + + lock (_dofInputs) + { + _totalBinds++; + + // Heartbeat so "no DoF lines" is never ambiguous (a gated switch you cannot SEE + // in the log is worthless -- lesson from this dossier). If OnInput ever runs, we + // log "armed" once; then a periodic "alive" line proves the hook is live and how + // many draws/small-RTs/DoF-inputs it has counted -- distinguishing "probe off" + // from "probe on but never reached the DoF pass (still at a 2D menu/loading)". + if (!_armed) + { + _armed = true; + Logger.Info?.Print(LogClass.Gpu, + "DOFPROBE armed: hook is live (RYUJINX_DOF_PROBE=1). Waiting for the low-res DoF pass -- get IN-GAME and move the camera."); + } + + long hb = Environment.TickCount64; + if (hb - _lastHeartbeatMs > 5000) + { + _lastHeartbeatMs = hb; + Logger.Info?.Print(LogClass.Gpu, + $"DOFPROBE alive: binds={_totalBinds} smallRTsizes={_smallRtSizes.Count} dofInputs={_dofInputs.Count}"); + } + + // Census: record every distinct small render-target size once. Confirms the real + // DoF resolution without hard-assuming 512x288. + if (rt.Width <= SmallRtMax) + { + string rtSize = $"{rt.Width}x{rt.Height} {rt.FormatInfo.Format}"; + if (_smallRtSizes.Add(rtSize)) + { + Logger.Info?.Print(LogClass.Gpu, $"DOFPROBE small-RT seen: {rtSize}"); + } + } + + // Grounding gate: only draws that render INTO the low-res DoF buffer. + if (rt.Width != DofWidth || rt.Height != DofHeight) + { + return; + } + + TextureInfo info = texture.Info; + + ulong va; + try + { + va = texture.Range.GetSubRange(0).Address; + } + catch + { + va = 0; + } + + DofInputVas.Add(va); // cross-reference key for the feedback probe + + string key = + $"stage={stage} @0x{va:X}/{info.FormatInfo.Format}/{info.Width}x{info.Height}/lv{info.Levels}/{info.Target}"; + + if (_dofInputs.TryGetValue(key, out long n)) + { + _dofInputs[key] = n + 1; + } + else + { + _dofInputs[key] = 1; + Logger.Info?.Print(LogClass.Gpu, $"DOFPROBE new DoF input: {key}"); + } + + long now = Environment.TickCount64; + if (now - _lastSummaryMs > 5000) + { + _lastSummaryMs = now; + Logger.Info?.Print(LogClass.Gpu, + $"DOFPROBE summary: {_dofInputs.Count} distinct DoF inputs (most persistent = history candidate) ====="); + foreach (KeyValuePair kv in _dofInputs) + { + Logger.Info?.Print(LogClass.Gpu, $"DOFPROBE hits={kv.Value,7} {kv.Key}"); + } + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppFeedbackProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppFeedbackProbe.cs new file mode 100644 index 000000000..66110d8ed --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppFeedbackProbe.cs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [FEEDBACKPROBE, READ-ONLY, default OFF] Frame-to-frame feedback detector for the XC2 + /// temporal artifact. For every texture ever used as a colour render target, it classifies + /// the transition across each guest frame boundary (Window.Present): + /// + /// Case A = written in frame N, and its FIRST access in frame N+1 is a READ + /// (read before any write) ==> TRUE temporal history buffer. + /// Case B = written in frame N, and its FIRST access in frame N+1 is a WRITE + /// (write before any read) ==> transient render target reused every frame. + /// + /// Writes are observed at SetRenderTargetColor (a texture bound as a colour target = + /// SignalModifying(true)); reads at CommitTextureBindings (a texture bound as a sampled input). + /// All three call sites (write, read, frame boundary) run on the GPU command thread, so the + /// first-access ordering within a frame is the true GPU order. Only render-target textures are + /// tracked: a read of a texture never seen as a target is a single dictionary miss, no insert. + /// Enable with RYUJINX_FEEDBACK_PROBE=1. Never modifies textures, targets or bindings. + /// + static class MvppFeedbackProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_FEEDBACK_PROBE") == "1"; + + private sealed class Rec + { + public long LastWriteFrame = long.MinValue; + public long FirstAccessFrame = long.MinValue; + public long CaseA; // write(N) -> read-first(N+1): temporal history + public long CaseB; // write(N) -> write-first(N+1): transient reused + public string Format; + public int Width; + public int Height; + public ulong Va; + } + + private static readonly Dictionary _rts = new(); + private static long _frame; + private static long _lastReportMs; + private static int _announced; + + /// Logs the gate state once per process, even when OFF (removes the "no lines" ambiguity). + public static void AnnounceOnce() + { + if (System.Threading.Interlocked.Exchange(ref _announced, 1) != 0) + { + return; + } + + string v = Environment.GetEnvironmentVariable("RYUJINX_FEEDBACK_PROBE"); + Logger.Info?.Print(LogClass.Gpu, + $"FEEDBACKPROBE gate check: RYUJINX_FEEDBACK_PROBE={(v ?? "")} -> enabled={Enabled}. " + + (Enabled ? "Probe ACTIVE." : "Probe OFF -- launch via XC2_FEEDBACK_PROBE.bat to enable.")); + } + + /// Current guest frame counter (for other probes' log lines). + public static long Frame => System.Threading.Interlocked.Read(ref _frame); + + /// Read the frame-to-frame classification of a texture VA (for the trace probe). + public static (long caseA, long caseB) Classify(ulong va) + { + lock (_rts) + { + return _rts.TryGetValue(va, out Rec r) ? (r.CaseA, r.CaseB) : (0, 0); + } + } + + private static ulong Va(Texture t) + { + try + { + return t.Range.GetSubRange(0).Address; + } + catch + { + return 0; + } + } + + /// A texture is bound as a colour render target this frame (a write). + public static void OnWrite(Texture texture) + { + if (texture == null) + { + return; + } + + ulong va = Va(texture); + + lock (_rts) + { + if (!_rts.TryGetValue(va, out Rec rec)) + { + rec = new Rec(); + _rts[va] = rec; + } + + if (rec.FirstAccessFrame != _frame) + { + rec.FirstAccessFrame = _frame; + + // First access of this frame is a WRITE. If it was also written the previous + // frame, it is a transient target reused every frame (Case B). + if (rec.LastWriteFrame == _frame - 1) + { + rec.CaseB++; + } + } + + rec.LastWriteFrame = _frame; + + TextureInfo info = texture.Info; + rec.Format = info.FormatInfo.Format.ToString(); + rec.Width = info.Width; + rec.Height = info.Height; + rec.Va = va; + } + } + + /// A texture is bound as a sampled input this frame (a read). Only render targets are tracked. + public static void OnRead(Texture texture) + { + if (texture == null) + { + return; + } + + ulong va = Va(texture); + + lock (_rts) + { + if (!_rts.TryGetValue(va, out Rec rec)) + { + return; // never a render target -> cannot be a history buffer + } + + if (rec.FirstAccessFrame != _frame) + { + rec.FirstAccessFrame = _frame; + + // First access of this frame is a READ. If it was written the previous frame, + // it carries content across the frame boundary = TRUE temporal history (Case A). + if (rec.LastWriteFrame == _frame - 1) + { + rec.CaseA++; + } + } + } + } + + /// Called at the guest frame boundary (Window.Present). Advances the frame and reports periodically. + public static void OnFrameBoundary() + { + lock (_rts) + { + _frame++; + + long now = Environment.TickCount64; + if (now - _lastReportMs < 2000) + { + return; + } + _lastReportMs = now; + + var caseA = new List(); + foreach (Rec r in _rts.Values) + { + if (r.CaseA > 0) + { + caseA.Add(r); + } + } + + caseA.Sort((a, b) => b.CaseA.CompareTo(a.CaseA)); + + Logger.Info?.Print(LogClass.Gpu, + $"FEEDBACKPROBE report: frame={_frame} trackedRTs={_rts.Count} caseA(history)={caseA.Count} " + + "(*** = also a DoF input; run with RYUJINX_DOF_PROBE=1 to populate) ====="); + + foreach (Rec r in caseA) + { + bool feedsDof = MvppDofProbe.DofInputVas.Contains(r.Va); + + Logger.Info?.Print(LogClass.Gpu, + $"FEEDBACKPROBE CASE A history: caseA={r.CaseA,6} caseB={r.CaseB,6} {r.Format}/{r.Width}x{r.Height} " + + $"va=0x{r.Va:X}{(feedsDof ? " *** FEEDS DoF" : "")}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppFullSyncProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppFullSyncProbe.cs new file mode 100644 index 000000000..c9066a356 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppFullSyncProbe.cs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [FULLSYNC] Read-only probe (RYUJINX_FULLSYNC=1), inert unless set. Changes nothing that is + /// rendered; it only reports which textures get a FULL upload from guest memory, and whether that + /// guest memory is actually empty. + /// + /// Why this is the current suspect. Journal (111) localised the XC2 block corruption to the pass + /// filling slot4, found the guest memory of that surface EMPTY, and measured a period of 8 lines = + /// one GOB. Two facts sat badly together: a desktop GPU has no GOB, so an 8-line period means the + /// data went through a guest block-linear layout at some point -- yet the surface was said to be a + /// pure GPU target that never round-trips through guest memory. + /// + /// Texture.SynchronizeMemory takes `SynchronizeFull()` whenever `_hasData` is false, which is the + /// state of a freshly created texture -- including a view created over an existing overlap, where + /// TextureCache calls SynchronizeMemory immediately after CreateView. SynchronizeFull reads + /// `_physicalMemory.GetSpan(Range)` and uploads it through the block-linear conversion. Uploading + /// empty or stale guest memory over an already-rendered surface would produce exactly what the + /// captures show: flat colour blocks where the memory is zero, displaced valid content where it is + /// stale, at GOB granularity. Nothing else proposed so far explains the flat blocks. + /// + /// This probe does not decide that this is the bug. It answers: does slot4 take this path, and is + /// the data it uploads empty? + /// + static class MvppFullSyncProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_FULLSYNC") == "1"; + + private static readonly HashSet _reported = new(); + + private static bool _armedLogged; + private static int _count; + + /// + /// Positive control. Without it, "no [FULLSYNC] line" cannot be told apart from "the flag never + /// took", and a silence that cannot be read is not a measurement. + /// + public static void ReportArmed() + { + if (!Enabled || _armedLogged) + { + return; + } + + _armedLogged = true; + + Logger.Warning?.Print(LogClass.Gpu, "[FULLSYNC] ARMED (RYUJINX_FULLSYNC=1). Expect one line per distinct texture shape taking the full-upload path."); + } + + // Per-shape counters. The first run showed that BOTH the corrupted surface (R8G8B8A8 720p) and + // the clean one (R11G11B10 720p) take this path with empty guest memory, so "takes the path" is + // not the discriminator. What can still separate them is HOW OFTEN, and whether the texture is + // a VIEW over a parent that already holds rendered pixels -- an empty upload onto a fresh + // texture is harmless, the same upload onto a parent's memory wipes what was drawn. + private static readonly Dictionary _shapeCounts = new(); + + private static int _fullResLogged; + + private const int FullResLogCap = 60; + + public static void OnFullSync(TextureInfo info, ReadOnlySpan data, bool isView) + { + if (!Enabled) + { + return; + } + + _count++; + + string key = $"{info.FormatInfo.Format}|{info.Width}x{info.Height}|{info.Target}"; + + lock (_shapeCounts) + { + _shapeCounts.TryGetValue(key, out int n); + _shapeCounts[key] = n + 1; + + // Full-screen surfaces are the ones journal (111) is about, and they are rare enough to + // log individually. Capped so a runaway loop cannot fill the log and slow the run. + if (info.Width == 1280 && info.Height == 720 && _fullResLogged < FullResLogCap) + { + _fullResLogged++; + + Logger.Warning?.Print(LogClass.Gpu, + $"[FULLSYNC/720p] #{n + 1} {info.FormatInfo.Format} isView={isView} " + + $"| sync {_count}"); + } + } + + lock (_reported) + { + if (!_reported.Add(key)) + { + return; + } + } + + // Sampled rather than exhaustive: these spans reach several megabytes and this runs on the + // render thread. A stride of 4093 (prime) avoids aligning with any power-of-two structure + // in the data, which a round stride could alias with and mistake for "all zero". + int nonZero = 0; + int sampled = 0; + + for (int i = 0; i < data.Length; i += 4093) + { + sampled++; + + if (data[i] != 0) + { + nonZero++; + } + } + + Logger.Warning?.Print(LogClass.Gpu, + $"[FULLSYNC] {info.FormatInfo.Format} {info.Width}x{info.Height} {info.Target} " + + $"({info.FormatInfo.BytesPerPixel} bpp) uploaded FROM GUEST | guest bytes {data.Length} | " + + $"sampled {sampled}, non-zero {nonZero} => {(nonZero == 0 ? "GUEST MEMORY IS EMPTY" : "guest has content")} | " + + $"total full-syncs so far {_count}"); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppGobProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppGobProbe.cs new file mode 100644 index 000000000..6c2f96220 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppGobProbe.cs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// Probe for the fork's gobBlocksInZ clamp (RYUJINX_GOBZ_PROBE=1, off by default). + /// + /// WHY. Xenoblade 2 shows rectangles of displaced-but-real content, in rows, appearing as the + /// camera turns and reportedly worse indoors where more objects are on screen. Measured and + /// ELIMINATED so far, each with the switch verified live in the log: shader cache (purged), + /// DLSS/MV++/FG (log proved mode=0, zero "using mode"), runtime mipmaps (never applied when + /// DLSS is off - checked in the code path), recycled device memory (2000+ fresh allocations + /// zeroed, artefact unchanged), history-reset storm (4 resets in a minute). + /// + /// What is left that can displace real content in rectangles is the BLOCK-LINEAR layout. Switch + /// textures are tiled, and decoding them needs the exact gob parameters the game declared. The + /// fork overrides one of them: for any non-3D target with more than one layer it forces + /// gobBlocksInZ to 1, a change added for the BOTW/TOTK 4K packs that upstream does not have. + /// A wrong stride there detiles the texture with the wrong pitch, which looks like blocks of + /// content moved around - exactly the reported shape. + /// + /// This probe does NOT change behaviour: the clamp still applies. It counts how often it fires + /// and describes what it hit, so the lead is settled by measurement: + /// never fires on XC2 -> dead lead, drop it; + /// fires constantly -> suspect confirmed, and RYUJINX_NO_GOBZ_CLAMP=1 tests it for real. + /// + static class MvppGobProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_GOBZ_PROBE") == "1"; + + /// + /// RYUJINX_NO_GOBZ_CLAMP=1: honour the descriptor instead of forcing gobBlocksInZ to 1. + /// The actual A/B, to be run only once the probe has shown the clamp fires at all. + /// ⚠️ The clamp exists for the BOTW/TOTK 4K packs -- disabling it may bring their original + /// problem back, so this is a diagnostic, never a default. + /// + public static readonly bool ClampDisabled = + Environment.GetEnvironmentVariable("RYUJINX_NO_GOBZ_CLAMP") == "1"; + + private static readonly object _lock = new(); + private static readonly Dictionary _shapes = new(); + private static int _total; + private static long _lastLogMs; + + public static void NoteClamp(int width, int height, int layers, int gobZ, string target, string format) + { + lock (_lock) + { + _total++; + + // Distinct shapes rather than a line per texture: a game binds the same few + // hundred textures over and over, and what we need is WHICH KINDS get clamped. + string key = $"{width}x{height}x{layers} {target} {format} gobZ={gobZ}"; + + _shapes.TryGetValue(key, out int n); + _shapes[key] = n + 1; + + long now = Environment.TickCount64; + + if (now - _lastLogMs < 5000) + { + return; + } + + _lastLogMs = now; + + System.Text.StringBuilder sb = new(); + sb.Append($"GOBPROBE: clamp gobBlocksInZ->1 fired {_total} times, {_shapes.Count} distinct shapes"); + sb.Append(ClampDisabled ? " [CLAMP DESACTIVE]" : ""); + sb.Append(':'); + + int shown = 0; + + foreach (KeyValuePair kv in _shapes) + { + if (shown++ >= 8) + { + sb.Append($" (+{_shapes.Count - 8} autres)"); + + break; + } + + sb.Append($" [{kv.Key} x{kv.Value}]"); + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppMap64Probe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppMap64Probe.cs new file mode 100644 index 000000000..1f881b27a --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppMap64Probe.cs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [MAP64] Read-only probe (RYUJINX_MAP64_PROBE=1), inert unless set. Changes nothing that is + /// rendered; it reports who PRODUCES the small DoF maps and what the host cache does to them. + /// + /// Why. EXP 4 (journal 122) proved the XC2 form-A rectangles require the data of the 64x36 DoF + /// tile map (fp_t_tcb_E): forcing its samples to a constant removes them, and the downstream + /// arithmetic was already exonerated. So the map's CONTENT arrives corrupted -- and every host + /// probe so far (STORAGEID/FULLSYNC/VIEWALIAS) filtered on 1280x720, so this surface was never + /// watched. This probe watches the small shapes (64x36, 320x180, 160x90) on four channels: + /// + /// 1. WRITER passes: every draw whose colour target is one of these shapes -- fragment/vertex + /// guest addresses, target VA and instance, and the VIEWPORT extents of the draw. A viewport + /// smaller than the map would leave the rest of the texels STALE, which is invisible while + /// the camera is still and visible in motion -- exactly the artifact's behaviour. + /// 2. Guest uploads onto them (SynchronizeFull), with a FULL emptiness scan (the texture is + /// tiny): an upload of empty guest memory over a rendered map mid-game would inject garbage. + /// 3. Partial group syncs onto them (the branch never instrumented before). + /// 4. Instance churn: distinct object identities seen per shape (STORAGEID's question, but on + /// the right surface this time). + /// + /// Reading grid, written before coding: writer draws with FULL 64x36 viewport every frame and + /// zero syncs => corruption comes from the writer's INPUTS, move up the chain. In-game uploads + /// or partial viewports or heavy churn => host-side mechanism found, instrument that path next. + /// No [MAP64] lines at all => instrument mute (VOID), never "nothing happens". + /// + static class MvppMap64Probe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MAP64_PROBE") == "1"; + + private static bool _armedLogged; + + /// Positive control (TextureCache ctor). Silence without it is unreadable. + public static void ReportArmed() + { + if (!Enabled || _armedLogged) + { + return; + } + + _armedLogged = true; + + Logger.Warning?.Print(LogClass.Gpu, + "[MAP64] ARMED (RYUJINX_MAP64_PROBE=1). Watching 64x36 / 320x180 / 160x90: writer passes + viewports, guest uploads, group syncs, instance churn."); + } + + private static bool IsWatched(TextureInfo info) + { + return (info.Width == 64 && info.Height == 36) || + (info.Width == 320 && info.Height == 180) || + (info.Width == 160 && info.Height == 90); + } + + private struct In + { + public string Fmt; + public int W; + public int H; + public ShaderStage Stage; + public int Handle; + } + + private static readonly List _inputs = new(); + + /// Per-draw sampled input (TextureBindingsManager, both bind paths). Self-gated. + public static void OnInput(Texture texture, ShaderStage stage, int handle) + { + if (!Enabled || texture == null) + { + return; + } + + lock (_inputs) + { + if (_inputs.Count >= 64) + { + return; + } + + TextureInfo info = texture.Info; + _inputs.Add(new In + { + Fmt = info.FormatInfo.Format.ToString(), + W = info.Width, + H = info.Height, + Stage = stage, + Handle = handle, + }); + } + } + + // One detailed line per distinct (fragment shader, target shape, viewport) -- a NEW viewport + // on a known writer is a finding on its own (partial write), so it re-triggers the log. + private static readonly HashSet<(ulong, string, int, int)> _seenWriters = new(); + + // Instance churn per shape, WRITER side (RuntimeHelpers identity, no field added). + private static readonly Dictionary> _instances = new(); + + private static long _totalDraws; + private static long _mapDraws; + private static long _fullSyncs; + private static long _fullSyncsEmpty; + private static long _groupSyncs; + private static long _summaryMs; + + /// + /// Called once per draw AFTER bindings are committed (StateUpdater probe block, gated there). + /// Viewport extents are the draw's host viewport 0, already multiplied by the RT scale. + /// + public static void OnDraw(TextureManager texMgr, ulong fsAddr, ulong vsAddr, int vpW, int vpH) + { + _totalDraws++; + + try + { + if (texMgr == null) + { + return; + } + + for (int slot = 0; slot < texMgr.ColorTargetsLength; slot++) + { + Texture ct = texMgr.GetColorTarget(slot); + if (ct == null || !IsWatched(ct.Info)) + { + continue; + } + + _mapDraws++; + + TextureInfo info = ct.Info; + string shape = $"{info.FormatInfo.Format}/{info.Width}x{info.Height}"; + int id = RuntimeHelpers.GetHashCode(ct); + + lock (_instances) + { + if (!_instances.TryGetValue(shape, out HashSet set)) + { + _instances[shape] = set = new HashSet(); + } + + set.Add(id); + } + + bool isNew; + lock (_seenWriters) + { + isNew = _seenWriters.Add((fsAddr, shape, vpW, vpH)); + } + + if (isNew) + { + var sb = new StringBuilder(); + lock (_inputs) + { + foreach (In i in _inputs) + { + sb.Append($" <-tcb_{i.Handle:X} {i.Fmt}/{i.W}x{i.H} {i.Stage};"); + } + } + + Logger.Warning?.Print(LogClass.Gpu, + $"[MAP64/WRITER] out_attr{slot} {shape} inst=0x{id:X8} | fs=0x{fsAddr:X} vs=0x{vsAddr:X} " + + $"| VIEWPORT {vpW}x{vpH}{(vpW < info.Width || vpH < info.Height ? " *** PARTIAL ***" : "")} " + + $"| inputs:{sb}"); + } + } + } + finally + { + lock (_inputs) + { + _inputs.Clear(); + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000 && _mapDraws > 0) + { + _summaryMs = now; + + var sb = new StringBuilder(); + lock (_instances) + { + foreach (KeyValuePair> kv in _instances) + { + sb.Append($"{kv.Key}: {kv.Value.Count} instance(s); "); + } + } + + Logger.Warning?.Print(LogClass.Gpu, + $"[MAP64/SUMMARY ~3s] mapDraws={_mapDraws} / totalDraws={_totalDraws} | " + + $"fullSyncs={_fullSyncs} (empty {_fullSyncsEmpty}) | groupSyncs={_groupSyncs} | {sb}"); + } + } + } + + private static long _fullSyncLogged; + private static long _groupSyncLogged; + private const long PerEventLogCap = 40; + + /// Full guest-memory upload onto a watched shape (Texture.SynchronizeFull). Rare = log each, capped. + public static void OnFullSync(Texture texture, ReadOnlySpan data) + { + if (!Enabled || texture == null || !IsWatched(texture.Info)) + { + return; + } + + _fullSyncs++; + + // The texture is tiny (64x36x4 = 9 KiB guest range): scan EVERY byte, no sampling caveat. + bool empty = true; + for (int i = 0; i < data.Length; i++) + { + if (data[i] != 0) + { + empty = false; + break; + } + } + + if (empty) + { + _fullSyncsEmpty++; + } + + if (_fullSyncLogged++ < PerEventLogCap) + { + TextureInfo info = texture.Info; + Logger.Warning?.Print(LogClass.Gpu, + $"[MAP64/FULLSYNC] {info.FormatInfo.Format}/{info.Width}x{info.Height} inst=0x{RuntimeHelpers.GetHashCode(texture):X8} " + + $"isView={texture.IsView} | guest bytes {data.Length} => {(empty ? "GUEST MEMORY EMPTY" : "guest has content")} " + + $"| fullSyncs so far {_fullSyncs}"); + } + } + + /// Partial (per-handle) group sync onto a watched shape (Texture.SynchronizeMemory, _hasData branch). + public static void OnGroupSync(Texture texture) + { + if (!Enabled || texture == null || !IsWatched(texture.Info)) + { + return; + } + + _groupSyncs++; + + if (_groupSyncLogged++ < PerEventLogCap) + { + TextureInfo info = texture.Info; + Logger.Warning?.Print(LogClass.Gpu, + $"[MAP64/GROUPSYNC] {info.FormatInfo.Format}/{info.Width}x{info.Height} inst=0x{RuntimeHelpers.GetHashCode(texture):X8} " + + $"| groupSyncs so far {_groupSyncs}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppMvBufProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppMvBufProbe.cs new file mode 100644 index 000000000..b106381e7 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppMvBufProbe.cs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [MVBUF] Read-only probe (RYUJINX_MVBUF_PROBE=1), inert unless set. Watches the XC2 + /// object-motion-vector buffer (R10G10B10A2Unorm 1280x720), the carrier of the form-A/B + /// corruption (EXP 5, journal 126): with its samples nulled, every artifact form disappears. + /// + /// Why clears matter. That buffer is written per-frame ONLY by moving/skinned geometry; the + /// rest of the screen keeps whatever the buffer already contains, so the game depends on a + /// RELIABLE per-frame clear to a neutral value. A clear that is skipped, scissored short, or + /// reordered leaves RECTANGLES of stale motion -- which the motion-blur chain then renders as + /// the measured smears. Upstream PR #4596 documents exactly this clear/read hazard family on + /// RTX 3000+ and names Xenoblade explicitly; and a MISSING clear is not a timing bug, which is + /// consistent with forced barriers and DeviceWaitIdle having had no effect (journal 112). + /// + /// Reading grid, written before coding: + /// - clears present EVERY frame, full scissor, neutral value -> the clear COMMAND is fine; + /// the failure would be content/decode or host-side execution -> instrument the Vulkan + /// clear path (v2) or the A2 sign-channel decode next; + /// - frames with ZERO clear of this target (and no full-screen writer draw those frames) -> + /// the game relies on a clear path this probe does not see (loadOp/fast clear) or the + /// clear is genuinely skipped -> hook the Vulkan level next; + /// - clears with PARTIAL scissor or component mask != 0xF -> partial-clear family; + /// - no [MVBUF] lines at all with ARMED present -> the buffer is not engine-cleared and not + /// drawn: copy path, v2. + /// + static class MvppMvBufProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVBUF_PROBE") == "1"; + + private static bool _armedLogged; + + public static void ReportArmed() + { + if (!Enabled || _armedLogged) + { + return; + } + + _armedLogged = true; + + Logger.Warning?.Print(LogClass.Gpu, + "[MVBUF] ARMED (RYUJINX_MVBUF_PROBE=1). Watching R10G10B10A2 1280x720: engine clears (value/mask/scissor), writer draws, per-frame reliability."); + } + + private static bool IsMvBuffer(Texture t) + { + return t != null && + t.Info.Width == 1280 && + t.Info.Height == 720 && + t.Info.FormatInfo.Format == Format.R10G10B10A2Unorm; + } + + // Frame accounting (advanced from the present boundary, always on GPU thread). + private static long _frames; + private static int _clearsThisFrame; + private static int _writesThisFrame; + private static long _frames0Clear; + private static long _frames1Clear; + private static long _framesMultiClear; + private static long _frames0Write; + private static long _framesWithWrite; + + private static long _clears; + private static long _writerDraws; + private static long _summaryMs; + + private static readonly HashSet _clearSignatures = new(); + private static readonly HashSet<(ulong, int, int)> _writers = new(); + + // v3: per-RT blend/write-mask census on the MV attachment. A transparent/additive draw + // BLENDING into the MV target (instead of masked-off or opaque-write) would accumulate + // garbage magnitudes exactly where transparency-heavy content sits -- the co-location the + // captures show. Console intent for such draws is mask-off or no MV attachment at all. + private static readonly HashSet _writerStates = new(); + private static long _drawsBlendOnMv; + private static long _drawsPartialMaskOnMv; + + // Host-instance identity split: the object the engine CLEARS vs the object shaders SAMPLE. + // Full overlap = one host texture, identities fine. Disjoint sets = the clear lands on one + // host copy while consumers read another (stale) one -- the desync suspect. + private static readonly HashSet _clearedIds = new(); + private static readonly HashSet _sampledIds = new(); + + /// Guest frame boundary (Window present). Folds the per-frame counters into histograms. + public static void OnPresent() + { + if (!Enabled) + { + return; + } + + // Only count frames once the buffer exists (first clear or write seen), so the + // pre-title-screen frames do not drown the histogram. + if (_clears + _writerDraws > 0) + { + _frames++; + + if (_clearsThisFrame == 0) + { + _frames0Clear++; + } + else if (_clearsThisFrame == 1) + { + _frames1Clear++; + } + else + { + _framesMultiClear++; + } + + if (_writesThisFrame == 0) + { + _frames0Write++; + } + else + { + _framesWithWrite++; + } + } + + _clearsThisFrame = 0; + _writesThisFrame = 0; + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000 && _frames > 0) + { + _summaryMs = now; + + Logger.Warning?.Print(LogClass.Gpu, + $"[MVBUF/SUMMARY ~3s] frames={_frames} | clears/frame: 0x{_frames0Clear} 1x{_frames1Clear} multi x{_framesMultiClear} | " + + $"writes/frame: 0 x{_frames0Write} >=1 x{_framesWithWrite} | totals: clears={_clears} writerDraws={_writerDraws} | " + + $"clearSigs={_clearSignatures.Count} writers={_writers.Count} | " + + $"BLEND-on-MV draws={_drawsBlendOnMv} partial-mask draws={_drawsPartialMaskOnMv}"); + } + } + + /// Engine clear about to hit a colour target (DrawManager.Clear). Logs each DISTINCT signature. + public static void OnClear(Texture target, int index, uint componentMask, ColorF color, bool customScissor, int sx, int sy, int sw, int sh) + { + if (!Enabled || !IsMvBuffer(target)) + { + return; + } + + _clears++; + _clearsThisFrame++; + + string sig = $"rgba=({color.Red:0.###},{color.Green:0.###},{color.Blue:0.###},{color.Alpha:0.###}) mask=0x{componentMask:X} " + + (customScissor ? $"scissor={sx},{sy} {sw}x{sh}" : "scissor=full"); + + lock (_clearSignatures) + { + if (_clearSignatures.Add(sig)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[MVBUF/CLEAR] out_attr{index} NEW SIGNATURE: {sig} | clear #{_clears}"); + } + } + + int id = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(target); + lock (_clearedIds) + { + if (_clearedIds.Add(id)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[MVBUF/ID] CLEAR target instance 0x{id:X8} (new; {_clearedIds.Count} cleared instance(s) so far)"); + } + } + } + + /// Shader sampling of the MV buffer (TextureBindingsManager, both bind paths). Self-gated. + public static void OnSampled(Texture texture) + { + if (!Enabled || !IsMvBuffer(texture)) + { + return; + } + + int id = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture); + lock (_clearedIds) + { + if (_sampledIds.Add(id)) + { + bool overlaps = _clearedIds.Contains(id); + Logger.Warning?.Print(LogClass.Gpu, + $"[MVBUF/ID] SAMPLED instance 0x{id:X8} isView={texture.IsView} " + + $"=> {(overlaps ? "SAME as a cleared instance" : "*** NEVER CLEARED (desync suspect) ***")} " + + $"| sampled {_sampledIds.Count}, cleared {_clearedIds.Count}"); + } + } + } + + /// Per-draw (StateUpdater probe block): draws whose colour target is the MV buffer. + public static void OnDraw(TextureManager texMgr, ulong fsAddr, int vpW, int vpH, ReadOnlySpan blendEnable, ReadOnlySpan writeMasks) + { + if (texMgr == null) + { + return; + } + + for (int slot = 0; slot < texMgr.ColorTargetsLength; slot++) + { + Texture ct = texMgr.GetColorTarget(slot); + if (!IsMvBuffer(ct)) + { + continue; + } + + _writerDraws++; + _writesThisFrame++; + + bool blend = slot < blendEnable.Length && blendEnable[slot]; + uint mask = slot < writeMasks.Length ? writeMasks[slot] : 0xFu; + + if (blend && mask != 0) + { + _drawsBlendOnMv++; + } + + if (mask != 0xF && mask != 0) + { + _drawsPartialMaskOnMv++; + } + + string state = $"out_attr{slot} fs=0x{fsAddr:X} vp={vpW}x{vpH} blend={(blend ? "ON" : "off")} mask=0x{mask:X}"; + bool isNew; + lock (_writerStates) + { + isNew = _writerStates.Add(state); + _writers.Add((fsAddr, vpW, vpH)); + } + + if (isNew && (blend || mask != 0xF)) + { + // Only the suspicious combinations get their own line; clean opaque writers + // stay in the summary counts (there are hundreds of them). + Logger.Warning?.Print(LogClass.Gpu, + $"[MVBUF/WRITER-STATE] {state}{(blend && mask != 0 ? " *** BLEND INTO MV ***" : "")}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppMvSyncProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppMvSyncProbe.cs new file mode 100644 index 000000000..689a75202 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppMvSyncProbe.cs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [MVSYNC] Read-only probe (RYUJINX_MVSYNC_PROBE=1), inert unless set. Journal 158. + /// + /// Last family standing: the poison appears IN the XC2 object-MV buffer (R10G10B10A2 + /// 1280x720) between the writers' stores and the consumers' reads -- every write-side value + /// scrub was negative (NaN, >=0.999, >=0.5), both read-side kills are positive, and the + /// periscope shows saturated garbage in the buffer at rest. This probe watches the three + /// host paths that can INJECT content into an existing texture behind the game's back, + /// none of which were ever measured for this format: + /// 1. TextureGroup.SynchronizePartial -- partial upload of "CPU-dirty" guest pages; + /// 2. Texture.SynchronizeFull (re-sync branch) -- full guest upload over live content; + /// 3. TextureGroupHandle.Copy -- copy-dependency pull from an overlapping texture. + /// + /// Reading grid (written before coding): + /// ARMED + heartbeats all zero -> no injection path fires; family dead -> next = Vulkan + /// clear/loadOp execution level. + /// partial/full syncs > 0 -> stale guest pages uploaded over rendered MV = smoking + /// gun -> instrument WHY dirty (tracking/protection). + /// copy-ins > 0 -> alias family: an overlapping texture overwrites the MV + /// buffer via copy dependency -> identify the source. + /// The 3s heartbeat prints even at zero so a negative is distinguishable from a mute probe. + /// + static class MvppMvSyncProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVSYNC_PROBE") == "1"; + + private static bool _armedLogged; + + private static long _partialSyncs; + private static long _fullSyncs; + private static long _copyIns; + private static long _events; + private static long _summaryMs; + + public static bool IsMvBuffer(Texture t) + { + return t != null && + t.Info.Width == 1280 && + t.Info.Height == 720 && + t.Info.FormatInfo.Format == Format.R10G10B10A2Unorm; + } + + /// Frame boundary (Window present): arming witness + 3s heartbeat, zeros included. + public static void OnPresent() + { + if (!Enabled) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[MVSYNC] ARMED (RYUJINX_MVSYNC_PROBE=1). Watching guest-data injection into R10G10B10A2 1280x720: partial sync / full re-sync / copy-dependency."); + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[MVSYNC/HEARTBEAT ~3s] partialSyncs={_partialSyncs} fullSyncs={_fullSyncs} copyIns={_copyIns}"); + } + } + + /// SynchronizeMemory decided an upload is needed for this storage (dirty branch taken). + public static void OnSyncDecision(Texture storage, bool partial, int regionCount, bool anyModified) + { + if (!Enabled || !IsMvBuffer(storage)) + { + return; + } + + long n; + if (partial) + { + n = ++_partialSyncs; + } + else + { + n = ++_fullSyncs; + } + + if (++_events <= 20 || _events % 100 == 0) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[MVSYNC] GUEST->TEXTURE {(partial ? "PARTIAL" : "FULL")} sync #{n} | regions={regionCount} anyModified={anyModified} | guest data uploaded OVER the MV buffer"); + } + } + + /// A copy dependency pulled data from an overlapping texture into this storage. + public static void OnCopyIn(Texture storage) + { + if (!Enabled || !IsMvBuffer(storage)) + { + return; + } + + long n = ++_copyIns; + + if (++_events <= 20 || _events % 100 == 0) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[MVSYNC] COPY-IN #{n} | copy dependency wrote into the MV buffer from an overlapping texture"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppScenePassProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppScenePassProbe.cs new file mode 100644 index 000000000..51e88d003 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppScenePassProbe.cs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [SCENEPROBE, READ-ONLY, default OFF] Per-draw identifier for the pass that produces the fresh + /// 1280x720 R11G11B10 scene the DoF consumes. Fires only for a draw whose colour target is a + /// 1280x720 R11G11B10Float texture, and only once per distinct fragment-shader guest address, so + /// the output is a short list of the distinct passes writing that format. For each it prints the + /// frame, the fragment + vertex shader guest addresses (stable pass identity, maps to Nsight via + /// the shader), the framebuffer (colour-target) VA, and every sampled texture of that draw with + /// its format/size and whether classifies it as temporal history. + /// The temporal-reconstruction pass is the one whose inputs include a 720p history [H] of its own + /// format (it reads its previous output). Never modifies any GPU state. + /// Enable with RYUJINX_SCENE_PROBE=1 (run alongside RYUJINX_FEEDBACK_PROBE=1 for the [H] flags). + /// + static class MvppScenePassProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_SCENE_PROBE") == "1"; + + private struct In + { + public ulong Va; + public string Fmt; + public int W; + public int H; + public ShaderStage Stage; + public int Handle; // [E0] texture-CB handle -> GLSL sampler name fp_t_tcb_ + public int Binding; // [E0] runtime binding slot -> GLSL layout(binding=N) + public AddressMode WrapU; // [CLAMPPROBE] sampler address mode U (edge/border/wrap) of this input + public AddressMode WrapV; // [CLAMPPROBE] sampler address mode V + // [FILTERPROBE] The address mode was checked and matches the console; the FILTER never was. + // It decides how the tiny 64x36 CoC map is interpolated, and that map sizes every bokeh + // sprite -- nearest vs linear there changes sprite sizes in ~20-pixel blocks. + public MinFilter MinF; + public MagFilter MagF; + } + + private static readonly List _inputs = new(); + private static readonly HashSet _seenShaders = new(); + private static int _announced; + + public static void AnnounceOnce() + { + if (System.Threading.Interlocked.Exchange(ref _announced, 1) != 0) + { + return; + } + + string v = Environment.GetEnvironmentVariable("RYUJINX_SCENE_PROBE"); + Logger.Info?.Print(LogClass.Gpu, + $"SCENEPROBE gate check: RYUJINX_SCENE_PROBE={(v ?? "")} -> enabled={Enabled}. " + + (Enabled ? "Probe ACTIVE." : "Probe OFF.")); + } + + private static ulong Va(Texture t) + { + try + { + return t.Range.GetSubRange(0).Address; + } + catch + { + return 0; + } + } + + /// Appends one sampled input of the current draw (called from CommitTextureBindings, gated). + public static void OnInput(Texture texture, ShaderStage stage, int handle, int binding, Sampler sampler) + { + if (texture == null) + { + return; + } + + lock (_inputs) + { + if (_inputs.Count >= 256) + { + return; // safety cap; a real draw never samples this many + } + + TextureInfo info = texture.Info; + _inputs.Add(new In + { + Va = Va(texture), + Fmt = info.FormatInfo.Format.ToString(), + W = info.Width, + H = info.Height, + Stage = stage, + Handle = handle, + Binding = binding, + WrapU = sampler != null ? sampler.ProbeAddressU : default, + WrapV = sampler != null ? sampler.ProbeAddressV : default, + MinF = sampler != null ? sampler.ProbeMinFilter : default, + MagF = sampler != null ? sampler.ProbeMagFilter : default, + }); + } + } + + /// + /// Called once per draw AFTER bindings are committed (StateUpdater, gated). If the draw writes a + /// 1280x720 R11G11B10 colour target, logs the pass and its sampled inputs (once per shader). + /// Always clears the per-draw input list. + /// + public static void OnDraw(TextureManager texMgr, ulong fsAddr, ulong vsAddr, long frame) + { + lock (_inputs) + { + try + { + Texture colorRt = texMgr?.GetAnyRenderTarget(); + if (colorRt == null) + { + return; + } + + TextureInfo rt = colorRt.Info; + bool isScene = rt.Width == 1280 && rt.Height == 720 && rt.FormatInfo.Format == Format.R11G11B10Float; + // [CLAMPPROBE] also catch the DoF bokeh scatter pass by its output signature (512x288 R16G16B16A16F), + // so we can log the address mode of the textures IT gathers (the OOB-gather edge suspect). + bool isDof = rt.Width == 512 && rt.Height == 288 && rt.FormatInfo.Format == Format.R16G16B16A16Float; + if (!isScene && !isDof) + { + return; + } + + if (!_seenShaders.Add(fsAddr)) + { + return; // this pass already reported + } + + ulong fbVa = Va(colorRt); + string kind = isDof ? "512x288 R16G16B16A16 (DoF/bokeh)" : "720p R11G11B10 (scene)"; + + Logger.Info?.Print(LogClass.Gpu, + $"SCENEPROBE pass writes {kind}: fs=0x{fsAddr:X} vs=0x{vsAddr:X} fb=0x{fbVa:X} frame={frame} inputs={_inputs.Count} ====="); + + // [E0] Every bound colour render target (out_attr_N) with its runtime format. out_attr1 is + // the resolved-colour / temporal-history feedback target H1 cares about. Read-only. + for (int slot = 0; slot < texMgr.ColorTargetsLength; slot++) + { + Texture ct = texMgr.GetColorTarget(slot); + if (ct != null) + { + TextureInfo cti = ct.Info; + Logger.Info?.Print(LogClass.Gpu, + $"SCENEPROBE -> out_attr{slot} RT 0x{Va(ct):X} {cti.FormatInfo.Format}/{cti.Width}x{cti.Height}"); + } + } + + foreach (In i in _inputs) + { + (long caseA, long caseB) = MvppFeedbackProbe.Classify(i.Va); + bool history = caseA > 0 && caseA >= caseB; + + var sb = new StringBuilder(); + sb.Append($"SCENEPROBE <- fp_t_tcb_{i.Handle:X} (bind={i.Binding}) 0x{i.Va:X} {i.Fmt}/{i.W}x{i.H} {i.Stage} wrap=[{i.WrapU}/{i.WrapV}] filter=[{i.MinF}/{i.MagF}] [A={caseA},B={caseB}]"); + if (history) + { + sb.Append(" [H HISTORY]"); + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + } + } + finally + { + _inputs.Clear(); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppTaaProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppTaaProbe.cs new file mode 100644 index 000000000..9001e0a6d --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppTaaProbe.cs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [TAAPROBE 28/07, LECTURE SEULE, OFF par defaut] Trouve la PASSE DE RESOLUTION TEMPORELLE du + /// jeu : le dessin qui LIT une cible couleur pleine resolution ecrite a l'image PRECEDENTE. + /// + /// POURQUOI UNE DEUXIEME SONDE ALORS QUE FEEDBACKPROBE EXISTE. FEEDBACKPROBE prend un verrou + /// sur un dictionnaire a CHAQUE liaison de texture, des milliers de fois par image. Il est + /// "lecture seule" pour les pixels, pas pour le temps : Alex a vu un defaut revenir pendant le + /// run qui l'utilisait. Celle-ci ne prend aucun verrou et ne fait, dans le cas courant, que + /// deux comparaisons d'entiers -- le tableau n'est parcouru que pour les textures qui ont deja + /// la bonne taille. Tous les appels arrivent sur le thread de commandes GPU. + /// + /// CE QU'ELLE CHERCHE. FEEDBACKPROBE a montre 33 cibles qui survivent d'une image a l'autre, + /// dont QUATRE en R11G11B10Float a la resolution de rendu pleine -- la forme d'un historique + /// couleur HDR (un bloom serait en resolution reduite, et il y en a justement a cote en + /// 960x540). Reste a savoir quel dessin les consomme : c'est lui, la passe temporelle. + /// + /// Elle n'ecrit qu'une ligne par combinaison distincte, une poignee en tout, puis se tait. + /// Aucune texture, aucune cible, aucune liaison n'est modifiee. + /// + static class MvppTaaProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_TAAPROBE") == "1"; + + // 24 emplacements : FEEDBACKPROBE en a compte 33 au total, dont une majorite en resolution + // reduite que le pre-filtre elimine avant d'arriver ici. + private const int Slots = 24; + + private static readonly ulong[] _va = new ulong[Slots]; + private static readonly long[] _lastSeen = new long[Slots]; + private static int _count; + + private static long _frame; + private static int _mainW; + private static int _mainH; + private static long _mainArea; + + private static readonly ulong[] _reported = new ulong[16]; + private static int _reportedN; + private static int _announced; + + public static void AnnounceOnce() + { + if (System.Threading.Interlocked.Exchange(ref _announced, 1) != 0) + { + return; + } + + string v = Environment.GetEnvironmentVariable("RYUJINX_MVPP_TAAPROBE"); + Logger.Info?.Print(LogClass.Gpu, + $"TAAPROBE gate check: RYUJINX_MVPP_TAAPROBE={(v ?? "")} -> enabled={Enabled}."); + } + + /// Frontiere d'image invitee. + public static void OnPresent() + { + if (Enabled) + { + _frame++; + } + } + + private static ulong Va(Texture t) => t.Range.GetSubRange(0).Address; + + private static int Find(ulong va) + { + for (int i = 0; i < _count; i++) + { + if (_va[i] == va) + { + return i; + } + } + + return -1; + } + + /// + /// Appelee la ou la sonde du flou l'est deja : elle recoit l'entree ECHANTILLONNEE et la + /// cible couleur du dessin en cours. Les deux ensemble suffisent -- pas besoin de se + /// greffer aussi sur la pose des cibles. + /// + public static void OnInput(Texture input, Texture target, ShaderStage stage) + { + if (!Enabled || input == null || target == null) + { + return; + } + + // Pre-filtre : la resolution principale est la plus grande cible couleur vue. Deux + // comparaisons d'entiers eliminent tout le trafic (masques, bloom, ombres, interface) + // avant le moindre parcours. + long area = (long)target.Info.Width * target.Info.Height; + + if (area > _mainArea) + { + _mainArea = area; + _mainW = target.Info.Width; + _mainH = target.Info.Height; + } + + if (target.Info.Width != _mainW || target.Info.Height != _mainH) + { + return; + } + + // La cible pleine resolution de ce dessin est notee comme "vue a cette image". + ulong tva = Va(target); + int ti = Find(tva); + + if (ti >= 0) + { + _lastSeen[ti] = _frame; + } + else if (_count < Slots) + { + _va[_count] = tva; + _lastSeen[_count] = _frame; + _count++; + } + + // L'entree doit elle aussi etre pleine resolution : un historique temporel a forcement + // la taille de ce qu'il reconstruit. + if (input.Info.Width != _mainW || input.Info.Height != _mainH) + { + return; + } + + int ii = Find(Va(input)); + + // Ecrite a une image PRECEDENTE et lue maintenant = historique temporel. + if (ii < 0 || _lastSeen[ii] >= _frame) + { + return; + } + + ulong key = Va(input) ^ (tva << 1); + + for (int i = 0; i < _reportedN; i++) + { + if (_reported[i] == key) + { + return; + } + } + + if (_reportedN < _reported.Length) + { + _reported[_reportedN++] = key; + } + + Logger.Info?.Print(LogClass.Gpu, + $"TAAPROBE PASSE TEMPORELLE #{_reportedN} (image {_frame}) : lit l'historique " + + $"{input.Info.FormatInfo.Format}/{input.Info.Width}x{input.Info.Height} " + + $"va=0x{Va(input):X} (ecrit a l'image {_lastSeen[ii]}) " + + $"-> ecrit {target.Info.FormatInfo.Format}/{target.Info.Width}x{target.Info.Height} " + + $"va=0x{tva:X} | etage {stage} | resolution principale {_mainW}x{_mainH}"); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppTraceProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppTraceProbe.cs new file mode 100644 index 000000000..4fac52019 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppTraceProbe.cs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [TRACEPROBE, READ-ONLY, default OFF] Rendering dependency-graph tracer for the XC2 temporal + /// artifact. At every texture read it records a directed edge "current render target <- input + /// texture" (both by guest VA). Over many draws this reconstructs the pass dependency graph. + /// Periodically it walks the graph UP from each DoF input (see ), + /// printing the producer chain and flagging every node that + /// classifies as temporal history (Case A). This answers: which pass writes the fresh DoF scene, + /// what it reads, and which of those reads is a temporal-history buffer -- recursively, until the + /// first temporal producer feeding the DoF scene is found. Never modifies any GPU state. + /// Enable with RYUJINX_TRACE_PROBE=1 (run alongside RYUJINX_DOF_PROBE=1 + RYUJINX_FEEDBACK_PROBE=1). + /// + static class MvppTraceProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_TRACE_PROBE") == "1"; + + private const int MaxDepth = 5; + + private sealed class Node + { + public string Fmt = "?"; + public int Width; + public int Height; + public readonly HashSet Inputs = new(); + } + + private static readonly Dictionary _graph = new(); + private static long _lastReportMs; + private static int _announced; + + public static void AnnounceOnce() + { + if (System.Threading.Interlocked.Exchange(ref _announced, 1) != 0) + { + return; + } + + string v = Environment.GetEnvironmentVariable("RYUJINX_TRACE_PROBE"); + Logger.Info?.Print(LogClass.Gpu, + $"TRACEPROBE gate check: RYUJINX_TRACE_PROBE={(v ?? "")} -> enabled={Enabled}. " + + (Enabled ? "Probe ACTIVE." : "Probe OFF.")); + } + + private static ulong Va(Texture t) + { + try + { + return t.Range.GetSubRange(0).Address; + } + catch + { + return 0; + } + } + + private static Node GetOrAdd(ulong va, TextureInfo info) + { + if (!_graph.TryGetValue(va, out Node n)) + { + n = new Node(); + _graph[va] = n; + } + + n.Fmt = info.FormatInfo.Format.ToString(); + n.Width = info.Width; + n.Height = info.Height; + return n; + } + + /// Records the edge "renderTarget <- input" for one read (call site gated on Enabled). + public static void OnEdge(Texture renderTarget, Texture input) + { + if (renderTarget == null || input == null) + { + return; + } + + ulong rtVa = Va(renderTarget); + ulong inVa = Va(input); + + if (rtVa == inVa || inVa == 0) + { + return; + } + + lock (_graph) + { + Node rt = GetOrAdd(rtVa, renderTarget.Info); + GetOrAdd(inVa, input.Info); // ensure the input has a node (its format), even as a leaf + rt.Inputs.Add(inVa); + } + } + + public static void OnFrameBoundary() + { + long now = Environment.TickCount64; + + lock (_graph) + { + if (now - _lastReportMs < 3000) + { + return; + } + _lastReportMs = now; + + Logger.Info?.Print(LogClass.Gpu, + $"TRACEPROBE dependency walk from DoF inputs (nodes={_graph.Count}) [H]=CaseA history ====="); + + foreach (ulong root in MvppDofProbe.DofInputVas) + { + if (!_graph.TryGetValue(root, out Node rn)) + { + continue; + } + + Logger.Info?.Print(LogClass.Gpu, + $"TRACEPROBE DoF-input 0x{root:X} {rn.Fmt}/{rn.Width}x{rn.Height}"); + + var visited = new HashSet(); + Walk(root, 1, visited); + } + } + } + + // Holds the _graph lock (called from OnFrameBoundary). + private static void Walk(ulong va, int depth, HashSet visited) + { + if (depth > MaxDepth || !visited.Add(va)) + { + return; + } + + if (!_graph.TryGetValue(va, out Node n) || n.Inputs.Count == 0) + { + return; + } + + foreach (ulong inVa in n.Inputs) + { + _graph.TryGetValue(inVa, out Node inNode); + (long caseA, long caseB) = MvppFeedbackProbe.Classify(inVa); + bool history = caseA > 0 && caseA >= caseB; + bool dofIn = MvppDofProbe.DofInputVas.Contains(inVa); + + var sb = new StringBuilder(); + sb.Append("TRACEPROBE "); + sb.Append(' ', depth * 2); + sb.Append($"<- 0x{inVa:X} {(inNode != null ? $"{inNode.Fmt}/{inNode.Width}x{inNode.Height}" : "?")}"); + sb.Append($" [A={caseA},B={caseB}]"); + if (history) + { + sb.Append(" [H]"); + } + if (dofIn) + { + sb.Append(" [DoF-in]"); + } + + Logger.Info?.Print(LogClass.Gpu, sb.ToString()); + + Walk(inVa, depth + 1, visited); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppTwinFixProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppTwinFixProbe.cs new file mode 100644 index 000000000..7d6d415f2 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppTwinFixProbe.cs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [TWINFIX] EXP 21 -- the tiebreaker (RYUJINX_TWINFIX=1 + RYUJINX_TWINMAP=1), journal 178. + /// + /// Measured chain (journal 174-177): the resolve and the DoF chain sample twin X, whose only + /// writer is the sky; the 800+ material shaders write twin Y; the guest pool descriptor + /// REALLY points at X (28k raw checks, 0 mismatch); and no mechanism on the emulator ever + /// transfers Y into X. The poison is X's never-written content. + /// + /// This experiment redirects, READ SIDE ONLY, every sampled binding of the sky-only twin to + /// the material twin. No feedback loop is possible (Y's writers do not read X). The sky + /// keeps writing X; sky pixels lose their MV during the test (acceptable: Y's sky region + /// holds the neutral clear). + /// flat-block counter ~0 => the whole diagnosis is PROVEN experimentally and this redirect + /// is the shape of the fix (X and Y must be one, as on console); + /// artifact unchanged => a flaw exists in the diagnosis chain -- back to cold analysis + /// with a decisive new fact. + /// Twin classification is automatic and conservative: exactly two twins, one with <= 4 + /// writers (sky) and one with >= 50 (materials); no redirect until both are established. + /// + static class MvppTwinFixProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_TWINFIX") == "1"; + + private static bool _classifiedLogged; + private static long _redirects; + + private static readonly HashSet<(ShaderStage, int)> _redirectTuples = new(); + + /// Fast-path gate: forces one slow-path re-resolution for bindings still cached + /// on the sky twin once the classification (and thus the redirect) is available. + public static bool NeedsRebind(Texture cached) + { + if (!Enabled || cached == null) + { + return false; + } + + if (!MvppMvSyncProbe.IsMvBuffer(cached)) + { + return false; + } + + if (!MvppTwinMapProbe.TryClassify(out ulong skyVa, out _)) + { + return false; + } + + return cached.Range.GetSubRange(0).Address == skyVa; + } + + /// Slow-path substitution: reads of the sky twin are served by the material twin. + public static Texture MaybeRedirect(Texture texture, ShaderStage stage, int handle) + { + if (!Enabled || texture == null || !MvppMvSyncProbe.IsMvBuffer(texture)) + { + return texture; + } + + if (!MvppTwinMapProbe.TryClassify(out ulong skyVa, out Texture materialTwin) || materialTwin == null) + { + return texture; + } + + if (texture.Range.GetSubRange(0).Address != skyVa) + { + return texture; + } + + if (!_classifiedLogged) + { + _classifiedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINFIX] twins classified; redirect ARMED: reads of sky-twin@0x{skyVa:X} will be served by material-twin@0x{materialTwin.Range.GetSubRange(0).Address:X}"); + } + + _redirects++; + + bool isNew; + lock (_redirectTuples) + { + isNew = _redirectTuples.Add((stage, handle)); + } + + if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINFIX] REDIRECT: stage={stage} handle=0x{handle:X} (tcb_{handle:X}) now reads the material twin | total redirected binds: {_redirects}"); + } + + return materialTwin; + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppTwinMapProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppTwinMapProbe.cs new file mode 100644 index 000000000..e83587a79 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppTwinMapProbe.cs @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [TWINMAP] Read-only probe (RYUJINX_TWINMAP=1), inert unless set. Journal 173. + /// + /// VKIMG found the game double-buffers its MV target (two R10G10B10A2 1280x720 guest + /// textures, both written and read every frame -- a two-stage intra-frame chain, VKPHASE). + /// Before any twin-viewing experiment, MEASURE the roles instead of guessing them: + /// - WRITE side: per twin (guest VA), which fragment shaders draw into it, and how many. + /// Expected asymmetry: the raw twin has ~190 material writers; the processed twin has + /// very few -- and those few NAME the processor. + /// - READ side: per twin, which (stage, guest tcb handle) samples it. Cross-checked with + /// the decompiled binding maps (resolve reads its MV at tcb_10, builder at tcb_8), this + /// names which twin the periscope v3 run already showed poisoned. + /// No swaps, no visual change, zero dataflow interference -- pure cartography. + /// + static class MvppTwinMapProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_TWINMAP") == "1"; + + private static bool _armedLogged; + private static long _summaryMs; + + private class TwinStats + { + public readonly HashSet Writers = new(); + public long WriteDraws; + public int WritersLogged; + + // v2 (journal 176): GMMU aliasing check. On console two GPU VAs can alias the same + // physical pages; a missed remap would leave the cached texture on stale backing. + public ulong GpuVa; + public ulong Backing; + public ulong LastTranslation; + + // v4 (journal 178): live Texture reference for TWINFIX's read-side redirect. + public WeakReference LastTexture; + + // v5 (journal 179): per-twin clear attribution + masked-bind census. + public long Clears; + public int ClearsLogged; + public long MaskedBinds; + + // v6 (journal 201): clear-COLOUR census, the classification discriminant that + // survives the v5 writer-count correction (both twins have ~730 writers, so the + // old <=4 / >=50 rule never matches any more). Measured (180): X is cleared + // BLACK (0,0,0,1), Y is cleared NEUTRAL (0.5,0.5,1,1) -- every frame. + public long BlackClears; + public long NeutralClears; + } + + private static readonly Dictionary _twins = new(); + private static readonly HashSet<(ulong, ShaderStage, int)> _reads = new(); + private static WeakReference _mm; + + // v3 POOLTRUTH (journal 177): raw guest pool descriptor vs actually-bound texture. + // The fast bind path skips descriptor re-reads unless the pool is flagged modified; if + // that tracking misses the game's per-frame updates, a stale binding survives forever -- + // which would pin the resolve on twin X while the game re-points it at Y. + private static long _poolMatches; + private static long _poolMismatches; + private static readonly HashSet<(ulong, ulong, int)> _mismatchTuples = new(); + + private static ulong GuestVa(Texture t) + { + return t.Range.GetSubRange(0).Address; + } + + /// v4 (TWINFIX): conservative twin classification. True only with exactly two twins, + /// one clearly sky-only (<= 4 writers) and one clearly material (>= 50 writers). + public static bool TryClassify(out ulong skyVa, out Texture materialTwin) + { + skyVa = 0; + materialTwin = null; + + lock (_twins) + { + if (_twins.Count != 2) + { + return false; + } + + TwinStats sky = null, mat = null; + ulong skyKey = 0; + + foreach (var kv in _twins) + { + if (kv.Value.Writers.Count <= 4) + { + sky = kv.Value; + skyKey = kv.Key; + } + else if (kv.Value.Writers.Count >= 50) + { + mat = kv.Value; + } + } + + // [v6] (journal 201) fallback: the writer-count rule died with the v5 counting + // correction (both twins have ~730 material writers). The discriminant that + // still holds, measured (180): X is cleared BLACK each frame, Y NEUTRAL. + // Same redirect direction as the (179) worse-mode: reads of X served by Y. + if (sky == null || mat == null) + { + TwinStats black = null, neutral = null; + ulong blackKey = 0; + + foreach (var kv in _twins) + { + if (kv.Value.BlackClears >= 10 && kv.Value.NeutralClears == 0) + { + black = kv.Value; + blackKey = kv.Key; + } + else if (kv.Value.NeutralClears >= 10 && kv.Value.BlackClears == 0) + { + neutral = kv.Value; + } + } + + if (black != null && neutral != null) + { + sky = black; + skyKey = blackKey; + mat = neutral; + } + } + + if (sky == null || mat == null || mat.LastTexture == null) + { + return false; + } + + if (!mat.LastTexture.TryGetTarget(out Texture matTex)) + { + return false; + } + + skyVa = skyKey; + materialTwin = matTex; + return true; + } + } + + /// Snapshot of the discovered twin VAs, for cross-probes (TWINXFER's DMA range check). + public static ulong[] TwinVas() + { + lock (_twins) + { + ulong[] vas = new ulong[_twins.Count]; + _twins.Keys.CopyTo(vas, 0); + return vas; + } + } + + /// Per-draw (StateUpdater): record writers per MV-shaped colour target. + public static void OnDraw(TextureManager texMgr, ulong fsAddr, Memory.MemoryManager memoryManager, ReadOnlySpan writeMasks) + { + if (!Enabled || texMgr == null || fsAddr == 0) + { + return; + } + + if (memoryManager != null) + { + _mm = new WeakReference(memoryManager); + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[TWINMAP] ARMED (RYUJINX_TWINMAP=1). Mapping writers and readers of each MV twin by guest VA."); + } + + // v5: NO early return -- a single draw can bind BOTH twins at different slots + // (the early return hid that for a whole evening, journal 179). Masks passed in: + // a bound-but-masked-off slot writes nothing and is counted apart. + bool any = false; + + for (int slot = 0; slot < texMgr.ColorTargetsLength; slot++) + { + Texture ct = texMgr.GetColorTarget(slot); + if (!MvppMvSyncProbe.IsMvBuffer(ct)) + { + continue; + } + + any = true; + ulong va = GuestVa(ct); + uint mask = (writeMasks.Length > slot) ? writeMasks[slot] : 0xFu; + + lock (_twins) + { + TwinStats stats = GetOrAdd(va, ct, memoryManager); + + stats.LastTexture = new WeakReference(ct); + + if (mask == 0) + { + stats.MaskedBinds++; + continue; + } + + stats.WriteDraws++; + + if (stats.Writers.Add(fsAddr) && stats.WritersLogged < 8) + { + stats.WritersLogged++; + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] WRITER of twin@0x{va:X}: fs=0x{fsAddr:X} slot={slot} mask=0x{mask:X} (writer #{stats.Writers.Count}; first 8 logged)"); + } + } + } + + if (any) + { + Heartbeat(); + } + } + + private static TwinStats GetOrAdd(ulong va, Texture ct, Memory.MemoryManager memoryManager) + { + if (!_twins.TryGetValue(va, out TwinStats stats)) + { + stats = new TwinStats + { + GpuVa = ct.Info.GpuAddress, + Backing = va, + }; + _twins[va] = stats; + + ulong translated = memoryManager?.Translate(ct.Info.GpuAddress) ?? 0; + stats.LastTranslation = translated; + + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] NEW TWIN target: twin@0x{va:X} ({_twins.Count} twin(s)) | GpuVa=0x{ct.Info.GpuAddress:X} backing=0x{va:X} translatesTo=0x{translated:X}" + + (translated != va ? " *** TRANSLATION != BACKING (aliasing/remap suspect) ***" : "")); + } + + return stats; + } + + /// v5: engine clear attribution per twin (DrawManager.Clear). + public static void OnClear(Texture target, uint componentMask, float r, float g, float b, float a) + { + if (!Enabled || target == null || !MvppMvSyncProbe.IsMvBuffer(target)) + { + return; + } + + ulong va = GuestVa(target); + + lock (_twins) + { + TwinStats stats = GetOrAdd(va, target, null); + stats.Clears++; + + // [v6] clear-colour census for the fallback classification. + if (r < 0.1f && g < 0.1f && b < 0.1f) + { + stats.BlackClears++; + } + else if (r > 0.4f && r < 0.6f && g > 0.4f && g < 0.6f) + { + stats.NeutralClears++; + } + + if (stats.ClearsLogged < 4) + { + stats.ClearsLogged++; + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] CLEAR of twin@0x{va:X}: rgba=({r:0.###},{g:0.###},{b:0.###},{a:0.###}) mask=0x{componentMask:X} (clear #{stats.Clears})"); + } + } + } + + /// Per-bind (TextureBindingsManager, both paths): record readers per MV-shaped sampled + /// texture. v3: also read the RAW guest pool descriptor and compare with what got bound. + public static void OnRead(Texture texture, ShaderStage stage, int handle, TexturePool pool, int textureId, Memory.MemoryManager memoryManager) + { + if (!Enabled || texture == null || !MvppMvSyncProbe.IsMvBuffer(texture)) + { + return; + } + + ulong va = GuestVa(texture); + bool isNew; + + lock (_twins) + { + isNew = _reads.Add((va, stage, handle)); + } + + if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] READER of twin@0x{va:X}: stage={stage} handle=0x{handle:X} (tcb_{handle:X}) | distinct readers: {_reads.Count}"); + } + + // v3 POOLTRUTH: guest truth vs emulator binding. + if (pool != null && memoryManager != null && textureId >= 0) + { + try + { + ulong descAddr = pool.Address + (ulong)textureId * 0x20; + TextureDescriptor raw = System.Runtime.InteropServices.MemoryMarshal.Read( + memoryManager.Physical.GetSpan(descAddr, 0x20)); + ulong rawVa = raw.UnpackAddress(); + ulong boundVa = texture.Info.GpuAddress; + + if (rawVa != boundVa) + { + _poolMismatches++; + + bool newMismatch; + lock (_twins) + { + newMismatch = _mismatchTuples.Add((rawVa, boundVa, handle)); + } + + if (newMismatch) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] *** POOL DESCRIPTOR MISMATCH ***: raw guest VA=0x{rawVa:X} vs BOUND VA=0x{boundVa:X} (poolId={textureId} handle=0x{handle:X} stage={stage}) | the emulator is reading a STALE binding"); + } + } + else + { + _poolMatches++; + } + } + catch + { + // Raw read outside mapped memory: report once via the heartbeat counters only. + } + } + + Heartbeat(); + } + + private static void Heartbeat() + { + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + + Memory.MemoryManager mm = null; + _mm?.TryGetTarget(out mm); + + lock (_twins) + { + string summary = ""; + foreach (var kv in _twins) + { + TwinStats stats = kv.Value; + summary += $" twin@0x{kv.Key:X}: writers={stats.Writers.Count} draws={stats.WriteDraws} maskedBinds={stats.MaskedBinds} CLEARS={stats.Clears} |"; + + // v2: live GMMU re-translation of the twin's VA. A change or a mismatch + // with the cached backing = the missed-remap smoking gun. + if (mm != null && stats.GpuVa != 0) + { + ulong translated = mm.Translate(stats.GpuVa); + + if (translated != stats.LastTranslation) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] *** TRANSLATION CHANGED for twin@0x{kv.Key:X}: GpuVa=0x{stats.GpuVa:X} was->0x{stats.LastTranslation:X} now->0x{translated:X} (backing=0x{stats.Backing:X}) ***"); + stats.LastTranslation = translated; + } + else if (translated != stats.Backing) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP] *** TRANSLATION != BACKING for twin@0x{kv.Key:X}: GpuVa=0x{stats.GpuVa:X} ->0x{translated:X} vs backing 0x{stats.Backing:X} ***"); + } + } + } + + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINMAP/HEARTBEAT ~3s]{summary} readers(distinct va,stage,handle)={_reads.Count} | poolTruth: match={_poolMatches} MISMATCH={_poolMismatches}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppTwinXferProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppTwinXferProbe.cs new file mode 100644 index 000000000..5fe88d125 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppTwinXferProbe.cs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [TWINXFER] Read-only probe (RYUJINX_TWINXFER=1), inert unless set. Journal 174. + /// + /// TWINMAP measured the twins' roles and it is damning: the resolve (tcb_10) and the DoF + /// chain read twin X, whose ONLY fragment writer is the sky (1 draw/frame) -- while the 882 + /// material shaders write twin Y. On console this can only work if SOMETHING transfers the + /// geometry MVs into X. Two mechanisms are invisible to every instrument so far: + /// 1. engine copies (2D blit / DMA) -- the game may issue a per-frame Y->X copy; + /// 2. a COMPUTE pass writing X via image store (storage-image bindings bypass both the + /// draw census and the sampled-texture hooks). + /// This probe watches both: + /// - every 2D/DMA texture copy whose source or destination is MV-shaped (src/dst VAs); + /// - every storage-IMAGE binding of an MV-shaped texture (stage, handle, isStore). + /// Reading grid: + /// Y->X copies present -> the transfer exists; next: is it executed correctly? + /// image-store writes to X -> the missing writer is a compute pass; instrument it; + /// NOTHING touches X -> X is genuinely sky-only + stale -> the game expects + /// aliasing/offset semantics the emulator does not reproduce -> address decode next. + /// + static class MvppTwinXferProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_TWINXFER") == "1"; + + private static bool _armedLogged; + private static long _copies; + private static long _imageBinds; + private static long _dmaHits; + private static long _summaryMs; + + private static readonly HashSet<(ulong, ulong, string)> _copyPairs = new(); + private static readonly HashSet<(ulong, ShaderStage, int, bool)> _imageTuples = new(); + private static readonly HashSet<(ulong, ulong, bool)> _dmaPairs = new(); + + // Generous per-twin window (real surface = 0x3C0000): a copy landing anywhere inside + // still names the mechanism; sub-range starts (per-line copies) must not be missed. + private const ulong TwinWindow = 0x400000; + + private static bool IsMv(Texture t) + { + return MvppMvSyncProbe.IsMvBuffer(t); + } + + private static ulong Va(Texture t) + { + return t.Range.GetSubRange(0).Address; + } + + /// Frame boundary (Gpu Window present): arming witness + heartbeat, zeros included. + public static void OnPresent() + { + if (!Enabled) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[TWINXFER] ARMED (RYUJINX_TWINXFER=1). Watching engine copies and storage-image bindings touching the MV twins."); + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINXFER/HEARTBEAT ~3s] copies={_copies} distinctPairs={_copyPairs.Count} | imageBinds={_imageBinds} distinctTuples={_imageTuples.Count} | " + + $"dmaHits={_dmaHits} distinctDma={_dmaPairs.Count} (twins connus: {MvppTwinMapProbe.TwinVas().Length})"); + } + } + + /// Engine texture copy (2D blit or DMA). Logs pairs where either side is MV-shaped. + public static void OnCopy(string path, Texture src, Texture dst) + { + if (!Enabled || (!IsMv(src) && !IsMv(dst))) + { + return; + } + + _copies++; + + ulong srcVa = src != null ? Va(src) : 0; + ulong dstVa = dst != null ? Va(dst) : 0; + + bool isNew; + lock (_copyPairs) + { + isNew = _copyPairs.Add((srcVa, dstVa, path)); + } + + if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINXFER] COPY via {path}: src@0x{srcVa:X} ({src?.Info.FormatInfo.Format}) -> dst@0x{dstVa:X} ({dst?.Info.FormatInfo.Format}) | distinct pairs: {_copyPairs.Count}"); + } + } + + /// Raw copy-engine launch (DmaClass.DmaCopy, BEFORE any branch): catches the + /// buffer-domain path no texture-level hook sees. Twin VAs come live from TWINMAP + /// (run both probes together). + public static void OnDma(ulong srcVa, ulong dstVa, int xCount, int yCount, bool copy2D, bool srcLinear, bool dstLinear) + { + if (!Enabled) + { + return; + } + + ulong[] twins = MvppTwinMapProbe.TwinVas(); + if (twins.Length == 0) + { + return; + } + + bool hit = false; + foreach (ulong twin in twins) + { + if ((srcVa >= twin && srcVa < twin + TwinWindow) || + (dstVa >= twin && dstVa < twin + TwinWindow)) + { + hit = true; + break; + } + } + + if (!hit) + { + return; + } + + _dmaHits++; + + bool isNew; + lock (_copyPairs) + { + isNew = _dmaPairs.Add((srcVa, dstVa, copy2D)); + } + + if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINXFER] *** DMA TOUCHING A TWIN ***: src=0x{srcVa:X} -> dst=0x{dstVa:X} x={xCount} y={yCount} copy2D={copy2D} srcLinear={srcLinear} dstLinear={dstLinear} | distinct: {_dmaPairs.Count}"); + } + } + + /// Storage-image binding of an MV-shaped texture (the compute-writer blind spot). + public static void OnImageBind(Texture texture, ShaderStage stage, int handle, bool isStore) + { + if (!Enabled || !IsMv(texture)) + { + return; + } + + _imageBinds++; + + ulong va = Va(texture); + bool isNew; + lock (_copyPairs) + { + isNew = _imageTuples.Add((va, stage, handle, isStore)); + } + + if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[TWINXFER] IMAGE BIND of twin@0x{va:X}: stage={stage} handle=0x{handle:X} store={(isStore ? "YES (writer!)" : "no")} | distinct: {_imageTuples.Count}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppViewAliasProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppViewAliasProbe.cs new file mode 100644 index 000000000..e2149af0a --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppViewAliasProbe.cs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [VIEWALIAS] Read-only probe (RYUJINX_VIEWPROBE=1), inert unless set. Changes nothing that is + /// rendered: it only reports, once per distinct case, when a texture is created as a VIEW over an + /// existing texture whose FORMAT differs. + /// + /// Why: journal entry (111) localised the XC2 block corruption to the deferred composition pass + /// that fills slot4 (4 bytes per pixel), measured x65 vs a clean buffer, with the signature + /// "valid content displaced" at GOB granularity -- and the guest memory is empty, so nothing is + /// being detiled. Meanwhile slot6 has identical dimensions AND identical bytes per pixel yet stays + /// clean, which rules out the block-linear size/tiling math (it depends only on those two things). + /// + /// What is left is format-specific host behaviour, and TextureCompatibility has exactly one rule + /// wide enough to matter here: for non-sampler textures, two textures are considered FULLY view + /// compatible whenever their bytes-per-pixel and compression status match, regardless of the actual + /// format. 4 bytes per pixel is the most crowded class in the renderer, so slot4 can legitimately + /// be born as a view over an unrelated 4-byte texture and read its memory. + /// + /// This probe does NOT decide whether that is the bug -- it answers whether it happens at all, and + /// on which pair of formats. Instruments before levers. + /// + static class MvppViewAliasProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_VIEWPROBE") == "1"; + + // One line per distinct (child format, parent format, size) case. Texture creation is frequent, + // so an ungated log would drown the file and slow the run enough to change what we measure. + private static readonly HashSet _reported = new(); + + private static bool _armedLogged; + private static bool _firstViewLogged; + private static int _viewCount; + + /// + /// Positive control, called once when the texture cache is built. WITHOUT it, "no [VIEWALIAS] + /// line" is ambiguous between "the flag never took", "the view path is never reached" and the + /// only meaningful reading, "views happen but never change format". A probe whose silence + /// cannot be interpreted is not an instrument. + /// + public static void ReportArmed() + { + if (!Enabled || _armedLogged) + { + return; + } + + _armedLogged = true; + + Logger.Warning?.Print(LogClass.Gpu, "[VIEWALIAS] ARMED (RYUJINX_VIEWPROBE=1). Expect a FIRST VIEW line if the view-creation path is ever reached."); + } + + public static void OnViewCreated(TextureInfo child, Texture parent, int firstLayer, int firstLevel, string compatibility) + { + if (!Enabled || parent == null) + { + return; + } + + TextureInfo p = parent.Info; + + // Second control: proves the path is alive, whatever the formats are. Logged before the + // same-format early-out below, otherwise a run with only ordinary views looks identical + // to a run where this code never executed. + _viewCount++; + + if (!_firstViewLogged) + { + _firstViewLogged = true; + + Logger.Warning?.Print(LogClass.Gpu, + $"[VIEWALIAS] FIRST VIEW reached: child {child.FormatInfo.Format} {child.Width}x{child.Height} " + + $"over parent {p.FormatInfo.Format} {p.Width}x{p.Height} | compat {compatibility}"); + } + + // Periodic tally so the end of the log states how many views happened in total, which turns + // "no differing-format line" into a quantified negative instead of a silence. + if ((_viewCount % 500) == 0) + { + Logger.Warning?.Print(LogClass.Gpu, $"[VIEWALIAS] tally: {_viewCount} views created so far, {_reported.Count} distinct cross-format cases."); + } + + // Same format is the ordinary, expected case: a plain view. Only report reinterpretation. + if (child.FormatInfo.Format == p.FormatInfo.Format) + { + return; + } + + string key = $"{child.FormatInfo.Format}|{p.FormatInfo.Format}|{child.Width}x{child.Height}|{p.Width}x{p.Height}"; + + lock (_reported) + { + if (!_reported.Add(key)) + { + return; + } + } + + Logger.Warning?.Print(LogClass.Gpu, + $"[VIEWALIAS] view {child.FormatInfo.Format} {child.Width}x{child.Height} " + + $"({child.FormatInfo.BytesPerPixel} bpp) created OVER parent {p.FormatInfo.Format} " + + $"{p.Width}x{p.Height} ({p.FormatInfo.BytesPerPixel} bpp) | parent addr 0x{parent.Range.GetSubRange(0).Address:X} " + + $"| firstLayer {firstLayer} firstLevel {firstLevel} | compat {compatibility}"); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppWriterCensusProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppWriterCensusProbe.cs new file mode 100644 index 000000000..ab58fab07 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppWriterCensusProbe.cs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader.Translation; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Image +{ + /// + /// [CENSUS] Read-only probe (RYUJINX_MVWRITER_CENSUS=1 + RYUJINX_NANSCRUB=1), journal 159. + /// + /// The value scrubs covered shaders selected by FINGERPRINT; the writer set is defined by + /// RENDER TARGET. The intersection was never verified. This probe checks, per draw whose + /// colour target is the XC2 object-MV buffer (R10G10B10A2 1280x720), whether the bound + /// fragment shader was armed by the +0.01 encode fingerprint -- any writer that was NOT is a + /// writer every scrub missed, free to deposit garbage since day one. + /// + /// Requirements: NANSCRUB=1 fills the armed registry (its NaN wrap is visually inert, EXP 14), + /// and the shader cache must be OFF -- cache-path translations carry address 0 (journal 148), + /// which would make every writer look unarmed (void). The bat handles both. + /// + /// Reading grid (written before coding): + /// UNARMED distinct > 0 -> the escaped writers, by address -> dumpmap + decompile them next; + /// UNARMED = 0, armed > 0 -> writers fully covered by the scrubs -> value family truly closed + /// at the FS level -> next stage is the Vulkan object level + /// (clear/loadOp execution, view identity below the Texture cache); + /// registry = 0 in the heartbeat -> VOID (cache ON or NANSCRUB missing), fix the run. + /// + static class MvppWriterCensusProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVWRITER_CENSUS") == "1"; + + private static bool _armedLogged; + + private static long _armedDraws; + private static long _unarmedDraws; + private static long _maskedOffDraws; + private static long _summaryMs; + + private static readonly HashSet _armedFs = new(); + private static readonly HashSet _unarmedFs = new(); + private static readonly HashSet _maskedOffFs = new(); + + /// Frame boundary (Window present): arming witness + 3s heartbeat, zeros included. + public static void OnPresent() + { + if (!Enabled) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[CENSUS] ARMED (RYUJINX_MVWRITER_CENSUS=1). Cross-checking MV-buffer writers against the fingerprint-armed registry."); + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + + int registry = NanScrubProbe.ArmedAddressCount(); + Logger.Warning?.Print(LogClass.Gpu, + $"[CENSUS/HEARTBEAT ~3s v3-program] registry={registry} | ARMED-program draws={_armedDraws} distinctVA={_armedFs.Count} | " + + $"UNARMED-program WRITING (mask!=0): draws={_unarmedDraws} distinctVA={_unarmedFs.Count} | " + + $"bound-but-masked-off: draws={_maskedOffDraws} distinctVA={_maskedOffFs.Count}" + + (registry == 0 ? " *** REGISTRY EMPTY: cache ON or NANSCRUB missing = VOID ***" : "")); + } + } + + /// Per-draw (StateUpdater probe block): classify draws whose colour target is the MV buffer. + /// v2: a draw with the MV slot bound but its colour write mask OFF writes nothing -- counted + /// apart, or the whole static-geometry MRT set shows up as false-positive "unarmed writers". + /// v3: classification by PROGRAM identity (ShaderProgramInfo.MvppMvEncodeArmed), not by VA -- + /// the guest places the same code at many VAs and the cache dedups by code, so only the + /// first VA ever reaches translation and the VA registry misses every mirror (journal 163). + public static void OnDraw(TextureManager texMgr, ulong fsAddr, ReadOnlySpan writeMasks, Ryujinx.Graphics.Shader.ShaderProgramInfo fragInfo) + { + if (texMgr == null || fsAddr == 0) + { + return; + } + + for (int slot = 0; slot < texMgr.ColorTargetsLength; slot++) + { + if (!MvppMvSyncProbe.IsMvBuffer(texMgr.GetColorTarget(slot))) + { + continue; + } + + uint mask = slot < writeMasks.Length ? writeMasks[slot] : 0xFu; + + if (mask == 0) + { + _maskedOffDraws++; + + bool newMasked; + lock (_maskedOffFs) + { + newMasked = _maskedOffFs.Add(fsAddr); + } + + if (newMasked) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[CENSUS] bound-but-masked-off writer: fs=0x{fsAddr:X} slot={slot} | distinct: {_maskedOffFs.Count}"); + } + + return; + } + + if (fragInfo != null && fragInfo.MvppMvEncodeArmed) + { + _armedDraws++; + lock (_armedFs) + { + _armedFs.Add(fsAddr); + } + } + else + { + _unarmedDraws++; + + bool isNew; + lock (_unarmedFs) + { + isNew = _unarmedFs.Add(fsAddr); + } + + if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[CENSUS] MV writer whose PROGRAM carries no fingerprint AND WRITING: fs=0x{fsAddr:X} slot={slot} mask=0x{mask:X} | escaped every value scrub | distinct unarmed VAs: {_unarmedFs.Count}"); + } + } + + return; // one classification per draw, even if bound on several slots + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/Sampler.cs b/src/Ryujinx.Graphics.Gpu/Image/Sampler.cs index 02b16abbf..7e88f5feb 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/Sampler.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/Sampler.cs @@ -28,6 +28,17 @@ namespace Ryujinx.Graphics.Gpu.Image public float ProbeMaxLod { get; } public float ProbeMipLodBias { get; } + /// + /// [GLOWPROBE, 21/07, read-only] Texture addressing modes and border colour, kept for the + /// Xenoblade 2 light-halo dossier. The halos are sliced off at the edge of their quad + /// instead of fading out - proven abnormal against a TOTK control where the same buffer + /// shows no hard edge at all - and the addressing mode is what decides what a sample + /// beyond the texture edge returns. Never read by any rendering path. + /// + public AddressMode ProbeAddressU { get; } + public AddressMode ProbeAddressV { get; } + public float ProbeBorderA { get; } + /// /// Host sampler object. /// @@ -99,6 +110,9 @@ namespace Ryujinx.Graphics.Gpu.Image ProbeMinLod = minLod; ProbeMaxLod = maxLod; ProbeMipLodBias = mipLodBias; + ProbeAddressU = addressU; + ProbeAddressV = addressV; + ProbeBorderA = descriptor.BorderColorA; float maxRequestedAnisotropy = descriptor.UnpackMaxAnisotropy(); float maxSupportedAnisotropy = context.Capabilities.MaximumSupportedAnisotropy; diff --git a/src/Ryujinx.Graphics.Gpu/Image/Texture.cs b/src/Ryujinx.Graphics.Gpu/Image/Texture.cs index 787b030f8..5a35ba383 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/Texture.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/Texture.cs @@ -246,6 +246,9 @@ namespace Ryujinx.Graphics.Gpu.Image /// public MultiRange Range { get; private set; } + /// [MVPP] Accès lecture seule à la mémoire physique invitée, pour la sonde PRESYNC GUEST. + internal PhysicalMemory PhysicalMemory => _physicalMemory; + /// /// Layer size in bytes. /// @@ -833,6 +836,8 @@ namespace Ryujinx.Graphics.Gpu.Image /// True if this texture is first copied to the given one, false for the opposite direction public void CreateCopyDependency(Texture contained, int layer, int level, bool copyTo) { + MvppCacheProbe.OnCopyDependency(this, contained); // read-only (gated) + if (contained.Group == Group) { return; @@ -904,7 +909,10 @@ namespace Ryujinx.Graphics.Gpu.Image // Fractional-crash probe (quality-mode dossier): a descale copy is the moment a // scaled texture becomes guest-visible data (flush/blacklist). Only fires under a // FRACTIONAL source scale (integer scales = the proven regime, daily untouched). - if (copy && scale == 1f && ScaleFactor != MathF.Floor(ScaleFactor)) + // [31/07] GATE AJOUTE, meme raison que sa jumelle dans SemaphoreUpdater : elle etait + // en release sans interrupteur et se declenche chez tout utilisateur en DLSS quality + // ou performance, qui produit une echelle fractionnaire par construction. + if (GAL.MvppDev.Enabled && copy && scale == 1f && ScaleFactor != MathF.Floor(ScaleFactor)) { Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, $"MVPP fract-probe: descale flush {Info.Width}x{Info.Height} {Info.FormatInfo.Format} target {Info.Target} from scale {ScaleFactor}."); @@ -1015,6 +1023,8 @@ namespace Ryujinx.Graphics.Gpu.Image /// public void SynchronizeMemory() { + MvppCacheProbe.OnSynchronize(); // read-only (gated) + if (Target == Target.TextureBuffer) { return; @@ -1027,8 +1037,14 @@ namespace Ryujinx.Graphics.Gpu.Image _dirty = false; + // Le VRAI travail : seul un appel qui trouve la texture SALE recharge des données. Les + // ~367 000 "resynchronisations" comptées avant étaient surtout des appels qui ressortent ici + // sans rien faire. C'est ce compteur-ci qui dit si le cache RECHARGE en boucle. + MvppCacheProbe.OnRealSync(Info.Width, Info.Height, Info.FormatInfo.Format, _hasData); // read-only (gated) + if (_hasData) { + MvppMap64Probe.OnGroupSync(this); // [MAP64] read-only (gated): partial syncs onto the small DoF maps Group.SynchronizeMemory(this); } else @@ -1062,6 +1078,13 @@ namespace Ryujinx.Graphics.Gpu.Image { ReadOnlySpan data = _physicalMemory.GetSpan(Range); + // [FULLSYNC, READ-ONLY] Report which surfaces get a full upload from guest memory, and + // whether that memory is empty. Gated; does nothing to the data or the upload. + MvppFullSyncProbe.OnFullSync(Info, data, IsView); + + // [MAP64, READ-ONLY] Same question, filtered on the small DoF maps (gated). + MvppMap64Probe.OnFullSync(this, data); + // [MIPPROBE, READ-ONLY] Measure mip alpha coverage once per texture. Gated; does nothing to data/upload. if (_mipProbe && !_mipProbed) { diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs index ece097e7f..421244d4d 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs @@ -138,6 +138,31 @@ namespace Ryujinx.Graphics.Gpu.Image } /// [MV++ UI audit, read-only] Like MvppEnumerateInputs but also reports the shader stage. + /// [GLOWPROBE, read-only] Same as MvppEnumerateInputsStage but also hands over the + /// SAMPLER bound with each texture: the Xenoblade 2 halo dossier needs the addressing mode, + /// which is what decides what a read past the texture edge returns. + public void MvppEnumerateInputsWithSampler(System.Action report) + { + for (int stage = 0; stage < _textureBindings.Length; stage++) + { + TextureBindingInfo[] texs = _textureBindings[stage]; + for (int i = 0; i < texs.Length; i++) + { + TextureBindingInfo b = texs[i]; + if (b.ArrayLength > 1 || b.Binding < 0 || b.Binding >= _textureState.Length) + { + continue; + } + + Texture t = _textureState[b.Binding].CachedTexture; + if (t != null) + { + report(stage, t, _textureState[b.Binding].CachedSampler); + } + } + } + } + public void MvppEnumerateInputsStage(System.Action report) { for (int stage = 0; stage < _textureBindings.Length; stage++) @@ -608,6 +633,12 @@ namespace Ryujinx.Graphics.Gpu.Image return true; } + MvppDofProbe.AnnounceOnce(); // [DOFPROBE] one-shot gate-state line on the real draw path, even when OFF + MvppFeedbackProbe.AnnounceOnce(); // [FEEDBACKPROBE] one-shot gate-state line, even when OFF + MvppTraceProbe.AnnounceOnce(); // [TRACEPROBE] one-shot gate-state line, even when OFF + MvppScenePassProbe.AnnounceOnce(); // [SCENEPROBE] one-shot gate-state line, even when OFF + MvppTaaProbe.AnnounceOnce(); // [TAAPROBE] one-shot gate-state line, even when OFF + bool specStateMatches = true; int cachedTextureBufferIndex = -1; @@ -651,11 +682,45 @@ namespace Ryujinx.Graphics.Gpu.Image state.SamplerHandle == samplerId && state.CachedTexture != null && state.CachedTexture.InvalidatedSequence == state.InvalidatedSequence && - state.CachedSampler?.IsDisposed != true) + state.CachedSampler?.IsDisposed != true && + !MvppTwinFixProbe.NeedsRebind(state.CachedTexture)) // [TWINFIX] one forced re-resolve once classified { // The texture is already bound. state.CachedTexture.SynchronizeMemory(); + if (MvppDofProbe.Enabled) + { + // [DOFPROBE] cached (fast) path: a stable input re-bound this draw. + MvppDofProbe.OnInput(state.CachedTexture, _channel.TextureManager.GetAnyRenderTarget(), stage); + } + + if (MvppTaaProbe.Enabled) + { + // [TAAPROBE] meme point d'observation, sans verrou : deux comparaisons + // d'entiers eliminent tout le trafic qui n'est pas pleine resolution. + MvppTaaProbe.OnInput(state.CachedTexture, _channel.TextureManager.GetAnyRenderTarget(), stage); + } + + if (MvppFeedbackProbe.Enabled) + { + MvppFeedbackProbe.OnRead(state.CachedTexture); // [FEEDBACKPROBE] input read (fast path) + } + + if (MvppTraceProbe.Enabled) + { + MvppTraceProbe.OnEdge(_channel.TextureManager.GetAnyRenderTarget(), state.CachedTexture); // [TRACEPROBE] dep edge (fast path) + } + + if (MvppScenePassProbe.Enabled) + { + MvppScenePassProbe.OnInput(state.CachedTexture, stage, bindingInfo.Handle, bindingInfo.Binding, state.CachedSampler); // [SCENEPROBE] per-draw input (fast path) + } + + MvppBuilderInProbe.OnInput(state.CachedTexture, stage, bindingInfo.Handle, bindingInfo.Binding, state.CachedSampler); // [BUILDERIN] per-draw input (fast path, self-gated) + MvppMap64Probe.OnInput(state.CachedTexture, stage, bindingInfo.Handle); // [MAP64] per-draw input (fast path, self-gated) + MvppMvBufProbe.OnSampled(state.CachedTexture); // [MVBUF] identity of the sampled MV buffer (self-gated) + MvppTwinMapProbe.OnRead(state.CachedTexture, stage, bindingInfo.Handle, texturePool, textureId, _channel.MemoryManager); // [TWINMAP] reader census + pool truth (self-gated) + state.CachedTexture.EnsureForcedMips(); // [FORCEMIPS] no-op unless boosted and dirty if ((usageFlags & TextureUsageFlags.NeedsScaleValue) != 0 && @@ -683,6 +748,44 @@ namespace Ryujinx.Graphics.Gpu.Image MvppPairProbe.OnPair(texture, sampler); // read-only (gated) } + if (MvppDofProbe.Enabled) + { + // [DOFPROBE] resolve (cache-miss) path: an input re-fetched this draw. The DoF + // feedback/history buffer is re-invalidated every frame, so it lands here often. + MvppDofProbe.OnInput(texture, _channel.TextureManager.GetAnyRenderTarget(), stage); + } + + if (MvppTaaProbe.Enabled) + { + // [TAAPROBE] chemin de resolution (defaut de cache) : c'est celui ou un + // historique temporel, re-invalide a chaque image, atterrit le plus souvent. + MvppTaaProbe.OnInput(texture, _channel.TextureManager.GetAnyRenderTarget(), stage); + } + + if (MvppFeedbackProbe.Enabled) + { + MvppFeedbackProbe.OnRead(texture); // [FEEDBACKPROBE] input read (resolve path) + } + + if (MvppTraceProbe.Enabled) + { + MvppTraceProbe.OnEdge(_channel.TextureManager.GetAnyRenderTarget(), texture); // [TRACEPROBE] dep edge (resolve path) + } + + if (MvppScenePassProbe.Enabled) + { + MvppScenePassProbe.OnInput(texture, stage, bindingInfo.Handle, bindingInfo.Binding, sampler); // [SCENEPROBE] per-draw input (resolve path) + } + + MvppBuilderInProbe.OnInput(texture, stage, bindingInfo.Handle, bindingInfo.Binding, sampler); // [BUILDERIN] per-draw input (resolve path, self-gated) + MvppMap64Probe.OnInput(texture, stage, bindingInfo.Handle); // [MAP64] per-draw input (resolve path, self-gated) + MvppMvBufProbe.OnSampled(texture); // [MVBUF] identity of the sampled MV buffer (self-gated) + MvppTwinMapProbe.OnRead(texture, stage, bindingInfo.Handle, texturePool, textureId, _channel.MemoryManager); // [TWINMAP] reader census + pool truth (self-gated) + + // [TWINFIX] (EXP 21) read-side redirect: reads of the sky-only twin are served by + // the material twin. Self-gated; returns the input texture unless armed+matched. + texture = MvppTwinFixProbe.MaybeRedirect(texture, stage, bindingInfo.Handle); + texture?.EnsureForcedMips(); // [FORCEMIPS] no-op unless boosted and dirty specStateMatches &= specState.MatchesTexture(stage, index, descriptor); @@ -798,6 +901,8 @@ namespace Ryujinx.Graphics.Gpu.Image // The texture is already bound. cachedTexture.SynchronizeMemory(); + MvppTwinXferProbe.OnImageBind(cachedTexture, stage, bindingInfo.Handle, isStore); // [TWINXFER] self-gated + if (isStore) { cachedTexture.SignalModified(); @@ -837,6 +942,8 @@ namespace Ryujinx.Graphics.Gpu.Image } else { + MvppTwinXferProbe.OnImageBind(texture, stage, bindingInfo.Handle, isStore); // [TWINXFER] self-gated + if (isStore) { texture?.SignalModified(); diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureCache.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureCache.cs index 4ff25cd8e..6816d630e 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureCache.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureCache.cs @@ -66,6 +66,12 @@ namespace Ryujinx.Graphics.Gpu.Image _textureOverlaps = new Texture[OverlapsBufferInitialCapacity]; _overlapInfo = new OverlapInfo[OverlapsBufferInitialCapacity]; + // [VIEWALIAS] / [FULLSYNC] / [MAP64] Positive controls: make the probes' silence interpretable. + MvppViewAliasProbe.ReportArmed(); + MvppFullSyncProbe.ReportArmed(); + MvppMap64Probe.ReportArmed(); + MvppMvBufProbe.ReportArmed(); + _cache = []; } @@ -872,6 +878,9 @@ namespace Ryujinx.Graphics.Gpu.Image info = info.CreateInfoWithFormat(overlap.Info.FormatInfo); } + // [VIEWALIAS] Read-only: report views created over a parent of a DIFFERENT format. + MvppViewAliasProbe.OnViewCreated(info, overlap, oInfo.FirstLayer, oInfo.FirstLevel, oInfo.Compatibility.ToString()); + texture = overlap.CreateView(info, sizeInfo, range.Value, oInfo.FirstLayer, oInfo.FirstLevel); texture.SynchronizeMemory(); } diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureGroup.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureGroup.cs index bfb4b839e..0816abce7 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureGroup.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureGroup.cs @@ -363,6 +363,7 @@ namespace Ryujinx.Graphics.Gpu.Image if (group.NeedsCopy && group.Copy(_context)) { + MvppMvSyncProbe.OnCopyIn(Storage); // [MVSYNC] read-only, self-gated anyModified |= true; // The copy target has been modified. handleDirty = false; } @@ -386,7 +387,11 @@ namespace Ryujinx.Graphics.Gpu.Image if (dirty) { - if (anyNotDirty || (_handles.Length > 1 && (anyModified || split))) + bool partialPath = anyNotDirty || (_handles.Length > 1 && (anyModified || split)); + + MvppMvSyncProbe.OnSyncDecision(Storage, partialPath, regionCount, anyModified); // [MVSYNC] read-only, self-gated + + if (partialPath) { // Partial texture invalidation. Only update the layers/levels with dirty flags of the storage. diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs index 7b960bb57..66dccabfa 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs @@ -114,6 +114,12 @@ namespace Ryujinx.Graphics.Gpu.Image _gpBindingsManager.MvppEnumerateInputsStage(report); } + /// [GLOWPROBE, read-only] Bound textures WITH their sampler. + public void MvppEnumerateGraphicsInputsWithSampler(System.Action report) + { + _gpBindingsManager.MvppEnumerateInputsWithSampler(report); + } + /// [MV++ UI audit, read-only] Enumerates the currently-bound colour render targets (all 8 slots). public void MvppEnumerateRenderTargets(System.Action report) { @@ -253,6 +259,11 @@ namespace Ryujinx.Graphics.Gpu.Image color.DemoteForcedMipsOnRenderTargetBind(); // [MIPS-RT fix] no-op hors boost (2 tests de champ) } + if (MvppFeedbackProbe.Enabled && color != null) + { + MvppFeedbackProbe.OnWrite(color); // [FEEDBACKPROBE] colour target bound = write (gated) + } + _rtColors[index] = color; } @@ -311,6 +322,18 @@ namespace Ryujinx.Graphics.Gpu.Image /// public Texture RenderTargetColor0 => _rtColors[0]; + /// + /// [E0/SCENEPROBE read-only] Number of colour render-target (MRT) slots. Lets the scene probe + /// enumerate every out_attr_N attachment. No behaviour change. + /// + public int ColorTargetsLength => _rtColors.Length; + + /// + /// [E0/SCENEPROBE read-only] Bound colour render target at the given MRT slot, or null. Lets the + /// scene probe log every out_attr_N attachment format (esp. out_attr1, the feedback target). + /// + public Texture GetColorTarget(int index) => (uint)index < (uint)_rtColors.Length ? _rtColors[index] : null; + /// /// Sets the host clip region, which should be the intersection of all render target texture sizes. /// diff --git a/src/Ryujinx.Graphics.Gpu/Image/TexturePool.cs b/src/Ryujinx.Graphics.Gpu/Image/TexturePool.cs index 974ea7b1c..e519b2ef9 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TexturePool.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TexturePool.cs @@ -598,7 +598,25 @@ namespace Ryujinx.Graphics.Gpu.Image int gobBlocksInZ = descriptor.UnpackGobBlocksInZ(); if (target != Target.Texture3D && gobBlocksInZ > 1 && depthOrLayers > 1) { - gobBlocksInZ = 1; + // [GOBPROBE, 21/07] This clamp is a FORK addition (it was put in for the BOTW/TOTK + // 4K packs); upstream honours the descriptor. It overrides the block-linear stride + // the game declared, and getting that stride wrong is precisely what produces + // RECTANGLES OF DISPLACED-BUT-REAL CONTENT -- the shape of the Xenoblade 2 artefact + // (blocky rows, worse indoors where more array textures are in flight, appearing as + // the camera turns and brings new ones in). + // + // Read-only for now: the clamp still applies exactly as before, we only COUNT it and + // describe what it hit. If it never fires on XC2 the lead dies here; if it fires + // constantly we have the suspect, and RYUJINX_NO_GOBZ_CLAMP=1 then tests it for real. + if (MvppGobProbe.Enabled) + { + MvppGobProbe.NoteClamp(width, height, depthOrLayers, gobBlocksInZ, target.ToString(), formatInfo.Format.ToString()); + } + + if (!MvppGobProbe.ClampDisabled) + { + gobBlocksInZ = 1; + } } int gobBlocksInTileX = descriptor.UnpackGobBlocksInTileX(); diff --git a/src/Ryujinx.Graphics.Gpu/Memory/Buffer.cs b/src/Ryujinx.Graphics.Gpu/Memory/Buffer.cs index 3bf02f54d..05462ada5 100644 --- a/src/Ryujinx.Graphics.Gpu/Memory/Buffer.cs +++ b/src/Ryujinx.Graphics.Gpu/Memory/Buffer.cs @@ -180,7 +180,7 @@ namespace Ryujinx.Graphics.Gpu.Memory } _externalFlushDelegate = ExternalFlush; - _loadDelegate = LoadRegion; + _loadDelegate = _bufCoalEnabled ? LoadRegionCoalesced : LoadRegion; _modifiedDelegate = RegionModified; _virtualDependenciesLock = new ReaderWriterLockSlim(); @@ -283,16 +283,128 @@ namespace Ryujinx.Graphics.Gpu.Memory /// /// Start address of the range to synchronize /// Size in bytes of the range to synchronize + // [Beast Roofer diag] RYUJINX_BUFSEQ=1 (EXP 13, gated OFF by default): defeat the + // sequence-number short-circuit of buffer synchronization. Within one sequence, a buffer + // region is checked for CPU writes at most once -- so a game writing constants MID-frame + // (CPU running ahead, standard) has those writes served only NEXT sequence: late-frame + // draws read one-generation-stale data. That is the LAST standing family for the XC2 + // stale-motion artifact (journal 145): patchy per draw, motion-only, barrier-insensitive. + // With the flag on, every query runs with a fresh sequence number (full recheck; slower, + // cannot be wrong). Artifact gone => root found; see the [BUFSEQ] witness counter. + private static readonly bool _bufSeqForce = + Environment.GetEnvironmentVariable("RYUJINX_BUFSEQ") == "1"; + + private static int _bufSeqCounter = int.MaxValue / 2; + private static long _bufSeqCalls; + private static long _bufSeqLogMs; + + // [BUFCOAL 02/08, journal (364)-(365)] Coalescence a trou tolere des uploads invite->hote + // (RYUJINX_BUFCOAL=1, OFF par defaut = chemin stock a l'octet pres). Mesure (362)-(363) : + // en rotation BOTW le fil GPU emet ~17 000 SetBufferData par vraie image (miettes de + // 160 o a 4 Ko), et A ~50 000 appels/s la machinerie de la file threadee par commande + // coute autant que les octets. Ce gate fusionne les regions sales d'un MEME buffer + // distantes de moins de RYUJINX_BUFCOAL_GAP octets (defaut 4096, borne 64 Ko) en UN + // LoadRegion, a l'interieur d'UN SEUL SynchronizeMemory (jamais de report inter-appel). + // ⚠️ SURETE : re-uploader des octets PROPRES n'est correct que si l'hote n'a pas de + // donnees que l'invite n'a pas — donc coalescence UNIQUEMENT quand _modifiedRanges est + // null (aucune plage ecrite par le GPU) ; sinon repli immediat sur le chemin stock + // (ExcludeModifiedRegions doit continuer de decouper autour des plages GPU). + private static readonly bool _bufCoalEnabled = + Environment.GetEnvironmentVariable("RYUJINX_BUFCOAL") == "1"; + + private static readonly ulong _bufCoalGap = + ulong.TryParse(Environment.GetEnvironmentVariable("RYUJINX_BUFCOAL_GAP"), out ulong g) + ? Math.Min(g, 0x10000) + : 0x1000; + + private static bool _bufCoalArmedLogged; + + private ulong _coalStart = ulong.MaxValue; + private ulong _coalEnd; + + /// + /// Gated replacement for the load delegate: merges dirty regions of this buffer that are + /// within the tolerated gap into one pending window, flushed by + /// at the end of the synchronization. Falls back to the stock path the moment the buffer + /// has GPU-modified ranges (see the [BUFCOAL] safety note above). + /// + private void LoadRegionCoalesced(ulong mAddress, ulong mSize) + { + if (!_bufCoalArmedLogged) + { + _bufCoalArmedLogged = true; + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"[BUFCOAL] ARME (RYUJINX_BUFCOAL=1, trou tolere {_bufCoalGap} o) - coalescence des uploads buffer."); + } + + if (_modifiedRanges != null) + { + FlushCoalesced(); + LoadRegion(mAddress, mSize); + + return; + } + + ulong end = mAddress + mSize; + + if (_coalStart == ulong.MaxValue) + { + _coalStart = mAddress; + _coalEnd = end; + } + else if (mAddress <= _coalEnd + _bufCoalGap && end + _bufCoalGap >= _coalStart) + { + _coalStart = Math.Min(_coalStart, mAddress); + _coalEnd = Math.Max(_coalEnd, end); + } + else + { + FlushCoalesced(); + _coalStart = mAddress; + _coalEnd = end; + } + } + + /// Uploads the pending coalesced window, if any. Must run before leaving the + /// synchronization that produced it (the window never survives across calls). + private void FlushCoalesced() + { + if (_coalStart != ulong.MaxValue) + { + ulong start = _coalStart; + ulong size = _coalEnd - _coalStart; + _coalStart = ulong.MaxValue; + + LoadRegion(start, size); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SynchronizeMemory(ulong address, ulong size) { if (_useGranular) { - _memoryTrackingGranular.QueryModified(address, size, _modifiedDelegate, _context.SequenceNumber); + int seq = _context.SequenceNumber; + + if (_bufSeqForce) + { + seq = System.Threading.Interlocked.Increment(ref _bufSeqCounter); + + long calls = ++_bufSeqCalls; + long now = Environment.TickCount64; + if (now - _bufSeqLogMs >= 3000) + { + _bufSeqLogMs = now; + Ryujinx.Common.Logging.Logger.Warning?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"[BUFSEQ] forced full rechecks so far: {calls}"); + } + } + + _memoryTrackingGranular.QueryModified(address, size, _modifiedDelegate, seq); } else { - if (_context.SequenceNumber != _sequenceNumber && _memoryTracking.DirtyOrVolatile()) + if ((_context.SequenceNumber != _sequenceNumber || _bufSeqForce) && _memoryTracking.DirtyOrVolatile()) { _memoryTracking.Reprotect(); @@ -304,6 +416,7 @@ namespace Ryujinx.Graphics.Gpu.Memory { BackingState.RecordSet(); _context.Renderer.SetBufferData(Handle, 0, _physicalMemory.GetSpan(Address, (int)Size)); + MvppPalProbe.OnUpload(Address, Size, site: 0, _physicalMemory); // [PAL/UPVOL] read-only (gated): full guest->host upload CopyToDependantVirtualBuffers(); } @@ -324,12 +437,18 @@ namespace Ryujinx.Graphics.Gpu.Memory } else { - LoadRegion(_dirtyStart, _dirtyEnd - _dirtyStart); + _loadDelegate(_dirtyStart, _dirtyEnd - _dirtyStart); } _dirtyStart = ulong.MaxValue; } } + + if (_bufCoalEnabled) + { + // [BUFCOAL] la fenetre en attente ne survit jamais a la synchro qui l'a produite. + FlushCoalesced(); + } } /// @@ -574,7 +693,7 @@ namespace Ryujinx.Graphics.Gpu.Memory } else { - LoadRegion(mAddress, mSize); + _loadDelegate(mAddress, mSize); } } @@ -591,6 +710,8 @@ namespace Ryujinx.Graphics.Gpu.Memory _context.Renderer.SetBufferData(Handle, offset, _physicalMemory.GetSpan(mAddress, (int)mSize)); + MvppPalProbe.OnUpload(mAddress, mSize, site: 1, _physicalMemory); // [PAL/UPVOL] read-only (gated): dirty-region upload (LoadRegion) + CopyToDependantVirtualBuffers(mAddress, mSize); } diff --git a/src/Ryujinx.Graphics.Gpu/Memory/BufferCache.cs b/src/Ryujinx.Graphics.Gpu/Memory/BufferCache.cs index 83869ed02..ae8351340 100644 --- a/src/Ryujinx.Graphics.Gpu/Memory/BufferCache.cs +++ b/src/Ryujinx.Graphics.Gpu/Memory/BufferCache.cs @@ -690,6 +690,9 @@ namespace Ryujinx.Graphics.Gpu.Memory MultiRange srcRange = TranslateAndCreateMultiBuffersPhysicalOnly(memoryManager, srcVa, size, BufferStage.Copy); MultiRange dstRange = TranslateAndCreateMultiBuffersPhysicalOnly(memoryManager, dstVa, size, BufferStage.Copy); + // [PAL] Read-only (gated): does a GPU copy write into the tracked palettes? + MvppPalProbe.OnGpuCopy(dstRange.GetSubRange(0).Address, size); + if (srcRange.Count == 1 && dstRange.Count == 1) { CopyBufferSingleRange(memoryManager, srcRange.GetSubRange(0).Address, dstRange.GetSubRange(0).Address, size); diff --git a/src/Ryujinx.Graphics.Gpu/Memory/BufferManager.cs b/src/Ryujinx.Graphics.Gpu/Memory/BufferManager.cs index 73647bef5..4bc5be7b9 100644 --- a/src/Ryujinx.Graphics.Gpu/Memory/BufferManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Memory/BufferManager.cs @@ -215,6 +215,9 @@ namespace Ryujinx.Graphics.Gpu.Memory { MultiRange range = _channel.MemoryManager.Physical.BufferCache.TranslateAndCreateMultiBuffers(_channel.MemoryManager, gpuVa, size, BufferStage.TransformFeedback); + // [PAL] Read-only (gated): does transform feedback write into the tracked palettes? + MvppPalProbe.OnXfbBind(index, range); + _transformFeedbackBuffers[index] = new BufferBounds(range); _transformFeedbackBuffersDirty = true; } @@ -262,6 +265,9 @@ namespace Ryujinx.Graphics.Gpu.Memory MultiRange range = _channel.MemoryManager.Physical.BufferCache.TranslateAndCreateMultiBuffers(_channel.MemoryManager, gpuVa, size, BufferStageUtils.ComputeStorage(flags)); + // [PAL] Read-only (gated): which compute dispatches touch the tracked palettes. + MvppPalProbe.OnComputeBind(index, range, (int)flags); + _cpStorageBuffers.SetBounds(index, range, flags); } @@ -286,6 +292,9 @@ namespace Ryujinx.Graphics.Gpu.Memory MultiRange range = _channel.MemoryManager.Physical.BufferCache.TranslateAndCreateMultiBuffers(_channel.MemoryManager, gpuVa, size, BufferStageUtils.GraphicsStorage(stage, flags)); + // [PAL] Read-only (gated): guest-hash the XC2 velocity palettes (vertex SSBO slots 0/1). + MvppPalProbe.OnBind(stage, index, range, _channel.MemoryManager.Physical); + if (!buffers.Buffers[index].Range.Equals(range)) { _gpStorageBuffersDirty = true; diff --git a/src/Ryujinx.Graphics.Gpu/Memory/MvppPalProbe.cs b/src/Ryujinx.Graphics.Gpu/Memory/MvppPalProbe.cs new file mode 100644 index 000000000..f20348244 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Memory/MvppPalProbe.cs @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Memory.Range; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Gpu.Memory +{ + /// + /// [PAL] Read-only probe (RYUJINX_PAL_PROBE=1), inert unless set. v2. + /// Watches the XC2 velocity matrix palettes (vertex-stage SSBOs, slots 0/1). + /// + /// v1 lessons (journal 137/138): slot 0 is shared by 7+ different SSBOs (first-bind-per-frame + /// sampling compared DIFFERENT buffers across frames), and a 256-byte prefix hash misses + /// palette changes past the first matrix. The v1 run still nailed the architecture: the two + /// big palettes (0x5DC00 bytes) PING-PONG between slots 0 and 1 every frame. + /// + /// v2 therefore tracks buffers BY ADDRESS, not by slot: every distinct (address, size) bound + /// at vertex slots 0/1 with size >= 64 KiB (the big palettes only), with a STRIDED hash over + /// the WHOLE range (1 byte every 4093 -- prime stride, ~94 samples for 384 KiB), once per + /// frame per buffer. Uploads (Buffer.LoadRegion / full SetBufferData) are attributed per + /// tracked buffer. Reading grid: + /// - a tracked palette shows guest CHANGES with ZERO intersecting upload that frame, and + /// the artifact correlates with motion of skinned objects => STALE DATA SERVED = root; + /// - every guest change is matched by an upload => buffer sync innocent for these ranges; + /// - palettes never change even with NPCs moving on screen => palettes are not per-frame + /// data as assumed: revisit the VS reading. + /// + static class MvppPalProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_PAL_PROBE") == "1"; + + private const ulong MinTrackedSize = 0x1000; // v5: >=4KiB -- catch skinned-character palettes too + private const int MaxTracked = 32; + private const int HashStride = 4093; + private const int AnomalyLogCap = 24; + + private static bool _armedLogged; + private static long _frame; + + private sealed class Rec + { + public ulong Address; + public ulong Size; + public uint LastHash; + public bool HashValid; + public bool SeenThisFrame; + public bool ChangedThisFrame; + public int UploadsThisFrame; + public long FramesSeen; + public long FramesChanged; + public long FramesChangedWithUpload; + public long FramesChangedNoUpload; + } + + private static readonly Dictionary _tracked = new(); + private static readonly object _lock = new(); + private static long _anomaliesLogged; + private static long _summaryMs; + + /// Vertex-stage storage buffer bound (BufferManager.SetGraphicsStorageBuffer). Self-gated. + public static void OnBind(int stage, int index, MultiRange range, PhysicalMemory pm) + { + if (!Enabled || stage != 0 || index > 5 || pm == null) // v5: slots 0..5 + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[PAL] ARMED v2 (RYUJINX_PAL_PROBE=1). Tracking big vertex SSBOs (slots 0/1, >=64KiB) BY ADDRESS: strided guest hash vs host uploads, per frame."); + } + + MemoryRange sub = range.GetSubRange(0); + if (sub.Address == MemoryManager.PteUnmapped || sub.Size < MinTrackedSize) + { + return; + } + + lock (_lock) + { + if (!_tracked.TryGetValue(sub.Address, out Rec rec)) + { + if (_tracked.Count >= MaxTracked) + { + return; + } + + _tracked[sub.Address] = rec = new Rec { Address = sub.Address, Size = sub.Size }; + + Logger.Warning?.Print(LogClass.Gpu, + $"[PAL] TRACKING palette 0x{sub.Address:X} size 0x{sub.Size:X} (slot {index}, {_tracked.Count} tracked)"); + } + + if (rec.SeenThisFrame) + { + return; // one hash per buffer per frame + } + + rec.SeenThisFrame = true; + rec.FramesSeen++; + + ReadOnlySpan data = pm.GetSpan(rec.Address, (int)rec.Size); + + uint hash = 2166136261; + for (int i = 0; i < data.Length; i += HashStride) + { + hash = (hash ^ data[i]) * 16777619; + } + + rec.ChangedThisFrame = rec.HashValid && hash != rec.LastHash; + rec.LastHash = hash; + rec.HashValid = true; + } + } + + // v3: identify the PRODUCER. v2 measured that no tracked palette is ever CPU-written + // (guest hash frozen over 3600 frames with NPCs moving) => they are GPU-written. This + // hook logs which COMPUTE dispatches bind a tracked range as storage, with the usage + // flags -- write-usage binds name the producer pass. + private static readonly HashSet<(ulong, int, int)> _computeBinds = new(); + + /// Compute storage buffer bound (BufferManager.SetComputeStorageBuffer). Self-gated. + public static void OnComputeBind(int index, MultiRange range, int flags) + { + if (!Enabled) + { + return; + } + + MemoryRange sub = range.GetSubRange(0); + + lock (_lock) + { + foreach (Rec rec in _tracked.Values) + { + if (sub.Address < rec.Address + rec.Size && + rec.Address < sub.Address + sub.Size) + { + if (_computeBinds.Add((rec.Address, index, flags))) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[PAL] COMPUTE BINDS palette 0x{rec.Address:X} at c-slot {index} flags=0x{flags:X} (bind range 0x{sub.Address:X}+0x{sub.Size:X})"); + } + } + } + } + } + + // v4: v3 measured ZERO compute binds on the tracked palettes -- the producer is not a + // compute SSBO write. Remaining GPU write paths: TRANSFORM FEEDBACK (vertex-shader + // skinning into buffers, classic for this engine generation) and DMA buffer copies. + private static readonly HashSet<(ulong, int)> _xfbBinds = new(); + private static readonly HashSet<(ulong, ulong)> _copyHits = new(); + + /// Transform feedback buffer bound (BufferManager.SetTransformFeedbackBuffer). Self-gated. + public static void OnXfbBind(int index, MultiRange range) + { + if (!Enabled) + { + return; + } + + MemoryRange sub = range.GetSubRange(0); + + lock (_lock) + { + foreach (Rec rec in _tracked.Values) + { + if (sub.Address < rec.Address + rec.Size && + rec.Address < sub.Address + sub.Size) + { + if (_xfbBinds.Add((rec.Address, index))) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[PAL] *** TRANSFORM FEEDBACK WRITES palette 0x{rec.Address:X} *** (xfb slot {index}, bind 0x{sub.Address:X}+0x{sub.Size:X})"); + } + } + } + } + } + + /// GPU buffer copy (BufferCache.CopyBuffer). Self-gated; physical addresses. + public static void OnGpuCopy(ulong dstAddress, ulong size) + { + if (!Enabled) + { + return; + } + + lock (_lock) + { + foreach (Rec rec in _tracked.Values) + { + if (dstAddress < rec.Address + rec.Size && + rec.Address < dstAddress + size) + { + if (_copyHits.Add((rec.Address, dstAddress))) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[PAL] *** GPU COPY WRITES palette 0x{rec.Address:X} *** (dst 0x{dstAddress:X}+0x{size:X})"); + } + } + } + } + } + + /// Guest-to-host buffer upload (Buffer.LoadRegion / full SetBufferData). Self-gated. + /// site : 0 = SetBufferData COMPLET (creation/reset, Buffer.cs:~338), 1 = LoadRegion + /// (region sale d'un buffer existant, Buffer.cs:~626). Sert au split UPVOL v2 (362). + public static void OnUpload(ulong address, ulong size, int site = 0, PhysicalMemory pm = null) + { + // [UPVOL] compteur de volume independant (gate RYUJINX_UPVOL, inerte sinon) -- + // partage ces sites d'appel pour ne pas toucher une 2e fois au chemin chaud. + MvppUpVolProbe.OnUpload(address, size, site, pm); + + if (!Enabled) + { + return; + } + + lock (_lock) + { + foreach (Rec rec in _tracked.Values) + { + if (rec.SeenThisFrame && + address < rec.Address + rec.Size && + rec.Address < address + size) + { + rec.UploadsThisFrame++; + } + } + } + } + + /// Guest frame boundary (Window present). Folds per-frame flags into the verdict counters. + public static void OnPresent() + { + if (!Enabled) + { + return; + } + + lock (_lock) + { + _frame++; + + foreach (Rec rec in _tracked.Values) + { + if (rec.SeenThisFrame && rec.ChangedThisFrame) + { + rec.FramesChanged++; + + if (rec.UploadsThisFrame > 0) + { + rec.FramesChangedWithUpload++; + } + else + { + rec.FramesChangedNoUpload++; + + if (_anomaliesLogged++ < AnomalyLogCap) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[PAL] frame {_frame}: palette 0x{rec.Address:X} guest CHANGED but ZERO host upload *** STALE DATA SERVED ***"); + } + } + } + + rec.SeenThisFrame = false; + rec.ChangedThisFrame = false; + rec.UploadsThisFrame = 0; + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000 && _frame > 0) + { + _summaryMs = now; + + var sb = new System.Text.StringBuilder(); + foreach (Rec rec in _tracked.Values) + { + if (rec.FramesSeen > 0) + { + sb.Append($"0x{rec.Address:X}: seen {rec.FramesSeen}, changed {rec.FramesChanged} (ok {rec.FramesChangedWithUpload}, NOUP {rec.FramesChangedNoUpload}); "); + } + } + + Logger.Warning?.Print(LogClass.Gpu, $"[PAL/SUMMARY ~3s] frames={_frame} | {sb}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Memory/MvppUpVolProbe.cs b/src/Ryujinx.Graphics.Gpu/Memory/MvppUpVolProbe.cs new file mode 100644 index 000000000..650d1010a --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Memory/MvppUpVolProbe.cs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Gpu.Memory +{ + /// + /// [UPVOL v2] Compteur de VOLUME des uploads invite->hote (RYUJINX_UPVOL=1, inerte sinon). + /// Journal (361)-(362). v1 a mesure : rotation = ~45 000 uploads/s, 112-122 Mo/s, tout en + /// 4-64 Ko (immobile 3 500/s, 8 Mo/s) => RE-UPLOADS, pas des donnees neuves. + /// v2 SPLIT PAR PORTE pour viser le fix : + /// site 0 = SetBufferData COMPLET (creation/reset de buffer, Buffer.cs:~338) + /// site 1 = LoadRegion (region sale d'un buffer existant, Buffer.cs:~626) + /// Rafale au site 1 => fix = politique de synchro (dedup/fusion/dissociation) ; + /// rafale au site 0 => fix = politique du cache (retention, tempete de recreation). + /// v2 corrige aussi le caveat (362) : DEUX fils appellent (GPU + present) => Interlocked. + /// Fenetre ~5 s, histogramme de tailles, log Info une ligne. + /// + static class MvppUpVolProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_UPVOL") == "1"; + + private static bool _armedLogged; + private static long _windowMs; + private static long _calls; + private static long _bytes; + private static long _maxSize; + private static long _site0Calls; + private static long _site0Bytes; + private static long _site1Calls; + private static long _site1Bytes; + // Buckets: <4K, 4K-64K, 64K-1M, >=1M (nombre, octets) + private static readonly long[] _bucketCalls = new long[4]; + private static readonly long[] _bucketBytes = new long[4]; + + // [MIRBENCH v3] micro-banc de vitesse de lecture du MIROIR invite (l'hypothese (366) : + // la copie productrice est ~35x plus lente PAR OCTET que la meme copie depuis le pool). + // A chaque fenetre : retenir le PLUS GROS upload ; au flush, relire CE range depuis le + // miroir (pm.GetSpan -> copie vers un scratch) et copier la MEME taille depuis un tableau + // gere -> deux debits Mo/s compares, meme taille, meme scratch. Lecture seule. + private static ulong _benchAddr; + private static long _benchSize; + private static PhysicalMemory _benchPm; + private static byte[] _benchScratch; + private static byte[] _benchManaged; + + public static void OnUpload(ulong address, ulong size, int site, PhysicalMemory pm) + { + if (!Enabled) + { + return; + } + + if ((long)size > Volatile.Read(ref _benchSize) && pm != null) + { + _benchAddr = address; + _benchPm = pm; + Volatile.Write(ref _benchSize, (long)size); + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Info?.Print(LogClass.Gpu, "[UPVOL] ARME v2 (RYUJINX_UPVOL=1) - volume des uploads buffer par porte (0=complet, 1=region), fenetre ~5 s."); + } + + long s = (long)size; + Interlocked.Increment(ref _calls); + Interlocked.Add(ref _bytes, s); + + long seenMax = Volatile.Read(ref _maxSize); + while (s > seenMax && Interlocked.CompareExchange(ref _maxSize, s, seenMax) != seenMax) + { + seenMax = Volatile.Read(ref _maxSize); + } + + if (site == 0) + { + Interlocked.Increment(ref _site0Calls); + Interlocked.Add(ref _site0Bytes, s); + } + else + { + Interlocked.Increment(ref _site1Calls); + Interlocked.Add(ref _site1Bytes, s); + } + + int b = s < 0x1000 ? 0 : s < 0x10000 ? 1 : s < 0x100000 ? 2 : 3; + Interlocked.Increment(ref _bucketCalls[b]); + Interlocked.Add(ref _bucketBytes[b], s); + + long now = Environment.TickCount64; + long window = Volatile.Read(ref _windowMs); + + if (window == 0) + { + Interlocked.CompareExchange(ref _windowMs, now, 0); + } + else if (now - window >= 5000 && Interlocked.CompareExchange(ref _windowMs, now, window) == window) + { + double sec = (now - window) / 1000.0; + long calls = Interlocked.Exchange(ref _calls, 0); + long bytes = Interlocked.Exchange(ref _bytes, 0); + long max = Interlocked.Exchange(ref _maxSize, 0); + long s0c = Interlocked.Exchange(ref _site0Calls, 0); + long s0b = Interlocked.Exchange(ref _site0Bytes, 0); + long s1c = Interlocked.Exchange(ref _site1Calls, 0); + long s1b = Interlocked.Exchange(ref _site1Bytes, 0); + long b0c = Interlocked.Exchange(ref _bucketCalls[0], 0); + long b0b = Interlocked.Exchange(ref _bucketBytes[0], 0); + long b1c = Interlocked.Exchange(ref _bucketCalls[1], 0); + long b1b = Interlocked.Exchange(ref _bucketBytes[1], 0); + long b2c = Interlocked.Exchange(ref _bucketCalls[2], 0); + long b2b = Interlocked.Exchange(ref _bucketBytes[2], 0); + long b3c = Interlocked.Exchange(ref _bucketCalls[3], 0); + long b3b = Interlocked.Exchange(ref _bucketBytes[3], 0); + + Logger.Info?.Print(LogClass.Gpu, + $"[UPVOL ~5s] appels {calls} ({calls / sec:F0}/s), total {bytes / 1048576.0:F1} Mo ({bytes / 1048576.0 / sec:F1} Mo/s), max {max / 1024} Ko | " + + $"COMPLET: {s0c} ({s0b / 1048576.0:F1} Mo) | REGION: {s1c} ({s1b / 1048576.0:F1} Mo) | " + + $"<4K: {b0c} ({b0b / 1024} Ko) | 4-64K: {b1c} ({b1b / 1048576.0:F1} Mo) | 64K-1M: {b2c} ({b2b / 1048576.0:F1} Mo) | >=1M: {b3c} ({b3b / 1048576.0:F1} Mo)"); + + RunMirrorBench(); + } + } + + /// [MIRBENCH] relit le plus gros upload de la fenetre depuis le miroir et copie + /// la meme taille depuis un tableau gere : deux debits compares, une ligne de log. + private static void RunMirrorBench() + { + long benchSize = Interlocked.Exchange(ref _benchSize, 0); + ulong benchAddr = _benchAddr; + PhysicalMemory pm = _benchPm; + + if (pm == null || benchSize < 0x4000) + { + return; // rien d'assez gros dans la fenetre pour un chrono fiable + } + + int sz = (int)Math.Min(benchSize, 0x40000); + + if (_benchScratch == null || _benchScratch.Length < sz) + { + _benchScratch = new byte[sz]; + _benchManaged = new byte[sz]; + } + + try + { + // Lecture MIROIR, DEUX passes chronometrees : la 1re est FROIDE (TLB/defauts de + // premiere lecture = exactement le cout que subit la copie de production, qui ne + // lit chaque region qu'une fois), la 2e est chaude (reference haute). Le scratch + // est reutilise entre fenetres => sa premiere-touche ne pollue que la fenetre 1. + ReadOnlySpan src = pm.GetSpan(benchAddr, sz); + long t0 = System.Diagnostics.Stopwatch.GetTimestamp(); + src.CopyTo(_benchScratch); + long t1 = System.Diagnostics.Stopwatch.GetTimestamp(); + src.CopyTo(_benchScratch); + long t2 = System.Diagnostics.Stopwatch.GetTimestamp(); + + // Meme taille, source = tableau gere ordinaire (chaud) + _benchManaged.AsSpan(0, sz).CopyTo(_benchScratch); + long t3 = System.Diagnostics.Stopwatch.GetTimestamp(); + _benchManaged.AsSpan(0, sz).CopyTo(_benchScratch); + long t4 = System.Diagnostics.Stopwatch.GetTimestamp(); + + double freq = System.Diagnostics.Stopwatch.Frequency; + double mirrorColdMBs = sz / ((t1 - t0) / freq) / 1048576.0; + double mirrorWarmMBs = sz / ((t2 - t1) / freq) / 1048576.0; + double managedMBs = sz / ((t4 - t3) / freq) / 1048576.0; + + Logger.Info?.Print(LogClass.Gpu, + $"[MIRBENCH] {sz / 1024} Ko @0x{benchAddr:X}: miroir FROID {mirrorColdMBs:F0} Mo/s, chaud {mirrorWarmMBs:F0} Mo/s, gere {managedMBs:F0} Mo/s (gere/froid {managedMBs / Math.Max(1, mirrorColdMBs):F1}x)"); + } + catch (Exception e) + { + Logger.Warning?.Print(LogClass.Gpu, $"[MIRBENCH] echec lecture @0x{benchAddr:X}+{sz}: {e.Message} (banc ignore cette fenetre)"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs b/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs index b0f2fe84b..c6a3d9186 100644 --- a/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs @@ -22,6 +22,16 @@ namespace Ryujinx.Graphics.Gpu.Memory Vector4 defaultScale = new() { X = 1f, Y = 0f, Z = 0f, W = 0f }; _data.RenderScale.AsSpan().Fill(defaultScale); DirtyRenderScale(0, SupportBuffer.RenderScaleMaxCount); + + // [JITTERINIT 02/08, journal (380)] Le bloc jitter est emis dans CHAQUE vertex shader + // et LIT JitterOffset a chaque image — mais le champ n'etait marque dirty que quand sa + // valeur CHANGEAIT : jitter jamais arme ⇒ zone jamais televersee ⇒ le shader lisait de + // la memoire GPU non initialisee (suspect n°1 du dossier ecran-vert, 4 jeux). Marquer + // le zero initial dirty garantit un (0,0) reellement present sur le GPU des la + // premiere image, exactement comme RenderScale ci-dessus. + _data.JitterOffset.X = 0f; + _data.JitterOffset.Y = 0f; + MarkDirty(SupportBuffer.JitterOffsetOffset, SupportBuffer.FieldSize); } /// diff --git a/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs b/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs index 317407881..d74c9eb66 100644 --- a/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs +++ b/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs @@ -22,7 +22,11 @@ namespace Ryujinx.Graphics.Gpu.Shader.DiskCache private const ushort FileFormatVersionMajor = 1; private const ushort FileFormatVersionMinor = 2; private const uint FileFormatVersionPacked = ((uint)FileFormatVersionMajor << 16) | FileFormatVersionMinor; - private const uint CodeGenVersion = 7354; + // [JITTEREMIT 03/08] Le bloc de jitter clip-space n'est plus emis dans les vertex shaders + // quand le jitter est eteint (il lisait Position en sortie avant de l'ecrire = indefini). + // La TRADUCTION change => les caches de shaders existants doivent etre invalides, sinon + // les utilisateurs continuent d'executer l'ancien code et le correctif ne les atteint pas. + private const uint CodeGenVersion = 7355; private const string SharedTocFileName = "shared.toc"; private const string SharedDataFileName = "shared.data"; @@ -573,7 +577,22 @@ namespace Ryujinx.Graphics.Gpu.Shader.DiskCache Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.ProbeEnabled || Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.RewriteEnabled || Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.CaptureEnabled || - Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.LodBiasEnabled; + Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.LodBiasEnabled || + Ryujinx.Graphics.Shader.Translation.HashTestProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MufuPrecProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.RroReduceProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.CocConstProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.TileMapConstProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvKillProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvResolveMvProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvppTileCapProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvppTruncEpsProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvppForceLod0Probe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvSignProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.SkyMvProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.NanScrubProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.PeriscopeProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.PeriscopeProbe.Stage2; public void AddShader(GpuContext context, CachedShaderProgram program, ReadOnlySpan hostCode, DiskCacheOutputStreams streams = null) { diff --git a/src/Ryujinx.Graphics.Gpu/Shader/ShaderCache.cs b/src/Ryujinx.Graphics.Gpu/Shader/ShaderCache.cs index 8330bf4b1..6df4e3c3e 100644 --- a/src/Ryujinx.Graphics.Gpu/Shader/ShaderCache.cs +++ b/src/Ryujinx.Graphics.Gpu/Shader/ShaderCache.cs @@ -70,6 +70,12 @@ namespace Ryujinx.Graphics.Gpu.Shader Environment.GetEnvironmentVariable("RYUJINX_MVPP_VEL_DUMPSPV"); private static int _mvppDumpCount; + // [DUMPMAP] Read-only bridge: when RYUJINX_DUMP_MAP=1, log each dumped shader's guest + // address <-> ShaderDumper file paths, so a guest VA (e.g. fs=0x100082C30) maps to its + // ShaderNNNN.bin. Gated OFF by default; does not change dumping or translation. + private static readonly bool _dumpMap = + Environment.GetEnvironmentVariable("RYUJINX_DUMP_MAP") == "1"; + private readonly struct ProgramToSave { public readonly CachedShaderProgram CachedProgram; @@ -847,6 +853,15 @@ namespace Ryujinx.Graphics.Gpu.Shader code ??= memoryManager.GetSpan(context.Address, context.Size).ToArray(); ShaderDumpPaths paths = dumper?.Dump(code, context.Stage == ShaderStage.Compute) ?? default; + + // [DUMPMAP] Read-only VA<->file bridge (gated RYUJINX_DUMP_MAP=1). Only fires when a dump + // actually happened (paths.HasPath). Does not alter dumping or translation. + if (_dumpMap && paths.HasPath) + { + Logger.Info?.Print(LogClass.Gpu, + $"[DUMPMAP] guest=0x{context.Address:X} stage={context.Stage} full=\"{paths.FullPath}\" code=\"{paths.CodePath}\""); + } + ShaderProgram program = context.Translate(asCompute); paths.Prepend(program); diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs index b723662cc..26214870e 100644 --- a/src/Ryujinx.Graphics.Gpu/Window.cs +++ b/src/Ryujinx.Graphics.Gpu/Window.cs @@ -119,6 +119,7 @@ namespace Ryujinx.Graphics.Gpu private int _framesAvailable; private long _mvppDepthLogMs; private long _mvppFifoLogMs; + private long _jitTraceMs; // [JITTRACE] cadence de l'etape B private int _mvppTakes; private int _mvppTakeHits; private Image.Texture _mvppHeldSceneDepth; @@ -269,10 +270,57 @@ namespace Ryujinx.Graphics.Gpu pt.Cache.Tick(); + // [PRESYNC, 21/07] LE test décisif du dossier XC2. Tous les étages de rendu sont propres au + // dernier draw, l'image affichée est détruite, et rien entre les deux ne montrait d'anomalie + // -- SAUF cette resynchronisation, jamais instrumentée. Si le render target composé sur GPU + // est aussi suivi comme mémoire invitée et marqué sale, SynchronizeMemory recharge la mémoire + // invitée (périmée, car composée côté GPU) PAR-DESSUS le rendu propre. On capture avant/après. + Engine.Threed.MvppPreSyncProbe.Before(texture); + texture.SynchronizeMemory(); + Engine.Threed.MvppPreSyncProbe.After(texture); + + // [LAYOUT, 21/07] La texture présentée arrive DÉJÀ corrompue (mosaïque en blocs = détuilage). + // On compare ce que le JEU a demandé (pt.Info, issu de EnqueueFrameThreadSafe) à ce que le + // cache a réellement fourni (texture.Info). Un désaccord de tuilage (isLinear / gobBlocksInY / + // stride) est le suspect exact. + Engine.Threed.MvppLayoutProbe.Compare(pt.Info, texture); + Engine.Threed.MvppPreSyncProbe.DumpGuest(texture); + MvppDestProbe.RegisterPresented(texture); // read-only destination-anchored probe (gated) + // [RTDUMP, 21/07] The frame as it actually reaches the screen. Everything captured + // so far was an INTERMEDIATE buffer, so "this one looks odd" could never be tied to + // "that block, right there, is what I see". Capturing the presented image in the + // same burst as the intermediates turns the whole investigation around: point at + // the defect, then find which buffer already carries it. + Engine.Threed.MvppRtDumpProbe.NotePresented(texture); + + // Vraie frontière d'image pour la sonde UI : sa détection d'origine (montée du facteur de + // résolution) est muette sur tout jeu rendu à l'échelle native, XC2 compris. + Engine.Threed.MvppUiProbe.OnPresent(); + Engine.Threed.MvppDrawStepProbe.OnPresent(texture); + Engine.Twod.MvppTwodProbe.OnPresent(); + Image.MvppCacheProbe.OnPresent(); + Image.MvppMvBufProbe.OnPresent(); // [MVBUF] frame boundary (self-gated) + Image.MvppMvSyncProbe.OnPresent(); // [MVSYNC] arming witness + heartbeat (self-gated) + Image.MvppWriterCensusProbe.OnPresent(); // [CENSUS] arming witness + heartbeat (self-gated) + Image.MvppTwinXferProbe.OnPresent(); // [TWINXFER] arming witness + heartbeat (self-gated) + Memory.MvppPalProbe.OnPresent(); // [PAL] frame boundary (self-gated) + Image.MvppTaaProbe.OnPresent(); // [TAAPROBE] frontiere d'image (auto-gardee) + // [JITTRACE] cadence de l'etape B, voir plus bas. + + if (Image.MvppFeedbackProbe.Enabled) + { + Image.MvppFeedbackProbe.OnFrameBoundary(); // [FEEDBACKPROBE] guest frame boundary (gated) + } + + if (Image.MvppTraceProbe.Enabled) + { + Image.MvppTraceProbe.OnFrameBoundary(); // [TRACEPROBE] dependency-graph report (gated) + } + float cropScaleX = texture.EffectiveScaleX; float cropScaleY = texture.EffectiveScaleY; @@ -321,9 +369,71 @@ namespace Ryujinx.Graphics.Gpu // the queue with the frame. if (DlssCameraState.FifoEnabled) { + // [GAMEJITTER 28/07] Le decalage sous-pixel du jeu sort de la file AVEC sa + // matrice et entre dans l'anneau de presentation AVEC elle : apparie a la meme + // image d'un bout a l'autre, sans champ statique qui pourrait deriver d'une + // image. Sur un cycle de 8 phases, declarer la mauvaise phase serait aussi faux + // que declarer zero. bool orderedValid = DlssCameraState.TryConsumeOrdered(out System.Numerics.Matrix4x4 orderedVp); + + // [GAMEJITTER 28/07, v2] Le decalage ne passe PLUS par la file ordonnee -- y + // ajouter deux flottants la faisait rendre du vide (voir le commentaire de + // _fifo). Il entre ici, dans l'anneau de presentation, qui est une file + // concurrente donc sure. La valeur est celle de la derniere lecture ACCEPTEE : + // elle a ete prise pendant les dessins de cette image, c'est donc la bonne + // phase pour l'image qu'on presente. + float orderedJx = Engine.Threed.MvppSoloCamera.LastJitterX; + float orderedJy = Engine.Threed.MvppSoloCamera.LastJitterY; + + // ⛔ [28/07 17h35] APPEL A 4 PARAMETRES NEUTRALISE — DETTE DE GAMEJITTER. + // La surcharge (vp, valid, jx, jy) a ete ajoutee a DlssCameraState le 28/07 a + // 11:46, mais le GAL.dll de l'installation date de 10:07 et ne la contient pas. + // Tant que le projet Gpu n'est pas recompile, personne ne s'en apercoit ; des + // qu'il l'est, l'appel part dans le vide : + // MissingMethodException: Method not found: 'Void + // DlssCameraState.PublishPresent(Matrix4x4 ByRef, Boolean, Single, Single)' + // (plantage constate 28/07 17h30 en deployant MULTIROT ; binaire restaure). + // + // Reconstruire le GAL reglerait la signature mais REINJECTERAIT GAMEJITTER et + // 18 jours de changements dans l'etat valide par Alex -- c'est exactement la + // casse du matin. Or GAMEJITTER est MORT : mesure du 28/07, 8 valeurs cote + // camera et `brut (0,000;0,000)` cote DLSS, le canal ne transporte rien. On + // perd donc zero fonctionnalite en appelant la surcharge a 2 parametres. + // + // A RETABLIR le jour ou le GAL sera reconstruit volontairement (une seule + // ligne, les deux valeurs sont deja calculees juste au-dessus). + _ = orderedJx; + _ = orderedJy; DlssCameraState.PublishPresent(in orderedVp, orderedValid); + // [FAMHOLD 01/08] Sonde R1/R2 du mecanisme (290), lecture pure : le hold de + // la file ordonnee (LastConsumeFresh == false) EST l'evenement mesure, et il + // vient d'etre etabli trois lignes plus haut. Voir MvppFamHold. + if (Engine.Threed.MvppFamHold.Enabled) + { + Engine.Threed.MvppFamHold.OnPresent(DlssCameraState.LastConsumeFresh, in orderedVp); + } + + // [EXTPROBE 01/08] Phase T du design E : prediction en memoire + comparaison + // au reel suivant, lecture pure. Voir MvppExtProbe. + if (Engine.Threed.MvppExtProbe.Enabled) + { + Engine.Threed.MvppExtProbe.OnPresent( + DlssCameraState.LastConsumeFresh, in orderedVp, DlssCameraState.TeleportSeq); + } + + // [JITTRACE 28/07] ETAPE B : ce qui SORT de la file et entre dans l'anneau. + // Si A est non nul et B vaut zero, la perte est dans la file ; si B est non nul + // et C vaut zero, elle est dans l'anneau ou apres. + if (Engine.Threed.MvppCameraCapture.JitTrace && + Environment.TickCount64 - _jitTraceMs >= 1000) + { + _jitTraceMs = Environment.TickCount64; + Logger.Info?.Print(LogClass.Gpu, + $"JITTRACE B (defile+publie) : jx={orderedJx:0.000000} jy={orderedJy:0.000000} " + + $"valide={orderedValid} frais={DlssCameraState.LastConsumeFresh}"); + } + if (MvppDev.Enabled && Environment.TickCount64 - _mvppFifoLogMs >= 5000) { _mvppFifoLogMs = Environment.TickCount64; diff --git a/src/Ryujinx.Graphics.OpenGL/Framebuffer.cs b/src/Ryujinx.Graphics.OpenGL/Framebuffer.cs index 394b8bc76..7fb2a02e1 100644 --- a/src/Ryujinx.Graphics.OpenGL/Framebuffer.cs +++ b/src/Ryujinx.Graphics.OpenGL/Framebuffer.cs @@ -35,6 +35,12 @@ namespace Ryujinx.Graphics.OpenGL } [MethodImpl(MethodImplOptions.AggressiveInlining)] + // [GLDUMP] (journal 216) zero-alloc accessors for the GL-side chain dump probe. + public TextureView MvppGetColor(int index) + { + return (uint)index < (uint)_colors.Length ? _colors[index] : null; + } + public void AttachColor(int index, TextureView color) { if (_colors[index] == color) diff --git a/src/Ryujinx.Graphics.OpenGL/MvppGlDumpProbe.cs b/src/Ryujinx.Graphics.OpenGL/MvppGlDumpProbe.cs new file mode 100644 index 000000000..ad4cd4e0e --- /dev/null +++ b/src/Ryujinx.Graphics.OpenGL/MvppGlDumpProbe.cs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.OpenGL.Image; +using System; +using System.IO; +using System.Runtime.CompilerServices; + +namespace Ryujinx.Graphics.OpenGL +{ + /// + /// [GLDUMP] (RYUJINX_GLDUMP=1, inert unless set). Journal 216, read-only. + /// + /// Mirror of the Vulkan-side MvppMvDumpProbe: dumps the XC2 DoF/MV chain buffers at the + /// END of their pass, under the OPENGL backend -- the backend that renders CLEAN. The + /// first buffer whose content diverges from the Vulkan dumps of the same scene names the + /// faulty pass mechanically, with zero hypotheses. + /// + static class MvppGlDumpProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_GLDUMP") == "1"; + + private const long IntervalMs = 8000; + private const long WindowMs = 400; + + private static bool _armedLogged; + private static long _lastDumpMs; + private static long _windowUntilMs; + private static int _dumpRound; + private static string _dir; + + private static bool IsWatched(TextureView view) + { + return (view.Info.Width, view.Info.Height, view.Info.Format) switch + { + (1280, 720, Format.R10G10B10A2Unorm) => true, + (1280, 720, Format.R8G8B8A8Unorm) => true, + (1280, 720, Format.R11G11B10Float) => true, + (640, 360, Format.R32Float) => true, + (512, 288, Format.R16G16B16A16Float) => true, + (320, 180, Format.R8G8B8A8Unorm) => true, + (64, 36, Format.R8G8B8A8Unorm) => true, + (64, 36, Format.R8Unorm) => true, + _ => false, + }; + } + + public static void OnRenderTargetsChange(Framebuffer fb) + { + if (!Enabled || fb == null) + { + return; + } + + long now = Environment.TickCount64; + bool inWindow = now < _windowUntilMs; + + if (!inWindow && now - _lastDumpMs < IntervalMs) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[GLDUMP] armed: OpenGL-side chain dumps at end of pass (mirror of the Vulkan probe)"); + } + + bool any = false; + + for (int i = 0; i < 8; i++) + { + TextureView view = fb.MvppGetColor(i); + + if (view == null || !IsWatched(view)) + { + continue; + } + + if (!any) + { + any = true; + + if (!inWindow) + { + _lastDumpMs = now; + _windowUntilMs = now + WindowMs; + _dumpRound++; + } + + _dir ??= Directory.CreateDirectory("gldump").FullName; + } + + int id = RuntimeHelpers.GetHashCode(view); + + try + { + using PinnedSpan pinned = view.GetData(0, 0); + ReadOnlySpan data = pinned.Get(); + + long nonZero = 0; + for (int b = 0; b < data.Length; b++) + { + if (data[b] != 0) + { + nonZero++; + } + } + + string file = Path.Combine(_dir, + $"round{_dumpRound:D3}_rt{i}_s{id:X8}_{view.Info.Width}x{view.Info.Height}_{view.Info.Format}_{now}.bin"); + File.WriteAllBytes(file, data.ToArray()); + + Logger.Warning?.Print(LogClass.Gpu, + $"[GLDUMP] round={_dumpRound} rt={i} bytes={data.Length} nonZeroBytes={100.0 * nonZero / data.Length:F1}% file={Path.GetFileName(file)}"); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Gpu, $"[GLDUMP] rt={i} FAILED: {ex.Message}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.OpenGL/Pipeline.cs b/src/Ryujinx.Graphics.OpenGL/Pipeline.cs index 5b1e63e3b..c2f68d190 100644 --- a/src/Ryujinx.Graphics.OpenGL/Pipeline.cs +++ b/src/Ryujinx.Graphics.OpenGL/Pipeline.cs @@ -1168,6 +1168,7 @@ namespace Ryujinx.Graphics.OpenGL public void SetRenderTargets(Span colors, ITexture depthStencil) { + MvppGlDumpProbe.OnRenderTargetsChange(_framebuffer); // [GLDUMP] read-only, self-gated EnsureFramebuffer(); for (int index = 0; index < colors.Length; index++) diff --git a/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/Instructions.cs b/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/Instructions.cs index 77a23d1f2..f50ca51c7 100644 --- a/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/Instructions.cs +++ b/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/Instructions.cs @@ -333,6 +333,18 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv private static OperationResult GenerateClamp(CodeGenContext context, AstOperation operation) { + // [NCLAMP] (journal 196, gated OFF by default): the guest SAT modifier flushes + // NaN to 0 in hardware (XC2's motion-blur builder computes 0*rsqrt(0) = NaN on + // every below-threshold pixel and depends on it). FClamp is UNDEFINED on NaN per + // the SPIR-V spec -- on NVIDIA Vulkan the NaN escapes into the temporal chain. + // NClamp(NaN, lo, hi) == lo by construction: the console semantics. + if (Translation.MvppNClampProbe.Enabled) + { + Translation.MvppNClampProbe.OnApplied(context.Logger); + + return GenerateTernary(context, operation, context.Delegates.GlslNClamp, context.Delegates.GlslSClamp); + } + return GenerateTernary(context, operation, context.Delegates.GlslFClamp, context.Delegates.GlslSClamp); } @@ -953,6 +965,15 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv private static OperationResult GenerateMaximum(CodeGenContext context, AstOperation operation) { + // [NMINMAX] (journal 199, gated OFF by default): hardware FMNMX returns the + // non-NaN operand; FMax is undefined on NaN per spec. See MvppNMinMaxProbe. + if (Translation.MvppNMinMaxProbe.Enabled) + { + Translation.MvppNMinMaxProbe.OnApplied(context.Logger); + + return GenerateBinary(context, operation, context.Delegates.GlslNMax, context.Delegates.GlslSMax); + } + return GenerateBinary(context, operation, context.Delegates.GlslFMax, context.Delegates.GlslSMax); } @@ -969,6 +990,14 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv private static OperationResult GenerateMinimum(CodeGenContext context, AstOperation operation) { + // [NMINMAX] see GenerateMaximum. + if (Translation.MvppNMinMaxProbe.Enabled) + { + Translation.MvppNMinMaxProbe.OnApplied(context.Logger); + + return GenerateBinary(context, operation, context.Delegates.GlslNMin, context.Delegates.GlslSMin); + } + return GenerateBinary(context, operation, context.Delegates.GlslFMin, context.Delegates.GlslSMin); } diff --git a/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/SpirvDelegates.cs b/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/SpirvDelegates.cs index 3716d76d9..ce5026018 100644 --- a/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/SpirvDelegates.cs +++ b/src/Ryujinx.Graphics.Shader/CodeGen/Spirv/SpirvDelegates.cs @@ -64,6 +64,8 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv public readonly FuncBinaryInstruction FDiv; public readonly FuncBinaryInstruction SDiv; public readonly FuncBinaryInstruction GlslFMax; + public readonly FuncBinaryInstruction GlslNMax; // [NMINMAX] NaN-aware max (journal 199) + public readonly FuncBinaryInstruction GlslNMin; // [NMINMAX] NaN-aware min (journal 199) public readonly FuncBinaryInstruction GlslSMax; public readonly FuncBinaryInstruction GlslFMin; public readonly FuncBinaryInstruction GlslSMin; @@ -103,6 +105,7 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv // Ternary public readonly FuncTernaryInstruction GlslFClamp; + public readonly FuncTernaryInstruction GlslNClamp; // [NCLAMP] NaN-aware clamp (journal 196) public readonly FuncTernaryInstruction GlslSClamp; public readonly FuncTernaryInstruction GlslFma; @@ -172,6 +175,8 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv FDiv = context.FDiv; SDiv = context.SDiv; GlslFMax = context.GlslFMax; + GlslNMax = context.GlslNMax; // [NMINMAX] + GlslNMin = context.GlslNMin; // [NMINMAX] GlslSMax = context.GlslSMax; GlslFMin = context.GlslFMin; GlslSMin = context.GlslSMin; @@ -211,6 +216,7 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv // Ternary GlslFClamp = context.GlslFClamp; + GlslNClamp = context.GlslNClamp; // [NCLAMP] GlslSClamp = context.GlslSClamp; GlslFma = context.GlslFma; diff --git a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitConversion.cs b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitConversion.cs index 4b917c1eb..f20624711 100644 --- a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitConversion.cs +++ b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitConversion.cs @@ -184,6 +184,16 @@ namespace Ryujinx.Graphics.Shader.Instructions Operand srcB = context.FPAbsNeg(src, absolute, negate, fpType); + // [TRUNCEPS] (journal 208, gated OFF by default): nudge the bokeh FS's F2I inputs + // to probe the texel-snap precision sensitivity of its quantized gather taps. + if (Translation.MvppTruncEpsProbe.Enabled && + srcType == DstFmt.F32 && + context.TranslatorContext.Address == 0x1000AE430UL) + { + Translation.MvppTruncEpsProbe.OnApplied(context.TranslatorContext.GpuAccessor); + srcB = context.FPAdd(srcB, ConstF(Translation.MvppTruncEpsProbe.Value.Value)); + } + srcB = roundingMode switch { RoundMode2.Round => context.FPRound(srcB, fpType), diff --git a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitFloatArithmetic.cs b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitFloatArithmetic.cs index 7d974a370..6d497d0e2 100644 --- a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitFloatArithmetic.cs +++ b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitFloatArithmetic.cs @@ -1,3 +1,5 @@ +using System; +using System.Globalization; using Ryujinx.Graphics.Shader.Decoders; using Ryujinx.Graphics.Shader.IntermediateRepresentation; using Ryujinx.Graphics.Shader.Translation; @@ -143,10 +145,27 @@ namespace Ryujinx.Graphics.Shader.Instructions EmitFadd(context, Instruction.FP32, srcA, srcB, op.Dest, op.NegA, op.NegB, op.AbsA, op.AbsB, op.Sat, op.WriteCC); } + // [Beast Roofer diag] NANSCRUB (EXP 14): the `x + 0.00999999978` add is the fingerprint of + // the XC2 motion-vector encode epilogue (sign-code + 0.01, decoded downstream by *3.01 and + // trunc) -- present in all ~199 MV-writing materials and the sky pass, and in nothing else + // surveyed (850-shader census, journal 131/133). Seeing it arms the NaN scrub for THIS + // translation's outputs (EmitterContext.PrepareForReturn). Fingerprint gate = works on the + // disk-cache recompile path too (address is 0 there, no address needed). + private const float NanScrubMagicImmediate = 0.00999999978f; + public static void Fadd32i(EmitterContext context) { InstFadd32i op = context.GetOp(); + if (NanScrubProbe.Enabled && + !context.TranslatorContext.MvppNanScrubArmed && + context.TranslatorContext.Definitions.Stage == ShaderStage.Fragment && + Math.Abs(BitConverter.Int32BitsToSingle(op.Imm32) - NanScrubMagicImmediate) < 1e-6f) + { + context.TranslatorContext.MvppNanScrubArmed = true; + NanScrubProbe.OnArmed(context.TranslatorContext.Address, context.TranslatorContext.GpuAccessor); + } + Operand srcA = GetSrcReg(context, op.SrcA); Operand srcB = GetSrcImm(context, op.Imm32); @@ -238,10 +257,83 @@ namespace Ryujinx.Graphics.Shader.Instructions EmitFmul(context, Instruction.FP32, op.Scale, srcA, srcB, op.Dest, op.NegA, op.Sat, op.WriteCC); } + // [Beast Roofer diag] HASH_TEST gate (env var RYUJINX_HASH_CONST). Temporary VALIDATION + // experiment (NOT a fix): force the XC2 DoF bokeh dither hash to a constant, to test whether + // the spatial hash is required for the border-streak artifact. The hash is + // `fract(sin(...) * 43758.5469)`; we replace ONLY that multiply's result with a constant K, + // so hash = fract(K) (K=0.5 -> 0.5, K=0.0 -> 0.0). Triple-gated below: shader address AND + // magic immediate AND env var present. _hashConstValue == null => OFF, bit-identical. + private const ulong HashTestFsAddress = 0x1000AE430UL; // guest FS 0x1000AE430 = Shader0053 (DoF bokeh) + private const float HashMagicImmediate = 43758.5469f; // the fract(sin(...) * K) fingerprint + private const float HashMagicTolerance = 0.1f; + + // Single source of truth for the flag: HashTestProbe is also read by the disk-cache probe + // interlock, so the instrumented shader can never be persisted to the host shader cache. + private static readonly float? _hashConstValue = HashTestProbe.ConstValue; + private static bool _hashTestLogged; + + private static bool IsHashMagicImmediate(int imm32) + { + return MathF.Abs(BitConverter.Int32BitsToSingle(imm32) - HashMagicImmediate) < HashMagicTolerance; + } + + // [Beast Roofer diag] SATSCRUB stage 2 (EXP 17): the `x * 3.00999999` multiply is the + // MV sign-code DECODE fingerprint -- present in exactly the 6 MV-chain processors + // (builder, ping-pong filter, downsamplers, temporal resolve; 850-shader census) whose + // outputs also need the saturation scrub, INCLUDING rt0 (their map output is rt0). + private const float SatScrubDecodeImmediate = 3.00999999f; + public static void Fmul32i(EmitterContext context) { InstFmul32i op = context.GetOp(); + if (NanScrubProbe.SatScrub && + !context.TranslatorContext.MvppSatScrubAllRts && + context.TranslatorContext.Definitions.Stage == ShaderStage.Fragment && + Math.Abs(BitConverter.Int32BitsToSingle(op.Imm32) - SatScrubDecodeImmediate) < 1e-5f) + { + context.TranslatorContext.MvppSatScrubAllRts = true; + context.TranslatorContext.MvppNanScrubArmed = true; + context.TranslatorContext.GpuAccessor.Log( + $"[SATSCRUB2] armed MV-chain shader (guest 0x{context.TranslatorContext.Address:X}) -- ALL rts scrubbed"); + } + + // [Beast Roofer diag] HASH_TEST: replace ONLY Shader0053's `sin(...) * 43758.5469` product + // (the DoF dither hash) with a constant, so hash = fract(const). Nothing else is touched; + // when the env var is absent this branch is skipped and behavior is bit-identical. + if (_hashConstValue.HasValue && context.TranslatorContext.Address == HashTestFsAddress) + { + bool isHashMultiply = IsHashMagicImmediate(op.Imm32); + + // Translation-time only (never per frame): log each Fmul32i of this shader up to and + // including the one that matches, so the intercepted instruction can be verified. + // Sat/WriteCC are printed because the override below skips them; both must be false. + if (!_hashTestLogged) + { + context.TranslatorContext.GpuAccessor.Log( + $"[HASH_TEST] Guest FS: 0x{HashTestFsAddress:X} | " + + $"Imm32: {BitConverter.Int32BitsToSingle(op.Imm32).ToString(CultureInfo.InvariantCulture)} | " + + $"SrcA: R{op.SrcA} | Dest: R{op.Dest} | " + + $"Sat: {op.Sat} | WriteCC: {op.WriteCC} | " + + $"Override: {(isHashMultiply ? "YES" : "NO")}" + + (isHashMultiply + ? $" | Replacement value: {_hashConstValue.Value.ToString(CultureInfo.InvariantCulture)}" + : string.Empty)); + + if (isHashMultiply) + { + _hashTestLogged = true; + } + } + + if (isHashMultiply) + { + context.Copy(GetDest(op.Dest), ConstF(_hashConstValue.Value)); + + return; + } + } + Operand srcA = GetSrcReg(context, op.SrcA); Operand srcB = GetSrcImm(context, op.Imm32); diff --git a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitMultifunction.cs b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitMultifunction.cs index 5c079378e..49683fc2f 100644 --- a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitMultifunction.cs +++ b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitMultifunction.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Ryujinx.Graphics.Shader.Decoders; using Ryujinx.Graphics.Shader.IntermediateRepresentation; using Ryujinx.Graphics.Shader.Translation; @@ -38,11 +39,11 @@ namespace Ryujinx.Graphics.Shader.Instructions switch (op.MufuOp) { case MufuOp.Cos: - res = context.FPCosine(res); + res = context.FPCosine(ReduceSinCosArg(context, res)); break; case MufuOp.Sin: - res = context.FPSine(res); + res = context.FPSine(ReduceSinCosArg(context, res)); break; case MufuOp.Ex2: @@ -54,11 +55,11 @@ namespace Ryujinx.Graphics.Shader.Instructions break; case MufuOp.Rcp: - res = context.FPReciprocal(res); + res = ReduceMufuPrecision(context, context.FPReciprocal(res), MufuOp.Rcp); break; case MufuOp.Rsq: - res = context.FPReciprocalSquareRoot(res); + res = ReduceMufuPrecision(context, context.FPReciprocalSquareRoot(res), MufuOp.Rsq); break; case MufuOp.Rcp64h: @@ -72,7 +73,7 @@ namespace Ryujinx.Graphics.Shader.Instructions break; case MufuOp.Sqrt: - res = context.FPSquareRoot(res); + res = ReduceMufuPrecision(context, context.FPSquareRoot(res), MufuOp.Sqrt); break; default: @@ -93,5 +94,90 @@ namespace Ryujinx.Graphics.Shader.Instructions context.Copy(GetDest(rd), srcB); } + + // [Beast Roofer diag/fix] RYUJINX_RRO_REDUCE=1 (OFF by default): Maxwell's RRO range-reduces + // the argument before every MUFU.SIN/COS; the emulator skips it (EmitRro above is a plain + // move), so large arguments reach the host sin/cos at full magnitude and lose precision. + // That breaks fract(sin(dot(coord, magic)) * k) dither hashes -> visible banding (XC2 DoF). + // Reducing x mod 2*pi is mathematically identical yet keeps a small, precise argument. + // Sin/Cos only, so MUFU.EX2 (exponential) is left untouched. Zero effect while OFF. + // The flag now lives in RroReduceProbe so the disk-cache interlock can see it too. + private static bool _rroReduceLogged; + + private static Operand ReduceSinCosArg(EmitterContext context, Operand x) + { + if (!RroReduceProbe.Enabled) + { + return x; + } + + if (!_rroReduceLogged) + { + _rroReduceLogged = true; + context.TranslatorContext.GpuAccessor.Log("RROREDUCE: sin/cos argument range reduction ACTIVE (RYUJINX_RRO_REDUCE=1)."); + } + + // x - 2*pi * floor(x / (2*pi)) + Operand k = context.FPFloor(context.FPMultiply(x, OperandHelper.ConstF(0.15915494309f))); + return context.FPSubtract(x, context.FPMultiply(k, OperandHelper.ConstF(6.28318530718f))); + } + + // [Beast Roofer diag] RYUJINX_MUFU_PREC=<0-23> (absent by default): Maxwell's MUFU is an + // APPROXIMATE unit -- MUFU.RCP/RSQ/SQRT are accurate to a couple of ulp -- while we translate + // them to the host's exact IEEE ops, so the emulator is strictly MORE precise than the console. + // XC2's DoF bokeh weights every tap with `w = clamp(bias - dist/CoC)` (~15 rcp + ~13 sqrt) and + // normalises by `1/sum(w)`: a result landing on the other side of a clamp boundary flips a whole + // tap in or out of the sum, which is a plausible mechanism for the border streaks. This gate + // drops the low mantissa bits of those three results to test whether that precision is in the + // causal chain. VALIDATION EXPERIMENT, NOT A FIX -- absent => not a single IR node is added. + // + // Truncation (a single BitwiseAnd) is used on purpose rather than round-to-nearest: Instruction.Add + // is typed Scalar (polymorphic), so an IAdd on a float-typed operand would be inferred as a FLOAT + // add instead of the intended add on the bit pattern. BitwiseAnd is declared S32/S32/S32, so the + // codegen is forced to emit floatBitsToInt/intBitsToFloat around it. The resulting downward bias + // is ~2^-N relative, i.e. invisible at N=12 and deliberately huge at N=0. + // + // N=0 is the CANARY: every rcp/rsq/sqrt result is flattened to a power of two, which wrecks the + // blur in an unmistakable way. If the picture does NOT visibly break at N=0, this shader is not + // on screen and any verdict from the finer runs would be worthless. + // The flag itself lives in MufuPrecProbe: single source of truth, and the disk-cache + // interlock reads the same property so an instrumented shader can never be persisted. + private const ulong MufuPrecFsAddress = 0x1000AE430UL; // guest FS = Shader0053 (XC2 DoF bokeh) + + private static readonly HashSet _mufuPrecLoggedOps = new(); + + private static Operand ReduceMufuPrecision(EmitterContext context, Operand res, MufuOp mufuOp) + { + // Double gate: env var present AND this is the bokeh fragment shader. Keeping it scoped to + // one shader means exactly one variable moves, and every other shader stays bit-identical. + ulong target = MufuPrecProbe.AddressOverride ?? MufuPrecFsAddress; // [v2] journal 219 + + if (!MufuPrecProbe.Enabled || context.TranslatorContext.Address != target) + { + return res; + } + + int bits = MufuPrecProbe.BitsKept.Value; + int dropped = MufuPrecProbe.MantissaBits - bits; + + if (dropped <= 0) + { + return res; // 23 bits kept = the exact host result, nothing to drop. + } + + // One line per distinct MUFU op, at translation time only (never per frame), so the log + // also tells us WHICH of the three actually occur in this shader. + lock (_mufuPrecLoggedOps) + { + if (_mufuPrecLoggedOps.Add(mufuOp)) + { + context.TranslatorContext.GpuAccessor.Log( + $"[MUFU_PREC] Guest FS: 0x{MufuPrecFsAddress:X} | Op: {mufuOp} | " + + $"Mantissa bits kept: {bits}/{MufuPrecProbe.MantissaBits} | Dropped: {dropped}"); + } + } + + return context.BitwiseAnd(res, OperandHelper.Const(unchecked((int)(0xFFFFFFFFu << dropped)))); + } } } diff --git a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitTexture.cs b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitTexture.cs index fa0b87317..c7a6ec5d8 100644 --- a/src/Ryujinx.Graphics.Shader/Instructions/InstEmitTexture.cs +++ b/src/Ryujinx.Graphics.Shader/Instructions/InstEmitTexture.cs @@ -3,6 +3,7 @@ using Ryujinx.Graphics.Shader.IntermediateRepresentation; using Ryujinx.Graphics.Shader.Translation; using System; using System.Collections.Generic; +using System.Globalization; using System.Numerics; using static Ryujinx.Graphics.Shader.IntermediateRepresentation.OperandHelper; @@ -1196,6 +1197,25 @@ namespace Ryujinx.Graphics.Shader.Instructions Operand[] dests, Operand[] sources) { + // [FORCELOD0] (journal 211, gated OFF by default): turn the bokeh gather's + // implicit-LOD colour taps into textureLod(0) -- probing the mip-divergence + // mechanism. Strictly gated to plain 2D samples of that one shader. + if (Translation.MvppForceLod0Probe.Enabled && + context.TranslatorContext.Address == 0x1000AE430UL && + (handle == 0xC || handle == 0xA) && + type == SamplerType.Texture2D && + flags == TextureFlags.None) + { + Translation.MvppForceLod0Probe.OnApplied(context.TranslatorContext.GpuAccessor, handle); + + flags = TextureFlags.LodLevel; + + Operand[] withLod = new Operand[sources.Length + 1]; + sources.CopyTo(withLod, 0); + withLod[^1] = ConstF(0); + sources = withLod; + } + SetBindingPair setAndBinding = flags.HasFlag(TextureFlags.Bindless) ? default : context.ResourceManager.GetTextureOrImageBinding( Instruction.TextureSample, type, @@ -1205,6 +1225,364 @@ namespace Ryujinx.Graphics.Shader.Instructions handle); context.TextureSample(type, flags, setAndBinding, componentMask, dests, sources); + + ForceConstantCoc(context, dests); + ForceConstantTileMap(context, dests, handle); + ForceConstantMvInput(context, dests, handle); + ForceConstantMvSign(context, dests, handle); + ForceConstantResolveMv(context, dests, handle); + CapTileMap(context, dests, handle); + CapturePeriscope2(context, dests, handle); + } + + // [Beast Roofer diag] PERISCOPE2 (ladder stage 1): in the temporal resolve, capture the raw + // 720p object-MV sample (tcb_10) so PrepareForReturn shows it as the scene -- one pixel per + // texel, no readback. See PeriscopeProbe.Stage2. + private const ulong Periscope2FsAddress = 0x100082C30UL; // temporal resolve (hash-confirmed, journal 132) + private const int Periscope2Handle = 0x10; // fp_t_tcb_10 = R10G10B10A2 720p object MVs + + private static void CapturePeriscope2(EmitterContext context, Operand[] dests, int handle) + { + if (!PeriscopeProbe.Stage2 || + dests == null || + dests.Length < 3 || + handle != Periscope2Handle || + context.TranslatorContext.Address != Periscope2FsAddress || + context.TranslatorContext.MvppPeriscopeSample != null) + { + return; + } + + // The dests are GPRs the game's compiler reuses after the sample; the override at + // PrepareForReturn reads their LAST value, not this one. Snapshot into fresh locals + // here so the screen shows the sample (v1 silently showed the shader's normal output). + context.TranslatorContext.MvppPeriscopeSample = new[] + { + context.Copy(dests[0]), + context.Copy(dests[1]), + context.Copy(dests[2]), + }; + context.TranslatorContext.GpuAccessor.Log( + $"[PERISCOPE2] Guest FS: 0x{Periscope2FsAddress:X} | raw 720p MV sample captured (v3 all-rt) | screen will show the MV buffer"); + } + + // [Beast Roofer diag] RYUJINX_COC_CONST= (absent by default): make every DoF bokeh sprite + // the same size, to split the last two suspects for the XC2 border streaks -- the circle-of- + // confusion that arrives, versus where the quads land. See CocConstProbe for the reasoning. + // + // The sample is still emitted and then overwritten rather than skipped: dropping it would change + // the shader's texture bindings and reflection, which is a second variable. The dead sample is + // removed by the optimiser anyway. Verified surgical: this geometry shader samples EXACTLY ONE + // texture (the CoC map at bokeh_gs.glsl:124), so the shader-address gate cannot hit anything else. + private const ulong CocConstGsAddress = 0x1000ADF30UL; // guest GS 0x1000ADF30 = Shader0052 (bokeh scatter) + + private static bool _cocConstLogged; + + private static void ForceConstantCoc(EmitterContext context, Operand[] dests) + { + if (!CocConstProbe.Enabled || dests == null || context.TranslatorContext.Address != CocConstGsAddress) + { + return; + } + + float value = CocConstProbe.Value.Value; + + // Translation time only, never per frame. Its presence in the log is what makes the visual + // verdict valid: no [COC_CONST] line means the experiment never ran, NOT that the CoC is + // innocent -- the disk-cache recompile path passes address 0 and would silently miss here. + if (!_cocConstLogged) + { + _cocConstLogged = true; + context.TranslatorContext.GpuAccessor.Log( + $"[COC_CONST] Guest GS: 0x{CocConstGsAddress:X} | CoC forced to {value.ToString(CultureInfo.InvariantCulture)} | " + + $"Overridden components: {dests.Length}"); + } + + foreach (Operand dest in dests) + { + if (dest != null) + { + context.Copy(dest, ConstF(value)); + } + } + } + + // [Beast Roofer diag] RYUJINX_TILEMAP_CONST= (absent by default): flatten the DoF tile + // map, to test whether it drives the XC2 form-A corruption rectangles -- their edges snap to + // the map's 20-px texel grid on every clean capture. See TileMapConstProbe for the reasoning. + // + // Same rules as the CoC probe above: the sample is still emitted and then overwritten rather + // than skipped (dropping it would change bindings/reflection, a second variable), and unlike + // that probe this shader samples SEVERAL textures, so the gate also matches the sampler + // handle -- fp_t_tcb_E is handle 0xE (naming scheme: ResourceManager.cs, "{prefix}_tcb_{X}"). + private const ulong TileMapFsAddress = 0x1000AE430UL; // guest FS 0x1000AE430 = Shader0053 (bokeh shade/accumulate) + private const int TileMapHandle = 0xE; // fp_t_tcb_E = 64x36 RGBA8 DoF tile map + + private static readonly HashSet _tileMapLoggedHandles = new(); + + private static void ForceConstantTileMap(EmitterContext context, Operand[] dests, int handle) + { + // PERISCOPE piggybacks on this gate's address+handle discrimination, so either flag + // opens the method; the constant override below still requires its own flag. + if ((!TileMapConstProbe.Enabled && !PeriscopeProbe.Enabled) || dests == null || context.TranslatorContext.Address != TileMapFsAddress) + { + return; + } + + bool match = handle == TileMapHandle; + + // [PERISCOPE] (EXP 15) capture the FIRST tile-map sample's operands for the output + // override in PrepareForReturn. Shares this gate's address+handle discrimination. + if (match && PeriscopeProbe.Enabled && context.TranslatorContext.MvppPeriscopeSample == null && dests.Length >= 3) + { + // Same register-reuse trap as CapturePeriscope2: snapshot to locals at the sample. + context.TranslatorContext.MvppPeriscopeSample = new[] + { + context.Copy(dests[0]), + context.Copy(dests[1]), + context.Copy(dests[2]), + }; + context.TranslatorContext.GpuAccessor.Log( + $"[PERISCOPE] Guest FS: 0x{TileMapFsAddress:X} | tile-map sample captured (v3 all-rt) | screen will show the raw 64x36 map"); + } + + // Translation time only, never per frame. One line per distinct sampler handle of this + // shader: the Override: NO lines (0xA, 0xC, ...) prove the filter discriminates, the 0xE + // line with Override: YES proves the right texture is hit. No [TILEMAP] line at all means + // the experiment never ran (void), NOT that the tile map is innocent -- the disk-cache + // recompile path passes address 0 and would silently miss here. + if (!TileMapConstProbe.Enabled) + { + return; // PERISCOPE-only run: capture done above, no constant override + } + + lock (_tileMapLoggedHandles) + { + if (_tileMapLoggedHandles.Add(handle)) + { + context.TranslatorContext.GpuAccessor.Log( + $"[TILEMAP] Guest FS: 0x{TileMapFsAddress:X} | Sampler handle: 0x{handle:X} | " + + (match + ? $"Override: YES | Replacement value: {TileMapConstProbe.Value.Value.ToString(CultureInfo.InvariantCulture)} | Components: {dests.Length}" + : "Override: NO")); + } + } + + if (!match) + { + return; + } + + float value = TileMapConstProbe.Value.Value; + + foreach (Operand dest in dests) + { + if (dest != null) + { + context.Copy(dest, ConstF(value)); + } + } + } + + // [Beast Roofer diag] RYUJINX_MVKILL_CONST= (absent by default): null the OBJECT-motion + // input of the motion-map builder, to decide which of its two inputs carries the form-A + // corruption (object MVs vs camera/depth reprojection). See MvKillProbe for the reasoning. + // Same rules as the probes above: sample emitted then overwritten, handle-gated because this + // shader samples several textures (0x8 = the R10G10B10A2 720p object-MV buffer). + private const ulong MvKillFsAddress = 0x1000AD730UL; // guest FS 0x1000AD730 = motion-map builder (MAP64 writer) + private const int MvKillHandle = 0x8; // fp_t_tcb_8 = R10G10B10A2 1280x720 object MVs + + private static readonly HashSet _mvKillLoggedHandles = new(); + + private static void ForceConstantMvInput(EmitterContext context, Operand[] dests, int handle) + { + if (!MvKillProbe.Enabled || dests == null || context.TranslatorContext.Address != MvKillFsAddress) + { + return; + } + + bool match = handle == MvKillHandle; + + // Translation time only. One line per distinct sampler handle of this shader; the + // Override: NO lines prove the filter discriminates. No [MVKILL] line at all = void. + lock (_mvKillLoggedHandles) + { + if (_mvKillLoggedHandles.Add(handle)) + { + context.TranslatorContext.GpuAccessor.Log( + $"[MVKILL] Guest FS: 0x{MvKillFsAddress:X} | Sampler handle: 0x{handle:X} | " + + (match + ? $"Override: YES | Replacement value: {MvKillProbe.Value.Value.ToString(CultureInfo.InvariantCulture)} | Components: {dests.Length}" + : "Override: NO")); + } + } + + if (!match) + { + return; + } + + float value = MvKillProbe.Value.Value; + + foreach (Operand dest in dests) + { + if (dest != null) + { + context.Copy(dest, ConstF(value)); + } + } + } + + // [Beast Roofer diag] RYUJINX_MVSIGN_CONST= (absent by default): override ONLY the sign + // channel (w = dests[2]) of the motion-map builder's single .xyw sample of the object-MV + // buffer, leaving the 10-bit magnitudes live. Splits "garbage in the A2 sign channel" from + // "garbage in the magnitudes". See MvSignProbe for the reasoning. + // [Beast Roofer diag] RYUJINX_RESOLVEMV_CONST= (absent by default): null the motion + // vector the TAA RESOLVE samples for its history reprojection (journal 203/204: the poison + // enters at the resolve; displaced-block artifact => the displacement vector is suspect #1). + private const ulong ResolveMvFsAddress = 0x100082C30UL; // guest FS = TAA temporal resolve (Shader0086) + private const int ResolveMvHandle = 0x10; // fp_t_tcb_10 = R10G10B10A2 720p MV (X) + + private static readonly HashSet _resolveMvLoggedHandles = new(); + + // [Beast Roofer diag] RYUJINX_TILECAP= (absent by default): soft-cap the bokeh's + // 64x36 tile-map samples -- x/y pulled toward their 0.5 bias by at most K, z clamped to K. + // Small blur survives, giant blur becomes impossible. See MvppTileCapProbe. + private static readonly HashSet _tileCapLoggedHandles = new(); + + private static void CapTileMap(EmitterContext context, Operand[] dests, int handle) + { + if (!MvppTileCapProbe.Enabled || dests == null || context.TranslatorContext.Address != TileMapFsAddress) + { + return; + } + + bool match = handle == TileMapHandle; + + lock (_tileCapLoggedHandles) + { + if (_tileCapLoggedHandles.Add(handle)) + { + context.TranslatorContext.GpuAccessor.Log( + $"[TILECAP] Guest FS: 0x{TileMapFsAddress:X} | Sampler handle: 0x{handle:X} | " + + (match + ? $"Cap: YES | K = {MvppTileCapProbe.Value.Value.ToString(CultureInfo.InvariantCulture)} | Channels: {MvppTileCapProbe.Channel ?? "xyz"} | Components: {dests.Length}" + : "Cap: NO")); + } + } + + if (!match) + { + return; + } + + float k = MvppTileCapProbe.Value.Value; + + for (int i = 0; i < dests.Length && i < 3; i++) + { + Operand dest = dests[i]; + + if (dest == null) + { + continue; + } + + // [v2] channel bisection (journal 209). + if (MvppTileCapProbe.Channel == "xy" && i == 2) + { + continue; + } + + if (MvppTileCapProbe.Channel == "z" && i < 2) + { + continue; + } + + if (i < 2) + { + // x/y: 0.5-biased -> 0.5 + clamp(v - 0.5, -K, +K). + Operand centered = context.FPSubtract(dest, ConstF(0.5f)); + Operand capped = context.FPMaximum(context.FPMinimum(centered, ConstF(k)), ConstF(-k)); + context.Copy(dest, context.FPAdd(capped, ConstF(0.5f))); + } + else + { + // z: plain magnitude -> min(v, K). + context.Copy(dest, context.FPMinimum(dest, ConstF(k))); + } + } + } + + private static void ForceConstantResolveMv(EmitterContext context, Operand[] dests, int handle) + { + if (!MvResolveMvProbe.Enabled || dests == null || context.TranslatorContext.Address != ResolveMvFsAddress) + { + return; + } + + bool match = handle == ResolveMvHandle; + + lock (_resolveMvLoggedHandles) + { + if (_resolveMvLoggedHandles.Add(handle)) + { + context.TranslatorContext.GpuAccessor.Log( + $"[RESOLVEMV] Guest FS: 0x{ResolveMvFsAddress:X} | Sampler handle: 0x{handle:X} | " + + (match + ? $"Override: YES | Replacement value: {MvResolveMvProbe.Value.Value.ToString(CultureInfo.InvariantCulture)} | Components: {dests.Length}" + : "Override: NO")); + } + } + + if (!match) + { + return; + } + + float value = MvResolveMvProbe.Value.Value; + + foreach (Operand dest in dests) + { + if (dest != null) + { + context.Copy(dest, ConstF(value)); + } + } + } + + private static readonly HashSet _mvSignLoggedHandles = new(); + + private static void ForceConstantMvSign(EmitterContext context, Operand[] dests, int handle) + { + if (!MvSignProbe.Enabled || dests == null || context.TranslatorContext.Address != MvKillFsAddress) + { + return; + } + + bool match = handle == MvKillHandle && dests.Length == 3; + + lock (_mvSignLoggedHandles) + { + if (_mvSignLoggedHandles.Add(handle)) + { + context.TranslatorContext.GpuAccessor.Log( + $"[MVSIGN] Guest FS: 0x{MvKillFsAddress:X} | Sampler handle: 0x{handle:X} | Components: {dests.Length} | " + + (match + ? $"Override: YES (w only) | Replacement value: {MvSignProbe.Value.Value.ToString(CultureInfo.InvariantCulture)}" + : "Override: NO")); + } + } + + if (!match) + { + return; + } + + // dests follow the component mask order of the .xyw sample: [0]=x, [1]=y, [2]=w. + if (dests[2] != null) + { + context.Copy(dests[2], ConstF(MvSignProbe.Value.Value)); + } } private static SamplerType ConvertSamplerType(TexDim dimensions) diff --git a/src/Ryujinx.Graphics.Shader/ShaderProgramInfo.cs b/src/Ryujinx.Graphics.Shader/ShaderProgramInfo.cs index 95b117a4e..add933767 100644 --- a/src/Ryujinx.Graphics.Shader/ShaderProgramInfo.cs +++ b/src/Ryujinx.Graphics.Shader/ShaderProgramInfo.cs @@ -22,6 +22,11 @@ namespace Ryujinx.Graphics.Shader // Computed by a gated IR scan at translation; NOT serialized in the disk cache (format unchanged). // Diagnostic metadata only -- never read by codegen or the render path. public bool UsesDiscard { get; } + // [CENSUS v3] True if this translation carried either MV-encode fingerprint (+0.01 sign add + // or *3.01 sign decode) -- i.e. the value scrubs cover every draw bound to this program, + // whatever guest VA it is bound at (the VA-based census was defeated by code dedup, + // journal 163). Like UsesDiscard: diagnostic metadata, NOT serialized in the disk cache. + public bool MvppMvEncodeArmed { get; } public byte ClipDistancesWritten { get; } public int FragmentOutputMap { get; } @@ -40,7 +45,8 @@ namespace Ryujinx.Graphics.Shader bool usesRtLayer, bool usesDiscard, byte clipDistancesWritten, - int fragmentOutputMap) + int fragmentOutputMap, + bool mvppMvEncodeArmed = false) { CBuffers = Array.AsReadOnly(cBuffers); SBuffers = Array.AsReadOnly(sBuffers); @@ -56,6 +62,7 @@ namespace Ryujinx.Graphics.Shader UsesDrawParameters = usesDrawParameters; UsesRtLayer = usesRtLayer; UsesDiscard = usesDiscard; + MvppMvEncodeArmed = mvppMvEncodeArmed; ClipDistancesWritten = clipDistancesWritten; FragmentOutputMap = fragmentOutputMap; } diff --git a/src/Ryujinx.Graphics.Shader/Translation/CocConstProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/CocConstProbe.cs new file mode 100644 index 000000000..e74231081 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/CocConstProbe.cs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [COC_CONST] Temporary VALIDATION experiment (RYUJINX_COC_CONST=<float>), inert unless set. + /// It splits the last two suspects for the XC2 border streaks in a single run. + /// + /// The DoF bokeh is a scatter: the geometry shader (guest GS 0x1000ADF30) receives one point per + /// scene pixel, reads the circle-of-confusion in a 64x36 R8 map, and expands the point into a + /// quad-sprite whose SIZE is that CoC times a game constant. Corrupting the fragment shader's + /// arithmetic outright (see MufuPrecProbe) did NOT move the streaks, so they are not produced by + /// the shading -- they come either from the CoC that arrives, or from where the quads land. + /// + /// While this flag is set, that shader's single texture read (verified: the GS samples exactly one + /// texture, the CoC map) returns the constant instead, so EVERY sprite gets the same size: + /// streaks GONE -> the CoC map, or the way we sample it, drives them; + /// streaks STAY -> it is the placement/rasterisation of the quads, independent of size. + /// + /// The value is also its own canary, which the previous experiment lacked: a mid value such as 0.5 + /// blurs the WHOLE screen uniformly regardless of depth, and 0 removes the blur entirely. Both are + /// impossible to miss, so "the picture looks normal" becomes a meaningful answer rather than an + /// ambiguous one -- unlike perturbing the weights of an average, which stays a plausible blur. + /// + /// This is a codegen probe: it alters generated host code, so it MUST be listed in + /// DiskCacheHostStorage's probe interlock, otherwise the instrumented shader would be persisted + /// and served to a later probe-less launch. It is also address-gated, and the disk-cache + /// recompile path passes address 0 (ParallelDiskCacheLoader), so the experiment only actually + /// runs with the shader cache disabled -- the [COC_CONST] log line is the proof it did. + /// + public static class CocConstProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_COC_CONST"); + + // Invariant culture on purpose: "0.5" parses, "0,5" does not and leaves the probe off. + // A silently-off probe reads as "innocent" and would invert the conclusion, so the + // [COC_CONST] log line is what proves the experiment actually ran. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs b/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs index b841d2a85..9f9301f2b 100644 --- a/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs +++ b/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs @@ -346,6 +346,15 @@ namespace Ryujinx.Graphics.Shader.Translation // scaled by w so it stays a constant pixel shift after the perspective divide -- the correct, // native-DLSS way to jitter, unlike a viewport shift. The offset is 0 unless jitter is enabled, // so the default path is byte-identical. + // + // [NOJITTEREMIT 02/08, journal (380)] Suspect du dossier ECRAN VERT (4 jeux, 3 UE) : ce bloc + // est emis dans CHAQUE vertex shader, sans gate — seule sa VALEUR est nulle au repos. Deux + // facons dont il peut nuire meme jitter eteint : (a) le champ JitterOffset n'est televerse + // que quand il CHANGE ⇒ jamais ecrit sur le GPU si le jitter n'a jamais servi ⇒ le shader lit + // de la memoire non initialisee (corrige en parallele cote SupportBufferUpdater) ; (b) le + // load/store supplementaire de Position peut perturber des programmes qui n'ecrivent pas + // Position comme prevu. RYUJINX_NOJITTER_EMIT=1 retire le bloc = epilogue STOCK exact. + if (!MvppJitterEmit.Disabled) { Operand jpx = this.Load(StorageKind.Output, IoVariable.Position, null, Const(0)); Operand jpy = this.Load(StorageKind.Output, IoVariable.Position, null, Const(1)); @@ -406,6 +415,9 @@ namespace Ryujinx.Graphics.Shader.Translation PrepareForVertexReturn(); } + // [Beast Roofer diag] one [SKYMV] log line per process, at translation time only. + private static bool _skyMvLogged; + public bool PrepareForReturn() { if (IsNonMain) @@ -557,6 +569,55 @@ namespace Ryujinx.Graphics.Shader.Translation Operand src = Register(regIndexBase + component, RegisterType.Gpr); + // [Beast Roofer diag] RYUJINX_SKYMV_CONST= (absent by default): replace the + // sky pass's motion output (render target 1 of guest FS 0x100068730) with a + // constant at the store point, colour output untouched. Splits "garbage magnitudes + // born in the sky writer" from "born in the material writers". See SkyMvProbe. + if (SkyMvProbe.Enabled && TranslatorContext.Address == 0x100068730UL && rtIndex == 1) + { + if (!_skyMvLogged) + { + _skyMvLogged = true; + TranslatorContext.GpuAccessor.Log( + $"[SKYMV] Guest FS: 0x100068730 | Override: YES (rt1) | Replacement value: " + + SkyMvProbe.Value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + src = ConstF(SkyMvProbe.Value.Value); + } + + // [Beast Roofer diag] PERISCOPE (EXP 15, gated OFF by default): replace the + // captured shader's colour outputs with the raw sample -- the screen becomes + // the probed texture. ALL rts, not just rt0: the XC2 temporal resolve writes + // 4 targets and the displayed colour is NOT rt0 (journal 156), so overriding + // every target is the only way to hit the screen without knowing which one + // it is. Diagnostic-only shader, loop metadata poisoning is acceptable. + if (TranslatorContext.MvppPeriscopeSample != null && component < 3) + { + src = TranslatorContext.MvppPeriscopeSample[component]; + } + + // [Beast Roofer diag] NANSCRUB (EXP 14, gated OFF by default): in shaders + // carrying the MV-encode fingerprint, wrap every output component with + // `x == x ? x : 0` -- NaN becomes 0 (the console's presumed conversion), + // legitimate values pass through bit-identical. See NanScrubProbe. + if (TranslatorContext.MvppNanScrubArmed) + { + Operand isNumber = this.FPCompareEqual(src, src); + src = this.ConditionalSelect(isNumber, src, ConstF(0)); + } + + // [Beast Roofer diag] SATSCRUB (EXP 16, gated OFF by default): in the same + // fingerprinted shaders, saturated (>= 0.999) values on non-rt0 outputs + // become 0 -- the host-side face of the degenerate-rcp garbage. See + // NanScrubProbe.SatScrub. Requires RYUJINX_NANSCRUB=1 too (arming). + if (TranslatorContext.MvppNanScrubArmed && NanScrubProbe.SatScrub && + (rtIndex != 0 || TranslatorContext.MvppSatScrubAllRts)) + { + Operand isSat = this.FPCompareLess(src, ConstF(NanScrubProbe.SatScrubThreshold)); + src = this.ConditionalSelect(isSat, src, ConstF(0)); + } + // Perform B <-> R swap if needed, for BGRA formats (not supported on OpenGL). if (!supportsBgra && (component == 0 || component == 2)) { diff --git a/src/Ryujinx.Graphics.Shader/Translation/HashTestProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/HashTestProbe.cs new file mode 100644 index 000000000..856cc1433 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/HashTestProbe.cs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [HASH_TEST] Temporary VALIDATION experiment (RYUJINX_HASH_CONST=<float>), inert unless set. + /// It answers one question and is meant to be deleted afterwards: is the per-pixel dither hash + /// of the XC2 DoF bokeh shader required for the border-streak artifact? + /// + /// That shader (guest FS 0x1000AE430) computes hash = fract(sin(...) * 43758.5469). While this + /// flag is set, the translator replaces ONLY that one multiply's result with the constant, so + /// hash = fract(const) and every other part of the shader (CoC, gathers, weights, accumulation, + /// normalization, sprite generation) is untouched. See InstEmit.Fmul32i for the injection site. + /// + /// This is a codegen probe: it alters generated host code, so it MUST be listed in + /// DiskCacheHostStorage's probe interlock, otherwise the instrumented shader would be persisted + /// and served to a later probe-less launch. It is also address-gated, and the disk-cache + /// recompile path passes address 0 (ParallelDiskCacheLoader), so the experiment only actually + /// runs with the shader cache disabled -- the [HASH_TEST] log line is the proof it did. + /// + public static class HashTestProbe + { + public static readonly float? ConstValue = Parse(); + + public static bool Enabled => ConstValue.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_HASH_CONST"); + + // Invariant culture on purpose: "0.5" parses, "0,5" does not and leaves the probe off. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MufuPrecProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MufuPrecProbe.cs new file mode 100644 index 000000000..f5ec31b1e --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MufuPrecProbe.cs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [MUFU_PREC] Temporary VALIDATION experiment (RYUJINX_MUFU_PREC=<0-23>), inert unless set. + /// It answers one question and is meant to be deleted afterwards: is the precision of the + /// approximate reciprocal/square-root unit in the causal chain of the XC2 border streaks? + /// + /// Maxwell's MUFU is deliberately approximate (MUFU.RCP/RSQ/SQRT are good to a couple of ulp), + /// while we translate those to the host's exact IEEE operations -- so the emulator is strictly + /// MORE precise than the console it emulates. The DoF bokeh shader (guest FS 0x1000AE430) weighs + /// every tap with a clamped `bias - dist/CoC` (~15 rcp + ~13 sqrt) and normalises by 1/sum(w), + /// so a value landing on the other side of a clamp boundary flips a whole tap in or out of the + /// sum. While this flag is set, the translator drops the low mantissa bits of those three + /// results in that one shader; everything else is untouched. See InstEmit.Mufu. + /// + /// Value = mantissa bits KEPT. 23 = no change. 0 = the canary: every result collapses to a power + /// of two, which must visibly wreck the blur -- if it does not, that shader is not on screen and + /// no verdict from the finer runs can be trusted. + /// + /// This is a codegen probe: it alters generated host code, so it MUST be listed in + /// DiskCacheHostStorage's probe interlock, otherwise the instrumented shader would be persisted + /// and served to a later probe-less launch. It is also address-gated, and the disk-cache + /// recompile path passes address 0 (ParallelDiskCacheLoader), so the experiment only actually + /// runs with the shader cache disabled -- the [MUFU_PREC] log line is the proof it did. + /// + public static class MufuPrecProbe + { + /// Widest mantissa a binary32 result can carry. + public const int MantissaBits = 23; + + /// Mantissa bits to keep, or null when the probe is off. + public static readonly int? BitsKept = Parse(); + + public static bool Enabled => BitsKept.HasValue; + + /// [v2] (journal 219) optional target override: RYUJINX_MUFU_ADDR=<hex guest FS + /// address> retargets the precision reduction (default stays the bokeh FS). The 219 diff + /// proved the BUILDER (0x1000AD730) transforms identical inputs differently per backend; + /// its MUFU ops (2x rcp, rsq, sqrt) were never probed. + public static readonly ulong? AddressOverride = ParseAddr(); + + private static ulong? ParseAddr() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_MUFU_ADDR"); + + if (raw != null && ulong.TryParse(raw.Trim().Replace("0x", ""), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong addr)) + { + return addr; + } + + return null; + } + + private static int? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_MUFU_PREC"); + + // Invariant culture on purpose, and blank/malformed leaves the probe off rather than + // defaulting to something: a silently-off probe reads as "innocent" and would invert + // the conclusion. The [MUFU_PREC] log line is what proves it actually ran. + if (!string.IsNullOrWhiteSpace(raw) && + int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int bits)) + { + return Math.Clamp(bits, 0, MantissaBits); + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvKillProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvKillProbe.cs new file mode 100644 index 000000000..3e2933365 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvKillProbe.cs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [MVKILL] Temporary VALIDATION experiment (RYUJINX_MVKILL_CONST=<float>), inert unless set. + /// Splits the two inputs of the XC2 motion-map builder pass. + /// + /// Chain established by the MAP64 probe + decompilation (journal 124/125): the 64x36 map that + /// drives form A (EXP 4) is a per-tile MOTION map. Its builder (guest FS 0x1000AD730) combines + /// OBJECT motion -- the 1280x720 R10G10B10A2 buffer, x/y encoded as sign*v^2 with the sign bits + /// in the 2-bit alpha -- with CAMERA motion reprojected from depth (R32F 640x360) and the + /// matrices in fp_c3[5..8]. While this flag is set, the builder's samples of the OBJECT-motion + /// buffer (sampler handle 0x8) return the constant instead. With 0.0, v^2 = 0, so object motion + /// is nulled regardless of the sign bits -- 0.0 is the only true zero of this encoding (0.5 is + /// NOT neutral here, unlike the biased encoding used one pass later). + /// flat-block counter reads ~0 on new captures -> form A needs OBJECT-MV data: the corruption + /// rides the R10G10B10A2 720p buffer (never watched host-side); watch that surface next; + /// flat blocks remain -> the camera/depth path carries it: instrument the 640x360 R32F next. + /// Canary caveat: camera-induced blur SURVIVES this override by design, so the picture can look + /// near-normal; the verdict comes from the flat-block counter, never from the eye. + /// + /// This is a codegen probe: it alters generated host code, so it MUST be listed in + /// DiskCacheHostStorage's probe interlock, and the shader cache must be OFF for the address + /// gate to match (the disk-cache recompile path passes address 0). The [MVKILL] log lines are + /// the proof the experiment ran. + /// + public static class MvKillProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_MVKILL_CONST"); + + // Invariant culture on purpose: "0.0" parses, "0,0" does not and leaves the probe off. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvResolveMvProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvResolveMvProbe.cs new file mode 100644 index 000000000..e4fed27f1 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvResolveMvProbe.cs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [RESOLVEMV] Surgical A/B (RYUJINX_RESOLVEMV_CONST=<float>), inert unless set. Journal 204. + /// + /// 203 bisected the chain: fresh frame CLEAN, TAA-resolve output TOUCHED, final full-blown -- + /// the poison enters AT the resolve (guest FS 0x100082C30, read line by line in 199) and + /// accumulates in its history loop. The artifact is DISPLACED content in whole blocks, and the + /// only displacement vector in that shader is the motion vector it samples from tcb_10 (X) to + /// reproject its history (sign*v^2 decode, closest-depth dilation). While this flag is set, the + /// resolve's tcb_10 samples return the constant instead. With 0.0: v^2 = 0 -> reprojection + /// offset 0 -> history sampled IN PLACE. + /// artifact DEAD (eye) -> the displacement path (MV decode/dilation/reprojection) carries + /// the poison -> next: split decode vs dilation vs UV math; + /// blocks remain -> displacement innocent -> the blend weight / clip box side. + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock; cache OFF (address gate). + /// + public static class MvResolveMvProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_RESOLVEMV_CONST"); + + // Invariant culture on purpose: "0.0" parses, "0,0" does not and leaves the probe off. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvSignProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvSignProbe.cs new file mode 100644 index 000000000..238ca9008 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvSignProbe.cs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [MVSIGN] Temporary VALIDATION experiment (RYUJINX_MVSIGN_CONST=<float>), inert unless set. + /// Splits the object-MV data itself: sign channel vs magnitude channels. + /// + /// Where we stand (journal 128/129): the motion-map builder's samples of the object-MV buffer + /// carry the corruption (EXP 5), and the ENTIRE clear family is dead -- command, area, host + /// identity and even execution ordering (EXP 6 draw-based clears changed nothing). So the + /// garbage is WRITTEN into the buffer, or made at the read. The builder decodes motion as + /// sign(w) * (xy)^2, where w is the 2-bit A2 alpha holding the x/y sign bits. EXP 5 nulled all + /// three components at once; this experiment overrides ONLY the w component (dests[2] of the + /// single .xyw sample, bokeh builder FS 0x1000AD730, handle 0x8), leaving magnitudes live: + /// artifact GONE -> the garbage lives in the SIGN channel: the emulator's handling of the + /// A2 component (write, blend or decode) is the narrow root surface to instrument next; + /// artifact STAYS -> the garbage lives in the 10-bit magnitudes: the material writes. + /// With 0.0 both sign bits read 0 (signs forced to -,-): legit object blur keeps its size but + /// collapses to one diagonal -- a subtle visual change; the verdict is the flat-block counter. + /// + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock; shader cache OFF required for + /// the address gate (recompile path passes address 0). The [MVSIGN] log lines are the proof. + /// + public static class MvSignProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_MVSIGN_CONST"); + + // Invariant culture on purpose: "0.0" parses, "0,0" does not and leaves the probe off. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvppForceLod0Probe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvppForceLod0Probe.cs new file mode 100644 index 000000000..88b8356f5 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvppForceLod0Probe.cs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [FORCELOD0] Surgical A/B (RYUJINX_FORCELOD0=1), inert unless set. Journal 211. + /// + /// The bokeh gather samples its colour inputs with IMPLICIT-LOD texture() while adjacent + /// pixels can hold wildly different gather parameters (the z-variation trigger, 210): + /// divergent UV derivatives push the sampler into HIGH MIPS -- and mip 3-4 of the 512x288 + /// half-res buffer is 64x36: 20-px texels on screen, the EXACT form-A grid. Unrendered + /// mips hold stale/garbage content = displaced blocks. This flag turns the gather's + /// implicit-LOD samples (handles 0xC / 0xA of that one shader) into textureLod(0). + /// blocks DEAD -> mechanism = LOD/mip divergence; real fix = sampler/view LOD state + /// (generic, upstream-able); + /// blocks ALIVE -> mips innocent; back to the selection lines with data in hand. + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock; cache OFF (address gate). + /// + public static class MvppForceLod0Probe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_FORCELOD0") == "1"; + + private static readonly HashSet _logged = new(); + + public static void OnApplied(IGpuAccessor gpuAccessor, int handle) + { + lock (_logged) + { + if (_logged.Add(handle)) + { + gpuAccessor.Log($"[FORCELOD0] bokeh FS sampler handle 0x{handle:X}: implicit-LOD sample forced to textureLod(0)"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvppJitterEmit.cs b/src/Ryujinx.Graphics.Shader/Translation/MvppJitterEmit.cs new file mode 100644 index 000000000..637826e7d --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvppJitterEmit.cs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [JITTEREMIT 03/08, journal (386)] Le bloc de jitter clip-space n'est plus emis QUE si le + /// jitter est reellement actif. Racine trouvee par Alex : le bloc LIT `Position` en SORTIE + /// avant de la reecrire, et lire une sortie jamais ecrite est INDEFINI — donc meme avec un + /// offset a zero, `0 * w + valeur_indefinie` reste indefini. Zeroter la donnee (fix + /// JITTERINIT du 02/08) etait necessaire mais PAS suffisant : il fallait ne pas emettre. + /// + /// Par defaut (profil livre = sans jitter) l'epilogue est desormais STOCK a l'octet pres. + /// RYUJINX_NOJITTER_EMIT=1 force le retrait meme si le jitter est arme (garde de diagnostic). + /// ⚠️ Change la TRADUCTION : `CodeGenVersion` doit etre bumpee, sinon les caches de shaders + /// existants servent l'ancien code et le correctif n'atteint pas les utilisateurs. + /// + public static class MvppJitterEmit + { + private static readonly bool _forceOff = + System.Environment.GetEnvironmentVariable("RYUJINX_NOJITTER_EMIT") == "1"; + + private static readonly bool _jitterOn = + System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_JITTER") is "1" or "true" or "on"; + + /// True quand le bloc ne doit PAS etre emis (defaut : jitter eteint). + public static bool Disabled => _forceOff || !_jitterOn; + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvppNClampProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvppNClampProbe.cs new file mode 100644 index 000000000..422f64f9b --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvppNClampProbe.cs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Graphics.Shader.CodeGen; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [NCLAMP] EXP (RYUJINX_NCLAMP=1, inert unless set). Journal 196. + /// + /// The XC2 motion-blur builder computes 0 * inversesqrt(0) = NaN on every below-threshold + /// pixel and relies on the hardware SAT modifier to flush it to 0. The guest SAT becomes + /// Instruction.Clamp; the GLSL backend's clamp() compiles on NVIDIA GL to the hardware + /// saturate (NaN -> 0, matching the console), but the SPIR-V backend emits FClamp, whose + /// result is UNDEFINED on NaN per spec -- on NVIDIA Vulkan the NaN propagates into the + /// temporal ping-pong and becomes the saturated masses. NClamp is the NaN-aware variant: + /// NClamp(NaN, 0, 1) == 0 by construction (NMax(NaN,0)=0 then NMin(0,1)=0) -- the exact + /// console semantics. + /// + /// Gated swap of FClamp -> NClamp for FLOAT clamps in the SPIR-V backend. Generic: no + /// game data, capability-level semantics fix. Requires shader re-translation (cache OFF + /// pre-flight in the bat) -- the armed line doubles as the translation witness. + /// + public static class MvppNClampProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_NCLAMP") == "1"; + + private static long _applied; + + /// Called once per float clamp translated under the gate. The first call + /// prints the armed witness: no line in the log = the gate is off OR nothing was + /// re-translated (shader cache still on) = VOID. + public static void OnApplied(ILogger logger) + { + if (Interlocked.Increment(ref _applied) == 1) + { + logger?.Log("[NCLAMP] armed: SPIR-V float clamps emitted as NClamp (NaN -> bound, console SAT semantics)"); + } + } + + public static long Applied => Interlocked.Read(ref _applied); + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvppNMinMaxProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvppNMinMaxProbe.cs new file mode 100644 index 000000000..aeb37fafe --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvppNMinMaxProbe.cs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Graphics.Shader.CodeGen; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [NMINMAX] EXP (RYUJINX_NMINMAX=1, inert unless set). Journal 199. + /// + /// Companion of [NCLAMP] (196): the hardware FMNMX instruction returns the NON-NaN + /// operand when one input is NaN -- the TAA resolve (Shader0086) builds its history + /// clip box from 116 min/max ops fed by 17 reciprocals; on hardware/GL a NaN entering + /// the chain is flushed out, while our SPIR-V GlslFMin/GlslFMax are UNDEFINED on NaN + /// per spec and may propagate it into the clamped history (temporal accumulation => + /// the masses). NMin/NMax have the exact hardware semantics. + /// + /// Gated swap of FMin/FMax -> NMin/NMax for FLOAT min/max in the SPIR-V backend. + /// Run TOGETHER with NCLAMP: the two halves are ONE semantic family (hardware NaN + /// behavior) -- the (184) lesson about testing halves jointly applies. + /// + public static class MvppNMinMaxProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_NMINMAX") == "1"; + + private static long _applied; + + /// First call prints the armed witness (translation-time): no line in the + /// log = gate off OR nothing re-translated (shader cache still on) = VOID. + public static void OnApplied(ILogger logger) + { + if (Interlocked.Increment(ref _applied) == 1) + { + logger?.Log("[NMINMAX] armed: SPIR-V float min/max emitted as NMin/NMax (NaN -> other operand, hardware FMNMX semantics)"); + } + } + + public static long Applied => Interlocked.Read(ref _applied); + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvppTileCapProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvppTileCapProbe.cs new file mode 100644 index 000000000..f558828b0 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvppTileCapProbe.cs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [TILECAP] Surgical A/B (RYUJINX_TILECAP=<float>), inert unless set. Journal 205. + /// + /// 203/204: the poison paints DISPLACED BLOCKS via the bokeh gather, driven by the 64x36 + /// tile map (EXP 4 proved the map's content causal: tcb_E const kills form A); the bokeh + /// FS picks the MAX z among the 3x3 neighbour tiles and blurs along that tile's (x,y). + /// One giant tile value contaminates its whole neighbourhood = the block clusters. + /// + /// Unlike EXP 4 (constant = kills ALL DoF), this caps the sampled tile values SOFTLY: + /// x/y pulled toward their 0.5 bias by at most K, z clamped to K. Small blur lives, + /// giant blur becomes impossible. + /// blocks DEAD, DoF alive -> the map VALUES are excessive -> upstream (builder math / + /// its constant buffers) makes them; the artifact is the gather honestly obeying them; + /// blocks REMAIN -> even small values misdrive the gather -> the defect is in + /// the bokeh FS itself (translation of its addressing/weights). + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock; cache OFF (address gate). + /// + public static class MvppTileCapProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + /// [v2] (journal 209, Alex's bisection): RYUJINX_TILECAP_CH = "xy" caps only + /// the direction channels, "z" caps only the selector/magnitude channel; absent = all + /// three. Whichever channel's uniformity kills the blocks ALONE names the trigger. + public static readonly string Channel = + Environment.GetEnvironmentVariable("RYUJINX_TILECAP_CH"); + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_TILECAP"); + + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/MvppTruncEpsProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/MvppTruncEpsProbe.cs new file mode 100644 index 000000000..cd0dc8627 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/MvppTruncEpsProbe.cs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; +using System.Threading; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [TRUNCEPS] Surgical A/B (RYUJINX_TRUNCEPS=<float>), inert unless set. Journal 208. + /// + /// 207 locked the perimeter: form A needs inter-tile VARIATION and lives in the bokeh + /// FS's variation-triggered gather. That gather addresses its taps through MANUAL texel + /// snapping -- trunc(x + 0.5) -- so a HAIR of upstream precision difference (fma + /// contraction, GL vs SPIR-V) flips the snap by one whole texel: block-shaped + /// displacement, exactly the artifact's form. This probe nudges every FP32 F2I input of + /// that one shader by a tiny epsilon. + /// blocks MOVE or DIE -> the snap is precision-critical: the defect is upstream + /// rounding/contraction feeding trunc -> real fix = match hardware contraction + /// (NoContraction / precise) or bias the snap; + /// blocks IDENTICAL -> snap innocent -> next suspect in the gather. + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock; cache OFF (address gate). + /// + public static class MvppTruncEpsProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static long _applied; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_TRUNCEPS"); + + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + + public static void OnApplied(IGpuAccessor gpuAccessor) + { + if (Interlocked.Increment(ref _applied) == 1) + { + gpuAccessor.Log( + $"[TRUNCEPS] armed: bokeh FS F2I inputs nudged by {Value.Value.ToString(CultureInfo.InvariantCulture)}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/NanScrubProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/NanScrubProbe.cs new file mode 100644 index 000000000..699412699 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/NanScrubProbe.cs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [NANSCRUB] Temporary VALIDATION experiment (RYUJINX_NANSCRUB=1), inert unless set. + /// Tests the value-divergence hypothesis left standing after the staleness/ordering tree was + /// emptied (journal 146): the material velocity math can produce NaN in degenerate cases + /// (inf * 0 chains are present in the encode), and writing NaN to a UNORM channel is + /// implementation-defined -- likely 0 on the console (no motion, invisible), possibly 1.0 on + /// the host GPU (maximum magnitude: giant smears). Patchy per object, motion-only, "delivered + /// but wrong", immune to every freshness/order guarantee -- all fourteen measurements fit. + /// + /// While this flag is set, fragment shaders carrying the MV-encode fingerprint (the + /// `Fadd32i +0.00999999978` sign-code add -- covers all ~199 material writers plus the sky, + /// with no address list, exactly like HASH_TEST's immediate gate) get every colour output + /// component wrapped at the store point with `x == x ? x : 0` (NaN becomes 0, the console's + /// presumed behavior; legitimate values are untouched, so colour outputs are safe to wrap too). + /// flat-block counter ~0 -> ROOT: NaN-to-UNORM conversion divergence; the generic fix is + /// this scrub gated per-target (or upstream-grade clamping at the encode); + /// flat blocks remain -> hypothesis dead; the value divergence is elsewhere (denormals, + /// precision of the encode chain) -- next split from the same fingerprint gate. + /// The [NANSCRUB] armed-shader count is the witness: zero armed = void, never "innocent". + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock. NOTE: the gate is a code + /// FINGERPRINT, not an address, so it also fires on the disk-cache recompile path (address 0) + /// -- no config pre-flight needed; the interlock alone forces guest retranslation. + /// + public static class NanScrubProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_NANSCRUB") == "1"; + + // [SATSCRUB] (EXP 16) The periscope decode (journal 150) showed the garbage is EXACTLY 1.0 + // magnitudes written by the materials: on the host the degenerate 1/w path yields a huge + // FINITE value, and huge * (1/max(huge,1)) ~= 1.0 -- while Maxwell's denormal flush turns + // the same path into Inf*0 = NaN, written as 0 = invisible on console. So the divergence is + // saturated-but-legal 1.0, which is why the NaN scrub (EXP 14) changed nothing. This flag + // scrubs >= 0.999 to 0 on the NON-rt0 outputs of fingerprinted shaders (rt0 = main colour, + // never the MV target in any surveyed layout; aux targets may lose rare legit 1.0s -- fine + // for a TEST). Artifact gone => root = degenerate-rcp saturation; real fix = epsilon guard. + public static readonly bool SatScrub = + Environment.GetEnvironmentVariable("RYUJINX_SATSCRUB") == "1"; + + // [SATSCRUB2] (EXP 18) Periscope rung 1 (journal 157): the 720p MV buffer already holds the + // saturated masses at rest, and the builder's 1/max normalisation turns ANY large writer + // output into an exact 1.0 in the map -- so the 0.999 cut can miss poison leaving the + // writers at 0.6-0.99 (why EXP 16/17 changed nothing while MVKILL killed everything). + // Optional scrub-threshold override, e.g. RYUJINX_SATSCRUB_T=0.5. Absent = 0.999 (EXP 16/17). + public static readonly float SatScrubThreshold = ParseThreshold(); + + private static float ParseThreshold() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_SATSCRUB_T"); + + return float.TryParse(raw, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float value) + ? value + : 0.999f; + } + + private static long _armed; + + // [CENSUS] (sonde 20, journal 159) Registry of the guest FS addresses the fingerprint armed, + // so the Gpu-side writer census can flag MV-buffer writers the scrubs never covered. + // Cache-path translations report address 0 (journal 148) and are skipped: the census run + // must disable the shader cache or every writer would look unarmed (void). + private static readonly System.Collections.Generic.HashSet _armedAddresses = new(); + + public static bool WasArmed(ulong address) + { + lock (_armedAddresses) + { + return _armedAddresses.Contains(address); + } + } + + public static int ArmedAddressCount() + { + lock (_armedAddresses) + { + return _armedAddresses.Count; + } + } + + public static void OnArmed(ulong address, IGpuAccessor gpuAccessor) + { + if (address != 0) + { + lock (_armedAddresses) + { + _armedAddresses.Add(address); + } + } + + long n = Interlocked.Increment(ref _armed); + + if (n <= 3 || n % 50 == 0) + { + gpuAccessor.Log($"[NANSCRUB] armed shader #{n} (guest 0x{address:X}) -- outputs will be NaN-scrubbed" + + (SatScrub ? $" + sat-scrubbed at >= {SatScrubThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}" : "")); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/Optimizations/BindlessElimination.cs b/src/Ryujinx.Graphics.Shader/Translation/Optimizations/BindlessElimination.cs index c40568a61..aee5eaeda 100644 --- a/src/Ryujinx.Graphics.Shader/Translation/Optimizations/BindlessElimination.cs +++ b/src/Ryujinx.Graphics.Shader/Translation/Optimizations/BindlessElimination.cs @@ -8,6 +8,10 @@ namespace Ryujinx.Graphics.Shader.Translation.Optimizations { class BindlessElimination { + // [NOBINDLESS 01/08] Voir GenerateBindlessAccess. OFF par defaut. + private static readonly bool _noBindless = + Environment.GetEnvironmentVariable("RYUJINX_SHADER_NOBINDLESS") == "1"; + public static void RunPass(BasicBlock block, ResourceManager resourceManager, IGpuAccessor gpuAccessor) { // We can turn a bindless into regular access by recognizing the pattern @@ -61,6 +65,19 @@ namespace Ryujinx.Graphics.Shader.Translation.Optimizations TextureOperation texOp, LinkedListNode node) { + // [NOBINDLESS 01/08] Test de DISCRIMINATION, gate OFF par defaut (variable absente => + // comportement a l'octet pres). RYUJINX_SHADER_NOBINDLESS=1 fait echouer la generation + // d'acces bindless exactement comme sur un hote sans separate-sampler (OpenGL) : la + // lecture de texture est supprimee et le resultat force a ZERO (voir le site appelant). + // BUT : sur un jeu ou les couleurs sont fausses en Vulkan et justes en OpenGL, si les + // couleurs redeviennent justes avec ce gate, c'est que le chemin bindless genere + // echantillonne les MAUVAISES textures (poignee rabattue dans les bornes du pool + // ligne ~99). Diagnostic seulement : ce n'est PAS un correctif candidat. + if (_noBindless) + { + return false; + } + if (!gpuAccessor.QueryHostSupportsSeparateSampler()) { // We depend on combining samplers and textures in the shader being supported for this. diff --git a/src/Ryujinx.Graphics.Shader/Translation/PeriscopeProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/PeriscopeProbe.cs new file mode 100644 index 000000000..b995deda9 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/PeriscopeProbe.cs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [PERISCOPE] Temporary VISUALIZATION experiment (RYUJINX_PERISCOPE=1), inert unless set. + /// Makes the screen show the RAW 64x36 motion tile map instead of the motion-blur result. + /// + /// After ten cause families died by measurement (journal 121-148), the missing piece is a + /// direct LOOK at the corrupted data itself: readbacks deform it ((112)(3)), Nsight is out. + /// This probe turns the display into the debugger: in the blur shader (guest FS 0x1000AE430), + /// the first sample of the tile map (handle 0xE) is captured at translation and REPLACES the + /// shader's colour output (rt0.xyz), so the final image IS the map, one texel = 20 screen px, + /// through the normal render path -- no readback, no deformation. Alex's captures become + /// memory dumps I can analyse offline: replicated rows would say scale/clamp mismatch, + /// static blocks would say uninitialized regions, flicker would say generation mixing. + /// + /// Address-gated like TILEMAP_CONST (the blur FS lacks the 0.01 fingerprint), so the shader + /// cache must be OFF for the run -- the TILEMAP-style pre-flight bat handles it. Codegen + /// probe: in the DiskCacheHostStorage interlock. Witness: the screen looks obviously wrong + /// (a coarse mosaic instead of the scene's blur) + the [PERISCOPE] log line. + /// + public static class PeriscopeProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_PERISCOPE") == "1"; + + // [PERISCOPE2] Ladder stage 1 (journal 153): display the RAW 720p object-MV buffer, + // one pixel per texel, by capturing the temporal resolve's tcb_10 sample (guest FS + // 0x100082C30) and overriding its rt0 scene output. Same field, same return-site override. + public static readonly bool Stage2 = + Environment.GetEnvironmentVariable("RYUJINX_PERISCOPE2") == "1"; + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/RroReduceProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/RroReduceProbe.cs new file mode 100644 index 000000000..0d0146160 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/RroReduceProbe.cs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [RROREDUCE] Range reduction of the sin/cos argument before MUFU (RYUJINX_RRO_REDUCE=1), + /// inert unless set. Maxwell's RRO instruction reduces the argument ahead of every MUFU.SIN/COS; + /// we translate RRO as a plain move, so large arguments reach the host sin/cos at full magnitude. + /// See InstEmit.Mufu / ReduceSinCosArg for the injection site. + /// + /// Single source of truth for the flag. This is a codegen probe -- it changes the generated host + /// code -- so it belongs in DiskCacheHostStorage's probe interlock exactly like the other probes: + /// without it, a run with the flag on would persist the modified shader into the shared host + /// cache and keep serving it to later launches that have the flag off, with the DLL restored and + /// nothing on screen to explain it. That hole was latent for as long as this flag existed; it is + /// closed here rather than left as a trap for the next experiment. + /// + public static class RroReduceProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_RRO_REDUCE") == "1"; + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/SkyMvProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/SkyMvProbe.cs new file mode 100644 index 000000000..699a56a20 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/SkyMvProbe.cs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [SKYMV] Temporary VALIDATION experiment (RYUJINX_SKYMV_CONST=<float>), inert unless set. + /// Splits the WRITERS of the corrupted object-MV magnitudes: sky pass vs scene materials. + /// + /// Where we stand (journal 130/131): the corruption lives in the 10-bit magnitude channels of + /// the MV buffer, written after a proven-good clear. The buffer has two writer classes: ~763 + /// per-material fragment shaders, and ONE full-screen pass at guest FS 0x100068730 -- the + /// SKY/background: it reprojects the view direction through the previous frame's matrices + /// (fp_c3[10..18]) and encodes camera-rotation motion as out_attr1 = (sqrt|mx|, sqrt|my|, 0, + /// signCode + 0.01), the sqrt built from an Rsq-then-Rcp MUFU chain never covered by any + /// precision test (EXP 2 only touched the blur shader). The sky covers exactly the regions + /// where the artifact is worst, and fills the screen when looking up/down -- the worst case. + /// + /// While this flag is set, every component the sky pass writes to render target 1 (its MV + /// output; its colour output at target 0 is untouched) is replaced by the constant at the + /// output-store point (EmitterContext.PrepareForReturn). With 0.0 the sky writes zero motion: + /// flat-block counter ~0 -> the garbage magnitudes are BORN IN THE SKY PASS's writes; the + /// root is inside its encode chain (Rsq/Rcp MUFU, normalize, matrix path) -- narrow next; + /// flat blocks remain -> sky innocent; the garbage comes from the material writers + /// (bisection or material-encode reading next). + /// Legit visual cost: the sky loses its motion blur only -- subtle; the verdict is the counter. + /// + /// Codegen probe: MUST be in DiskCacheHostStorage's interlock; shader cache OFF required for + /// the address gate (recompile path passes address 0). The [SKYMV] log line is the proof. + /// + public static class SkyMvProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_SKYMV_CONST"); + + // Invariant culture on purpose: "0.0" parses, "0,0" does not and leaves the probe off. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/TileMapConstProbe.cs b/src/Ryujinx.Graphics.Shader/Translation/TileMapConstProbe.cs new file mode 100644 index 000000000..1af1dda53 --- /dev/null +++ b/src/Ryujinx.Graphics.Shader/Translation/TileMapConstProbe.cs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using System; +using System.Globalization; + +namespace Ryujinx.Graphics.Shader.Translation +{ + /// + /// [TILEMAP] Temporary VALIDATION experiment (RYUJINX_TILEMAP_CONST=<float>), inert unless set. + /// Tests whether the DoF tile map drives the XC2 "form A" corruption rectangles. + /// + /// Form-A characterisation (journal entry 121) measured, on clean-run captures, that every + /// locatable edge of the corruption rectangles snaps to a 20-px grid at 720p (12 of 12 edges + /// across two captures, within 1 px) -- and 20 px is exactly one texel of the 64x36 DoF maps + /// (1280/64 = 720/36 = 20). The bokeh fragment shader (guest FS 0x1000AE430) reads one such map, + /// fp_t_tcb_E (64x36 RGBA8, sampler handle 0xE), as a 3x3 neighbourhood (bokeh_fs.glsl:866-906): + /// the shape of a per-tile classification map, and the one bokeh input no instrument ever + /// examined. While this flag is set, every sample of that map in that shader returns the + /// constant instead: + /// flat-block counter reads 0 on new captures -> the tile map (its content or our sampling of + /// it) is in form A's causal chain; next step is watching the 64x36 surface host-side; + /// flat blocks remain -> the map is innocent; next suspects are fp_t_tcb_C (320x180), then + /// fp_t_tcb_A. + /// The value is its own canary: 1.0 saturates every tile of an RGBA8 map (bounded, no perf + /// hazard) and visibly changes the DoF everywhere, so "picture unchanged" means the override is + /// not being consumed (void result), never "innocent". + /// + /// This is a codegen probe: it alters generated host code, so it MUST be listed in + /// DiskCacheHostStorage's probe interlock, otherwise the instrumented shader would be persisted + /// and served to a later probe-less launch. It is also address-gated, and the disk-cache + /// recompile path passes address 0 (ParallelDiskCacheLoader), so the experiment only actually + /// runs with the shader cache disabled -- the [TILEMAP] log lines are the proof it did. + /// + public static class TileMapConstProbe + { + public static readonly float? Value = Parse(); + + public static bool Enabled => Value.HasValue; + + private static float? Parse() + { + string raw = Environment.GetEnvironmentVariable("RYUJINX_TILEMAP_CONST"); + + // Invariant culture on purpose: "1.0" parses, "1,0" does not and leaves the probe off. + // A silently-off probe reads as "innocent" and would invert the conclusion, so the + // [TILEMAP] log lines are what prove the experiment actually ran. + if (raw != null && + float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Shader/Translation/TranslatorContext.cs b/src/Ryujinx.Graphics.Shader/Translation/TranslatorContext.cs index 9369ed947..b923c94d7 100644 --- a/src/Ryujinx.Graphics.Shader/Translation/TranslatorContext.cs +++ b/src/Ryujinx.Graphics.Shader/Translation/TranslatorContext.cs @@ -27,6 +27,21 @@ namespace Ryujinx.Graphics.Shader.Translation Environment.GetEnvironmentVariable("RYUJINX_MVPP_CUTOUT_PROBE") == "1"; public ulong Address { get; } + + // [Beast Roofer diag] NANSCRUB (EXP 14): set by Fadd32i when the MV-encode fingerprint + // (+0.00999999978 sign-code add) is seen in this translation; PrepareForReturn then wraps + // every colour output with a NaN scrub. Per-translation state, parallel-safe by ownership. + public bool MvppNanScrubArmed; + + // [Beast Roofer diag] PERISCOPE (EXP 15): the blur shader's first tile-map sample operands, + // captured at EmitTextureSample; PrepareForReturn replaces rt0.xyz with them so the screen + // shows the raw map. Per-translation state, parallel-safe by ownership. + internal IntermediateRepresentation.Operand[] MvppPeriscopeSample; + + // [Beast Roofer diag] SATSCRUB stage 2 (EXP 17): armed via the *3.00999999 decode + // fingerprint (the 6 MV-chain processors incl. the builder); their MV output IS rt0, + // so the saturation scrub must include it for these shaders. + public bool MvppSatScrubAllRts; public int Size { get; } public int Cb1DataSize => _program.Cb1DataSize; @@ -433,7 +448,8 @@ namespace Ryujinx.Graphics.Shader.Translation usedFeatures.HasFlag(FeatureFlags.RtLayer), usesDiscard, clipDistancesWritten, - originalDefinitions.OmapTargets); + originalDefinitions.OmapTargets, + MvppNanScrubArmed || MvppSatScrubAllRts); HostCapabilities hostCapabilities = new HostCapabilities( GpuAccessor.QueryHostReducedPrecision(), diff --git a/src/Ryujinx.Graphics.Vulkan/DescriptorSetUpdater.cs b/src/Ryujinx.Graphics.Vulkan/DescriptorSetUpdater.cs index e08e34df4..66af898f7 100644 --- a/src/Ryujinx.Graphics.Vulkan/DescriptorSetUpdater.cs +++ b/src/Ryujinx.Graphics.Vulkan/DescriptorSetUpdater.cs @@ -498,6 +498,27 @@ namespace Ryujinx.Graphics.Vulkan SignalDirty(DirtyFlags.Storage); } + // [MVREADBAR] Read-only scan (gated at the call site): is any currently-bound sampled + // texture the XC2 object-MV buffer? Bound refs are in-order at the backend by construction, + // which is what makes this the correct place to detect the MV-sampling draws. + public bool MvppAnySampledMvBuffer() + { + for (int i = 0; i < _textureRefs.Length; i++) + { + TextureView view = _textureRefs[i].View; + + if (view != null && + view.Width == 1280 && + view.Height == 720 && + view.VkFormat == Silk.NET.Vulkan.Format.A2B10G10R10UnormPack32) + { + return true; + } + } + + return false; + } + public void SetTextureAndSampler( CommandBufferScoped cbs, ShaderStage stage, @@ -511,6 +532,9 @@ namespace Ryujinx.Graphics.Vulkan } else if (texture is TextureView view) { + MvppVkImgProbe.OnSampled(view); // [VKIMG] read-only, self-gated + MvppSampStoreProbe.OnSampled(stage, binding, view); // [SAMPSTORE] read-only, self-gated + ref TextureRef iRef = ref _textureRefs[binding]; iRef.View?.ClearUsage(FeedbackLoopHazards); @@ -536,6 +560,9 @@ namespace Ryujinx.Graphics.Vulkan { if (texture is TextureView view) { + MvppVkImgProbe.OnSampled(view); // [VKIMG] read-only, self-gated + MvppSampStoreProbe.OnSampled(stage, binding, view); // [SAMPSTORE] read-only, self-gated + view.Storage.QueueWriteToReadBarrier(cbs, AccessFlags.ShaderReadBit, stage.ConvertToPipelineStageFlags()); _textureRefs[binding] = new(stage, view, view.GetIdentityImageView(), ((SamplerHolder)sampler)?.GetSampler()); @@ -864,6 +891,8 @@ namespace Ryujinx.Graphics.Vulkan ref DescriptorImageInfo texture = ref textures[i]; ref TextureRef refs = ref _textureRefs[binding + i]; + MvppDescTruthProbe.OnDescriptorWrite(refs.View, refs.ImageView); // [DESCTRUTH] read-only, self-gated + texture.ImageView = refs.ImageView?.Get(cbs).Value ?? default; texture.Sampler = refs.Sampler?.Get(cbs).Value ?? default; @@ -985,6 +1014,8 @@ namespace Ryujinx.Graphics.Vulkan ref DescriptorImageInfo texture = ref textures[i]; ref TextureRef refs = ref _textureRefs[binding + i]; + MvppDescTruthProbe.OnDescriptorWrite(refs.View, refs.ImageView); // [DESCTRUTH] read-only, self-gated + texture.ImageView = refs.ImageView?.Get(cbs).Value ?? default; texture.Sampler = refs.Sampler?.Get(cbs).Value ?? default; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs index 7fa42eacb..8bcedad6e 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs @@ -57,6 +57,27 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public static readonly bool BirthClearDisabled = IsTruthy(Environment.GetEnvironmentVariable("RYUJINX_DLSS_NOBIRTHCLEAR")); + /// + /// RYUJINX_BIRTHCLEAR_ALL=1 (OFF by default): zero EVERY fresh texture allocation, not + /// only those on the DLSS-SR fractional path. + /// + /// Diagnostic for the 21/07 Xenoblade 2 artifact -- blocks of recognizable-but-wrong + /// content appearing when the camera TURNS. Measured on Alex's machine with DLSS OFF + /// (log: read mode=0, zero "using mode"), no mods, shader cache purged, native res: the + /// artifact is there, so it belongs to none of the DLSS/MV++/FG stack. What it does match + /// is the mechanism this file already documents above: the Vulkan backend never clears a + /// new image (Undefined -> General only) and the suballocator recycles freed device + /// memory, so a render target the game has not finished writing shows an EARLIER FRAME. + /// Turning the camera is exactly when a streaming game allocates new targets. + /// + /// The existing fix is gated to SrFractional on purpose, to keep texture creation + /// byte-identical to upstream everywhere else. This switch lifts that gate for the test + /// only: artifact gone => the cause is recycled memory and we know what to build; still + /// there => the mechanism is ruled out and we drop it. + /// + public static readonly bool BirthClearAll = + IsTruthy(Environment.GetEnvironmentVariable("RYUJINX_BIRTHCLEAR_ALL")); + // Porte A - DLSS Frame Generation probe (RYUJINX_DLSS_FG=1, requires RYUJINX_DLSS=1): // adds DLSS_G to slInit's featuresToLoad and probes slIsFeatureSupported(DLSS_G) after // device registration. Diagnostic only: no frame is ever generated (present-injection is diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssSharpenPass.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssSharpenPass.cs new file mode 100644 index 000000000..0d067a3e8 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssSharpenPass.cs @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). Reuses the fork's FSR RCAS sharpening shader (new LINEAR variant). + +using Ryujinx.Common; +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.Shader; +using Ryujinx.Graphics.Shader.Translation; +using Silk.NET.Vulkan; +using System; +using SamplerCreateInfo = Ryujinx.Graphics.GAL.SamplerCreateInfo; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// [SHARPEN 02/08, journal (372)] Nettete post-DLSS en PRE-PASSE : RCAS maison (variante + /// LINEAR_OUTPUT de FsrSharpening.glsl — float lineaire, AUCUN encodage) applique 1:1 sur la + /// sortie DLSS vers une texture temporaire ; le blit final habituel (tonemap scRGB/PQ, flip, + /// barres d'aspect) consomme ensuite la version nette. Universel : aucune contrainte de + /// cadre, contrairement au remplacement du blit (ecarte — fenetre 3840x2075 avec barres + /// mesuree au log). Meme mecanique RCAS-sur-lineaire que la variante HDR validee a l'oeil + /// a l'epoque du chantier HDR. + /// Gate RYUJINX_DLSS_SHARPEN=1 (OFF par defaut) ; force RYUJINX_DLSS_SHARPEN_LEVEL 0-100 + /// (defaut 25 ; mappee comme le slider FSR de l'UI : 1.5 - level*0.015). + /// + class DlssSharpenPass : IDisposable + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_SHARPEN") == "1"; + + public static readonly int Level = + int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_DLSS_SHARPEN_LEVEL"), out int l) + ? Math.Clamp(l, 0, 100) + : 25; + + private readonly VulkanRenderer _renderer; + private readonly PipelineHelperShader _pipeline; + private readonly IProgram _program; + private readonly ISampler _sampler; + private TextureView _sharpened; + private bool _armedLogged; + + public DlssSharpenPass(VulkanRenderer renderer, Device device) + { + _renderer = renderer; + + _pipeline = new PipelineHelperShader(renderer, device); + _pipeline.Initialize(); + + // Meme layout que la passe de nettete du FsrScalingFilter (bindings declares 2/3/4 ; + // seule la 4 est consommee par le shader RCAS). + ResourceLayout layout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 3) + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 4) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _sampler = renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); + + _program = renderer.CreateProgramWithMinimalLayout([ + new ShaderSource( + EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/DlssSharpenLinear.spv"), + ShaderStage.Compute, + TargetLanguage.Spirv) + ], layout); + } + + /// Nettete 1:1 de la sortie DLSS vers la texture temporaire ; rend la version + /// nette (ou null si la creation de la temporaire echoue — l'appelant blitte l'original). + public TextureView Run(TextureView input, CommandBufferScoped cbs) + { + if (_sharpened == null || + _sharpened.Width != input.Width || + _sharpened.Height != input.Height || + _sharpened.Info.Format != input.Info.Format) + { + _sharpened?.Dispose(); + _sharpened = _renderer.CreateTexture(input.Info) as TextureView; + } + + if (_sharpened == null) + { + return null; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS nettete: ARMEE (SHARPEN=1, niveau {Level}/100) - RCAS lineaire maison en pre-passe sur la sortie DLSS."); + } + + // Meme encodage de force que le slider FSR de l'UI. + ReadOnlySpan sharpening = [1.5f - (Level * 0.01f * 1.5f)]; + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, sharpening); + + int threadGroupWorkRegionDim = 16; + int dispatchX = (input.Width + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim; + int dispatchY = (input.Height + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim; + + _pipeline.SetCommandBuffer(cbs); + _pipeline.SetProgram(_program); + _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, input, _sampler); + _pipeline.SetUniformBuffers([new BufferAssignment(4, buffer.Range)]); + _pipeline.SetImage(ShaderStage.Compute, 0, _sharpened); + _pipeline.DispatchCompute(dispatchX, dispatchY, 1); + _pipeline.ComputeBarrier(); + + _pipeline.Finish(); + + return _sharpened; + } + + public void Dispose() + { + _pipeline?.Dispose(); + _program?.Dispose(); + _sampler?.Dispose(); + _sharpened?.Dispose(); + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 352e34b3a..92fbe4de7 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -111,6 +111,39 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private static readonly bool _mvppDepthSonde = Environment.GetEnvironmentVariable("RYUJINX_MVPP_DEPTHSONDE") == "1"; + // RYUJINX_MVPP_CHECKER=1 (28/07) : sonde lecture-seule de DISCONTINUITÉ DE CHEMIN. + // Hypothèse testée : le ciel/la brume lointaine de XC2 sont rendus en demi-résolution + // ALTERNÉE (damier). Le chemin de reprojection étant choisi par pixel sur un seuil de + // depth, un pixel sur deux du même nuage part dans l'autre famille de vecteurs, et le + // damier alternant d'une image à l'autre, un même point bascule -> « les nuages + // apparaissent et disparaissent » (Alex, 28/07, au loin, caméra en mouvement). + // Discriminant = chkDiag (orthogonaux opposés + diagonal concordant) : un bord d'objet + // ordinaire est une courbe et ne peut pas produire cette signature. Slots 233-236, + // réserve libre. Aucun paramètre ajouté au bloc d'uniformes (reste à 53/53). + private static readonly bool _mvppChecker = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_CHECKER") == "1"; + + // RYUJINX_MVPP_WARMUP= (28/07) : DELAI DE GRACE avant que la reprojection ecrive. + // + // Mesure du jour : les lignes noires autour des arbres apparaissent ou non SELON LA + // VITESSE DE CHARGEMENT. Meme code, trois lancements : au premier (chargement a froid, + // decor lent a arriver) AUCUNE ligne ; aux suivants (chargement a chaud) elles reviennent. + // Alex l'a note de lui-meme : « la fois ou ca a marche, les feuilles ne sont pas apparues + // tout de suite ». + // + // Lecture : quand le decor arrive TARD, MV++ a eu le temps de se stabiliser. Quand il + // arrive TOT, la passe ecrit ses tout premiers vecteurs -- les moins fiables, juste apres + // l'election -- pile pendant que le feuillage se dessine, et DLSS aligne son historique + // dessus. Le defaut n'est donc pas dans la logique mais dans le MOMENT. + // + // Ici on exige N images de paire de matrices valide AVANT de laisser la passe ecrire. Le + // comportement cesse de dependre de la vitesse du disque. 0 = desactive (comportement + // historique). 30 images ~ 1 seconde a 30 fps. + private static readonly int _mvppWarmup = + Math.Max(0, (int)ParseEnvFloat("RYUJINX_MVPP_WARMUP", 0f)); + + private int _mvppWarmupCount; + // RYUJINX_MVPP_DEPTHGUARD=1 (19/07) : garde-fou depth-sanity — décision d'Alex 18/07 // (« générique prudent », le 1 du plan 1-puis-2). Détection : en pan, un résidu // |mv point-based − rotation-pure| ÉGAL à toutes les profondeurs est non-physique @@ -174,6 +207,65 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // DLSS start (title appearance), 0/unset = capture immediately (today's behavior). private static readonly float _mvppDumpDelay = ParseEnvFloat("RYUJINX_MVPP_DUMP_DELAY", 0f); + // RYUJINX_MVPP_DUMP_INTERVAL_MS= (28/07) : espacement entre deux captures. Le défaut + // historique de 2000 ms convient pour un artefact PERSISTANT, mais rend structurellement + // invisible un défaut d'ALTERNANCE (« les nuages apparaissent et disparaissent ») : deux + // instantanés à 2 s d'intervalle ne peuvent pas montrer un basculement image par image. + // Mettre 0 capture des images CONSÉCUTIVES. Coût assumé : readback synchrone à chaque + // image = saccade franche pendant les 10 slots, et ~2 Go sur disque. Défaut inchangé. + private static readonly float _mvppDumpIntervalMs = ParseEnvFloat("RYUJINX_MVPP_DUMP_INTERVAL_MS", 2000f); + + // RYUJINX_MVPP_DUMP_BURST= (28/07) : taille d'une RAFALE d'images consécutives. + // Le déclenchement à l'horloge seule est trop fragile pour un défaut qui n'existe que + // pendant un geste : la capture du 28/07 16h16 a photographié une caméra POSÉE (image + // qui ne varie que de 1,7/255 entre deux prises, champ MV à 0,005 px = correct pour une + // caméra immobile) et n'a donc rien pu observer. Avec n>1, on capture n images d'affilée + // puis on attend INTERVAL_MS avant la rafale suivante : plusieurs fenêtres de tir au + // lieu d'une seule, sans perdre la consécutivité dont l'analyse d'alternance a besoin. + // 1 = comportement historique. + private static readonly int _mvppDumpBurst = + Math.Max(1, (int)ParseEnvFloat("RYUJINX_MVPP_DUMP_BURST", 1f)); + + // RYUJINX_MVPP_DUMP_ON_MOVE= (28/07) : ne DEMARRER une rafale que si la matrice camera + // a change de valeur sur les n derniers presents CONSECUTIFS. Supprime le probleme de + // timing : au lieu de tirer a l'aveugle en esperant que le joueur bouge, on tire quand il + // bouge REELLEMENT. Une fois la rafale commencee elle va jusqu'au bout (il faut des images + // consecutives, meme si le geste s'arrete au milieu). 0 = desactive (horloge seule). + private static readonly int _mvppDumpOnMove = + Math.Max(0, (int)ParseEnvFloat("RYUJINX_MVPP_DUMP_ON_MOVE", 0f)); + + // [HOLDCAP 31/07] RYUJINX_MVPP_DUMP_ON_HOLD= : ne tire une rafale QUE sur un figement + // de paire (hold de la file) precede d'au moins presents de mouvement continu. + // 0 = OFF (defaut) => le declencheur existant est bit-pour-bit inchange. + // Raison d'etre : DUMP_ON_MOVE exige _mvppMoveStreak >= n AU moment du tir, donc il ne + // peut PAS capturer un figement (la serie vient d'etre remise a zero). Verifie sur les + // 10 dumps du 30/07 : curr==prev nulle part, prev==prev2 nulle part -- 20 occasions, + // zero capture. Ce declencheur-ci vise exactement le trou. + private static readonly int _mvppDumpOnHold = + Math.Max(0, (int)ParseEnvFloat("RYUJINX_MVPP_DUMP_ON_HOLD", 0f)); + + // [INTERVALFIX 31/07] RYUJINX_MVPP_INTERVAL_FIX=1. OFF par defaut => _mvppIntervalFix reste + // faux, aucun saut de decalage n'a lieu, l'intervalle vaut 1 en permanence et le facteur + // envoye au shader vaut 1.0 : comportement bit-pour-bit celui d'avant. + // + // CE QU'IL CORRIGE, MESURE LE 31/07 SUR 4 EVENEMENTS CAPTURES. + // Quand la file de cameras est a sec, TryConsumeOrdered rend _fifoLast : la valeur qui + // arrive est IDENTIQUE a _mvppVpCurr. La laisser entrer fige la paire (curr == prev) donc + // la reprojection n'a aucun mouvement a decrire : + // image du figement + 1 : declare (+0.00, +0.00) sur 13 tuiles / 13, ecran a 19-56 px ; + // image suivante : declare x1,94 a x3,50 le mouvement reel (la paire couvre alors + // DEUX intervalles) ; + // image d'apres : juste a 1,02 px. + // Retenu -> lance au double -> remis en place = l'oscillation ("ca bouge un peu et ca + // revient"). Somme nette JUSTE sur deux images (0 + 2x = 2x), d'ou l'absence de derive. + // + // LE CORRECTIF EN DEUX MOITIES INDISSOCIABLES : sauter le decalage sur le doublon (la paire + // garde son intervalle precedent, non nul et plausible), puis diviser par l'ecart de + // tampons a l'image de rattrapage. Sauter SEUL = ELECTROT, rejete le 28/07 : le defaut est + // deplace, pas supprime. + private static readonly bool _mvppIntervalFix = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_INTERVAL_FIX") == "1"; + // See the field-side comment at _lastResetMs (zoom-at-startup dossier). private static readonly float _resetCooldownMs = ParseEnvFloat("RYUJINX_DLSS_RESET_COOLDOWN_MS", 0f); @@ -184,6 +276,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // filters the ramp train while keeping the real cut. Default 0.02 = stock behavior. private static readonly float _sceneCutMaxMotion = ParseEnvFloat("RYUJINX_DLSS_SCENECUT_MAX_MOTION", SceneCutMaxMotion); + // [CUTONJUMP 27/07] Last camera-warp sequence number seen. See the use site near the reset + // decision: a change means the camera source detected a warp since the previous frame. + private int _lastTeleportSeq; + // RYUJINX_DLSS_DEPTH=0: force the zeroed dummy depth (jitter-tremble discriminant, see the // depthSource selection). Unset/other = real depth pipeline unchanged (E9b default). private static readonly bool _forceDummyDepth = @@ -493,6 +589,34 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private float _skyDriftEmaX; private float _skyDriftEmaY; + // [SKYCALM 27/07] See the ingestion site: the drift estimate is only fed while the camera is + // calm, because a pan cannot inform it and can only poison it. + private static readonly bool _mvppSkyCalm = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SKYCALM") == "1"; + + private const float SkyCalmMaxPxPerFrame = 0.1f; + + // [SKYDEPTH 27/07] See the parameter site. Default 1.0 = historic behaviour. + private static readonly float _skyDepth = + ParseEnvFloat("RYUJINX_MVPP_SKYDEPTH", 1.0f); + + // [SKYGRID clamp 27/07] See the clamp site. Default keeps the historic 0.15 exactly, so + // without the variable nothing changes on any game. + private static readonly float _skyGridClamp = ParseEnvFloat("RYUJINX_MVPP_SKYGRID_CLAMP", 0.15f); + + // [SKYGRID pop 27/07] See the population gate. Default keeps the historic 200 exactly. + // [SKYBAND 27/07] See the band fallback. Default OFF: without it the behaviour is the + // historic one, zone estimate or global average, nothing in between. + private static readonly bool _skyBand = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_SKYBAND") == "1"; + + private readonly uint[] _bandCount = new uint[4]; + private readonly float[] _bandX = new float[4]; + private readonly float[] _bandY = new float[4]; + + private static readonly uint _skyGridMinSamples = + (uint)MathF.Max(1f, ParseEnvFloat("RYUJINX_MVPP_SKYGRID_MINSAMPLES", 200f)); + // RYUJINX_MVPP_SKYGRID=1 (18/07, SKYFLOW v2) : le dôme reçoit, EN PLUS de la dérive globale // SKYDRIFT, un RÉSIDU local par zone (grille 8x4, EMA par zone, interpolé bilinéairement // dans le shader = lisse par construction — le remède mesuré aux pointillés de SKYFLOW v1 @@ -648,6 +772,94 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private bool _mvppHasCurr; private bool _mvppHasPair; private bool _mvppHasPrev2; + + // [VPSTAMP 28/07] Numero de present auquel chaque matrice a ete ADOPTEE. Le decalage + // curr/prev n'a lieu que si une camera fraiche est disponible (PresentVpValid) : quand + // une image n'en a pas, la paire reste en place (le vecteur decrit l'intervalle + // PRECEDENT) et l'image suivante saute par-dessus (le vecteur CUMULE deux intervalles). + // Mesure 28/07 : image 5 = intervalle 3->4 a 0,59 px pres ; image 8 = cumul 6->8 a + // 1,13 px pres, contre 5,08 et 16,35 px sous l'hypothese d'un appariement correct. + // curr - prev DOIT valoir 1. Toute autre valeur est le defaut, lu directement. + private long _mvppVpCurrStamp; + private long _mvppVpPrevStamp; + private long _mvppVpPrev2Stamp; + private int _mvppNoCamPresents; // presents arrives SANS camera fraiche (la cause racine) + + // [MOVESTREAK 28/07] Nombre de presents CONSECUTIFS ou la matrice camera a change de + // valeur. Sert a declencher les dumps SUR LE MOUVEMENT au lieu d'une horloge : un defaut + // qui n'existe que pendant un geste ne se capture pas a l'aveugle (echec du 16h16, et + // 2 rafales sur 4 perdues au 16h23). Remis a zero des qu'un present ne bouge plus. + private int _mvppMoveStreak; + + // [HOLDCAP 31/07] INSTRUMENT SEUL -- aucune decision de rendu ne lit ces trois champs. + // Quand la file de cameras est a sec, TryConsumeOrdered rend _fifoLast : la paire se fige + // (curr == prev) et _mvppMoveStreak est ecrase par 0. On garde donc sa valeur d'AVANT le + // figement, plus la duree du figement en cours, pour pouvoir declencher un dump SUR + // l'evenement que DUMP_ON_MOVE exclut structurellement (il exige du mouvement AU moment + // du tir, or ici le mouvement vient de s'arreter). Mesure 31/07 : 1 a 3 presents figes + // sur 150 en regime ordinaire, rafales jusqu'a 45. + private int _mvppStreakBeforeFreeze; + private int _mvppHoldRun; + private bool _mvppFrozenPair; + + // [INTERVALFIX 31/07] Vrai quand le decalage de paire vient d'etre saute. Sert a n'en + // sauter QU'UN de suite : au 2e figement consecutif on reprend le comportement actuel, + // sinon un ecran de chargement (145 figements consecutifs mesures le 31/07) figerait la + // paire pendant des centaines d'images et la reprojection deviendrait geometriquement + // absurde. Remis a faux des qu'un decalage a lieu. + private bool _mvppShiftSkipped; + + // [HOLDCLASS 31/07] Profondeur de la file camera apres consommation, snapshot au present. + // Instrument seul : ecrit dans vp_NN.txt, lu par personne d'autre. + private int _mvppFifoDepth; + + // [ROTDELTA 28/07] echelle du changement de ROTATION par present (voir le calcul). + private float _mvppRotDeltaMax; + private double _mvppRotDeltaSum; + private int _mvppRotDeltaN; + + // [JUMPDIST 28/07] Distribution des sauts de rotation, et RECURRENCE de leur valeur. + // Motif : le 28/07 le ROT delta max valait 1,146910 puis 1,146910 puis 1,146947 sur des + // fenetres de 5 s DIFFERENTES -- la meme valeur au 5e chiffre. Un humain au stick ne + // produit jamais ca ; un saut vers un etat FIXE, si. Hypothese a trancher : une SECONDE + // matrice camera (autre passe de rendu franchissant la validation) s'intercale, et le + // vecteur calcule decrit alors l'ECART ENTRE DEUX CAMERAS au lieu d'un mouvement. + // deux cameras -> quelques valeurs distinctes, tres repetees (pic net) + // vrai geste -> valeurs toutes differentes (etalement continu) + // Lecture seule, aucun effet sur le rendu. + private readonly int[] _mvppJumpBins = new int[5]; // <0.01, <0.1, <0.5, <1.0, >=1.0 + private readonly float[] _mvppJumpVals = new float[8]; // valeurs distinctes des GROS sauts + private readonly int[] _mvppJumpHits = new int[8]; + private int _mvppJumpDistinct; + private int _mvppJumpOverflow; + + // [CAMSPLIT 28/07] Rejet de la SECONDE camera. Mesure du 28/07 16h55, XC2, camera POSEE + // (Alex : « ca le fait meme sans bouger la camera, a un certain angle ») : la distribution + // des sauts de rotation est BINAIRE -- 121-141 changements sous 0,01 (le jitter du jeu) et + // 10 a 26 sauts valant EXACTEMENT 1,13893, avec RIEN entre 0,1 et 1,0 sur 7 fenetres de + // 5 s. Une valeur unique repetee = deux etats FIXES, pas un geste. Environ une image sur + // sept recevait donc un vecteur decrivant l'ECART ENTRE DEUX CAMERAS au lieu d'un + // mouvement (mesure : 14 px horizontaux declares sur un deplacement purement vertical). + // + // Le seuil vit dans un VIDE mesure : legitime < 0,1, intrus > 1,0, marge x5 des deux cotes. + // + // ⚠️ Pourquoi ce n'est pas CAMGUARD (rejete le 27/07 pour avoir fige la camera a 91-98 % + // de rejets) : celui-ci filtre sur une frontiere MESUREE avec du vide de part et d'autre, + // et surtout il ne peut PAS geler -- au-dela de CamSplitMaxStreak rejets consecutifs, la + // matrice est adoptee de force. Une vraie coupure de scene passe donc toujours, avec au + // pire trois images de retard, au lieu d'un blocage permanent. + // Plafond de rejets CONSECUTIFS avant adoption forcee. Regle a 3 au premier essai + // (28/07 17h04) : le filtre a bien mordu (7-22 rejets / 5 s, verdict d'Alex « ca ne le + // fait plus comme tantot ») mais il restait 1 a 4 adoptions forcees par fenetre, chacune + // = une image polluee. La seconde camera s'installe donc parfois plus de 3 images + // d'affilee. On MESURE la longueur des salves (`streakMax`) avant de choisir le plafond. + private static readonly int _mvppCamSplitStreak = + Math.Max(1, (int)ParseEnvFloat("RYUJINX_MVPP_CAMSPLIT_STREAK", 3f)); + private static readonly float _mvppCamSplit = ParseEnvFloat("RYUJINX_MVPP_CAMSPLIT", 0f); + private int _mvppCamRejectStreak; + private int _mvppCamRejected; + private int _mvppCamForced; + private int _mvppCamStreakMax; // plus longue salve consecutive vue sur la fenetre de log // [1.2.3 bavard] throttle de la ligne « MV++ en pause » (le silence qui a coûté le // dossier nuages 4K : mode armé, passe jamais lancée, zéro trace au log). private long _mvppPauseLogMs; @@ -693,8 +905,31 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private readonly Device _device; private DlssGpuTimer _gpuTimer; private DlssGpuTimer _reprojTimer; // [18/07] chrono par-passe (reproj seule), label MVPP-REPROJ + private DlssSharpenPass _sharpenPass; // [SHARPEN 02/08] nettete RCAS maison, gate OFF par defaut private TextureView _output; + + // [DUPSKIP 02/08] Anti-doublons de presentation (journal (348)-(350)) : quand le jeu + // re-presente sans avoir rendu (BOTW ~30 vraies images/s en rotation camera, presentees + // ~60), ne PAS ré-accumuler le temporel sur une image identique a camera figee — re- + // blitter la sortie precedente (_output persiste entre presents). Producteur du signal : + // MvppCameraCapture -> GAL.DlssCameraState.RenderedFrameSeq. Ferme = zero changement. + private static readonly bool _dupSkipEnabled = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_DUPSKIP") == "1"; + + private long _lastRenderedSeq = -1; + private int _dupSkips; + private int _dupEvals; + private long _dupLogMs; + private bool _dupArmedLogged; + + // [DUPSKIP v2] Plafond de sauts CONSECUTIFS — trou v1 attrape par Alex a la premiere + // minute (ecran fige en menu : un etat sans dessins 3D immobilise la sequence, et v1 + // re-presentait l'ancienne image pour toujours pendant que le jeu tournait dessous). + // Au-dela du plafond on force une vraie evaluation : au pire on retrouve le comportement + // d'aujourd'hui (accumuler sur un doublon), jamais un gel. 8 presents ~ 130 ms a 60 Hz. + private const int DupSkipMaxConsecutive = 8; + private int _dupConsecutive; private TextureView _depth; private TextureView _motion; private TextureView _motionFiltered; @@ -954,14 +1189,92 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // here through the frame queue; keep the previous one (the reprojection pair the MV // compute pass will consume) and heartbeat so the end-to-end chain is verifiable in // the log. Inert unless the capture is enabled (RYUJINX_MVPP_CAP=1). - if (DlssCameraState.PresentVpValid) + // [CAMSPLIT] Une matrice qui saute en ROTATION au-dela du seuil n'est pas la camera du + // jeu qui a bouge : c'est une AUTRE camera (voir le champ). On ne l'adopte pas -- sauf + // apres CamSplitMaxStreak rejets d'affilee, ou c'est la scene qui a reellement change. + bool camSplitReject = false; + if (_mvppCamSplit > 0f && _mvppHasCurr && DlssCameraState.PresentVpValid) { - _mvppVpPrev2 = _mvppVpPrev; - _mvppHasPrev2 = _mvppHasPair; - _mvppVpPrev = _mvppVpCurr; - _mvppHasPair = _mvppHasCurr; - _mvppVpCurr = DlssCameraState.PresentVp; - _mvppHasCurr = true; + if (RotJump(in _mvppVpCurr, DlssCameraState.PresentVp) >= _mvppCamSplit) + { + if (_mvppCamRejectStreak < _mvppCamSplitStreak) + { + camSplitReject = true; + _mvppCamRejectStreak++; + _mvppCamRejected++; + _mvppCamStreakMax = Math.Max(_mvppCamStreakMax, _mvppCamRejectStreak); + } + else + { + _mvppCamRejectStreak = 0; + _mvppCamForced++; + } + } + else + { + _mvppCamRejectStreak = 0; + } + } + + // [HOLDCLASS 31/07] INSTRUMENT SEUL, aucune decision ne le lit. Profondeur de la file + // ordonnee APRES la consommation de ce present, deduite des compteurs deja publics de + // DlssCameraState : chaque push ajoute un element, chaque consommation FRAICHE en + // retire un, chaque drop aussi. Aucune modification de la GAL n'est donc necessaire. + // + // CE QU'ON CHERCHE A TRANCHER. Un ecart de presents de 2 ne dit PAS que le vecteur + // couvre deux intervalles : mesure du 31/07 sur 6 evenements, 2 seulement doublaient + // vraiment (rapports 1,86 / 1,97 / 2,19) contre 0,94 / 0,98 / 1,00 pour les autres. + // Hypothese a prouver : si le jeu a produit une camera pendant le present tenu mais en + // RETARD, elle attend encore dans la file au rattrapage (profondeur >= 1 apres + // consommation) ; s'il n'a rien produit, la file est vide (0). + // + // ⚠️ LIMITE CONNUE, a dire avant de lire un chiffre : la consommation a lieu sur le fil + // de presentation (Gpu/Window.cs) et cette lecture a lieu ici, un peu plus tard. Un + // push arrive entre les deux serait compte. C'est une course, non eliminee -- elle + // s'evaluera sur la distribution, pas sur un evenement isole. + // ⚠️ Valide UNIQUEMENT si RYUJINX_MVPP_DEV est ETEINT : le bloc de log de MvppDev remet + // ces compteurs a zero toutes les 5 s, ce qui casserait la difference. + _mvppFifoDepth = GAL.DlssCameraState.StatPushes + - GAL.DlssCameraState.StatFresh + - GAL.DlssCameraState.StatDrops; + + // [INTERVALFIX 31/07] Le doublon se lit sur la valeur QUI ARRIVE, avant tout decalage : + // c'est la seule lecture valide dans les deux modes. Correctif ETEINT, incomingDup est + // exactement l'ancienne condition post-decalage (_mvppHasPair && prev.Equals(curr)), + // puisque le decalage fait glisser _mvppVpCurr vers _mvppVpPrev et _mvppHasCurr vers + // _mvppHasPair. Rien ne change donc quand la variable est absente. + bool incomingDup = _mvppHasCurr + && DlssCameraState.PresentVpValid + && DlssCameraState.PresentVp.Equals(_mvppVpCurr); + + bool intervalSkip = _mvppIntervalFix && incomingDup && !_mvppShiftSkipped; + + if (DlssCameraState.PresentVpValid && !camSplitReject) + { + if (intervalSkip) + { + // Paire ET tampons inchanges : le vecteur de cette image decrit l'intervalle + // PRECEDENT (non nul, plausible) au lieu de zero. L'ecart de tampons passera a + // 2 a l'image de rattrapage, ou le shader divisera par 2. Les deux moities ne + // valent que ensemble : sauter seul = ELECTROT (defaut deplace, pas supprime). + _mvppShiftSkipped = true; + } + else + { + _mvppVpPrev2 = _mvppVpPrev; + _mvppHasPrev2 = _mvppHasPair; + _mvppVpPrev = _mvppVpCurr; + _mvppHasPair = _mvppHasCurr; + _mvppVpCurr = DlssCameraState.PresentVp; + _mvppHasCurr = true; + + // [VPSTAMP] horodatage de l'adoption, en numeros de present (voir le champ). + _mvppVpPrev2Stamp = _mvppVpPrevStamp; + _mvppVpPrevStamp = _mvppVpCurrStamp; + _mvppVpCurrStamp = _presentSerial; + + _mvppShiftSkipped = false; + } // Change-rate + alternation counters: with the camera untouched a healthy stream // delivers the SAME VP every present (changed ~0/N). changed~N with a high @@ -969,7 +1282,42 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // cameras interleaving (a second scaled pass passing validation, e.g. env-cubemap // faces); changed~N with ~0 alternations = the game's camera really micro-moves. _mvppPresents++; - if (_mvppHasPair && !_mvppVpPrev.Equals(_mvppVpCurr)) + + // [HOLDCAP 31/07] Comptabilite du figement, INSTRUMENT SEUL. La ligne + // _mvppMoveStreak plus bas est STRICTEMENT equivalente a l'ancienne (meme + // condition, meme resultat) : mvppFreshPair EST l'ancienne condition, extraite + // dans une variable pour pouvoir la lire deux fois sans la recalculer. + // [INTERVALFIX] Reformule sur incomingDup : STRICTEMENT equivalent a l'ancienne + // expression post-decalage quand le correctif est eteint, et seule version juste + // quand il est arme (la paire n'a alors pas bouge, donc la comparer ne dirait plus + // rien du flux camera). Garde l'instrument HOLDCAP vivant dans les deux modes -- + // sans ca, DUMP_ON_HOLD ne se declencherait plus et le test de validation ne + // capturerait rien. + bool mvppFreshPair = _mvppHasPair && !incomingDup; + _mvppFrozenPair = incomingDup; + + if (_mvppFrozenPair) + { + // Au PREMIER present fige seulement : la serie d'avant n'est plus lisible + // ensuite (elle vaut 0 des le present suivant). + if (_mvppHoldRun == 0) + { + _mvppStreakBeforeFreeze = _mvppMoveStreak; + } + + _mvppHoldRun++; + } + else + { + _mvppHoldRun = 0; + } + + _mvppMoveStreak = mvppFreshPair ? _mvppMoveStreak + 1 : 0; + + // [INTERVALFIX] Meme reformulation, meme raison : identique correctif eteint, et + // n'invente pas un "changement" sur une image ou la paire a deliberement ete + // laissee en place. + if (mvppFreshPair) { _mvppChanged++; @@ -997,6 +1345,58 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } } + // [ROTDELTA 28/07] Delta sur les seuls termes de ROTATION (bloc 3x3 haut + // gauche). Mesure 28/07 : la matrice change a presque chaque present alors + // que l'image ne bouge PAS d'un pixel -- c'est le jitter sous-pixel du jeu + // (8 phases, +-0,44 px), qui vit dans la projection/translation. La rotation, + // elle, ne bouge que quand le joueur tourne VRAIMENT la camera. On mesure + // l'echelle des deux regimes avant de choisir un seuil : sonde, puis bouton. + float rot = 0f; + for (int r = 0; r < 3; r++) + { + for (int c = 0; c < 3; c++) + { + rot = Math.Max(rot, Math.Abs(cur[r * 4 + c] - prv[r * 4 + c])); + } + } + + _mvppRotDeltaMax = Math.Max(_mvppRotDeltaMax, rot); + _mvppRotDeltaSum += rot; + _mvppRotDeltaN++; + + // [JUMPDIST] repartition, puis recurrence des GROS sauts (voir le champ). + _mvppJumpBins[rot < 0.01f ? 0 : rot < 0.1f ? 1 : rot < 0.5f ? 2 : rot < 1.0f ? 3 : 4]++; + + if (rot >= 0.5f) + { + bool matched = false; + for (int k = 0; k < _mvppJumpDistinct; k++) + { + // Tolerance 0,002 : bien au-dessus du bruit float32, bien en dessous + // de l'ecart entre deux sauts physiquement differents. + if (Math.Abs(_mvppJumpVals[k] - rot) < 0.002f) + { + _mvppJumpHits[k]++; + matched = true; + break; + } + } + + if (!matched) + { + if (_mvppJumpDistinct < _mvppJumpVals.Length) + { + _mvppJumpVals[_mvppJumpDistinct] = rot; + _mvppJumpHits[_mvppJumpDistinct] = 1; + _mvppJumpDistinct++; + } + else + { + _mvppJumpOverflow++; + } + } + } + // RYUJINX_MVPP_TRACE=1: raw translation-column series, one line per changed // present. The SHAPE of the series is the judge the aggregates cannot be: // a smooth drift = one real camera moving; two interleaved slowly-evolving @@ -1014,14 +1414,58 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _mvppHeartbeatMs = nowMs; Logger.Info?.Print(LogClass.Gpu, $"MVPP consumer: camera VP changed on {_mvppChanged}/{_mvppPresents} presents, A/B/A/B alternations {_mvppAlternated}, " + + $"NO-CAM {_mvppNoCamPresents} presents (paire figee -> vecteur en retard ou cumule), " + + $"paire actuelle curr@{_mvppVpCurrStamp} prev@{_mvppVpPrevStamp} (ecart {_mvppVpCurrStamp - _mvppVpPrevStamp}, doit valoir 1), " + + $"ROT delta max={_mvppRotDeltaMax:0.######} moy={(_mvppRotDeltaN > 0 ? _mvppRotDeltaSum / _mvppRotDeltaN : 0):0.######} sur {_mvppRotDeltaN} changements, " + $"max element delta {_mvppDeltaMax:0.######} at m[{_mvppDeltaIdx / 4}][{_mvppDeltaIdx % 4}] (row-major)."); + // [JUMPDIST] repartition + les valeurs de GROS saut et leur nombre de repetitions. + // Peu de valeurs tres repetees = etats FIXES = deuxieme camera. Beaucoup de + // valeurs a 1 occurrence = vrai geste continu. + var top = new System.Text.StringBuilder(); + for (int k = 0; k < _mvppJumpDistinct; k++) + { + top.Append($"{_mvppJumpVals[k]:0.#####}x{_mvppJumpHits[k]} "); + } + + Logger.Info?.Print(LogClass.Gpu, + $"MVPP JUMPDIST: bins <0.01={_mvppJumpBins[0]} <0.1={_mvppJumpBins[1]} <0.5={_mvppJumpBins[2]} " + + $"<1.0={_mvppJumpBins[3]} >=1.0={_mvppJumpBins[4]} | gros sauts distincts={_mvppJumpDistinct}" + + (_mvppJumpOverflow > 0 ? $" (+{_mvppJumpOverflow} hors table)" : "") + + $" : {(top.Length > 0 ? top.ToString().TrimEnd() : "aucun")}" + + (_mvppCamSplit > 0f + ? $" || CAMSPLIT seuil={_mvppCamSplit:0.###} plafond={_mvppCamSplitStreak} rejets={_mvppCamRejected} " + + $"adoptions-forcees={_mvppCamForced} salve-max={_mvppCamStreakMax}" + : "")); + + _mvppCamRejected = 0; + _mvppCamForced = 0; + _mvppCamStreakMax = 0; + + Array.Clear(_mvppJumpBins); + Array.Clear(_mvppJumpVals); + Array.Clear(_mvppJumpHits); + _mvppJumpDistinct = 0; + _mvppJumpOverflow = 0; _mvppChanged = 0; + _mvppNoCamPresents = 0; + _mvppRotDeltaMax = 0f; + _mvppRotDeltaSum = 0; + _mvppRotDeltaN = 0; _mvppPresents = 0; _mvppAlternated = 0; _mvppDeltaMax = 0f; _mvppDeltaIdx = 0; } } + else + { + // [VPSTAMP 28/07] Present arrive SANS camera fraiche : la paire curr/prev ne + // bouge pas, alors que l'image, elle, avance. La passe de reprojection decrira + // donc l'intervalle PRECEDENT (retard), et au present suivant la paire sautera + // par-dessus l'image manquee (vecteur CUMULE sur deux intervalles). C'est le + // mecanisme candidat des vecteurs faux mesures le 28/07 sur le ciel. + _mvppNoCamPresents++; + } if (input.Width == 0 || input.Height == 0) { @@ -1340,6 +1784,67 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } } + // [DUPSKIP 02/08] Presentation-doublon : la sequence des vraies images rendues n'a pas + // bouge depuis le dernier Evaluate -> re-blit de la sortie precedente, telle quelle, + // sans toucher au temporel ni aux ressources. Garde-fous : seq 0 = producteur absent + // (inerte, anti-gel) ; _hasPrev et une sortie a la BONNE taille exiges. En 60 fps + // plein (une vraie image par present), la sequence bouge a chaque present et ce + // chemin ne se declenche JAMAIS — garde-fou n1 du banc, compteur a l'appui. + if (_dupSkipEnabled) + { + if (!_dupArmedLogged) + { + _dupArmedLogged = true; + Logger.Info?.Print(LogClass.Gpu, "DLSS: anti-doublons ARME (DUPSKIP=1)."); + } + + long dupSeq = System.Threading.Interlocked.Read(ref GAL.DlssCameraState.RenderedFrameSeq); + + if (dupSeq != 0 && _hasPrev && _output != null && + _output.Width == dlssOutW && _output.Height == dlssOutH && + dupSeq == _lastRenderedSeq && + _dupConsecutive < DupSkipMaxConsecutive) + { + _dupSkips++; + _dupConsecutive++; + + long nowDup = Environment.TickCount64; + + if (nowDup - _dupLogMs >= 5000) + { + _dupLogMs = nowDup; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS anti-doublons: {_dupSkips} sauts / {_dupEvals} evaluates (fenetre ~5 s)."); + _dupSkips = 0; + _dupEvals = 0; + } + + _gd.HelperShader.BlitColor( + _gd, + cbs, + _output, + dst, + new Extents2D(0, 0, _output.Width, _output.Height), + dstRegion, + true, + true, + hdr, + paperWhite, + peak, + curve, + gamma, + blend, + whiten, + pqOutput); + + return true; + } + + _lastRenderedSeq = dupSeq; + _dupConsecutive = 0; + _dupEvals++; + } + EnsureResources(input, dlssOutW, dlssOutH, cbs); if (!StreamlineDlss.SetOptions(ViewportId, mode, (uint)dlssOutW, (uint)dlssOutH, hdr)) @@ -1434,10 +1939,17 @@ namespace Ryujinx.Graphics.Vulkan.Dlss sceneCut = false; } - bool reset = !_hasPrev || sceneCut || bReset; // [B étape 1] bReset forces a clear on B (re)entry / native-res change + // [CUTONJUMP 27/07] The camera source signals a warp the moment it reads a displacement + // no continuous movement can produce (zone load, teleport, a cutscene taking the view). + // The history describes another place, so it is dropped for this frame. Inert unless + // the source's own gate is armed: without it the sequence never moves. + bool camWarp = _hasPrev && GAL.DlssCameraState.TeleportSeq != _lastTeleportSeq; + _lastTeleportSeq = GAL.DlssCameraState.TeleportSeq; + + bool reset = !_hasPrev || sceneCut || camWarp || bReset; // [B étape 1] bReset forces a clear on B (re)entry / native-res change // [B étape 2] resolve WHY we reset, for the ALIGN1 log. A dyn-res dip must NOT appear here as a // reset cause (it stays in B via the native lock) -- that is exactly the flash we removed. - string resetReason = !reset ? "none" : (!_hasPrev ? "first-frame" : sceneCut ? "scene-cut" : bResetReason); + string resetReason = !reset ? "none" : (!_hasPrev ? "first-frame" : sceneCut ? "scene-cut" : camWarp ? "camera-warp" : bResetReason); if (reset) { _framesSinceReset = 0; @@ -1620,7 +2132,30 @@ namespace Ryujinx.Graphics.Vulkan.Dlss bool mvppRan = false; bool mvppPairOk = _mvppVpDelay ? _mvppHasPrev2 : _mvppHasPair; - if (_mvppReprojMode > 0 && mvppPairOk && mvppDepth != null && _hasPrev) + + // [WARMUP] Compte les images consecutives de paire valide ; toute perte remet a zero, + // donc un rechargement ou un changement de scene redemande le delai complet. + if (mvppPairOk) + { + if (_mvppWarmupCount < int.MaxValue) + { + _mvppWarmupCount++; + } + } + else + { + _mvppWarmupCount = 0; + } + + bool warmedUp = _mvppWarmup == 0 || _mvppWarmupCount >= _mvppWarmup; + + if (_mvppWarmup > 0 && mvppPairOk && _mvppWarmupCount == _mvppWarmup) + { + Logger.Info?.Print(LogClass.Gpu, + $"MVPP WARMUP: {_mvppWarmup} images de camera stable ecoulees, la reprojection commence a ecrire."); + } + + if (_mvppReprojMode > 0 && mvppPairOk && warmedUp && mvppDepth != null && _hasPrev) { // Double-buffered depth snapshots for the dynamic mask: the pass reads LAST // frame's and writes THIS frame's on the input grid. (A CmdCopyImage of the @@ -1681,9 +2216,52 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // every input at those same coords. if (_mvppDump && mvppRan && _mvppDumpCount < 10 && Environment.TickCount64 >= _mvppDumpNotBeforeMs && - Environment.TickCount64 - _mvppDumpLastMs >= 2000) + Environment.TickCount64 - _mvppDumpLastMs >= + (_mvppDumpCount % _mvppDumpBurst != 0 ? 0L : (long)_mvppDumpIntervalMs) && + // [MOVESTREAK] garde de mouvement : ne s'applique qu'au DEBUT d'une rafale. + // [HOLDCAP 31/07] Si DUMP_ON_HOLD est arme (> 0), il REMPLACE la garde de + // mouvement au debut de rafale : on tire sur le figement lui-meme, au PREMIER + // present fige (_mvppHoldRun == 1), et seulement s'il etait precede d'au moins + // presents de mouvement continu. La suite de la rafale (N+1, N+2) reste + // libre, exactement comme avec DUMP_ON_MOVE. + // A 0 (defaut) l'expression se reduit a (_mvppDumpOnMove == 0 || + // _mvppMoveStreak >= _mvppDumpOnMove) : comportement d'origine inchange. + (_mvppDumpCount % _mvppDumpBurst != 0 + || (_mvppDumpOnHold > 0 + ? (_mvppFrozenPair && _mvppHoldRun == 1 && + _mvppStreakBeforeFreeze >= _mvppDumpOnHold) + : (_mvppDumpOnMove == 0 || _mvppMoveStreak >= _mvppDumpOnMove)))) { _mvppDumpLastMs = Environment.TickCount64; + + // [VPSTAMP 28/07] A cote de chaque image dumpee, l'identite EXACTE de la paire de + // matrices qui a servi a la calculer. L'ecart curr-prev doit valoir 1 ; 2 ou plus + // = le vecteur couvre plusieurs intervalles, ce qui se lit directement dans le MV. + // Ecrit AVANT les champs pour que le numero de slot corresponde. + try + { + string mdir = System.IO.Path.Combine(AppContext.BaseDirectory, "mvpp-dump"); + System.IO.Directory.CreateDirectory(mdir); + System.IO.File.WriteAllText( + System.IO.Path.Combine(mdir, $"vp_{_mvppDumpCount:00}.txt"), + $"present={_presentSerial}\ncurrStamp={_mvppVpCurrStamp}\nprevStamp={_mvppVpPrevStamp}\n" + + $"prev2Stamp={_mvppVpPrev2Stamp}\necart={_mvppVpCurrStamp - _mvppVpPrevStamp}\n" + + $"vpDelay={(_mvppVpDelay ? 1 : 0)}\nnoCamSinceLastLog={_mvppNoCamPresents}\n" + + // [HOLDCAP 31/07] L'etat de figement de CETTE image, pour que l'analyse + // hors ligne n'ait pas a le redeviner en comparant les matrices. + $"frozenPair={(_mvppFrozenPair ? 1 : 0)}\nholdRun={_mvppHoldRun}\n" + + $"streakBeforeFreeze={_mvppStreakBeforeFreeze}\nmoveStreak={_mvppMoveStreak}\n" + + // [HOLDCLASS 31/07] le classifieur candidat, voir le champ. + $"fifoDepth={_mvppFifoDepth}\nfifoPushes={GAL.DlssCameraState.StatPushes}\n" + + $"fifoFresh={GAL.DlssCameraState.StatFresh}\nfifoHolds={GAL.DlssCameraState.StatHolds}\n" + + $"fifoDrops={GAL.DlssCameraState.StatDrops}\n" + + $"vpCurr={_mvppVpCurr}\nvpPrev={_mvppVpPrev}\nvpPrev2={_mvppVpPrev2}\n"); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Gpu, $"MVPP vpstamp dump FAILED: {ex.Message}"); + } + DumpField(_mvppMotion, "mv"); if (mvppDepth != null) { @@ -1695,6 +2273,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } DumpField(input, "in"); // v3: the COLOR INPUT -- the seams arrive in it (scale-1x proof) + // [HOLDCAP 31/07] L'image PRECEDENTE. Sans elle on ne mesure que la MOITIE du + // mecanisme : le depassement a l'image N+1 se lit avec in_N/in_N+1, mais la + // RETENUE a l'image du figement exige l'image d'avant, qui n'est dans aucun slot. + if (_prevColor != null) + { + DumpField(_prevColor, "prev"); + } + // [DYNSONDE étape 2] la carte par-pixel du masque dynamique (R8, 1=dynPixel) : // c'est ELLE qui donne tailles et formes des objets auto-mobiles (les zones // 8x4 du log ne donnent que la répartition grossière). @@ -1851,12 +2437,31 @@ namespace Ryujinx.Graphics.Vulkan.Dlss fgMvTex = Describe(_mvppMotionRaw, cbs); } + // [HUDLESSFEED 28/07] L'image SANS INTERFACE, publiee par la couche Gpu via le pont + // MvppColorSnapshot. C'est ce qui manque a dlfg depuis le debut : sans elle il + // derive UI = backbuffer - hudless = 0, conclut qu'il n'y a pas d'interface, et + // deplace le HUD comme du decor -- le HUD dedouble mesure sur les captures d'Alex + // (losange et « 129 » en double, decales de ~110 px). + // + // Publiee a la resolution NATIVE du jeu (1920x1080) alors que le backbuffer est + // upscale : Streamline a deja rejete un HUD-less de taille differente une fois + // (« HUD-less buffer extent does not match color buffer size »). On tague quand + // meme et on LIT LE VERDICT au lieu de le supposer -- si le rejet revient, il + // faudra redimensionner avant de taguer, et on le saura par la mesure. + StreamlineDlss.DlssTexture hudlessTex = default; + + if (GAL.MvppColorSnapshot.SceneColorHost is TextureView hudlessView) + { + hudlessTex = Describe(hudlessView, cbs); + } + StreamlineFrameGen.OnFrameEvaluated( (IntPtr)cbs.CommandBuffer.Handle, ViewportId, StreamlineDlss.LastFrameToken, in depthTex, - in fgMvTex); + in fgMvTex, + in hudlessTex); } _gpuTimer?.End(cbs.CommandBuffer); @@ -1866,14 +2471,24 @@ namespace Ryujinx.Graphics.Vulkan.Dlss BarrierOutput(cbs); } + // [SHARPEN 02/08] Nettete RCAS maison en pre-passe (gate OFF par defaut) : le blit + // final consomme la version nette, tout le reste (tonemap, flip, barres) inchange. + TextureView presentSrc = _output; + + if (DlssSharpenPass.Enabled) + { + _sharpenPass ??= new DlssSharpenPass(_gd, _device); + presentSrc = _sharpenPass.Run(_output, cbs) ?? _output; + } + // DLSS output is already at the final resolution; blit it to the swapchain applying the // same scRGB/HDR tone-map as the normal present path. _gd.HelperShader.BlitColor( _gd, cbs, - _output, + presentSrc, dst, - new Extents2D(0, 0, _output.Width, _output.Height), + new Extents2D(0, 0, presentSrc.Width, presentSrc.Height), dstRegion, true, true, @@ -2048,7 +2663,35 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // [PROJQUIET v4] pas d'ingestion pendant la fenêtre non plus : la somme lue peut // contenir des frames du rideau (mesuré 18/07 : EMA 0.46 = 30x le vrai signal, // injecté au dôme plusieurs secondes après l'entrée en jeu). - if (_mvppSkyDrift && _framesSinceReset >= _mvppSkyCutCooldown && _projQuietFrames == 0) + // [SKYCALM 27/07] Ingest only when the measurement is trustworthy, i.e. when the + // camera is calm. RYUJINX_MVPP_SKYCALM=1, OFF by default. + // + // WHY. Measured on Alex's XC2 run: driftEMA = 0.161 DURING pans against 0.040 at + // rest, and after he stops the camera it takes ~6 seconds to come down + // (117 -> 80 -> 55 -> 44 -> 32). An EMA charged by the pans keeps injecting + // movement into an image that has become still - the "elastic sky" of 18/07. + // Note the mechanism is the OPPOSITE of what was assumed back then (it was + // believed to melt during pans and swell at rest). + // + // The value we want is the sky's OWN drift - the cloud sea moving 4 to 10 px per + // MINUTE, i.e. a hair per frame. During a pan the same measurement also carries + // whatever the camera compensation left over, which is orders of magnitude + // larger. So a pan cannot inform this estimate; it can only poison it. + // + // Threshold in PIXELS PER FRAME, deliberately: it is a screen-space quantity, so + // unlike a world-unit constant it means the same thing in every game. 0.1 px per + // frame is 6 px per second - already far above the real drift, and far below the + // 0.161 measured mid-pan. + // + // WORST CASE IS THE CURRENT BEHAVIOUR. If the camera never goes calm the EMA + // simply stops updating and holds its last calm value; it never runs away. That + // is why this is safe where a motion threshold was not: refusing to ingest costs + // nothing, whereas refusing to reproject cost two regressions this month. + bool calm = !_mvppSkyCalm || + (MathF.Abs(meanFX) <= SkyCalmMaxPxPerFrame && + MathF.Abs(meanFY) <= SkyCalmMaxPxPerFrame); + + if (_mvppSkyDrift && calm && _framesSinceReset >= _mvppSkyCutCooldown && _projQuietFrames == 0) { _skyDriftEmaX += 0.3f * (meanFX - _skyDriftEmaX); _skyDriftEmaY += 0.3f * (meanFY - _skyDriftEmaY); @@ -2060,14 +2703,81 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // son EMA décroît vers 0 => elle retombe sur la dérive globale seule. if (_mvppSkyGrid) { + // [SKYBAND 27/07] Fallback by BAND before falling back to the whole sky. + // + // WHAT THE LOG SAID. Every single tick of Alex's run reads + // "y0=(...) y1=(...) y2=(...) y3=(-)": the fourth band NEVER has an + // estimate. That band is the horizon - where his distant clouds are, and + // the only place he still sees ghosting. Its individual zones each hold + // too little sky to clear the quota (the horizon is largely hidden by + // terrain), so every one of them falls back on the average of the WHOLE + // sky, which is dominated by the wide-open zones overhead. A distant + // cloud is then described by the motion of a nearby one. + // + // WHY NOT JUST LOWER THE QUOTA - we tried, and Alex's eye rejected it: + // at 40 samples the starved zones produce noise, and the noise costs + // more than the ghosting it fixes. The quota is right; the FALLBACK was + // wrong. Eight zones that are individually too thin are, together, a + // perfectly populated band - same measurement, eight times the samples, + // and still far closer to those clouds than a global average. + // + // Costs one pass over 32 entries per tick (1 Hz) and nothing per frame. + if (_skyBand) + { + for (int b = 0; b < 4; b++) + { + uint bc = 0; + long bx = 0, by = 0; + + for (int k = 0; k < 8; k++) + { + int idx = b * 8 + k; + bc += s[92 + idx]; + bx += unchecked((int)s[28 + idx]); + by += unchecked((int)s[60 + idx]); + } + + _bandCount[b] = bc; + _bandX[b] = bc > 0 ? bx / 16f / bc - meanFX : 0f; + _bandY[b] = bc > 0 ? by / 16f / bc - meanFY : 0f; + } + } + for (int i = 0; i < 32; i++) { uint zc = s[92 + i]; float tx = 0f, ty = 0f; - if (zc >= 200) + // [SKYGRID pop 27/07] 200 samples was the bar for a zone to earn its + // own estimate. Measured on Alex's XC2: "zones=16/32" in most ticks, + // so HALF the sky gets no fine correction at all and falls back on + // the single global drift - and the zones that starve are exactly + // the ones holding small, distant clouds, which is where he still + // sees ghosting. The bar protects against a noisy estimate built on + // a handful of pixels; it is the VALUE that was never measured here. + // Exposed so it can be, default unchanged. + if (zc >= _skyGridMinSamples) { - tx = Math.Clamp(unchecked((int)s[28 + i]) / 16f / zc - meanFX, -0.15f, 0.15f); - ty = Math.Clamp(unchecked((int)s[60 + i]) / 16f / zc - meanFY, -0.15f, 0.15f); + // [SKYGRID clamp 27/07] The 0.15 bound was set on Zelda, where the + // per-zone residuals measured <= 0.05 - a 3x margin there. On + // Alex's XC2 the dispersion between zones measures 0.40 in X and + // 0.60 in Y (37 samples, median 0.17/0.29), so the correction was + // being clipped to a third of what the probe had just measured: + // the residual is seen, then thrown away, and what remains of it + // is exactly the ghosting he still sees on the far clouds. + // + // The bound exists so a bird or an effect crossing one zone + // cannot poison that zone's estimate, so it must stay - it is + // only its VALUE that was borrowed from another game. Exposed as + // a variable to be measured here before it is made automatic. + tx = Math.Clamp(unchecked((int)s[28 + i]) / 16f / zc - meanFX, -_skyGridClamp, _skyGridClamp); + ty = Math.Clamp(unchecked((int)s[60 + i]) / 16f / zc - meanFY, -_skyGridClamp, _skyGridClamp); + } + else if (_skyBand && _bandCount[i / 8] >= _skyGridMinSamples) + { + // Too thin alone, but its band has plenty. Same clamp: a band + // estimate is an estimate like any other and gets no privilege. + tx = Math.Clamp(_bandX[i / 8], -_skyGridClamp, _skyGridClamp); + ty = Math.Clamp(_bandY[i / 8], -_skyGridClamp, _skyGridClamp); } _zoneDriftEmaX[i] += 0.25f * (tx - _zoneDriftEmaX[i]); @@ -2193,6 +2903,21 @@ namespace Ryujinx.Graphics.Vulkan.Dlss $"mid={(s[0] > 0 ? 100f * midN / s[0] : 0f):0.0}% midRes={(midN > 0 ? s[229] / 4f / midN : 0f):0.00} px."); } + // [CHECKER 28/07] La ligne qui tranche l'hypothèse du damier. Lecture : + // diag ÉLEVÉ (quelques % et plus) -> le contenu lointain EST en damier et + // nos deux familles de vecteurs s'y alternent = la cause du clignotement. + // diag ~0 avec split vivant -> discontinuités normales (silhouettes), + // l'hypothèse MEURT et on n'a rien cassé pour le savoir. + // chkN à 0 -> la bande lointaine n'est pas peuplée + // ici : refaire la mesure en visant le ciel, pas un décor proche. + if (_mvppChecker && s[233] > 0) + { + uint cN = s[233]; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP CHECKER: farBand={cN} split={100f * s[234] / cN:0.0}% diag={100f * s[235] / cN:0.0}% " + + $"sky={100f * s[236] / cN:0.0}% (diag = signature damier stricte ; split seul = non concluant)."); + } + // [DEPTHGUARD 19/07] évaluation du critère à 1 Hz. On n'évalue que quand les // deux populations existent (menus/écrans titre = bins vides -> streaks gelés, // ni bons ni mauvais). Tripped, la passe reproj tourne toujours -> la sonde @@ -2343,8 +3068,15 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _mvppComposedThisFrame = mvppCompose; // std140: two mat4 (16 floats each) + a run of scalars (padded to 16 bytes). - // 52 floats = 17 scalars in use (32-48) + padding to the 16-byte boundary. - Span p = stackalloc float[52]; + // [31/07] COMPTE REEL, le commentaire precedent etait perime (il annoncait 52 flottants + // et "17 scalaires (32-48)") : 32 flottants de matrices + 22 scalaires p[32..53] = 54, + // et le bloc GLSL declare exactement 2 mat4 + 22 scalaires. TOUT AJOUT SE FAIT EN FIN + // DE BLOC et se compte DES DEUX COTES avant deploiement -- un bloc plus court cote CPU + // que cote shader = lecture hors limites a chaque image, artefacts qu'aucun reglage ne + // desarme (demi-journee perdue le 28/07, toutes les comparaisons de ce jour faussees). + // En std140 53 et 54 flottants padent tous deux a 224 octets : la taille du bloc ne + // change pas, seul le nombre de flottants ecrits. + Span p = stackalloc float[54]; WriteMatrix(p, 0, in invT); WriteMatrix(p, 16, in prevT); p[32] = input.Width; @@ -2384,10 +3116,35 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // le TRAVAIL des stats (cadence = fix shippable) ; inchangé -> pression REGISTRES // (le bloc stats gonfle l'allocation du shader entier) -> passe séparée requise. bool statsThisFrame = _mvppStatsStride <= 1 || (_framesSinceReset % _mvppStatsStride) == 0; - p[49] = (MvppDev.Enabled || _mvppSkyDrift || _mvppDynSonde || _mvppDepthSonde || _mvppDepthGuard) && statsThisFrame ? 1f : 0f; // grille de stats : sondes dev OU boucle SKYDRIFT OU DYNSONDE OU DEPTHSONDE OU DEPTHGUARD + p[49] = (MvppDev.Enabled || _mvppSkyDrift || _mvppDynSonde || _mvppDepthSonde || _mvppDepthGuard || _mvppChecker) && statsThisFrame ? 1f : 0f; // grille de stats : sondes dev OU boucle SKYDRIFT OU DYNSONDE OU DEPTHSONDE OU DEPTHGUARD OU CHECKER p[50] = _mvppSkyDrift && !skyCut ? _skyDriftEmaX : 0f; // SKYDRIFT : dérive contenu ciel (px/frame, EMA 1 Hz) p[51] = _mvppSkyDrift && !skyCut ? _skyDriftEmaY : 0f; + // [SKYDEPTH 27/07] Window depth at or above which a pixel counts as sky. 1.0 is the + // historic rule - the far plane and nothing else - and stays the default, so without + // the variable every game keeps byte-identical behaviour. + // + // Lowering it slightly brings in what is drawn JUST in front of the dome: distant + // clouds. They were counted by nobody - excluded from the sky drift estimate (measured: + // sky = 1.5 M texels while only 2 zones of 32 were populated) and left to a reprojection + // that can only describe camera motion, never an object's own drift. Alex had already + // narrowed it by eye: "it is the far clouds, not the ones flying close". + // + // The bound must stay TIGHT: go too low and distant mountains join the sky and receive + // a drift that is not theirs. This is a value to measure, not to guess, which is why it + // is a variable before it is ever a default. + p[52] = _skyDepth; + + // [INTERVALFIX 31/07] Facteur temporel : combien d'intervalles la paire (curr, prev) + // couvre-t-elle REELLEMENT. Les tampons sont en numeros de present (_presentSerial, + // un tick par present, jamais remis a zero), donc leur ecart EST ce nombre. + // Normalement 1. Il ne vaut 2 qu'a l'image de rattrapage d'un figement, quand le + // decalage a ete saute au present precedent. Plafonne a 2 : au-dela, l'hypothese de + // vitesse constante ne tient plus, et le saut de decalage est deja limite a un seul + // de suite. Correctif eteint => aucun saut => ecart toujours 1 => facteur 1.0. + long vpSpan = _mvppVpCurrStamp - _mvppVpPrevStamp; + p[53] = _mvppIntervalFix && vpSpan == 2 ? 0.5f : 1.0f; + using ScopedTemporaryBuffer buffer = _gd.BufferManager.ReserveOrCreate(_gd, cbs, p.Length * sizeof(float)); buffer.Holder.SetDataUnchecked(buffer.Offset, p); @@ -2637,6 +3394,30 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // borné ~[0..16]. Hypothèse à MESURER avant tout bouton : pan = évolution continue (delta // petit), rideau/swap d'UI/téléport = saut de projection (delta O(1)) — le discriminant que // ni le flow ni les métriques d'écran n'ont su donner (post-mortem v2/v3 plus haut). + // [CAMSPLIT 28/07] Plus grand ecart element-a-element sur le bloc 3x3 de ROTATION. La + // rotation est choisie exprès : le jitter sous-pixel du jeu vit dans la projection et la + // translation (il fait changer la matrice a presque chaque image sans que l'image bouge + // d'un pixel), alors que la rotation ne bouge que si la camera tourne VRAIMENT. Mesure + // 28/07 : legitime < 0,1 ; intrusion de la seconde camera = 1,13893, constant. + private static float RotJump(in System.Numerics.Matrix4x4 a, in System.Numerics.Matrix4x4 b) + { + Span fa = stackalloc float[16]; + Span fb = stackalloc float[16]; + WriteMatrix(fa, 0, in a); + WriteMatrix(fb, 0, in b); + + float m = 0f; + for (int r = 0; r < 3; r++) + { + for (int c = 0; c < 3; c++) + { + m = Math.Max(m, Math.Abs(fa[r * 4 + c] - fb[r * 4 + c])); + } + } + + return m; + } + private static float VpPairJump(in System.Numerics.Matrix4x4 a, in System.Numerics.Matrix4x4 b) { Span fa = stackalloc float[16]; @@ -3994,6 +4775,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public void Dispose() { DrainFgRetired(all: true); + _sharpenPass?.Dispose(); // [SHARPEN] _output?.Dispose(); _depth?.Dispose(); _motion?.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs index 1c83d1405..265b8a90c 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs @@ -259,6 +259,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private static IntPtr _frameToken; // token of the frame currently in flight (set at evaluate, cleared after present) private static bool _tagFailedLogged; private static bool _markerFailedLogged; + private static bool _hudlessLogged; private static uint _presentsSinceStateLog; private static uint _lastLoggedGenerated; @@ -618,7 +619,8 @@ namespace Ryujinx.Graphics.Vulkan.Dlss uint viewportId, IntPtr frameToken, in StreamlineDlss.DlssTexture depth, - in StreamlineDlss.DlssTexture motion) + in StreamlineDlss.DlssTexture motion, + in StreamlineDlss.DlssTexture hudless) { if (!_activationEnabled || frameToken == IntPtr.Zero) { @@ -639,12 +641,32 @@ namespace Ryujinx.Graphics.Vulkan.Dlss Resource rDepth = MakeResource(in depth); Resource rMotion = MakeResource(in motion); - ResourceTag* tags = stackalloc ResourceTag[2]; + // [HUDLESSFEED 28/07] Troisieme tag quand l'image sans interface est disponible. + // Sans lui, dlfg derive UI = backbuffer - hudless = 0 et deforme le HUD. + bool hasHudless = hudless.Image != IntPtr.Zero && hudless.Width > 0 && hudless.Height > 0; + Resource rHudless = hasHudless ? MakeResource(in hudless) : default; + + uint nTags = hasHudless ? 3u : 2u; + ResourceTag* tags = stackalloc ResourceTag[3]; tags[0] = MakeTag(&rDepth, BufferTypeDepth, depth.Width, depth.Height); tags[1] = MakeTag(&rMotion, BufferTypeMotionVectors, motion.Width, motion.Height); + if (hasHudless) + { + tags[2] = MakeTag(&rHudless, BufferTypeHudLessColor, hudless.Width, hudless.Height); + } + ViewportHandle vp = MakeViewport(viewportId); - int r = slSetTagForFrame(frameToken, in vp, tags, 2, cmdBuffer); + int r = slSetTagForFrame(frameToken, in vp, tags, nTags, cmdBuffer); + + // [HUDLESSFEED] Verdict LU, pas suppose : une seule ligne, au premier tag avec HUD-less. + if (hasHudless && !_hudlessLogged) + { + _hudlessLogged = true; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS-FG: HUD-less tague {hudless.Width}x{hudless.Height} " + + $"(couleur {SwapchainWidth}x{SwapchainHeight}) -> slSetTagForFrame = {SlResult(r)}."); + } if (r != 0) { if (!_tagFailedLogged) diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/DlssSharpenLinear.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/DlssSharpenLinear.spv new file mode 100644 index 0000000000000000000000000000000000000000..f4ba3a824a21272a385c99fc524dcd5201314679 GIT binary patch literal 20372 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 1.0, + // i.e. the dome itself. Distant clouds are GEOMETRY drawn in front of it - + // their depth is 0.999-something - so they were counted by nobody: excluded + // from the sky drift estimate, and handed to a reprojection that only knows + // how to describe CAMERA motion, never an object's own drift. Measured on + // XC2: sky = 1.5 M texels while zones = 2/32, the contradiction that gave + // this away. Alex had already narrowed it by eye - "it is the far clouds, + // not the ones flying close". + + // [INTERVALFIX 31/07] AJOUTE EN FIN DE BLOC (index 53) -- aucun index existant ne bouge. + // Combien d'intervalles la paire (curr, prev) couvre REELLEMENT, sous forme de facteur : + // 1.0 en regime normal, 0.5 a l'image de rattrapage d'un figement de camera (la paire y + // couvre DEUX presents ; mesure du 31/07 sur dumps : x1,94 a x3,50 le mouvement reel). + // Applique a la part CAMERA seulement -- jamais a la derive du ciel, deja exprimee en px + // par image. Correctif eteint cote CPU => vaut 1.0 en permanence, sortie inchangee. + float intervalScale; }; layout (std430, set = 1, binding = 0) buffer ReprojStats { @@ -173,6 +192,27 @@ layout (std430, set = 1, binding = 0) buffer ReprojStats { // farRot=0 they write MV=0 -> frozen decor in a pan = ghost. The // population the v1 probe structurally missed (BOTW suspect #1). uint depthBehindN; // statPixels leaving via the E7 "behind previous camera" path. + + // [CHECKER 28/07] Sonde de DISCONTINUITÉ DE CHEMIN entre pixels VOISINS. Lecture seule : + // n'écrit aucun MV, ne modifie aucune branche. Slots 233-236 (réserve libre 233..255). + // + // POURQUOI. Le choix du chemin de reprojection est fait PAR PIXEL sur un seuil de depth + // (d <= 0 || d >= skyDepth -> rotation seule ; sinon reconstruction géométrique). Si le + // ciel/la brume lointaine sont rendus en DEMI-RÉSOLUTION ALTERNÉE (damier), un pixel sur + // deux du MÊME nuage tombe de l'autre côté du seuil et reçoit un vecteur d'une autre + // famille -- et comme le damier alterne d'une image à l'autre, un même point bascille + // entre les deux chemins. Symptôme d'Alex 28/07 : « d'autres nuages apparaissent et + // disparaissent », au loin, seulement en bougeant la caméra. + // + // COMMENT LIRE. chkSplit/chkN = discordance quelconque (inclut les silhouettes normales, + // donc NON concluant seul). chkDiag/chkN = signature DAMIER STRICTE : les deux voisins + // orthogonaux prennent l'autre chemin ET le voisin DIAGONAL reprend le même. Un bord + // d'objet ordinaire ne peut PAS produire ça (une silhouette est une courbe, pas un + // échiquier). C'est chkDiag qui tranche, pas chkSplit. + uint chkN; // dénominateur : statPixels de la bande lointaine (d >= 0.98 ou ciel) + uint chkSplit; // dont au moins un voisin orthogonal a pris l'AUTRE chemin + uint chkDiag; // dont les DEUX voisins orthogonaux diffèrent ET le diagonal concorde + uint chkSky; // dont le pixel courant est parti par le chemin ciel (répartition) }; // [SKYGRID v2 18/07] Bilinear interpolation of the per-zone drift residuals over the 8x4 @@ -234,7 +274,12 @@ vec2 RotOnlyMv(vec2 uvIn, int w, int h) vec2 pn = pc.xy / pc.w; float puy = flipY > 0.5 ? (0.5 - 0.5 * pn.y) : (pn.y * 0.5 + 0.5); - return clamp((vec2(pn.x * 0.5 + 0.5, puy) - uvIn) * vec2(w, h), vec2(-maxMotion), vec2(maxMotion)); + // [INTERVALFIX 31/07] Normalisation temporelle : ce vecteur decrit l'ecart entre vpPrev et + // vpCurr, qui vaut DEUX presents a l'image de rattrapage d'un figement. intervalScale vaut + // 1.0 partout ailleurs. Applique ici pour couvrir d'un seul endroit les deux consommateurs + // de RotOnlyMv : le chemin ciel (SKYROT) et le melange de distance (FARROT). + return intervalScale * + clamp((vec2(pn.x * 0.5 + 0.5, puy) - uvIn) * vec2(w, h), vec2(-maxMotion), vec2(maxMotion)); } void main() @@ -293,6 +338,48 @@ void main() // written for EVERY pixel (sky included) before any early-out below. imageStore(imgDepthSnap, p, vec4(d, 0.0, 0.0, 0.0)); + // [CHECKER 28/07] Sonde de discontinuité de chemin (lecture seule). Placée AVANT l'early-out + // ci-dessous : les pixels ciel sortent par un return, il faut les compter tant qu'ils sont là. + // Restreinte à la BANDE LOINTAINE (le défaut est au loin, et compter tout l'écran noierait + // le signal sous les silhouettes des objets proches, qui sont des discontinuités légitimes). + if (statPixel) + { + bool skyHere = d <= 0.0 || d >= skyDepth; + if (skyHere || d >= 0.98) + { + // Voisins échantillonnés dans le MÊME dialecte que le pixel courant (UV normalisées + // × sous-rect actif) : un texelFetch ici lirait un autre domaine et mentirait. + ivec2 lim = ivec2(w - 1, h - 1); + vec2 uvR = (vec2(min(p + ivec2(1, 0), lim)) + 0.5) / vec2(w, h); + vec2 uvD = (vec2(min(p + ivec2(0, 1), lim)) + 0.5) / vec2(w, h); + vec2 uvX = (vec2(min(p + ivec2(1, 1), lim)) + 0.5) / vec2(w, h); + vec2 act = vec2(depthActiveX, depthActiveY); + float dR = textureLod(DepthTex, uvR * act, 0.0).r; + float dD = textureLod(DepthTex, uvD * act, 0.0).r; + float dX = textureLod(DepthTex, uvX * act, 0.0).r; + + bool sR = dR <= 0.0 || dR >= skyDepth; + bool sD = dD <= 0.0 || dD >= skyDepth; + bool sX = dX <= 0.0 || dX >= skyDepth; + + bool split = (skyHere != sR) || (skyHere != sD); + // Signature damier stricte : orthogonaux opposés, diagonal concordant. + bool diag = (skyHere != sR) && (skyHere != sD) && (skyHere == sX); + + uint rN = subgroupAdd(1u); + uint rS = subgroupAdd(split ? 1u : 0u); + uint rD = subgroupAdd(diag ? 1u : 0u); + uint rK = subgroupAdd(skyHere ? 1u : 0u); + if (subgroupElect()) + { + atomicAdd(chkN, rN); + atomicAdd(chkSplit, rS); + atomicAdd(chkDiag, rD); + atomicAdd(chkSky, rK); + } + } + } + // Far plane / cleared depth carries no geometry to reconstruct -- but PROOF (E4 log, // center d=0 while aimed at the WORLD) shows distant TOTK ground lives at the reversed-Z // far sentinel: rows quantize across the exact-0 boundary, and the historic "emit zero" @@ -300,7 +387,7 @@ void main() // other fixes because none touched this early-out). Rotation-only reprojection is EXACT // at infinity (sky and infinite ground alike), so when the E4 machinery is armed // (farRotStart > 0) route sentinel pixels through it instead of zero. - if (d <= 0.0 || d >= 1.0) + if (d <= 0.0 || d >= skyDepth) { // SKYROT: the far-plane/SKY case (d >= 1.0) gets rotation-only MV so the camera's // rotation of the sky is described instead of a frozen zero (ghost sky/clouds). This @@ -308,7 +395,7 @@ void main() // the distance-blend path further down (that one is FARROT's, gated on farRotStart and // acting on real geometry). Distant foliage at d~0.98 never enters here -> its reproj // MV is untouched, so the sky-ghost fix and the foliage-shimmer verdict stay separable. - bool useRot = farRotStart > 0.0 || (skyRot > 0.5 && d >= 1.0); + bool useRot = farRotStart > 0.0 || (skyRot > 0.5 && d >= skyDepth); vec2 mvFar = useRot ? RotOnlyMv(uv, w, h) : vec2(0.0); // SKYFLOW: on the sky sentinel only (d >= 1.0, same guard as SKYROT), replace the @@ -317,7 +404,7 @@ void main() // under the same maxMotion clamp as everywhere else. The flow sees rotation AND // content drift composited, which is exactly what the clouds need. bool flowTaken = false; - if (skyFlow > 0.5 && d >= 1.0) + if (skyFlow > 0.5 && d >= skyDepth) { vec2 flowSky = texelFetch(FlowMvTex, p, 0).rg; float flowMag = length(flowSky); @@ -336,7 +423,7 @@ void main() // DLSS the tiny coherent motion (~0.01-0.03 px/frame, sonde 17/07) that the deadband // suppresses as noise. Not added when the flow sample was taken directly: the flow // already contains the drift (rotation + content composited). - if (!flowTaken && d >= 1.0) + if (!flowTaken && d >= skyDepth) { // Global drift (SKYDRIFT) + per-zone residual (SKYGRID v2, bilinear = smooth). mvFar += vec2(driftX, driftY) + ZoneDriftBilinear(p, w, h); @@ -350,7 +437,7 @@ void main() // rafale d'atomiques par subgroup actif au lieu d'une par pixel (mesuré chrono B : // 33 ms -> cible <2). Les compteurs conditionnels (d>=1.0) sont hissés en ternaires // pour tout réduire en un passage. Sémantique des sommes inchangée à l'unité près. - bool d1 = d >= 1.0; + bool d1 = d >= skyDepth; // SKYROT sonde (read-only, does NOT change MV): sky MV WRITTEN to imgReprojMv // (post-deadband = fed to DLSS), x1024 cap 4 px. SKYDRIFT sonde: signed raw flow. // SKYCOH sonde: neighbour taps (clamped) for the flow field's coherence length. @@ -491,6 +578,10 @@ void main() vec2 prevUv = vec2(prevNdc.x * 0.5 + 0.5, prevUvY); // Reprojected off-screen = the previous frame holds no history for this pixel + // [EDGE3] Drapeau porte jusqu'a l'ecriture du masque, tout en bas : l'ecriture finale + // (dynPixel) ecraserait un imageStore fait ici. + bool edgeBias = false; + // (disocclusion at the screen edge). Policy is selectable (E5): the historic zero-MV // answer paints its own artifact -- a strip of "nothing moves" on newly revealed // textured content each frame of an upward sweep = the fine black lines (user-nailed @@ -509,6 +600,27 @@ void main() prevUv = clamp(prevUv, vec2(0.0), vec2(1.0)); } // edgeMode >= 1.5: keep the raw off-screen target (DLSS handles OOB history itself). + + // [EDGE3 28/07] Mode 3 = le vecteur BRUT du mode 2, PLUS le drapeau « pas d'historique ici ». + // + // Mesure du jour, verdict d'Alex sur les trois modes existants : + // 0 -> lignes noires autour des arbres, disparition LENTE + // 1 -> idem (son reglage courant) + // 2 -> lignes qui partent D'UN COUP... mais image plus SOMBRE + // + // Le mode 2 est donc le seul qui reconstruit vite : donner le vrai deplacement aide. + // Son prix vient de ce qu'il ne PREVIENT PAS DLSS : le vecteur pointe hors de l'image, + // DLSS y cherche un historique qui n'existe pas et ramene du vide -- la « bande sombre » + // que le dossier avait deja notee sans en identifier la cause. + // + // Le masque de biais dit exactement « pour ce pixel, fie-toi a la couleur ACTUELLE ». En + // le posant sur ces pixels-la, on garde la reconstruction rapide du mode 2 et on supprime + // la raison de l'assombrissement. Aucun parametre ajoute au bloc d'uniformes (edgeMode + // existe deja) -- la regle du 28/07 matin reste respectee. + if (edgeMode >= 2.5) + { + edgeBias = true; + } } // Dynamic-object / disocclusion mask: reproject the CURRENT surface into the previous @@ -567,7 +679,11 @@ void main() // DLSS convention (matches the flow pass): motion in render-resolution pixels, pointing // from the current pixel towards where it was in the previous frame. - vec2 mv = (prevUv - uv) * vec2(w, h); + // [INTERVALFIX 31/07] Meme normalisation que dans RotOnlyMv, pour le chemin geometrique : + // prevUv vient de vpPrev, qui peut etre a DEUX presents de vpCurr. Place AVANT mvPointPx et + // avant le melange FARROT pour que la sonde de profondeur et le melange voient la meme + // echelle que la sortie. Vaut 1.0 tant que le correctif est eteint. + vec2 mv = intervalScale * ((prevUv - uv) * vec2(w, h)); // [DEPTHSONDE 19/07] the raw point-based MV, BEFORE the FARROT blend below: the probe // measures the reconstruction itself, not its mitigation. @@ -600,7 +716,7 @@ void main() mv = vec2(0.0); } - imageStore(imgBiasMask, p, vec4(dynPixel ? 1.0 : 0.0)); + imageStore(imgBiasMask, p, vec4((dynPixel || edgeBias) ? 1.0 : 0.0)); // [DYNSONDE 18/07] mesures sur les pixels dynamiques des bandes denses, mode sonde // seulement : vitalite du flow (le piege connu = confidence gating qui retrecit sur diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv index 3b8baad4e983cf086bc613844d2ddde18c2d8dfb..f97e60f0b0e4df1abd822dc94e53f8ae887e2654 100644 GIT binary patch literal 49896 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 DM-&T? literal 44832 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 DCcw8C diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_depthsonde b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_depthsonde new file mode 100644 index 0000000000000000000000000000000000000000..45d663a050a5e248b8e3554fc4adf8ab3c2f4ae3 GIT binary patch literal 41920 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 literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_flowgate b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_flowgate new file mode 100644 index 0000000000000000000000000000000000000000..3e211c3fc37dfd472bfe004bdeb8f7778699f2c4 GIT binary patch literal 29412 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 literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skydepth b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skydepth new file mode 100644 index 0000000000000000000000000000000000000000..3b8baad4e983cf086bc613844d2ddde18c2d8dfb GIT binary patch literal 44832 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 DCcw8C literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skyflow b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skyflow new file mode 100644 index 0000000000000000000000000000000000000000..c36bdd214ea037c21aeafc9edd9195a3c1407687 GIT binary patch literal 26776 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 OGxnp1_nkK1|NjTihD0#{ literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_subgroup b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_subgroup new file mode 100644 index 0000000000000000000000000000000000000000..1ba10091f4669f68ca9294fadbeb45b53b446685 GIT binary patch literal 35104 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 literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/FramebufferParams.cs b/src/Ryujinx.Graphics.Vulkan/FramebufferParams.cs index 8c4ee779c..113f9dbc2 100644 --- a/src/Ryujinx.Graphics.Vulkan/FramebufferParams.cs +++ b/src/Ryujinx.Graphics.Vulkan/FramebufferParams.cs @@ -187,14 +187,24 @@ namespace Ryujinx.Graphics.Vulkan // gate, so bound it by each attachment's PHYSICAL mip extent: a strict no-op when // metadata is consistent (every integer-scale regime), a 1px underscan on a low mip // otherwise -- invisible, and the read side sees the physical size anyway. + // + // [FBCLAMP 02/08, journal (379)] Suspect n°1 du dossier ECRAN VERT (4 reproducteurs, + // 3 UE) : le « no-op strict » suppose des metadonnees coherentes — les vues ALIASEES + // d'Unreal (pools de cibles recycles entre formats/topologies) peuvent donner + // PhysicalMip < taille de vue ⇒ framebuffer SOUS-dimensionne = rendu tronque/jamais + // ecrit. RYUJINX_FBCLAMP_STOCK=1 rend la garde transparente (comportement stock + // exact) pour l'A/B ; OFF par defaut = comportement v1.2.3 inchange. + private static readonly bool _fbClampStock = + Environment.GetEnvironmentVariable("RYUJINX_FBCLAMP_STOCK") == "1"; + private static uint PhysicalMipWidth(TextureView texture) { - return (uint)Math.Max(1, texture.Storage.Info.Width >> texture.FirstLevel); + return _fbClampStock ? uint.MaxValue : (uint)Math.Max(1, texture.Storage.Info.Width >> texture.FirstLevel); } private static uint PhysicalMipHeight(TextureView texture) { - return (uint)Math.Max(1, texture.Storage.Info.Height >> texture.FirstLevel); + return _fbClampStock ? uint.MaxValue : (uint)Math.Max(1, texture.Storage.Info.Height >> texture.FirstLevel); } public FramebufferParams Update(ReadOnlySpan colors, ITexture depthStencil) @@ -438,6 +448,13 @@ namespace Ryujinx.Graphics.Vulkan return new Auto(new DisposableFramebuffer(api, _device, framebuffer), null, _attachments[..AttachmentsCount]); } + // [OMAPVENT] (journal 185) zero-alloc colour view accessor for the draw-level + // ventilation probe: index is the ATTACHMENT position (rt slot = AttachmentIndices[index]). + public TextureView MvppGetColorView(int index) + { + return _colors != null && (uint)index < (uint)_colors.Length ? _colors[index] : null; + } + public TextureView[] GetAttachmentViews() { TextureView[] result = new TextureView[AttachmentsCount]; diff --git a/src/Ryujinx.Graphics.Vulkan/MemoryAllocation.cs b/src/Ryujinx.Graphics.Vulkan/MemoryAllocation.cs index d0d0ac1e7..c66276b0f 100644 --- a/src/Ryujinx.Graphics.Vulkan/MemoryAllocation.cs +++ b/src/Ryujinx.Graphics.Vulkan/MemoryAllocation.cs @@ -28,6 +28,8 @@ namespace Ryujinx.Graphics.Vulkan HostPointer = hostPointer; Offset = offset; Size = size; + + MvppMemAliasProbe.OnAlloc(memory.Handle, offset, size, host: false); // [MEMALIAS] read-only, self-gated } public MemoryAllocation( @@ -42,10 +44,14 @@ namespace Ryujinx.Graphics.Vulkan HostPointer = hostPointer; Offset = offset; Size = size; + + MvppMemAliasProbe.OnAlloc(memory.Handle, offset, size, host: true); // [MEMALIAS] read-only, self-gated } public void Dispose() { + MvppMemAliasProbe.OnFree(Memory.Handle, Offset, Size); // [MEMALIAS] read-only, self-gated + if (_hostMemory != null) { _hostMemory.Free(Memory, Offset, Size); diff --git a/src/Ryujinx.Graphics.Vulkan/MvppClearRectProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppClearRectProbe.cs new file mode 100644 index 000000000..8e71dcc87 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppClearRectProbe.cs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Silk.NET.Vulkan; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [CLEARRECT] Read-only probe (RYUJINX_CLEARRECT_PROBE=1), inert unless set. Logs the rectangle + /// ACTUALLY passed to vkCmdClearAttachments for clears on 1280x720 framebuffers. + /// + /// Why. The XC2 object-MV buffer (R10G10B10A2 720p) is the proven carrier of the artifact + /// (journal 126), and the guest issues a correct FULL-SCREEN clear of it every frame (MVBUF + /// probe: 2 clears/frame, mask 0xF, full scissor, neutral value 0.5/0.5). Yet stale rectangles + /// survive. At this level the cleared area is ClearScissor -- a pipeline field updated by every + /// SetScissors call -- so a stale or clamped scissor at clear time would silently shrink the + /// clear to a SUB-RECTANGLE: exactly the artifact's shape family. This probe records the final + /// ClearRect (post GetClearRect clamping) as deduplicated signatures. + /// + /// Reading grid, written before coding: any signature with rect smaller than the framebuffer on + /// the MV clear (identified by its 0.5/0.5 colour) => ROOT FOUND at this line (stale scissor); + /// all rects full => the clear executes on the right area, move to identity/barrier angles + /// (the Gpu-side MVBUF identity extension runs in the same session). + /// + static class MvppClearRectProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_CLEARRECT_PROBE") == "1"; + + private static bool _armedLogged; + private static long _clears720; + private static readonly HashSet _signatures = new(); + + public static void OnClear(FramebufferParams fb, int index, Silk.NET.Vulkan.ClearRect rect, float r, float g, float b, float a) + { + if (!Enabled || fb == null) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[CLEARRECT] ARMED (RYUJINX_CLEARRECT_PROBE=1). Logging final vkCmdClearAttachments rects on 1280x720 framebuffers."); + } + + if (fb.Width != 1280 || fb.Height != 720) + { + return; + } + + _clears720++; + + bool full = rect.Rect.Offset.X == 0 && rect.Rect.Offset.Y == 0 && + rect.Rect.Extent.Width == fb.Width && rect.Rect.Extent.Height == fb.Height; + + string fmt = index >= 0 && index < fb.AttachmentFormats.Length ? fb.AttachmentFormats[index].ToString() : "?"; + string sig = $"att{index} {fmt} rgba=({r:0.###},{g:0.###},{b:0.###},{a:0.###}) " + + $"rect={rect.Rect.Offset.X},{rect.Rect.Offset.Y} {rect.Rect.Extent.Width}x{rect.Rect.Extent.Height}" + + (full ? " (FULL)" : " *** PARTIAL ***"); + + lock (_signatures) + { + if (_signatures.Add(sig)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[CLEARRECT] NEW SIGNATURE: {sig} | 720p clears so far {_clears720}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppDecompProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppDecompProbe.cs new file mode 100644 index 000000000..b5659c123 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppDecompProbe.cs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using Silk.NET.Vulkan; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [MVDECOMP] EXP 20 (RYUJINX_MVDECOMP=1), inert unless set. Journal 171. + /// + /// Weaponizes the oldest unexplained clue of the case -- "a readback CHANGES the artifact" + /// (journal 112): a readback forces the driver to decompress/resolve the image's metadata. + /// If the poison is desynced compression metadata (the H1 of journal 170, still standing + /// after NOFBL), then forcing a decompression round-trip EVERY FRAME on the two MV images + /// should kill or visibly mutate the artifact: + /// flat-block counter ~0, or artifact character clearly changed -> H1 CONFIRMED, and this + /// round-trip is a per-format WORKAROUND candidate while a real fix is designed; + /// strictly unchanged -> H1 seriously wounded -> pivot to twin roles (journal 169 reserve). + /// + /// Mechanism: at present, outside any render pass, emit for each tracked MV-shaped storage a + /// layout round-trip General -> TransferSrcOptimal -> General (full subresource). On NVIDIA + /// this is a metadata resolve point. Engine-visible layout is unchanged (General everywhere). + /// + static class MvppDecompProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVDECOMP") == "1"; + + private static bool _armedLogged; + private static long _roundTrips; + private static long _frames; + private static long _summaryMs; + + private static readonly Dictionary> _tracked = new(); + + private static bool IsMvShape(TextureView view) + { + return view != null && + view.Valid && + view.Info.Width == 1280 && + view.Info.Height == 720 && + view.Info.Format == Ryujinx.Graphics.GAL.Format.R10G10B10A2Unorm; + } + + /// Colour-target bind (SetRenderTargetsInternal): track live MV-shaped storages. + public static void OnBind(Span colors) + { + if (!Enabled) + { + return; + } + + for (int i = 0; i < colors.Length; i++) + { + if (colors[i] is TextureView view && IsMvShape(view)) + { + int id = RuntimeHelpers.GetHashCode(view.Storage); + lock (_tracked) + { + if (!_tracked.ContainsKey(id) && _tracked.Count < 4) + { + _tracked[id] = new WeakReference(view.Storage); + Logger.Warning?.Print(LogClass.Gpu, + $"[MVDECOMP] tracking MV storage 0x{id:X8} ({_tracked.Count} tracked)"); + } + } + } + } + } + + /// Present-time tick (outside render pass): emit the decompression round-trip. + public static unsafe void Tick(VulkanRenderer gd, CommandBufferScoped cbs) + { + if (!Enabled) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[MVDECOMP] armed: per-frame layout round-trip General->TransferSrc->General on tracked MV storages (forced metadata decompression)"); + } + + _frames++; + + lock (_tracked) + { + foreach (var kv in _tracked) + { + if (!kv.Value.TryGetTarget(out TextureStorage storage)) + { + continue; + } + + Image image = storage.GetImage().Get(cbs).Value; + + ImageSubresourceRange range = new( + ImageAspectFlags.ColorBit, + 0, + Vk.RemainingMipLevels, + 0, + Vk.RemainingArrayLayers); + + ImageMemoryBarrier toTransfer = new() + { + SType = StructureType.ImageMemoryBarrier, + SrcAccessMask = TextureStorage.DefaultAccessMask, + DstAccessMask = AccessFlags.TransferReadBit, + OldLayout = ImageLayout.General, + NewLayout = ImageLayout.TransferSrcOptimal, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = range, + }; + + ImageMemoryBarrier backToGeneral = toTransfer with + { + SrcAccessMask = AccessFlags.TransferReadBit, + DstAccessMask = TextureStorage.DefaultAccessMask, + OldLayout = ImageLayout.TransferSrcOptimal, + NewLayout = ImageLayout.General, + }; + + gd.Api.CmdPipelineBarrier( + cbs.CommandBuffer, + PipelineStageFlags.AllCommandsBit, + PipelineStageFlags.TransferBit, + 0, + 0, + null, + 0, + null, + 1, + &toTransfer); + + gd.Api.CmdPipelineBarrier( + cbs.CommandBuffer, + PipelineStageFlags.TransferBit, + PipelineStageFlags.AllCommandsBit, + 0, + 0, + null, + 0, + null, + 1, + &backToGeneral); + + _roundTrips++; + } + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[MVDECOMP/HEARTBEAT ~3s] frames={_frames} roundTrips={_roundTrips} tracked={_tracked.Count}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppDescTruthProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppDescTruthProbe.cs new file mode 100644 index 000000000..922427463 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppDescTruthProbe.cs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Runtime.CompilerServices; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [DESCTRUTH] (RYUJINX_DESCTRUTH=1, inert unless set). Journal 193, read-only. + /// + /// After 192: the copy engine reads the MV images CLEAN (848 dumps + 104k forced copies + /// with zero visual effect) while the sampler reads garbage. Every identity check so far + /// compared C# objects; the one thing never verified is the actual Vulkan HANDLE the + /// sampler uses: descriptor writes resolve the Auto<DisposableImageView> CACHED in + /// TextureRef at bind time -- if the view recreated its image view since, the descriptor + /// carries a stale VkImageView over a dead VkImage (recycled memory = amorphous garbage; + /// copies unaffected -- no descriptors; OpenGL immune -- no descriptor sets; barriers + /// useless). This probe compares, at every descriptor write of an MV-shaped view, the + /// cached Auto against view.GetImageView() live. + /// + static class MvppDescTruthProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_DESCTRUTH") == "1"; + + private static bool _armedLogged; + private static long _checks; + private static long _stale; + private static long _staleLogged; + private static long _heartbeatMs; + + public static void OnDescriptorWrite(TextureView view, Auto cachedImageView) + { + if (!Enabled || view == null) + { + return; + } + + if (view.Width != 1280 || + view.Height != 720 || + view.VkFormat != Silk.NET.Vulkan.Format.A2B10G10R10UnormPack32) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[DESCTRUTH] armed: comparing the CACHED image-view Auto written into descriptors vs the view's live one, MV-shaped views only"); + } + + _checks++; + + Auto current = view.GetImageView(); + + if (!ReferenceEquals(cachedImageView, current)) + { + _stale++; + + if (_staleLogged < 20) + { + _staleLogged++; + Logger.Warning?.Print(LogClass.Gpu, + $"[DESCTRUTH] !! STALE DESCRIPTOR: cachedAuto=0x{(cachedImageView != null ? RuntimeHelpers.GetHashCode(cachedImageView) : 0):X8} " + + $"currentAuto=0x{(current != null ? RuntimeHelpers.GetHashCode(current) : 0):X8} storage=0x{(view.Storage != null ? RuntimeHelpers.GetHashCode(view.Storage) : 0):X8}"); + } + } + + long now = Environment.TickCount64; + if (now - _heartbeatMs >= 5000) + { + _heartbeatMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[DESCTRUTH/HB ~5s] checks={_checks} stale={_stale}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppDofStateProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppDofStateProbe.cs new file mode 100644 index 000000000..f6f49164a --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppDofStateProbe.cs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [DOFSTATE] (RYUJINX_DOFSTATE=1, inert unless set). Journal 214, read-only. + /// + /// 213: the whole chain's shader math is read/innocented end to end -- what was never + /// audited is the FIXED-FUNCTION state of the bokeh accumulation pass. Top suspect: the + /// winding of the GS-emitted triangle strips vs Vulkan's negative-viewport Y flip (a + /// mis-compensated FrontFace would draw quads the console culls: double-painted sprites + /// = blocks, exactly where sprites exist = where tiles vary). Probe first: log, once per + /// distinct state, the cull/front-face/topology/blend of every draw whose framebuffer + /// holds a DoF-chain-sized colour target (512x288 / 320x180 / 64x36). + /// + static class MvppDofStateProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_DOFSTATE") == "1"; + + private static readonly object _lock = new(); + private static readonly HashSet _seen = new(); + private static bool _armedLogged; + + public static void OnDraw(FramebufferParams fb, ref PipelineState state) + { + if (!Enabled || fb == null) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[DOFSTATE] armed: logging fixed-function state of draws into DoF-chain-sized targets"); + } + + bool interesting = false; + + for (int a = 0; a < fb.ColorAttachmentsCount; a++) + { + TextureView view = fb.MvppGetColorView(a); + + if (view != null && + ((view.Width == 512 && view.Height == 288) || + (view.Width == 320 && view.Height == 180) || + (view.Width == 64 && view.Height == 36))) + { + interesting = true; + break; + } + } + + if (!interesting) + { + return; + } + + var blend = state.Internal.ColorBlendAttachmentState[0]; + + string line = + $"fb={fb.AttachmentFormats[0]} {fb.Width}x{fb.Height} rts={fb.ColorAttachmentsCount} | " + + $"topo={state.Topology} cull={state.CullMode} front={state.FrontFace} | " + + $"blend={blend.BlendEnable} colorOp={blend.ColorBlendOp} src={blend.SrcColorBlendFactor} dst={blend.DstColorBlendFactor} " + + $"alphaOp={blend.AlphaBlendOp} srcA={blend.SrcAlphaBlendFactor} dstA={blend.DstAlphaBlendFactor} mask=0x{(uint)blend.ColorWriteMask:X}"; + + lock (_lock) + { + if (_seen.Add(line)) + { + Logger.Warning?.Print(LogClass.Gpu, $"[DOFSTATE] NEW STATE: {line}"); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppDrawClearProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppDrawClearProbe.cs new file mode 100644 index 000000000..d9f61e1f7 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppDrawClearProbe.cs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Silk.NET.Vulkan; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [DRAWCLEAR] Temporary VALIDATION experiment (RYUJINX_DRAWCLEAR=1), inert unless set. + /// Tests the LAST standing mechanism for the XC2 stale-motion corruption. + /// + /// State of the elimination (journal 127): the guest orders a correct full-screen neutral clear + /// of the object-MV buffer every frame; the final vkCmdClearAttachments rect is FULL every time + /// (CLEARRECT probe); and the cleared host instances are exactly the sampled ones (MVBUF/ID). + /// Command, area and identity are all correct -- yet stale rectangles survive, only in motion, + /// on RTX 50. What remains is the EXECUTION ORDERING of CmdClearAttachments itself against the + /// surrounding work on this driver family: the exact hazard family of upstream PR #4596 (RTX + /// 3000+, Xenoblade named explicitly, heuristic fix that can miss cases). + /// + /// While this flag is set, full-mask clears on 1280x720 A2B10G10R10 attachments (the MV buffer; + /// nothing else in XC2 has that shape+format) are routed through the HelperShader draw-based + /// clear -- the same code path Ryujinx already uses when componentMask is partial -- instead of + /// CmdClearAttachments. A draw participates in normal pipeline ordering and barriers. + /// artifact GONE (flat-block counter ~0) -> root confirmed: CmdClearAttachments ordering on + /// this driver; the fix is this reroute, generalized behind a compat option (fully GENERIC: + /// format+size trigger, no game address -- shippable, unlike the shader-side overrides); + /// artifact STAYS -> the clear path is innocent end-to-end; rethink from the writes/decode. + /// The [DRAWCLEAR] reroute counter is the witness: zero reroutes logged = void, not "innocent". + /// + static class MvppDrawClearProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_DRAWCLEAR") == "1"; + + private static bool _armedLogged; + private static long _rerouted; + private static long _lastLogMs; + + /// True when this clear must take the draw-based path (gated + MV-buffer shape only). + public static bool ShouldReroute(FramebufferParams fb, int index) + { + if (!Enabled || fb == null) + { + return false; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[DRAWCLEAR] ARMED (RYUJINX_DRAWCLEAR=1). Full-mask clears of 1280x720 A2B10G10R10 attachments go through the HelperShader draw path."); + } + + bool match = fb.Width == 1280 && fb.Height == 720 && + index >= 0 && index < fb.AttachmentFormats.Length && + fb.AttachmentFormats[index] == Format.A2B10G10R10UnormPack32; + + if (match) + { + long n = Interlocked.Increment(ref _rerouted); + long now = Environment.TickCount64; + + if (now - Interlocked.Read(ref _lastLogMs) >= 3000) + { + Interlocked.Exchange(ref _lastLogMs, now); + Logger.Warning?.Print(LogClass.Gpu, $"[DRAWCLEAR] rerouted clears so far: {n}"); + } + } + + return match; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppHistWatchProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppHistWatchProbe.cs new file mode 100644 index 000000000..6b02f17b1 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppHistWatchProbe.cs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [HISTWATCH] (RYUJINX_HISTWATCH=1, inert unless set). Journal 195, read-only. + /// + /// The 194 dumps proved the R32F 640x360 depth-history input of the motion-blur builder + /// is a PERFECT CONSTANT (78,800 everywhere, 122 dumps) -- never filled -- while the + /// downstream maps saturate scene-shaped during camera motion. OMAPVENT sees ZERO draws + /// targeting it and there are no pipeline skips, so if anything is supposed to fill it, + /// it is a COPY / DrawTexture / host upload -- the exact paths the draw-level probes + /// never see. This probe logs every such operation touching any 640x360 view (or any + /// R32Sfloat destination), with source shape and path. + /// + static class MvppHistWatchProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_HISTWATCH") == "1"; + + private static readonly object _lock = new(); + private static readonly Dictionary _events = new(); + private static bool _armedLogged; + private static long _heartbeatMs; + + private static bool IsInteresting(TextureView view) + { + return view != null && + ((view.Width == 640 && view.Height == 360) || + view.VkFormat == Silk.NET.Vulkan.Format.R32Sfloat); + } + + private static string Describe(TextureView view) + { + return view == null ? "null" : $"{view.VkFormat} {view.Width}x{view.Height}"; + } + + private static void Record(string evt) + { + lock (_lock) + { + _events.TryGetValue(evt, out long n); + _events[evt] = n + 1; + + if (n == 0) + { + Logger.Warning?.Print(LogClass.Gpu, $"[HISTWATCH] NEW: {evt}"); + } + + long now = Environment.TickCount64; + if (now - _heartbeatMs >= 5000) + { + _heartbeatMs = now; + foreach (KeyValuePair kv in _events) + { + Logger.Warning?.Print(LogClass.Gpu, $"[HISTWATCH/HB ~5s] {kv.Value}x {kv.Key}"); + } + } + } + } + + private static void ArmOnce() + { + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[HISTWATCH] armed: watching copies / DrawTexture / host uploads touching 640x360 or R32Sfloat views"); + } + } + + public static void OnCopy(TextureView src, TextureView dst, string path) + { + if (!Enabled) + { + return; + } + + ArmOnce(); + + if (IsInteresting(src) || IsInteresting(dst)) + { + Record($"COPY[{path}] {Describe(src)} -> {Describe(dst)}"); + } + } + + public static void OnSetData(TextureView dst) + { + if (!Enabled) + { + return; + } + + ArmOnce(); + + if (IsInteresting(dst)) + { + Record($"SETDATA -> {Describe(dst)}"); + } + } + + public static void OnDrawTexture(TextureView src, FramebufferParams fb) + { + if (!Enabled) + { + return; + } + + ArmOnce(); + + bool fbInteresting = false; + + if (fb != null) + { + for (int a = 0; a < fb.ColorAttachmentsCount; a++) + { + if (IsInteresting(fb.MvppGetColorView(a))) + { + fbInteresting = true; + break; + } + } + } + + if (fbInteresting || IsInteresting(src)) + { + Record($"DRAWTEXTURE src={Describe(src)} fbHas640x360OrR32F={fbInteresting}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppMemAliasProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppMemAliasProbe.cs new file mode 100644 index 000000000..884e337e9 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppMemAliasProbe.cs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [MEMALIAS] (RYUJINX_MEMALIAS=1, inert unless set). Journal 188, read-only. + /// + /// After 187: writers, readers, bindings, masks and storage identities of the MV twin X + /// are ALL correct, yet its content is garbage -- the poison enters at the memory level, + /// below the objects. The one family a full DeviceWaitIdle per frame cannot fix (and + /// (112) measured exactly that null effect) is MEMORY ALIASING: another live resource + /// whose sub-allocated range overlaps the MV image's range, legitimately writing its own + /// data over X. It would also explain why OpenGL is clean (the GL driver manages memory, + /// no manual sub-allocation). + /// + /// Tracks every MemoryAllocation (ctor/Dispose) as live (memory handle, offset, size) + /// ranges; TextureStorage tags MV-shaped images (R10G10B10A2 1280x720). Logs any range + /// overlap between a live MV image and any other live allocation, and any mid-session + /// free of an MV range. + /// + static class MvppMemAliasProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MEMALIAS") == "1"; + + private sealed class Range + { + public ulong Handle; + public ulong Offset; + public ulong Size; + public bool IsMv; + public bool IsHost; + public string MvDesc; + public long BornMs; + } + + private static readonly object _lock = new(); + private static readonly Dictionary<(ulong, ulong), Range> _live = new(); + private static readonly List _liveMv = new(); + private static bool _armedLogged; + private static long _heartbeatMs; + private static long _overlaps; + private static long _allocs; + + private static bool Overlaps(Range a, Range b) + { + return a.Handle == b.Handle && + a.Offset < b.Offset + b.Size && + b.Offset < a.Offset + a.Size; + } + + public static void OnAlloc(ulong memoryHandle, ulong offset, ulong size, bool host) + { + if (!Enabled || memoryHandle == 0) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[MEMALIAS] armed: tracking live device-memory ranges; MV-shaped images tagged; overlaps reported"); + } + + Range range = new() + { + Handle = memoryHandle, + Offset = offset, + Size = size, + IsHost = host, + BornMs = Environment.TickCount64, + }; + + lock (_lock) + { + _allocs++; + _live[(memoryHandle, offset)] = range; + + foreach (Range mv in _liveMv) + { + if (!ReferenceEquals(mv, range) && Overlaps(mv, range)) + { + _overlaps++; + Logger.Warning?.Print(LogClass.Gpu, + $"[MEMALIAS] !! OVERLAP: new alloc mem=0x{memoryHandle:X} off={offset} size={size} " + + $"overlaps LIVE MV {mv.MvDesc} mem=0x{mv.Handle:X} off={mv.Offset} size={mv.Size}"); + } + } + + Heartbeat(); + } + } + + public static void OnFree(ulong memoryHandle, ulong offset, ulong size) + { + if (!Enabled || memoryHandle == 0) + { + return; + } + + lock (_lock) + { + if (_live.Remove((memoryHandle, offset), out Range range) && range.IsMv) + { + _liveMv.Remove(range); + Logger.Warning?.Print(LogClass.Gpu, + $"[MEMALIAS] MV FREED: {range.MvDesc} mem=0x{memoryHandle:X} off={offset} size={size} " + + $"lifetimeMs={Environment.TickCount64 - range.BornMs}"); + } + } + } + + /// Called by TextureStorage right after a successful image allocation. + public static void TagMv(ulong memoryHandle, ulong offset, string desc) + { + if (!Enabled || memoryHandle == 0) + { + return; + } + + lock (_lock) + { + if (!_live.TryGetValue((memoryHandle, offset), out Range range)) + { + return; + } + + range.IsMv = true; + range.MvDesc = desc; + _liveMv.Add(range); + + Logger.Warning?.Print(LogClass.Gpu, + $"[MEMALIAS] MV ALLOC: {desc} mem=0x{memoryHandle:X} off={offset} size={range.Size}"); + + foreach (Range other in _live.Values) + { + if (!ReferenceEquals(other, range) && Overlaps(range, other)) + { + _overlaps++; + Logger.Warning?.Print(LogClass.Gpu, + $"[MEMALIAS] !! OVERLAP at MV birth: MV {desc} off={offset} size={range.Size} " + + $"overlaps live alloc mem=0x{other.Handle:X} off={other.Offset} size={other.Size} host={other.IsHost}"); + } + } + } + } + + private static void Heartbeat() + { + long now = Environment.TickCount64; + if (now - _heartbeatMs < 5000) + { + return; + } + + _heartbeatMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[MEMALIAS/HB ~5s] liveAllocs={_live.Count} liveMv={_liveMv.Count} allocsTotal={_allocs} overlaps={_overlaps}"); + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppMvDumpProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppMvDumpProbe.cs new file mode 100644 index 000000000..79aa62a15 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppMvDumpProbe.cs @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.IO; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [MVDUMP] (RYUJINX_MVDUMP=1, inert unless set). Journal 189, read-only. + /// + /// The decisive fork after 188: every write channel into the MV twin X is measured clean + /// (FS values >= 0.5 scrubbed, clears land, no copies/DMA/compute, no aliasing, no + /// identity split, sync forced with no effect) yet the periscope -- which reads THROUGH + /// THE SAMPLER -- shows saturated masses at rest. Last untested split: is the garbage + /// really IN the image memory, or is it FABRICATED on the read path? + /// + /// This probe reads the image memory through the OTHER channel: vkCmdCopyImageToBuffer + /// via TextureView.GetData (no sampler, no guest texture cache, no re-resolution). + /// Every ~8 s, dumps each MV-shaped colour attachment (A2B10G10R10 1280x720) of the + /// currently bound framebuffer to portable-side files + logs quick pixel stats. + /// + /// Reading: dumps DIRTY (hot masses) => the poison is genuinely in memory (writers + /// below 0.5 or an unseen channel); dumps CLEAN while the periscope showed masses at + /// rest => the read path fabricates the garbage (sampler/layout/format decode). + /// + static class MvppMvDumpProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVDUMP") == "1"; + + /// [MVFLUSH] (EXP, journal 192): per-frame copy WITHOUT saving anything. + /// The intervention IS the copy: (112) recorded that a readback CHANGES the artifact, + /// and the v3 dumps read clean memory while the sampler sees garbage -- if a forced + /// copy at the end of every MV pass kills the artifact, the copy resolves what the + /// sampler cannot (driver compression-metadata family), and that is the proof. + public static readonly bool FlushEnabled = + Environment.GetEnvironmentVariable("RYUJINX_MVFLUSH") == "1"; + + private const long IntervalMs = 8000; + + // [v3] One fb-switch was starving the others (a global timer only ever caught the + // first MV pass of the frame). When the timer fires, open a short WINDOW instead: + // every MV-shaped attachment leaving the pipeline within it gets dumped -- sky pass, + // material MRT and the slot-0 pass land in the same round/frame. + private const long WindowMs = 400; + + private static bool _armedLogged; + private static long _lastDumpMs; + private static long _windowUntilMs; + private static int _dumpRound; + private static string _dir; + + /// Returns true when at least one dump ran (the caller must re-begin the + /// render pass: the copy path ends it). + /// [v4] (journal 194) the whole motion-blur chain, not just the 720p twins: + /// the temporal history and the intermediate maps were never read through the copy + /// path (the (29) graph: MV 720p + R32F 640x360 history -> builder -> 320x180 + /// ping-pong -> 64x36 tile maps). + private static bool IsWatched(TextureView view) + { + return (view.Width, view.Height, view.VkFormat) switch + { + (1280, 720, Silk.NET.Vulkan.Format.A2B10G10R10UnormPack32) => true, + // [v5] (journal 200) the TAA resolve's colour inputs/outputs -- including the + // history (tcb_A) and the (111) slot4 -- where the striping was measured. + (1280, 720, Silk.NET.Vulkan.Format.R8G8B8A8Unorm) => true, + (1280, 720, Silk.NET.Vulkan.Format.B10G11R11UfloatPack32) => true, + (640, 360, Silk.NET.Vulkan.Format.R32Sfloat) => true, + (512, 288, Silk.NET.Vulkan.Format.R16G16B16A16Sfloat) => true, // [v6] cible du scatter (215) + (320, 180, Silk.NET.Vulkan.Format.R8G8B8A8Unorm) => true, + (64, 36, Silk.NET.Vulkan.Format.R8G8B8A8Unorm) => true, + (64, 36, Silk.NET.Vulkan.Format.R8Unorm) => true, + _ => false, + }; + } + + private static long _flushCount; + private static long _flushHeartbeatMs; + + public static bool MaybeDump(FramebufferParams fb) + { + if (fb == null || (!Enabled && !FlushEnabled)) + { + return false; + } + + long now = Environment.TickCount64; + + if (FlushEnabled) + { + return FlushPass(fb, now); + } + + bool inWindow = now < _windowUntilMs; + + if (!inWindow && now - _lastDumpMs < IntervalMs) + { + return false; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[MVDUMP v2] armed: raw copy-path dumps (vkCmdCopyImageToBuffer, no sampler) of MV-shaped attachments at END of their pass, every ~8s"); + } + + bool any = false; + + for (int a = 0; a < fb.ColorAttachmentsCount; a++) + { + TextureView view = fb.MvppGetColorView(a); + + if (view == null || !IsWatched(view)) + { + continue; + } + + // Only stamp the timer once we actually saw a watched framebuffer, so dumps + // happen at chain-pass moments and not on menus. + if (!any) + { + any = true; + + if (!inWindow) + { + _lastDumpMs = now; + _windowUntilMs = now + WindowMs; + _dumpRound++; + } + + _dir ??= Directory.CreateDirectory("mvdump").FullName; + } + + int bind = fb.AttachmentIndices[a]; + int storageId = view.Storage != null ? System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(view.Storage) : 0; + + try + { + using Ryujinx.Graphics.GAL.PinnedSpan pinned = view.GetData(0, 0); + ReadOnlySpan data = pinned.Get(); + + long nonZero = 0; + long hot = 0; + int pixels = data.Length / 4; + + for (int i = 0; i + 3 < data.Length; i += 4) + { + uint px = (uint)(data[i] | (data[i + 1] << 8) | (data[i + 2] << 16) | (data[i + 3] << 24)); + uint rgb = px & 0x3FFFFFFFu; + + if (rgb != 0) + { + nonZero++; + } + + // Any 10-bit channel >= ~0.95 (0x3CC) counts as hot (the masses are + // saturated white/yellow after the builder's normalisation). + if ((px & 0x3FFu) >= 0x3CC || ((px >> 10) & 0x3FFu) >= 0x3CC || ((px >> 20) & 0x3FFu) >= 0x3CC) + { + hot++; + } + } + + string file = Path.Combine(_dir, $"round{_dumpRound:D3}_rt{bind}_s{storageId:X8}_{view.Width}x{view.Height}_{view.VkFormat}_{now}.bin"); + File.WriteAllBytes(file, data.ToArray()); + + Logger.Warning?.Print(LogClass.Gpu, + $"[MVDUMP] round={_dumpRound} rt={bind} storage=0x{storageId:X8} bytes={data.Length} " + + $"nonZero={100.0 * nonZero / pixels:F1}% hot={100.0 * hot / pixels:F2}% file={Path.GetFileName(file)}"); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Gpu, $"[MVDUMP] rt={bind} FAILED: {ex.Message}"); + } + } + + return any; + } + + /// [MVFLUSH] copy every MV-shaped attachment of the outgoing framebuffer, + /// every single pass, and throw the bytes away. Witness = heartbeat count. + private static bool FlushPass(FramebufferParams fb, long now) + { + bool any = false; + + for (int a = 0; a < fb.ColorAttachmentsCount; a++) + { + TextureView view = fb.MvppGetColorView(a); + + if (view == null || + view.Width != 1280 || + view.Height != 720 || + view.VkFormat != Silk.NET.Vulkan.Format.A2B10G10R10UnormPack32) + { + continue; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[MVFLUSH] armed: forced copy (vkCmdCopyImageToBuffer) of every MV-shaped attachment at END of every pass -- the copy IS the intervention"); + } + + try + { + view.GetData(0, 0).Dispose(); + any = true; + _flushCount++; + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Gpu, $"[MVFLUSH] rt={fb.AttachmentIndices[a]} FAILED: {ex.Message}"); + } + } + + if (any && now - _flushHeartbeatMs >= 5000) + { + _flushHeartbeatMs = now; + Logger.Warning?.Print(LogClass.Gpu, $"[MVFLUSH/HB ~5s] forced copies so far: {_flushCount}"); + } + + return any; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppNoFblProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppNoFblProbe.cs new file mode 100644 index 000000000..813a2027c --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppNoFblProbe.cs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [NOFBL] EXP 19 (RYUJINX_NOFBL=1), inert unless set. Journal 170. + /// + /// Cold re-derivation verdict: every measurement (sane written values with proven-total + /// scrub coverage, sane clears, no injection, sane objects/phase) says the MV buffers' + /// MEMORY is fine and the READ decodes garbage -- driver compression/layout metadata + /// desync on NVIDIA (the real substance of the PR #4596 family; the user runs Blackwell). + /// Our plausible trigger: AttachmentFeedbackLoopBitExt is set on EVERY attachable image + /// and the feedback-loop machinery (detection + pipeline create flags) is live, while the + /// two MV buffers are attached AND sampled every frame. + /// + /// This switch reports the VK_EXT_attachment_feedback_loop_* extensions as ABSENT, so + /// image usage, pipeline flags and detection stay consistently inert -- ONE variable. + /// flat-block counter ~0 -> trigger confirmed; real fix = only use feedback-loop + /// usage/flags when a genuine loop exists (upstream candidate); + /// unchanged -> the trigger is elsewhere in the metadata family (fast-clear tags); + /// next levers per journal 170. + /// + static class MvppNoFblProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_NOFBL") == "1"; + + private static bool _logged; + + public static void ReportStripped() + { + if (_logged) + { + return; + } + + _logged = true; + + Logger.Warning?.Print(LogClass.Gpu, + "[NOFBL] armed: VK_EXT_attachment_feedback_loop_* reported ABSENT -- no feedback-loop image usage, pipeline flags or detection this run"); + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppNoFragMask0Probe.cs b/src/Ryujinx.Graphics.Vulkan/MvppNoFragMask0Probe.cs new file mode 100644 index 000000000..2613f70cc --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppNoFragMask0Probe.cs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [NOFRAG] Temporary VALIDATION experiment (RYUJINX_NOFRAG_MASK0=1), inert unless set. + /// Tests whether fragment-less draws are the source of the XC2 stale-motion garbage. + /// + /// Measured (journal 133): XC2 issues ~6 draws per frame with the fragment stage DISABLED + /// (depth/stencil-only) while the object-MV buffer is still bound as an MRT colour target with + /// stale guest state: blend=ON, colorWriteMask=0xF. On console, no fragment shader means no + /// colour writes, period. On Vulkan the colour output of a fragment-less pipeline with a + /// non-zero write mask is UNDEFINED -- the driver may write (and here BLEND) garbage. That + /// would deposit blended garbage magnitudes into the MV buffer exactly at the geometry those + /// draws touch, every frame, visible only in motion (a garbage motion vector on a still camera + /// still decodes to 'move by v', but the whole chain is only LOOKED AT while things move). + /// + /// While this flag is set, any pipeline created WITHOUT a fragment stage gets all its colour + /// write masks forced to 0 and blending disabled -- exact console semantics, fully generic + /// (no game address, no format, no size). The counter below is the witness: 0 applications + /// logged = void, not "innocent". + /// flat-block counter ~0 -> ROOT FOUND and this IS the fix (to ship as a proper option or + /// unconditionally after regression testing -- it cannot break titles that rely on + /// defined behavior, because there is none to rely on); + /// flat blocks remain -> fragment-less draws innocent; back to the 199 material writers. + /// + static class MvppNoFragMask0Probe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_NOFRAG_MASK0") == "1"; + + private static long _applied; + private static long _lastLogMs; + + public static void OnApplied() + { + long n = Interlocked.Increment(ref _applied); + long now = Environment.TickCount64; + + if (now - Interlocked.Read(ref _lastLogMs) >= 3000) + { + Interlocked.Exchange(ref _lastLogMs, now); + Logger.Warning?.Print(LogClass.Gpu, + $"[NOFRAG] fragment-less pipelines sanitized (mask=0, blend=off) so far: {n}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppOmapMaskVk.cs b/src/Ryujinx.Graphics.Vulkan/MvppOmapMaskVk.cs new file mode 100644 index 000000000..7d8286aa9 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppOmapMaskVk.cs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [OMAPMASK-VK] EXP 22v2 (RYUJINX_OMAPMASK=1), inert unless set. Journal 183. + /// + /// The hardware rule the OpenGL backend already applies (Pipeline.cs:1564, + /// `_componentMasks & _fragmentOutputMap`) and the Vulkan backend never did: a fragment + /// shader that does not export to a bound colour attachment must not write it. Without it, + /// Vulkan writes UNDEFINED values -- on NVIDIA, register garbage. XC2's material pass binds + /// its second MV-shaped target at slot 3 while ~half its shaders export nothing there: + /// fresh flicker over a clean clear every frame = the block/grid artifact (proven absent + /// under the OpenGL backend, 25/07 23h13). + /// + /// Applied in PipelineBase.SetRenderTargetColorMasks (mask arrival) and re-derived at + /// SetProgram (map change) -- the exact two update points OpenGL uses. + /// + static class MvppOmapMaskVk + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_OMAPMASK") == "1"; + + private static bool _armedLogged; + private static long _applications; + private static long _reductions; + private static long _summaryMs; + + /// One target's effective mask under the hardware rule. + public static uint Apply(uint componentMask, int rtIndex, int fragmentOutputMap) + { + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[OMAPMASK-VK] armed: colour write masks ANDed with the fragment output map at the Vulkan pipeline level (no export = no write, as OpenGL already does)"); + } + + _applications++; + + uint omap = (uint)(fragmentOutputMap >> (rtIndex * 4)) & 0xFu; + uint effective = componentMask & omap; + + if (effective != componentMask) + { + _reductions++; + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[OMAPMASK-VK/HEARTBEAT ~3s] applications={_applications} reductions={_reductions}"); + } + + return effective; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppOmapVentProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppOmapVentProbe.cs new file mode 100644 index 000000000..5fdb7e99d --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppOmapVentProbe.cs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using VkFormat = Silk.NET.Vulkan.Format; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [OMAPVENT] (RYUJINX_OMAPVENT=1, inert unless set). Journal 185, read-only. + /// + /// Journal 184's open question: the omap rule (no export = no write) applies 46% of the + /// time yet the XC2 artifact survives -- but the counter lived at mask-arrival time and + /// never said WHICH attachment the reductions hit. This probe ventilates at DRAW time, + /// where the real framebuffer and the real program are both known: per + /// (rt slot, format, size, storage identity, guest mask, omap nibble), how many draws ran. + /// A row with guest=0xF omap=0x0 on the 720p target at slot 3 is the couple (X, slot 3) + /// actually being reduced in gameplay; zero such rows means the reductions all land + /// elsewhere and the theory breathes. + /// + /// Purely observational: computes what the rule WOULD do from the raw guest masks, + /// independent of whether RYUJINX_OMAPMASK is armed. + /// + static class MvppOmapVentProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_OMAPVENT") == "1"; + + /// Omap value used when the bound program has no guest fragment map + /// (internal/helper programs, no fragment stage): prints as "none". + private const uint NoMap = 0x10; + + internal sealed class Row + { + public long Draws; + public int Bind; + public VkFormat Fmt; + public int W; + public int H; + public int StorageId; + public uint Guest; + public uint Omap; + } + + private static readonly object _lock = new(); + private static readonly Dictionary<(int, VkFormat, int, int, int, uint, uint), Row> _rows = new(); + private static readonly Row[] _emptyRows = Array.Empty(); + private static bool _armedLogged; + private static long _heartbeatMs; + private static long _totalDraws; + + /// Rebuild the cached row set for the current (framebuffer, program, guest + /// masks) state. Called only when one of the three changed; per-draw cost is just + /// incrementing the returned rows. + public static Row[] Resolve(FramebufferParams fb, ShaderCollection program, uint[] guestMasks, int guestMaskCount) + { + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + $"[OMAPVENT] armed: draw-level ventilation by (rt, storage); omap fix = {(MvppOmapMaskVk.Enabled ? "ON" : "OFF")}"); + } + + if (fb == null) + { + return _emptyRows; + } + + int map = (program != null && !program.IsCompute) ? program.MvppFragmentOutputMap : -1; + int colorCount = fb.ColorAttachmentsCount; + Row[] rows = new Row[colorCount]; + int count = 0; + + lock (_lock) + { + for (int a = 0; a < colorCount; a++) + { + TextureView view = fb.MvppGetColorView(a); + if (view == null) + { + continue; + } + + int bind = fb.AttachmentIndices[a]; + uint guest = (uint)bind < (uint)guestMaskCount ? guestMasks[bind] & 0xFu : 0xFu; + uint omap = map >= 0 ? (uint)(map >> (bind * 4)) & 0xFu : NoMap; + int storageId = view.Storage != null ? RuntimeHelpers.GetHashCode(view.Storage) : 0; + + (int, VkFormat, int, int, int, uint, uint) key = + (bind, view.VkFormat, view.Width, view.Height, storageId, guest, omap); + + if (!_rows.TryGetValue(key, out Row row)) + { + row = new Row + { + Bind = bind, + Fmt = view.VkFormat, + W = view.Width, + H = view.Height, + StorageId = storageId, + Guest = guest, + Omap = omap, + }; + _rows.Add(key, row); + } + + rows[count++] = row; + } + } + + if (count != rows.Length) + { + Array.Resize(ref rows, count); + } + + return rows; + } + + public static void OnDraw(Row[] rows) + { + Interlocked.Increment(ref _totalDraws); + + for (int i = 0; i < rows.Length; i++) + { + Interlocked.Increment(ref rows[i].Draws); + } + + long now = Environment.TickCount64; + if (now - Interlocked.Read(ref _heartbeatMs) >= 5000) + { + Interlocked.Exchange(ref _heartbeatMs, now); + DumpRows(); + } + } + + private static void DumpRows() + { + Row[] snapshot; + lock (_lock) + { + snapshot = new Row[_rows.Count]; + _rows.Values.CopyTo(snapshot, 0); + } + + // 720p targets first (the MV twins live there), then by traffic. + Array.Sort(snapshot, (a, b) => + { + bool a720 = a.H == 720; + bool b720 = b.H == 720; + if (a720 != b720) + { + return a720 ? -1 : 1; + } + + return Interlocked.Read(ref b.Draws).CompareTo(Interlocked.Read(ref a.Draws)); + }); + + Logger.Warning?.Print(LogClass.Gpu, + $"[OMAPVENT/HB ~5s] totalDraws={Interlocked.Read(ref _totalDraws)} rows={snapshot.Length} (720p first, top 24)"); + + int printed = 0; + foreach (Row row in snapshot) + { + if (printed++ >= 24) + { + break; + } + + bool reduced = row.Omap != NoMap && (row.Guest & row.Omap) != row.Guest; + string omapText = row.Omap == NoMap ? "none" : $"0x{row.Omap:X}"; + + Logger.Warning?.Print(LogClass.Gpu, + $"[OMAPVENT] rt={row.Bind} {row.Fmt} {row.W}x{row.H} storage=0x{row.StorageId:X8} " + + $"guest=0x{row.Guest:X} omap={omapText}{(reduced ? " REDUCED" : "")} draws={Interlocked.Read(ref row.Draws)}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppReadBarProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppReadBarProbe.cs new file mode 100644 index 000000000..8033ad04f --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppReadBarProbe.cs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [MVREADBAR] Temporary VALIDATION experiment (RYUJINX_MVREADBAR=1), inert unless set. + /// Tests the last strong branch of the XC2 stale-motion tree: an INTRA-frame read-during-write + /// hazard -- the motion-map builder sampling the object-MV buffer while the geometry pass's + /// writes to it are not yet visible (journal 143; the old clue "a readback CHANGES the + /// artifact" (112) is exactly the timing sensitivity this family predicts). + /// + /// Every GAL-level barrier entry (Barrier, TextureBarrier, ...) routes through the DEFERRED + /// batching system (Gd.Barriers) -- the machinery under suspicion -- so this experiment + /// deliberately bypasses it: at DRAW time (bound textures are in-order by construction at the + /// backend), if any sampled texture is the MV buffer (A2B10G10R10 1280x720), the render pass + /// is ended and an IMMEDIATE full vkCmdPipelineBarrier (AllCommands -> AllCommands, + /// MemoryWrite -> MemoryRead|Write) is recorded before the draw -- the same immediate pattern + /// the fork's optical-flow code uses. Expected hits: a handful of draws per frame. + /// flat-block counter ~0 -> ROOT: the deferred barrier system leaves the geometry->builder + /// edge unordered on this driver; the proper fix is a correct dependency at that edge; + /// flat blocks remain -> branch dead; back to the CPU-write-tracking branch with all facts. + /// The [MVREADBAR] counter is the witness: zero applications = void, never "innocent". + /// + static class MvppReadBarProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_MVREADBAR") == "1"; + + private static long _applied; + private static long _lastLogMs; + + public static void OnApplied() + { + long n = Interlocked.Increment(ref _applied); + long now = Environment.TickCount64; + + if (now - Interlocked.Read(ref _lastLogMs) >= 3000) + { + Interlocked.Exchange(ref _lastLogMs, now); + Logger.Warning?.Print(LogClass.Gpu, + $"[MVREADBAR] immediate pre-draw barriers on MV-sampling draws so far: {n}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppSampStoreProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppSampStoreProbe.cs new file mode 100644 index 000000000..d94370efc --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppSampStoreProbe.cs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.Shader; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [SAMPSTORE] (RYUJINX_SAMPSTORE=1, inert unless set). Journal 187, read-only. + /// + /// The 186 run sharpened the paradox: the MV twin X receives ~15k REAL exported writes + /// per frame (attachment storage id stable all run) yet its content is garbage at rest. + /// Writers write, readers read poison -- so measure the one thing never compared: + /// the storage IDENTITY the readers actually sample. Records every sampled view shaped + /// like the MV twins (A2B10G10R10, 1280x720) by (storage id, stage, binding). + /// + /// Read TOGETHER with [OMAPVENT] rows from the SAME session (RuntimeHelpers hashes are + /// per-instance): sampled ids == attachment ids => identity split dead, poison lives + /// inside the image itself; a sampled id that matches NO attachment id => the readers + /// consume a different storage than the writers fill = the mechanism. + /// + static class MvppSampStoreProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_SAMPSTORE") == "1"; + + private sealed class Entry + { + public long Samples; + public int StorageId; + public string FirstSeen; + } + + private static readonly object _lock = new(); + private static readonly Dictionary _byStorage = new(); + private static bool _armedLogged; + private static long _heartbeatMs; + + public static void OnSampled(ShaderStage stage, int binding, TextureView view) + { + if (!Enabled) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[SAMPSTORE] armed: recording storage identity of every sampled MV-shaped view (A2B10G10R10 1280x720)"); + } + + if (view == null || + view.Width != 1280 || + view.Height != 720 || + view.VkFormat != Silk.NET.Vulkan.Format.A2B10G10R10UnormPack32) + { + return; + } + + int storageId = view.Storage != null ? RuntimeHelpers.GetHashCode(view.Storage) : 0; + + lock (_lock) + { + if (!_byStorage.TryGetValue(storageId, out Entry entry)) + { + entry = new Entry + { + StorageId = storageId, + FirstSeen = $"stage={stage} binding={binding}", + }; + _byStorage.Add(storageId, entry); + + Logger.Warning?.Print(LogClass.Gpu, + $"[SAMPSTORE] NEW sampled MV-shaped storage=0x{storageId:X8} ({entry.FirstSeen})"); + } + + entry.Samples++; + + long now = Environment.TickCount64; + if (now - _heartbeatMs >= 5000) + { + _heartbeatMs = now; + + foreach (Entry e in _byStorage.Values) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[SAMPSTORE/HB ~5s] storage=0x{e.StorageId:X8} samples={e.Samples} (first: {e.FirstSeen})"); + } + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppStorageBarrierProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppStorageBarrierProbe.cs new file mode 100644 index 000000000..272902fd8 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppStorageBarrierProbe.cs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [STORAGEID] Read-only probe (RYUJINX_STORAGEID=1), inert unless set. Emits no barrier and + /// changes nothing that is rendered. + /// + /// Why. Ryujinx PR #4596 added protection against recent Nvidia GPUs clearing or rewriting a + /// render target before the previous frame finished sampling it -- the PR explicitly names + /// Xenoblade 1/2/3. The protection is a heuristic living in TextureStorage: + /// + /// QueueWriteToReadBarrier : _lastReadAccess |= dstAccessFlags (this surface was READ) + /// QueueLoadOpBarrier : if ((_lastModificationAccess | _lastReadAccess) != None) barrier + /// + /// _lastReadAccess is an INSTANCE field. The barrier therefore only fires if the surface is the + /// SAME TextureStorage instance from the frame that read it to the frame that writes it. Journal + /// (112) suspected that our surface -- a pure GPU target with empty guest memory -- does not keep a + /// stable identity across frames: a new instance starts with _lastReadAccess = None, the condition + /// is false, no barrier is emitted, and the clear races the previous frame's sampling. That would + /// explain what nothing else did: it survives full CPU/GPU serialisation (a content problem, not a + /// timing one), a readback "fixes" it (it forces the cache to re-resolve), and it only shows in + /// motion. That angle was never instrumented -- the agent assigned to it crashed. + /// + /// So this probe measures the two faces of that claim: + /// 1. how many distinct TextureStorage instances a given full-screen shape goes through, and + /// 2. how often QueueLoadOpBarrier runs with NOTHING recorded, i.e. the barrier is SKIPPED. + /// + /// Identity uses RuntimeHelpers.GetHashCode so no field has to be added to a hot class. It is + /// unique per live instance, which is exactly the lifetime we are asking about. + /// + static class MvppStorageBarrierProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_STORAGEID") == "1"; + + private static bool _armedLogged; + + // Per shape: the set of distinct instances seen, plus barrier emitted / skipped counters. + private sealed class ShapeStats + { + public readonly HashSet Instances = new(); + public int LoadOps; + public int Skipped; + public int ReadNotRecorded; + } + + private static readonly Dictionary _stats = new(); + + private static int _loadOpTotal; + + public static void ReportArmed() + { + if (!Enabled || _armedLogged) + { + return; + } + + _armedLogged = true; + + Logger.Warning?.Print(LogClass.Gpu, "[STORAGEID] ARMED (RYUJINX_STORAGEID=1). Expect periodic lines for full-screen targets."); + } + + /// + /// Called from QueueLoadOpBarrier. is the real decision the + /// heuristic just took, not a guess: false means nothing was recorded on this instance, so the + /// protection did not fire for this write. + /// + /// + /// is the measurement that actually matters, and it is NOT the + /// same as "a barrier was emitted". srcAccessFlags = _lastModificationAccess | _lastReadAccess, + /// and _lastModificationAccess is re-armed at the end of every load op, so from the second + /// write onwards a barrier is emitted almost always -- covering the previous WRITE. Only + /// _lastReadAccess makes it cover the previous READ, which is the hazard PR #4596 is about. + /// + public static void OnLoadOp(object storage, int width, int height, string format, bool barrierEmitted, bool readRecorded) + { + if (!Enabled) + { + return; + } + + // Full-screen colour targets only: those are the surfaces journal (111)/(112) is about, and + // an unfiltered log here would be enormous and would itself perturb the frame timing. + if (width != 1280 || height != 720) + { + return; + } + + int id = RuntimeHelpers.GetHashCode(storage); + + lock (_stats) + { + if (!_stats.TryGetValue(format, out ShapeStats s)) + { + s = new ShapeStats(); + _stats[format] = s; + } + + s.Instances.Add(id); + s.LoadOps++; + + if (!barrierEmitted) + { + s.Skipped++; + } + + if (!readRecorded) + { + s.ReadNotRecorded++; + } + + _loadOpTotal++; + + // Periodic summary rather than one line per event: what matters is the RATIO of skipped + // barriers and the NUMBER of distinct instances, not any individual occurrence. + if ((_loadOpTotal % 400) == 0) + { + foreach (KeyValuePair kv in _stats) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[STORAGEID] {kv.Key} 1280x720 | storages {kv.Value.Instances.Count} " + + $"| loadOp {kv.Value.LoadOps} | no barrier {kv.Value.Skipped} " + + $"| READ NOT COVERED {kv.Value.ReadNotRecorded} " + + $"({(kv.Value.LoadOps == 0 ? 0 : kv.Value.ReadNotRecorded * 100L / kv.Value.LoadOps)}%)"); + } + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppVkDropProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppVkDropProbe.cs new file mode 100644 index 000000000..21b157d11 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppVkDropProbe.cs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [VKDROP] Read-only probe (RYUJINX_VKDROP_PROBE=1), inert unless set. Journal 165. + /// + /// Last floor of the search: every FS value scrub was proven to cover 100% of the XC2 + /// object-MV buffer's writers (census v3) and yet the buffer holds saturated garbage at + /// rest (periscope) -- so the poison enters BETWEEN the fragment store and the image + /// memory. FramebufferParams silently treats a bound colour target whose TextureView is + /// INVALID (orphaned view after a storage swap) as a HOLE: the attachment is dropped for + /// that framebuffer, so neither draws nor clears touch the image while the game believes + /// they do -- leaving stale VkImage memory that flickers, which is exactly what the + /// periscope shows. No earlier instrument watched this boundary (they all lived at the + /// Gpu Texture level, above the view). + /// + /// This probe watches every SetRenderTargets bind: for colour targets shaped like the MV + /// buffer (R10G10B10A2Unorm 1280x720) it counts valid binds vs INVALID-view binds. + /// drops = 0, validBinds > 0 -> the view never flaps; floor closed -> next: clear + /// execution / host write mask. + /// drops > 0 -> smoking gun: frames where the MV attachment silently + /// vanished; next = find who invalidates the view. + /// Heartbeat every ~3 s even at zero (a negative must be distinguishable from a mute probe). + /// + static class MvppVkDropProbe + { + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_VKDROP_PROBE") == "1"; + + private static bool _armedLogged; + + private static long _validBinds; + private static long _invalidBinds; + private static long _nullSlots; + private static long _summaryMs; + + private static bool IsMvShape(TextureView view) + { + return view != null && + view.Info.Width == 1280 && + view.Info.Height == 720 && + view.Info.Format == Format.R10G10B10A2Unorm; + } + + /// Called at every SetRenderTargetsInternal with the raw guest-ordered colour span. + public static void OnBind(Span colors) + { + if (!Enabled) + { + return; + } + + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[VKDROP] ARMED (RYUJINX_VKDROP_PROBE=1). Watching MV-shaped colour targets (R10G10B10A2 1280x720) for invalid-view binds at the Vulkan boundary."); + } + + for (int i = 0; i < colors.Length; i++) + { + if (colors[i] is TextureView view && IsMvShape(view)) + { + if (view.Valid) + { + _validBinds++; + } + else + { + long n = ++_invalidBinds; + + if (n <= 20 || n % 500 == 0) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[VKDROP] MV attachment slot {i} bound with INVALID view (#{n}) -- attachment silently dropped: no draw, no clear will reach the image"); + } + } + } + else if (colors[i] == null) + { + _nullSlots++; + } + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[VKDROP/HEARTBEAT ~3s] MV validBinds={_validBinds} INVALID-view binds={_invalidBinds}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/MvppVkImgProbe.cs b/src/Ryujinx.Graphics.Vulkan/MvppVkImgProbe.cs new file mode 100644 index 000000000..022da63ee --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/MvppVkImgProbe.cs @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). + +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// [VKIMG] Read-only probe (RYUJINX_VKIMG_PROBE=1), inert unless set. Journal 166. + /// + /// Read-side twin check. Every write-side door is closed by measurement (values, view + /// validity, clear command/index/execution, guest uploads, copies) and the buffer is still + /// poisoned as READ. Last mechanism compatible with every constraint: the consumers' + /// descriptors point at a STALE TWIN -- a TextureView that is perfectly valid but whose + /// underlying TextureStorage (VkImage) is not the one the G-buffer pass renders into + /// (storage replacement recreates the image; a readback forces resolution, which is the + /// old (112) clue "readback CHANGES the artifact"). No instrument ever compared VkImage + /// identity between the ATTACHMENT side and the SAMPLED side. + /// + /// This probe records, for MV-shaped views (R10G10B10A2Unorm 1280x720): + /// - the TextureStorage identity of every view bound as a COLOUR ATTACHMENT; + /// - the TextureStorage identity of every view bound as a SAMPLED texture; + /// and flags sampled identities never seen on the attachment side. + /// all sampled ids ALSO seen as attachments -> one image, no twin; floor closed; + /// sampled id NEVER seen as attachment -> smoking gun: consumers read a stale twin. + /// Heartbeat every ~3 s even at zero. + /// + static class MvppVkImgProbe + { + // NOTE: reads the VKPHASE env var directly instead of the PhaseEnabled field -- static + // initializers run in declaration order, and reading a later field here silently yields + // false (cost one VOID run, journal 168). + public static readonly bool Enabled = + Environment.GetEnvironmentVariable("RYUJINX_VKIMG_PROBE") == "1" || + Environment.GetEnvironmentVariable("RYUJINX_VKPHASE_PROBE") == "1"; + + // [VKPHASE] (sonde 23, journal 167) VKIMG found TWO MV-shaped storages, both written and + // both sampled -- the game double-buffers its MV target. The discriminating question is + // PER-FRAME PHASE: does each frame sample the half it just wrote (healthy) or the other + // one (emulator out of phase => every measurement of the week explained)? This mode + // tags storages with stable letters (A, B, ... by first-seen order) and logs the DISTINCT + // per-frame signatures "W:A R:A" with counts, folded at present. + public static readonly bool PhaseEnabled = + Environment.GetEnvironmentVariable("RYUJINX_VKPHASE_PROBE") == "1"; + + private static bool _armedLogged; + + private static long _attachBinds; + private static long _sampledBinds; + private static long _staleSampled; + private static long _summaryMs; + + private static readonly HashSet _attachIds = new(); + private static readonly HashSet _sampledIds = new(); + private static readonly HashSet _staleIds = new(); + + // Phase mode state (all under _attachIds lock). + private static readonly Dictionary _letters = new(); + private static readonly HashSet _frameWrites = new(); + private static readonly HashSet _frameReads = new(); + private static readonly Dictionary _signatures = new(); + private static long _frames; + + private static char Letter(int id) + { + if (!_letters.TryGetValue(id, out char c)) + { + c = (char)('A' + Math.Min(_letters.Count, 25)); + _letters[id] = c; + } + + return c; + } + + private static bool IsMvShape(TextureView view) + { + return view != null && + view.Valid && + view.Info.Width == 1280 && + view.Info.Height == 720 && + view.Info.Format == Format.R10G10B10A2Unorm; + } + + private static void ReportArmed() + { + if (!_armedLogged) + { + _armedLogged = true; + Logger.Warning?.Print(LogClass.Gpu, + "[VKIMG] ARMED (RYUJINX_VKIMG_PROBE=1). Comparing VkImage (TextureStorage) identity of the MV buffer: attachment side vs sampled side."); + } + } + + /// Colour-target bind (SetRenderTargetsInternal): record attachment-side storage ids. + public static void OnBind(Span colors) + { + if (!Enabled) + { + return; + } + + ReportArmed(); + + for (int i = 0; i < colors.Length; i++) + { + if (colors[i] is TextureView view && IsMvShape(view)) + { + _attachBinds++; + + int id = RuntimeHelpers.GetHashCode(view.Storage); + lock (_attachIds) + { + if (_attachIds.Add(id)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[VKIMG] ATTACHMENT storage 0x{id:X8} (new; {_attachIds.Count} attachment storage(s) so far)"); + } + + if (PhaseEnabled) + { + _frameWrites.Add(Letter(id)); + } + } + } + } + + Heartbeat(); + } + + /// Sampled-texture bind (DescriptorSetUpdater, both paths): record + cross-check. + public static void OnSampled(TextureView view) + { + if (!Enabled || !IsMvShape(view)) + { + return; + } + + ReportArmed(); + _sampledBinds++; + + int id = RuntimeHelpers.GetHashCode(view.Storage); + bool isNew; + bool seenAsAttachment; + + lock (_attachIds) + { + isNew = _sampledIds.Add(id); + seenAsAttachment = _attachIds.Contains(id); + + if (PhaseEnabled) + { + _frameReads.Add(Letter(id)); + } + + if (!seenAsAttachment) + { + _staleSampled++; + if (_staleIds.Add(id)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[VKIMG] *** SAMPLED storage 0x{id:X8} NEVER SEEN AS ATTACHMENT (stale-twin suspect) *** | sampled {_sampledIds.Count}, attached {_attachIds.Count}"); + } + } + else if (isNew) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[VKIMG] SAMPLED storage 0x{id:X8} = same as an attachment storage (healthy) | sampled {_sampledIds.Count}, attached {_attachIds.Count}"); + } + } + + Heartbeat(); + } + + /// Frame boundary (Vulkan Window.Present): fold the per-frame write/read letter sets + /// into a signature and count it. New signatures are logged immediately. + public static void OnPresent() + { + if (!PhaseEnabled) + { + return; + } + + lock (_attachIds) + { + _frames++; + + if (_frameWrites.Count > 0 || _frameReads.Count > 0) + { + string w = _frameWrites.Count > 0 ? string.Join("+", _frameWrites) : "-"; + string r = _frameReads.Count > 0 ? string.Join("+", _frameReads) : "-"; + string sig = $"W:{w} R:{r}"; + + if (!_signatures.TryGetValue(sig, out long n)) + { + Logger.Warning?.Print(LogClass.Gpu, + $"[VKPHASE] NEW frame signature: {sig} (frame {_frames})"); + } + + _signatures[sig] = n + 1; + _frameWrites.Clear(); + _frameReads.Clear(); + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + + string summary = ""; + foreach (var kv in _signatures) + { + summary += $" [{kv.Key}]x{kv.Value}"; + } + + Logger.Warning?.Print(LogClass.Gpu, + $"[VKPHASE/HEARTBEAT ~3s] frames={_frames} signatures:{summary}"); + } + } + } + + private static void Heartbeat() + { + if (PhaseEnabled) + { + return; // phase mode reports at present, not per bind + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 3000) + { + _summaryMs = now; + Logger.Warning?.Print(LogClass.Gpu, + $"[VKIMG/HEARTBEAT ~3s] attachBinds={_attachBinds} ids={_attachIds.Count} | sampledBinds={_sampledBinds} ids={_sampledIds.Count} | STALE-sampled binds={_staleSampled} ids={_staleIds.Count}"); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/PipelineBase.cs b/src/Ryujinx.Graphics.Vulkan/PipelineBase.cs index 7c63dafc3..42a1deab8 100644 --- a/src/Ryujinx.Graphics.Vulkan/PipelineBase.cs +++ b/src/Ryujinx.Graphics.Vulkan/PipelineBase.cs @@ -94,6 +94,18 @@ namespace Ryujinx.Graphics.Vulkan private bool _passWritesDepthStencil; private readonly PipelineColorBlendAttachmentState[] _storedBlend; + + // [OMAPMASK-VK] (journal 183) raw guest colour masks + last applied fragment output map, + // for re-derivation when the bound program changes. See MvppOmapMaskVk. + private readonly uint[] _mvppGuestColorMasks = new uint[Constants.MaxRenderTargets]; + private int _mvppGuestMaskCount; + private int _mvppLastOutputMap = -1; + + // [OMAPVENT] (journal 185) cached ventilation rows for the current + // (framebuffer, program, guest masks) state; rebuilt lazily on the next draw + // after any of the three changes. Read-only probe, self-gated. + private MvppOmapVentProbe.Row[] _mvppVentRows; + private bool _mvppVentDirty = true; public ulong DrawCount { get; private set; } public bool RenderPassActive { get; private set; } @@ -250,6 +262,9 @@ namespace Ryujinx.Graphics.Vulkan ClearAttachment attachment = new(ImageAspectFlags.ColorBit, (uint)index, clearValue); ClearRect clearRect = FramebufferParams.GetClearRect(ClearScissor, layer, layerCount); + // [CLEARRECT] Read-only (gated): record the FINAL rect this clear will cover. + MvppClearRectProbe.OnClear(FramebufferParams, index, clearRect, color.Red, color.Green, color.Blue, color.Alpha); + Gd.Api.CmdClearAttachments(CommandBuffer, 1, &attachment, 1, &clearRect); } @@ -350,6 +365,62 @@ namespace Ryujinx.Graphics.Vulkan Gd.Api.CmdDispatchIndirect(CommandBuffer, indirectBuffer.Get(Cbs, indirectBufferOffset, 12).Value, (ulong)indirectBufferOffset); } + // [MVREADBAR] (EXP 12, gated OFF by default) Immediate full barrier before any draw that + // samples the object-MV buffer, bypassing the deferred barrier batcher on purpose -- see + // MvppReadBarProbe. Must be recorded OUTSIDE a render pass (self-dependency rules), the + // next draw re-begins the pass automatically. + private unsafe void MvppEmitReadBarrierIfNeeded() + { + if (!MvppReadBarProbe.Enabled || !_descriptorSetUpdater.MvppAnySampledMvBuffer()) + { + return; + } + + EndRenderPass(); + + MemoryBarrier mb = new() + { + SType = StructureType.MemoryBarrier, + SrcAccessMask = AccessFlags.MemoryWriteBit, + DstAccessMask = AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, + }; + + Gd.Api.CmdPipelineBarrier( + CommandBuffer, + PipelineStageFlags.AllCommandsBit, + PipelineStageFlags.AllCommandsBit, + 0, + 1, + &mb, + 0, + null, + 0, + null); + + MvppReadBarProbe.OnApplied(); + } + + // [OMAPVENT] (journal 185) draw-level ventilation of the omap rule by real target: + // resolves (framebuffer, program, guest masks) into aggregate rows only when one of + // them changed; steady-state per-draw cost is a few increments. Read-only. + private void MvppVentOnDraw() + { + MvppDofStateProbe.OnDraw(FramebufferParams, ref _newState); // [DOFSTATE] read-only, self-gated + + if (!MvppOmapVentProbe.Enabled) + { + return; + } + + if (_mvppVentDirty) + { + _mvppVentDirty = false; + _mvppVentRows = MvppOmapVentProbe.Resolve(FramebufferParams, _program, _mvppGuestColorMasks, _mvppGuestMaskCount); + } + + MvppOmapVentProbe.OnDraw(_mvppVentRows); + } + public void Draw(int vertexCount, int instanceCount, int firstVertex, int firstInstance) { // [INJECT étape C3b] The immediately-preceding inject command replaced this draw with DLSS @@ -361,6 +432,8 @@ namespace Ryujinx.Graphics.Vulkan return; } + MvppEmitReadBarrierIfNeeded(); + if (vertexCount == 0) { return; @@ -373,6 +446,7 @@ namespace Ryujinx.Graphics.Vulkan BeginRenderPass(); DrawCount++; + MvppVentOnDraw(); // [OMAPVENT] read-only, self-gated if (Gd.TopologyUnsupported(_topology)) { @@ -429,6 +503,8 @@ namespace Ryujinx.Graphics.Vulkan public void DrawIndexed(int indexCount, int instanceCount, int firstIndex, int firstVertex, int firstInstance) { + MvppEmitReadBarrierIfNeeded(); // [MVREADBAR] (gated) see Draw + // [INJECT étape C3b] The immediately-preceding inject command replaced this draw with DLSS // (evaluate OK). Skip it once so the DLSS output survives. Only ever set under the feature flag. if (_mvppSkipNextDraw) @@ -452,6 +528,7 @@ namespace Ryujinx.Graphics.Vulkan BeginRenderPass(); DrawCount++; + MvppVentOnDraw(); // [OMAPVENT] read-only, self-gated if (_indexBufferPattern != null) { @@ -495,6 +572,7 @@ namespace Ryujinx.Graphics.Vulkan BeginRenderPass(); DrawCount++; + MvppVentOnDraw(); // [OMAPVENT] read-only, self-gated if (_indexBufferPattern != null) { @@ -545,6 +623,7 @@ namespace Ryujinx.Graphics.Vulkan BeginRenderPass(); DrawCount++; + MvppVentOnDraw(); // [OMAPVENT] read-only, self-gated if (_indexBufferPattern != null) { @@ -634,6 +713,7 @@ namespace Ryujinx.Graphics.Vulkan BeginRenderPass(); ResumeTransformFeedbackInternal(); DrawCount++; + MvppVentOnDraw(); // [OMAPVENT] read-only, self-gated Gd.Api.CmdDrawIndirect(CommandBuffer, buffer, (ulong)indirectBuffer.Offset, 1, (uint)indirectBuffer.Size); } @@ -664,6 +744,7 @@ namespace Ryujinx.Graphics.Vulkan BeginRenderPass(); ResumeTransformFeedbackInternal(); DrawCount++; + MvppVentOnDraw(); // [OMAPVENT] read-only, self-gated Gd.DrawIndirectCountApi.CmdDrawIndirectCount( CommandBuffer, @@ -677,6 +758,7 @@ namespace Ryujinx.Graphics.Vulkan public void DrawTexture(ITexture texture, ISampler sampler, Extents2DF srcRegion, Extents2DF dstRegion) { + MvppHistWatchProbe.OnDrawTexture(texture as TextureView, FramebufferParams); // [HISTWATCH] read-only, self-gated if (texture is TextureView srcTexture) { CullModeFlags oldCullMode = _newState.CullMode; @@ -986,6 +1068,16 @@ namespace Ryujinx.Graphics.Vulkan PipelineShaderStageCreateInfo[] stages = internalProgram.GetInfos(); _program = internalProgram; + _mvppVentDirty = true; // [OMAPVENT] + + // [OMAPMASK-VK] the effective masks depend on the fragment output map: re-derive + // them when the program's map differs from the one last applied (OpenGL does the + // same in its bind path). Terminates: the re-run updates _mvppLastOutputMap. + if (MvppOmapMaskVk.Enabled && !internalProgram.IsCompute && _mvppGuestMaskCount > 0 && + internalProgram.MvppFragmentOutputMap != _mvppLastOutputMap) + { + SetRenderTargetColorMasks(_mvppGuestColorMasks.AsSpan(0, _mvppGuestMaskCount)); + } _descriptorSetUpdater.SetProgram(Cbs, internalProgram, _currentPipelineHandle != 0); _bindingBarriersDirty = true; @@ -1035,7 +1127,35 @@ namespace Ryujinx.Graphics.Vulkan public void SetRenderTargetColorMasks(ReadOnlySpan componentMask) { - int count = Math.Min(Constants.MaxRenderTargets, componentMask.Length); + // [OMAPMASK-VK] keep the raw guest masks (re-derivation on program change) and apply + // the hardware rule: no fragment export = no write. Gated OFF by default. + int guestCount = Math.Min(componentMask.Length, _mvppGuestColorMasks.Length); + componentMask.Slice(0, guestCount).CopyTo(_mvppGuestColorMasks); + _mvppGuestMaskCount = guestCount; + _mvppVentDirty = true; // [OMAPVENT] + + Span maskedStorage = stackalloc uint[Constants.MaxRenderTargets]; + scoped ReadOnlySpan effectiveMask = componentMask; + + if (MvppOmapMaskVk.Enabled) + { + int map = (_program != null && !_program.IsCompute) ? _program.MvppFragmentOutputMap : -1; + _mvppLastOutputMap = map; + + if (map >= 0) + { + int maskedCount = Math.Min(componentMask.Length, maskedStorage.Length); + + for (int i = 0; i < maskedCount; i++) + { + maskedStorage[i] = MvppOmapMaskVk.Apply(componentMask[i], i, map); + } + + effectiveMask = maskedStorage.Slice(0, maskedCount); + } + } + + int count = Math.Min(Constants.MaxRenderTargets, effectiveMask.Length); int writtenAttachments = 0; Span colorBlendAttachmentStateSpan = _newState.Internal.ColorBlendAttachmentState.AsSpan(); @@ -1043,7 +1163,7 @@ namespace Ryujinx.Graphics.Vulkan for (int i = 0; i < count; i++) { ref PipelineColorBlendAttachmentState vkBlend = ref colorBlendAttachmentStateSpan[i]; - ColorComponentFlags newMask = (ColorComponentFlags)componentMask[i]; + ColorComponentFlags newMask = (ColorComponentFlags)effectiveMask[i]; // When color write mask is 0, remove all blend state to help the pipeline cache. // Restore it when the mask becomes non-zero. @@ -1063,7 +1183,7 @@ namespace Ryujinx.Graphics.Vulkan vkBlend.ColorWriteMask = newMask; - if (componentMask[i] != 0) + if (effectiveMask[i] != 0) { writtenAttachments++; } @@ -1087,6 +1207,17 @@ namespace Ryujinx.Graphics.Vulkan private void SetRenderTargetsInternal(Span colors, ITexture depthStencil, bool filterWriteMasked) { + // [MVDUMP v2] (journal 190) dump the OUTGOING framebuffer's MV attachments at the + // moment the engine switches away from them = END-of-pass content, exactly what + // the resolve is about to sample. v1 fired at the FIRST draw of the pass and only + // ever photographed the pristine clear. The copy path ends any active render + // pass itself; the next draw re-begins its own. + MvppMvDumpProbe.MaybeDump(FramebufferParams); + + MvppVkDropProbe.OnBind(colors); // [VKDROP] read-only, self-gated + MvppVkImgProbe.OnBind(colors); // [VKIMG] read-only, self-gated + MvppDecompProbe.OnBind(colors); // [MVDECOMP] tracking only here, self-gated + _mvppVentDirty = true; // [OMAPVENT] CreateFramebuffer(colors, depthStencil, filterWriteMasked); CreateRenderPass(); SignalStateChange(); @@ -1876,6 +2007,19 @@ namespace Ryujinx.Graphics.Vulkan Gd.Api.CmdEndRenderPass(CommandBuffer); SignalRenderPassEnd(); RenderPassActive = false; + + if (RenderSyncSwitch.ForceRenderPassBarrier) + { + RenderSyncSwitch.AnnounceOnce(); + + TextureView.InsertMemoryBarrier( + Gd.Api, + CommandBuffer, + AccessFlags.MemoryWriteBit, + AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, + PipelineStageFlags.AllCommandsBit, + PipelineStageFlags.AllCommandsBit); + } } } diff --git a/src/Ryujinx.Graphics.Vulkan/PipelineFull.cs b/src/Ryujinx.Graphics.Vulkan/PipelineFull.cs index b948f8f0b..539446e5c 100644 --- a/src/Ryujinx.Graphics.Vulkan/PipelineFull.cs +++ b/src/Ryujinx.Graphics.Vulkan/PipelineFull.cs @@ -42,6 +42,19 @@ namespace Ryujinx.Graphics.Vulkan _pendingQueryCopies.Clear(); } + // [MVDECOMP] (EXP 20, gated OFF by default) present-time hook: forced metadata + // decompression round-trip on the tracked MV storages, outside any render pass. + internal void MvppDecompTick() + { + if (!MvppDecompProbe.Enabled) + { + return; + } + + EndRenderPass(); + MvppDecompProbe.Tick(Gd, Cbs); + } + public void ClearRenderTargetColor(int index, int layer, int layerCount, uint componentMask, ColorF color) { if (FramebufferParams == null) @@ -49,11 +62,13 @@ namespace Ryujinx.Graphics.Vulkan return; } - if (componentMask != 0xf || Gd.IsQualcommProprietary) + if (componentMask != 0xf || Gd.IsQualcommProprietary || MvppDrawClearProbe.ShouldReroute(FramebufferParams, index)) { // We can't use CmdClearAttachments if not writing all components, // because on Vulkan, the pipeline state does not affect clears. // On proprietary Adreno drivers, CmdClearAttachments appears to execute out of order, so it's better to not use it at all. + // [DRAWCLEAR] (gated, default OFF) also takes this path for the XC2 MV buffer, to test + // whether CmdClearAttachments ordering is the source of the stale-motion rectangles. TextureView dstTexture = FramebufferParams.GetColorView(index); if (dstTexture == null) { diff --git a/src/Ryujinx.Graphics.Vulkan/PipelineState.cs b/src/Ryujinx.Graphics.Vulkan/PipelineState.cs index 0c779b694..307fae8ed 100644 --- a/src/Ryujinx.Graphics.Vulkan/PipelineState.cs +++ b/src/Ryujinx.Graphics.Vulkan/PipelineState.cs @@ -474,17 +474,36 @@ namespace Ryujinx.Graphics.Vulkan ScissorCount = ScissorsCount, }; + // Declared here, not inside the if: its address is chained into viewportState.PNext + // and only read ~200 lines later by vkCreateGraphicsPipelines. A local's storage is + // only guaranteed for its own block, so the previous form let the stack slot be + // reused by a later local while the driver still held a pointer to it - the depth + // range the pipeline is built with then depends on codegen rather than on DepthMode. + PipelineViewportDepthClipControlCreateInfoEXT viewportDepthClipControlState = new() + { + SType = StructureType.PipelineViewportDepthClipControlCreateInfoExt, + NegativeOneToOne = DepthMode, + }; + if (gd.Capabilities.SupportsDepthClipControl) { - PipelineViewportDepthClipControlCreateInfoEXT viewportDepthClipControlState = new() - { - SType = StructureType.PipelineViewportDepthClipControlCreateInfoExt, - NegativeOneToOne = DepthMode, - }; - viewportState.PNext = &viewportDepthClipControlState; } + // Gated (RYUJINX_VK_PROVOKING_LAST=1), see ProvokingVertexOverride. Declared out here + // and not inside the if for the same reason as viewportDepthClipControlState above: + // its address is held until vkCreateGraphicsPipelines runs ~200 lines below. + PipelineRasterizationProvokingVertexStateCreateInfoEXT provokingVertexState = new() + { + SType = StructureType.PipelineRasterizationProvokingVertexStateCreateInfoExt, + ProvokingVertexMode = ProvokingVertexModeEXT.LastVertexExt, + }; + + if (ProvokingVertexOverride.Active) + { + rasterizationState.PNext = &provokingVertexState; + } + PipelineMultisampleStateCreateInfo multisampleState = new() { SType = StructureType.PipelineMultisampleStateCreateInfo, @@ -520,10 +539,54 @@ namespace Ryujinx.Graphics.Vulkan }; uint blendEnables = 0; - + Span colorBlendAttachmentStateSpan = Internal.ColorBlendAttachmentState.AsSpan(); + // [Beast Roofer diag] RYUJINX_NOFRAG_MASK0=1 (EXP 9, gated OFF by default): when the + // pipeline has NO fragment stage (depth/stencil-only draws -- XC2 issues ~6 per frame + // with the motion-vector MRT still bound, stale blend=ON and mask=0xF), the Vulkan + // spec leaves colour attachment output UNDEFINED, while the console guarantees no + // colour write at all. Forcing the write masks to 0 reproduces console semantics + // exactly -- fully generic (no game data). If the XC2 stale-motion artifact dies with + // this on, those undefined writes were the root; see journal (134). + // Like the MoltenVK workaround below, the change is applied only for the create call + // and RESTORED right after: `Internal` doubles as the cache key, and leaving it + // mutated would poison every later pipeline (first version of this experiment did + // exactly that -- whole screen lost its image). + bool nofragSanitized = false; + Span nofragSavedMasks = stackalloc ColorComponentFlags[Constants.MaxRenderTargets]; + uint nofragSavedBlend = 0; + + if (MvppNoFragMask0Probe.Enabled) + { + bool hasFragment = false; + for (int i = 0; i < (int)StagesCount; i++) + { + if (Stages[i].Stage == ShaderStageFlags.FragmentBit) + { + hasFragment = true; + break; + } + } + + if (!hasFragment) + { + nofragSanitized = true; + + for (int i = 0; i < Constants.MaxRenderTargets; i++) + { + nofragSavedMasks[i] = colorBlendAttachmentStateSpan[i].ColorWriteMask; + nofragSavedBlend |= colorBlendAttachmentStateSpan[i].BlendEnable ? 1u << i : 0u; + + colorBlendAttachmentStateSpan[i].ColorWriteMask = 0; + colorBlendAttachmentStateSpan[i].BlendEnable = false; + } + + MvppNoFragMask0Probe.OnApplied(); + } + } + if (gd.IsMoltenVk && Internal.AttachmentIntegerFormatMask != 0) { // Blend can't be enabled for integer formats, so let's make sure it is disabled. @@ -645,6 +708,17 @@ namespace Ryujinx.Graphics.Vulkan Result result = gd.Api.CreateGraphicsPipelines(device, cache, 1, &pipelineCreateInfo, null, &pipelineHandle); + // [Beast Roofer diag] NOFRAG: restore the state object right after the create call, + // on every path -- it is also the pipeline cache key (see comment at the save site). + if (nofragSanitized) + { + for (int i = 0; i < Constants.MaxRenderTargets; i++) + { + colorBlendAttachmentStateSpan[i].ColorWriteMask = nofragSavedMasks[i]; + colorBlendAttachmentStateSpan[i].BlendEnable = (nofragSavedBlend & (1u << i)) != 0; + } + } + if (throwOnError) { result.ThrowOnError(); diff --git a/src/Ryujinx.Graphics.Vulkan/RenderSyncSwitch.cs b/src/Ryujinx.Graphics.Vulkan/RenderSyncSwitch.cs new file mode 100644 index 000000000..022bac576 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/RenderSyncSwitch.cs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room diagnostic code. + +using Ryujinx.Common.Logging; +using System; +using System.Threading; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// Discriminating test for the "render-target read-after-write synchronization hazard" hypothesis + /// for the Xenoblade camera-motion artefact. OFF by default. + /// + /// After the aliasing hypothesis was refuted by measurement (CopyToImpl never called on XC2, 0/3000 + /// presents), and the upstream family (Eden #322, present on Ryujinx 1.3.3) points at render-target + /// readback/synchronization, this switch forces MAXIMAL inter-pass synchronization to see whether a + /// missing barrier is the cause. + /// + /// RYUJINX_FORCE_RP_BARRIER=1 inserts a FULL global memory barrier (all writes -> all reads/writes, + /// all stages) at the end of EVERY render pass, so every render target's writes are made available + /// and visible before any later pass can sample them. + /// - artefact VANISHES => a missing inter-pass barrier IS the cause. We hold the right thread. + /// - artefact SURVIVES => it is NOT a missing render-pass barrier; look elsewhere (intra-pass + /// self-dependency, or a genuine driver-tiling issue). + /// Very slow (FPS is irrelevant for this test), gated, reversible, released behaviour byte-identical + /// when the variable is unset. + /// + static class RenderSyncSwitch + { + public static readonly bool ForceRenderPassBarrier = + Environment.GetEnvironmentVariable("RYUJINX_FORCE_RP_BARRIER") == "1"; + + /// + /// RYUJINX_FORCE_GPU_IDLE=1 forces a full DeviceWaitIdle once per presented frame -- maximal + /// CPU/GPU serialization, the faithful mirror of what makes the screen-capture readback clean + /// (FlushAllCommands + fence wait). If the artefact vanishes, it is a CPU/GPU timing race (a + /// category, not a shippable fix). If it survives even this, synchronization is exhausted as a + /// cause. OFF by default. + /// + public static readonly bool ForceGpuIdlePerFrame = + Environment.GetEnvironmentVariable("RYUJINX_FORCE_GPU_IDLE") == "1"; + + private static int _announced; + private static int _idleAnnounced; + + /// + /// Logged the first time the forced barrier is inserted, so the run's log PROVES the switch is + /// armed (a diagnostic you cannot see in the log is worthless). + /// + public static void AnnounceOnce() + { + if (Interlocked.Exchange(ref _announced, 1) == 0) + { + Logger.Info?.Print(LogClass.Gpu, + "FORCE-RP-BARRIER: armed -- a full memory barrier is inserted at the end of every " + + "render pass (maximal inter-pass synchronization). Expect very low FPS; that is fine."); + } + } + + /// + /// Logged the first time the per-frame DeviceWaitIdle runs, so the run's log proves the switch + /// is armed. + /// + public static void AnnounceIdleOnce() + { + if (Interlocked.Exchange(ref _idleAnnounced, 1) == 0) + { + Logger.Info?.Print(LogClass.Gpu, + "FORCE-GPU-IDLE: armed -- a full DeviceWaitIdle runs every frame (maximal CPU/GPU " + + "serialization, the mirror of the clean readback). Expect very low FPS; that is fine."); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj index 83e6bb833..7fef99718 100644 --- a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj +++ b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Ryujinx.Graphics.Vulkan/ShaderCollection.cs b/src/Ryujinx.Graphics.Vulkan/ShaderCollection.cs index 31493cbab..86e99fe4f 100644 --- a/src/Ryujinx.Graphics.Vulkan/ShaderCollection.cs +++ b/src/Ryujinx.Graphics.Vulkan/ShaderCollection.cs @@ -54,6 +54,11 @@ namespace Ryujinx.Graphics.Vulkan private HashTableSlim> _graphicsPipelineCache; private HashTableSlim> _computePipelineCache; + // [OMAPMASK-VK] (journal 183) The guest fragment shader's output map (4 bits per target, + // from GAL ShaderInfo), set by VulkanRenderer.CreateProgram. -1 = unknown/internal + // program (helper shaders) = no masking. Mirrors OpenGL's Program.FragmentOutputMap. + public int MvppFragmentOutputMap { get; set; } = -1; + private readonly VulkanRenderer _gd; private Device _device; private bool _initialized; diff --git a/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs b/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs index cb101c53f..f5661a5ff 100644 --- a/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs +++ b/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs @@ -36,6 +36,9 @@ namespace Ryujinx.Graphics.Vulkan AccessFlags.TransferReadBit | AccessFlags.TransferWriteBit; + // [BIRTHCLEAR diagnostic] Number of fresh allocations actually zeroed this session. + private static int _birthClearCount; + private readonly VulkanRenderer _gd; private readonly Device _device; @@ -148,6 +151,14 @@ namespace Ryujinx.Graphics.Vulkan gd.Api.BindImageMemory(device, _image, allocation.Memory, allocation.Offset).ThrowOnError(); _allocationAuto = new Auto(allocation); + + // [MEMALIAS] read-only, self-gated: tag MV-shaped images for overlap tracking. + if (MvppMemAliasProbe.Enabled && + info.Width == 1280 && info.Height == 720 && + info.Format == Ryujinx.Graphics.GAL.Format.R10G10B10A2Unorm) + { + MvppMemAliasProbe.TagMv(allocation.Memory.Handle, allocation.Offset, $"{info.Format} {info.Width}x{info.Height}"); + } _imageAuto = new Auto(new DisposableImage(_gd.Api, device, _image), null, _allocationAuto); // [BIRTHCLEAR] Fresh allocations only: the suballocator recycles freed device memory @@ -155,8 +166,29 @@ namespace Ryujinx.Graphics.Vulkan // DLSS before the game's writes cover the quantized allocation -- the recycled bytes // are then an earlier frame (the startup ghost image in Quality). Never on the // foreign branch below: that memory aliases live content. - InitialTransition(ImageLayout.Undefined, ImageLayout.General, - zeroFill: Dlss.DlssIntegration.SrFractional && !Dlss.DlssIntegration.BirthClearDisabled); + // RYUJINX_BIRTHCLEAR_ALL lifts the SrFractional gate for diagnosis (21/07 XC2 + // artifact on camera rotation, DLSS proven off) -- off by default, so the + // released paths keep their exact current behaviour. + bool birthClear = (Dlss.DlssIntegration.SrFractional || Dlss.DlssIntegration.BirthClearAll) && + !Dlss.DlssIntegration.BirthClearDisabled; + + // A diagnostic switch that cannot be SEEN in the log is worse than no switch: + // "the artefact is still there" then means either "mechanism cleared" or "my + // variable did nothing", and there is no way to tell the two apart. Counted and + // reported so the negative result is trustworthy. + if (birthClear) + { + int n = System.Threading.Interlocked.Increment(ref _birthClearCount); + + if (n == 1 || n == 200 || n == 2000) + { + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"BIRTHCLEAR: {n} fresh allocations zeroed " + + $"(all={Dlss.DlssIntegration.BirthClearAll}, srFractional={Dlss.DlssIntegration.SrFractional})."); + } + } + + InitialTransition(ImageLayout.Undefined, ImageLayout.General, zeroFill: birthClear); } else { @@ -530,6 +562,12 @@ namespace Ryujinx.Graphics.Vulkan AccessFlags.DepthStencilAttachmentWriteBit | AccessFlags.DepthStencilAttachmentReadBit : AccessFlags.ColorAttachmentWriteBit | AccessFlags.ColorAttachmentReadBit; + // [STORAGEID, READ-ONLY] Record whether the PR #4596 protection actually fires for this + // write, and how many distinct storage instances this shape goes through. Gated; the + // decision below is untouched. + MvppStorageBarrierProbe.ReportArmed(); + MvppStorageBarrierProbe.OnLoadOp(this, Info.Width, Info.Height, Info.Format.ToString(), srcAccessFlags != AccessFlags.None, _lastReadAccess != AccessFlags.None); + if (srcAccessFlags != AccessFlags.None) { ImageAspectFlags aspectFlags = Info.Format.ConvertAspectFlags(); diff --git a/src/Ryujinx.Graphics.Vulkan/TextureView.cs b/src/Ryujinx.Graphics.Vulkan/TextureView.cs index 4f02b4554..e1eb80b20 100644 --- a/src/Ryujinx.Graphics.Vulkan/TextureView.cs +++ b/src/Ryujinx.Graphics.Vulkan/TextureView.cs @@ -218,6 +218,7 @@ namespace Ryujinx.Graphics.Vulkan public void CopyTo(ITexture destination, int firstLayer, int firstLevel) { + MvppHistWatchProbe.OnCopy(this, destination as TextureView, "full"); // [HISTWATCH] read-only, self-gated TextureView src = this; TextureView dst = (TextureView)destination; @@ -280,6 +281,7 @@ namespace Ryujinx.Graphics.Vulkan public void CopyTo(ITexture destination, int srcLayer, int dstLayer, int srcLevel, int dstLevel) { + MvppHistWatchProbe.OnCopy(this, destination as TextureView, "layer"); // [HISTWATCH] read-only, self-gated TextureView src = this; TextureView dst = (TextureView)destination; @@ -337,6 +339,7 @@ namespace Ryujinx.Graphics.Vulkan public void CopyTo(ITexture destination, Extents2D srcRegion, Extents2D dstRegion, bool linearFilter) { + MvppHistWatchProbe.OnCopy(this, destination as TextureView, "blit"); // [HISTWATCH] read-only, self-gated TextureView dst = (TextureView)destination; if (_gd.CommandBufferPool.OwnedByCurrentThread) @@ -367,6 +370,8 @@ namespace Ryujinx.Graphics.Vulkan bool srcUsesStorageFormat = src.VkFormat == src.Storage.VkFormat; bool dstUsesStorageFormat = dst.VkFormat == dst.Storage.VkFormat; + UnsafeBlitProbe.NoteEntry(src, dst, srcUsesStorageFormat, dstUsesStorageFormat); + int layers = Math.Min(dst.Info.GetDepthOrLayers(), src.Info.GetDepthOrLayers()); int levels = Math.Min(dst.Info.Levels, src.Info.Levels); @@ -442,8 +447,13 @@ namespace Ryujinx.Graphics.Vulkan bool isDepthOrStencil = dst.Info.Format.IsDepthOrStencil; - if (!VulkanConfiguration.UseUnsafeBlit || (_gd.Vendor != Vendor.Nvidia && _gd.Vendor != Vendor.Intel)) + if (UnsafeBlitProbe.KillSwitch || !VulkanConfiguration.UseUnsafeBlit || (_gd.Vendor != Vendor.Nvidia && _gd.Vendor != Vendor.Intel)) { + if (UnsafeBlitProbe.KillSwitch) + { + UnsafeBlitProbe.AnnounceKillOnce(); + } + _gd.HelperShader.Blit( _gd, src, @@ -458,6 +468,8 @@ namespace Ryujinx.Graphics.Vulkan return; } + UnsafeBlitProbe.Note(src, dst, srcUsesStorageFormat, dstUsesStorageFormat); + Auto srcImage; Auto dstImage; @@ -769,6 +781,7 @@ namespace Ryujinx.Graphics.Vulkan /// public void SetData(MemoryOwner data) { + MvppHistWatchProbe.OnSetData(this); // [HISTWATCH] read-only, self-gated SetData(data.Span, 0, 0, Info.GetLayers(), Info.Levels, singleSlice: false); data.Dispose(); } @@ -776,6 +789,7 @@ namespace Ryujinx.Graphics.Vulkan /// public void SetData(MemoryOwner data, int layer, int level) { + MvppHistWatchProbe.OnSetData(this); // [HISTWATCH] read-only, self-gated SetData(data.Span, layer, level, 1, 1, singleSlice: true); data.Dispose(); } @@ -783,6 +797,7 @@ namespace Ryujinx.Graphics.Vulkan /// public void SetData(MemoryOwner data, int layer, int level, Rectangle region) { + MvppHistWatchProbe.OnSetData(this); // [HISTWATCH] read-only, self-gated SetData(data.Span, layer, level, 1, 1, singleSlice: true, region); data.Dispose(); } diff --git a/src/Ryujinx.Graphics.Vulkan/UnsafeBlitProbe.cs b/src/Ryujinx.Graphics.Vulkan/UnsafeBlitProbe.cs new file mode 100644 index 000000000..63e477b66 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/UnsafeBlitProbe.cs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room diagnostic code. + +using Ryujinx.Common.Logging; +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Ryujinx.Graphics.Vulkan +{ + /// + /// Diagnostic for the Xenoblade "camera-motion" artefact (XC2/XC3/XCX), OFF by default. + /// + /// HYPOTHESIS (21/07, after four host-path readers cleared every CPU layout-conversion path): + /// the corruption autocorrelates at 8 lines = the height of ONE host Nvidia GOB tile (64B x 8), + /// NOT the guest Tegra swizzle -- which never runs on the composed scene target, whose guest + /// memory is empty. The one surviving host mechanism that reinterprets a render target's bytes + /// under a DIFFERENT format sharing the SAME device memory is the UseUnsafeBlit path in + /// TextureView.CopyToImpl: it builds a second, format-aliased VkImage over the live allocation + /// (CreateAliasedStorageUnsafe). A missing aliasing barrier there is a per-vendor hazard -- which + /// matches "only when the camera moves", "builds up", intermittent, clean under readback (GetData + /// forces flush + fence), and the upstream 'amd-vendor-bug' classification on stock Ryujinx and + /// Eden since 2024. + /// + /// TWO SWITCHES, both OFF by default so released behaviour is byte-identical when unset: + /// - RYUJINX_UNSAFE_BLIT_PROBE=1 : log only. Announces itself ARMED, counts EVERY CopyToImpl + /// call and splits safe-vs-unsafe, so a null result is unambiguous. (SONDE -- run first.) + /// - RYUJINX_NO_UNSAFE_BLIT=1 : forces the SAFE HelperShader.Blit path instead. If the artefact + /// dies, the hazard is confirmed; if it survives, refuted -- with nothing broken. (BOUTON A/B.) + /// + /// Generic by construction: no game name, no hardcoded resolution. It logs EVERY distinct blit + /// signature and lets a human read which shapes appear. + /// + static class UnsafeBlitProbe + { + /// RYUJINX_NO_UNSAFE_BLIT=1 forces CopyToImpl onto the safe shader-blit path. + public static readonly bool KillSwitch = + Environment.GetEnvironmentVariable("RYUJINX_NO_UNSAFE_BLIT") == "1"; + + private static readonly bool _probe = + Environment.GetEnvironmentVariable("RYUJINX_UNSAFE_BLIT_PROBE") == "1"; + + private static int _armed; + private static long _entries; + private static long _unsafe; + private static int _killAnnounced; + private static readonly ConcurrentDictionary _seenEntry = new(); + private static readonly ConcurrentDictionary _seenUnsafe = new(); + + private static long _presents; + private static int _presentReported; + + /// + /// Called unconditionally from Window.Present (which is guaranteed to run). Its FIRST line + /// prints the env value as the rendering process actually sees it -- so a silent probe can be + /// told apart from "env never reached the process" (A) versus "CopyToImpl never called" (B). + /// Then, if armed, it periodically reports the CopyToImpl totals independently of whether the + /// unsafe branch ever fired. + /// + public static void PresentTick() + { + long p = Interlocked.Increment(ref _presents); + + if (Interlocked.Exchange(ref _presentReported, 1) == 0) + { + string v = Environment.GetEnvironmentVariable("RYUJINX_UNSAFE_BLIT_PROBE") ?? "(null)"; + Logger.Info?.Print(LogClass.Gpu, + $"UNSAFE-BLIT: present path alive. RYUJINX_UNSAFE_BLIT_PROBE seen as '{v}' " + + $"(probe {(_probe ? "ON" : "OFF")}). If ON, CopyToImpl totals follow."); + } + + if (_probe && (p == 300 || p == 3000 || p == 30000)) + { + Logger.Info?.Print(LogClass.Gpu, + $"UNSAFE-BLIT: after {p} presents -- {Interlocked.Read(ref _entries)} CopyToImpl calls, " + + $"{Interlocked.Read(ref _unsafe)} on the UNSAFE aliased branch, " + + $"{_seenEntry.Count} distinct shapes ({_seenUnsafe.Count} distinct unsafe)."); + } + } + + private static string Sig(TextureView src, TextureView dst, bool srcStore, bool dstStore) + { + return $"{src.Info.Format} {src.Width}x{src.Height}(store={srcStore}) -> " + + $"{dst.Info.Format} {dst.Width}x{dst.Height}(store={dstStore})"; + } + + /// + /// Called at the TOP of CopyToImpl on EVERY call. Announces the probe armed the first time (so + /// "no unsafe blit" can never be confused with "probe was not running"), counts all calls, and + /// periodically reports how many took the UNSAFE format-aliased branch. + /// + public static void NoteEntry(TextureView src, TextureView dst, bool srcUsesStorageFormat, bool dstUsesStorageFormat) + { + if (!_probe) + { + return; + } + + if (Interlocked.Exchange(ref _armed, 1) == 0) + { + Logger.Info?.Print(LogClass.Gpu, + "UNSAFE-BLIT: probe ARMED -- watching every TextureView.CopyToImpl. " + + "'unsafe' = the format-aliased branch that shares device memory (the hazard suspect)."); + } + + long n = Interlocked.Increment(ref _entries); + + if (_seenEntry.TryAdd(Sig(src, dst, srcUsesStorageFormat, dstUsesStorageFormat), 0)) + { + Logger.Info?.Print(LogClass.Gpu, + $"UNSAFE-BLIT: CopyToImpl shape #{_seenEntry.Count}: {Sig(src, dst, srcUsesStorageFormat, dstUsesStorageFormat)}"); + } + + if (n == 1 || n == 2000 || n == 20000 || n == 200000) + { + Logger.Info?.Print(LogClass.Gpu, + $"UNSAFE-BLIT: {n} CopyToImpl calls, {Interlocked.Read(ref _unsafe)} took the UNSAFE aliased branch, " + + $"{_seenEntry.Count} distinct shapes ({_seenUnsafe.Count} distinct on the unsafe branch)."); + } + } + + /// + /// Called when the UNSAFE format-aliased branch is actually taken. Records each distinct shape + /// once, loudly, so we can read whether the corrupt-buffer shape ever passes through here. + /// + public static void Note(TextureView src, TextureView dst, bool srcUsesStorageFormat, bool dstUsesStorageFormat) + { + if (!_probe) + { + return; + } + + Interlocked.Increment(ref _unsafe); + + if (_seenUnsafe.TryAdd(Sig(src, dst, srcUsesStorageFormat, dstUsesStorageFormat), 0)) + { + Logger.Info?.Print(LogClass.Gpu, + $"UNSAFE-BLIT: >>> UNSAFE aliased shape #{_seenUnsafe.Count}: {Sig(src, dst, srcUsesStorageFormat, dstUsesStorageFormat)} <<<"); + } + } + + /// + /// Called the first time the kill-switch actually diverts a blit, so the A/B run's log PROVES + /// the switch is armed (a diagnostic you cannot see in the log is worse than no diagnostic). + /// + public static void AnnounceKillOnce() + { + if (Interlocked.Exchange(ref _killAnnounced, 1) == 0) + { + Logger.Info?.Print(LogClass.Gpu, + "UNSAFE-BLIT: RYUJINX_NO_UNSAFE_BLIT=1 armed -- format-aliased blits are being " + + "redirected to the safe HelperShader path."); + } + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs index a31454834..d9273f200 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs @@ -415,6 +415,18 @@ namespace Ryujinx.Graphics.Vulkan features2.PNext = &supportedFeaturesDepthClipControl; } + PhysicalDeviceProvokingVertexFeaturesEXT supportedFeaturesProvokingVertex = new() + { + SType = StructureType.PhysicalDeviceProvokingVertexFeaturesExt, + PNext = features2.PNext, + }; + + if (ProvokingVertexOverride.Requested && + physicalDevice.IsDeviceExtensionPresent(ProvokingVertexOverride.ExtensionName)) + { + features2.PNext = &supportedFeaturesProvokingVertex; + } + PhysicalDeviceAttachmentFeedbackLoopLayoutFeaturesEXT supportedFeaturesAttachmentFeedbackLoopLayout = new() { SType = StructureType.PhysicalDeviceAttachmentFeedbackLoopLayoutFeaturesExt, @@ -644,6 +656,42 @@ namespace Ryujinx.Graphics.Vulkan pExtendedFeatures = &featuresDepthClipControl; } + // Gated (RYUJINX_VK_PROVOKING_LAST=1), see ProvokingVertexOverride. Nothing is chained and + // no extension is enabled when the gate is closed, so device creation is byte-identical. + PhysicalDeviceProvokingVertexFeaturesEXT featuresProvokingVertex; + + if (ProvokingVertexOverride.Requested && + physicalDevice.IsDeviceExtensionPresent(ProvokingVertexOverride.ExtensionName) && + supportedFeaturesProvokingVertex.ProvokingVertexLast) + { + featuresProvokingVertex = new PhysicalDeviceProvokingVertexFeaturesEXT + { + SType = StructureType.PhysicalDeviceProvokingVertexFeaturesExt, + PNext = pExtendedFeatures, + ProvokingVertexLast = true, + }; + + pExtendedFeatures = &featuresProvokingVertex; + + ProvokingVertexOverride.Active = true; + } + + // Witness line. Without it, a request that could not be honoured is indistinguishable + // from a request that changed nothing, and "no visible change" becomes uninterpretable. + if (ProvokingVertexOverride.Requested) + { + if (ProvokingVertexOverride.Active) + { + Logger.Info?.Print(LogClass.Gpu, "PROVOKING: ARME - flat varyings resolved from the LAST vertex"); + } + else + { + Logger.Warning?.Print(LogClass.Gpu, + $"PROVOKING: DEMANDE MAIS INACTIF - extension present: {physicalDevice.IsDeviceExtensionPresent(ProvokingVertexOverride.ExtensionName)}, " + + $"provokingVertexLast: {supportedFeaturesProvokingVertex.ProvokingVertexLast}. Le test n'a PAS eu lieu."); + } + } + PhysicalDeviceAttachmentFeedbackLoopLayoutFeaturesEXT featuresAttachmentFeedbackLoopLayout; if (physicalDevice.IsDeviceExtensionPresent("VK_EXT_attachment_feedback_loop_layout") && @@ -739,6 +787,11 @@ namespace Ryujinx.Graphics.Vulkan string[] enabledExtensions = _requiredExtensions.Union(_desirableExtensions.Intersect(physicalDevice.DeviceExtensions)).ToArray(); + if (ProvokingVertexOverride.Active) + { + enabledExtensions = enabledExtensions.Append(ProvokingVertexOverride.ExtensionName).ToArray(); + } + if (Dlss.DlssIntegration.IsEnabled) { // Add the device extensions NGX/DLSS needs (only those the device actually supports). diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index fcb9720b7..585f9a245 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -327,6 +327,15 @@ namespace Ryujinx.Graphics.Vulkan bool supportsAttachmentFeedbackLoop = _physicalDevice.IsDeviceExtensionPresent("VK_EXT_attachment_feedback_loop_layout"); + // [NOFBL] (EXP 19, journal 170, gated OFF by default): report the feedback-loop + // extensions as absent so image usage bits, pipeline create flags and the detection + // machinery stay consistently inert -- one switch, one variable. + if (MvppNoFblProbe.Enabled && supportsAttachmentFeedbackLoop) + { + MvppNoFblProbe.ReportStripped(); + supportsAttachmentFeedbackLoop = false; + } + if (supportsAttachmentFeedbackLoop) { featuresAttachmentFeedbackLoop.PNext = features2.PNext; @@ -335,6 +344,11 @@ namespace Ryujinx.Graphics.Vulkan bool supportsDynamicAttachmentFeedbackLoop = _physicalDevice.IsDeviceExtensionPresent("VK_EXT_attachment_feedback_loop_dynamic_state"); + if (MvppNoFblProbe.Enabled) + { + supportsDynamicAttachmentFeedbackLoop = false; + } + if (supportsDynamicAttachmentFeedbackLoop) { featuresDynamicAttachmentFeedbackLoop.PNext = features2.PNext; @@ -594,12 +608,22 @@ namespace Ryujinx.Graphics.Vulkan bool isCompute = sources.Length == 1 && sources[0].Stage == ShaderStage.Compute; + ShaderCollection program; + if (info.State.HasValue || isCompute) { - return new ShaderCollection(this, _device, sources, info.ResourceLayout, info.State ?? default, info.FromCache); + program = new ShaderCollection(this, _device, sources, info.ResourceLayout, info.State ?? default, info.FromCache); + } + else + { + program = new ShaderCollection(this, _device, sources, info.ResourceLayout); } - return new ShaderCollection(this, _device, sources, info.ResourceLayout); + // [OMAPMASK-VK] carry the fragment output map so the pipeline can apply the + // hardware write rule (mirrors what OpenGL's Program has always stored). + program.MvppFragmentOutputMap = info.FragmentOutputMap; + + return program; } internal ShaderCollection CreateProgramWithMinimalLayout(ShaderSource[] sources, ResourceLayout resourceLayout, SpecDescription[] specDescription = null) diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index d5c079d4a..7dddd3e7d 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -35,6 +35,27 @@ namespace Ryujinx.Graphics.Vulkan private bool _swapchainViaFgProxy; private bool _fgPresentLogged; + // [FGFEED 02/08] Nourrir la FG pendant les doublons (journal (353), suite du diagnostic + // (348)-(352)) : quand la presentation invitee ne porte AUCUN dessin (sequence + // RenderedFrameSeq immobile, produite par Gpu/MvppCameraCapture), on AVALE le present + // hote en entier — rien n'est acquis, rien n'est evalue, la fonction retourne. Streamline + // ne voit alors QUE les vraies images (~25-30/s en rotation BOTW) et la FG interpole + // entre elles avec de vrais MVs : des intermediaires a la place des fantomes. Surete : + // le backend Vulkan n'appelle jamais swapBuffersCallback et le release de la texture + // invitee se fait au retour du Present quoi qu'il arrive (Gpu/Window.cs:531-533) — avaler + // est invisible pour l'invite. Exige la FG armee (proxy SL), sinon INERTE : sans FG, + // avaler = saccade nue (lecon DUPSKIP (352)). Plafond de 8 avalages consecutifs + // (~130 ms) : un menu statique continue de presenter ~7 img/s, jamais un gel. + private static readonly bool _fgFeedEnabled = + System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_FGFEED") == "1"; + private const int FgFeedMaxConsecutive = 8; + private long _fgFeedLastSeq; + private int _fgFeedConsecutive; + private int _fgFeedSwallowed; + private int _fgFeedPresented; + private long _fgFeedLogMs; + private bool _fgFeedArmedLogged; + private int _width; private int _height; private VSyncMode _vSyncMode; @@ -73,6 +94,26 @@ namespace Ryujinx.Graphics.Vulkan private BufferHandle _presentCapBuffer; // staging réutilisé (la vue swapchain n'a PAS de storage : copie brute) private int _presentCapBufferSize; private bool _presentCapPending; // copie enregistrée dans le cbs, lecture après le flush du present + + // [21/07 v2] Rafale d'images CONSÉCUTIVES + capture appariée de la texture invitée. + private static readonly int _presentCapBurst = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_PRESENTCAP_BURST"), out int pcb) && pcb > 0 ? pcb : 8; + private static readonly int _presentCapGapMs = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_PRESENTCAP_GAP"), out int pcg) && pcg > 0 ? pcg : 3000; + private static readonly int _presentCapMax = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_PRESENTCAP_MAX"), out int pcm) && pcm > 0 ? pcm : 48; + // Délai avant la PREMIÈRE rafale. Sans lui, les 48 captures partent pendant le chargement du jeu + // et le run entier est perdu -- exactement ce qui est arrivé au run 14:27 (captures 00:07 -> 00:24). + private static readonly int _presentCapStartSec = + int.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_PRESENTCAP_START"), out int pcs) && pcs > 0 ? pcs : 45; + private long _presentCapArmMs; + private bool _presentCapAnnounced; + private int _presentCapBurstLeft; + private BufferHandle _presentCapGuestBuffer; // staging de la texture invitée, appariée à la même image + private int _presentCapGuestSize; + private bool _presentCapGuestPending; + private int _presentCapGuestW, _presentCapGuestH; + private string _presentCapGuestFmt; // [FLASHCAP / Mesure A] Detect flashes on the PRESENTED frame (readable) and auto-dump full frames on // a spike, to SEE where the artifact is. Throttled sampling; default OFF. private readonly bool _flashCap = System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_FLASHCAP") == "1"; @@ -98,6 +139,18 @@ namespace Ryujinx.Graphics.Vulkan // no DLSS input change, no reconstruction change. Default OFF. private static readonly bool _flashForce = System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_FLASHCAP_FORCE") == "1"; + + // [SYNCFRAME, 21/07] Banc artefact XC2, piste n°1 « synchronisation » : force une attente GPU + // complète après la soumission du command buffer de present, À CHAQUE IMAGE. Attente pure : + // aucun état de rendu touché, aucune passe ajoutée, aucun buffer lu. Banc uniquement (perte de + // FPS assumée), à ne jamais migrer vers le daily. Default OFF. + private static readonly bool _syncFrame = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_SYNCFRAME") == "1"; + private static bool _syncFrameLive = _syncFrame; + private static bool _syncFrameAnnounced; + private static long _syncFrameCount; + private static long _syncFrameStallTicks; + private static long _syncFrameNextLogMs; private bool _isLinear; private float _scalingFilterLevel; private (int, int, int, int, int, int, int, int, int, int, Type, Type)? _dlssSrPresentSig; @@ -704,6 +757,65 @@ namespace Ryujinx.Graphics.Vulkan public unsafe override void Present(ITexture texture, ITexture depthTexture, ImageCrop crop, Action swapBuffersCallback) { + // [FGFEED 02/08] Avalage des presentations-doublons — TOUT EN TETE : un present avale + // n'existe pour aucune couche Vulkan (pas d'acquire, pas de flush, pas de DLSS, pas + // de marqueurs PCL — la sequence de marqueurs par vraie image reste correcte pour la + // FG). Temoin d'armement a la PREMIERE presentation, decouple de l'usage ((348)). + if (_fgFeedEnabled) + { + bool fgArmed = _swapchainViaFgProxy && _gd.FgHooks != null; + + if (!_fgFeedArmedLogged) + { + _fgFeedArmedLogged = true; + Logger.Info?.Print(LogClass.Gpu, "DLSS FG-nourrie: ARMEE (FGFEED=1)."); + + if (!fgArmed) + { + Logger.Warning?.Print(LogClass.Gpu, + "DLSS FG-nourrie: FG non armee (pas de proxy Streamline) -> avalage INERTE, presentation inchangee."); + } + } + + // Fenetre imprimee toutes les ~5 s DES QUE le gate est arme, avalages ou pas : + // « 0 avale » en plaine 60 fps doit se LIRE, pas se deduire d'un silence + // (un temoin absent est indiscernable d'un temoin casse — lecon (348)). + long fgFeedNow = Environment.TickCount64; + + if (fgFeedNow - _fgFeedLogMs >= 5000) + { + if (_fgFeedLogMs != 0) + { + Logger.Info?.Print(LogClass.Gpu, + $"DLSS FG-nourrie: {_fgFeedSwallowed} doublons avales / {_fgFeedPresented} presentes (fenetre ~5 s)."); + } + + _fgFeedLogMs = fgFeedNow; + _fgFeedSwallowed = 0; + _fgFeedPresented = 0; + } + + long fgFeedSeq = System.Threading.Interlocked.Read(ref DlssCameraState.RenderedFrameSeq); + + if (fgArmed && fgFeedSeq != 0 && + fgFeedSeq == _fgFeedLastSeq && + _fgFeedConsecutive < FgFeedMaxConsecutive && + !ScreenCaptureRequested) + { + _fgFeedSwallowed++; + _fgFeedConsecutive++; + + return; + } + + _fgFeedLastSeq = fgFeedSeq; + _fgFeedConsecutive = 0; + _fgFeedPresented++; + } + + MvppVkImgProbe.OnPresent(); // [VKPHASE] frame boundary, read-only, self-gated + _gd.PipelineInternal.MvppDecompTick(); // [MVDECOMP] EXP 20, self-gated + _gd.PipelineInternal.AutoFlush.Present(); // [CANARY v2] Détruit les swapchains parquées dont le délai de retrait est écoulé. @@ -928,6 +1040,14 @@ namespace Ryujinx.Graphics.Vulkan $"filter={_scalingFilter?.GetType().Name ?? "none"} effect={_effect?.GetType().Name ?? "none"}"); } + UnsafeBlitProbe.PresentTick(); + + if (RenderSyncSwitch.ForceGpuIdlePerFrame) + { + RenderSyncSwitch.AnnounceIdleOnce(); + _gd.Api.DeviceWaitIdle(_device); + } + if (ScreenCaptureRequested) { if (_effect != null) @@ -1076,12 +1196,51 @@ namespace Ryujinx.Graphics.Vulkan // = NRE : cette vue est un wrapper SANS TextureStorage → copie Vulkan brute vers // un staging, LUE après le flush du present (drapeau différé + DeviceWaitIdle, // banc seulement). dlssHandled seulement : les menus ne brûlent pas les slots. - if (_presentCapUsable && dlssHandled && _presentCapCount < 40) + // [21/07] `dlssHandled` retiré : il rendait cette capture INARMABLE sans DLSS, donc jamais + // utilisable dans les conditions de repro de l'artefact XC2 (DLSS off). C'est la seule capture + // du dépôt qui regarde l'image de swapchain APRÈS le blit d'agrandissement -- l'autre sonde + // (Gpu/Window.cs:281) capture la texture invitée AVANT tout le chemin de présentation, et trois + // de ses images relues à l'œil sont propres alors que l'artefact est décrit comme continu. + // La capture reste gated par RYUJINX_MVPP_PRESENTCAP, off par défaut. + // [21/07 v2] L'artefact est TEMPOREL : il s'accumule, disparaît, revient. Une image toutes les + // 2 s ne peut rien en dire. On capture donc des images CONSÉCUTIVES par rafales, et surtout on + // capture la texture INVITÉE et l'image de SWAPCHAIN sur LA MÊME IMAGE, dans le même command + // buffer -- c'est la seule façon d'attribuer une différence à l'étage de présentation plutôt + // qu'à deux instants différents. Rafale et pause réglables sans rebuild. + if (_presentCapUsable && _presentCapCount < _presentCapMax) { long presNowMs = System.Environment.TickCount64; - if (presNowMs - _presentCapLastMs >= 2000) + + if (_presentCapArmMs == 0) { - _presentCapLastMs = presNowMs; + _presentCapArmMs = presNowMs; + } + + if (!_presentCapAnnounced) + { + _presentCapAnnounced = true; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP PRESENTCAP: armed -- premiere rafale dans {_presentCapStartSec}s, " + + $"{_presentCapBurst} images CONSECUTIVES par rafale, pause {_presentCapGapMs}ms, {_presentCapMax} au total. " + + "Sois en jeu, l'artefact visible, avant la fin du compte a rebours."); + } + + bool presReady = presNowMs - _presentCapArmMs >= _presentCapStartSec * 1000L; + bool presInBurst = presReady && _presentCapBurstLeft > 0; + + if (presReady && !presInBurst && presNowMs - _presentCapLastMs >= _presentCapGapMs) + { + _presentCapBurstLeft = _presentCapBurst; + presInBurst = true; + } + + if (presInBurst) + { + _presentCapBurstLeft--; + if (_presentCapBurstLeft == 0) + { + _presentCapLastMs = presNowMs; + } try { int presBpp = _format == VkFormat.R16G16B16A16Sfloat ? 8 : 4; @@ -1105,6 +1264,38 @@ namespace Ryujinx.Graphics.Vulkan new Extent3D((uint)_width, (uint)_height, 1)); _gd.Api.CmdCopyImageToBuffer(cbs.CommandBuffer, swapchainImage, ImageLayout.General, presBuf, 1, in presRegion); _presentCapPending = true; + + // MÊME IMAGE, MÊME command buffer : la texture invitée telle qu'elle entre dans + // le chemin de présentation. C'est le seul appariement qui permette d'attribuer + // une différence à l'étage de présentation et non à deux instants distincts. + // (Avec DLSS off et effect=none, `view` est encore la texture du jeu.) + int guestBpp = view.Info.BytesPerPixel; + int guestSize = view.Width * view.Height * guestBpp; + + if (_presentCapGuestBuffer == BufferHandle.Null) + { + _presentCapGuestBuffer = _gd.BufferManager.CreateWithHandle(_gd, guestSize); + _presentCapGuestSize = guestSize; + } + + if (guestSize == _presentCapGuestSize) + { + Image guestImage = view.GetImage().Get(cbs).Value; + + Transition(cbs.CommandBuffer, guestImage, + AccessFlags.MemoryWriteBit, AccessFlags.TransferReadBit, + ImageLayout.General, ImageLayout.General); + + var guestBuf = _gd.BufferManager.GetBuffer(cbs.CommandBuffer, _presentCapGuestBuffer, true).Get(cbs).Value; + BufferImageCopy guestRegion = new(0, 0, 0, presSl, new Offset3D(0, 0, 0), + new Extent3D((uint)view.Width, (uint)view.Height, 1)); + _gd.Api.CmdCopyImageToBuffer(cbs.CommandBuffer, guestImage, ImageLayout.General, guestBuf, 1, in guestRegion); + + _presentCapGuestPending = true; + _presentCapGuestW = view.Width; + _presentCapGuestH = view.Height; + _presentCapGuestFmt = view.Info.Format.ToString(); + } } } catch (System.Exception presEx) @@ -1148,6 +1339,19 @@ namespace Ryujinx.Graphics.Vulkan string presFile = System.IO.Path.Combine(presDir, $"pres_{_presentCapCount:00}_{_width}x{_height}_{_format}.bin"); System.IO.File.WriteAllBytes(presFile, presData.Get().ToArray()); + + // La moitié invitée de la PAIRE, écrite avec le MÊME index : pres_NN et guest_NN sont + // la même image, capturées dans le même command buffer. + if (_presentCapGuestPending) + { + _presentCapGuestPending = false; + using var guestData = _gd.BufferManager.GetData(_presentCapGuestBuffer, 0, _presentCapGuestSize); + string guestFile = System.IO.Path.Combine(presDir, + $"guest_{_presentCapCount:00}_{_presentCapGuestW}x{_presentCapGuestH}_{_presentCapGuestFmt}.bin"); + System.IO.File.WriteAllBytes(guestFile, guestData.Get().ToArray()); + Logger.Info?.Print(LogClass.Gpu, $"MVPP PRESENTCAP paire: {guestFile}."); + } + _presentCapCount++; Logger.Info?.Print(LogClass.Gpu, $"MVPP PRESENTCAP: {presFile}."); } @@ -1163,6 +1367,64 @@ namespace Ryujinx.Graphics.Vulkan [PipelineStageFlags.ColorAttachmentOutputBit], [_renderFinishedSemaphores[semaphoreIndex]]); + // [SYNCFRAME] Banc XC2 : sérialise CPU/GPU une fois par image. Ici et pas ailleurs — c'est le + // seul instant où le command buffer de l'image est déjà soumis (le Return ci-dessus) et où la + // présentation n'a pas encore été demandée (après QueuePresent on mesurerait le vsync). + if (_syncFrameLive) + { + if (!_syncFrameAnnounced) + { + // Armement prouvé AVANT le premier wait : un interrupteur qu'on ne voit pas dans le + // log ne vaut rien, y compris quand le wait échoue du premier coup. + _syncFrameAnnounced = true; + Logger.Info?.Print(LogClass.Gpu, + "MVPP SYNCFRAME: ON -- DeviceWaitIdle après chaque present (banc uniquement, perte de FPS attendue)."); + } + + long syncStart = System.Diagnostics.Stopwatch.GetTimestamp(); + + try + { + // vkDeviceWaitIdle exige la synchro externe de TOUTES les VkQueue : on prend le même + // verrou que QueuePresent plus bas. Et sous le proxy Streamline, l'attente doit passer + // par les hooks FG (même règle qu'à la recréation de swapchain, l.199-210) : un + // DeviceWaitIdle natif sous le proxy peut geler le device. + lock (_gd.QueueLock) + { + if (_swapchainViaFgProxy && _gd.FgHooks != null) + { + _gd.FgHooks.DeviceWaitIdle(); + } + else + { + _gd.Api.DeviceWaitIdle(_device); + } + } + + _syncFrameCount++; + _syncFrameStallTicks += System.Diagnostics.Stopwatch.GetTimestamp() - syncStart; + + // Battement toutes les 5 s : c'est LUI qui prouve que le wait tourne par image, et le + // stall moyen qui dit ce que le test coûte (à lire avec les FPS du run). + long syncNowMs = System.Environment.TickCount64; + if (syncNowMs >= _syncFrameNextLogMs) + { + _syncFrameNextLogMs = syncNowMs + 5000; + double syncAvgMs = + (_syncFrameStallTicks * 1000.0 / System.Diagnostics.Stopwatch.Frequency) / _syncFrameCount; + Logger.Info?.Print(LogClass.Gpu, + $"MVPP SYNCFRAME: {_syncFrameCount} images synchronisées, stall moyen {syncAvgMs:F2} ms, fgProxy={_swapchainViaFgProxy}."); + } + } + catch (System.Exception syncEx) + { + // Un instrument de banc ne doit pas tuer le processus : on désarme et on le dit. + _syncFrameLive = false; + Logger.Warning?.Print(LogClass.Gpu, + $"MVPP SYNCFRAME: désarmé après erreur inattendue -- {syncEx.Message}"); + } + } + // TODO: Present queue. Semaphore semaphore = _renderFinishedSemaphores[semaphoreIndex]; SwapchainKHR swapchain = _swapchain; diff --git a/src/Ryujinx.HLE/FileSystem/ContentManager.cs b/src/Ryujinx.HLE/FileSystem/ContentManager.cs index d6eedd32f..d98f7e9ad 100644 --- a/src/Ryujinx.HLE/FileSystem/ContentManager.cs +++ b/src/Ryujinx.HLE/FileSystem/ContentManager.cs @@ -222,7 +222,9 @@ namespace Ryujinx.HLE.FileSystem FileStream file = new(aoc.ContainerPath, FileMode.Open, FileAccess.Read); using UniqueRef ncaFile = new(); - switch (Path.GetExtension(aoc.ContainerPath)) + // [ROBUSTESSE 20/07] Case-insensitive: a container named "FILE.NSP" (uppercase, seen in + // the wild) silently fell into the default arm and the AOC was never served to the game. + switch (Path.GetExtension(aoc.ContainerPath).ToLowerInvariant()) { case ".xci": XciPartition xci = new Xci(_virtualFileSystem.KeySet, file.AsStorage()).OpenPartition(XciPartitionType.Secure); @@ -425,7 +427,15 @@ namespace Ryujinx.HLE.FileSystem private LocationEntry GetLocation(ulong titleId, NcaContentType contentType, StorageId storageId) { - LinkedList locationList = _locationEntries[storageId]; + // [ROBUSTESSE 20/07] A guest can pass a StorageId we have no entries for (seen in the wild: + // Pokemon Scarlet requesting its AOC data with StorageId.None after GetAocDataStorage failed + // on a scene-made DLC NSP). The callers already handle an empty LocationEntry (same shape + // FirstOrDefault produces when the title is absent), so answer "not found" instead of + // killing the emulator with a KeyNotFoundException. + if (!_locationEntries.TryGetValue(storageId, out LinkedList locationList)) + { + return default; + } return locationList.ToList().FirstOrDefault(x => x.TitleId == titleId && x.ContentType == contentType); } diff --git a/src/Ryujinx.HLE/HOS/Services/Fs/IFileSystemProxy.cs b/src/Ryujinx.HLE/HOS/Services/Fs/IFileSystemProxy.cs index 75d8edfb2..e90414114 100644 --- a/src/Ryujinx.HLE/HOS/Services/Fs/IFileSystemProxy.cs +++ b/src/Ryujinx.HLE/HOS/Services/Fs/IFileSystemProxy.cs @@ -863,7 +863,14 @@ namespace Ryujinx.HLE.HOS.Services.Fs } } - throw new FileNotFoundException($"System archive with titleid {titleId:x16} was not found on Storage {storageId}. Found in {installedStorage}."); + // [ROBUSTESSE 20/07] A guest asking for a data title we cannot resolve (seen in the wild: + // Pokemon Scarlet requesting an AOC whose scene-made NSP has no openable data section) must + // get a filesystem error back, not kill the emulator. The game decides what to do with it + // (most titles handle a missing AOC gracefully). + Logger.Warning?.Print(LogClass.ServiceFs, + $"Data title {titleId:x16} requested on Storage {storageId} could not be resolved; returning PathDoesNotExist to the guest."); + + return ResultCode.PathDoesNotExist; } [CommandCmif(203)] diff --git a/src/Ryujinx/Systems/AppLibrary/ApplicationLibrary.cs b/src/Ryujinx/Systems/AppLibrary/ApplicationLibrary.cs index a397e48cc..098cf11c7 100644 --- a/src/Ryujinx/Systems/AppLibrary/ApplicationLibrary.cs +++ b/src/Ryujinx/Systems/AppLibrary/ApplicationLibrary.cs @@ -596,6 +596,22 @@ namespace Ryujinx.Ava.Systems.AppLibrary if (nca.Header.ContentType == NcaContentType.PublicData) { + // [ROBUSTESSE 20/07] Reject AOC NCAs whose data section cannot actually be + // opened (seen in the wild: scene-made "unlocker" DLC NSPs). Registering them + // only defers the failure to game runtime; skipping here makes the existing + // "no valid DLC in this file" dialog fire at ADD time, while the user watches. + try + { + _ = nca.OpenStorage(NcaSectionType.Data, IntegrityCheckLevel.None); + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, + $"AOC NCA '{fileEntry.FullPath}' in '{filePath}' has no openable data section, skipping. Error: {exception.Message}"); + + continue; + } + titleUpdates.Add(new DownloadableContentModel(nca.Header.TitleId, filePath, fileEntry.FullPath)); } } diff --git a/src/Ryujinx/Systems/DlssUiSettings.cs b/src/Ryujinx/Systems/DlssUiSettings.cs index 257775030..08176c503 100644 --- a/src/Ryujinx/Systems/DlssUiSettings.cs +++ b/src/Ryujinx/Systems/DlssUiSettings.cs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT // Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. // Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. @@ -16,8 +16,8 @@ namespace Ryujinx.Ava.Systems /// translates the saved choice into the RYUJINX_DLSS_* environment variables the (env-var-driven) DLSS /// backend reads, UNLESS those are already set externally (a launcher .bat / dev override wins). /// - /// The DLSS render preset IS a user choice (user decision 05/07: "ça ne doit pas être - /// verrouillé"): the second token stores the nvngx preset value (11=K default, 13=M, 12=L, + /// The DLSS render preset IS a user choice (user decision 05/07: "ça ne doit pas être + /// verrouillé"): the second token stores the nvngx preset value (11=K default, 13=M, 12=L, /// 10=J, 6=F), applied as RYUJINX_DLSS_PRESET at startup; 0/absent = engine defaults. /// /// File format: a "mode [preset]" line. Mode index: 0 = Off, 1 = DLAA, 2 = Quality, @@ -132,15 +132,72 @@ namespace Ryujinx.Ava.Systems return false; } - /// Writes mode index + preset value + FG multiplier + mip-generation flag. Takes effect on the next application launch. - public static void Save(int mode, int presetValue, int fgMultiplier, bool forceMips) + /// [SHARPEN 02/08] Reads the saved post-DLSS sharpening level (0 = off, 1-100 = RCAS strength; + /// 5th token, absent for pre-sharpening-era files). + public static int LoadSharpenLevel() { try { - File.WriteAllText(FilePath, string.Create(CultureInfo.InvariantCulture, $"{mode} {presetValue} {fgMultiplier} {(forceMips ? 1 : 0)}")); + if (File.Exists(FilePath)) + { + string[] parts = File.ReadAllText(FilePath).Trim() + .Split(new[] { ' ', ',', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length > 4 && + int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out int sharpen)) + { + return Math.Clamp(sharpen, 0, 100); + } + } + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"Failed to read DLSS sharpening setting: {ex.Message}"); + } + + return 0; + } + + /// [XC2STACK 03/08] Reads a boolean token by index (6th = advanced motion stack, + /// 7th = motion-blur skip). Absent = false, so pre-existing files keep today's behaviour. + private static bool LoadFlag(int index) + { + try + { + if (File.Exists(FilePath)) + { + string[] parts = File.ReadAllText(FilePath).Trim() + .Split(new[] { ' ', ',', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + return parts.Length > index && parts[index] == "1"; + } + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"Failed to read DLSS UI flag {index}: {ex.Message}"); + } + + return false; + } + + /// [DOFSKIP UI 03/08] The ONE option that TAKES SOMETHING AWAY, hence the only + /// one behind a checkbox (user decision): skipping the Xenoblade-family motion-blur + /// scatter pass removes its Vulkan block artifact AND the blur with it (see MvppDofSkip - + /// journal 226..246 eliminated every shader, input, constant and path by measurement; + /// this is a trade, not a repair). Everything else in the motion stack only changes which + /// camera data is trusted, removes nothing, and ships in the profile. + public static bool LoadDofSkip() => LoadFlag(5); + + /// Writes mode index + preset value + FG multiplier + mip-generation flag + sharpening level + /// + XC2 stack flag. Takes effect on the next application launch. + public static void Save(int mode, int presetValue, int fgMultiplier, bool forceMips, int sharpenLevel, bool dofSkipFlag) + { + try + { + File.WriteAllText(FilePath, string.Create(CultureInfo.InvariantCulture, $"{mode} {presetValue} {fgMultiplier} {(forceMips ? 1 : 0)} {Math.Clamp(sharpenLevel, 0, 100)} {(dofSkipFlag ? 1 : 0)}")); Logger.Info?.Print(LogClass.Application, - $"DLSS UI saved: mode={mode} preset={presetValue} fg={fgMultiplier}x mips={(forceMips ? 1 : 0)} -> {FilePath}"); + $"DLSS UI saved: mode={mode} preset={presetValue} fg={fgMultiplier}x mips={(forceMips ? 1 : 0)} sharpen={sharpenLevel} xc2Stack={(dofSkipFlag ? 1 : 0)} -> {FilePath}"); } catch (Exception ex) { @@ -154,6 +211,47 @@ namespace Ryujinx.Ava.Systems /// Off leaves DLSS disabled. Sets the validated profile: clip-space jitter 1.0 + LOD -0.5. The /// render preset follows the user's UI choice (second token); unset = engine defaults (K). /// + /// [ENVDUMP 03/08] Journalise TOUTES les variables RYUJINX_* presentes dans le + /// processus, triees. Permet de diffuser deux journaux (lancement UI vs lancement bat) + /// et de voir mecaniquement ce qui differe, au lieu de le deduire du code. + // [ENVDUMP] Diagnostic seulement : RYUJINX_ENVDUMP=1. Eteint par defaut - une release + // ne crache pas 24 lignes de variables a chaque demarrage (parti par erreur en 1.2.4). + private static readonly bool _envDump = + Environment.GetEnvironmentVariable("RYUJINX_ENVDUMP") == "1"; + + public static void DumpEnvironment(string moment) + { + if (!_envDump) + { + return; + } + + try + { + var vars = new System.Collections.Generic.SortedDictionary(StringComparer.Ordinal); + + foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables()) + { + string k = (string)e.Key; + if (k.StartsWith("RYUJINX_", StringComparison.Ordinal)) + { + vars[k] = (string)e.Value; + } + } + + Logger.Info?.Print(LogClass.Application, $"ENVDUMP ({moment}) : {vars.Count} variables RYUJINX_*"); + + foreach (var kv in vars) + { + Logger.Info?.Print(LogClass.Application, $"ENVDUMP {kv.Key}={kv.Value}"); + } + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"ENVDUMP failed: {ex.Message}"); + } + } + public static void ApplyAtStartup() { // Runtime mip generation is independent of the DLSS mode; an externally set variable @@ -170,6 +268,10 @@ namespace Ryujinx.Ava.Systems if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("RYUJINX_DLSS"))) { + // [ENVDUMP] Lancement par bat : le profil UI se retire, mais on journalise quand + // meme l'environnement pour pouvoir le comparer a un lancement par l'interface. + DumpEnvironment("lancement par bat, profil UI ignore"); + return; } @@ -214,8 +316,60 @@ namespace Ryujinx.Ava.Systems // 1-in-8 measured too noisy -- zone EMAs breathe). +0.4 ms. Approved 17-18/07. Environment.SetEnvironmentVariable("RYUJINX_MVPP_SKYDRIFT", "1"); Environment.SetEnvironmentVariable("RYUJINX_MVPP_SKYGRID", "1"); + + // [03/08, corrige le meme jour] RYUJINX_MVPP_STATS_STRIDE reste a 4. Retire un moment + // pour gagner en qualite de nuages, il a immediatement coute le seuil vsync sur XC2 + // (60 -> 30 des que la camera bouge, avec Frame Generation) et un tremblement general + // jamais vu auparavant. C'est un reglage de PERFORMANCE : on ne le change pas sans + // mesurer ce qu'il coute, sur un jeu qui tourne deja pres de la limite. Verdict a + // l'oeil de l'utilisateur, dans sa configuration reelle : 4 = parfait, 1 = casse. Environment.SetEnvironmentVariable("RYUJINX_MVPP_STATS_STRIDE", "4"); + // v1.2.4: camera-cut handling, validated at length on Xenoblade Chronicles 2 (01/08: + // election churn 35 -> 4-9, "elastic" mechanism 55% -> 13%, cinematics approved) and + // eye-checked on BOTW + TOTK before shipping (02/08). + // - ELECT_HYST: the camera election holds through a scene change instead of re-electing + // on the first frames of the new scene (the "ghost double image" after cuts). + // - ELECT_WARP: a hard camera warp (teleport) resolves as a clean history reset instead + // of a brief flash of distorted image. + // - SCENECUT_MAX_MOTION: a scene cut is only declared when the frame is nearly still, + // so fast camera motion is never mistaken for a cut. + Environment.SetEnvironmentVariable("RYUJINX_MVPP_ELECT_HYST", "500"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_ELECT_WARP", "1"); + Environment.SetEnvironmentVariable("RYUJINX_DLSS_SCENECUT_MAX_MOTION", "0.01"); + + // [XC2STACK 03/08] Opt-in stack, off unless the user ticks it. Ten refinements of the + // solo-camera path (the one used when a game stores its camera outside the canonical + // triplet), validated over days on Xenoblade Chronicles 2 but unproven elsewhere - + // hence a checkbox rather than a default. Nothing visual is removed by these; they + // only change WHICH camera data the motion-vector stack trusts and when. + // [XC2STACK 03/08] The whole Xenoblade-validated stack behind ONE switch (user decision: + // "un seul bouton avec tout"), off by default so nobody is exposed without asking. + // Ten solo-camera refinements that remove nothing from the image, plus the + // motion-blur scatter skip which IS a trade (blur removed, not repaired). + bool dofSkip = LoadDofSkip(); + if (dofSkip) + { + Environment.SetEnvironmentVariable("RYUJINX_MVPP_VPSOLO", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_DEJITTER", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_ASPECTLOCK", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_SKYREJECT", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_SNAPGUARD", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_SKYCALM", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_MULTIADDR", "1"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_MULTIADDR_MAXROT", "0.5"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_SNAPHOLD", "20"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_SNAPFLOOR", "0.5"); + Environment.SetEnvironmentVariable("RYUJINX_MVPP_NOLDR", "1"); + + // [03/08] RYUJINX_MVPP_DOF_SCATTER_SKIP retire de cette case : les blocs noirs + // qu'il masquait etaient causes par NOTRE bloc de jitter (lecture d'une sortie + // Position non ecrite). Le vrai correctif les supprime a la racine, donc sauter + // la passe de flou ne sert plus a rien - et couter au joueur le flou de mouvement + // du jeu pour masquer un bug qu'on a repare serait absurde. Le gate existe + // toujours (RYUJINX_MVPP_DOF_SCATTER_SKIP=1) pour qui en aurait encore besoin. + } + int fgMultiplier = LoadFgMultiplier(); if (fgMultiplier > 1) { @@ -235,9 +389,26 @@ namespace Ryujinx.Ava.Systems Environment.SetEnvironmentVariable("RYUJINX_DLSS_PRESET", preset.ToString(CultureInfo.InvariantCulture)); } + // [SHARPEN 02/08] Post-DLSS RCAS sharpening (journal (372)): 0 = off, 1-100 = the UI + // slider, same strength mapping as the FSR sharpening slider. Backend gate: + // DlssSharpenPass (linear pre-pass before the final tone-mapped blit). + int sharpen = LoadSharpenLevel(); + if (sharpen > 0) + { + Environment.SetEnvironmentVariable("RYUJINX_DLSS_SHARPEN", "1"); + Environment.SetEnvironmentVariable("RYUJINX_DLSS_SHARPEN_LEVEL", sharpen.ToString(CultureInfo.InvariantCulture)); + } + + // [ENVDUMP 03/08] Ce que le PROCESSUS a reellement en memoire, quelle que soit + // l'origine (profil UI ou bat). Comparer deux journaux vaut mieux que comparer du + // code : c'est la seule facon de prouver qu'un lancement par l'interface et un + // lancement par bat sont identiques - ou de voir exactement ce qui manque. + DumpEnvironment("apres profil UI"); + Logger.Info?.Print(LogClass.Application, $"DLSS UI profile applied: mode={_modeEnv[mode]}, fg={fgMultiplier}x, " + - $"preset={(preset > 0 ? preset.ToString() : "engine default (K)")} (no jitter)."); + $"preset={(preset > 0 ? preset.ToString() : "engine default (K)")}, sharpen={(sharpen > 0 ? sharpen.ToString() : "off")}, " + + $"dofSkip={(dofSkip ? "on" : "off")} (no jitter)."); } } } diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 5dc04db90..224328e89 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Collections; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Media.Imaging; using Avalonia.Threading; @@ -558,7 +558,7 @@ namespace Ryujinx.Ava.UI.ViewModels } } - // DLSS render preset -- a USER choice (decision 05/07: "ça ne doit pas être verrouillé"). + // DLSS render preset -- a USER choice (decision 05/07: "ça ne doit pas être verrouillé"). // Combo index into DlssUiSettings.PresetValues: 0=K (default), 1=M, 2=L, 3=J, 4=F. private int _dlssPresetIndex; public int DlssPresetIndex @@ -578,6 +578,32 @@ namespace Ryujinx.Ava.UI.ViewModels // prompt for every pre-preset-era user (save wrote 11, the file said 0). private bool _dlssPresetWasUnset; + // [SHARPEN 02/08] Nettete post-DLSS (RCAS maison), 0 = off, 1-100 = force du slider. + // Applique au prochain lancement, comme le reste du panneau DLSS. + private int _dlssSharpenLevel; + public int DlssSharpenLevel + { + get => _dlssSharpenLevel; + set + { + _dlssSharpenLevel = Math.Clamp(value, 0, 100); + OnPropertyChanged(); + } + } + + // [XC2STACK 03/08] Pile de mouvement validee sur Xenoblade Chronicles 2, en un seul + // interrupteur (coche = tout marche, decoche = rien). Applique au prochain lancement. + private bool _enableDofSkip; + public bool EnableDofSkip + { + get => _enableDofSkip; + set + { + _enableDofSkip = value; + OnPropertyChanged(); + } + } + // Set true by SaveSettings when the DLSS mode actually changed, so the settings window can offer a // one-click self-restart (the DLSS env vars are only read at app startup). public bool DlssModeRestartPending { get; set; } @@ -969,6 +995,8 @@ namespace Ryujinx.Ava.UI.ViewModels _dlssPresetWasUnset = presetIndex < 0; // after the property write (the setter clears it) DlssFgIndex = Math.Clamp(Ryujinx.Ava.Systems.DlssUiSettings.LoadFgMultiplier() - 1, 0, 2); EnableForceMips = Ryujinx.Ava.Systems.DlssUiSettings.LoadForceMips(); + DlssSharpenLevel = Ryujinx.Ava.Systems.DlssUiSettings.LoadSharpenLevel(); // [SHARPEN] + EnableDofSkip = Ryujinx.Ava.Systems.DlssUiSettings.LoadDofSkip(); // [DOFSKIP] // Audio AudioBackend = (int)config.System.AudioBackend.Value; @@ -1125,8 +1153,10 @@ namespace Ryujinx.Ava.UI.ViewModels DlssModeRestartPending = dlssMode != Ryujinx.Ava.Systems.DlssUiSettings.Load() || dlssPresetValue != Ryujinx.Ava.Systems.DlssUiSettings.LoadPresetValue() || DlssFgIndex + 1 != Ryujinx.Ava.Systems.DlssUiSettings.LoadFgMultiplier() || - EnableForceMips != Ryujinx.Ava.Systems.DlssUiSettings.LoadForceMips(); - Ryujinx.Ava.Systems.DlssUiSettings.Save(dlssMode, dlssPresetValue, DlssFgIndex + 1, EnableForceMips); + EnableForceMips != Ryujinx.Ava.Systems.DlssUiSettings.LoadForceMips() || + DlssSharpenLevel != Ryujinx.Ava.Systems.DlssUiSettings.LoadSharpenLevel() || // [SHARPEN] + EnableDofSkip != Ryujinx.Ava.Systems.DlssUiSettings.LoadDofSkip(); // [DOFSKIP] + Ryujinx.Ava.Systems.DlssUiSettings.Save(dlssMode, dlssPresetValue, DlssFgIndex + 1, EnableForceMips, DlssSharpenLevel, EnableDofSkip); if (ConfigurationState.Instance.Graphics.BackendThreading != (BackendThreading)GraphicsBackendMultithreadingIndex) { diff --git a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml index 020d6f8f5..194ea0e11 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -387,6 +387,27 @@ + + + + + + + +