304 lines
9.8 KiB
C#
304 lines
9.8 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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 const uint MemCommit = 0x1000;
|
|
private const uint MemReserve = 0x2000;
|
|
private const uint MemRelease = 0x8000;
|
|
private const uint PageReadWrite = 0x04;
|
|
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
private static extern nint VirtualAlloc(
|
|
nint lpAddress,
|
|
nuint dwSize,
|
|
uint flAllocationType,
|
|
uint flProtect);
|
|
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
private static extern int VirtualFree(nint lpAddress, nuint dwSize, uint dwFreeType);
|
|
|
|
private static ulong AllocateTrackedPages(out void* allocation)
|
|
{
|
|
// VirtualProtect (Windows) / mprotect (POSIX) must target
|
|
// VirtualAlloc/mmap pages. Protecting CRT heap pages poisons
|
|
// neighbouring allocator metadata and crashes the test host.
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
var windowsAllocation = VirtualAlloc(
|
|
0,
|
|
HostPageAlignment,
|
|
MemCommit | MemReserve,
|
|
PageReadWrite);
|
|
Assert.NotEqual(nint.Zero, windowsAllocation);
|
|
allocation = (void*)windowsAllocation;
|
|
return (ulong)windowsAllocation;
|
|
}
|
|
|
|
allocation = NativeMemory.AlignedAlloc(2 * HostPageAlignment, HostPageAlignment);
|
|
return (ulong)allocation;
|
|
}
|
|
|
|
private static void FreeTrackedPages(void* allocation)
|
|
{
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
_ = VirtualFree((nint)allocation, 0, MemRelease);
|
|
return;
|
|
}
|
|
|
|
NativeMemory.Free(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);
|
|
FreeTrackedPages(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);
|
|
FreeTrackedPages(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);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void UntrackedAddressHasNoGeneration()
|
|
{
|
|
if (!GuestImageWriteTracker.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Assert.False(GuestImageWriteTracker.TryGetWriteGeneration(0xDEAD_0000_0000UL, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyTrackDoesNotArmWriteProtection()
|
|
{
|
|
if (!GuestImageWriteTracker.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.watch-only",
|
|
protect: false);
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.False(protect);
|
|
Assert.False(armed);
|
|
|
|
// Pages stay writable: a native store must not require a fault handler.
|
|
*(byte*)address = 0xAB;
|
|
Assert.Equal(0xAB, *(byte*)address);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyRangesAreExcludedFromManagedWriteSnapshot()
|
|
{
|
|
if (!GuestImageWriteTracker.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.watch-only",
|
|
protect: false);
|
|
// Watch-only must not widen the NotifyManagedWrite hot path.
|
|
GuestImageWriteTracker.NotifyManagedWrite(address, sizeof(uint));
|
|
Assert.False(GuestImageWriteTracker.ConsumeDirty(address));
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.False(protect);
|
|
Assert.False(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ProtectedTrackArmsWriteProtection()
|
|
{
|
|
if (!GuestImageWriteTracker.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.True(protect);
|
|
Assert.True(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WatchOnlyTrackDoesNotDowngradeProtectedRange()
|
|
{
|
|
if (!GuestImageWriteTracker.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = AllocateTrackedPages(out var allocation);
|
|
try
|
|
{
|
|
GuestImageWriteTracker.Track(address, TrackedByteCount, source: "test.rt");
|
|
GuestImageWriteTracker.Track(
|
|
address,
|
|
TrackedByteCount,
|
|
source: "test.texture-cache",
|
|
protect: false);
|
|
Assert.True(
|
|
GuestImageWriteTracker.TryGetProtectionState(
|
|
address,
|
|
out var protect,
|
|
out var armed));
|
|
Assert.True(protect);
|
|
Assert.True(armed);
|
|
}
|
|
finally
|
|
{
|
|
GuestImageWriteTracker.Untrack(address);
|
|
FreeTrackedPages(allocation);
|
|
}
|
|
}
|
|
}
|