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); + } }