diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5839733f..404a1e98 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,6 +17,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
index ca25bb1c..b76069ee 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
@@ -55,6 +55,7 @@ public sealed partial class DirectExecutionBackend
}
_exceptionHandler = (nint)AddVectoredExceptionHandler(1u, _exceptionHandlerStub);
Console.Error.WriteLine($"[LOADER][INFO] Exception handler installed: 0x{_exceptionHandler:X16}");
+ SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
_unhandledFilterDelegate = UnhandledExceptionFilter;
_unhandledFilterHandle = GCHandle.Alloc(_unhandledFilterDelegate);
@@ -117,6 +118,13 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
+ if (exceptionCode == 3221225477u &&
+ exceptionRecord->NumberParameters >= 2 &&
+ SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
+ exceptionRecord->ExceptionInformation[1]))
+ {
+ return -1;
+ }
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
{
return -1;
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
index 4ed87961..c352b2c4 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
@@ -69,6 +69,15 @@ 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]))
+ {
+ return -1;
+ }
+
return TryRecoverUnresolvedSentinel(exceptionInfo);
}
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
index 4fb10e6f..54c7fb06 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
@@ -13,6 +13,7 @@ using SharpEmu.Core.Cpu.Debugging;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
+using SharpEmu.Libs.Diagnostics;
namespace SharpEmu.Core.Cpu.Native;
@@ -3651,6 +3652,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
$"[LOADER][INFO] Scheduled guest thread '{thread.Name}' handle=0x{thread.ThreadHandle:X16} " +
$"entry=0x{thread.EntryPoint:X16} arg=0x{thread.Argument:X16} priority={thread.Priority} " +
$"host_priority={MapGuestThreadPriority(thread.Priority)} affinity=0x{thread.AffinityMask:X}");
+ LoadProgressDiagnostics.ArmIfNorthAudioThread(thread.Name);
Pump(creatorContext, "pthread_create");
// Pump is suppressed while another cooperative dispatch is active. The
// background dispatcher would eventually observe this thread, but an
diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
index 28abd50a..f7cfc00c 100644
--- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
+++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
@@ -238,15 +238,22 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
-
- // Reserve address space only for very large non-executable regions; commit is done lazily later.
- var reservedOnly = !executable &&
+ var allowLazyReserve = !executable &&
alignedSize >= LargeDataReserveThreshold &&
alignedSize > FullCommitRegionLimit;
- var result = reservedOnly
- ? _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite)
- : _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
+ // Commit first so titles that walk guest memory via raw host pointers
+ // (GTA post-RenderThread workers) keep fully backed pages. Fall back to
+ // reserve-only + lazy commit only when a huge non-exec commit fails —
+ // that is the Poppy / large-reservation path #608 was aiming for.
+ var reservedOnly = false;
+ var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
+ if (result == 0 && allowLazyReserve)
+ {
+ result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
+ reservedOnly = result != 0;
+ }
+
if (result == 0)
{
return false;
@@ -260,7 +267,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
- var state = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
+ var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
_gate.EnterWriteLock();
try
@@ -279,9 +286,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
-
- var allocationKind = executable ? "executable memory" : "data memory";
- TraceVmem($"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} ({alignedSize} bytes)");
+ var allocationKind = reservedOnly
+ ? "reserved data memory (lazy commit)"
+ : (executable ? "executable memory" : "data memory");
+ TraceVmem(
+ $"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} " +
+ $"({alignedSize} bytes) lazy_prime={lazyPrimeState}");
return true;
}
@@ -312,55 +322,44 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
- var reservedOnly = false;
- var preferReserveOnly = !executable &&
+ var allowLazyReserve = !executable &&
alignedSize >= LargeDataReserveThreshold &&
alignedSize > FullCommitRegionLimit;
+ var reservedOnly = false;
- ulong result = 0;
- if (preferReserveOnly)
- {
- result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
- if (result == 0 && allowAlternative)
- {
- result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
- }
-
- if (result != 0)
- {
- reservedOnly = true;
- }
- }
-
- if (result == 0)
- {
- result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
- }
+ // Prefer a full commit. Only fall back to reserve-only when a large
+ // non-executable commit cannot be satisfied (see TryAllocateAtExact).
+ ulong result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
if (result == 0)
{
if (!allowAlternative)
{
- throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
- }
-
- TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
- result = _hostMemory.Allocate(0, alignedSize, hostProtection);
-
- if (result == 0)
- {
- if (!executable)
+ if (allowLazyReserve)
{
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
- if (result == 0 && allowAlternative)
+ reservedOnly = result != 0;
+ }
+
+ if (result == 0)
+ {
+ throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
+ }
+ }
+ else
+ {
+ TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
+ result = _hostMemory.Allocate(0, alignedSize, hostProtection);
+
+ if (result == 0 && allowLazyReserve)
+ {
+ result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
+ if (result == 0)
{
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
}
- if (result != 0)
- {
- reservedOnly = true;
- }
+ reservedOnly = result != 0;
}
if (result == 0)
@@ -371,8 +370,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
var actualAddress = result;
-
- var lazyPrimeState = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
+ var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
_gate.EnterWriteLock();
try
@@ -399,7 +397,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return actualAddress;
}
- private string ReserveRegion(ulong actualAddress, ulong alignedSize)
+ ///
+ /// Commits the leading slice of a reserve-only region so early guest touches
+ /// succeed before on-demand runs.
+ ///
+ private string PrimeLazyReserveRegion(ulong actualAddress, ulong alignedSize)
{
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
if (primeBytes == 0)
@@ -426,11 +428,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var state = committedBytes == primeBytes
? $"ok:{committedBytes:X}"
: $"partial:{committedBytes:X}/{primeBytes:X}";
- TraceVmem($"region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
+ TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
return state;
}
- TraceVmem($"Failed to reserve region at 0x{actualAddress:X16} ({primeBytes} bytes)!");
+ TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
return $"fail:{primeBytes:X}";
}
@@ -1316,12 +1318,26 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
try
{
var region = FindRegion(virtualAddress, 1);
- if (region is null ||
- (region.IsReservedOnly && !EnsureRangeCommitted(virtualAddress, 1, region)))
+ if (region is null)
{
return null;
}
+ // Raw host pointers are walked by native/JIT code without further
+ // EnsureRangeCommitted calls. For reserve-only regions, commit a
+ // leading working-set chunk from this address so the common case
+ // does not immediately AV on the next page.
+ if (region.IsReservedOnly)
+ {
+ var regionEnd = region.VirtualAddress + region.Size;
+ var remaining = regionEnd > virtualAddress ? regionEnd - virtualAddress : 0;
+ var commitBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
+ if (commitBytes == 0 || !EnsureRangeCommitted(virtualAddress, commitBytes, region))
+ {
+ return null;
+ }
+ }
+
return (void*)virtualAddress;
}
finally
diff --git a/src/SharpEmu.HLE/GuestImageWriteTracker.cs b/src/SharpEmu.HLE/GuestImageWriteTracker.cs
index c749f7a3..057d9ae8 100644
--- a/src/SharpEmu.HLE/GuestImageWriteTracker.cs
+++ b/src/SharpEmu.HLE/GuestImageWriteTracker.cs
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
@@ -30,6 +31,12 @@ public static unsafe class GuestImageWriteTracker
public ulong End;
public int Dirty;
public int Armed;
+ ///
+ /// When false the range is watch-only: managed writes still dirty it via
+ /// , but pages are never write-protected
+ /// so native CPU stores do not fault.
+ ///
+ public bool Protect;
public int FirstCpuWriteSeen;
public int PendingFirstCpuWrite;
public long WriteGeneration;
@@ -80,8 +87,17 @@ public static unsafe class GuestImageWriteTracker
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
- private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
- Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
+ // Windows defaults off: VirtualProtect fault sync still regresses titles
+ // like Dead Cells / Demon's Souls. Opt in with SHARPEMU_GUEST_IMAGE_CPU_SYNC=1
+ // when a title needs CPU-written guest planes (e.g. GTA intro). Linux/macOS
+ // keep the historical opt-out (=0 disables).
+ private static readonly bool _enabled =
+ OperatingSystem.IsWindows()
+ ? string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
+ "1",
+ StringComparison.Ordinal)
+ : 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 =
@@ -95,14 +111,67 @@ public static unsafe class GuestImageWriteTracker
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
private static long _lifetimeTraceSequence;
+ private const uint PageReadonly = 0x02;
+ private const uint PageReadWrite = 0x04;
+
[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);
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern int VirtualProtect(
+ nint lpAddress,
+ nuint dwSize,
+ uint flNewProtect,
+ out uint lpflOldProtect);
+
+ [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 const uint MemCommit = 0x1000;
+ private const uint MemReserve = 0x2000;
+ private const uint MemRelease = 0x8000;
+
public static bool Enabled => _enabled;
+ ///
+ /// Test/diagnostics helper: whether is tracked
+ /// with write protection armed (watch-only ranges report protect=false).
+ ///
+ public static bool TryGetProtectionState(
+ ulong address,
+ out bool protect,
+ out bool armed)
+ {
+ protect = false;
+ armed = false;
+ if (!_enabled)
+ {
+ return false;
+ }
+
+ lock (_gate)
+ {
+ if (!_rangesByAddress.TryGetValue(address, out var range))
+ {
+ return false;
+ }
+
+ protect = range.Protect;
+ armed = Volatile.Read(ref range.Armed) != 0;
+ return true;
+ }
+ }
+
///
/// Exercises the fault-handling path once outside signal context so every
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
@@ -115,7 +184,17 @@ public static unsafe class GuestImageWriteTracker
return;
}
- var scratch = NativeMemory.AllocZeroed(4096);
+ // VirtualProtect only belongs on VirtualAlloc/mmap pages. Warming on
+ // CRT heap memory makes neighbouring heap metadata read-only and
+ // crashes the process on Windows.
+ var scratch = OperatingSystem.IsWindows()
+ ? VirtualAlloc(0, 4096, MemCommit | MemReserve, PageReadWrite)
+ : (nint)NativeMemory.AllocZeroed(4096);
+ if (scratch == 0)
+ {
+ return;
+ }
+
try
{
// Warm the timestamp P/Invoke used by the signal-safe scalar
@@ -129,16 +208,29 @@ public static unsafe class GuestImageWriteTracker
}
finally
{
- NativeMemory.Free(scratch);
+ if (OperatingSystem.IsWindows())
+ {
+ _ = VirtualFree(scratch, 0, MemRelease);
+ }
+ else
+ {
+ NativeMemory.Free((void*)scratch);
+ }
}
}
- /// Registers a range and arms write protection on it.
+ ///
+ /// Registers a range. When is true, arms write
+ /// protection so native stores fault and mark the range dirty. When false,
+ /// the range is watch-only (managed HLE writes still dirty via
+ /// ) and never VirtualProtect'd.
+ ///
public static void Track(
ulong address,
ulong byteCount,
long sourceSequence = 0,
- string source = "unspecified")
+ string source = "unspecified",
+ bool protect = true)
{
if (!_enabled || address == 0 || byteCount == 0)
{
@@ -159,6 +251,7 @@ public static unsafe class GuestImageWriteTracker
// a fresh immutable range, carrying the write generation so
// resizes do not hide guest CPU rewrites from cache owners.
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
+ var keepProtect = range.Protect || protect;
DisarmLocked(range, "replace-range");
_rangesByAddress.Remove(address);
range = new TrackedRange
@@ -167,6 +260,7 @@ public static unsafe class GuestImageWriteTracker
ByteCount = byteCount,
Start = start,
End = start + length,
+ Protect = keepProtect,
WriteGeneration = writeGeneration,
};
_rangesByAddress[address] = range;
@@ -181,6 +275,7 @@ public static unsafe class GuestImageWriteTracker
ByteCount = byteCount,
Start = start,
End = start + length,
+ Protect = protect,
TraceLifetime =
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
SourceSequence = sourceSequence,
@@ -192,13 +287,22 @@ public static unsafe class GuestImageWriteTracker
else
{
FlushPendingFirstCpuWrite(range);
+ // Protect is sticky: a later watch-only Track (texture cache)
+ // must not disarm an RT that already needs page faults.
+ if (protect && !range.Protect)
+ {
+ range.Protect = true;
+ }
}
range.SourceSequence = sourceSequence;
range.Source = source;
range.TraceLifetime =
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
- ArmLocked(range, "arm");
+ if (range.Protect)
+ {
+ ArmLocked(range, "arm");
+ }
}
}
@@ -277,7 +381,8 @@ public static unsafe class GuestImageWriteTracker
lock (_gate)
{
- if (_rangesByAddress.TryGetValue(address, out var range))
+ if (_rangesByAddress.TryGetValue(address, out var range) &&
+ range.Protect)
{
ArmLocked(range, "rearm");
}
@@ -445,10 +550,7 @@ public static unsafe class GuestImageWriteTracker
}
if (needsUnprotect &&
- Mprotect(
- (nint)writableStart,
- (nuint)(writableEnd - writableStart),
- ProtRead | ProtWrite) != 0)
+ !TrySetProtection(writableStart, writableEnd - writableStart, writable: true))
{
return false;
}
@@ -462,7 +564,11 @@ public static unsafe class GuestImageWriteTracker
}
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
- if (wasArmed)
+ var wasDirty = Interlocked.Exchange(ref range.Dirty, 1) != 0;
+ // Protected ranges bump generation once per arm/fault cycle.
+ // Watch-only ranges never arm, so bump on the first dirty mark
+ // (NotifyManagedWrite) so cache owners still see a rewrite.
+ if (wasArmed || (!range.Protect && !wasDirty))
{
Interlocked.Increment(ref range.WriteGeneration);
}
@@ -480,8 +586,6 @@ public static unsafe class GuestImageWriteTracker
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
}
-
- Volatile.Write(ref range.Dirty, 1);
}
return true;
@@ -497,10 +601,7 @@ public static unsafe class GuestImageWriteTracker
// 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;
+ var failed = !TrySetProtection(range.Start, range.End - range.Start, writable: false);
if (failed)
{
Volatile.Write(ref range.Armed, 0);
@@ -520,10 +621,7 @@ public static unsafe class GuestImageWriteTracker
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
if (wasArmed)
{
- _ = Mprotect(
- (nint)range.Start,
- (nuint)(range.End - range.Start),
- ProtRead | ProtWrite);
+ _ = TrySetProtection(range.Start, range.End - range.Start, writable: true);
}
if (range.TraceLifetime)
@@ -534,7 +632,13 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked()
{
- Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
+ // Fault / NotifyManagedWrite hot paths must only see protected ranges.
+ // Watch-only texture-cache registrations used to widen Start..End across
+ // nearly all GPU memory so every managed guest write walked this path.
+ var protectedRanges = _rangesByAddress.Values
+ .Where(static range => range.Protect)
+ .ToArray();
+ Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(protectedRanges));
}
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
@@ -679,8 +783,35 @@ public static unsafe class GuestImageWriteTracker
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
}
+ private static bool TrySetProtection(ulong start, ulong length, bool writable)
+ {
+ if (length == 0)
+ {
+ return true;
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ return VirtualProtect(
+ (nint)start,
+ (nuint)length,
+ writable ? PageReadWrite : PageReadonly,
+ out _) != 0;
+ }
+
+ return Mprotect(
+ (nint)start,
+ (nuint)length,
+ writable ? ProtRead | ProtWrite : ProtRead) == 0;
+ }
+
private static long GetMonotonicNanoseconds()
{
+ if (OperatingSystem.IsWindows())
+ {
+ return Stopwatch.GetTimestamp() * 1_000_000_000L / Stopwatch.Frequency;
+ }
+
Timespec time;
return ClockGetTime(ClockMonotonicRaw, &time) == 0
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
diff --git a/src/SharpEmu.HLE/Host/IHostAudioOutput.cs b/src/SharpEmu.HLE/Host/IHostAudioOutput.cs
index 2f84fe57..82c8ce50 100644
--- a/src/SharpEmu.HLE/Host/IHostAudioOutput.cs
+++ b/src/SharpEmu.HLE/Host/IHostAudioOutput.cs
@@ -19,5 +19,11 @@ public interface IHostAudioOutput
/// Throws when the host has no usable output device; callers degrade to a silent
/// port and pace the guest instead.
///
- IHostAudioStream OpenStereoPcm16Stream(uint sampleRate);
+ /// Host stream sample rate in Hz.
+ ///
+ /// Soft backpressure cap for queued stereo PCM16. Default 32 KiB (~171 ms at
+ /// 48 kHz) matches classic AudioOut latency. Bursty AudioOut2 / FMOD feeders
+ /// may pass a deeper cap to avoid underruns.
+ ///
+ IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024);
}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs b/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs
index afcae681..0c1cd2b7 100644
--- a/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs
+++ b/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs
@@ -14,9 +14,6 @@ namespace SharpEmu.HLE.Host.Posix;
///
internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
{
- // 32KB of stereo PCM16 at 48kHz is ~170ms; keep the same device-side
- // queue depth the WinMM/CoreAudio ports enforce in managed code.
- private const uint DeviceLatencyMicroseconds = 170_000;
private const int StreamPlayback = 0;
private const int FormatS16LittleEndian = 2;
private const int AccessReadWriteInterleaved = 3;
@@ -27,7 +24,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
private nint _pcm;
private bool _disposed;
- public PosixAlsaAudioStream(uint sampleRate)
+ public PosixAlsaAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
if (!OperatingSystem.IsLinux())
{
@@ -47,6 +44,14 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
$"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
}
+ // Match WinMM/CoreAudio soft queue depth: 32 KiB stereo PCM16 @ 48 kHz
+ // is ~170 ms. AudioOut2 may request a deeper bed.
+ var queuedBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
+ var latencyMicroseconds = (uint)Math.Clamp(
+ (long)queuedBytes * 1_000_000L / Math.Max(sampleRate * 4u, 1u),
+ 20_000L,
+ 2_000_000L);
+
status = snd_pcm_set_params(
_pcm,
FormatS16LittleEndian,
@@ -54,7 +59,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
2,
sampleRate,
1,
- DeviceLatencyMicroseconds);
+ latencyMicroseconds);
if (status != 0)
{
_ = snd_pcm_close(_pcm);
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs b/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs
index f5ff3295..bbea2602 100644
--- a/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs
+++ b/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs
@@ -13,11 +13,11 @@ namespace SharpEmu.HLE.Host.Posix;
///
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
{
- private const int MaximumQueuedPcmBytes = 32 * 1024;
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
private const uint FlagIsSignedInteger = 0x4;
private const uint FlagIsPacked = 0x8;
+ private readonly int _maximumQueuedPcmBytes;
private readonly object _gate = new();
private readonly AutoResetEvent _completion = new(false);
private readonly Queue _freeBuffers = new();
@@ -27,13 +27,15 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
private bool _started;
private bool _disposed;
- public PosixCoreAudioStream(uint sampleRate)
+ public PosixCoreAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
if (!OperatingSystem.IsMacOS())
{
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
}
+ _maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
+
var format = new AudioStreamBasicDescription
{
SampleRate = sampleRate,
@@ -73,7 +75,7 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
var outputLength = stereoPcm16.Length;
while (_queuedPcmBytes != 0 &&
- _queuedPcmBytes + outputLength > MaximumQueuedPcmBytes)
+ _queuedPcmBytes + outputLength > _maximumQueuedPcmBytes)
{
Monitor.Exit(_gate);
try
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs
index 133479ef..d45bac77 100644
--- a/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs
@@ -12,10 +12,10 @@ internal sealed class PosixHostAudio : IHostAudioOutput
{
public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
- public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate)
+ public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
return OperatingSystem.IsMacOS()
- ? new PosixCoreAudioStream(sampleRate)
- : new PosixAlsaAudioStream(sampleRate);
+ ? new PosixCoreAudioStream(sampleRate, maxQueuedPcmBytes)
+ : new PosixAlsaAudioStream(sampleRate, maxQueuedPcmBytes);
}
}
diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs b/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs
index 30ce38c3..cb443e84 100644
--- a/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs
+++ b/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs
@@ -9,7 +9,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
{
public string BackendName => "winmm";
- public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate) => new WaveOutStream(sampleRate);
+ public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024) =>
+ new WaveOutStream(sampleRate, maxQueuedPcmBytes);
private sealed partial class WaveOutStream : IHostAudioStream
{
@@ -17,8 +18,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
private const uint CallbackEvent = 0x0005_0000;
private const ushort WaveFormatPcm = 1;
private const uint WaveHeaderDone = 0x0000_0001;
- private const int MaximumQueuedPcmBytes = 32 * 1024;
+ private readonly int _maximumQueuedPcmBytes;
private readonly object _gate = new();
private readonly AutoResetEvent _completion = new(false);
private readonly Queue _buffers = new();
@@ -26,8 +27,9 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
private int _queuedPcmBytes;
private bool _disposed;
- public WaveOutStream(uint sampleRate)
+ public WaveOutStream(uint sampleRate, int maxQueuedPcmBytes)
{
+ _maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
var format = new WaveFormat
{
FormatTag = WaveFormatPcm,
@@ -62,7 +64,7 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
ReapCompletedBuffers();
while (_queuedPcmBytes != 0 &&
- _queuedPcmBytes + stereoPcm16.Length > MaximumQueuedPcmBytes)
+ _queuedPcmBytes + stereoPcm16.Length > _maximumQueuedPcmBytes)
{
if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
{
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index dc22ab0c..3ee6acfc 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -55,9 +55,12 @@ public static partial class AgcExports
private const uint ItEventWrite = 0x46;
private const uint ItReleaseMem = 0x49;
private const uint ItDmaData = 0x50;
+ private const uint ItRewind = 0x59;
private const uint ItSetContextReg = 0x69;
private const uint ItSetShReg = 0x76;
private const uint ItSetUconfigReg = 0x79;
+ private const uint RewindValidBit = 1u << 31;
+ private const uint RewindOffloadEnableBit = 1u << 24;
private const uint ItGetLodStats = 0x8E;
private const uint RZero = 0x00;
private const uint RDrawIndexAuto = 0x04;
@@ -80,12 +83,18 @@ public static partial class AgcExports
private const uint RIndexCount = 0x1C;
private const uint SpiShaderPgmLoPs = 0x8;
private const uint SpiShaderPgmHiPs = 0x9;
+ private const uint SpiShaderPgmLoVs = 0x48;
+ private const uint SpiShaderPgmHiVs = 0x49;
private const uint SpiShaderPgmLoEs = 0xC8;
private const uint SpiShaderPgmHiEs = 0xC9;
+ private const uint SpiShaderPgmLoHs = 0x108;
+ private const uint SpiShaderPgmHiHs = 0x109;
+ private const uint SpiShaderPgmRsrc1Hs = 0x10A;
private const uint SpiShaderPgmLoLs = 0x148;
private const uint SpiShaderPgmHiLs = 0x149;
private const uint SpiShaderPgmLoGs = 0x8A;
private const uint SpiShaderPgmHiGs = 0x8B;
+ private const uint SpiShaderPgmChksumGs = 0x80;
private const uint SpiPsInputEna = 0x1B3;
private const uint SpiPsInputAddr = 0x1B4;
private const uint ComputePgmLo = 0x20C;
@@ -99,6 +108,10 @@ public static partial class AgcExports
private const uint ComputeNumThreadZ = 0x209;
private const uint SpiPsInputCntl0 = 0x191;
private const uint VgtPrimitiveType = 0x242;
+ private const uint VgtIndexType = 0x243;
+ // GE_INDX_OFFSET — base vertex for DrawIndexed / firstVertex for
+ // DrawIndexAuto. Glyph meshes and UI icon batches rely on this.
+ private const uint GeIndxOffset = 0x24A;
private const uint PaScScreenScissorTl = 0x0C;
private const uint PaScScreenScissorBr = 0x0D;
private const uint CbTargetMask = 0x8E;
@@ -185,6 +198,16 @@ public static partial class AgcExports
private const ulong ShaderNumOutputSemanticsOffset = 0x56;
private const ulong ShaderTypeOffset = 0x5A;
private const ulong ShaderNumShRegistersOffset = 0x5C;
+ private const int ShaderStructBytes = 0x60;
+ private const ulong FusedShaderImageAlignment = 4;
+ private const byte ComputeShaderType = 0;
+ private const byte PsShaderType = 1;
+ private const byte GsShaderType = 2;
+ private const byte HsShaderType = 3;
+ private const byte GsFrontShaderType = 4;
+ private const byte HsFrontShaderType = 5;
+ private const byte GsBackShaderType = 6;
+ private const byte HsBackShaderType = 7;
private const ulong CommandBufferCursorUpOffset = 0x10;
private const ulong CommandBufferCursorDownOffset = 0x18;
private const ulong CommandBufferCallbackOffset = 0x20;
@@ -192,6 +215,8 @@ public static partial class AgcExports
private const ulong CommandBufferReservedDwOffset = 0x30;
private const ulong ShaderSpecialGeCntlOffset = 0x00;
private const ulong ShaderSpecialVgtShaderStagesEnOffset = 0x08;
+ private const uint VgtShaderStagesHsW32EnBit = 1u << 21;
+ private const uint VgtShaderStagesGsW32EnBit = 1u << 22;
private const ulong ShaderSpecialVgtGsOutPrimTypeOffset = 0x20;
private const ulong ShaderSpecialGeUserVgprEnOffset = 0x28;
private const uint CbSetShRegisterRangeMarker = 0x6875000D;
@@ -215,7 +240,7 @@ public static partial class AgcExports
private static readonly ConcurrentDictionary<
(ulong Es, ulong EsState, ulong Ps, ulong PsState, ulong OutputLayout,
uint OutputCount, uint Attributes, uint PsInputEna, uint PsInputAddr,
- ulong AliasAlignment),
+ ulong PsInputCntl, ulong AliasAlignment),
(IGuestCompiledShader Vertex, IGuestCompiledShader Pixel)> _graphicsShaderCache = new();
private static readonly ConcurrentDictionary<
(ulong Cs, ulong State, uint LocalX, uint LocalY, uint LocalZ,
@@ -294,6 +319,7 @@ public static partial class AgcExports
private static int _tracedVertexRangeCount;
private static long _dcbWaitRegMemTraceCount;
private static long _createShaderTraceCount;
+ private static long _cbMetadataSkipTraceCount;
private static long _packetPayloadTraceCount;
private static bool _tracedMissingPixelShaderBindings;
private static long _unsatisfiedWaitTraceCount;
@@ -447,6 +473,7 @@ public static partial class AgcExports
uint AttributeCount,
uint VertexCount,
uint InstanceCount,
+ int BaseVertex,
GuestIndexBuffer? IndexBuffer,
IReadOnlyList Textures,
IReadOnlyList GlobalMemoryBindings,
@@ -583,6 +610,11 @@ public static partial class AgcExports
public uint FrameDrawCount { get; set; }
public uint FrameDispatchCount { get; set; }
public ulong FlipCount { get; set; }
+ // Coalesce ACQUIRE_MEM invalidations within one DCB parse so North
+ // Yankton load does not enqueue hundreds of empty OrderedGuestActions.
+ public bool PendingAcquireInvalidation { get; set; }
+ public ulong PendingAcquireBase { get; set; }
+ public ulong PendingAcquireSize { get; set; }
}
private sealed class SubmittedGpuState
@@ -764,6 +796,177 @@ public static partial class AgcExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
+ // NID captured from shipped titles; the friendly name collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation.
+ #pragma warning disable SHEM004
+ [SysAbiExport(
+ Nid = "dolOmWH+huQ",
+ ExportName = "sceAgcGetFusedShaderSize",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int GetFusedShaderSize(CpuContext ctx)
+ {
+ var destinationAddress = ctx[CpuRegister.Rdi];
+ var frontAddress = ctx[CpuRegister.Rsi];
+ var backAddress = ctx[CpuRegister.Rdx];
+ if (destinationAddress == 0 || frontAddress == 0 || backAddress == 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!TryReadByte(ctx, frontAddress + ShaderTypeOffset, out var frontType) ||
+ !TryReadByte(ctx, backAddress + ShaderTypeOffset, out var backType) ||
+ !TryReadByte(ctx, backAddress + ShaderNumShRegistersOffset, out var registerCount))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (!IsFusedShaderHalfPair(frontType, backType))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!ctx.TryWriteUInt64(destinationAddress, registerCount * 8UL) ||
+ !ctx.TryWriteUInt64(destinationAddress + 8, FusedShaderImageAlignment))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAgc(
+ $"agc.get_fused_shader_size front=0x{frontAddress:X16} back=0x{backAddress:X16} " +
+ $"types={frontType}/{backType} registers={registerCount}");
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+ #pragma warning restore SHEM004
+
+ // NID captured from shipped titles; the friendly name collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation.
+ #pragma warning disable SHEM004
+ [SysAbiExport(
+ Nid = "fd5Bp5tGTgo",
+ ExportName = "sceAgcFuseShaderHalves",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int FuseShaderHalves(CpuContext ctx)
+ {
+ var fusedAddress = ctx[CpuRegister.Rdi];
+ var frontAddress = ctx[CpuRegister.Rsi];
+ var backAddress = ctx[CpuRegister.Rdx];
+ var scratchAddress = ctx[CpuRegister.Rcx];
+ if (fusedAddress == 0 || frontAddress == 0 || backAddress == 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!TryReadByte(ctx, frontAddress + ShaderTypeOffset, out var frontType) ||
+ !TryReadByte(ctx, backAddress + ShaderTypeOffset, out var backType))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (!IsFusedShaderHalfPair(frontType, backType))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!TryReadUInt64(ctx, frontAddress + ShaderSpecialsOffset, out var frontSpecialsAddress) ||
+ !TryReadUInt64(ctx, backAddress + ShaderSpecialsOffset, out var backSpecialsAddress))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var isGeometryPair = frontType == GsFrontShaderType;
+ if (frontSpecialsAddress != 0 && backSpecialsAddress != 0)
+ {
+ if (!TryReadUInt32(ctx, frontSpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), out var frontStages) ||
+ !TryReadUInt32(ctx, backSpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), out var backStages))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var waveSizeBit = isGeometryPair ? VgtShaderStagesGsW32EnBit : VgtShaderStagesHsW32EnBit;
+ if (((frontStages ^ backStages) & waveSizeBit) != 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+ }
+
+ if (!TryReadUInt64(ctx, backAddress + ShaderShRegistersOffset, out var backRegistersAddress) ||
+ !TryReadByte(ctx, backAddress + ShaderNumShRegistersOffset, out var registerCount) ||
+ !TryReadUInt64(ctx, frontAddress + ShaderCodeOffset, out var frontCodeAddress))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ Span header = stackalloc byte[ShaderStructBytes];
+ if (!ctx.Memory.TryRead(backAddress, header) ||
+ !ctx.Memory.TryWrite(fusedAddress, header))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var fusedRegistersAddress = backRegistersAddress;
+ if (scratchAddress != 0 && backRegistersAddress != 0 && registerCount != 0)
+ {
+ Span registers = stackalloc byte[registerCount * 8];
+ if (!ctx.Memory.TryRead(backRegistersAddress, registers) ||
+ !ctx.Memory.TryWrite(scratchAddress, registers))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ fusedRegistersAddress = scratchAddress;
+ }
+
+ if (!TryWriteByte(ctx, fusedAddress + ShaderTypeOffset, isGeometryPair ? GsShaderType : HsShaderType) ||
+ !ctx.TryWriteUInt64(fusedAddress + ShaderUserDataOffset, 0) ||
+ !ctx.TryWriteUInt64(fusedAddress + ShaderShRegistersOffset, fusedRegistersAddress))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (isGeometryPair)
+ {
+ if (!TryReadUInt64(ctx, frontAddress + ShaderShRegistersOffset, out var frontRegistersAddress) ||
+ !TryReadByte(ctx, frontAddress + ShaderNumShRegistersOffset, out var frontRegisterCount))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ for (var occurrence = 0; occurrence < 2; occurrence++)
+ {
+ if (!TryFindShaderRegister(ctx, fusedRegistersAddress, registerCount, SpiShaderPgmChksumGs, occurrence, out var fusedEntry) ||
+ !TryFindShaderRegister(ctx, frontRegistersAddress, frontRegisterCount, SpiShaderPgmChksumGs, occurrence, out var frontEntry))
+ {
+ continue;
+ }
+
+ if (!TryReadUInt32(ctx, frontEntry + sizeof(uint), out var checksum) ||
+ !TryWriteUInt32(ctx, fusedEntry + sizeof(uint), checksum))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+ }
+
+ if (!PatchFusedProgramAddress(
+ ctx,
+ fusedRegistersAddress,
+ registerCount,
+ isGeometryPair ? SpiShaderPgmLoEs : SpiShaderPgmLoLs,
+ frontCodeAddress))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAgc(
+ $"agc.fuse_shader_halves fused=0x{fusedAddress:X16} front=0x{frontAddress:X16} " +
+ $"back=0x{backAddress:X16} scratch=0x{scratchAddress:X16} types={frontType}/{backType} " +
+ $"registers={registerCount} code=0x{frontCodeAddress:X16}");
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+ #pragma warning restore SHEM004
+
[SysAbiExport(
Nid = "vcmNN+AAXnY",
ExportName = "sceAgcSetCxRegIndirectPatchSetAddress",
@@ -825,7 +1028,11 @@ public static partial class AgcExports
var geometryShaderAddress = ctx[CpuRegister.Rcx];
var primitiveType = (uint)ctx[CpuRegister.R8];
- if (cxRegistersAddress == 0 || ucRegistersAddress == 0 || hullShaderAddress != 0 || geometryShaderAddress == 0)
+ // Hull is optional: tessellation pipelines (GTA fused HS, Ghost of Yōtei)
+ // pass a non-null hull-state block here. Geometry-derived CX/UC writes
+ // stay the same; the hull stage itself is not modelled yet, so it is
+ // only recorded in the trace (#583).
+ if (cxRegistersAddress == 0 || ucRegistersAddress == 0 || geometryShaderAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
@@ -847,7 +1054,9 @@ public static partial class AgcExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
- TraceAgc($"agc.create_prim_state cx=0x{cxRegistersAddress:X16} uc=0x{ucRegistersAddress:X16} gs=0x{geometryShaderAddress:X16} type={shaderType} prim=0x{primitiveType:X8}");
+ TraceAgc(
+ $"agc.create_prim_state cx=0x{cxRegistersAddress:X16} uc=0x{ucRegistersAddress:X16} " +
+ $"hull=0x{hullShaderAddress:X16} gs=0x{geometryShaderAddress:X16} type={shaderType} prim=0x{primitiveType:X8}");
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -865,54 +1074,229 @@ public static partial class AgcExports
var geometryShaderAddress = ctx[CpuRegister.Rsi];
var pixelShaderAddress = ctx[CpuRegister.Rdx];
- if (registersAddress == 0 || geometryShaderAddress == 0)
+ if (registersAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- if (!TryReadUInt64(ctx, geometryShaderAddress + ShaderOutputSemanticsOffset, out var outputSemanticsAddress) ||
- !TryReadUInt32(ctx, geometryShaderAddress + ShaderNumOutputSemanticsOffset, out var outputSemanticsCount))
- {
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
- }
-
+ // SPI_PS_INPUT_CNTL maps each PS VINTRP ATTR slot to a VS/GS param export.
+ // Walk PS input semantics, find the GS output with the same semantic id,
+ // and pack the hardware CNTL word (location in bits [4:0], Flat at 0x400).
+ uint inputSemanticsCount = 0;
ulong inputSemanticsAddress = 0;
- if (pixelShaderAddress != 0 &&
- (!TryReadUInt64(ctx, pixelShaderAddress + ShaderInputSemanticsOffset, out inputSemanticsAddress) ||
- !TryReadUInt32(ctx, pixelShaderAddress + ShaderNumInputSemanticsOffset, out _)))
+ if (pixelShaderAddress != 0)
{
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
- }
-
- for (uint i = 0; i < 32; i++)
- {
- uint value = 0;
- if (i < outputSemanticsCount && outputSemanticsAddress != 0)
- {
- var flat = false;
- if (pixelShaderAddress != 0 && inputSemanticsAddress != 0 &&
- TryReadUInt32(ctx, inputSemanticsAddress + (i * sizeof(uint)), out var inputSemantic))
- {
- flat = ((inputSemantic >> 22) & 0x1) != 0;
- }
-
- value = i | (flat ? 0x400u : 0u);
- }
-
- var destination = registersAddress + (i * 8);
- if (!TryWriteUInt32(ctx, destination, SpiPsInputCntl0 + i) ||
- !TryWriteUInt32(ctx, destination + sizeof(uint), value))
+ if (!TryReadUInt64(ctx, pixelShaderAddress + ShaderInputSemanticsOffset, out inputSemanticsAddress) ||
+ !TryReadUInt32(ctx, pixelShaderAddress + ShaderNumInputSemanticsOffset, out inputSemanticsCount))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
- TraceAgc($"agc.create_interpolant_mapping regs=0x{registersAddress:X16} gs=0x{geometryShaderAddress:X16} ps=0x{pixelShaderAddress:X16}");
+ if (inputSemanticsCount == 0 || inputSemanticsAddress == 0)
+ {
+ if (!TryWriteIdentityInterpolantRegisters(ctx, registersAddress, 0))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAgc(
+ $"agc.create_interpolant_mapping regs=0x{registersAddress:X16} " +
+ $"gs=0x{geometryShaderAddress:X16} ps=0x{pixelShaderAddress:X16} inputs=0");
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
+ if (geometryShaderAddress == 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ // NumOutputSemantics is a u16 at header +0x56.
+ if (!TryReadUInt64(ctx, geometryShaderAddress + ShaderOutputSemanticsOffset, out var outputSemanticsAddress) ||
+ !TryReadUInt16(ctx, geometryShaderAddress + ShaderNumOutputSemanticsOffset, out var outputSemanticsCount))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ inputSemanticsCount = Math.Min(inputSemanticsCount, 32u);
+ for (uint psIndex = 0; psIndex < inputSemanticsCount; psIndex++)
+ {
+ if (!TryReadUInt32(
+ ctx,
+ inputSemanticsAddress + (psIndex * sizeof(uint)),
+ out var psWord))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var psSemantic = psWord & 0xFFu;
+ uint? gsWord = null;
+ if (outputSemanticsAddress != 0)
+ {
+ for (uint gsIndex = 0; gsIndex < outputSemanticsCount; gsIndex++)
+ {
+ if (!TryReadUInt32(
+ ctx,
+ outputSemanticsAddress + (gsIndex * sizeof(uint)),
+ out var candidate))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if ((candidate & 0xFFu) == psSemantic)
+ {
+ gsWord = candidate;
+ break;
+ }
+ }
+ }
+
+ var value = (psWord & 0x0030_0000u) != 0
+ ? CreateInterpolantF16Value(psWord, gsWord)
+ : CreateInterpolantNonF16Value(psWord, gsWord.HasValue);
+ value = gsWord is { } matched
+ ? CreateInterpolantMappingValue(value, psWord, matched)
+ : CreateInterpolantDefaultParamValue(value, psWord);
+
+ if (!TryWriteInterpolantRegister(ctx, registersAddress, psIndex, value))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ if (!TryWriteIdentityInterpolantRegisters(ctx, registersAddress, inputSemanticsCount))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAgc(
+ $"agc.create_interpolant_mapping regs=0x{registersAddress:X16} " +
+ $"gs=0x{geometryShaderAddress:X16} ps=0x{pixelShaderAddress:X16} " +
+ $"inputs={inputSemanticsCount} outputs={outputSemanticsCount}");
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
#pragma warning restore SHEM004
+ private static uint ApplyInterpolantDefaultValue(uint value, uint psWord)
+ {
+ value &= ~0x0000_0300u;
+ value |= ((psWord >> 28) & 0x3u) << 8;
+ return value;
+ }
+
+ private static uint ApplyInterpolantDefaultValueHi(uint value, uint psWord)
+ {
+ value &= ~0x0060_0000u;
+ value |= ((psWord >> 30) & 0x3u) << 21;
+ return value;
+ }
+
+ private static uint CreateInterpolantMappingValue(uint value, uint psWord, uint gsWord)
+ {
+ var flatShade =
+ (psWord & 0x0040_0000u) != 0 || (psWord & 0x0100_0000u) != 0
+ ? 0x0000_0400u
+ : 0u;
+ value &= ~0x0000_001Fu;
+ value |= (gsWord >> 8) & 0x1Fu;
+ value &= ~0x0000_0400u;
+ value |= flatShade;
+ return ApplyInterpolantDefaultValue(value, psWord);
+ }
+
+ private static uint CreateInterpolantDefaultParamValue(uint value, uint psWord)
+ {
+ value &= ~0x0000_001Fu;
+ value &= ~0x0000_0400u;
+ return ApplyInterpolantDefaultValue(value, psWord);
+ }
+
+ private static uint CreateInterpolantF16Value(uint psWord, uint? gsWord)
+ {
+ var value = (psWord << 4) & 0x0300_0000u;
+ if (gsWord is null)
+ {
+ value |= 0x0018_0020u;
+ }
+ else
+ {
+ var commonWord = psWord & gsWord.Value;
+ value &= 0xFFF7_FFDFu;
+ value |= (commonWord >> 15) & 0x20u;
+ value ^= 0x0008_0020u;
+ value &= ~0x0010_0000u;
+ value |= (~commonWord >> 1) & 0x0010_0000u;
+ }
+
+ return ApplyInterpolantDefaultValueHi(value, psWord);
+ }
+
+ private static uint CreateInterpolantNonF16Value(uint psWord, bool hasGsSemantic)
+ {
+ uint value = 0;
+ if ((psWord & 0x0100_0000u) != 0 || !hasGsSemantic)
+ {
+ value |= 0x20u;
+ }
+
+ return value;
+ }
+
+ private static bool TryWriteInterpolantRegister(
+ CpuContext ctx,
+ ulong registersAddress,
+ uint index,
+ uint value)
+ {
+ var destination = registersAddress + (index * 8);
+ return TryWriteUInt32(ctx, destination, SpiPsInputCntl0 + index) &&
+ TryWriteUInt32(ctx, destination + sizeof(uint), value);
+ }
+
+ private static bool TryWriteIdentityInterpolantRegisters(
+ CpuContext ctx,
+ ulong registersAddress,
+ uint firstIndex)
+ {
+ for (uint i = firstIndex; i < 32u; i++)
+ {
+ if (!TryWriteInterpolantRegister(ctx, registersAddress, i, i))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static uint[] ReadPsInputCntlRegisters(IReadOnlyDictionary cxRegisters)
+ {
+ var cntl = new uint[32];
+ for (uint i = 0; i < 32u; i++)
+ {
+ // Unprogrammed slots default to identity (ATTR i → param i).
+ cntl[i] = cxRegisters.TryGetValue(SpiPsInputCntl0 + i, out var value)
+ ? value
+ : i;
+ }
+
+ return cntl;
+ }
+
+ private static ulong ComputePsInputCntlFingerprint(ReadOnlySpan cntl)
+ {
+ const ulong prime = 1099511628211UL;
+ var hash = 14695981039346656037UL;
+ foreach (var value in cntl)
+ {
+ hash = (hash ^ value) * prime;
+ }
+
+ return hash;
+ }
+
// NID captured from shipped titles; the friendly name collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation.
#pragma warning disable SHEM004
[SysAbiExport(
@@ -3210,6 +3594,16 @@ public static partial class AgcExports
continue;
}
+ var isAcquireMem = op == ItNop && register == RAcquireMem && length >= 8;
+ // Flush coalesced ACQUIRE_MEM only before packets that consume guest
+ // resources (draw/dispatch/dma/flip). Flushing before every register
+ // write produced a storm of tiny ordered actions during load.
+ if (!isAcquireMem &&
+ PacketRequiresPendingAcquireFlush(op, register, length))
+ {
+ FlushPendingAcquireInvalidation(ctx, state, tracePackets);
+ }
+
if (op == ItSetPredication)
{
ApplySubmittedPredication(ctx, state, currentAddress, length, tracePackets);
@@ -3217,6 +3611,26 @@ public static partial class AgcExports
continue;
}
+ if (op == ItRewind && length >= 2)
+ {
+ if (HandleSubmittedRewind(
+ ctx,
+ state,
+ commandAddress,
+ currentAddress,
+ offset,
+ length,
+ dwordCount,
+ tracePackets))
+ {
+ FlushPendingAcquireInvalidation(ctx, state, tracePackets);
+ return true; // suspended until RewindPatchSetRewindState
+ }
+
+ offset += length;
+ continue;
+ }
+
if (op == ItNop &&
register is RDrawReset or RAcbReset &&
length >= 2)
@@ -3229,7 +3643,7 @@ public static partial class AgcExports
$"packet=0x{currentAddress:X16}");
}
- if (op == ItNop && register == RAcquireMem && length >= 8)
+ if (isAcquireMem)
{
ApplySubmittedAcquireMem(
ctx,
@@ -3380,6 +3794,7 @@ public static partial class AgcExports
dwordCount, is64Bit: register == RWaitMem64, isStandard: false,
tracePackets))
{
+ FlushPendingAcquireInvalidation(ctx, state, tracePackets);
return true; // DCB suspended until the awaited label is written
}
}
@@ -3390,6 +3805,7 @@ public static partial class AgcExports
ctx, state, commandAddress, currentAddress, offset, length,
dwordCount, is64Bit: false, isStandard: true, tracePackets))
{
+ FlushPendingAcquireInvalidation(ctx, state, tracePackets);
return true; // DCB suspended until the awaited label is written
}
}
@@ -3524,7 +3940,8 @@ public static partial class AgcExports
vertexBuffers,
pendingComposite.RenderState,
pendingComposite.DepthTarget,
- pendingComposite.PixelShaderAddress);
+ pendingComposite.PixelShaderAddress,
+ pendingComposite.BaseVertex);
TraceAgcShader(
$"agc.deferred_composite ps=0x{pendingComposite.PixelShaderAddress:X16} " +
$"src=0x{pendingComposite.Textures.FirstOrDefault()?.Descriptor.Address ?? 0:X16} " +
@@ -3635,6 +4052,7 @@ public static partial class AgcExports
offset += length;
}
+ FlushPendingAcquireInvalidation(ctx, state, tracePackets);
return false;
}
@@ -3766,9 +4184,27 @@ public static partial class AgcExports
$"agc_dma_data dst=0x{destinationAddress:X16} bytes={byteCount}",
packetAddress,
destinationAddress,
- byteCount);
+ byteCount,
+ deferLabelCompletion: true);
}
+ private static bool PacketRequiresPendingAcquireFlush(
+ uint op,
+ uint register,
+ uint length) =>
+ op is ItDispatchDirect or ItDispatchIndirect ||
+ op is ItDrawIndirect or
+ ItDrawIndexIndirect or
+ ItDrawIndex2 or
+ ItDrawIndexAuto or
+ ItDrawIndexMultiAuto or
+ ItDrawIndexOffset2 ||
+ op == ItDmaData ||
+ (op == ItNop && register == RDmaData && length >= 7) ||
+ (op == ItNop && register == RFlip && length >= 6) ||
+ (op == ItNop && register == RDrawIndexAuto && length >= 2) ||
+ (op == ItNop && register == RWaitFlipDone && length >= 3);
+
private static void SubmitOrderedGpuSideEffect(
CpuContext ctx,
SubmittedGpuState gpuState,
@@ -3777,7 +4213,8 @@ public static partial class AgcExports
string debugName,
ulong packetAddress,
ulong producerAddress = 0,
- ulong producerLength = 0)
+ ulong producerLength = 0,
+ bool deferLabelCompletion = false)
{
var producer = RegisterLabelProducer(
ctx.Memory,
@@ -3800,11 +4237,24 @@ public static partial class AgcExports
void ApplyAndQueueCompletion()
{
action();
- // DMA side effects can enqueue a Vulkan image mirror while this
- // ordered action is executing. Completing the label here would
- // wake another queue before that mirror is visible. Queue a
- // second same-queue ordered action after all immediate follow-up
- // writes; it fences those writes before publishing the producer.
+ // No label producer → nothing to wake; skip the follow-up enqueue
+ // that was doubling OrderedGuestAction traffic during load.
+ if (producer is null)
+ {
+ CompleteAndWake();
+ return;
+ }
+
+ // Release/write-data paths cannot enqueue Vulkan image mirrors, so
+ // complete the producer in the same ordered action. DMA can enqueue
+ // a mirror while applying; defer completion until after those
+ // follow-ups so waiters see the mirrored image.
+ if (!deferLabelCompletion)
+ {
+ CompleteAndWake();
+ return;
+ }
+
if (GuestGpu.Current.SubmitOrderedGuestAction(
CompleteAndWake,
$"{debugName} completion") == 0)
@@ -3995,67 +4445,112 @@ public static partial class AgcExports
return;
}
- var queueName = state.QueueName;
- var submissionId = state.ActiveSubmissionId;
- var debugName =
- $"acquire_mem base=0x{acquire.BaseAddress:X16} size=0x{acquire.SizeBytes:X16} " +
- $"gcr=0x{acquire.GcrControl:X8}";
- void ApplyAcquire()
- {
- // ExecuteOrderedGuestAction first flushes and waits for this guest
- // queue, then writes back dirty guest buffers. At that exact PM4
- // point, refresh only tracked guest images covered by the acquire
- // range. Cached sampled textures use the same dirty tracker and
- // are evicted by the presenter without throwing away clean cache
- // entries (hardware invalidation does not imply changed bytes).
- if (acquire.InvalidatesGuestResources)
- {
- SyncCpuWrittenGuestImages(
- ctx,
- acquire.BaseAddress,
- acquire.CoversAllGuestMemory
- ? ulong.MaxValue
- : acquire.SizeBytes);
- }
+ // The bulk PM4 read is itself a parser-side cache. Do not retain it
+ // across a guest cache-invalidation point.
+ _dcbWindowBuffer = null;
+ _dcbWindowByteLength = 0;
+ if (!acquire.InvalidatesGuestResources)
+ {
if (tracePacket)
{
TraceAgc(
- $"agc.acquire_mem_applied queue={queueName} " +
- $"submission={submissionId} packet=0x{packetAddress:X16} " +
- $"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}");
+ $"agc.acquire_mem_skip_no_invalidate queue={state.QueueName} " +
+ $"submission={state.ActiveSubmissionId} packet=0x{packetAddress:X16} " +
+ $"gcr=0x{acquire.GcrControl:X8}");
}
+
+ return;
}
- var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
- ApplyAcquire,
- debugName);
- if (sequence == 0)
- {
- // Headless startup has no host GPU queue, but the guest-memory
- // cache model still needs the same invalidation semantics.
- ApplyAcquire();
- }
-
- // The bulk PM4 read is itself a parser-side cache. Do not retain it
- // across a guest cache-invalidation point; subsequent packets return
- // to live guest memory while the host barrier remains ordered in the
- // logical GPU queue. Submission stays asynchronous, matching hardware
- // and avoiding a CPU stall for every ACQUIRE_MEM packet.
- _dcbWindowBuffer = null;
- _dcbWindowByteLength = 0;
+ var size = acquire.CoversAllGuestMemory ? ulong.MaxValue : acquire.SizeBytes;
+ NotePendingAcquireInvalidation(state, acquire.BaseAddress, size);
if (tracePacket)
{
TraceAgc(
- $"agc.acquire_mem queue={queueName} " +
- $"submission={submissionId} packet=0x{packetAddress:X16} " +
+ $"agc.acquire_mem_coalesce queue={state.QueueName} " +
+ $"submission={state.ActiveSubmissionId} packet=0x{packetAddress:X16} " +
$"engine={acquire.Engine} cbdb=0x{acquire.CbDbControl:X8} " +
$"base=0x{acquire.BaseAddress:X16} size=0x{acquire.SizeBytes:X16} " +
$"scope={(acquire.CoversAllGuestMemory ? "all" : "range")} " +
$"poll={acquire.PollInterval} gcr=0x{acquire.GcrControl:X8} " +
- $"resource_inv={acquire.InvalidatesGuestResources} " +
- $"sequence={sequence} scheduled={(sequence != 0)}");
+ $"pending_base=0x{state.PendingAcquireBase:X16} " +
+ $"pending_size=0x{state.PendingAcquireSize:X16}");
+ }
+ }
+
+ private static void NotePendingAcquireInvalidation(
+ SubmittedDcbState state,
+ ulong baseAddress,
+ ulong sizeBytes)
+ {
+ if (!state.PendingAcquireInvalidation)
+ {
+ state.PendingAcquireInvalidation = true;
+ state.PendingAcquireBase = baseAddress;
+ state.PendingAcquireSize = sizeBytes;
+ return;
+ }
+
+ if (state.PendingAcquireSize == ulong.MaxValue || sizeBytes == ulong.MaxValue)
+ {
+ state.PendingAcquireBase = 0;
+ state.PendingAcquireSize = ulong.MaxValue;
+ return;
+ }
+
+ var existingEnd = state.PendingAcquireBase > ulong.MaxValue - state.PendingAcquireSize
+ ? ulong.MaxValue
+ : state.PendingAcquireBase + state.PendingAcquireSize;
+ var newEnd = baseAddress > ulong.MaxValue - sizeBytes
+ ? ulong.MaxValue
+ : baseAddress + sizeBytes;
+ var mergedBase = Math.Min(state.PendingAcquireBase, baseAddress);
+ var mergedEnd = Math.Max(existingEnd, newEnd);
+ state.PendingAcquireBase = mergedBase;
+ state.PendingAcquireSize = mergedEnd == ulong.MaxValue
+ ? ulong.MaxValue
+ : mergedEnd - mergedBase;
+ }
+
+ private static void FlushPendingAcquireInvalidation(
+ CpuContext ctx,
+ SubmittedDcbState state,
+ bool tracePacket)
+ {
+ if (!state.PendingAcquireInvalidation)
+ {
+ return;
+ }
+
+ var baseAddress = state.PendingAcquireBase;
+ var sizeBytes = state.PendingAcquireSize;
+ state.PendingAcquireInvalidation = false;
+ state.PendingAcquireBase = 0;
+ state.PendingAcquireSize = 0;
+
+ var queueName = state.QueueName;
+ var submissionId = state.ActiveSubmissionId;
+ var debugName =
+ $"acquire_mem_flush base=0x{baseAddress:X16} size=0x{sizeBytes:X16}";
+ void ApplyAcquire()
+ {
+ SyncCpuWrittenGuestImages(ctx, baseAddress, sizeBytes);
+ if (tracePacket)
+ {
+ TraceAgc(
+ $"agc.acquire_mem_applied queue={queueName} " +
+ $"submission={submissionId} " +
+ $"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics} " +
+ $"base=0x{baseAddress:X16} size=0x{sizeBytes:X16}");
+ }
+ }
+
+ var sequence = GuestGpu.Current.SubmitOrderedGuestAction(ApplyAcquire, debugName);
+ if (sequence == 0)
+ {
+ ApplyAcquire();
}
}
@@ -4266,49 +4761,22 @@ public static partial class AgcExports
/// CPU-authored surfaces once per flip. Surfaces only the GPU writes keep
/// all-zero guest memory and are skipped, preserving their GPU content.
///
- private static long _guestImageSyncTraceCount;
-
private static void SyncCpuWrittenGuestImages(
CpuContext ctx,
ulong scopeAddress = 0,
ulong scopeByteCount = ulong.MaxValue)
{
+ // Uploads used to copy full planes here and SubmitGuestImageWrite on the
+ // AGC producer thread, which hit the payload guest-work caps and
+ // soft-locked titles (GTA). The presenter's render drain owns the
+ // read/upload/re-arm; this call is only a scoped wake.
+ _ = ctx;
if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled || scopeByteCount == 0)
{
return;
}
- foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents())
- {
- if (scopeByteCount != ulong.MaxValue &&
- !RangesOverlap(address, byteCount, scopeAddress, scopeByteCount))
- {
- continue;
- }
-
- if (!SharpEmu.HLE.GuestImageWriteTracker.ConsumeDirty(address))
- {
- continue;
- }
-
- if (byteCount == 0 || byteCount > MaxPresentedTextureBytes)
- {
- continue;
- }
-
- var pixels = new byte[byteCount];
- if (ctx.Memory.TryRead(address, pixels))
- {
- GuestGpu.Current.SubmitGuestImageWrite(address, pixels);
- if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64)
- {
- Console.Error.WriteLine(
- $"[SYNC] cpu-write addr=0x{address:X} {width}x{height}");
- }
- }
-
- SharpEmu.HLE.GuestImageWriteTracker.Rearm(address);
- }
+ GuestGpu.Current.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
}
private static long _dmaMirrorTraceCount;
@@ -4419,7 +4887,8 @@ public static partial class AgcExports
$"dma_data dst=0x{destinationHigh:X8}{destinationLow:X8} bytes={byteCount}",
packetAddress,
writesGuestMemory ? destinationAddress : 0,
- writesGuestMemory ? byteCount : 0);
+ writesGuestMemory ? byteCount : 0,
+ deferLabelCompletion: true);
}
private static void ApplySubmittedStandardDmaDataSnapshot(
@@ -4919,6 +5388,80 @@ public static partial class AgcExports
return true;
}
+ private static bool HandleSubmittedRewind(
+ CpuContext ctx,
+ SubmittedDcbState state,
+ ulong commandAddress,
+ ulong packetAddress,
+ uint offset,
+ uint length,
+ uint dwordCount,
+ bool tracePacket)
+ {
+ var bodyAddress = packetAddress + sizeof(uint);
+ if (!TryReadUInt32(ctx, bodyAddress, out var body))
+ {
+ return false;
+ }
+
+ if ((body & RewindValidBit) != 0)
+ {
+ if (tracePacket)
+ {
+ TraceAgc(
+ $"agc.dcb.rewind_valid queue={state.QueueName} " +
+ $"packet=0x{packetAddress:X16} body=0x{body:X8}");
+ }
+
+ return false; // already valid — keep parsing
+ }
+
+ if (!_gpuWaitSuspendEnabled)
+ {
+ return false;
+ }
+
+ // Suspend until RewindPatchSetRewindState sets bit 31 on the body dword.
+ const uint compareEqual = 3;
+ var waiter = new GpuWaitRegistry.WaitingDcb
+ {
+ CommandBufferAddress = commandAddress,
+ ResumeAddress = packetAddress + ((ulong)length * sizeof(uint)),
+ TotalDwords = dwordCount,
+ ResumeOffset = offset + length,
+ ReferenceValue = RewindValidBit,
+ Mask = RewindValidBit,
+ CompareFunction = compareEqual,
+ ControlValue = 0,
+ Is64Bit = false,
+ IsStandard = true,
+ WaitAddress = bodyAddress,
+ Memory = ctx.Memory,
+ QueueName = state.QueueName,
+ SubmissionId = state.ActiveSubmissionId,
+ RegisteredTicks = System.Diagnostics.Stopwatch.GetTimestamp(),
+ State = state,
+ };
+
+ GpuWaitRegistry.Register(bodyAddress, waiter);
+ var gpuState = _submittedGpuStates.GetValue(
+ ctx.Memory,
+ static _ => new SubmittedGpuState());
+ EnsureGpuWaitMonitor(ctx, gpuState);
+ TraceAgcShader(
+ $"agc.rewind_suspend queue={state.QueueName} " +
+ $"submission={state.ActiveSubmissionId} " +
+ $"packet=0x{packetAddress:X16} body=0x{bodyAddress:X16}");
+ if (tracePacket)
+ {
+ TraceAgc(
+ $"agc.dcb.rewind_suspend queue={state.QueueName} " +
+ $"packet=0x{packetAddress:X16} body=0x{body:X8}");
+ }
+
+ return true;
+ }
+
private static bool HandleSubmittedWaitRegMem(
CpuContext ctx,
SubmittedDcbState state,
@@ -5102,6 +5645,9 @@ public static partial class AgcExports
$"[LOADER][TRACE] agc.wait_monitor_resumed count={resumed} " +
$"remaining={remaining}");
}
+
+ SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
+ ctx.Memory);
if (remaining == 0)
{
gpuState.WaitMonitorRunning = false;
@@ -5547,6 +6093,10 @@ public static partial class AgcExports
}
directDestination[startRegister + index] = value;
+ if (op == ItSetUconfigReg)
+ {
+ ApplyUcIndexTypeIfNeeded(state, startRegister + index, value);
+ }
}
return;
@@ -5581,6 +6131,25 @@ public static partial class AgcExports
// Dropping it leaves stale depth/render-control state active in
// later passes.
destination[registerOffset] = value;
+ if (register == RUcRegsIndirect)
+ {
+ ApplyUcIndexTypeIfNeeded(state, registerOffset, value);
+ }
+ }
+ }
+
+ ///
+ /// GraphicsDcbSetIndexSize writes VGT_INDEX_TYPE via SET_UCONFIG_REG.
+ /// Mirror that into .
+ ///
+ private static void ApplyUcIndexTypeIfNeeded(
+ SubmittedDcbState state,
+ uint registerOffset,
+ uint value)
+ {
+ if (registerOffset == VgtIndexType)
+ {
+ state.IndexSize = value & 0x3;
}
}
@@ -5665,6 +6234,25 @@ public static partial class AgcExports
}
state.TranslatedDraw = null;
state.GuestDrawKind = GuestDrawKind.None;
+
+ // CB modes EliminateFastClear / FmaskDecompress / DccDecompress run
+ // colour-buffer metadata ops. The bound shader is only a vehicle and
+ // must not be applied as a normal colour draw.
+ if (TryGetCbColorControlMode(state.CxRegisters, out var cbMode) &&
+ IsCbMetadataColorMode(cbMode))
+ {
+ if (_traceAgcShader || ShouldTraceHotPath(ref _cbMetadataSkipTraceCount))
+ {
+ TraceAgcShader(
+ $"agc.cb_metadata_skip seq={drawSequence} mode={cbMode} " +
+ $"es=0x{(hasExportShader ? exportShaderAddress : 0):X16} " +
+ $"ps=0x{(hasPixelShader ? pixelShaderAddress : 0):X16} " +
+ $"vertices={vertexCount}");
+ }
+
+ return;
+ }
+
foreach (var target in renderTargets)
{
state.KnownRenderTargets[target.Address] = target;
@@ -5758,7 +6346,8 @@ public static partial class AgcExports
depthOnlyDraw.IndexBuffer,
vertexBuffers,
renderState,
- depthOnlyDraw.PixelShaderAddress);
+ depthOnlyDraw.PixelShaderAddress,
+ depthOnlyDraw.BaseVertex);
if (_traceAgcShader)
{
@@ -5915,7 +6504,8 @@ public static partial class AgcExports
sharedVertexBuffers,
translatedDraw.RenderState,
translatedDraw.DepthTarget,
- translatedDraw.PixelShaderAddress);
+ translatedDraw.PixelShaderAddress,
+ translatedDraw.BaseVertex);
}
}
else
@@ -5957,7 +6547,8 @@ public static partial class AgcExports
translatedDraw.IndexBuffer,
vertexBuffers,
renderState,
- translatedDraw.PixelShaderAddress);
+ translatedDraw.PixelShaderAddress,
+ translatedDraw.BaseVertex);
}
else
{
@@ -6260,6 +6851,7 @@ public static partial class AgcExports
AttributeCount: 0,
vertexCount,
state.InstanceCount,
+ GetBaseVertex(state),
indexed ? CreateGuestIndexBuffer(ctx, state, vertexCount) : null,
textures,
exportEvaluation.GlobalMemoryBindings,
@@ -6450,6 +7042,48 @@ public static partial class AgcExports
TraceAstroTitlePixelGlobalProbe(pixelEvaluation);
}
+ // Patch BufferFormat from the attrib table onto the V# before host
+ // vertex input. IR discovery often keeps a stale float format from the
+ // unpatched sharp — that turns UI glyphs into gradient triangles.
+ // Match by stride+offset (not bare base address) so interleaved streams
+ // keep loading-video bindings intact.
+ if (exportEvaluation.VertexInputs is { Count: > 0 } discoveredInputs &&
+ AgcVertexMetadata.TryGetVertexTableRegisters(
+ ctx,
+ exportShaderAddress,
+ exportShaderHeader,
+ out var vertexTables))
+ {
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ exportEvaluation.ScalarRegisters,
+ vertexTables,
+ discoveredInputs);
+ if (!ReferenceEquals(merged, discoveredInputs))
+ {
+ TraceAgcShader(
+ $"agc.vertex_metadata_format es=0x{exportShaderAddress:X16} " +
+ $"count={merged.Count}");
+ exportEvaluation = exportEvaluation with { VertexInputs = merged };
+ }
+ }
+
+ state.UcRegisters.TryGetValue(VgtPrimitiveType, out var earlyPrimitiveType);
+ if (IsRectListPrimitive(earlyPrimitiveType) &&
+ (exportEvaluation.VertexInputs is null || exportEvaluation.VertexInputs.Count == 0) &&
+ !VertexProgramExportsParameters(exportState.Program) &&
+ GetInterpolatedAttributeCount(pixelState) != 0)
+ {
+ ReturnPooledEvaluationArrays(exportEvaluation);
+ ReturnPooledEvaluationArrays(pixelEvaluation);
+ error =
+ $"rect-list-no-param-exports ps_inputs={GetInterpolatedAttributeCount(pixelState)}";
+ TraceAgcShader(
+ $"agc.rect_list_skip es=0x{exportShaderAddress:X16} " +
+ $"ps=0x{pixelShaderAddress:X16} {error}");
+ return false;
+ }
+
// Every bound color target the shader exports to. Deferred renderers
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
// Fall back to slot 0 if we cannot match any export to a bound target.
@@ -6522,6 +7156,8 @@ public static partial class AgcExports
var pixelStateFingerprint = _bakeScalars
? ComputeShaderStateFingerprint(pixelEvaluation)
: ComputeShaderStructuralFingerprint(pixelEvaluation);
+ var psInputCntl = ReadPsInputCntlRegisters(state.CxRegisters);
+ var psInputCntlFingerprint = ComputePsInputCntlFingerprint(psInputCntl);
var shaderKey = (
exportShaderAddress,
exportStateFingerprint,
@@ -6532,6 +7168,7 @@ public static partial class AgcExports
attributeCount,
psInputEna,
psInputAddr,
+ psInputCntlFingerprint,
_storageBufferOffsetAlignment);
var guestGlobalBuffers =
@@ -6605,6 +7242,7 @@ public static partial class AgcExports
scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers,
pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr,
+ pixelInputCntl: psInputCntl,
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment) ||
!GuestGpu.Current.TryCompileVertexShader(
@@ -6735,6 +7373,7 @@ public static partial class AgcExports
GetInterpolatedAttributeCount(pixelState),
vertexCount,
state.InstanceCount,
+ GetBaseVertex(state),
indexed ? CreateGuestIndexBuffer(ctx, state, vertexCount) : null,
textures,
globalMemoryBindings,
@@ -7046,6 +7685,22 @@ public static partial class AgcExports
ColorFunc: 0,
};
+ private static AgcIndexHelpers.ProsperoIndexType GetProsperoIndexType(SubmittedDcbState state) =>
+ // IndexSize is latched from ItIndexType and from UC VGT_INDEX_TYPE
+ // writes. Do not fall back to a stale UC value when IndexSize is 0 —
+ // that mis-classified 16-bit draws as index8 and blanked meshes.
+ AgcIndexHelpers.Decode(state.IndexSize);
+
+ ///
+ /// ResolveVertexOffset for the common UC path: GE_INDX_OFFSET is the
+ /// DrawIndexed vertexOffset / DrawAuto firstVertex. Embedded-fetch SGPR
+ /// fallback is not required when the game latches this register (GTA UI).
+ ///
+ private static int GetBaseVertex(SubmittedDcbState state) =>
+ state.UcRegisters.TryGetValue(GeIndxOffset, out var indexOffset)
+ ? unchecked((int)indexOffset)
+ : 0;
+
private static GuestIndexBuffer? CreateGuestIndexBuffer(
CpuContext ctx,
SubmittedDcbState state,
@@ -7056,17 +7711,40 @@ public static partial class AgcExports
return null;
}
- var is32Bit = state.IndexSize != 0;
- var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort);
- var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
- var byteCount = checked((int)(indexCount * (uint)bytesPerIndex));
- var data = GuestDataPool.Shared.Rent(byteCount);
- var span = data.AsSpan(0, byteCount);
+ var indexType = GetProsperoIndexType(state);
+ var guestBytesPerIndex = AgcIndexHelpers.GetGuestStrideBytes(indexType);
+ var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)guestBytesPerIndex);
+ var guestByteCount = checked((int)(indexCount * (uint)guestBytesPerIndex));
var address = state.IndexBufferAddress + byteOffset;
+
+ // Host backends only bind u16/u32. Expand kIndex8 -> u16.
+ if (indexType == AgcIndexHelpers.ProsperoIndexType.Index8)
+ {
+ var guestData = GuestDataPool.Shared.Rent(guestByteCount);
+ var guestSpan = guestData.AsSpan(0, guestByteCount);
+ if (!ctx.Memory.TryRead(address, guestSpan) &&
+ !KernelMemoryCompatExports.TryReadTrackedLibcHeap(address, guestSpan))
+ {
+ GuestDataPool.Shared.Return(guestData);
+ return null;
+ }
+
+ var hostByteCount = checked((int)(indexCount * sizeof(ushort)));
+ var hostData = GuestDataPool.Shared.Rent(hostByteCount);
+ AgcIndexHelpers.ExpandIndex8ToU16(
+ guestSpan,
+ hostData.AsSpan(0, hostByteCount));
+ GuestDataPool.Shared.Return(guestData);
+ return new GuestIndexBuffer(hostData, hostByteCount, Is32Bit: false, Pooled: true);
+ }
+
+ var is32Bit = indexType == AgcIndexHelpers.ProsperoIndexType.Index32;
+ var data = GuestDataPool.Shared.Rent(guestByteCount);
+ var span = data.AsSpan(0, guestByteCount);
if (ctx.Memory.TryRead(address, span) ||
KernelMemoryCompatExports.TryReadTrackedLibcHeap(address, span))
{
- return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true);
+ return new GuestIndexBuffer(data, guestByteCount, is32Bit, Pooled: true);
}
GuestDataPool.Shared.Return(data);
@@ -7080,7 +7758,10 @@ public static partial class AgcExports
bool indexed,
out uint recordCount)
{
- recordCount = Math.Max(drawCount, Math.Max(state.InstanceCount, 1u));
+ var baseVertex = (uint)Math.Max(GetBaseVertex(state), 0);
+ recordCount = Math.Max(
+ baseVertex + drawCount,
+ Math.Max(state.InstanceCount, 1u));
if (!indexed)
{
return true;
@@ -7091,8 +7772,8 @@ public static partial class AgcExports
return false;
}
- var is32Bit = state.IndexSize != 0;
- var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort);
+ var indexType = GetProsperoIndexType(state);
+ var bytesPerIndex = AgcIndexHelpers.GetGuestStrideBytes(indexType);
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var address = state.IndexBufferAddress + byteOffset;
const int chunkBytes = 64 * 1024;
@@ -7117,12 +7798,22 @@ public static partial class AgcExports
for (var index = 0; index < chunkIndices; index++)
{
- var value = is32Bit
- ? BinaryPrimitives.ReadUInt32LittleEndian(
- span.Slice(index * sizeof(uint), sizeof(uint)))
- : BinaryPrimitives.ReadUInt16LittleEndian(
- span.Slice(index * sizeof(ushort), sizeof(ushort)));
- if (value == (is32Bit ? uint.MaxValue : ushort.MaxValue))
+ uint value = indexType switch
+ {
+ AgcIndexHelpers.ProsperoIndexType.Index32 =>
+ BinaryPrimitives.ReadUInt32LittleEndian(
+ span.Slice(index * sizeof(uint), sizeof(uint))),
+ AgcIndexHelpers.ProsperoIndexType.Index8 => span[index],
+ _ => BinaryPrimitives.ReadUInt16LittleEndian(
+ span.Slice(index * sizeof(ushort), sizeof(ushort))),
+ };
+ var restart = indexType switch
+ {
+ AgcIndexHelpers.ProsperoIndexType.Index32 => uint.MaxValue,
+ AgcIndexHelpers.ProsperoIndexType.Index8 => 0xFFu,
+ _ => ushort.MaxValue,
+ };
+ if (value == restart)
{
// Primitive-restart markers do not address vertex data.
continue;
@@ -7142,17 +7833,24 @@ public static partial class AgcExports
}
var indexedRecords = sawIndex && maxIndex != uint.MaxValue
- ? maxIndex + 1
- : 1u;
+ ? baseVertex + maxIndex + 1
+ : Math.Max(baseVertex + 1, 1u);
recordCount = Math.Max(indexedRecords, Math.Max(state.InstanceCount, 1u));
if (_traceVertexRanges &&
Interlocked.Increment(ref _tracedVertexRangeCount) <= 512)
{
+ var indexBits = indexType switch
+ {
+ AgcIndexHelpers.ProsperoIndexType.Index32 => 32,
+ AgcIndexHelpers.ProsperoIndexType.Index8 => 8,
+ _ => 16,
+ };
Console.Error.WriteLine(
$"[LOADER][TRACE] agc.vertex_range indexed=1 draw_count={drawCount} " +
- $"max_index={(sawIndex ? maxIndex : 0)} records={recordCount} " +
- $"instances={state.InstanceCount} index_size={(is32Bit ? 32 : 16)} " +
- $"index_addr=0x{state.IndexBufferAddress:X16} offset={state.DrawIndexOffset}");
+ $"max_index={(sawIndex ? maxIndex : 0)} base_vertex={baseVertex} " +
+ $"records={recordCount} instances={state.InstanceCount} " +
+ $"index_size={indexBits} index_addr=0x{state.IndexBufferAddress:X16} " +
+ $"offset={state.DrawIndexOffset}");
}
return true;
}
@@ -7162,6 +7860,20 @@ public static partial class AgcExports
? (packedMasks >> (int)(target * 4)) & 0xFu
: 0;
+ private static bool VertexProgramExportsParameters(Gen5ShaderProgram program)
+ {
+ foreach (var instruction in program.Instructions)
+ {
+ if (instruction.Control is Gen5ExportControl export &&
+ export.Target is >= 32 and < 64)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
{
var maxAttribute = -1;
@@ -7231,6 +7943,7 @@ public static partial class AgcExports
Mix(input.NumberFormat);
Mix(input.Stride);
Mix(input.OffsetBytes);
+ Mix(input.PerInstance ? 1u : 0u);
}
}
@@ -7276,6 +7989,35 @@ public static partial class AgcExports
return hash;
}
+ private enum CbColorMode : byte
+ {
+ Disable = 0,
+ Normal = 1,
+ EliminateFastClear = 2,
+ Resolve = 3,
+ FmaskDecompress = 5,
+ DccDecompress = 6,
+ }
+
+ private static bool TryGetCbColorControlMode(
+ IReadOnlyDictionary registers,
+ out uint mode)
+ {
+ mode = 0;
+ if (!registers.TryGetValue(CbColorControl, out var colorControl))
+ {
+ return false;
+ }
+
+ mode = (colorControl >> 4) & 0x7u;
+ return true;
+ }
+
+ private static bool IsCbMetadataColorMode(uint mode) =>
+ mode is (uint)CbColorMode.EliminateFastClear or
+ (uint)CbColorMode.FmaskDecompress or
+ (uint)CbColorMode.DccDecompress;
+
private static bool TryGetHardwareColorResolveTargets(
IReadOnlyDictionary registers,
out RenderTargetDescriptor source,
@@ -7283,8 +8025,8 @@ public static partial class AgcExports
{
source = default;
destination = default;
- if (!registers.TryGetValue(CbColorControl, out var colorControl) ||
- ((colorControl >> 4) & 0x7u) != 3u)
+ if (!TryGetCbColorControlMode(registers, out var mode) ||
+ mode != (uint)CbColorMode.Resolve)
{
return false;
}
@@ -7866,10 +8608,15 @@ public static partial class AgcExports
? $"{entry.Name}=0x{value:X8}"
: $"{entry.Name}=missing"));
var blend = draw.RenderState.Blend;
+ var rectExpanded = AgcPrimitiveHelpers.GetRectListDrawVertexCount(
+ draw.PrimitiveType,
+ draw.VertexCount,
+ indexed: draw.IndexBuffer is not null,
+ hasVertexBuffers: draw.VertexInputs.Count > 0);
TraceAgcShader(
$"agc.shader_draw es=0x{draw.ExportShaderAddress:X16} " +
$"ps=0x{draw.PixelShaderAddress:X16} spirv={draw.PixelShader.Payload.Length} " +
- $"primitive=0x{draw.PrimitiveType:X} " +
+ $"primitive=0x{draw.PrimitiveType:X} verts={draw.VertexCount}->{rectExpanded} " +
$"blend={(blend.Enable ? 1 : 0)}:{blend.ColorSrcFactor}/{blend.ColorDstFactor}/{blend.ColorFunc} " +
$"write_mask=0x{blend.WriteMask:X} scissor={scissor} viewport={viewport} " +
$"raster=[{raster}] " +
@@ -8210,7 +8957,8 @@ public static partial class AgcExports
binding.OffsetBytes,
binding.Data,
binding.DataLength,
- binding.DataPooled);
+ binding.DataPooled,
+ binding.PerInstance);
}
return buffers;
@@ -8561,6 +9309,9 @@ public static partial class AgcExports
// generation-stale when the guest CPU rewrites a CPU-backed image
// (video planes, streamed font atlases), which routes this draw back
// through the texel copy below so the refresh path re-uploads.
+ // With the write tracker off (Windows default), IsGuestImageUploadKnown
+ // uses a cheap guest-memory probe so static UI can still skip (Dead
+ // Cells menus) while changing CPU content (GTA Bink) forces a copy.
if (!isStorage &&
!wantsArrayUpload &&
descriptor.Address != 0 &&
@@ -8673,21 +9424,18 @@ public static partial class AgcExports
// When the presenter already holds this exact texture identity in
// its cache, the texel copy below would be discarded on arrival; for
// scenes that sample large textures every draw this copy dominated
- // CPU time. The dirty peek closes the race with eviction: a texture
- // the guest rewrote must ship fresh texels with this draw, because
- // the render thread evicts the stale cache entry before executing it
- // (skipping would leave the draw with no pixels and a fallback
- // texture for the frame — visible flicker on animated textures).
+ // CPU time (Dead Cells menus). The dirty peek closes the race with
+ // eviction when the write tracker is on. With the tracker off,
+ // PeekDirty is always false so a cached identity keeps skipping —
+ // correct for static UI atlases. CPU-updated guest Bink planes are
+ // handled by the upload-known gate above (forced copies when the
+ // tracker cannot invalidate), not by disabling this cache skip.
var sampler = ToGuestSampler(samplerDescriptor);
// Track the guest allocation before reading its texels so a CPU
// rewrite landing after the copy still bumps the write generation.
// The generation rides on the texture and is recorded by the
// presenter after upload, where the upload-known skip compares it
// against the tracker to force fresh texels for rewritten memory.
- SharpEmu.HLE.GuestImageWriteTracker.Track(
- descriptor.Address,
- physicalSourceByteCount,
- source: "agc.decoded-texture");
var hasWriteGeneration =
SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration(
descriptor.Address,
@@ -9154,36 +9902,51 @@ public static partial class AgcExports
TranslatedGuestDraw draw,
IReadOnlyList vertexBuffers)
{
- if (draw.PrimitiveType != 0x11 ||
- draw.IndexBuffer is not null ||
- vertexBuffers.Count == 0 ||
- _rectListTraceCount >= 8 ||
- Interlocked.Increment(ref _rectListTraceCount) > 8)
+ if (!AgcPrimitiveHelpers.IsRectListPrimitive(draw.PrimitiveType) ||
+ _rectListTraceCount >= 16 ||
+ Interlocked.Increment(ref _rectListTraceCount) > 16)
{
return;
}
- var buffer = vertexBuffers[0];
- var stride = Math.Max(buffer.Stride, 4u);
+ var expanded = AgcPrimitiveHelpers.GetRectListDrawVertexCount(
+ draw.PrimitiveType,
+ draw.VertexCount,
+ indexed: draw.IndexBuffer is not null,
+ hasVertexBuffers: vertexBuffers.Count > 0);
var text = new System.Text.StringBuilder();
- for (var vertex = 0; vertex < 3; vertex++)
- {
- var baseOffset = (int)(buffer.OffsetBytes + vertex * stride);
- if (baseOffset + 16 > buffer.Length)
- {
- break;
- }
+ text.Append(
+ $"agc.rectlist prim=0x{draw.PrimitiveType:X} verts={draw.VertexCount}->{expanded} " +
+ $"indexed={(draw.IndexBuffer is not null ? 1 : 0)} vb={vertexBuffers.Count}");
- var x = BitConverter.ToSingle(buffer.Data, baseOffset);
- var y = BitConverter.ToSingle(buffer.Data, baseOffset + 4);
- var z = BitConverter.ToSingle(buffer.Data, baseOffset + 8);
- var w = BitConverter.ToSingle(buffer.Data, baseOffset + 12);
- text.Append($" v{vertex}=({x:0.###},{y:0.###},{z:0.###},{w:0.###})");
+ if (vertexBuffers.Count > 0)
+ {
+ var buffer = vertexBuffers[0];
+ var stride = Math.Max(buffer.Stride, 4u);
+ text.Append(
+ $" stride={buffer.Stride} " +
+ $"fmt={buffer.DataFormat}/{buffer.NumberFormat}x{buffer.ComponentCount}");
+ for (var vertex = 0; vertex < 3; vertex++)
+ {
+ var baseOffset = (int)(buffer.OffsetBytes + vertex * stride);
+ if (baseOffset + 16 > buffer.Length)
+ {
+ break;
+ }
+
+ var x = BitConverter.ToSingle(buffer.Data, baseOffset);
+ var y = BitConverter.ToSingle(buffer.Data, baseOffset + 4);
+ var z = BitConverter.ToSingle(buffer.Data, baseOffset + 8);
+ var w = BitConverter.ToSingle(buffer.Data, baseOffset + 12);
+ text.Append($" v{vertex}=({x:0.###},{y:0.###},{z:0.###},{w:0.###})");
+ }
+ }
+ else
+ {
+ text.Append(" procedural=1");
}
- TraceAgcShader(
- $"agc.rectlist verts={draw.VertexCount} stride={buffer.Stride} " +
- $"fmt={buffer.DataFormat}/{buffer.NumberFormat}x{buffer.ComponentCount}{text}");
+ TraceAgcShader(text.ToString());
}
private static int _textureDumpCount;
@@ -10761,6 +11524,7 @@ public static partial class AgcExports
out var compileError,
pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr,
+ pixelInputCntl: ReadPsInputCntlRegisters(state.CxRegisters),
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment))
{
@@ -11245,44 +12009,213 @@ public static partial class AgcExports
return false;
}
- if (!TryReadUInt32(ctx, shRegistersAddress, out var loRegister) ||
- !TryReadUInt32(ctx, shRegistersAddress + 8, out var hiRegister))
- {
- return false;
- }
-
+ // Type bytes follow the Prospero half/fused enum used by fuse-shader
+ // (#326). Type 3 still patches VS PGM registers on this tree (pre-fuse
+ // CreateShader behavior); type 5 is the HS front half / hull path.
var expectedLo = shaderType switch
{
- 0 => ComputePgmLo,
- 1 => SpiShaderPgmLoPs,
- 2 or 6 => SpiShaderPgmLoEs,
- 4 => SpiShaderPgmLoGs,
- 7 => SpiShaderPgmLoLs,
+ ComputeShaderType => ComputePgmLo,
+ PsShaderType => SpiShaderPgmLoPs,
+ GsShaderType or GsBackShaderType => SpiShaderPgmLoEs,
+ HsShaderType => SpiShaderPgmLoVs,
+ GsFrontShaderType => SpiShaderPgmLoGs,
+ HsFrontShaderType => SpiShaderPgmLoHs,
+ HsBackShaderType => SpiShaderPgmLoLs,
_ => 0u,
};
var expectedHi = shaderType switch
{
- 0 => ComputePgmHi,
- 1 => SpiShaderPgmHiPs,
- 2 or 6 => SpiShaderPgmHiEs,
- 4 => SpiShaderPgmHiGs,
- 7 => SpiShaderPgmHiLs,
+ ComputeShaderType => ComputePgmHi,
+ PsShaderType => SpiShaderPgmHiPs,
+ GsShaderType or GsBackShaderType => SpiShaderPgmHiEs,
+ HsShaderType => SpiShaderPgmHiVs,
+ GsFrontShaderType => SpiShaderPgmHiGs,
+ HsFrontShaderType => SpiShaderPgmHiHs,
+ HsBackShaderType => SpiShaderPgmHiLs,
_ => 0u,
};
- if (expectedLo == 0 || loRegister != expectedLo || hiRegister != expectedHi)
+
+ // GTA V Enhanced hull shaders (type 5) put RSRC1/RSRC2 (0x10A/0x10B) at
+ // the front of the SH default table; PGM_LO/HI sit elsewhere (or are
+ // filled later via SetShRegisterDirect).
+ if (!TryFindShaderProgramRegisterPair(
+ ctx,
+ shRegistersAddress,
+ registerCount,
+ expectedLo,
+ expectedHi,
+ out var loEntryAddress,
+ out var hiEntryAddress,
+ out var foundLo,
+ out var foundHi))
{
- TraceCreateShader(0, headerAddress, codeAddress, $"unexpected-registers type={shaderType} lo=0x{loRegister:X8} hi=0x{hiRegister:X8}");
+ TryReadUInt32(ctx, shRegistersAddress, out var firstLo);
+ // GTA V Enhanced HS headers start at RSRC1/RSRC2 (0x10A/0x10B) and
+ // omit PGM_LO/HI from the default table. Still succeed: the code VA
+ // lives at ShaderCodeOffset and later binder paths republish it.
+ if (shaderType == HsFrontShaderType && firstLo is SpiShaderPgmRsrc1Hs or SpiShaderPgmLoHs)
+ {
+ TraceCreateShader(
+ 0,
+ headerAddress,
+ codeAddress,
+ $"skip-pgm-patch type={HsFrontShaderType} first_lo=0x{firstLo:X8}");
+ return true;
+ }
+
+ TraceCreateShader(
+ 0,
+ headerAddress,
+ codeAddress,
+ $"unexpected-registers type={shaderType} expected_lo=0x{expectedLo:X8} first_lo=0x{firstLo:X8}");
return false;
}
var loValue = (uint)((codeAddress >> 8) & 0xFFFF_FFFFUL);
var hiValue = (uint)((codeAddress >> 40) & 0xFFUL);
- return TryWriteUInt32(ctx, shRegistersAddress + sizeof(uint), loValue) &&
- TryWriteUInt32(ctx, shRegistersAddress + 8 + sizeof(uint), hiValue);
+ if (!TryWriteUInt32(ctx, loEntryAddress + sizeof(uint), loValue) ||
+ !TryWriteUInt32(ctx, hiEntryAddress + sizeof(uint), hiValue))
+ {
+ return false;
+ }
+
+ if (foundLo != expectedLo || foundHi != expectedHi)
+ {
+ TraceCreateShader(
+ 0,
+ headerAddress,
+ codeAddress,
+ $"patched-alt-registers type={shaderType} lo=0x{foundLo:X8} hi=0x{foundHi:X8}");
+ }
+
+ return true;
+ }
+
+ private static readonly (uint Lo, uint Hi)[] ShaderProgramRegisterPairs =
+ [
+ (ComputePgmLo, ComputePgmHi),
+ (SpiShaderPgmLoPs, SpiShaderPgmHiPs),
+ (SpiShaderPgmLoVs, SpiShaderPgmHiVs),
+ (SpiShaderPgmLoEs, SpiShaderPgmHiEs),
+ (SpiShaderPgmLoGs, SpiShaderPgmHiGs),
+ (SpiShaderPgmLoHs, SpiShaderPgmHiHs),
+ (SpiShaderPgmLoLs, SpiShaderPgmHiLs),
+ ];
+
+ private static bool TryFindShaderProgramRegisterPair(
+ CpuContext ctx,
+ ulong shRegistersAddress,
+ byte registerCount,
+ uint preferredLo,
+ uint preferredHi,
+ out ulong loEntryAddress,
+ out ulong hiEntryAddress,
+ out uint foundLo,
+ out uint foundHi)
+ {
+ loEntryAddress = 0;
+ hiEntryAddress = 0;
+ foundLo = 0;
+ foundHi = 0;
+
+ ulong preferredLoAddress = 0;
+ ulong preferredHiAddress = 0;
+ ulong fallbackLoAddress = 0;
+ ulong fallbackHiAddress = 0;
+ uint fallbackLo = 0;
+ uint fallbackHi = 0;
+
+ for (uint index = 0; index < registerCount; index++)
+ {
+ var entryAddress = shRegistersAddress + ((ulong)index * 8);
+ if (!TryReadUInt32(ctx, entryAddress, out var offset))
+ {
+ return false;
+ }
+
+ if (preferredLo != 0 && offset == preferredLo)
+ {
+ preferredLoAddress = entryAddress;
+ }
+ else if (preferredHi != 0 && offset == preferredHi)
+ {
+ preferredHiAddress = entryAddress;
+ }
+
+ if (fallbackLoAddress != 0)
+ {
+ continue;
+ }
+
+ foreach (var pair in ShaderProgramRegisterPairs)
+ {
+ if (offset != pair.Lo)
+ {
+ continue;
+ }
+
+ // Prefer a contiguous LO/HI pair when present.
+ if (index + 1 < registerCount &&
+ TryReadUInt32(ctx, entryAddress + 8, out var nextOffset) &&
+ nextOffset == pair.Hi)
+ {
+ fallbackLoAddress = entryAddress;
+ fallbackHiAddress = entryAddress + 8;
+ fallbackLo = pair.Lo;
+ fallbackHi = pair.Hi;
+ break;
+ }
+
+ for (uint hiIndex = 0; hiIndex < registerCount; hiIndex++)
+ {
+ if (hiIndex == index)
+ {
+ continue;
+ }
+
+ var hiAddress = shRegistersAddress + ((ulong)hiIndex * 8);
+ if (!TryReadUInt32(ctx, hiAddress, out var hiOffset) || hiOffset != pair.Hi)
+ {
+ continue;
+ }
+
+ fallbackLoAddress = entryAddress;
+ fallbackHiAddress = hiAddress;
+ fallbackLo = pair.Lo;
+ fallbackHi = pair.Hi;
+ break;
+ }
+
+ break;
+ }
+ }
+
+ if (preferredLoAddress != 0 && preferredHiAddress != 0)
+ {
+ loEntryAddress = preferredLoAddress;
+ hiEntryAddress = preferredHiAddress;
+ foundLo = preferredLo;
+ foundHi = preferredHi;
+ return true;
+ }
+
+ if (fallbackLoAddress != 0 && fallbackHiAddress != 0)
+ {
+ loEntryAddress = fallbackLoAddress;
+ hiEntryAddress = fallbackHiAddress;
+ foundLo = fallbackLo;
+ foundHi = fallbackHi;
+ return true;
+ }
+
+ return false;
}
private static bool IsEsGeometryShaderType(byte shaderType) =>
- shaderType is 2 or 6;
+ shaderType is GsShaderType or GsBackShaderType;
+
+ private static bool IsRectListPrimitive(uint primitiveType) =>
+ AgcPrimitiveHelpers.IsRectListPrimitive(primitiveType);
private static int SetIndirectPatchAddress(CpuContext ctx, string registerSpace)
{
@@ -11520,6 +12453,82 @@ public static partial class AgcExports
TryWriteUInt32(ctx, destinationAddress + sizeof(uint), value);
}
+ private static bool IsFusedShaderHalfPair(byte frontType, byte backType) =>
+ (frontType == GsFrontShaderType && backType == GsBackShaderType) ||
+ (frontType == HsFrontShaderType && backType == HsBackShaderType);
+
+ private static bool TryFindShaderRegister(
+ CpuContext ctx,
+ ulong registersAddress,
+ int registerCount,
+ uint registerOffset,
+ int occurrence,
+ out ulong entryAddress)
+ {
+ if (registersAddress != 0)
+ {
+ for (var index = 0; index < registerCount; index++)
+ {
+ var address = registersAddress + (ulong)index * 8;
+ if (!TryReadUInt32(ctx, address, out var current) || current != registerOffset)
+ {
+ continue;
+ }
+
+ if (occurrence == 0)
+ {
+ entryAddress = address;
+ return true;
+ }
+
+ occurrence--;
+ }
+ }
+
+ entryAddress = 0;
+ return false;
+ }
+
+ // A missing or unpaired lo/hi register is not an error: the retail library
+ // leaves absent registers untouched, unlike the create-time patch which
+ // requires them.
+ private static bool PatchFusedProgramAddress(
+ CpuContext ctx,
+ ulong registersAddress,
+ int registerCount,
+ uint loRegisterOffset,
+ ulong codeAddress)
+ {
+ if (!TryFindShaderRegister(ctx, registersAddress, registerCount, loRegisterOffset, 0, out var loEntry))
+ {
+ TraceAgc($"agc.fuse_shader_halves.pgm_absent lo=0x{loRegisterOffset:X} regs=0x{registersAddress:X16}");
+ return true;
+ }
+
+ var hiEntry = loEntry + 8;
+ if (hiEntry >= registersAddress + (ulong)registerCount * 8 ||
+ !TryReadUInt32(ctx, hiEntry, out var hiOffset) ||
+ hiOffset != loRegisterOffset + 1)
+ {
+ TraceAgc($"agc.fuse_shader_halves.pgm_unpaired lo=0x{loRegisterOffset:X} regs=0x{registersAddress:X16}");
+ return true;
+ }
+
+ if (!TryReadUInt32(ctx, hiEntry + sizeof(uint), out var hiValue))
+ {
+ return false;
+ }
+
+ return TryWriteUInt32(ctx, loEntry + sizeof(uint), (uint)(codeAddress >> 8)) &&
+ TryWriteUInt32(ctx, hiEntry + sizeof(uint), (hiValue & 0xFFFF_FF00u) | (uint)((codeAddress >> 40) & 0xFFUL));
+ }
+
+ private static bool TryWriteByte(CpuContext ctx, ulong address, byte value)
+ {
+ Span buffer = [value];
+ return ctx.Memory.TryWrite(address, buffer);
+ }
+
private static bool RelocatePointerField(CpuContext ctx, ulong fieldAddress)
{
if (!TryReadUInt64(ctx, fieldAddress, out var relativeAddress))
@@ -11766,6 +12775,28 @@ public static partial class AgcExports
}
}
+ private static bool TryReadUInt16(CpuContext ctx, ulong address, out ushort value)
+ {
+ if (_dcbWindowBuffer is { } window &&
+ address >= _dcbWindowStart &&
+ address - _dcbWindowStart + sizeof(ushort) <= (ulong)_dcbWindowByteLength)
+ {
+ value = BinaryPrimitives.ReadUInt16LittleEndian(
+ window.AsSpan((int)(address - _dcbWindowStart)));
+ return true;
+ }
+
+ Span buffer = stackalloc byte[sizeof(ushort)];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = BinaryPrimitives.ReadUInt16LittleEndian(buffer);
+ return true;
+ }
+
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
{
if (_dcbWindowBuffer is { } window &&
@@ -12137,9 +13168,7 @@ public static partial class AgcExports
$"[LOADER][TRACE] agc.create_shader dst=0x{destinationAddress:X16} header=0x{headerAddress:X16} code=0x{codeAddress:X16} {detail}");
}
- // Hardware REWIND is a fixed 2-dword header + valid-bit packet (same floor
- // as CbNopGetSize). No Rewind writer is implemented yet; size-only is enough
- // for callers that allocate the packet before filling it.
+ // Hardware REWIND is a fixed 2-dword header + body (valid bit 31).
[SysAbiExport(
Nid = "QIXCsbipds0",
ExportName = "sceAgcDcbRewindGetSize",
@@ -12151,6 +13180,86 @@ public static partial class AgcExports
return (int)ctx[CpuRegister.Rax];
}
+ // Writes IT_REWIND. When valid=0 the submit parser suspends until
+ // sceAgcRewindPatchSetRewindState sets bit 31 on the body dword.
+ [SysAbiExport(
+ Nid = "zfcxg-ewMK8",
+ ExportName = "sceAgcDcbRewind",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int DcbRewind(CpuContext ctx)
+ {
+ var dcb = ctx[CpuRegister.Rdi];
+ // rsi bit0 = valid; bit1 = offload_enable (PM4 body bits 31 / 24).
+ var flags = ctx[CpuRegister.Rsi];
+ var valid = (flags & 1UL) != 0;
+ var offloadEnable = (flags & 2UL) != 0;
+ if (dcb == 0)
+ {
+ return ReturnPointer(ctx, 0);
+ }
+
+ var body = (valid ? RewindValidBit : 0u) |
+ (offloadEnable ? RewindOffloadEnableBit : 0u);
+ if (!TryAllocateCommandDwords(ctx, dcb, 2, out var cmd) ||
+ !ctx.TryWriteUInt32(cmd, Pm4(2, ItRewind, RZero)) ||
+ !ctx.TryWriteUInt32(cmd + 4, body))
+ {
+ return ReturnPointer(ctx, 0);
+ }
+
+ TraceAgc($"agc.dcb_rewind buf=0x{dcb:X16} cmd=0x{cmd:X16} valid={valid} offload={offloadEnable}");
+ return ReturnPointer(ctx, cmd);
+ }
+
+ // Patches the REWIND body dword's valid bit and wakes any DCB suspended on it.
+ // rdi is the packet pointer returned by sceAgcDcbRewind (header address).
+ [SysAbiExport(
+ Nid = "ziVA3whp3p4",
+ ExportName = "sceAgcRewindPatchSetRewindState",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int RewindPatchSetRewindState(CpuContext ctx)
+ {
+ var packetAddress = ctx[CpuRegister.Rdi];
+ var valid = (ctx[CpuRegister.Rsi] & 1UL) != 0;
+ if (packetAddress == 0 ||
+ (long)packetAddress < 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ var bodyAddress = packetAddress;
+ if (TryReadUInt32(ctx, packetAddress, out var header) &&
+ ((header >> 8) & 0xFFu) == ItRewind)
+ {
+ bodyAddress = packetAddress + sizeof(uint);
+ }
+
+ if (!TryReadUInt32(ctx, bodyAddress, out var body) ||
+ !TryWriteUInt32(
+ ctx,
+ bodyAddress,
+ valid ? body | RewindValidBit : body & ~RewindValidBit))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (!TryReadUInt32(ctx, bodyAddress, out var patched))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (valid)
+ {
+ GpuWaitRegistry.RecordProduced(ctx.Memory, bodyAddress, patched);
+ }
+
+ TraceAgc($"agc.rewind_patch addr=0x{bodyAddress:X16} valid={valid} body=0x{patched:X8}");
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
// Matches the 4-dword INDIRECT_BUFFER packet DcbJump writes below.
// Returning NOT_FOUND here left callers with a null packet pointer and an
// immediate write AV on RenderThread.
@@ -12192,6 +13301,82 @@ public static partial class AgcExports
return ReturnPointer(ctx, cmd);
}
+ [SysAbiExport(
+ Nid = "b-oySn+G2tE",
+ ExportName = "sceAgcAcbJumpGetSize",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int AcbJumpGetSize(CpuContext ctx)
+ {
+ ctx[CpuRegister.Rax] = 4u * sizeof(uint);
+ return (int)ctx[CpuRegister.Rax];
+ }
+
+ [SysAbiExport(
+ Nid = "e1DFTg+Sd8U",
+ ExportName = "sceAgcAcbJump",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int AcbJump(CpuContext ctx) => DcbJump(ctx);
+
+ // Sony SetCf* range writer — SET_CONTEXT_REG packet (same shape as SH range).
+ [SysAbiExport(
+ Nid = "BVFg3CWU6Eo",
+ ExportName = "sceAgcDcbSetCfRegisterRangeDirect",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int DcbSetCfRegisterRangeDirect(CpuContext ctx) =>
+ DcbSetRegisterRangeDirect(ctx, ItSetContextReg, "cf");
+
+ // Logged unresolved as LHFXRrlTPD8 during North Yankton load.
+ [SysAbiExport(
+ Nid = "LHFXRrlTPD8",
+ ExportName = "sceAgcDcbSetCxRegisterDirect",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int DcbSetCxRegisterDirect(CpuContext ctx) =>
+ DcbSetRegisterDirect(ctx, ItSetContextReg, "cx");
+
+ private static int DcbSetRegisterRangeDirect(CpuContext ctx, uint op, string registerSpace)
+ {
+ var commandBufferAddress = ctx[CpuRegister.Rdi];
+ var offset = (uint)ctx[CpuRegister.Rsi];
+ var valuesAddress = ctx[CpuRegister.Rdx];
+ var valueCount = (uint)ctx[CpuRegister.Rcx];
+ if (commandBufferAddress == 0 || valueCount == 0 || valueCount > 0x3FFE)
+ {
+ return ReturnPointer(ctx, 0);
+ }
+
+ var packetDwords = valueCount + 2;
+ if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
+ !TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, op, 0)) ||
+ !TryWriteUInt32(ctx, commandAddress + 4, offset & 0xFFFFu))
+ {
+ return ReturnPointer(ctx, 0);
+ }
+
+ for (uint i = 0; i < valueCount; i++)
+ {
+ var value = 0u;
+ if (valuesAddress != 0 &&
+ !TryReadUInt32(ctx, valuesAddress + (i * sizeof(uint)), out value))
+ {
+ return ReturnPointer(ctx, 0);
+ }
+
+ if (!TryWriteUInt32(ctx, commandAddress + 8 + (i * sizeof(uint)), value))
+ {
+ return ReturnPointer(ctx, 0);
+ }
+ }
+
+ TraceAgc(
+ $"agc.dcb_set_{registerSpace}_range buf=0x{commandBufferAddress:X16} " +
+ $"cmd=0x{commandAddress:X16} offset=0x{offset:X4} count={valueCount}");
+ return ReturnPointer(ctx, commandAddress);
+ }
+
[SysAbiExport(
Nid = "bbFueFP+J4k",
ExportName = "sceAgcDcbSetPredication",
diff --git a/src/SharpEmu.Libs/Agc/AgcIndexHelpers.cs b/src/SharpEmu.Libs/Agc/AgcIndexHelpers.cs
new file mode 100644
index 00000000..10557b78
--- /dev/null
+++ b/src/SharpEmu.Libs/Agc/AgcIndexHelpers.cs
@@ -0,0 +1,49 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+
+namespace SharpEmu.Libs.Agc;
+
+/// Prospero/AGC IndexType helpers (gpu_defs / renderDraw index8 expand).
+internal static class AgcIndexHelpers
+{
+ internal enum ProsperoIndexType : uint
+ {
+ Index16 = 0,
+ Index32 = 1,
+ Index8 = 2,
+ }
+
+ internal static ProsperoIndexType Decode(uint raw) =>
+ (raw & 0x3u) switch
+ {
+ 1 => ProsperoIndexType.Index32,
+ 2 => ProsperoIndexType.Index8,
+ _ => ProsperoIndexType.Index16,
+ };
+
+ internal static int GetGuestStrideBytes(ProsperoIndexType indexType) =>
+ indexType switch
+ {
+ ProsperoIndexType.Index32 => sizeof(uint),
+ ProsperoIndexType.Index8 => sizeof(byte),
+ _ => sizeof(ushort),
+ };
+
+ /// Expand guest u8 indices to host u16 (Vulkan/Metal bindable).
+ internal static void ExpandIndex8ToU16(ReadOnlySpan source, Span destination)
+ {
+ if (destination.Length < source.Length * sizeof(ushort))
+ {
+ throw new ArgumentException("destination too small for u8->u16 expansion.");
+ }
+
+ for (var index = 0; index < source.Length; index++)
+ {
+ BinaryPrimitives.WriteUInt16LittleEndian(
+ destination.Slice(index * sizeof(ushort), sizeof(ushort)),
+ source[index]);
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/Agc/AgcPrimitiveHelpers.cs b/src/SharpEmu.Libs/Agc/AgcPrimitiveHelpers.cs
new file mode 100644
index 00000000..702df5f5
--- /dev/null
+++ b/src/SharpEmu.Libs/Agc/AgcPrimitiveHelpers.cs
@@ -0,0 +1,107 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.Libs.Agc;
+
+///
+/// Shared Prospero/AGC primitive-type helpers (renderDraw / gpu_defs).
+///
+internal static class AgcPrimitiveHelpers
+{
+ internal const uint PrimitiveRectList = 7;
+ internal const uint PrimitiveRectListLegacy = 0x11;
+
+ internal enum GsOutputPrimitiveType : uint
+ {
+ Points = 0,
+ Lines = 1,
+ Triangles = 2,
+ Rectangle2D = 3,
+ RectList = 4,
+ }
+
+ internal static bool IsRectListPrimitive(uint primitiveType) =>
+ primitiveType is PrimitiveRectList or PrimitiveRectListLegacy;
+
+ ///
+ /// Maps draw prim type to VGT_GS_OUT_PRIM_TYPE when NGG is not enabled
+ /// on the GS (GraphicsPrimitiveTypeToGsOut).
+ ///
+ internal static uint PrimitiveTypeToGsOut(uint primitiveType) =>
+ primitiveType switch
+ {
+ 1 => (uint)GsOutputPrimitiveType.Points, // PointList
+ 2 or 3 or 10 or 11 or 18 => (uint)GsOutputPrimitiveType.Lines,
+ PrimitiveRectList => (uint)GsOutputPrimitiveType.Rectangle2D,
+ PrimitiveRectListLegacy => (uint)GsOutputPrimitiveType.RectList,
+ _ => (uint)GsOutputPrimitiveType.Triangles,
+ };
+
+ ///
+ /// Rect-list auto-draw topology selection.
+ /// NGG single-rect UI quads (DualSense prompts) submit count 1/3/4 and
+ /// must become a 4-vert triangle strip — even when the VS has embedded
+ /// vertex-buffer fetches (those still show up as host VBs). Indexed and
+ /// larger auto counts stay triangle list so the loading video is safe.
+ ///
+ internal static bool ShouldDrawRectListAsTriangleStrip(
+ uint primitiveType,
+ bool indexed,
+ uint vertexCount,
+ bool hasVertexBuffers = false)
+ {
+ _ = hasVertexBuffers;
+ if (indexed || !IsRectListPrimitive(primitiveType))
+ {
+ return false;
+ }
+
+ if (primitiveType == PrimitiveRectListLegacy)
+ {
+ return true;
+ }
+
+ // NGG kRectList: strip for auto + ngg_rectlist_draw.
+ // Restrict to the single-rect counts GTA UI actually submits.
+ return vertexCount is 1 or 3 or 4;
+ }
+
+ ///
+ /// Host vertex count for auto rect-list draws that expand to a strip.
+ /// NGG single-rect: always 4. Legacy 0x11: 3 -> 4.
+ ///
+ internal static uint GetRectListDrawVertexCount(
+ uint primitiveType,
+ uint vertexCount,
+ bool indexed,
+ bool hasVertexBuffers = false)
+ {
+ if (!ShouldDrawRectListAsTriangleStrip(
+ primitiveType,
+ indexed,
+ vertexCount,
+ hasVertexBuffers))
+ {
+ return vertexCount;
+ }
+
+ if (primitiveType == PrimitiveRectList)
+ {
+ return 4;
+ }
+
+ if (primitiveType == PrimitiveRectListLegacy && vertexCount == 3)
+ {
+ return 4;
+ }
+
+ return vertexCount;
+ }
+
+ ///
+ /// Legacy helper — prefer
+ /// .
+ ///
+ internal static bool IsRectListTriangleStrip(uint primitiveType) =>
+ IsRectListPrimitive(primitiveType);
+}
diff --git a/src/SharpEmu.Libs/Agc/AgcVertexMetadata.cs b/src/SharpEmu.Libs/Agc/AgcVertexMetadata.cs
new file mode 100644
index 00000000..8537ef27
--- /dev/null
+++ b/src/SharpEmu.Libs/Agc/AgcVertexMetadata.cs
@@ -0,0 +1,788 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.ShaderCompiler;
+
+namespace SharpEmu.Libs.Agc;
+
+///
+/// AGC embedded vertex metadata. Locates
+/// PtrVertexBufferTable / PtrVertexAttribDescTable and builds authoritative
+/// attribute layouts that draw translation merges onto IR-discovered fetches.
+///
+internal static class AgcVertexMetadata
+{
+ private const ushort IllegalDirectOffset = 0xFFFF;
+ private const ulong ShaderUserDataOffset = 0x08;
+ private const ulong ShaderInputSemanticsOffset = 0x30;
+ private const ulong ShaderNumInputSemanticsOffset = 0x50;
+
+ internal enum AgcDirectResourceType : uint
+ {
+ PtrVertexBufferTable = 8,
+ PtrVertexAttribDescTable = 10,
+ Last = PtrVertexAttribDescTable,
+ }
+
+ internal readonly record struct VertexTableRegisters(
+ int VertexBufferReg,
+ int VertexAttribReg,
+ uint InputSemanticsCount,
+ ulong InputSemanticsAddress);
+
+ ///
+ /// One AGC attrib-table resource.
+ /// Representation: is the V# base; attribute byte
+ /// offset is applied as (Vulkan bind offset),
+ /// not folded into the base — avoids double-counting when the IR prolog
+ /// already bumped the sharp address.
+ ///
+ internal readonly record struct MetadataVertexResource(
+ uint Location,
+ uint Semantic,
+ uint HardwareMapping,
+ uint SizeInElements,
+ ulong SharpBase,
+ uint Stride,
+ uint OffsetBytes,
+ uint DataFormat,
+ uint NumberFormat,
+ uint ComponentCount,
+ bool PerInstance);
+
+ ///
+ /// Reads AGC user-data direct-resource offsets for the ES header mapped to
+ /// . Returns false when the header is
+ /// unknown or the tables are absent (attribute-less clears).
+ ///
+ internal static bool TryGetVertexTableRegisters(
+ CpuContext ctx,
+ ulong shaderCodeAddress,
+ ulong shaderHeaderAddress,
+ out VertexTableRegisters registers)
+ {
+ registers = new VertexTableRegisters(-1, -1, 0, 0);
+ if (shaderHeaderAddress == 0 ||
+ !TryReadUInt64(ctx, shaderHeaderAddress + ShaderUserDataOffset, out var userDataAddress) ||
+ userDataAddress == 0)
+ {
+ return false;
+ }
+
+ // ShaderUserData layout:
+ // 0x00: uint16_t* direct_resource_offset
+ // 0x08: sharp_resource_offset[4]
+ // 0x28: eud_size_dw, srt_size_dw
+ // 0x2C: direct_resource_count
+ if (!TryReadUInt64(ctx, userDataAddress, out var directResourceOffset) ||
+ !TryReadUInt16(ctx, userDataAddress + 0x2C, out var directResourceCount))
+ {
+ return false;
+ }
+
+ var maxTypes = (uint)AgcDirectResourceType.Last + 1u;
+ if (directResourceCount > maxTypes || directResourceOffset == 0)
+ {
+ return false;
+ }
+
+ var vertexBufferReg = -1;
+ var vertexAttribReg = -1;
+ for (uint type = 0; type < directResourceCount; type++)
+ {
+ if (!TryReadUInt16(
+ ctx,
+ directResourceOffset + (type * sizeof(ushort)),
+ out var reg) ||
+ reg == IllegalDirectOffset)
+ {
+ continue;
+ }
+
+ switch ((AgcDirectResourceType)type)
+ {
+ case AgcDirectResourceType.PtrVertexBufferTable:
+ vertexBufferReg = reg;
+ break;
+ case AgcDirectResourceType.PtrVertexAttribDescTable:
+ vertexAttribReg = reg;
+ break;
+ }
+ }
+
+ if (vertexBufferReg < 0 || vertexAttribReg < 0)
+ {
+ return false;
+ }
+
+ if (!TryReadUInt64(
+ ctx,
+ shaderHeaderAddress + ShaderInputSemanticsOffset,
+ out var inputSemanticsAddress) ||
+ !TryReadUInt32(
+ ctx,
+ shaderHeaderAddress + ShaderNumInputSemanticsOffset,
+ out var inputSemanticsCount) ||
+ inputSemanticsCount == 0 ||
+ inputSemanticsAddress == 0)
+ {
+ return false;
+ }
+
+ registers = new VertexTableRegisters(
+ vertexBufferReg,
+ vertexAttribReg,
+ inputSemanticsCount,
+ inputSemanticsAddress);
+ return true;
+ }
+
+ ///
+ /// Builds attrib resources from AGC input_semantics + tables.
+ /// ShaderSemantic packing:
+ /// bits [7:0] semantic → attrib table index
+ /// bits [15:8] hardware_mapping → VGPR destination
+ /// bits [19:16] size_in_elements
+ ///
+ internal static bool TryBuildVertexResourcesFromMetadata(
+ CpuContext ctx,
+ IReadOnlyList scalarRegisters,
+ VertexTableRegisters tables,
+ out IReadOnlyList resources)
+ {
+ resources = Array.Empty();
+ if (tables.VertexAttribReg < 0 ||
+ tables.VertexBufferReg < 0 ||
+ tables.VertexAttribReg + 1 >= scalarRegisters.Count ||
+ tables.VertexBufferReg + 1 >= scalarRegisters.Count ||
+ tables.InputSemanticsCount == 0)
+ {
+ return false;
+ }
+
+ var attribTable =
+ ((ulong)scalarRegisters[tables.VertexAttribReg + 1] << 32) |
+ scalarRegisters[tables.VertexAttribReg];
+ var bufferTable =
+ ((ulong)scalarRegisters[tables.VertexBufferReg + 1] << 32) |
+ scalarRegisters[tables.VertexBufferReg];
+ if (attribTable == 0 || bufferTable == 0)
+ {
+ return false;
+ }
+
+ var built = new List((int)tables.InputSemanticsCount);
+ for (uint i = 0; i < tables.InputSemanticsCount; i++)
+ {
+ if (!TryReadUInt32(
+ ctx,
+ tables.InputSemanticsAddress + (i * sizeof(uint)),
+ out var semanticWord))
+ {
+ return false;
+ }
+
+ // Attrib index is semantic bits [7:0], not hardware_mapping.
+ var semantic = semanticWord & 0xFFu;
+ var hardwareMapping = (semanticWord >> 8) & 0xFFu;
+ var sizeInElements = (semanticWord >> 16) & 0xFu;
+ if (!TryReadUInt32(ctx, attribTable + (semantic * sizeof(uint)), out var attribWord))
+ {
+ return false;
+ }
+
+ // Attrib dword: buffer index [4:0], format [13:5], offset [25:14], fetch [26].
+ var bufferIndex = attribWord & 0x1Fu;
+ var format = (attribWord >> 5) & 0x1FFu;
+ var offset = (attribWord >> 14) & 0xFFFu;
+ var fetchIndex = (attribWord >> 26) & 0x1u;
+ var sharpAddress = bufferTable + (bufferIndex * 16u);
+ if (!TryReadUInt32(ctx, sharpAddress, out var sharp0) ||
+ !TryReadUInt32(ctx, sharpAddress + 4, out var sharp1))
+ {
+ return false;
+ }
+
+ var sharpBase = sharp0 | ((ulong)(sharp1 & 0xFFFFu) << 32);
+ var stride = (sharp1 >> 16) & 0x3FFFu;
+ if (sharpBase == 0 || stride == 0)
+ {
+ continue;
+ }
+
+ var fallbackComponents = sizeInElements != 0 ? sizeInElements : 4u;
+ var (dataFormat, numberFormat, components) =
+ MapAttribFormat(format, fallbackComponents);
+ built.Add(new MetadataVertexResource(
+ Location: i,
+ Semantic: semantic,
+ HardwareMapping: hardwareMapping,
+ SizeInElements: sizeInElements,
+ SharpBase: sharpBase,
+ Stride: stride,
+ OffsetBytes: offset,
+ DataFormat: dataFormat,
+ NumberFormat: numberFormat,
+ ComponentCount: components,
+ PerInstance: fetchIndex != 0));
+ }
+
+ if (built.Count == 0)
+ {
+ return false;
+ }
+
+ resources = built;
+ return true;
+ }
+
+ ///
+ /// Patch IR-discovered fetches from the attrib table onto the V# format/offset.
+ /// Prefer 1:1 Location pairing when counts match on one interleaved stream
+ /// (GTA UI glyphs). Otherwise match by stride + byte offset. Never rebases
+ /// BaseAddress/Data/Location/Pc/PerInstance.
+ ///
+ internal static IReadOnlyList MergeVertexInputsFromMetadata(
+ CpuContext ctx,
+ IReadOnlyList scalarRegisters,
+ VertexTableRegisters tables,
+ IReadOnlyList discovered)
+ {
+ if (discovered.Count == 0 ||
+ !TryBuildVertexResourcesFromMetadata(
+ ctx,
+ scalarRegisters,
+ tables,
+ out var resources))
+ {
+ return discovered;
+ }
+
+ if (TryMergeByLocationPairing(discovered, resources, out var paired))
+ {
+ return paired;
+ }
+
+ var merged = new List(discovered.Count);
+ var usedResources = new bool[resources.Count];
+ var changed = false;
+ foreach (var input in discovered)
+ {
+ if (!TryMatchMetadataResource(input, resources, usedResources, out var resource, out var fillOffset))
+ {
+ merged.Add(input);
+ continue;
+ }
+
+ var refined = ApplyMetadataFormat(input, resource, fillOffset);
+ changed |= refined != input;
+ merged.Add(refined);
+ }
+
+ return changed ? merged : discovered;
+ }
+
+ ///
+ /// When discovery and metadata describe the same interleaved stream with
+ /// equal attribute counts, pair by sorted Location (semantic order).
+ /// Keeps each binding's Pc/Location for SPIR-V; overlays format + offset.
+ ///
+ private static bool TryMergeByLocationPairing(
+ IReadOnlyList discovered,
+ IReadOnlyList resources,
+ out IReadOnlyList merged)
+ {
+ merged = discovered;
+ if (discovered.Count != resources.Count || discovered.Count == 0)
+ {
+ return false;
+ }
+
+ var orderedInputs = discovered.OrderBy(static input => input.Location).ToArray();
+ var orderedResources = resources.OrderBy(static resource => resource.Location).ToArray();
+ var streamBase = orderedResources[0].SharpBase;
+ var streamStride = orderedResources[0].Stride;
+ for (var index = 0; index < orderedResources.Length; index++)
+ {
+ var resource = orderedResources[index];
+ var input = orderedInputs[index];
+ if (resource.SharpBase != streamBase ||
+ resource.Stride != streamStride ||
+ (input.Stride != 0 && input.Stride != streamStride) ||
+ !IsSameVertexStream(input, resource))
+ {
+ return false;
+ }
+ }
+
+ var byPc = new Dictionary(discovered.Count);
+ var changed = false;
+ for (var index = 0; index < orderedInputs.Length; index++)
+ {
+ var input = orderedInputs[index];
+ var resource = orderedResources[index];
+ var fillOffset = input.BaseAddress == resource.SharpBase ||
+ IsAddressInsideCapturedSpan(input, resource.SharpBase);
+ var refined = ApplyMetadataFormat(input, resource, fillOffset);
+ changed |= refined != input;
+ byPc[input.Pc] = refined;
+ }
+
+ if (!changed)
+ {
+ return false;
+ }
+
+ var result = new Gen5VertexInputBinding[discovered.Count];
+ for (var index = 0; index < discovered.Count; index++)
+ {
+ result[index] = byPc[discovered[index].Pc];
+ }
+
+ merged = result;
+ return true;
+ }
+
+ private static Gen5VertexInputBinding ApplyMetadataFormat(
+ Gen5VertexInputBinding input,
+ MetadataVertexResource resource,
+ bool fillOffsetBytes)
+ {
+ var components = input.ComponentCount != 0 &&
+ input.ComponentCount < resource.ComponentCount
+ ? input.ComponentCount
+ : resource.ComponentCount;
+
+ return input with
+ {
+ DataFormat = resource.DataFormat,
+ NumberFormat = resource.NumberFormat,
+ ComponentCount = components,
+ OffsetBytes = fillOffsetBytes ? resource.OffsetBytes : input.OffsetBytes,
+ };
+ }
+
+ ///
+ /// Legacy entry point — forwards to .
+ ///
+ internal static IReadOnlyList RefineVertexInputs(
+ CpuContext ctx,
+ IReadOnlyList scalarRegisters,
+ VertexTableRegisters tables,
+ IReadOnlyList discovered) =>
+ MergeVertexInputsFromMetadata(ctx, scalarRegisters, tables, discovered);
+
+ ///
+ /// Collects SBufferLoad / SLoad PCs that read the AGC attrib or buffer
+ /// tables (embedded-fetch prolog). Those loads are executed on the
+ /// CPU during scalar evaluation; once vertex inputs are bound they must
+ /// not run again as live SSBOs on the GPU.
+ ///
+ internal static HashSet CollectFetchPrologPcs(
+ Gen5ShaderProgram program,
+ VertexTableRegisters tables)
+ {
+ var pcs = new HashSet();
+ if (tables.VertexAttribReg < 0 || tables.VertexBufferReg < 0)
+ {
+ return pcs;
+ }
+
+ var tableRegs = new HashSet
+ {
+ (uint)tables.VertexAttribReg,
+ (uint)tables.VertexAttribReg + 1u,
+ (uint)tables.VertexBufferReg,
+ (uint)tables.VertexBufferReg + 1u,
+ };
+
+ foreach (var instruction in program.Instructions)
+ {
+ var isScalarLoad =
+ instruction.Opcode.StartsWith("SBufferLoad", StringComparison.Ordinal) ||
+ instruction.Opcode.StartsWith("SLoad", StringComparison.Ordinal);
+ if (!isScalarLoad)
+ {
+ continue;
+ }
+
+ // SMEM loads encode the scalar base pointer in Sources[0].
+ if (instruction.Sources.Count > 0 &&
+ instruction.Sources[0] is
+ {
+ Kind: Gen5OperandKind.ScalarRegister,
+ Value: var scalarBase,
+ } &&
+ tableRegs.Contains(scalarBase))
+ {
+ pcs.Add(instruction.Pc);
+ continue;
+ }
+
+ if (instruction.Control is Gen5BufferMemoryControl buffer &&
+ tableRegs.Contains(buffer.ScalarResource))
+ {
+ pcs.Add(instruction.Pc);
+ }
+ }
+
+ return pcs;
+ }
+
+ private static bool TryMatchMetadataResource(
+ Gen5VertexInputBinding input,
+ IReadOnlyList resources,
+ bool[] usedResources,
+ out MetadataVertexResource resource,
+ out bool fillOffsetBytes)
+ {
+ resource = default;
+ fillOffsetBytes = false;
+ var bestScore = int.MinValue;
+ var bestIndex = -1;
+ var bestFillOffset = false;
+ for (var index = 0; index < resources.Count; index++)
+ {
+ if (usedResources[index])
+ {
+ continue;
+ }
+
+ var candidate = resources[index];
+ if (candidate.Stride != 0 &&
+ input.Stride != 0 &&
+ candidate.Stride != input.Stride)
+ {
+ continue;
+ }
+
+ if (!IsSameVertexStream(input, candidate))
+ {
+ continue;
+ }
+
+ var attrAddress = candidate.SharpBase + candidate.OffsetBytes;
+ var score = int.MinValue;
+ var fillOffset = false;
+
+ // Post-capture interleaved: shared BaseAddress, distinct OffsetBytes.
+ if (input.OffsetBytes == candidate.OffsetBytes &&
+ (input.BaseAddress == candidate.SharpBase ||
+ IsAddressInsideCapturedSpan(input, candidate.SharpBase)))
+ {
+ score = 400;
+ }
+ // IR prolog baked attrib offset into the V# base.
+ else if (input.BaseAddress == attrAddress)
+ {
+ score = 350;
+ }
+ // Discovery never saw the attrib offset — only safe when this
+ // resource's offset uniquely identifies it among unused entries.
+ else if (input.BaseAddress == candidate.SharpBase &&
+ input.OffsetBytes == 0 &&
+ candidate.OffsetBytes != 0 &&
+ IsUniqueUnusedOffset(resources, usedResources, candidate.OffsetBytes, index))
+ {
+ score = 300;
+ fillOffset = true;
+ }
+ else if (input.BaseAddress == candidate.SharpBase &&
+ input.OffsetBytes == 0 &&
+ candidate.OffsetBytes == 0)
+ {
+ score = 250;
+ }
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ bestIndex = index;
+ bestFillOffset = fillOffset;
+ }
+ }
+
+ // Require an offset-aware match. Bare SharpBase ties (score 250) are
+ // only accepted when a single unused resource remains for that stream.
+ if (bestIndex < 0 || bestScore < 300)
+ {
+ if (bestIndex < 0 || bestScore < 250)
+ {
+ return false;
+ }
+
+ var unusedSameStream = 0;
+ for (var index = 0; index < resources.Count; index++)
+ {
+ if (!usedResources[index] && IsSameVertexStream(input, resources[index]))
+ {
+ unusedSameStream++;
+ }
+ }
+
+ if (unusedSameStream != 1)
+ {
+ return false;
+ }
+ }
+
+ usedResources[bestIndex] = true;
+ resource = resources[bestIndex];
+ fillOffsetBytes = bestFillOffset;
+ return true;
+ }
+
+ private static bool IsSameVertexStream(
+ Gen5VertexInputBinding input,
+ MetadataVertexResource resource)
+ {
+ if (input.BaseAddress == resource.SharpBase ||
+ input.BaseAddress == resource.SharpBase + resource.OffsetBytes)
+ {
+ return true;
+ }
+
+ return IsAddressInsideCapturedSpan(input, resource.SharpBase);
+ }
+
+ private static bool IsAddressInsideCapturedSpan(
+ Gen5VertexInputBinding input,
+ ulong address) =>
+ input.DataLength > 0 &&
+ address >= input.BaseAddress &&
+ address < input.BaseAddress + (ulong)input.DataLength;
+
+ private static bool IsUniqueUnusedOffset(
+ IReadOnlyList resources,
+ bool[] usedResources,
+ uint offsetBytes,
+ int candidateIndex)
+ {
+ for (var index = 0; index < resources.Count; index++)
+ {
+ if (index == candidateIndex || usedResources[index])
+ {
+ continue;
+ }
+
+ if (resources[index].OffsetBytes == offsetBytes)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Attrib-table format
+ /// fields are VertexAttribFormat; V# / Vulkan paths need BufferFormat.
+ /// Unknown values pass through (already BufferFormat).
+ ///
+ private static uint VertexAttribFormatToBufferFormat(uint format) =>
+ format switch
+ {
+ 0 => 0, // Invalid
+ 4 => 1, // k8UNorm
+ 8 => 2, // k8SNorm
+ 12 => 3, // k8UScaled
+ 16 => 4, // k8SScaled
+ 20 => 5, // k8UInt
+ 24 => 6, // k8SInt
+ 28 => 7, // k16UNorm
+ 32 => 8, // k16SNorm
+ 36 => 9, // k16UScaled
+ 40 => 10, // k16SScaled
+ 44 => 11, // k16UInt
+ 48 => 12, // k16SInt
+ 52 => 13, // k16Float
+ 57 => 14, // k8_8UNorm
+ 61 => 15, // k8_8SNorm
+ 65 => 16, // k8_8UScaled
+ 69 => 17, // k8_8SScaled
+ 73 => 18, // k8_8UInt
+ 77 => 19, // k8_8SInt
+ 80 => 20, // k32UInt
+ 84 => 21, // k32SInt
+ 88 => 22, // k32Float
+ 93 => 23, // k16_16UNorm
+ 97 => 24, // k16_16SNorm
+ 101 => 25, // k16_16UScaled
+ 105 => 26, // k16_16SScaled
+ 109 => 27, // k16_16UInt
+ 113 => 28, // k16_16SInt
+ 117 => 29, // k16_16Float
+ 122 => 30, // k11_11_10UNorm
+ 126 => 31,
+ 130 => 32,
+ 134 => 33,
+ 138 => 34,
+ 142 => 35,
+ 146 => 36,
+ 150 => 37, // k10_11_11UNorm
+ 154 => 38,
+ 158 => 39,
+ 162 => 40,
+ 166 => 41,
+ 170 => 42,
+ 174 => 43,
+ 179 => 44, // k2_10_10_10UNorm
+ 183 => 45,
+ 187 => 46,
+ 191 => 47,
+ 195 => 48,
+ 199 => 49,
+ 203 => 50, // k10_10_10_2UNorm
+ 207 => 51,
+ 211 => 52,
+ 215 => 53,
+ 219 => 54,
+ 223 => 55,
+ 227 => 56, // k8_8_8_8UNorm
+ 231 => 57,
+ 235 => 58,
+ 239 => 59,
+ 243 => 60,
+ 247 => 61,
+ 249 => 62, // k32_32UInt
+ 253 => 63,
+ 257 => 64, // k32_32Float
+ 263 => 65, // k16_16_16_16UNorm
+ 267 => 66,
+ 271 => 67,
+ 275 => 68,
+ 279 => 69,
+ 283 => 70,
+ 287 => 71, // k16_16_16_16Float
+ 290 => 72, // k32_32_32UInt
+ 294 => 73,
+ 298 => 74,
+ 303 => 75, // k32_32_32_32UInt
+ 307 => 76,
+ 311 => 77, // k32_32_32_32Float
+ _ => format,
+ };
+
+ ///
+ /// Maps Prospero attrib-table formats onto GNM (DataFormat, NumberFormat,
+ /// Components) for ToVkVertexFormat. Accepts VertexAttribFormat
+ /// or BufferFormat (pass-through). NumberFormat: 0 Unorm, 1 SNorm,
+ /// 2 UScaled, 3 SScaled, 4 UInt, 5 SInt, 7 Float.
+ ///
+ private static (uint DataFormat, uint NumberFormat, uint Components) MapAttribFormat(
+ uint attribFormat,
+ uint fallbackComponents)
+ {
+ // Prospero VertexAttribFormat quirks before BufferFormat conversion.
+ if (attribFormat == 113)
+ {
+ return (14, 7, 4); // R32G32B32A32_SFLOAT
+ }
+
+ if (attribFormat == 121)
+ {
+ return (5, 7, 2); // R16G16_SFLOAT
+ }
+
+ var bufferFormat = VertexAttribFormatToBufferFormat(attribFormat);
+
+ // Prospero::BufferFormat numeric values (gpu_defs.h).
+ return bufferFormat switch
+ {
+ 1 => (1, 0, 1), // k8UNorm
+ 2 => (1, 1, 1), // k8SNorm
+ 3 => (1, 2, 1), // k8UScaled
+ 4 => (1, 3, 1), // k8SScaled
+ 5 => (1, 4, 1), // k8UInt
+ 6 => (1, 5, 1), // k8SInt
+ 7 => (2, 0, 1), // k16UNorm
+ 8 => (2, 1, 1), // k16SNorm
+ 9 => (2, 2, 1), // k16UScaled
+ 10 => (2, 3, 1), // k16SScaled
+ 11 => (2, 4, 1), // k16UInt
+ 12 => (2, 5, 1), // k16SInt
+ 13 => (2, 7, 1), // k16Float
+ 14 => (3, 0, 2), // k8_8UNorm
+ 15 => (3, 1, 2), // k8_8SNorm
+ 16 => (3, 2, 2), // k8_8UScaled
+ 17 => (3, 3, 2), // k8_8SScaled
+ 18 => (3, 4, 2), // k8_8UInt
+ 19 => (3, 5, 2), // k8_8SInt
+ 20 => (4, 4, 1), // k32UInt
+ 21 => (4, 5, 1), // k32SInt
+ 22 => (4, 7, 1), // k32Float
+ 23 => (5, 0, 2), // k16_16UNorm
+ 24 => (5, 1, 2), // k16_16SNorm
+ 25 => (5, 2, 2), // k16_16UScaled
+ 26 => (5, 3, 2), // k16_16SScaled
+ 27 => (5, 4, 2), // k16_16UInt
+ 28 => (5, 5, 2), // k16_16SInt
+ 29 => (5, 7, 2), // k16_16Float
+ 50 => (9, 0, 4), // k10_10_10_2UNorm
+ 51 => (9, 1, 4), // k10_10_10_2SNorm
+ 56 => (10, 0, 4), // k8_8_8_8UNorm
+ 57 => (10, 1, 4), // k8_8_8_8SNorm
+ 58 => (10, 2, 4), // k8_8_8_8UScaled
+ 59 => (10, 3, 4), // k8_8_8_8SScaled
+ 60 => (10, 4, 4), // k8_8_8_8UInt
+ 61 => (10, 5, 4), // k8_8_8_8SInt
+ 62 => (11, 4, 2), // k32_32UInt
+ 63 => (11, 5, 2), // k32_32SInt
+ 64 => (11, 7, 2), // k32_32Float
+ 65 => (12, 0, 4), // k16_16_16_16UNorm
+ 66 => (12, 1, 4), // k16_16_16_16SNorm
+ 67 => (12, 2, 4), // k16_16_16_16UScaled
+ 68 => (12, 3, 4), // k16_16_16_16SScaled
+ 69 => (12, 4, 4), // k16_16_16_16UInt
+ 70 => (12, 5, 4), // k16_16_16_16SInt
+ 71 => (12, 7, 4), // k16_16_16_16Float
+ 72 => (13, 4, 3), // k32_32_32UInt
+ 73 => (13, 5, 3), // k32_32_32SInt
+ 74 => (13, 7, 3), // k32_32_32Float
+ 75 => (14, 4, 4), // k32_32_32_32UInt
+ 76 => (14, 5, 4), // k32_32_32_32SInt
+ 77 => (14, 7, 4), // k32_32_32_32Float
+ _ => (14, 7, Math.Clamp(fallbackComponents, 1u, 4u)),
+ };
+ }
+
+ private static bool TryReadUInt16(CpuContext ctx, ulong address, out ushort value)
+ {
+ Span buffer = stackalloc byte[2];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer);
+ return true;
+ }
+
+ private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
+ {
+ Span buffer = stackalloc byte[4];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer);
+ return true;
+ }
+
+ private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
+ {
+ Span buffer = stackalloc byte[8];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buffer);
+ return true;
+ }
+}
diff --git a/src/SharpEmu.Libs/Agc/GnmTiling.cs b/src/SharpEmu.Libs/Agc/GnmTiling.cs
index 515a1f75..9800cb8f 100644
--- a/src/SharpEmu.Libs/Agc/GnmTiling.cs
+++ b/src/SharpEmu.Libs/Agc/GnmTiling.cs
@@ -12,7 +12,7 @@ internal enum DetileEquation
/// Unsupported mode/format; caller must use the CPU path or raw upload.
None,
- /// Exact AddrLib XOR equation (RDNA2 modes 5/9/24/27): factored X/Y terms.
+ /// Exact AddrLib XOR equation (RDNA2 modes 1/5/9/24/27): factored X/Y terms.
ExactXor,
/// Other modes: a precomputed in-block Morton/standard element-offset table.
@@ -145,6 +145,18 @@ internal static unsafe class GnmTiling
Y(2), X(2), Y(3), X(3), Y(4), X(4), Y(5), X(5)],
];
+ // GFX10 256B_S: 8-bit micro-tile equation (low octet of the 4K_S pattern).
+ // The generic StandardSwizzle bit-interleave is a different layout and leaves
+ // a broken grid on Gen5 UI atlases that ship as Standard256B.
+ private static readonly AddressBit[][] Standard256 =
+ [
+ [X(0), X(1), X(2), X(3), Y(0), Y(1), Y(2), Y(3)],
+ [Zero, X(0), X(1), X(2), Y(0), Y(1), Y(2), X(3)],
+ [Zero, Zero, X(0), X(1), Y(0), Y(1), Y(2), X(2)],
+ [Zero, Zero, Zero, X(0), Y(0), Y(1), X(1), X(2)],
+ [Zero, Zero, Zero, Zero, Y(0), Y(1), X(0), X(1)],
+ ];
+
// GFX10 4K_S has a separate 12-bit micro-tile equation. It is not the
// generic x/y interleave used by the 64K standard block; using that larger
// equation leaves a regular grid in linearized atlases.
@@ -789,6 +801,7 @@ internal static unsafe class GnmTiling
pattern = swizzleMode switch
{
+ 1 => Standard256[bytesPerElementLog2],
5 => Standard4K[bytesPerElementLog2],
9 => RbPlus64KStandard[bytesPerElementLog2],
24 => RbPlus64KDepthX[bytesPerElementLog2],
diff --git a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs
index 33ef58c7..a85c6c96 100644
--- a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs
+++ b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Diagnostics;
+
namespace SharpEmu.Libs.Agc;
///
@@ -92,6 +94,64 @@ internal static class GpuWaitRegistry
}
}
+ public readonly record struct OutstandingSnapshot(
+ int Outstanding,
+ int Latched,
+ long OldestAgeMs,
+ ulong SampleWaitAddress,
+ string? SampleQueueName);
+
+ ///
+ /// Diagnostics snapshot of suspended WAIT_REG_MEM / dims waiters.
+ ///
+ public static OutstandingSnapshot SnapshotOutstanding(object? memory = null)
+ {
+ lock (_gate)
+ {
+ var outstanding = 0;
+ var latched = 0;
+ var oldestTicks = long.MaxValue;
+ ulong sampleAddress = 0;
+ string? sampleQueue = null;
+ var now = Stopwatch.GetTimestamp();
+ foreach (var (_, list) in _waiters)
+ {
+ foreach (var waiter in list)
+ {
+ if (memory is not null &&
+ !ReferenceEquals(waiter.Memory, memory))
+ {
+ continue;
+ }
+
+ outstanding++;
+ if (waiter.Latched)
+ {
+ latched++;
+ }
+
+ if (waiter.RegisteredTicks != 0 &&
+ waiter.RegisteredTicks < oldestTicks)
+ {
+ oldestTicks = waiter.RegisteredTicks;
+ sampleAddress = waiter.WaitAddress;
+ sampleQueue = waiter.QueueName;
+ }
+ }
+ }
+
+ var oldestAgeMs = oldestTicks == long.MaxValue || oldestTicks == 0
+ ? 0L
+ : (now - oldestTicks) * 1000L / Stopwatch.Frequency;
+ return new OutstandingSnapshot(
+ outstanding,
+ latched,
+ oldestAgeMs,
+ sampleAddress,
+ sampleQueue);
+ }
+ }
+
public static void Register(ulong address, WaitingDcb waiter)
{
waiter.WaitAddress = address;
diff --git a/src/SharpEmu.Libs/Ampr/AmprExports.cs b/src/SharpEmu.Libs/Ampr/AmprExports.cs
index 001386f6..dc09061a 100644
--- a/src/SharpEmu.Libs/Ampr/AmprExports.cs
+++ b/src/SharpEmu.Libs/Ampr/AmprExports.cs
@@ -275,6 +275,19 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
+ // Offset -1 means "continue after the previous read of this file id".
+ // #216 dropped this wiring; without it sequential pack/streamer reads
+ // fail as INVALID_ARGUMENT and RAGE load jobs never complete while the
+ // North Yankton UI keeps flipping.
+ if (fileOffset == unchecked((ulong)(long)-1))
+ {
+ fileOffset = PakDirectoryTracker.ResolveSequentialOffset(fileId, size);
+ }
+ else if (fileOffset > long.MaxValue)
+ {
+ fileOffset = 0;
+ }
+
var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out var bytesRead);
if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
@@ -282,6 +295,8 @@ public static class AmprExports
return result;
}
+ PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, fileOffset, bytesRead);
+
if (!AppendReadFileRecord(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
diff --git a/src/SharpEmu.Libs/Audio/AjmExports.cs b/src/SharpEmu.Libs/Audio/AjmExports.cs
index fd07e8bc..01571353 100644
--- a/src/SharpEmu.Libs/Audio/AjmExports.cs
+++ b/src/SharpEmu.Libs/Audio/AjmExports.cs
@@ -23,13 +23,32 @@ public static class AjmExports
private static int _nextContextId;
private static int _nextBatchId;
+ private const uint AjmCodecMp3 = 0;
+
+ private sealed class AjmInstanceState
+ {
+ public required uint Codec { get; init; }
+ public required ulong Flags { get; init; }
+ public AjmMp3Decoder? Mp3 { get; init; }
+
+ public bool PreferPcm16
+ {
+ get
+ {
+ // AjmInstanceFlags: version:3, channels:4, format:3
+ var encoding = (Flags >> 7) & 0x7;
+ return encoding is 0 or 1; // S16 / S32 — we emit S16 for both
+ }
+ }
+ }
+
private sealed class AjmContextState
{
public object Gate { get; } = new();
public HashSet RegisteredCodecs { get; } = new();
- public Dictionary InstancesBySlot { get; } = new();
+ public Dictionary InstancesBySlot { get; } = new();
public int NextInstanceIndex { get; set; }
}
@@ -169,7 +188,14 @@ public static class AjmExports
}
state.NextInstanceIndex = nextInstanceIndex;
- state.InstancesBySlot.Add(instanceSlot, instanceId);
+ state.InstancesBySlot.Add(
+ instanceSlot,
+ new AjmInstanceState
+ {
+ Codec = codecType,
+ Flags = flags,
+ Mp3 = codecType == AjmCodecMp3 ? new AjmMp3Decoder() : null,
+ });
}
Trace($"instance_create context={contextId} codec={codecType} flags=0x{flags:X} instance=0x{instanceId:X8}");
@@ -229,11 +255,9 @@ public static class AjmExports
}
///
- /// Enqueues a decode job on a batch. Titles call this on the Bink/AJM hot
- /// path; leaving it unresolved floods Import WARN spam. This is a silence
- /// stub, not a codec: advance the batch cursor and report the input as
- /// consumed with silence produced so the title does not spin on the same
- /// packet.
+ /// Enqueues a decode job on a batch. GTA V Enhanced streams menu music
+ /// through AJM MP3 (codec 0); we decode eagerly here so BatchStart/Wait
+ /// stay synchronous no-ops.
///
[SysAbiExport(
Nid = "39WxhR-ePew",
@@ -255,37 +279,120 @@ public static class AjmExports
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
- // Best-effort: bump the batch cursor when the guest filled AjmBatchInfo.
- // Still succeed without it — the unresolved stub returned 0 and titles
- // keep calling; failing here would reintroduce hot-path spam via retries.
_ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
- // Silence: clear PCM out and claim full input consumed so the guest
- // advances its bitstream cursor instead of re-submitting forever.
- if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
+ var inputConsumed = 0;
+ var outputWritten = 0;
+ ulong totalSamples = 0;
+ var frames = 0u;
+ var decoded = false;
+
+ if (TryGetInstance(instanceId, out var instance) &&
+ instance.Mp3 is not null &&
+ inputAddress != 0 &&
+ inputSize is > 0 and <= MaxSilentPcmBytes &&
+ outputAddress != 0 &&
+ outputSize is > 0 and <= MaxSilentPcmBytes)
{
- ClearGuestMemory(ctx, outputAddress, outputSize);
+ var input = new byte[inputSize];
+ var output = new byte[outputSize];
+ if (ctx.Memory.TryRead(inputAddress, input))
+ {
+ var result = instance.Mp3.Decode(input, output, pcm16: instance.PreferPcm16);
+ if (result.OutputWritten > 0)
+ {
+ if (!ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, result.OutputWritten)))
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ // Zero any remainder so stale PCM does not leak into FMOD.
+ if ((ulong)result.OutputWritten < outputSize)
+ {
+ ClearGuestMemory(
+ ctx,
+ outputAddress + (ulong)result.OutputWritten,
+ outputSize - (ulong)result.OutputWritten);
+ }
+
+ decoded = true;
+ inputConsumed = result.InputConsumed;
+ outputWritten = result.OutputWritten;
+ frames = result.Frames;
+ totalSamples = instance.Mp3.TotalDecodedSamples;
+ }
+ else
+ {
+ inputConsumed = result.InputConsumed;
+ totalSamples = instance.Mp3.TotalDecodedSamples;
+ }
+ }
+ }
+
+ if (!decoded)
+ {
+ // Fallback: silence + consume input so the guest does not spin.
+ if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
+ {
+ ClearGuestMemory(ctx, outputAddress, outputSize);
+ }
+
+ if (inputConsumed == 0)
+ {
+ inputConsumed = inputSize > int.MaxValue ? int.MaxValue : (int)inputSize;
+ }
+
+ if (frames == 0 && (inputSize != 0 || outputSize != 0))
+ {
+ frames = 1;
+ }
}
WriteDecodeStreamResult(
ctx,
resultAddress,
- inputConsumed: inputSize > int.MaxValue ? int.MaxValue : (int)inputSize,
- outputWritten: 0,
- totalDecodedSamples: 0,
- frames: inputSize != 0 || outputSize != 0 ? 1u : 0u);
+ inputConsumed,
+ outputWritten,
+ totalSamples,
+ frames);
Trace(
$"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " +
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " +
- $"result=0x{resultAddress:X16}");
+ $"written={outputWritten} frames={frames} result=0x{resultAddress:X16}");
return ctx.SetReturn(0);
}
+ private static bool TryGetInstance(uint instanceId, out AjmInstanceState instance)
+ {
+ instance = null!;
+ var codec = instanceId >> 14;
+ var slot = instanceId & 0x3FFF;
+ if (slot == 0)
+ {
+ return false;
+ }
+
+ foreach (var context in Contexts.Values)
+ {
+ lock (context.Gate)
+ {
+ if (context.InstancesBySlot.TryGetValue(slot, out var found) &&
+ found.Codec == codec)
+ {
+ instance = found;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
///
- /// Submits a built batch. Instant-complete silence stub: publish a batch id
- /// and clear any error out. Decode sidebands were already filled at
- /// job-enqueue time.
+ /// Submits a built batch. Hot path after BatchJobDecode; unresolved WARNs
+ /// dominate the log. Instant-complete: publish a batch id and clear any
+ /// error out. Decode sidebands were already filled at job-enqueue time.
///
[SysAbiExport(
Nid = "5tOfnaClcqM",
@@ -363,6 +470,7 @@ public static class AjmExports
private const ulong AjmBatchInfoSizeField = 16;
private const ulong AjmBatchInfoLastGoodJobField = 24;
private const ulong AjmJobRunSize = 64;
+ private const int OrbisAjmErrorJobCreation = unchecked((int)0x80930012);
private const ulong MaxSilentPcmBytes = 1 << 20;
// AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8).
private const int DecodeSidebandBytes = 32;
diff --git a/src/SharpEmu.Libs/Audio/AjmMp3Decoder.cs b/src/SharpEmu.Libs/Audio/AjmMp3Decoder.cs
new file mode 100644
index 00000000..cb1e68f0
--- /dev/null
+++ b/src/SharpEmu.Libs/Audio/AjmMp3Decoder.cs
@@ -0,0 +1,231 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using NLayer;
+using System.Buffers.Binary;
+using System.Reflection;
+
+namespace SharpEmu.Libs.Audio;
+
+///
+/// Stateful AJM MP3 (codec 0) decoder. GTA menu music arrives as ~960-byte
+/// packets that NLayer must decode with a persistent bit-reservoir.
+///
+internal sealed class AjmMp3Decoder
+{
+ private static readonly Type? MpegStreamReaderType =
+ typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.MpegStreamReader");
+
+ private static readonly MethodInfo? NextFrameMethod =
+ MpegStreamReaderType?.GetMethod(
+ "NextFrame",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+
+ private static readonly MethodInfo? ClearBufferMethod =
+ typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.FrameBase")
+ ?.GetMethod("ClearBuffer", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+
+ private readonly MpegFrameDecoder _decoder = new();
+ private readonly object _gate = new();
+ private byte[] _pending = Array.Empty();
+ private readonly float[] _floatScratch = new float[1152 * 2];
+
+ public ulong TotalDecodedSamples { get; private set; }
+
+ public void Reset()
+ {
+ lock (_gate)
+ {
+ _decoder.Reset();
+ _pending = Array.Empty();
+ TotalDecodedSamples = 0;
+ }
+ }
+
+ public DecodeResult Decode(ReadOnlySpan input, Span output, bool pcm16)
+ {
+ lock (_gate)
+ {
+ if (MpegStreamReaderType is null || NextFrameMethod is null)
+ {
+ return DecodeResult.Failed;
+ }
+
+ var merged = new byte[_pending.Length + input.Length];
+ if (_pending.Length != 0)
+ {
+ _pending.CopyTo(merged, 0);
+ }
+
+ input.CopyTo(merged.AsSpan(_pending.Length));
+
+ using var stream = new MemoryStream(merged, writable: false);
+ object? reader;
+ try
+ {
+ reader = Activator.CreateInstance(
+ MpegStreamReaderType,
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
+ binder: null,
+ args: [stream],
+ culture: null);
+ }
+ catch
+ {
+ return DecodeResult.Failed;
+ }
+
+ if (reader is null)
+ {
+ return DecodeResult.Failed;
+ }
+
+ var outputOffset = 0;
+ var inputConsumed = 0;
+ var frames = 0u;
+ var samplesThisCall = 0u;
+
+ while (outputOffset < output.Length)
+ {
+ object? frameObj;
+ try
+ {
+ frameObj = NextFrameMethod.Invoke(reader, null);
+ }
+ catch
+ {
+ break;
+ }
+
+ if (frameObj is not IMpegFrame frame)
+ {
+ break;
+ }
+
+ try
+ {
+ var frameOffset = GetFrameOffset(frameObj);
+ var frameLength = frame.FrameLength;
+ if (frameLength <= 0 || frameOffset + frameLength > merged.Length)
+ {
+ break;
+ }
+
+ int sampleCount;
+ try
+ {
+ sampleCount = _decoder.DecodeFrame(frame, _floatScratch, 0);
+ }
+ catch
+ {
+ _decoder.Reset();
+ inputConsumed = frameOffset + frameLength;
+ continue;
+ }
+
+ if (sampleCount <= 0)
+ {
+ inputConsumed = frameOffset + frameLength;
+ continue;
+ }
+
+ var channels = frame.ChannelMode == MpegChannelMode.Mono ? 1 : 2;
+ var bytesPerSample = pcm16 ? 2 : 4;
+ var byteCount = sampleCount * bytesPerSample;
+ if (outputOffset + byteCount > output.Length)
+ {
+ // Not enough room for this frame — leave it for next job.
+ break;
+ }
+
+ if (pcm16)
+ {
+ WritePcm16(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
+ }
+ else
+ {
+ WriteFloat(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
+ }
+
+ outputOffset += byteCount;
+ inputConsumed = frameOffset + frameLength;
+ frames++;
+ samplesThisCall += (uint)(sampleCount / Math.Max(channels, 1));
+ TotalDecodedSamples += (ulong)(sampleCount / Math.Max(channels, 1));
+ }
+ finally
+ {
+ try
+ {
+ ClearBufferMethod?.Invoke(frameObj, null);
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+ }
+
+ _pending = inputConsumed < merged.Length
+ ? merged[inputConsumed..]
+ : Array.Empty();
+
+ // Consume the portion of *this* input that left the pending window.
+ var pendingBefore = merged.Length - input.Length;
+ var consumedFromInput = Math.Clamp(inputConsumed - pendingBefore, 0, input.Length);
+
+ return new DecodeResult(
+ Success: frames > 0 || consumedFromInput > 0,
+ InputConsumed: consumedFromInput,
+ OutputWritten: outputOffset,
+ Frames: frames,
+ SamplesThisCall: samplesThisCall);
+ }
+ }
+
+ private static int GetFrameOffset(object frameObj)
+ {
+ for (var type = frameObj.GetType(); type is not null; type = type.BaseType)
+ {
+ var prop = type.GetProperty(
+ "Offset",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
+ if (prop?.GetValue(frameObj) is long offset)
+ {
+ return checked((int)offset);
+ }
+ }
+
+ return 0;
+ }
+
+ private static void WritePcm16(ReadOnlySpan samples, Span destination)
+ {
+ for (var i = 0; i < samples.Length; i++)
+ {
+ var sample = samples[i];
+ var scaled = sample < 0f ? sample * 32768f : sample * 32767f;
+ var value = (short)Math.Clamp(MathF.Round(scaled), short.MinValue, short.MaxValue);
+ BinaryPrimitives.WriteInt16LittleEndian(destination[(i * 2)..], value);
+ }
+ }
+
+ private static void WriteFloat(ReadOnlySpan samples, Span destination)
+ {
+ for (var i = 0; i < samples.Length; i++)
+ {
+ var bits = BitConverter.SingleToInt32Bits(samples[i]);
+ BinaryPrimitives.WriteInt32LittleEndian(destination[(i * 4)..], bits);
+ }
+ }
+
+ internal readonly record struct DecodeResult(
+ bool Success,
+ int InputConsumed,
+ int OutputWritten,
+ uint Frames,
+ uint SamplesThisCall)
+ {
+ public static DecodeResult Failed { get; } = new(false, 0, 0, 0, 0);
+ }
+}
diff --git a/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs b/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs
index 18b37515..e59a7c01 100644
--- a/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs
+++ b/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.Host;
+using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics;
@@ -15,36 +17,66 @@ public static class AudioOut2Exports
// Clearing 0x80 bytes here overwrote the caller's stack canary immediately
// following the 0x40-byte parameter block.
private const int AudioOut2ContextParamSize = 0x40;
- private const int AudioOut2ContextMemorySize = 0x10000;
- private const int AudioOut2ContextMemoryAlignment = 0x10000;
+ // Keep these modest. GTA V Enhanced stack-allocates QueryMemory results next
+ // to the frame canary: a 16-byte {size,align} write to [rbp-0x38] plants
+ // align at [rbp-0x30] (observed canary=0x100). Size-only (8 bytes) on stack.
+ private const int AudioOut2ContextMemorySize = 0x4000;
+ private const int AudioOut2ContextMemoryAlignment = 0x100;
+ // Exact object body size. Do not page-align to 64K — the RAGE Main Thread
+ // stack-allocates this and a 64K VLA is what planted 0x10000 on the canary.
+ private const int SpeakerArrayHeaderSize = 0x40;
+ private const int SpeakerArrayEntrySize = 0x100;
+ // Extra scratch the title writes after the per-channel entries (coefficients).
+ private const int SpeakerArrayScratchBytes = 0x400;
+ private const uint SpeakerArrayDefaultChannels = 8;
+ private const uint SpeakerArrayMaxChannels = 32;
+ // Field read by GTA at object+0x34 (see AV at eboot+0xB07D: mov eax,[rbx+0x34]).
+ private const int SpeakerArrayDivisorFieldOffset = 0x34;
+ private const int SpeakerArrayResultFieldOffset = 0x3C;
+ private const uint SpeakerArrayDefaultDivisor = 1;
+ private const int SpeakerArrayCoefficientBytes = 0x400;
+ // OrbisAudioOutPortState is 0x20 bytes. Never grow this from r8/r9 — those
+ // regs arrive polluted with GetSize leftovers (0x840/0x10C/0x180) and caused
+ // PortGetState/GetSpeakerInfo to overwrite the speaker-array param block
+ // (param+0x18 == first PortGetState out) and smash the Main Thread canary
+ // with ContextMemoryAlignment (0x100).
+ private const int PortStateSize = 0x20;
+ private const int SpeakerInfoSize = 0x20;
+ private const int PortParamSize = 0x40;
+ private const int AttributeEntrySize = 0x18;
+ private const uint PortAttributeIdPcm = 0;
+ private const ushort PortStateOutputConnectedPrimary = 0x01;
private static long _nextContextHandle = 1;
private static long _nextUserHandle = 1;
private static int _nextPortId;
private static long _pushTraceCount;
+ private static long _submitTraceCount;
+ private static long _attributePcmTraceCount;
- // Per-context audio parameters captured at ContextCreate so ContextAdvance
- // can pace to the real playback cadence (grain samples at the sample rate).
+ private static readonly ConcurrentDictionary SpeakerArrays = new();
private static readonly ConcurrentDictionary Contexts = new();
+ private static readonly ConcurrentDictionary Ports = new();
private sealed class ContextState
{
private readonly object _paceGate = new();
private long _nextAdvanceTimestamp;
- public ContextState(uint frequency, uint channels, uint grainSamples)
+ public ContextState(ulong handle, uint frequency, uint grainSamples, uint queueDepth, IHostAudioStream? backend)
{
+ Handle = handle;
Frequency = frequency == 0 ? 48000 : frequency;
- Channels = channels == 0 ? 2 : channels;
GrainSamples = grainSamples == 0 ? 256 : grainSamples;
+ QueueDepth = queueDepth == 0 ? 4 : queueDepth;
+ Backend = backend;
}
+ public ulong Handle { get; }
public uint Frequency { get; }
- public uint Channels { get; }
public uint GrainSamples { get; }
+ public uint QueueDepth { get; }
+ public IHostAudioStream? Backend { get; }
- // Blocks the advancing thread until one grain worth of wall-clock time
- // has elapsed since the previous advance, matching hardware timing so
- // audio-gated titles neither spin nor drift ahead.
public void PaceAdvance()
{
long delay;
@@ -68,6 +100,45 @@ public static class AudioOut2Exports
}
}
+ private sealed class PortState
+ {
+ public PortState(
+ ulong handle,
+ ulong contextHandle,
+ ushort portType,
+ uint dataFormat,
+ uint samplingFrequency,
+ uint grainSamples)
+ {
+ Handle = handle;
+ ContextHandle = contextHandle;
+ PortType = portType;
+ DataFormat = dataFormat;
+ SamplingFrequency = samplingFrequency == 0 ? 48000 : samplingFrequency;
+ GrainSamples = grainSamples == 0 ? 256 : grainSamples;
+ }
+
+ public ulong Handle { get; }
+ public ulong ContextHandle { get; }
+ /// Full Prospero port type (low byte = MAIN/BGM/…, 0x0100 = object).
+ public ushort PortType { get; }
+ public uint DataFormat { get; }
+ public uint SamplingFrequency { get; }
+ public uint GrainSamples { get; }
+ public ulong PcmAddress;
+ }
+
+ // Two host streams: primary FMOD context (menus) and everything else
+ // (Bink/intro). Mixing those into one waveOut re-crunched audio; the OS
+ // mixer keeps separate devices clean.
+ private static readonly object HostBackendGate = new();
+ private static IHostAudioStream? PrimaryBackend;
+ private static IHostAudioStream? SecondaryBackend;
+ private static string PrimaryBackendName = "none";
+ private static string SecondaryBackendName = "none";
+ private static ulong PrimaryContextHandle;
+ private static readonly object HostSubmitGate = new();
+
[SysAbiExport(
Nid = "g2tViFIohHE",
ExportName = "sceAudioOut2Initialize",
@@ -92,12 +163,17 @@ public static class AudioOut2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
+ // Layout matches libSceAudioOut2 SceAudioOut2ContextParam (no size prefix):
+ // max_ports, max_object_ports, guarantee_object_ports, queue_depth,
+ // num_grains, flags, reserved...
Span param = stackalloc byte[AudioOut2ContextParamSize];
param.Clear();
- BinaryPrimitives.WriteUInt32LittleEndian(param[0x00..], AudioOut2ContextParamSize);
- BinaryPrimitives.WriteUInt32LittleEndian(param[0x04..], 2);
- BinaryPrimitives.WriteUInt32LittleEndian(param[0x08..], 48000);
- BinaryPrimitives.WriteUInt32LittleEndian(param[0x0C..], 0x400);
+ BinaryPrimitives.WriteUInt32LittleEndian(param[0x00..], 256);
+ BinaryPrimitives.WriteUInt32LittleEndian(param[0x04..], 256);
+ BinaryPrimitives.WriteUInt32LittleEndian(param[0x08..], 0);
+ BinaryPrimitives.WriteUInt32LittleEndian(param[0x0C..], 4);
+ BinaryPrimitives.WriteUInt32LittleEndian(param[0x10..], 512);
+ BinaryPrimitives.WriteUInt32LittleEndian(param[0x14..], 1);
return ctx.Memory.TryWrite(paramAddress, param)
? SetReturn(ctx, 0)
@@ -112,19 +188,36 @@ public static class AudioOut2Exports
public static int AudioOut2ContextQueryMemory(CpuContext ctx)
{
var paramAddress = ctx[CpuRegister.Rdi];
- var memoryInfoAddress = ctx[CpuRegister.Rsi];
+ var memoryInfoAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rsi], ctx[CpuRegister.Rdx]);
if (paramAddress == 0 || memoryInfoAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- Span memoryInfo = stackalloc byte[0x20];
+ // Heap: {size, alignment} (16 bytes), matching sceAudioPropagationSystemQueryMemory.
+ // Stack: SIZE ONLY as a full ulong (8 bytes). Writing alignment at +8 is how
+ // [rbp-0x30] became 0x100 on GTA V Enhanced. Do NOT shrink this to uint32 —
+ // Main reads the out as a 64-bit size; a 4-byte write leaves a garbage high
+ // dword (observed 0x7<<32|0x4000) and the allocator aborts with int 0x41.
+ if (IsGuestStackAddress(memoryInfoAddress))
+ {
+ Span sizeOnly = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(sizeOnly, AudioOut2ContextMemorySize);
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] audio_out2.context-query-memory stack-size-only " +
+ $"out=0x{memoryInfoAddress:X} size=0x{AudioOut2ContextMemorySize:X}");
+ return ctx.Memory.TryWrite(memoryInfoAddress, sizeOnly)
+ ? SetReturn(ctx, 0)
+ : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ Span memoryInfo = stackalloc byte[0x10];
memoryInfo.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x00..], AudioOut2ContextMemorySize);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x08..], AudioOut2ContextMemoryAlignment);
- BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x10..], AudioOut2ContextMemorySize);
- BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x18..], AudioOut2ContextMemoryAlignment);
-
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] audio_out2.context-query-memory out=0x{memoryInfoAddress:X} " +
+ $"size=0x{AudioOut2ContextMemorySize:X} align=0x{AudioOut2ContextMemoryAlignment:X}");
return ctx.Memory.TryWrite(memoryInfoAddress, memoryInfo)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -146,28 +239,27 @@ public static class AudioOut2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- // Read channels/frequency/grain from the reset-param blob so the
- // context can pace advances to the real audio cadence.
- uint channels = 2;
+ // Prospero AudioOut2 context params are port/queue config, not an AudioOut
+ // open-style frequency/channel block. Sample rate is fixed at 48 kHz.
uint frequency = 48000;
uint grain = 256;
+ uint queueDepth = 4;
Span param = stackalloc byte[AudioOut2ContextParamSize];
if (ctx.Memory.TryRead(paramAddress, param))
{
- var pc = BinaryPrimitives.ReadUInt32LittleEndian(param[0x04..]);
- var pf = BinaryPrimitives.ReadUInt32LittleEndian(param[0x08..]);
- var pg = BinaryPrimitives.ReadUInt32LittleEndian(param[0x0C..]);
- if (pc is > 0 and <= 8) channels = pc;
- if (pf is >= 8000 and <= 192000) frequency = pf;
- // Values below one cache line are flags/counts in observed PS5
- // callers, not audio grains. Keep the hardware-sized default.
- if (pg is >= 64 and <= 0x4000) grain = pg;
+ var qd = BinaryPrimitives.ReadUInt32LittleEndian(param[0x0C..]);
+ var ng = BinaryPrimitives.ReadUInt32LittleEndian(param[0x10..]);
+ if (qd is >= 1 and <= 32) queueDepth = qd;
+ if (ng is >= 64 and <= 0x4000) grain = ng;
TraceAudioOut2($"context-param address=0x{paramAddress:X} bytes={Convert.ToHexString(param)}");
}
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
- Contexts[handle] = new ContextState(frequency, channels, grain);
- TraceAudioOut2($"context-create handle=0x{handle:X} frequency={frequency} channels={channels} grain={grain} memory=0x{memoryAddress:X} size=0x{memorySize:X}");
+ // Backend is bound lazily on first real Push (primary vs secondary device).
+ Contexts[handle] = new ContextState(handle, frequency, grain, queueDepth, backend: null);
+ TraceAudioOut2(
+ $"context-create handle=0x{handle:X} frequency={frequency} grain={grain} " +
+ $"queue={queueDepth} memory=0x{memoryAddress:X} size=0x{memorySize:X} backend=pending");
return TryWriteUInt64(ctx, outContextAddress, handle)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -180,6 +272,7 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextDestroy(CpuContext ctx)
{
+ // Shared backend lifetime is process-wide; just drop the context entry.
Contexts.TryRemove(ctx[CpuRegister.Rdi], out _);
return SetReturn(ctx, 0);
}
@@ -198,18 +291,25 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextPush(CpuContext ctx)
{
+ // ABI: sceAudioOut2ContextPush(ctx, blocking). RSI is a blocking flag
+ // (observed 1), not a PCM pointer. PCM is attached earlier via
+ // PortSetAttributes(attribute_id=PCM) and flushed here.
var handle = ctx[CpuRegister.Rdi];
- var traceCount = Interlocked.Increment(ref _pushTraceCount);
- if (traceCount <= 16)
+ var blocking = unchecked((uint)ctx[CpuRegister.Rsi]);
+ if (Interlocked.Increment(ref _pushTraceCount) <= 8)
{
- TraceAudioOut2($"context-push count={traceCount} rdi=0x{handle:X} rsi=0x{ctx[CpuRegister.Rsi]:X} rdx=0x{ctx[CpuRegister.Rdx]:X} rcx=0x{ctx[CpuRegister.Rcx]:X}");
+ TraceAudioOut2($"context-push handle=0x{handle:X} blocking={blocking}");
}
- if (Contexts.TryGetValue(handle, out var context))
+ if (!Contexts.TryGetValue(handle, out var context))
+ {
+ return SetReturn(ctx, 0);
+ }
+
+ // Host Submit already blocks on the waveOut queue; only fall back to
+ // software pacing when nothing was queued (silence / non-primary ctx).
+ if (!TrySubmitContextAudio(ctx, context))
{
- // FMOD's PS5 output path uses ContextPush as the submission clock
- // and does not call ContextAdvance. Pace pushes to one hardware
- // grain so the feeder cannot outrun playback and starve the game.
context.PaceAdvance();
}
@@ -223,11 +323,9 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextAdvance(CpuContext ctx)
{
- // Advancing renders one grain of audio on hardware; pace it to the same
- // wall-clock cadence so the guest audio thread runs at the right speed.
- if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var context))
+ if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var state))
{
- context.PaceAdvance();
+ state.PaceAdvance();
}
return SetReturn(ctx, 0);
@@ -240,11 +338,93 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextGetQueueLevel(CpuContext ctx)
{
- // The advance path paces synchronously, so the queue is always drained.
- var levelAddress = ctx[CpuRegister.Rsi];
- if (levelAddress != 0)
+ // ABI out is a 32-bit queue depth (GTA compares dword [out] to 4). A
+ // uint64 write into a stack slot at [rbp-0x14] next to the canary at
+ // [rbp-0x10] zeroed the canary low half and killed Bink Snd @ eboot+0xAE36.
+ var outLevelAddress = ctx[CpuRegister.Rsi];
+ if (outLevelAddress == 0)
{
- _ = TryWriteUInt64(ctx, levelAddress, 0);
+ outLevelAddress = ctx[CpuRegister.Rdx];
+ }
+
+ if (outLevelAddress != 0)
+ {
+ Span level = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(level, 0);
+ if (!ctx.Memory.TryWrite(outLevelAddress, level))
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ return SetReturn(ctx, 0);
+ }
+
+ [SysAbiExport(
+ Nid = "Q8DZkKQ-SYc",
+ ExportName = "sceAudioOut2LoContextGetQueueLevel",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2LoContextGetQueueLevel(CpuContext ctx) =>
+ AudioOut2ContextGetQueueLevel(ctx);
+
+ [SysAbiExport(
+ Nid = "8XTArSPyWHk",
+ ExportName = "sceAudioOut2PortSetAttributes",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2PortSetAttributes(CpuContext ctx)
+ {
+ // sceAudioOut2PortSetAttributes(port, attributes*, num).
+ // Attribute id 0 = PCM; value points at { const void* data }.
+ var portHandle = ctx[CpuRegister.Rdi];
+ var attributesAddress = ctx[CpuRegister.Rsi];
+ var attributeCount = unchecked((uint)ctx[CpuRegister.Rdx]);
+ if (!Ports.TryGetValue(portHandle, out var port))
+ {
+ return SetReturn(ctx, 0);
+ }
+
+ if (attributeCount == 0 || attributesAddress == 0)
+ {
+ return SetReturn(ctx, 0);
+ }
+
+ if (attributeCount > 32)
+ {
+ attributeCount = 32;
+ }
+
+ Span entry = stackalloc byte[AttributeEntrySize];
+ Span pcm = stackalloc byte[8];
+ for (uint i = 0; i < attributeCount; i++)
+ {
+ if (!ctx.Memory.TryRead(attributesAddress + (i * AttributeEntrySize), entry))
+ {
+ break;
+ }
+
+ var attributeId = BinaryPrimitives.ReadUInt32LittleEndian(entry);
+ var valueAddress = BinaryPrimitives.ReadUInt64LittleEndian(entry[0x08..]);
+ var valueSize = BinaryPrimitives.ReadUInt64LittleEndian(entry[0x10..]);
+ if (attributeId != PortAttributeIdPcm || valueAddress == 0 || valueSize < 8)
+ {
+ continue;
+ }
+
+ if (!ctx.Memory.TryRead(valueAddress, pcm))
+ {
+ continue;
+ }
+
+ port.PcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcm);
+ var n = Interlocked.Increment(ref _attributePcmTraceCount);
+ if (n <= 8 || n % 500 == 0)
+ {
+ TraceAudioOut2(
+ $"port-set-pcm#{n} port=0x{portHandle:X} pcm=0x{port.PcmAddress:X} " +
+ $"format=0x{port.DataFormat:X} grains={port.GrainSamples}");
+ }
}
return SetReturn(ctx, 0);
@@ -257,29 +437,63 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortCreate(CpuContext ctx)
{
- var type = unchecked((int)ctx[CpuRegister.Rdi]);
+ // sceAudioOut2PortCreate(ctx, PortParam*, outPort*).
+ var contextHandle = ctx[CpuRegister.Rdi];
var paramAddress = ctx[CpuRegister.Rsi];
- var outPortAddress = ctx[CpuRegister.Rdx];
- var contextAddress = ctx[CpuRegister.Rcx];
- if (type < 0 || type > 255 || paramAddress == 0 || outPortAddress == 0 || contextAddress == 0)
+ var outPortAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rdx], ctx[CpuRegister.Rcx]);
+ if (outPortAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- var portId = unchecked((uint)Interlocked.Increment(ref _nextPortId)) & 0xFF;
- var handle = 0x2000_0000UL | ((ulong)(uint)type << 16) | portId;
- return TryWriteUInt64(ctx, outPortAddress, handle)
- ? SetReturn(ctx, 0)
- : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ ushort portType = 0;
+ uint dataFormat = 0x0000_0200; // float stereo default
+ uint samplingFrequency = 48000;
+ uint grainSamples = 256;
+ if (Contexts.TryGetValue(contextHandle, out var context))
+ {
+ grainSamples = context.GrainSamples;
+ samplingFrequency = context.Frequency;
+ }
+
+ if (paramAddress != 0 && IsPlausibleGuestObjectPointer(paramAddress))
+ {
+ Span param = stackalloc byte[PortParamSize];
+ if (ctx.Memory.TryRead(paramAddress, param))
+ {
+ portType = BinaryPrimitives.ReadUInt16LittleEndian(param);
+ dataFormat = BinaryPrimitives.ReadUInt32LittleEndian(param[0x04..]);
+ var freq = BinaryPrimitives.ReadUInt32LittleEndian(param[0x08..]);
+ if (freq is >= 8000 and <= 192000)
+ {
+ samplingFrequency = freq;
+ }
+ }
+ }
+
+ var portId = (uint)Interlocked.Increment(ref _nextPortId);
+ // Handle encodes only the low type byte; PortState keeps the full type
+ // so object ports (0x01xx) can still be filtered at submit time.
+ var handle = 0x2000_0000UL | ((ulong)(portType & 0xFF) << 16) | portId;
+ Ports[handle] = new PortState(
+ handle,
+ contextHandle,
+ portType,
+ dataFormat,
+ samplingFrequency,
+ grainSamples);
+ if (!TryWriteUInt64(ctx, outPortAddress, handle))
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAudioOut2(
+ $"port-create handle=0x{handle:X} ctx=0x{contextHandle:X} type=0x{portType:X} " +
+ $"format=0x{dataFormat:X} freq={samplingFrequency} out=0x{outPortAddress:X}");
+ return SetReturn(ctx, 0);
}
- [SysAbiExport(
- Nid = "8XTArSPyWHk",
- ExportName = "sceAudioOut2PortSetAttributes",
- Target = Generation.Gen5,
- LibraryName = "libSceAudioOut2")]
- public static int AudioOut2PortSetAttributes(CpuContext ctx) => SetReturn(ctx, 0);
-
+ // Fixed-size connected stereo state. Do not trust r8/r9 for byte counts.
[SysAbiExport(
Nid = "gatEUKG+Ea4",
ExportName = "sceAudioOut2PortGetState",
@@ -287,27 +501,51 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortGetState(CpuContext ctx)
{
- var handle = ctx[CpuRegister.Rdi];
- var stateAddress = ctx[CpuRegister.Rsi];
- if (handle == 0 || stateAddress == 0)
+ var portHandle = ctx[CpuRegister.Rdi];
+ var stateAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rsi], ctx[CpuRegister.Rdx]);
+ if (stateAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- var type = (int)((handle >> 16) & 0xFF);
- Span state = stackalloc byte[0x20];
+ // Stack out-buffers with garbage handles were writing 0x20 bytes over
+ // caller frames / canaries (state=0x7FFFDE1FF688 right before fail).
+ // Heap outs still get a real state blob even when the handle wasn't
+ // minted by PortCreate — this title synthesizes port ids itself.
+ if (IsGuestStackAddress(stateAddress))
+ {
+ TraceAudioOut2(
+ $"port-get-state skip-stack handle=0x{portHandle:X} state=0x{stateAddress:X}");
+ return SetReturn(ctx, 0);
+ }
+
+ Span state = stackalloc byte[PortStateSize];
state.Clear();
- var output = type == 2 ? 0x40 : 0x01;
- var channels = type == 2 ? 1 : 2;
- BinaryPrimitives.WriteUInt16LittleEndian(state[0x00..], unchecked((ushort)output));
- state[0x02] = unchecked((byte)channels);
+ // +0x00 u16 output = CONNECTED_PRIMARY (1)
+ // +0x02 u8 channels = from port format when known, else 2
+ // +0x04 s16 volume = -1 (N/A for main)
+ byte channels = 2;
+ if (Ports.TryGetValue(portHandle, out var port) &&
+ TryDecodeDataFormat(port.DataFormat, out var decodedChannels, out _, out _))
+ {
+ channels = (byte)Math.Clamp(decodedChannels, 1, 16);
+ }
+
+ BinaryPrimitives.WriteUInt16LittleEndian(state[0x00..], PortStateOutputConnectedPrimary);
+ state[0x02] = channels;
BinaryPrimitives.WriteInt16LittleEndian(state[0x04..], -1);
- return ctx.Memory.TryWrite(stateAddress, state)
- ? SetReturn(ctx, 0)
- : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ if (!ctx.Memory.TryWrite(stateAddress, state))
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAudioOut2(
+ $"port-get-state handle=0x{portHandle:X} state=0x{stateAddress:X} bytes=0x{PortStateSize:X}");
+ return SetReturn(ctx, 0);
}
+ // rdi=out buffer, rsi=type/flag (not a pointer). Fixed-size write only.
[SysAbiExport(
Nid = "DImz2Ft9E2g",
ExportName = "sceAudioOut2GetSpeakerInfo",
@@ -315,21 +553,134 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")]
public static int AudioOut2GetSpeakerInfo(CpuContext ctx)
{
- var infoAddress = ctx[CpuRegister.Rdi];
+ var infoAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rdi], ctx[CpuRegister.Rdx]);
if (infoAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- Span info = stackalloc byte[0x40];
- info.Clear();
- BinaryPrimitives.WriteUInt32LittleEndian(info[0x00..], 1);
- BinaryPrimitives.WriteUInt32LittleEndian(info[0x04..], 2);
- BinaryPrimitives.WriteUInt32LittleEndian(info[0x08..], 48000);
+ // Same rule as PortGetState — never bulk-write speaker info onto the stack.
+ if (IsGuestStackAddress(infoAddress))
+ {
+ TraceAudioOut2($"get-speaker-info skip-stack out=0x{infoAddress:X}");
+ return SetReturn(ctx, 0);
+ }
- return ctx.Memory.TryWrite(infoAddress, info)
- ? SetReturn(ctx, 0)
- : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ Span info = stackalloc byte[SpeakerInfoSize];
+ info.Clear();
+ BinaryPrimitives.WriteUInt32LittleEndian(info[0x00..], 2);
+ BinaryPrimitives.WriteUInt32LittleEndian(info[0x04..], 48000);
+ BinaryPrimitives.WriteUInt16LittleEndian(info[0x08..], PortStateOutputConnectedPrimary);
+
+ if (!ctx.Memory.TryWrite(infoAddress, info))
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceAudioOut2(
+ $"get-speaker-info out=0x{infoAddress:X} type=0x{ctx[CpuRegister.Rsi]:X} bytes=0x{SpeakerInfoSize:X}");
+ return SetReturn(ctx, 0);
+ }
+
+ // Matches sceAudio3dGetSpeakerArrayMemorySize(uiNumSpeakers, bIs3d): size is
+ // returned directly in rax. Exact channel-scaled body — never a 64K slab.
+ [SysAbiExport(
+ Nid = "G1YOKDJYX2Y",
+ ExportName = "sceAudioOut2GetSpeakerArrayMemorySize",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2GetSpeakerArrayMemorySize(CpuContext ctx)
+ {
+ var numChannels = (uint)ctx[CpuRegister.Rdi];
+ if (numChannels == 0 || numChannels > SpeakerArrayMaxChannels)
+ {
+ numChannels = SpeakerArrayDefaultChannels;
+ }
+
+ var size = ComputeSpeakerArrayBytes(numChannels);
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] audio_out2.speaker-array-get-size rdi=0x{ctx[CpuRegister.Rdi]:X} " +
+ $"rsi=0x{ctx[CpuRegister.Rsi]:X} rdx=0x{ctx[CpuRegister.Rdx]:X} -> 0x{size:X}");
+ ctx[CpuRegister.Rax] = unchecked((ulong)size);
+ return size;
+ }
+
+ [SysAbiExport(
+ Nid = "4BlZurolOAo",
+ ExportName = "sceAudioOut2GetSpeakerArrayCoefficients",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2GetSpeakerArrayCoefficients(CpuContext ctx) =>
+ WriteZeroSpeakerArrayCoefficients(ctx, "coefficients");
+
+ [SysAbiExport(
+ Nid = "28QqMnuuJ9Y",
+ ExportName = "sceAudioOut2GetSpeakerArrayAmbisonicsCoefficients",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2GetSpeakerArrayAmbisonicsCoefficients(CpuContext ctx) =>
+ WriteZeroSpeakerArrayCoefficients(ctx, "ambisonics-coefficients");
+
+ // rdi = param (may share a heap slab with PortGetState/GetSpeakerInfo outs —
+ // do NOT read buffer*/size* from it). rsi = &outHandle, rdx = reserved/size
+ // slot (leave alone), rcx = channels. Always heap-allocate a fresh object.
+ [SysAbiExport(
+ Nid = "+k91hoTuoA8",
+ ExportName = "sceAudioOut2SpeakerArrayCreate",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2SpeakerArrayCreate(CpuContext ctx)
+ {
+ var param = ctx[CpuRegister.Rdi];
+ var outHandleAddress = ctx[CpuRegister.Rsi];
+ var outReservedAddress = ctx[CpuRegister.Rdx];
+ var channels = (uint)ctx[CpuRegister.Rcx];
+ if (channels == 0 || channels > SpeakerArrayMaxChannels)
+ {
+ channels = SpeakerArrayDefaultChannels;
+ }
+
+ if (outHandleAddress == 0)
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ var bytes = ComputeSpeakerArrayBytes(channels);
+ if (!TryAllocateSpeakerArrayMemory(ctx, (ulong)bytes, out var memory) ||
+ !InitializeSpeakerArrayObject(ctx, memory, channels))
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][ERROR] audio_out2.speaker-array-create alloc-failed bytes=0x{bytes:X} " +
+ $"channels={channels}");
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ SpeakerArrays[memory] = 0;
+ // Publish ONLY the out-handle slot. rdx is an adjacent size/reserved
+ // local on GTA's stack — writing it previously fed canary corruption.
+ if (!TryWriteUInt64(ctx, outHandleAddress, memory))
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] audio_out2.speaker-array-create object=0x{memory:X} bytes=0x{bytes:X} " +
+ $"channels={channels} param=0x{param:X} out=0x{outHandleAddress:X} " +
+ $"reserved=0x{outReservedAddress:X} (untouched)");
+
+ ctx[CpuRegister.Rax] = memory;
+ return 0;
+ }
+
+ [SysAbiExport(
+ Nid = "erCWQR5eKiQ",
+ ExportName = "sceAudioOut2SpeakerArrayDestroy",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAudioOut2")]
+ public static int AudioOut2SpeakerArrayDestroy(CpuContext ctx)
+ {
+ SpeakerArrays.TryRemove(ctx[CpuRegister.Rdi], out _);
+ return SetReturn(ctx, 0);
}
[SysAbiExport(
@@ -337,7 +688,11 @@ public static class AudioOut2Exports
ExportName = "sceAudioOut2PortDestroy",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
- public static int AudioOut2PortDestroy(CpuContext ctx) => SetReturn(ctx, 0);
+ public static int AudioOut2PortDestroy(CpuContext ctx)
+ {
+ Ports.TryRemove(ctx[CpuRegister.Rdi], out _);
+ return SetReturn(ctx, 0);
+ }
[SysAbiExport(
Nid = "IaZXJ9M79uo",
@@ -367,6 +722,500 @@ public static class AudioOut2Exports
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
+ private static IHostAudioStream? ResolveContextBackend(ContextState context, out string backendName)
+ {
+ lock (HostBackendGate)
+ {
+ if (PrimaryContextHandle == 0)
+ {
+ PrimaryContextHandle = context.Handle;
+ }
+
+ if (context.Handle == PrimaryContextHandle)
+ {
+ if (PrimaryBackend is null)
+ {
+ try
+ {
+ var audio = HostPlatform.Current.Audio;
+ // Deeper host queue than classic AudioOut: FMOD's bursty
+ // AudioOut2 Push pattern underran a 32 KiB (~171 ms) bed.
+ PrimaryBackend = audio.OpenStereoPcm16Stream(
+ context.Frequency,
+ maxQueuedPcmBytes: 128 * 1024);
+ PrimaryBackendName = audio.BackendName + "-primary";
+ }
+ catch (Exception exception)
+ {
+ PrimaryBackendName = "silent";
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] AudioOut2 primary backend unavailable: {exception.Message}");
+ }
+ }
+
+ backendName = PrimaryBackendName;
+ return PrimaryBackend;
+ }
+
+ if (SecondaryBackend is null)
+ {
+ try
+ {
+ var audio = HostPlatform.Current.Audio;
+ SecondaryBackend = audio.OpenStereoPcm16Stream(
+ context.Frequency,
+ maxQueuedPcmBytes: 128 * 1024);
+ SecondaryBackendName = audio.BackendName + "-secondary";
+ }
+ catch (Exception exception)
+ {
+ SecondaryBackendName = "silent";
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] AudioOut2 secondary backend unavailable: {exception.Message}");
+ }
+ }
+
+ backendName = SecondaryBackendName;
+ return SecondaryBackend;
+ }
+ }
+
+ private static bool TrySubmitContextAudio(CpuContext ctx, ContextState context)
+ {
+ var frames = checked((int)context.GrainSamples);
+ if (frames <= 0)
+ {
+ return false;
+ }
+
+ // Serialize submits per process so ArrayPool buffers stay private; primary
+ // and secondary devices still receive independent PCM (no digital sum).
+ lock (HostSubmitGate)
+ {
+ if (!TryPickMainBed(context.Handle, out var mainPort))
+ {
+ mainPort = null;
+ }
+
+ if (!TryPickAuxBed(context.Handle, out var auxPort))
+ {
+ auxPort = null;
+ }
+
+ if (mainPort is null && auxPort is null)
+ {
+ return false;
+ }
+
+ var mix = ArrayPool.Shared.Rent(frames * 2);
+ var source = ArrayPool.Shared.Rent(frames * 16 * sizeof(float));
+ var output = ArrayPool.Shared.Rent(frames * AudioPcmConversion.OutputFrameSize);
+ try
+ {
+ mix.AsSpan(0, frames * 2).Clear();
+ var mixedPorts = 0;
+ ulong lastPort = 0;
+ uint lastFormat = 0;
+ var lastChannels = 0;
+
+ // At most one MAIN/BGM + one AUX on this context. Menus stay on
+ // MAIN; intro/Bink rides AUX without stacking every bed.
+ for (var bed = 0; bed < 2; bed++)
+ {
+ var port = bed == 0 ? mainPort : auxPort;
+ if (port is null ||
+ !TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat))
+ {
+ continue;
+ }
+
+ var byteLength = checked(frames * ch * bps);
+ if (byteLength <= 0 || byteLength > source.Length)
+ {
+ continue;
+ }
+
+ var sourceSpan = source.AsSpan(0, byteLength);
+ if (!ctx.Memory.TryRead(port.PcmAddress, sourceSpan))
+ {
+ continue;
+ }
+
+ MixPortIntoStereo(
+ sourceSpan,
+ mix.AsSpan(0, frames * 2),
+ frames,
+ ch,
+ bps,
+ isFloat,
+ additive: mixedPorts > 0);
+ mixedPorts++;
+ lastPort = port.Handle;
+ lastFormat = port.DataFormat;
+ lastChannels = ch;
+ }
+
+ if (mixedPorts == 0)
+ {
+ return false;
+ }
+
+ var outputSpan = output.AsSpan(0, frames * AudioPcmConversion.OutputFrameSize);
+ var peak = 0f;
+ var any = false;
+ for (var frame = 0; frame < frames; frame++)
+ {
+ var left = Math.Clamp(mix[frame * 2], -1f, 1f);
+ var right = Math.Clamp(mix[(frame * 2) + 1], -1f, 1f);
+ var framePeak = Math.Max(Math.Abs(left), Math.Abs(right));
+ if (framePeak > peak)
+ {
+ peak = framePeak;
+ }
+
+ if (framePeak > 1e-7f)
+ {
+ any = true;
+ }
+
+ BinaryPrimitives.WriteInt16LittleEndian(
+ outputSpan[(frame * AudioPcmConversion.OutputFrameSize)..],
+ FloatToPcm16(left));
+ BinaryPrimitives.WriteInt16LittleEndian(
+ outputSpan[((frame * AudioPcmConversion.OutputFrameSize) + 2)..],
+ FloatToPcm16(right));
+ }
+
+ if (!any)
+ {
+ return false;
+ }
+
+ // Bind primary only after a real grain so silent early contexts
+ // do not steal the menu device.
+ var backend = ResolveContextBackend(context, out var backendName);
+ if (backend is null)
+ {
+ return false;
+ }
+
+ var n = Interlocked.Increment(ref _submitTraceCount);
+ if (n <= 8 || n % 200 == 0)
+ {
+ TraceAudioOut2(
+ $"context-submit#{n} handle=0x{context.Handle:X} frames={frames} " +
+ $"ports={mixedPorts} lastPort=0x{lastPort:X} format=0x{lastFormat:X} " +
+ $"ch={lastChannels} peak={peak:F4} backend={backendName}");
+ }
+
+ return backend.Submit(outputSpan);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(mix);
+ ArrayPool.Shared.Return(source);
+ ArrayPool.Shared.Return(output);
+ }
+ }
+ }
+
+ private static bool TryPickMainBed(ulong contextHandle, out PortState? chosen)
+ {
+ chosen = null;
+ var chosenScore = int.MinValue;
+ foreach (var port in Ports.Values)
+ {
+ if (port.ContextHandle != contextHandle ||
+ port.PcmAddress == 0 ||
+ IsObjectPort(port.PortType) ||
+ !IsMainOrBgmPort(port.PortType) ||
+ !TryDecodeDataFormat(port.DataFormat, out var channels, out _, out _))
+ {
+ continue;
+ }
+
+ var score = channels switch
+ {
+ 2 => 300,
+ 1 => 200,
+ 8 => 100,
+ _ => 50,
+ };
+ if ((port.PortType & 0xFF) == 0)
+ {
+ score += 20; // MAIN over BGM
+ }
+
+ if (score > chosenScore)
+ {
+ chosenScore = score;
+ chosen = port;
+ }
+ }
+
+ return chosen is not null;
+ }
+
+ private static bool TryPickAuxBed(ulong contextHandle, out PortState? chosen)
+ {
+ chosen = null;
+ var chosenScore = int.MinValue;
+ foreach (var port in Ports.Values)
+ {
+ if (port.ContextHandle != contextHandle ||
+ port.PcmAddress == 0 ||
+ IsObjectPort(port.PortType) ||
+ (port.PortType & 0xFF) != 6 ||
+ !TryDecodeDataFormat(port.DataFormat, out var channels, out _, out _))
+ {
+ continue;
+ }
+
+ var score = channels switch
+ {
+ 2 => 300,
+ 1 => 200,
+ 8 => 100,
+ _ => 50,
+ };
+ if (score > chosenScore)
+ {
+ chosenScore = score;
+ chosen = port;
+ }
+ }
+
+ return chosen is not null;
+ }
+
+ private static bool IsMainOrBgmPort(ushort portType)
+ {
+ var kind = portType & 0xFF;
+ return kind is 0 or 1;
+ }
+
+ private static void MixPortIntoStereo(
+ ReadOnlySpan source,
+ Span mix,
+ int frames,
+ int channels,
+ int bytesPerSample,
+ bool isFloat,
+ bool additive)
+ {
+ var frameSize = channels * bytesPerSample;
+ for (var frame = 0; frame < frames; frame++)
+ {
+ var frameBytes = source.Slice(frame * frameSize, frameSize);
+ float left;
+ float right;
+ if (channels >= 8)
+ {
+ var fl = ReadNormalizedSample(frameBytes, 0, bytesPerSample, isFloat);
+ var fr = ReadNormalizedSample(frameBytes, 1, bytesPerSample, isFloat);
+ var c = ReadNormalizedSample(frameBytes, 2, bytesPerSample, isFloat);
+ var bl = ReadNormalizedSample(frameBytes, 4, bytesPerSample, isFloat);
+ var br = ReadNormalizedSample(frameBytes, 5, bytesPerSample, isFloat);
+ var sl = ReadNormalizedSample(frameBytes, 6, bytesPerSample, isFloat);
+ var sr = ReadNormalizedSample(frameBytes, 7, bytesPerSample, isFloat);
+ const float side = 0.70710678f;
+ left = fl + (c * side) + (bl * side) + (sl * side);
+ right = fr + (c * side) + (br * side) + (sr * side);
+ }
+ else
+ {
+ left = ReadNormalizedSample(frameBytes, 0, bytesPerSample, isFloat);
+ right = channels == 1
+ ? left
+ : ReadNormalizedSample(frameBytes, 1, bytesPerSample, isFloat);
+ }
+
+ if (additive)
+ {
+ mix[frame * 2] += left;
+ mix[(frame * 2) + 1] += right;
+ }
+ else
+ {
+ mix[frame * 2] = left;
+ mix[(frame * 2) + 1] = right;
+ }
+ }
+ }
+
+ private static float ReadNormalizedSample(
+ ReadOnlySpan frame,
+ int channel,
+ int bytesPerSample,
+ bool isFloat)
+ {
+ var sample = frame.Slice(channel * bytesPerSample, bytesPerSample);
+ if (isFloat)
+ {
+ var bits = BinaryPrimitives.ReadInt32LittleEndian(sample);
+ var value = BitConverter.Int32BitsToSingle(bits);
+ return float.IsFinite(value) ? value : 0f;
+ }
+
+ return BinaryPrimitives.ReadInt16LittleEndian(sample) / 32768f;
+ }
+
+ private static short FloatToPcm16(float value)
+ {
+ var scale = value < 0f ? 32768f : short.MaxValue;
+ return (short)Math.Clamp(MathF.Round(value * scale), short.MinValue, short.MaxValue);
+ }
+
+ private static bool IsObjectPort(ushort portType) => (portType & 0xFF00) == 0x0100;
+
+ private static bool TryDecodeDataFormat(
+ uint dataFormat,
+ out int channels,
+ out int bytesPerSample,
+ out bool isFloat)
+ {
+ channels = (int)((dataFormat >> 8) & 0xFF);
+ if (channels == 0)
+ {
+ channels = 2;
+ }
+
+ if (channels is < 1 or > 16)
+ {
+ bytesPerSample = 0;
+ isFloat = false;
+ return false;
+ }
+
+ var dataType = dataFormat & 0x7Fu;
+ isFloat = dataType == 0;
+ bytesPerSample = isFloat ? 4 : dataType == 1 ? 2 : 0;
+ return bytesPerSample != 0;
+ }
+
+ private static int ComputeSpeakerArrayBytes(uint channels) =>
+ SpeakerArrayHeaderSize + (int)(channels * SpeakerArrayEntrySize) + SpeakerArrayScratchBytes;
+
+ private static bool InitializeSpeakerArrayObject(CpuContext ctx, ulong memory, uint channels)
+ {
+ // Header only — never wipe the full GetSize slab (and never touch stack).
+ Span body = stackalloc byte[SpeakerArrayHeaderSize];
+ body.Clear();
+ BinaryPrimitives.WriteUInt32LittleEndian(body[0x00..], (uint)SpeakerArrayHeaderSize);
+ BinaryPrimitives.WriteUInt32LittleEndian(body[0x04..], channels);
+ BinaryPrimitives.WriteUInt32LittleEndian(body[SpeakerArrayDivisorFieldOffset..], SpeakerArrayDefaultDivisor);
+ BinaryPrimitives.WriteUInt32LittleEndian(body[SpeakerArrayResultFieldOffset..], 0);
+ return ctx.Memory.TryWrite(memory, body);
+ }
+
+ // Prefer the high guest arena (0x6000_xxxx). TryAllocateHleData advances
+ // _nextVirtualAddress into the title's direct-memory window (~0x1559_xxxx);
+ // publishing an object there made sceKernelBatchMap(fixed, 0x1559C80000,
+ // 0x20000) return NOT_FOUND and abort RenderThread with int 0x41.
+ // Never mint the old 0x1559C0xxxx "cookie" pointers — they are unmapped and
+ // collide with dmem VAs.
+ private static bool TryAllocateSpeakerArrayMemory(CpuContext ctx, ulong bytes, out ulong memory)
+ {
+ memory = 0;
+ var length = Math.Max(bytes, 0x1000UL);
+
+ if (TryAllocateViaGuestAllocator(ctx, length, 0x1000, out memory) &&
+ IsSafeSpeakerArrayAddress(memory))
+ {
+ return true;
+ }
+
+ if (Kernel.KernelMemoryCompatExports.TryAllocateHleData(ctx, length, 0x1000, out memory) &&
+ IsSafeSpeakerArrayAddress(memory))
+ {
+ return true;
+ }
+
+ memory = 0;
+ return false;
+ }
+
+ private static bool TryAllocateViaGuestAllocator(CpuContext ctx, ulong length, ulong alignment, out ulong memory)
+ {
+ memory = 0;
+ var allocator = ctx.Memory as IGuestMemoryAllocator;
+ if (allocator is null && ctx.Memory is ICpuMemoryWrapper { Inner: IGuestMemoryAllocator inner })
+ {
+ allocator = inner;
+ }
+
+ return allocator is not null && allocator.TryAllocateGuestMemory(length, alignment, out memory);
+ }
+
+ private static bool IsSafeSpeakerArrayAddress(ulong value) =>
+ IsPlausibleGuestObjectPointer(value) &&
+ !IsGuestStackAddress(value) &&
+ !IsDirectMemoryWindowAddress(value);
+
+ // GTA V Enhanced BatchMap fixed dmem VAs observed around 0x1559_xxxx_xxxx.
+ // Keep HLE speaker-array objects out of that window.
+ private static bool IsDirectMemoryWindowAddress(ulong value) =>
+ value >= 0x0000_1400_0000_0000UL && value < 0x0000_1800_0000_0000UL;
+
+ private static bool IsPlausibleGuestObjectPointer(ulong value) =>
+ value >= 0x1000_0000UL &&
+ value != 0x10000UL &&
+ value < 0x0000_8000_0000_0000UL;
+
+ // Windows user stacks sit in 0x00007FFFxxxxxxxx. Never treat those as
+ // heap objects we can bulk-initialize.
+ private static bool IsGuestStackAddress(ulong value) =>
+ value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
+
+ private static ulong ResolveGuestOutBuffer(ulong primary, ulong secondary)
+ {
+ // Accept heap or stack out-buffers (PortGetState legitimately uses both),
+ // but never small integers / size constants.
+ if (IsWritableOutBuffer(primary))
+ {
+ return primary;
+ }
+
+ if (IsWritableOutBuffer(secondary))
+ {
+ return secondary;
+ }
+
+ return 0;
+ }
+
+ private static bool IsWritableOutBuffer(ulong value) =>
+ value != 0 &&
+ value != 0x10000UL &&
+ value >= 0x1000UL &&
+ (IsPlausibleGuestObjectPointer(value) || IsGuestStackAddress(value));
+
+ private static int WriteZeroSpeakerArrayCoefficients(CpuContext ctx, string label)
+ {
+ var destination = ctx[CpuRegister.Rsi];
+ if (destination == 0)
+ {
+ destination = ctx[CpuRegister.Rdx];
+ }
+
+ // Coefficients are large — only wipe real heap objects, never stack.
+ if (destination != 0 &&
+ IsPlausibleGuestObjectPointer(destination) &&
+ !IsGuestStackAddress(destination))
+ {
+ Span zeros = stackalloc byte[SpeakerArrayCoefficientBytes];
+ zeros.Clear();
+ if (!ctx.Memory.TryWrite(destination, zeros))
+ {
+ TraceAudioOut2($"{label} write-failed dest=0x{destination:X}");
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ TraceAudioOut2($"{label} ok dest=0x{destination:X}");
+ return SetReturn(ctx, 0);
+ }
+
private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value)
{
Span buffer = stackalloc byte[sizeof(ulong)];
diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs
index 7626d5f1..85bed025 100644
--- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs
+++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs
@@ -212,6 +212,14 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
+ // Same rule as AudioOut2 PortGetState: never bulk-write onto the caller
+ // stack. Some titles place small locals next to the canary; a full
+ // SceAudioOutPortState write smashes it.
+ if (IsGuestStackAddress(stateAddress))
+ {
+ return ctx.SetReturn(0);
+ }
+
// SceAudioOutPortState: report a connected primary output at full volume
// so pacing/mixing code sees a live port. We do no host rerouting, so
// rerouteCounter and flag stay zero.
@@ -229,6 +237,9 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
+ private static bool IsGuestStackAddress(ulong value) =>
+ value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
+
[SysAbiExport(
Nid = "w3PdaSTSwGE",
ExportName = "sceAudioOutOutputs",
diff --git a/src/SharpEmu.Libs/Diagnostics/LoadProgressDiagnostics.cs b/src/SharpEmu.Libs/Diagnostics/LoadProgressDiagnostics.cs
new file mode 100644
index 00000000..cd4e2d29
--- /dev/null
+++ b/src/SharpEmu.Libs/Diagnostics/LoadProgressDiagnostics.cs
@@ -0,0 +1,155 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+using System.Threading;
+using SharpEmu.Libs.Agc;
+
+namespace SharpEmu.Libs.Diagnostics;
+
+///
+/// Rate-limited progress probes armed when GTA's 'North Audio Update' thread
+/// starts. Used to classify North Yankton freezes (flip vs present vs GPU wait)
+/// without enabling full AGC/VideoOut trace.
+///
+public static class LoadProgressDiagnostics
+{
+ // Keep probes live long enough to cover a stuck Yankton session.
+ private const long ActiveWindowMs = 120_000;
+
+ private static long _armedTicks;
+ private static long _flipSubmitTraceCount;
+ private static long _orderedFlipEnqueueTraceCount;
+ private static long _presentTakenTraceCount;
+ private static long _presentNotTakenTraceCount;
+ private static long _gpuWaitSnapshotTraceCount;
+
+ public static void ArmIfNorthAudioThread(string? threadName)
+ {
+ if (string.IsNullOrEmpty(threadName) ||
+ threadName.IndexOf("North Audio", StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ return;
+ }
+
+ if (Interlocked.CompareExchange(
+ ref _armedTicks,
+ Stopwatch.GetTimestamp(),
+ 0) == 0)
+ {
+ Console.Error.WriteLine(
+ "[LOADER][TRACE] load_progress.armed reason=north_audio " +
+ $"window_ms={ActiveWindowMs}");
+ }
+ }
+
+ public static bool IsActive
+ {
+ get
+ {
+ var armed = Volatile.Read(ref _armedTicks);
+ if (armed == 0)
+ {
+ return false;
+ }
+
+ var elapsedMs = (Stopwatch.GetTimestamp() - armed) * 1000L /
+ Stopwatch.Frequency;
+ return elapsedMs <= ActiveWindowMs;
+ }
+ }
+
+ public static void TraceFlipSubmit(
+ int handle,
+ int bufferIndex,
+ int flipMode,
+ bool submitGpuImage,
+ bool guestImageSubmitted,
+ ulong guestImageAddress,
+ int flipEventCount)
+ {
+ if (!IsActive || !ShouldTrace(ref _flipSubmitTraceCount, out var count))
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] load_progress.flip_submit count={count} " +
+ $"handle={handle} index={bufferIndex} mode={flipMode} " +
+ $"gpu_image={submitGpuImage} submitted={guestImageSubmitted} " +
+ $"addr=0x{guestImageAddress:X16} events={flipEventCount}");
+ }
+
+ public static void TraceOrderedFlipEnqueue(
+ int videoOutHandle,
+ int displayBufferIndex,
+ ulong address,
+ long version,
+ bool enqueued)
+ {
+ if (!IsActive ||
+ !ShouldTrace(ref _orderedFlipEnqueueTraceCount, out var count))
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] load_progress.ordered_flip count={count} " +
+ $"handle={videoOutHandle} index={displayBufferIndex} " +
+ $"addr=0x{address:X16} version={version} enqueued={enqueued}");
+ }
+
+ public static void TracePresentTaken(
+ long presentedSequence,
+ ulong guestImageAddress,
+ long guestImageVersion)
+ {
+ if (!IsActive || !ShouldTrace(ref _presentTakenTraceCount, out var count))
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] load_progress.present_taken count={count} " +
+ $"seq={presentedSequence} addr=0x{guestImageAddress:X16} " +
+ $"version={guestImageVersion}");
+ }
+
+ public static void TracePresentNotTaken(
+ long presentedSequence,
+ bool hasPendingPresentation)
+ {
+ if (!IsActive ||
+ !ShouldTrace(ref _presentNotTakenTraceCount, out var count))
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] load_progress.present_not_taken count={count} " +
+ $"seq={presentedSequence} pending={hasPendingPresentation}");
+ }
+
+ public static void TraceGpuWaitSnapshot(object? memory = null)
+ {
+ if (!IsActive ||
+ !ShouldTrace(ref _gpuWaitSnapshotTraceCount, out var count))
+ {
+ return;
+ }
+
+ var snapshot = GpuWaitRegistry.SnapshotOutstanding(memory);
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] load_progress.gpu_waits count={count} " +
+ $"outstanding={snapshot.Outstanding} latched={snapshot.Latched} " +
+ $"oldest_ms={snapshot.OldestAgeMs} " +
+ $"sample_addr=0x{snapshot.SampleWaitAddress:X16} " +
+ $"sample_queue={snapshot.SampleQueueName ?? "-"}");
+ }
+
+ private static bool ShouldTrace(ref long counter, out long count)
+ {
+ count = Interlocked.Increment(ref counter);
+ return count <= 16 || (count & (count - 1)) == 0;
+ }
+}
diff --git a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
index 5f883fe9..f2a64b6b 100644
--- a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
+++ b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
@@ -89,7 +89,8 @@ internal sealed record GuestVertexBuffer(
uint OffsetBytes,
byte[] Data,
int Length,
- bool Pooled);
+ bool Pooled,
+ bool PerInstance = false);
internal sealed record GuestIndexBuffer(
byte[] Data,
diff --git a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
index e1b1ef4b..818621eb 100644
--- a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
@@ -54,6 +54,7 @@ internal interface IGuestGpuBackend
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1);
bool TryCompileComputeShader(
@@ -108,7 +109,8 @@ internal interface IGuestGpuBackend
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
- ulong shaderAddress = 0);
+ ulong shaderAddress = 0,
+ int baseVertex = 0);
void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -124,7 +126,8 @@ internal interface IGuestGpuBackend
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
- ulong shaderAddress = 0);
+ ulong shaderAddress = 0,
+ int baseVertex = 0);
void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -236,6 +239,12 @@ internal interface IGuestGpuBackend
void SubmitGuestImageWrite(ulong address, byte[] pixels);
+ ///
+ /// Asks the presenter to refresh CPU-dirty guest images on its render/present
+ /// drain. Must not enqueue retained plane copies on the producer path.
+ ///
+ void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue);
+
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs
index 2715eac0..76d0bb55 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs
@@ -218,6 +218,13 @@ internal static class MetalGuestFormats
{
var format = (dataFormat, numberType) switch
{
+ // Early G-buffer / scene targets (R16 + RG32). Keep in sync with
+ // VulkanVideoPresenter.TryDecodeRenderTargetFormat.
+ (2, 0) => MtlPixelFormat.R16Unorm,
+ (2, 1) => MtlPixelFormat.R16Snorm,
+ (2, 4) => MtlPixelFormat.R16Uint,
+ (2, 5) => MtlPixelFormat.R16Sint,
+ (2, 7) => MtlPixelFormat.R16Float,
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
@@ -230,6 +237,8 @@ internal static class MetalGuestFormats
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(10, _) => MtlPixelFormat.Rgba8Unorm,
+ (11, 4) => MtlPixelFormat.Rg32Uint,
+ (11, 5) => MtlPixelFormat.Rg32Sint,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
@@ -258,10 +267,12 @@ internal static class MetalGuestFormats
var outputKind = format switch
{
- MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
- MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
- MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
- MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
+ MtlPixelFormat.R8Uint or MtlPixelFormat.R16Uint or MtlPixelFormat.R32Uint or
+ MtlPixelFormat.Rg16Uint or MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rgba8Uint or
+ MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
+ MtlPixelFormat.R16Sint or MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or
+ MtlPixelFormat.Rg32Sint or MtlPixelFormat.Rgba8Sint or MtlPixelFormat.Rgba16Sint =>
+ Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new MetalRenderTargetFormat(format, outputKind);
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
index deae08ee..cffef1ff 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
@@ -70,6 +70,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
@@ -85,6 +86,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
scalarRegisterBufferIndex,
pixelInputEnable,
pixelInputAddress,
+ pixelInputCntl,
storageBufferOffsetAlignment))
{
return false;
@@ -251,7 +253,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
- ulong shaderAddress = 0) =>
+ ulong shaderAddress = 0,
+ int baseVertex = 0) =>
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
Msl(pixelShader),
textures,
@@ -265,7 +268,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
indexBuffer,
vertexBuffers,
renderState,
- shaderAddress);
+ shaderAddress,
+ baseVertex);
public void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -281,7 +285,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
- ulong shaderAddress = 0) =>
+ ulong shaderAddress = 0,
+ int baseVertex = 0) =>
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
Msl(pixelShader),
textures,
@@ -296,7 +301,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
vertexBuffers,
renderState,
depthTarget,
- shaderAddress);
+ shaderAddress,
+ baseVertex);
public void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -395,6 +401,9 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
+ public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
+ MetalVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
+
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
index 788e6227..5214e501 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Buffers.Binary;
using SharpEmu.Libs.Agc;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
@@ -87,7 +88,8 @@ internal static partial class MetalVideoPresenter
uint InstanceCount,
uint PrimitiveType,
GuestIndexBuffer? IndexBuffer,
- GuestRenderState RenderState);
+ GuestRenderState RenderState,
+ int BaseVertex = 0);
private sealed record OffscreenGuestDraw(
TranslatedGuestDraw Draw,
@@ -245,7 +247,8 @@ internal static partial class MetalVideoPresenter
IReadOnlyList? vertexBuffers,
GuestRenderState? renderState,
GuestDepthTarget? depthTarget,
- ulong shaderAddress)
+ ulong shaderAddress,
+ int baseVertex = 0)
{
if (targets.Count == 0)
{
@@ -293,7 +296,8 @@ internal static partial class MetalVideoPresenter
instanceCount,
primitiveType,
indexBuffer,
- effectiveRenderState),
+ effectiveRenderState,
+ baseVertex),
ToArray(targets),
depthTarget,
PublishTarget: true,
@@ -321,7 +325,8 @@ internal static partial class MetalVideoPresenter
GuestIndexBuffer? indexBuffer,
IReadOnlyList? vertexBuffers,
GuestRenderState? renderState,
- ulong shaderAddress)
+ ulong shaderAddress,
+ int baseVertex = 0)
{
if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0)
{
@@ -348,7 +353,8 @@ internal static partial class MetalVideoPresenter
instanceCount,
primitiveType,
indexBuffer,
- renderState ?? GuestRenderState.Default),
+ renderState ?? GuestRenderState.Default,
+ baseVertex),
[new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)],
depthTarget,
PublishTarget: false,
@@ -981,17 +987,38 @@ internal static partial class MetalVideoPresenter
private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw)
{
- var primitive = GetPrimitiveType(draw.PrimitiveType);
- var vertexCount = draw.PrimitiveType == 0x11 && draw.IndexBuffer is null
- ? 4u
- : draw.VertexCount;
+ var indexed = draw.IndexBuffer is not null;
+ var hasVertexBuffers = draw.VertexBuffers.Length > 0;
+ var primitive = GetPrimitiveType(
+ draw.PrimitiveType,
+ indexed,
+ draw.VertexCount,
+ hasVertexBuffers);
+ var vertexCount = AgcPrimitiveHelpers.GetRectListDrawVertexCount(
+ draw.PrimitiveType,
+ draw.VertexCount,
+ indexed,
+ hasVertexBuffers);
+ var baseVertex = (nuint)Math.Max(draw.BaseVertex, 0);
if (draw.IndexBuffer is { } indexBuffer)
{
var device = MetalNative.Send(encoder, MetalNative.Selector("device"));
var slice = AllocateUpload(
device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset);
- indexBuffer.Data.AsSpan(0, Math.Min(indexBuffer.Length, indexBuffer.Data.Length))
- .CopyTo(slice);
+ var source = indexBuffer.Data.AsSpan(
+ 0,
+ Math.Min(indexBuffer.Length, indexBuffer.Data.Length));
+ // Metal drawIndexed without baseVertex: bake GE_INDX_OFFSET into
+ // the uploaded indices so glyph batches still hit the right verts.
+ if (draw.BaseVertex != 0)
+ {
+ BakeBaseVertexIntoIndices(source, slice, indexBuffer.Is32Bit, draw.BaseVertex);
+ }
+ else
+ {
+ source.CopyTo(slice);
+ }
+
MetalNative.SendDrawIndexedPrimitives(
encoder,
MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
@@ -1012,12 +1039,46 @@ internal static partial class MetalVideoPresenter
encoder,
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
primitive,
- 0,
+ baseVertex,
vertexCount,
Math.Max(draw.InstanceCount, 1));
}
}
+ private static void BakeBaseVertexIntoIndices(
+ ReadOnlySpan source,
+ Span destination,
+ bool is32Bit,
+ int baseVertex)
+ {
+ if (is32Bit)
+ {
+ var count = source.Length / sizeof(uint);
+ for (var index = 0; index < count; index++)
+ {
+ var value = BinaryPrimitives.ReadUInt32LittleEndian(
+ source.Slice(index * sizeof(uint), sizeof(uint)));
+ var adjusted = unchecked((uint)(value + baseVertex));
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ destination.Slice(index * sizeof(uint), sizeof(uint)),
+ adjusted);
+ }
+
+ return;
+ }
+
+ var shortCount = source.Length / sizeof(ushort);
+ for (var index = 0; index < shortCount; index++)
+ {
+ var value = BinaryPrimitives.ReadUInt16LittleEndian(
+ source.Slice(index * sizeof(ushort), sizeof(ushort)));
+ var adjusted = unchecked((ushort)(value + baseVertex));
+ BinaryPrimitives.WriteUInt16LittleEndian(
+ destination.Slice(index * sizeof(ushort), sizeof(ushort)),
+ adjusted);
+ }
+ }
+
private static bool TryGetDrawPipeline(
nint device,
TranslatedGuestDraw draw,
@@ -1226,8 +1287,11 @@ internal static partial class MetalVideoPresenter
? vertexBuffer.Stride
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
- // MTLVertexStepFunction.PerVertex = 1.
- MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1);
+ // MTLVertexStepFunction: PerVertex = 1, PerInstance = 2.
+ MetalNative.Send(
+ layout,
+ MetalNative.Selector("setStepFunction:"),
+ vertexBuffer.PerInstance ? 2 : 1);
}
return descriptor;
@@ -2059,13 +2123,30 @@ internal static partial class MetalVideoPresenter
return 3;
case 6:
- case 0x11:
return 4;
default:
return 3;
}
}
+ private static nuint GetPrimitiveType(
+ uint guestPrimitiveType,
+ bool indexed,
+ uint vertexCount,
+ bool hasVertexBuffers)
+ {
+ if (AgcPrimitiveHelpers.ShouldDrawRectListAsTriangleStrip(
+ guestPrimitiveType,
+ indexed,
+ vertexCount,
+ hasVertexBuffers))
+ {
+ return 4; // MTLPrimitiveTypeTriangleStrip
+ }
+
+ return GetPrimitiveType(guestPrimitiveType);
+ }
+
private static bool IsIntegerFormat(Gen5PixelOutputKind kind) =>
kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs
index d21e6b9d..2adc93e8 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs
@@ -128,6 +128,7 @@ internal static partial class MetalVideoPresenter
private static readonly Dictionary _guestImageVersions = new();
private static readonly Dictionary<(int Handle, int BufferIndex), long>
_lastOrderedGuestFlipVersions = new();
+ private static readonly Dictionary _untrackedGuestImageContentProbes = new();
private static long _orderedGuestFlipVersionSequence;
private static volatile ICpuMemory? _guestMemory;
@@ -162,6 +163,22 @@ internal static partial class MetalVideoPresenter
public static void AttachGuestMemory(ICpuMemory memory) =>
_guestMemory = memory;
+ private static int _cpuWrittenGuestImageSyncRequested;
+ private static long _guestImageCpuSyncTraceCount;
+
+ public static void RequestCpuWrittenGuestImageSync(
+ ulong scopeAddress = 0,
+ ulong scopeByteCount = ulong.MaxValue)
+ {
+ _ = scopeAddress;
+ if (scopeByteCount == 0 || !GuestImageWriteTracker.Enabled)
+ {
+ return;
+ }
+
+ Volatile.Write(ref _cpuWrittenGuestImageSyncRequested, 1);
+ }
+
public static long SubmitOrderedGuestAction(Action action, string debugName)
{
ArgumentNullException.ThrowIfNull(action);
@@ -253,8 +270,110 @@ internal static partial class MetalVideoPresenter
}
}
- public static bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
- IsGuestImageAvailable(address, format, numberType);
+ public static bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType)
+ {
+ var guestFormat = GetGuestTextureFormat(format, numberType);
+ if (address == 0 || guestFormat == 0)
+ {
+ return false;
+ }
+
+ ulong probeByteCount = 0;
+ lock (_gate)
+ {
+ if (!_availableGuestImages.TryGetValue(address, out var availableFormat) ||
+ availableFormat != guestFormat)
+ {
+ return false;
+ }
+
+ if (GuestImageWriteTracker.Enabled)
+ {
+ return true;
+ }
+
+ if (_guestImageExtents.TryGetValue(address, out var extent))
+ {
+ probeByteCount = extent.ByteCount;
+ }
+ }
+
+ return IsUntrackedGuestImageContentUnchanged(address, probeByteCount);
+ }
+
+ private static bool IsUntrackedGuestImageContentUnchanged(ulong address, ulong byteCount)
+ {
+ var memory = _guestMemory;
+ if (memory is null || byteCount == 0)
+ {
+ return true;
+ }
+
+ var probe = ComputeSparseGuestContentProbe(memory, address, byteCount);
+ lock (_gate)
+ {
+ if (!_untrackedGuestImageContentProbes.TryGetValue(address, out var previous))
+ {
+ _untrackedGuestImageContentProbes[address] = probe;
+ return true;
+ }
+
+ if (previous == probe)
+ {
+ return true;
+ }
+
+ _untrackedGuestImageContentProbes[address] = probe;
+ return false;
+ }
+ }
+
+ private static ulong ComputeSparseGuestContentProbe(
+ ICpuMemory memory,
+ ulong address,
+ ulong byteCount)
+ {
+ Span sample = stackalloc byte[64];
+ ulong hash = 14695981039346656037UL;
+ Span offsets = stackalloc ulong[3];
+ var offsetCount = 0;
+ offsets[offsetCount++] = 0;
+ if (byteCount > 128)
+ {
+ offsets[offsetCount++] = byteCount / 2;
+ }
+
+ if (byteCount > 64)
+ {
+ offsets[offsetCount++] = byteCount - 64;
+ }
+
+ for (var o = 0; o < offsetCount; o++)
+ {
+ var offset = offsets[o];
+ if (offset >= byteCount)
+ {
+ continue;
+ }
+
+ var length = (int)Math.Min(64UL, byteCount - offset);
+ if (!memory.TryRead(address + offset, sample[..length]))
+ {
+ hash ^= 0x9E3779B97F4A7C15UL + offset;
+ continue;
+ }
+
+ for (var i = 0; i < length; i++)
+ {
+ hash ^= sample[i];
+ hash *= 1099511628211UL;
+ }
+
+ hash ^= (ulong)length + offset;
+ }
+
+ return hash ^ byteCount;
+ }
public static bool GuestImageWantsInitialData(ulong address)
{
@@ -599,7 +718,7 @@ internal static partial class MetalVideoPresenter
var completedWork = 0;
RecycleCompletedUploadPages();
RecycleCompletedSnapshotResources();
- EvictDirtyCachedDrawTextures();
+ DrainGuestImageCpuSync(device);
try
{
while (completedWork < MaxGuestWorkPerRender)
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs
index 3e8d4a9f..f54fc680 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs
@@ -45,12 +45,11 @@ internal static partial class MetalVideoPresenter
texture.Pitch,
texture.Sampler);
- /// Caching requires the write tracker: without page protection a
- /// guest CPU write would never evict the entry and draws would sample
- /// stale texels forever. Storage textures are shader-writable on the GPU,
- /// so their content identity is not stable either.
+ /// Storage textures are shader-writable on the GPU, so their
+ /// content identity is not stable. CPU rewrites of protected/CPU-backed
+ /// images still evict via DrainGuestImageCpuSync when those addresses
+ /// are dirty.
private static bool IsCacheableDrawTexture(GuestDrawTexture texture) =>
- GuestImageWriteTracker.Enabled &&
texture.Address != 0 &&
!texture.IsStorage &&
!texture.IsFallback;
@@ -69,20 +68,100 @@ internal static partial class MetalVideoPresenter
_ = MetalNative.Send(handle, MetalNative.Selector("retain"));
_drawTextureCache[key] = handle;
_cachedDrawTextureIdentities[key] = 0;
- GuestImageWriteTracker.Track(
- texture.Address,
- (ulong)texture.RgbaPixels.Length,
- Volatile.Read(ref _executingGuestWorkSequence),
- "metal.texture-cache");
+ // No GuestImageWriteTracker.Track: watch-only cache registrations
+ // widened the managed-write hot path. CPU-backed / protected images
+ // own dirty notifications used for eviction.
}
- /// Runs once per drain, before any queued draw executes: a draw
- /// whose texels the submit thread skipped must never resolve to an entry
- /// the guest has since rewritten.
- private static void EvictDirtyCachedDrawTextures()
+ ///
+ /// Single dirty consumer per drain: re-upload CPU-written guest images,
+ /// evict matching draw-texture cache entries, then re-arm once per address.
+ ///
+ private static void DrainGuestImageCpuSync(nint device)
{
+ if (!GuestImageWriteTracker.Enabled)
+ {
+ return;
+ }
+
+ _ = Interlocked.Exchange(ref _cpuWrittenGuestImageSyncRequested, 0);
+
+ HashSet? dirtyAddresses = null;
+ List<(ulong Address, uint Width, uint Height, ulong ByteCount)>? extents = null;
+ lock (_gate)
+ {
+ if (_guestImageExtents.Count > 0)
+ {
+ extents = new(_guestImageExtents.Count);
+ foreach (var entry in _guestImageExtents)
+ {
+ extents.Add((
+ entry.Key,
+ entry.Value.Width,
+ entry.Value.Height,
+ entry.Value.ByteCount));
+ }
+ }
+ }
+
+ var memory = _guestMemory;
+ if (extents is not null)
+ {
+ foreach (var (address, width, height, byteCount) in extents)
+ {
+ if (!GuestImageWriteTracker.ConsumeDirty(address))
+ {
+ continue;
+ }
+
+ (dirtyAddresses ??= []).Add(address);
+ if (memory is null ||
+ byteCount == 0 ||
+ byteCount > 128UL * 1024UL * 1024UL)
+ {
+ continue;
+ }
+
+ GuestImage? image;
+ lock (_gate)
+ {
+ _guestImages.TryGetValue(address, out image);
+ }
+
+ if (image is null)
+ {
+ continue;
+ }
+
+ var pixels = new byte[byteCount];
+ if (!memory.TryRead(address, pixels) ||
+ pixels.AsSpan().IndexOfAnyExcept((byte)0) < 0)
+ {
+ continue;
+ }
+
+ ExecuteGuestImageWrite(
+ device,
+ queue: 0,
+ new GuestImageWrite(address, pixels, 0));
+ if (Interlocked.Increment(ref _guestImageCpuSyncTraceCount) <= 64)
+ {
+ Console.Error.WriteLine(
+ $"[SYNC] cpu-write-drain addr=0x{address:X} {width}x{height}");
+ }
+ }
+ }
+
if (_drawTextureCache.Count == 0)
{
+ if (dirtyAddresses is not null)
+ {
+ foreach (var address in dirtyAddresses)
+ {
+ GuestImageWriteTracker.Rearm(address);
+ }
+ }
+
return;
}
@@ -90,17 +169,17 @@ internal static partial class MetalVideoPresenter
// share one source address (same texels, different samplers), and
// ConsumeDirty clears the flag on first read — evicting only the
// first identity would leave the others sampling stale texels.
- HashSet? dirtyAddresses = null;
foreach (var entry in _drawTextureCache)
{
- if (dirtyAddresses is not null && dirtyAddresses.Contains(entry.Key.Address))
+ var address = entry.Key.Address;
+ if (dirtyAddresses is not null && dirtyAddresses.Contains(address))
{
continue;
}
- if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
+ if (GuestImageWriteTracker.ConsumeDirty(address))
{
- (dirtyAddresses ??= []).Add(entry.Key.Address);
+ (dirtyAddresses ??= []).Add(address);
}
}
@@ -118,6 +197,14 @@ internal static partial class MetalVideoPresenter
_drawTextureCache.Clear();
_cachedDrawTextureIdentities.Clear();
+ if (dirtyAddresses is not null)
+ {
+ foreach (var address in dirtyAddresses)
+ {
+ GuestImageWriteTracker.Rearm(address);
+ }
+ }
+
return;
}
diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
index f2fcef1e..fbe46938 100644
--- a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
@@ -64,6 +64,7 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
@@ -79,6 +80,7 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
scalarRegisterBufferIndex,
pixelInputEnable,
pixelInputAddress,
+ pixelInputCntl,
storageBufferOffsetAlignment))
{
return false;
@@ -179,7 +181,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
- ulong shaderAddress = 0) =>
+ ulong shaderAddress = 0,
+ int baseVertex = 0) =>
VulkanVideoPresenter.SubmitDepthOnlyTranslatedDraw(
Spirv(pixelShader),
textures,
@@ -193,7 +196,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
indexBuffer,
vertexBuffers,
renderState,
- shaderAddress);
+ shaderAddress,
+ baseVertex);
public void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -209,7 +213,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
- ulong shaderAddress = 0) =>
+ ulong shaderAddress = 0,
+ int baseVertex = 0) =>
VulkanVideoPresenter.SubmitOffscreenTranslatedDraw(
Spirv(pixelShader),
textures,
@@ -224,7 +229,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
vertexBuffers,
renderState,
depthTarget,
- shaderAddress);
+ shaderAddress,
+ baseVertex);
public void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
@@ -375,6 +381,9 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
+ public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
+ VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
+
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index 2b1461d6..03fc9f40 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -1,4 +1,4 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
+// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
@@ -1773,6 +1773,113 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
+ // WithPrefix sibling of sceKernelAprResolveFilepathsToIdsAndFileSizes.
+ // Resource streamers resolve relative asset paths against a shared directory
+ // prefix. Without HLE, every call returned the generic NOT_FOUND sentinel
+ // and no asset received a real file id/size. Signature inferred from
+ // observed guest registers (rdi=prefix, rsi=path list, rdx=count, rcx=ids,
+ // r8=sizes, r9=error index): the no-prefix sibling's args shifted right by
+ // one with a leading `const char* prefix`.
+ [SysAbiExport(
+ Nid = "w5fcCG+t31g",
+ ExportName = "sceKernelAprResolveFilepathsWithPrefixToIdsAndFileSizes",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libKernel")]
+ public static int KernelAprResolveFilepathsWithPrefixToIdsAndFileSizes(CpuContext ctx)
+ {
+ var prefixAddress = ctx[CpuRegister.Rdi];
+ var pathListAddress = ctx[CpuRegister.Rsi];
+ var count = ctx[CpuRegister.Rdx];
+ var idsAddress = ctx[CpuRegister.Rcx];
+ var sizesAddress = ctx[CpuRegister.R8];
+ var errorIndexAddress = ctx[CpuRegister.R9];
+ if (pathListAddress == 0 || count == 0 || sizesAddress == 0 || count > 1024)
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
+ }
+
+ var prefix = string.Empty;
+ if (prefixAddress != 0)
+ {
+ _ = TryReadNullTerminatedUtf8(ctx, prefixAddress, MaxGuestStringLength, out prefix);
+ }
+
+ for (ulong i = 0; i < count; i++)
+ {
+ if (idsAddress != 0 &&
+ !TryWriteUInt32Compat(ctx, idsAddress + (i * sizeof(uint)), uint.MaxValue))
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+
+ if (!TryResolveAprFilepath(ctx, pathListAddress, i, out var relativePath))
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+
+ var guestPath = CombineAprPrefixedPath(prefix, relativePath);
+ var hostPath = ResolveGuestPath(guestPath);
+ if (!TryGetAprFileSize(hostPath, out var fileSize))
+ {
+ LogIoTrace("apr_resolve_with_prefix", guestPath, $"host='{hostPath}' index={i} count={count} result=not_found");
+ if (sizesAddress != 0 &&
+ !TryWriteUInt64Compat(ctx, sizesAddress + (i * sizeof(ulong)), 0))
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+
+ if (errorIndexAddress != 0 &&
+ !TryWriteUInt32Compat(ctx, errorIndexAddress, (uint)i))
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+
+ KernelRuntimeCompatExports.TrySetErrno(ctx, 2); // ENOENT
+ ctx[CpuRegister.Rax] = ulong.MaxValue;
+ return -1;
+ }
+
+ var fileId = AmprFileRegistry.Register(guestPath, hostPath);
+ LogIoTrace("apr_resolve_with_prefix", guestPath, $"host='{hostPath}' index={i} count={count} id=0x{fileId:X8} size={fileSize}");
+
+ if (idsAddress != 0 &&
+ !TryWriteUInt32Compat(ctx, idsAddress + (i * sizeof(uint)), fileId))
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+
+ if (!TryWriteUInt64Compat(ctx, sizesAddress + (i * sizeof(ulong)), fileSize))
+ {
+ KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+ }
+
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
+ private static string CombineAprPrefixedPath(string prefix, string relative)
+ {
+ if (string.IsNullOrEmpty(prefix))
+ {
+ return relative;
+ }
+
+ if (string.IsNullOrEmpty(relative))
+ {
+ return prefix;
+ }
+
+ return $"{prefix.TrimEnd('/')}/{relative.TrimStart('/')}";
+ }
+
// The IDs-only sibling of sceKernelAprResolveFilepathsToIdsAndFileSizes.
// Games that stream via AMPR APR call this to turn asset paths into file
// IDs, then hand those IDs to sceAmprAprCommandBufferReadFile. Without it
@@ -7182,28 +7289,41 @@ public static partial class KernelMemoryCompatExports
{
if (fd < 0 || bufferAddress == 0 || requested < 512)
{
+ ctx[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
OpenDirectory? directory;
+ bool isOpenFile;
lock (_fdGate)
{
_openDirectories.TryGetValue(fd, out directory);
+ isOpenFile = directory is null && _openFiles.ContainsKey(fd);
}
if (directory is null)
{
- return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
+ // A regular file fd used with getdents must not look like EOF (rax=0);
+ // that path has caused GTA's fiWriteAsyncDataWorker to treat the fd
+ // integer as a pointer and AV at address 0xB1.
+ var error = isOpenFile
+ ? OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
+ : OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
+ LogIoTrace("getdents", $"fd:{fd}", $"result={(isOpenFile ? "not_directory" : "badfd")}");
+ ctx[CpuRegister.Rax] = unchecked((ulong)(int)error);
+ return (int)error;
}
var currentIndex = directory.NextIndex;
if (basePointerAddress != 0 && !TryWriteUInt64Compat(ctx, basePointerAddress, (ulong)currentIndex))
{
+ ctx[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (currentIndex >= directory.Entries.Length)
{
+ LogIoTrace("getdents", directory.Path, $"fd={fd} result=eof entries={directory.Entries.Length}");
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -7234,11 +7354,16 @@ public static partial class KernelMemoryCompatExports
private static string[] EnumerateDirectoryEntries(string hostPath)
{
- return Directory.EnumerateFileSystemEntries(hostPath)
+ // Real getdents always yields "." / ".." before other names. An empty
+ // host dir previously returned EOF on the first call (rax=0), which
+ // sent GTA's fiWriteAsyncDataWorker down a path that treated the fd as
+ // a pointer (AV at 0xB1 on /download0/cloudcache/).
+ var children = Directory.EnumerateFileSystemEntries(hostPath)
.Select(Path.GetFileName)
.Where(static name => !string.IsNullOrEmpty(name))
- .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
- .ToArray()!;
+ .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase);
+
+ return new[] { ".", ".." }.Concat(children).ToArray()!;
}
private static uint ComputeDirectoryEntryHash(ReadOnlySpan utf8Name)
@@ -7792,6 +7917,47 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
+ private static long _checkReachabilityMissTraceCount;
+
+ // POSIX access(2)-style path reachability probe used by RAGE EnumerationThread.
+ [SysAbiExport(
+ Nid = "uWyW3v98sU4",
+ ExportName = "sceKernelCheckReachability",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libKernel")]
+ public static int KernelCheckReachability(CpuContext ctx)
+ {
+ var pathAddress = ctx[CpuRegister.Rdi];
+ if (pathAddress == 0)
+ {
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
+ }
+
+ if (!TryReadNullTerminatedUtf8(ctx, pathAddress, MaxGuestStringLength, out var guestPath))
+ {
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ }
+
+ var hostPath = ResolveGuestPath(guestPath);
+ if (File.Exists(hostPath) || Directory.Exists(hostPath))
+ {
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
+ // EnumerationThread probes missing mounts during load; surface guest/host
+ // paths so North Yankton stalls are diagnosable without SHARPEMU_LOG_IO.
+ var miss = Interlocked.Increment(ref _checkReachabilityMissTraceCount);
+ if (miss <= 16 || (miss & (miss - 1)) == 0)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] kernel.check_reachability_miss count={miss} " +
+ $"guest='{guestPath}' host='{hostPath}'");
+ }
+
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
+ }
+
[SysAbiExport(
Nid = "fgIsQ10xYVA",
ExportName = "sceKernelChmod",
diff --git a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs
index 1043e306..21f42db7 100644
--- a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs
+++ b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Buffers.Binary;
+using System.Text;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Np;
@@ -8,7 +10,34 @@ namespace SharpEmu.Libs.Np;
public static class NpEntitlementAccessExports
{
private const int BootParamClearSize = 0x20;
- private const int EmptyAddcontInfoListSize = 0x10;
+ private const int EntitlementLabelSize = 17;
+ private const int EntitlementLabelPadding = 3;
+ private const int AddcontEntitlementInfoSize =
+ EntitlementLabelSize + EntitlementLabelPadding + sizeof(uint) + sizeof(uint);
+
+ // libSceNpEntitlementAccess package / download codes observed on Prospero.
+ // package_type 3 = PSAL (license-style add-on); download_status 4 = INSTALLED.
+ private const uint PackageTypePsal = 3;
+ private const uint DownloadStatusInstalled = 4;
+ private const uint SkuFlagFull = 3;
+
+ private const int NpEntitlementAccessErrorParameter = unchecked((int)0x817D0002);
+ private const int NpEntitlementAccessErrorNoEntitlement = unchecked((int)0x817D0007);
+
+ // Offline add-on entitlements titles query through NpEntitlementAccess.
+ // GTA V Enhanced (PPSA04264) gates Story Mode on these three labels; without
+ // them the frontend offers "Buy GTAV Story Mode" despite a full dump.
+ private static readonly AddcontEntitlement[] OwnedAddcontEntitlements =
+ [
+ new("85y-je", PackageTypePsal, DownloadStatusInstalled),
+ new("5d5c48", PackageTypePsal, DownloadStatusInstalled),
+ new("_mtqu6", PackageTypePsal, DownloadStatusInstalled),
+ ];
+
+ private readonly record struct AddcontEntitlement(
+ string Label,
+ uint PackageType,
+ uint DownloadStatus);
[SysAbiExport(
Nid = "jO8DM8oyego",
@@ -19,21 +48,46 @@ public static class NpEntitlementAccessExports
{
var initParam = ctx[CpuRegister.Rdi];
var bootParam = ctx[CpuRegister.Rsi];
-
- if (bootParam != 0)
+ if (initParam == 0 || bootParam == 0)
{
- Span clear = stackalloc byte[BootParamClearSize];
- clear.Clear();
- if (!ctx.Memory.TryWrite(bootParam, clear))
- {
- return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
- }
+ return ctx.SetReturn(NpEntitlementAccessErrorParameter);
+ }
+
+ Span clear = stackalloc byte[BootParamClearSize];
+ clear.Clear();
+ if (!ctx.Memory.TryWrite(bootParam, clear))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceNpEntitlementAccess($"initialize init=0x{initParam:X16} boot=0x{bootParam:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
+ [SysAbiExport(
+ Nid = "lPDO62PpJIA",
+ ExportName = "sceNpEntitlementAccessGetSkuFlag",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceNpEntitlementAccess")]
+ public static int NpEntitlementAccessGetSkuFlag(CpuContext ctx)
+ {
+ var skuFlagAddress = ctx[CpuRegister.Rdi];
+ if (skuFlagAddress == 0)
+ {
+ return ctx.SetReturn(NpEntitlementAccessErrorParameter);
+ }
+
+ Span skuFlagBytes = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(skuFlagBytes, SkuFlagFull);
+ if (!ctx.Memory.TryWrite(skuFlagAddress, skuFlagBytes))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ TraceNpEntitlementAccess($"get_sku_flag -> {SkuFlagFull}");
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
+ }
+
[SysAbiExport(
Nid = "TFyU+KFBv54",
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfoList",
@@ -42,28 +96,53 @@ public static class NpEntitlementAccessExports
public static int NpEntitlementAccessGetAddcontEntitlementInfoList(CpuContext ctx)
{
var listAddress = ctx[CpuRegister.Rsi];
- if (listAddress != 0)
+ var listNum = (uint)ctx[CpuRegister.Rdx];
+ var hitNumAddress = ctx[CpuRegister.Rcx];
+
+ if (hitNumAddress == 0 || (listAddress == 0 && listNum != 0))
{
- Span emptyList = stackalloc byte[EmptyAddcontInfoListSize];
- emptyList.Clear();
- if (!ctx.Memory.TryWrite(listAddress, emptyList))
+ return ctx.SetReturn(NpEntitlementAccessErrorParameter);
+ }
+
+ var hitNum = (uint)OwnedAddcontEntitlements.Length;
+ Span hitNumBytes = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(hitNumBytes, hitNum);
+ if (!ctx.Memory.TryWrite(hitNumAddress, hitNumBytes))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (listAddress != 0 && listNum != 0)
+ {
+ var clearBytes = checked((int)listNum * AddcontEntitlementInfoSize);
+ Span clear = clearBytes <= 512
+ ? stackalloc byte[clearBytes]
+ : new byte[clearBytes];
+ clear.Clear();
+ if (!ctx.Memory.TryWrite(listAddress, clear))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
+
+ var copyNum = Math.Min(listNum, hitNum);
+ for (var index = 0u; index < copyNum; index++)
+ {
+ if (!TryWriteAddcontEntitlementInfo(
+ ctx,
+ listAddress + index * (ulong)AddcontEntitlementInfoSize,
+ OwnedAddcontEntitlements[(int)index]))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
}
TraceNpEntitlementAccess(
- $"get_addcont_info_list service=0x{ctx[CpuRegister.Rdi]:X16} list=0x{listAddress:X16} " +
- $"max={ctx[CpuRegister.Rdx]} flags=0x{ctx[CpuRegister.Rcx]:X16} -> empty");
+ $"get_addcont_info_list service={ctx[CpuRegister.Rdi]} list=0x{listAddress:X16} " +
+ $"list_num={listNum} hit_num={hitNum}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
- private const int EmptyAddcontInfoSize = 0x30;
-
- // Singular lookup of one add-on-content entitlement (rdx = info out). We own
- // no DLC, so report an empty/zeroed info and success — matching the list
- // variant's "no entitlements" answer. Dead Cells calls this while loading a
- // level; leaving it unresolved left the info struct uninitialized.
[SysAbiExport(
Nid = "xddD23+8TfQ",
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfo",
@@ -71,21 +150,84 @@ public static class NpEntitlementAccessExports
LibraryName = "libSceNpEntitlementAccess")]
public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx)
{
+ var labelAddress = ctx[CpuRegister.Rsi];
var infoAddress = ctx[CpuRegister.Rdx];
- if (infoAddress != 0)
+ if (labelAddress == 0 || infoAddress == 0)
{
- Span info = stackalloc byte[EmptyAddcontInfoSize];
- info.Clear();
- if (!ctx.Memory.TryWrite(infoAddress, info))
+ return ctx.SetReturn(NpEntitlementAccessErrorParameter);
+ }
+
+ Span labelBytes = stackalloc byte[EntitlementLabelSize];
+ if (!ctx.Memory.TryRead(labelAddress, labelBytes))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var label = ReadEntitlementLabel(labelBytes);
+ Span info = stackalloc byte[AddcontEntitlementInfoSize];
+ info.Clear();
+ if (!ctx.Memory.TryWrite(infoAddress, info))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ foreach (var entitlement in OwnedAddcontEntitlements)
+ {
+ if (!string.Equals(label, entitlement.Label, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!TryWriteAddcontEntitlementInfo(ctx, infoAddress, entitlement))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
+
+ TraceNpEntitlementAccess(
+ $"get_addcont_info service={ctx[CpuRegister.Rdi]} label='{label}' -> owned");
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
TraceNpEntitlementAccess(
- $"get_addcont_info service=0x{ctx[CpuRegister.Rdi]:X16} label=0x{ctx[CpuRegister.Rsi]:X16} " +
- $"info=0x{infoAddress:X16} -> empty");
- return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
+ $"get_addcont_info service={ctx[CpuRegister.Rdi]} label='{label}' -> no entitlement");
+ return ctx.SetReturn(NpEntitlementAccessErrorNoEntitlement);
+ }
+
+ private static bool TryWriteAddcontEntitlementInfo(
+ CpuContext ctx,
+ ulong address,
+ AddcontEntitlement entitlement)
+ {
+ Span info = stackalloc byte[AddcontEntitlementInfoSize];
+ info.Clear();
+
+ var labelBytes = Encoding.ASCII.GetBytes(entitlement.Label);
+ var labelLength = Math.Min(labelBytes.Length, EntitlementLabelSize - 1);
+ labelBytes.AsSpan(0, labelLength).CopyTo(info);
+
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ info.Slice(EntitlementLabelSize + EntitlementLabelPadding, sizeof(uint)),
+ entitlement.PackageType);
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ info.Slice(
+ EntitlementLabelSize + EntitlementLabelPadding + sizeof(uint),
+ sizeof(uint)),
+ entitlement.DownloadStatus);
+
+ return ctx.Memory.TryWrite(address, info);
+ }
+
+ private static string ReadEntitlementLabel(ReadOnlySpan bytes)
+ {
+ var length = 0;
+ while (length < bytes.Length && bytes[length] != 0)
+ {
+ length++;
+ }
+
+ return length == 0
+ ? string.Empty
+ : Encoding.ASCII.GetString(bytes[..length]);
}
private static void TraceNpEntitlementAccess(string message)
diff --git a/src/SharpEmu.Libs/Np/NpManagerExports.cs b/src/SharpEmu.Libs/Np/NpManagerExports.cs
index 1247683b..ab43b2c0 100644
--- a/src/SharpEmu.Libs/Np/NpManagerExports.cs
+++ b/src/SharpEmu.Libs/Np/NpManagerExports.cs
@@ -86,6 +86,25 @@ public static class NpManagerExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
+ ///
+ /// Accepts the premium-event callback and never invokes it. Offline sessions
+ /// have no PS Plus / premium transitions to deliver, and leaving this NID
+ /// unresolved returns NOT_FOUND which soft-locks titles that register it
+ /// during settings / store probes (GTA V Enhanced).
+ ///
+ [SysAbiExport(
+ Nid = "+yqjab2fUJA",
+ ExportName = "sceNpRegisterPremiumEventCallback",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceNpManager")]
+ public static int NpRegisterPremiumEventCallback(CpuContext ctx)
+ {
+ TraceNp(
+ $"register_premium_event_callback cb=0x{ctx[CpuRegister.Rdi]:X16} " +
+ $"userdata=0x{ctx[CpuRegister.Rsi]:X16}");
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
+ }
+
[SysAbiExport(
Nid = "qQJfO8HAiaY",
ExportName = "sceNpRegisterStateCallbackA",
diff --git a/src/SharpEmu.Libs/Remoteplay/RemoteplayExports.cs b/src/SharpEmu.Libs/Remoteplay/RemoteplayExports.cs
new file mode 100644
index 00000000..e6b9aecf
--- /dev/null
+++ b/src/SharpEmu.Libs/Remoteplay/RemoteplayExports.cs
@@ -0,0 +1,51 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+
+namespace SharpEmu.Libs.Remoteplay;
+
+// SharpEmu does not implement PS5 Remote Play. Titles still probe this API
+// during startup (initialize + connection-status checks while bringing up
+// pad/network subsystems). Without a handler they get ORBIS_GEN2_ERROR_NOT_FOUND
+// instead of a real status code. Reporting a clean "initialized, not connected"
+// state lets callers take their normal no-remote-play path.
+public static class RemoteplayExports
+{
+ private const int StatusDisconnected = 0;
+
+ [SysAbiExport(
+ Nid = "k1SwgkMSOM8",
+ ExportName = "sceRemoteplayInitialize",
+ Target = Generation.Gen5,
+ LibraryName = "libSceRemoteplay")]
+ public static int RemoteplayInitialize(CpuContext ctx) => SetReturn(ctx, 0);
+
+ [SysAbiExport(
+ Nid = "g3PNjYKWqnQ",
+ ExportName = "sceRemoteplayGetConnectionStatus",
+ Target = Generation.Gen5,
+ LibraryName = "libSceRemoteplay")]
+ public static int RemoteplayGetConnectionStatus(CpuContext ctx)
+ {
+ var statusAddress = ctx[CpuRegister.Rsi];
+ if (statusAddress != 0)
+ {
+ Span status = stackalloc byte[0x10];
+ status.Clear();
+ status[0] = StatusDisconnected;
+ if (!ctx.Memory.TryWrite(statusAddress, status))
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ return SetReturn(ctx, 0);
+ }
+
+ private static int SetReturn(CpuContext ctx, int result)
+ {
+ ctx[CpuRegister.Rax] = unchecked((ulong)result);
+ return result;
+ }
+}
diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
index f0ac613c..13169f87 100644
--- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj
+++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
@@ -26,6 +26,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.Libs/Stubs/GameServiceStubs.cs b/src/SharpEmu.Libs/Stubs/GameServiceStubs.cs
index 8a346987..79801c12 100644
--- a/src/SharpEmu.Libs/Stubs/GameServiceStubs.cs
+++ b/src/SharpEmu.Libs/Stubs/GameServiceStubs.cs
@@ -100,6 +100,26 @@ public static class GameServiceStubs
Target = Generation.Gen5, LibraryName = "libSceVoice")]
public static int VoiceSetThreadsParams(CpuContext ctx) => Ok(ctx);
+ [SysAbiExport(Nid = "nXpje5yNpaE", ExportName = "sceVoiceCreatePort",
+ Target = Generation.Gen5, LibraryName = "libSceVoice")]
+ public static int VoiceCreatePort(CpuContext ctx) => OkWithHandle(ctx, CpuRegister.Rdi);
+
+ [SysAbiExport(Nid = "b7kJI+nx2hg", ExportName = "sceVoiceDeletePort",
+ Target = Generation.Gen5, LibraryName = "libSceVoice")]
+ public static int VoiceDeletePort(CpuContext ctx) => Ok(ctx);
+
+ [SysAbiExport(Nid = "oV9GAdJ23Gw", ExportName = "sceVoiceConnectIPortToOPort",
+ Target = Generation.Gen5, LibraryName = "libSceVoice")]
+ public static int VoiceConnectIPortToOPort(CpuContext ctx) => Ok(ctx);
+
+ [SysAbiExport(Nid = "ajVj3QG2um4", ExportName = "sceVoiceDisconnectIPortFromOPort",
+ Target = Generation.Gen5, LibraryName = "libSceVoice")]
+ public static int VoiceDisconnectIPortFromOPort(CpuContext ctx) => Ok(ctx);
+
+ [SysAbiExport(Nid = "Oo0S5PH7FIQ", ExportName = "sceVoiceEnd",
+ Target = Generation.Gen5, LibraryName = "libSceVoice")]
+ public static int VoiceEnd(CpuContext ctx) => Ok(ctx);
+
[SysAbiExport(Nid = "dPj4ZtRcIWk", ExportName = "sceContentSearchInit",
Target = Generation.Gen5, LibraryName = "libSceContentSearch")]
public static int ContentSearchInit(CpuContext ctx) => Ok(ctx);
diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
index fd174b00..290d9bd7 100644
--- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
+++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
@@ -3,6 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.HLE.Host;
+using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel;
@@ -1233,6 +1234,15 @@ public static class VideoOutExports
$"videoout.submit_flip handle={handle} index={bufferIndex} mode={flipMode} " +
$"arg={flipArg} addr=0x{guestImageAddress:X16} submitted={guestImageSubmitted} " +
$"events={flipEventCount} ordered_completion={!submitGpuImage}");
+ LoadProgressDiagnostics.TraceFlipSubmit(
+ handle,
+ bufferIndex,
+ flipMode,
+ submitGpuImage,
+ guestImageSubmitted,
+ guestImageAddress,
+ flipEventCount);
+ LoadProgressDiagnostics.TraceGpuWaitSnapshot(ctx.Memory);
ReportFrameRate(presented: false);
var diagnosticFlipNumber = Interlocked.Increment(ref _diagnosticFlipCount);
if (_holdFirstFlipMilliseconds > 0 && diagnosticFlipNumber == _holdFlipNumber)
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanHostBufferPool.cs b/src/SharpEmu.Libs/VideoOut/VulkanHostBufferPool.cs
index b6d2404f..d71d10c3 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanHostBufferPool.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanHostBufferPool.cs
@@ -18,6 +18,7 @@ internal readonly record struct VulkanHostBufferAllocation(
internal sealed class VulkanHostBufferPool : IDisposable
{
+ private readonly object _gate = new();
private readonly Dictionary>
_available = [];
private readonly Dictionary _allocations = [];
@@ -40,16 +41,19 @@ internal sealed class VulkanHostBufferPool : IDisposable
VulkanHostBufferPoolKey key,
out VulkanHostBufferAllocation allocation)
{
- if (!_available.TryGetValue(key, out var available) ||
- !available.TryPop(out allocation))
+ lock (_gate)
{
- allocation = default;
- return false;
- }
+ if (!_available.TryGetValue(key, out var available) ||
+ !available.TryPop(out allocation))
+ {
+ allocation = default;
+ return false;
+ }
- _cachedHandles.Remove(allocation.Buffer.Handle);
- CachedBytes -= allocation.Key.Capacity;
- return true;
+ _cachedHandles.Remove(allocation.Buffer.Handle);
+ CachedBytes -= allocation.Key.Capacity;
+ return true;
+ }
}
public void Register(VulkanHostBufferAllocation allocation)
@@ -59,51 +63,79 @@ internal sealed class VulkanHostBufferPool : IDisposable
throw new ArgumentException("A pooled buffer must have a valid handle.", nameof(allocation));
}
- _allocations.Add(allocation.Buffer.Handle, allocation);
+ lock (_gate)
+ {
+ _allocations.Add(allocation.Buffer.Handle, allocation);
+ }
}
public bool Return(VkBuffer buffer, DeviceMemory memory)
{
- if (!_allocations.TryGetValue(buffer.Handle, out var allocation) ||
- allocation.Memory.Handle != memory.Handle)
+ VulkanHostBufferAllocation? toDestroy = null;
+ lock (_gate)
{
- return false;
+ if (!_allocations.TryGetValue(buffer.Handle, out var allocation) ||
+ allocation.Memory.Handle != memory.Handle)
+ {
+ return false;
+ }
+
+ if (!_cachedHandles.Add(buffer.Handle))
+ {
+ return true;
+ }
+
+ if (allocation.Key.Capacity > MaximumCachedBytes - CachedBytes)
+ {
+ _cachedHandles.Remove(buffer.Handle);
+ _allocations.Remove(buffer.Handle);
+ toDestroy = allocation;
+ }
+ else
+ {
+ if (!_available.TryGetValue(allocation.Key, out var available))
+ {
+ available = [];
+ _available.Add(allocation.Key, available);
+ }
+
+ available.Push(allocation);
+ CachedBytes += allocation.Key.Capacity;
+ }
}
- if (!_cachedHandles.Add(buffer.Handle))
- {
- return true;
+ // Destroy outside the lock — _destroy calls into Vulkan which may
+ // grab device-level locks, and holding _gate while doing so risks
+ // a lock-ordering deadlock with a thread that holds the device lock
+ // and is waiting on _gate.
+if (toDestroy is { } td)
+{
+ _destroy(td);
+
}
- if (allocation.Key.Capacity > MaximumCachedBytes - CachedBytes)
- {
- _cachedHandles.Remove(buffer.Handle);
- _allocations.Remove(buffer.Handle);
- _destroy(allocation);
- return true;
- }
-
- if (!_available.TryGetValue(allocation.Key, out var available))
- {
- available = [];
- _available.Add(allocation.Key, available);
- }
-
- available.Push(allocation);
- CachedBytes += allocation.Key.Capacity;
return true;
}
public void Dispose()
{
- foreach (var allocation in _allocations.Values)
+ // Snapshot under the lock, destroy outside — _destroy calls into
+ // Vulkan which may grab device-level locks; holding _gate while
+ // doing so risks a lock-ordering deadlock with any thread that
+ // acquires the device lock first and then waits on _gate.
+ List toDestroy;
+ lock (_gate)
+ {
+ toDestroy = new List(_allocations.Values);
+ _allocations.Clear();
+ _available.Clear();
+ _cachedHandles.Clear();
+ CachedBytes = 0;
+ }
+
+ foreach (var allocation in toDestroy)
{
_destroy(allocation);
}
-
- _allocations.Clear();
- _available.Clear();
- _cachedHandles.Clear();
- CachedBytes = 0;
}
}
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index b7dd43e1..9928d675 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -45,7 +45,8 @@ internal sealed record VulkanTranslatedGuestDraw(
uint InstanceCount,
uint PrimitiveType,
GuestIndexBuffer? IndexBuffer,
- GuestRenderState RenderState);
+ GuestRenderState RenderState,
+ int BaseVertex = 0);
internal sealed record VulkanOffscreenGuestDraw(
VulkanTranslatedGuestDraw Draw,
@@ -456,6 +457,21 @@ internal static unsafe class VulkanVideoPresenter
: 100_000_000UL;
private static readonly HashSet _tracedFenceTimeouts = new();
private static long _guestQueueBackpressureTraceCount;
+ private static long _orderedActionFenceWaitTraceCount;
+ private static long _guestQueueStarvationTraceCount;
+ private static long _guestQueueStarvationLastQueued = -1;
+ // Zero-payload sync (ordered actions / flip markers) may exceed the
+ // payload item cap without hard-blocking producers; byte budget still
+ // bounds fat compute/draw snapshots. Override with
+ // SHARPEMU_PENDING_GUEST_SYNC_ITEMS (default 8x payload item cap).
+ private static readonly int _maxPendingGuestSyncItems =
+ int.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_SYNC_ITEMS"),
+ out var pendingGuestSyncItems) && pendingGuestSyncItems > 0
+ ? pendingGuestSyncItems
+ : Math.Max(_maxPendingGuestWorkItems * 8, 4096);
+ private static int _pendingPayloadGuestWorkCount;
+ private static int _pendingSyncGuestWorkCount;
// Diagnostic: skip every compute dispatch (mistranslated compute shaders
// run long / GPU-hang and starve the present). Isolates whether the
// geometry+composite path renders on its own.
@@ -469,7 +485,8 @@ internal static unsafe class VulkanVideoPresenter
uint.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_SKIP_TALL_COMPUTE_Z"), out var z)
? z
: 0;
- private const uint GuestPrimitiveRectList = 0x11;
+ private const uint GuestPrimitiveRectList = AgcPrimitiveHelpers.PrimitiveRectListLegacy;
+ private const uint GuestPrimitiveRectListNgg = AgcPrimitiveHelpers.PrimitiveRectList;
private static readonly object _gate = new();
private readonly record struct PendingGuestWork(
@@ -505,6 +522,10 @@ internal static unsafe class VulkanVideoPresenter
// memory (video frames, streamed atlases) and the upload-known skip in
// draw translation must ship fresh texels instead of reusing the image.
private static readonly Dictionary _cpuBackedUploadGenerations = new();
+ // Sparse guest-memory fingerprints used when the write tracker is off so
+ // upload-known can still skip static UI atlases without missing CPU-updated
+ // video planes (see IsGuestImageUploadKnown).
+ private static readonly Dictionary _untrackedGuestImageContentProbes = new();
private static readonly Dictionary<(int Handle, int BufferIndex), long>
_lastOrderedGuestFlipVersions = new();
private static long _orderedGuestFlipVersionSequence;
@@ -840,11 +861,14 @@ internal static unsafe class VulkanVideoPresenter
_pendingGuestQueueSchedule.Clear();
_pendingGuestQueueCursor = 0;
_pendingGuestWorkCount = 0;
+ _pendingPayloadGuestWorkCount = 0;
+ _pendingSyncGuestWorkCount = 0;
_pendingGuestWorkBytes = 0;
_pendingGuestImagePresentations.Clear();
_guestImageWorkSequences.Clear();
_availableGuestImages.Clear();
_cpuBackedUploadGenerations.Clear();
+ _untrackedGuestImageContentProbes.Clear();
_lastOrderedGuestFlipVersions.Clear();
_orderedGuestFlipVersionSequence = 0;
_pendingGuestImageUploads.Clear();
@@ -1029,7 +1053,8 @@ internal static unsafe class VulkanVideoPresenter
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
- ulong shaderAddress = 0)
+ ulong shaderAddress = 0,
+ int baseVertex = 0)
{
SubmitOffscreenTranslatedDraw(
pixelSpirv,
@@ -1045,7 +1070,8 @@ internal static unsafe class VulkanVideoPresenter
vertexBuffers,
renderState,
depthTarget,
- shaderAddress);
+ shaderAddress,
+ baseVertex);
}
// Manual scans (targets are <= 8) so the per-draw validation does not
@@ -1098,7 +1124,8 @@ internal static unsafe class VulkanVideoPresenter
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
- ulong shaderAddress = 0)
+ ulong shaderAddress = 0,
+ int baseVertex = 0)
{
if (pixelSpirv.Length == 0 ||
targets.Count == 0 ||
@@ -1165,7 +1192,8 @@ internal static unsafe class VulkanVideoPresenter
instanceCount,
primitiveType,
indexBuffer,
- effectiveRenderState),
+ effectiveRenderState,
+ baseVertex),
targets.ToArray(),
depthTarget,
PublishTarget: true,
@@ -1190,7 +1218,8 @@ internal static unsafe class VulkanVideoPresenter
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList? vertexBuffers = null,
GuestRenderState? renderState = null,
- ulong shaderAddress = 0)
+ ulong shaderAddress = 0,
+ int baseVertex = 0)
{
if (pixelSpirv.Length == 0 ||
depthTarget.Address == 0 ||
@@ -1220,7 +1249,8 @@ internal static unsafe class VulkanVideoPresenter
instanceCount,
primitiveType,
indexBuffer,
- renderState ?? GuestRenderState.Default),
+ renderState ?? GuestRenderState.Default,
+ baseVertex),
[new GuestRenderTarget(
Address: 0,
depthTarget.Width,
@@ -1686,7 +1716,7 @@ internal static unsafe class VulkanVideoPresenter
var version = ++_orderedGuestFlipVersionSequence;
_lastOrderedGuestFlipVersions[(videoOutHandle, displayBufferIndex)] = version;
- return EnqueueGuestWorkLocked(
+ var enqueued = EnqueueGuestWorkLocked(
new VulkanOrderedGuestFlip(
version,
videoOutHandle,
@@ -1695,6 +1725,13 @@ internal static unsafe class VulkanVideoPresenter
width,
height,
pitchInPixel)) > 0;
+ SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceOrderedFlipEnqueue(
+ videoOutHandle,
+ displayBufferIndex,
+ address,
+ version,
+ enqueued);
+ return enqueued;
}
}
@@ -1836,6 +1873,26 @@ internal static unsafe class VulkanVideoPresenter
internal static void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
_guestMemory = memory;
+ // Flip/acquire wakes: the render drain always scans dirty guest images;
+ // this flag only forces a full-scope pass after a Sync kick (and is the
+ // seam Agc uses instead of enqueueing plane copies on the producer path).
+ private static int _cpuWrittenGuestImageSyncRequested;
+ private static long _guestImageCpuSyncTraceCount;
+
+ internal static void RequestCpuWrittenGuestImageSync(
+ ulong scopeAddress = 0,
+ ulong scopeByteCount = ulong.MaxValue)
+ {
+ _ = scopeAddress;
+ if (scopeByteCount == 0 ||
+ !SharpEmu.HLE.GuestImageWriteTracker.Enabled)
+ {
+ return;
+ }
+
+ Volatile.Write(ref _cpuWrittenGuestImageSyncRequested, 1);
+ }
+
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
_cachedTextureIdentities.ContainsKey(identity);
@@ -1905,6 +1962,7 @@ internal static unsafe class VulkanVideoPresenter
return false;
}
+ ulong probeByteCount = 0;
lock (_gate)
{
var known =
@@ -1930,8 +1988,98 @@ internal static unsafe class VulkanVideoPresenter
return false;
}
+ if (SharpEmu.HLE.GuestImageWriteTracker.Enabled)
+ {
+ return true;
+ }
+
+ if (_guestImageExtents.TryGetValue(address, out var extent))
+ {
+ probeByteCount = extent.ByteCount;
+ }
+ }
+
+ // Tracker off: availability never goes generation-stale. A sparse
+ // guest-memory probe lets static upload-known textures keep skipping
+ // (Dead Cells menus) while CPU-rewritten planes (GTA Bink) fall through
+ // to a full texel copy when the probe changes.
+ return IsUntrackedGuestImageContentUnchanged(address, probeByteCount);
+ }
+
+ private static bool IsUntrackedGuestImageContentUnchanged(ulong address, ulong byteCount)
+ {
+ var memory = _guestMemory;
+ if (memory is null || byteCount == 0)
+ {
+ // No probe possible — preserve the historical skip so UI stays
+ // cheap; video planes normally have extents registered.
return true;
}
+
+ var probe = ComputeSparseGuestContentProbe(memory, address, byteCount);
+ lock (_gate)
+ {
+ if (!_untrackedGuestImageContentProbes.TryGetValue(address, out var previous))
+ {
+ _untrackedGuestImageContentProbes[address] = probe;
+ return true;
+ }
+
+ if (previous == probe)
+ {
+ return true;
+ }
+
+ _untrackedGuestImageContentProbes[address] = probe;
+ return false;
+ }
+ }
+
+ private static ulong ComputeSparseGuestContentProbe(
+ SharpEmu.HLE.ICpuMemory memory,
+ ulong address,
+ ulong byteCount)
+ {
+ Span sample = stackalloc byte[64];
+ ulong hash = 14695981039346656037UL;
+ Span offsets = stackalloc ulong[3];
+ var offsetCount = 0;
+ offsets[offsetCount++] = 0;
+ if (byteCount > 128)
+ {
+ offsets[offsetCount++] = byteCount / 2;
+ }
+
+ if (byteCount > 64)
+ {
+ offsets[offsetCount++] = byteCount - 64;
+ }
+
+ for (var o = 0; o < offsetCount; o++)
+ {
+ var offset = offsets[o];
+ if (offset >= byteCount)
+ {
+ continue;
+ }
+
+ var length = (int)Math.Min(64UL, byteCount - offset);
+ if (!memory.TryRead(address + offset, sample[..length]))
+ {
+ hash ^= 0x9E3779B97F4A7C15UL + offset;
+ continue;
+ }
+
+ for (var i = 0; i < length; i++)
+ {
+ hash ^= sample[i];
+ hash *= 1099511628211UL;
+ }
+
+ hash ^= (ulong)length + offset;
+ }
+
+ return hash ^ byteCount;
}
public static bool TrySubmitGuestImageBlit(
@@ -2025,6 +2173,15 @@ internal static unsafe class VulkanVideoPresenter
{
var format = (dataFormat, numberType) switch
{
+ // Early G-buffer / scene targets (R16 + RG32). GTA V Enhanced hits
+ // these as color targets; texture decode already knew them.
+ (2, 0) => Format.R16Unorm,
+ (2, 1) => Format.R16SNorm,
+ (2, 2) => Format.R16Uscaled,
+ (2, 3) => Format.R16Sscaled,
+ (2, 4) => Format.R16Uint,
+ (2, 5) => Format.R16Sint,
+ (2, 7) => Format.R16Sfloat,
(4, 4) => Format.R32Uint,
(4, 5) => Format.R32Sint,
(4, 7) => Format.R32Sfloat,
@@ -2037,6 +2194,8 @@ internal static unsafe class VulkanVideoPresenter
(10, 5) => Format.R8G8B8A8Sint,
(10, 9) => Format.R8G8B8A8Srgb,
(10, _) => Format.R8G8B8A8Unorm,
+ (11, 4) => Format.R32G32Uint,
+ (11, 5) => Format.R32G32Sint,
(11, 7) => Format.R32G32Sfloat,
(12, 4) => Format.R16G16B16A16Uint,
(12, 5) => Format.R16G16B16A16Sint,
@@ -2065,10 +2224,11 @@ internal static unsafe class VulkanVideoPresenter
var outputKind = format switch
{
- Format.R8Uint or Format.R32Uint or Format.R16G16Uint or
- Format.R8G8B8A8Uint or Format.R16G16B16A16Uint => Gen5PixelOutputKind.Uint,
- Format.R32Sint or Format.R16G16Sint or Format.R8G8B8A8Sint or
- Format.R16G16B16A16Sint => Gen5PixelOutputKind.Sint,
+ Format.R8Uint or Format.R16Uint or Format.R32Uint or Format.R16G16Uint or
+ Format.R32G32Uint or Format.R8G8B8A8Uint or Format.R16G16B16A16Uint =>
+ Gen5PixelOutputKind.Uint,
+ Format.R16Sint or Format.R32Sint or Format.R16G16Sint or Format.R32G32Sint or
+ Format.R8G8B8A8Sint or Format.R16G16B16A16Sint => Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new VulkanRenderTargetFormat(format, outputKind);
@@ -2376,6 +2536,7 @@ internal static unsafe class VulkanVideoPresenter
private static long EnqueueGuestWorkLocked(object work)
{
var payloadBytes = GetGuestWorkPayloadBytes(work);
+ var isPayloadWork = IsPayloadBearingGuestWork(work);
var backpressureLogged = false;
// Work executed by the render-thread consumer can enqueue an ordered
// same-queue completion marker. Blocking that consumer on the normal
@@ -2383,10 +2544,19 @@ internal static unsafe class VulkanVideoPresenter
// can drain an item to make room for the marker. The consumer has
// already removed the current item, and each immediate follow-up is
// bounded by that item, so admitting it cannot cause unbounded growth.
+ //
+ // Item cap applies to payload-bearing compute/draw/image writes. Zero-
+ // payload ordered sync / flip markers use a higher sync ceiling so
+ // ACQUIRE/label traffic cannot hard-block behind the 512 draw cap.
+ // Byte budget remains the RAM safety valve for fat dispatches
+ // (SHARPEMU_PENDING_GUEST_WORK_MB).
while (!_enqueueAsImmediateQueueFollowup &&
!_closed &&
_thread is not null &&
- (_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
+ ((isPayloadWork &&
+ _pendingPayloadGuestWorkCount >= _maxPendingGuestWorkItems) ||
+ (!isPayloadWork &&
+ _pendingSyncGuestWorkCount >= _maxPendingGuestSyncItems) ||
// Always admit one item when no payload is outstanding, even
// when that single item exceeds the configured budget. This
// avoids an impossible wait while still bounding the normal
@@ -2406,12 +2576,39 @@ internal static unsafe class VulkanVideoPresenter
$"[LOADER][TRACE] vk.guest_queue_backpressure " +
$"count={traceCount} " +
$"queued={_pendingGuestWorkCount} " +
+ $"payload={_pendingPayloadGuestWorkCount}/{_maxPendingGuestWorkItems} " +
+ $"sync={_pendingSyncGuestWorkCount}/{_maxPendingGuestSyncItems} " +
$"logical_queues={_pendingGuestWorkByQueue.Count} " +
$"retained_mb={_pendingGuestWorkBytes / (1024 * 1024)} " +
$"incoming_mb={payloadBytes / (1024 * 1024)} " +
$"budget_mb={_maxPendingGuestWorkBytes / (1024 * 1024)} " +
$"work={work.GetType().Name}" +
- GetGuestWorkPayloadBreakdown(work));
+ GetGuestWorkPayloadBreakdown(work) +
+ FormatGuestQueueBacklogLocked());
+ }
+
+ // Sustained full-queue backpressure is the North Yankton soft-lock
+ // signature: ordered actions pile up and producers block for seconds.
+ var atItemCap = isPayloadWork
+ ? _pendingPayloadGuestWorkCount >= _maxPendingGuestWorkItems
+ : _pendingSyncGuestWorkCount >= _maxPendingGuestSyncItems;
+ if (atItemCap &&
+ _pendingGuestWorkCount != _guestQueueStarvationLastQueued)
+ {
+ _guestQueueStarvationLastQueued = _pendingGuestWorkCount;
+ var starvationCount = Interlocked.Increment(
+ ref _guestQueueStarvationTraceCount);
+ if (starvationCount <= 8 || (starvationCount & (starvationCount - 1)) == 0)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] vk.guest_queue_starvation " +
+ $"count={starvationCount} " +
+ $"queued={_pendingGuestWorkCount} " +
+ $"payload={_pendingPayloadGuestWorkCount}/{_maxPendingGuestWorkItems} " +
+ $"sync={_pendingSyncGuestWorkCount}/{_maxPendingGuestSyncItems} " +
+ $"work={work.GetType().Name}" +
+ FormatGuestQueueBacklogLocked());
+ }
}
}
@@ -2465,6 +2662,15 @@ internal static unsafe class VulkanVideoPresenter
}
RecordGuestImageWritersLocked(work, sequence);
_pendingGuestWorkCount++;
+ if (isPayloadWork)
+ {
+ _pendingPayloadGuestWorkCount++;
+ }
+ else
+ {
+ _pendingSyncGuestWorkCount++;
+ }
+
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
// Wake the embedded render loop parked in WaitForRenderWork; without a
// pulse it only notices new work when its timed wait expires.
@@ -2472,6 +2678,72 @@ internal static unsafe class VulkanVideoPresenter
return sequence;
}
+ private static bool IsPayloadBearingGuestWork(object work) => work is
+ VulkanComputeGuestDispatch or
+ VulkanOffscreenGuestDraw or
+ VulkanGuestImageWrite or
+ VulkanOffscreenColorClear;
+
+ private static bool IsPrioritySyncGuestWork(object work) => work is
+ VulkanOrderedGuestAction or
+ VulkanOrderedGuestFlip or
+ VulkanOrderedGuestFlipWait;
+
+ private static string FormatGuestQueueBacklogLocked()
+ {
+ var typeCounts = new Dictionary(StringComparer.Ordinal);
+ var orderedNameCounts = new Dictionary(StringComparer.Ordinal);
+ foreach (var queue in _pendingGuestWorkByQueue.Values)
+ {
+ for (var node = queue.First; node is not null; node = node.Next)
+ {
+ var work = node.Value.Work;
+ var typeName = work.GetType().Name;
+ typeCounts[typeName] = typeCounts.GetValueOrDefault(typeName) + 1;
+ if (work is VulkanOrderedGuestAction ordered)
+ {
+ var prefix = GetOrderedActionDebugPrefix(ordered.DebugName);
+ orderedNameCounts[prefix] = orderedNameCounts.GetValueOrDefault(prefix) + 1;
+ }
+ }
+ }
+
+ static string TopEntries(Dictionary counts, int limit)
+ {
+ if (counts.Count == 0)
+ {
+ return "-";
+ }
+
+ return string.Join(
+ ',',
+ counts
+ .OrderByDescending(static entry => entry.Value)
+ .Take(limit)
+ .Select(static entry => $"{entry.Key}:{entry.Value}"));
+ }
+
+ return $" types=[{TopEntries(typeCounts, 6)}]" +
+ $" ordered=[{TopEntries(orderedNameCounts, 8)}]";
+ }
+
+ private static string GetOrderedActionDebugPrefix(string debugName)
+ {
+ if (string.IsNullOrEmpty(debugName))
+ {
+ return "(empty)";
+ }
+
+ var span = debugName.AsSpan();
+ var cut = span.IndexOfAny(' ', '=');
+ if (cut <= 0)
+ {
+ cut = Math.Min(span.Length, 48);
+ }
+
+ return debugName[..cut];
+ }
+
private static long GetGuestWorkDependencyLocked(object work)
{
IReadOnlyList textures = work switch
@@ -2532,10 +2804,17 @@ internal static unsafe class VulkanVideoPresenter
private static bool TryTakeGuestWork(
out PendingGuestWork work,
- HashSet? excludedQueues = null)
+ HashSet? excludedQueues = null,
+ bool preferSyncWork = false)
{
lock (_gate)
{
+ if (preferSyncWork &&
+ TryTakePreferredSyncGuestWorkLocked(out work, excludedQueues))
+ {
+ return true;
+ }
+
var queuesToProbe = _pendingGuestQueueSchedule.Count;
while (_pendingGuestQueueSchedule.Count > 0 && queuesToProbe > 0)
{
@@ -2573,19 +2852,7 @@ internal static unsafe class VulkanVideoPresenter
continue;
}
- queue.RemoveFirst();
- _pendingGuestWorkCount--;
- if (queue.Count == 0)
- {
- _pendingGuestWorkByQueue.Remove(queueName);
- _pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor);
- }
- else
- {
- _pendingGuestQueueCursor =
- (_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count;
- }
-
+ RemoveTakenGuestWorkLocked(queueName, queue, advanceScheduleCursor: true);
return true;
}
@@ -2594,6 +2861,94 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ private static bool TryTakePreferredSyncGuestWorkLocked(
+ out PendingGuestWork work,
+ HashSet? excludedQueues)
+ {
+ // When the backlog is elevated, prefer draining ordered sync / flip
+ // markers ahead of heavy compute/draw heads on other logical queues.
+ // Within a queue, FIFO still holds — we only choose among ready heads.
+ for (var index = 0; index < _pendingGuestQueueSchedule.Count; index++)
+ {
+ var queueName = _pendingGuestQueueSchedule[index];
+ if (excludedQueues?.Contains(queueName) == true)
+ {
+ continue;
+ }
+
+ if (!_pendingGuestWorkByQueue.TryGetValue(queueName, out var queue) ||
+ queue.First is not { } first)
+ {
+ continue;
+ }
+
+ work = first.Value;
+ if (!IsPrioritySyncGuestWork(work.Work) ||
+ !IsGuestWorkCompletedLocked(work.RequiredSequence))
+ {
+ continue;
+ }
+
+ RemoveTakenGuestWorkLocked(queueName, queue, advanceScheduleCursor: false);
+ if (_pendingGuestQueueSchedule.Count > 0)
+ {
+ _pendingGuestQueueCursor =
+ (Math.Min(index, _pendingGuestQueueSchedule.Count - 1) + 1) %
+ _pendingGuestQueueSchedule.Count;
+ }
+ else
+ {
+ _pendingGuestQueueCursor = 0;
+ }
+
+ return true;
+ }
+
+ work = default;
+ return false;
+ }
+
+ private static void RemoveTakenGuestWorkLocked(
+ string queueName,
+ LinkedList queue,
+ bool advanceScheduleCursor)
+ {
+ var work = queue.First!.Value;
+ queue.RemoveFirst();
+ _pendingGuestWorkCount--;
+ if (IsPayloadBearingGuestWork(work.Work))
+ {
+ _pendingPayloadGuestWorkCount = Math.Max(0, _pendingPayloadGuestWorkCount - 1);
+ }
+ else
+ {
+ _pendingSyncGuestWorkCount = Math.Max(0, _pendingSyncGuestWorkCount - 1);
+ }
+
+ var scheduleIndex = _pendingGuestQueueSchedule.IndexOf(queueName);
+ if (queue.Count == 0)
+ {
+ _pendingGuestWorkByQueue.Remove(queueName);
+ if (scheduleIndex >= 0)
+ {
+ _pendingGuestQueueSchedule.RemoveAt(scheduleIndex);
+ if (_pendingGuestQueueCursor > scheduleIndex)
+ {
+ _pendingGuestQueueCursor--;
+ }
+ else if (_pendingGuestQueueCursor >= _pendingGuestQueueSchedule.Count)
+ {
+ _pendingGuestQueueCursor = 0;
+ }
+ }
+ }
+ else if (advanceScheduleCursor && scheduleIndex >= 0)
+ {
+ _pendingGuestQueueCursor =
+ (scheduleIndex + 1) % _pendingGuestQueueSchedule.Count;
+ }
+ }
+
private static bool RequeueGuestWorkFront(in PendingGuestWork work)
{
lock (_gate)
@@ -2615,6 +2970,15 @@ internal static unsafe class VulkanVideoPresenter
// the retained-byte total a second time.
queue.AddFirst(work);
_pendingGuestWorkCount++;
+ if (IsPayloadBearingGuestWork(work.Work))
+ {
+ _pendingPayloadGuestWorkCount++;
+ }
+ else
+ {
+ _pendingSyncGuestWorkCount++;
+ }
+
System.Threading.Monitor.PulseAll(_gate);
return true;
}
@@ -3117,6 +3481,7 @@ internal static unsafe class VulkanVideoPresenter
public bool Index32Bit;
public uint VertexCount = 3;
public uint InstanceCount = 1;
+ public int BaseVertex;
public PrimitiveTopology Topology = PrimitiveTopology.TriangleList;
public GuestBlendState[] Blends = [GuestBlendState.Default];
public GuestBlendConstant BlendConstant;
@@ -3211,6 +3576,7 @@ internal static unsafe class VulkanVideoPresenter
public uint NumberFormat;
public uint Stride;
public uint OffsetBytes;
+ public bool PerInstance;
}
private const Format DepthFormat = Format.D32Sfloat;
@@ -5298,7 +5664,57 @@ internal static unsafe class VulkanVideoPresenter
var status = _vk.GetFenceStatus(_device, fence);
if (status == Result.NotReady)
{
- return false;
+ // Never block the drain for seconds on ordered-action visibility.
+ // A multi-second WaitForFences here tanked Dead Cells (~0.4fps)
+ // and soft-locked GTA after intro once the GPU was busy. Sync
+ // item ceiling is high enough that deferring a tick is safe;
+ // take a short probe wait on dedicated render threads only.
+ if (OperatingSystem.IsMacOS())
+ {
+ return false;
+ }
+
+ const ulong orderedVisibilityProbeNs = 2_000_000UL; // 2ms
+ var waitResult = _vk.WaitForFences(
+ _device,
+ 1,
+ &fence,
+ true,
+ orderedVisibilityProbeNs);
+ if (waitResult == Result.Timeout)
+ {
+ return false;
+ }
+
+ if (waitResult == Result.ErrorDeviceLost)
+ {
+ _deviceLost = true;
+ return true;
+ }
+
+ Check(
+ waitResult,
+ $"vkWaitForFences(queue visibility: {_activeGuestQueue.Name})");
+ var waitTrace = Interlocked.Increment(ref _orderedActionFenceWaitTraceCount);
+ if (waitTrace <= 8 || (waitTrace & (waitTrace - 1)) == 0)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] vk.ordered_action_fence_wait " +
+ $"count={waitTrace} queue={_activeGuestQueue.Name} " +
+ $"submission='{target.DebugName}'");
+ }
+
+ CollectCompletedGuestSubmissions(waitForOldest: false);
+ if (_traceVulkanShaderEnabled)
+ {
+ TraceVulkanShader(
+ $"vk.queue_visibility queue={_activeGuestQueue.Name} " +
+ $"submission={_activeGuestQueue.SubmissionId} " +
+ $"target_timeline={targetTimeline} " +
+ $"completed_timeline={_completedTimeline} waited=1");
+ }
+
+ return true;
}
if (status == Result.ErrorDeviceLost)
@@ -6177,9 +6593,18 @@ internal static unsafe class VulkanVideoPresenter
GlobalMemoryBuffers =
new GlobalBufferResource[draw.GlobalMemoryBuffers.Count],
VertexBuffers = new VertexBufferResource[draw.VertexBuffers.Count],
- VertexCount = GetDrawVertexCount(draw.PrimitiveType, draw.VertexCount, draw.IndexBuffer),
+ VertexCount = GetDrawVertexCount(
+ draw.PrimitiveType,
+ draw.VertexCount,
+ draw.IndexBuffer,
+ draw.VertexBuffers.Count > 0),
InstanceCount = Math.Max(draw.InstanceCount, 1),
- Topology = GetPrimitiveTopology(draw.PrimitiveType),
+ BaseVertex = draw.BaseVertex,
+ Topology = GetPrimitiveTopology(
+ draw.PrimitiveType,
+ indexed: draw.IndexBuffer is not null,
+ vertexCount: draw.VertexCount,
+ hasVertexBuffers: draw.VertexBuffers.Count > 0),
Blends = draw.RenderState.Blends.ToArray(),
BlendConstant = draw.RenderState.BlendConstant,
Scissor = draw.RenderState.Scissor,
@@ -6723,33 +7148,47 @@ internal static unsafe class VulkanVideoPresenter
PName = entryPoint,
};
- var vertexBindingDescriptions =
- new VertexInputBindingDescription[resources.VertexBuffers.Length];
+ // One Vulkan binding per unique host buffer and input rate
+ // (fetch_index). Attributes share that binding with
+ // Offset = OffsetBytes.
+ var bindingByBuffer = new Dictionary<(ulong Handle, bool PerInstance), uint>();
+ var vertexBindingList = new List();
var vertexAttributeDescriptions =
new VertexInputAttributeDescription[resources.VertexBuffers.Length];
for (var index = 0; index < resources.VertexBuffers.Length; index++)
{
var vertexBuffer = resources.VertexBuffers[index];
- vertexBindingDescriptions[index] = new VertexInputBindingDescription
+ var bufferKey = (vertexBuffer.Buffer.Handle, vertexBuffer.PerInstance);
+ if (!bindingByBuffer.TryGetValue(bufferKey, out var bindingIndex))
{
- Binding = (uint)index,
- Stride = vertexBuffer.Stride == 0
- ? Math.Max(vertexBuffer.ComponentCount, 1) * sizeof(float)
- : vertexBuffer.Stride,
- InputRate = VertexInputRate.Vertex,
- };
+ bindingIndex = (uint)vertexBindingList.Count;
+ bindingByBuffer[bufferKey] = bindingIndex;
+ vertexBindingList.Add(new VertexInputBindingDescription
+ {
+ Binding = bindingIndex,
+ Stride = vertexBuffer.Stride == 0
+ ? Math.Max(vertexBuffer.ComponentCount, 1) * sizeof(float)
+ : vertexBuffer.Stride,
+ InputRate = vertexBuffer.PerInstance
+ ? VertexInputRate.Instance
+ : VertexInputRate.Vertex,
+ });
+ }
+
vertexAttributeDescriptions[index] = new VertexInputAttributeDescription
{
Location = vertexBuffer.Location,
- Binding = (uint)index,
+ Binding = bindingIndex,
Format = ToVkVertexFormat(
vertexBuffer.DataFormat,
vertexBuffer.NumberFormat,
vertexBuffer.ComponentCount),
- Offset = 0,
+ Offset = vertexBuffer.OffsetBytes,
};
}
+ var vertexBindingDescriptions = vertexBindingList.ToArray();
+
fixed (VertexInputBindingDescription* vertexBindingPointerBase = vertexBindingDescriptions)
fixed (VertexInputAttributeDescription* vertexAttributePointerBase = vertexAttributeDescriptions)
{
@@ -7510,6 +7949,19 @@ internal static unsafe class VulkanVideoPresenter
_pendingAliasImageDumps.Enqueue(guestImage);
}
+ // With the write tracker off, AGC ships real texels again but
+ // aliased guest images created as GPU RTs stay !IsCpuBacked, so
+ // fingerprint refresh never runs and CPU-updated planes (guest
+ // Bink) stay black. Promote only when this draw carried
+ // non-zero guest pixels — same outcome as the old per-draw path.
+ if (!guestImage.IsCpuBacked &&
+ !SharpEmu.HLE.GuestImageWriteTracker.Enabled &&
+ texture.RgbaPixels.Length > 0 &&
+ texture.RgbaPixels.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
+ {
+ guestImage.IsCpuBacked = true;
+ }
+
if (TryCreateCpuTextureRefreshResource(
texture,
guestImage,
@@ -7873,33 +8325,131 @@ internal static unsafe class VulkanVideoPresenter
resource.Cached = true;
_textureCache[key] = resource;
MarkTextureContentCached(key);
- SharpEmu.HLE.GuestImageWriteTracker.Track(
- texture.Address,
- (ulong)(texture.TiledSource?.Length ?? texture.RgbaPixels.Length),
- CurrentGuestWorkSequenceForDiagnostics,
- "vulkan.texture-cache");
+ // Do not Track sampled textures here. Watch-only registration
+ // still poisoned NotifyManagedWrite when it shared the fault
+ // snapshot; protected RTs / CPU-backed images own dirtying.
}
return resource;
}
- private void EvictDirtyCachedTextures()
+ ///
+ /// Single dirty consumer per drain: re-upload CPU-written guest images
+ /// from guest memory, evict matching texture-cache entries, then
+ /// re-arm each address once. Must not enqueue .
+ ///
+ private void DrainGuestImageCpuSync()
{
+ if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled)
+ {
+ return;
+ }
+
+ _ = Interlocked.Exchange(ref _cpuWrittenGuestImageSyncRequested, 0);
+
+ HashSet? dirtyAddresses = null;
+ List<(ulong Address, uint Width, uint Height, ulong ByteCount)>? extents = null;
+ lock (_gate)
+ {
+ if (_guestImageExtents.Count > 0)
+ {
+ extents = new(_guestImageExtents.Count);
+ foreach (var entry in _guestImageExtents)
+ {
+ extents.Add((
+ entry.Key,
+ entry.Value.Width,
+ entry.Value.Height,
+ entry.Value.ByteCount));
+ }
+ }
+ }
+
+ var memory = _guestMemory;
+ if (extents is not null)
+ {
+ foreach (var (address, width, height, byteCount) in extents)
+ {
+ if (!SharpEmu.HLE.GuestImageWriteTracker.ConsumeDirty(address))
+ {
+ continue;
+ }
+
+ (dirtyAddresses ??= []).Add(address);
+ if (memory is null ||
+ byteCount == 0 ||
+ byteCount > 128UL * 1024UL * 1024UL ||
+ !_guestImages.TryGetValue(address, out var target))
+ {
+ continue;
+ }
+
+ // GPU-only RTs often get Dirty via page-overlap. A full
+ // plane read/upload per false dirty destroys Dead Cells FPS
+ // and can stall GTA after intro. Probe 4 KiB first unless
+ // the surface is already known CPU-backed.
+ if (!target.IsCpuBacked)
+ {
+ var probeLen = (int)Math.Min(byteCount, 4096UL);
+ var probe = new byte[probeLen];
+ if (!memory.TryRead(address, probe) ||
+ probe.AsSpan().IndexOfAnyExcept((byte)0) < 0)
+ {
+ continue;
+ }
+
+ target.IsCpuBacked = true;
+ }
+
+ var pixels = new byte[byteCount];
+ if (!memory.TryRead(address, pixels) ||
+ pixels.AsSpan().IndexOfAnyExcept((byte)0) < 0)
+ {
+ continue;
+ }
+
+ UploadGuestImageInitialData(target, pixels);
+ if (Interlocked.Increment(ref _guestImageCpuSyncTraceCount) <= 64)
+ {
+ Console.Error.WriteLine(
+ $"[SYNC] cpu-write-drain addr=0x{address:X} {width}x{height}");
+ }
+ }
+ }
+
if (_textureCache.Count == 0)
{
+ if (dirtyAddresses is not null)
+ {
+ foreach (var address in dirtyAddresses)
+ {
+ SharpEmu.HLE.GuestImageWriteTracker.Rearm(address);
+ }
+ }
+
return;
}
List? evicted = null;
foreach (var entry in _textureCache)
{
- if (SharpEmu.HLE.GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
+ var address = entry.Key.Address;
+ if (dirtyAddresses is not null && dirtyAddresses.Contains(address))
{
(evicted ??= []).Add(entry.Key);
+ continue;
+ }
+
+ if (SharpEmu.HLE.GuestImageWriteTracker.ConsumeDirty(address))
+ {
+ (dirtyAddresses ??= []).Add(address);
+ (evicted ??= []).Add(entry.Key);
}
}
- if (evicted is null && _textureCache.Count <= 2048)
+ if (evicted is null &&
+ dirtyAddresses is null &&
+ _textureCache.Count <= 2048)
{
return;
}
@@ -7910,7 +8460,7 @@ internal static unsafe class VulkanVideoPresenter
// is flushed first so the retire timeline exactly covers every
// recorded reference (nothing may guess which submission lands
// next on the shared queue).
- if (_batchOpen)
+ if (_batchOpen && (evicted is not null || _textureCache.Count > 2048))
{
FlushBatchedGuestCommands();
}
@@ -7925,16 +8475,24 @@ internal static unsafe class VulkanVideoPresenter
_textureCache.Clear();
ClearCachedTextureIdentities();
- return;
+ }
+ else if (evicted is not null)
+ {
+ foreach (var key in evicted)
+ {
+ if (_textureCache.Remove(key, out var resource))
+ {
+ UnmarkTextureContentCached(key);
+ _deferredTextureDestroys.Enqueue((resource, retireTimeline));
+ }
+ }
}
- foreach (var key in evicted!)
+ if (dirtyAddresses is not null)
{
- if (_textureCache.Remove(key, out var resource))
+ foreach (var address in dirtyAddresses)
{
- UnmarkTextureContentCached(key);
- _deferredTextureDestroys.Enqueue((resource, retireTimeline));
- SharpEmu.HLE.GuestImageWriteTracker.Rearm(key.Address);
+ SharpEmu.HLE.GuestImageWriteTracker.Rearm(address);
}
}
}
@@ -8566,6 +9124,7 @@ internal static unsafe class VulkanVideoPresenter
_guestImages.Add(texture.Address, guestImage);
resource.OwnsStorage = false;
resource.GuestImage = guestImage;
+ TrackCpuBackedGuestImage(guestImage);
lock (_gate)
{
if (guestFormat != 0)
@@ -9556,6 +10115,7 @@ internal static unsafe class VulkanVideoPresenter
NumberFormat = guestBuffer.NumberFormat,
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
+ PerInstance = guestBuffer.PerInstance,
};
}
@@ -9573,6 +10133,7 @@ internal static unsafe class VulkanVideoPresenter
NumberFormat = guestBuffer.NumberFormat,
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
+ PerInstance = guestBuffer.PerInstance,
};
private VkBuffer CreateHostBuffer(
@@ -9652,7 +10213,11 @@ internal static unsafe class VulkanVideoPresenter
_vk.FreeMemory(_device, allocation.Memory, null);
}
- private static PrimitiveTopology GetPrimitiveTopology(uint primitiveType) =>
+ private static PrimitiveTopology GetPrimitiveTopology(
+ uint primitiveType,
+ bool indexed,
+ uint vertexCount,
+ bool hasVertexBuffers) =>
primitiveType switch
{
1 => PrimitiveTopology.PointList,
@@ -9660,7 +10225,12 @@ internal static unsafe class VulkanVideoPresenter
3 => PrimitiveTopology.LineStrip,
5 => PrimitiveTopology.TriangleFan,
6 => PrimitiveTopology.TriangleStrip,
- GuestPrimitiveRectList => PrimitiveTopology.TriangleStrip,
+ GuestPrimitiveRectListNgg or GuestPrimitiveRectList
+ when AgcPrimitiveHelpers.ShouldDrawRectListAsTriangleStrip(
+ primitiveType,
+ indexed,
+ vertexCount,
+ hasVertexBuffers) => PrimitiveTopology.TriangleStrip,
_ => PrimitiveTopology.TriangleList,
};
@@ -9674,21 +10244,28 @@ internal static unsafe class VulkanVideoPresenter
private static Format ToVkVertexFormat(
uint dataFormat,
uint numberFormat,
- uint componentCount) =>
- (dataFormat, numberFormat) switch
+ uint componentCount)
+ {
+ var format = (dataFormat, numberFormat) switch
{
(1, 0) => Format.R8Unorm,
(1, 1) => Format.R8SNorm,
+ (1, 2) => Format.R8Uscaled,
+ (1, 3) => Format.R8Sscaled,
(1, 4) => Format.R8Uint,
(1, 5) => Format.R8Sint,
(1, 9) => Format.R8Srgb,
(2, 0) => Format.R16Unorm,
(2, 1) => Format.R16SNorm,
+ (2, 2) => Format.R16Uscaled,
+ (2, 3) => Format.R16Sscaled,
(2, 4) => Format.R16Uint,
(2, 5) => Format.R16Sint,
(2, 7) => Format.R16Sfloat,
(3, 0) => Format.R8G8Unorm,
(3, 1) => Format.R8G8SNorm,
+ (3, 2) => Format.R8G8Uscaled,
+ (3, 3) => Format.R8G8Sscaled,
(3, 4) => Format.R8G8Uint,
(3, 5) => Format.R8G8Sint,
(3, 9) => Format.R8G8Srgb,
@@ -9743,6 +10320,9 @@ internal static unsafe class VulkanVideoPresenter
(14, 4) => Format.R32G32B32A32Uint,
(14, 5) => Format.R32G32B32A32Sint,
(14, 7) => Format.R32G32B32A32Sfloat,
+ // Prospero VertexAttribFormat quirks also seen as buffer formats.
+ (113, _) => Format.R32G32B32A32Sfloat,
+ (121, _) => Format.R16G16Sfloat,
(16, 0) => Format.B5G6R5UnormPack16,
(17, 0) => Format.R5G5B5A1UnormPack16,
(19, 0) => Format.R4G4B4A4UnormPack16,
@@ -9750,6 +10330,38 @@ internal static unsafe class VulkanVideoPresenter
_ => ToVkFloatVertexFormat(componentCount),
};
+ return NarrowVkVertexFormat(format, componentCount);
+ }
+
+ ///
+ /// Narrow a sharp's full VkFormat to the component count the VS fetch
+ /// actually consumes.
+ ///
+ private static Format NarrowVkVertexFormat(Format format, uint usedComponents)
+ {
+ if (usedComponents == 0)
+ {
+ return format;
+ }
+
+ return (format, usedComponents) switch
+ {
+ (Format.R32G32B32A32Sfloat, 1) => Format.R32Sfloat,
+ (Format.R32G32B32A32Sfloat, 2) => Format.R32G32Sfloat,
+ (Format.R32G32B32A32Sfloat, 3) => Format.R32G32B32Sfloat,
+ (Format.R32G32B32Sfloat, 1) => Format.R32Sfloat,
+ (Format.R32G32B32Sfloat, 2) => Format.R32G32Sfloat,
+ (Format.R16G16B16A16Sfloat, 1) => Format.R16Sfloat,
+ (Format.R16G16B16A16Sfloat, 2) => Format.R16G16Sfloat,
+ (Format.R8G8B8A8Unorm, 1) => Format.R8Unorm,
+ (Format.R8G8B8A8Unorm, 2) => Format.R8G8Unorm,
+ (Format.R8G8B8A8SNorm, 2) => Format.R8G8SNorm,
+ (Format.R8G8B8A8Uint, 1) => Format.R8Uint,
+ (Format.R8G8B8A8Uint, 2) => Format.R8G8Uint,
+ _ => format,
+ };
+ }
+
private static Format ToVkFloatVertexFormat(uint componentCount) =>
componentCount switch
{
@@ -9776,15 +10388,13 @@ internal static unsafe class VulkanVideoPresenter
private static uint GetDrawVertexCount(
uint primitiveType,
uint vertexCount,
- GuestIndexBuffer? indexBuffer)
- {
- if (primitiveType == GuestPrimitiveRectList && indexBuffer is null)
- {
- return 4;
- }
-
- return vertexCount;
- }
+ GuestIndexBuffer? indexBuffer,
+ bool hasVertexBuffers) =>
+ AgcPrimitiveHelpers.GetRectListDrawVertexCount(
+ primitiveType,
+ vertexCount,
+ indexed: indexBuffer is not null,
+ hasVertexBuffers);
private static BlendFactor ToVkBlendFactor(uint factor) =>
factor switch
@@ -12447,15 +13057,7 @@ internal static unsafe class VulkanVideoPresenter
depth));
}
- if (target.Width <= 1920 && target.Height <= 1080)
- {
- SharpEmu.HLE.GuestImageWriteTracker.Track(
- target.Address,
- (ulong)target.Width * target.Height * depth *
- GetTextureBytesPerPixel(target.Format),
- CurrentGuestWorkSequenceForDiagnostics,
- "vulkan.render-target");
- }
+ TrackCpuBackedGuestImage(retained);
if (_traceGuestImageEvents)
{
@@ -12606,15 +13208,7 @@ internal static unsafe class VulkanVideoPresenter
depth));
}
- if (target.Width <= 1920 && target.Height <= 1080)
- {
- SharpEmu.HLE.GuestImageWriteTracker.Track(
- target.Address,
- (ulong)target.Width * target.Height * depth *
- GetTextureBytesPerPixel(target.Format),
- CurrentGuestWorkSequenceForDiagnostics,
- "vulkan.render-target");
- }
+ TrackCpuBackedGuestImage(resource);
if (_traceGuestImageEvents)
{
@@ -12626,6 +13220,30 @@ internal static unsafe class VulkanVideoPresenter
return resource;
}
+ 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)
+ {
+ return;
+ }
+
+ var depth = Math.Max(image.Depth, 1u);
+ SharpEmu.HLE.GuestImageWriteTracker.Track(
+ image.Address,
+ GetVulkanImageByteCount(
+ image.Format,
+ image.Width,
+ image.Height,
+ depth),
+ CurrentGuestWorkSequenceForDiagnostics,
+ "vulkan.render-target");
+ }
+
private (RenderPass RenderPass, RenderPass InitialRenderPass, Framebuffer Framebuffer)
CreateRenderPassAndFramebuffer(
Format format,
@@ -13487,7 +14105,7 @@ internal static unsafe class VulkanVideoPresenter
CollectCompletedGuestSubmissions(waitForOldest: false);
}
- EvictDirtyCachedTextures();
+ DrainGuestImageCpuSync();
var completedWork = 0;
HashSet? deferredOrderedQueues = null;
var workBudgetTicks = _renderWorkBudgetTicks;
@@ -13495,6 +14113,12 @@ internal static unsafe class VulkanVideoPresenter
? System.Diagnostics.Stopwatch.GetTimestamp() + workBudgetTicks
: long.MaxValue;
var workLimit = _maxGuestWorkPerRender;
+ // Prefer ordered sync / flip heads while the queue is elevated so
+ // label wakeups are not starved behind fat compute/draw items on
+ // sibling logical queues.
+ var preferSyncWork =
+ _pendingSyncGuestWorkCount >= (_maxPendingGuestWorkItems / 2) ||
+ _pendingGuestWorkCount >= (_maxPendingGuestWorkItems / 2);
while (completedWork < workLimit)
{
// Never block the macOS main thread waiting for in-flight GPU
@@ -13509,7 +14133,10 @@ internal static unsafe class VulkanVideoPresenter
break;
}
- if (!TryTakeGuestWork(out var pendingGuestWork, deferredOrderedQueues))
+ if (!TryTakeGuestWork(
+ out var pendingGuestWork,
+ deferredOrderedQueues,
+ preferSyncWork))
{
break;
}
@@ -13606,8 +14233,21 @@ internal static unsafe class VulkanVideoPresenter
if (deferGuestWork)
{
- deferredOrderedQueues ??= new HashSet(StringComparer.Ordinal);
- deferredOrderedQueues.Add(pendingGuestWork.Queue.Name);
+ // macOS: non-blocking defer — exclude this logical queue for
+ // the rest of the tick so sibling queues can still progress.
+ // Windows/Linux already blocked in WaitForFences; excluding
+ // the only busy queue ends the drain immediately and leaves
+ // OrderedGuestAction stacked. Leave the item at the front and
+ // end this Render; the next tick retries after GPU progress.
+ if (OperatingSystem.IsMacOS())
+ {
+ deferredOrderedQueues ??= new HashSet(StringComparer.Ordinal);
+ deferredOrderedQueues.Add(pendingGuestWork.Queue.Name);
+ }
+ else
+ {
+ break;
+ }
}
if (workStart != 0)
@@ -13660,8 +14300,14 @@ internal static unsafe class VulkanVideoPresenter
{
// A render-loop tick with no newer flip is normal. Warn only when
// an actual queued presentation is waiting on unfinished guest work.
+ var hasPendingPresentation =
+ HasPendingGuestPresentation(_presentedSequence);
+ SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentNotTaken(
+ _presentedSequence,
+ hasPendingPresentation);
+ SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot();
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
- HasPendingGuestPresentation(_presentedSequence) &&
+ hasPendingPresentation &&
_presentNotTakenLoggedSequence != _presentedSequence)
{
_presentNotTakenLoggedSequence = _presentedSequence;
@@ -13686,6 +14332,10 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentTaken(
+ presentation.Sequence,
+ presentation.GuestImageAddress,
+ presentation.GuestImageVersion);
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
{
Console.Error.WriteLine(
@@ -15679,12 +16329,14 @@ internal static unsafe class VulkanVideoPresenter
resources.IndexBuffer,
0,
resources.Index32Bit ? IndexType.Uint32 : IndexType.Uint16);
+ // vertexOffset = ResolveVertexOffset(GE_INDX_OFFSET).
+ // GTA UI glyphs use relative indices + a nonzero base vertex.
_vk.CmdDrawIndexed(
_commandBuffer,
resources.VertexCount,
resources.InstanceCount,
0,
- 0,
+ resources.BaseVertex,
0);
}
else
@@ -15693,7 +16345,7 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer,
resources.VertexCount,
resources.InstanceCount,
- 0,
+ (uint)Math.Max(resources.BaseVertex, 0),
0);
}
@@ -16825,6 +17477,7 @@ internal static unsafe class VulkanVideoPresenter
{
_availableGuestImages.Clear();
_cpuBackedUploadGenerations.Clear();
+ _untrackedGuestImageContentProbes.Clear();
_lastOrderedGuestFlipVersions.Clear();
}
DestroySwapchainResources();
diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs
index c036752a..cb0678bc 100644
--- a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs
+++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs
@@ -74,6 +74,7 @@ public static partial class Gen5MslTranslator
int pixelRenderTargetSlot = 0,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1) =>
TryCompilePixelShader(
state,
@@ -87,6 +88,7 @@ public static partial class Gen5MslTranslator
initialScalarBufferIndex,
pixelInputEnable,
pixelInputAddress,
+ pixelInputCntl,
storageBufferOffsetAlignment);
public static bool TryCompilePixelShader(
@@ -101,6 +103,7 @@ public static partial class Gen5MslTranslator
int initialScalarBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1)
{
shader = default!;
@@ -161,7 +164,8 @@ public static partial class Gen5MslTranslator
pixelOutputBindings: outputs,
imageBindingBase: imageBindingBase,
pixelInputEnable: pixelInputEnable,
- pixelInputAddress: pixelInputAddress);
+ pixelInputAddress: pixelInputAddress,
+ pixelInputCntl: pixelInputCntl);
return context.TryCompile(out shader, out error);
}
@@ -254,6 +258,7 @@ public static partial class Gen5MslTranslator
private readonly int _imageBindingBase;
private readonly uint _pixelInputEnable;
private readonly uint _pixelInputAddress;
+ private readonly uint[] _pixelInputCntl;
private readonly Dictionary _imageBindingByPc = [];
private readonly Dictionary _bufferBindingByPc = [];
private readonly List<(bool IsStorage, string ComponentKind)> _imageKinds = [];
@@ -294,12 +299,20 @@ public static partial class Gen5MslTranslator
int imageBindingBase = 0,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
int requiredVertexOutputCount = 0)
{
_pixelOutputBindings = pixelOutputBindings ?? [];
_imageBindingBase = imageBindingBase;
_pixelInputEnable = pixelInputEnable;
_pixelInputAddress = pixelInputAddress;
+ _pixelInputCntl = new uint[32];
+ for (uint i = 0; i < 32u; i++)
+ {
+ _pixelInputCntl[i] = pixelInputCntl is not null && i < (uint)pixelInputCntl.Count
+ ? pixelInputCntl[(int)i]
+ : i;
+ }
_requiredVertexOutputCount = requiredVertexOutputCount;
_stage = stage;
_state = state;
@@ -592,7 +605,13 @@ public static partial class Gen5MslTranslator
source.AppendLine(" float4 sharpemu_frag_coord [[position]];");
foreach (var attribute in _pixelAttributes)
{
- source.AppendLine($" float4 attr{attribute} [[user(locn{attribute})]];");
+ var cntl = attribute < (uint)_pixelInputCntl.Length
+ ? _pixelInputCntl[attribute]
+ : attribute;
+ var location = cntl & 0x1Fu;
+ var flat = (cntl & 0x400u) != 0 ? ", flat" : string.Empty;
+ source.AppendLine(
+ $" float4 attr{attribute} [[user(locn{location}){flat}]];");
}
source.AppendLine("};");
diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs
index 05e7bb0e..6b7425a1 100644
--- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs
+++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs
@@ -31,6 +31,7 @@ public static partial class Gen5SpirvTranslator
int pixelRenderTargetSlot = 0,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1) =>
TryCompilePixelShader(
state,
@@ -44,6 +45,7 @@ public static partial class Gen5SpirvTranslator
initialScalarBufferIndex,
pixelInputEnable,
pixelInputAddress,
+ pixelInputCntl,
storageBufferOffsetAlignment);
public static bool TryCompilePixelShader(
@@ -58,6 +60,7 @@ public static partial class Gen5SpirvTranslator
int initialScalarBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
ulong storageBufferOffsetAlignment = 1)
{
if (outputs.Count > 8 || outputs.Any(output => output.GuestSlot > 7))
@@ -99,6 +102,7 @@ public static partial class Gen5SpirvTranslator
initialScalarBufferIndex,
pixelInputEnable: pixelInputEnable,
pixelInputAddress: pixelInputAddress,
+ pixelInputCntl: pixelInputCntl,
storageBufferOffsetAlignment: storageBufferOffsetAlignment);
return context.TryCompile(out shader, out error);
}
@@ -231,6 +235,7 @@ public static partial class Gen5SpirvTranslator
private readonly int _initialScalarBufferIndex;
private readonly uint _pixelInputEnable;
private readonly uint _pixelInputAddress;
+ private readonly uint[] _pixelInputCntl;
private readonly ulong _storageBufferOffsetAlignment;
private readonly List _interfaces = [];
private readonly Dictionary _pixelInputs = [];
@@ -343,6 +348,7 @@ public static partial class Gen5SpirvTranslator
int initialScalarBufferIndex,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
+ IReadOnlyList? pixelInputCntl = null,
int requiredVertexOutputCount = 0,
uint waveLaneCount = 32,
ulong storageBufferOffsetAlignment = 1)
@@ -368,6 +374,13 @@ public static partial class Gen5SpirvTranslator
_initialScalarBufferIndex = initialScalarBufferIndex;
_pixelInputEnable = pixelInputEnable;
_pixelInputAddress = pixelInputAddress;
+ _pixelInputCntl = new uint[32];
+ for (uint i = 0; i < 32u; i++)
+ {
+ _pixelInputCntl[i] = pixelInputCntl is not null && i < (uint)pixelInputCntl.Count
+ ? pixelInputCntl[(int)i]
+ : i;
+ }
if (storageBufferOffsetAlignment == 0 ||
(storageBufferOffsetAlignment & (storageBufferOffsetAlignment - 1)) != 0 ||
storageBufferOffsetAlignment > uint.MaxValue)
@@ -1242,7 +1255,18 @@ public static partial class Gen5SpirvTranslator
var variable = _module.AddGlobalVariable(
inputVec4Pointer,
SpirvStorageClass.Input);
- _module.AddDecoration(variable, SpirvDecoration.Location, attribute);
+ // VINTRP ATTR selects the PS input slot. SPI_PS_INPUT_CNTL
+ // maps that slot to a VS parameter export location.
+ var cntl = attribute < (uint)_pixelInputCntl.Length
+ ? _pixelInputCntl[attribute]
+ : attribute;
+ var location = cntl & 0x1Fu;
+ _module.AddDecoration(variable, SpirvDecoration.Location, location);
+ if ((cntl & 0x400u) != 0)
+ {
+ _module.AddDecoration(variable, SpirvDecoration.Flat);
+ }
+
_pixelInputs.Add(attribute, variable);
_interfaces.Add(variable);
}
diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs
index ad46c715..758eaf88 100644
--- a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs
+++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs
@@ -313,7 +313,8 @@ public sealed record Gen5VertexInputBinding(
uint OffsetBytes,
byte[] Data,
int DataLength,
- bool DataPooled);
+ bool DataPooled,
+ bool PerInstance = false);
public sealed record Gen5ShaderEvaluation(
IReadOnlyList InitialScalarRegisters,
diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs
index d7d67759..7c980cb2 100644
--- a/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs
+++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs
@@ -893,8 +893,11 @@ public static class Gen5ShaderScalarEvaluator
Gen5ShaderInstruction instruction,
Gen5BufferMemoryControl control,
BufferDescriptor descriptor) =>
+ // AGC embedded fetch is BufferLoadFormat/TBufferLoadFormat with idxen.
+ // offen is allowed: the constant/scalar offset folds into OffsetBytes
+ // (UI glyph shaders use this shape). Rejecting offen left those loads
+ // as live SSBOs and dropped vertex attributes.
control.IndexEnabled &&
- !control.OffsetEnabled &&
control.DwordCount is >= 1 and <= 4 &&
descriptor.BaseAddress != 0 &&
descriptor.Stride != 0 &&
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcFusedShaderTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcFusedShaderTests.cs
new file mode 100644
index 00000000..136fa18e
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcFusedShaderTests.cs
@@ -0,0 +1,330 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Agc;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+// sceAgcGetFusedShaderSize (dolOmWH+huQ) and sceAgcFuseShaderHalves (fd5Bp5tGTgo)
+// join a GS or HS front/back shader half pair into one shader: the fused header
+// is the back half retyped, the back half's SH registers become the fused
+// register image, and the front half contributes its program address
+// (SPI_SHADER_PGM_LO/HI_ES) and checksum registers.
+public sealed class AgcFusedShaderTests
+{
+ private const ulong BaseAddress = 0x1_0000_0000;
+ private const int MemorySize = 0x4000;
+
+ private const ulong FrontShader = BaseAddress + 0x0000;
+ private const ulong BackShader = BaseAddress + 0x0100;
+ private const ulong FusedShader = BaseAddress + 0x0200;
+ private const ulong FrontRegisters = BaseAddress + 0x0300;
+ private const ulong BackRegisters = BaseAddress + 0x0400;
+ private const ulong FrontSpecials = BaseAddress + 0x0500;
+ private const ulong BackSpecials = BaseAddress + 0x0600;
+ private const ulong Scratch = BaseAddress + 0x0700;
+ private const ulong SizeResult = BaseAddress + 0x0800;
+
+ private const ulong ShaderUserDataOffset = 0x08;
+ private const ulong ShaderCodeOffset = 0x10;
+ private const ulong ShaderShRegistersOffset = 0x20;
+ private const ulong ShaderSpecialsOffset = 0x28;
+ private const ulong ShaderTypeOffset = 0x5A;
+ private const ulong ShaderNumShRegistersOffset = 0x5C;
+
+ private const byte GsFront = 4;
+ private const byte HsFront = 5;
+ private const byte GsBack = 6;
+ private const byte HsBack = 7;
+
+ private const ulong FrontCode = 0x0000_1234_5678_9A00;
+
+ [Fact]
+ public void GetFusedShaderSize_GsPair_ReportsBackRegisterBytes()
+ {
+ var (memory, ctx) = CreateGsPair();
+
+ ctx[CpuRegister.Rdi] = SizeResult;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ var result = AgcExports.GetFusedShaderSize(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(5UL * 8UL, ReadUInt64(memory, SizeResult));
+ Assert.Equal(4UL, ReadUInt64(memory, SizeResult + 8));
+ }
+
+ [Fact]
+ public void GetFusedShaderSize_MismatchedHalves_Rejects()
+ {
+ var (memory, ctx) = CreateGsPair();
+ WriteByte(memory, BackShader + ShaderTypeOffset, HsBack);
+
+ ctx[CpuRegister.Rdi] = SizeResult;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ var result = AgcExports.GetFusedShaderSize(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
+ Assert.Equal(0UL, ReadUInt64(memory, SizeResult));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_GsPairWithScratch_BuildsFusedShader()
+ {
+ var (memory, ctx) = CreateGsPair();
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+
+ // Fused header is the back half with type kGs, cleared user data, and
+ // registers relocated to the scratch image.
+ Assert.Equal(2, ReadByte(memory, FusedShader + ShaderTypeOffset));
+ Assert.Equal(0UL, ReadUInt64(memory, FusedShader + ShaderUserDataOffset));
+ Assert.Equal(Scratch, ReadUInt64(memory, FusedShader + ShaderShRegistersOffset));
+ Assert.Equal(5, ReadByte(memory, FusedShader + ShaderNumShRegistersOffset));
+ Assert.Equal(
+ ReadUInt64(memory, BackShader + ShaderCodeOffset),
+ ReadUInt64(memory, FusedShader + ShaderCodeOffset));
+
+ // The back half's own register image is untouched.
+ Assert.Equal(0x1111_1111u, ReadUInt32(memory, BackRegisters + 4));
+
+ // Scratch image: LO_ES points at the front code, HI_ES keeps its upper
+ // bits, both checksum occurrences carry the front half's values.
+ Assert.Equal(0xC8u, ReadUInt32(memory, Scratch + 0));
+ Assert.Equal(0x3456_789Au, ReadUInt32(memory, Scratch + 4));
+ Assert.Equal(0xC9u, ReadUInt32(memory, Scratch + 8));
+ Assert.Equal(0xAABB_CC12u, ReadUInt32(memory, Scratch + 12));
+ Assert.Equal(0xAAAA_0001u, ReadUInt32(memory, Scratch + 20));
+ Assert.Equal(0xBBBB_0002u, ReadUInt32(memory, Scratch + 28));
+ Assert.Equal(0x5555_5555u, ReadUInt32(memory, Scratch + 36));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_NoScratch_PatchesBackRegistersInPlace()
+ {
+ var (memory, ctx) = CreateGsPair();
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = 0;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(BackRegisters, ReadUInt64(memory, FusedShader + ShaderShRegistersOffset));
+ Assert.Equal(0x3456_789Au, ReadUInt32(memory, BackRegisters + 4));
+ Assert.Equal(0xAABB_CC12u, ReadUInt32(memory, BackRegisters + 12));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_WaveSizeMismatch_Rejects()
+ {
+ var (memory, ctx) = CreateGsPair();
+ WriteUInt32(memory, BackSpecials + 0x08 + 4, 0u);
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
+ Assert.Equal(0, ReadByte(memory, FusedShader + ShaderTypeOffset));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_HsPair_PatchesLoLs()
+ {
+ var (memory, ctx) = CreateGsPair();
+ WriteByte(memory, FrontShader + ShaderTypeOffset, HsFront);
+ WriteByte(memory, BackShader + ShaderTypeOffset, HsBack);
+ WriteUInt32(memory, BackRegisters + 0, 0x148u);
+ WriteUInt32(memory, BackRegisters + 8, 0x149u);
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(3, ReadByte(memory, FusedShader + ShaderTypeOffset));
+ Assert.Equal(0x3456_789Au, ReadUInt32(memory, Scratch + 4));
+ // Checksum grafting is a geometry-pair behavior; the HS image keeps its own values.
+ Assert.Equal(0x1111_0001u, ReadUInt32(memory, Scratch + 20));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_MissingSpecials_SkipsWaveSizeGate()
+ {
+ var (memory, ctx) = CreateGsPair();
+ // The divergence the mismatch test rejects passes when a half lacks specials.
+ WriteUInt64(memory, FrontShader + ShaderSpecialsOffset, 0);
+ WriteUInt32(memory, BackSpecials + 0x08 + 4, 0u);
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(2, ReadByte(memory, FusedShader + ShaderTypeOffset));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_ProgramRegisterAbsent_LeavesImageUntouched()
+ {
+ var (memory, ctx) = CreateGsPair();
+ WriteByte(memory, FrontShader + ShaderTypeOffset, HsFront);
+ WriteByte(memory, BackShader + ShaderTypeOffset, HsBack);
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ // No LO_LS entry in the back image: the fuse still succeeds and the
+ // scratch copy stays verbatim.
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(3, ReadByte(memory, FusedShader + ShaderTypeOffset));
+ Assert.Equal(0x1111_1111u, ReadUInt32(memory, Scratch + 4));
+ Assert.Equal(0xAABB_CC77u, ReadUInt32(memory, Scratch + 12));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_UnpairedProgramRegister_LeavesImageUntouched()
+ {
+ var (memory, ctx) = CreateGsPair();
+ WriteByte(memory, FrontShader + ShaderTypeOffset, HsFront);
+ WriteByte(memory, BackShader + ShaderTypeOffset, HsBack);
+ WriteUInt32(memory, BackRegisters + 0, 0x148u);
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ // LO_LS is present but the next entry is not HI_LS, so the patch is skipped.
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(0x1111_1111u, ReadUInt32(memory, Scratch + 4));
+ }
+
+ [Fact]
+ public void FuseShaderHalves_ProgramRegisterAtImageEnd_LeavesImageUntouched()
+ {
+ var (memory, ctx) = CreateGsPair();
+ WriteByte(memory, FrontShader + ShaderTypeOffset, HsFront);
+ WriteByte(memory, BackShader + ShaderTypeOffset, HsBack);
+ WriteUInt32(memory, BackRegisters + 4 * 8, 0x148u);
+
+ ctx[CpuRegister.Rdi] = FusedShader;
+ ctx[CpuRegister.Rsi] = FrontShader;
+ ctx[CpuRegister.Rdx] = BackShader;
+ ctx[CpuRegister.Rcx] = Scratch;
+ var result = AgcExports.FuseShaderHalves(ctx);
+
+ // The hi half of the pair would sit past the register image.
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(0x5555_5555u, ReadUInt32(memory, Scratch + 36));
+ }
+
+ private static (FakeCpuMemory Memory, CpuContext Ctx) CreateGsPair()
+ {
+ var memory = new FakeCpuMemory(BaseAddress, MemorySize);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ WriteByte(memory, FrontShader + ShaderTypeOffset, GsFront);
+ WriteUInt64(memory, FrontShader + ShaderCodeOffset, FrontCode);
+ WriteUInt64(memory, FrontShader + ShaderShRegistersOffset, FrontRegisters);
+ WriteUInt64(memory, FrontShader + ShaderSpecialsOffset, FrontSpecials);
+ WriteByte(memory, FrontShader + ShaderNumShRegistersOffset, 4);
+
+ WriteByte(memory, BackShader + ShaderTypeOffset, GsBack);
+ WriteUInt64(memory, BackShader + ShaderCodeOffset, 0x0000_0BAD_F00D_BE00);
+ WriteUInt64(memory, BackShader + ShaderShRegistersOffset, BackRegisters);
+ WriteUInt64(memory, BackShader + ShaderSpecialsOffset, BackSpecials);
+ WriteUInt64(memory, BackShader + ShaderUserDataOffset, 0xDEAD_BEEF);
+ WriteByte(memory, BackShader + ShaderNumShRegistersOffset, 5);
+
+ // Back image: ES program address pair, two checksum slots, one bystander.
+ WriteRegister(memory, BackRegisters, 0, 0xC8u, 0x1111_1111u);
+ WriteRegister(memory, BackRegisters, 1, 0xC9u, 0xAABB_CC77u);
+ WriteRegister(memory, BackRegisters, 2, 0x80u, 0x1111_0001u);
+ WriteRegister(memory, BackRegisters, 3, 0x80u, 0x1111_0002u);
+ WriteRegister(memory, BackRegisters, 4, 0x10u, 0x5555_5555u);
+
+ // Front image: GS RSRC pair as shipped, then the checksum values to graft.
+ WriteRegister(memory, FrontRegisters, 0, 0x8Au, 0x0123_4567u);
+ WriteRegister(memory, FrontRegisters, 1, 0x8Bu, 0x89AB_CDEFu);
+ WriteRegister(memory, FrontRegisters, 2, 0x80u, 0xAAAA_0001u);
+ WriteRegister(memory, FrontRegisters, 3, 0x80u, 0xBBBB_0002u);
+
+ // VGT_SHADER_STAGES_EN register pairs with the GS wave32 enable bit set on both halves.
+ WriteUInt32(memory, FrontSpecials + 0x08, 0x1F1u);
+ WriteUInt32(memory, FrontSpecials + 0x08 + 4, 1u << 22);
+ WriteUInt32(memory, BackSpecials + 0x08, 0x1F1u);
+ WriteUInt32(memory, BackSpecials + 0x08 + 4, 1u << 22);
+
+ return (memory, ctx);
+ }
+
+ private static void WriteRegister(FakeCpuMemory memory, ulong array, int index, uint offset, uint value)
+ {
+ WriteUInt32(memory, array + (ulong)index * 8, offset);
+ WriteUInt32(memory, array + (ulong)index * 8 + 4, value);
+ }
+
+ private static void WriteByte(FakeCpuMemory memory, ulong address, byte value)
+ {
+ Span buffer = [value];
+ Assert.True(memory.TryWrite(address, buffer));
+ }
+
+ private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
+ {
+ Span buffer = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
+ Assert.True(memory.TryWrite(address, buffer));
+ }
+
+ private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
+ {
+ Span buffer = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
+ Assert.True(memory.TryWrite(address, buffer));
+ }
+
+ private static byte ReadByte(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[1];
+ Assert.True(memory.TryRead(address, buffer));
+ return buffer[0];
+ }
+
+ private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[sizeof(uint)];
+ Assert.True(memory.TryRead(address, buffer));
+ return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
+ }
+
+ private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[sizeof(ulong)];
+ Assert.True(memory.TryRead(address, buffer));
+ return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcPrimStateHullVariantTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcPrimStateHullVariantTests.cs
new file mode 100644
index 00000000..a9b87863
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcPrimStateHullVariantTests.cs
@@ -0,0 +1,66 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Agc;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+public sealed class AgcPrimStateHullVariantTests
+{
+ private const ulong BaseAddress = 0x1_0000_0000;
+ private const ulong CxRegistersAddress = BaseAddress + 0x100;
+ private const ulong UcRegistersAddress = BaseAddress + 0x200;
+ private const ulong HullStateAddress = BaseAddress + 0x300;
+ private const ulong GeometryShaderAddress = BaseAddress + 0x400;
+ private const ulong SpecialsAddress = BaseAddress + 0x500;
+
+ // Tessellation pipelines pass a non-null hull-state block; the
+ // geometry-derived register writes must still happen instead of an
+ // INVALID_ARGUMENT that leaves the caller's register storage as garbage.
+ [Fact]
+ public void CreatePrimState_AcceptsHullStateBlock()
+ {
+ var memory = new FakeCpuMemory(BaseAddress, 0x1000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ memory.TryWrite(GeometryShaderAddress + 0x5A, new byte[] { 2 });
+ WriteUInt64(memory, GeometryShaderAddress + 0x28, SpecialsAddress);
+
+ // Specials: {register, value} pairs at GeCntl 0x00, StagesEn 0x08,
+ // GsOutPrimType 0x20, GeUserVgprEn 0x28.
+ WriteUInt64(memory, SpecialsAddress + 0x00, 0x0000_0111_0000_0222UL);
+ WriteUInt64(memory, SpecialsAddress + 0x08, 0x0000_0333_0000_0444UL);
+ WriteUInt64(memory, SpecialsAddress + 0x20, 0x0000_0555_0000_0666UL);
+ WriteUInt64(memory, SpecialsAddress + 0x28, 0x0000_0777_0000_0888UL);
+
+ ctx[CpuRegister.Rdi] = CxRegistersAddress;
+ ctx[CpuRegister.Rsi] = UcRegistersAddress;
+ ctx[CpuRegister.Rdx] = HullStateAddress;
+ ctx[CpuRegister.Rcx] = GeometryShaderAddress;
+ ctx[CpuRegister.R8] = 0x11;
+
+ Assert.Equal(
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ AgcExports.CreatePrimState(ctx));
+
+ Assert.NotEqual(0u, ReadUInt32(memory, CxRegistersAddress));
+ Assert.Equal(0x11u, ReadUInt32(memory, UcRegistersAddress + 20));
+ }
+
+ private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
+ {
+ Span bytes = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+
+ private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
+ {
+ Span value = stackalloc byte[sizeof(uint)];
+ Assert.True(memory.TryRead(address, value));
+ return BinaryPrimitives.ReadUInt32LittleEndian(value);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcRectListIndexHelpersTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcRectListIndexHelpersTests.cs
new file mode 100644
index 00000000..bfabddee
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcRectListIndexHelpersTests.cs
@@ -0,0 +1,95 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.Libs.Agc;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+///
+/// Regression coverage for the AGC UI path: index8 expansion and rect-list
+/// vertex counts / topology selection.
+///
+public sealed class AgcRectListIndexHelpersTests
+{
+ [Theory]
+ [InlineData(0u, 0u, 2)] // Index16
+ [InlineData(1u, 1u, 4)] // Index32
+ [InlineData(2u, 2u, 1)] // Index8
+ [InlineData(0x402u, 2u, 1)] // UC 0x400|size -> Index8
+ public void IndexType_DecodeAndStride_MatchProspero(uint raw, uint expected, int stride)
+ {
+ var decoded = AgcIndexHelpers.Decode(raw);
+ Assert.Equal((AgcIndexHelpers.ProsperoIndexType)expected, decoded);
+ Assert.Equal(stride, AgcIndexHelpers.GetGuestStrideBytes(decoded));
+ }
+
+ [Fact]
+ public void ExpandIndex8ToU16_PreservesValues()
+ {
+ ReadOnlySpan source = [0x00, 0x01, 0xFF, 0x7F];
+ Span destination = stackalloc byte[8];
+ AgcIndexHelpers.ExpandIndex8ToU16(source, destination);
+ Assert.Equal(0, BinaryPrimitives.ReadUInt16LittleEndian(destination[..2]));
+ Assert.Equal(1, BinaryPrimitives.ReadUInt16LittleEndian(destination.Slice(2, 2)));
+ Assert.Equal(255, BinaryPrimitives.ReadUInt16LittleEndian(destination.Slice(4, 2)));
+ Assert.Equal(127, BinaryPrimitives.ReadUInt16LittleEndian(destination.Slice(6, 2)));
+ }
+
+ [Theory]
+ // NGG single-rect UI (DualSense): expand even when VBs are present
+ [InlineData(7u, 3u, false, true, 4u)]
+ [InlineData(7u, 1u, false, false, 4u)]
+ [InlineData(7u, 4u, false, true, 4u)]
+ // Indexed / multi-vert auto: keep guest count (loading video)
+ [InlineData(7u, 3u, true, false, 3u)]
+ [InlineData(7u, 6u, false, true, 6u)]
+ [InlineData(7u, 4u, true, true, 4u)]
+ [InlineData(0x11u, 3u, false, false, 4u)]
+ [InlineData(0x11u, 6u, false, false, 6u)]
+ [InlineData(4u, 3u, false, false, 3u)]
+ public void RectListDrawVertexCount_MatchesExpansion(
+ uint primitiveType,
+ uint vertexCount,
+ bool indexed,
+ bool hasVertexBuffers,
+ uint expected)
+ {
+ Assert.Equal(
+ expected,
+ AgcPrimitiveHelpers.GetRectListDrawVertexCount(
+ primitiveType,
+ vertexCount,
+ indexed,
+ hasVertexBuffers));
+ }
+
+ [Theory]
+ [InlineData(7u, false, 3u, true, true)]
+ [InlineData(7u, false, 6u, true, false)]
+ [InlineData(7u, true, 3u, false, false)]
+ [InlineData(0x11u, false, 3u, true, true)]
+ [InlineData(0x11u, true, 3u, false, false)]
+ public void RectListTriangleStrip_MatchesGuards(
+ uint primitiveType,
+ bool indexed,
+ uint vertexCount,
+ bool hasVertexBuffers,
+ bool expected) =>
+ Assert.Equal(
+ expected,
+ AgcPrimitiveHelpers.ShouldDrawRectListAsTriangleStrip(
+ primitiveType,
+ indexed,
+ vertexCount,
+ hasVertexBuffers));
+
+ [Theory]
+ [InlineData(7u, (uint)AgcPrimitiveHelpers.GsOutputPrimitiveType.Rectangle2D)]
+ [InlineData(0x11u, (uint)AgcPrimitiveHelpers.GsOutputPrimitiveType.RectList)]
+ [InlineData(4u, (uint)AgcPrimitiveHelpers.GsOutputPrimitiveType.Triangles)]
+ [InlineData(1u, (uint)AgcPrimitiveHelpers.GsOutputPrimitiveType.Points)]
+ public void PrimitiveTypeToGsOut_MatchesProspero(uint primitiveType, uint expected) =>
+ Assert.Equal(expected, AgcPrimitiveHelpers.PrimitiveTypeToGsOut(primitiveType));
+}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcVertexMetadataTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcVertexMetadataTests.cs
new file mode 100644
index 00000000..32d1703e
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcVertexMetadataTests.cs
@@ -0,0 +1,293 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Agc;
+using SharpEmu.ShaderCompiler;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+///
+/// Coverage for AGC attrib-table → BufferFormat merge and semantic indexing.
+///
+public sealed class AgcVertexMetadataTests
+{
+ [Fact]
+ public void BuildVertexResources_UsesSemanticNotHardwareMappingAsAttribIndex()
+ {
+ // input_semantics[0]: semantic=1, hardware_mapping=4, size=2
+ // If hardware_mapping were wrongly used as the attrib index, we'd read
+ // attrib[4] instead of attrib[1] and get the wrong format/offset.
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ // ShaderSemantic word: semantic=1, hw_mapping=4, size_in_elements=2
+ WriteUInt32(memory, semanticsAddress, 1u | (4u << 8) | (2u << 16));
+
+ // attrib[0] unused garbage
+ WriteUInt32(memory, attribTable, 0xDEAD_BEEFu);
+ // attrib[1]: buffer=0, format=k16_16Float(29), offset=8, fetch=0
+ WriteUInt32(memory, attribTable + 4, 0u | (29u << 5) | (8u << 14));
+
+ // V# at buffer table[0]: base=sharpBase, stride=16
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(
+ memory,
+ bufferTable + 4,
+ (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[8] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[9] = (uint)(attribTable >> 32);
+ scalars[10] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[11] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 10,
+ VertexAttribReg: 8,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: semanticsAddress);
+
+ Assert.True(
+ AgcVertexMetadata.TryBuildVertexResourcesFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ out var resources));
+ Assert.Single(resources);
+ Assert.Equal(1u, resources[0].Semantic);
+ Assert.Equal(4u, resources[0].HardwareMapping);
+ Assert.Equal(8u, resources[0].OffsetBytes);
+ Assert.Equal(5u, resources[0].DataFormat); // R16G16
+ Assert.Equal(7u, resources[0].NumberFormat); // Float
+ Assert.Equal(2u, resources[0].ComponentCount);
+ Assert.Equal(sharpBase, resources[0].SharpBase);
+ Assert.False(resources[0].PerInstance);
+ }
+
+ [Fact]
+ public void MergeVertexInputs_OverlaysFormatWithoutRebasingCapture()
+ {
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ WriteUInt32(memory, semanticsAddress, 0u | (0u << 8) | (4u << 16));
+ // format k8_8_8_8UNorm(56), offset=12
+ WriteUInt32(memory, attribTable, 0u | (56u << 5) | (12u << 14));
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(memory, bufferTable + 4, (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[4] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[5] = (uint)(attribTable >> 32);
+ scalars[6] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[7] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 6,
+ VertexAttribReg: 4,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: semanticsAddress);
+
+ var data = new byte[64];
+ var discovered = new[]
+ {
+ new Gen5VertexInputBinding(
+ Pc: 0x40,
+ Location: 0,
+ ComponentCount: 4,
+ DataFormat: 14, // wrong IR guess
+ NumberFormat: 7,
+ BaseAddress: sharpBase,
+ Stride: 16,
+ OffsetBytes: 0,
+ Data: data,
+ DataLength: data.Length,
+ DataPooled: false),
+ };
+
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ discovered);
+ Assert.Single(merged);
+ Assert.Equal(0u, merged[0].Location);
+ Assert.Equal(sharpBase, merged[0].BaseAddress);
+ Assert.Same(data, merged[0].Data);
+ Assert.Equal(10u, merged[0].DataFormat); // RGBA8
+ Assert.Equal(0u, merged[0].NumberFormat); // Unorm
+ Assert.Equal(12u, merged[0].OffsetBytes);
+ Assert.Equal(0x40u, merged[0].Pc);
+ }
+
+ [Fact]
+ public void MergeVertexInputs_AcceptsVertexAttribFormatEnums()
+ {
+ // Attrib tables store VertexAttribFormat (227 = rgba8 unorm), not
+ // BufferFormat (56). Without conversion the format patch is a no-op.
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ WriteUInt32(memory, semanticsAddress, 0u | (0u << 8) | (4u << 16));
+ WriteUInt32(memory, attribTable, 0u | (227u << 5) | (12u << 14)); // VertexAttribFormat
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(memory, bufferTable + 4, (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[4] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[5] = (uint)(attribTable >> 32);
+ scalars[6] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[7] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 6,
+ VertexAttribReg: 4,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: semanticsAddress);
+
+ var data = new byte[64];
+ var discovered = new[]
+ {
+ new Gen5VertexInputBinding(
+ 0x40, 0, 4, 14, 7, sharpBase, 16, 12, data, data.Length, false),
+ };
+
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ discovered);
+ Assert.Equal(10u, merged[0].DataFormat);
+ Assert.Equal(0u, merged[0].NumberFormat);
+ Assert.Equal(12u, merged[0].OffsetBytes);
+ }
+
+ [Fact]
+ public void MergeVertexInputs_MatchesInterleavedAttrsByOffsetNotBareBase()
+ {
+ // Both attributes share SharpBase. Matching by base alone would assign
+ // the color format to position (video/UI regression).
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ // semantic0 → pos float4 @0; semantic1 → color rgba8 @12
+ WriteUInt32(memory, semanticsAddress, 0u | (0u << 8) | (4u << 16));
+ WriteUInt32(memory, semanticsAddress + 4, 1u | (4u << 8) | (4u << 16));
+ WriteUInt32(memory, attribTable, 0u | (77u << 5) | (0u << 14)); // k32_32_32_32Float
+ WriteUInt32(memory, attribTable + 4, 0u | (56u << 5) | (12u << 14)); // rgba8unorm @12
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(memory, bufferTable + 4, (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[4] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[5] = (uint)(attribTable >> 32);
+ scalars[6] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[7] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 6,
+ VertexAttribReg: 4,
+ InputSemanticsCount: 2,
+ InputSemanticsAddress: semanticsAddress);
+
+ var data = new byte[64];
+ var discovered = new[]
+ {
+ new Gen5VertexInputBinding(
+ 0x40, 0, 4, 14, 7, sharpBase, 16, 0, data, data.Length, false),
+ new Gen5VertexInputBinding(
+ 0x80, 1, 4, 14, 7, sharpBase, 16, 12, data, data.Length, false),
+ };
+
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ discovered);
+ Assert.Equal(2, merged.Count);
+ Assert.Equal(0u, merged[0].OffsetBytes);
+ Assert.Equal(12u, merged[1].OffsetBytes);
+ Assert.Equal(0u, merged[1].NumberFormat); // Unorm color, not float
+ Assert.Equal(10u, merged[1].DataFormat); // RGBA8
+ Assert.Equal(sharpBase, merged[0].BaseAddress);
+ Assert.Equal(sharpBase, merged[1].BaseAddress);
+ Assert.Same(data, merged[0].Data);
+ }
+
+ [Fact]
+ public void CollectFetchPrologPcs_FindsSBufferLoadsFromTableRegisters()
+ {
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 10,
+ VertexAttribReg: 8,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: 1);
+
+ var program = new Gen5ShaderProgram(
+ 0,
+ [
+ new Gen5ShaderInstruction(
+ 0x10,
+ Gen5ShaderEncoding.Smem,
+ "SBufferLoadDword",
+ Words: [],
+ Sources: [Gen5Operand.Scalar(8)],
+ Destinations: [Gen5Operand.Scalar(20)],
+ new Gen5ScalarMemoryControl(1, 0, null)),
+ new Gen5ShaderInstruction(
+ 0x20,
+ Gen5ShaderEncoding.Smem,
+ "SBufferLoadDword",
+ Words: [],
+ Sources: [Gen5Operand.Scalar(12)],
+ Destinations: [Gen5Operand.Scalar(24)],
+ new Gen5ScalarMemoryControl(1, 0, null)),
+ new Gen5ShaderInstruction(
+ 0x30,
+ Gen5ShaderEncoding.Sopp,
+ "SEndpgm",
+ Words: [],
+ Sources: [],
+ Destinations: [],
+ null),
+ ]);
+
+ var pcs = AgcVertexMetadata.CollectFetchPrologPcs(program, tables);
+ Assert.Contains(0x10u, pcs);
+ Assert.DoesNotContain(0x20u, pcs);
+ }
+
+ private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
+ {
+ Span bytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs b/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs
index d33ef96d..e0c46d2d 100644
--- a/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs
@@ -6,7 +6,7 @@ using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
-// TryDetile's exact-XOR fast path (PS5 swizzle modes 5/9/24/27) factors the
+// TryDetile's exact-XOR fast path (PS5 swizzle modes 1/5/9/24/27) factors the
// AddrLib bit-interleave into independent per-column X and per-row Y terms so
// the inner loop is one array load and one XOR instead of a 16-bit interleave.
// These tests pin that the factored output stays byte-identical to the direct
@@ -84,6 +84,50 @@ public sealed class GnmTilingDetileTests
}
}
+ // Gen5 Standard256B (mode 1) uses the 8-bit AddrLib S equation, not the
+ // generic StandardSwizzle bit-interleave. Pin that TryDetile recovers a
+ // known linear fill placed with that equation.
+ private static readonly (uint XMask, uint YMask)[] Standard256_1Bpp =
+ [
+ (1u << 0, 0), (1u << 1, 0), (1u << 2, 0), (1u << 3, 0),
+ (0, 1u << 0), (0, 1u << 1), (0, 1u << 2), (0, 1u << 3),
+ ];
+
+ [Theory]
+ [InlineData(32, 32)]
+ [InlineData(64, 48)]
+ public void TryDetile_ExactXorMode1_MatchesReferenceAddressEquation(
+ int elementsWide,
+ int elementsHigh)
+ {
+ const uint swizzleMode = 1; // Standard256B
+ const int bytesPerElement = 1;
+ const int blockBytes = 256;
+ const int blockWidth = 16;
+ const int blockHeight = 16;
+ var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
+ var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
+
+ var tiled = new byte[blocksPerRow * blocksPerColumn * blockBytes];
+ for (var y = 0; y < elementsHigh; y++)
+ {
+ for (var x = 0; x < elementsWide; x++)
+ {
+ var blockIndex = (long)(y / blockHeight) * blocksPerRow + (x / blockWidth);
+ var sourceByte = (int)(blockIndex * blockBytes +
+ ReferenceOffset((uint)x, (uint)y, Standard256_1Bpp));
+ tiled[sourceByte] = (byte)(y * elementsWide + x);
+ }
+ }
+
+ var linear = new byte[elementsWide * elementsHigh * bytesPerElement];
+ Assert.True(GnmTiling.TryDetile(tiled, linear, swizzleMode, elementsWide, elementsHigh, bytesPerElement));
+ for (var i = 0; i < elementsWide * elementsHigh; i++)
+ {
+ Assert.Equal((byte)i, linear[i]);
+ }
+ }
+
// GetDetileParams must reproduce TryDetile bit-for-bit: the CPU fallback and
// the GPU compute kernel both consume these params, so a detile driven purely
// by DetileParams (the shared addressing formula the kernel runs) must equal
@@ -94,8 +138,8 @@ public sealed class GnmTilingDetileTests
[InlineData(9u, 4, 300, 300)] // 64 KiB standard (exact-XOR)
[InlineData(24u, 4, 128, 256)] // 64 KiB RB+ Z_X (exact-XOR)
[InlineData(5u, 4, 200, 120)] // 4 KiB standard (exact-XOR)
+ [InlineData(1u, 4, 64, 64)] // 256 B standard (exact-XOR)
[InlineData(8u, 4, 128, 128)] // 64 KiB Z (block-table path)
- [InlineData(1u, 4, 64, 64)] // 256 B standard (block-table path)
public void GetDetileParams_ReproducesTryDetile(uint mode, int bpp, int w, int h)
{
var p = GnmTiling.GetDetileParams(mode, bpp, w, h);
diff --git a/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs b/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs
index cf587f8d..c303c8d1 100644
--- a/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs
@@ -105,6 +105,92 @@ public sealed class AprStreamingContractTests
}
}
+ [Fact]
+ public void ResolveFilepathsWithPrefixToIdsAndFileSizes_CombinesPrefixAndResolvesRealFile()
+ {
+ // Resource streamers call WithPrefix to join a directory prefix with a
+ // relative asset path. Without HLE every call returned NOT_FOUND and no
+ // asset received a real file id/size.
+ const ulong memoryBase = 0x1_0000_0000;
+ const ulong prefixAddress = memoryBase + 0x80;
+ const ulong pathListAddress = memoryBase + 0x100;
+ const ulong pathAddress = memoryBase + 0x200;
+ const ulong idsAddress = memoryBase + 0x800;
+ const ulong sizesAddress = memoryBase + 0x880;
+ byte[] fileContents = [1, 2, 3, 4, 5, 6];
+ var mountRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"sharpemu-apr-prefix-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(mountRoot);
+ var mountPoint = $"/sharpemu_apr_prefix_mnt_{Guid.NewGuid():N}";
+ const string fileName = "asset.bin";
+ var hostPath = Path.Combine(mountRoot, fileName);
+
+ try
+ {
+ File.WriteAllBytes(hostPath, fileContents);
+ KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
+ var memory = new FakeCpuMemory(memoryBase, 0x4000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ memory.WriteCString(prefixAddress, mountPoint);
+ memory.WriteCString(pathAddress, fileName);
+ WriteUInt64(memory, pathListAddress, pathAddress);
+
+ context[CpuRegister.Rdi] = prefixAddress;
+ context[CpuRegister.Rsi] = pathListAddress;
+ context[CpuRegister.Rdx] = 1;
+ context[CpuRegister.Rcx] = idsAddress;
+ context[CpuRegister.R8] = sizesAddress;
+ context[CpuRegister.R9] = 0;
+
+ Assert.Equal(
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ KernelMemoryCompatExports.KernelAprResolveFilepathsWithPrefixToIdsAndFileSizes(context));
+ Assert.NotEqual(uint.MaxValue, ReadUInt32(memory, idsAddress));
+ Assert.Equal((ulong)fileContents.Length, ReadUInt64(memory, sizesAddress));
+ }
+ finally
+ {
+ KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
+ if (Directory.Exists(mountRoot))
+ {
+ Directory.Delete(mountRoot, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public void ResolveFilepathsWithPrefixToIdsAndFileSizes_MissingFile_FailsFastWithErrorIndex()
+ {
+ const ulong memoryBase = 0x1_0000_0000;
+ const ulong prefixAddress = memoryBase + 0x80;
+ const ulong pathListAddress = memoryBase + 0x100;
+ const ulong pathAddress = memoryBase + 0x200;
+ const ulong idsAddress = memoryBase + 0x800;
+ const ulong sizesAddress = memoryBase + 0x880;
+ const ulong errorIndexAddress = memoryBase + 0x8F0;
+ var memory = new FakeCpuMemory(memoryBase, 0x4000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ memory.WriteCString(prefixAddress, "/does-not-exist-prefix");
+ memory.WriteCString(pathAddress, $"missing-{Guid.NewGuid():N}.bin");
+ WriteUInt64(memory, pathListAddress, pathAddress);
+
+ context[CpuRegister.Rdi] = prefixAddress;
+ context[CpuRegister.Rsi] = pathListAddress;
+ context[CpuRegister.Rdx] = 1;
+ context[CpuRegister.Rcx] = idsAddress;
+ context[CpuRegister.R8] = sizesAddress;
+ context[CpuRegister.R9] = errorIndexAddress;
+
+ Assert.Equal(
+ -1,
+ KernelMemoryCompatExports.KernelAprResolveFilepathsWithPrefixToIdsAndFileSizes(context));
+ Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
+ Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress));
+ Assert.Equal(0ul, ReadUInt64(memory, sizesAddress));
+ Assert.Equal(0u, ReadUInt32(memory, errorIndexAddress));
+ }
+
[Fact]
public void ResolveFilepathsToIdsAndFileSizes_MissingFile_FailsFastWithErrorIndex()
{
diff --git a/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs
new file mode 100644
index 00000000..a09b0658
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs
@@ -0,0 +1,86 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Audio;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Audio;
+
+public sealed class AudioOut2PortGetStateExportsTests
+{
+ private const ulong MemoryBase = 0x1_0000_0000;
+ private const ulong StateAddress = MemoryBase + 0x100;
+
+ private static CpuContext CreateContext(out FakeCpuMemory memory)
+ {
+ memory = new FakeCpuMemory(MemoryBase, 0x1000);
+ return new CpuContext(memory, Generation.Gen5);
+ }
+
+ [Fact]
+ public void PortGetState_WritesFixedSizeIgnoringPollutedR9()
+ {
+ var ctx = CreateContext(out var memory);
+ // Paint the buffer so we can see the write footprint.
+ Span paint = stackalloc byte[0x100];
+ paint.Fill(0xAB);
+ Assert.True(memory.TryWrite(StateAddress, paint));
+
+ ctx[CpuRegister.Rdi] = 0xDE1FF6800001UL;
+ ctx[CpuRegister.Rsi] = StateAddress;
+ ctx[CpuRegister.Rdx] = StateAddress + 0x200;
+ // Polluted GetSize leftover — must NOT enlarge the write.
+ ctx[CpuRegister.R9] = 0x180;
+
+ var result = AudioOut2Exports.AudioOut2PortGetState(ctx);
+
+ Assert.Equal(0, result);
+ Span state = stackalloc byte[0x100];
+ Assert.True(memory.TryRead(StateAddress, state));
+ Assert.Equal(1, BinaryPrimitives.ReadUInt16LittleEndian(state));
+ Assert.Equal(2, state[2]);
+ // Bytes past the fixed 0x20 header must remain untouched.
+ Assert.Equal(0xAB, state[0x20]);
+ Assert.Equal(0xAB, state[0x7F]);
+ }
+
+ [Fact]
+ public void PortGetState_SkipsGuestStackOutBuffer()
+ {
+ var ctx = CreateContext(out _);
+ const ulong stackOut = 0x00007FFFDE1FF688UL;
+ ctx[CpuRegister.Rdi] = 0xDE1FF688004DUL;
+ ctx[CpuRegister.Rsi] = stackOut;
+ ctx[CpuRegister.Rdx] = 0;
+
+ var result = AudioOut2Exports.AudioOut2PortGetState(ctx);
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public void GetSpeakerInfo_WritesFixedSizeToRdiNotRsiTypeFlag()
+ {
+ var ctx = CreateContext(out var memory);
+ Span paint = stackalloc byte[0x80];
+ paint.Fill(0xCD);
+ Assert.True(memory.TryWrite(StateAddress, paint));
+
+ ctx[CpuRegister.Rdi] = StateAddress;
+ ctx[CpuRegister.Rsi] = 1;
+ ctx[CpuRegister.Rdx] = StateAddress + 0x200;
+ ctx[CpuRegister.R8] = 0x840;
+ ctx[CpuRegister.R9] = 0x10C;
+
+ var result = AudioOut2Exports.AudioOut2GetSpeakerInfo(ctx);
+
+ Assert.Equal(0, result);
+ Span info = stackalloc byte[0x80];
+ Assert.True(memory.TryRead(StateAddress, info));
+ Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(info));
+ Assert.Equal(48000u, BinaryPrimitives.ReadUInt32LittleEndian(info[4..]));
+ Assert.Equal(0xCD, info[0x20]);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Audio/AudioOut2SpeakerArrayExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2SpeakerArrayExportsTests.cs
new file mode 100644
index 00000000..8ada9051
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2SpeakerArrayExportsTests.cs
@@ -0,0 +1,140 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Audio;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Audio;
+
+public sealed class AudioOut2SpeakerArrayExportsTests
+{
+ private const ulong MemoryBase = 0x1_0000_0000;
+ private const ulong OutHandleAddress = MemoryBase + 0x100;
+ private const ulong ReservedAddress = MemoryBase + 0x120;
+ private const ulong ParamAddress = MemoryBase + 0x200;
+ private const ulong SpeakerMemoryAddress = MemoryBase + 0x400;
+
+ private static CpuContext CreateContext(out FakeCpuMemory memory)
+ {
+ memory = new FakeCpuMemory(MemoryBase, 0x2000);
+ return new CpuContext(memory, Generation.Gen5);
+ }
+
+ private static void WriteU64(FakeCpuMemory memory, ulong address, ulong value)
+ {
+ Span bytes = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+
+ private static ulong ReadU64(FakeCpuMemory memory, ulong address)
+ {
+ Span bytes = stackalloc byte[8];
+ Assert.True(memory.TryRead(address, bytes));
+ return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
+ }
+
+ private static uint ReadU32(FakeCpuMemory memory, ulong address)
+ {
+ Span bytes = stackalloc byte[4];
+ Assert.True(memory.TryRead(address, bytes));
+ return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
+ }
+
+ [Fact]
+ public void GetSpeakerArrayMemorySize_NeverReturnsTheNotFoundSentinel()
+ {
+ var ctx = CreateContext(out _);
+ ctx[CpuRegister.Rdi] = 8;
+
+ var result = AudioOut2Exports.AudioOut2GetSpeakerArrayMemorySize(ctx);
+
+ Assert.NotEqual((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
+ Assert.Equal(0x40 + 8 * 0x100 + 0x400, result);
+ Assert.Equal((ulong)result, ctx[CpuRegister.Rax]);
+ Assert.True(result < 0x10000);
+ }
+
+ [Fact]
+ public void GetSpeakerArrayMemorySize_TwoChannelsIsExactChannelScaledSize()
+ {
+ var ctx = CreateContext(out _);
+ ctx[CpuRegister.Rdi] = 2;
+
+ var result = AudioOut2Exports.AudioOut2GetSpeakerArrayMemorySize(ctx);
+
+ Assert.Equal(0x40 + 2 * 0x100 + 0x400, result);
+ Assert.Equal(0x640UL, ctx[CpuRegister.Rax]);
+ }
+
+ [Fact]
+ public void SpeakerArrayCreate_PublishesObjectPointerAndLeavesReservedSizeAlone()
+ {
+ var ctx = CreateContext(out var memory);
+ // Stage a size in the reserved slot the way callers do before Create.
+ WriteU64(memory, ReservedAddress, 0x100);
+ ctx[CpuRegister.Rdi] = ParamAddress;
+ ctx[CpuRegister.Rsi] = OutHandleAddress;
+ ctx[CpuRegister.Rdx] = ReservedAddress;
+ ctx[CpuRegister.Rcx] = 2;
+
+ var result = AudioOut2Exports.AudioOut2SpeakerArrayCreate(ctx);
+
+ Assert.Equal(0, result);
+ Assert.NotEqual(0UL, ctx[CpuRegister.Rax]);
+ Assert.NotEqual(0x100UL, ctx[CpuRegister.Rax]);
+ Assert.Equal(ctx[CpuRegister.Rax], ReadU64(memory, OutHandleAddress));
+ // Reserved/size slot must remain untouched — writing it corrupted canaries.
+ Assert.Equal(0x100UL, ReadU64(memory, ReservedAddress));
+ }
+
+ [Fact]
+ public void SpeakerArrayCreate_PublishesHandleForTypicalCallShape()
+ {
+ var ctx = CreateContext(out _);
+ ctx[CpuRegister.Rdi] = ParamAddress;
+ ctx[CpuRegister.Rsi] = OutHandleAddress;
+ ctx[CpuRegister.Rdx] = ReservedAddress;
+ ctx[CpuRegister.Rcx] = 2;
+
+ var result = AudioOut2Exports.AudioOut2SpeakerArrayCreate(ctx);
+
+ Assert.Equal(0, result);
+ Assert.NotEqual(0UL, ctx[CpuRegister.Rax]);
+ Assert.NotEqual(0x10000UL, ctx[CpuRegister.Rax]);
+ Assert.NotEqual((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
+ }
+
+ [Fact]
+ public void SpeakerArrayCreate_IgnoresCorruptedParamBufferFields()
+ {
+ var ctx = CreateContext(out var memory);
+ // Simulate PortGetState having overwritten param+0x18 (size) with a
+ // state blob — Create must NOT adopt that as an in-place buffer.
+ WriteU64(memory, ParamAddress + 0x10, SpeakerMemoryAddress);
+ WriteU64(memory, ParamAddress + 0x18, 0x100);
+ ctx[CpuRegister.Rdi] = ParamAddress;
+ ctx[CpuRegister.Rsi] = OutHandleAddress;
+ ctx[CpuRegister.Rdx] = ReservedAddress;
+ ctx[CpuRegister.Rcx] = 2;
+
+ var result = AudioOut2Exports.AudioOut2SpeakerArrayCreate(ctx);
+
+ Assert.Equal(0, result);
+ Assert.NotEqual(SpeakerMemoryAddress, ctx[CpuRegister.Rax]);
+ Assert.NotEqual(0x100UL, ctx[CpuRegister.Rax]);
+ }
+
+ [Fact]
+ public void SpeakerArrayDestroy_UnknownHandleStillSucceeds()
+ {
+ var ctx = CreateContext(out _);
+ ctx[CpuRegister.Rdi] = 0xDEAD_BEEF;
+
+ var result = AudioOut2Exports.AudioOut2SpeakerArrayDestroy(ctx);
+
+ Assert.Equal(0, result);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs b/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs
index defe7f78..c016a46d 100644
--- a/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs
+++ b/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs
@@ -7,15 +7,18 @@ namespace SharpEmu.Libs.Tests;
// A single contiguous guest region backed by a byte[]. Enough to hand C strings and small
// structures to HLE exports under test without a live guest.
-internal sealed class FakeCpuMemory : ICpuMemory
+internal sealed class FakeCpuMemory : ICpuMemory, IGuestMemoryAllocator
{
private readonly ulong _base;
private readonly byte[] _storage;
+ private ulong _allocBump;
public FakeCpuMemory(ulong baseAddress, int size)
{
_base = baseAddress;
_storage = new byte[size];
+ // Bump from the top so test fixtures at low offsets stay intact.
+ _allocBump = baseAddress + (ulong)size;
}
public bool TryRead(ulong virtualAddress, Span destination)
@@ -40,6 +43,33 @@ internal sealed class FakeCpuMemory : ICpuMemory
return true;
}
+ public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
+ {
+ address = 0;
+ if (size == 0 || alignment == 0 || (alignment & (alignment - 1)) != 0)
+ {
+ return false;
+ }
+
+ var alignedSize = (size + alignment - 1) & ~(alignment - 1);
+ if (alignedSize > _allocBump - _base)
+ {
+ return false;
+ }
+
+ var next = (_allocBump - alignedSize) & ~(alignment - 1);
+ if (next < _base)
+ {
+ return false;
+ }
+
+ _allocBump = next;
+ address = next;
+ return true;
+ }
+
+ public bool TryFreeGuestMemory(ulong address) => false;
+
public ulong WriteCString(ulong virtualAddress, string text)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
diff --git a/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs
index ad564297..041987ca 100644
--- a/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Memory/GuestImageWriteTrackerTests.cs
@@ -24,13 +24,53 @@ public sealed unsafe class GuestImageWriteTrackerTests
// 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()
{
@@ -58,7 +98,7 @@ public sealed unsafe class GuestImageWriteTrackerTests
finally
{
GuestImageWriteTracker.Untrack(address);
- NativeMemory.Free(allocation);
+ FreeTrackedPages(allocation);
}
}
@@ -89,7 +129,7 @@ public sealed unsafe class GuestImageWriteTrackerTests
finally
{
GuestImageWriteTracker.Untrack(address);
- NativeMemory.Free(allocation);
+ FreeTrackedPages(allocation);
}
}
@@ -118,7 +158,7 @@ public sealed unsafe class GuestImageWriteTrackerTests
finally
{
GuestImageWriteTracker.Untrack(address);
- NativeMemory.Free(allocation);
+ FreeTrackedPages(allocation);
}
}
@@ -132,4 +172,132 @@ public sealed unsafe class GuestImageWriteTrackerTests
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);
+ }
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs
index 807c4c52..23e92730 100644
--- a/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs
@@ -79,7 +79,16 @@ public sealed class GuestMemoryAllocatorTests
var pointer = memory.GetPointer(address + 0x123);
Assert.Equal(address + 0x123, (ulong)pointer);
- Assert.Equal([(address, pageSize, HostPageProtection.ReadWrite)], host.CommitCalls);
+ // GetPointer commits a 32 MiB working-set chunk as 4 KiB pages against
+ // this fake host (Query reports 4 KiB reserved regions). Non-page-
+ // aligned start makes AlignUp(addr + chunk) cover one extra page.
+ const ulong lazyPrimeChunkBytes = 0x0200_0000UL;
+ var endPage = (address + 0x123 + lazyPrimeChunkBytes + pageSize - 1) & ~(pageSize - 1);
+ Assert.Equal((int)((endPage - address) / pageSize), host.CommitCalls.Count);
+ Assert.Equal((address, pageSize, HostPageProtection.ReadWrite), host.CommitCalls[0]);
+ Assert.All(
+ host.CommitCalls,
+ call => Assert.Equal(pageSize, call.Size));
}
[Fact]
diff --git a/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
index 5ad2a8b2..1608ddef 100644
--- a/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
@@ -9,9 +9,10 @@ using Xunit;
namespace SharpEmu.Libs.Tests.Memory;
// PhysicalVirtualMemory is the host-backed (identity-mapped) implementation.
-// Reserve-only regions (> 4 GiB, non-executable) defer commit until first
-// access; TryAllocateGuestMemory serves a first-fit free-list with coalescing.
-// These tests pin that behaviour through fake IHostMemory implementations.
+// Huge non-executable maps (> 4 GiB) prefer commit-first, then fall back to
+// reserve-only + lazy commit when Allocate fails. TryAllocateGuestMemory serves
+// a first-fit free-list with coalescing. These tests pin that behaviour through
+// fake IHostMemory implementations that refuse full Allocate for huge sizes.
public sealed class PhysicalVirtualMemoryTests
{
// 1. Lazy commit: a reserve-only region has its pages committed on demand
@@ -22,7 +23,8 @@ public sealed class PhysicalVirtualMemoryTests
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
- // > 4 GiB, non-executable -> reserve-only with lazy commit.
+ // > 4 GiB, non-executable; fake host rejects Allocate so reserve-only
+ // + lazy commit is used.
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
Assert.NotEqual(0UL, address);
@@ -108,8 +110,17 @@ public sealed class PhysicalVirtualMemoryTests
Assert.NotEqual(0UL, (ulong)pointer);
Assert.Equal(address + 0x123, (ulong)pointer);
+ // GetPointer primes a 32 MiB working-set chunk (page-sized commits
+ // against this fake host's 4 KiB Query regions). Non-page-aligned
+ // start makes AlignUp(addr + chunk) cover one extra page.
+ const ulong lazyPrimeChunkBytes = 0x0200_0000UL;
var page = (address + 0x123) & ~0xFFFUL;
- Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
+ var endPage = (address + 0x123 + lazyPrimeChunkBytes + 0xFFFUL) & ~0xFFFUL;
+ Assert.Equal((int)((endPage - page) / 0x1000UL), host.CommitCalls.Count);
+ Assert.Equal((page, 0x1000UL, HostPageProtection.ReadWrite), host.CommitCalls[0]);
+ Assert.All(
+ host.CommitCalls,
+ call => Assert.Equal(0x1000UL, call.Size));
}
[Fact]
@@ -187,7 +198,8 @@ public sealed class PhysicalVirtualMemoryTests
public int QueryCalls { get; private set; }
- public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
+ // Force the commit-first → reserve-only fallback for huge maps.
+ public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => 0;
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
diff --git a/tests/SharpEmu.Libs.Tests/Remoteplay/RemoteplayExportsTests.cs b/tests/SharpEmu.Libs.Tests/Remoteplay/RemoteplayExportsTests.cs
new file mode 100644
index 00000000..9c0b6dbf
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Remoteplay/RemoteplayExportsTests.cs
@@ -0,0 +1,58 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.Libs.Remoteplay;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Remoteplay;
+
+public sealed class RemoteplayExportsTests
+{
+ private const ulong MemoryBase = 0x1_0000_0000;
+ private const ulong StatusAddress = MemoryBase + 0x100;
+
+ private static CpuContext CreateContext(out FakeCpuMemory memory)
+ {
+ memory = new FakeCpuMemory(MemoryBase, 0x1000);
+ return new CpuContext(memory, Generation.Gen5);
+ }
+
+ [Fact]
+ public void Initialize_Succeeds()
+ {
+ var ctx = CreateContext(out _);
+
+ var result = RemoteplayExports.RemoteplayInitialize(ctx);
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public void GetConnectionStatus_WritesDisconnectedStatus()
+ {
+ var ctx = CreateContext(out var memory);
+ ctx[CpuRegister.Rdi] = 0x1000_0000;
+ ctx[CpuRegister.Rsi] = StatusAddress;
+ memory.TryWrite(StatusAddress, stackalloc byte[] { 0xFF, 0xFF, 0xFF, 0xFF });
+
+ var result = RemoteplayExports.RemoteplayGetConnectionStatus(ctx);
+
+ Assert.Equal(0, result);
+ var status = new byte[4];
+ Assert.True(ctx.Memory.TryRead(StatusAddress, status));
+ Assert.Equal(0, status[0]);
+ }
+
+ [Fact]
+ public void GetConnectionStatus_NullOutPointerStillSucceeds()
+ {
+ var ctx = CreateContext(out _);
+ ctx[CpuRegister.Rdi] = 0x1000_0000;
+ ctx[CpuRegister.Rsi] = 0;
+
+ var result = RemoteplayExports.RemoteplayGetConnectionStatus(ctx);
+
+ Assert.Equal(0, result);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPresentEncodeFormatTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPresentEncodeFormatTests.cs
index 9d4f7f18..2b8bdaec 100644
--- a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPresentEncodeFormatTests.cs
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPresentEncodeFormatTests.cs
@@ -56,4 +56,26 @@ public sealed class VulkanPresentEncodeFormatTests
{
Assert.False(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
}
+
+ // GTA V Enhanced early G-buffer color targets observed as COMPAT failures
+ // when missing from TryDecodeRenderTargetFormat (format=2 / format=11).
+ [Theory]
+ [InlineData(2u, 0u, Format.R16Unorm)]
+ [InlineData(2u, 4u, Format.R16Uint)]
+ [InlineData(2u, 7u, Format.R16Sfloat)]
+ [InlineData(11u, 4u, Format.R32G32Uint)]
+ [InlineData(11u, 5u, Format.R32G32Sint)]
+ [InlineData(11u, 7u, Format.R32G32Sfloat)]
+ public void TryDecodeRenderTargetFormat_DecodesGen5R16AndRg32(
+ uint dataFormat,
+ uint numberType,
+ Format expected)
+ {
+ Assert.True(
+ VulkanVideoPresenter.TryDecodeRenderTargetFormat(
+ dataFormat,
+ numberType,
+ out var decoded));
+ Assert.Equal(expected, decoded.Format);
+ }
}