From c63044115208a9a3a132ea9f372c283be8f0c562 Mon Sep 17 00:00:00 2001
From: ParantezTech <12572227+par274@users.noreply.github.com>
Date: Tue, 28 Jul 2026 01:05:55 +0300
Subject: [PATCH] [cpu] hooked windows write faults into guest image tracking
---
.../DirectExecutionBackend.Exceptions.cs | 9 +
.../Native/DirectExecutionBackend.Imports.cs | 58 ++++++-
src/SharpEmu.Libs/Agc/AgcExports.cs | 11 ++
src/SharpEmu.Libs/Agc/GpuWaitProfile.cs | 160 ++++++++++++++++++
4 files changed, 230 insertions(+), 8 deletions(-)
create mode 100644 src/SharpEmu.Libs/Agc/GpuWaitProfile.cs
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
index b76069ee..fb3812e2 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
@@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend
}
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}");
+
+ // The raw handler carries the guest-image write-fault bridge, so the
+ // path must be compiled before the first protected-page store can
+ // reach it. Guest code has not started yet, so warming here cannot
+ // race a real fault.
+ SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
+ Console.Error.WriteLine(
+ "[LOADER][INFO] Guest image CPU write tracking: " +
+ $"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}");
}
else
{
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
index c352b2c4..f6e5dd94 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
@@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
+ directExecutionBackend.ClearActiveImportIndex();
return r;
}
- return directExecutionBackend.DispatchImport(importIndex, argPackPtr);
+ var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
+ directExecutionBackend.ClearActiveImportIndex();
+ return result;
}
catch (Exception ex)
{
@@ -69,11 +72,7 @@ public sealed partial class DirectExecutionBackend
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
{
- EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
- if (exceptionRecord->ExceptionCode == 3221225477u &&
- exceptionRecord->NumberParameters >= 2 &&
- SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
- exceptionRecord->ExceptionInformation[1]))
+ if (TryHandleGuestImageWriteFault(exceptionInfo))
{
return -1;
}
@@ -81,6 +80,37 @@ public sealed partial class DirectExecutionBackend
return TryRecoverUnresolvedSentinel(exceptionInfo);
}
+ ///
+ /// Windows counterpart of the POSIX SIGSEGV bridge into
+ /// . Guest code runs natively,
+ /// so a store into a surface the GPU backend has cached is an ordinary CPU
+ /// write with nothing to intercept — the page is write-protected instead and
+ /// the resulting fault is what tells the backend to re-upload. Without this
+ /// the cache serves the first upload forever, and anything the guest CPU
+ /// draws (a software-decoded movie frame, a memset fog layer) never reaches
+ /// the screen.
+ ///
+ private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo)
+ {
+ if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled)
+ {
+ return false;
+ }
+
+ var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
+ // STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation
+ // is [accessKind, address] with 0=read, 1=write, 8=DEP execute.
+ if (exceptionRecord->ExceptionCode != 3221225477u ||
+ exceptionRecord->NumberParameters < 2 ||
+ exceptionRecord->ExceptionInformation[0] != 1uL)
+ {
+ return false;
+ }
+
+ return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
+ exceptionRecord->ExceptionInformation[1]);
+ }
+
private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
{
return TryRecoverUnresolvedSentinel(exceptionInfo);
@@ -174,6 +204,10 @@ public sealed partial class DirectExecutionBackend
{
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
}
+ if (_profileGuestRip)
+ {
+ EnsureGuestRipSampler();
+ }
int num2 = Volatile.Read(in _rawSentinelRecoveries);
if (num2 != _lastReportedRawSentinelRecoveries)
{
@@ -187,6 +221,10 @@ public sealed partial class DirectExecutionBackend
}
cpuContext.Rip = importStubEntry.Address;
+ if (_profileGuestRip)
+ {
+ cpuContext.ActiveImportIndex = importIndex;
+ }
LoadImportVolatileArguments(cpuContext, argPackPtr);
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
@@ -1453,7 +1491,7 @@ public sealed partial class DirectExecutionBackend
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
var expectedMutexTrylockBusy =
- string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
+ (nid is "K-jXhbt2gn4" or "upoVrzMHFeE") &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
@@ -1470,6 +1508,9 @@ public sealed partial class DirectExecutionBackend
var expectedPrivacyInvalidParameter =
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80960009);
+ var expectedPlayGoChunkEnumerationEnd =
+ string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
+ resultValue == unchecked((int)0x80B2000C);
if (!expectedFileProbeMiss &&
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
@@ -1478,7 +1519,8 @@ public sealed partial class DirectExecutionBackend
!expectedPollSemaBusy &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
- !expectedPrivacyInvalidParameter)
+ !expectedPrivacyInvalidParameter &&
+ !expectedPlayGoChunkEnumerationEnd)
{
return true;
}
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index 3ee6acfc..cc919c0e 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -4383,6 +4383,14 @@ public static partial class AgcExports
_tracedProducerlessWaits.Clear();
}
+ if (!stale)
+ {
+ // Count before the deduplication below: the warning fires once
+ // per label, so on its own it cannot say how often a queue
+ // actually suspends.
+ GpuWaitProfile.RecordSuspend(producer is not null);
+ }
+
if (!stale && producer is null &&
!_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress)))
@@ -5648,6 +5656,8 @@ public static partial class AgcExports
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
ctx.Memory);
+ GpuWaitProfile.RecordMonitorPoll(resumed != 0);
+ GpuWaitProfile.ReportIfDue(remaining);
if (remaining == 0)
{
gpuState.WaitMonitorRunning = false;
@@ -5841,6 +5851,7 @@ public static partial class AgcExports
$"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " +
$"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " +
$"waited_ms={waitedMilliseconds:F3}");
+ GpuWaitProfile.RecordResume(waiter.WaitAddress, waitedMilliseconds);
if (remainingDwords == 0)
{
state.IsSuspended = false;
diff --git a/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs b/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs
new file mode 100644
index 00000000..39aa169f
--- /dev/null
+++ b/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs
@@ -0,0 +1,160 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+
+namespace SharpEmu.Libs.Agc;
+
+///
+/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with
+/// SHARPEMU_PROFILE_GPU_WAIT=1.
+///
+/// The existing agc.wait_suspended warning is deduplicated per label, so
+/// a label that suspends every frame is reported once and then goes silent —
+/// which makes the log useless for judging whether GPU waits cost frame time.
+/// This counts every suspension and every resume, and reports how long the
+/// queues actually sat blocked.
+///
+internal static class GpuWaitProfile
+{
+ public static readonly bool Enabled = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static readonly double _reportSeconds =
+ double.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT_REPORT_S"),
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var seconds) && seconds > 0
+ ? seconds
+ : 5.0;
+
+ private static readonly object _gate = new();
+ private static readonly Dictionary _byLabel = new();
+ private static long _suspensions;
+ private static long _resumes;
+ private static long _producerless;
+ private static long _monitorPolls;
+ private static long _monitorEmptyPolls;
+ private static double _totalWaitMilliseconds;
+ private static double _maxWaitMilliseconds;
+ private static long _windowStart = Stopwatch.GetTimestamp();
+
+ public static void RecordSuspend(bool hasProducer)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ _suspensions++;
+ if (!hasProducer)
+ {
+ _producerless++;
+ }
+ }
+ }
+
+ public static void RecordResume(ulong label, double waitedMilliseconds)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ _resumes++;
+ _totalWaitMilliseconds += waitedMilliseconds;
+ if (waitedMilliseconds > _maxWaitMilliseconds)
+ {
+ _maxWaitMilliseconds = waitedMilliseconds;
+ }
+
+ if (_byLabel.Count < 4096)
+ {
+ var existing = _byLabel.TryGetValue(label, out var entry) ? entry : default;
+ _byLabel[label] = (existing.Count + 1, existing.Milliseconds + waitedMilliseconds);
+ }
+ }
+ }
+
+ ///
+ /// Called once per wake of the wait monitor. An empty poll means the monitor
+ /// burned a wakeup without resuming anything, which is the cost of the
+ /// backoff loop rather than of the wait itself.
+ ///
+ public static void RecordMonitorPoll(bool resumedAny)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ _monitorPolls++;
+ if (!resumedAny)
+ {
+ _monitorEmptyPolls++;
+ }
+ }
+ }
+
+ public static void ReportIfDue(int remainingWaiters)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ string line;
+ lock (_gate)
+ {
+ var now = Stopwatch.GetTimestamp();
+ var elapsedTicks = now - _windowStart;
+ if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
+ {
+ return;
+ }
+
+ _windowStart = now;
+ var seconds = elapsedTicks / (double)Stopwatch.Frequency;
+
+ // Total blocked time across all queues. Above 1000ms/s the queues are
+ // overlapping their stalls, so compare it against the frame budget,
+ // not against wall time.
+ var top = _byLabel
+ .OrderByDescending(entry => entry.Value.Milliseconds)
+ .Take(5)
+ .Select(entry =>
+ $"0x{entry.Key:X}={entry.Value.Milliseconds / seconds:F0}ms/s" +
+ $"/n{entry.Value.Count}")
+ .ToArray();
+
+ line =
+ $"[PERF][GPUWAIT] {seconds:F1}s suspend/s={_suspensions / seconds:F0} " +
+ $"resume/s={_resumes / seconds:F0} producerless/s={_producerless / seconds:F0} " +
+ $"blocked_ms/s={_totalWaitMilliseconds / seconds:F0} " +
+ $"avg_ms={(_resumes > 0 ? _totalWaitMilliseconds / _resumes : 0):F2} " +
+ $"max_ms={_maxWaitMilliseconds:F1} " +
+ $"monitor_polls/s={_monitorPolls / seconds:F0} " +
+ $"empty={(_monitorPolls > 0 ? _monitorEmptyPolls * 100.0 / _monitorPolls : 0):F0}% " +
+ $"outstanding={remainingWaiters} top: {string.Join(" | ", top)}";
+
+ _suspensions = 0;
+ _resumes = 0;
+ _producerless = 0;
+ _monitorPolls = 0;
+ _monitorEmptyPolls = 0;
+ _totalWaitMilliseconds = 0;
+ _maxWaitMilliseconds = 0;
+ _byLabel.Clear();
+ }
+
+ Console.Error.WriteLine(line);
+ }
+}