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 @@ + + + + + + + +