diff --git a/src/SharpEmu.HLE/GuestImageWriteTracker.cs b/src/SharpEmu.HLE/GuestImageWriteTracker.cs index adf7e290..c749f7a3 100644 --- a/src/SharpEmu.HLE/GuestImageWriteTracker.cs +++ b/src/SharpEmu.HLE/GuestImageWriteTracker.cs @@ -32,6 +32,7 @@ public static unsafe class GuestImageWriteTracker public int Armed; public int FirstCpuWriteSeen; public int PendingFirstCpuWrite; + public long WriteGeneration; public bool TraceLifetime; public long SourceSequence; public long FirstCpuWriteTraceSequence; @@ -155,10 +156,21 @@ public static unsafe class GuestImageWriteTracker { // Never resize an object that is still reachable from the // signal handler's lock-free snapshot. Retire it and publish - // a fresh immutable range. + // a fresh immutable range, carrying the write generation so + // resizes do not hide guest CPU rewrites from cache owners. + var writeGeneration = Volatile.Read(ref range.WriteGeneration); DisarmLocked(range, "replace-range"); _rangesByAddress.Remove(address); - range = null; + range = new TrackedRange + { + Address = address, + ByteCount = byteCount, + Start = start, + End = start + length, + WriteGeneration = writeGeneration, + }; + _rangesByAddress[address] = range; + RebuildSnapshotLocked(); } if (range is null) @@ -272,6 +284,31 @@ public static unsafe class GuestImageWriteTracker } } + /// + /// Returns the monotonic first-write generation for a tracked allocation. + /// Unlike the consuming dirty flag, this remains changed after another + /// cache owner consumes and re-arms the range. + /// + public static bool TryGetWriteGeneration(ulong address, out long generation) + { + generation = 0; + if (!_enabled) + { + return false; + } + + lock (_gate) + { + if (!_rangesByAddress.TryGetValue(address, out var range)) + { + return false; + } + + generation = Volatile.Read(ref range.WriteGeneration); + return true; + } + } + /// /// Prepares pages touched by a managed HLE memory write. Native guest /// stores fault and enter through the @@ -425,6 +462,10 @@ public static unsafe class GuestImageWriteTracker } var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0; + if (wasArmed) + { + Interlocked.Increment(ref range.WriteGeneration); + } if (wasArmed && range.TraceLifetime && Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0) diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index e55e21c9..07c06b5b 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -7893,9 +7893,13 @@ public static partial class AgcExports return true; } + // Upload-known (not plain availability): the presenter's answer goes + // generation-stale when the guest CPU rewrites a CPU-backed image + // (video planes, streamed font atlases), which routes this draw back + // through the texel copy below so the refresh path re-uploads. if (!isStorage && descriptor.Address != 0 && - GuestGpu.Current.IsGpuGuestImageAvailable( + GuestGpu.Current.IsGuestImageUploadKnown( descriptor.Address, descriptor.Format, descriptor.NumberType)) @@ -8002,6 +8006,19 @@ public static partial class AgcExports // (skipping would leave the draw with no pixels and a fallback // texture for the frame — visible flicker on animated textures). var sampler = ToGuestSampler(samplerDescriptor); + // Track the guest allocation before reading its texels so a CPU + // rewrite landing after the copy still bumps the write generation. + // The generation rides on the texture and is recorded by the + // presenter after upload, where the upload-known skip compares it + // against the tracker to force fresh texels for rewritten memory. + SharpEmu.HLE.GuestImageWriteTracker.Track( + descriptor.Address, + physicalSourceByteCount, + source: "agc.decoded-texture"); + var hasWriteGeneration = + SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration( + descriptor.Address, + out var writeGeneration); if (!_textureCopySkipDisabled && descriptor.Address != 0 && !SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) && @@ -8093,7 +8110,8 @@ public static partial class AgcExports Pitch: sourceWidth, TileMode: descriptor.TileMode, DstSelect: descriptor.DstSelect, - Sampler: ToGuestSampler(samplerDescriptor)); + Sampler: ToGuestSampler(samplerDescriptor), + WriteGeneration: hasWriteGeneration ? writeGeneration : -1); return true; } diff --git a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs index 70b25adf..759bf8ef 100644 --- a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs +++ b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs @@ -27,7 +27,10 @@ internal sealed record GuestDrawTexture( uint Pitch = 0, uint TileMode = 0, uint DstSelect = 0xFAC, - GuestSampler Sampler = default); + GuestSampler Sampler = default, + // Guest CPU write-tracker generation of the memory RgbaPixels was read + // from; -1 when the range is untracked or the pixels were not read here. + long WriteGeneration = -1); /// Raw guest sampler descriptor dwords, copied verbatim from guest memory. internal readonly record struct GuestSampler( diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index b7a4684e..d2f5dfc5 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -472,6 +472,11 @@ internal static unsafe class VulkanVideoPresenter private static readonly Queue _pendingGuestImagePresentations = new(); private static readonly Dictionary _guestImageWorkSequences = new(); private static readonly Dictionary _availableGuestImages = new(); + // Write-tracker generation last uploaded for a CPU-backed guest image. + // A newer generation in the tracker means the guest CPU rewrote the + // memory (video frames, streamed atlases) and the upload-known skip in + // draw translation must ship fresh texels instead of reusing the image. + private static readonly Dictionary _cpuBackedUploadGenerations = new(); private static readonly Dictionary<(int Handle, int BufferIndex), long> _lastOrderedGuestFlipVersions = new(); private static long _orderedGuestFlipVersionSequence; @@ -811,6 +816,7 @@ internal static unsafe class VulkanVideoPresenter _pendingGuestImagePresentations.Clear(); _guestImageWorkSequences.Clear(); _availableGuestImages.Clear(); + _cpuBackedUploadGenerations.Clear(); _lastOrderedGuestFlipVersions.Clear(); _orderedGuestFlipVersionSequence = 0; _pendingGuestImageUploads.Clear(); @@ -1790,9 +1796,30 @@ internal static unsafe class VulkanVideoPresenter lock (_gate) { - return _availableGuestImages.TryGetValue(address, out var availableFormat) && + var known = + _availableGuestImages.TryGetValue(address, out var availableFormat) && availableFormat == guestFormat || _pendingGuestImageUploads.ContainsKey((address, guestFormat)); + if (!known) + { + return false; + } + + // CPU-backed images (video frames, streamed atlases) go stale when + // the guest CPU rewrites the memory after the recorded upload; a + // changed write-tracker generation forces a fresh texel copy so + // the refresh path can re-upload. GPU-rendered images have no + // generation entry and keep the plain availability answer. + if (_cpuBackedUploadGenerations.TryGetValue(address, out var uploadedGeneration) && + SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration( + address, + out var currentGeneration) && + currentGeneration != uploadedGeneration) + { + return false; + } + + return true; } } @@ -2990,6 +3017,10 @@ internal static unsafe class VulkanVideoPresenter public Sampler Sampler; public GuestImageResource? GuestImage; public GuestDepthResource? GuestDepth; + // Write-tracker generation of the guest memory the staged pixels + // were read from; -1 when unknown. Recorded on the guest image + // after upload so stale-content skips can be detected. + public long WriteGeneration = -1; // A sampled render-target alias cannot remain bound to the same // image while that image is a color attachment. The per-draw // snapshot uses this source to copy the target's pre-draw contents @@ -7004,6 +7035,18 @@ internal static unsafe class VulkanVideoPresenter if ((guestImage.Initialized || guestImage.InitialUploadPending) && guestImage.CpuContentFingerprint == fingerprint) { + // Content unchanged despite a newer write generation: advance + // the recorded generation so later draws can skip the copy + // again instead of restaging identical texels every draw. + if (texture.WriteGeneration >= 0) + { + lock (_gate) + { + _cpuBackedUploadGenerations[texture.Address] = + texture.WriteGeneration; + } + } + return false; } @@ -7033,6 +7076,7 @@ internal static unsafe class VulkanVideoPresenter GuestImage = guestImage, CpuContentFingerprint = fingerprint, UpdatesCpuContent = true, + WriteGeneration = texture.WriteGeneration, }; return true; } @@ -7718,6 +7762,7 @@ internal static unsafe class VulkanVideoPresenter SamplerState = texture.Sampler, CpuContentFingerprint = contentFingerprint, UpdatesCpuContent = texture.Address != 0, + WriteGeneration = texture.WriteGeneration, }; if (texture.Address != 0 && @@ -7750,6 +7795,12 @@ internal static unsafe class VulkanVideoPresenter _availableGuestImages[texture.Address] = guestFormat; } + if (texture.WriteGeneration >= 0) + { + _cpuBackedUploadGenerations[texture.Address] = + texture.WriteGeneration; + } + _guestImageExtents[texture.Address] = (width, height, expectedSize); } @@ -11222,6 +11273,7 @@ internal static unsafe class VulkanVideoPresenter lock (_gate) { _availableGuestImages.Remove(target.Address); + _cpuBackedUploadGenerations.Remove(target.Address); _guestImageExtents.Remove(target.Address); } @@ -11246,6 +11298,7 @@ internal static unsafe class VulkanVideoPresenter _guestImages.Add(target.Address, retained); lock (_gate) { + _cpuBackedUploadGenerations.Remove(target.Address); _guestImageExtents[target.Address] = ( target.Width, target.Height, @@ -13873,6 +13926,11 @@ internal static unsafe class VulkanVideoPresenter if (texture.UpdatesCpuContent) { guestImage.CpuContentFingerprint = texture.CpuContentFingerprint; + if (texture.WriteGeneration >= 0) + { + _cpuBackedUploadGenerations[guestImage.Address] = + texture.WriteGeneration; + } } } } @@ -15190,6 +15248,7 @@ internal static unsafe class VulkanVideoPresenter lock (_gate) { _availableGuestImages.Clear(); + _cpuBackedUploadGenerations.Clear(); _lastOrderedGuestFlipVersions.Clear(); } DestroySwapchainResources(); diff --git a/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs new file mode 100644 index 00000000..ad564297 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs @@ -0,0 +1,135 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests.Memory; + +/// +/// The write generation lets GPU caches detect guest CPU rewrites even after +/// another cache owner consumed the (single) dirty flag: the generation is +/// monotonic and survives consume/re-arm cycles and range replacement. These +/// invariants back the presenter's stale-upload detection for CPU-rewritten +/// images (video planes, streamed font atlases). +/// +public sealed unsafe class GuestImageWriteTrackerTests +{ + // The tracker aligns to the guest's 4 KiB pages; the mprotect underneath + // operates on host pages, which are 16 KiB on Apple Silicon (the emulator + // itself always runs with 4 KiB host pages under Rosetta, but this test + // host may not). Align the allocation to the largest host page size so + // the kernel's rounding stays inside memory this test owns instead of + // spilling onto neighbouring heap pages. + private const nuint TrackedByteCount = 4096; + private const nuint HostPageAlignment = 16384; + + private static ulong AllocateTrackedPages(out void* allocation) + { + allocation = NativeMemory.AlignedAlloc(2 * HostPageAlignment, HostPageAlignment); + return (ulong)allocation; + } + + [Fact] + public void GenerationSurvivesDirtyConsume() + { + if (!GuestImageWriteTracker.Enabled) + { + return; + } + + var address = AllocateTrackedPages(out var allocation); + try + { + GuestImageWriteTracker.Track(address, TrackedByteCount); + Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation)); + Assert.Equal(0, generation); + + Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address)); + Assert.True(GuestImageWriteTracker.ConsumeDirty(address)); + + // Consuming the dirty flag must not roll back the generation: + // that is exactly what lets a second cache owner still observe + // the rewrite after the first owner consumed the flag. + Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation)); + Assert.Equal(1, generation); + } + finally + { + GuestImageWriteTracker.Untrack(address); + NativeMemory.Free(allocation); + } + } + + [Fact] + public void GenerationIncrementsOncePerArmedLifetime() + { + if (!GuestImageWriteTracker.Enabled) + { + return; + } + + var address = AllocateTrackedPages(out var allocation); + try + { + GuestImageWriteTracker.Track(address, TrackedByteCount); + Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address)); + // The first fault disarmed the range; later writes are free-running + // and must not inflate the generation until the owner re-arms. + Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address)); + Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation)); + Assert.Equal(1, generation); + + GuestImageWriteTracker.Rearm(address); + Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address)); + Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation)); + Assert.Equal(2, generation); + } + finally + { + GuestImageWriteTracker.Untrack(address); + NativeMemory.Free(allocation); + } + } + + [Fact] + public void GenerationCarriesAcrossRangeReplacement() + { + if (!GuestImageWriteTracker.Enabled) + { + return; + } + + var address = AllocateTrackedPages(out var allocation); + try + { + GuestImageWriteTracker.Track(address, TrackedByteCount); + Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address)); + + // Re-registering the same allocation with a different size retires + // the range object (the signal handler may still see the old + // snapshot) but must carry the generation, otherwise a resize + // would hide the rewrite from cache owners. + GuestImageWriteTracker.Track(address, 2 * TrackedByteCount); + Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation)); + Assert.Equal(1, generation); + } + finally + { + GuestImageWriteTracker.Untrack(address); + NativeMemory.Free(allocation); + } + } + + [Fact] + public void UntrackedAddressHasNoGeneration() + { + if (!GuestImageWriteTracker.Enabled) + { + return; + } + + Assert.False(GuestImageWriteTracker.TryGetWriteGeneration(0xDEAD_0000_0000UL, out _)); + } +}