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 _));
+ }
+}