mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-31 23:19:44 +08:00
fix(gpu): reflect guest CPU writes into large and render-target-aliased images (#722)
Ports the still-applicable half of the archived fork's stale-texture fix
(a42ccae) onto current main. Silent Hill: The Short Message (PPSA10112)
shows both faces: black title-screen UI (glyph atlas frozen at its first
upload) and stale/garbage rows on the brightness screen (a 3840x2160 UI
sheet whose backing bytes were never re-read).
1. Write tracking is armed under a byte budget, not a resolution cap.
GetOrCreateGuestImage armed GuestImageWriteTracker only when
target.Width <= 1920 && target.Height <= 1080. Silent Hill renders at
3840x2160, so every one of its render targets was excluded from
CPU-write tracking and no guest rewrite of one could ever invalidate
it: SyncCpuWrittenGuestImages (the flip / ACQUIRE_MEM re-upload path)
only ever visits ranges the tracker armed.
The cap presumably existed as a perf guard, but resolution is the
wrong proxy for the cost. Arming is one mprotect over the range, and
the fault handler unprotects the whole range on the first store, so a
write burst costs one fault regardless of size. What actually scales
with the surface is the dirty re-upload: one byte[byteCount]
allocation plus a full guest-memory read per dirty flip. So the guard
is now a byte budget, set equal to the 128 MiB limit that
SyncCpuWrittenGuestImages itself enforces before re-uploading. Above
that, arming can only cost faults; it can never produce a re-upload.
That is generous for 4K (RGBA8 32 MiB, RGBA16F 63 MiB, RGBA32F
127 MiB all fit) while still excluding volume textures that a
resolution cap could not see at all (512^3 RGBA8 is 512 MiB behind a
"512x512" surface).
Both sites now also arm the exact extent recorded in
_guestImageExtents (GetTextureByteCount) instead of
Width*Height*depth*GetTextureBytesPerPixel, so the armed range and
the range the sync path reads back are the same bytes; the old
expression over-counted block-compressed and unknown formats.
2. The CPU-texture refresh path no longer gates on IsCpuBacked alone.
TryCreateCpuTextureRefreshResource bailed on !guestImage.IsCpuBacked.
That flag is a latch: it flips false the first time an address is
used as a render target and never flips back. A surface that was
rendered into once and is afterwards rewritten by the guest CPU was
therefore frozen at its last GPU content forever, even when the parse
thread had already shipped fresh texels for it.
The gate is replaced by ShouldRefreshGuestImageFromCpu: CPU-backed,
or the parse-time write generation is above zero and differs from the
generation recorded by the last upload. Keeping the positive-
generation requirement preserves the pure GPU-feedback case (render
into an image, then sample it) that IsCpuBacked used to protect: such
a surface is now tracked (change 1) but never CPU-written, so its
generation stays zero and its live image is left alone. This matters
more than it did in the fork, precisely because change 1 arms
tracking on far more render targets. Dropping the gate outright, as
the fork did, would let a target sampled under a format tag that the
availability map does not match be overwritten once with whatever
sits in guest memory.
The existing content fingerprint still suppresses redundant uploads,
and MarkSampledImagesInitialized records the uploaded generation, so
a rewritten surface re-uploads exactly once per guest write burst.
Not ported: the fork's third part added a PeekDirty guard to the
parse-time snapshot fast path in AgcExports. It is superseded. That path
now calls IsGuestImageUploadKnown, which already compares
_cpuBackedUploadGenerations against the tracker's write generation - a
monotonic value that survives another owner consuming the dirty flag,
unlike PeekDirty, which both EvictDirtyCachedTextures and
SyncCpuWrittenGuestImages clear. Guest images with no generation entry
are covered instead by SyncCpuWrittenGuestImages. Adding a non-consuming
PeekDirty there would also make every draw between a CPU write and the
next flip fall through to a full texel re-read of the surface (33 MiB
for a 4K sheet), since Track re-arms without clearing the dirty flag.
The fork's promotion-path Track ("vulkan.cpu-backed-image") is likewise
superseded: AgcExports already arms every sampled texture's backing
extent as "agc.decoded-texture" before reading its texels.
Both decisions are extracted as pure internal predicates so they can be
unit-tested; the Vulkan device code around them needs a real device.
This commit is contained in:
@@ -1777,6 +1777,57 @@ internal static unsafe class VulkanVideoPresenter
|
||||
uint depth) =>
|
||||
checked(GetGuestImageByteCount(format, width, height) * Math.Max(depth, 1u));
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on the backing extent that guest CPU-write tracking is
|
||||
/// armed over. Deliberately equal to the presenter-side re-upload budget
|
||||
/// used by the AGC flip/acquire sync path: arming a range larger than the
|
||||
/// sync path is willing to read back would fault and dirty forever without
|
||||
/// ever producing a re-upload, so it is pure cost.
|
||||
/// </summary>
|
||||
internal const ulong MaxTrackedGuestImageBytes = 128UL * 1024UL * 1024UL;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a guest surface is eligible for CPU-write tracking.
|
||||
/// The predicate is byte-based on purpose: the cost that actually scales
|
||||
/// with surface size is the dirty re-upload (one allocation plus a guest
|
||||
/// memory read of the whole extent per dirty flip), not the arming itself
|
||||
/// (one mprotect and, per write burst, one fault for the whole range).
|
||||
/// A resolution cap was the wrong proxy — it ignored bytes-per-texel and
|
||||
/// volume depth while excluding the 4K UI sheets that most need
|
||||
/// invalidation.
|
||||
/// </summary>
|
||||
internal static bool ShouldTrackGuestImageWrites(ulong byteCount) =>
|
||||
byteCount != 0 && byteCount <= MaxTrackedGuestImageBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a sampled guest image whose backing memory the parse
|
||||
/// thread just re-read should be re-uploaded from those bytes.
|
||||
/// <para>
|
||||
/// <paramref name="isCpuBacked"/> is a latch that flips false the first
|
||||
/// time an address is used as a render target and never flips back, so it
|
||||
/// cannot be the sole gate: a font atlas or UI sheet that was also
|
||||
/// rendered into is permanently frozen at its first upload afterwards.
|
||||
/// The write tracker answers the real question. A parse-time generation
|
||||
/// above zero means a guest CPU store was observed on the backing range,
|
||||
/// and a generation the last upload does not already cover means those
|
||||
/// bytes are newer than the host image.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Requiring a positive generation keeps the pure GPU-feedback case
|
||||
/// (render into an image, then sample it) safe: such a surface is tracked
|
||||
/// but never CPU-written, so its generation stays zero and the live image
|
||||
/// is preserved instead of being overwritten with guest memory.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool ShouldRefreshGuestImageFromCpu(
|
||||
bool isCpuBacked,
|
||||
long textureWriteGeneration,
|
||||
bool hasUploadedGeneration,
|
||||
long uploadedGeneration) =>
|
||||
isCpuBacked ||
|
||||
(textureWriteGeneration > 0 &&
|
||||
(!hasUploadedGeneration || uploadedGeneration != textureWriteGeneration));
|
||||
|
||||
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
|
||||
// or Undefined when no counterpart exists. Used to encode linear-float
|
||||
// guest flips on their way into a UNORM swapchain.
|
||||
@@ -8217,8 +8268,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
out TextureResource resource)
|
||||
{
|
||||
resource = default!;
|
||||
if (!guestImage.IsCpuBacked ||
|
||||
guestImage.Width != texture.Width ||
|
||||
if (guestImage.Width != texture.Width ||
|
||||
guestImage.Height != texture.Height ||
|
||||
guestImage.Depth != GetGuestTextureDepth(texture.Type, texture.Depth) ||
|
||||
IsGuestTexture3D(guestImage.Type) != IsGuestTexture3D(texture.Type) ||
|
||||
@@ -8228,6 +8278,32 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return false;
|
||||
}
|
||||
|
||||
// IsCpuBacked alone used to gate this path, but it is a latch that
|
||||
// flips false the first time the address is used as a render target
|
||||
// and never flips back. On PS5 that address is unified memory: a
|
||||
// surface that was rendered into once and is later rewritten by the
|
||||
// guest CPU (glyph atlas rasterization, a 4K UI sheet redrawn on the
|
||||
// brightness screen) must still be re-read. Fall back on the write
|
||||
// tracker, which reports genuine CPU stores and leaves pure
|
||||
// render-into-then-sample feedback untouched.
|
||||
bool hasUploadedGeneration;
|
||||
long uploadedGeneration;
|
||||
lock (_gate)
|
||||
{
|
||||
hasUploadedGeneration = _cpuBackedUploadGenerations.TryGetValue(
|
||||
texture.Address,
|
||||
out uploadedGeneration);
|
||||
}
|
||||
|
||||
if (!ShouldRefreshGuestImageFromCpu(
|
||||
guestImage.IsCpuBacked,
|
||||
texture.WriteGeneration,
|
||||
hasUploadedGeneration,
|
||||
uploadedGeneration))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rowLength = texture.TileMode == 0
|
||||
? Math.Max(texture.Pitch, texture.Width)
|
||||
: texture.Width;
|
||||
@@ -13348,20 +13424,33 @@ internal static unsafe class VulkanVideoPresenter
|
||||
retained.IsCpuBacked = false;
|
||||
retained.CpuContentFingerprint = 0;
|
||||
_guestImages.Add(target.Address, retained);
|
||||
var retainedByteCount = GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth);
|
||||
lock (_gate)
|
||||
{
|
||||
_cpuBackedUploadGenerations.Remove(target.Address);
|
||||
_guestImageExtents[target.Address] = (
|
||||
target.Width,
|
||||
target.Height,
|
||||
GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth));
|
||||
retainedByteCount);
|
||||
}
|
||||
|
||||
TrackCpuBackedGuestImage(retained);
|
||||
// Arm the exact extent the flip/acquire sync path would read
|
||||
// back, budgeted by bytes rather than by resolution: the old
|
||||
// 1920x1080 cap left every 4K surface permanently
|
||||
// un-invalidated, so a guest CPU rewrite of one was never
|
||||
// reflected and the sample served stale bytes.
|
||||
if (ShouldTrackGuestImageWrites(retainedByteCount))
|
||||
{
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
target.Address,
|
||||
retainedByteCount,
|
||||
CurrentGuestWorkSequenceForDiagnostics,
|
||||
"vulkan.render-target");
|
||||
}
|
||||
|
||||
if (_traceGuestImageEvents)
|
||||
{
|
||||
@@ -13500,19 +13589,31 @@ internal static unsafe class VulkanVideoPresenter
|
||||
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
|
||||
}
|
||||
_guestImages.Add(target.Address, resource);
|
||||
var createdByteCount = GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth);
|
||||
lock (_gate)
|
||||
{
|
||||
_guestImageExtents[target.Address] = (
|
||||
target.Width,
|
||||
target.Height,
|
||||
GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth));
|
||||
createdByteCount);
|
||||
}
|
||||
|
||||
TrackCpuBackedGuestImage(resource);
|
||||
// See the retained-variant path above: track the full backing
|
||||
// extent under a byte budget instead of a resolution cap so
|
||||
// oversized render targets the guest later rewrites with the CPU
|
||||
// are re-uploaded on the next sample.
|
||||
if (ShouldTrackGuestImageWrites(createdByteCount))
|
||||
{
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
target.Address,
|
||||
createdByteCount,
|
||||
CurrentGuestWorkSequenceForDiagnostics,
|
||||
"vulkan.render-target");
|
||||
}
|
||||
|
||||
if (_traceGuestImageEvents)
|
||||
{
|
||||
@@ -13526,24 +13627,25 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private void TrackCpuBackedGuestImage(GuestImageResource image)
|
||||
{
|
||||
// Arm ≤1080p guest images so native CPU stores fault. Drain skips
|
||||
// false overlap dirties with a 4 KiB zero probe unless IsCpuBacked.
|
||||
if (image.Width == 0 ||
|
||||
image.Height == 0 ||
|
||||
image.Width > 1920 ||
|
||||
image.Height > 1080)
|
||||
if (image.Width == 0 || image.Height == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var depth = Math.Max(image.Depth, 1u);
|
||||
var byteCount = GetVulkanImageByteCount(
|
||||
image.Format,
|
||||
image.Width,
|
||||
image.Height,
|
||||
depth);
|
||||
if (!ShouldTrackGuestImageWrites(byteCount))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
image.Address,
|
||||
GetVulkanImageByteCount(
|
||||
image.Format,
|
||||
image.Width,
|
||||
image.Height,
|
||||
depth),
|
||||
byteCount,
|
||||
CurrentGuestWorkSequenceForDiagnostics,
|
||||
"vulkan.render-target");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the two policy decisions that let a guest CPU rewrite reach a host
|
||||
/// image that the GPU also renders into. Both used to be answered by proxies
|
||||
/// that silently excluded real surfaces: a 1920x1080 arming cap (which left
|
||||
/// every 4K target un-invalidated) and an IsCpuBacked latch (which flips false
|
||||
/// the first time an address is used as a render target and never flips back).
|
||||
/// The Vulkan device code around them cannot be unit-tested without a device,
|
||||
/// so the decisions themselves are exercised here.
|
||||
/// </summary>
|
||||
public sealed class VulkanGuestImageCpuSyncPolicyTests
|
||||
{
|
||||
private const uint Rgba8Format = 10;
|
||||
private const uint Rgba16FFormat = 11;
|
||||
private const uint Rgba32FFormat = 14;
|
||||
|
||||
[Theory]
|
||||
// 4K UI sheets are the whole point of the change: under the old
|
||||
// resolution cap none of these were ever armed.
|
||||
[InlineData(Rgba8Format, 3840u, 2160u)]
|
||||
[InlineData(Rgba16FFormat, 3840u, 2160u)]
|
||||
[InlineData(Rgba32FFormat, 3840u, 2160u)]
|
||||
// Supersampled and unusual-aspect targets above 1080p in one axis only.
|
||||
[InlineData(Rgba8Format, 2560u, 1440u)]
|
||||
[InlineData(Rgba8Format, 1920u, 2160u)]
|
||||
[InlineData(Rgba8Format, 3840u, 1080u)]
|
||||
public void OversizedTargetsRemainEligibleForWriteTracking(
|
||||
uint format,
|
||||
uint width,
|
||||
uint height)
|
||||
{
|
||||
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
depth: 1);
|
||||
|
||||
Assert.True(VulkanVideoPresenter.ShouldTrackGuestImageWrites(byteCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubHdTargetsRemainEligibleForWriteTracking()
|
||||
{
|
||||
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
|
||||
Rgba8Format,
|
||||
1280u,
|
||||
720u,
|
||||
depth: 1);
|
||||
|
||||
Assert.True(VulkanVideoPresenter.ShouldTrackGuestImageWrites(byteCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyExtentIsNotTracked()
|
||||
{
|
||||
Assert.False(VulkanVideoPresenter.ShouldTrackGuestImageWrites(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtentBeyondTheReUploadBudgetIsNotTracked()
|
||||
{
|
||||
// Arming beyond the budget the flip/acquire sync path is willing to
|
||||
// read back can only cost faults; it can never produce a re-upload.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldTrackGuestImageWrites(
|
||||
VulkanVideoPresenter.MaxTrackedGuestImageBytes));
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldTrackGuestImageWrites(
|
||||
VulkanVideoPresenter.MaxTrackedGuestImageBytes + 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VolumeDepthCountsAgainstTheTrackingBudget()
|
||||
{
|
||||
// A resolution cap could not see volume depth at all. 512^3 RGBA8 is
|
||||
// 512 MiB of backing memory behind a "512x512" surface.
|
||||
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
|
||||
Rgba8Format,
|
||||
512u,
|
||||
512u,
|
||||
depth: 512u);
|
||||
|
||||
Assert.False(VulkanVideoPresenter.ShouldTrackGuestImageWrites(byteCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuBackedImageAlwaysRefreshes()
|
||||
{
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: true,
|
||||
textureWriteGeneration: -1,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderTargetLatchNoLongerBlocksAnObservedCpuWrite()
|
||||
{
|
||||
// The surface was rendered into (IsCpuBacked latched false) and the
|
||||
// guest CPU then rewrote its backing memory, so the parse thread
|
||||
// shipped fresh texels carrying a newer generation than the upload.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 3,
|
||||
hasUploadedGeneration: true,
|
||||
uploadedGeneration: 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservedCpuWriteRefreshesEvenWithNoRecordedUpload()
|
||||
{
|
||||
// The render-target recreate/retain paths drop the recorded upload
|
||||
// generation; a tracked CPU write must still win.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 1,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GpuFeedbackSurfaceKeepsItsLiveImage()
|
||||
{
|
||||
// Tracked but never CPU-written: generation zero. Render-into-then-
|
||||
// sample must not be overwritten with guest memory.
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 0,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntrackedSurfaceKeepsItsLiveImage()
|
||||
{
|
||||
// -1 is the "no tracker generation" sentinel the parse thread ships
|
||||
// when the range is not tracked (or tracking is disabled entirely,
|
||||
// as on Windows).
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: -1,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlreadyUploadedGenerationDoesNotRefreshAgain()
|
||||
{
|
||||
// The upload recorded this exact generation, so the host image is
|
||||
// current: re-uploading every draw would restage the whole surface.
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 4,
|
||||
hasUploadedGeneration: true,
|
||||
uploadedGeneration: 4));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user