sharpemu/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageFaultTests.cs

229 lines
12 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.GpuMemory;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Tests.Gpu.Buffers;
using SharpEmu.Libs.Tests.Gpu.Scheduling;
using SharpEmu.Libs.Tests.Gpu.Vulkan;
using Silk.NET.Vulkan;
using Xunit;
using static SharpEmu.Libs.Tests.Gpu.Images.ImageCacheTestSupport;
namespace SharpEmu.Libs.Tests.Gpu.Images;
// Guest faults on image-watched pages reach the image store through the real host fault handler.
[Collection(SchedulingStateCollection.Name)]
public sealed class GuestImageFaultTests
{
private const string WorkerEnvironmentVariable = "SHARPEMU_IMAGE_FAULT_WORKER";
[Fact]
public async Task GuestFaultsRecoverThroughTheImageStore()
{
if (!GuestFaultWorker.CanRun)
{
return;
}
if (GuestFaultWorker.IsWorker(WorkerEnvironmentVariable))
{
RunGuest();
return;
}
await GuestFaultWorker.RunIsolatedAsync(WorkerEnvironmentVariable, typeof(GuestImageFaultTests), nameof(GuestFaultsRecoverThroughTheImageStore));
}
private static void RunGuest()
{
using var vulkan = HeadlessVulkan.TryCreate();
if (!GatePrerequisites.Ready(vulkan))
{
GuestFaultWorker.Report(GuestFaultWorker.Skipped, "no Vulkan device");
return;
}
using var harness = new CacheHarness(vulkan);
using var fatal = new FatalScope();
GuestGpuMemoryHook.Attach(harness.Gpu);
try
{
using var guest = new SyntheticGuest(harness, "image-fault-worker");
var resolved = 0;
// Write fault on an image page: the image turns dirty and the next use re-uploads it.
var address = harness.MapBacked(0x10000, ReadWrite);
harness.Write(address, Bytes(1u, 2u, 3u, 4u));
var request = Color32(address, 4);
var imageIdentifier = harness.Acquire(ref request);
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(address));
guest.Write(address + 4, 0x22u);
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(address));
Assert.True(harness.Image(imageIdentifier).IsDefinitelyCpuDirty);
Assert.Contains($"faults_resolved={++resolved} ", GuestGpuMemoryHook.GetSummary());
Assert.Equal(imageIdentifier, harness.Acquire(ref request));
Assert.False(harness.Image(imageIdentifier).IsCpuDirty);
Assert.Equal(Bytes(1u, 0x22u, 0u, 4u), harness.ReadImageBytes(harness.Image(imageIdentifier)));
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(address));
// Writes beside a two-page image unwatch its head, then its tail; the edge hash then
// proves the image clean, and later dirty, without a byte-range record.
var edged = harness.MapBacked(0x10000, ReadWrite);
var edgedAddress = edged + 0x800;
harness.Write(edgedAddress, Enumerable.Range(0, 0x400).SelectMany(index => Bytes((uint)index)).ToArray());
var edgedRequest = Color32(edgedAddress, 0x400);
var edgedId = harness.Acquire(ref edgedRequest);
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(edged));
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(edged + 0x1000));
guest.Write(edged + 0x10, 0xAA);
Assert.Contains($"faults_resolved={++resolved} ", GuestGpuMemoryHook.GetSummary());
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(edged));
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(edged + 0x1000));
Assert.False(harness.Image(edgedId).IsCpuDirty);
guest.Write(edged + 0x1900, 0xBB);
Assert.Contains($"faults_resolved={++resolved} ", GuestGpuMemoryHook.GetSummary());
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(edged + 0x1000));
Assert.True(harness.Image(edgedId).IsCpuDirty);
Assert.False(harness.Image(edgedId).IsDefinitelyCpuDirty);
Assert.Equal(edgedId, harness.Acquire(ref edgedRequest));
Assert.False(harness.Image(edgedId).IsCpuDirty);
Assert.Equal(0x0000_0001_0000_0000UL, BitConverter.ToUInt64(harness.ReadImageBytes(harness.Image(edgedId)), 0));
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(edged));
guest.Write(edged + 0x10, 0xAC);
guest.Write(edged + 0x1900, 0xBD);
resolved += 2;
Assert.Contains($"faults_resolved={resolved} ", GuestGpuMemoryHook.GetSummary());
Assert.True(harness.Image(edgedId).IsCpuDirty);
guest.Write(edgedAddress, 0x0000_00AB_0000_00CDUL);
Assert.Contains($"faults_resolved={resolved} ", GuestGpuMemoryHook.GetSummary());
Assert.Equal(edgedId, harness.Acquire(ref edgedRequest));
Assert.False(harness.Image(edgedId).IsCpuDirty);
Assert.Equal(0x0000_00AB_0000_00CDUL, BitConverter.ToUInt64(harness.ReadImageBytes(harness.Image(edgedId)), 0));
// A page watched by an image and a buffer notifies both stores on one fault.
var shared = harness.MapBacked(0x10000, ReadWrite);
var sharedRequest = Color32(shared, 4);
var sharedId = harness.Acquire(ref sharedRequest);
harness.Worker.Run(() => harness.Cache.ObtainBuffer(shared + 0x100, 0x100, isWritten: false));
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(shared));
guest.Write(shared + 0x108, 0x3333);
Assert.Contains($"faults_resolved={++resolved} ", GuestGpuMemoryHook.GetSummary());
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(shared));
Assert.True(harness.Cache.HasCpuDirtyPages(shared, 0x1000));
Assert.True(harness.Image(sharedId).IsCpuDirty);
Assert.Equal(0x3333UL, guest.Read(shared + 0x108));
// Two guest threads writing one image page through the live handler both resume.
var raced = harness.MapBacked(0x10000, ReadWrite);
var racedRequest = Color32(raced, 4);
var racedId = harness.Acquire(ref racedRequest);
harness.MarkGpuWritten(racedId);
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(raced));
var outcomes = guest.Race(2, write: true, raced + 8, slot => 0x1000UL + (ulong)slot);
Assert.All(outcomes, outcome => Assert.True(outcome.Ok, outcome.Error));
var landed = guest.Read(raced + 8);
Assert.True(landed == 0x1000 || landed == 0x1001);
Assert.True(harness.Image(racedId).IsDefinitelyCpuDirty);
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(raced));
Assert.Contains("faults_resolved=", GuestGpuMemoryHook.GetSummary());
resolved = ResolvedCount();
// The renderer can delete an image after a write faults but before its store lookup.
var retiredMapping = harness.MapBacked(2 * TrackerLayout.BlockBytes, ReadWrite);
var boundary = (retiredMapping / TrackerLayout.BlockBytes + 1) * TrackerLayout.BlockBytes;
var retiredRequest = Color32(boundary - 8, 4);
var retiredIdentifier = harness.Acquire(ref retiredRequest);
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(boundary));
Assert.False(harness.Store.DownloadToCpu(boundary, 8));
var deletingStore = new BeforeWriteStore(harness.Store, () =>
harness.Worker.Run(() => harness.Images.DeleteImageForTest(retiredIdentifier)));
harness.Gpu.AttachStores(deletingStore, harness.Images);
try
{
guest.Write(boundary, 0x1234_5678_9ABC_DEF0);
}
finally
{
harness.Gpu.AttachStores(harness.Cache, harness.Images);
}
Assert.False(deletingStore.HandledWrite);
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(boundary));
Assert.Equal(0x1234_5678_9ABC_DEF0UL, guest.Read(boundary));
Assert.Contains($"faults_resolved={++resolved} ", GuestGpuMemoryHook.GetSummary());
harness.Finish();
Assert.False(harness.Images.Contains(retiredIdentifier));
// Unmapping a watched image range deletes the image and drops its watch.
var unmapped = harness.MapBacked(0x10000, ReadWrite);
var unmappedRequest = Color32(unmapped, 4);
var unmappedId = harness.Acquire(ref unmappedRequest);
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(unmapped));
harness.Gpu.Unregister(unmapped, 0x10000);
Assert.False(harness.Images.Contains(unmappedId));
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(unmapped));
Assert.False(GuestGpuMemoryHook.TryResolveFault(FaultKind.Write, unmapped + 8));
Assert.Equal(resolved, ResolvedCount());
// A texel obtain copies the GPU result into the buffer but the image keeps ownership:
// the guest reads its own copy without a fault until a scheduled readback publishes.
var owned = harness.MapBacked(0x10000, ReadWrite);
harness.Write(owned, Bytes(9u, 9u, 9u, 9u));
var ownedRequest = Color32(owned, 4);
var ownedId = harness.Acquire(ref ownedRequest);
Assert.True(harness.Worker.Run(() => harness.Images.TryClearImageFromBuffer(owned, 16, 0x5A5A5A5Au)));
Assert.True(harness.Image(ownedId).IsGpuModified);
var (texel, texelOffset) = harness.Worker.Run(() => harness.Cache.ObtainBuffer(owned, 0x4100, isWritten: false, isTexelBuffer: true));
Assert.Equal(Bytes(0x5A5A5A5Au, 0x5A5A5A5Au), harness.ReadBack(texel, texelOffset, 8));
Assert.True(harness.Image(ownedId).IsGpuModified);
Assert.False(harness.Cache.HasGpuDirtyBytes(owned, 16));
Assert.Equal(HostPageProtection.ReadOnly, harness.Protection(owned));
Assert.Equal(0x0000_0009_0000_0009UL, guest.Read(owned + 8));
Assert.Equal(resolved, ResolvedCount());
harness.Images.SetLinearReadback(true);
harness.Worker.Run(() =>
{
harness.Images.ScheduleReadbackForTest(ownedId);
harness.Images.FlushScheduledReadbacks();
});
harness.Finish();
Assert.Equal(0x5A5A5A5A5A5A5A5AUL, guest.Read(owned + 8));
Assert.Equal(resolved, ResolvedCount());
harness.Shutdown();
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(address));
Assert.Equal(HostPageProtection.ReadWrite, harness.Protection(owned));
GuestFaultWorker.Report(GuestFaultWorker.Completed, GuestGpuMemoryHook.GetSummary());
}
finally
{
GuestGpuMemoryHook.Attach(null);
}
static int ResolvedCount()
{
var summary = GuestGpuMemoryHook.GetSummary();
const string key = "faults_resolved=";
var start = summary.IndexOf(key, StringComparison.Ordinal) + key.Length;
var end = summary.IndexOf(' ', start);
return int.Parse(summary.AsSpan(start, end - start));
}
}
private sealed class BeforeWriteStore(IGuestBufferStore store, Action beforeWrite) : IGuestBufferStore
{
private Action? _beforeWrite = beforeWrite;
public bool HandledWrite { get; private set; }
public bool MarkCpuWrite(ulong address, ulong size)
{
Interlocked.Exchange(ref _beforeWrite, null)?.Invoke();
return HandledWrite = store.MarkCpuWrite(address, size);
}
public bool DownloadToCpu(ulong address, ulong size) => store.DownloadToCpu(address, size);
}
}