diff --git a/src/Ryujinx.Graphics.GAL/DlssCameraState.cs b/src/Ryujinx.Graphics.GAL/DlssCameraState.cs index 5259bd871..d9e21c03f 100644 --- a/src/Ryujinx.Graphics.GAL/DlssCameraState.cs +++ b/src/Ryujinx.Graphics.GAL/DlssCameraState.cs @@ -2,6 +2,7 @@ // 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 System.Collections.Concurrent; using System.Collections.Generic; using System.Numerics; @@ -31,11 +32,53 @@ namespace Ryujinx.Graphics.GAL /// /// View-projection of the frame currently being presented, republished at dequeue. - /// Written and read on the render thread; no locking needed on this side. + /// Written by and read on the render thread only; the + /// GPU thread hands values across through the present ring below, never directly. /// public static Matrix4x4 PresentVp; public static bool PresentVpValid; + // 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 + // it while the render thread read it mid-evaluate (torn 64-byte matrix, or frame N + // evaluated with frame N+1's camera whenever the render thread lagged a present behind -- + // 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(); + + /// + /// Enqueues the camera paired with the frame being enqueued for presentation (GPU thread). + /// + public static void PublishPresent(in Matrix4x4 vp, bool valid) + { + _presentRing.Enqueue((vp, valid)); + + // 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 + // fifo below. + while (_presentRing.Count > 4) + { + _presentRing.TryDequeue(out _); + } + } + + /// + /// Dequeues the camera paired with the present being executed and republishes it on + /// /. Render thread, exactly once per + /// executed present. A re-present without a new frame (swapchain recreation) finds the + /// ring empty and keeps the previous values: same camera for the same content. + /// + public static void ConsumePresent() + { + if (_presentRing.TryDequeue(out (Matrix4x4 Vp, bool Valid) entry)) + { + PresentVp = entry.Vp; + PresentVpValid = entry.Valid; + } + } + /// /// Publishes the view-projection captured for the frame being rendered (GPU thread). /// diff --git a/src/Ryujinx.Graphics.GAL/MvppDev.cs b/src/Ryujinx.Graphics.GAL/MvppDev.cs new file mode 100644 index 000000000..56bcf9cb3 --- /dev/null +++ b/src/Ryujinx.Graphics.GAL/MvppDev.cs @@ -0,0 +1,18 @@ +// 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. + +namespace Ryujinx.Graphics.GAL +{ + /// + /// Single switch for the MV++ development diagnostics: heartbeats, per-second stats + /// readbacks and counter log lines. Release default = OFF, where the pipelines run + /// identically and only the instruments go quiet (anything feeding the UI, like the FG + /// counter poll, keeps running silently). RYUJINX_MVPP_DEV=1 brings every gauge back. + /// + public static class MvppDev + { + public static readonly bool Enabled = + System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_DEV") == "1"; + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs index aac7f7c25..2189b0cc7 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/DrawManager.cs @@ -482,6 +482,9 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed MvppFinalDrawProbe.OnDrawTexture(_channel, texture); // read-only: DrawTexture bypasses DrawEnd (gated) + float gSrcX0 = srcX0, gSrcY0 = srcY0, gSrcX1 = srcX1, gSrcY1 = srcY1; + float gDstX0 = dstX0, gDstY0 = dstY0, gDstX1 = dstX1, gDstY1 = dstY1; + srcX0 *= texture.EffectiveScaleX; srcY0 *= texture.EffectiveScaleY; srcX1 *= texture.EffectiveScaleX; @@ -495,6 +498,21 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed dstX1 *= dstScaleX; dstY1 *= dstScaleY; + bool anyScaled = (texture != null && (texture.EffectiveScaleX != 1f || texture.EffectiveScaleY != 1f)) || + dstScaleX != 1f || dstScaleY != 1f; + + if (anyScaled) + { + Image.MvppSeamProbe.OnScaledCopy( // read-only edge-hit tracer (gated); the bound RT is not resolvable here + "DRAWTEX", + texture, null, + gSrcX0, gSrcY0, gSrcX1, gSrcY1, + gDstX0, gDstY0, gDstX1, gDstY1, + srcX0, srcY0, srcX1, srcY1, + dstX0, dstY0, dstX1, dstY1, + "sampler"); + } + _context.Renderer.Pipeline.DrawTexture( texture?.HostTexture, sampler?.GetHostSampler(texture), diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs index c7c65f93d..64f75e46b 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/MvppCameraCapture.cs @@ -118,7 +118,10 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed private static readonly bool _pairProbe = Environment.GetEnvironmentVariable("RYUJINX_MVPP_PAIRPROBE") == "1"; - private static ulong _probeLastFp; + // Change detection on the 0xC0 camera block: raw byte compare against the last copy. + // Vectorized SequenceEqual beats hashing all 192 bytes on every scaled draw, and an + // exact compare cannot collide. + private static readonly byte[] _probeLastBlock = new byte[0xC0]; private static bool _probeHasFp; private static int _probeChanges; private static readonly int[] _probeHist = new int[4]; @@ -604,16 +607,10 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed ReadOnlySpan block = channel.MemoryManager.Physical.GetSpan(address + (ulong)_cachedOffset, 0xC0); - ulong fp = 14695981039346656037UL; // FNV-1a - foreach (byte b in block) - { - fp = (fp ^ b) * 1099511628211UL; - } - - if (!_probeHasFp || fp != _probeLastFp) + if (!_probeHasFp || !block.SequenceEqual(_probeLastBlock)) { _probeHasFp = true; - _probeLastFp = fp; + block.CopyTo(_probeLastBlock); _statBlockChanges++; if (_pairProbe) @@ -978,7 +975,7 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed _lastPosZ = camPosZ; long now = Environment.TickCount64; - if (now - _lastHeartbeatMs >= 5000) + if (GAL.MvppDev.Enabled && now - _lastHeartbeatMs >= 5000) { _lastHeartbeatMs = now; Logger.Info?.Print(LogClass.Gpu, diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs index ff928c8fa..481a814ee 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs @@ -907,10 +907,21 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed float scaleY = _channel.TextureManager.RenderTargetScaleY; if (scaleX != 1f || scaleY != 1f) { + int gx = x, gy = y, gw = width, gh = height; + x = (int)(x * scaleX); y = (int)(y * scaleY); width = (int)MathF.Ceiling(width * scaleX); height = (int)MathF.Ceiling(height * scaleY); + + Image.MvppSeamProbe.OnScaledCopy( // read-only edge-hit tracer (gated); truncated pos + ceiled size = the +-1px seam suspect + "SCISSOR", + null, null, + gx, gy, gx + gw, gy + gh, + gx, gy, gx + gw, gy + gh, + x, y, x + width, y + height, + x, y, x + width, y + height, + "n/a"); } regions[index] = new Rectangle(x, y, width, height); diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs b/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs index 22962690b..c77aee330 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Twod/TwodClass.cs @@ -385,6 +385,17 @@ namespace Ryujinx.Graphics.Gpu.Engine.Twod bool linearFilter = _state.State.SetPixelsFromMemorySampleModeFilter == SetPixelsFromMemorySampleModeFilter.Bilinear; + Image.MvppSeamProbe.OnScaledCopy( // read-only edge-hit tracer (gated) + "2D-BLIT", + srcTexture, dstTexture, + srcX1 / srcTexture.Info.SamplesInX, srcY1 / srcTexture.Info.SamplesInY, + srcX2 / srcTexture.Info.SamplesInX, srcY2 / srcTexture.Info.SamplesInY, + dstX1 / dstTexture.Info.SamplesInX, dstY1 / dstTexture.Info.SamplesInY, + dstX2 / dstTexture.Info.SamplesInX, dstY2 / dstTexture.Info.SamplesInY, + srcRegion.X1, srcRegion.Y1, srcRegion.X2, srcRegion.Y2, + dstRegion.X1, dstRegion.Y1, dstRegion.X2, dstRegion.Y2, + linearFilter ? "linear" : "point"); + srcTexture.HostTexture.CopyTo(dstTexture.HostTexture, srcRegion, dstRegion, linearFilter); dstTexture.SignalModified(); diff --git a/src/Ryujinx.Graphics.Gpu/Image/MvppSeamProbe.cs b/src/Ryujinx.Graphics.Gpu/Image/MvppSeamProbe.cs new file mode 100644 index 000000000..f5e3a6c79 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Image/MvppSeamProbe.cs @@ -0,0 +1,221 @@ +// 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.Image +{ + /// + /// [SEAMPROBE, READ-ONLY, default OFF] Residual x3 seam hunt: logs every SCALED copy/draw + /// whose source or destination rectangle has an EDGE at a watched guest-space coordinate. + /// A full-surface copy cannot create an interior line, so only edge hits matter; the watched + /// coordinates come from the dump analysis (seamscan), never hard-coded per game. + /// + /// Enable with RYUJINX_MVPP_SEAMPROBE="x60,y727,y810,y844" (guest coords, axis-prefixed); + /// RYUJINX_MVPP_SEAMPROBE_TOL widens the edge match (default 2 guest pixels). + /// Call sites: the 2D blit engine (TwodClass) and DrawTexture (DrawManager) -- the only two + /// paths that copy scaled SUB-regions (texture-cache family copies are full-slice). + /// Never modifies textures, regions or draws. + /// + static class MvppSeamProbe + { + private const int MaxShapes = 400; // stop logging new shapes past this (bounds a pathological run) + + private static readonly int[] _watchX; + private static readonly int[] _watchY; + private static readonly int _tol; + private static bool _enabled; + + private static readonly HashSet _seenShapes = new(); + private static int _matches; + private static long _summaryMs; + + static MvppSeamProbe() + { + string spec = Environment.GetEnvironmentVariable("RYUJINX_MVPP_SEAMPROBE"); + + if (string.IsNullOrEmpty(spec)) + { + _watchX = _watchY = Array.Empty(); + return; + } + + List xs = new(), ys = new(); + + foreach (string token in spec.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (token.Length > 1 && int.TryParse(token.AsSpan(1), out int v)) + { + if (char.ToLowerInvariant(token[0]) == 'x') + { + xs.Add(v); + continue; + } + + if (char.ToLowerInvariant(token[0]) == 'y') + { + ys.Add(v); + continue; + } + } + + Logger.Warning?.Print(LogClass.Gpu, $"SEAMPROBE: ignored malformed token '{token}' (want x or y)"); + } + + _watchX = xs.ToArray(); + _watchY = ys.ToArray(); + _tol = int.TryParse(Environment.GetEnvironmentVariable("RYUJINX_MVPP_SEAMPROBE_TOL"), out int t) + ? Math.Clamp(t, 0, 32) + : 2; + _enabled = _watchX.Length + _watchY.Length > 0; + + if (_enabled) + { + Logger.Info?.Print(LogClass.Gpu, + $"SEAMPROBE armed: x=[{string.Join(",", _watchX)}] y=[{string.Join(",", _watchY)}] tol={_tol}"); + } + } + + private static bool _announced; + + /// + /// Prints the probe's status once, from a site that always runs (TextureManager creation). + /// Without this, a run where no call site ever fires is indistinguishable from a run + /// where the environment variable was never set (13/07: two silent logs in a row). + /// + public static void Announce() + { + if (_announced) + { + return; + } + + _announced = true; + Logger.Info?.Print(LogClass.Gpu, + $"SEAMPROBE status: enabled={_enabled} x=[{string.Join(",", _watchX)}] y=[{string.Join(",", _watchY)}] tol={_tol}"); + } + + /// + /// Inspects one scaled copy/draw. Guest rectangles are PRE-scale (the space the watched + /// coordinates live in); host rectangles are what actually reaches the backend. + /// + public static void OnScaledCopy( + string op, + Texture src, Texture dst, + float srcGx1, float srcGy1, float srcGx2, float srcGy2, + float dstGx1, float dstGy1, float dstGx2, float dstGy2, + float srcHx1, float srcHy1, float srcHx2, float srcHy2, + float dstHx1, float dstHy1, float dstHx2, float dstHy2, + string filter) + { + if (!_enabled) + { + return; + } + + // A diagnostic on the GPU thread must never take the process down. + try + { + // Unscaled ops cannot produce a scaling seam. When one side is unknown (null, + // e.g. the bound render target of a DrawTexture), the call site gates instead. + if (src != null && dst != null && + src.EffectiveScaleX == 1f && src.EffectiveScaleY == 1f && + dst.EffectiveScaleX == 1f && dst.EffectiveScaleY == 1f) + { + return; + } + + string hit = EdgeHit(srcGx1, srcGx2, dstGx1, dstGx2, _watchX, "x") + ?? EdgeHit(srcGy1, srcGy2, dstGy1, dstGy2, _watchY, "y"); + + if (hit == null) + { + return; + } + + _matches++; + + long shape = Fnv( + (ulong)op.GetHashCode(), + src?.Range.GetSubRange(0).Address ?? 0, + dst?.Range.GetSubRange(0).Address ?? 0, + ((long)(int)srcGx1 << 32) ^ (int)srcGy1, + ((long)(int)srcGx2 << 32) ^ (int)srcGy2, + ((long)(int)dstGx1 << 32) ^ (int)dstGy1, + ((long)(int)dstGx2 << 32) ^ (int)dstGy2); + + if (_seenShapes.Count < MaxShapes && _seenShapes.Add(shape)) + { + Logger.Info?.Print(LogClass.Gpu, + $"SEAMPROBE {op} hit={hit} filter={filter} " + + $"src={Describe(src)} g[{srcGx1:0.#},{srcGy1:0.#},{srcGx2:0.#},{srcGy2:0.#}] h[{srcHx1:0.#},{srcHy1:0.#},{srcHx2:0.#},{srcHy2:0.#}] -> " + + $"dst={Describe(dst)} g[{dstGx1:0.#},{dstGy1:0.#},{dstGx2:0.#},{dstGy2:0.#}] h[{dstHx1:0.#},{dstHy1:0.#},{dstHx2:0.#},{dstHy2:0.#}]"); + } + + long now = Environment.TickCount64; + if (now - _summaryMs >= 5000) + { + _summaryMs = now; + Logger.Info?.Print(LogClass.Gpu, + $"SEAMPROBE summary: matches={_matches} shapes={_seenShapes.Count}"); + } + } + catch (Exception e) + { + _enabled = false; + Logger.Warning?.Print(LogClass.Gpu, $"SEAMPROBE: disabled after unexpected error: {e}"); + } + } + + /// Names the first watched coordinate an edge lands on, or null. + private static string EdgeHit(float sA, float sB, float dA, float dB, int[] watch, string axis) + { + foreach (int w in watch) + { + if (Math.Abs(sA - w) <= _tol || Math.Abs(sB - w) <= _tol) + { + return $"{axis}{w}(src)"; + } + + if (Math.Abs(dA - w) <= _tol || Math.Abs(dB - w) <= _tol) + { + return $"{axis}{w}(dst)"; + } + } + + return null; + } + + private static string Describe(Texture t) + { + if (t == null) + { + return "null"; + } + + return $"@0x{t.Range.GetSubRange(0).Address:X}/{t.Info.FormatInfo.Format}/{t.Info.Width}x{t.Info.Height}" + + $"/lvl{t.FirstLevel}+{t.Info.Levels}/eff({t.EffectiveScaleX:0.####},{t.EffectiveScaleY:0.####})"; + } + + private static long Fnv(params ulong[] vals) + { + unchecked + { + ulong x = 14695981039346656037UL; + foreach (ulong v in vals) + { + x = (x ^ v) * 1099511628211UL; + } + return (long)x; + } + } + + private static long Fnv(ulong a, ulong b, ulong c, long d, long e, long f, long g) + { + return Fnv(a, b, c, (ulong)d, (ulong)e, (ulong)f, (ulong)g); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Image/Texture.cs b/src/Ryujinx.Graphics.Gpu/Image/Texture.cs index 4ce5f07b1..464d4791e 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/Texture.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/Texture.cs @@ -557,7 +557,7 @@ namespace Ryujinx.Graphics.Gpu.Image } } - if (nowMs - _forceMipsSummaryMs > 5000) + if (GAL.MvppDev.Enabled && nowMs - _forceMipsSummaryMs > 5000) { _forceMipsSummaryMs = nowMs; diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs index 553e41060..ece097e7f 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs @@ -374,6 +374,11 @@ namespace Ryujinx.Graphics.Gpu.Image case ShaderStage.Fragment: // The exact (quantized) host/guest ratio, not the nominal scale: // shader coordinate compensation must land on the real content size. + // KNOWN LIMIT: this support-buffer slot is a single scalar, so Y + // compensates with X's quantized ratio (they can differ by the + // size-dependent mip pad). Sub-texel error; separating them means a + // support-buffer layout + shader codegen change (invalidates every + // shader cache) -> deliberately parked as its own dossier. float scale = texture.EffectiveScaleX; if (scale != 1) diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs index 87a30eb86..522cce3d0 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs @@ -46,6 +46,9 @@ namespace Ryujinx.Graphics.Gpu.Image /// public float RenderTargetScaleY { get; private set; } = 1f; + // [COUTURE PROBE -- TEMPORARY, DISPOSABLE] throttle for the edge-seam hunt log. + private long _coutureLogMs; + /// /// Creates a new instance of the texture manager. /// @@ -68,6 +71,8 @@ namespace Ryujinx.Graphics.Gpu.Image _rtColors = new Texture[Constants.TotalRenderTargets]; _rtHostColors = new ITexture[Constants.TotalRenderTargets]; _rtColorsBound = new bool[Constants.TotalRenderTargets]; + + MvppSeamProbe.Announce(); // one status line per boot, even if no call site ever fires } /// @@ -200,6 +205,22 @@ namespace Ryujinx.Graphics.Gpu.Image return texture != null && !(texture.ScaleMode == TextureScaleMode.Blacklisted || texture.ScaleMode == TextureScaleMode.Undesired) && texture.ScaleFactor != GraphicsConfig.ResScale; } + /// + /// [DLSS-SR fractional] Nominal equality is not enough to skip the scale update: two + /// targets can share the nominal scale yet carry different quantized host/guest ratios + /// (the mip-align pad is size-dependent, e.g. a 1600x900 and a 1280x720 pass). Switching + /// between them without re-deriving the group ratio leaves viewport/scissor covering the + /// OTHER pass's allocation: the last column/row is never rasterized (edge flicker). + /// + /// The texture being bound as a render target + /// True if the texture's exact ratio disagrees with the current group ratio + private bool EffectiveScaleMismatch(Texture texture) + { + return TextureCache.DlssSrSpike && + texture.ScaleMode == TextureScaleMode.Scaled && + (texture.EffectiveScaleX != RenderTargetScaleX || texture.EffectiveScaleY != RenderTargetScaleY); + } + /// /// Sets the render target color buffer. /// @@ -209,7 +230,10 @@ namespace Ryujinx.Graphics.Gpu.Image public bool SetRenderTargetColor(int index, Texture color) { bool hasValue = color != null; - bool changesScale = (hasValue != (_rtColors[index] != null)) || (hasValue && RenderTargetScale != color.ScaleFactor); + // [P1.5 RE-APPLIED 12/07 soir, A/B user] First in-game read blamed this for the seam + // flicker, but the pre-v1.2.1 binaries flicker too (user A/B) -- verdict reopened. + // User hypothesis: WITH this fix, Quality mode no longer showed the seam. Testing. + bool changesScale = (hasValue != (_rtColors[index] != null)) || (hasValue && (RenderTargetScale != color.ScaleFactor || EffectiveScaleMismatch(color))); if (_rtColors[index] != color) { @@ -242,7 +266,7 @@ namespace Ryujinx.Graphics.Gpu.Image public bool SetRenderTargetDepthStencil(Texture depthStencil) { bool hasValue = depthStencil != null; - bool changesScale = (hasValue != (_rtDepthStencil != null)) || (hasValue && RenderTargetScale != depthStencil.ScaleFactor); + bool changesScale = (hasValue != (_rtDepthStencil != null)) || (hasValue && (RenderTargetScale != depthStencil.ScaleFactor || EffectiveScaleMismatch(depthStencil))); // [P1.5 RE-APPLIED, A/B user] see SetRenderTargetColor if (_rtDepthStencil != depthStencil) { @@ -449,6 +473,40 @@ namespace Ryujinx.Graphics.Gpu.Image RenderTargetScaleX = scaleX; RenderTargetScaleY = scaleY; + + // [COUTURE PROBE -- TEMPORARY, DISPOSABLE] Edge-seam hunt (12/07): whenever the chosen + // group ratio disagrees with a bound target's exact ratio, the scissor covers + // ceil(guestW x group) while that target really spans guestW x effective -- the + // uncovered last columns ARE the black line. Print the exact numbers, throttled ~1/s. + // covX/covY = covered/needed host pixels; the difference is the line's width. + if (TextureCache.DlssSrSpike && targetScale != 1f && + Environment.TickCount64 - _coutureLogMs >= 1000) + { + void Report(Texture target, string slot) + { + if (target == null || target.ScaleMode != TextureScaleMode.Scaled) + { + return; + } + + if (target.EffectiveScaleX != scaleX || target.EffectiveScaleY != scaleY) + { + _coutureLogMs = Environment.TickCount64; + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"COUTURE: {slot} {target.Info.Width}x{target.Info.Height} eff=({target.EffectiveScaleX:0.00000},{target.EffectiveScaleY:0.00000}) " + + $"vs group=({scaleX:0.00000},{scaleY:0.00000}) nominal={targetScale:0.00000} singleUse={singleUse} " + + $"covX={(int)MathF.Ceiling(target.Info.Width * scaleX)}/{(int)MathF.Ceiling(target.Info.Width * target.EffectiveScaleX)} " + + $"covY={(int)MathF.Ceiling(target.Info.Height * scaleY)}/{(int)MathF.Ceiling(target.Info.Height * target.EffectiveScaleY)}"); + } + } + + for (int i = 0; i < _rtColors.Length; i++) + { + Report(_rtColors[i], $"color{i}"); + } + + Report(_rtDepthStencil, "depth"); + } } /// diff --git a/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs b/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs index e59f8571a..317407881 100644 --- a/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs +++ b/src/Ryujinx.Graphics.Gpu/Shader/DiskCache/DiskCacheHostStorage.cs @@ -322,12 +322,12 @@ namespace Ryujinx.Graphics.Gpu.Shader.DiskCache throw new DiskCacheLoadException(DiskCacheLoadResult.IncompatibleVersion); } - // MV++ Phase 2.1 run A: the position-slice analysis only runs at translation - // time, so the probe flag forces the guest-recompile path (the same one every - // codegen version bump takes) -- host cache is skipped, every shader gets - // re-translated and analyzed once, the cache is refreshed with identical code. - bool loadHostCache = header.CodeGenVersion == CodeGenVersion && - !Ryujinx.Graphics.Shader.Translation.MvppPositionProbe.Enabled; + // MV++ shader-codegen probes: any flag that changes or instruments the generated + // host code must take the guest-recompile path (the same one every codegen version + // bump takes). Loading would serve shaders built with the OTHER setting when the + // flag was toggled between launches -- the stale-instrumentation trap. The write + // side is guarded in AddShader for the same reason. + bool loadHostCache = header.CodeGenVersion == CodeGenVersion && !_mvppCodegenProbeActive; int programIndex = 0; @@ -562,8 +562,27 @@ namespace Ryujinx.Graphics.Gpu.Shader.DiskCache /// Cached program /// Optional host binary code /// Output streams to use + // MV++ shader-codegen probes that alter or instrument the generated host code. While any + // of them is active the shared host cache must be neither loaded (stale code from the + // other setting) nor written (instrumented code would poison the next probe-less launch). + private static readonly bool _mvppCodegenProbeActive = + Ryujinx.Graphics.Shader.Translation.MvppPositionProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvppSurviveProbe.Enabled || + Ryujinx.Graphics.Shader.Translation.MvppSurviveProbe.MipMarkEnabled || + Ryujinx.Graphics.Shader.Translation.MvppSurviveProbe.BufferEnabled || + Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.ProbeEnabled || + Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.RewriteEnabled || + Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.CaptureEnabled || + Ryujinx.Graphics.Shader.Translation.MvppHashedAlpha.LodBiasEnabled; + public void AddShader(GpuContext context, CachedShaderProgram program, ReadOnlySpan hostCode, DiskCacheOutputStreams streams = null) { + // Probe builds never persist: their generated code only matches while the flag is on. + if (_mvppCodegenProbeActive) + { + return; + } + uint stagesBitMask = 0; for (int index = 0; index < program.Shaders.Length; index++) diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs index fd7048123..b723662cc 100644 --- a/src/Ryujinx.Graphics.Gpu/Window.cs +++ b/src/Ryujinx.Graphics.Gpu/Window.cs @@ -122,6 +122,7 @@ namespace Ryujinx.Graphics.Gpu private int _mvppTakes; private int _mvppTakeHits; private Image.Texture _mvppHeldSceneDepth; + private int _mvppHeldSceneDepthSeq; private long _colorPubDiagMs; // [COLORCAP snapshot] throttled publish diagnostic public bool IsFrameAvailable => _framesAvailable != 0; @@ -320,10 +321,10 @@ namespace Ryujinx.Graphics.Gpu // the queue with the frame. if (DlssCameraState.FifoEnabled) { - DlssCameraState.PresentVpValid = DlssCameraState.TryConsumeOrdered(out System.Numerics.Matrix4x4 orderedVp); - DlssCameraState.PresentVp = orderedVp; + bool orderedValid = DlssCameraState.TryConsumeOrdered(out System.Numerics.Matrix4x4 orderedVp); + DlssCameraState.PublishPresent(in orderedVp, orderedValid); - if (Environment.TickCount64 - _mvppFifoLogMs >= 5000) + if (MvppDev.Enabled && Environment.TickCount64 - _mvppFifoLogMs >= 5000) { _mvppFifoLogMs = Environment.TickCount64; Logger.Info?.Print(LogClass.Gpu, @@ -338,8 +339,7 @@ namespace Ryujinx.Graphics.Gpu } else { - DlssCameraState.PresentVp = pt.CameraVp; - DlssCameraState.PresentVpValid = pt.CameraVpValid; + DlssCameraState.PublishPresent(pt.CameraVp, pt.CameraVpValid); } // MV++: prefer the depth pinned at the scaled main pass (deterministic) over the @@ -358,10 +358,14 @@ namespace Ryujinx.Graphics.Gpu if (scene != null) { _mvppHeldSceneDepth = scene; + _mvppHeldSceneDepthSeq = scene.InvalidatedSequence; } - else if (_mvppHeldSceneDepth?.HostTexture == null) + else if (_mvppHeldSceneDepth != null && _mvppHeldSceneDepth.InvalidatedSequence != _mvppHeldSceneDepthSeq) { - _mvppHeldSceneDepth = null; // disposed under us: drop the stale hold + // Drop the stale hold. HostTexture is never nulled on dispose (stock keeps the + // dead reference), so testing it can NOT detect destruction; InvalidatedSequence + // is the stock validity idiom and bumps on dispose, unmap AND host replacement. + _mvppHeldSceneDepth = null; } presentDepth = _mvppHeldSceneDepth; @@ -376,7 +380,7 @@ namespace Ryujinx.Graphics.Gpu } // MV++ diag: what depth is published at present time? (heartbeat, capture runs only) - if (DlssCameraState.Enabled && Environment.TickCount64 - _mvppDepthLogMs >= 5000) + if (MvppDev.Enabled && DlssCameraState.Enabled && Environment.TickCount64 - _mvppDepthLogMs >= 5000) { _mvppDepthLogMs = Environment.TickCount64; Image.Texture d = presentDepth; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs index e2037e65f..cad89b031 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs @@ -36,6 +36,64 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public const string StreamlineReflex = "sl.reflex.dll"; public const string StreamlinePcl = "sl.pcl.dll"; + // Memoized auto-scan result. The library scan walks every game folder of every launcher + // on every drive - hundreds of directories on every single launch when the DLL is not in + // the BYO folder - and the winning path almost never moves. Remember it, validate it + // still exists, and only rescan when it vanished. The BYO folder is checked first every + // time, so dropping a DLL there always overrides the cache. + private static string PathCacheFile + { + get + { + try + { + return Path.Combine(Common.Configuration.AppDataManager.BaseDirPath, "dlss_path.cfg"); + } + catch + { + return null; + } + } + } + + private static string ReadCachedNgxPath() + { + try + { + string cacheFile = PathCacheFile; + if (cacheFile != null && File.Exists(cacheFile)) + { + string cached = File.ReadAllText(cacheFile).Trim(); + if (cached.Length > 0 && File.Exists(cached)) + { + return cached; + } + } + } + catch + { + // Unreadable cache = no cache; the scan below rebuilds it. + } + + return null; + } + + private static void WriteCachedNgxPath(string path) + { + try + { + string cacheFile = PathCacheFile; + if (cacheFile != null) + { + File.WriteAllText(cacheFile, path); + } + } + catch + { + // Not fatal: next launch just scans again. + } + } + /// /// Returns the path to a usable nvngx_dlss.dll, or null if none could be found. /// @@ -58,7 +116,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return null; } - // 2. Auto-scan known locations and pick the newest version found. + // 2. A still-valid memoized result skips the library walk entirely. + string cached = ReadCachedNgxPath(); + if (cached != null) + { + return cached; + } + + // 3. Auto-scan known locations and pick the newest version found. string best = null; Version bestVersion = null; @@ -76,6 +141,11 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } } + if (best != null) + { + WriteCachedNgxPath(best); + } + return best; } diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs index 6580a56d6..7fa42eacb 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs @@ -37,6 +37,26 @@ namespace Ryujinx.Graphics.Vulkan.Dlss /// True when the user opted into DLSS via RYUJINX_DLSS=1. public static bool IsEnabled => _enabled; + /// + /// True when the DLSS-SR fractional path (RYUJINX_DLSS_SR=1) is active. Read by + /// TextureStorage to zero fresh image allocations at birth: the memory suballocator + /// recycles freed device memory within the process, and on this path a render target + /// can reach DLSS (and the screen) before the game's own writes cover the quantized + /// allocation -- the recycled bytes are then a recognizable earlier frame (the + /// startup ghost image in Quality). Off this path the flag stays false and texture + /// creation is byte-identical to upstream. + /// + public static bool SrFractional => _srFractional; + + /// + /// RYUJINX_DLSS_NOBIRTHCLEAR=1: diagnostic kill-switch for the TEXTURE birth-clear only + /// (the swapchain birth-clear stays on -- it owns the validated FG startup-ghost fix). + /// A/B for the 13/07 question: does zeroing fresh allocations HARDEN the fractional-path + /// black line (the uncovered rim used to show soft recycled memory, now pure black)? + /// + public static readonly bool BirthClearDisabled = + IsTruthy(Environment.GetEnvironmentVariable("RYUJINX_DLSS_NOBIRTHCLEAR")); + // 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/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 663d8d5d1..796291b85 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -597,6 +597,11 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // resets once instead of every frame (which would leave the image permanently aliased). private BufferHandle _sceneChangeBuffer; private bool _wasChanging; + + // [SKYFLASH] True once the motion meter has measured real motion at least once this + // session; a "low motion" scene-cut verdict means nothing before that (the MVs simply + // have not started flowing), and acting on it resets history on the FIRST camera move. + private bool _motionMeterWarm; private int _framesSinceReset; private long _lastResetMs = Environment.TickCount64; // cooldown counts from DLSS start (huge value = never reset this session) private string _lastSizeSig; // zoom dossier: last logged (input, mode, output) triple @@ -797,6 +802,18 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return false; } + // [FG lifetime] one tick per present: dispose parked views once dlfg's queue has drained. + _presentSerial++; + DrainFgRetired(all: false); + + // MV++: pick up the camera paired with THIS frame (enqueued per present by the GPU + // thread; order does the cross-thread pairing). Consumed HERE and not in the Vulkan + // window's Present: once the DLSS-G swapchain proxy engages, that entry stops running + // every frame and the ring starves -- the frozen-camera regression of 12/07 evening + // (consumer saw 0 changes while the capture published dozens). This method provably + // runs once per evaluated frame: its consumer stats count every present. + DlssCameraState.ConsumePresent(); + // MV++ Phase 1 plumbing proof: the presented frame's camera view-projection arrives // 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 @@ -1252,7 +1269,19 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // every 6s, measured: changed~85% but motion 3-4.5%), while still catching genuine cuts (motion // near 0). This is the dominant flicker fix. _framesSinceReset++; + + // [SKYFLASH 13/07] Trust a low-motion verdict only after the meter has measured real + // motion once. Measured on the user's run: the session's ONLY reset fired at the FIRST + // camera move (changed=91% motion=0.2%) -- the change metric saturates while the MVs + // have not started flowing yet -> false scene cut -> one-frame history reset = the + // startup sky flash. The real session start is already covered by the first-frame reset. + if (motionFraction >= _sceneCutMaxMotion) + { + _motionMeterWarm = true; + } + bool sceneCut = _hasPrev && changing && !_wasChanging + && _motionMeterWarm && motionFraction < _sceneCutMaxMotion && _framesSinceReset >= ResetCooldownFrames && Environment.TickCount64 - _lastResetMs >= (long)_resetCooldownMs; @@ -1670,9 +1699,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss System.Numerics.Matrix4x4 prevT = System.Numerics.Matrix4x4.Transpose(vpPrev); // Log the PREVIOUS frame's agreement stats ~1/s (host-mapped buffer, no GPU stall), - // then clear the counters for this frame's dispatch. + // then clear the counters for this frame's dispatch. Dev gauge: with MvppDev off the + // stats are never read, so the whole read/zero cycle is skipped (release default). long nowMs = Environment.TickCount64; - if (nowMs - _mvppStatsLogMs >= 1000) + if (MvppDev.Enabled && nowMs - _mvppStatsLogMs >= 1000) { _mvppStatsLogMs = nowMs; @@ -1712,8 +1742,11 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } } - Span statsZero = stackalloc uint[MvppStatsCount]; - _gd.BufferManager.SetData(_mvppStatsBuffer, 0, statsZero); + if (MvppDev.Enabled) + { + Span statsZero = stackalloc uint[MvppStatsCount]; + _gd.BufferManager.SetData(_mvppStatsBuffer, 0, statsZero); + } // Window-depth -> NDC-z convention: from the guest's viewport transform unless the // env override forces it. Rotation MVs are immune to a wrong mapping, translation @@ -3147,6 +3180,44 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return crc; } + // [FG lifetime] DLSS-G's present workers read the tagged depth/mvec views on their own + // command streams, invisible to our fence tracking. Destroying a view the instant we + // recreate it (dyn-res ramp) can pull it from under an in-flight interpolation: + // intermittent DEVICE_LOST. With FG loaded, park old views and dispose them a few + // presents later, once dlfg's queue depth has certainly drained. FG off = immediate + // dispose, exactly the previous behavior. + private readonly System.Collections.Generic.List<(TextureView View, long RetireAfter)> _fgRetired = new(); + private long _presentSerial; + + private void RetireFgVisible(TextureView view) + { + if (view == null) + { + return; + } + + if (_gd.FgHooks != null) + { + _fgRetired.Add((view, _presentSerial + 4)); + } + else + { + view.Dispose(); + } + } + + private void DrainFgRetired(bool all) + { + for (int i = _fgRetired.Count - 1; i >= 0; i--) + { + if (all || _fgRetired[i].RetireAfter <= _presentSerial) + { + _fgRetired[i].View.Dispose(); + _fgRetired.RemoveAt(i); + } + } + } + private void EnsureResources(TextureView input, int outW, int outH, CommandBufferScoped cbs) { if (_output != null && _inW == input.Width && _inH == input.Height && _outW == outW && _outH == outH) @@ -3160,10 +3231,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss if (_dlssDynRes && _output != null && _outW == outW && _outH == outH && (_inW != input.Width || _inH != input.Height)) { - _depth?.Dispose(); - _motion?.Dispose(); - _motionFiltered?.Dispose(); - _prevColor?.Dispose(); + RetireFgVisible(_depth); + RetireFgVisible(_motion); + RetireFgVisible(_motionFiltered); + RetireFgVisible(_prevColor); _heldDepth = null; _hasHeldDepth = false; @@ -3181,11 +3252,11 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return; } - _output?.Dispose(); - _depth?.Dispose(); - _motion?.Dispose(); - _motionFiltered?.Dispose(); - _prevColor?.Dispose(); // was leaked on every reallocation (dynamic-resolution games churn this) + RetireFgVisible(_output); + RetireFgVisible(_depth); + RetireFgVisible(_motion); + RetireFgVisible(_motionFiltered); + RetireFgVisible(_prevColor); // was leaked on every reallocation (dynamic-resolution games churn this) // E9b: _heldDepth points INTO the per-size cache -- never dispose entries during // gameplay (in-flight CB race); just drop the reference, the cache reuses/keeps them. _heldDepth = null; @@ -3260,11 +3331,13 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public void Dispose() { + DrainFgRetired(all: true); _output?.Dispose(); _depth?.Dispose(); _motion?.Dispose(); _motionFiltered?.Dispose(); _prevColor?.Dispose(); + _injectTintTex?.Dispose(); // [TINTPROBE] was the one view Dispose() forgot _injectDepthZero?.Dispose(); _injectMotionZero?.Dispose(); _injectMotion?.Dispose(); // [C4] scene-res reproj MV + its shader-required aux images diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs index 03361d1ca..a0c96a243 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs @@ -294,11 +294,33 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // 13=M (newer transformer -- preset-M chantier). When the env is set EXPLICITLY it now also // overrides the DLAA slot (preset selectable end-to-end, per external user request); without // it, defaults are untouched: DLAA pinned to K, upscaling modes K. - private static readonly bool _presetEnvSet = - uint.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_PRESET"), out _); + private static readonly uint? _presetEnv = ParsePresetEnv(); - private static readonly uint _upscalePreset = - uint.TryParse(System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_PRESET"), out uint _p) ? _p : 11u; + private static readonly bool _presetEnvSet = _presetEnv.HasValue; + + private static readonly uint _upscalePreset = _presetEnv ?? 11u; + + // Validated: the shipped Streamline's DLSSPreset enum stops at 13 (M). An undefined value + // (99, garbage) would go straight to slDLSSSetOptions with driver-undefined behavior. + private static uint? ParsePresetEnv() + { + string raw = System.Environment.GetEnvironmentVariable("RYUJINX_DLSS_PRESET"); + + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + if (uint.TryParse(raw, out uint value) && value <= 13u) + { + return value; + } + + Ryujinx.Common.Logging.Logger.Warning?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"RYUJINX_DLSS_PRESET=\"{raw}\" is not a valid DLSS preset (0-13): using engine defaults."); + + return null; + } // Optional DLSS sharpening (RYUJINX_DLSS_SHARPNESS, 0..1; default 0 = off, unchanged). private static readonly float _sharpness = @@ -626,7 +648,17 @@ namespace Ryujinx.Graphics.Vulkan.Dlss /// /// True while DLSS super resolution is actually evaluating frames -- drives the green /// status-bar DLSS chip (user request 05/07: same treatment as the VRR/FG indicators). + /// Freshness-based: refreshed by every successful evaluate, considered off when none + /// happened for 2 seconds. A latched bool only went false on evaluate FAILURE, so the + /// chip kept lying whenever DLSS simply stopped being called (path switched away, game + /// stopped, emulation paused). /// - public static volatile bool UiActive; + public static bool UiActive + { + get => Environment.TickCount64 - System.Threading.Interlocked.Read(ref _uiActiveAtMs) < 2000; + set => System.Threading.Interlocked.Exchange(ref _uiActiveAtMs, value ? Environment.TickCount64 : 0); + } + + private static long _uiActiveAtMs; } } diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs index aefb2249e..571657a71 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineFrameGen.cs @@ -274,8 +274,17 @@ namespace Ryujinx.Graphics.Vulkan.Dlss /// the current multiplier. Drives the status-bar FG chip (user request 05/07: green like /// the VRR indicator when interpolating, visible-but-neutral when armed at x1 -- the old /// suffix silently vanished at x1, which read as "it disappeared"). + /// Freshness-based like the DLSS chip: refreshed by the once-per-second counter poll, + /// considered off when no poll happened for 3 seconds -- a latched bool survived the + /// game's shutdown and showed a stale FG chip on the next game until FG re-armed. /// - public static volatile bool UiArmed; + public static bool UiArmed + { + get => Environment.TickCount64 - System.Threading.Interlocked.Read(ref _uiArmedAtMs) < 3000; + set => System.Threading.Interlocked.Exchange(ref _uiArmedAtMs, value ? Environment.TickCount64 : 0); + } + + private static long _uiArmedAtMs; // FG counter (NVIDIA-overlay style): count OUR app presents over a wall-clock 1s window, and read // DLSSGState.numFramesActuallyPresented (= frames SL actually pushed to the display since the last @@ -739,10 +748,15 @@ namespace Ryujinx.Graphics.Vulkan.Dlss UiMultiplier = mult >= 2 ? (int)mult : 1; UiArmed = true; - Logger.Info?.Print(LogClass.Gpu, - $"DLSS-FG COUNTER: app {appFps:0.0} fps x{mult} (numFramesActuallyPresented) -> display ~{displayFps:0.0} fps " + - $"[app {_appPresentsInWindow} presents in {elapsedMs} ms] status=0x{state.Status:X}. " + - (mult >= 2 ? "FG IS INTERPOLATING." : "FG enabled but NOT generating extra frames.")); + // The poll itself feeds the status-bar chips and must keep running; only the log + // line is a dev gauge (MvppDev, release default quiet). + if (GAL.MvppDev.Enabled) + { + Logger.Info?.Print(LogClass.Gpu, + $"DLSS-FG COUNTER: app {appFps:0.0} fps x{mult} (numFramesActuallyPresented) -> display ~{displayFps:0.0} fps " + + $"[app {_appPresentsInWindow} presents in {elapsedMs} ms] status=0x{state.Status:X}. " + + (mult >= 2 ? "FG IS INTERPOLATING." : "FG enabled but NOT generating extra frames.")); + } } private static void LogState(uint viewportId, bool force) diff --git a/src/Ryujinx.Graphics.Vulkan/Shader.cs b/src/Ryujinx.Graphics.Vulkan/Shader.cs index 7cefc0dfc..ec6d14841 100644 --- a/src/Ryujinx.Graphics.Vulkan/Shader.cs +++ b/src/Ryujinx.Graphics.Vulkan/Shader.cs @@ -27,6 +27,10 @@ namespace Ryujinx.Graphics.Vulkan // MV++ (b1) 5B3 modprobe: FNV64 of the SPIR-V bytes ACTUALLY handed to vkCreateShaderModule // (identity check against the dumped .spv files -- the disk-cache trap hit twice already). + // Only computed under the probe: hashing every compiled shader byte-by-byte costs real time + // on a big game's first boot, and the probe log is the sole consumer. + private static readonly bool _mvppModProbe = + Environment.GetEnvironmentVariable("RYUJINX_MVPP_VEL_MODHASH") == "1"; public ulong SpirvHash { get; private set; } public readonly Task CompileTask; @@ -56,12 +60,15 @@ namespace Ryujinx.Graphics.Vulkan } } - ulong fnv = 14695981039346656037UL; - foreach (byte b in spirv) + if (_mvppModProbe) { - fnv = (fnv ^ b) * 1099511628211UL; + ulong fnv = 14695981039346656037UL; + foreach (byte b in spirv) + { + fnv = (fnv ^ b) * 1099511628211UL; + } + SpirvHash = fnv; } - SpirvHash = fnv; fixed (byte* pCode = spirv) { diff --git a/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs b/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs index b102efaf2..cb101c53f 100644 --- a/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs +++ b/src/Ryujinx.Graphics.Vulkan/TextureStorage.cs @@ -150,7 +150,13 @@ namespace Ryujinx.Graphics.Vulkan _allocationAuto = new Auto(allocation); _imageAuto = new Auto(new DisposableImage(_gd.Api, device, _image), null, _allocationAuto); - InitialTransition(ImageLayout.Undefined, ImageLayout.General); + // [BIRTHCLEAR] Fresh allocations only: the suballocator recycles freed device memory + // within the process, and on the DLSS-SR fractional path a render target can reach + // 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); } else { @@ -253,7 +259,7 @@ namespace Ryujinx.Graphics.Vulkan return false; } - private unsafe void InitialTransition(ImageLayout srcLayout, ImageLayout dstLayout) + private unsafe void InitialTransition(ImageLayout srcLayout, ImageLayout dstLayout, bool zeroFill = false) { CommandBufferScoped cbs; bool useTempCbs = !_gd.CommandBufferPool.OwnedByCurrentThread; @@ -304,6 +310,48 @@ namespace Ryujinx.Graphics.Vulkan 1, in barrier); + // [BIRTHCLEAR] Zero the whole image so the first read sees defined data instead of the + // recycled allocation's previous contents. Transfer clears are forbidden on compressed + // formats (and those textures are always fully written from guest data anyway). + if (zeroFill && !_info.IsCompressed) + { + if ((aspectFlags & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit)) != 0) + { + ClearDepthStencilValue zeroDepth = new() { Depth = 0f, Stencil = 0 }; + _gd.Api.CmdClearDepthStencilImage(cbs.CommandBuffer, _imageAuto.Get(cbs).Value, dstLayout, in zeroDepth, 1, in subresourceRange); + } + else + { + ClearColorValue zeroColor = default; + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _imageAuto.Get(cbs).Value, dstLayout, in zeroColor, 1, in subresourceRange); + } + + ImageMemoryBarrier clearBarrier = new() + { + SType = StructureType.ImageMemoryBarrier, + SrcAccessMask = AccessFlags.TransferWriteBit, + DstAccessMask = DefaultAccessMask, + OldLayout = dstLayout, + NewLayout = dstLayout, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = _imageAuto.Get(cbs).Value, + SubresourceRange = subresourceRange, + }; + + _gd.Api.CmdPipelineBarrier( + cbs.CommandBuffer, + PipelineStageFlags.TransferBit, + PipelineStageFlags.AllCommandsBit, + 0, + 0, + null, + 0, + null, + 1, + in clearBarrier); + } + if (useTempCbs) { cbs.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index c26d2fed6..439d7dbdf 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -84,7 +84,7 @@ namespace Ryujinx.Graphics.Vulkan System.Environment.GetEnvironmentVariable("RYUJINX_MVPP_FLASHCAP_FORCE") == "1"; private bool _isLinear; private float _scalingFilterLevel; - private string _dlssSrPresentSig; + private (int, int, int, int, int, int, int, int, int, int, Type, Type)? _dlssSrPresentSig; private bool _updateScalingFilter; private ScalingFilter _currentScalingFilter; private bool _colorSpacePassthroughEnabled; @@ -318,6 +318,15 @@ namespace Ryujinx.Graphics.Vulkan swapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, pSwapchainImages); } + // [BIRTHCLEAR-SWAPCHAIN] Presentable images start as recycled device memory, and on + // the DLSS-FG path the Streamline proxy can present before our first blit covers + // them -- the seconds-long startup ghost image (user repro 13/07: FG on = ghost, + // FG off = gone). Zero them once at creation; the daily (no DLSS) is untouched. + if (Dlss.DlssIntegration.IsEnabled) + { + BirthClearSwapchainImages(); + } + if (viaFgProxy) { // DLSS-FG Porte B.2: DLSSGOptions wants numBackBuffers + the swapchain format @@ -355,6 +364,87 @@ namespace Ryujinx.Graphics.Vulkan } } + /// + /// [BIRTHCLEAR-SWAPCHAIN] Zeroes every swapchain image once, right after creation, so + /// anything presented before the first real blit shows black instead of recycled VRAM. + /// Same command-buffer acquisition pattern as TextureStorage.InitialTransition. + /// + private unsafe void BirthClearSwapchainImages() + { + CommandBufferScoped cbs; + bool useTempCbs = !_gd.CommandBufferPool.OwnedByCurrentThread; + + if (useTempCbs) + { + cbs = _gd.BackgroundResources.Get().GetPool().Rent(); + } + else + { + if (_gd.PipelineInternal != null) + { + cbs = _gd.PipelineInternal.GetPreloadCommandBuffer(); + } + else + { + cbs = _gd.CommandBufferPool.Rent(); + useTempCbs = true; + } + } + + ImageSubresourceRange range = new(ImageAspectFlags.ColorBit, 0, 1, 0, 1); + ClearColorValue zeroColor = default; + + foreach (Image image in _swapchainImages) + { + ImageMemoryBarrier toClear = new() + { + SType = StructureType.ImageMemoryBarrier, + SrcAccessMask = 0, + DstAccessMask = AccessFlags.TransferWriteBit, + OldLayout = ImageLayout.Undefined, + NewLayout = ImageLayout.TransferDstOptimal, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = range, + }; + + _gd.Api.CmdPipelineBarrier( + cbs.CommandBuffer, + PipelineStageFlags.TopOfPipeBit, + PipelineStageFlags.TransferBit, + 0, 0, null, 0, null, 1, in toClear); + + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, image, ImageLayout.TransferDstOptimal, in zeroColor, 1, in range); + + // PresentSrc so an early present (the FG proxy path) finds a legal layout; our own + // present path transitions from Undefined anyway, so this never blocks the blit. + ImageMemoryBarrier toPresent = new() + { + SType = StructureType.ImageMemoryBarrier, + SrcAccessMask = AccessFlags.TransferWriteBit, + DstAccessMask = 0, + OldLayout = ImageLayout.TransferDstOptimal, + NewLayout = ImageLayout.PresentSrcKhr, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = range, + }; + + _gd.Api.CmdPipelineBarrier( + cbs.CommandBuffer, + PipelineStageFlags.TransferBit, + PipelineStageFlags.BottomOfPipeBit, + 0, 0, null, 0, null, 1, in toPresent); + } + + if (useTempCbs) + { + cbs.Dispose(); + } + } + private unsafe TextureView CreateSwapchainImageView(Image swapchainImage, VkFormat format, TextureCreateInfo info) { ComponentMapping componentMapping = new( @@ -687,17 +777,21 @@ namespace Ryujinx.Graphics.Vulkan // Logged on every CHANGE, not once: the "screen shrinks/grows by itself at startup" // report (quality mode, 04/07) needs the present-geometry timeline -- a once-only // probe hid whatever bounces during the boot/dyn-res ramp. Steady state = one line. - string presentSig = - $"view {view.Width}x{view.Height} active {view.ActiveWidth}x{view.ActiveHeight} " + - $"src[{srcX0},{srcY0},{srcX1},{srcY1}] dst {_width}x{_height} " + - $"filter={_scalingFilter?.GetType().Name ?? "none"} effect={_effect?.GetType().Name ?? "none"}"; + // Compared as values: building the description string on every present cost 60-120 + // allocations/second for a probe that almost never fires. + (int, int, int, int, int, int, int, int, int, int, Type, Type) presentSig = + (view.Width, view.Height, view.ActiveWidth, view.ActiveHeight, + srcX0, srcY0, srcX1, srcY1, _width, _height, + _scalingFilter?.GetType(), _effect?.GetType()); - if (presentSig != _dlssSrPresentSig) + if (_dlssSrPresentSig == null || !presentSig.Equals(_dlssSrPresentSig.Value)) { bool first = _dlssSrPresentSig == null; _dlssSrPresentSig = presentSig; Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, - $"DLSS-SR probe{(first ? "" : " (CHANGED)")}: {presentSig}"); + $"DLSS-SR probe{(first ? "" : " (CHANGED)")}: view {view.Width}x{view.Height} active {view.ActiveWidth}x{view.ActiveHeight} " + + $"src[{srcX0},{srcY0},{srcX1},{srcY1}] dst {_width}x{_height} " + + $"filter={_scalingFilter?.GetType().Name ?? "none"} effect={_effect?.GetType().Name ?? "none"}"); } if (ScreenCaptureRequested) @@ -1050,6 +1144,18 @@ namespace Ryujinx.Graphics.Vulkan // RYUJINX_TAA=1 forces the native temporal filter regardless of the UI selection (dev override). AntiAliasing antiAliasing = Effects.TemporalFilter.IsEnabled ? AntiAliasing.Taa : _currentAntiAliasing; + // Guard (user report 12/07, crash at launch): TAA and DLSS together stack two + // temporal accumulators over the same present path. DLSS already does the + // temporal work, so it wins; the TAA choice stays untouched in the config and + // comes back the moment DLSS is off. + if (antiAliasing == AntiAliasing.Taa && Dlss.DlssIntegration.IsEnabled) + { + Ryujinx.Common.Logging.Logger.Warning?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + "TAA is selected but DLSS is active: skipping the native TAA pass (DLSS already provides temporal AA)."); + + antiAliasing = AntiAliasing.None; + } + switch (antiAliasing) { case AntiAliasing.Fxaa: @@ -1213,6 +1319,16 @@ namespace Ryujinx.Graphics.Vulkan { unsafe { + // DLSS-FG: same teardown protocol as RecreateSwapchain -- DLSS-G must be eOff and + // its in-flight present work retired BEFORE this swapchain dies, or dlfg's present + // workers hang the device on exit (close-with-FG freeze). + if (_swapchainViaFgProxy && _gd.FgHooks != null) + { + Dlss.StreamlineFrameGen.OnSwapchainTeardown(Dlss.DlssUpscaler.ViewportId); + + _gd.FgHooks.DeviceWaitIdle(); + } + for (int i = 0; i < _swapchainImageViews.Length; i++) { _swapchainImageViews[i].Dispose(); @@ -1235,6 +1351,15 @@ namespace Ryujinx.Graphics.Vulkan _effect?.Dispose(); _scalingFilter?.Dispose(); _dlss?.Dispose(); + + // [COLORCAP snapshot] the held per-size color copies -- same cleanup loop as the + // twin _heldDepthCache in DlssUpscaler.Dispose. + foreach (TextureView held in _heldColorCache.Values) + { + held?.Dispose(); + } + + _heldColorCache.Clear(); } } diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs index 91b4b7d9f..0ee240479 100644 --- a/src/Ryujinx/Systems/AppHost.cs +++ b/src/Ryujinx/Systems/AppHost.cs @@ -659,15 +659,24 @@ namespace Ryujinx.Ava.Systems // We only need to wait for all commands submitted during the main gpu loop to be processed. // If the GPU has no work and is cancelled, we need to handle that as well. - WaitHandle.WaitAny(new[] { _gpuDoneEvent, _gpuCancellationTokenSource.Token.WaitHandle }); + int doneIndex = WaitHandle.WaitAny(new[] { _gpuDoneEvent, _gpuCancellationTokenSource.Token.WaitHandle }); if (_renderingStarted) { // Waiting for work to be finished before we dispose. Device.Gpu.WaitUntilGpuReady(); } - - _gpuDoneEvent.Dispose(); + + // Dispose the event only when the render loop actually signalled it. When WaitAny + // returned on CANCELLATION, the loop's finally is still flushing and will Set() this + // event afterwards -- disposing here crashed the process at every "unlucky" close + // (ObjectDisposedException on the render thread, the long-standing close crash, + // stack finally captured 13/07). Skipping the dispose on that path leaks one handle + // into process exit, which the runtime reclaims. + if (doneIndex == 0) + { + _gpuDoneEvent.Dispose(); + } _gpuCancellationTokenSource.Dispose(); DisposeGpu(); @@ -1219,7 +1228,16 @@ namespace Ryujinx.Ava.Systems threaded.FlushThreadedCommands(); Logger.Info?.PrintMsg(LogClass.Gpu, "Flushed!"); } - _gpuDoneEvent.Set(); + + try + { + _gpuDoneEvent.Set(); + } + catch (ObjectDisposedException) + { + // Close race second belt: the disposer stopped waiting (cancellation) and + // already tore the event down; there is nobody left to signal. + } } }); diff --git a/src/Ryujinx/Systems/DlssRestart.cs b/src/Ryujinx/Systems/DlssRestart.cs index d45e8b4bb..4056f1c7a 100644 --- a/src/Ryujinx/Systems/DlssRestart.cs +++ b/src/Ryujinx/Systems/DlssRestart.cs @@ -122,9 +122,13 @@ namespace Ryujinx.Ava.Systems // fact, whether the clean exit hung (watchdog fired) or succeeded (it did not). Thread watchdog = new(() => { - Thread.Sleep(1000); + // 5 s, not 1 s: the clean exit is also what flushes the shader cache TOC and the + // config to disk. A 1 s fuse could Kill() mid-write and corrupt them; the hang + // this watchdog exists for is indefinite, so the extra seconds only cost time in + // the rare stuck case. + Thread.Sleep(5000); Logger.Warning?.Print(LogClass.Application, - "DLSS restart: clean exit hung >1s -- force-terminating the old process to complete the relaunch."); + "DLSS restart: clean exit hung >5s -- force-terminating the old process to complete the relaunch."); Process.GetCurrentProcess().Kill(); }) { diff --git a/src/Ryujinx/Systems/DlssUiSettings.cs b/src/Ryujinx/Systems/DlssUiSettings.cs index 09c5d4ef4..b13854a56 100644 --- a/src/Ryujinx/Systems/DlssUiSettings.cs +++ b/src/Ryujinx/Systems/DlssUiSettings.cs @@ -195,6 +195,11 @@ namespace Ryujinx.Ava.Systems Environment.SetEnvironmentVariable("RYUJINX_MVPP_FLIPY", "1"); Environment.SetEnvironmentVariable("RYUJINX_MVPP_VPFIFO", "1"); Environment.SetEnvironmentVariable("RYUJINX_MVPP_SKYROT", "1"); + // Leading-edge disocclusion = clamp to nearest valid history (user A/B 13/07 at x3: + // 0 zero-MV = fine black lines on pans, 1 clamp = far less, >=2 raw = three lines). + // The engine default stays 0 so diagnostic baselines are unchanged; only the shipped + // UI profile opts in. + Environment.SetEnvironmentVariable("RYUJINX_MVPP_EDGE", "1"); int fgMultiplier = LoadFgMultiplier(); if (fgMultiplier > 1) diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 6e8c1cbb6..5dc04db90 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -567,10 +567,17 @@ namespace Ryujinx.Ava.UI.ViewModels set { _dlssPresetIndex = value; + _dlssPresetWasUnset = false; // an explicit pick replaces "engine default" OnPropertyChanged(); } } + // True when the saved preset token was absent/0 (engine default) or unknown: the combo + // displays K (which IS the engine default) but saving without touching it must preserve + // the default token -- silently writing an explicit K also tripped a false restart + // prompt for every pre-preset-era user (save wrote 11, the file said 0). + private bool _dlssPresetWasUnset; + // 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; } @@ -957,7 +964,9 @@ namespace Ryujinx.Ava.UI.ViewModels ScalingFilterLevel = config.Graphics.ScalingFilterLevel.Value; DlssMode = dlssSaved; int presetSaved = Ryujinx.Ava.Systems.DlssUiSettings.LoadPresetValue(); - DlssPresetIndex = Math.Max(0, Array.IndexOf(Ryujinx.Ava.Systems.DlssUiSettings.PresetValues, presetSaved)); + int presetIndex = Array.IndexOf(Ryujinx.Ava.Systems.DlssUiSettings.PresetValues, presetSaved); + DlssPresetIndex = Math.Max(0, presetIndex); + _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(); @@ -1109,8 +1118,10 @@ namespace Ryujinx.Ava.UI.ViewModels } config.Graphics.ScalingFilterLevel.Value = ScalingFilterLevel; - int dlssPresetValue = Ryujinx.Ava.Systems.DlssUiSettings.PresetValues[ - Math.Clamp(DlssPresetIndex, 0, Ryujinx.Ava.Systems.DlssUiSettings.PresetValues.Length - 1)]; + int dlssPresetValue = _dlssPresetWasUnset && DlssPresetIndex == 0 + ? 0 // untouched engine default stays the engine default + : Ryujinx.Ava.Systems.DlssUiSettings.PresetValues[ + Math.Clamp(DlssPresetIndex, 0, Ryujinx.Ava.Systems.DlssUiSettings.PresetValues.Length - 1)]; DlssModeRestartPending = dlssMode != Ryujinx.Ava.Systems.DlssUiSettings.Load() || dlssPresetValue != Ryujinx.Ava.Systems.DlssUiSettings.LoadPresetValue() || DlssFgIndex + 1 != Ryujinx.Ava.Systems.DlssUiSettings.LoadFgMultiplier() ||