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 000000000..f4ba3a824
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/DlssSharpenLinear.spv differ
diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl
index 65bd0decd..810436b36 100644
--- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl
+++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl
@@ -5,6 +5,10 @@ layout (local_size_x = 64) in;
layout( rgb10_a2, binding = 0, set = 3) uniform image2D imgOutput;
#elif defined(HDR_OUTPUT)
layout( rgba16f, binding = 0, set = 3) uniform image2D imgOutput;
+#elif defined(LINEAR_OUTPUT)
+// [SHARPEN 02/08] Variante nettete post-DLSS : RCAS brut sur float lineaire, AUCUN encodage
+// (le blit final garde le tonemap/PQ). Compilee seule vers DlssSharpenLinear.spv.
+layout( rgba16f, binding = 0, set = 3) uniform image2D imgOutput;
#else
layout( rgba8, binding = 0, set = 3) uniform image2D imgOutput;
#endif
diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.glsl
index b0c27e9a0..f9fb71799 100644
--- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.glsl
+++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.glsl
@@ -102,6 +102,25 @@ layout (binding = 2) uniform params {
float driftX; // SKYDRIFT (v2): accumulated sky CONTENT drift (px/frame, signed) -- the
float driftY; // CPU's 1 Hz read of the sonde sums, EMA-smoothed, fed back as params.
// 0 when unarmed: the addition below is a no-op, path byte-identical.
+ float skyDepth; // [SKYDEPTH 27/07] Window depth at or above which a pixel counts as sky.
+ // 1.0 = the historic behaviour (the far plane and nothing else).
+ //
+ // WHY IT IS A PARAMETER NOW. The sky estimate only ever looked at d >= 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 3b8baad4e..f97e60f0b 100644
Binary files a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv differ
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 000000000..45d663a05
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_depthsonde differ
diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_dynsonde b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_dynsonde
new file mode 100644
index 000000000..5e93c0822
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_dynsonde differ
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 000000000..3e211c3fc
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_flowgate differ
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 000000000..3b8baad4e
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skydepth differ
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 000000000..c36bdd214
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_skyflow differ
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 000000000..1ba10091f
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_avant_subgroup differ
diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_depthsonde_v1 b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_depthsonde_v1
new file mode 100644
index 000000000..5d8b0083f
Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MvppReproject.spv.bak_depthsonde_v1 differ
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 @@
+
+
+
+
+
+
+
+