// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using System.Runtime.InteropServices;
namespace SharpEmu.HLE;
///
/// Detects guest CPU writes into memory that backs a host GPU image. On PS5
/// render targets alias unified memory, so games freely mix CPU writes and GPU
/// draws on the same surface (Chowdren titles memset their fog layers every
/// frame). Host GPU images are separate storage, so the video backend needs to
/// know when the guest CPU rewrote a surface to re-upload it. Ranges are
/// write-protected; the first write faults, the fault handler restores write
/// access and marks the range dirty, and the video backend consumes the dirty
/// flag once per flip and re-arms protection after re-uploading.
///
public static unsafe class GuestImageWriteTracker
{
private const int ProtRead = 0x1;
private const int ProtWrite = 0x2;
private const int ClockMonotonicRaw = 4;
private sealed class TrackedRange
{
public ulong Address;
public ulong ByteCount;
public ulong Start;
public ulong End;
public int Dirty;
public int Armed;
public int FirstCpuWriteSeen;
public int PendingFirstCpuWrite;
public bool TraceLifetime;
public long SourceSequence;
public long FirstCpuWriteTraceSequence;
public long FirstCpuWriteTimestampNanoseconds;
public ulong FirstCpuWriteAddress;
public ulong FirstCpuWritePage;
public string Source = "unspecified";
}
[StructLayout(LayoutKind.Sequential)]
private struct Timespec
{
public long Seconds;
public long Nanoseconds;
}
private static readonly object _gate = new();
private static readonly Dictionary _rangesByAddress = new();
// Snapshot array read lock-free from the signal handler; rebuilt on every
// mutation under the gate. Signal handlers must not take managed locks.
private static TrackedRange[] _rangeSnapshot = [];
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
ParseSourceList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_MEMORY_LIFETIME"));
private static readonly bool _lifetimeTraceEnabled =
_lifetimeTraceFilter.Wildcard ||
_lifetimeTraceFilter.Addresses.Length != 0 ||
_lifetimeSourceTraceFilter.Wildcard ||
_lifetimeSourceTraceFilter.Sources.Length != 0;
private static readonly long _lifetimeTraceEpochNanoseconds =
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
private static long _lifetimeTraceSequence;
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
private static extern int Mprotect(nint address, nuint length, int protection);
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
private static extern int ClockGetTime(int clockId, Timespec* time);
public static bool Enabled => _enabled;
///
/// Exercises the fault-handling path once outside signal context so every
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
/// fault arrives — a cold signal path is silently never entered there.
///
public static void WarmUp()
{
if (!_enabled)
{
return;
}
var scratch = NativeMemory.AllocZeroed(4096);
try
{
// Warm the timestamp P/Invoke used by the signal-safe scalar
// capture path before a real protected-page write reaches it.
_ = GetMonotonicNanoseconds();
var address = (ulong)scratch;
Track(address, 4096);
_ = TryHandleWriteFault(address);
_ = ConsumeDirty(address);
Untrack(address);
}
finally
{
NativeMemory.Free(scratch);
}
}
/// Registers a range and arms write protection on it.
public static void Track(
ulong address,
ulong byteCount,
long sourceSequence = 0,
string source = "unspecified")
{
if (!_enabled || address == 0 || byteCount == 0)
{
return;
}
var (start, length) = PageAlign(address, byteCount);
lock (_gate)
{
_rangesByAddress.TryGetValue(address, out var range);
if (range is not null &&
(range.Start != start ||
range.End != start + length ||
range.ByteCount != byteCount))
{
// Never resize an object that is still reachable from the
// signal handler's lock-free snapshot. Retire it and publish
// a fresh immutable range.
DisarmLocked(range, "replace-range");
_rangesByAddress.Remove(address);
range = null;
}
if (range is null)
{
range = new TrackedRange
{
Address = address,
ByteCount = byteCount,
Start = start,
End = start + length,
TraceLifetime =
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
SourceSequence = sourceSequence,
Source = source,
};
_rangesByAddress[address] = range;
RebuildSnapshotLocked();
}
else
{
FlushPendingFirstCpuWrite(range);
}
range.SourceSequence = sourceSequence;
range.Source = source;
range.TraceLifetime =
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
ArmLocked(range, "arm");
}
}
public static void Untrack(ulong address)
{
if (!_enabled)
{
return;
}
lock (_gate)
{
if (_rangesByAddress.TryGetValue(address, out var range))
{
DisarmLocked(range, "untrack");
_rangesByAddress.Remove(address);
RebuildSnapshotLocked();
}
}
}
///
/// Returns true when the guest CPU wrote the range since the last call,
/// clearing the flag. The caller re-arms via after it
/// finished reading the guest bytes.
///
public static bool ConsumeDirty(ulong address)
{
if (!_enabled)
{
return false;
}
lock (_gate)
{
if (!_rangesByAddress.TryGetValue(address, out var range))
{
return false;
}
FlushPendingFirstCpuWrite(range);
return Interlocked.Exchange(ref range.Dirty, 0) != 0;
}
}
///
/// Non-consuming variant of : reports whether
/// the range has been written since it was last re-armed, leaving the
/// flag for the owner that evicts and re-uploads.
///
public static bool PeekDirty(ulong address)
{
if (!_enabled)
{
return false;
}
lock (_gate)
{
if (!_rangesByAddress.TryGetValue(address, out var range))
{
return false;
}
FlushPendingFirstCpuWrite(range);
return Volatile.Read(ref range.Dirty) != 0;
}
}
public static void Rearm(ulong address)
{
if (!_enabled)
{
return;
}
lock (_gate)
{
if (_rangesByAddress.TryGetValue(address, out var range))
{
ArmLocked(range, "rearm");
}
}
}
///
/// Prepares pages touched by a managed HLE memory write. Native guest
/// stores fault and enter through the
/// POSIX signal bridge, but a managed Buffer.MemoryCopy into a protected
/// page is surfaced by the runtime as a fatal AccessViolation instead of
/// a resumable guest fault. Visit every page in the write span up front so
/// all overlapping texture owners are dirtied and made writable.
///
public static void NotifyManagedWrite(ulong address, ulong byteCount)
{
if (!_enabled || address == 0 || byteCount == 0)
{
return;
}
var end = address > ulong.MaxValue - byteCount
? ulong.MaxValue
: address + byteCount;
var candidate = address;
while (candidate < end)
{
_ = TryHandleWriteFault(candidate);
var nextPage = (candidate & ~0xFFFUL) + 0x1000UL;
if (nextPage <= candidate)
{
break;
}
candidate = nextPage;
}
}
///
/// Flushes scalar first-write records captured by the POSIX signal handler.
/// Call only from ordinary managed execution, never from signal context.
///
public static void FlushPendingDiagnostics()
{
if (!_enabled || !_lifetimeTraceEnabled)
{
return;
}
lock (_gate)
{
foreach (var range in _rangesByAddress.Values)
{
FlushPendingFirstCpuWrite(range);
}
}
}
///
/// Signal-handler entry: if the fault address lies in a tracked, armed
/// range, restore write access, mark the range dirty, and return true so
/// the faulting write can be retried. Must not allocate or lock.
///
public static bool TryHandleWriteFault(ulong faultAddress)
{
if (!_enabled || faultAddress == 0)
{
return false;
}
var ranges = Volatile.Read(ref _rangeSnapshot);
var writableStart = ulong.MaxValue;
var writableEnd = 0UL;
for (var index = 0; index < ranges.Length; index++)
{
var range = ranges[index];
if (faultAddress < range.Start || faultAddress >= range.End)
{
continue;
}
writableStart = Math.Min(writableStart, range.Start);
writableEnd = Math.Max(writableEnd, range.End);
}
if (writableStart == ulong.MaxValue)
{
return false;
}
// Ranges are page-aligned and may overlap (font atlases and other
// suballocations commonly share pages). Unprotecting one range also
// makes every overlapping tracked page writable. Expand to the full
// transitive overlap and dirty/disarm every owner, otherwise only the
// first dictionary entry observes the write and the others retain a
// stale cached texture indefinitely.
var expanded = true;
while (expanded)
{
expanded = false;
for (var index = 0; index < ranges.Length; index++)
{
var range = ranges[index];
if (range.Start >= writableEnd || range.End <= writableStart)
{
continue;
}
var start = Math.Min(writableStart, range.Start);
var end = Math.Max(writableEnd, range.End);
if (start != writableStart || end != writableEnd)
{
writableStart = start;
writableEnd = end;
expanded = true;
}
}
}
var needsUnprotect = false;
for (var index = 0; index < ranges.Length; index++)
{
var range = ranges[index];
if (range.Start < writableEnd && range.End > writableStart &&
Volatile.Read(ref range.Armed) != 0)
{
needsUnprotect = true;
break;
}
}
if (needsUnprotect &&
Mprotect(
(nint)writableStart,
(nuint)(writableEnd - writableStart),
ProtRead | ProtWrite) != 0)
{
return false;
}
for (var index = 0; index < ranges.Length; index++)
{
var range = ranges[index];
if (range.Start >= writableEnd || range.End <= writableStart)
{
continue;
}
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
if (wasArmed &&
range.TraceLifetime &&
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
{
// Signal context: capture preallocated scalar fields only.
// Formatting and I/O are deferred to a locked safe path.
range.FirstCpuWriteTraceSequence =
Interlocked.Increment(ref _lifetimeTraceSequence);
range.FirstCpuWriteTimestampNanoseconds = GetMonotonicNanoseconds();
range.FirstCpuWriteAddress = faultAddress;
range.FirstCpuWritePage = faultAddress & ~0xFFFUL;
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
}
Volatile.Write(ref range.Dirty, 1);
}
return true;
}
private static void ArmLocked(TrackedRange range, string operation)
{
FlushPendingFirstCpuWrite(range);
if (Interlocked.Exchange(ref range.Armed, 1) == 1)
{
return;
}
// A new publication/rearm starts a new first-write lifetime.
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
var failed = Mprotect(
(nint)range.Start,
(nuint)(range.End - range.Start),
ProtRead) != 0;
if (failed)
{
Volatile.Write(ref range.Armed, 0);
}
if (range.TraceLifetime)
{
TraceLifetime(
range,
failed ? $"{operation}-failed-errno-{Marshal.GetLastPInvokeError()}" : operation);
}
}
private static void DisarmLocked(TrackedRange range, string operation)
{
FlushPendingFirstCpuWrite(range);
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
if (wasArmed)
{
_ = Mprotect(
(nint)range.Start,
(nuint)(range.End - range.Start),
ProtRead | ProtWrite);
}
if (range.TraceLifetime)
{
TraceLifetime(range, wasArmed ? operation : $"{operation}-already-disarmed");
}
}
private static void RebuildSnapshotLocked()
{
_rangeSnapshot = _rangesByAddress.Values.ToArray();
}
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
{
const ulong pageMask = 0xFFFUL;
var start = address & ~pageMask;
var end = (address + byteCount + pageMask) & ~pageMask;
return (start, end - start);
}
private static bool ShouldTraceRange(ulong start, ulong end)
{
if (_lifetimeTraceFilter.Wildcard)
{
return true;
}
var addresses = _lifetimeTraceFilter.Addresses;
for (var index = 0; index < addresses.Length; index++)
{
if (addresses[index] >= start && addresses[index] < end)
{
return true;
}
}
return false;
}
private static (bool Wildcard, ulong[] Addresses) ParseAddressList(string? addresses)
{
if (string.IsNullOrWhiteSpace(addresses))
{
return (false, []);
}
var parsedAddresses = new List();
foreach (var token in addresses.Split(
[',', ';', ' ', '\t'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (token == "*")
{
return (true, []);
}
var span = token.AsSpan();
if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
span = span[2..];
}
if (ulong.TryParse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed))
{
parsedAddresses.Add(parsed);
}
}
return (false, parsedAddresses.ToArray());
}
private static bool ShouldTraceSource(string source)
{
if (_lifetimeSourceTraceFilter.Wildcard)
{
return true;
}
return Array.IndexOf(_lifetimeSourceTraceFilter.Sources, source) >= 0;
}
private static (bool Wildcard, string[] Sources) ParseSourceList(string? sources)
{
if (string.IsNullOrWhiteSpace(sources))
{
return (false, []);
}
var parsedSources = new List();
foreach (var token in sources.Split(
[',', ';'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (token == "*")
{
return (true, []);
}
parsedSources.Add(token);
}
return (false, parsedSources.ToArray());
}
private static void FlushPendingFirstCpuWrite(TrackedRange range)
{
var spin = new SpinWait();
while (Volatile.Read(ref range.FirstCpuWriteSeen) == 1)
{
spin.SpinOnce();
}
if (!range.TraceLifetime || Interlocked.Exchange(ref range.PendingFirstCpuWrite, 0) == 0)
{
return;
}
TraceLifetime(
range,
"first-cpu-write-disarm",
range.FirstCpuWriteAddress,
range.FirstCpuWritePage,
range.FirstCpuWriteTraceSequence,
range.FirstCpuWriteTimestampNanoseconds);
}
private static void TraceLifetime(
TrackedRange range,
string operation,
ulong faultAddress = 0,
ulong faultPage = 0,
long traceSequence = 0,
long timestampNanoseconds = 0)
{
if (traceSequence == 0)
{
traceSequence = Interlocked.Increment(ref _lifetimeTraceSequence);
}
if (timestampNanoseconds == 0)
{
timestampNanoseconds = GetMonotonicNanoseconds();
}
var elapsedMilliseconds =
(timestampNanoseconds - _lifetimeTraceEpochNanoseconds) / 1_000_000.0;
Console.Error.WriteLine(
$"[WT][LIFETIME] seq={traceSequence} t_ms={elapsedMilliseconds:F3} " +
$"event={operation} source_seq={range.SourceSequence} source='{range.Source}' " +
$"requested=0x{range.Address:X16}+0x{range.ByteCount:X} " +
$"range=0x{range.Start:X16}..0x{range.End:X16} " +
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
}
private static long GetMonotonicNanoseconds()
{
Timespec time;
return ClockGetTime(ClockMonotonicRaw, &time) == 0
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
: 0;
}
}