diff --git a/REUSE.toml b/REUSE.toml index df760ef..b7162dd 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -8,6 +8,8 @@ path = [ "**/packages.lock.json", "scripts/ps5_names.txt", "src/SharpEmu.GUI/Languages/**", + "src/SharpEmu.ShaderCompiler.Metal/Templates/**", + "tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**", "_logs/**", ".github/images/**", ".github/pull_request_template.md", diff --git a/SharpEmu.slnx b/SharpEmu.slnx index a867d51..aa18593 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -14,11 +14,13 @@ SPDX-License-Identifier: GPL-2.0-or-later + + diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json index f23527a..f41e08b 100644 --- a/src/SharpEmu.CLI/packages.lock.json +++ b/src/SharpEmu.CLI/packages.lock.json @@ -253,6 +253,7 @@ "dependencies": { "SharpEmu.HLE": "[0.0.2-beta.3, )", "SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )", + "SharpEmu.ShaderCompiler.Metal": "[0.0.2-beta.3, )", "SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )", "Silk.NET.Input": "[2.23.0, )", "Silk.NET.Vulkan": "[2.23.0, )", @@ -270,6 +271,12 @@ "SharpEmu.HLE": "[0.0.2-beta.3, )" } }, + "sharpemu.shadercompiler.metal": { + "type": "Project", + "dependencies": { + "SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )" + } + }, "sharpemu.shadercompiler.vulkan": { "type": "Project", "dependencies": { diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index 7fbad04..4b40ecb 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -2541,28 +2541,22 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source) { - // Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks - // xmm2 to its low 40 bits, then copies the resulting second dword into - // xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes: - // extract source byte 4 and insert the zero-extended value into lane 1. - ReadOnlySpan pattern = - [ - 0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00, - 0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02, - ]; - for (var i = 0; i < pattern.Length; i++) + // Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's + // EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite + // it into an equivalent SSE4.1 sequence. Match/encode is isolated in + // Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes. + var window = new ReadOnlySpan(source, Sse4aExtrqBlendPatch.SequenceLength); + if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister)) { - if (source[i] != pattern[i]) - { - return false; - } + return false; + } + + Span replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength]; + if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement)) + { + return false; } - ReadOnlySpan replacement = - [ - 0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04, - 0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01, - ]; uint oldProtect = 0; if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect)) { diff --git a/src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs b/src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs new file mode 100644 index 0000000..d624826 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs @@ -0,0 +1,115 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; + +namespace SharpEmu.Core.Cpu.Native; + +/// +/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an +/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but +/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode +/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM +/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the +/// source register is read from the ModRM r/m field rather than hard-coded. +/// +/// The match/encode logic is deliberately free of native page-patching so it +/// can be unit-tested against handcrafted byte sequences. +/// +public static class Sse4aExtrqBlendPatch +{ + /// Length in bytes of both the matched idiom and its replacement. + public const int SequenceLength = 12; + + /// + /// Matches the 12-byte idiom, extracting the destination register D and the + /// source (scratch) register N: + /// + /// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits + /// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD + /// + /// N lives in the ModRM r/m field of both instructions; D (the blend + /// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv. + /// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension). + /// The compiler allocates whichever registers it likes — Dead Cells builds use + /// D=xmm0 and D=xmm3, others differ — so both are read from the encoding. + /// + public static bool TryMatch(ReadOnlySpan source, out int destRegister, out int srcRegister) + { + destRegister = -1; + srcRegister = -1; + if (source.Length < SequenceLength) + { + return false; + } + + // EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00. + if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 || + (source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00) + { + return false; + } + + var n = source[3] & 0x07; + + // VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 02 ModRM 02. + // VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D. + if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 || + source[9] != 0x02 || source[11] != 0x02) + { + return false; + } + + var d = (~(source[8] >> 3)) & 0x0F; + if (d > 7) + { + return false; + } + + // ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register). + if (source[10] != (0xC0 | (d << 3) | n)) + { + return false; + } + + destRegister = d; + srcRegister = n; + return true; + } + + /// + /// Writes the SSE4.1 equivalent into : + /// + /// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended) + /// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1 + /// + /// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4 + /// zero-extended, so the two-instruction extract/insert reproduces the exact + /// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch + /// at every site the compiler emits this idiom. + /// + public static bool TryEncode(int destRegister, int srcRegister, Span destination) + { + if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength) + { + return false; + } + + // PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4. + destination[0] = 0x66; + destination[1] = 0x0F; + destination[2] = 0x3A; + destination[3] = 0x14; + destination[4] = (byte)(0xC0 | (srcRegister << 3)); + destination[5] = 0x04; + + // PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1. + destination[6] = 0x66; + destination[7] = 0x0F; + destination[8] = 0x3A; + destination[9] = 0x22; + destination[10] = (byte)(0xC0 | (destRegister << 3)); + destination[11] = 0x01; + return true; + } +} diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs index 06535ea..2eb2f84 100644 --- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs +++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs @@ -873,6 +873,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) { + // A managed write into a page the guest-image write tracker has + // protected surfaces as a fatal AccessViolation — the runtime turns + // SIGSEGV in managed code into an exception before the resumable + // signal bridge can restore access (native guest stores recover + // there). Pre-visit the span so tracked pages are unprotected and + // their owners dirtied before the copy; guest addresses are + // host-identical, matching the tracker's fault addresses. + GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length); + var requiresExclusiveAccess = false; _gate.EnterReadLock(); try diff --git a/src/SharpEmu.HLE/GuestImageWriteTracker.cs b/src/SharpEmu.HLE/GuestImageWriteTracker.cs index 079bd09..adf7e29 100644 --- a/src/SharpEmu.HLE/GuestImageWriteTracker.cs +++ b/src/SharpEmu.HLE/GuestImageWriteTracker.cs @@ -51,9 +51,33 @@ public static unsafe class GuestImageWriteTracker private static readonly object _gate = new(); private static readonly Dictionary _rangesByAddress = new(); - // Snapshot array read lock-free from the signal handler; rebuilt on every - // mutation under the gate. Signal handlers must not take managed locks. - private static TrackedRange[] _rangeSnapshot = []; + /// Immutable snapshot read lock-free from the signal handler and + /// the managed-write pre-visit; rebuilt on every mutation under the gate + /// (signal handlers must not take managed locks). Carrying the overall + /// bounds inside the same object keeps the hot-path intersection test + /// consistent with the array it guards. + private sealed class RangeSnapshot + { + public static readonly RangeSnapshot Empty = new([]); + + public readonly TrackedRange[] Ranges; + public readonly ulong Start; + public readonly ulong End; + + public RangeSnapshot(TrackedRange[] ranges) + { + Ranges = ranges; + Start = ulong.MaxValue; + End = 0; + foreach (var range in ranges) + { + Start = Math.Min(Start, range.Start); + End = Math.Max(End, range.End); + } + } + } + + private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty; private static readonly bool _enabled = !OperatingSystem.IsWindows() && Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0"; @@ -266,6 +290,17 @@ public static unsafe class GuestImageWriteTracker var end = address > ulong.MaxValue - byteCount ? ulong.MaxValue : address + byteCount; + + // Fast rejection for the hot path: this runs on every managed guest + // write, and almost none of them touch tracked texture pages. The + // bounds live inside the snapshot so they are always consistent with + // the ranges the per-page visit below would consult. + var snapshot = Volatile.Read(ref _rangeSnapshot); + if (snapshot.Ranges.Length == 0 || end <= snapshot.Start || address >= snapshot.End) + { + return; + } + var candidate = address; while (candidate < end) { @@ -311,7 +346,7 @@ public static unsafe class GuestImageWriteTracker return false; } - var ranges = Volatile.Read(ref _rangeSnapshot); + var ranges = Volatile.Read(ref _rangeSnapshot).Ranges; var writableStart = ulong.MaxValue; var writableEnd = 0UL; for (var index = 0; index < ranges.Length; index++) @@ -458,7 +493,7 @@ public static unsafe class GuestImageWriteTracker private static void RebuildSnapshotLocked() { - _rangeSnapshot = _rangesByAddress.Values.ToArray(); + Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray())); } private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount) diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 64f48c3..39419e4 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -14,6 +14,12 @@ namespace SharpEmu.Libs.Agc; public static partial class AgcExports { + // The backend is a process-fixed singleton, so its offset-alignment + // requirement is snapshot once: several per-draw paths (shader-key + // hashing, buffer-offset alignment) read it in loops. + private static readonly ulong _storageBufferOffsetAlignment = + GuestGpu.Current.GuestStorageBufferOffsetAlignment; + #if DEBUG static AgcExports() { @@ -2666,7 +2672,7 @@ public static partial class AgcExports TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}"); } - VulkanVideoPresenter.AttachGuestMemory(ctx.Memory); + GuestGpu.Current.AttachGuestMemory(ctx.Memory); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); lock (gpuState.Gate) { @@ -2718,7 +2724,7 @@ public static partial class AgcExports $"addr=0x{commandAddress:X16} dwords={dwordCount}"); } - VulkanVideoPresenter.AttachGuestMemory(ctx.Memory); + GuestGpu.Current.AttachGuestMemory(ctx.Memory); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); lock (gpuState.Gate) { @@ -2946,7 +2952,7 @@ public static partial class AgcExports // guest-memory writes have finished. Put the notification on that same // logical graphics queue instead of approximating completion with a // timer, which can wake Unity while its upload data is still stale. - if (VulkanVideoPresenter.SubmitOrderedGuestAction( + if (GuestGpu.Current.SubmitOrderedGuestAction( TriggerCompletionEvents, $"agc submit completion {submissionId}") == 0) { @@ -2970,11 +2976,11 @@ public static partial class AgcExports return false; } - using var guestQueueScope = VulkanVideoPresenter.EnterGuestQueue( + using var guestQueueScope = GuestGpu.Current.EnterGuestQueue( state.QueueName, state.ActiveSubmissionId); var windowByteCount = checked((int)(dwordCount * sizeof(uint))); - var rented = VulkanVideoPresenter.GuestDataPool.Rent(windowByteCount); + var rented = GuestDataPool.Shared.Rent(windowByteCount); try { if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount))) @@ -2996,7 +3002,7 @@ public static partial class AgcExports { _dcbWindowBuffer = null; _dcbWindowByteLength = 0; - VulkanVideoPresenter.GuestDataPool.Return(rented); + GuestDataPool.Shared.Return(rented); } } @@ -3298,17 +3304,33 @@ public static partial class AgcExports indexed: false); } - if ((op is ItDispatchDirect or ItDispatchIndirect) && - TryReadComputeDispatch( - ctx, - state, - currentAddress, - length, - op, - out var dispatch)) + if (op is ItDispatchDirect or ItDispatchIndirect) { - state.FrameDispatchCount++; - ObserveComputeDispatch(ctx, gpuState, state, dispatch); + if (TryReadComputeDispatch( + ctx, + state, + currentAddress, + length, + op, + out var dispatch, + out var indirectDimsRetryAddress)) + { + state.FrameDispatchCount++; + ObserveComputeDispatch(ctx, gpuState, state, dispatch); + } + else if (indirectDimsRetryAddress != 0 && + HandleSubmittedIndirectDimsWait( + ctx, + state, + commandAddress, + currentAddress, + offset, + dwordCount, + indirectDimsRetryAddress, + tracePackets)) + { + return true; // suspend until the producer computes the dims + } } if (op == ItNop && @@ -3317,7 +3339,7 @@ public static partial class AgcExports TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) && TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex)) { - var waitSequence = VulkanVideoPresenter.SubmitOrderedGuestFlipWait( + var waitSequence = GuestGpu.Current.SubmitOrderedGuestFlipWait( unchecked((int)waitVideoOutHandle), unchecked((int)waitDisplayBufferIndex)); TraceAgcShader( @@ -3677,7 +3699,7 @@ public static partial class AgcExports // 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. - if (VulkanVideoPresenter.SubmitOrderedGuestAction( + if (GuestGpu.Current.SubmitOrderedGuestAction( CompleteAndWake, $"{debugName} completion") == 0) { @@ -3685,7 +3707,7 @@ public static partial class AgcExports } } - if (VulkanVideoPresenter.SubmitOrderedGuestAction( + if (GuestGpu.Current.SubmitOrderedGuestAction( ApplyAndQueueCompletion, debugName) == 0) { @@ -3895,11 +3917,11 @@ public static partial class AgcExports TraceAgc( $"agc.acquire_mem_applied queue={queueName} " + $"submission={submissionId} packet=0x{packetAddress:X16} " + - $"work_sequence={VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics}"); + $"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}"); } } - var sequence = VulkanVideoPresenter.SubmitOrderedGuestAction( + var sequence = GuestGpu.Current.SubmitOrderedGuestAction( ApplyAcquire, debugName); if (sequence == 0) @@ -4045,7 +4067,7 @@ public static partial class AgcExports return; } - foreach (var (address, width, height, byteCount) in VulkanVideoPresenter.GetGuestImageExtents()) + foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents()) { if (scopeByteCount != ulong.MaxValue && !RangesOverlap(address, byteCount, scopeAddress, scopeByteCount)) @@ -4066,7 +4088,7 @@ public static partial class AgcExports var pixels = new byte[byteCount]; if (ctx.Memory.TryRead(address, pixels)) { - VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels); + GuestGpu.Current.SubmitGuestImageWrite(address, pixels); if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64) { Console.Error.WriteLine( @@ -4109,7 +4131,7 @@ public static partial class AgcExports ulong byteCount, uint? fillValue) { - var hasImage = VulkanVideoPresenter.TryGetGuestImageExtent( + var hasImage = GuestGpu.Current.TryGetGuestImageExtent( destinationAddress, out var width, out var height, @@ -4133,14 +4155,14 @@ public static partial class AgcExports if (fillValue is { } fill) { - VulkanVideoPresenter.SubmitGuestImageFill(destinationAddress, fill); + GuestGpu.Current.SubmitGuestImageFill(destinationAddress, fill); return; } var pixels = new byte[imageBytes]; if (ctx.Memory.TryRead(destinationAddress, pixels)) { - VulkanVideoPresenter.SubmitGuestImageWrite(destinationAddress, pixels); + GuestGpu.Current.SubmitGuestImageWrite(destinationAddress, pixels); } } @@ -4528,6 +4550,17 @@ public static partial class AgcExports ? fallbackMs : 0L) * System.Diagnostics.Stopwatch.Frequency / 1000L; + // How long a suspended GPU wait may sit before the deadlock breaker may + // release it using the last value a real producer wrote to its label. Long + // enough that legitimate GPU work (which completes within a frame) never + // trips it; short enough that a wedged cross-queue cycle unblocks quickly. + private static readonly long _gpuDeadlockBreakTicks = + (long.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_GPU_DEADLOCK_BREAK_MS"), + out var deadlockMs) && deadlockMs > 0 + ? deadlockMs + : 500L) * System.Diagnostics.Stopwatch.Frequency / 1000L; + // Reads the WAIT_REG_MEM watched address, reference, mask, and 3-bit compare // function for both the AGC NOP-encapsulated (RWaitMem32/64) and the standard // ItWaitRegMem packet layouts. @@ -4592,6 +4625,85 @@ public static partial class AgcExports // Returns true when the DCB should suspend parsing at this wait (its // continuation was registered into GpuWaitRegistry); false to keep parsing // (already satisfied, unreadable, or legacy force-satisfy mode). + // How long an indirect dispatch may wait for its producing dispatch to write + // non-zero dimensions before we give up and drop it (matching the pre-existing + // reject behavior). The producer runs on the render thread within a frame or + // two; this only bounds the pathological/legitimately-empty case. + private const long IndirectDimsRetryBudgetMs = 150; + + private static readonly object _indirectDimsGate = new(); + // Keys (memory, packetAddress) whose retry deadline elapsed. Added by + // DrainResumableDcbs when it resumes an expired retry, consumed by the very + // next re-parse of that packet so it drops instead of re-suspending. Never + // persists across frames — a fresh submit of the same packet retries anew. + private static readonly HashSet<(object, ulong)> _indirectDimsExpired = new(); + + // Suspends an indirect-dispatch DCB until the guest buffer holding its + // thread-group dimensions becomes non-zero (written by a prior GPU dispatch), + // then re-parses the dispatch. Returns false — so the caller drops the work — + // when the dims already expired once (genuinely empty dispatch). + private static bool HandleSubmittedIndirectDimsWait( + CpuContext ctx, + SubmittedDcbState state, + ulong commandAddress, + ulong packetAddress, + uint offset, + uint dwordCount, + ulong dimsAddress, + bool tracePacket) + { + if (!_gpuWaitSuspendEnabled || + dimsAddress == 0 || + dimsAddress % sizeof(uint) != 0) + { + return false; + } + + var key = (ctx.Memory, packetAddress); + lock (_indirectDimsGate) + { + // This is the re-parse right after the deadline elapsed: drop the + // dispatch instead of suspending again. + if (_indirectDimsExpired.Remove(key)) + { + return false; + } + } + + var waiter = new GpuWaitRegistry.WaitingDcb + { + CommandBufferAddress = commandAddress, + ResumeAddress = packetAddress, // re-parse this dispatch packet + ResumeOffset = offset, + TotalDwords = dwordCount, + WaitAddress = dimsAddress, + ReferenceValue = 0, + Mask = 0xFFFFFFFF, + CompareFunction = 4, // NOT_EQUAL: dims became available + Is64Bit = false, + IsStandard = false, + Memory = ctx.Memory, + QueueName = state.QueueName, + SubmissionId = state.ActiveSubmissionId, + RegisteredTicks = System.Diagnostics.Stopwatch.GetTimestamp(), + RetryDeadlineTicks = System.Diagnostics.Stopwatch.GetTimestamp() + + (IndirectDimsRetryBudgetMs * System.Diagnostics.Stopwatch.Frequency / 1000L), + State = state, + }; + + GpuWaitRegistry.Register(dimsAddress, waiter); + var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); + EnsureGpuWaitMonitor(ctx, gpuState); + if (tracePacket) + { + TraceAgc( + $"agc.dispatch_indirect_wait dims=0x{dimsAddress:X16} " + + $"packet=0x{packetAddress:X16} queue={state.QueueName}"); + } + + return true; + } + private static bool HandleSubmittedWaitRegMem( CpuContext ctx, SubmittedDcbState state, @@ -4857,7 +4969,50 @@ public static partial class AgcExports ? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null : TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null); - if (woken is null) + // Indirect-dispatch dimension retries whose deadline elapsed are + // resumed so they drop instead of stalling. Flag each so its immediate + // re-parse drops the dispatch rather than suspending again. + var expiredRetries = GpuWaitRegistry.CollectExpiredRetries( + ctx.Memory, System.Diagnostics.Stopwatch.GetTimestamp()); + if (expiredRetries is not null) + { + lock (_indirectDimsGate) + { + foreach (var retry in expiredRetries) + { + _indirectDimsExpired.Add((ctx.Memory, retry.ResumeAddress)); + } + } + + foreach (var retry in expiredRetries) + { + ResumeSuspendedDcb(ctx, gpuState, retry, tracePackets); + } + } + + // Break cross-queue deadlocks: a waiter stuck past the deadline whose + // label a real producer already signalled (but guest memory has since + // been reset for reuse) is released using that produced value. Only + // fires for genuinely wedged waits, so fast-resolving ones on working + // titles are untouched. + var deadlockBroken = GpuWaitRegistry.CollectDeadlockBroken( + ctx.Memory, System.Diagnostics.Stopwatch.GetTimestamp(), _gpuDeadlockBreakTicks); + if (deadlockBroken is not null) + { + foreach (var waiter in deadlockBroken) + { + if (tracePackets) + { + TraceAgc( + $"agc.deadlock_break label=0x{waiter.WaitAddress:X16} " + + $"queue={waiter.QueueName} submission={waiter.SubmissionId}"); + } + + ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets); + } + } + + if (woken is null && expiredRetries is null && deadlockBroken is null) { if (_gpuWaitStaleTicks > 0 && GpuWaitRegistry.CollectUnreportedStale( @@ -4887,9 +5042,12 @@ public static partial class AgcExports return; } - foreach (var waiter in woken) + if (woken is not null) { - ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets); + foreach (var waiter in woken) + { + ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets); + } } } } @@ -5033,6 +5191,15 @@ public static partial class AgcExports _ => false, }); + // Record + latch the written value so a same-frame label reset + // cannot lose the wakeup, and so the deadlock breaker can release + // a cross-queue waiter later (see ApplySubmittedReleaseMem). + if (wroteData && dataSelection is 1 or 2) + { + GpuWaitRegistry.RecordProduced( + ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data); + } + if (tracePacket) { TraceAgc( @@ -5098,6 +5265,16 @@ public static partial class AgcExports _ => false, }; + // Latch waiters against the value we just wrote: the guest reuses + // these labels and can reset them to 0 before the wake pass reads + // memory, which otherwise loses the wakeup and stalls at a black + // screen (Astro Bot: graphics queue waiting on a compute EOP label). + if (wroteData && dataSelection is 1 or 2) + { + GpuWaitRegistry.RecordProduced( + ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data); + } + if (tracePacket) { TraceAgc( @@ -5398,7 +5575,7 @@ public static partial class AgcExports state.KnownRenderTargets[resolveSource.Address] = resolveSource; state.KnownRenderTargets[resolveDestination.Address] = resolveDestination; ProvideRenderTargetInitialData(ctx, resolveSource); - if (VulkanVideoPresenter.TrySubmitGuestImageBlit( + if (GuestGpu.Current.TrySubmitGuestImageBlit( resolveSource.Address, resolveSource.Width, resolveSource.Height, @@ -5709,7 +5886,7 @@ public static partial class AgcExports var cacheKey = ( exportShaderAddress, exportFingerprint, - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment); + _storageBufferOffsetAlignment); _depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader); if (vertexShader is null) @@ -5734,7 +5911,7 @@ public static partial class AgcExports : guestGlobalBufferCount + 1, requiredVertexOutputCount: 0, storageBufferOffsetAlignment: - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment)) + _storageBufferOffsetAlignment)) { ReturnPooledEvaluationArrays(exportEvaluation); return false; @@ -5746,7 +5923,7 @@ public static partial class AgcExports exportFingerprint, vertexShader!, exportState.Program); - VulkanVideoPresenter.CountSpirvCompilation(); + GuestGpu.Current.CountShaderCompilation(); _depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!); } @@ -6032,7 +6209,7 @@ public static partial class AgcExports attributeCount, psInputEna, psInputAddr, - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment); + _storageBufferOffsetAlignment); var guestGlobalBuffers = pixelEvaluation.GlobalMemoryBindings.Count + @@ -6068,7 +6245,7 @@ public static partial class AgcExports pixelInputEnable: psInputEna, pixelInputAddress: psInputAddr, storageBufferOffsetAlignment: - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment) || + _storageBufferOffsetAlignment) || !GuestGpu.Current.TryCompileVertexShader( exportState, exportEvaluation, @@ -6080,7 +6257,7 @@ public static partial class AgcExports scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1, requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState), storageBufferOffsetAlignment: - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment)) + _storageBufferOffsetAlignment)) { ReturnPooledEvaluationArrays(exportEvaluation); ReturnPooledEvaluationArrays(pixelEvaluation); @@ -6100,7 +6277,7 @@ public static partial class AgcExports pixelStateFingerprint, compiled.Pixel, pixelState.Program); - VulkanVideoPresenter.CountSpirvCompilation(); + GuestGpu.Current.CountShaderCompilation(); _graphicsShaderCache.TryAdd(shaderKey, compiled); } @@ -6378,7 +6555,7 @@ public static partial class AgcExports var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort); var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex); var byteCount = checked((int)(indexCount * (uint)bytesPerIndex)); - var data = VulkanVideoPresenter.GuestDataPool.Rent(byteCount); + var data = GuestDataPool.Shared.Rent(byteCount); var span = data.AsSpan(0, byteCount); var address = state.IndexBufferAddress + byteOffset; if (ctx.Memory.TryRead(address, span) || @@ -6387,7 +6564,7 @@ public static partial class AgcExports return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true); } - VulkanVideoPresenter.GuestDataPool.Return(data); + GuestDataPool.Shared.Return(data); return null; } @@ -6414,7 +6591,7 @@ public static partial class AgcExports var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex); var address = state.IndexBufferAddress + byteOffset; const int chunkBytes = 64 * 1024; - var scratch = VulkanVideoPresenter.GuestDataPool.Rent(chunkBytes); + var scratch = GuestDataPool.Shared.Rent(chunkBytes); var remaining = drawCount; var maxIndex = 0u; var sawIndex = false; @@ -6456,7 +6633,7 @@ public static partial class AgcExports } finally { - VulkanVideoPresenter.GuestDataPool.Return(scratch); + GuestDataPool.Shared.Return(scratch); } var indexedRecords = sawIndex && maxIndex != uint.MaxValue @@ -6580,7 +6757,7 @@ public static partial class AgcExports { hash = (hash ^ ( binding.BaseAddress & - (VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))) * prime; + (_storageBufferOffsetAlignment - 1))) * prime; } if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters) @@ -6670,7 +6847,8 @@ public static partial class AgcExports scissor, DecodeViewport(registers, target.Width, target.Height, scissor), DecodeRasterState(registers), - DecodeDepthState(registers)); + DecodeDepthState(registers), + DecodeBlendConstant(registers)); } private static GuestRenderState CreateRenderState( @@ -6703,7 +6881,8 @@ public static partial class AgcExports scissor, DecodeViewport(registers, target.Width, target.Height, scissor), DecodeRasterState(registers), - DecodeDepthState(registers)); + DecodeDepthState(registers), + DecodeBlendConstant(registers)); } // DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE @@ -6808,6 +6987,22 @@ public static partial class AgcExports return new GuestRasterState(cullFront, cullBack, frontFaceClockwise, wireframe); } + /// CB_BLEND_RED..ALPHA carry the constant blend color as raw + /// float bits; unwritten registers read as the reset value (0.0). + private static GuestBlendConstant DecodeBlendConstant( + IReadOnlyDictionary registers) + { + registers.TryGetValue(CbBlendRed, out var red); + registers.TryGetValue(CbBlendGreen, out var green); + registers.TryGetValue(CbBlendBlue, out var blue); + registers.TryGetValue(CbBlendAlpha, out var alpha); + return new GuestBlendConstant( + BitConverter.Int32BitsToSingle(unchecked((int)red)), + BitConverter.Int32BitsToSingle(unchecked((int)green)), + BitConverter.Int32BitsToSingle(unchecked((int)blue)), + BitConverter.Int32BitsToSingle(unchecked((int)alpha))); + } + private static GuestBlendState DecodeBlendState( IReadOnlyDictionary registers, uint slot) @@ -7320,7 +7515,7 @@ public static partial class AgcExports IReadOnlyList registers, IReadOnlyList bindings) { - var bytes = VulkanVideoPresenter.GuestDataPool.Rent( + var bytes = GuestDataPool.Shared.Rent( GetRuntimeScalarBufferLength(bindings.Count)); PackRuntimeScalarStateInto(bytes, registers, bindings); return bytes; @@ -7346,7 +7541,7 @@ public static partial class AgcExports { var byteBias = checked((uint)( bindings[index].BaseAddress & - (VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))); + (_storageBufferOffsetAlignment - 1))); BinaryPrimitives.WriteUInt32LittleEndian( bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)), byteBias); @@ -7389,7 +7584,7 @@ public static partial class AgcExports { if (binding.DataPooled && returned.Add(binding.Data)) { - VulkanVideoPresenter.GuestDataPool.Return(binding.Data); + GuestDataPool.Shared.Return(binding.Data); } } @@ -7399,7 +7594,7 @@ public static partial class AgcExports { if (binding.DataPooled && returned.Add(binding.Data)) { - VulkanVideoPresenter.GuestDataPool.Return(binding.Data); + GuestDataPool.Shared.Return(binding.Data); } } } @@ -7425,7 +7620,7 @@ public static partial class AgcExports { if (binding.DataPooled && returned.Add(binding.Data)) { - VulkanVideoPresenter.GuestDataPool.Return(binding.Data); + GuestDataPool.Shared.Return(binding.Data); } } } @@ -7436,7 +7631,7 @@ public static partial class AgcExports { if (binding.DataPooled && returned.Add(binding.Data)) { - VulkanVideoPresenter.GuestDataPool.Return(binding.Data); + GuestDataPool.Shared.Return(binding.Data); } } } @@ -7444,7 +7639,7 @@ public static partial class AgcExports if (index && draw.IndexBuffer is { Pooled: true } indexBuffer && returned.Add(indexBuffer.Data)) { - VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data); + GuestDataPool.Shared.Return(indexBuffer.Data); } } @@ -7701,7 +7896,7 @@ public static partial class AgcExports if (!isStorage && descriptor.Address != 0 && - VulkanVideoPresenter.IsGuestImageAvailable( + GuestGpu.Current.IsGpuGuestImageAvailable( descriptor.Address, descriptor.Format, descriptor.NumberType)) @@ -7730,7 +7925,7 @@ public static partial class AgcExports { var initialPixels = Array.Empty(); var uploadKnown = descriptor.Address != 0 && - VulkanVideoPresenter.IsGuestImageUploadKnown( + GuestGpu.Current.IsGuestImageUploadKnown( descriptor.Address, descriptor.Format, descriptor.NumberType); @@ -7811,8 +8006,8 @@ public static partial class AgcExports if (!_textureCopySkipDisabled && descriptor.Address != 0 && !SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) && - VulkanVideoPresenter.IsTextureContentCached( - new VulkanVideoPresenter.TextureContentIdentity( + GuestGpu.Current.IsTextureContentCached( + new TextureContentIdentity( descriptor.Address, descriptor.Width, descriptor.Height, @@ -7915,7 +8110,7 @@ public static partial class AgcExports CpuContext ctx, RenderTargetDescriptor target) { - if (!VulkanVideoPresenter.GuestImageWantsInitialData(target.Address)) + if (!GuestGpu.Current.GuestImageWantsInitialData(target.Address)) { return; } @@ -7941,7 +8136,7 @@ public static partial class AgcExports if (nonZero) { - VulkanVideoPresenter.ProvideGuestImageInitialData(target.Address, initialData); + GuestGpu.Current.ProvideGuestImageInitialData(target.Address, initialData); } } @@ -8238,9 +8433,14 @@ public static partial class AgcExports ulong packetAddress, uint packetLength, uint opcode, - out ComputeDispatch dispatch) + out ComputeDispatch dispatch, + out ulong indirectDimsRetryAddress) { dispatch = default; + // Non-zero only when this is an INDIRECT dispatch whose dimensions read as + // zero — meaning the producing GPU dispatch that computes them has not run + // yet. The caller suspends on this address instead of dropping the work. + indirectDimsRetryAddress = 0; ulong dimensionsAddress; uint initiator; string dispatchSource; @@ -8289,6 +8489,17 @@ public static partial class AgcExports if (dispatchEndX == 0 || dispatchEndY == 0 || dispatchEndZ == 0) { + // Indirect dispatches read their dimensions from a guest buffer a + // prior GPU dispatch fills. Zero here means that producer has not run + // yet — signal the caller to suspend on the dims buffer and retry, + // rather than dropping the work (which black-screens GPU-driven games + // like Astro Bot). Direct dispatches carry dims inline, so a zero is + // genuinely malformed and still rejected. + if (opcode == ItDispatchIndirect) + { + indirectDimsRetryAddress = dimensionsAddress; + } + return RejectComputeDispatch( dimensionsAddress, initiator, @@ -8623,7 +8834,7 @@ public static partial class AgcExports // still queued, so the clear could erase newly constructed CPU // objects. Waiting on the work sequence also retires preceding // Vulkan writes before the next evaluator snapshot is captured. - if (!VulkanVideoPresenter.WaitForGuestWork(semanticCopySequence)) + if (!GuestGpu.Current.WaitForGuestWork(semanticCopySequence)) { computeError = $"semantic-global-write-sync-timeout sequence={semanticCopySequence}"; @@ -8641,7 +8852,7 @@ public static partial class AgcExports localSizeY, localSizeZ, dispatch.WaveLaneCount, - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment); + _storageBufferOffsetAlignment); var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count; var totalGlobalBufferCount = _bakeScalars ? guestGlobalBufferCount @@ -8663,7 +8874,7 @@ public static partial class AgcExports : guestGlobalBufferCount, waveLaneCount: dispatch.WaveLaneCount, storageBufferOffsetAlignment: - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment)) + _storageBufferOffsetAlignment)) { DumpCompiledShader( "cs", @@ -8704,7 +8915,7 @@ public static partial class AgcExports dispatch.ThreadCountZ); gpuDispatch = true; if (writesGlobalMemory && - !VulkanVideoPresenter.WaitForGuestWork(workSequence)) + !GuestGpu.Current.WaitForGuestWork(workSequence)) { computeError = $"global-write-sync-timeout sequence={workSequence}"; } @@ -8898,7 +9109,7 @@ public static partial class AgcExports } var destinationAddress = destination.BaseAddress; - workSequence = VulkanVideoPresenter.SubmitOrderedGuestAction( + workSequence = GuestGpu.Current.SubmitOrderedGuestAction( () => { if (!ctx.Memory.TryWrite(destinationAddress, output)) @@ -8912,7 +9123,7 @@ public static partial class AgcExports GuestImageWriteTracker.Track( destinationAddress, (ulong)output.Length, - VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics, + GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics, "agc.masked-dword-copy"); }, $"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}"); @@ -9601,7 +9812,7 @@ public static partial class AgcExports pixelInputEnable: psInputEna, pixelInputAddress: psInputAddr, storageBufferOffsetAlignment: - VulkanVideoPresenter.GuestStorageBufferOffsetAlignment)) + _storageBufferOffsetAlignment)) { TraceAgcShader( $"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " + @@ -11225,4 +11436,35 @@ public static partial class AgcExports TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}"); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); } + + // Tessellation-factor ring and hull-shader off-chip buffers are guest-driver + // configuration for on-hardware tessellation memory. Our translator handles + // shader execution directly, so there is no guest-side ring to program: the + // guest driver only needs these to report success so init proceeds. Games + // (e.g. Unity titles) call them during GPU setup and stall if unresolved. + [SysAbiExport( + Nid = "XlNp7jzGiPo", + ExportName = "sceAgcDriverSetTFRing", + Target = Generation.Gen5, + LibraryName = "libSceAgcDriver")] + public static int DriverSetTFRing(CpuContext ctx) + { + TraceAgc( + $"agc.driver_set_tf_ring ring=0x{ctx[CpuRegister.Rdi]:X16} " + + $"size=0x{(uint)ctx[CpuRegister.Rsi]:X8}"); + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); + } + + [SysAbiExport( + Nid = "MM4IZSEYytQ", + ExportName = "sceAgcDriverSetHsOffchipParam", + Target = Generation.Gen5, + LibraryName = "libSceAgcDriver")] + public static int DriverSetHsOffchipParam(CpuContext ctx) + { + TraceAgc( + $"agc.driver_set_hs_offchip_param buffer=0x{ctx[CpuRegister.Rdi]:X16} " + + $"param=0x{(uint)ctx[CpuRegister.Rsi]:X8}"); + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); + } } diff --git a/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs b/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs index d6dedbc..bb6699d 100644 --- a/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs +++ b/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using SharpEmu.Libs.Gpu; using SharpEmu.Libs.Kernel; using SharpEmu.Libs.VideoOut; using SharpEmu.ShaderCompiler; @@ -28,6 +29,6 @@ internal static class AgcShaderCompilerHooks Gen5ShaderScalarEvaluator.FallbackMemoryReader = KernelMemoryCompatExports.TryReadTrackedLibcHeap; Gen5ShaderScalarEvaluator.GlobalMemoryPool = - VulkanVideoPresenter.GuestDataPool; + GuestDataPool.Shared; } } diff --git a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs index dd8b3fe..abbb3ef 100644 --- a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs +++ b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs @@ -37,10 +37,26 @@ internal static class GpuWaitRegistry public long RegisteredTicks; public bool StaleReported; public object? State; + // Latched by LatchSatisfiedByValue when a producer wrote a value that + // satisfies this waiter. The label is frequently reused (reset to 0 for + // the next frame) immediately after the producing write, so re-reading + // guest memory at wake time can miss the transient satisfied window. + // Latching records satisfaction at the moment of the write instead. + public bool Latched; + // Non-zero for indirect-dispatch dimension retries: a bounded deadline + // (Stopwatch ticks) after which the waiter is resumed even if unsatisfied, + // so a legitimately empty indirect dispatch can never stall forever. + public long RetryDeadlineTicks; } private static readonly object _gate = new(); private static readonly Dictionary> _waiters = new(); + // The last value each label producer wrote. Used only by the deadlock + // breaker: our serial submission parser cannot model two GPU queues running + // concurrently, so a label written -> reset -> re-waited across queues can + // cycle forever even though a real producer did signal it. Keyed by (memory, + // address) so distinct guest processes never alias. + private static readonly Dictionary<(object, ulong), ulong> _lastProduced = new(); public static int Count { @@ -114,8 +130,14 @@ internal static class GpuWaitRegistry continue; } - var value = readValue(address, list[i].Is64Bit); - if (value is null || !Compare(list[i], value.Value)) + var satisfied = list[i].Latched; + if (!satisfied) + { + var value = readValue(address, list[i].Is64Bit); + satisfied = value is not null && Compare(list[i], value.Value); + } + + if (!satisfied) { continue; } @@ -236,6 +258,162 @@ internal static class GpuWaitRegistry return matches; } + /// + /// Records satisfaction for every waiter at whose + /// condition is met by — the value a producer just + /// wrote to that label. Called from the ordered producer side effect so a + /// same-frame label reset cannot lose the wakeup. The waiters stay registered + /// (latched) and are drained by the next CollectSatisfied. Returns true when + /// at least one waiter latched, so the caller can trigger a wake pass. + /// + public static bool LatchSatisfiedByValue(object memory, ulong address, ulong value) + { + var latchedAny = false; + lock (_gate) + { + if (!_waiters.TryGetValue(address, out var list)) + { + return false; + } + + for (var i = 0; i < list.Count; i++) + { + var waiter = list[i]; + if (waiter.Latched || + !ReferenceEquals(waiter.Memory, memory) || + !Compare(waiter, value)) + { + continue; + } + + waiter.Latched = true; + list[i] = waiter; + latchedAny = true; + } + } + + return latchedAny; + } + + /// + /// Removes and returns waiters carrying a + /// that has elapsed. Used for indirect-dispatch dimension retries: the caller + /// resumes them so a genuinely empty dispatch (dims that never become non-zero) + /// is dropped after a bounded wait instead of stalling the queue forever. + /// + public static List? CollectExpiredRetries(object memory, long nowTicks) + { + List? expired = null; + lock (_gate) + { + List? emptied = null; + foreach (var (address, list) in _waiters) + { + for (var i = list.Count - 1; i >= 0; i--) + { + var waiter = list[i]; + if (waiter.RetryDeadlineTicks == 0 || + !ReferenceEquals(waiter.Memory, memory) || + nowTicks < waiter.RetryDeadlineTicks) + { + continue; + } + + expired ??= new List(); + expired.Add(waiter); + list.RemoveAt(i); + } + + if (list.Count == 0) + { + emptied ??= new List(); + emptied.Add(address); + } + } + + if (emptied is not null) + { + foreach (var address in emptied) + { + _waiters.Remove(address); + } + } + } + + return expired; + } + + /// Records the value a label producer wrote, for the deadlock + /// breaker. Also latches any already-waiting waiter it satisfies. + public static bool RecordProduced(object memory, ulong address, ulong value) + { + lock (_gate) + { + if (_lastProduced.Count >= 8192) + { + _lastProduced.Clear(); + } + + _lastProduced[(memory, address)] = value; + } + + return LatchSatisfiedByValue(memory, address, value); + } + + /// + /// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns + /// (and removes) waiters that have been stuck longer than + /// and whose condition is satisfied by the + /// last value a real producer wrote to their label — even though guest + /// memory has since been reset. Never fabricates a value: a waiter is only + /// released when an actual producer signalled it at least once. + /// + public static List? CollectDeadlockBroken( + object memory, + long nowTicks, + long minAgeTicks) + { + List? broken = null; + lock (_gate) + { + List? emptied = null; + foreach (var (address, list) in _waiters) + { + for (var i = list.Count - 1; i >= 0; i--) + { + var waiter = list[i]; + if (!ReferenceEquals(waiter.Memory, memory) || + nowTicks - waiter.RegisteredTicks < minAgeTicks || + !_lastProduced.TryGetValue((memory, address), out var produced) || + !Compare(waiter, produced)) + { + continue; + } + + broken ??= new List(); + broken.Add(waiter); + list.RemoveAt(i); + } + + if (list.Count == 0) + { + emptied ??= new List(); + emptied.Add(address); + } + } + + if (emptied is not null) + { + foreach (var address in emptied) + { + _waiters.Remove(address); + } + } + } + + return broken; + } + public static bool Compare(in WaitingDcb waiter, ulong value) { var masked = value & waiter.Mask; @@ -260,6 +438,7 @@ internal static class GpuWaitRegistry lock (_gate) { _waiters.Clear(); + _lastProduced.Clear(); } } } diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs index a736b8c..ceee3fb 100644 --- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs @@ -14,6 +14,12 @@ public static class AudioOutExports private static readonly ConcurrentDictionary Ports = new(); private static int _nextPortHandle; + // Diagnostic: confirm sceAudioOutOutput is actually called and whether the + // guest submits real samples or silence. Gated so it costs nothing when off. + private static readonly bool _traceOutput = string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_OUT"), "1", StringComparison.Ordinal); + private static long _outputCount; + private sealed class PortState : IDisposable { private readonly object _paceGate = new(); @@ -155,6 +161,37 @@ public static class AudioOutExports return ctx.SetReturn(0); } + [SysAbiExport( + Nid = "GrQ9s4IrNaQ", + ExportName = "sceAudioOutGetPortState", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAudioOut")] + public static int AudioOutGetPortState(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + var stateAddress = ctx[CpuRegister.Rsi]; + if (stateAddress == 0 || !Ports.TryGetValue(handle, out var port)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + // 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. + Span state = stackalloc byte[16]; + state.Clear(); + System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(state, 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian( + state[2..], (ushort)port.Channels); + state[7] = 127; + if (!ctx.Memory.TryWrite(stateAddress, state)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + return ctx.SetReturn(0); + } + [SysAbiExport( Nid = "QOQtbeDqsT4", ExportName = "sceAudioOutOutput", @@ -166,7 +203,12 @@ public static class AudioOutExports var sourceAddress = ctx[CpuRegister.Rsi]; if (!Ports.TryGetValue(handle, out var port)) { - return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + // Host shutdown disposes the ports while guest audio threads are + // still draining their last buffers; report success so the guest + // winds down without a per-buffer error (and its WARN log flood). + return ctx.SetReturn(_shutdown + ? 0 + : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } if (sourceAddress == 0) @@ -183,6 +225,17 @@ public static class AudioOutExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + if (_traceOutput) + { + var n = Interlocked.Increment(ref _outputCount); + if (n <= 8 || n % 200 == 0) + { + var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample); + Console.Error.WriteLine( + $"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}"); + } + } + if (port.Backend is null) { port.PaceSilence(); @@ -266,8 +319,40 @@ public static class AudioOutExports return ctx.SetReturn(0); } + // Peak normalized amplitude [0,1] of an interleaved PCM buffer, used only by + // the SHARPEMU_LOG_AUDIO_OUT diagnostic to distinguish real audio from silence. + private static float PeakAmplitude(ReadOnlySpan source, bool isFloat, int bytesPerSample) + { + var peak = 0f; + if (isFloat && bytesPerSample == 4) + { + for (var i = 0; i + 4 <= source.Length; i += 4) + { + var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(source.Slice(i, 4))); + if (v > peak) + { + peak = v; + } + } + } + else if (bytesPerSample == 2) + { + for (var i = 0; i + 2 <= source.Length; i += 2) + { + var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(source.Slice(i, 2)) / 32768f); + if (v > peak) + { + peak = v; + } + } + } + + return peak; + } + public static void ShutdownAllPorts() { + Volatile.Write(ref _shutdown, true); foreach (var handle in Ports.Keys) { if (Ports.TryRemove(handle, out var port)) @@ -277,6 +362,8 @@ public static class AudioOutExports } } + private static bool _shutdown; + private static bool TryGetFormat( int rawFormat, out int channels, diff --git a/src/SharpEmu.Libs/Audio/AudioPropagationExports.cs b/src/SharpEmu.Libs/Audio/AudioPropagationExports.cs new file mode 100644 index 0000000..806198e --- /dev/null +++ b/src/SharpEmu.Libs/Audio/AudioPropagationExports.cs @@ -0,0 +1,154 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; + +namespace SharpEmu.Libs.Audio; + +// PS5 acoustic-propagation (3D-audio ray/portal/room) module. We do not model +// acoustic propagation; the geometry-driven reverb/occlusion it produces is a +// quality feature, not a correctness gate. Games (e.g. Astro Bot) call it +// during audio init and hard-assert if any entry point is missing: +// ASSERT ... sceAudioPropagationSystemQueryMemory failed : 0x80020002 +// The API is placement-style: QueryMemory reports a buffer size, the game +// allocates it, and the "system"/objects live inside that caller-owned buffer, +// so success-returning stubs let init proceed without us owning any state. +public static class AudioPropagationExports +{ + private const int Ok = 0; + + // QueryMemory reports the working-set size the caller must allocate before + // SystemCreate. rsi points at the out size/alignment; write a modest, + // aligned block so the caller's allocation succeeds. + [SysAbiExport( + Nid = "7xyAxrusLko", + ExportName = "sceAudioPropagationSystemQueryMemory", + Target = Generation.Gen5, + LibraryName = "libSceAudioPropagation")] + public static int SystemQueryMemory(CpuContext ctx) + { + var outAddress = ctx[CpuRegister.Rsi]; + if (outAddress != 0) + { + // {size, alignment} — 1 MiB / 256 B covers the caller's allocation. + ctx.TryWriteUInt64(outAddress, 0x10_0000); + ctx.TryWriteUInt64(outAddress + sizeof(ulong), 0x100); + } + + return ctx.SetReturn(Ok); + } + + [SysAbiExport(Nid = "GrA9ke1QT+E", ExportName = "sceAudioPropagationSystemQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "aNEqtSHdUSo", ExportName = "sceAudioPropagationSystemCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemCreate(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "x5VPqg5iyAk", ExportName = "sceAudioPropagationSystemDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemDestroy(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "ile38Gl-p5M", ExportName = "sceAudioPropagationSystem", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int System(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "cMl3u+7QBBM", ExportName = "sceAudioPropagationSystemMemoryInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemMemoryInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "3B9IabLByyM", ExportName = "sceAudioPropagationSystemOptionInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemOptionInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "B2KI2AachWE", ExportName = "sceAudioPropagationSystemLock", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemLock(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "kIdb+iQUzCs", ExportName = "sceAudioPropagationSystemSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "VlBT16890mA", ExportName = "sceAudioPropagationSystemSetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemSetRays(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "ht-QXT3zGxo", ExportName = "sceAudioPropagationSystemGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemGetRays(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "CPLV6G-eXmk", ExportName = "sceAudioPropagationSystemRegisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemRegisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "XKCN4gpeYsM", ExportName = "sceAudioPropagationSystemUnregisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SystemUnregisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "8bI5h8req30", ExportName = "sceAudioPropagationRoomCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int RoomCreate(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "S0JwP2AFTTE", ExportName = "sceAudioPropagationRoomDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int RoomDestroy(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "b-dYXrjSNZU", ExportName = "sceAudioPropagationPortalCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int PortalCreate(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "ZQXE-xS6MTE", ExportName = "sceAudioPropagationPortalDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int PortalDestroy(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "WXMhENV2NcA", ExportName = "sceAudioPropagationPortalSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int PortalSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "i687TNRF+hw", ExportName = "sceAudioPropagationPortalSettingsInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int PortalSettingsInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "d84otraxt2s", ExportName = "sceAudioPropagationSourceCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceCreate(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "wkseM3LWPuc", ExportName = "sceAudioPropagationSourceDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceDestroy(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "-wsUTr31yeg", ExportName = "sceAudioPropagationSourceSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "PBcrVpEqUVY", ExportName = "sceAudioPropagationSourceCalculateAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceCalculateAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "eEeKqFeNI3o", ExportName = "sceAudioPropagationSourceGetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceGetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "G+QLTfyLMYk", ExportName = "sceAudioPropagationSourceGetAudioPathCount", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceGetAudioPathCount(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "aKJZx7wCma8", ExportName = "sceAudioPropagationSourceGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceGetRays(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "3aEY9tPXGKc", ExportName = "sceAudioPropagationSourceQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "hhz9pITnC8k", ExportName = "sceAudioPropagationSourceRender", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceRender(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "SoKPzY1-3SU", ExportName = "sceAudioPropagationSourceRenderInfoInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceRenderInfoInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "tKSmk2JsMAA", ExportName = "sceAudioPropagationSourceSetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceSetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "5vzOS2pHMFc", ExportName = "sceAudioPropagationSourceSetAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceSetAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "MNmGapXrYRs", ExportName = "sceAudioPropagationSourceSetAudioPathsParamInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int SourceSetAudioPathsParamInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "i-0aUex3zCE", ExportName = "sceAudioPropagationAudioPathInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int AudioPathInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "JZIkSbmt2BE", ExportName = "sceAudioPropagationAudioPathPointInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int AudioPathPointInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "tL2AEPejVQE", ExportName = "sceAudioPropagationPathGetNumPoints", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int PathGetNumPoints(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "2BSFmuKtRss", ExportName = "sceAudioPropagationMaterialInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int MaterialInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "0r2+9UTg1BA", ExportName = "sceAudioPropagationRayInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int RayInit(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "BbOT4vBwAjs", ExportName = "sceAudioPropagationResetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int ResetAttributes(CpuContext ctx) => ctx.SetReturn(Ok); + + [SysAbiExport(Nid = "gCmQm6dvMxw", ExportName = "sceAudioPropagationReportApi", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")] + public static int ReportApi(CpuContext ctx) => ctx.SetReturn(Ok); +} diff --git a/src/SharpEmu.Libs/Gpu/GuestDataPool.cs b/src/SharpEmu.Libs/Gpu/GuestDataPool.cs new file mode 100644 index 0000000..d885bdc --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/GuestDataPool.cs @@ -0,0 +1,21 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers; + +namespace SharpEmu.Libs.Gpu; + +/// +/// The pool backing AGC-to-presenter ownership transfers, shared by every backend +/// (the AGC layer rents, the presenter returns, so both sides must use one pool). +/// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size classes +/// thousands of times per second; the process-wide shared pool trims and +/// repartitions those large arrays aggressively under GC load, causing hundreds of +/// MiB/s of replacement byte[] allocations, so this pool is bounded and non-shared. +/// +internal static class GuestDataPool +{ + public static ArrayPool Shared { get; } = ArrayPool.Create( + maxArrayLength: 16 * 1024 * 1024, + maxArraysPerBucket: 96); +} diff --git a/src/SharpEmu.Libs/Gpu/GuestGpu.cs b/src/SharpEmu.Libs/Gpu/GuestGpu.cs index 78c6b1f..2402c60 100644 --- a/src/SharpEmu.Libs/Gpu/GuestGpu.cs +++ b/src/SharpEmu.Libs/Gpu/GuestGpu.cs @@ -1,6 +1,7 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using SharpEmu.Libs.Gpu.Metal; using SharpEmu.Libs.Gpu.Vulkan; namespace SharpEmu.Libs.Gpu; @@ -8,11 +9,39 @@ namespace SharpEmu.Libs.Gpu; /// /// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the /// host seam: static HLE export classes resolve the renderer through . -/// Vulkan is the only backend today; Metal/DX12 slot in here. +/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal +/// backend (macOS only) while it is being brought up. macOS flips to Metal by default +/// once the presenter reaches parity. /// internal static class GuestGpu { - private static readonly Lazy Instance = new(static () => new VulkanGuestGpuBackend()); + private static readonly Lazy Instance = new(Create); public static IGuestGpuBackend Current => Instance.Value; + + private static IGuestGpuBackend Create() + { + var requested = Environment.GetEnvironmentVariable("SHARPEMU_GPU_BACKEND"); + if (string.IsNullOrEmpty(requested) || requested.Equals("vulkan", StringComparison.OrdinalIgnoreCase)) + { + return new VulkanGuestGpuBackend(); + } + + if (requested.Equals("metal", StringComparison.OrdinalIgnoreCase)) + { + if (!OperatingSystem.IsMacOS()) + { + Console.Error.WriteLine( + "[LOADER][WARN] SHARPEMU_GPU_BACKEND=metal is only available on macOS; using Vulkan."); + return new VulkanGuestGpuBackend(); + } + + Console.Error.WriteLine("[LOADER][INFO] GPU backend: Metal (SHARPEMU_GPU_BACKEND)."); + return new MetalGuestGpuBackend(); + } + + Console.Error.WriteLine( + $"[LOADER][WARN] Unknown SHARPEMU_GPU_BACKEND value '{requested}'; using Vulkan."); + return new VulkanGuestGpuBackend(); + } } diff --git a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs index 9c143a3..70b25ad 100644 --- a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs +++ b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs @@ -36,6 +36,20 @@ internal readonly record struct GuestSampler( uint Word2, uint Word3); +/// Identity of a texture's content in a backend texture cache, keyed +/// entirely on raw guest descriptor values; the AGC layer uses it to skip texel +/// copies for content the backend already holds. +internal readonly record struct TextureContentIdentity( + ulong Address, + uint Width, + uint Height, + uint Format, + uint NumberType, + uint DstSelect, + uint TileMode, + uint Pitch, + GuestSampler Sampler); + internal sealed record GuestMemoryBuffer( ulong BaseAddress, byte[] Data, @@ -122,12 +136,22 @@ internal readonly record struct GuestBlendState( WriteMask: 0xFu); } +/// CB_BLEND_RED..ALPHA: the constant color referenced by the +/// CONSTANT_COLOR / CONSTANT_ALPHA blend factors. One constant serves every +/// render target of a draw; the hardware reset value is transparent black. +internal readonly record struct GuestBlendConstant( + float Red, + float Green, + float Blue, + float Alpha); + internal sealed record GuestRenderState( IReadOnlyList Blends, GuestRect? Scissor, GuestViewport? Viewport, GuestRasterState Raster, - GuestDepthState Depth) + GuestDepthState Depth, + GuestBlendConstant BlendConstant = default) { public static GuestRenderState Default { get; } = new( [GuestBlendState.Default], diff --git a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs index 371ad82..e1b1ef4 100644 --- a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs +++ b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs @@ -1,6 +1,7 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using SharpEmu.HLE; using SharpEmu.ShaderCompiler; namespace SharpEmu.Libs.Gpu; @@ -17,6 +18,10 @@ namespace SharpEmu.Libs.Gpu; /// internal interface IGuestGpuBackend { + /// Human-readable name of this backend ("Metal", "Vulkan"), shown in + /// the window title on macOS where either backend can run. + string BackendName { get; } + /// Starts the presenter (window + device) once; safe to call repeatedly. void EnsureStarted(uint width, uint height); @@ -188,4 +193,70 @@ internal interface IGuestGpuBackend /// the guest codes cross the seam and each backend maps them internally. /// bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind); + + // Guest work ordering. AGC submissions execute on a single backend consumer in + // logical guest-queue order; sequences returned here are backend work tickets. + // A backend without a running presenter returns 0 from the Submit* methods and + // callers fall back to executing inline. + + /// Scopes subsequent submissions on this thread to a named guest queue. + IDisposable EnterGuestQueue(string queueName, ulong submissionId); + + /// Enqueues an action at its exact position in the current guest queue; + /// returns its work sequence, or 0 when nothing could be enqueued. + long SubmitOrderedGuestAction(Action action, string debugName); + + /// Preserves sceAgcDcbWaitUntilSafeForRendering in queue order. + long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex); + + /// Blocks until the given work sequence completes; false on timeout, + /// close, or a non-positive sequence. + bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite); + + /// Sequence currently executing on the guest-work consumer; diagnostics only. + long CurrentGuestWorkSequenceForDiagnostics { get; } + + // Guest image lifecycle beyond presentation: CPU-visible seeding, writes, and + // extent queries the AGC layer uses to keep guest memory and backend images + // coherent. Addresses and formats are always raw guest values. + + /// Whether the image exists on the backend or an already-queued upload + /// owns its initialization (a pending image may skip a duplicate upload but is + /// not yet a valid flip source). + bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType); + + /// True when the first draw into this address must seed the backend + /// image from guest memory (PS5 render targets alias guest memory, so + /// CPU-prefilled pixels are visible before the first draw). + bool GuestImageWantsInitialData(ulong address); + + void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels); + + void SubmitGuestImageFill(ulong address, uint fillValue); + + void SubmitGuestImageWrite(ulong address, byte[] pixels); + + bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount); + + IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents(); + + /// Whether the backend's texture cache already holds this content; lets + /// the AGC layer skip copying texels out of guest memory on every draw. + bool IsTextureContentCached(in TextureContentIdentity identity); + + /// Guest memory handle for backend self-healing (cache misses re-read + /// texels directly instead of showing a fallback pattern). + void AttachGuestMemory(ICpuMemory memory); + + /// Alignment the AGC layer must apply to storage-buffer offsets before + /// they cross the seam. + ulong GuestStorageBufferOffsetAlignment { get; } + + /// Counts a guest shader translation for the perf overlay. + void CountShaderCompilation(); + + (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters(); + + /// Asks a running presenter to close its window. + void RequestClose(); } diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs new file mode 100644 index 0000000..454e585 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs @@ -0,0 +1,28 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text; +using SharpEmu.ShaderCompiler.Metal; + +namespace SharpEmu.Libs.Gpu.Metal; + +/// +/// The Metal backend's compiled shader: MSL source plus the reflection data +/// () the presenter needs to create and bind pipeline +/// states. The diagnostics payload is the source text — Metal has no portable +/// binary form until an MTLBinaryArchive is introduced. +/// +internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader +{ + private byte[]? _payload; + + public Gen5MslShader Shader { get; } = shader; + + /// MTLLibrary handle cached by the presenter after the first + /// runtime compile; the render loop is its only reader and writer. + internal nint CachedLibrary; + + public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source); + + public string PayloadFileExtension => "msl"; +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs new file mode 100644 index 0000000..2715eac --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs @@ -0,0 +1,270 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.Libs.Gpu.Metal; + +/// +/// MTLPixelFormat raw values — only the formats the backend maps. Declared here +/// rather than pulled from a binding package: the Metal backend talks to the OS +/// exclusively through objc_msgSend, so ABI constants are owned locally. +/// +internal enum MtlPixelFormat : uint +{ + Invalid = 0, + R8Unorm = 10, + R8Snorm = 12, + R8Uint = 13, + R8Sint = 14, + R16Unorm = 20, + R16Snorm = 22, + R16Uint = 23, + R16Sint = 24, + R16Float = 25, + Rg8Unorm = 30, + Rg8Snorm = 32, + Rg8Uint = 33, + Rg8Sint = 34, + B5G6R5Unorm = 40, + R32Uint = 53, + R32Sint = 54, + R32Float = 55, + Rg16Unorm = 60, + Rg16Uint = 63, + Rg16Sint = 64, + Rg16Float = 65, + Rgba8Unorm = 70, + Rgba8UnormSrgb = 71, + Rgba8Uint = 73, + Rgba8Sint = 74, + Bgra8Unorm = 80, + Bgra8UnormSrgb = 81, + Rgb10A2Unorm = 90, + Rg11B10Float = 92, + Rgb9E5Float = 93, + Bgr10A2Unorm = 94, + Rg32Uint = 103, + Rg32Sint = 104, + Rg32Float = 105, + Rgba16Unorm = 110, + Rgba16Uint = 113, + Rgba16Sint = 114, + Rgba16Float = 115, + Rgba32Uint = 123, + Rgba32Sint = 124, + Rgba32Float = 125, + Bc1Rgba = 130, + Bc1RgbaSrgb = 131, + Bc2Rgba = 132, + Bc2RgbaSrgb = 133, + Bc3Rgba = 134, + Bc3RgbaSrgb = 135, + Bc4RUnorm = 140, + Bc4RSnorm = 141, + Bc5RgUnorm = 142, + Bc5RgSnorm = 143, + Bc6HRgbFloat = 150, + Bc6HRgbUfloat = 151, + Bc7RgbaUnorm = 152, + Bc7RgbaUnormSrgb = 153, + Depth32Float = 252, +} + +/// A sampled-texture format: the Metal pixel format plus the byte +/// layout the upload path needs. is nonzero for +/// block-compressed formats (bytes per 4x4 block); otherwise +/// applies. +internal readonly record struct MetalTextureFormat( + MtlPixelFormat Format, + uint BytesPerPixel, + uint BlockBytes) +{ + public bool IsBlockCompressed => BlockBytes != 0; +} + +internal readonly record struct MetalRenderTargetFormat( + MtlPixelFormat Format, + Gen5PixelOutputKind OutputKind) +{ + public static uint GetBytesPerPixel(MtlPixelFormat format) => + format switch + { + MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Uint => 1, + MtlPixelFormat.Rg8Unorm => 2, + MtlPixelFormat.Rg32Float => 8, + MtlPixelFormat.Rgba16Unorm or MtlPixelFormat.Rgba16Uint or + MtlPixelFormat.Rgba16Sint or MtlPixelFormat.Rgba16Float => 8, + MtlPixelFormat.Rgba32Float => 16, + _ => 4, + }; +} + +/// +/// Guest texture-descriptor codes to Metal formats, mirroring the Vulkan +/// backend's table case for case so both backends accept the same guest +/// formats. Guest format 9 (2:10:10:10) maps to BGR10A2 — the bit layout that +/// matches Vulkan's A2R10G10B10 pack. +/// +internal static class MetalGuestFormats +{ + /// Guest sampled-texture format to Metal, mirroring the Vulkan + /// backend's GetTextureFormat case for case (including its RGBA8 fallback + /// for unmapped codes, so unknown formats render something rather than + /// nothing). BC formats upload raw blocks — Mac-family GPUs decode them + /// natively. + public static MetalTextureFormat DecodeTextureFormat(uint dataFormat, uint numberType) + { + var format = (dataFormat, numberType) switch + { + (1, 0) => MtlPixelFormat.R8Unorm, + (1, 1) => MtlPixelFormat.R8Snorm, + (1, 4) => MtlPixelFormat.R8Uint, + (1, 5) => MtlPixelFormat.R8Sint, + (2, 0) => MtlPixelFormat.R16Unorm, + (2, 1) => MtlPixelFormat.R16Snorm, + (2, 4) => MtlPixelFormat.R16Uint, + (2, 5) => MtlPixelFormat.R16Sint, + (2, 7) => MtlPixelFormat.R16Float, + (3, 0) => MtlPixelFormat.Rg8Unorm, + (3, 1) => MtlPixelFormat.Rg8Snorm, + (3, 4) => MtlPixelFormat.Rg8Uint, + (3, 5) => MtlPixelFormat.Rg8Sint, + (4, 4) => MtlPixelFormat.R32Uint, + (4, 5) => MtlPixelFormat.R32Sint, + (4, 7) => MtlPixelFormat.R32Float, + (5, 0) => MtlPixelFormat.Rg16Unorm, + (5, 4) => MtlPixelFormat.Rg16Uint, + (5, 5) => MtlPixelFormat.Rg16Sint, + (5, 7) => MtlPixelFormat.Rg16Float, + (6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float, + (8, _) or (9, _) => MtlPixelFormat.Bgr10A2Unorm, + (10, 4) => MtlPixelFormat.Rgba8Uint, + (10, 5) => MtlPixelFormat.Rgba8Sint, + (10, 9) => MtlPixelFormat.Rgba8UnormSrgb, + (11, 4) => MtlPixelFormat.Rg32Uint, + (11, 5) => MtlPixelFormat.Rg32Sint, + (11, 7) => MtlPixelFormat.Rg32Float, + (12, 0) => MtlPixelFormat.Rgba16Unorm, + (12, 4) => MtlPixelFormat.Rgba16Uint, + (12, 5) => MtlPixelFormat.Rgba16Sint, + (12, 7) => MtlPixelFormat.Rgba16Float, + (13, 4) or (14, 4) => MtlPixelFormat.Rgba32Uint, + (13, 5) or (14, 5) => MtlPixelFormat.Rgba32Sint, + (13, _) or (14, _) => MtlPixelFormat.Rgba32Float, + (16, 0) => MtlPixelFormat.B5G6R5Unorm, + (34, 7) => MtlPixelFormat.Rgb9E5Float, + (169, _) => MtlPixelFormat.Bc1Rgba, + (170, _) => MtlPixelFormat.Bc1RgbaSrgb, + (171, _) => MtlPixelFormat.Bc2Rgba, + (172, _) => MtlPixelFormat.Bc2RgbaSrgb, + (173, _) => MtlPixelFormat.Bc3Rgba, + (174, _) => MtlPixelFormat.Bc3RgbaSrgb, + (175, 1) or (176, _) => MtlPixelFormat.Bc4RSnorm, + (175, _) => MtlPixelFormat.Bc4RUnorm, + (177, 1) or (178, _) => MtlPixelFormat.Bc5RgSnorm, + (177, _) => MtlPixelFormat.Bc5RgUnorm, + (179, _) => MtlPixelFormat.Bc6HRgbUfloat, + (180, _) => MtlPixelFormat.Bc6HRgbFloat, + (181, _) => MtlPixelFormat.Bc7RgbaUnorm, + (182, _) => MtlPixelFormat.Bc7RgbaUnormSrgb, + _ => MtlPixelFormat.Rgba8Unorm, + }; + + var blockBytes = format switch + { + MtlPixelFormat.Bc1Rgba or MtlPixelFormat.Bc1RgbaSrgb or + MtlPixelFormat.Bc4RUnorm or MtlPixelFormat.Bc4RSnorm => 8u, + MtlPixelFormat.Bc2Rgba or MtlPixelFormat.Bc2RgbaSrgb or + MtlPixelFormat.Bc3Rgba or MtlPixelFormat.Bc3RgbaSrgb or + MtlPixelFormat.Bc5RgUnorm or MtlPixelFormat.Bc5RgSnorm or + MtlPixelFormat.Bc6HRgbFloat or MtlPixelFormat.Bc6HRgbUfloat or + MtlPixelFormat.Bc7RgbaUnorm or MtlPixelFormat.Bc7RgbaUnormSrgb => 16u, + _ => 0u, + }; + + var bytesPerPixel = format switch + { + MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Snorm or + MtlPixelFormat.R8Uint or MtlPixelFormat.R8Sint => 1u, + MtlPixelFormat.R16Unorm or MtlPixelFormat.R16Snorm or + MtlPixelFormat.R16Uint or MtlPixelFormat.R16Sint or + MtlPixelFormat.R16Float or MtlPixelFormat.Rg8Unorm or + MtlPixelFormat.Rg8Snorm or MtlPixelFormat.Rg8Uint or + MtlPixelFormat.Rg8Sint or MtlPixelFormat.B5G6R5Unorm => 2u, + MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rg32Sint or + MtlPixelFormat.Rg32Float or MtlPixelFormat.Rgba16Unorm or + MtlPixelFormat.Rgba16Uint or MtlPixelFormat.Rgba16Sint or + MtlPixelFormat.Rgba16Float => 8u, + MtlPixelFormat.Rgba32Uint or MtlPixelFormat.Rgba32Sint or + MtlPixelFormat.Rgba32Float => 16u, + _ => 4u, + }; + + return new MetalTextureFormat(format, bytesPerPixel, blockBytes); + } + + /// Source byte footprint of a sampled texture, block-aware — + /// the same math the AGC layer uses to size the texel copy it ships. + public static ulong GetTextureByteCount(in MetalTextureFormat format, uint width, uint height) => + format.IsBlockCompressed + ? checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * format.BlockBytes) + : checked((ulong)width * height * format.BytesPerPixel); + + public static bool TryDecodeRenderTargetFormat( + uint dataFormat, + uint numberType, + out MetalRenderTargetFormat result) + { + var format = (dataFormat, numberType) switch + { + (4, 4) => MtlPixelFormat.R32Uint, + (4, 5) => MtlPixelFormat.R32Sint, + (4, 7) => MtlPixelFormat.R32Float, + (5, 4) => MtlPixelFormat.Rg16Uint, + (5, 5) => MtlPixelFormat.Rg16Sint, + (5, 7) => MtlPixelFormat.Rg16Float, + (6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float, + (9, _) => MtlPixelFormat.Bgr10A2Unorm, + (10, 4) => MtlPixelFormat.Rgba8Uint, + (10, 5) => MtlPixelFormat.Rgba8Sint, + (10, 9) => MtlPixelFormat.Rgba8UnormSrgb, + (10, _) => MtlPixelFormat.Rgba8Unorm, + (11, 7) => MtlPixelFormat.Rg32Float, + (12, 4) => MtlPixelFormat.Rgba16Uint, + (12, 5) => MtlPixelFormat.Rgba16Sint, + (12, 7) => MtlPixelFormat.Rgba16Float, + (13, 7) or (14, 7) => MtlPixelFormat.Rgba32Float, + (20, 0) => MtlPixelFormat.R32Uint, + (29, 0) or (4, 0) => MtlPixelFormat.R32Float, + (1, 0) or (36, 0) => MtlPixelFormat.R8Unorm, + (49, 0) => MtlPixelFormat.R8Uint, + (3, 0) => MtlPixelFormat.Rg8Unorm, + (5, 0) => MtlPixelFormat.Rg16Unorm, + (7, 0) => MtlPixelFormat.Rg11B10Float, + (12, 0) => MtlPixelFormat.Rgba16Unorm, + (13, 0) or (14, 0) => MtlPixelFormat.Rgba32Float, + (22, 0) or (71, 0) => MtlPixelFormat.Rgba16Float, + (56, 0) or (62, 0) or (64, 0) => MtlPixelFormat.Rgba8Unorm, + (75, 0) => MtlPixelFormat.Rg32Float, + _ => MtlPixelFormat.Invalid, + }; + + if (format == MtlPixelFormat.Invalid) + { + result = default; + return false; + } + + 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, + _ => Gen5PixelOutputKind.Float, + }; + result = new MetalRenderTargetFormat(format, outputKind); + return true; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs new file mode 100644 index 0000000..deae08e --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs @@ -0,0 +1,426 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Metal; + +namespace SharpEmu.Libs.Gpu.Metal; + +/// +/// Metal backend for the guest-GPU seam: MSL codegen via +/// SharpEmu.ShaderCompiler.Metal, rendering via the Metal presenter — the full +/// surface (presentation, guest images, ordered flips, translated draws, and +/// compute) with no Vulkan, MoltenVK, or windowing-library dependency. +/// +internal sealed class MetalGuestGpuBackend : IGuestGpuBackend +{ + public string BackendName => "Metal"; + + private static readonly IGuestCompiledShader DepthOnlyFragmentShader = + new MetalCompiledGuestShader(new Gen5MslShader( + MslFixedShaders.CreateDepthOnlyFragment(), + "depth_only_fs", + Gen5MslStage.Pixel, + [], + [], + AttributeCount: 0, + [])); + + public bool TryCompileVertexShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + out IGuestCompiledShader? shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int scalarRegisterBufferIndex = -1, + int requiredVertexOutputCount = 0, + ulong storageBufferOffsetAlignment = 1) + { + shader = null; + if (!Gen5MslTranslator.TryCompileVertexShader( + state, + evaluation, + out var compiled, + out error, + globalBufferBase, + totalGlobalBufferCount, + imageBindingBase, + scalarRegisterBufferIndex, + requiredVertexOutputCount, + storageBufferOffsetAlignment)) + { + return false; + } + + shader = new MetalCompiledGuestShader(compiled); + return true; + } + + public bool TryCompilePixelShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + IReadOnlyList outputs, + out IGuestCompiledShader? shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int scalarRegisterBufferIndex = -1, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + ulong storageBufferOffsetAlignment = 1) + { + shader = null; + if (!Gen5MslTranslator.TryCompilePixelShader( + state, + evaluation, + outputs, + out var compiled, + out error, + globalBufferBase, + totalGlobalBufferCount, + imageBindingBase, + scalarRegisterBufferIndex, + pixelInputEnable, + pixelInputAddress, + storageBufferOffsetAlignment)) + { + return false; + } + + shader = new MetalCompiledGuestShader(compiled); + return true; + } + + public bool TryCompileComputeShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + uint localSizeX, + uint localSizeY, + uint localSizeZ, + out IGuestCompiledShader? shader, + out string error, + int totalGlobalBufferCount = -1, + int initialScalarBufferIndex = -1, + uint waveLaneCount = 32, + ulong storageBufferOffsetAlignment = 1) + { + shader = null; + // Wave64 compute is emulated by the translator: cross-lane ops bridge + // the two 32-wide Apple simdgroups of a guest wave through threadgroup + // scratch, and wave-agnostic kernels run per-thread unchanged. + if (!Gen5MslTranslator.TryCompileComputeShader( + state, + evaluation, + localSizeX, + localSizeY, + localSizeZ, + out var compiled, + out error, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount, + storageBufferOffsetAlignment)) + { + return false; + } + + shader = new MetalCompiledGuestShader(compiled); + return true; + } + + public IGuestCompiledShader GetDepthOnlyFragmentShader() => + DepthOnlyFragmentShader; + + public bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind) + { + if (MetalGuestFormats.TryDecodeRenderTargetFormat(dataFormat, numberType, out var format)) + { + outputKind = format.OutputKind; + return true; + } + + outputKind = default; + return false; + } + + public void EnsureStarted(uint width, uint height) => + MetalVideoPresenter.EnsureStarted(width, height); + + public void HideSplashScreen() => + MetalVideoPresenter.HideSplashScreen(); + + public void Submit(byte[] bgraFrame, uint width, uint height) => + MetalVideoPresenter.Submit(bgraFrame, width, height); + + public bool TrySubmitGuestImage( + ulong address, + uint width, + uint height, + uint pitchInPixel) => + MetalVideoPresenter.TrySubmitGuestImage(address, width, height, pitchInPixel); + + public bool TrySubmitOrderedGuestImageFlip( + int videoOutHandle, + int displayBufferIndex, + ulong address, + uint width, + uint height, + uint pitchInPixel) => + MetalVideoPresenter.TrySubmitOrderedGuestImageFlip( + videoOutHandle, + displayBufferIndex, + address, + width, + height, + pitchInPixel); + + public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) => + MetalVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat); + + public bool IsGpuGuestImageAvailable(ulong address, uint format, uint numberType) => + MetalVideoPresenter.IsGuestImageAvailable(address, format, numberType); + + public bool TrySubmitGuestImageBlit( + ulong sourceAddress, + uint sourceWidth, + uint sourceHeight, + uint sourceFormat, + uint sourceNumberType, + ulong destinationAddress, + uint destinationWidth, + uint destinationHeight, + uint destinationFormat, + uint destinationNumberType) => + MetalVideoPresenter.TrySubmitGuestImageBlit( + sourceAddress, + sourceWidth, + sourceHeight, + sourceFormat, + sourceNumberType, + destinationAddress, + destinationWidth, + destinationHeight, + destinationFormat, + destinationNumberType); + + public void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) => + MetalVideoPresenter.SubmitGuestDraw(drawKind, width, height); + + public void SubmitTranslatedDraw( + IGuestCompiledShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint width, + uint height, + uint attributeCount, + IGuestCompiledShader? vertexShader = null, + uint vertexCount = 3, + uint instanceCount = 1, + uint primitiveType = 4, + GuestIndexBuffer? indexBuffer = null, + IReadOnlyList? vertexBuffers = null, + GuestRenderState? renderState = null) => + MetalVideoPresenter.SubmitTranslatedDraw( + Msl(pixelShader), + textures, + globalMemoryBuffers, + width, + height, + attributeCount, + vertexShader is null ? null : Msl(vertexShader), + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + vertexBuffers, + renderState); + + public void SubmitDepthOnlyTranslatedDraw( + IGuestCompiledShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + GuestDepthTarget depthTarget, + IGuestCompiledShader? vertexShader = null, + uint vertexCount = 3, + uint instanceCount = 1, + uint primitiveType = 4, + GuestIndexBuffer? indexBuffer = null, + IReadOnlyList? vertexBuffers = null, + GuestRenderState? renderState = null, + ulong shaderAddress = 0) => + MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw( + Msl(pixelShader), + textures, + globalMemoryBuffers, + attributeCount, + depthTarget, + vertexShader is null ? null : Msl(vertexShader), + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + vertexBuffers, + renderState, + shaderAddress); + + public void SubmitOffscreenTranslatedDraw( + IGuestCompiledShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + IReadOnlyList targets, + IGuestCompiledShader? vertexShader = null, + uint vertexCount = 3, + uint instanceCount = 1, + uint primitiveType = 4, + GuestIndexBuffer? indexBuffer = null, + IReadOnlyList? vertexBuffers = null, + GuestRenderState? renderState = null, + GuestDepthTarget? depthTarget = null, + ulong shaderAddress = 0) => + MetalVideoPresenter.SubmitOffscreenTranslatedDraw( + Msl(pixelShader), + textures, + globalMemoryBuffers, + attributeCount, + targets, + vertexShader is null ? null : Msl(vertexShader), + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + vertexBuffers, + renderState, + depthTarget, + shaderAddress); + + public void SubmitStorageTranslatedDraw( + IGuestCompiledShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + uint width, + uint height, + ulong shaderAddress = 0) => + MetalVideoPresenter.SubmitStorageTranslatedDraw( + Msl(pixelShader), + textures, + globalMemoryBuffers, + attributeCount, + width, + height, + shaderAddress); + + private static MetalCompiledGuestShader Msl(IGuestCompiledShader shader) => + shader as MetalCompiledGuestShader ?? + throw new InvalidOperationException( + $"shader handle of type {shader.GetType().Name} was not compiled by the Metal backend"); + + public long SubmitComputeDispatch( + ulong shaderAddress, + IGuestCompiledShader computeShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint groupCountX, + uint groupCountY, + uint groupCountZ, + uint baseGroupX, + uint baseGroupY, + uint baseGroupZ, + uint localSizeX, + uint localSizeY, + uint localSizeZ, + bool isIndirect, + bool writesGlobalMemory, + uint threadCountX = uint.MaxValue, + uint threadCountY = uint.MaxValue, + uint threadCountZ = uint.MaxValue) + { + // The translated kernel bakes its threadgroup size; localSize and + // isIndirect are already folded in by the AGC layer before submission. + _ = localSizeX; + _ = localSizeY; + _ = localSizeZ; + _ = isIndirect; + return MetalVideoPresenter.SubmitComputeDispatch( + shaderAddress, + Msl(computeShader), + textures, + globalMemoryBuffers, + groupCountX, + groupCountY, + groupCountZ, + baseGroupX, + baseGroupY, + baseGroupZ, + writesGlobalMemory, + threadCountX, + threadCountY, + threadCountZ); + } + + private long _perfShaderCompilations; + + public IDisposable EnterGuestQueue(string queueName, ulong submissionId) => + MetalVideoPresenter.EnterGuestQueue(queueName, submissionId); + + public long SubmitOrderedGuestAction(Action action, string debugName) => + MetalVideoPresenter.SubmitOrderedGuestAction(action, debugName); + + public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) => + MetalVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex); + + public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) => + MetalVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds); + + public long CurrentGuestWorkSequenceForDiagnostics => + MetalVideoPresenter.CurrentGuestWorkSequenceForDiagnostics; + + public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) => + MetalVideoPresenter.IsGuestImageUploadKnown(address, format, numberType); + + public bool GuestImageWantsInitialData(ulong address) => + MetalVideoPresenter.GuestImageWantsInitialData(address); + + public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) => + MetalVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels); + + public void SubmitGuestImageFill(ulong address, uint fillValue) => + MetalVideoPresenter.SubmitGuestImageFill(address, fillValue); + + public void SubmitGuestImageWrite(ulong address, byte[] pixels) => + MetalVideoPresenter.SubmitGuestImageWrite(address, pixels); + + public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) => + MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount); + + public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() => + MetalVideoPresenter.GetGuestImageExtents(); + + public bool IsTextureContentCached(in TextureContentIdentity identity) => + MetalVideoPresenter.IsTextureContentCached(identity); + + public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) => + MetalVideoPresenter.AttachGuestMemory(memory); + + // Over-alignment is always valid, and 256 covers every Metal buffer-offset + // requirement (Intel Macs need 256 for constant buffers; Apple GPUs less). + public ulong GuestStorageBufferOffsetAlignment => 256; + + public void CountShaderCompilation() => + Interlocked.Increment(ref _perfShaderCompilations); + + public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() + { + var (draws, drawMs, pipelines) = MetalVideoPresenter.ReadAndResetDrawPerfCounters(); + return (draws, drawMs, pipelines, Interlocked.Exchange(ref _perfShaderCompilations, 0)); + } + + public void RequestClose() => + MetalVideoPresenter.RequestClose(); + +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs new file mode 100644 index 0000000..83dbfa2 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs @@ -0,0 +1,182 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE.Host; +using SharpEmu.HLE.Host.Posix; + +namespace SharpEmu.Libs.Gpu.Metal; + +/// +/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX +/// host input seam so pad emulation works like the Vulkan presenter's +/// HostWindowInput. Key events arrive on the AppKit main thread as macOS +/// virtual key codes; pad reads happen on guest threads, so state is guarded. +/// Window gamepads are not surfaced by AppKit — controller support would go +/// through GameController.framework and is out of scope here. +/// +internal static class MetalHostInput +{ + private static readonly object Gate = new(); + private static readonly HashSet Pressed = new(); + private static volatile bool _connected; + + /// Registers this window's keyboard as the host input source. + public static void Attach() + { + _connected = true; + PosixHostInput.SetSource(new MetalWindowInputSource()); + Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation."); + } + + // Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the + // macOS key code at each elapsed-seconds mark for a few frames, letting + // headless test runs navigate menus without a human at the keyboard. + private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys(); + private static readonly System.Diagnostics.Stopwatch _autoKeyClock = + System.Diagnostics.Stopwatch.StartNew(); + + private static List<(double, ushort, bool[])> ParseAutoKeys() + { + var keys = new List<(double, ushort, bool[])>(); + var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY"); + if (string.IsNullOrWhiteSpace(spec)) + { + return keys; + } + + foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = entry.Split(':'); + if (parts.Length == 2 && + double.TryParse(parts[0], out var at) && + TryParseKeyCode(parts[1], out var key)) + { + keys.Add((at, key, new bool[2])); + } + } + + return keys; + } + + private static bool TryParseKeyCode(string text, out ushort key) + { + return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key) + : ushort.TryParse(text, out key); + } + + /// Called once per render frame; fires and releases scripted keys. + public static void PumpAutoKeys() + { + if (_autoKeys.Count == 0) + { + return; + } + + var elapsed = _autoKeyClock.Elapsed.TotalSeconds; + foreach (var (at, key, state) in _autoKeys) + { + if (!state[0] && elapsed >= at) + { + state[0] = true; + KeyDown(key, isRepeat: false); + Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s"); + } + else if (state[0] && !state[1] && elapsed >= at + 0.2) + { + state[1] = true; + KeyUp(key); + } + } + } + + public static void KeyDown(ushort keyCode, bool isRepeat) + { + // kVK_F1: parity with the Vulkan window's perf-overlay toggle. + if (keyCode == 0x7A && !isRepeat) + { + VideoOut.PerfOverlay.Toggle(); + } + + lock (Gate) + { + Pressed.Add(keyCode); + } + } + + public static void KeyUp(ushort keyCode) + { + lock (Gate) + { + Pressed.Remove(keyCode); + } + } + + private static bool IsKeyCodeDown(ushort keyCode) + { + lock (Gate) + { + return Pressed.Contains(keyCode); + } + } + + private sealed class MetalWindowInputSource : IPosixWindowInputSource + { + public bool HasKeyboardFocus => _connected; + + public bool IsKeyDown(int virtualKey) => + TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode); + + public int GetGamepadStates(Span destination) => 0; + + public string? DescribeConnectedGamepad() => null; + } + + /// Windows virtual-key semantics (the seam's contract) to macOS + /// kVK virtual key codes, covering the keys pad emulation polls. + private static bool TryMapVirtualKey(int vk, out ushort keyCode) + { + keyCode = vk switch + { + 0x08 => 0x33, // Backspace -> kVK_Delete + 0x09 => 0x30, // Tab + 0x0D => 0x24, // Enter -> kVK_Return + 0x1B => 0x35, // Escape + 0x20 => 0x31, // Space + 0x25 => 0x7B, // Left + 0x26 => 0x7E, // Up + 0x27 => 0x7C, // Right + 0x28 => 0x7D, // Down + // Letters: macOS ANSI key codes are layout-position based and + // non-contiguous, so map each polled letter explicitly. + 0x41 => 0x00, // A + 0x42 => 0x0B, // B + 0x43 => 0x08, // C + 0x44 => 0x02, // D + 0x45 => 0x0E, // E + 0x46 => 0x03, // F + 0x47 => 0x05, // G + 0x48 => 0x04, // H + 0x49 => 0x22, // I + 0x4A => 0x26, // J + 0x4B => 0x28, // K + 0x4C => 0x25, // L + 0x4D => 0x2E, // M + 0x4E => 0x2D, // N + 0x4F => 0x1F, // O + 0x50 => 0x23, // P + 0x51 => 0x0C, // Q + 0x52 => 0x0F, // R + 0x53 => 0x01, // S + 0x54 => 0x11, // T + 0x55 => 0x20, // U + 0x56 => 0x09, // V + 0x57 => 0x0D, // W + 0x58 => 0x07, // X + 0x59 => 0x10, // Y + 0x5A => 0x06, // Z + _ => ushort.MaxValue, + }; + return keyCode != ushort.MaxValue; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs new file mode 100644 index 0000000..2afb7f1 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs @@ -0,0 +1,430 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.Libs.Gpu.Metal; + +// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct +// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta +// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding +// them entirely keeps one calling convention everywhere. +[StructLayout(LayoutKind.Sequential)] +internal struct CGRect +{ + public double X; + public double Y; + public double Width; + public double Height; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct CGSize +{ + public double Width; + public double Height; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MtlClearColor +{ + public double Red; + public double Green; + public double Blue; + public double Alpha; +} + +/// MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output +/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5). +[StructLayout(LayoutKind.Sequential)] +internal struct MtlTextureSwizzleChannels +{ + public byte Red; + public byte Green; + public byte Blue; + public byte Alpha; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MtlRegion +{ + public nuint X; + public nuint Y; + public nuint Z; + public nuint Width; + public nuint Height; + public nuint Depth; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MtlSize +{ + public nuint Width; + public nuint Height; + public nuint Depth; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MtlOrigin +{ + public nuint X; + public nuint Y; + public nuint Z; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MtlScissorRect +{ + public nuint X; + public nuint Y; + public nuint Width; + public nuint Height; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MtlViewport +{ + public double OriginX; + public double OriginY; + public double Width; + public double Height; + public double ZNear; + public double ZFar; +} + +/// +/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal +/// through objc_msgSend, with one LibraryImport overload per distinct native +/// signature. Dependency-free by design — this plus the OS frameworks is the entire +/// Metal path, which is what keeps it NativeAOT-clean. +/// +internal static partial class MetalNative +{ + private const string CoreFoundation = + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + + [LibraryImport(CoreFoundation)] + public static partial nint CFRunLoopGetMain(); + + [LibraryImport(CoreFoundation)] + public static partial void CFRunLoopStop(nint runLoop); + + private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib"; + private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal"; + private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit"; + private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore"; + + private static bool _frameworksLoaded; + + /// + /// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is + /// pulled in by its own LibraryImport. Call once before any Class() lookup. + /// + public static void EnsureFrameworksLoaded() + { + if (_frameworksLoaded) + { + return; + } + + NativeLibrary.Load(AppKitFramework); + NativeLibrary.Load(QuartzCoreFramework); + _frameworksLoaded = true; + } + + [LibraryImport(MetalFramework)] + public static partial nint MTLCreateSystemDefaultDevice(); + + [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] + private static partial nint objc_getClass(string name); + + [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] + private static partial nint sel_registerName(string name); + + [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] + public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes); + + [LibraryImport(ObjCLibrary)] + public static partial void objc_registerClassPair(nint cls); + + [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] + [return: MarshalAs(UnmanagedType.I1)] + public static partial bool class_addMethod(nint cls, nint name, nint imp, string types); + + [LibraryImport(ObjCLibrary)] + public static partial nint objc_autoreleasePoolPush(); + + [LibraryImport(ObjCLibrary)] + public static partial void objc_autoreleasePoolPop(nint pool); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint Send(nint receiver, nint selector); + + /// objc_msgSend for -gpuResourceID. MTLResourceID is a one-field + /// 8-byte struct, returned in a register on the x86-64 ABI, so it maps to a + /// ulong return — the value written into a Tier 2 argument buffer slot. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial ulong SendGpuResourceId(nint receiver, nint selector); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint Send(nint receiver, nint selector, nint argument); + + + /// objc_msgSend for a CGRect-returning selector (e.g. -bounds). + /// A 32-byte struct is returned via the x86-64 stret ABI — a hidden + /// pointer to caller storage passed ahead of self/_cmd — so this must not + /// be folded into the plain objc_msgSend overloads. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")] + public static partial void SendStretRect(out CGRect result, nint receiver, nint selector); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendAtIndex(nint receiver, nint selector, nuint index); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + public static partial bool SendBool(nint receiver, nint selector); + + /// One-argument BOOL sends, e.g. respondsToSelector:. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + public static partial bool SendBool(nint receiver, nint selector, nint argument); + + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial double SendDouble(nint receiver, nint selector); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoid(nint receiver, nint selector); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoid(nint receiver, nint selector, nint argument); + + /// Two-object-argument void sends, e.g. setObject:forKey:. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1); + + /// performSelectorOnMainThread:withObject:waitUntilDone: — the SEL + /// to perform is itself an argument, followed by the object and the wait + /// flag. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidPerformSelector( + nint receiver, + nint selector, + nint performedSelector, + nint argument, + [MarshalAs(UnmanagedType.I1)] bool waitUntilDone); + + /// setSwizzle: on MTLTextureDescriptor. Four one-byte + /// MTLTextureSwizzle values, passed packed like the framework expects. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidSwizzle( + nint receiver, + nint selector, + MtlTextureSwizzleChannels channels); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidDouble(nint receiver, nint selector, double argument); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidSize(nint receiver, nint selector, CGSize size); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidBlendColor( + nint receiver, + nint selector, + float red, + float green, + float blue, + float alpha); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidViewport(nint receiver, nint selector, MtlViewport viewport); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendSetAtIndex(nint receiver, nint selector, nint value, nuint index); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidCopyTexture(nint receiver, nint selector, nint source, nint destination); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendNewBuffer(nint receiver, nint selector, nuint length, nuint options); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendCopyTextureToBuffer( + nint receiver, + nint selector, + nint sourceTexture, + nuint sourceSlice, + nuint sourceLevel, + MtlOrigin sourceOrigin, + MtlSize sourceSize, + nint destinationBuffer, + nuint destinationOffset, + nuint destinationBytesPerRow, + nuint destinationBytesPerImage); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendCopyBufferToTexture( + nint receiver, + nint selector, + nint sourceBuffer, + nuint sourceOffset, + nuint sourceBytesPerRow, + nuint sourceBytesPerImage, + MtlSize sourceSize, + nint destinationTexture, + nuint destinationSlice, + nuint destinationLevel, + MtlOrigin destinationOrigin); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendDispatch( + nint receiver, + nint selector, + MtlSize threadgroups, + MtlSize threadsPerThreadgroup); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidScissor(nint receiver, nint selector, MtlScissorRect rect); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendDrawPrimitivesInstanced( + nint receiver, + nint selector, + nuint primitiveType, + nuint vertexStart, + nuint vertexCount, + nuint instanceCount); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendDrawIndexedPrimitives( + nint receiver, + nint selector, + nuint primitiveType, + nuint indexCount, + nuint indexType, + nint indexBuffer, + nuint indexBufferOffset, + nuint instanceCount); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendTimer( + nint receiver, + nint selector, + double interval, + nint target, + nint timerSelector, + nint userInfo, + [MarshalAs(UnmanagedType.I1)] bool repeats); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendInitWindow( + nint receiver, + nint selector, + CGRect contentRect, + nuint styleMask, + nuint backing, + [MarshalAs(UnmanagedType.I1)] bool defer); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendNextEvent( + nint receiver, + nint selector, + ulong eventMask, + nint untilDate, + nint inMode, + [MarshalAs(UnmanagedType.I1)] bool dequeue); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial nint SendTextureDescriptor( + nint receiver, + nint selector, + nuint pixelFormat, + nuint width, + nuint height, + [MarshalAs(UnmanagedType.I1)] bool mipmapped); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendReplaceRegion( + nint receiver, + nint selector, + MtlRegion region, + nuint mipmapLevel, + nint bytes, + nuint bytesPerRow); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendDrawPrimitives( + nint receiver, + nint selector, + nuint primitiveType, + nuint vertexStart, + nuint vertexCount); + + public static nint Class(string name) => objc_getClass(name); + + public static nint Selector(string name) => sel_registerName(name); + + /// Autoreleased NSString — only valid inside an autorelease pool + /// unless the caller retains it. + public static nint NsString(string value) + { + var utf8 = Marshal.StringToCoTaskMemUTF8(value); + try + { + return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8); + } + finally + { + Marshal.FreeCoTaskMem(utf8); + } + } + + /// Reads an NSString's UTF-8 contents, or null if the handle is nil. + public static string? ReadNsString(nint nsString) + { + if (nsString == 0) + { + return null; + } + + var utf8 = Send(nsString, Selector("UTF8String")); + return utf8 == 0 ? null : Marshal.PtrToStringUTF8(utf8); + } + + public static string DescribeError(nint error) + { + if (error == 0) + { + return "unknown error"; + } + + var description = Send(error, Selector("localizedDescription")); + var utf8 = Send(description, Selector("UTF8String")); + return Marshal.PtrToStringUTF8(utf8) ?? "unknown error"; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs new file mode 100644 index 0000000..72698eb --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs @@ -0,0 +1,50 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Libs.Gpu.Metal; + +// Guest draws and compute dispatches batch into one command buffer per drain +// instead of one per work item, mirroring the Vulkan presenter's batched guest +// commands: commit overhead dominated CPU time for scenes with dozens of draws +// per frame. Ordering inside the batch is by encoder sequence (snapshot blits +// for a draw's feedback reads are encoded before its render pass opens), and +// everything that must observe batched work on the serial queue — flips, image +// writes/blits, CPU-visible write-backs, the present pass — flushes first. +internal static partial class MetalVideoPresenter +{ + private static nint _batchCommandBuffer; + private static bool _batchOpen; + + /// Returns the open batch command buffer, opening one on first + /// use. Render thread only, like the drain it serves. + private static nint BeginBatchedGuestCommands(nint queue) + { + if (_batchOpen) + { + return _batchCommandBuffer; + } + + _batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + _batchOpen = _batchCommandBuffer != 0; + return _batchCommandBuffer; + } + + /// Commits the open batch (if any), tagging the upload pages and + /// snapshot resources it consumed. Returns the committed command buffer so + /// write-back sites can wait on it, or 0 when nothing was open. + private static nint FlushBatchedGuestCommands() + { + if (!_batchOpen) + { + return 0; + } + + _batchOpen = false; + var commandBuffer = _batchCommandBuffer; + _batchCommandBuffer = 0; + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + TagUploadPages(commandBuffer); + TagSnapshotResources(commandBuffer); + return commandBuffer; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs new file mode 100644 index 0000000..97f1b3d --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs @@ -0,0 +1,424 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Libs.Gpu.Metal; + +// Guest compute dispatches: ordered guest work like draws, with two contracts to +// honor. Storage images are shared live through the guest-image registry so a +// dispatch's writes are visible to later draws, blits, and flips of the same +// address; and CPU-visible buffer writes land back in guest memory before the +// work item completes, which is the ordering point WaitForGuestWork promises. +internal static partial class MetalVideoPresenter +{ + private static readonly bool _skipAllCompute = + Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1"; + private static bool _tracedDispatchBase; + + private sealed record ComputeGuestDispatch( + ulong ShaderAddress, + MetalCompiledGuestShader Shader, + GuestDrawTexture[] Textures, + GuestMemoryBuffer[] GlobalMemoryBuffers, + uint GroupCountX, + uint GroupCountY, + uint GroupCountZ, + uint BaseGroupX, + uint BaseGroupY, + uint BaseGroupZ, + uint ThreadCountX, + uint ThreadCountY, + uint ThreadCountZ); + + private static readonly Dictionary _computePipelineCache = new(); + + public static long SubmitComputeDispatch( + ulong shaderAddress, + MetalCompiledGuestShader computeShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint groupCountX, + uint groupCountY, + uint groupCountZ, + uint baseGroupX, + uint baseGroupY, + uint baseGroupZ, + bool writesGlobalMemory, + uint threadCountX, + uint threadCountY, + uint threadCountZ) + { + var hasStorage = false; + foreach (var texture in textures) + { + hasStorage |= texture.IsStorage; + } + + if (groupCountX == 0 || + groupCountY == 0 || + groupCountZ == 0 || + (!hasStorage && !writesGlobalMemory)) + { + return 0; + } + + lock (_gate) + { + if (_closed || _thread is null) + { + return 0; + } + + // Storage images a dispatch writes become flip sources and sampled + // inputs for later work, exactly like published render targets. + foreach (var texture in textures) + { + if (!texture.IsStorage || texture.Address == 0) + { + continue; + } + + var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType); + if (guestFormat != 0) + { + _availableGuestImages[texture.Address] = guestFormat; + } + } + + var sequence = EnqueueGuestWorkLocked( + new ComputeGuestDispatch( + shaderAddress, + computeShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + groupCountX, + groupCountY, + groupCountZ, + baseGroupX, + baseGroupY, + baseGroupZ, + threadCountX, + threadCountY, + threadCountZ)); + foreach (var texture in textures) + { + if (texture.IsStorage && texture.Address != 0) + { + _guestImageWorkSequences[texture.Address] = sequence; + } + } + + return sequence; + } + } + + private static void ExecuteComputeDispatch(nint device, nint queue, ComputeGuestDispatch dispatch) + { + if (_skipAllCompute) + { + ReturnPooledComputeData(dispatch); + return; + } + + VideoOut.PerfOverlay.RecordDraw(); + + if ((dispatch.BaseGroupX | dispatch.BaseGroupY | dispatch.BaseGroupZ) != 0 && + !_tracedDispatchBase) + { + // Metal has no dispatch-base; the translated kernel derives its ids + // from the raw grid position, so a nonzero base computes offset-zero + // work until base support lands in the emitted kernel. + _tracedDispatchBase = true; + Console.Error.WriteLine( + "[LOADER][WARN] Metal compute dispatch with nonzero base group " + + $"({dispatch.BaseGroupX},{dispatch.BaseGroupY},{dispatch.BaseGroupZ}); " + + "executing without the base offset."); + } + + if (!TryGetComputePipeline(device, dispatch.Shader, out var pipeline)) + { + ReturnPooledComputeData(dispatch); + return; + } + + var commandBuffer = BeginBatchedGuestCommands(queue); + + // Pre-resolve textures before the compute encoder opens: snapshot + // blits for feedback reads encode into the batch and encoder order + // must place them ahead of this dispatch. + Span textureHandles = stackalloc nint[dispatch.Textures.Length]; + Span textureOwned = stackalloc bool[dispatch.Textures.Length]; + for (var index = 0; index < dispatch.Textures.Length; index++) + { + var descriptor = dispatch.Textures[index]; + if (descriptor.IsStorage && descriptor.Address != 0) + { + textureHandles[index] = EnsureStorageImage(device, descriptor)?.Texture ?? 0; + textureOwned[index] = false; + } + else + { + textureHandles[index] = CreateDrawTexture( + device, commandBuffer, descriptor, out var ownedTexture); + textureOwned[index] = ownedTexture; + } + } + + var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder")); + MetalNative.SendVoid(encoder, MetalNative.Selector("setComputePipelineState:"), pipeline); + + var writeBackBuffers = new List<(nint Pointer, GuestMemoryBuffer Guest)>(); + var selSetBuffer = MetalNative.Selector("setBuffer:offset:atIndex:"); + var bufferCount = dispatch.GlobalMemoryBuffers.Length; + Span boundBytes = stackalloc uint[Math.Max(bufferCount, 1)]; + for (var index = 0; index < bufferCount; index++) + { + var guest = dispatch.GlobalMemoryBuffers[index]; + var pointer = UploadGlobalBuffer( + device, guest, out var buffer, out var offset, out boundBytes[index]); + MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)index); + if (guest.Writable && guest.WriteBackToGuest) + { + writeBackBuffers.Add((pointer, guest)); + } + } + + // SharpEmuUniforms: the dispatch limit clamps the overshoot threads of the + // last threadgroup row, then each bound buffer's byte length follows + // (including the alignment-bias prefix the shader indexes past). + var shader = dispatch.Shader.Shader; + var uniforms = AllocateUpload( + device, + 16 + (Math.Max(bufferCount, 1) * sizeof(uint)), + out var uniformsBuffer, + out var uniformsOffset); + WriteDispatchLimit(uniforms, 0, dispatch.ThreadCountX, dispatch.GroupCountX, shader.ThreadgroupSizeX); + WriteDispatchLimit(uniforms, 4, dispatch.ThreadCountY, dispatch.GroupCountY, shader.ThreadgroupSizeY); + WriteDispatchLimit(uniforms, 8, dispatch.ThreadCountZ, dispatch.GroupCountZ, shader.ThreadgroupSizeZ); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0); + for (var index = 0; index < bufferCount; index++) + { + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( + uniforms[(16 + (index * sizeof(uint)))..], + boundBytes[index]); + } + + // Bind at the stage's declared SharpEmuUniforms slot (see the draw path: + // stages compute their own index from globalBufferBase + total count). + var uniformsIndex = shader.UniformsBufferIndex; + MetalNative.SendSetBuffer( + encoder, + selSetBuffer, + uniformsBuffer, + (nuint)uniformsOffset, + (nuint)(uniformsIndex >= 0 ? uniformsIndex : bufferCount)); + + var selSetTexture = MetalNative.Selector("setTexture:atIndex:"); + for (var index = 0; index < dispatch.Textures.Length; index++) + { + var texture = textureHandles[index]; + if (texture != 0) + { + MetalNative.SendSetAtIndex(encoder, selSetTexture, texture, (nuint)index); + if (textureOwned[index]) + { + MetalNative.SendVoid(texture, MetalNative.Selector("release")); + } + } + } + + // Samplers travel in an argument buffer bound at setBuffer (see the draw + // path), sidestepping Metal's 16-sampler-per-stage cap. + BindSamplerArgumentBuffer(device, encoder, selSetBuffer, dispatch.Shader, dispatch.Textures); + + MetalNative.SendDispatch( + encoder, + MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"), + new MtlSize + { + Width = dispatch.GroupCountX, + Height = dispatch.GroupCountY, + Depth = dispatch.GroupCountZ, + }, + new MtlSize + { + Width = Math.Max(shader.ThreadgroupSizeX, 1), + Height = Math.Max(shader.ThreadgroupSizeY, 1), + Depth = Math.Max(shader.ThreadgroupSizeZ, 1), + }); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + + // CPU-visible writes are ordering points (see the draw path): flush + // the batch and wait so the write-back lands before this work item + // completes. Pure-GPU dispatches stay in the open batch. + if (writeBackBuffers.Count > 0) + { + var committed = FlushBatchedGuestCommands(); + MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted")); + WriteBuffersBackToGuest(writeBackBuffers); + } + + foreach (var descriptor in dispatch.Textures) + { + if (!descriptor.IsStorage || descriptor.Address == 0) + { + continue; + } + + GuestImage? image; + lock (_gate) + { + _guestImages.TryGetValue(descriptor.Address, out image); + } + + if (image is not null) + { + image.MarkContentChanged(); + } + } + + ReturnPooledComputeData(dispatch); + } + + /// The live, shared storage image for a guest address: dispatches, + /// draws, blits, and flips of the same address all see one texture. + private static GuestImage? EnsureStorageImage(nint device, GuestDrawTexture descriptor) + { + lock (_gate) + { + if (_guestImages.TryGetValue(descriptor.Address, out var existing)) + { + return existing; + } + } + + if (descriptor.Width == 0 || descriptor.Height == 0 || + descriptor.Width > 16384 || descriptor.Height > 16384) + { + return null; + } + + var format = MetalGuestFormats.TryDecodeRenderTargetFormat( + descriptor.Format, descriptor.NumberType, out var decoded) + ? decoded.Format + : MtlPixelFormat.Rgba8Unorm; + var textureDescriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)format, + descriptor.Width, + descriptor.Height, + mipmapped: false); + MetalNative.Send( + textureDescriptor, + MetalNative.Selector("setUsage:"), + (nint)(UsageShaderRead | UsageShaderWrite | UsageRenderTarget)); + var image = new GuestImage + { + Texture = MetalNative.Send( + device, MetalNative.Selector("newTextureWithDescriptor:"), textureDescriptor), + Width = descriptor.Width, + Height = descriptor.Height, + Format = format, + }; + if (image.Texture == 0) + { + return null; + } + + var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format); + // Snapshot copies arrive in the image's native texel layout; only + // 4-byte texels can be RGBA8 verbatim, wider ones carry native bytes. + if ((ulong)descriptor.RgbaPixels.Length >= (ulong)descriptor.Width * bytesPerPixel) + { + var pitch = descriptor.Pitch != 0 + ? Math.Max(descriptor.Pitch, descriptor.Width) + : descriptor.Width; + ReplaceTextureContents( + image.Texture, descriptor.Width, descriptor.Height, descriptor.RgbaPixels, pitch, bytesPerPixel); + image.MarkContentChanged(); + } + + lock (_gate) + { + if (_guestImages.TryGetValue(descriptor.Address, out var raced)) + { + MetalNative.SendVoid(image.Texture, MetalNative.Selector("release")); + return raced; + } + + _guestImages[descriptor.Address] = image; + _guestImageExtents[descriptor.Address] = + (descriptor.Width, descriptor.Height, (ulong)descriptor.Width * descriptor.Height * bytesPerPixel); + } + + return image; + } + + private static bool TryGetComputePipeline(nint device, MetalCompiledGuestShader shader, out nint pipeline) + { + lock (_computePipelineCache) + { + if (_computePipelineCache.TryGetValue(shader, out pipeline)) + { + return pipeline != 0; + } + } + + var function = GetShaderFunction(device, shader); + if (function != 0) + { + nint error = 0; + pipeline = MetalNative.Send( + device, + MetalNative.Selector("newComputePipelineStateWithFunction:error:"), + function, + ref error); + if (pipeline == 0) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal compute pipeline creation failed: {MetalNative.DescribeError(error)}"); + } + else + { + Interlocked.Increment(ref _perfPipelineCreations); + } + } + else + { + pipeline = 0; + } + + lock (_computePipelineCache) + { + _computePipelineCache[shader] = pipeline; + } + + return pipeline != 0; + } + + private static void WriteDispatchLimit( + Span uniforms, + int offset, + uint threadCount, + uint groupCount, + uint threadgroupSize) + { + var limit = threadCount != uint.MaxValue + ? threadCount + : groupCount * Math.Max(threadgroupSize, 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( + uniforms[offset..], + limit); + } + + private static void ReturnPooledComputeData(ComputeGuestDispatch dispatch) + { + foreach (var buffer in dispatch.GlobalMemoryBuffers) + { + if (buffer.Pooled) + { + GuestDataPool.Shared.Return(buffer.Data); + } + } + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs new file mode 100644 index 0000000..86740ef --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs @@ -0,0 +1,2153 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Metal; + +namespace SharpEmu.Libs.Gpu.Metal; + +// Translated guest draws. Submission mirrors the Vulkan presenter (offscreen and +// depth-only draws are ordered guest work publishing into guest images; onscreen +// draws ride the presentation), while execution is idiomatic Metal: render passes +// express load/clear intent directly, the driver's hazard tracking replaces the +// explicit barrier choreography, and the binding layout follows the translation +// contract documented on Gen5MslTranslator (global buffers at their flat slot, +// SharpEmuUniforms after them, textures and samplers at the image slots, vertex +// streams at a high base that never collides with global buffers). +internal static partial class MetalVideoPresenter +{ + private const nuint VertexBufferSlotBase = 26; + + /// Metal's vertex stage exposes buffer indices 0..30; setting a + /// vertex-descriptor attribute to 31 is a framework assertion that aborts + /// the process. + private const nuint MaxVertexStageBufferIndex = 30; + + private static int _vertexSlotOverflowTraces; + + /// Assigns each vertex stream a Metal buffer slot, sharing one + /// slot between attributes that read the same guest buffer — interleaved + /// vertices arrive from AGC as one per + /// attribute, so without sharing a handful of attributes exhausts the + /// vertex-stage buffer range. Deterministic over the draw's buffer array; + /// the pipeline descriptor and the bind path both derive from it. Returns + /// false when the unique streams still overflow Metal's last slot. + private static bool TryAssignVertexBufferSlots( + GuestVertexBuffer[] vertexBuffers, + Span slots) + { + var uniqueCount = 0; + var overflowed = false; + for (var index = 0; index < vertexBuffers.Length; index++) + { + var buffer = vertexBuffers[index]; + var shared = false; + if (buffer.BaseAddress != 0) + { + for (var prior = 0; prior < index; prior++) + { + var candidate = vertexBuffers[prior]; + if (candidate.BaseAddress == buffer.BaseAddress && + candidate.Stride == buffer.Stride && + candidate.Length == buffer.Length) + { + slots[index] = slots[prior]; + shared = true; + break; + } + } + } + + if (shared) + { + continue; + } + + slots[index] = VertexBufferSlotBase + (nuint)uniqueCount; + uniqueCount++; + overflowed |= slots[index] > MaxVertexStageBufferIndex; + } + + return !overflowed; + } + private const nuint UsageShaderRead = 1; + private const nuint UsageShaderWrite = 2; + private const nuint UsageRenderTarget = 4; + private static bool _tracedTriangleFan; + + private sealed record TranslatedGuestDraw( + MetalCompiledGuestShader? VertexShader, + MetalCompiledGuestShader PixelShader, + GuestDrawTexture[] Textures, + GuestMemoryBuffer[] GlobalMemoryBuffers, + GuestVertexBuffer[] VertexBuffers, + uint AttributeCount, + uint VertexCount, + uint InstanceCount, + uint PrimitiveType, + GuestIndexBuffer? IndexBuffer, + GuestRenderState RenderState); + + private sealed record OffscreenGuestDraw( + TranslatedGuestDraw Draw, + GuestRenderTarget[] Targets, + GuestDepthTarget? DepthTarget, + bool PublishTarget, + ulong ShaderAddress); + + private sealed record PipelineKey( + MetalCompiledGuestShader? VertexShader, + MetalCompiledGuestShader PixelShader, + ulong StateHash); + + private static long _perfDrawCount; + private static long _perfDrawTicks; + private static long _perfPipelineCreations; + + public static (long Draws, double DrawMs, long Pipelines) ReadAndResetDrawPerfCounters() + { + var draws = Interlocked.Exchange(ref _perfDrawCount, 0); + var ticks = Interlocked.Exchange(ref _perfDrawTicks, 0); + var pipelines = Interlocked.Exchange(ref _perfPipelineCreations, 0); + return (draws, ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency, pipelines); + } + + private static readonly Dictionary _pipelineCache = new(); + private static readonly Dictionary _samplerCache = new(); + private static readonly Dictionary _guestDepthImages = new(); + + // A depth target sampled later through its read address must resolve to + // the same image the write address produced (the Vulkan presenter matches + // either address on its depth resources). + private static readonly Dictionary _guestDepthReadAliases = new(); + + // Retired same-address render targets: a game that recreates a target with + // a new extent at the same address may still sample the old content later, + // so replacement retires the image here instead of releasing it, and the + // draw-texture path scores candidates like the Vulkan presenter's + // guest-image variants. Bounded FIFO so stale variants cannot accumulate. + private const int MaxGuestImageVariants = 32; + private static readonly Dictionary<(ulong Address, uint Width, uint Height, MtlPixelFormat Format), GuestImage> + _guestImageVariants = new(); + private static readonly Queue<(ulong Address, uint Width, uint Height, MtlPixelFormat Format)> + _guestImageVariantOrder = new(); + private static readonly Dictionary<(MtlPixelFormat Format, uint Width, uint Height), nint> + _transientTargets = new(); + + public static void SubmitTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint width, + uint height, + uint attributeCount, + MetalCompiledGuestShader? vertexShader, + uint vertexCount, + uint instanceCount, + uint primitiveType, + GuestIndexBuffer? indexBuffer, + IReadOnlyList? vertexBuffers, + GuestRenderState? renderState) + { + if (width == 0 || height == 0) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + _latestPresentation = new Presentation( + null, + width, + height, + sequence, + IsSplash: false, + RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(), + TranslatedDraw: new TranslatedGuestDraw( + vertexShader, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + vertexBuffers is null ? [] : ToArray(vertexBuffers), + attributeCount, + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + renderState ?? GuestRenderState.Default)); + if (_thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + StartPresenterLocked(); + } + } + + public static void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) + { + if (drawKind == GuestDrawKind.None || width == 0 || height == 0) + { + return; + } + + lock (_gate) + { + if (_closed || + _latestPresentation is { Pixels: null } latest && + latest.DrawKind == drawKind && + latest.Width == width && + latest.Height == height) + { + return; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + _latestPresentation = new Presentation( + null, + width, + height, + sequence, + IsSplash: false, + RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(), + DrawKind: drawKind); + if (_thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + StartPresenterLocked(); + } + } + + public static void SubmitOffscreenTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + IReadOnlyList targets, + MetalCompiledGuestShader? vertexShader, + uint vertexCount, + uint instanceCount, + uint primitiveType, + GuestIndexBuffer? indexBuffer, + IReadOnlyList? vertexBuffers, + GuestRenderState? renderState, + GuestDepthTarget? depthTarget, + ulong shaderAddress) + { + if (targets.Count == 0) + { + return; + } + + var effectiveRenderState = renderState ?? GuestRenderState.Default; + if (effectiveRenderState.Blends.Count == 1 && targets.Count > 1) + { + var blends = new GuestBlendState[targets.Count]; + for (var index = 0; index < blends.Length; index++) + { + blends[index] = effectiveRenderState.Blends[0]; + } + + effectiveRenderState = effectiveRenderState with { Blends = blends }; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + foreach (var target in targets) + { + var guestTextureFormat = GetGuestTextureFormat(target.Format, target.NumberType); + if (target.Address != 0 && guestTextureFormat != 0) + { + _availableGuestImages[target.Address] = guestTextureFormat; + } + } + + var workSequence = EnqueueGuestWorkLocked( + new OffscreenGuestDraw( + new TranslatedGuestDraw( + vertexShader, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + vertexBuffers is null ? [] : ToArray(vertexBuffers), + attributeCount, + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + effectiveRenderState), + ToArray(targets), + depthTarget, + PublishTarget: true, + shaderAddress)); + foreach (var target in targets) + { + if (target.Address != 0) + { + _guestImageWorkSequences[target.Address] = workSequence; + } + } + } + } + + public static void SubmitDepthOnlyTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + GuestDepthTarget depthTarget, + MetalCompiledGuestShader? vertexShader, + uint vertexCount, + uint instanceCount, + uint primitiveType, + GuestIndexBuffer? indexBuffer, + IReadOnlyList? vertexBuffers, + GuestRenderState? renderState, + ulong shaderAddress) + { + if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + EnqueueGuestWorkLocked( + new OffscreenGuestDraw( + new TranslatedGuestDraw( + vertexShader, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + vertexBuffers is null ? [] : ToArray(vertexBuffers), + attributeCount, + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + renderState ?? GuestRenderState.Default), + [new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)], + depthTarget, + PublishTarget: false, + shaderAddress)); + } + } + + public static void SubmitStorageTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + uint width, + uint height, + ulong shaderAddress) + { + var hasStorage = false; + foreach (var texture in textures) + { + hasStorage |= texture.IsStorage; + } + + if (width == 0 || height == 0 || !hasStorage) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + EnqueueGuestWorkLocked( + new OffscreenGuestDraw( + new TranslatedGuestDraw( + null, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + [], + attributeCount, + 3, + 1, + 4, + null, + GuestRenderState.Default), + [new GuestRenderTarget(Address: 0, width, height, Format: 12, NumberType: 7)], + DepthTarget: null, + PublishTarget: false, + shaderAddress)); + } + } + + private static long CurrentSubmittingQueueTailLocked() + { + var queue = _submittingGuestQueue; + return queue is { } identity && + _lastEnqueuedGuestWorkByQueue.TryGetValue(identity.Name, out var tail) + ? tail + : 0; + } + + private static T[] ToArray(IReadOnlyList source) + { + var result = new T[source.Count]; + for (var index = 0; index < result.Length; index++) + { + result[index] = source[index]; + } + + return result; + } + + private static void ExecuteOffscreenDraw(nint device, nint queue, OffscreenGuestDraw work) + { + var perfStart = System.Diagnostics.Stopwatch.GetTimestamp(); + Interlocked.Increment(ref _perfDrawCount); + VideoOut.PerfOverlay.RecordDraw(); + try + { + ExecuteOffscreenDrawCore(device, queue, work); + } + finally + { + Interlocked.Add( + ref _perfDrawTicks, + System.Diagnostics.Stopwatch.GetTimestamp() - perfStart); + } + } + + private static void ExecuteOffscreenDrawCore(nint device, nint queue, OffscreenGuestDraw work) + { + var draw = work.Draw; + var targetFormats = new MetalRenderTargetFormat[work.Targets.Length]; + for (var index = 0; index < targetFormats.Length; index++) + { + var target = work.Targets[index]; + if (!MetalGuestFormats.TryDecodeRenderTargetFormat( + target.Format, + target.NumberType, + out targetFormats[index])) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal skipped draw with unsupported color target " + + $"format={target.Format} number_type={target.NumberType}."); + ReturnPooledGuestData(draw); + return; + } + } + + if (draw.RenderState.Blends.Count != targetFormats.Length) + { + ReturnPooledGuestData(draw); + return; + } + + // Resolve color targets: published guest images by address, transient + // pooled textures for address-0 targets (depth-only and storage draws). + var targetTextures = new nint[work.Targets.Length]; + var targetLoadActions = new nuint[work.Targets.Length]; + var publishedTargets = new GuestImage?[work.Targets.Length]; + var firstWidth = work.Targets[0].Width; + var firstHeight = work.Targets[0].Height; + for (var index = 0; index < work.Targets.Length; index++) + { + var target = work.Targets[index]; + if (target.Address == 0) + { + var extentWidth = target.Width == 0 ? firstWidth : target.Width; + var extentHeight = target.Height == 0 ? firstHeight : target.Height; + targetTextures[index] = GetTransientTarget( + device, targetFormats[index].Format, extentWidth, extentHeight); + targetLoadActions[index] = LoadActionClear; + continue; + } + + var image = EnsureGuestRenderTarget(device, target, targetFormats[index].Format); + if (image is null) + { + ReturnPooledGuestData(draw); + return; + } + + publishedTargets[index] = image; + targetTextures[index] = image.Texture; + targetLoadActions[index] = image.Initialized ? LoadActionLoad : LoadActionClear; + } + + if (targetTextures[0] == 0) + { + ReturnPooledGuestData(draw); + return; + } + + // Depth attachment, keyed by guest DB address; read-only depth drops write. + GuestImage? depth = null; + var depthState = draw.RenderState.Depth; + if (work.DepthTarget is { } depthTarget && (depthState.TestEnable || depthState.WriteEnable)) + { + if (depthTarget.ReadOnly && depthState.WriteEnable) + { + depthState = depthState with { WriteEnable = false }; + } + + var depthWidth = Math.Max(depthTarget.Width, firstWidth); + var depthHeight = Math.Max(depthTarget.Height, firstHeight); + depth = EnsureGuestDepthImage(device, depthTarget, depthWidth, depthHeight); + } + + if (!TryGetDrawPipeline(device, draw, targetFormats, depth is not null, out var pipeline)) + { + ReturnPooledGuestData(draw); + return; + } + + var commandBuffer = BeginBatchedGuestCommands(queue); + Span textureHandles = stackalloc nint[draw.Textures.Length]; + Span textureOwned = stackalloc bool[draw.Textures.Length]; + ResolveDrawTextures(device, commandBuffer, draw.Textures, textureHandles, textureOwned); + + var pass = MetalNative.Send( + MetalNative.Class("MTLRenderPassDescriptor"), + MetalNative.Selector("renderPassDescriptor")); + var colorAttachments = MetalNative.Send(pass, MetalNative.Selector("colorAttachments")); + for (var index = 0; index < targetTextures.Length; index++) + { + var attachment = MetalNative.SendAtIndex( + colorAttachments, MetalNative.Selector("objectAtIndexedSubscript:"), (nuint)index); + MetalNative.SendVoid(attachment, MetalNative.Selector("setTexture:"), targetTextures[index]); + MetalNative.Send(attachment, MetalNative.Selector("setLoadAction:"), (nint)targetLoadActions[index]); + MetalNative.Send(attachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore); + } + + if (depth is not null && work.DepthTarget is { } depthDescriptor) + { + var depthAttachment = MetalNative.Send(pass, MetalNative.Selector("depthAttachment")); + MetalNative.SendVoid(depthAttachment, MetalNative.Selector("setTexture:"), depth.Texture); + MetalNative.Send( + depthAttachment, + MetalNative.Selector("setLoadAction:"), + (nint)(depth.Initialized ? LoadActionLoad : LoadActionClear)); + MetalNative.Send(depthAttachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore); + MetalNative.SendVoidDouble( + depthAttachment, MetalNative.Selector("setClearDepth:"), depthDescriptor.ClearDepth); + } + + var encoder = MetalNative.Send( + commandBuffer, MetalNative.Selector("renderCommandEncoderWithDescriptor:"), pass); + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + + EncodeRenderState(device, encoder, draw.RenderState, depthState, depth is not null, firstWidth, firstHeight); + EncodeDrawBindings(device, encoder, work, textureHandles, textureOwned, out var writeBackBuffers); + EncodeDrawCall(encoder, draw); + + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + + // CPU-visible GPU writes are ordering points in the guest command + // stream: completing this work item is the signal WaitForGuestWork + // relies on, so the batch must land and the write-back must complete + // before this item does. Pure-GPU draws stay in the open batch. + if (writeBackBuffers.Count > 0) + { + var committed = FlushBatchedGuestCommands(); + MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted")); + WriteBuffersBackToGuest(writeBackBuffers); + } + + for (var index = 0; index < publishedTargets.Length; index++) + { + if (publishedTargets[index] is { } published) + { + published.MarkContentChanged(); + } + } + + depth?.MarkContentChanged(); + ReturnPooledGuestData(draw); + } + + /// Renders a presentation-carried draw (onscreen translated draw or a + /// recognized fixed-function draw) into the reusable onscreen target. + private static nint ExecutePresentationDraw(nint device, nint queue, Presentation presentation) + { + var target = GetTransientTarget( + device, MtlPixelFormat.Bgra8Unorm, presentation.Width, presentation.Height); + if (target == 0) + { + return 0; + } + + if (presentation.TranslatedDraw is { } translatedDraw) + { + ExecuteOffscreenDrawToTexture(device, queue, translatedDraw, target); + } + else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric) + { + if (!TryGetFixedDrawPipeline(device, out var pipeline)) + { + return 0; + } + + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + var encoder = MetalNative.Send( + commandBuffer, + MetalNative.Selector("renderCommandEncoderWithDescriptor:"), + CreateClearPass(target, new MtlClearColor { Alpha = 1 })); + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + MetalNative.SendDrawPrimitives( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"), + PrimitiveTypeTriangle, + 0, + 3); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + } + + return target; + } + + private static void ExecuteOffscreenDrawToTexture( + nint device, + nint queue, + TranslatedGuestDraw draw, + nint target) + { + var formats = new[] { new MetalRenderTargetFormat(MtlPixelFormat.Bgra8Unorm, Gen5PixelOutputKind.Float) }; + if (!TryGetDrawPipeline(device, draw, formats, hasDepth: false, out var pipeline)) + { + ReturnPooledGuestData(draw); + return; + } + + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + Span textureHandles = stackalloc nint[draw.Textures.Length]; + Span textureOwned = stackalloc bool[draw.Textures.Length]; + ResolveDrawTextures(device, commandBuffer, draw.Textures, textureHandles, textureOwned); + var encoder = MetalNative.Send( + commandBuffer, + MetalNative.Selector("renderCommandEncoderWithDescriptor:"), + CreateClearPass(target, new MtlClearColor { Alpha = 1 })); + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + var work = new OffscreenGuestDraw(draw, [], null, PublishTarget: false, ShaderAddress: 0); + EncodeDrawBindings(device, encoder, work, textureHandles, textureOwned, out var writeBackBuffers); + EncodeDrawCall(encoder, draw); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + TagUploadPages(commandBuffer); + TagSnapshotResources(commandBuffer); + if (writeBackBuffers.Count > 0) + { + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted")); + WriteBuffersBackToGuest(writeBackBuffers); + } + + ReturnPooledGuestData(draw); + } + + private static void EncodeRenderState( + nint device, + nint encoder, + GuestRenderState renderState, + GuestDepthState depthState, + bool hasDepth, + uint targetWidth, + uint targetHeight) + { + // CB_BLEND_RED..ALPHA feed the CONSTANT_COLOR / CONSTANT_ALPHA blend + // factors; encoder state, so set unconditionally like the Vulkan + // presenter's dynamic blend constants. + var blendConstant = renderState.BlendConstant; + MetalNative.SendVoidBlendColor( + encoder, + MetalNative.Selector("setBlendColorRed:green:blue:alpha:"), + blendConstant.Red, + blendConstant.Green, + blendConstant.Blue, + blendConstant.Alpha); + + if (renderState.Viewport is { } viewport) + { + // Guests program Vulkan-style negative-height viewports to get y-up + // rendering out of Vulkan's y-down NDC. Metal's NDC is already + // y-up and rejects negative heights (the draw rasterizes nothing), + // so the equivalent is the normalized rect with the same on-screen + // mapping. + double originY = viewport.Y; + double height = viewport.Height; + if (height < 0) + { + originY += height; + height = -height; + } + + MetalNative.SendVoidViewport( + encoder, + MetalNative.Selector("setViewport:"), + new MtlViewport + { + OriginX = viewport.X, + OriginY = originY, + Width = viewport.Width, + Height = height, + ZNear = viewport.MinDepth, + ZFar = viewport.MaxDepth, + }); + } + + if (renderState.Scissor is { } scissor) + { + var x = (nuint)Math.Clamp(scissor.X, 0, (int)targetWidth); + var y = (nuint)Math.Clamp(scissor.Y, 0, (int)targetHeight); + var width = Math.Min(scissor.Width, targetWidth - (uint)x); + var height = Math.Min(scissor.Height, targetHeight - (uint)y); + if (width > 0 && height > 0) + { + MetalNative.SendVoidScissor( + encoder, + MetalNative.Selector("setScissorRect:"), + new MtlScissorRect { X = x, Y = y, Width = width, Height = height }); + } + } + + var raster = renderState.Raster; + // MTLCullMode: None=0, Front=1, Back=2. + var cullMode = raster switch + { + { CullFront: true, CullBack: true } => 3, + { CullFront: true } => 1, + { CullBack: true } => 2, + _ => 0, + }; + if (cullMode == 3) + { + // Culling both faces draws nothing; Metal has no such mode, so an + // empty scissor is the cheapest equivalent. + MetalNative.SendVoidScissor( + encoder, + MetalNative.Selector("setScissorRect:"), + new MtlScissorRect { X = 0, Y = 0, Width = 1, Height = 1 }); + } + else if (cullMode != 0) + { + MetalNative.Send(encoder, MetalNative.Selector("setCullMode:"), (nint)cullMode); + } + + // MTLWinding: Clockwise=0, CounterClockwise=1. + MetalNative.Send( + encoder, + MetalNative.Selector("setFrontFacingWinding:"), + raster.FrontFaceClockwise ? 0 : 1); + if (raster.Wireframe) + { + // MTLTriangleFillMode.Lines = 1. + MetalNative.Send(encoder, MetalNative.Selector("setTriangleFillMode:"), 1); + } + + if (hasDepth) + { + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLDepthStencilDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + // The guest ZFUNC encoding matches MTLCompareFunction ordering. + MetalNative.Send( + descriptor, + MetalNative.Selector("setDepthCompareFunction:"), + (nint)(depthState.TestEnable ? depthState.CompareOp & 0x7 : 7)); + MetalNative.SendVoidBool( + descriptor, MetalNative.Selector("setDepthWriteEnabled:"), depthState.WriteEnable); + var depthStencilState = MetalNative.Send( + device, MetalNative.Selector("newDepthStencilStateWithDescriptor:"), descriptor); + MetalNative.SendVoid(encoder, MetalNative.Selector("setDepthStencilState:"), depthStencilState); + } + } + + /// Builds and binds a stage's sampler argument buffer: one 8-byte + /// Tier 2 resource ID per sampled image, written into an arena slice and + /// bound at the shader's SamplerArgBufferIndex. The stage's images are + /// draw.Textures[ImageBindingBase + j] for its j-th image, matching how the + /// translator numbered SamplerSlots. No-op for stages that sample nothing. + private static void BindSamplerArgumentBuffer( + nint device, + nint encoder, + nint selSetBuffer, + MetalCompiledGuestShader shader, + GuestDrawTexture[] textures) + { + var slots = shader.Shader.SamplerSlots; + var count = shader.Shader.SamplerCount; + var argIndex = shader.Shader.SamplerArgBufferIndex; + if (slots is null || count == 0 || argIndex < 0) + { + return; + } + + var imageBase = shader.Shader.ImageBindingBase; + var slice = AllocateUpload(device, count * sizeof(ulong), out var buffer, out var offset); + slice.Clear(); + for (var j = 0; j < slots.Count; j++) + { + var slot = slots[j]; + if (slot < 0) + { + continue; + } + + var sampler = GetOrCreateSampler(device, textures[imageBase + j].Sampler); + var resourceId = MetalNative.SendGpuResourceId(sampler, MetalNative.Selector("gpuResourceID")); + System.Buffers.Binary.BinaryPrimitives.WriteUInt64LittleEndian( + slice[(slot * sizeof(ulong))..], resourceId); + } + + MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)argIndex); + } + + /// Resolves every texture a draw samples, encoding any snapshot + /// blits into ; must run before the + /// consuming encoder opens on that command buffer. + private static void ResolveDrawTextures( + nint device, + nint blitCommandBuffer, + GuestDrawTexture[] textures, + Span handles, + Span owned) + { + for (var index = 0; index < textures.Length; index++) + { + handles[index] = CreateDrawTexture( + device, blitCommandBuffer, textures[index], out var ownedTexture); + owned[index] = ownedTexture; + } + } + + /// Binds everything the translation contract names: global buffers + /// and SharpEmuUniforms to both stages, the pre-resolved textures/samplers + /// to both stages, vertex streams at the high slots. Collects the writable + /// buffers for guest write-back. + private static void EncodeDrawBindings( + nint device, + nint encoder, + OffscreenGuestDraw work, + ReadOnlySpan textureHandles, + ReadOnlySpan textureOwned, + out List<(nint Pointer, GuestMemoryBuffer Guest)> writeBackBuffers) + { + var draw = work.Draw; + writeBackBuffers = []; + + var selSetVertexBuffer = MetalNative.Selector("setVertexBuffer:offset:atIndex:"); + var selSetFragmentBuffer = MetalNative.Selector("setFragmentBuffer:offset:atIndex:"); + var bufferCount = draw.GlobalMemoryBuffers.Length; + Span boundBytes = stackalloc uint[Math.Max(bufferCount, 1)]; + for (var index = 0; index < bufferCount; index++) + { + var guest = draw.GlobalMemoryBuffers[index]; + var pointer = UploadGlobalBuffer( + device, guest, out var buffer, out var offset, out boundBytes[index]); + MetalNative.SendSetBuffer(encoder, selSetVertexBuffer, buffer, (nuint)offset, (nuint)index); + MetalNative.SendSetBuffer(encoder, selSetFragmentBuffer, buffer, (nuint)offset, (nuint)index); + if (guest.Writable && guest.WriteBackToGuest) + { + writeBackBuffers.Add((pointer, guest)); + } + } + + // SharpEmuUniforms per the translation contract: dispatch limit (unused by + // graphics stages), reserved, then each bound buffer's byte length + // (including the alignment-bias prefix the shader indexes past). + var uniforms = AllocateUpload( + device, + 16 + (Math.Max(bufferCount, 1) * sizeof(uint)), + out var uniformsBuffer, + out var uniformsOffset); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms, 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[4..], 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[8..], 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0); + for (var index = 0; index < bufferCount; index++) + { + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( + uniforms[(16 + (index * sizeof(uint)))..], + boundBytes[index]); + } + + // Each stage declares SharpEmuUniforms at its own translation-time index + // (globalBufferBase + totalGlobalBufferCount). A draw whose vertex-stage + // guest buffers sit after the pixel stage's gives the two stages + // different indices, so bind the buffer at each stage's declared slot — + // one shared index leaves the other stage's uniforms unbound, which + // zeroes its bounds-checked loads (caught by Metal API validation as + // "missing Buffer binding ... for sharpemu_uniforms"). + var vertexUniformsIndex = draw.VertexShader?.Shader.UniformsBufferIndex ?? -1; + MetalNative.SendSetBuffer( + encoder, + selSetVertexBuffer, + uniformsBuffer, + (nuint)uniformsOffset, + (nuint)(vertexUniformsIndex >= 0 ? vertexUniformsIndex : bufferCount)); + var fragmentUniformsIndex = draw.PixelShader.Shader.UniformsBufferIndex; + MetalNative.SendSetBuffer( + encoder, + selSetFragmentBuffer, + uniformsBuffer, + (nuint)uniformsOffset, + (nuint)(fragmentUniformsIndex >= 0 ? fragmentUniformsIndex : bufferCount)); + + var selSetVertexTexture = MetalNative.Selector("setVertexTexture:atIndex:"); + var selSetFragmentTexture = MetalNative.Selector("setFragmentTexture:atIndex:"); + // Texture slots are global across the draw's stages ([0, vertexImageBase) + // is the pixel stage's block), so textures bind to both stage tables at + // their global index. + for (var index = 0; index < draw.Textures.Length; index++) + { + var texture = textureHandles[index]; + if (texture != 0) + { + MetalNative.SendSetAtIndex(encoder, selSetVertexTexture, texture, (nuint)index); + MetalNative.SendSetAtIndex(encoder, selSetFragmentTexture, texture, (nuint)index); + if (textureOwned[index]) + { + MetalNative.SendVoid(texture, MetalNative.Selector("release")); + } + } + } + + // Samplers travel in a per-stage argument buffer (Metal caps direct + // sampler slots at 16 per stage, but shaders sample more), one entry + // per sampled image. + BindSamplerArgumentBuffer(device, encoder, selSetFragmentBuffer, draw.PixelShader, draw.Textures); + if (draw.VertexShader is { } vertexShader) + { + BindSamplerArgumentBuffer(device, encoder, selSetVertexBuffer, vertexShader, draw.Textures); + } + + Span vertexSlots = stackalloc nuint[draw.VertexBuffers.Length]; + _ = TryAssignVertexBufferSlots(draw.VertexBuffers, vertexSlots); + for (var index = 0; index < draw.VertexBuffers.Length; index++) + { + // Streams sharing a slot read the same guest bytes; the first + // occurrence uploads and binds them once. + var duplicate = false; + for (var prior = 0; prior < index; prior++) + { + if (vertexSlots[prior] == vertexSlots[index]) + { + duplicate = true; + break; + } + } + + if (duplicate) + { + continue; + } + + var vertexBuffer = draw.VertexBuffers[index]; + var length = Math.Max(vertexBuffer.Length, 1); + var slice = AllocateUpload(device, length, out var buffer, out var offset); + vertexBuffer.Data.AsSpan(0, Math.Min(vertexBuffer.Length, vertexBuffer.Data.Length)) + .CopyTo(slice); + // The attribute's byte offset (set in the vertex descriptor) + // selects the field inside the interleaved vertex; the bind + // offset is just the arena slice. + MetalNative.SendSetBuffer( + encoder, selSetVertexBuffer, buffer, (nuint)offset, vertexSlots[index]); + } + } + + 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; + 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); + MetalNative.SendDrawIndexedPrimitives( + encoder, + MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"), + primitive, + vertexCount, + indexBuffer.Is32Bit ? 1u : 0u, + buffer, + (nuint)offset, + Math.Max(draw.InstanceCount, 1)); + if (indexBuffer.Pooled) + { + GuestDataPool.Shared.Return(indexBuffer.Data); + } + } + else + { + MetalNative.SendDrawPrimitivesInstanced( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"), + primitive, + 0, + vertexCount, + Math.Max(draw.InstanceCount, 1)); + } + } + + private static bool TryGetDrawPipeline( + nint device, + TranslatedGuestDraw draw, + MetalRenderTargetFormat[] targetFormats, + bool hasDepth, + out nint pipeline) + { + var stateHash = 14695981039346656037UL; + void Mix(ulong value) + { + stateHash = (stateHash ^ value) * 1099511628211UL; + } + + for (var index = 0; index < targetFormats.Length; index++) + { + Mix((ulong)targetFormats[index].Format); + var blend = draw.RenderState.Blends[index]; + Mix(blend.Enable ? 1UL : 0UL); + Mix(blend.ColorSrcFactor | ((ulong)blend.ColorDstFactor << 8) | ((ulong)blend.ColorFunc << 16)); + Mix(blend.AlphaSrcFactor | ((ulong)blend.AlphaDstFactor << 8) | ((ulong)blend.AlphaFunc << 16)); + Mix(blend.SeparateAlphaBlend ? 1UL : 0UL); + Mix(blend.WriteMask); + } + + Mix(hasDepth ? 2UL : 1UL); + Span vertexSlots = stackalloc nuint[draw.VertexBuffers.Length]; + if (!TryAssignVertexBufferSlots(draw.VertexBuffers, vertexSlots)) + { + if (_vertexSlotOverflowTraces < 16) + { + _vertexSlotOverflowTraces++; + Console.Error.WriteLine( + "[LOADER][WARN] Metal skipped draw: " + + $"{draw.VertexBuffers.Length} vertex streams need more than " + + $"{MaxVertexStageBufferIndex - VertexBufferSlotBase + 1} unique buffer slots."); + } + + pipeline = 0; + return false; + } + + for (var index = 0; index < draw.VertexBuffers.Length; index++) + { + var vertexBuffer = draw.VertexBuffers[index]; + Mix(vertexBuffer.Location | + ((ulong)vertexBuffer.ComponentCount << 8) | + ((ulong)vertexBuffer.DataFormat << 16) | + ((ulong)vertexBuffer.NumberFormat << 26) | + ((ulong)vertexBuffer.Stride << 34)); + // The attribute byte offset and the assigned slot are baked into + // the pipeline's vertex descriptor, so both must key the cache + // (the slot captures which streams alias one guest buffer). + Mix(vertexBuffer.OffsetBytes | ((ulong)vertexSlots[index] << 32)); + } + + var key = new PipelineKey(draw.VertexShader, draw.PixelShader, stateHash); + lock (_pipelineCache) + { + if (_pipelineCache.TryGetValue(key, out pipeline)) + { + return pipeline != 0; + } + } + + pipeline = CreateDrawPipeline(device, draw, targetFormats, hasDepth); + lock (_pipelineCache) + { + _pipelineCache[key] = pipeline; + } + + return pipeline != 0; + } + + private static nint CreateDrawPipeline( + nint device, + TranslatedGuestDraw draw, + MetalRenderTargetFormat[] targetFormats, + bool hasDepth) + { + var vertexFunction = draw.VertexShader is { } vertexShader + ? GetShaderFunction(device, vertexShader) + : GetFixedFullscreenVertexFunction(device, draw.AttributeCount); + var fragmentFunction = GetShaderFunction(device, draw.PixelShader); + if (vertexFunction == 0 || fragmentFunction == 0) + { + return 0; + } + + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction); + + var colorAttachments = MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")); + for (var index = 0; index < targetFormats.Length; index++) + { + var attachment = MetalNative.SendAtIndex( + colorAttachments, MetalNative.Selector("objectAtIndexedSubscript:"), (nuint)index); + MetalNative.Send( + attachment, MetalNative.Selector("setPixelFormat:"), (nint)targetFormats[index].Format); + var blend = draw.RenderState.Blends[index]; + MetalNative.Send( + attachment, + MetalNative.Selector("setWriteMask:"), + (nint)ToMetalWriteMask(blend.WriteMask)); + if (blend.Enable && !IsIntegerFormat(targetFormats[index].OutputKind)) + { + MetalNative.SendVoidBool(attachment, MetalNative.Selector("setBlendingEnabled:"), true); + MetalNative.Send( + attachment, + MetalNative.Selector("setSourceRGBBlendFactor:"), + (nint)ToMetalBlendFactor(blend.ColorSrcFactor)); + MetalNative.Send( + attachment, + MetalNative.Selector("setDestinationRGBBlendFactor:"), + (nint)ToMetalBlendFactor(blend.ColorDstFactor)); + MetalNative.Send( + attachment, + MetalNative.Selector("setRgbBlendOperation:"), + (nint)ToMetalBlendOperation(blend.ColorFunc)); + var alphaSrc = blend.SeparateAlphaBlend ? blend.AlphaSrcFactor : blend.ColorSrcFactor; + var alphaDst = blend.SeparateAlphaBlend ? blend.AlphaDstFactor : blend.ColorDstFactor; + var alphaFunc = blend.SeparateAlphaBlend ? blend.AlphaFunc : blend.ColorFunc; + MetalNative.Send( + attachment, + MetalNative.Selector("setSourceAlphaBlendFactor:"), + (nint)ToMetalBlendFactor(alphaSrc)); + MetalNative.Send( + attachment, + MetalNative.Selector("setDestinationAlphaBlendFactor:"), + (nint)ToMetalBlendFactor(alphaDst)); + MetalNative.Send( + attachment, + MetalNative.Selector("setAlphaBlendOperation:"), + (nint)ToMetalBlendOperation(alphaFunc)); + } + } + + if (hasDepth) + { + MetalNative.Send( + descriptor, + MetalNative.Selector("setDepthAttachmentPixelFormat:"), + (nint)MtlPixelFormat.Depth32Float); + } + + if (draw.VertexShader is not null && draw.VertexBuffers.Length > 0) + { + MetalNative.SendVoid( + descriptor, + MetalNative.Selector("setVertexDescriptor:"), + CreateVertexDescriptor(draw.VertexBuffers)); + } + + nint error = 0; + var pipeline = MetalNative.Send( + device, + MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"), + descriptor, + ref error); + if (pipeline == 0) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal draw pipeline creation failed: {MetalNative.DescribeError(error)}"); + } + + Interlocked.Increment(ref _perfPipelineCreations); + return pipeline; + } + + private static nint CreateVertexDescriptor(GuestVertexBuffer[] vertexBuffers) + { + var descriptor = MetalNative.Send( + MetalNative.Class("MTLVertexDescriptor"), MetalNative.Selector("vertexDescriptor")); + var attributes = MetalNative.Send(descriptor, MetalNative.Selector("attributes")); + var layouts = MetalNative.Send(descriptor, MetalNative.Selector("layouts")); + var selAt = MetalNative.Selector("objectAtIndexedSubscript:"); + Span slots = stackalloc nuint[vertexBuffers.Length]; + _ = TryAssignVertexBufferSlots(vertexBuffers, slots); + for (var index = 0; index < vertexBuffers.Length; index++) + { + var vertexBuffer = vertexBuffers[index]; + var slot = slots[index]; + var attribute = MetalNative.SendAtIndex(attributes, selAt, vertexBuffer.Location); + MetalNative.Send( + attribute, + MetalNative.Selector("setFormat:"), + (nint)ToMetalVertexFormat( + vertexBuffer.DataFormat, vertexBuffer.NumberFormat, vertexBuffer.ComponentCount)); + // The guest byte offset is the attribute's position inside the + // interleaved vertex; carry it on the attribute (buffer bound at 0) + // rather than the buffer bind offset. Metal fetches a fixed + // (bind-offset + attribute-offset + index*stride), so the two are + // arithmetically equal, but a non-zero per-buffer bind offset here + // fetched zero on this path — keeping the attribute offset is the + // layout Metal's vertex-descriptor path expects. + var attributeOffset = vertexBuffer.OffsetBytes < (uint)vertexBuffer.Length + ? vertexBuffer.OffsetBytes + : 0; + MetalNative.Send(attribute, MetalNative.Selector("setOffset:"), (nint)attributeOffset); + MetalNative.Send(attribute, MetalNative.Selector("setBufferIndex:"), (nint)slot); + + var layout = MetalNative.SendAtIndex(layouts, selAt, slot); + var stride = vertexBuffer.Stride != 0 + ? 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); + } + + return descriptor; + } + + private static nint GetShaderFunction(nint device, MetalCompiledGuestShader shader) + { + if (shader.CachedLibrary == 0) + { + if (!TryCompileLibrary(device, shader.Shader.Source, out var library, out var error)) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal shader compile failed: {error}"); + return 0; + } + + shader.CachedLibrary = library; + } + + return MetalNative.Send( + shader.CachedLibrary, + MetalNative.Selector("newFunctionWithName:"), + MetalNative.NsString(shader.Shader.EntryPoint)); + } + + private static readonly Dictionary _fixedVertexLibraries = new(); + private static nint _fixedDrawPipeline; + + private static nint GetFixedFullscreenVertexFunction(nint device, uint attributeCount) + { + nint library; + lock (_fixedVertexLibraries) + { + _fixedVertexLibraries.TryGetValue(attributeCount, out library); + } + + if (library == 0) + { + if (!TryCompileLibrary( + device, MslFixedShaders.CreateFullscreenVertex(attributeCount), out library, out var error)) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal fullscreen vertex compile failed: {error}"); + return 0; + } + + lock (_fixedVertexLibraries) + { + _fixedVertexLibraries[attributeCount] = library; + } + } + + return MetalNative.Send( + library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("fullscreen_vs")); + } + + private static bool TryGetFixedDrawPipeline(nint device, out nint pipeline) + { + if (_fixedDrawPipeline != 0) + { + pipeline = _fixedDrawPipeline; + return true; + } + + pipeline = 0; + var vertexFunction = GetFixedFullscreenVertexFunction(device, 1); + if (vertexFunction == 0) + { + return false; + } + + if (!TryCompileLibrary(device, MslFixedShaders.CreateAttributeFragment(0), out var library, out var error)) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal fixed draw pipeline unavailable: {error}"); + return false; + } + + var fragmentFunction = MetalNative.Send( + library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("attribute_fs")); + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction); + var attachment = MetalNative.SendAtIndex( + MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")), + MetalNative.Selector("objectAtIndexedSubscript:"), + 0); + MetalNative.Send(attachment, MetalNative.Selector("setPixelFormat:"), (nint)MtlPixelFormat.Bgra8Unorm); + nint pipelineError = 0; + pipeline = MetalNative.Send( + device, + MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"), + descriptor, + ref pipelineError); + _fixedDrawPipeline = pipeline; + _ = error; + return pipeline != 0; + } + + private static GuestImage? EnsureGuestRenderTarget( + nint device, + GuestRenderTarget target, + MtlPixelFormat format) + { + lock (_gate) + { + if (_guestImages.TryGetValue(target.Address, out var existing) && + existing.Width == target.Width && + existing.Height == target.Height) + { + return existing; + } + } + + if (target.Width == 0 || target.Height == 0 || target.Width > 16384 || target.Height > 16384) + { + return null; + } + + var image = new GuestImage + { + Texture = CreateGuestTexture(device, format, target.Width, target.Height), + Width = target.Width, + Height = target.Height, + Format = format, + }; + if (image.Texture == 0) + { + return null; + } + + byte[]? initialData; + lock (_gate) + { + _pendingGuestImageInitialData.Remove(target.Address, out initialData); + if (_guestImages.TryGetValue(target.Address, out var replaced)) + { + RetireGuestImageVariantLocked(target.Address, replaced); + } + + _guestImages[target.Address] = image; + _guestImageExtents[target.Address] = (target.Width, target.Height, (ulong)target.Width * target.Height * 4); + } + + // Pending initial data is RGBA8; only 4-byte-texel targets take it verbatim. + if (initialData is not null && + MetalRenderTargetFormat.GetBytesPerPixel(format) == 4 && + (ulong)initialData.Length >= (ulong)target.Width * target.Height * 4) + { + ReplaceTextureContents( + image.Texture, target.Width, target.Height, initialData, target.Width, bytesPerPixel: 4); + // A guest-memory seed initializes content without marking it + // GPU-produced; the version still moves so snapshots refresh. + image.Initialized = true; + image.ContentVersion++; + } + + return image; + } + + private static void RetireGuestImageVariantLocked(ulong address, GuestImage retired) + { + var key = (address, retired.Width, retired.Height, retired.Format); + if (_guestImageVariants.Remove(key, out var previous)) + { + previous.ReleaseSnapshot(); + MetalNative.SendVoid(previous.Texture, MetalNative.Selector("release")); + } + else + { + while (_guestImageVariantOrder.Count >= MaxGuestImageVariants) + { + var evicted = _guestImageVariantOrder.Dequeue(); + if (_guestImageVariants.Remove(evicted, out var old)) + { + old.ReleaseSnapshot(); + MetalNative.SendVoid(old.Texture, MetalNative.Selector("release")); + } + } + + _guestImageVariantOrder.Enqueue(key); + } + + _guestImageVariants[key] = retired; + } + + private static GuestImage EnsureGuestDepthImage( + nint device, + GuestDepthTarget target, + uint width, + uint height) + { + var address = target.Address; + lock (_gate) + { + if (target.ReadAddress != 0 && target.ReadAddress != address) + { + _guestDepthReadAliases[target.ReadAddress] = address; + } + + if (_guestDepthImages.TryGetValue(address, out var existing) && + existing.Width == width && + existing.Height == height) + { + return existing; + } + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)MtlPixelFormat.Depth32Float, + width, + height, + mipmapped: false); + MetalNative.Send( + descriptor, MetalNative.Selector("setUsage:"), (nint)(UsageRenderTarget | UsageShaderRead)); + // MTLStorageMode.Private = 2: depth never round-trips to the CPU. + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), 2); + var image = new GuestImage + { + Texture = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor), + Width = width, + Height = height, + Format = MtlPixelFormat.Depth32Float, + }; + lock (_gate) + { + if (_guestDepthImages.Remove(address, out var replaced)) + { + replaced.ReleaseSnapshot(); + MetalNative.SendVoid(replaced.Texture, MetalNative.Selector("release")); + } + + _guestDepthImages[address] = image; + } + + return image; + } + + private static nint GetTransientTarget(nint device, MtlPixelFormat format, uint width, uint height) + { + var key = (format, width, height); + lock (_transientTargets) + { + if (_transientTargets.TryGetValue(key, out var existing)) + { + return existing; + } + } + + var texture = CreateGuestTexture(device, format, width, height); + lock (_transientTargets) + { + _transientTargets[key] = texture; + } + + return texture; + } + + private static int _missingTextureTraces; + + /// Resolves one draw texture. Snapshot copies for feedback reads + /// are encoded into , so this must be + /// called before the consuming render or compute encoder opens on that + /// same command buffer — encoder order is what keeps the snapshot after + /// earlier batched passes that render to the source image. + private static nint CreateDrawTexture( + nint device, + nint blitCommandBuffer, + GuestDrawTexture texture, + out bool ownedByCaller) + { + ownedByCaller = true; + if (texture.Width == 0 || texture.Height == 0) + { + return 0; + } + + var cacheable = IsCacheableDrawTexture(texture); + + // Feedback reads of a live guest render target sample an ordered + // snapshot, resolved like the Vulkan presenter: a guest depth image + // first (shadow-style depth sampling), then the current image or a + // retired variant at the same address, scored by descriptor match. + if (texture.RgbaPixels.Length == 0 && texture.Address != 0) + { + if (TryCreateDepthSampleTexture(device, blitCommandBuffer, texture, out var depthSample)) + { + ownedByCaller = false; + return depthSample; + } + + var live = ResolveGuestImageAlias(texture); + if (live is { Initialized: true } && blitCommandBuffer != 0) + { + // One snapshot per content version: draws sampling the same + // unchanged image share it, so the blit happens per content + // change instead of per draw — compositing games otherwise + // copy a full render target for every draw. The image holds + // the retain; consuming command buffers keep replaced + // snapshots alive until they complete. + if (live.SnapshotTexture != 0 && live.SnapshotVersion == live.ContentVersion) + { + ownedByCaller = false; + return live.SnapshotTexture; + } + + var snapshot = CreateGuestTexture(device, live.Format, live.Width, live.Height); + if (snapshot != 0) + { + EncodeCopyTexture(blitCommandBuffer, live.Texture, snapshot); + live.ReleaseSnapshot(); + live.SnapshotTexture = snapshot; + live.SnapshotVersion = live.ContentVersion; + ownedByCaller = false; + return snapshot; + } + } + + // Empty texels can also mean the submit thread skipped the + // guest-memory copy because this identity is cached here. + if (cacheable && TryGetCachedDrawTexture(texture, out var cachedSkip)) + { + ownedByCaller = false; + return cachedSkip; + } + + // A miss on skipped texels is an invalidation race (the entry + // was evicted after the submit thread checked). Self-heal by + // reading the texels directly rather than rendering a fallback. + var refreshed = TryReadGuestDrawTexturePixels(texture); + if (refreshed is null) + { + if (_missingTextureTraces < 16) + { + _missingTextureTraces++; + Console.Error.WriteLine( + $"[LOADER][WARN] Metal draw texture unresolved: live 0x{texture.Address:X} " + + $"{texture.Width}x{texture.Height} found={live is not null} " + + $"init={live?.Initialized ?? false}"); + } + + return 0; + } + + texture = texture with { RgbaPixels = refreshed }; + } + else if (cacheable && TryGetCachedDrawTexture(texture, out var cached)) + { + // Fresh texels for an identity already cached: the content is + // unchanged (a guest write would have evicted the entry at drain + // start), so skip the redundant texture creation and upload. + ownedByCaller = false; + return cached; + } + + // AGC ships the raw (detiled) source texels; create the texture in the + // guest's native format — Mac-family GPUs sample BC blocks directly — + // and size expectations with the same block-aware math AGC used. + var textureFormat = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType); + var pitch = texture.Pitch != 0 ? Math.Max(texture.Pitch, texture.Width) : texture.Width; + var expectedBytes = MetalGuestFormats.GetTextureByteCount(textureFormat, pitch, texture.Height); + if ((ulong)texture.RgbaPixels.Length < expectedBytes) + { + if (_missingTextureTraces < 16) + { + _missingTextureTraces++; + Console.Error.WriteLine( + $"[LOADER][WARN] Metal draw texture undersized: 0x{texture.Address:X} " + + $"{texture.Width}x{texture.Height} pitch={texture.Pitch} " + + $"fmt={texture.Format}/{texture.NumberType} " + + $"bytes={texture.RgbaPixels.Length} expected={expectedBytes}"); + } + + return 0; + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)textureFormat.Format, + texture.Width, + texture.Height, + mipmapped: false); + // MTLStorageModeShared (0): CPU-uploaded (replaceRegion) + GPU-sampled; + // the Managed default reads stale on unified memory (see CreateGuestTexture). + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0); + if (texture.IsStorage) + { + MetalNative.Send( + descriptor, + MetalNative.Selector("setUsage:"), + (nint)(UsageShaderRead | UsageShaderWrite)); + } + else if (texture.DstSelect != 0xFAC && texture.DstSelect != 0) + { + // Channel select from the guest descriptor, like the Vulkan view's + // component mapping. Shader-writable textures reject swizzles, so + // storage stays identity (matching Vulkan, which never swizzles + // storage views either). + MetalNative.SendVoidSwizzle( + descriptor, + MetalNative.Selector("setSwizzle:"), + ToMetalSwizzle(texture.DstSelect)); + } + + var handle = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + if (handle != 0) + { + ReplaceDrawTextureContents(handle, texture, pitch, textureFormat); + if (cacheable) + { + CacheDrawTexture(texture, handle); + } + } + + return handle; + } + + /// Uploads a draw texture's source texels: linear formats reuse the + /// row-clamping helper; block-compressed formats upload whole 4x4 block rows + /// with the block-row stride replaceRegion expects. + private static unsafe void ReplaceDrawTextureContents( + nint handle, + GuestDrawTexture texture, + uint pitch, + in MetalTextureFormat format) + { + if (!format.IsBlockCompressed) + { + ReplaceTextureContents( + handle, texture.Width, texture.Height, texture.RgbaPixels, pitch, format.BytesPerPixel); + return; + } + + var blocksWide = ((ulong)Math.Max(pitch, texture.Width) + 3) / 4; + var bytesPerBlockRow = blocksWide * format.BlockBytes; + if (bytesPerBlockRow == 0) + { + return; + } + + var blockRows = Math.Min( + ((ulong)texture.Height + 3) / 4, + (ulong)texture.RgbaPixels.Length / bytesPerBlockRow); + if (blockRows == 0) + { + return; + } + + var texelRows = Math.Min(texture.Height, (uint)(blockRows * 4)); + fixed (byte* source = texture.RgbaPixels) + { + MetalNative.SendReplaceRegion( + handle, + MetalNative.Selector("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), + new MtlRegion { X = 0, Y = 0, Z = 0, Width = texture.Width, Height = texelRows, Depth = 1 }, + 0, + (nint)source, + (nuint)bytesPerBlockRow); + } + } + + /// Guest DST_SEL (3 bits per channel: 0=zero, 1=one, 4..7=RGBA) to + /// Metal swizzle bytes, mirroring the Vulkan view's component mapping. + private static MtlTextureSwizzleChannels ToMetalSwizzle(uint dstSelect) => new() + { + Red = ToMetalSwizzleChannel(dstSelect & 0x7, identity: 2), + Green = ToMetalSwizzleChannel((dstSelect >> 3) & 0x7, identity: 3), + Blue = ToMetalSwizzleChannel((dstSelect >> 6) & 0x7, identity: 4), + Alpha = ToMetalSwizzleChannel((dstSelect >> 9) & 0x7, identity: 5), + }; + + private static byte ToMetalSwizzleChannel(uint selector, byte identity) => + selector switch + { + 0 => 0, + 1 => 1, + 4 => 2, + 5 => 3, + 6 => 4, + 7 => 5, + _ => identity, + }; + + /// Resolves a live guest texture to the current image or a retired + /// same-address variant, scored by descriptor match like the Vulkan + /// presenter's guest-image variants: exact extent outranks format, format + /// outranks initialization, and the active image breaks ties. + private static GuestImage? ResolveGuestImageAlias(GuestDrawTexture texture) + { + var hasViewFormat = MetalGuestFormats.TryDecodeRenderTargetFormat( + texture.Format, texture.NumberType, out var viewFormat); + GuestImage? best = null; + var bestScore = int.MinValue; + + void Consider(GuestImage candidate, bool isActive) + { + // Exact extent always qualifies; a larger image qualifies only for + // tiled descriptors, mirroring IsCompatibleGuestImageAlias. + var sizeMatch = candidate.Width == texture.Width && + candidate.Height == texture.Height; + if (!sizeMatch && + (texture.TileMode == 0 || + texture.Width == 0 || + texture.Height == 0 || + texture.Width > candidate.Width || + texture.Height > candidate.Height)) + { + return; + } + + var score = 0; + if (sizeMatch) + { + score += 32; + } + + if (hasViewFormat && candidate.Format == viewFormat.Format) + { + score += 16; + } + + if (candidate.Initialized) + { + score += 4; + } + + if (isActive) + { + score += 1; + } + + if (score > bestScore) + { + best = candidate; + bestScore = score; + } + } + + lock (_gate) + { + if (_guestImages.TryGetValue(texture.Address, out var active)) + { + Consider(active, isActive: true); + } + + foreach (var (key, candidate) in _guestImageVariants) + { + if (key.Address == texture.Address) + { + Consider(candidate, isActive: false); + } + } + } + + return best; + } + + /// Snapshots a guest depth image for sampling when the texture + /// descriptor names a depth target's write or read address. Depth32Float + /// cannot blit to a color format, so the copy round-trips through a + /// buffer into an R32Float texture the translated shader can sample. + private static bool TryCreateDepthSampleTexture( + nint device, + nint blitCommandBuffer, + GuestDrawTexture texture, + out nint sample) + { + sample = 0; + // Identity channel select only; swizzled depth reads keep the + // unresolved-texture warning until a title needs them. + if (texture.DstSelect != 0xFAC || blitCommandBuffer == 0) + { + return false; + } + + GuestImage? depth; + lock (_gate) + { + if (!_guestDepthImages.TryGetValue(texture.Address, out depth) && + _guestDepthReadAliases.TryGetValue(texture.Address, out var primary)) + { + _guestDepthImages.TryGetValue(primary, out depth); + } + } + + if (depth is null || + !depth.Initialized || + texture.Width > depth.Width || + texture.Height > depth.Height) + { + return false; + } + + var bytesPerRow = (nuint)depth.Width * 4; + var bytesPerImage = bytesPerRow * depth.Height; + var staging = AcquireSnapshotBuffer(device, bytesPerImage); + if (staging == 0) + { + return false; + } + + sample = AcquireSnapshotTexture( + device, + MtlPixelFormat.R32Float, + depth.Width, + depth.Height, + (nint)UsageShaderRead); + if (sample == 0) + { + return false; + } + + var blit = MetalNative.Send(blitCommandBuffer, MetalNative.Selector("blitCommandEncoder")); + var size = new MtlSize { Width = depth.Width, Height = depth.Height, Depth = 1 }; + MetalNative.SendCopyTextureToBuffer( + blit, + MetalNative.Selector( + "copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:" + + "toBuffer:destinationOffset:destinationBytesPerRow:destinationBytesPerImage:"), + depth.Texture, + 0, + 0, + default, + size, + staging, + 0, + bytesPerRow, + bytesPerImage); + MetalNative.SendCopyBufferToTexture( + blit, + MetalNative.Selector( + "copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:" + + "toTexture:destinationSlice:destinationLevel:destinationOrigin:"), + staging, + 0, + bytesPerRow, + bytesPerImage, + size, + sample, + 0, + 0, + default); + MetalNative.SendVoid(blit, MetalNative.Selector("endEncoding")); + return true; + } + + private static nint GetOrCreateSampler(nint device, GuestSampler sampler) + { + lock (_samplerCache) + { + if (_samplerCache.TryGetValue(sampler, out var cached)) + { + return cached; + } + } + + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLSamplerDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.Send( + descriptor, + MetalNative.Selector("setSAddressMode:"), + (nint)ToMetalAddressMode(sampler.Word0 & 0x7)); + MetalNative.Send( + descriptor, + MetalNative.Selector("setTAddressMode:"), + (nint)ToMetalAddressMode((sampler.Word0 >> 3) & 0x7)); + MetalNative.Send( + descriptor, + MetalNative.Selector("setRAddressMode:"), + (nint)ToMetalAddressMode((sampler.Word0 >> 6) & 0x7)); + var magFilter = (sampler.Word2 >> 20) & 0x3; + var minFilter = (sampler.Word2 >> 22) & 0x3; + MetalNative.Send( + descriptor, + MetalNative.Selector("setMagFilter:"), + magFilter is 1 or 3 ? 1 : 0); + MetalNative.Send( + descriptor, + MetalNative.Selector("setMinFilter:"), + minFilter is 1 or 3 ? 1 : 0); + + var handle = MetalNative.Send( + device, MetalNative.Selector("newSamplerStateWithDescriptor:"), descriptor); + lock (_samplerCache) + { + _samplerCache[sampler] = handle; + } + + return handle; + } + + // A guest global buffer is bound so the shader's alignment bias (the guest + // base address's low bits below the storage-buffer offset alignment) lands + // on the real data: the slice holds bias + length bytes with the data at + // offset bias, matching how the Vulkan backend binds into a larger + // allocation at an aligned-down descriptor offset. boundBytes is what + // SharpEmuUniforms must carry so the shader's bounds check passes. The + // returned pointer addresses the data (past the bias) for write-backs. + private const ulong StorageBufferOffsetAlignment = 256; + + private static unsafe nint UploadGlobalBuffer( + nint device, + GuestMemoryBuffer guest, + out nint buffer, + out int offset, + out uint boundBytes) + { + var bias = (int)((ulong)guest.BaseAddress & (StorageBufferOffsetAlignment - 1)); + var length = Math.Clamp(guest.Length, 0, guest.Data.Length); + boundBytes = (uint)(bias + length); + var slice = AllocateUpload(device, bias + Math.Max(length, 1), out buffer, out offset); + if (bias != 0) + { + // Deterministic zeros below the bias, like the padded copy had. + slice[..bias].Clear(); + } + + guest.Data.AsSpan(0, length).CopyTo(slice[bias..]); + fixed (byte* data = slice) + { + return (nint)(data + bias); + } + } + + private static void WriteBuffersBackToGuest( + List<(nint Pointer, GuestMemoryBuffer Guest)> writeBackBuffers) + { + var memory = _guestMemory; + if (memory is null) + { + return; + } + + foreach (var (pointer, guest) in writeBackBuffers) + { + unsafe + { + // The pointer addresses the slice's data (past the alignment + // bias) inside its shared-storage arena page, which stays + // alive until the command buffer completes — and the caller + // waited on that before reading. + _ = memory.TryWrite( + guest.BaseAddress, + new ReadOnlySpan((void*)pointer, guest.Length)); + } + } + } + + private static void ReturnPooledGuestData(TranslatedGuestDraw draw) + { + foreach (var buffer in draw.GlobalMemoryBuffers) + { + if (buffer.Pooled) + { + GuestDataPool.Shared.Return(buffer.Data); + } + } + + foreach (var vertexBuffer in draw.VertexBuffers) + { + if (vertexBuffer.Pooled) + { + GuestDataPool.Shared.Return(vertexBuffer.Data); + } + } + } + + // MTLPrimitiveType: Point=0, Line=1, LineStrip=2, Triangle=3, TriangleStrip=4. + private static nuint GetPrimitiveType(uint guestPrimitiveType) + { + switch (guestPrimitiveType) + { + case 1: + return 0; + case 2: + return 1; + case 3: + return 2; + case 5: + // Metal has no triangle fans; a list is the closest safe shape. + if (!_tracedTriangleFan) + { + _tracedTriangleFan = true; + Console.Error.WriteLine( + "[LOADER][WARN] Metal has no triangle-fan primitive; drawing as a list."); + } + + return 3; + case 6: + case 0x11: + return 4; + default: + return 3; + } + } + + private static bool IsIntegerFormat(Gen5PixelOutputKind kind) => + kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint; + + // Guest CB write-mask bits are R=1,G=2,B=4,A=8; MTLColorWriteMask reverses them. + private static nuint ToMetalWriteMask(uint guestMask) => + ((guestMask & 1) != 0 ? 8u : 0u) | + ((guestMask & 2) != 0 ? 4u : 0u) | + ((guestMask & 4) != 0 ? 2u : 0u) | + ((guestMask & 8) != 0 ? 1u : 0u); + + // Guest CB_BLEND factor codes to MTLBlendFactor, matching the Vulkan mapping. + private static nuint ToMetalBlendFactor(uint factor) => + factor switch + { + 0 => 0, // Zero + 1 => 1, // One + 2 => 2, // SourceColor + 3 => 3, // OneMinusSourceColor + 4 => 4, // SourceAlpha + 5 => 5, // OneMinusSourceAlpha + 6 => 8, // DestinationAlpha + 7 => 9, // OneMinusDestinationAlpha + 8 => 6, // DestinationColor + 9 => 7, // OneMinusDestinationColor + 10 => 10, // SourceAlphaSaturated + 13 => 11, // BlendColor + 14 => 12, // OneMinusBlendColor + 15 => 15, // Source1Color + 16 => 16, // OneMinusSource1Color + 17 => 17, // Source1Alpha + 18 => 18, // OneMinusSource1Alpha + 19 => 13, // BlendAlpha + 20 => 14, // OneMinusBlendAlpha + _ => 1, + }; + + // Guest COMB_FCN codes to MTLBlendOperation (Add=0, Sub=1, RevSub=2, Min=3, Max=4). + private static nuint ToMetalBlendOperation(uint function) => + function switch + { + 0 => 0, + 1 => 1, + 2 => 3, + 3 => 4, + 4 => 2, + _ => 0, + }; + + // Guest sampler clamp codes to MTLSamplerAddressMode, matching the Vulkan mapping. + private static nuint ToMetalAddressMode(uint mode) => + mode switch + { + 0 => 2, // Repeat + 1 => 3, // MirrorRepeat + 2 => 0, // ClampToEdge + 3 or 5 or 7 => 1, // MirrorClampToEdge + 4 or 6 => 5, // ClampToBorderColor + _ => 0, + }; + + // Guest vertex (dataFormat, numberFormat) codes to MTLVertexFormat raw values, + // mirroring the Vulkan attribute table; unmapped codes fall back to float{n}. + private static nuint ToMetalVertexFormat(uint dataFormat, uint numberFormat, uint componentCount) + { + var format = (dataFormat, numberFormat) switch + { + (1, 0) => 47u, // ucharNormalized + (1, 1) => 48u, // charNormalized + (1, 4) => 45u, // uchar + (1, 5) => 46u, // char + (2, 0) => 51u, // ushortNormalized + (2, 1) => 52u, // shortNormalized + (2, 4) => 49u, // ushort + (2, 5) => 50u, // short + (2, 7) => 53u, // half + (3, 0) => 7u, // uchar2Normalized + (3, 1) => 10u, // char2Normalized + (3, 4) => 1u, // uchar2 + (3, 5) => 4u, // char2 + (4, 4) => 36u, // uint + (4, 5) => 32u, // int + (4, 7) => 28u, // float + (5, 0) => 19u, // ushort2Normalized + (5, 1) => 22u, // short2Normalized + (5, 4) => 13u, // ushort2 + (5, 5) => 16u, // short2 + (5, 7) => 25u, // half2 + (6, 7) or (7, 7) => 54u, // floatRG11B10 + (8, 0) or (9, 0) => 41u, // uint1010102Normalized (R in bits 0..9) + (8, 1) or (9, 1) => 40u, // int1010102Normalized + (10, 0) => 9u, // uchar4Normalized + (10, 1) => 12u, // char4Normalized + (10, 4) => 3u, // uchar4 + (10, 5) => 6u, // char4 + (11, 4) => 37u, // uint2 + (11, 5) => 33u, // int2 + (11, 7) => 29u, // float2 + (12, 0) => 21u, // ushort4Normalized + (12, 1) or (12, 6) => 24u, // short4Normalized + (12, 4) => 15u, // ushort4 + (12, 5) => 18u, // short4 + (12, 7) => 27u, // half4 + (13, 4) => 38u, // uint3 + (13, 5) => 34u, // int3 + (13, 7) => 30u, // float3 + (14, 4) => 39u, // uint4 + (14, 5) => 35u, // int4 + (14, 7) => 31u, // float4 + (34, 7) => 55u, // floatRGB9E5 + _ => 0u, + }; + if (format != 0) + { + return format; + } + + return componentCount switch + { + 1 => 28, + 2 => 29, + 3 => 30, + _ => 31, + }; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs new file mode 100644 index 0000000..d21e6b9 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs @@ -0,0 +1,1237 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.Libs.Gpu.Metal; + +// Guest work ordering and guest images, mirroring the Vulkan presenter's model: +// AGC submissions become queued work items consumed in logical-guest-queue order +// by the render loop; guest images are Metal textures keyed by guest address, +// seeded once from guest memory (PS5 render targets alias guest memory) and kept +// coherent through explicit write/fill mirroring; ordered flips capture the named +// image into an immutable version at their exact queue position so later work +// cannot change the frame a flip selected. +internal static partial class MetalVideoPresenter +{ + private const int MaxPendingGuestWork = 64; + private const int MaxGuestWorkPerRender = 256; + private const int MaxPendingGuestFlipVersions = 4; + private const ulong MaxPendingGuestWorkBytes = 256UL * 1024 * 1024; + private static readonly long _renderWorkBudgetTicks = + 12L * System.Diagnostics.Stopwatch.Frequency / 1000L; + + private readonly record struct GuestQueueIdentity(string Name, ulong SubmissionId) + { + public static GuestQueueIdentity Default { get; } = new("host.default", 0); + } + + private readonly record struct PendingGuestWork( + object Work, + ulong PayloadBytes, + long Sequence, + GuestQueueIdentity Queue); + + private sealed record OrderedGuestAction(Action Action, string DebugName); + + private sealed record GuestImageWrite(ulong Address, byte[]? Pixels, uint FillValue); + + private sealed record OrderedGuestFlip( + long Version, + int VideoOutHandle, + int DisplayBufferIndex, + ulong Address, + uint Width, + uint Height, + uint PitchInPixel); + + private sealed record OrderedGuestFlipWait( + long Version, + int VideoOutHandle, + int DisplayBufferIndex); + + private sealed record GuestImageBlit(ulong SourceAddress, ulong DestinationAddress); + + /// A guest-addressed Metal texture (or an immutable captured version). + private sealed class GuestImage + { + public nint Texture; + public uint Width; + public uint Height; + public MtlPixelFormat Format; + public bool Initialized; + + /// True once GPU work (draw, blit, dispatch) or an explicit + /// guest write produced this content; false while it only holds a + /// speculative guest-memory seed. Flips prefer produced content. + public bool GpuWritten; + + /// Bumped whenever anything changes this image's content; + /// feedback-read snapshots are reused until it moves. Games that + /// composite by sampling their render target otherwise force a + /// full-texture blit on every draw. + public int ContentVersion; + + /// Cached feedback-read snapshot (one retain held here) and + /// the content version it captured. Command buffers that sampled it + /// retain it through completion, so replacing releases immediately. + public nint SnapshotTexture; + public int SnapshotVersion; + + public void MarkContentChanged() + { + Initialized = true; + GpuWritten = true; + ContentVersion++; + } + + public void ReleaseSnapshot() + { + if (SnapshotTexture != 0) + { + MetalNative.SendVoid(SnapshotTexture, MetalNative.Selector("release")); + SnapshotTexture = 0; + } + } + } + + // PS5 exposes independent graphics and asynchronous-compute queues; keep FIFO + // order within each logical guest queue and schedule ready queues round-robin + // so one slow queue cannot delay another (same policy as the Vulkan backend). + private static readonly Dictionary> + _pendingGuestWorkByQueue = new(StringComparer.Ordinal); + private static readonly List _pendingGuestQueueSchedule = []; + private static int _pendingGuestQueueCursor; + private static int _pendingGuestWorkCount; + private static ulong _pendingGuestWorkBytes; + private static long _enqueuedGuestWorkSequence; + private static long _completedGuestWorkSequence; + private static readonly HashSet _completedGuestWorkOutOfOrder = []; + private static readonly Dictionary _lastEnqueuedGuestWorkByQueue = + new(StringComparer.Ordinal); + private static long _executingGuestWorkSequence; + [ThreadStatic] + private static GuestQueueIdentity? _submittingGuestQueue; + [ThreadStatic] + private static bool _enqueueAsImmediateQueueFollowup; + [ThreadStatic] + private static LinkedListNode? _immediateFollowupTail; + + private static readonly Dictionary _availableGuestImages = new(); + private static readonly Dictionary + _guestImageExtents = new(); + private static readonly Dictionary _guestImages = new(); + private static readonly Dictionary _pendingGuestImageInitialData = new(); + private static readonly Dictionary _guestImageWorkSequences = new(); + private static readonly Queue _pendingGuestImagePresentations = new(); + private static readonly Dictionary _guestImageVersions = new(); + private static readonly Dictionary<(int Handle, int BufferIndex), long> + _lastOrderedGuestFlipVersions = new(); + private static long _orderedGuestFlipVersionSequence; + private static volatile ICpuMemory? _guestMemory; + + private sealed class GuestQueueScope : IDisposable + { + private readonly GuestQueueIdentity? _previous; + private bool _disposed; + + public GuestQueueScope(GuestQueueIdentity queue) + { + _previous = _submittingGuestQueue; + _submittingGuestQueue = queue; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _submittingGuestQueue = _previous; + } + } + + public static IDisposable EnterGuestQueue(string queueName, ulong submissionId) => + new GuestQueueScope(new GuestQueueIdentity( + string.IsNullOrWhiteSpace(queueName) ? "guest.unknown" : queueName, + submissionId)); + + public static void AttachGuestMemory(ICpuMemory memory) => + _guestMemory = memory; + + public static long SubmitOrderedGuestAction(Action action, string debugName) + { + ArgumentNullException.ThrowIfNull(action); + lock (_gate) + { + return _closed || _thread is null + ? 0 + : EnqueueGuestWorkLocked(new OrderedGuestAction(action, debugName)); + } + } + + public static long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) + { + lock (_gate) + { + var version = _lastOrderedGuestFlipVersions.TryGetValue( + (videoOutHandle, displayBufferIndex), + out var lastVersion) + ? lastVersion + : 0; + return _closed || _thread is null + ? 0 + : EnqueueGuestWorkLocked( + new OrderedGuestFlipWait(version, videoOutHandle, displayBufferIndex)); + } + } + + public static long CurrentGuestWorkSequenceForDiagnostics => + Volatile.Read(ref _executingGuestWorkSequence); + + public static bool WaitForGuestWork(long workSequence, int timeoutMilliseconds) + { + if (workSequence <= 0) + { + return false; + } + + var waitIndefinitely = timeoutMilliseconds == Timeout.Infinite; + var deadline = waitIndefinitely + ? long.MaxValue + : Environment.TickCount64 + Math.Max(timeoutMilliseconds, 1); + lock (_gate) + { + while (!_closed && !IsGuestWorkCompletedLocked(workSequence)) + { + var remaining = waitIndefinitely ? 1_000 : deadline - Environment.TickCount64; + if (remaining <= 0) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal guest work wait timed out sequence={workSequence} " + + $"contiguous_completed={_completedGuestWorkSequence}"); + return false; + } + + // Closing the presenter pulses this monitor, so an unbounded + // correctness wait remains interruptible. + Monitor.Wait(_gate, checked((int)Math.Min(remaining, 1_000))); + } + + return IsGuestWorkCompletedLocked(workSequence); + } + } + + public static void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) + { + if (address == 0 || guestFormat == 0) + { + return; + } + + lock (_gate) + { + _availableGuestImages[address] = guestFormat; + } + } + + public static bool IsGuestImageAvailable(ulong address, uint format, uint numberType) + { + var guestFormat = GetGuestTextureFormat(format, numberType); + if (address == 0 || guestFormat == 0) + { + return false; + } + + lock (_gate) + { + return _availableGuestImages.TryGetValue(address, out var availableFormat) && + availableFormat == guestFormat; + } + } + + public static bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) => + IsGuestImageAvailable(address, format, numberType); + + public static bool GuestImageWantsInitialData(ulong address) + { + if (address == 0) + { + return false; + } + + lock (_gate) + { + return !_availableGuestImages.ContainsKey(address) && + !_pendingGuestImageInitialData.ContainsKey(address); + } + } + + public static void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) + { + lock (_gate) + { + _pendingGuestImageInitialData[address] = rgbaPixels; + } + } + + public static bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) + { + lock (_gate) + { + if (_guestImageExtents.TryGetValue(address, out var extent)) + { + (width, height, byteCount) = extent; + return true; + } + } + + width = 0; + height = 0; + byteCount = 0; + return false; + } + + public static IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() + { + lock (_gate) + { + var extents = new (ulong, uint, uint, ulong)[_guestImageExtents.Count]; + var index = 0; + foreach (var entry in _guestImageExtents) + { + extents[index++] = (entry.Key, entry.Value.Width, entry.Value.Height, entry.Value.ByteCount); + } + + return extents; + } + } + + public static void SubmitGuestImageFill(ulong address, uint fillValue) + { + lock (_gate) + { + if (_closed || !_guestImageExtents.ContainsKey(address)) + { + return; + } + + _guestImageWorkSequences[address] = EnqueueGuestWorkLocked( + new GuestImageWrite(address, null, fillValue)); + } + } + + public static void SubmitGuestImageWrite(ulong address, byte[] pixels) + { + lock (_gate) + { + if (_closed || !_guestImageExtents.ContainsKey(address)) + { + return; + } + + _guestImageWorkSequences[address] = EnqueueGuestWorkLocked( + new GuestImageWrite(address, pixels, 0)); + } + } + + public static bool TrySubmitGuestImage(ulong address, uint width, uint height, uint pitchInPixel) + { + lock (_gate) + { + if (_closed || !_availableGuestImages.ContainsKey(address)) + { + return false; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + // Wait only for the work that last wrote this image, not the global + // queue tail — requiring the tail lets a fast guest permanently + // outrun the renderer. + var requiredWorkSequence = _guestImageWorkSequences.TryGetValue( + address, + out var imageWorkSequence) + ? imageWorkSequence + : _completedGuestWorkSequence; + var presentation = new Presentation( + null, + width, + height, + sequence, + IsSplash: false, + GuestImageAddress: address, + GuestImagePitch: pitchInPixel, + RequiredGuestWorkSequence: requiredWorkSequence); + _latestPresentation = presentation; + _pendingGuestImagePresentations.Enqueue(presentation); + while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork) + { + RetireSkippedPresentationLocked(_pendingGuestImagePresentations.Dequeue()); + } + } + + return true; + } + + public static bool TrySubmitOrderedGuestImageFlip( + int videoOutHandle, + int displayBufferIndex, + ulong address, + uint width, + uint height, + uint pitchInPixel) + { + lock (_gate) + { + if (_closed || _thread is null || !_availableGuestImages.ContainsKey(address)) + { + return false; + } + + var version = ++_orderedGuestFlipVersionSequence; + _lastOrderedGuestFlipVersions[(videoOutHandle, displayBufferIndex)] = version; + return EnqueueGuestWorkLocked( + new OrderedGuestFlip( + version, + videoOutHandle, + displayBufferIndex, + address, + width, + height, + pitchInPixel)) > 0; + } + } + + /// Same-extent, same-format image copies only; anything else returns + /// false and the caller keeps its CPU fallback. + public static bool TrySubmitGuestImageBlit( + ulong sourceAddress, + uint sourceWidth, + uint sourceHeight, + uint sourceFormat, + uint sourceNumberType, + ulong destinationAddress, + uint destinationWidth, + uint destinationHeight, + uint destinationFormat, + uint destinationNumberType) + { + if (sourceWidth != destinationWidth || + sourceHeight != destinationHeight || + GetGuestTextureFormat(sourceFormat, sourceNumberType) != + GetGuestTextureFormat(destinationFormat, destinationNumberType)) + { + return false; + } + + lock (_gate) + { + if (_closed || + _thread is null || + !_guestImages.TryGetValue(sourceAddress, out var source) || + !source.Initialized || + !_guestImages.TryGetValue(destinationAddress, out var destination) || + source.Width != destination.Width || + source.Height != destination.Height || + source.Format != destination.Format) + { + return false; + } + + _guestImageWorkSequences[destinationAddress] = EnqueueGuestWorkLocked( + new GuestImageBlit(sourceAddress, destinationAddress)); + return true; + } + } + + private static bool IsGuestWorkCompletedLocked(long sequence) => + sequence <= 0 || + sequence <= _completedGuestWorkSequence || + _completedGuestWorkOutOfOrder.Contains(sequence); + + private static long EnqueueGuestWorkLocked(object work) + { + var payloadBytes = GetGuestWorkPayloadBytes(work); + // Work executed by the render-loop consumer can enqueue an ordered + // same-queue follow-up; blocking the consumer on producer backpressure + // would deadlock a full queue, so follow-ups are always admitted. + while (!_enqueueAsImmediateQueueFollowup && + !_closed && + _thread is not null && + (_pendingGuestWorkCount >= MaxPendingGuestWork || + // Always admit one item when no payload is outstanding, even + // when that single item exceeds the configured budget: with + // nothing left to drain, waiting for room would never return. + // The budget bounds the normal multi-item backlog. + (_pendingGuestWorkBytes != 0 && + payloadBytes > MaxPendingGuestWorkBytes - + Math.Min(_pendingGuestWorkBytes, MaxPendingGuestWorkBytes)))) + { + // Full queue: ask the render loop to drain now rather than at its + // next timer tick, or this producer stalls a frame per admission. + ScheduleGuestWorkDrain(); + Monitor.Wait(_gate); + } + + if (_closed) + { + return 0; + } + + var queue = _submittingGuestQueue ?? GuestQueueIdentity.Default; + var sequence = ++_enqueuedGuestWorkSequence; + _lastEnqueuedGuestWorkByQueue[queue.Name] = sequence; + if (!_pendingGuestWorkByQueue.TryGetValue(queue.Name, out var pendingQueue)) + { + pendingQueue = new LinkedList(); + _pendingGuestWorkByQueue.Add(queue.Name, pendingQueue); + _pendingGuestQueueSchedule.Add(queue.Name); + } + + var pending = new PendingGuestWork(work, payloadBytes, sequence, queue); + if (_enqueueAsImmediateQueueFollowup && + _immediateFollowupTail is { List: not null } tail && + ReferenceEquals(tail.List, pendingQueue)) + { + _immediateFollowupTail = pendingQueue.AddAfter(tail, pending); + } + else if (_enqueueAsImmediateQueueFollowup) + { + _immediateFollowupTail = pendingQueue.AddFirst(pending); + } + else + { + pendingQueue.AddLast(pending); + } + + _pendingGuestWorkCount++; + var total = _pendingGuestWorkBytes + payloadBytes; + _pendingGuestWorkBytes = total < _pendingGuestWorkBytes ? ulong.MaxValue : total; + if (!_enqueueAsImmediateQueueFollowup) + { + // Drain promptly: guests that submit work and then wait on its + // side effects (release-mem labels, write-backs) round-trip + // through this queue several times per frame, and a timer-tick + // drain cadence turns each round-trip into a full frame interval. + ScheduleGuestWorkDrain(); + } + + return sequence; + } + + private static ulong GetGuestWorkPayloadBytes(object work) => + work is GuestImageWrite { Pixels: { } pixels } ? (ulong)pixels.Length : 0; + + private static bool TryTakeGuestWork(out PendingGuestWork work) + { + lock (_gate) + { + while (_pendingGuestQueueSchedule.Count > 0) + { + if (_pendingGuestQueueCursor >= _pendingGuestQueueSchedule.Count) + { + _pendingGuestQueueCursor = 0; + } + + var queueName = _pendingGuestQueueSchedule[_pendingGuestQueueCursor]; + if (!_pendingGuestWorkByQueue.TryGetValue(queueName, out var queue) || + queue.First is not { } first) + { + _pendingGuestWorkByQueue.Remove(queueName); + _pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor); + continue; + } + + work = first.Value; + queue.RemoveFirst(); + _pendingGuestWorkCount--; + if (queue.Count == 0) + { + _pendingGuestWorkByQueue.Remove(queueName); + _pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor); + } + else + { + _pendingGuestQueueCursor = + (_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count; + } + + return true; + } + + work = default; + return false; + } + } + + private static void CompleteGuestWork(in PendingGuestWork pending) + { + lock (_gate) + { + _pendingGuestWorkBytes = pending.PayloadBytes >= _pendingGuestWorkBytes + ? 0 + : _pendingGuestWorkBytes - pending.PayloadBytes; + if (pending.Sequence == _completedGuestWorkSequence + 1) + { + _completedGuestWorkSequence = pending.Sequence; + while (_completedGuestWorkOutOfOrder.Remove(_completedGuestWorkSequence + 1)) + { + _completedGuestWorkSequence++; + } + } + else if (pending.Sequence > _completedGuestWorkSequence) + { + _completedGuestWorkOutOfOrder.Add(pending.Sequence); + } + + Monitor.PulseAll(_gate); + } + } + + /// Drains queued guest work on the render loop, bounded by count and a + /// wall-clock budget so a backlog cannot starve the event pump or the present. + private static void DrainGuestWork(nint device, nint queue) + { + var deadline = System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks; + var completedWork = 0; + RecycleCompletedUploadPages(); + RecycleCompletedSnapshotResources(); + EvictDirtyCachedDrawTextures(); + try + { + while (completedWork < MaxGuestWorkPerRender) + { + if (!TryTakeGuestWork(out var pendingGuestWork)) + { + return; + } + + Volatile.Write(ref _executingGuestWorkSequence, pendingGuestWork.Sequence); + using var guestQueueScope = EnterGuestQueue( + pendingGuestWork.Queue.Name, + pendingGuestWork.Queue.SubmissionId); + _enqueueAsImmediateQueueFollowup = true; + _immediateFollowupTail = null; + try + { + // Draws and compute dispatches encode into the shared batch + // command buffer; everything else must observe their output + // on the serial queue, so it flushes the batch first. + switch (pendingGuestWork.Work) + { + case GuestImageWrite write: + FlushBatchedGuestCommands(); + ExecuteGuestImageWrite(device, queue, write); + break; + case OrderedGuestAction action: + FlushBatchedGuestCommands(); + ExecuteOrderedGuestAction(action); + break; + case OrderedGuestFlip flip: + FlushBatchedGuestCommands(); + ExecuteOrderedGuestFlip(device, queue, flip); + break; + case OrderedGuestFlipWait: + // Reaching this marker in queue order is the guarantee: + // the flip it follows has already captured its image. + break; + case GuestImageBlit blit: + FlushBatchedGuestCommands(); + ExecuteGuestImageBlit(queue, blit); + break; + case OffscreenGuestDraw offscreenDraw: + ExecuteOffscreenDraw(device, queue, offscreenDraw); + break; + case ComputeGuestDispatch computeDispatch: + ExecuteComputeDispatch(device, queue, computeDispatch); + break; + } + } + catch (Exception exception) + { + Console.Error.WriteLine( + $"[LOADER][ERROR] Metal guest work failed " + + $"({pendingGuestWork.Work.GetType().Name}): {exception.Message}"); + } + finally + { + CompleteGuestWork(pendingGuestWork); + _enqueueAsImmediateQueueFollowup = false; + _immediateFollowupTail = null; + Volatile.Write(ref _executingGuestWorkSequence, 0); + } + + completedWork++; + if (System.Diagnostics.Stopwatch.GetTimestamp() >= deadline) + { + return; + } + } + } + finally + { + // Whatever path exits the drain, batched work must reach the queue: + // the present pass and the next drain's recyclers both assume every + // encoded command buffer has been committed. + _ = FlushBatchedGuestCommands(); + } + } + + private static void ExecuteOrderedGuestAction(OrderedGuestAction ordered) + { + try + { + ordered.Action(); + } + catch (Exception exception) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal ordered guest action '{ordered.DebugName}' failed: {exception.Message}"); + } + } + + private static void ExecuteGuestImageWrite(nint device, nint queue, GuestImageWrite write) + { + GuestImage? image; + lock (_gate) + { + _guestImages.TryGetValue(write.Address, out image); + } + + if (image is null) + { + return; + } + + if (write.Pixels is { } pixels) + { + var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(image.Format); + if ((ulong)pixels.Length < (ulong)image.Width * image.Height * bytesPerPixel) + { + return; + } + + // Swap in a freshly written texture instead of mutating one an + // in-flight present may still sample (the command buffer keeps its + // own reference to the old texture until it completes). + var replacement = CreateGuestTexture(device, image.Format, image.Width, image.Height); + if (replacement == 0) + { + return; + } + + ReplaceTextureContents(replacement, image.Width, image.Height, pixels, image.Width, bytesPerPixel); + var previous = image.Texture; + image.Texture = replacement; + MetalNative.SendVoid(previous, MetalNative.Selector("release")); + } + else + { + ClearTexture(queue, image.Texture, write.FillValue); + } + + // The pixel path swapped the texture out entirely; either way the + // cached snapshot no longer reflects this image. + image.ReleaseSnapshot(); + image.MarkContentChanged(); + } + + private static void ExecuteOrderedGuestFlip(nint device, nint queue, OrderedGuestFlip flip) + { + // The flipped VideoOut address is the display buffer's start, but games + // render into the pixel surface, which sits past the buffer's surface + // metadata (64KB+ on PS5). Prefer a drawn image inside the buffer's + // extent over seeding a new (empty) image at the exact start address. + GuestImage? image; + lock (_gate) + { + image = FindGuestImageForFlipLocked(flip.Address, flip.Width, flip.Height); + } + + image ??= EnsureGuestImage(device, flip.Address, flip.Width, flip.Height, flip.PitchInPixel); + if (image is null || !image.Initialized) + { + return; + } + + // Capture the mutable image into an immutable generation at this exact + // queue position; later work cannot change the frame this flip selected. + var captured = new GuestImage + { + Texture = CreateGuestTexture(device, image.Format, image.Width, image.Height), + Width = image.Width, + Height = image.Height, + Format = image.Format, + Initialized = true, + }; + if (captured.Texture == 0) + { + return; + } + + CopyTexture(queue, image.Texture, captured.Texture); + + lock (_gate) + { + _guestImageVersions[flip.Version] = captured; + // Retain a short version history, always preserving the newest. + while (_guestImageVersions.Count > MaxPendingGuestFlipVersions) + { + var oldest = 0L; + foreach (var version in _guestImageVersions.Keys) + { + if (oldest == 0 || version < oldest) + { + oldest = version; + } + } + + if (oldest == flip.Version) + { + break; + } + + if (_guestImageVersions.Remove(oldest, out var retired)) + { + MetalNative.SendVoid(retired.Texture, MetalNative.Selector("release")); + } + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + var presentation = new Presentation( + null, + image.Width, + image.Height, + sequence, + IsSplash: false, + GuestImageAddress: flip.Address, + GuestImageVersion: flip.Version); + _latestPresentation = presentation; + _pendingGuestImagePresentations.Enqueue(presentation); + while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork) + { + RetireSkippedPresentationLocked(_pendingGuestImagePresentations.Dequeue()); + } + } + } + + private static void ExecuteGuestImageBlit(nint queue, GuestImageBlit blit) + { + GuestImage? source, destination; + lock (_gate) + { + _guestImages.TryGetValue(blit.SourceAddress, out source); + _guestImages.TryGetValue(blit.DestinationAddress, out destination); + } + + if (source is null || destination is null || !source.Initialized) + { + return; + } + + CopyTexture(queue, source.Texture, destination.Texture); + destination.MarkContentChanged(); + } + + private static bool _tracedFlipAlias; + + /// Resolves a flip to produced content: the exact-address image when + /// GPU work wrote it, else the nearest same-extent produced image within the + /// display buffer's plausible metadata window above the start address (games + /// render into the pixel surface past the buffer's metadata block), else the + /// exact-address image even if it only holds a speculative seed. + private static GuestImage? FindGuestImageForFlipLocked(ulong address, uint width, uint height) + { + _guestImages.TryGetValue(address, out var exact); + if (exact is { Initialized: true, GpuWritten: true }) + { + return exact; + } + + GuestImage? best = null; + var bestDelta = ulong.MaxValue; + ulong bestAddress = 0; + foreach (var entry in _guestImages) + { + if (entry.Key <= address) + { + continue; + } + + var delta = entry.Key - address; + if (delta <= 0x20_0000 && + delta < bestDelta && + entry.Value.Width == width && + entry.Value.Height == height && + entry.Value is { Initialized: true, GpuWritten: true }) + { + best = entry.Value; + bestDelta = delta; + bestAddress = entry.Key; + } + } + + if (best is not null) + { + if (!_tracedFlipAlias) + { + _tracedFlipAlias = true; + Console.Error.WriteLine( + $"[LOADER][INFO] Metal flip alias: display buffer 0x{address:X16} " + + $"presents drawn surface 0x{bestAddress:X16} (+0x{bestDelta:X})."); + } + + return best; + } + + return exact is { Initialized: true } ? exact : null; + } + + /// Returns the mutable image for a guest address, creating and seeding + /// it (pending initial data first, guest memory second) on first use. + private static GuestImage? EnsureGuestImage( + nint device, + ulong address, + uint width, + uint height, + uint pitchInPixel) + { + uint formatTag; + byte[]? initialData; + lock (_gate) + { + if (_guestImages.TryGetValue(address, out var existing)) + { + return existing; + } + + if (!_availableGuestImages.TryGetValue(address, out formatTag)) + { + return null; + } + + _pendingGuestImageInitialData.Remove(address, out initialData); + } + + if (width == 0 || height == 0 || width > 16384 || height > 16384) + { + return null; + } + + var format = DecodeGuestFormatTag(formatTag); + var image = new GuestImage + { + Texture = CreateGuestTexture(device, format, width, height), + Width = width, + Height = height, + Format = format, + }; + if (image.Texture == 0) + { + return null; + } + + var pitch = pitchInPixel == 0 ? width : Math.Max(pitchInPixel, width); + var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format); + if (initialData is not null && + bytesPerPixel == 4 && + (ulong)initialData.Length >= (ulong)width * height * 4) + { + // Pending initial data is RGBA8; only 4-byte-texel images can take + // it verbatim. Wider formats seed from guest memory below, whose + // layout is the image's native one. + ReplaceTextureContents(image.Texture, width, height, initialData, width, bytesPerPixel); + image.Initialized = true; + image.ContentVersion++; + } + else if (_guestMemory is { } memory) + { + // PS5 render targets alias guest memory: CPU-prefilled pixels are + // visible before the first draw, so the first use seeds from there. + var byteCount = checked((int)((ulong)pitch * height * bytesPerPixel)); + var guestPixels = GuestDataPool.Shared.Rent(byteCount); + try + { + if (memory.TryRead(address, guestPixels.AsSpan(0, byteCount))) + { + ReplaceTextureContents(image.Texture, width, height, guestPixels, pitch, bytesPerPixel); + image.Initialized = true; + image.ContentVersion++; + } + } + finally + { + GuestDataPool.Shared.Return(guestPixels); + } + } + + lock (_gate) + { + _guestImages[address] = image; + _guestImageExtents[address] = (width, height, (ulong)pitch * height * bytesPerPixel); + } + + return image; + } + + private static void RetireSkippedPresentationLocked(Presentation presentation) + { + if (presentation.GuestImageVersion != 0 && + _guestImageVersions.Remove(presentation.GuestImageVersion, out var version)) + { + MetalNative.SendVoid(version.Texture, MetalNative.Selector("release")); + } + } + + // Guest texture-format tags, byte-identical to the Vulkan backend's encoding so + // VideoOut's registered display-buffer tags mean the same thing on both. + private static uint GetGuestTextureFormat(uint format, uint numberType) => + IsKnownGuestTextureFormat(format) + ? 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu) + : 0; + + private static bool IsKnownGuestTextureFormat(uint format) => + format is >= 1 and <= 19 or 34 or >= 169 and <= 182; + + private static MtlPixelFormat DecodeGuestFormatTag(uint tag) + { + var format = (tag >> 8) & 0x1FFu; + var numberType = tag & 0xFFu; + return MetalGuestFormats.TryDecodeRenderTargetFormat(format, numberType, out var decoded) + ? decoded.Format + : MtlPixelFormat.Rgba8Unorm; + } + + /// Picks the newest ready queued guest presentation (retiring the ones + /// it supersedes), falling back to the latest CPU frame or splash. + private static bool TryTakePresentation(long presentedSequence, out Presentation presentation) + { + lock (_gate) + { + Presentation? selected = null; + while (_pendingGuestImagePresentations.Count > 0) + { + var head = _pendingGuestImagePresentations.Peek(); + if (!IsGuestWorkCompletedLocked(head.RequiredGuestWorkSequence)) + { + break; + } + + _pendingGuestImagePresentations.Dequeue(); + if (selected is not null) + { + RetireSkippedPresentationLocked(selected); + } + + selected = head; + } + + if (selected is not null) + { + presentation = selected; + return true; + } + + if (_latestPresentation is { } latest && + latest.Sequence > presentedSequence && + (latest.Pixels is not null || + (latest.TranslatedDraw is not null || latest.DrawKind != GuestDrawKind.None) && + IsGuestWorkCompletedLocked(latest.RequiredGuestWorkSequence))) + { + presentation = latest; + return true; + } + + presentation = default!; + return false; + } + } + + private static bool TryResolveGuestPresentation( + nint device, + Presentation presentation, + out nint texture, + out uint width, + out uint height, + out bool owned) + { + GuestImage? image = null; + owned = false; + lock (_gate) + { + if (presentation.GuestImageVersion != 0) + { + owned = _guestImageVersions.Remove(presentation.GuestImageVersion, out image); + } + + if (image is null && presentation.GuestImageAddress != 0) + { + _guestImages.TryGetValue(presentation.GuestImageAddress, out image); + } + } + + // An unordered flip can name a registered display buffer that no work has + // materialized yet; seed it from guest memory so CPU-rendered buffers show. + image ??= presentation.GuestImageAddress != 0 + ? EnsureGuestImage( + device, + presentation.GuestImageAddress, + presentation.Width, + presentation.Height, + presentation.GuestImagePitch) + : null; + + if (image is not { Initialized: true }) + { + if (owned && image is not null) + { + MetalNative.SendVoid(image.Texture, MetalNative.Selector("release")); + } + + texture = 0; + width = 0; + height = 0; + owned = false; + return false; + } + + texture = image.Texture; + width = image.Width; + height = image.Height; + return true; + } + + private static void SwitchPresentSource( + nint texture, + uint width, + uint height, + bool ownsTexture, + ref nint presentTexture, + ref uint presentWidth, + ref uint presentHeight, + ref nint ownedTexture) + { + if (ownedTexture != 0 && ownedTexture != texture) + { + // In-flight command buffers hold their own references; dropping ours + // is safe even if the previous frame is still presenting. + MetalNative.SendVoid(ownedTexture, MetalNative.Selector("release")); + ownedTexture = 0; + } + + if (ownsTexture) + { + ownedTexture = texture; + } + + presentTexture = texture; + presentWidth = width; + presentHeight = height; + } + + private static nint CreateGuestTexture(nint device, MtlPixelFormat format, uint width, uint height) + { + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)format, + width, + height, + mipmapped: false); + // ShaderRead | RenderTarget: the present pass samples these and fills + // clear them through a render pass. + MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), (nint)5); + // MTLStorageModeShared (0): these textures are CPU-populated + // (replaceRegion) and GPU-sampled. The MTLTextureDescriptor default is + // Managed, which on unified memory needs an explicit host->device sync + // we never issue, so the GPU reads stale (uninitialized/white) texels — + // Xcode's frame capture flags exactly this. Shared is coherent on + // Apple Silicon with no sync. + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0); + return MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + } + + /// Uploads pixel rows sized by the texture's real texel width. The + /// row count is clamped to what actually holds, so + /// replaceRegion can never read past the managed buffer. + private static void ReplaceTextureContents( + nint texture, + uint width, + uint height, + byte[] pixels, + uint pitchInPixel, + uint bytesPerPixel) + { + var bytesPerRow = (ulong)Math.Max(pitchInPixel, width) * bytesPerPixel; + var lastRowBytes = (ulong)width * bytesPerPixel; + if (lastRowBytes == 0 || (ulong)pixels.Length < lastRowBytes) + { + return; + } + + var maxRows = (((ulong)pixels.Length - lastRowBytes) / bytesPerRow) + 1; + var rows = (uint)Math.Min(height, maxRows); + if (rows == 0) + { + return; + } + + unsafe + { + fixed (byte* source = pixels) + { + MetalNative.SendReplaceRegion( + texture, + MetalNative.Selector("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), + new MtlRegion { X = 0, Y = 0, Z = 0, Width = width, Height = rows, Depth = 1 }, + 0, + (nint)source, + (nuint)bytesPerRow); + } + } + } + + /// Clears via a render pass (GPU-side, hazard-tracked against in-flight + /// sampling) using the raw 32-bit guest fill pattern interpreted as RGBA8. + private static void ClearTexture(nint queue, nint texture, uint fillValue) + { + var color = new MtlClearColor + { + Red = (fillValue & 0xFF) / 255.0, + Green = ((fillValue >> 8) & 0xFF) / 255.0, + Blue = ((fillValue >> 16) & 0xFF) / 255.0, + Alpha = ((fillValue >> 24) & 0xFF) / 255.0, + }; + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + var encoder = MetalNative.Send( + commandBuffer, + MetalNative.Selector("renderCommandEncoderWithDescriptor:"), + CreateClearPass(texture, color)); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + } + + private static void CopyTexture(nint queue, nint source, nint destination) + { + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + EncodeCopyTexture(commandBuffer, source, destination); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + } + + /// Encodes a full-texture copy into an existing command buffer; + /// used by the batched draw path, where the copy must be ordered after the + /// batch's earlier passes rather than committed ahead of them. + private static void EncodeCopyTexture(nint commandBuffer, nint source, nint destination) + { + var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("blitCommandEncoder")); + MetalNative.SendVoidCopyTexture( + encoder, + MetalNative.Selector("copyFromTexture:toTexture:"), + source, + destination); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs new file mode 100644 index 0000000..9bdcf05 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs @@ -0,0 +1,180 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Libs.Gpu.Metal; + +// Feedback reads (draws sampling a live guest render target or depth image) +// need a fresh ordered snapshot per draw. Creating and destroying an MTLTexture +// — and for depth reads a private staging MTLBuffer — per draw is measurable +// CPU and allocator churn at hundreds of feedback draws per second, so both +// recycle through a pool with the same lifecycle as the upload arena pages: +// acquired snapshots are tagged with the command buffer that samples them at +// commit, and return to the free list once that command buffer completes (the +// command queue is serial, so the earlier snapshot-blit command buffer is +// necessarily complete by then too). Everything here runs on the render thread. +internal static partial class MetalVideoPresenter +{ + private const int MaxFreeSnapshotResources = 16; + + private sealed class PooledSnapshotResource + { + public nint Handle; + public bool IsBuffer; + + /// Texture identity (unused for buffers). + public uint Format; + public uint Width; + public uint Height; + public nint Usage; + + /// Buffer capacity in bytes (unused for textures). + public nuint Capacity; + + /// Retained handle of the command buffer that samples this + /// snapshot; the resource is reusable once it completes. + public nint LastCommandBuffer; + } + + private static readonly List _retiredSnapshotResources = []; + private static readonly List _pendingSnapshotResources = []; + private static readonly List _freeSnapshotResources = []; + + /// Returns completed snapshot resources to the free list; called + /// once per render-loop drain, next to the upload-page recycler. + private static void RecycleCompletedSnapshotResources() + { + for (var index = _retiredSnapshotResources.Count - 1; index >= 0; index--) + { + var resource = _retiredSnapshotResources[index]; + if (resource.LastCommandBuffer != 0) + { + // MTLCommandBufferStatus: Completed = 4, Error = 5. + var status = MetalNative.Send( + resource.LastCommandBuffer, MetalNative.Selector("status")); + if (status < 4) + { + continue; + } + + MetalNative.SendVoid(resource.LastCommandBuffer, MetalNative.Selector("release")); + resource.LastCommandBuffer = 0; + } + + _retiredSnapshotResources.RemoveAt(index); + if (_freeSnapshotResources.Count < MaxFreeSnapshotResources) + { + _freeSnapshotResources.Add(resource); + } + else + { + MetalNative.SendVoid(resource.Handle, MetalNative.Selector("release")); + } + } + } + + /// Pops a pooled snapshot texture matching the exact identity, or + /// creates one. The returned handle is owned by the pool — callers must not + /// release it, and it must be tagged at the next commit. + private static nint AcquireSnapshotTexture( + nint device, + MtlPixelFormat format, + uint width, + uint height, + nint usage) + { + for (var index = 0; index < _freeSnapshotResources.Count; index++) + { + var candidate = _freeSnapshotResources[index]; + if (!candidate.IsBuffer && + candidate.Format == (uint)format && + candidate.Width == width && + candidate.Height == height && + candidate.Usage == usage) + { + _freeSnapshotResources.RemoveAt(index); + _pendingSnapshotResources.Add(candidate); + return candidate.Handle; + } + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)format, + width, + height, + mipmapped: false); + MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), usage); + var handle = MetalNative.Send( + device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + if (handle == 0) + { + return 0; + } + + _pendingSnapshotResources.Add(new PooledSnapshotResource + { + Handle = handle, + Format = (uint)format, + Width = width, + Height = height, + Usage = usage, + }); + return handle; + } + + /// Pops a pooled private-storage staging buffer of at least + /// , or creates one. Pool-owned like + /// . + private static nint AcquireSnapshotBuffer(nint device, nuint minimumBytes) + { + for (var index = 0; index < _freeSnapshotResources.Count; index++) + { + var candidate = _freeSnapshotResources[index]; + if (candidate.IsBuffer && candidate.Capacity >= minimumBytes) + { + _freeSnapshotResources.RemoveAt(index); + _pendingSnapshotResources.Add(candidate); + return candidate.Handle; + } + } + + // MTLResourceStorageModePrivate = 32: staging never touches the CPU. + var handle = MetalNative.SendNewBuffer( + device, MetalNative.Selector("newBufferWithLength:options:"), minimumBytes, 32); + if (handle == 0) + { + return 0; + } + + _pendingSnapshotResources.Add(new PooledSnapshotResource + { + Handle = handle, + IsBuffer = true, + Capacity = minimumBytes, + }); + return handle; + } + + /// Marks every snapshot resource acquired since the previous tag + /// as owing its lifetime to . Called at the + /// same commit sites as ; a resource acquired + /// for a draw that never committed is tagged by the next commit, which is + /// conservative but safe. + private static void TagSnapshotResources(nint commandBuffer) + { + if (_pendingSnapshotResources.Count == 0) + { + return; + } + + foreach (var resource in _pendingSnapshotResources) + { + resource.LastCommandBuffer = MetalNative.Send( + commandBuffer, MetalNative.Selector("retain")); + _retiredSnapshotResources.Add(resource); + } + + _pendingSnapshotResources.Clear(); + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs new file mode 100644 index 0000000..3e8d4a9 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs @@ -0,0 +1,179 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Concurrent; +using SharpEmu.HLE; + +namespace SharpEmu.Libs.Gpu.Metal; + +// Draw textures decoded from guest memory are cached across draws keyed by +// their full descriptor identity, mirroring the Vulkan presenter's texture +// cache: once an identity is marked cached, the AGC submit thread skips the +// guest-memory read/detile/copy entirely (shipping empty texels) and the +// render thread serves the cached MTLTexture — for scenes that sample large +// textures every draw, that per-draw copy dominated both allocation churn +// and CPU time. GuestImageWriteTracker write-protects the source pages, so +// a guest CPU write dirties the address and the entry is evicted at the next +// drain; the following draw ships fresh texels and re-populates the cache. +internal static partial class MetalVideoPresenter +{ + private const int MaxCachedDrawTextures = 2048; + + /// Render-thread-only cache of decoded draw textures; each value + /// holds one retain. Committed command buffers retain the textures they + /// reference, so eviction releases immediately without a GPU drain. + private static readonly Dictionary _drawTextureCache = new(); + + /// Identities the AGC submit thread may skip texel copies for. + /// Read from the submit thread, written by the render thread. + private static readonly ConcurrentDictionary _cachedDrawTextureIdentities = new(); + + internal static bool IsTextureContentCached(in TextureContentIdentity identity) => + _cachedDrawTextureIdentities.ContainsKey(identity); + + /// Builds the same identity the AGC layer checks before skipping + /// a texel copy; the two must agree field-for-field or skips and cache + /// entries would never line up. + private static TextureContentIdentity GetDrawTextureIdentity(GuestDrawTexture texture) => new( + texture.Address, + texture.Width, + texture.Height, + texture.Format, + texture.NumberType, + texture.DstSelect, + texture.TileMode, + 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. + private static bool IsCacheableDrawTexture(GuestDrawTexture texture) => + GuestImageWriteTracker.Enabled && + texture.Address != 0 && + !texture.IsStorage && + !texture.IsFallback; + + private static bool TryGetCachedDrawTexture(GuestDrawTexture texture, out nint handle) => + _drawTextureCache.TryGetValue(GetDrawTextureIdentity(texture), out handle); + + private static void CacheDrawTexture(GuestDrawTexture texture, nint handle) + { + var key = GetDrawTextureIdentity(texture); + if (_drawTextureCache.Remove(key, out var previous)) + { + MetalNative.SendVoid(previous, MetalNative.Selector("release")); + } + + _ = 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"); + } + + /// 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() + { + if (_drawTextureCache.Count == 0) + { + return; + } + + // Evict by address rather than by identity: several identities can + // 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)) + { + continue; + } + + if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address)) + { + (dirtyAddresses ??= []).Add(entry.Key.Address); + } + } + + if (dirtyAddresses is null && _drawTextureCache.Count <= MaxCachedDrawTextures) + { + return; + } + + if (_drawTextureCache.Count > MaxCachedDrawTextures) + { + foreach (var entry in _drawTextureCache) + { + MetalNative.SendVoid(entry.Value, MetalNative.Selector("release")); + } + + _drawTextureCache.Clear(); + _cachedDrawTextureIdentities.Clear(); + return; + } + + List? evicted = null; + foreach (var entry in _drawTextureCache) + { + if (dirtyAddresses!.Contains(entry.Key.Address)) + { + (evicted ??= []).Add(entry.Key); + } + } + + if (evicted is not null) + { + foreach (var key in evicted) + { + if (_drawTextureCache.Remove(key, out var handle)) + { + _cachedDrawTextureIdentities.TryRemove(key, out _); + MetalNative.SendVoid(handle, MetalNative.Selector("release")); + } + } + } + + foreach (var address in dirtyAddresses!) + { + GuestImageWriteTracker.Rearm(address); + } + } + + /// Self-heal for the skip/eviction race: the submit thread saw a + /// cached identity and skipped the copy, but the entry was evicted before + /// this draw executed. Read the texels directly rather than rendering a + /// fallback texture for the frame, sized with the same block-aware math + /// the draw path expects. + private static byte[]? TryReadGuestDrawTexturePixels(GuestDrawTexture texture) + { + var memory = _guestMemory; + if (memory is null || texture.Address == 0) + { + return null; + } + + var width = Math.Max(texture.Width, 1u); + var height = Math.Max(texture.Height, 1u); + var rowLength = texture.TileMode == 0 + ? Math.Max(texture.Pitch, width) + : width; + var format = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType); + var byteCount = MetalGuestFormats.GetTextureByteCount(format, rowLength, height); + if (byteCount == 0 || byteCount > int.MaxValue) + { + return null; + } + + var pixels = new byte[(int)byteCount]; + return memory.TryRead(texture.Address, pixels) ? pixels : null; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs new file mode 100644 index 0000000..700ce86 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs @@ -0,0 +1,162 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Libs.Gpu.Metal; + +// Per-draw upload data (guest global buffers, uniforms, vertex and index +// bytes) bump-allocates from shared-storage arena pages bound by offset, +// instead of creating one MTLBuffer and one managed copy per binding per +// draw — which dominated allocation churn (hundreds of MB/s) and held the +// guest flip rate well under the display rate. Pages recycle once the last +// command buffer that referenced them reports completion; everything here +// runs on the render thread, so no state is locked. +internal static partial class MetalVideoPresenter +{ + private const int UploadPageBytes = 8 * 1024 * 1024; + + // Superset of every Metal bind-offset alignment rule (constant address + // space on Intel Macs is the strictest at 256), and conveniently the + // guest storage-buffer alignment the shader bias contract assumes. + private const int UploadAlignment = 256; + + private sealed class UploadPage + { + public nint Buffer; + public nint Contents; + public int Capacity; + public int Offset; + + /// Retained handle of the last command buffer that consumed + /// data from this page; the page is reusable once it completes. + public nint LastCommandBuffer; + + /// Stamp of the last TagUploadPages call that saw this page, + /// so a commit only re-tags pages it actually touched. + public int TouchStamp; + } + + private static readonly List _retiredUploadPages = []; + private static readonly Stack _freeUploadPages = new(); + private static readonly List _touchedUploadPages = []; + private static UploadPage? _currentUploadPage; + private static int _uploadTouchStamp; + + /// Returns completed pages to the free stack. Called once per + /// render-loop drain; completion is polled (command buffer status) rather + /// than block-based so the ObjC interop stays block-free. + private static void RecycleCompletedUploadPages() + { + for (var index = _retiredUploadPages.Count - 1; index >= 0; index--) + { + var page = _retiredUploadPages[index]; + if (page.LastCommandBuffer != 0) + { + // MTLCommandBufferStatus: Completed = 4, Error = 5. + var status = MetalNative.Send( + page.LastCommandBuffer, MetalNative.Selector("status")); + if (status < 4) + { + continue; + } + + MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release")); + page.LastCommandBuffer = 0; + } + + _retiredUploadPages.RemoveAt(index); + if (page.Capacity == UploadPageBytes) + { + page.Offset = 0; + _freeUploadPages.Push(page); + } + else + { + // Oversized one-off allocation; not worth pooling. + MetalNative.SendVoid(page.Buffer, MetalNative.Selector("release")); + } + } + } + + /// Bump-allocates an aligned slice for CPU-written upload data. + /// The returned span is the slice's shared-storage memory; bind the + /// buffer at the returned offset. + private static unsafe Span AllocateUpload( + nint device, + int length, + out nint buffer, + out int offset) + { + var page = _currentUploadPage; + var aligned = page is null + ? 0 + : (page.Offset + UploadAlignment - 1) & ~(UploadAlignment - 1); + if (page is null || aligned + length > page.Capacity) + { + if (page is not null) + { + _retiredUploadPages.Add(page); + } + + page = AcquireUploadPage(device, length); + _currentUploadPage = page; + aligned = 0; + } + + if (page.TouchStamp != _uploadTouchStamp) + { + page.TouchStamp = _uploadTouchStamp; + _touchedUploadPages.Add(page); + } + + buffer = page.Buffer; + offset = aligned; + page.Offset = aligned + length; + return new Span((void*)(page.Contents + aligned), length); + } + + private static UploadPage AcquireUploadPage(nint device, int minimumBytes) + { + if (minimumBytes <= UploadPageBytes && _freeUploadPages.Count > 0) + { + return _freeUploadPages.Pop(); + } + + var capacity = Math.Max(minimumBytes, UploadPageBytes); + // Options 0 = MTLResourceStorageModeShared: CPU writes are coherent + // and write-backs read the GPU's stores after waitUntilCompleted. + var handle = MetalNative.SendNewBuffer( + device, MetalNative.Selector("newBufferWithLength:options:"), (nuint)capacity, 0); + return new UploadPage + { + Buffer = handle, + Contents = MetalNative.Send(handle, MetalNative.Selector("contents")), + Capacity = capacity, + }; + } + + /// Marks every page touched since the previous tag as owing its + /// lifetime to . Called after each commit + /// that consumed arena data. + private static void TagUploadPages(nint commandBuffer) + { + if (_touchedUploadPages.Count == 0) + { + _uploadTouchStamp++; + return; + } + + foreach (var page in _touchedUploadPages) + { + if (page.LastCommandBuffer != 0) + { + MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release")); + } + + page.LastCommandBuffer = MetalNative.Send( + commandBuffer, MetalNative.Selector("retain")); + } + + _touchedUploadPages.Clear(); + _uploadTouchStamp++; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs new file mode 100644 index 0000000..ee9f809 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs @@ -0,0 +1,1154 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SharpEmu.HLE; +using SharpEmu.Libs.VideoOut; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Metal; + +namespace SharpEmu.Libs.Gpu.Metal; + +/// +/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink +/// requested from the content view drives in sync with the +/// display refresh, on the main run loop — the loop whose Core Animation observer +/// commits presented drawables to the window server, so it must be a real running +/// run loop (a hand-pumped event drain never fires that observer and the window +/// stays black). Everything AppKit runs on the process main thread via +/// (AppKit traps off-main), which the CLI parks for us. +/// +internal static partial class MetalVideoPresenter +{ + private const uint DefaultWindowWidth = 1280; + private const uint DefaultWindowHeight = 720; + + // NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable + // both lets the user drag the window edges and turns the green zoom button + // into the full-screen toggle (paired with the collection behavior below). + private const nuint WindowStyleMask = 1 | 2 | 4 | 8; + + // NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native + // full-screen, so the green button enters full-screen rather than zooming. + private const nuint FullScreenPrimaryBehavior = 1 << 7; + private const nuint BackingStoreBuffered = 2; + private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm; + private const nuint LoadActionLoad = 1; + private const nuint LoadActionClear = 2; + private const nuint StoreActionStore = 1; + private const nuint PrimitiveTypeTriangle = 3; + private const nuint SamplerMinMagFilterLinear = 1; + + private sealed record Presentation( + byte[]? Pixels, + uint Width, + uint Height, + long Sequence, + bool IsSplash, + ulong GuestImageAddress = 0, + long GuestImageVersion = 0, + uint GuestImagePitch = 0, + long RequiredGuestWorkSequence = 0, + TranslatedGuestDraw? TranslatedDraw = null, + GuestDrawKind DrawKind = GuestDrawKind.None); + + private static readonly object _gate = new(); + private static Thread? _thread; + private static bool _closed; + private static bool _splashHidden; + private static bool _closeRequested; + private static Presentation? _latestPresentation; + private static bool _loggedFirstPresentedFrame; + private static int _titleRefreshCounter; + private static string? _lastWindowTitle; + + // CPU-rasterized perf HUD (F1), blitted over the frame like the Vulkan + // presenter does; the panel texture lives for the window's lifetime. + private static nint _overlayTexture; + private static readonly byte[] _overlayPixels = + new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4]; + + // Presenter objects and per-frame present state, shared between window setup + // and the display-link RenderFrame callback (both on the main thread). + private static nint _device; + private static nint _commandQueue; + private static nint _metalLayer; + private static nint _presentPipeline; + private static nint _presentSampler; + private static nint _window; + private static nint _application; + private static nint _renderTimer; + private static nint _renderTimerTarget; + private static double _drawableWidth; + private static double _drawableHeight; + private static nint _frameTexture; + private static uint _frameTextureWidth; + private static uint _frameTextureHeight; + private static nint _presentTexture; + private static uint _presentTextureWidth; + private static uint _presentTextureHeight; + private static nint _ownedVersionTexture; + private static ulong _presentGuestAddress; + private static long _presentedSequence = -1; + private static bool _userClosed; + private static uint _windowWidth; + private static uint _windowHeight; + + public static void EnsureStarted(uint width, uint height) + { + if (width == 0 || height == 0) + { + return; + } + + lock (_gate) + { + if (_closed || _thread is not null) + { + return; + } + } + + var hasSplash = PngSplashLoader.TryLoad( + out var splashPixels, + out var splashWidth, + out var splashHeight); + lock (_gate) + { + if (_closed || _thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + _latestPresentation ??= _splashHidden + ? new Presentation(CreateBlackFrame(width, height), width, height, 1, IsSplash: false) + : hasSplash + ? new Presentation(splashPixels, splashWidth, splashHeight, 1, IsSplash: true) + : new Presentation(null, width, height, 0, IsSplash: false); + StartPresenterLocked(); + } + } + + public static void HideSplashScreen() + { + lock (_gate) + { + _splashHidden = true; + if (_closed || _latestPresentation is not { IsSplash: true } latest) + { + return; + } + + _latestPresentation = new Presentation( + CreateBlackFrame(latest.Width, latest.Height), + latest.Width, + latest.Height, + latest.Sequence + 1, + IsSplash: false); + Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut hid splash"); + } + } + + public static void Submit(byte[] bgraFrame, uint width, uint height) + { + if (bgraFrame.Length != checked((int)(width * height * 4))) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + _latestPresentation = new Presentation(bgraFrame, width, height, sequence, IsSplash: false); + if (_thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + StartPresenterLocked(); + } + } + + /// Asks a running presenter loop to close its window and return. + public static void RequestClose() + { + Volatile.Write(ref _closeRequested, true); + } + + private static void StartPresenterLocked() + { + if (HostMainThread.IsAvailable) + { + _thread = Thread.CurrentThread; + HostMainThread.SetShutdownRequestHandler(RequestClose); + HostMainThread.Post(Run); + return; + } + + _thread = new Thread(Run) + { + IsBackground = true, + Name = "SharpEmu Metal VideoOut", + }; + _thread.Start(); + } + + private static void Run() + { + try + { + RunWindowLoop(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal VideoOut presenter failed: {exception}"); + } + finally + { + lock (_gate) + { + _closed = true; + _thread = null; + // Wake guest-work waiters and backpressured producers so close + // never strands a blocked guest thread. + Monitor.PulseAll(_gate); + } + } + } + + private static void RunWindowLoop() + { + MetalNative.EnsureFrameworksLoaded(); + + _device = MetalNative.MTLCreateSystemDefaultDevice(); + if (_device == 0) + { + Console.Error.WriteLine("[LOADER][ERROR] No Metal device available."); + return; + } + + // Mirror the Vulkan presenter: fold the selected GPU's name into the + // window title. Without this the Metal title never gains the "· " + // suffix the Vulkan path shows. + var deviceName = MetalNative.ReadNsString( + MetalNative.Send(_device, MetalNative.Selector("name"))); + if (!string.IsNullOrEmpty(deviceName)) + { + VideoOutExports.SetSelectedGpuName(deviceName); + } + + // Fixed window like the Vulkan presenter: guest frames letterbox into + // it. Sizing the window from the guest's display mode (4K) exceeds the + // screen — macOS clamps the window while the layer keeps the requested + // geometry, leaving the visible region showing nothing but clear. + const uint width = DefaultWindowWidth; + const uint height = DefaultWindowHeight; + + var setupPool = MetalNative.objc_autoreleasePoolPush(); + try + { + _application = MetalNative.Send( + MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication")); + // NSApplicationActivationPolicyRegular: dock icon + key window like any app. + MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0); + MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching")); + + _window = CreateWindow(width, height); + + // Swap in the key-capturing view before the metal layer attaches so + // the layer lands on the input-aware content view. + var keyView = MetalNative.SendInitFrame( + MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")), + MetalNative.Selector("initWithFrame:"), + new CGRect { X = 0, Y = 0, Width = width, Height = height }); + MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView); + + _metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight); + _commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue")); + if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError)) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal present pipeline failed: {pipelineError}"); + return; + } + + _presentSampler = CreateLinearSampler(_device); + + MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0); + MetalNative.SendVoidBool( + _application, MetalNative.Selector("activateIgnoringOtherApps:"), true); + MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView); + MetalHostInput.Attach(); + + // A repeating NSTimer on this (main) run loop fires onFrame: at the + // display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:) + // is the natural choice but its callback never fires under the x86-64 + // Rosetta process this emulator runs as — proven in isolation against + // a bare AppKit harness, where a timer fires and composites and the + // display link does not. nextDrawable still throttles presentation to + // the display, so the timer only needs to keep up, not pace precisely. + _renderTimerTarget = CreateRenderTimerTarget(); + _renderTimer = MetalNative.Send( + MetalNative.SendTimer( + MetalNative.Class("NSTimer"), + MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"), + 1.0 / 60.0, + _renderTimerTarget, + MetalNative.Selector("onFrame:"), + 0, + repeats: true), + MetalNative.Selector("retain")); + } + finally + { + MetalNative.objc_autoreleasePoolPop(setupPool); + } + + Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started."); + + // [NSApp run] runs the main run loop (its Core Animation observer commits + // presented drawables to the window server) AND fully activates the app, + // which a bare CFRunLoopRun does not — the CADisplayLink is only serviced + // once the app is running, and NSApp dispatches window events itself. + // Returns once RenderFrame stops it. + MetalNative.SendVoid(_application, MetalNative.Selector("run")); + + var closePool = MetalNative.objc_autoreleasePoolPush(); + try + { + MetalNative.SendVoid(_window, MetalNative.Selector("close")); + } + finally + { + MetalNative.objc_autoreleasePoolPop(closePool); + } + + if (_userClosed) + { + Console.Error.WriteLine( + "[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown."); + VideoOutExports.NotifyPresentationWindowClosed(); + } + } + + /// + /// An NSView subclass that records key events for pad emulation. Overriding + /// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and + /// swallowing the events also silences the system alert beep AppKit plays + /// for unhandled keys. Registered once per process. + /// + private static unsafe nint CreateKeyViewClass() + { + var cls = MetalNative.objc_allocateClassPair( + MetalNative.Class("NSView"), "SharpEmuMetalView", 0); + if (cls == 0) + { + return MetalNative.Class("SharpEmuMetalView"); + } + + var keyDown = (nint)(delegate* unmanaged[Cdecl])&OnKeyDown; + MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@"); + var keyUp = (nint)(delegate* unmanaged[Cdecl])&OnKeyUp; + MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@"); + // Command-modified keys never reach keyDown: — AppKit routes them through + // performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here. + var keyEquivalent = (nint)(delegate* unmanaged[Cdecl])&OnPerformKeyEquivalent; + MetalNative.class_addMethod( + cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@"); + // First responder status is what routes key events to this view. + var accepts = (nint)(delegate* unmanaged[Cdecl])&AcceptsFirstResponder; + MetalNative.class_addMethod( + cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:"); + MetalNative.objc_registerClassPair(cls); + return cls; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnKeyDown(nint self, nint cmd, nint nsEvent) + { + try + { + var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF); + var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")); + + // Function keys can arrive here even with Command held (AppKit only + // reroutes some chords through the key-equivalent path), so catch + // Cmd+F1 in both places — and keep it away from MetalHostInput so it + // never toggles the plain-F1 perf overlay. + var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags")); + if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0) + { + if (!isRepeat) + { + ToggleMetalPerformanceHud(); + } + + return; + } + + MetalHostInput.KeyDown(keyCode, isRepeat); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnKeyUp(nint self, nint cmd, nint nsEvent) + { + try + { + var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF); + MetalHostInput.KeyUp(keyCode); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static byte AcceptsFirstResponder(nint self, nint cmd) => 1; + + private const ushort KeyCodeF1 = 0x7A; + private const ulong NsEventModifierFlagCommand = 1UL << 20; + private static bool _metalHudVisible; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent) + { + try + { + var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF); + var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags")); + if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0) + { + if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"))) + { + ToggleMetalPerformanceHud(); + } + + return 1; // handled: no system beep, no further routing + } + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}"); + } + + return 0; + } + + /// + /// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps + /// the built-in CPU-rasterized perf overlay). Configured per Apple's + /// "Customizing Metal Performance HUD": developerHUDProperties takes + /// mode=default|disabled and logging=default, plus any MTL_HUD_* environment + /// keys directly in the dictionary — all three HUD flags (enabled, per-frame + /// logging, shader-compile logging) ride in one property set. Runs on the + /// AppKit main thread (the key-equivalent path), same thread as the render loop. + /// + private static void ToggleMetalPerformanceHud() + { + var layer = _metalLayer; + if (layer == 0) + { + return; + } + + var setProperties = MetalNative.Selector("setDeveloperHUDProperties:"); + if (!MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setProperties)) + { + Console.Error.WriteLine("[LOADER][WARN] Metal Performance HUD unavailable on this macOS."); + return; + } + + _metalHudVisible = !_metalHudVisible; + var pool = MetalNative.objc_autoreleasePoolPush(); + try + { + var properties = MetalNative.Send( + MetalNative.Class("NSMutableDictionary"), MetalNative.Selector("dictionary")); + var setObjectForKey = MetalNative.Selector("setObject:forKey:"); + if (_metalHudVisible) + { + var defaultValue = MetalNative.NsString("default"); + MetalNative.SendVoid(properties, setObjectForKey, defaultValue, MetalNative.NsString("mode")); + MetalNative.SendVoid(properties, setObjectForKey, defaultValue, MetalNative.NsString("logging")); + MetalNative.SendVoid( + properties, + setObjectForKey, + MetalNative.NsString("1"), + MetalNative.NsString("MTL_HUD_LOG_SHADER_ENABLED")); + } + else + { + MetalNative.SendVoid( + properties, setObjectForKey, MetalNative.NsString("disabled"), MetalNative.NsString("mode")); + } + + MetalNative.SendVoid(layer, setProperties, properties); + } + finally + { + MetalNative.objc_autoreleasePoolPop(pool); + } + + Console.Error.WriteLine( + $"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1)."); + } + + private static unsafe nint CreateRenderTimerTarget() + { + // A minimal NSObject subclass whose onFrame: is our unmanaged callback — + // the dependency-free way to hand a target/selector to NSTimer without a + // binding library. Registered once per process. + var cls = MetalNative.objc_allocateClassPair( + MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0); + if (cls != 0) + { + var imp = (nint)(delegate* unmanaged[Cdecl])&OnRenderTimer; + // "v@:@": void return, self, _cmd, one object argument (the timer). + MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@"); + var wakeImp = (nint)(delegate* unmanaged[Cdecl])&OnGuestWorkWake; + MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@"); + MetalNative.objc_registerClassPair(cls); + } + else + { + cls = MetalNative.Class("SharpEmuRenderTimerTarget"); + } + + return MetalNative.Send( + MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init")); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnRenderTimer(nint self, nint cmd, nint timer) + { + try + { + RenderFrame(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal render frame failed: {exception}"); + } + } + + /// Set while an onGuestWork: wake is scheduled on the main run + /// loop; coalesces enqueue-side wake requests to one in-flight message. + private static int _guestWorkWakeScheduled; + + /// Wakes the main run loop to drain guest work now instead of at + /// the next render tick. Guest submit→wait round-trips (release-mem labels, + /// CPU-visible write-backs) otherwise cost a full frame interval each — + /// games that chain several per frame crawl at a fraction of the display + /// rate. Safe from any thread; no-op until the presenter starts. + internal static void ScheduleGuestWorkDrain() + { + if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0) + { + return; + } + + var target = _renderTimerTarget; + if (target == 0) + { + Volatile.Write(ref _guestWorkWakeScheduled, 0); + return; + } + + MetalNative.SendVoidPerformSelector( + target, + MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"), + MetalNative.Selector("onGuestWork:"), + 0, + waitUntilDone: false); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnGuestWorkWake(nint self, nint cmd, nint argument) + { + Volatile.Write(ref _guestWorkWakeScheduled, 0); + if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested)) + { + return; + } + + var pool = MetalNative.objc_autoreleasePoolPush(); + try + { + DrainGuestWork(_device, _commandQueue); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}"); + } + finally + { + MetalNative.objc_autoreleasePoolPop(pool); + } + } + + private static void RenderFrame() + { + MetalHostInput.PumpAutoKeys(); + var pool = MetalNative.objc_autoreleasePoolPush(); + try + { + // NSApp.run dispatches window events itself, so there is no manual + // event drain here. + var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible")); + if (Volatile.Read(ref _closeRequested) || !visible) + { + _userClosed = !visible && !Volatile.Read(ref _closeRequested); + MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate")); + // Stop both the AppKit loop and the underlying CFRunLoop so + // [NSApp run] returns. + MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0); + MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain()); + return; + } + + DrainGuestWork(_device, _commandQueue); + + if (TryTakePresentation(_presentedSequence, out var presentation)) + { + _presentedSequence = presentation.Sequence; + if (presentation.Pixels is not null) + { + UploadFrame( + _device, + presentation, + ref _frameTexture, + ref _frameTextureWidth, + ref _frameTextureHeight); + SwitchPresentSource( + _frameTexture, + _frameTextureWidth, + _frameTextureHeight, + ownsTexture: false, + ref _presentTexture, + ref _presentTextureWidth, + ref _presentTextureHeight, + ref _ownedVersionTexture); + _presentGuestAddress = 0; + } + else if (presentation.TranslatedDraw is not null || + presentation.DrawKind != GuestDrawKind.None) + { + var drawTarget = ExecutePresentationDraw(_device, _commandQueue, presentation); + if (drawTarget != 0) + { + // Transient targets are pooled by the presenter; the + // present source borrows them. + SwitchPresentSource( + drawTarget, + presentation.Width, + presentation.Height, + ownsTexture: false, + ref _presentTexture, + ref _presentTextureWidth, + ref _presentTextureHeight, + ref _ownedVersionTexture); + _presentGuestAddress = 0; + } + } + else if (TryResolveGuestPresentation( + _device, + presentation, + out var guestTexture, + out var guestWidth, + out var guestHeight, + out var ownsGuestTexture)) + { + // Captured versions are immutable and owned here; mutable + // address-keyed images are re-resolved at encode time so a + // write swapping the texture never leaves a stale handle. + SwitchPresentSource( + ownsGuestTexture ? guestTexture : 0, + guestWidth, + guestHeight, + ownsGuestTexture, + ref _presentTexture, + ref _presentTextureWidth, + ref _presentTextureHeight, + ref _ownedVersionTexture); + _presentGuestAddress = ownsGuestTexture ? 0 : presentation.GuestImageAddress; + } + } + + if (_presentGuestAddress != 0) + { + // Re-resolve every frame: a guest-image write swaps the texture + // behind the address. + _presentTexture = 0; + lock (_gate) + { + if (_guestImages.TryGetValue(_presentGuestAddress, out var borrowed) && + borrowed.Initialized) + { + _presentTexture = borrowed.Texture; + _presentTextureWidth = borrowed.Width; + _presentTextureHeight = borrowed.Height; + } + } + } + + // The window title reflects late guest state (the game registers its + // application name after boot) plus the GPU suffix; the Vulkan + // presenter re-reads it, so refresh periodically here for parity. + if ((++_titleRefreshCounter & 0x3F) == 0) + { + var title = VideoOutExports.GetWindowTitle(); + if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal)) + { + _lastWindowTitle = title; + MetalNative.SendVoid( + _window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title)); + } + } + + // The window is resizable, so the backing layer's bounds follow the + // window while its drawable size does not — match them before asking + // for a drawable, or nextDrawable keeps handing back the original + // resolution and Core Animation stretches it (blurry, mis-scaled + // overlay). No-op when the size is unchanged, i.e. almost every tick. + SyncDrawableSizeToLayer(); + + var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable")); + if (drawable == 0) + { + // No free drawable this tick; the next timer fire retries. + return; + } + + if (_presentTexture != 0 && !_loggedFirstPresentedFrame) + { + _loggedFirstPresentedFrame = true; + Console.Error.WriteLine( + $"[LOADER][INFO] Metal VideoOut presenting {_presentTextureWidth}x{_presentTextureHeight}."); + } + + var drawableTexture = MetalNative.Send(drawable, MetalNative.Selector("texture")); + var commandBuffer = MetalNative.Send(_commandQueue, MetalNative.Selector("commandBuffer")); + var pass = CreateClearPass( + drawableTexture, + new MtlClearColor { Red = 0, Green = 0, Blue = 0, Alpha = 1 }); + var encoder = MetalNative.Send( + commandBuffer, MetalNative.Selector("renderCommandEncoderWithDescriptor:"), pass); + if (_presentTexture != 0) + { + EncodePresent( + encoder, + _presentPipeline, + _presentSampler, + _presentTexture, + _presentTextureWidth, + _presentTextureHeight, + _drawableWidth, + _drawableHeight); + } + + if (PerfOverlay.Enabled) + { + EncodeOverlay(encoder); + } + + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("presentDrawable:"), drawable); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + PerfOverlay.RecordPresent(); + } + finally + { + MetalNative.objc_autoreleasePoolPop(pool); + } + } + + /// Draws the CPU-rasterized perf panel over the frame's top-left + /// corner, reusing the present pipeline with a panel-sized viewport. + private static void EncodeOverlay(nint encoder) + { + if (_overlayTexture == 0) + { + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + PixelFormatBgra8Unorm, + PerfOverlay.PanelWidth, + PerfOverlay.PanelHeight, + mipmapped: false); + _overlayTexture = MetalNative.Send( + _device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + if (_overlayTexture == 0) + { + return; + } + } + + int pendingWork; + lock (_gate) + { + pendingWork = _pendingGuestWorkCount; + } + + PerfOverlay.Fill(_overlayPixels, pendingWork, 0); + ReplaceTextureContents( + _overlayTexture, + PerfOverlay.PanelWidth, + PerfOverlay.PanelHeight, + _overlayPixels, + PerfOverlay.PanelWidth, + bytesPerPixel: 4); + + const double margin = 16; + var panelWidth = Math.Min(PerfOverlay.PanelWidth, _drawableWidth - margin); + var panelHeight = Math.Min(PerfOverlay.PanelHeight, _drawableHeight - margin); + if (panelWidth <= 0 || panelHeight <= 0) + { + return; + } + + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), _presentPipeline); + MetalNative.SendVoidViewport( + encoder, + MetalNative.Selector("setViewport:"), + new MtlViewport + { + OriginX = margin, + OriginY = margin, + Width = panelWidth, + Height = panelHeight, + ZNear = 0, + ZFar = 1, + }); + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentTexture:atIndex:"), _overlayTexture, 0); + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentSamplerState:atIndex:"), _presentSampler, 0); + MetalNative.SendDrawPrimitives( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"), + PrimitiveTypeTriangle, + 0, + 3); + } + + private static nint CreateWindow(uint width, uint height) + { + var window = MetalNative.SendInitWindow( + MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")), + MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"), + new CGRect { X = 0, Y = 0, Width = width, Height = height }, + WindowStyleMask, + BackingStoreBuffered, + defer: false); + // The presenter owns the handle; AppKit must not free it on user close. + MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false); + MetalNative.Send( + window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior); + MetalNative.SendVoid( + window, + MetalNative.Selector("setTitle:"), + MetalNative.NsString(VideoOutExports.GetWindowTitle())); + MetalNative.SendVoid(window, MetalNative.Selector("center")); + // makeKeyAndOrderFront happens after the metal layer is attached. + return window; + } + + /// Keeps the CAMetalLayer's drawable size (pixels) matched to its + /// current bounds (points) × scale as the window resizes or moves between + /// displays. CAMetalLayer never updates drawableSize on its own, even as a + /// view's backing layer, so the render loop drives it. + private static void SyncDrawableSizeToLayer() + { + MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds")); + var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale")); + if (scale <= 0) + { + scale = 1; + } + + var width = Math.Max(1, Math.Round(bounds.Width * scale)); + var height = Math.Max(1, Math.Round(bounds.Height * scale)); + if (width == _drawableWidth && height == _drawableHeight) + { + return; + } + + _drawableWidth = width; + _drawableHeight = height; + MetalNative.SendVoidSize( + _metalLayer, + MetalNative.Selector("setDrawableSize:"), + new CGSize { Width = width, Height = height }); + } + + private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight) + { + var contentView = MetalNative.Send(window, MetalNative.Selector("contentView")); + var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor")); + if (scale <= 0) + { + scale = 1; + } + + const uint width = DefaultWindowWidth; + const uint height = DefaultWindowHeight; + drawableWidth = width * scale; + drawableHeight = height * scale; + + var layer = MetalNative.Send( + MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device); + MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm); + // A Core Animation layer composites with its alpha channel by default, so + // a presented frame whose guest alpha is zero would show through as the + // window background (black). The presenter output is a finished opaque + // frame; mark the layer opaque so alpha never reaches the compositor. + MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true); + MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true); + MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale); + MetalNative.SendVoidSize( + layer, + MetalNative.Selector("setDrawableSize:"), + new CGSize { Width = drawableWidth, Height = drawableHeight }); + + // A manually created layer defaults to a zero-size frame, and a hosted + // layer's geometry is the caller's job: without this the presenter + // happily presents every drawable into a layer with no on-screen + // extent — a permanently black window. + MetalNative.SendVoidRect( + layer, + MetalNative.Selector("setFrame:"), + new CGRect { X = 0, Y = 0, Width = width, Height = height }); + + // wantsLayer FIRST, then the layer: that makes the metal layer the + // view's AppKit-managed BACKING layer (geometry and window-server + // commits handled by AppKit) — the SDL/GLFW pattern. The reverse order + // creates a layer-hosting view whose tree the app must commit itself, + // which never composites under a manually pumped run loop. + MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true); + MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer); + MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush")); + return layer; + } + + private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error) + { + pipeline = 0; + var dbg = Environment.GetEnvironmentVariable("SHARPEMU_METAL_DBG"); + var fragmentSource = dbg switch + { + "solid" => MslFixedShaders.CreateSolidFragment(0f, 1f, 0f, 1f), + "uv" => MslFixedShaders.CreateAttributeFragment(0), + _ => MslFixedShaders.CreatePresentFragment(), + }; + if (!TryCompileLibrary(device, MslFixedShaders.CreateFullscreenVertex(1), out var vertexLibrary, out error) || + !TryCompileLibrary(device, fragmentSource, out var fragmentLibrary, out error)) + { + return false; + } + + var selNewFunction = MetalNative.Selector("newFunctionWithName:"); + var fragmentEntry = dbg switch { "solid" => "solid_fs", "uv" => "attribute_fs", _ => "present_fs" }; + var vertexFunction = MetalNative.Send(vertexLibrary, selNewFunction, MetalNative.NsString("fullscreen_vs")); + var fragmentFunction = MetalNative.Send(fragmentLibrary, selNewFunction, MetalNative.NsString(fragmentEntry)); + if (vertexFunction == 0 || fragmentFunction == 0) + { + error = "present shader entry points missing from the compiled libraries"; + return false; + } + + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction); + var colorAttachment = MetalNative.SendAtIndex( + MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")), + MetalNative.Selector("objectAtIndexedSubscript:"), + 0); + MetalNative.Send(colorAttachment, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm); + + nint nsError = 0; + pipeline = MetalNative.Send( + device, + MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"), + descriptor, + ref nsError); + if (pipeline == 0) + { + error = MetalNative.DescribeError(nsError); + return false; + } + + return true; + } + + private static bool TryCompileLibrary(nint device, string source, out nint library, out string error) + { + error = string.Empty; + + // Fast-math off everywhere for parity with translated guest shaders, + // whose GCN float semantics do not survive it. + var options = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLCompileOptions"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoidBool(options, MetalNative.Selector("setFastMathEnabled:"), false); + + nint nsError = 0; + library = MetalNative.Send( + device, + MetalNative.Selector("newLibraryWithSource:options:error:"), + MetalNative.NsString(source), + options, + ref nsError); + if (library == 0) + { + error = MetalNative.DescribeError(nsError); + return false; + } + + return true; + } + + private static nint CreateLinearSampler(nint device) + { + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLSamplerDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.Send(descriptor, MetalNative.Selector("setMinFilter:"), (nint)SamplerMinMagFilterLinear); + MetalNative.Send(descriptor, MetalNative.Selector("setMagFilter:"), (nint)SamplerMinMagFilterLinear); + return MetalNative.Send(device, MetalNative.Selector("newSamplerStateWithDescriptor:"), descriptor); + } + + private static void UploadFrame( + nint device, + Presentation presentation, + ref nint frameTexture, + ref uint textureWidth, + ref uint textureHeight) + { + if (frameTexture == 0 || textureWidth != presentation.Width || textureHeight != presentation.Height) + { + if (frameTexture != 0) + { + MetalNative.SendVoid(frameTexture, MetalNative.Selector("release")); + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + PixelFormatBgra8Unorm, + presentation.Width, + presentation.Height, + mipmapped: false); + // Shared (0): CPU-uploaded frame, GPU-sampled by the present pass; + // the Managed default reads stale on unified memory. + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0); + frameTexture = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + textureWidth = presentation.Width; + textureHeight = presentation.Height; + } + + ReplaceTextureContents( + frameTexture, + presentation.Width, + presentation.Height, + presentation.Pixels!, + presentation.Width, + bytesPerPixel: 4); + } + + private static nint CreateClearPass(nint targetTexture, MtlClearColor clearColor) + { + var pass = MetalNative.Send( + MetalNative.Class("MTLRenderPassDescriptor"), + MetalNative.Selector("renderPassDescriptor")); + var colorAttachment = MetalNative.SendAtIndex( + MetalNative.Send(pass, MetalNative.Selector("colorAttachments")), + MetalNative.Selector("objectAtIndexedSubscript:"), + 0); + MetalNative.SendVoid(colorAttachment, MetalNative.Selector("setTexture:"), targetTexture); + MetalNative.Send(colorAttachment, MetalNative.Selector("setLoadAction:"), (nint)LoadActionClear); + MetalNative.Send(colorAttachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore); + MetalNative.SendVoidClearColor( + colorAttachment, + MetalNative.Selector("setClearColor:"), + clearColor); + return pass; + } + + private static void EncodePresent( + nint encoder, + nint pipeline, + nint sampler, + nint frameTexture, + uint frameWidth, + uint frameHeight, + double drawableWidth, + double drawableHeight) + { + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + + // Aspect-fit letterbox: scale the frame into the drawable via the viewport. + var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight); + var viewportWidth = frameWidth * scale; + var viewportHeight = frameHeight * scale; + MetalNative.SendVoidViewport( + encoder, + MetalNative.Selector("setViewport:"), + new MtlViewport + { + OriginX = (drawableWidth - viewportWidth) * 0.5, + OriginY = (drawableHeight - viewportHeight) * 0.5, + Width = viewportWidth, + Height = viewportHeight, + ZNear = 0, + ZFar = 1, + }); + + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentTexture:atIndex:"), frameTexture, 0); + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentSamplerState:atIndex:"), sampler, 0); + MetalNative.SendDrawPrimitives( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"), + PrimitiveTypeTriangle, + 0, + 3); + } + + private static byte[] CreateBlackFrame(uint width, uint height) + { + if (width == 0 || height == 0 || width > 8192 || height > 8192) + { + width = 1; + height = 1; + } + + var pixels = GC.AllocateUninitializedArray(checked((int)(width * height * 4))); + pixels.AsSpan().Clear(); + for (var offset = 3; offset < pixels.Length; offset += 4) + { + pixels[offset] = 0xFF; + } + + return pixels; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs index c221fe4..f2fcef1 100644 --- a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs +++ b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs @@ -15,6 +15,8 @@ namespace SharpEmu.Libs.Gpu.Vulkan; /// internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend { + public string BackendName => "Vulkan"; + private static readonly IGuestCompiledShader DepthOnlyFragmentShader = new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment()); @@ -343,6 +345,60 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend return false; } + public IDisposable EnterGuestQueue(string queueName, ulong submissionId) => + VulkanVideoPresenter.EnterGuestQueue(queueName, submissionId); + + public long SubmitOrderedGuestAction(Action action, string debugName) => + VulkanVideoPresenter.SubmitOrderedGuestAction(action, debugName); + + public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) => + VulkanVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex); + + public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) => + VulkanVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds); + + public long CurrentGuestWorkSequenceForDiagnostics => + VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics; + + public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) => + VulkanVideoPresenter.IsGuestImageUploadKnown(address, format, numberType); + + public bool GuestImageWantsInitialData(ulong address) => + VulkanVideoPresenter.GuestImageWantsInitialData(address); + + public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) => + VulkanVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels); + + public void SubmitGuestImageFill(ulong address, uint fillValue) => + VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue); + + public void SubmitGuestImageWrite(ulong address, byte[] pixels) => + VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels); + + public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) => + VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount); + + public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() => + VulkanVideoPresenter.GetGuestImageExtents(); + + public bool IsTextureContentCached(in TextureContentIdentity identity) => + VulkanVideoPresenter.IsTextureContentCached(identity); + + public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) => + VulkanVideoPresenter.AttachGuestMemory(memory); + + public ulong GuestStorageBufferOffsetAlignment => + VulkanVideoPresenter.GuestStorageBufferOffsetAlignment; + + public void CountShaderCompilation() => + VulkanVideoPresenter.CountSpirvCompilation(); + + public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() => + VulkanVideoPresenter.ReadAndResetPerfCounters(); + + public void RequestClose() => + VulkanVideoPresenter.RequestClose(); + private static byte[] Spirv(IGuestCompiledShader shader) => shader is VulkanCompiledGuestShader vulkanShader ? vulkanShader.Spirv diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 7191f30..0a6c46e 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -224,6 +224,21 @@ public static partial class KernelMemoryCompatExports } } + /// Removes a guest mount registered by . + public static bool UnregisterGuestPathMount(string guestMountPoint) + { + var normalizedMountPoint = NormalizeGuestStatCachePath(guestMountPoint); + if (normalizedMountPoint is null) + { + return false; + } + + lock (_guestMountGate) + { + return _guestMounts.Remove(normalizedMountPoint); + } + } + internal static bool TryAllocateHleData( CpuContext ctx, ulong length, diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs index 083f068..a3ed204 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs @@ -696,7 +696,14 @@ public static class KernelPthreadCompatExports } } - if (state.OwnerThreadId == 0 && state.Waiters.Count == 0) + // pthread_mutex_trylock succeeds whenever the mutex is not currently + // held; unlike the blocking lock it does not queue behind waiters + // (POSIX gives it no fairness obligation). Gating trylock on an empty + // wait queue is wrong and, worse, lets a single stale/undrainable + // waiter wedge a spin-on-trylock loop forever even though the mutex + // is free (owner==0). The blocking lock still honours FIFO so real + // blocked waiters are not starved by a barging locker. + if (state.OwnerThreadId == 0 && (tryOnly || state.Waiters.Count == 0)) { state.OwnerThreadId = currentThreadId; state.RecursionCount = 1; @@ -1234,7 +1241,21 @@ public static class KernelPthreadCompatExports var currentThreadId = KernelPthreadState.GetCurrentThreadHandle(); lock (mutexState) { - if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1) + if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0) + { + // The guest holds the mutex through a path our host-side tracking + // never observed — most commonly libkernel's uncontended userspace + // fast-path, which locks the mutex word directly without an HLE + // call. Real pthread_cond_wait requires the caller to own the + // mutex and does not verify it for normal mutexes, so returning + // EPERM here is wrong: it spins the guest and, worse, leaves the + // mutex held (the unlock below is skipped), wedging every thread + // that later blocks on pthread_mutex_lock. Adopt ownership so the + // unlock/wait/re-lock cycle is balanced and releases the mutex. + mutexState.OwnerThreadId = currentThreadId; + mutexState.RecursionCount = 1; + } + else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1) { return mutexState.OwnerThreadId == currentThreadId ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT @@ -1385,6 +1406,31 @@ public static class KernelPthreadCompatExports bool cooperative, string? wakeKey = null) { + // A guest thread can have at most one pending acquisition on a mutex — + // it is either running or blocked on exactly one wait. If a waiter for + // this thread is still queued when it comes back for a fresh + // acquisition, that entry is a stale leftover the thread abandoned + // (most often a cond_timedwait timeout whose re-acquire hand-off was + // lost). Stale entries clog the FIFO head with waiters no thread is + // blocked on, so the unlock hand-off wakes a dead wake-key and the + // mutex wedges permanently (observed deadlocking Hades: several + // re-acquire waiters from one thread piled ahead of a live locker). + // Prune any prior entry for this thread before enqueueing the new one. + if (threadId != 0) + { + for (var node = state.Waiters.First; node is not null;) + { + var next = node.Next; + if (node.Value.ThreadId == threadId) + { + state.Waiters.Remove(node); + node.Value.Node = null; + } + + node = next; + } + } + var waiter = new PthreadMutexWaiter { ThreadId = threadId, diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs index 3e2d32e..19ed2b4 100644 --- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs +++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs @@ -3,6 +3,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.Kernel; +using System.Buffers; using System.Buffers.Binary; using System.Threading; @@ -25,24 +26,44 @@ public static class Ngs2Exports private static long _nextUid; private static long _renderCount; - private sealed record SystemState(uint Uid); - private sealed record RackState(ulong SystemHandle, uint RackId); - private sealed record VoiceState(ulong RackHandle, uint VoiceIndex); + // NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender. + // The grain length defaults to 256 frames (matching the 8192-byte AudioOut + // buffers games copy it into) until the title overrides it. + private const int DefaultGrainSamples = 256; + private const double OutputSampleRate = 48000.0; - [SysAbiExport( - Nid = "koBbCMvOKWw", - ExportName = "sceNgs2SystemCreate", - Target = Generation.Gen4 | Generation.Gen5, - LibraryName = "libSceNgs2")] - public static int Ngs2SystemCreate(CpuContext ctx) + private sealed class SystemState { - var bufferInfoAddress = ctx[CpuRegister.Rsi]; - if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer)) + public SystemState(uint uid) => Uid = uid; + + public uint Uid { get; } + public int GrainSamples { get; set; } = DefaultGrainSamples; + } + + private sealed record RackState(ulong SystemHandle, uint RackId); + + private sealed class VoiceState + { + public VoiceState(ulong rackHandle, uint voiceIndex) { - return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + RackHandle = rackHandle; + VoiceIndex = voiceIndex; } - return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer); + public ulong RackHandle { get; } + public uint VoiceIndex { get; } + + // Software-mixer playback state. Pcm is the fully decoded mono waveform; + // Position is a fractional read cursor advanced at the source/output rate + // ratio each output frame. + public short[]? Pcm { get; set; } + public ulong SourceAddr { get; set; } + public int SourceRate { get; set; } + public double Position { get; set; } + public bool Playing { get; set; } + public int LoopStart { get; set; } = -1; + public int LoopEnd { get; set; } + public float Gain { get; set; } = 1f; } [SysAbiExport( @@ -58,14 +79,34 @@ public static class Ngs2Exports return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); } - if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle)) + if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) || + !ctx.TryWriteUInt64(outHandleAddress, handle)) { return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } - return CreateSystem(ctx, outHandleAddress, handle); + lock (StateGate) + { + Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid))); + } + + return SetReturn(ctx, 0); } + // Non-allocator create: identical to the WithAllocator form for our purposes. + // The only signature difference is the caller-supplied buffer info in rsi + // (vs an allocator callback); the system option (rdi) and out-handle (rdx) + // sit at the same argument positions, so we reuse the same implementation. + // Dead Cells uses these variants — leaving sceNgs2SystemCreate unresolved + // gave the game a garbage system handle, so every later rack/voice call + // failed and it polled sceNgs2VoiceGetState forever, freezing at FLIP 0. + [SysAbiExport( + Nid = "koBbCMvOKWw", + ExportName = "sceNgs2SystemCreate", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNgs2")] + public static int Ngs2SystemCreate(CpuContext ctx) => Ngs2SystemCreateWithAllocator(ctx); + [SysAbiExport( Nid = "u-WrYDaJA3k", ExportName = "sceNgs2SystemDestroy", @@ -94,27 +135,6 @@ public static class Ngs2Exports return SetReturn(ctx, 0); } - [SysAbiExport( - Nid = "cLV4aiT9JpA", - ExportName = "sceNgs2RackCreate", - Target = Generation.Gen4 | Generation.Gen5, - LibraryName = "libSceNgs2")] - public static int Ngs2RackCreate(CpuContext ctx) - { - var bufferInfoAddress = ctx[CpuRegister.Rcx]; - if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer)) - { - return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); - } - - return CreateRack( - ctx, - ctx[CpuRegister.Rdi], - unchecked((uint)ctx[CpuRegister.Rsi]), - ctx[CpuRegister.R8], - hostBuffer); - } - [SysAbiExport( Nid = "U546k6orxQo", ExportName = "sceNgs2RackCreateWithAllocator", @@ -138,14 +158,29 @@ public static class Ngs2Exports return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); } - if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle)) + if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) || + !ctx.TryWriteUInt64(outHandleAddress, handle)) { return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } - return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle); + lock (StateGate) + { + Racks[handle] = new RackState(systemHandle, rackId); + } + + return SetReturn(ctx, 0); } + // Non-allocator rack create: system handle (rdi), rack id (rsi) and the + // out-handle (r8) share the WithAllocator argument layout, so reuse it. + [SysAbiExport( + Nid = "cLV4aiT9JpA", + ExportName = "sceNgs2RackCreate", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNgs2")] + public static int Ngs2RackCreate(CpuContext ctx) => Ngs2RackCreateWithAllocator(ctx); + [SysAbiExport( Nid = "lCqD7oycmIM", ExportName = "sceNgs2RackDestroy", @@ -220,14 +255,217 @@ public static class Ngs2Exports LibraryName = "libSceNgs2")] public static int Ngs2VoiceControl(CpuContext ctx) { + var voiceHandle = ctx[CpuRegister.Rdi]; + var paramList = ctx[CpuRegister.Rsi]; lock (StateGate) { - return SetReturn( - ctx, - Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : OrbisNgs2ErrorInvalidVoiceHandle); + if (!Voices.ContainsKey(voiceHandle)) + { + return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle); + } + } + + if (ShouldTrace()) + { + TraceVoiceParamList(ctx, voiceHandle, paramList); + } + + HandleVoiceParams(ctx, voiceHandle, paramList); + return SetReturn(ctx, 0); + } + + // Parse the SceNgs2VoiceParamHead command list (header = u32 size, u32 id; + // params are laid out contiguously) and apply the ones the mixer needs: + // the waveform-blocks param arms a voice with decoded PCM, and the port + // matrix param carries its output gain. + private static void HandleVoiceParams(CpuContext ctx, ulong voiceHandle, ulong paramList) + { + if (paramList == 0) + { + return; + } + + var offset = paramList; + for (var guard = 0; guard < 32; guard++) + { + if (!ctx.TryReadUInt32(offset, out var size) || + !ctx.TryReadUInt32(offset + 4, out var id)) + { + return; + } + + switch (id) + { + case 0x10000001: + ApplyWaveformParam(ctx, voiceHandle, offset); + break; + case 0x20010001: + ApplyPortMatrixParam(ctx, voiceHandle, offset); + break; + } + + // Advance to the next contiguous block; the game normally sends one + // param per call (size==whole block), so stop when size is degenerate. + if (size < 8 || size > 0x1000) + { + return; + } + + offset += (size + 7) & ~7u; } } + // Waveform-blocks param: the guest pointer at +8 references a "VAGp" + // (PS-ADPCM) container. Decode it once and arm the voice for playback. + private static void ApplyWaveformParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset) + { + if (!ctx.TryReadUInt64(paramOffset + 8, out var dataAddr) || dataAddr <= 0x10000) + { + return; + } + + lock (StateGate) + { + if (Voices.TryGetValue(voiceHandle, out var existing) && + existing.SourceAddr == dataAddr && existing.Pcm is not null) + { + // Same waveform already armed — don't restart it every frame. + return; + } + } + + Span header = stackalloc byte[Ngs2VagDecoder.VagHeaderSize]; + if (!ctx.Memory.TryRead(dataAddr, header) || !Ngs2VagDecoder.IsVag(header)) + { + return; + } + + var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(header[0x0C..]); + var totalBytes = Ngs2VagDecoder.VagHeaderSize + Math.Clamp(declaredSize, 0, 8 * 1024 * 1024); + var raw = System.Buffers.ArrayPool.Shared.Rent(totalBytes); + try + { + if (!ctx.Memory.TryRead(dataAddr, raw.AsSpan(0, totalBytes)) || + !Ngs2VagDecoder.TryDecode(raw.AsSpan(0, totalBytes), out var waveform)) + { + return; + } + + lock (StateGate) + { + if (!Voices.TryGetValue(voiceHandle, out var voice)) + { + return; + } + + voice.Pcm = waveform.Samples; + voice.SourceAddr = dataAddr; + voice.SourceRate = waveform.SampleRate; + voice.LoopStart = waveform.LoopStart; + voice.LoopEnd = waveform.LoopEnd > 0 ? waveform.LoopEnd : waveform.Samples.Length; + voice.Position = 0; + voice.Playing = true; + } + + if (ShouldTrace()) + { + var peak = 0; + for (var i = 0; i < waveform.Samples.Length; i++) + { + peak = Math.Max(peak, Math.Abs((int)waveform.Samples[i])); + } + + Console.Error.WriteLine( + $"[LOADER][TRACE] ngs2.arm voice=0x{voiceHandle:X16} addr=0x{dataAddr:X} rate={waveform.SampleRate} samples={waveform.Samples.Length} loop={waveform.LoopStart} peak={peak}"); + } + } + finally + { + System.Buffers.ArrayPool.Shared.Return(raw); + } + } + + // Port matrix param: the first float level is a reasonable proxy for the + // voice's output gain until per-channel panning is implemented. + private static void ApplyPortMatrixParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset) + { + if (!ctx.TryReadUInt32(paramOffset + 12, out var levelBits)) + { + return; + } + + var level = BitConverter.UInt32BitsToSingle(levelBits); + if (!float.IsFinite(level) || level < 0f || level > 8f) + { + return; + } + + lock (StateGate) + { + if (Voices.TryGetValue(voiceHandle, out var voice)) + { + voice.Gain = level; + } + } + } + + // Empirically dump the SceNgs2VoiceParamHead-chained command list so we can + // confirm the real struct layout (size/next/id) against public NGS2 sources + // before building the software mixer. Assumed header: u16 size, s16 next + // (byte offset to the next block, 0 = end), u32 id. + private static void TraceVoiceParamList(CpuContext ctx, ulong voiceHandle, ulong paramList) + { + if (paramList == 0) + { + return; + } + + Span peek = stackalloc byte[32]; + var offset = paramList; + for (int guard = 0; guard < 32; guard++) + { + if (!ctx.TryReadUInt16(offset, out var size) || + !ctx.TryReadUInt16(offset + 2, out var next) || + !ctx.TryReadUInt32(offset + 4, out var id)) + { + Console.Error.WriteLine($"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} @0x{offset:X}: unreadable header"); + return; + } + + peek.Clear(); + var readable = Math.Min((int)Math.Max((ushort)8, size), peek.Length); + ctx.Memory.TryRead(offset, peek[..readable]); + Console.Error.WriteLine( + $"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} id=0x{id:X} size={size} next={unchecked((short)next)} bytes={Convert.ToHexString(peek[..readable])}"); + + // For the waveform-blocks param, follow the embedded pointers and + // dump the pointed-to bytes so we can tell PCM16 from ATRAC9. + if (id == 0x10000001 && Interlocked.Increment(ref _waveformDumps) <= 8) + { + for (int po = 8; po + 8 <= readable; po += 8) + { + if (ctx.TryReadUInt64(offset + (ulong)po, out var ptr) && ptr > 0x10000 && + ctx.Memory.TryRead(ptr, peek)) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] ngs2.waveform @+{po} ptr=0x{ptr:X} head={Convert.ToHexString(peek)}"); + } + } + } + + var advance = unchecked((short)next); + if (advance <= 0) + { + return; + } + + offset += (ulong)advance; + } + } + + private static long _waveformDumps; + private static long _renderInfoDumps; + [SysAbiExport( Nid = "AbYvTOZ8Pts", ExportName = "sceNgs2VoiceRunCommands", @@ -273,11 +511,32 @@ public static class Ngs2Exports { return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + + // SceNgs2RenderBufferInfo: {ptr@0, size@8, waveformType@16, + // channelsCount@20}. Mix the armed voices into the leading grain + // as interleaved float32 — this is what the game copies to + // sceAudioOutOutput, so it is where NGS2 audio must appear. + var channels = 2; + if (ctx.TryReadUInt32(entryAddress + 20, out var declaredChannels) && + declaredChannels is > 0 and <= 8) + { + channels = (int)declaredChannels; + } + + MixVoicesIntoGrain(ctx, systemHandle, bufferAddress, bufferSize, channels); + + if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4) + { + Span rbi = stackalloc byte[RenderBufferInfoSize]; + ctx.Memory.TryRead(entryAddress, rbi); + Console.Error.WriteLine( + $"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}"); + } } } var count = Interlocked.Increment(ref _renderCount); - if (ShouldTrace() && (count <= 4 || count % 10_000 == 0)) + if (ShouldTrace() && (count <= 4 || count % 200 == 0)) { Console.Error.WriteLine( $"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}"); @@ -286,6 +545,135 @@ public static class Ngs2Exports return SetReturn(ctx, 0); } + // Sum every armed voice belonging to this system into the leading grain of + // the render buffer as interleaved float32. The buffer was just zeroed, so + // this is a plain additive mix; silence stays silence when nothing plays. + private static void MixVoicesIntoGrain( + CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels) + { + int grain; + lock (StateGate) + { + if (!Systems.TryGetValue(systemHandle, out var system)) + { + return; + } + + grain = system.GrainSamples; + } + + var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float))); + if (capacityFrames <= 0) + { + return; + } + + var floatCount = capacityFrames * channels; + var accum = ArrayPool.Shared.Rent(floatCount); + var mixedAnything = false; + try + { + Array.Clear(accum, 0, floatCount); + lock (StateGate) + { + foreach (var pair in Voices) + { + var voice = pair.Value; + if (!voice.Playing || voice.Pcm is null || voice.Pcm.Length == 0) + { + continue; + } + + if (!Racks.TryGetValue(voice.RackHandle, out var rack) || + rack.SystemHandle != systemHandle) + { + continue; + } + + MixOneVoice(accum, capacityFrames, channels, voice); + mixedAnything = true; + } + } + + if (mixedAnything) + { + WriteGrain(ctx, bufferAddress, accum, floatCount); + } + } + finally + { + ArrayPool.Shared.Return(accum); + } + } + + // Resample one voice from its source rate to 48 kHz (nearest-sample) and add + // it to the front stereo pair. Advances the voice cursor and handles loop / + // one-shot end. Must be called under StateGate. + private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice) + { + var pcm = voice.Pcm!; + var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length; + var loopStart = voice.LoopStart; + var step = voice.SourceRate / OutputSampleRate; + var gain = voice.Gain / 32768f; + var pos = voice.Position; + for (var f = 0; f < frames; f++) + { + var idx = (int)pos; + if (idx >= loopEnd) + { + if (loopStart >= 0 && loopStart < loopEnd) + { + pos = loopStart; + idx = loopStart; + } + else + { + voice.Playing = false; + break; + } + } + + if (idx < 0 || idx >= pcm.Length) + { + voice.Playing = false; + break; + } + + var sample = pcm[idx] * gain; + var baseIndex = f * channels; + accum[baseIndex] += sample; + if (channels > 1) + { + accum[baseIndex + 1] += sample; + } + + pos += step; + } + + voice.Position = pos; + } + + private static void WriteGrain(CpuContext ctx, ulong address, float[] accum, int count) + { + var bytes = ArrayPool.Shared.Rent(count * sizeof(float)); + try + { + var span = bytes.AsSpan(0, count * sizeof(float)); + for (var i = 0; i < count; i++) + { + var value = Math.Clamp(accum[i], -1f, 1f); + BinaryPrimitives.WriteSingleLittleEndian(span.Slice(i * sizeof(float), sizeof(float)), value); + } + + ctx.Memory.TryWrite(address, span); + } + finally + { + ArrayPool.Shared.Return(bytes); + } + } + [SysAbiExport( Nid = "pgFAiLR5qT4", ExportName = "sceNgs2SystemQueryBufferSize", @@ -323,7 +711,25 @@ public static class Ngs2Exports ExportName = "sceNgs2SystemSetGrainSamples", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNgs2")] - public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(ctx); + public static int Ngs2SystemSetGrainSamples(CpuContext ctx) + { + var systemHandle = ctx[CpuRegister.Rdi]; + var grain = unchecked((int)ctx[CpuRegister.Rsi]); + lock (StateGate) + { + if (!Systems.TryGetValue(systemHandle, out var system)) + { + return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle); + } + + if (grain > 0 && grain <= 8192) + { + system.GrainSamples = grain; + } + } + + return SetReturn(ctx, 0); + } [SysAbiExport( Nid = "-tbc2SxQD60", @@ -412,67 +818,6 @@ public static class Ngs2Exports } } - private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle) - { - if (outHandleAddress == 0) - { - return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); - } - - if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle)) - { - return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); - } - - lock (StateGate) - { - Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid))); - } - - return SetReturn(ctx, 0); - } - - private static int CreateRack( - CpuContext ctx, - ulong systemHandle, - uint rackId, - ulong outHandleAddress, - ulong handle) - { - lock (StateGate) - { - if (!Systems.ContainsKey(systemHandle)) - { - return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle); - } - } - - if (outHandleAddress == 0) - { - return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); - } - - if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle)) - { - return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); - } - - lock (StateGate) - { - Racks[handle] = new RackState(systemHandle, rackId); - } - - return SetReturn(ctx, 0); - } - - private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer) - { - hostBuffer = 0; - return address != 0 && - ctx.TryReadUInt64(address, out hostBuffer) && - hostBuffer != 0; - } - private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle) { handle = 0; diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs b/src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs new file mode 100644 index 0000000..05623f6 --- /dev/null +++ b/src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs @@ -0,0 +1,150 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; + +namespace SharpEmu.Libs.Ngs2; + +// Clean-room PS-ADPCM ("VAG") decoder. NGS2 sampler voices point at waveforms +// wrapped in the classic Sony "VAGp" container: a 48-byte big-endian header +// followed by 16-byte ADPCM frames (2-byte predictor/shift + flags, then 14 +// bytes = 28 nibbles = 28 samples). The predictor coefficient table and the +// nibble decode are the publicly documented PSX SPU ADPCM algorithm. +public static class Ngs2VagDecoder +{ + // Standard PS-ADPCM predictor filters (scaled by 1/64). + private static readonly int[] Coeff0 = { 0, 60, 115, 98, 122 }; + private static readonly int[] Coeff1 = { 0, 0, -52, -55, -60 }; + + public const int VagHeaderSize = 0x30; + private const uint VagMagic = 0x56414770; // "VAGp" + + public readonly struct Waveform + { + public Waveform(short[] samples, int sampleRate, int loopStart, int loopEnd) + { + Samples = samples; + SampleRate = sampleRate; + LoopStart = loopStart; + LoopEnd = loopEnd; + } + + public short[] Samples { get; } + public int SampleRate { get; } + public int LoopStart { get; } // -1 when the waveform does not loop + public int LoopEnd { get; } + } + + // True when the buffer begins with a recognizable "VAGp" container header. + public static bool IsVag(ReadOnlySpan data) => + data.Length >= VagHeaderSize && + BinaryPrimitives.ReadUInt32BigEndian(data) == VagMagic; + + // Decode a full "VAGp" container into mono PCM16. Returns false when the + // header is missing/short so callers can skip unsupported formats safely. + public static bool TryDecode(ReadOnlySpan data, out Waveform waveform) + { + waveform = default; + if (!IsVag(data)) + { + return false; + } + + // Header (big-endian): +0x0C dataSize, +0x10 sampleRate. + var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x0C..]); + var sampleRate = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x10..]); + if (sampleRate <= 0) + { + sampleRate = 48000; + } + + var body = data[VagHeaderSize..]; + // Trust the declared payload size when it fits; otherwise decode what we + // actually have (some tools pad or under-report). + var available = body.Length - (body.Length % 16); + var frameBytes = declaredSize > 0 && declaredSize <= available ? declaredSize - (declaredSize % 16) : available; + if (frameBytes <= 0) + { + return false; + } + + waveform = Decode(body[..frameBytes], sampleRate); + return waveform.Samples.Length > 0; + } + + // Decode raw 16-byte-framed PS-ADPCM (no container header) into PCM16 and + // resolve loop points from the per-frame flag bytes. + public static Waveform Decode(ReadOnlySpan frames, int sampleRate) + { + var frameCount = frames.Length / 16; + var samples = new short[frameCount * 28]; + var loopStart = -1; + var loopEnd = -1; + + var hist1 = 0; + var hist2 = 0; + var outIndex = 0; + var ended = false; + for (var frame = 0; frame < frameCount && !ended; frame++) + { + var offset = frame * 16; + var header = frames[offset]; + var shift = header & 0x0F; + var filter = (header >> 4) & 0x0F; + if (filter > 4) + { + filter = 0; + } + + // Per-frame loop marker (exact PS-ADPCM values, not bit masks): + // 3 = loop start, 6 = loop end + jump back, 1/7 = one-shot end. + var flags = frames[offset + 1]; + var blockStart = outIndex; + if (flags == 0x03) + { + loopStart = blockStart; + } + + var f0 = Coeff0[filter]; + var f1 = Coeff1[filter]; + for (var i = 0; i < 14; i++) + { + var d = frames[offset + 2 + i]; + for (var nibble = 0; nibble < 2; nibble++) + { + var raw = nibble == 0 ? d & 0x0F : d >> 4; + // Sign-extend the 4-bit sample into the top nibble, then scale. + var s = (short)(raw << 12) >> shift; + var predicted = (hist1 * f0 + hist2 * f1) >> 6; + var sample = Math.Clamp(s + predicted, short.MinValue, short.MaxValue); + samples[outIndex++] = (short)sample; + hist2 = hist1; + hist1 = sample; + } + } + + if (flags == 0x06) + { + loopEnd = outIndex; + } + else if (flags == 0x01 || flags == 0x07) + { + ended = true; + } + } + + // Trim to the samples we actually decoded (a one-shot end marker can stop + // us before the declared frame count). + if (outIndex != samples.Length) + { + Array.Resize(ref samples, outIndex); + } + + if (loopStart >= 0 && loopEnd <= loopStart) + { + loopEnd = outIndex; + } + + return new Waveform(samples, sampleRate, loopStart, loopEnd); + } +} diff --git a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs index 4efe1f6..1043e30 100644 --- a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs +++ b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs @@ -58,6 +58,36 @@ public static class NpEntitlementAccessExports 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", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNpEntitlementAccess")] + public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx) + { + var infoAddress = ctx[CpuRegister.Rdx]; + if (infoAddress != 0) + { + Span info = stackalloc byte[EmptyAddcontInfoSize]; + info.Clear(); + if (!ctx.Memory.TryWrite(infoAddress, info)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + 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); + } + private static void TraceNpEntitlementAccess(string message) { if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal)) diff --git a/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs b/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs index 529d3c0..e2fb769 100644 --- a/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs +++ b/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs @@ -197,4 +197,16 @@ public static class NpUniversalDataSystemExports { return ctx.SetReturn(0, typeof(long)); } + + // Telemetry property setter (event property array, string value). We do not + // upload analytics, so accept and drop it — matching the other Set* stubs. + [SysAbiExport( + Nid = "4llLk7YJRTE", + ExportName = "sceNpUniversalDataSystemEventPropertyArraySetString", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNpUniversalDataSystem")] + public static int NpUniversalDataSystemEventPropertyArraySetString(CpuContext ctx) + { + return ctx.SetReturn(0, typeof(long)); + } } diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 91884ea..1299040 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -65,6 +65,35 @@ public static class PadExports LibraryName = "libScePad")] public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true); + // scePadGetHandle(userId, type, index): returns the handle of an already-open + // pad without opening a new one. Dead Cells calls it every frame to poll + // input; leaving it unresolved returned a garbage handle so the input path + // (and the game loop that drives it) misbehaved. Same validation as + // scePadOpen — the one primary pad — returning its handle or a not-connected + // error, never opening or logging. + [SysAbiExport( + Nid = "u1GRHp+oWoY", + ExportName = "scePadGetHandle", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadGetHandle(CpuContext ctx) + { + var userId = unchecked((int)ctx[CpuRegister.Rdi]); + var type = unchecked((int)ctx[CpuRegister.Rsi]); + var index = unchecked((int)ctx[CpuRegister.Rdx]); + if (!_initialized) + { + return ctx.SetReturn(OrbisPadErrorNotInitialized); + } + + if (userId != PrimaryUserId || type is not (0 or 1 or 2) || index != 0) + { + return ctx.SetReturn(OrbisPadErrorDeviceNotConnected); + } + + return ctx.SetReturn(PrimaryPadHandle); + } + // scePadOpen rejects a non-null 4th arg and non-standard ports; scePadOpenExt accepts a // ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected). private static int PadOpenCore(CpuContext ctx, bool extended) @@ -216,6 +245,38 @@ public static class PadExports : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + [SysAbiExport( + Nid = "AcslpN1jHR8", + ExportName = "scePadDeviceClassGetExtendedInformation", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadDeviceClassGetExtendedInformation(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + var informationAddress = ctx[CpuRegister.Rsi]; + if (!IsPrimaryPadHandle(handle)) + { + return ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + if (informationAddress == 0) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + // ScePadDeviceClassExtendedInformation: deviceClass 0 = standard pad + // (DualSense). We emulate no special peripheral (guitar/drums/wheel), so + // the class-data union stays zeroed — the guest treats it as a plain + // controller with no extended capabilities. + Span information = stackalloc byte[0x20]; + information.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(information[0x00..], 0); + + return ctx.Memory.TryWrite(informationAddress, information) + ? ctx.SetReturn(0) + : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + [SysAbiExport( Nid = "YndgXqQVV7c", ExportName = "scePadReadState", diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs index cf470dc..41ef98a 100644 --- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs +++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs @@ -45,6 +45,530 @@ public static class SaveDataExports _titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim()); _preparedTransactionResources.Clear(); } + + lock (_eventGate) + { + _events.Clear(); + } + + lock (_mountGate) + { + _mounts.Clear(); + } + } + + // Additional error codes and the async-event model (see sceSaveDataGetEventResult). + private const int OrbisSaveDataErrorBusy = unchecked((int)0x809F0006); + private const int OrbisSaveDataErrorNoEvent = unchecked((int)0x809F0008); // NOT_FOUND: no pending event + private const int OrbisSaveDataErrorBadMounted = unchecked((int)0x809F0013); + // SceSaveDataEventType + private const uint EventTypeUmountBackupEnd = 1; + private const uint EventTypeBackupEnd = 2; + private const uint EventTypeSaveDataMemorySyncEnd = 3; + private const int SaveDataEventSize = 0x60; + private const int MountInfoSize = 0x40; + private const uint DefaultBlockSize = 32768; + private const ulong DefaultTotalBlocks = 0x8000; // 1 GiB of 32 KiB blocks + + private static readonly object _eventGate = new(); + private static readonly Queue _events = new(); + private static readonly object _mountGate = new(); + // mountPoint -> live mount, for umount/IsMounted/GetMountInfo. + private static readonly Dictionary _mounts = new(StringComparer.Ordinal); + + private readonly record struct SaveDataEvent(uint Type, int ErrorCode, int UserId, string DirName); + private sealed record MountEntry(string SlotDir, string DirName, int UserId); + + private static void EnqueueEvent(uint type, int userId, string dirName, int errorCode = 0) + { + lock (_eventGate) + { + _events.Enqueue(new SaveDataEvent(type, errorCode, userId, dirName)); + } + TraceSaveData($"event.enqueue type={type} user={userId} dir='{dirName}' err=0x{errorCode:X}"); + } + + [SysAbiExport( + Nid = "j8xKtiFj0SY", + ExportName = "sceSaveDataGetEventResult", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataGetEventResult(CpuContext ctx) + { + // rdi: SceSaveDataEventParam* (filter, ignored). rsi: SceSaveDataEvent* out. + var eventAddress = ctx[CpuRegister.Rsi]; + if (eventAddress == 0) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + SaveDataEvent pending; + lock (_eventGate) + { + if (_events.Count == 0) + { + // No queued completion. Games poll this from a worker; report the + // defined "no event" status so the loop keeps polling instead of + // acting on an uninitialized event struct. + return SetReturn(ctx, OrbisSaveDataErrorNoEvent); + } + + pending = _events.Dequeue(); + } + + Span ev = stackalloc byte[SaveDataEventSize]; + ev.Clear(); + BinaryPrimitives.WriteUInt32LittleEndian(ev[0x00..], pending.Type); + BinaryPrimitives.WriteInt32LittleEndian(ev[0x04..], pending.ErrorCode); + BinaryPrimitives.WriteInt32LittleEndian(ev[0x08..], pending.UserId); + WriteAscii(ev.Slice(0x10, SaveDataDirNameSize), pending.DirName); + if (!ctx.Memory.TryWrite(eventAddress, ev)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + return SetReturn(ctx, 0); + } + + [SysAbiExport( + Nid = "hsKd5c21sQc", + ExportName = "sceSaveDataRegisterEventCallback", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataRegisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0); + + [SysAbiExport( + Nid = "v-AK1AxQhS0", + ExportName = "sceSaveDataUnregisterEventCallback", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataUnregisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0); + + // ---- lifecycle ---- + [SysAbiExport(Nid = "ZkZhskCPXFw", ExportName = "sceSaveDataInitialize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataInitialize(CpuContext ctx) => SaveDataInitializeCommon(ctx); + + [SysAbiExport(Nid = "l1NmDeDpNGU", ExportName = "sceSaveDataInitialize2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataInitialize2(CpuContext ctx) => SaveDataInitializeCommon(ctx); + + private static int SaveDataInitializeCommon(CpuContext ctx) + { + try + { + Directory.CreateDirectory(ResolveSaveDataRoot()); + return SetReturn(ctx, 0); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + } + + [SysAbiExport(Nid = "yKDy8S5yLA0", ExportName = "sceSaveDataTerminate", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataTerminate(CpuContext ctx) => SetReturn(ctx, 0); + + // ---- mount variants (all share the SceSaveDataMount layout) ---- + [SysAbiExport(Nid = "32HQAQdwM2o", ExportName = "sceSaveDataMount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataMount(CpuContext ctx) => SaveDataMount3(ctx); + + [SysAbiExport(Nid = "0z45PIH+SNI", ExportName = "sceSaveDataMount2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataMount2(CpuContext ctx) => SaveDataMount3(ctx); + + [SysAbiExport(Nid = "xz0YMi6BfNk", ExportName = "sceSaveDataMount5", Target = Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataMount5(CpuContext ctx) => SaveDataMount3(ctx); + + [SysAbiExport(Nid = "BMR4F-Uek3E", ExportName = "sceSaveDataUmount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataUmount(CpuContext ctx) => SaveDataUmount2(ctx); + + [SysAbiExport(Nid = "ieP6jP138Qo", ExportName = "sceSaveDataIsMounted", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataIsMounted(CpuContext ctx) + { + var outAddress = ctx[CpuRegister.Rsi]; + int mountCount; + lock (_mountGate) + { + mountCount = _mounts.Count; + } + + if (outAddress != 0) + { + TryWriteUInt32(ctx, outAddress, mountCount > 0 ? 1u : 0u); + } + + return SetReturn(ctx, 0); + } + + [SysAbiExport(Nid = "65VH0Qaaz6s", ExportName = "sceSaveDataGetMountInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetMountInfo(CpuContext ctx) + { + var mountPointAddress = ctx[CpuRegister.Rdi]; + var infoAddress = ctx[CpuRegister.Rsi]; + if (mountPointAddress == 0 || infoAddress == 0 || + !TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint)) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + MountEntry? entry; + lock (_mountGate) + { + _mounts.TryGetValue(mountPoint, out entry); + } + + if (entry is null) + { + return SetReturn(ctx, OrbisSaveDataErrorBadMounted); + } + + var used = SafeDirectorySize(entry.SlotDir); + var usedBlocks = (ulong)((used + DefaultBlockSize - 1) / DefaultBlockSize); + Span info = stackalloc byte[MountInfoSize]; + info.Clear(); + BinaryPrimitives.WriteUInt64LittleEndian(info[0x00..], DefaultTotalBlocks); // blocks + BinaryPrimitives.WriteUInt64LittleEndian(info[0x08..], usedBlocks); // freeBlocks slot reused as used + return ctx.Memory.TryWrite(infoAddress, info) + ? SetReturn(ctx, 0) + : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + // ---- delete ---- + [SysAbiExport(Nid = "S1GkePI17zQ", ExportName = "sceSaveDataDelete", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataDelete(CpuContext ctx) => SaveDataDeleteCommon(ctx); + + [SysAbiExport(Nid = "SQWusLoK8Pw", ExportName = "sceSaveDataDelete5", Target = Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataDelete5(CpuContext ctx) => SaveDataDeleteCommon(ctx); + + private static int SaveDataDeleteCommon(CpuContext ctx) + { + // SceSaveDataDelete: +0x00 userId, +0x08 dirName*, ... (dirName drives the slot). + var deleteAddress = ctx[CpuRegister.Rdi]; + if (deleteAddress == 0 || + !TryReadInt32(ctx, deleteAddress, out var userId) || + !ctx.TryReadUInt64(deleteAddress + 0x08, out var dirNameAddress) || + dirNameAddress == 0 || + !TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName) || + string.IsNullOrWhiteSpace(dirName)) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + try + { + var slotDir = SaveDataStorage.SlotDir(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), dirName); + if (!Directory.Exists(slotDir)) + { + return SetReturn(ctx, OrbisSaveDataErrorNotFound); + } + + Directory.Delete(slotDir, recursive: true); + TraceSaveData($"delete user={userId} dir='{dirName}' path='{slotDir}'"); + return SetReturn(ctx, 0); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + } + + // ---- params (metadata shown in the save UI) ---- + [SysAbiExport(Nid = "XgvSuIdnMlw", ExportName = "sceSaveDataGetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetParam(CpuContext ctx) => TransferParam(ctx, write: false); + + [SysAbiExport(Nid = "85zul--eGXs", ExportName = "sceSaveDataSetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataSetParam(CpuContext ctx) => TransferParam(ctx, write: true); + + private static int TransferParam(CpuContext ctx, bool write) + { + // rdi: mount-point string (16 bytes). rsi: paramType. rdx: SceSaveDataParam*. rcx: size. + var mountPointAddress = ctx[CpuRegister.Rdi]; + var paramAddress = ctx[CpuRegister.Rdx]; + if (mountPointAddress == 0 || paramAddress == 0 || + !TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint)) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + MountEntry? entry; + lock (_mountGate) + { + _mounts.TryGetValue(mountPoint, out entry); + } + + if (entry is null) + { + return SetReturn(ctx, OrbisSaveDataErrorBadMounted); + } + + try + { + if (write) + { + Span raw = stackalloc byte[SaveDataParamSize]; + if (!ctx.Memory.TryRead(paramAddress, raw)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + var metadata = new SaveDataMetadata + { + Title = ReadAsciiField(raw.Slice(0x00, 128)), + SubTitle = ReadAsciiField(raw.Slice(0x80, 128)), + Detail = ReadAsciiField(raw.Slice(0x100, 1024)), + UserParam = BinaryPrimitives.ReadUInt32LittleEndian(raw[0x500..]), + }; + SaveDataStorage.WriteMetadata(entry.SlotDir, metadata); + TraceSaveData($"set_param mount='{mountPoint}' title='{metadata.Title}'"); + return SetReturn(ctx, 0); + } + + var loaded = SaveDataStorage.ReadMetadata(entry.SlotDir); + var param = new byte[SaveDataParamSize]; + WriteAscii(param.AsSpan(0x00, 128), loaded.Title); + WriteAscii(param.AsSpan(0x80, 128), loaded.SubTitle); + WriteAscii(param.AsSpan(0x100, 1024), loaded.Detail); + BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), loaded.UserParam); + BinaryPrimitives.WriteInt64LittleEndian( + param.AsSpan(0x508), + new DateTimeOffset(SafeLastWriteUtc(entry.SlotDir)).ToUnixTimeSeconds()); + return ctx.Memory.TryWrite(paramAddress, param) + ? SetReturn(ctx, 0) + : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + } + + // ---- icons ---- + [SysAbiExport(Nid = "c88Yy54Mx0w", ExportName = "sceSaveDataSaveIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataSaveIcon(CpuContext ctx) => TransferIconForMount(ctx, write: true); + + [SysAbiExport(Nid = "cGjO3wM3V28", ExportName = "sceSaveDataLoadIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataLoadIcon(CpuContext ctx) => TransferIconForMount(ctx, write: false); + + private static int TransferIconForMount(CpuContext ctx, bool write) + { + // rdi: mount-point string. rsi: SceSaveDataIcon* {buf@+0x00, bufSize@+0x08, dataSize@+0x10}. + var mountPointAddress = ctx[CpuRegister.Rdi]; + var iconAddress = ctx[CpuRegister.Rsi]; + if (mountPointAddress == 0 || iconAddress == 0 || + !TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) || + !ctx.TryReadUInt64(iconAddress + 0x00, out var bufferAddress) || + !ctx.TryReadUInt64(iconAddress + 0x08, out var bufferSize)) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + MountEntry? entry; + lock (_mountGate) + { + _mounts.TryGetValue(mountPoint, out entry); + } + + if (entry is null) + { + return SetReturn(ctx, OrbisSaveDataErrorBadMounted); + } + + var iconPath = SaveDataStorage.IconPath(entry.SlotDir); + try + { + if (write) + { + var length = checked((int)Math.Min(bufferSize, (ulong)16 * 1024 * 1024)); + var bytes = ArrayPool.Shared.Rent(length); + try + { + if (!ctx.Memory.TryRead(bufferAddress, bytes.AsSpan(0, length))) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + Directory.CreateDirectory(Path.GetDirectoryName(iconPath)!); + File.WriteAllBytes(iconPath, bytes.AsSpan(0, length).ToArray()); + } + finally + { + ArrayPool.Shared.Return(bytes); + } + + return SetReturn(ctx, 0); + } + + if (!File.Exists(iconPath)) + { + return SetReturn(ctx, OrbisSaveDataErrorNotFound); + } + + var data = File.ReadAllBytes(iconPath); + var copy = (int)Math.Min((ulong)data.Length, bufferSize); + if (bufferAddress != 0 && copy > 0 && !ctx.Memory.TryWrite(bufferAddress, data.AsSpan(0, copy))) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + TryWriteUInt32(ctx, iconAddress + 0x10, (uint)data.Length); // dataSize + return SetReturn(ctx, 0); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + } + + // ---- size / progress / abort ---- + [SysAbiExport(Nid = "A1ThglSGUwA", ExportName = "sceSaveDataGetAllSize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetAllSize(CpuContext ctx) + { + var outAddress = ctx[CpuRegister.Rsi]; + long total = 0; + try + { + var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId()); + if (Directory.Exists(titleRoot)) + { + total = SafeDirectorySize(titleRoot); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Report zero on an unreadable tree rather than fail the query. + } + + if (outAddress != 0) + { + var kib = (ulong)((total + 1023) / 1024); + ctx.TryWriteUInt64(outAddress, kib); + } + + return SetReturn(ctx, 0); + } + + [SysAbiExport(Nid = "ANmSWUiyyGQ", ExportName = "sceSaveDataGetProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetProgress(CpuContext ctx) + { + // Our operations complete synchronously, so any in-flight progress is 100%. + var outAddress = ctx[CpuRegister.Rdi]; + if (outAddress != 0) + { + Span progress = stackalloc byte[8]; + progress.Clear(); + BinaryPrimitives.WriteSingleLittleEndian(progress, 1.0f); + ctx.Memory.TryWrite(outAddress, progress); + } + + return SetReturn(ctx, 0); + } + + [SysAbiExport(Nid = "Wz-4JZfeO9g", ExportName = "sceSaveDataClearProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataClearProgress(CpuContext ctx) => SetReturn(ctx, 0); + + [SysAbiExport(Nid = "dQ2GohUHXzk", ExportName = "sceSaveDataAbort", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataAbort(CpuContext ctx) => SetReturn(ctx, 0); + + [SysAbiExport(Nid = "eBSSNIG6hMk", ExportName = "sceSaveDataGetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetEventInfo(CpuContext ctx) => SetReturn(ctx, 0); + + [SysAbiExport(Nid = "52pL2GKkdjA", ExportName = "sceSaveDataSetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataSetEventInfo(CpuContext ctx) => SetReturn(ctx, 0); + + [SysAbiExport(Nid = "Z7z6HXWORJY", ExportName = "sceSaveDataSaveIconByPath", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataSaveIconByPath(CpuContext ctx) => SetReturn(ctx, 0); + + [SysAbiExport(Nid = "SN7rTPHS+Cg", ExportName = "sceSaveDataGetSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetSaveDataCount(CpuContext ctx) + { + var outAddress = ctx[CpuRegister.Rsi]; + var count = 0; + try + { + var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId()); + if (Directory.Exists(titleRoot)) + { + foreach (var dir in Directory.EnumerateDirectories(titleRoot)) + { + if (!string.Equals(Path.GetFileName(dir), "sce_sdmemory", StringComparison.Ordinal)) + { + count++; + } + } + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Report zero on an unreadable tree. + } + + if (outAddress != 0) + { + TryWriteUInt32(ctx, outAddress, (uint)count); + } + + return SetReturn(ctx, 0); + } + + [SysAbiExport(Nid = "pc4guaUPVqA", ExportName = "sceSaveDataGetMountedSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetMountedSaveDataCount(CpuContext ctx) + { + var outAddress = ctx[CpuRegister.Rsi]; + int mounted; + lock (_mountGate) + { + mounted = _mounts.Count; + } + + if (outAddress != 0) + { + TryWriteUInt32(ctx, outAddress, (uint)mounted); + } + + return SetReturn(ctx, 0); + } + + // ---- SaveDataMemory v1 aliases (identical arg layout to the v2 forms) ---- + [SysAbiExport(Nid = "v7AAAMo0Lz4", ExportName = "sceSaveDataSetupSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataSetupSaveDataMemory(CpuContext ctx) => SaveDataSetupSaveDataMemory2(ctx); + + [SysAbiExport(Nid = "7Bt5pBC-Aco", ExportName = "sceSaveDataGetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataGetSaveDataMemory(CpuContext ctx) => SaveDataGetSaveDataMemory2(ctx); + + [SysAbiExport(Nid = "h3YURzXGSVQ", ExportName = "sceSaveDataSetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")] + public static int SaveDataSetSaveDataMemory(CpuContext ctx) => SaveDataSetSaveDataMemory2(ctx); + + private static string ReadAsciiField(ReadOnlySpan field) + { + var length = field.IndexOf((byte)0); + if (length < 0) + { + length = field.Length; + } + + return Encoding.ASCII.GetString(field[..length]); + } + + private static long SafeDirectorySize(string root) + { + try + { + return Directory.Exists(root) ? GetDirectorySize(root) : 0; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return 0; + } + } + + private static DateTime SafeLastWriteUtc(string path) + { + try + { + return Directory.Exists(path) ? Directory.GetLastWriteTimeUtc(path) : DateTime.UtcNow; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return DateTime.UtcNow; + } } [SysAbiExport( @@ -221,6 +745,10 @@ public static class SaveDataExports const string mountPoint = "/savedata0"; KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath); + lock (_mountGate) + { + _mounts[mountPoint] = new MountEntry(savePath, dirName, userId); + } Span result = stackalloc byte[MountResultSize]; result.Clear(); @@ -318,10 +846,20 @@ public static class SaveDataExports LibraryName = "libSceSaveData")] public static int SaveDataUmount2(CpuContext ctx) { - // Unmounting a save directory always succeeds in the stub filesystem; - // returning an error here makes the game's save flow stall before it - // hands control to the title/gameplay state. - TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}"); + // rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2. + var mountPointAddress = ctx[CpuRegister.Rdi]; + if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) && + !string.IsNullOrEmpty(mountPoint)) + { + lock (_mountGate) + { + _mounts.Remove(mountPoint); + } + + KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint); + TraceSaveData($"umount2 mount='{mountPoint}'"); + } + return SetReturn(ctx, 0); } @@ -405,9 +943,12 @@ public static class SaveDataExports private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry) { + var metadata = SaveDataStorage.ReadMetadata(entry.Path); var param = new byte[SaveDataParamSize]; - WriteAscii(param.AsSpan(0x00, 128), "Saved Data"); - WriteAscii(param.AsSpan(0x100, 1024), entry.Name); + WriteAscii(param.AsSpan(0x00, 128), metadata.Title); + WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle); + WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail); + BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam); BinaryPrimitives.WriteInt64LittleEndian( param.AsSpan(0x508, sizeof(long)), new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds()); @@ -474,11 +1015,14 @@ public static class SaveDataExports return false; } + // Saves are keyed by title id only (single-user emulation) under + // ~/SharpEmu/Saves//; userId is accepted for API fidelity but not + // part of the host path. private static string ResolveTitleSaveRoot(int userId, string titleId) => - Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId)); + SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId); private static string ResolveSaveDataMemoryPath(int userId) => - Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat"); + SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId())); private static bool TryReadMemoryData( CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset) @@ -490,14 +1034,7 @@ public static class SaveDataExports ctx.TryReadUInt64(address + 0x10, out offset); } - private static string ResolveSaveDataRoot() - { - var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR"); - var root = string.IsNullOrWhiteSpace(configured) - ? Path.Combine(AppContext.BaseDirectory, "user", "savedata") - : configured; - return Path.GetFullPath(root); - } + private static string ResolveSaveDataRoot() => SaveDataStorage.Root(); private static string ResolveConfiguredTitleId() { @@ -525,12 +1062,7 @@ public static class SaveDataExports return "default"; } - private static string SanitizePathSegment(string value) - { - var invalid = Path.GetInvalidFileNameChars(); - var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()); - return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized; - } + private static string SanitizePathSegment(string value) => SaveDataStorage.Sanitize(value); private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value) { @@ -790,8 +1322,17 @@ public static class SaveDataExports return ctx.SetReturn(OrbisSaveDataErrorParameter); } - return ctx.SetReturn( - File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady); + if (!File.Exists(ResolveSaveDataMemoryPath(userId))) + { + return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady); + } + + // The write already reached disk synchronously, but the guest treats + // sync as asynchronous and blocks a worker on sceSaveDataGetEventResult + // until the SAVE_DATA_MEMORY_SYNC_END event arrives. Post it so that + // poll completes (this is what wedged Dead Cells at FLIP 0 in-level). + EnqueueEvent(EventTypeSaveDataMemorySyncEnd, userId, string.Empty); + return ctx.SetReturn(0); } private static int TransferSaveDataMemory(CpuContext ctx, bool write) diff --git a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs new file mode 100644 index 0000000..e8e966e --- /dev/null +++ b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpEmu.Libs.SaveData; + +/// +/// Host-side layout and metadata for PS5 save data. Saves live under +/// ~/SharpEmu/Saves/<titleId>/<dirName>/ (overridable via +/// SHARPEMU_SAVEDATA_DIR); the game's files are written directly inside a +/// slot through the mounted /savedata0 filesystem, and the PS5 UI +/// metadata (title/subtitle/detail/userParam) plus icon live under +/// <slot>/sce_sys/. This type is pure filesystem logic with no guest +/// interop so the path and metadata handling can be unit-tested. +/// +public static class SaveDataStorage +{ + /// Root of all saves: the env override, else ~/SharpEmu/Saves. + public static string Root(string? overrideDir = null) + { + var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR"); + var root = string.IsNullOrWhiteSpace(configured) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "SharpEmu", + "Saves") + : configured; + return Path.GetFullPath(root); + } + + /// Per-title directory: <root>/<titleId>. + public static string TitleRoot(string root, string titleId) => + Path.Combine(root, Sanitize(titleId)); + + /// A single save slot: <titleRoot>/<dirName>. + public static string SlotDir(string titleRoot, string dirName) => + Path.Combine(titleRoot, Sanitize(dirName)); + + /// The SaveDataMemory blob shared by a title. + public static string MemoryPath(string titleRoot) => + Path.Combine(titleRoot, "sce_sdmemory", "memory.dat"); + + public static string ParamPath(string slotDir) => + Path.Combine(slotDir, "sce_sys", "param.json"); + + public static string IconPath(string slotDir) => + Path.Combine(slotDir, "sce_sys", "icon0.png"); + + /// + /// Replaces characters that are invalid in a host path segment. Empty or + /// all-invalid input collapses to "default" so a bad guest name can never + /// escape the save root or produce an empty segment. + /// + public static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "default"; + } + + var invalid = Path.GetInvalidFileNameChars(); + Span buffer = value.Length <= 128 ? stackalloc char[value.Length] : new char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + var ch = value[i]; + buffer[i] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch; + } + + var sanitized = new string(buffer).Trim(); + return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized; + } + + /// Reads a slot's metadata, or defaults if none has been written. + public static SaveDataMetadata ReadMetadata(string slotDir) + { + var path = ParamPath(slotDir); + if (File.Exists(path)) + { + try + { + var parsed = JsonSerializer.Deserialize(File.ReadAllText(path), SaveDataMetadataContext.Default.SaveDataMetadata); + if (parsed is not null) + { + return parsed; + } + } + catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) + { + // Fall through to defaults on a corrupt or unreadable metadata file. + } + } + + return SaveDataMetadata.CreateDefault(Path.GetFileName(slotDir.TrimEnd(Path.DirectorySeparatorChar))); + } + + /// Writes a slot's metadata, creating sce_sys/ as needed. + public static void WriteMetadata(string slotDir, SaveDataMetadata metadata) + { + var path = ParamPath(slotDir); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(metadata, SaveDataMetadataContext.Default.SaveDataMetadata)); + } +} + +/// PS5 save-slot metadata surfaced by sceSaveDataGetParam / the save UI. +public sealed record SaveDataMetadata +{ + [JsonPropertyName("title")] + public string Title { get; init; } = "Saved Data"; + + [JsonPropertyName("subTitle")] + public string SubTitle { get; init; } = string.Empty; + + [JsonPropertyName("detail")] + public string Detail { get; init; } = string.Empty; + + [JsonPropertyName("userParam")] + public uint UserParam { get; init; } + + public static SaveDataMetadata CreateDefault(string dirName) => + new() { Title = string.IsNullOrWhiteSpace(dirName) ? "Saved Data" : dirName }; +} + +[JsonSerializable(typeof(SaveDataMetadata))] +[JsonSourceGenerationOptions(WriteIndented = true)] +internal sealed partial class SaveDataMetadataContext : JsonSerializerContext +{ +} diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj index 9b0babf..7656651 100644 --- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj +++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj @@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + -{ - private readonly object _gate = new(); - private readonly int _maxArrayLength; - private readonly ulong _maxCachedBytes; - private readonly int _maxArraysPerBucket; - private readonly Dictionary> _cachedByBucket = []; - private readonly HashSet _leases = - new(System.Collections.Generic.ReferenceEqualityComparer.Instance); - private ulong _cachedBytes; - - public BoundedByteArrayPool( - int maxArrayLength, - ulong maxCachedBytes, - int maxArraysPerBucket) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength); - ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket); - _maxArrayLength = maxArrayLength; - _maxCachedBytes = maxCachedBytes; - _maxArraysPerBucket = maxArraysPerBucket; - } - - public override byte[] Rent(int minimumLength) - { - ArgumentOutOfRangeException.ThrowIfNegative(minimumLength); - var length = GetAllocationLength(minimumLength); - byte[]? array = null; - lock (_gate) - { - if (length <= _maxArrayLength && - _cachedByBucket.TryGetValue(length, out var bucket) && - bucket.TryPop(out array)) - { - _cachedBytes -= (ulong)array.LongLength; - } - - array ??= new byte[length]; - _leases.Add(array); - } - - return array; - } - - public override void Return(byte[] array, bool clearArray = false) - { - ArgumentNullException.ThrowIfNull(array); - lock (_gate) - { - if (!_leases.Remove(array)) - { - return; - } - } - - if (clearArray) - { - Array.Clear(array); - } - - lock (_gate) - { - if (array.Length > _maxArrayLength || - !IsBucketLength(array.Length) || - (ulong)array.LongLength > _maxCachedBytes - - Math.Min(_cachedBytes, _maxCachedBytes)) - { - return; - } - - if (!_cachedByBucket.TryGetValue(array.Length, out var bucket)) - { - bucket = new Stack(); - _cachedByBucket.Add(array.Length, bucket); - } - - if (bucket.Count >= _maxArraysPerBucket) - { - return; - } - - bucket.Push(array); - _cachedBytes += (ulong)array.LongLength; - } - } - - public void Trim() - { - lock (_gate) - { - _cachedByBucket.Clear(); - _cachedBytes = 0; - } - } - - private int GetAllocationLength(int minimumLength) - { - if (minimumLength <= 16) - { - return 16; - } - - if (minimumLength > _maxArrayLength) - { - return minimumLength; - } - - return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength)); - } - - private static bool IsBucketLength(int length) => - length >= 16 && (length & (length - 1)) == 0; -} diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 71689fe..fd174b0 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -131,9 +131,14 @@ public static class VideoOutExports return; } + // macOS can run either backend (Vulkan through MoltenVK, or Metal), so + // name the active one in the title to make which is in use unambiguous. + var backendSuffix = OperatingSystem.IsMacOS() + ? $" ({GuestGpu.Current.BackendName})" + : string.Empty; lock (_stateGate) { - _windowTitle = $"{_windowTitle} · {gpuName.Trim()}"; + _windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}"; } } @@ -166,11 +171,12 @@ public static class VideoOutExports HostSessionControl.RequestShutdown(reason); // A hosted game can still be issuing AGC work after it requests its - // own shutdown. Keep the Vulkan resources alive until the GUI session - // reaches its guest-safe exit path and disposes the host surface. + // own shutdown. Keep the presenter's resources alive until the GUI + // session reaches its guest-safe exit path and disposes the host + // surface. if (!embedded) { - VulkanVideoPresenter.RequestClose(); + GuestGpu.Current.RequestClose(); } // The embedded GUI owns the process lifetime. A guest shutdown should @@ -1070,6 +1076,12 @@ public static class VideoOutExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } + // SceVideoOutBufferCategory is a 32-bit enum passed on the stack; the + // upper 32 bits of the slot are stale (games leave GNM magic there), so + // mask before validating. UNCOMPRESSED (0) and COMPRESSED (1) are both + // valid — we present either identically, so accept both. + var category = (uint)categoryRaw; + if (!TryGetPort(handle, out var port)) { return OrbisVideoOutErrorInvalidHandle; @@ -1090,7 +1102,7 @@ public static class VideoOutExports return OrbisVideoOutErrorInvalidValue; } - if (categoryRaw != 0 || option != 0) + if (category > 1 || option != 0) { return OrbisVideoOutErrorInvalidValue; } @@ -1209,7 +1221,7 @@ public static class VideoOutExports { TriggerFlipEvents(); } - else if (VulkanVideoPresenter.SubmitOrderedGuestAction( + else if (GuestGpu.Current.SubmitOrderedGuestAction( TriggerFlipEvents, $"videoout flip complete handle={handle} index={bufferIndex}") == 0) { @@ -1263,7 +1275,7 @@ public static class VideoOutExports var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency; var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0); var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0); - var (draws, drawMs, pipelines, spirvCompiles) = VulkanVideoPresenter.ReadAndResetPerfCounters(); + var (draws, drawMs, pipelines, spirvCompiles) = GuestGpu.Current.ReadAndResetPerfCounters(); Console.Error.WriteLine( $"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " + $"presented_fps={presentedCount / elapsedSeconds:F1} " + @@ -1702,7 +1714,7 @@ public static class VideoOutExports SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq; // Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags - // the backend keys its guest-image registry on (see VulkanVideoPresenter. + // the backend keys its guest-image registry on (see the presenter's // GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit). // Unknown formats default to 56 (8-bit RGBA) with a logged warning so games // display something rather than silently failing the flip pipeline. diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 9ceba3c..94af4c0 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -337,15 +337,6 @@ internal static unsafe class VulkanVideoPresenter // thread's physical-device query) gives shader translation and descriptor // creation one stable aliasing contract on every conformant device. internal const ulong GuestStorageBufferOffsetAlignment = 256; - // Guest draw snapshots churn through a small set of 128 KiB-16 MiB size - // classes thousands of times per second. The process-wide shared pool - // trims and repartitions those large arrays aggressively under GC load, - // causing hundreds of MiB/s of replacement byte[] allocations. Keep a - // bounded, non-shared pool for AGC-to-presenter ownership transfers. - internal static BoundedByteArrayPool GuestDataPool { get; } = new( - maxArrayLength: 16 * 1024 * 1024, - maxCachedBytes: 256UL * 1024 * 1024, - maxArraysPerBucket: 8); // The pending queue and per-render drain budget bound how much guest GPU // work can be buffered ahead of the presenter. Draws are batched into // shared command buffers, so draining a large batch per render tick is @@ -1719,17 +1710,6 @@ internal static unsafe class VulkanVideoPresenter private static readonly System.Collections.Concurrent.ConcurrentDictionary< TextureContentIdentity, byte> _cachedTextureIdentities = new(); - internal readonly record struct TextureContentIdentity( - ulong Address, - uint Width, - uint Height, - uint Format, - uint NumberType, - uint DstSelect, - uint TileMode, - uint Pitch, - GuestSampler Sampler); - // Guest memory handle for render-thread self-healing: when a draw whose // texel copy was skipped misses the texture cache (eviction, cache // clear, or any other race), the presenter re-reads the texels itself @@ -2932,6 +2912,7 @@ internal static unsafe class VulkanVideoPresenter public uint InstanceCount = 1; public PrimitiveTopology Topology = PrimitiveTopology.TriangleList; public GuestBlendState[] Blends = [GuestBlendState.Default]; + public GuestBlendConstant BlendConstant; // Vulkan format of this draw's color target. Needed to suppress // blending on formats Metal cannot blend (integer / 32-bit float), // which otherwise makes vkCreateGraphicsPipelines fail and can @@ -5375,7 +5356,7 @@ internal static unsafe class VulkanVideoPresenter { if (buffer.Pooled && returned.Add(buffer.Data)) { - GuestDataPool.Return(buffer.Data); + GuestDataPool.Shared.Return(buffer.Data); } } @@ -5383,14 +5364,14 @@ internal static unsafe class VulkanVideoPresenter { if (buffer.Pooled && returned.Add(buffer.Data)) { - GuestDataPool.Return(buffer.Data); + GuestDataPool.Shared.Return(buffer.Data); } } if (draw.IndexBuffer is { Pooled: true } indexBuffer && returned.Add(indexBuffer.Data)) { - GuestDataPool.Return(indexBuffer.Data); + GuestDataPool.Shared.Return(indexBuffer.Data); } } @@ -5870,6 +5851,7 @@ internal static unsafe class VulkanVideoPresenter InstanceCount = Math.Max(draw.InstanceCount, 1), Topology = GetPrimitiveTopology(draw.PrimitiveType), Blends = draw.RenderState.Blends.ToArray(), + BlendConstant = draw.RenderState.BlendConstant, Scissor = draw.RenderState.Scissor, Viewport = draw.RenderState.Viewport, Raster = draw.RenderState.Raster, @@ -6005,7 +5987,7 @@ internal static unsafe class VulkanVideoPresenter resources.Index32Bit = indexBuffer.Is32Bit; if (indexBuffer.Pooled) { - GuestDataPool.Return(indexBuffer.Data); + GuestDataPool.Shared.Return(indexBuffer.Data); } } @@ -6034,7 +6016,7 @@ internal static unsafe class VulkanVideoPresenter { if (vertex.Pooled && returnedVertexData.Add(vertex.Data)) { - GuestDataPool.Return(vertex.Data); + GuestDataPool.Shared.Return(vertex.Data); } } } @@ -6530,13 +6512,16 @@ internal static unsafe class VulkanVideoPresenter AttachmentCount = (uint)resources.Blends.Length, PAttachments = colorBlendAttachments, }; - var dynamicStateValues = stackalloc DynamicState[2]; + var dynamicStateValues = stackalloc DynamicState[3]; dynamicStateValues[0] = DynamicState.Viewport; dynamicStateValues[1] = DynamicState.Scissor; + // CB_BLEND_RED..ALPHA vary per draw without a pipeline + // identity change, so the constant stays dynamic. + dynamicStateValues[2] = DynamicState.BlendConstants; var dynamicState = new PipelineDynamicStateCreateInfo { SType = StructureType.PipelineDynamicStateCreateInfo, - DynamicStateCount = 2, + DynamicStateCount = 3, PDynamicStates = dynamicStateValues, }; var depth = resources.Depth; @@ -8312,7 +8297,7 @@ internal static unsafe class VulkanVideoPresenter } if (guestBuffer.Pooled) { - GuestDataPool.Return(guestBuffer.Data); + GuestDataPool.Shared.Return(guestBuffer.Data); } return new GlobalBufferResource @@ -8360,7 +8345,7 @@ internal static unsafe class VulkanVideoPresenter { var descriptorSize = checked((guestSize + byteBias + 3) & ~3UL); var descriptorLength = checked((int)descriptorSize); - var snapshot = GuestDataPool.Rent(descriptorLength); + var snapshot = GuestDataPool.Shared.Rent(descriptorLength); try { var snapshotData = snapshot.AsSpan(0, descriptorLength); @@ -8389,10 +8374,10 @@ internal static unsafe class VulkanVideoPresenter } finally { - GuestDataPool.Return(snapshot); + GuestDataPool.Shared.Return(snapshot); if (guestBuffer.Pooled) { - GuestDataPool.Return(guestBuffer.Data); + GuestDataPool.Shared.Return(guestBuffer.Data); } } } @@ -8407,7 +8392,7 @@ internal static unsafe class VulkanVideoPresenter out var mapped); if (guestBuffer.Pooled) { - GuestDataPool.Return(guestBuffer.Data); + GuestDataPool.Shared.Return(guestBuffer.Data); } return new GlobalBufferResource @@ -9921,7 +9906,7 @@ internal static unsafe class VulkanVideoPresenter // into millions of writes for alternating output patterns. const int pageSize = 4096; const int unreadableMergeGap = 16; - var livePageBuffer = GuestDataPool.Rent(pageSize); + var livePageBuffer = GuestDataPool.Shared.Rent(pageSize); var pageRuns = new List<(int Start, int Length)>(64); try { @@ -10091,7 +10076,7 @@ internal static unsafe class VulkanVideoPresenter } finally { - GuestDataPool.Return(livePageBuffer); + GuestDataPool.Shared.Return(livePageBuffer); } var probe = mappedBytes[..Math.Min(mappedBytes.Length, 256)]; @@ -14148,6 +14133,15 @@ internal static unsafe class VulkanVideoPresenter drawViewport.Y += ViewportDebugEpsilon; } _vk.CmdSetViewport(_commandBuffer, 0, 1, &drawViewport); + // CB_BLEND_RED..ALPHA feed the CONSTANT_COLOR/CONSTANT_ALPHA factors. + var blendConstants = stackalloc float[4] + { + resources.BlendConstant.Red, + resources.BlendConstant.Green, + resources.BlendConstant.Blue, + resources.BlendConstant.Alpha, + }; + _vk.CmdSetBlendConstants(_commandBuffer, blendConstants); if (resources.VertexBuffers.Length != 0) { var buffers = stackalloc VkBuffer[resources.VertexBuffers.Length]; @@ -15023,7 +15017,6 @@ internal static unsafe class VulkanVideoPresenter _vk.DestroyInstance(_instance, null); _instance = default; } - GuestDataPool.Trim(); } private void RecreateSwapchainResources(string operation, Result result) diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs new file mode 100644 index 0000000..547db07 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal; + +// MSL-specific shader artifact types. These stay beside the MSL emitter (not in the +// backend-neutral SharpEmu.ShaderCompiler project): each codegen owns its own +// compiled-shader shape, mirroring Gen5SpirvShader on the Vulkan side. +public enum Gen5MslStage +{ + Vertex, + Pixel, + Compute, +} + +/// +/// A translated Metal shader: MSL source text plus the reflection data the Metal +/// backend needs to bind it. Buffer argument indices follow the translation +/// contract documented on : global memory buffers +/// occupy [[buffer(globalBufferBase + i)]] in +/// order, and compute shaders reserve one trailing slot for the dispatch-limit +/// uniform. Unlike SPIR-V, Metal fixes the threadgroup size at dispatch time, so +/// the size the shader was translated for is carried here. +/// +/// +/// is the [[buffer(N)]] slot this stage's +/// SharpEmuUniforms argument was emitted at (globalBufferBase + +/// totalGlobalBufferCount, both translation-time inputs). Stages sharing a draw +/// can disagree — a vertex stage whose guest buffers sit after the pixel +/// stage's has a higher base — so the presenter must bind the uniforms buffer +/// per stage at this exact index rather than assuming one shared slot. +/// Texture slots are global across a draw's stages ([[texture( +/// ImageBindingBase + i)]]). Samplers live in a per-stage argument buffer +/// bound at (Metal caps direct +/// [[sampler(N)]] slots at 16 per stage, but shaders sample more), holding one +/// sampler per sampled image. maps this stage's +/// image binding index to its [[id(N)]] entry in that argument buffer, -1 for +/// storage images that take none; is the entry +/// count. +/// +public sealed record Gen5MslShader( + string Source, + string EntryPoint, + Gen5MslStage Stage, + IReadOnlyList GlobalMemoryBindings, + IReadOnlyList ImageBindings, + uint AttributeCount, + IReadOnlyList VertexInputs, + uint ThreadgroupSizeX = 1, + uint ThreadgroupSizeY = 1, + uint ThreadgroupSizeZ = 1, + int UniformsBufferIndex = -1, + int ImageBindingBase = 0, + IReadOnlyList? SamplerSlots = null, + int SamplerCount = 0, + int SamplerArgBufferIndex = -1); diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs new file mode 100644 index 0000000..0907f20 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs @@ -0,0 +1,1610 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal; + +public static partial class Gen5MslTranslator +{ + private sealed partial class CompilationContext + { + private const string TauLiteral = "6.2831853071795862f"; + + // ---- vector ALU ---- + + private bool TryEmitVectorAlu( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + if (instruction.Opcode == "VNop") + { + return true; + } + + if (instruction.Control is Gen5SdwaControl sdwa && + (sdwa.Source0Select == 7 || + sdwa.Source1Select == 7 || + sdwa.DestinationSelect == 7 || + sdwa.DestinationUnused == 3)) + { + error = $"reserved SDWA selector/modifier in {instruction.Opcode}"; + return false; + } + + if (instruction.Control is Gen5DppControl dppControl && + !IsSupportedDppControl(dppControl.Control)) + { + error = $"unsupported DPP16 control 0x{dppControl.Control:X3}"; + return false; + } + + if (instruction.Opcode.StartsWith("VCmp", StringComparison.Ordinal)) + { + return TryEmitVectorCompare(instruction, out error); + } + + switch (instruction.Opcode) + { + case "VReadfirstlaneB32": + { + if (instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.ScalarRegister || + instruction.Sources.Count == 0) + { + error = "invalid read-first-lane operands"; + return false; + } + + // Under the single-lane graphics model the "first active + // lane" is always this lane; a real simd_shuffle would read + // another fragment's value. Compute broadcasts from the + // first guest-active lane (the ballot of EXEC), matching + // the SPIR-V translator — SPIR-V's own BroadcastFirst uses + // the first host-active invocation, which may be a lane the + // guest has masked off. + var value = RawSource(instruction, 0); + if (IsSingleLaneStage) + { + StoreScalar(instruction.Destinations[0].Value, Temp("uint", value)); + return true; + } + + if (IsWave64) + { + StoreScalar( + instruction.Destinations[0].Value, + EmitWave64ReadFirstLane(value)); + return true; + } + + var mask = Temp("uint", "sharpemu_ballot(exec)"); + var firstLane = Temp("uint", $"{mask} == 0u ? 0u : (uint)ctz({mask})"); + StoreScalar( + instruction.Destinations[0].Value, + Temp("uint", ShuffleLane(value, firstLane))); + return true; + } + case "VReadlaneB32": + { + if (instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.ScalarRegister) + { + error = "VReadlaneB32 expects scalar destination"; + return false; + } + + var value = RawSource(instruction, 0); + var lane = Temp("uint", $"({RawSource(instruction, 1)}) & 31u"); + StoreScalar( + instruction.Destinations[0].Value, + Temp("uint", ShuffleLane(value, lane))); + return true; + } + case "VWritelaneB32": + { + // vdst[lane(src1)] = src0; a writelane lands regardless of EXEC. + var destination = DestinationVector(instruction); + var source = RawSource(instruction, 0); + var lane = RawSource(instruction, 1); + StoreVector( + destination, + $"(sharpemu_lane == (({lane}) & 31u)) ? ({source}) : v[{destination}]", + guardWithExec: false); + return true; + } + case "VCndmaskB32": + { + // dst = mask-bit(lane) ? src1 : src0. Sources are raw (no + // float modifiers), matching the SPIR-V translator; the mask + // is VCC for VOP2 and an explicit SGPR operand for VOP3. + var mask = instruction.Sources.Count > 2 + ? MaskBitExpression(instruction.Sources[2]) + : "vcc"; + StoreVector( + DestinationVector(instruction), + $"({mask}) ? ({RawSource(instruction, 1)}) : ({RawSource(instruction, 0)})"); + return true; + } + } + + return TryEmitVectorValue(instruction, out error); + } + + private bool TryEmitVectorValue( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + var destination = DestinationVector(instruction); + string? expression = instruction.Opcode switch + { + "VMovB32" => RawSource(instruction, 0), + + // ---- float arithmetic ---- + "VAddF32" => FloatResult(instruction, $"{F(instruction, 0)} + {F(instruction, 1)}"), + "VSubF32" => FloatResult(instruction, $"{F(instruction, 0)} - {F(instruction, 1)}"), + "VSubrevF32" => FloatResult(instruction, $"{F(instruction, 1)} - {F(instruction, 0)}"), + "VMulF32" => FloatResult(instruction, $"{F(instruction, 0)} * {F(instruction, 1)}"), + "VMinF32" => FloatResult(instruction, $"fmin({F(instruction, 0)}, {F(instruction, 1)})"), + "VMaxF32" => FloatResult(instruction, $"fmax({F(instruction, 0)}, {F(instruction, 1)})"), + // The decoder normalizes mk/ak literal placement, so every MAD/FMA + // form is fma(src0, src1, src2) exactly like the SPIR-V translator. + "VFmaF32" or "VMadF32" or "VMadAkF32" or "VMadMkF32" or "VFmaAkF32" or "VFmaMkF32" => + FloatResult(instruction, $"fma({F(instruction, 0)}, {F(instruction, 1)}, {F(instruction, 2)})"), + "VFmacF32" or "VMacF32" => + FloatResult(instruction, $"fma({F(instruction, 0)}, {F(instruction, 1)}, as_type(v[{destination}]))"), + "VFloorF32" => FloatResult(instruction, $"floor({F(instruction, 0)})"), + "VCeilF32" => FloatResult(instruction, $"ceil({F(instruction, 0)})"), + "VTruncF32" => FloatResult(instruction, $"trunc({F(instruction, 0)})"), + "VRndneF32" => FloatResult(instruction, $"rint({F(instruction, 0)})"), + "VFractF32" => FloatResult(instruction, $"fract({F(instruction, 0)})"), + "VSqrtF32" => FloatResult(instruction, $"sqrt({F(instruction, 0)})"), + "VRsqF32" => FloatResult(instruction, $"rsqrt({F(instruction, 0)})"), + "VRcpF32" or "VRcpIflagF32" => FloatResult(instruction, $"(1.0f / {F(instruction, 0)})"), + "VLogF32" => FloatResult(instruction, $"log2({F(instruction, 0)})"), + "VExpF32" => FloatResult(instruction, $"exp2({F(instruction, 0)})"), + // GCN sin/cos take revolutions; mirror the SPIR-V Tau prescale. + "VSinF32" => FloatResult(instruction, $"sin({F(instruction, 0)} * {TauLiteral})"), + "VCosF32" => FloatResult(instruction, $"cos({F(instruction, 0)} * {TauLiteral})"), + "VLdexpF32" => + FloatResult(instruction, $"ldexp({F(instruction, 0)}, as_type({RawSource(instruction, 1)}))"), + "VMin3F32" => + FloatResult(instruction, $"fmin(fmin({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)})"), + "VMax3F32" => + FloatResult(instruction, $"fmax(fmax({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)})"), + "VMed3F32" => + FloatResult(instruction, $"fmax(fmin({F(instruction, 0)}, {F(instruction, 1)}), fmin(fmax({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)}))"), + + // ---- conversions ---- + "VCvtF32I32" => FloatResult(instruction, $"(float)as_type({RawSource(instruction, 0)})"), + "VCvtF32U32" => FloatResult(instruction, $"(float)({RawSource(instruction, 0)})"), + "VCvtU32F32" => $"(uint)({F(instruction, 0)})", + "VCvtI32F32" => AsUInt($"(int)({F(instruction, 0)})"), + // RPI rounds toward positive infinity; FLR toward negative. + "VCvtRpiI32F32" => AsUInt($"(int)ceil({F(instruction, 0)})"), + "VCvtFlrI32F32" => AsUInt($"(int)floor({F(instruction, 0)})"), + "VCvtF32Ubyte0" => FloatResult(instruction, $"(float)(({RawSource(instruction, 0)}) & 0xFFu)"), + "VCvtF32Ubyte1" => FloatResult(instruction, $"(float)((({RawSource(instruction, 0)}) >> 8) & 0xFFu)"), + "VCvtF32Ubyte2" => FloatResult(instruction, $"(float)((({RawSource(instruction, 0)}) >> 16) & 0xFFu)"), + "VCvtF32Ubyte3" => FloatResult(instruction, $"(float)((({RawSource(instruction, 0)}) >> 24) & 0xFFu)"), + "VCvtF16F32" => + $"((uint)as_type(half({F(instruction, 0)})))", + "VCvtF32F16" => + AsUInt($"(float)as_type((ushort)(({RawSource(instruction, 0)}) & 0xFFFFu))"), + "VCvtOffF32I4" => + AsUInt($"sharpemu_off_i4_table[({RawSource(instruction, 0)}) & 15u]"), + "VCvtPkU8F32" => + EmitCvtPkU8F32(instruction), + "VCvtPkrtzF16F32" => + EmitCvtPkrtzF16F32(instruction), + "VCvtPknormI16F32" => + $"pack_float_to_snorm2x16(float2({F(instruction, 0)}, {F(instruction, 1)}))", + "VCvtPknormU16F32" => + $"pack_float_to_unorm2x16(float2({F(instruction, 0)}, {F(instruction, 1)}))", + + // ---- integer arithmetic ---- + "VAddU32" or "VAddI32" => + $"(({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)}))", + "VSubU32" or "VSubI32" => + $"(({RawSource(instruction, 0)}) - ({RawSource(instruction, 1)}))", + "VSubrevU32" or "VSubrevI32" => + $"(({RawSource(instruction, 1)}) - ({RawSource(instruction, 0)}))", + // The SPIR-V translator treats the U24 multiply as a full 32-bit + // multiply (only the Hi/Mad forms mask); mirror it exactly. + "VMulLoU32" or "VMulLoI32" or "VMulU32U24" => + $"(({RawSource(instruction, 0)}) * ({RawSource(instruction, 1)}))", + "VMulHiU32" => + $"mulhi({RawSource(instruction, 0)}, {RawSource(instruction, 1)})", + "VMulHiU32U24" => + $"mulhi(({RawSource(instruction, 0)}) & 0xFFFFFFu, ({RawSource(instruction, 1)}) & 0xFFFFFFu)", + "VMulHiI32" => + AsUInt($"mulhi(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)}))"), + "VMadU32U24" => + $"(((({RawSource(instruction, 0)}) & 0xFFFFFFu) * (({RawSource(instruction, 1)}) & 0xFFFFFFu)) + ({RawSource(instruction, 2)}))", + "VMadU32U16" => + $"(((({RawSource(instruction, 0)}) & 0xFFFFu) * (({RawSource(instruction, 1)}) & 0xFFFFu)) + ({RawSource(instruction, 2)}))", + "VAdd3U32" => + $"(({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)}) + ({RawSource(instruction, 2)}))", + "VAddLshlU32" => + $"((({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)})) << (({RawSource(instruction, 2)}) & 31u))", + "VLshlAddU32" => + $"((({RawSource(instruction, 0)}) << (({RawSource(instruction, 1)}) & 31u)) + ({RawSource(instruction, 2)}))", + "VMinU32" => $"min({RawSource(instruction, 0)}, {RawSource(instruction, 1)})", + "VMaxU32" => $"max({RawSource(instruction, 0)}, {RawSource(instruction, 1)})", + "VMinI32" => + AsUInt($"min(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)}))"), + "VMaxI32" => + AsUInt($"max(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)}))"), + "VMin3U32" => + $"min(min({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), {RawSource(instruction, 2)})", + "VMax3U32" => + $"max(max({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), {RawSource(instruction, 2)})", + "VMin3I32" => + AsUInt($"min(min(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), as_type({RawSource(instruction, 2)}))"), + "VMax3I32" => + AsUInt($"max(max(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), as_type({RawSource(instruction, 2)}))"), + "VMed3U32" => + $"max(min({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), min(max({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), {RawSource(instruction, 2)}))", + "VMed3I32" => + AsUInt($"max(min(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), min(max(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), as_type({RawSource(instruction, 2)})))"), + + // ---- bitwise ---- + "VAndB32" => $"(({RawSource(instruction, 0)}) & ({RawSource(instruction, 1)}))", + "VOrB32" => $"(({RawSource(instruction, 0)}) | ({RawSource(instruction, 1)}))", + "VXorB32" => $"(({RawSource(instruction, 0)}) ^ ({RawSource(instruction, 1)}))", + "VXnorB32" => $"~(({RawSource(instruction, 0)}) ^ ({RawSource(instruction, 1)}))", + "VNotB32" => $"~({RawSource(instruction, 0)})", + "VAndOrB32" => + $"((({RawSource(instruction, 0)}) & ({RawSource(instruction, 1)})) | ({RawSource(instruction, 2)}))", + "VOr3U32" => + $"(({RawSource(instruction, 0)}) | ({RawSource(instruction, 1)}) | ({RawSource(instruction, 2)}))", + "VLshlOrU32" => + $"((({RawSource(instruction, 0)}) << (({RawSource(instruction, 1)}) & 31u)) | ({RawSource(instruction, 2)}))", + "VLshlB32" => $"(({RawSource(instruction, 0)}) << (({RawSource(instruction, 1)}) & 31u))", + "VLshlrevB32" => $"(({RawSource(instruction, 1)}) << (({RawSource(instruction, 0)}) & 31u))", + "VLshrB32" => $"(({RawSource(instruction, 0)}) >> (({RawSource(instruction, 1)}) & 31u))", + "VLshrrevB32" => $"(({RawSource(instruction, 1)}) >> (({RawSource(instruction, 0)}) & 31u))", + "VAshrI32" => + AsUInt($"(as_type({RawSource(instruction, 0)}) >> (({RawSource(instruction, 1)}) & 31u))"), + "VAshrrevI32" => + AsUInt($"(as_type({RawSource(instruction, 1)}) >> (({RawSource(instruction, 0)}) & 31u))"), + "VBfeU32" => + $"extract_bits({RawSource(instruction, 0)}, ({RawSource(instruction, 1)}) & 31u, ({RawSource(instruction, 2)}) & 31u)", + "VBfiB32" => + $"((({RawSource(instruction, 0)}) & ({RawSource(instruction, 1)})) | (~({RawSource(instruction, 0)}) & ({RawSource(instruction, 2)})))", + "VBfmB32" => + $"(((1u << (({RawSource(instruction, 0)}) & 31u)) - 1u) << (({RawSource(instruction, 1)}) & 31u))", + "VBfrevB32" => $"reverse_bits({RawSource(instruction, 0)})", + "VBcntU32B32" => $"(popcount({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)}))", + "VFfblB32" => + $"(({RawSource(instruction, 0)}) == 0u ? 0xFFFFFFFFu : (uint)ctz({RawSource(instruction, 0)}))", + + // ---- wave / lane ---- + // mbcnt reads the mask dword the guest passes (no cross-lane + // op), so only the per-lane thread-mask math differs by wave + // size. Wave64 lanes 32..63 count the whole low half in mbcnt_lo + // and their own partial in mbcnt_hi; a 1u << lane for lane>=32 + // would be undefined, so those are split out. + "VMbcntLoU32B32" => IsWave64 + ? $"((sharpemu_lane >= 32u ? popcount({RawSource(instruction, 0)}) : popcount(({RawSource(instruction, 0)}) & ((1u << sharpemu_lane) - 1u))) + ({RawSource(instruction, 1)}))" + : $"(popcount(({RawSource(instruction, 0)}) & ((1u << sharpemu_lane) - 1u)) + ({RawSource(instruction, 1)}))", + "VMbcntHiU32B32" => IsWave64 + ? $"((sharpemu_lane >= 32u ? popcount(({RawSource(instruction, 0)}) & ((1u << (sharpemu_lane - 32u)) - 1u)) : 0u) + ({RawSource(instruction, 1)}))" + // Wave32: the high mask half holds no lanes; pass the addend. + : RawSource(instruction, 1), + "VPermlane16B32" => EmitPermlane16(instruction, exchangeRows: false), + "VPermlanex16B32" => EmitPermlane16(instruction, exchangeRows: true), + + // ---- cube map helpers ---- + "VCubeidF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Id), + "VCubescF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Sc), + "VCubetcF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Tc), + "VCubemaF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Ma), + + _ => null, + }; + + if (expression is null) + { + switch (instruction.Opcode) + { + case "VAddCoU32": + { + var left = Temp("uint", RawSource(instruction, 0)); + var right = Temp("uint", RawSource(instruction, 1)); + var sum = Temp("uint", $"{left} + {right}"); + StoreCarryOut(instruction, $"{sum} < {left}"); + expression = sum; + break; + } + case "VSubCoU32": + case "VSubrevCoU32": + { + var reverse = instruction.Opcode == "VSubrevCoU32"; + var left = Temp("uint", RawSource(instruction, reverse ? 1 : 0)); + var right = Temp("uint", RawSource(instruction, reverse ? 0 : 1)); + StoreCarryOut(instruction, $"{left} < {right}"); + expression = $"({left} - {right})"; + break; + } + case "VAddcU32": + case "VAddCoCiU32": + { + var left = Temp("uint", RawSource(instruction, 0)); + var right = Temp("uint", RawSource(instruction, 1)); + var carryIn = instruction.Sources.Count > 2 + ? MaskBitExpression(instruction.Sources[2]) + : "vcc"; + var partial = Temp("uint", $"{left} + {right}"); + var sum = Temp("uint", $"{partial} + (({carryIn}) ? 1u : 0u)"); + StoreCarryOut(instruction, $"({partial} < {left}) || ({sum} < {partial})"); + expression = sum; + break; + } + case "VSubbU32": + case "VSubbrevU32": + { + var reverse = instruction.Opcode == "VSubbrevU32"; + var left = Temp("uint", RawSource(instruction, reverse ? 1 : 0)); + var right = Temp("uint", RawSource(instruction, reverse ? 0 : 1)); + var borrowIn = instruction.Sources.Count > 2 + ? MaskBitExpression(instruction.Sources[2]) + : "vcc"; + var borrow = Temp("uint", $"({borrowIn}) ? 1u : 0u"); + var partial = Temp("uint", $"{left} - {right}"); + StoreCarryOut(instruction, $"({left} < {right}) || ({partial} < {borrow})"); + expression = $"({partial} - {borrow})"; + break; + } + case "VMadU64U32": + { + // 64-bit product+addend into a VGPR pair, carry to SDST. + var product = Temp( + "ulong", + $"(ulong)({RawSource(instruction, 0)}) * (ulong)({RawSource(instruction, 1)})"); + var addend = Temp("ulong", RawSource64(instruction, 2)); + var wide = Temp("ulong", $"{product} + {addend}"); + StoreCarryOut(instruction, $"{wide} < {addend}"); + StoreVector(destination + 1, $"(uint)({wide} >> 32)"); + expression = $"(uint){wide}"; + break; + } + default: + error = $"unsupported vector opcode {instruction.Opcode}"; + return false; + } + } + + var result = Temp("uint", expression); + if (instruction.Control is Gen5DppControl dpp) + { + var writeEnabled = EmitDppWriteEnabled(dpp); + result = Temp("uint", $"({writeEnabled}) ? {result} : v[{destination}]"); + } + + if (instruction.Control is Gen5SdwaControl { ScalarDestination: null } sdwaDestination) + { + result = ApplySdwaDestination(sdwaDestination, result, $"v[{destination}]"); + } + + StoreVector(destination, result); + return true; + } + + private string EmitCvtPkU8F32(Gen5ShaderInstruction instruction) + { + var converted = Temp("uint", $"(uint)({F(instruction, 0)})"); + var offset = Temp("uint", $"(({RawSource(instruction, 1)}) & 3u) << 3"); + var baseValue = Temp("uint", RawSource(instruction, 2)); + return $"(({baseValue} & ~(0xFFu << {offset})) | (({converted} & 0xFFu) << {offset}))"; + } + + private string EmitCvtPkrtzF16F32(Gen5ShaderInstruction instruction) + { + // Round-to-zero via mantissa truncation before the half conversion, + // mirroring the SPIR-V translator's TruncateFloat32ForPack. + var first = Temp( + "float", + $"as_type(as_type({F(instruction, 0)}) & 0xFFFFE000u)"); + var second = Temp( + "float", + $"as_type(as_type({F(instruction, 1)}) & 0xFFFFE000u)"); + return $"(((uint)as_type(half({first}))) | (((uint)as_type(half({second}))) << 16))"; + } + + // ---- DPP / SDWA machinery ---- + + private static bool IsSupportedDppControl(uint control) => + control <= 0xFF || + control is >= 0x101 and <= 0x10F or + >= 0x111 and <= 0x11F or + >= 0x121 and <= 0x12F or + 0x140 or 0x141 or + >= 0x150 and <= 0x15F or + >= 0x160 and <= 0x16F; + + /// Target lane + in-range flag for a DPP16 control. + private (string TargetLane, string InRange) EmitDppSourceLane(Gen5DppControl control) + { + var dpp = control.Control; + if (dpp <= 0xFF) + { + // Quad permute: two selector bits per lane-in-quad. + var selected = Temp( + "uint", + $"({dpp}u >> ((sharpemu_lane & 3u) * 2u)) & 3u"); + return (Temp("uint", $"(sharpemu_lane & 0xFFFFFFFCu) + {selected}"), "true"); + } + + if (dpp is >= 0x101 and <= 0x10F) + { + // row_shl + var shifted = Temp("uint", $"(sharpemu_lane & 15u) + {dpp & 15}u"); + var inRange = Temp("bool", $"{shifted} < 16u"); + return (Temp("uint", $"(sharpemu_lane & 0xFFFFFFF0u) + ({shifted} & 15u)"), inRange); + } + + if (dpp is >= 0x111 and <= 0x11F) + { + // row_shr + var inRange = Temp("bool", $"(sharpemu_lane & 15u) >= {dpp & 15}u"); + return ( + Temp("uint", $"(sharpemu_lane & 0xFFFFFFF0u) + (((sharpemu_lane & 15u) - {dpp & 15}u) & 15u)"), + inRange); + } + + if (dpp is >= 0x121 and <= 0x12F) + { + // row_ror + return ( + Temp("uint", $"(sharpemu_lane & 0xFFFFFFF0u) + (((sharpemu_lane & 15u) - {dpp & 15}u) & 15u)"), + "true"); + } + + var target = dpp switch + { + 0x140 => "(sharpemu_lane & 0xFFFFFFF0u) + (15u - (sharpemu_lane & 15u))", + 0x141 => "(sharpemu_lane & 0xFFFFFFF8u) + (7u - (sharpemu_lane & 7u))", + >= 0x150 and <= 0x15F => $"(sharpemu_lane & 0xFFFFFFF0u) + {dpp & 15}u", + >= 0x160 and <= 0x16F => $"(sharpemu_lane & 0xFFFFFFF0u) + ((sharpemu_lane & 15u) ^ {dpp & 15}u)", + _ => "sharpemu_lane", + }; + return (Temp("uint", target), "true"); + } + + // Under the single-lane graphics model every shuffle-select resolves + // to the lane's own value (the register conceptually holds this + // thread's value in every lane); compute lanes are real simdgroup + // threads and shuffle for real. Mirrors the SPIR-V translator's + // no-subgroup fallback for graphics stages. + private bool IsSingleLaneStage => _stage != Gen5MslStage.Compute; + + private string ShuffleLane(string value, string targetLane) => + IsSingleLaneStage ? value : $"simd_shuffle({value}, (ushort){targetLane})"; + + private string LaneActiveExpression(string targetLane) => + IsSingleLaneStage ? "exec" : $"simd_shuffle(exec ? 1u : 0u, (ushort){targetLane}) != 0u"; + + private string ApplyDppSource(Gen5DppControl control, string value) + { + var stored = Temp("uint", value); + var (targetLane, inRange) = EmitDppSourceLane(control); + var safeTarget = Temp("uint", $"(({inRange}) ? {targetLane} : sharpemu_lane) & 31u"); + var shuffled = Temp("uint", ShuffleLane(stored, safeTarget)); + if (control.FetchInactive) + { + return shuffled; + } + + var sourceActive = Temp("bool", LaneActiveExpression(safeTarget)); + return Temp("uint", $"(({inRange}) && {sourceActive}) ? {shuffled} : 0u"); + } + + private string ApplyDpp8Source(Gen5Dpp8Control control, string value) + { + var stored = Temp("uint", value); + var selector = Temp( + "uint", + $"({control.LaneSelectors}u >> ((sharpemu_lane & 7u) * 3u)) & 7u"); + var targetLane = Temp("uint", $"((sharpemu_lane & 0xFFFFFFF8u) + {selector}) & 31u"); + var shuffled = Temp("uint", ShuffleLane(stored, targetLane)); + if (control.FetchInactive) + { + return shuffled; + } + + var sourceActive = Temp("bool", LaneActiveExpression(targetLane)); + return Temp("uint", $"{sourceActive} ? {shuffled} : 0u"); + } + + private string EmitDppWriteEnabled(Gen5DppControl control) + { + var (_, inRange) = EmitDppSourceLane(control); + var rowEnabled = $"(({control.RowMask}u >> (sharpemu_lane >> 4)) & 1u) != 0u"; + var bankEnabled = $"(({control.BankMask}u >> (sharpemu_lane & 3u)) & 1u) != 0u"; + var sourceAllows = control.BoundControl ? "true" : inRange; + return Temp("bool", $"({rowEnabled}) && ({bankEnabled}) && ({sourceAllows})"); + } + + private string ApplySdwaDestination( + Gen5SdwaControl control, + string value, + string previous) + { + var (shift, width) = control.DestinationSelect switch + { + 0 => (0u, 8u), + 1 => (8u, 8u), + 2 => (16u, 8u), + 3 => (24u, 8u), + 4 => (0u, 16u), + 5 => (16u, 16u), + _ => (0u, 32u), + }; + if (width == 32) + { + return value; + } + + var lowMask = width == 8 ? 0xFFu : 0xFFFFu; + var fieldMask = lowMask << (int)shift; + var upperStart = shift + width; + var upperMask = upperStart == 32 ? 0u : uint.MaxValue << (int)upperStart; + var positioned = Temp("uint", $"(({value}) & 0x{lowMask:X}u) << {shift}"); + return control.DestinationUnused switch + { + // 0: unused bits zeroed. 1: sign-extend upward. 2: preserve. + 0 => positioned, + 1 => Temp( + "uint", + $"{positioned} | ((({positioned} & 0x{1u << (int)(shift + width - 1):X}u) != 0u) ? 0x{upperMask:X}u : 0u)"), + 2 => Temp("uint", $"(({previous}) & 0x{~fieldMask:X}u) | {positioned}"), + _ => throw new InvalidOperationException("reserved SDWA destination-unused mode"), + }; + } + + // ---- compares ---- + + private bool TryEmitVectorCompare( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + var opcode = instruction.Opcode; + string condition; + if (opcode is "VCmpClassF32" or "VCmpxClassF32") + { + condition = EmitCompareClass(instruction); + } + else if (opcode is "VCmpTruF32" or "VCmpxTruF32" or "VCmpTI32" or "VCmpTU32") + { + condition = "true"; + } + else if (opcode is "VCmpFF32" or "VCmpxFF32" or "VCmpFI32" or "VCmpFU32") + { + condition = "false"; + } + else if (opcode is "VCmpOF32" or "VCmpxOF32") + { + condition = $"(!isnan({F(instruction, 0)}) && !isnan({F(instruction, 1)}))"; + } + else if (opcode is "VCmpUF32" or "VCmpxUF32") + { + condition = $"(isnan({F(instruction, 0)}) || isnan({F(instruction, 1)}))"; + } + else if (opcode.EndsWith("F32", StringComparison.Ordinal)) + { + // Ordered compares are the plain C operators (false on NaN); + // the Nxx forms are their unordered negations (true on NaN). + var (op, unordered) = TrimCompare(opcode) switch + { + "Lt" => ("<", false), + "Eq" => ("==", false), + "Le" => ("<=", false), + "Gt" => (">", false), + "Lg" => ("!=", false), + "Ge" => (">=", false), + "Neq" => ("==", true), + "Nlt" => ("<", true), + "Nle" => ("<=", true), + "Ngt" => (">", true), + "Nge" => (">=", true), + "Nlg" => ("!=", true), + _ => (string.Empty, false), + }; + if (op.Length == 0) + { + error = $"unsupported float compare {opcode}"; + return false; + } + + var comparison = $"({F(instruction, 0)} {op} {F(instruction, 1)})"; + condition = unordered ? $"(!{comparison})" : comparison; + } + else + { + var signed = opcode.EndsWith("I32", StringComparison.Ordinal); + var op = TrimCompare(opcode) switch + { + "Eq" => "==", + "Ne" => "!=", + "Lt" => "<", + "Le" => "<=", + "Gt" => ">", + "Ge" => ">=", + _ => string.Empty, + }; + if (op.Length == 0) + { + error = $"unsupported integer compare {opcode}"; + return false; + } + + condition = signed + ? $"(as_type({RawSource(instruction, 0)}) {op} as_type({RawSource(instruction, 1)}))" + : $"(({RawSource(instruction, 0)}) {op} ({RawSource(instruction, 1)}))"; + } + + // Only EXEC-enabled lanes can pass; balloting the raw condition + // would leak results from disabled lanes into saveexec/branches. + var active = Temp("bool", $"exec && {condition}"); + if (instruction.Control is Gen5DppControl compareDpp) + { + var writeEnabled = EmitDppWriteEnabled(compareDpp); + active = Temp("bool", $"({writeEnabled}) ? {active} : vcc"); + } + + if (opcode.StartsWith("VCmpx", StringComparison.Ordinal)) + { + // GFX10 VCMPX writes EXEC only. + Line($"exec = {active};"); + EmitBallotStore(ExecLoRegister, "exec"); + } + else + { + var target = instruction.Control is Gen5SdwaControl + { ScalarDestination: { } scalarDestination } + ? scalarDestination + : VccLoRegister; + StoreMaskBit(target, active); + } + + return true; + } + + private string EmitCompareClass(Gen5ShaderInstruction instruction) + { + var source = Temp("float", F(instruction, 0)); + var raw = Temp("uint", RawSource(instruction, 0)); + var mask = Temp("uint", RawSource(instruction, 1)); + var negative = Temp("bool", $"({raw} & 0x80000000u) != 0u"); + var nan = Temp("bool", $"isnan({source})"); + var infinite = Temp("bool", $"isinf({source})"); + var zero = Temp("bool", $"{source} == 0.0f"); + var subnormal = Temp( + "bool", + $"fabs({source}) > 0.0f && fabs({source}) < as_type(0x00800000u)"); + var normal = Temp( + "bool", + $"!({nan} || {infinite} || {zero} || {subnormal})"); + // Class bits: 0 sNaN, 1 qNaN, 2 -inf, 3 -normal, 4 -subnormal, + // 5 -zero, 6 +zero, 7 +subnormal, 8 +normal, 9 +inf. + return Temp( + "bool", + $"((({mask} & 3u) != 0u) && {nan}) || " + + $"((({mask} >> 2) & 1u) != 0u && {infinite} && {negative}) || " + + $"((({mask} >> 3) & 1u) != 0u && {normal} && {negative}) || " + + $"((({mask} >> 4) & 1u) != 0u && {subnormal} && {negative}) || " + + $"((({mask} >> 5) & 1u) != 0u && {zero} && {negative}) || " + + $"((({mask} >> 6) & 1u) != 0u && {zero} && !{negative}) || " + + $"((({mask} >> 7) & 1u) != 0u && {subnormal} && !{negative}) || " + + $"((({mask} >> 8) & 1u) != 0u && {normal} && !{negative}) || " + + $"((({mask} >> 9) & 1u) != 0u && {infinite} && !{negative})"); + } + + private static string TrimCompare(string opcode) + { + var trimmed = opcode.StartsWith("VCmpx", StringComparison.Ordinal) + ? opcode["VCmpx".Length..] + : opcode["VCmp".Length..]; + return trimmed[..^3]; + } + + private void StoreCarryOut(Gen5ShaderInstruction instruction, string carryCondition) + { + var active = Temp("bool", $"exec && ({carryCondition})"); + var target = instruction.Control is Gen5Vop3Control { ScalarDestination: { } register } + ? register + : VccLoRegister; + StoreMaskBit(target, active); + } + + /// + /// Writes this lane's bit of a wave mask: VCC/EXEC update the per-lane + /// bool and mirror the ballot into their architectural SGPRs; a plain + /// SGPR receives the ballot of the per-lane condition. + /// + private void StoreMaskBit(uint register, string condition) + { + switch (register) + { + case VccLoRegister: + Line($"vcc = {condition};"); + EmitBallotStore(VccLoRegister, "vcc"); + return; + case ExecLoRegister: + Line($"exec = {condition};"); + EmitBallotStore(ExecLoRegister, "exec"); + return; + default: + if (register < ScalarRegisterFileCount) + { + EmitBallotStore(register, condition); + } + + return; + } + } + + /// Broadcasts from the first guest-active + /// lane (lowest set bit of the 64-lane EXEC mask) to all lanes, through the + /// threadgroup broadcast slot — mirroring the SPIR-V translator's + /// BroadcastFirstWave64Active. Returns the temp holding the result. + private string EmitWave64ReadFirstLane(string value) + { + Line("if (sharpemu_lane == 0u) { sharpemu_wave_scratch[2] = 0u; }"); + // 64-lane EXEC mask across both halves (slots 0/1), broadcast in 2. + Line("sharpemu_wave_scratch[(sharpemu_lane >> 5) & 1u] = sharpemu_ballot(exec);"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + var lo = Temp("uint", "sharpemu_wave_scratch[0]"); + var hi = Temp("uint", "sharpemu_wave_scratch[1]"); + var first = Temp( + "uint", + $"({lo} != 0u) ? (uint)ctz({lo}) : (({hi} != 0u) ? (32u + (uint)ctz({hi})) : 0u)"); + var anyActive = Temp("bool", $"(({lo}) | ({hi})) != 0u"); + Line($"if ({anyActive} && sharpemu_lane == {first}) {{ sharpemu_wave_scratch[2] = {value}; }}"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + var result = Temp("uint", "sharpemu_wave_scratch[2]"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + return result; + } + + /// Stores the wave ballot of into the + /// mask register pair (low, low+1). Wave32 fills the low dword and clears + /// the high; wave64 bridges both 32-wide halves through threadgroup + /// scratch so the pair holds the full 64-lane mask. The bridging barriers + /// are safe because the guest program's scalar PC keeps all 64 lanes in + /// lockstep through the dispatcher (one wave per threadgroup). + private void EmitBallotStore(uint loRegister, string condition) + { + var hiRegister = loRegister + 1; + if (!IsWave64) + { + Line($"s[{loRegister}] = sharpemu_ballot({condition});"); + if (hiRegister < ScalarRegisterFileCount) + { + Line($"s[{hiRegister}] = 0u;"); + } + + return; + } + + // simd_ballot is uniform across a simdgroup, so every lane of a half + // writes the same 32-bit value to that half's slot — no first-lane + // guard needed. Barrier, read both halves, barrier before the slot + // can be reused by the next ballot. + Line($"sharpemu_wave_scratch[(sharpemu_lane >> 5) & 1u] = sharpemu_ballot({condition});"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + Line($"s[{loRegister}] = sharpemu_wave_scratch[0];"); + if (hiRegister < ScalarRegisterFileCount) + { + Line($"s[{hiRegister}] = sharpemu_wave_scratch[1];"); + } + + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + } + + // ---- permlane / cube ---- + + private string EmitPermlane16(Gen5ShaderInstruction instruction, bool exchangeRows) + { + if (instruction.Control is not Gen5Vop3Control control || + (control.OperandSelect & ~3u) != 0 || + control.AbsoluteMask != 0 || + control.NegateMask != 0 || + control.OutputModifier != 0 || + control.Clamp) + { + throw new NotSupportedException( + $"invalid permlane modifiers for {instruction.Opcode}"); + } + + var value = Temp("uint", RawSource(instruction, 0)); + var selectorLow = Temp("uint", RawSource(instruction, 1)); + var selectorHigh = Temp("uint", RawSource(instruction, 2)); + var localLane = Temp("uint", "sharpemu_lane & 15u"); + var selector = Temp( + "uint", + $"({localLane} < 8u ? ({selectorLow} >> ({localLane} << 2)) : ({selectorHigh} >> (({localLane} - 8u) << 2))) & 15u"); + var rowBase = exchangeRows + ? "((sharpemu_lane & 0xFFFFFFF0u) ^ 16u)" + : "(sharpemu_lane & 0xFFFFFFF0u)"; + var targetLane = Temp("uint", $"({rowBase} + {selector}) & 31u"); + var shuffled = Temp("uint", ShuffleLane(value, targetLane)); + var fetchInactive = (control.OperandSelect & 1) != 0; + if (fetchInactive) + { + return shuffled; + } + + var sourceActive = Temp("bool", LaneActiveExpression(targetLane)); + return Temp("uint", $"{sourceActive} ? {shuffled} : 0u"); + } + + private enum CubeCoordinate + { + Id, + Sc, + Tc, + Ma, + } + + private string EmitCubeCoordinate( + Gen5ShaderInstruction instruction, + CubeCoordinate coordinate) + { + var x = Temp("float", F(instruction, 0)); + var y = Temp("float", F(instruction, 1)); + var z = Temp("float", F(instruction, 2)); + var amaxXY = Temp("float", $"fmax(fabs({x}), fabs({y}))"); + var amax = Temp("float", $"fmax(fabs({z}), {amaxXY})"); + if (coordinate == CubeCoordinate.Ma) + { + return FloatResult(instruction, $"2.0f * {amax}"); + } + + var isZMax = Temp("bool", $"fabs({z}) >= {amaxXY}"); + var yGeX = Temp("bool", $"fabs({y}) >= fabs({x})"); + var isYMax = Temp("bool", $"!{isZMax} && {yGeX}"); + switch (coordinate) + { + case CubeCoordinate.Id: + { + var zCase = $"({z} < 0.0f ? 5.0f : 4.0f)"; + var yCase = $"({y} < 0.0f ? 3.0f : 2.0f)"; + var xCase = $"({x} < 0.0f ? 1.0f : 0.0f)"; + return FloatResult( + instruction, + $"({isZMax} ? {zCase} : ({yGeX} ? {yCase} : {xCase}))"); + } + case CubeCoordinate.Sc: + { + var zCase = $"({z} < 0.0f ? (-{x}) : {x})"; + var xCase = $"({x} < 0.0f ? {z} : (-{z}))"; + return FloatResult( + instruction, + $"({isZMax} ? {zCase} : ({isYMax} ? {x} : {xCase}))"); + } + default: + { + var yCase = $"({y} < 0.0f ? (-{z}) : {z})"; + return FloatResult( + instruction, + $"({isYMax} ? {yCase} : (-{y}))"); + } + } + } + + // ---- scalar ALU ---- + + private bool TryEmitScalarAlu( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + if (instruction.Encoding == Gen5ShaderEncoding.Sopc) + { + return TryEmitScalarCompare(instruction, out error); + } + + if (instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.ScalarRegister) + { + error = "missing scalar destination"; + return false; + } + + var destination = instruction.Destinations[0].Value; + if (instruction.Encoding == Gen5ShaderEncoding.Sopk) + { + var immediate = unchecked((uint)(short)(instruction.Words[0] & 0xFFFF)); + if (instruction.Opcode.StartsWith("SCmpk", StringComparison.Ordinal)) + { + return TryEmitScalarCompareK(instruction, destination, immediate, out error); + } + + var value = instruction.Opcode switch + { + "SMovkI32" => FormatUInt(immediate), + "SAddkI32" => $"({ScalarExpression(destination)} + {FormatUInt(immediate)})", + "SMulkI32" => $"({ScalarExpression(destination)} * {FormatUInt(immediate)})", + _ => string.Empty, + }; + if (value.Length == 0) + { + error = $"unsupported scalar immediate {instruction.Opcode}"; + return false; + } + + StoreScalar(destination, Temp("uint", value)); + return true; + } + + if (instruction.Opcode == "SGetpcB64") + { + var pc = _state.Program.Address + + instruction.Pc + + (ulong)(instruction.Words.Count * sizeof(uint)); + StoreScalar(destination, FormatUInt((uint)pc)); + StoreScalar(destination + 1, FormatUInt((uint)(pc >> 32))); + return true; + } + + if (instruction.Opcode.EndsWith("B64", StringComparison.Ordinal) || + instruction.Opcode is "SBfeU64" or "SBfeI64") + { + return TryEmitScalar64(instruction, destination, out error); + } + + var left = Temp("uint", RawSource(instruction, 0)); + if (instruction.Opcode.EndsWith("SaveexecB32", StringComparison.Ordinal)) + { + var oldExec = Temp("uint", $"s[{ExecLoRegister}]"); + var operation = instruction.Opcode[1..instruction.Opcode.IndexOf( + "Saveexec", + StringComparison.Ordinal)]; + var combined = operation switch + { + "And" => $"({left} & {oldExec})", + "Or" => $"({left} | {oldExec})", + "Xor" => $"({left} ^ {oldExec})", + "Nand" => $"~({left} & {oldExec})", + "Nor" => $"~({left} | {oldExec})", + "Xnor" => $"~({left} ^ {oldExec})", + "Andn1" => $"(~{left} & {oldExec})", + "Andn2" => $"({left} & ~{oldExec})", + "Orn1" => $"(~{left} | {oldExec})", + "Orn2" => $"({left} | ~{oldExec})", + _ => string.Empty, + }; + if (combined.Length == 0) + { + error = $"unsupported scalar 32-bit saveexec opcode {instruction.Opcode}"; + return false; + } + + var mask = Temp("uint", combined); + StoreScalar(destination, oldExec); + Line($"s[{ExecLoRegister}] = {mask};"); + Line($"s[{ExecHiRegister}] = 0u;"); + Line($"exec = (({mask} >> sharpemu_lane) & 1u) != 0u;"); + Line($"scc = {mask} != 0u;"); + return true; + } + + switch (instruction.Opcode) + { + case "SMovB32": + StoreScalar(destination, left); + return true; + case "SNotB32": + { + var result = Temp("uint", $"~{left}"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SBrevB32": + { + var result = Temp("uint", $"reverse_bits({left})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SBcnt1I32B32": + { + var result = Temp("uint", $"popcount({left})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SFF1I32B32": + { + var result = Temp( + "uint", + $"{left} == 0u ? 0xFFFFFFFFu : (uint)ctz({left})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SBitset1B32": + StoreScalar( + destination, + $"{ScalarExpression(destination)} | (1u << ({left} & 31u))"); + return true; + } + + if (instruction.Sources.Count < 2) + { + error = $"missing scalar source for {instruction.Opcode}"; + return false; + } + + var right = Temp("uint", RawSource(instruction, 1)); + string resultExpression; + string sccStatement; + switch (instruction.Opcode) + { + case "SAddU32": + resultExpression = $"({left} + {right})"; + sccStatement = "RESULT < " + left; + break; + case "SSubU32": + resultExpression = $"({left} - {right})"; + sccStatement = $"{right} > {left}"; + break; + case "SAddI32": + resultExpression = $"({left} + {right})"; + sccStatement = $"((~({left} ^ {right}) & ({left} ^ RESULT)) >> 31) != 0u"; + break; + case "SSubI32": + resultExpression = $"({left} - {right})"; + sccStatement = $"(((({left} ^ {right})) & ({left} ^ RESULT)) >> 31) != 0u"; + break; + case "SAddcU32": + { + var partial = Temp("uint", $"{left} + {right}"); + var sum = Temp("uint", $"{partial} + (scc ? 1u : 0u)"); + Line($"scc = ({partial} < {left}) || ({sum} < {partial});"); + StoreScalar(destination, sum); + return true; + } + case "SSubbU32": + { + var borrow = Temp("uint", "scc ? 1u : 0u"); + var partial = Temp("uint", $"{left} - {right}"); + var difference = Temp("uint", $"{partial} - {borrow}"); + Line($"scc = ({right} > {left}) || (({borrow} == 1u) && ({right} == {left}));"); + StoreScalar(destination, difference); + return true; + } + case "SMulI32": + resultExpression = $"({left} * {right})"; + sccStatement = string.Empty; + break; + case "SMulHiU32": + resultExpression = $"mulhi({left}, {right})"; + sccStatement = string.Empty; + break; + case "SAndB32": + resultExpression = $"({left} & {right})"; + sccStatement = "NONZERO"; + break; + case "SOrB32": + resultExpression = $"({left} | {right})"; + sccStatement = "NONZERO"; + break; + case "SXorB32": + resultExpression = $"({left} ^ {right})"; + sccStatement = "NONZERO"; + break; + case "SNandB32": + resultExpression = $"~({left} & {right})"; + sccStatement = "NONZERO"; + break; + case "SNorB32": + resultExpression = $"~({left} | {right})"; + sccStatement = "NONZERO"; + break; + case "SXnorB32": + resultExpression = $"~({left} ^ {right})"; + sccStatement = "NONZERO"; + break; + case "SAndn2B32": + resultExpression = $"({left} & ~{right})"; + sccStatement = "NONZERO"; + break; + case "SOrn2B32": + resultExpression = $"({left} | ~{right})"; + sccStatement = "NONZERO"; + break; + case "SLshlB32": + resultExpression = $"({left} << ({right} & 31u))"; + sccStatement = "NONZERO"; + break; + case "SLshrB32": + resultExpression = $"({left} >> ({right} & 31u))"; + sccStatement = "NONZERO"; + break; + case "SAshrI32": + resultExpression = $"(uint)(as_type({left}) >> ({right} & 31u))"; + sccStatement = "NONZERO"; + break; + case "SBfmB32": + resultExpression = $"(((1u << ({left} & 31u)) - 1u) << ({right} & 31u))"; + sccStatement = string.Empty; + break; + case "SBfeU32": + case "SBfeI32": + { + // Width clamps to the bits remaining above the offset. + var offset = Temp("uint", $"{right} & 31u"); + var width = Temp( + "uint", + $"min(({right} >> 16) & 0x7Fu, 32u - {offset})"); + var result = instruction.Opcode == "SBfeI32" + ? Temp( + "uint", + $"{width} == 0u ? 0u : (uint)extract_bits(as_type({left}), {offset}, {width})") + : Temp( + "uint", + $"{width} == 0u ? 0u : extract_bits({left}, {offset}, {width})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SCselectB32": + resultExpression = $"(scc ? {left} : {right})"; + sccStatement = string.Empty; + break; + case "SMinU32": + resultExpression = $"min({left}, {right})"; + sccStatement = $"{left} < {right}"; + break; + case "SMaxU32": + resultExpression = $"max({left}, {right})"; + sccStatement = $"{left} > {right}"; + break; + case "SMinI32": + resultExpression = $"(uint)min(as_type({left}), as_type({right}))"; + sccStatement = $"as_type({left}) < as_type({right})"; + break; + case "SMaxI32": + resultExpression = $"(uint)max(as_type({left}), as_type({right}))"; + sccStatement = $"as_type({left}) > as_type({right})"; + break; + case "SLshl1AddU32": + case "SLshl2AddU32": + case "SLshl3AddU32": + case "SLshl4AddU32": + { + var shift = (uint)(instruction.Opcode[5] - '0'); + resultExpression = $"(({left} << {shift}) + {right})"; + sccStatement = string.Empty; + break; + } + case "SPackLlB32B16": + resultExpression = $"(({left} & 0xFFFFu) | ({right} << 16))"; + sccStatement = string.Empty; + break; + case "SPackLhB32B16": + resultExpression = $"(({left} & 0xFFFFu) | ({right} & 0xFFFF0000u))"; + sccStatement = string.Empty; + break; + case "SPackHhB32B16": + resultExpression = $"(({left} >> 16) | ({right} & 0xFFFF0000u))"; + sccStatement = string.Empty; + break; + default: + error = $"unsupported scalar opcode {instruction.Opcode}"; + return false; + } + + var value2 = Temp("uint", resultExpression); + StoreScalar(destination, value2); + if (sccStatement == "NONZERO") + { + Line($"scc = {value2} != 0u;"); + } + else if (sccStatement.Length != 0) + { + Line($"scc = {sccStatement.Replace("RESULT", value2)};"); + } + + return true; + } + + private bool TryEmitScalarCompare( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + if (instruction.Sources.Count < 2) + { + error = "missing scalar compare source"; + return false; + } + + var left = Temp("uint", RawSource(instruction, 0)); + var right = Temp("uint", RawSource(instruction, 1)); + if (instruction.Opcode is "SBitcmp0B32" or "SBitcmp1B32") + { + var isSet = $"(({left} >> ({right} & 31u)) & 1u) != 0u"; + Line(instruction.Opcode == "SBitcmp1B32" + ? $"scc = {isSet};" + : $"scc = !({isSet});"); + return true; + } + + return TryEmitScalarCompareCore(instruction.Opcode, "SCmp", left, right, out error); + } + + private bool TryEmitScalarCompareK( + Gen5ShaderInstruction instruction, + uint destination, + uint immediate, + out string error) => + TryEmitScalarCompareCore( + instruction.Opcode, + "SCmpk", + ScalarExpression(destination), + FormatUInt(immediate), + out error); + + private bool TryEmitScalarCompareCore( + string opcode, + string prefix, + string left, + string right, + out string error) + { + error = string.Empty; + var suffix = opcode[prefix.Length..]; + var signed = suffix.EndsWith("I32", StringComparison.Ordinal); + var op = suffix[..^3] switch + { + "Eq" => "==", + "Lg" => "!=", + "Gt" => ">", + "Ge" => ">=", + "Lt" => "<", + "Le" => "<=", + _ => string.Empty, + }; + if (op.Length == 0) + { + error = $"unsupported scalar compare {opcode}"; + return false; + } + + Line(signed + ? $"scc = as_type({left}) {op} as_type({right});" + : $"scc = ({left}) {op} ({right});"); + return true; + } + + // ---- 64-bit scalar ops over register pairs ---- + + private bool TryEmitScalar64( + Gen5ShaderInstruction instruction, + uint destination, + out string error) + { + error = string.Empty; + var left = Temp("ulong", RawSource64(instruction, 0)); + if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal)) + { + var oldExec = Temp("ulong", Scalar64Expression(ExecLoRegister)); + var operation = instruction.Opcode[1..instruction.Opcode.IndexOf( + "Saveexec", + StringComparison.Ordinal)]; + var combined = operation switch + { + "And" => $"({left} & {oldExec})", + "Or" => $"({left} | {oldExec})", + "Xor" => $"({left} ^ {oldExec})", + "Nand" => $"~({left} & {oldExec})", + "Nor" => $"~({left} | {oldExec})", + "Xnor" => $"~({left} ^ {oldExec})", + "Andn1" => $"(~{left} & {oldExec})", + "Andn2" => $"({left} & ~{oldExec})", + "Orn1" => $"(~{left} | {oldExec})", + "Orn2" => $"({left} | ~{oldExec})", + _ => string.Empty, + }; + if (combined.Length == 0) + { + error = $"unsupported scalar 64-bit saveexec opcode {instruction.Opcode}"; + return false; + } + + var mask = Temp("ulong", combined); + StoreScalar64(destination, oldExec); + Line($"s[{ExecLoRegister}] = (uint){mask};"); + Line($"s[{ExecHiRegister}] = (uint)({mask} >> 32);"); + Line($"exec = ((((uint){mask}) >> sharpemu_lane) & 1u) != 0u;"); + Line($"scc = {mask} != 0ul;"); + return true; + } + + string value; + var setsScc = true; + switch (instruction.Opcode) + { + case "SMovB64": + value = left; + setsScc = false; + break; + case "SNotB64": + value = $"~{left}"; + break; + case "SWqmB64": + { + // Whole-quad mode: each 4-lane group becomes all-ones if any + // of its bits is set. + var quadAny = Temp( + "ulong", + $"({left} | ({left} >> 1) | ({left} >> 2) | ({left} >> 3)) & 0x1111111111111111ul"); + value = $"({quadAny} * 0xFul)"; + break; + } + case "SLshlB64" or "SLshrB64": + { + var shift = Temp("uint", $"({RawSource(instruction, 1)}) & 63u"); + value = instruction.Opcode == "SLshlB64" + ? $"({left} << {shift})" + : $"({left} >> {shift})"; + break; + } + case "SBfmB64": + { + var width = Temp("ulong", $"(ulong)(({RawSource(instruction, 0)}) & 63u)"); + var offset = Temp("ulong", $"(ulong)(({RawSource(instruction, 1)}) & 63u)"); + value = $"((((1ul << {width}) - 1ul)) << {offset})"; + break; + } + case "SBfeU64" or "SBfeI64": + { + var control = Temp("uint", RawSource(instruction, 1)); + var offset = Temp("uint", $"{control} & 63u"); + var width = Temp("uint", $"min(({control} >> 16) & 0x7Fu, 64u - {offset})"); + var mask = Temp( + "ulong", + $"{width} >= 64u ? 0xFFFFFFFFFFFFFFFFul : ((1ul << {width}) - 1ul)"); + var extracted = Temp("ulong", $"({left} >> {offset}) & {mask}"); + if (instruction.Opcode == "SBfeI64") + { + var signBit = Temp( + "ulong", + $"{width} == 0u ? 0ul : (1ul << ({width} - 1u))"); + extracted = Temp( + "ulong", + $"{width} == 0u ? 0ul : (({extracted} ^ {signBit}) - {signBit})"); + } + + value = extracted; + break; + } + default: + { + if (instruction.Sources.Count < 2) + { + error = "missing scalar 64-bit source"; + return false; + } + + var right = Temp("ulong", RawSource64(instruction, 1)); + value = instruction.Opcode switch + { + "SAndB64" => $"({left} & {right})", + "SOrB64" => $"({left} | {right})", + "SXorB64" => $"({left} ^ {right})", + "SNandB64" => $"~({left} & {right})", + "SNorB64" => $"~({left} | {right})", + "SXnorB64" => $"~({left} ^ {right})", + "SAndn1B64" => $"(~{left} & {right})", + "SAndn2B64" => $"({left} & ~{right})", + "SOrn1B64" => $"(~{left} | {right})", + "SOrn2B64" => $"({left} | ~{right})", + "SCselectB64" => $"(scc ? {left} : {right})", + _ => string.Empty, + }; + if (value.Length == 0) + { + error = $"unsupported scalar 64-bit opcode {instruction.Opcode}"; + return false; + } + + setsScc = instruction.Opcode != "SCselectB64"; + break; + } + } + + var stored = Temp("ulong", value); + StoreScalar64(destination, stored); + if (setsScc) + { + Line($"scc = {stored} != 0ul;"); + } + + return true; + } + + // ---- operand helpers ---- + + private uint DestinationVector(Gen5ShaderInstruction instruction) + { + var destination = instruction.Destinations[0]; + return destination.Kind == Gen5OperandKind.VectorRegister + ? destination.Value + : throw new NotSupportedException( + $"vector destination expected in {instruction.Opcode}"); + } + + /// + /// Raw 32-bit source with DPP/DPP8 lane remapping on src0 and SDWA + /// byte/word selection + integer modifiers, mirroring GetRawSource. + /// + private string RawSource( + Gen5ShaderInstruction instruction, + int sourceIndex, + bool applySdwaIntegerModifiers = true) + { + var value = SourceExpression(instruction.Sources[sourceIndex], instruction); + if (sourceIndex == 0 && instruction.Control is Gen5DppControl dpp) + { + value = ApplyDppSource(dpp, value); + } + else if (sourceIndex == 0 && instruction.Control is Gen5Dpp8Control dpp8) + { + value = ApplyDpp8Source(dpp8, value); + } + + if (instruction.Control is Gen5SdwaControl sdwa) + { + var selector = sourceIndex switch + { + 0 => sdwa.Source0Select, + 1 => sdwa.Source1Select, + _ => 6u, + }; + value = selector switch + { + 0 => $"(({value}) & 0xFFu)", + 1 => $"((({value}) >> 8) & 0xFFu)", + 2 => $"((({value}) >> 16) & 0xFFu)", + 3 => $"((({value}) >> 24) & 0xFFu)", + 4 => $"(({value}) & 0xFFFFu)", + 5 => $"((({value}) >> 16) & 0xFFFFu)", + _ => value, + }; + var signExtend = sourceIndex switch + { + 0 => sdwa.Source0SignExtend, + 1 => sdwa.Source1SignExtend, + _ => false, + }; + if (signExtend && selector != 6) + { + var width = selector <= 3 ? 8u : 16u; + value = $"(uint)extract_bits(as_type({value}), 0u, {width}u)"; + } + + if (applySdwaIntegerModifiers) + { + if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0) + { + value = $"(uint)abs(as_type({value}))"; + } + + if ((sdwa.NegateMask & (1u << sourceIndex)) != 0) + { + value = $"(0u - ({value}))"; + } + } + } + + return value; + } + + /// 64-bit source: SGPR/VGPR pair, sign-extended inline, or zero-extended 32-bit. + private string RawSource64(Gen5ShaderInstruction instruction, int sourceIndex) + { + var operand = instruction.Sources[sourceIndex]; + switch (operand.Kind) + { + case Gen5OperandKind.ScalarRegister: + return Scalar64Expression(operand.Value); + case Gen5OperandKind.VectorRegister: + return $"((ulong)v[{operand.Value}] | ((ulong)v[{operand.Value + 1}] << 32))"; + case Gen5OperandKind.EncodedConstant when operand.Value is >= 193 and <= 208: + { + // Inline negatives sign-extend: -1 denotes a full 64-bit mask. + var signed = -(long)(operand.Value - 192); + return $"0x{unchecked((ulong)signed):X}ul"; + } + default: + return $"(ulong)({RawSource(instruction, sourceIndex)})"; + } + } + + private string Scalar64Expression(uint register) => register switch + { + // VCC/EXEC read their architectural SGPR pairs like any other + // register — programs park plain data there (see StoreScalar). + _ when register + 1 < ScalarRegisterFileCount => + $"((ulong)s[{register}] | ((ulong)s[{register + 1}] << 32))", + _ => "0ul", + }; + + private void StoreScalar64(uint register, string ulongValue) + { + StoreScalar(register, $"(uint)({ulongValue})"); + StoreScalar(register + 1, $"(uint)(({ulongValue}) >> 32)"); + } + + /// Float view of a source with abs/neg modifiers from VOP3/SDWA/DPP. + private string F(Gen5ShaderInstruction instruction, int sourceIndex) + { + var expression = AsFloat( + RawSource(instruction, sourceIndex, applySdwaIntegerModifiers: false)); + var (absoluteMask, negateMask) = instruction.Control switch + { + Gen5Vop3Control control => (control.AbsoluteMask, control.NegateMask), + Gen5SdwaControl control => (control.AbsoluteMask, control.NegateMask), + Gen5DppControl control => (control.AbsoluteMask, control.NegateMask), + _ => (0u, 0u), + }; + if ((absoluteMask & (1u << sourceIndex)) != 0) + { + expression = $"fabs({expression})"; + } + + if ((negateMask & (1u << sourceIndex)) != 0) + { + expression = $"(-{expression})"; + } + + return expression; + } + + /// + /// Wraps a float expression with VOP3/SDWA output modifiers and clamp, + /// then bitcasts back to the register file's uint domain. + /// + private string FloatResult(Gen5ShaderInstruction instruction, string expression) + { + var (outputModifier, clamp) = instruction.Control switch + { + Gen5Vop3Control control => (control.OutputModifier, control.Clamp), + Gen5SdwaControl control => (control.OutputModifier, control.Clamp), + _ => (0u, false), + }; + expression = outputModifier switch + { + 1 => $"(({expression}) * 2.0f)", + 2 => $"(({expression}) * 4.0f)", + 3 => $"(({expression}) * 0.5f)", + _ => expression, + }; + if (clamp) + { + expression = $"clamp({expression}, 0.0f, 1.0f)"; + } + + return AsUInt($"({expression})"); + } + + /// The lane's bit of a mask operand (VCC/EXEC/SGPR mask). + private string MaskBitExpression(Gen5Operand operand) => operand switch + { + { Kind: Gen5OperandKind.ScalarRegister, Value: VccLoRegister } => "vcc", + { Kind: Gen5OperandKind.ScalarRegister, Value: ExecLoRegister } => "exec", + { Kind: Gen5OperandKind.ScalarRegister } scalar => + $"(((s[{scalar.Value}] >> sharpemu_lane) & 1u) != 0u)", + _ => throw new NotSupportedException("mask operand must be a scalar register"), + }; + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs new file mode 100644 index 0000000..7b95234 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs @@ -0,0 +1,888 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text; +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal; + +public static partial class Gen5MslTranslator +{ + private sealed partial class CompilationContext + { + private const uint ImageDescriptorDwords = 8; + private const uint SamplerDescriptorDwords = 4; + + // ---- image resources ---- + + /// + /// Classifies every image binding (storage vs sampled, component kind + /// from the descriptor's unified format) and seeds the PC lookup, + /// mirroring DeclareImages on the SPIR-V side. MSL needs no format on + /// the texture type — only the component type and access. + /// + private void DeclareImageKinds() + { + for (var index = 0; index < _evaluation.ImageBindings.Count; index++) + { + var binding = _evaluation.ImageBindings[index]; + _imageBindingByPc.TryAdd(binding.Pc, index); + var isStorage = Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode); + _imageKinds.Add((isStorage, DecodeImageComponentKind(binding.ResourceDescriptor))); + } + + // Seed each binding's access from the opcode that defined it; the body + // emission (TryEmitImage) then ORs in the access of every instruction + // that resolves to the same binding, so a load and a store sharing one + // binding correctly become read_write. + _imageBindingReads = new bool[_imageKinds.Count]; + _imageBindingWrites = new bool[_imageKinds.Count]; + for (var index = 0; index < _evaluation.ImageBindings.Count; index++) + { + MarkImageBindingAccess(index, _evaluation.ImageBindings[index].Opcode); + } + + // Assign one sampler per sampled image (storage images take none). + // Computed before body emission because the sample calls reference + // the slots. Samplers live in an argument buffer (see + // EmitImageArguments), so there is no 16-slot cap to dedup against — + // each image keeps its own sampler, matching the SPIR-V/Vulkan path. + _samplerSlots = new int[_imageKinds.Count]; + _samplerCount = 0; + for (var index = 0; index < _imageKinds.Count; index++) + { + _samplerSlots[index] = _imageKinds[index].IsStorage ? -1 : _samplerCount++; + } + } + + /// Records that reads and/or writes the + /// storage image at , so EmitImageArguments + /// can pick the minimal Metal access qualifier. + private void MarkImageBindingAccess(int bindingIndex, string opcode) + { + if ((uint)bindingIndex >= (uint)_imageBindingReads.Length) + { + return; + } + + if (opcode.StartsWith("ImageStore", StringComparison.Ordinal)) + { + _imageBindingWrites[bindingIndex] = true; + } + else if (opcode.StartsWith("ImageAtomic", StringComparison.Ordinal)) + { + _imageBindingReads[bindingIndex] = true; + _imageBindingWrites[bindingIndex] = true; + } + else + { + // ImageLoad/ImageLoadMip and ImageGetResinfo read the texture; + // sampled ops are non-storage and ignore these flags. + _imageBindingReads[bindingIndex] = true; + } + } + + /// "float", "int", or "uint" from the descriptor's unified format. + private static string DecodeImageComponentKind(IReadOnlyList descriptor) + { + if (descriptor.Count < 2) + { + return "float"; + } + + var unifiedFormat = (descriptor[1] >> 20) & 0x1FFu; + if (!Gfx10UnifiedFormat.TryDecode(unifiedFormat, out _, out var numberType)) + { + return "float"; + } + + return numberType switch + { + 4 => "uint", + 5 => "int", + _ => "float", + }; + } + + /// Per image binding: its sampler's [[id(N)]] inside the sampler + /// argument buffer, or -1 for storage images. Set by DeclareImageKinds. + private int[] _samplerSlots = []; + + /// Number of sampled images (= sampler argument-buffer entries). + private int _samplerCount; + + /// Buffer slot the sampler argument buffer binds to, past this + /// stage's global buffers, uniforms, and scalar-state buffer. + private int SamplerArgBufferIndex => + Math.Max(UniformsBufferIndex, _initialScalarBufferIndex) + 1; + + /// Emits the texture arguments (direct [[texture(N)]] slots, which + /// run to 31 — enough) plus, when the stage samples anything, the sampler + /// argument buffer. Samplers go through an argument buffer rather than + /// [[sampler(N)]] slots because Metal caps those at 16 per stage while + /// real shaders sample more (void Terrarium's scene shader: 17); argument + /// buffers have no such limit on Apple Silicon. + private void EmitImageArguments(StringBuilder source) + { + for (var index = 0; index < _imageKinds.Count; index++) + { + var (isStorage, kind) = _imageKinds[index]; + var textureSlot = _imageBindingBase + index; + if (isStorage) + { + // Minimal access keeps read_write textures under Metal's cap + // of 8 per function: only images that are both read and + // written (or resolve a load and a store to one binding) need + // read_write; the rest are read-only or write-only. + var access = _imageBindingWrites[index] + ? (_imageBindingReads[index] ? "read_write" : "write") + : "read"; + source.AppendLine( + $" texture2d<{kind}, access::{access}> tex{index} [[texture({textureSlot})]],"); + } + else + { + source.AppendLine($" texture2d<{kind}> tex{index} [[texture({textureSlot})]],"); + } + } + + if (_samplerCount > 0) + { + source.AppendLine( + $" constant Gen5Samplers& sharpemu_samplers [[buffer({SamplerArgBufferIndex})]],"); + } + } + + /// Declares the sampler argument-buffer struct at file scope (one + /// sampler per sampled image). Empty when the stage samples nothing. + private void EmitSamplerArgumentBufferStruct(StringBuilder source) + { + if (_samplerCount == 0) + { + return; + } + + source.AppendLine("struct Gen5Samplers"); + source.AppendLine("{"); + for (var slot = 0; slot < _samplerCount; slot++) + { + source.AppendLine($" sampler smp{slot} [[id({slot})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + } + + private bool TryResolveDominatingImageBinding( + Gen5ShaderInstruction instruction, + Gen5ImageControl control, + out int bindingIndex) + { + if (_imageBindingByPc.TryGetValue(instruction.Pc, out bindingIndex) && + bindingIndex < _imageKinds.Count) + { + return true; + } + + var storage = Gen5ShaderTranslator.IsStorageImageOperation(instruction.Opcode); + for (var index = 0; index < _evaluation.ImageBindings.Count; index++) + { + var candidate = _evaluation.ImageBindings[index]; + if (candidate.Control.ScalarResource != control.ScalarResource || + candidate.Control.ScalarSampler != control.ScalarSampler || + Gen5ShaderTranslator.IsStorageImageOperation(candidate.Opcode) != storage || + !HasSameScalarDefinitions( + candidate.Pc, + instruction.Pc, + control.ScalarResource, + ImageDescriptorDwords) || + (UsesSampler(instruction.Opcode) && + !HasSameScalarDefinitions( + candidate.Pc, + instruction.Pc, + control.ScalarSampler, + SamplerDescriptorDwords))) + { + continue; + } + + bindingIndex = index; + _imageBindingByPc.Add(instruction.Pc, index); + return true; + } + + bindingIndex = -1; + return false; + } + + private bool HasSameScalarDefinitions( + uint candidatePc, + uint targetPc, + uint firstRegister, + uint registerCount) + { + if (firstRegister + registerCount > ScalarRegisterFileCount || + !_scalarDefinitionsBeforePc.TryGetValue(candidatePc, out var candidate) || + !_scalarDefinitionsBeforePc.TryGetValue(targetPc, out var target)) + { + return false; + } + + for (var register = firstRegister; + register < firstRegister + registerCount; + register++) + { + var definition = candidate[register]; + if (definition is ConflictingScalarDefinition or UnreachableScalarDefinition || + target[register] != definition) + { + return false; + } + } + + return true; + } + + private static bool UsesSampler(string opcode) => + opcode.StartsWith("ImageSample", StringComparison.Ordinal) || + opcode.StartsWith("ImageGather", StringComparison.Ordinal); + + // ---- image instruction emission ---- + + private bool TryEmitImage( + Gen5ShaderInstruction instruction, + Gen5ImageControl image, + out string error) + { + error = string.Empty; + if (!TryResolveDominatingImageBinding(instruction, image, out var bindingIndex)) + { + error = $"unresolved image binding t=s{image.ScalarResource} s=s{image.ScalarSampler}"; + return false; + } + + // The resolving instruction may differ from the one that defined the + // binding (a store can dominate a load's binding); fold its access in. + MarkImageBindingAccess(bindingIndex, instruction.Opcode); + var (isStorage, kind) = _imageKinds[bindingIndex]; + var texture = $"tex{bindingIndex}"; + + if (instruction.Opcode == "ImageGetResinfo") + { + var width = Temp("uint", isStorage + ? $"{texture}.get_width()" + : $"{texture}.get_width(0)"); + var height = Temp("uint", isStorage + ? $"{texture}.get_height()" + : $"{texture}.get_height(0)"); + uint outputIndex = 0; + for (var component = 0; component < 4; component++) + { + if ((image.Dmask & (1u << component)) == 0) + { + continue; + } + + StoreVector( + image.VectorData + outputIndex++, + component switch + { + 0 => width, + 1 => height, + _ => "1u", + }); + } + + return true; + } + + if (instruction.Opcode is "ImageStore" or "ImageStoreMip") + { + if (!isStorage) + { + error = "image store is not bound as storage"; + return false; + } + + var x = Temp("int", $"as_type({ImageIntegerAddress(image, 0)})"); + var y = Temp("int", $"as_type({ImageIntegerAddress(image, 1)})"); + var components = new string[4]; + uint sourceIndex = 0; + for (var component = 0; component < 4; component++) + { + components[component] = (image.Dmask & (1u << component)) != 0 + ? ImageTexelComponent(kind, ImageStoreComponent(image, kind, sourceIndex++)) + : kind == "float" ? "0.0f" : "0"; + } + + // Bounds-checked, EXEC-guarded write. + Line($"if (exec && {x} >= 0 && {y} >= 0 && {x} < (int){texture}.get_width() && {y} < (int){texture}.get_height())"); + Line("{"); + _indent++; + Line($"{texture}.write({VectorLiteral(kind)}({components[0]}, {components[1]}, {components[2]}, {components[3]}), uint2((uint){x}, (uint){y}));"); + _indent--; + Line("}"); + return true; + } + + if (isStorage && instruction.Opcode is not ("ImageLoad" or "ImageLoadMip")) + { + error = $"unsupported storage image opcode {instruction.Opcode}"; + return false; + } + + string sampled; + var writeAllComponents = false; + if (instruction.Opcode is "ImageLoad" or "ImageLoadMip") + { + var mip = _evaluation.ImageBindings[bindingIndex].MipLevel ?? 0; + var widthQuery = isStorage ? $"{texture}.get_width()" : $"{texture}.get_width({mip}u)"; + var heightQuery = isStorage ? $"{texture}.get_height()" : $"{texture}.get_height({mip}u)"; + var x = Temp( + "uint", + $"(uint)clamp(as_type({ImageIntegerAddress(image, 0)}), 0, (int){widthQuery} - 1)"); + var y = Temp( + "uint", + $"(uint)clamp(as_type({ImageIntegerAddress(image, 1)}), 0, (int){heightQuery} - 1)"); + sampled = Temp( + $"vec<{kind}, 4>", + isStorage + ? $"{texture}.read(uint2({x}, {y}))" + : $"{texture}.read(uint2({x}, {y}), {mip}u)"); + } + else if (instruction.Opcode.StartsWith("ImageSample", StringComparison.Ordinal)) + { + if (!TryEmitImageSample(instruction, image, bindingIndex, kind, out sampled, out error)) + { + return false; + } + } + else if (instruction.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal)) + { + if (!TryEmitImageGather(instruction, image, bindingIndex, kind, out sampled, out error)) + { + return false; + } + + writeAllComponents = true; + } + else + { + error = $"unsupported image opcode {instruction.Opcode}"; + return false; + } + + var outputValues = new List(4); + for (var component = 0; component < 4; component++) + { + if (!writeAllComponents && (image.Dmask & (1u << component)) == 0) + { + continue; + } + + var value = $"{sampled}[{component}]"; + outputValues.Add(kind == "uint" ? value : AsUInt(value)); + } + + if (image.D16) + { + for (var index = 0; index < outputValues.Count; index += 2) + { + var low = outputValues[index]; + var high = index + 1 < outputValues.Count ? outputValues[index + 1] : "0u"; + StoreVector( + image.VectorData + (uint)(index / 2), + PackImageD16(kind, low, high)); + } + } + else + { + for (var index = 0; index < outputValues.Count; index++) + { + StoreVector(image.VectorData + (uint)index, outputValues[index]); + } + } + + return true; + } + + private bool TryEmitImageSample( + Gen5ShaderInstruction instruction, + Gen5ImageControl image, + int bindingIndex, + string kind, + out string sampled, + out string error) + { + sampled = string.Empty; + error = string.Empty; + var opcode = instruction.Opcode; + var texture = $"tex{bindingIndex}"; + var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}"; + var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal); + var hasCompare = opcode.Contains("SampleC", StringComparison.Ordinal); + var hasGradients = opcode.Contains("SampleD", StringComparison.Ordinal); + var hasZeroLod = opcode.Contains("Lz", StringComparison.Ordinal); + var hasLod = !hasZeroLod && opcode.Contains("SampleL", StringComparison.Ordinal); + var hasBias = opcode.Contains("SampleB", StringComparison.Ordinal); + + // RDNA MIMG address operands are ordered + // {offset}{bias}{z-compare}{derivatives}{body}; SAMPLE_L carries LOD + // as the final body component instead. + var addressCursor = 0; + var offsetX = "0"; + var offsetY = "0"; + if (hasOffset) + { + addressCursor = AlignFullImageAddress(image, addressCursor); + var packed = Temp( + "int", + $"as_type(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])"); + offsetX = Temp("int", $"extract_bits({packed}, 0u, 6u)"); + offsetY = Temp("int", $"extract_bits({packed}, 8u, 6u)"); + addressCursor += ImageFullAddressSlots(image); + } + + var bias = hasBias ? Temp("float", ImageFloatAddress(image, addressCursor++)) : "0.0f"; + var reference = "0.0f"; + if (hasCompare) + { + addressCursor = AlignFullImageAddress(image, addressCursor); + reference = Temp( + "float", + $"as_type(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])"); + addressCursor += ImageFullAddressSlots(image); + } + + var gradientX = "float2(0.0f)"; + var gradientY = "float2(0.0f)"; + if (hasGradients) + { + gradientX = Temp( + "float2", + $"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})"); + gradientY = Temp( + "float2", + $"float2({ImageFloatAddress(image, addressCursor + 2)}, {ImageFloatAddress(image, addressCursor + 3)})"); + addressCursor += 4; + } + + var coordinates = Temp( + "float2", + $"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})"); + var lod = hasZeroLod + ? "0.0f" + : hasLod + ? Temp("float", ImageFloatAddress(image, addressCursor + 2)) + : bias; + if (hasOffset) + { + // Per-lane texel offsets fold into normalized coordinates using + // the selected mip extent, mirroring the SPIR-V translator + // (Metal sample offsets must be compile-time constants). + var explicitLod = hasGradients || hasZeroLod || hasLod; + var offsetLod = explicitLod && !hasGradients ? lod : "0.0f"; + var mipLevel = Temp("uint", $"(uint)max((int)({offsetLod}), 0)"); + coordinates = Temp( + "float2", + $"{coordinates} + float2((float){offsetX} / (float){texture}.get_width({mipLevel}), " + + $"(float){offsetY} / (float){texture}.get_height({mipLevel}))"); + } + + var samplerArguments = hasGradients + ? $", gradient2d({gradientX}, {gradientY})" + : hasZeroLod || hasLod + ? $", level({lod})" + : hasBias + ? $", bias({bias})" + : string.Empty; + sampled = Temp( + $"vec<{kind}, 4>", + $"{texture}.sample({samplerName}, {coordinates}{samplerArguments})"); + if (hasCompare) + { + // Manual PCF: reference passes when <= texel, broadcast (r,r,r,1). + var passes = Temp("bool", $"{reference} <= (float){sampled}[0]"); + var one = kind == "float" ? "1.0f" : "1"; + var zero = kind == "float" ? "0.0f" : "0"; + sampled = Temp( + $"vec<{kind}, 4>", + $"{VectorLiteral(kind)}({passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {one})"); + } + + return true; + } + + private bool TryEmitImageGather( + Gen5ShaderInstruction instruction, + Gen5ImageControl image, + int bindingIndex, + string kind, + out string sampled, + out string error) + { + sampled = string.Empty; + error = string.Empty; + var opcode = instruction.Opcode; + var texture = $"tex{bindingIndex}"; + var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}"; + var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal); + var hasCompare = opcode.Contains("Gather4C", StringComparison.Ordinal); + var addressCursor = 0; + var offset = "int2(0)"; + if (hasOffset) + { + var packed = Temp( + "int", + $"as_type(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])"); + offset = Temp( + "int2", + $"int2(extract_bits({packed}, 0u, 6u), extract_bits({packed}, 8u, 6u))"); + addressCursor += ImageFullAddressSlots(image); + } + + var reference = "0.0f"; + if (hasCompare) + { + addressCursor = AlignFullImageAddress(image, addressCursor); + reference = Temp( + "float", + $"as_type(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])"); + addressCursor += ImageFullAddressSlots(image); + } + + var coordinates = Temp( + "float2", + $"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})"); + + // The gathered component is selected from the first dmask bit. + uint component = 0; + while (component < 3 && (image.Dmask & (1u << (int)component)) == 0) + { + component++; + } + + var componentName = hasCompare ? "x" : component switch + { + 0 => "x", + 1 => "y", + 2 => "z", + _ => "w", + }; + sampled = Temp( + $"vec<{kind}, 4>", + $"{texture}.gather({samplerName}, {coordinates}, {offset}, component::{componentName})"); + if (hasCompare) + { + var one = kind == "float" ? "1.0f" : "1"; + var zero = kind == "float" ? "0.0f" : "0"; + var compared = Temp( + $"vec<{kind}, 4>", + $"{VectorLiteral(kind)}(" + + $"{reference} <= (float){sampled}[0] ? {one} : {zero}, " + + $"{reference} <= (float){sampled}[1] ? {one} : {zero}, " + + $"{reference} <= (float){sampled}[2] ? {one} : {zero}, " + + $"{reference} <= (float){sampled}[3] ? {one} : {zero})"); + sampled = compared; + } + + return true; + } + + private static string VectorLiteral(string kind) => $"vec<{kind}, 4>"; + + private static int ImageAddressRegister(Gen5ImageControl image, int component) => + image.A16 ? component / 2 : component; + + private static int ImageFullAddressSlots(Gen5ImageControl image) => + image.A16 ? 2 : 1; + + private static int AlignFullImageAddress(Gen5ImageControl image, int component) => + image.A16 ? (component + 1) & ~1 : component; + + /// Float address component, unpacking A16 half pairs. + private string ImageFloatAddress(Gen5ImageControl image, int component) + { + var register = image.GetAddressRegister(ImageAddressRegister(image, component)); + return image.A16 + ? $"(float)as_type(v[{register}])[{component & 1}]" + : $"as_type(v[{register}])"; + } + + /// Integer address component, unpacking A16 16-bit pairs. + private string ImageIntegerAddress(Gen5ImageControl image, int component) + { + var register = image.GetAddressRegister(ImageAddressRegister(image, component)); + return image.A16 + ? $"((v[{register}] >> {(component & 1) * 16}) & 0xFFFFu)" + : $"v[{register}]"; + } + + /// One store-source component, unpacking D16 halves. + private string ImageStoreComponent(Gen5ImageControl image, string kind, uint component) + { + if (!image.D16) + { + return $"v[{image.VectorData + component}]"; + } + + var packed = $"v[{image.VectorData + (component / 2)}]"; + if (kind == "float") + { + return AsUInt($"(float)as_type({packed})[{component & 1}]"); + } + + var low = $"(({packed} >> {(component & 1) * 16}) & 0xFFFFu)"; + return kind == "int" + ? $"(uint)extract_bits(as_type({low}), 0u, 16u)" + : low; + } + + private static string ImageTexelComponent(string kind, string raw) => kind switch + { + "int" => $"as_type({raw})", + "uint" => raw, + _ => $"as_type({raw})", + }; + + private string PackImageD16(string kind, string low, string high) + { + if (kind == "float") + { + return $"(((uint)as_type(half(as_type({low})))) | (((uint)as_type(half(as_type({high})))) << 16))"; + } + + return $"((({low}) & 0xFFFFu) | ((({high}) & 0xFFFFu) << 16))"; + } + + // ---- exports ---- + + private bool TryEmitExport( + Gen5ShaderInstruction instruction, + Gen5ExportControl export, + out string error) + { + error = string.Empty; + if (instruction.Sources.Count < 4) + { + error = "missing export sources"; + return false; + } + + if (_stage == Gen5MslStage.Vertex) + { + return TryEmitVertexExport(instruction, export); + } + + if (_stage != Gen5MslStage.Pixel) + { + // Compute programs have no export interface. + return true; + } + + Gen5PixelOutputBinding? binding = null; + foreach (var candidate in _pixelOutputBindings) + { + if (candidate.GuestSlot == export.Target) + { + binding = candidate; + break; + } + } + + if (binding is null) + { + return true; + } + + var field = $"sharpemu_out.mrt{binding.Value.GuestSlot}"; + var componentType = binding.Value.Kind switch + { + Gen5PixelOutputKind.Uint => "uint", + Gen5PixelOutputKind.Sint => "int", + _ => "float", + }; + var values = new string[4]; + for (var component = 0; component < 4; component++) + { + if ((export.EnableMask & (1u << component)) == 0) + { + values[component] = $"{field}[{component}]"; + continue; + } + + if (export.Compressed) + { + var packed = $"v[{instruction.Sources[component >> 1].Value}]"; + var half = $"(float)as_type({packed})[{component & 1}]"; + values[component] = binding.Value.Kind switch + { + Gen5PixelOutputKind.Uint => $"(uint)({half})", + Gen5PixelOutputKind.Sint => $"(int)({half})", + _ => half, + }; + continue; + } + + var raw = $"v[{instruction.Sources[component].Value}]"; + values[component] = binding.Value.Kind switch + { + Gen5PixelOutputKind.Uint => raw, + Gen5PixelOutputKind.Sint => $"as_type({raw})", + _ => $"as_type({raw})", + }; + } + + // A lane removed from EXEC keeps the previous output value; killed + // fragments are discarded in the epilogue. + Line($"{field} = exec ? vec<{componentType}, 4>({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};"); + return true; + } + + private bool TryEmitVertexExport( + Gen5ShaderInstruction instruction, + Gen5ExportControl export) + { + // Target 12 is POS0; 32..63 are the param outputs. Everything else + // (other position slots, MRTZ) is ignored like the SPIR-V side. + string field; + if (export.Target == 12) + { + field = "sharpemu_out.sharpemu_position"; + } + else if (export.Target is >= 32 and < 64 && + _vertexOutputs.Contains(export.Target - 32)) + { + field = $"sharpemu_out.param{export.Target - 32}"; + } + else + { + return true; + } + + var values = new string[4]; + for (var component = 0; component < 4; component++) + { + if ((export.EnableMask & (1u << component)) == 0) + { + values[component] = component == 3 ? "1.0f" : "0.0f"; + continue; + } + + if (export.Compressed) + { + var packed = $"v[{instruction.Sources[component >> 1].Value}]"; + values[component] = $"(float)as_type({packed})[{component & 1}]"; + continue; + } + + values[component] = $"as_type(v[{instruction.Sources[component].Value}])"; + } + + Line($"{field} = exec ? float4({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};"); + return true; + } + + /// + /// Vertex attribute fetch: the evaluator captured this buffer load as a + /// fixed-function vertex input, so read the stage_in field instead of + /// guest memory (bound via MTLVertexDescriptor by the backend). + /// + private bool TryEmitVertexInputFetch( + Gen5BufferMemoryControl control, + Gen5VertexInputBinding input, + out string error) + { + error = string.Empty; + if (control.DwordCount == 0 || control.DwordCount > input.ComponentCount) + { + error = + $"invalid vertex input fetch components={control.DwordCount} " + + $"input={input.ComponentCount}"; + return false; + } + + for (uint component = 0; component < control.DwordCount; component++) + { + var value = input.ComponentCount == 1 + ? $"sharpemu_vin.in{input.Location}" + : $"sharpemu_vin.in{input.Location}[{component}]"; + StoreVector(control.VectorData + component, AsUInt(value)); + } + + return true; + } + + // ---- interpolation / pixel inputs ---- + + private bool TryEmitInterpolation( + Gen5ShaderInstruction instruction, + Gen5InterpolationControl interpolation, + out string error) + { + error = string.Empty; + if (_stage != Gen5MslStage.Pixel || + !_pixelAttributes.Contains(interpolation.Attribute) || + instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.VectorRegister) + { + error = "invalid interpolated attribute"; + return false; + } + + StoreVector( + instruction.Destinations[0].Value, + AsUInt($"sharpemu_in.attr{interpolation.Attribute}[{interpolation.Channel}]")); + return true; + } + + /// + /// Seeds pixel input VGPRs in SPI_PS_INPUT_ADDR compact order: the + /// interpolation slots reserve registers even though V_INTERP reads MSL + /// varyings directly, and the position inputs land in the + /// hardware-selected VGPRs from the fragment coordinate. + /// + private void EmitPixelInputState(StringBuilder source) + { + uint vgpr = 0; + + void Advance(int bit, uint dwordCount) + { + if ((_pixelInputAddress & (1u << bit)) != 0) + { + vgpr += dwordCount; + } + } + + void Position(int bit, string component) + { + var mask = 1u << bit; + if ((_pixelInputAddress & mask) == 0) + { + return; + } + + if ((_pixelInputEnable & mask) != 0) + { + source.AppendLine( + $" v[{vgpr}] = as_type(sharpemu_in.sharpemu_frag_coord.{component});"); + } + + vgpr++; + } + + Advance(0, 2); // PERSP_SAMPLE + Advance(1, 2); // PERSP_CENTER + Advance(2, 2); // PERSP_CENTROID + Advance(3, 3); // PERSP_PULL_MODEL + Advance(4, 2); // LINEAR_SAMPLE + Advance(5, 2); // LINEAR_CENTER + Advance(6, 2); // LINEAR_CENTROID + Advance(7, 1); // LINE_STIPPLE + Position(8, "x"); + Position(9, "y"); + Position(10, "z"); + Position(11, "w"); + } + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs new file mode 100644 index 0000000..c036752 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs @@ -0,0 +1,2238 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Globalization; +using System.Text; +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal; + +/// +/// Gen5 (gfx10) -> Metal Shading Language codegen. Consumes the backend-neutral +/// (Gen5ShaderState, Gen5ShaderEvaluation) contract out of Gen5ShaderTranslator / +/// Gen5ShaderScalarEvaluator and emits MSL source text; the Metal renderer compiles +/// it with MTLLibrary at bind time. +/// +/// The execution model mirrors Gen5SpirvTranslator: one GPU invocation is one GCN +/// lane (wave32 — natively the Apple simdgroup width), the register file is typeless +/// 32-bit uints (float ALU bitcasts through as_type<float>), and control flow is a +/// PC-dispatcher loop — a bounded while over a switch of GCN basic blocks — rather +/// than reconstructed structured control flow. EXEC/VCC live in their architectural +/// SGPRs (s106/s107, s126/s127) as raw data, with per-lane bools as synced views. +/// Graphics stages model a single logical wave lane (lane 0, ballots degrade to bit +/// 0, shuffle-family selects resolve to the lane's own value) — the SPIR-V +/// translator's no-subgroup fallback — because Metal leaves simdgroup ops undefined +/// inside the divergent dispatcher loop. Compute threads map one-to-one onto real +/// simdgroup lanes and shuffle for real. +/// +/// Wave64: 64-bit masks are carried faithfully as data — every B64 mask op, +/// saveexec, and VCCZ/EXECZ test reads and writes the full register pair. A +/// wave64 guest wave is two 32-wide Apple simdgroups co-resident in one +/// threadgroup; cross-lane ops that span the full 64 lanes (ballots into +/// EXEC/VCC, read-first-lane) rendezvous the two halves through threadgroup +/// scratch with a barrier — the guest's scalar PC keeps all 64 lanes lockstep +/// through the dispatcher, so the barriers are reached uniformly. This mirrors +/// the SPIR-V translator's bridge and shares its scope: the scratch is indexed +/// by half, so it is correct for a one-wave (64-thread) workgroup; readlane +/// across halves stays a 32-wide shuffle (same as the SPIR-V path). Wave- +/// agnostic wave64 kernels translate per-thread unchanged. +/// +/// Buffer argument contract (documented for the Metal backend): +/// [[buffer(globalBufferBase + i)]] global memory binding i, in +/// Gen5ShaderEvaluation.GlobalMemoryBindings order +/// [[buffer(uniformsIndex)]] one SharpEmuUniforms constant buffer holding the +/// compute dispatch limit and per-buffer byte +/// lengths, where uniformsIndex is +/// globalBufferBase + totalGlobalBufferCount +/// +public static partial class Gen5MslTranslator +{ + private const uint ScalarRegisterFileCount = 128; + private const uint VectorRegisterFileCount = 256; + private const uint LdsDwordCount = 8192; + private const uint LdsDwordMask = LdsDwordCount - 1; + // Graphics stages model LDS as per-invocation scratch; a full 32 KB array + // per fragment/vertex invocation risks Metal compile limits, and + // per-invocation write-then-read correctness only needs deterministic + // address masking (mirrors the SPIR-V translator's Private-array choice). + private const uint PrivateLdsDwordCount = 2048; + private const uint VccLoRegister = 106; + private const uint VccHiRegister = 107; + private const uint ExecLoRegister = 126; + private const uint ExecHiRegister = 127; + + public static bool TryCompilePixelShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + Gen5PixelOutputKind outputKind, + out Gen5MslShader shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int initialScalarBufferIndex = -1, + int pixelRenderTargetSlot = 0, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + ulong storageBufferOffsetAlignment = 1) => + TryCompilePixelShader( + state, + evaluation, + [new Gen5PixelOutputBinding((uint)pixelRenderTargetSlot, 0, outputKind)], + out shader, + out error, + globalBufferBase, + totalGlobalBufferCount, + imageBindingBase, + initialScalarBufferIndex, + pixelInputEnable, + pixelInputAddress, + storageBufferOffsetAlignment); + + public static bool TryCompilePixelShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + IReadOnlyList outputs, + out Gen5MslShader shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int initialScalarBufferIndex = -1, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + ulong storageBufferOffsetAlignment = 1) + { + shader = default!; + error = string.Empty; + if (outputs.Count > 8) + { + error = "pixel outputs must contain at most eight guest slots in the 0..7 range"; + return false; + } + + for (var index = 0; index < outputs.Count; index++) + { + if (outputs[index].GuestSlot > 7) + { + error = "pixel outputs must contain at most eight guest slots in the 0..7 range"; + return false; + } + + for (var other = index + 1; other < outputs.Count; other++) + { + if (outputs[other].GuestSlot == outputs[index].GuestSlot || + outputs[other].HostLocation == outputs[index].HostLocation) + { + error = "pixel output guest slots and host locations must be unique"; + return false; + } + } + } + + // Host locations must be dense 0..N-1 so [[color(n)]] attachments match. + for (uint location = 0; location < outputs.Count; location++) + { + var found = false; + foreach (var output in outputs) + { + found |= output.HostLocation == location; + } + + if (!found) + { + error = "pixel output host locations must be dense in the 0..N-1 range"; + return false; + } + } + + var context = new CompilationContext( + Gen5MslStage.Pixel, + state, + evaluation, + 1, + 1, + 1, + globalBufferBase, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount: 32, + storageBufferOffsetAlignment, + pixelOutputBindings: outputs, + imageBindingBase: imageBindingBase, + pixelInputEnable: pixelInputEnable, + pixelInputAddress: pixelInputAddress); + return context.TryCompile(out shader, out error); + } + + public static bool TryCompileVertexShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + out Gen5MslShader shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int initialScalarBufferIndex = -1, + int requiredVertexOutputCount = 0, + ulong storageBufferOffsetAlignment = 1) + { + var context = new CompilationContext( + Gen5MslStage.Vertex, + state, + evaluation, + 1, + 1, + 1, + globalBufferBase, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount: 32, + storageBufferOffsetAlignment, + imageBindingBase: imageBindingBase, + requiredVertexOutputCount: requiredVertexOutputCount); + return context.TryCompile(out shader, out error); + } + + public static bool TryCompileComputeShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + uint localSizeX, + uint localSizeY, + uint localSizeZ, + out Gen5MslShader shader, + out string error, + int totalGlobalBufferCount = -1, + int initialScalarBufferIndex = -1, + uint waveLaneCount = 32, + ulong storageBufferOffsetAlignment = 1) + { + var context = new CompilationContext( + Gen5MslStage.Compute, + state, + evaluation, + Math.Max(localSizeX, 1), + Math.Max(localSizeY, 1), + Math.Max(localSizeZ, 1), + globalBufferBase: 0, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount, + storageBufferOffsetAlignment); + return context.TryCompile(out shader, out error); + } + + private sealed partial class CompilationContext + { + // Safety valve for the PC-dispatcher loop, mirroring the SPIR-V + // translator: a mistranslated loop-exit condition must terminate the + // invocation instead of wedging the GPU queue. + private static readonly int _maxDispatcherSteps = + int.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_SHADER_MAX_STEPS"), + out var maxSteps) && maxSteps >= 0 + ? maxSteps + : 100_000; + + private const long InitialScalarDefinition = -1; + private const long ConflictingScalarDefinition = -2; + private const long UnreachableScalarDefinition = -3; + + private readonly Gen5MslStage _stage; + private readonly Gen5ShaderState _state; + private readonly Gen5ShaderEvaluation _evaluation; + private readonly uint _localSizeX; + private readonly uint _localSizeY; + private readonly uint _localSizeZ; + private readonly int _globalBufferBase; + private readonly int _totalGlobalBufferCount; + private readonly int _initialScalarBufferIndex; + private readonly uint _waveLaneCount; + private readonly ulong _storageBufferOffsetAlignment; + private readonly Dictionary _scalarDefinitionsBeforePc = []; + private readonly IReadOnlyList _pixelOutputBindings; + private readonly int _imageBindingBase; + private readonly uint _pixelInputEnable; + private readonly uint _pixelInputAddress; + private readonly Dictionary _imageBindingByPc = []; + private readonly Dictionary _bufferBindingByPc = []; + private readonly List<(bool IsStorage, string ComponentKind)> _imageKinds = []; + // Per storage-image binding: whether the body reads it, writes it, or + // both. Metal caps access::read_write textures at 8 per function, so each + // binding is declared with the minimal access it actually uses. + private bool[] _imageBindingReads = []; + private bool[] _imageBindingWrites = []; + private readonly SortedSet _pixelAttributes = []; + private readonly SortedSet _vertexOutputs = []; + private readonly Dictionary _vertexInputsByPc = []; + private readonly int _requiredVertexOutputCount; + private readonly StringBuilder _body = new(); + private int _indent; + private int _nextTemp; + private bool _usesLds; + private bool _usesFormatLoads; + private bool _usesWaveScratch; + + /// True when this stage emulates a 64-lane guest wave across two + /// 32-wide Apple simdgroups (compute only; graphics stages use the + /// single-lane model regardless of guest wave size). + private bool IsWave64 => _waveLaneCount == 64 && _stage == Gen5MslStage.Compute; + + public CompilationContext( + Gen5MslStage stage, + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + uint localSizeX, + uint localSizeY, + uint localSizeZ, + int globalBufferBase, + int totalGlobalBufferCount, + int initialScalarBufferIndex, + uint waveLaneCount, + ulong storageBufferOffsetAlignment, + IReadOnlyList? pixelOutputBindings = null, + int imageBindingBase = 0, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + int requiredVertexOutputCount = 0) + { + _pixelOutputBindings = pixelOutputBindings ?? []; + _imageBindingBase = imageBindingBase; + _pixelInputEnable = pixelInputEnable; + _pixelInputAddress = pixelInputAddress; + _requiredVertexOutputCount = requiredVertexOutputCount; + _stage = stage; + _state = state; + _evaluation = evaluation; + _localSizeX = localSizeX; + _localSizeY = localSizeY; + _localSizeZ = localSizeZ; + _globalBufferBase = globalBufferBase; + _totalGlobalBufferCount = totalGlobalBufferCount < 0 + ? evaluation.GlobalMemoryBindings.Count + : totalGlobalBufferCount; + _initialScalarBufferIndex = initialScalarBufferIndex; + _waveLaneCount = waveLaneCount == 64 ? 64u : 32u; + if (storageBufferOffsetAlignment == 0 || + (storageBufferOffsetAlignment & (storageBufferOffsetAlignment - 1)) != 0 || + storageBufferOffsetAlignment > uint.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(storageBufferOffsetAlignment), + storageBufferOffsetAlignment, + "storage-buffer offset alignment must be a uint-sized power of two"); + } + + _storageBufferOffsetAlignment = storageBufferOffsetAlignment; + } + + public bool TryCompile(out Gen5MslShader shader, out string error) + { + shader = default!; + error = string.Empty; + try + { + // A 64-lane guest wave that uses cross-lane ops needs the + // threadgroup-scratch bridge (ballots span both 32-wide halves, + // read-first-lane broadcasts across them). Programs without such + // ops are wave-size-agnostic and need no scratch. + _usesWaveScratch = IsWave64 && UsesWaveSensitiveOperations(); + + var blocks = BuildBasicBlocks(_state.Program.Instructions); + if (blocks.Count == 0) + { + error = "shader contains no executable blocks"; + return false; + } + + BuildScalarDefinitionInfo(blocks, _state.Program.Instructions); + DeclareImageKinds(); + foreach (var instruction in _state.Program.Instructions) + { + _usesLds |= instruction.Control is Gen5DataShareControl { Gds: false }; + _usesFormatLoads |= IsFormatBufferLoad(instruction.Opcode); + if (instruction.Control is Gen5InterpolationControl interpolationControl) + { + _pixelAttributes.Add(interpolationControl.Attribute); + } + + if (_stage == Gen5MslStage.Vertex && + instruction.Control is Gen5ExportControl { Target: >= 32 and < 64 } vertexExport) + { + _vertexOutputs.Add(vertexExport.Target - 32); + } + } + + if (_stage == Gen5MslStage.Vertex) + { + // Cover every location the paired fragment shader reads, + // even ones this vertex program never exports, so Metal's + // exact vertex-out/fragment-in interface match succeeds. + // Extras stay zero-filled. + for (uint location = 0; location < _requiredVertexOutputCount; location++) + { + _vertexOutputs.Add(location); + } + + foreach (var input in _evaluation.VertexInputs ?? []) + { + if (input.ComponentCount is >= 1 and <= 4) + { + _vertexInputsByPc.TryAdd(input.Pc, input); + } + } + } + + // Emit the dispatcher body first: block translation discovers + // nothing that changes the signature in the compute stage, but + // keeping the order body-then-wrap matches how the pixel/vertex + // stages will need it (their IO discovery happens during block + // translation). + _indent = 2; + for (var index = 0; index < blocks.Count; index++) + { + Line($"case {index}u:"); + Line("{"); + _indent++; + if (!TryEmitBlock(blocks, index, out error)) + { + error = $"block=0x{blocks[index].StartPc:X}: {error}"; + return false; + } + + _indent--; + Line("}"); + Line("break;"); + } + + var source = new StringBuilder(); + EmitModule(source, blocks.Count); + shader = new Gen5MslShader( + source.ToString(), + EntryPointName, + _stage, + _evaluation.GlobalMemoryBindings, + _evaluation.ImageBindings, + AttributeCount: _stage switch + { + Gen5MslStage.Pixel => (uint)_pixelAttributes.Count, + Gen5MslStage.Vertex => (uint)_vertexOutputs.Count, + _ => 0, + }, + VertexInputs: _stage == Gen5MslStage.Vertex + ? _evaluation.VertexInputs ?? [] + : [], + _localSizeX, + _localSizeY, + _localSizeZ, + UniformsBufferIndex: UniformsBufferIndex, + ImageBindingBase: _imageBindingBase, + SamplerSlots: _samplerSlots, + SamplerCount: _samplerCount, + SamplerArgBufferIndex: _samplerCount > 0 ? SamplerArgBufferIndex : -1); + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + /// Mirrors the SPIR-V translator's subgroup-usage predicates: + /// the ops whose results depend on the wave width or on other lanes. + /// A program without them is wave-size-agnostic. + private bool UsesWaveSensitiveOperations() + { + foreach (var instruction in _state.Program.Instructions) + { + if (instruction.Control is Gen5DppControl or Gen5Dpp8Control || + instruction.Opcode is "VPermlane16B32" or "VPermlanex16B32" + or "VReadlaneB32" or "VReadfirstlaneB32" + or "VMbcntLoU32B32" or "VMbcntHiU32B32" || + instruction.Opcode.Contains("Saveexec", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("SCbranchExec", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("SCbranchVcc", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("VCmpx", StringComparison.Ordinal)) + { + return true; + } + + foreach (var operand in instruction.Sources) + { + if (IsWaveMaskOperand(operand)) + { + return true; + } + } + + foreach (var operand in instruction.Destinations) + { + if (IsWaveMaskOperand(operand)) + { + return true; + } + } + } + + return false; + } + + private static bool IsWaveMaskOperand(Gen5Operand operand) => + operand.Kind == Gen5OperandKind.ScalarRegister && + operand.Value is VccLoRegister or VccHiRegister or ExecLoRegister or ExecHiRegister; + + /// Rewrites the trailing comma of the last emitted parameter + /// line into the closing parenthesis. Every stage emits each entry + /// parameter with a trailing comma so optional parameters never need + /// to know whether they are last. + private static void CloseParameterList(StringBuilder source) + { + var index = source.Length - 1; + while (index >= 0 && (source[index] == '\n' || source[index] == '\r')) + { + index--; + } + + if (index >= 0 && source[index] == ',') + { + source.Remove(index, source.Length - index); + source.AppendLine(")"); + } + } + + private string EntryPointName => _stage switch + { + Gen5MslStage.Vertex => "gen5_vs", + Gen5MslStage.Pixel => "gen5_ps", + _ => "gen5_cs", + }; + + private int UniformsBufferIndex => _globalBufferBase + _totalGlobalBufferCount; + + private void EmitModule(StringBuilder source, int blockCount) + { + source.AppendLine("// Generated by SharpEmu Gen5MslTranslator."); + source.AppendLine("#include "); + source.AppendLine(); + source.AppendLine("using namespace metal;"); + source.AppendLine(); + + // Uniforms: dispatch bounds plus per-buffer byte lengths. Metal has + // no OpArrayLength equivalent, so buffer extents travel with the + // dispatch instead of being queried in-shader. + if (_usesFormatLoads) + { + EmitFormatLoadPrelude(source); + } + + source.AppendLine("struct SharpEmuUniforms"); + source.AppendLine("{"); + source.AppendLine(" uint dispatch_limit_x;"); + source.AppendLine(" uint dispatch_limit_y;"); + source.AppendLine(" uint dispatch_limit_z;"); + source.AppendLine(" uint reserved;"); + source.AppendLine($" uint buffer_bytes[{Math.Max(_totalGlobalBufferCount, 1)}];"); + source.AppendLine("};"); + source.AppendLine(); + EmitSamplerArgumentBufferStruct(source); + EmitPrelude(source); + source.AppendLine(); + + if (_stage == Gen5MslStage.Vertex) + { + // Stage IO structs: fetched attributes from the evaluated vertex + // inputs (bound via MTLVertexDescriptor), position plus the + // param outputs the paired fragment shader reads. + if (_vertexInputsByPc.Count != 0) + { + source.AppendLine("struct Gen5VsIn"); + source.AppendLine("{"); + var declared = new HashSet(); + foreach (var input in _vertexInputsByPc.Values) + { + if (!declared.Add(input.Location)) + { + continue; + } + + var fieldType = input.ComponentCount == 1 + ? "float" + : $"float{input.ComponentCount}"; + source.AppendLine( + $" {fieldType} in{input.Location} [[attribute({input.Location})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + } + + source.AppendLine("struct Gen5VsOut"); + source.AppendLine("{"); + source.AppendLine(" float4 sharpemu_position [[position]];"); + foreach (var location in _vertexOutputs) + { + source.AppendLine($" float4 param{location} [[user(locn{location})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + source.AppendLine($"vertex Gen5VsOut {EntryPointName}("); + if (_vertexInputsByPc.Count != 0) + { + source.AppendLine(" Gen5VsIn sharpemu_vin [[stage_in]],"); + } + } + else if (_stage == Gen5MslStage.Pixel) + { + // Stage IO structs: interpolated attributes discovered from the + // program's V_INTERP controls, MRT outputs from the bindings. + source.AppendLine("struct Gen5PsIn"); + source.AppendLine("{"); + source.AppendLine(" float4 sharpemu_frag_coord [[position]];"); + foreach (var attribute in _pixelAttributes) + { + source.AppendLine($" float4 attr{attribute} [[user(locn{attribute})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + source.AppendLine("struct Gen5PsOut"); + source.AppendLine("{"); + foreach (var binding in _pixelOutputBindings) + { + var fieldType = binding.Kind switch + { + Gen5PixelOutputKind.Uint => "uint4", + Gen5PixelOutputKind.Sint => "int4", + _ => "float4", + }; + source.AppendLine( + $" {fieldType} mrt{binding.GuestSlot} [[color({binding.HostLocation})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + source.AppendLine($"fragment Gen5PsOut {EntryPointName}("); + source.AppendLine(" Gen5PsIn sharpemu_in [[stage_in]],"); + } + else + { + source.AppendLine($"kernel void {EntryPointName}("); + } + + for (var index = 0; index < _evaluation.GlobalMemoryBindings.Count; index++) + { + source.AppendLine( + $" device uint* b{index} [[buffer({_globalBufferBase + index})]],"); + } + + if (_initialScalarBufferIndex >= 0) + { + // The per-dispatch scalar-state buffer sits at its flat slot, + // past every stage's global bindings; the shader only reads it. + source.AppendLine( + $" const device uint* b{_initialScalarBufferIndex} " + + $"[[buffer({_initialScalarBufferIndex})]],"); + } + + source.AppendLine( + $" constant SharpEmuUniforms& sharpemu_uniforms [[buffer({UniformsBufferIndex})]],"); + EmitImageArguments(source); + if (_stage == Gen5MslStage.Compute) + { + source.AppendLine(" uint3 sharpemu_local_id [[thread_position_in_threadgroup]],"); + source.AppendLine(" uint3 sharpemu_group_id [[threadgroup_position_in_grid]],"); + } + + if (_stage == Gen5MslStage.Vertex) + { + source.AppendLine(" uint sharpemu_vertex_id [[vertex_id]],"); + source.AppendLine(" uint sharpemu_instance_id [[instance_id]],"); + } + else if (_stage == Gen5MslStage.Compute && IsWave64) + { + // A 64-lane guest wave is two 32-wide Apple simdgroups. Metal + // packs a threadgroup's threads into simdgroups in ascending + // thread_index order, so thread_index_in_threadgroup & 63 is the + // guest lane and its low bit-5 selects the half. Both halves sit + // in one threadgroup, so a threadgroup_barrier rendezvous bridges + // the wave for 64-wide ballots (see EmitWave64Ballot). + source.AppendLine(" uint sharpemu_tg_index [[thread_index_in_threadgroup]],"); + } + else if (_stage == Gen5MslStage.Compute) + { + // Compute threads map one-to-one onto guest lanes, so wave ops + // address the invocation's real simdgroup lane. + source.AppendLine(" uint sharpemu_lane [[thread_index_in_simdgroup]],"); + } + + CloseParameterList(source); + source.AppendLine("{"); + if (_stage == Gen5MslStage.Compute && IsWave64) + { + source.AppendLine(" uint sharpemu_lane = sharpemu_tg_index & 63u;"); + if (_usesWaveScratch && !_usesLds) + { + // Two dwords bridge each half's ballot; the third carries a + // broadcast value for read-first-lane. Indexed only by half, + // so correct for a one-wave (64-thread) workgroup — larger + // workgroups would need per-wave scratch (matches the SPIR-V + // translator's bridge scope). When the shader also uses LDS the + // bridge instead aliases the top of that allocation (below) so + // total threadgroup memory stays within Metal's 32 KB limit. + source.AppendLine(" threadgroup uint sharpemu_wave_scratch[3];"); + } + } + if (_stage != Gen5MslStage.Compute) + { + // Graphics stages model a single logical wave lane — the SPIR-V + // translator's no-subgroup fallback — because Metal leaves + // simdgroup ops undefined inside the divergent dispatcher loop. + // Ballots degrade to bit 0 in the prelude and shuffle-family + // selects resolve to the lane's own value. + source.AppendLine(" const uint sharpemu_lane = 0u;"); + } + if (_usesLds) + { + if (_stage == Gen5MslStage.Compute) + { + // 32 KB of guest LDS as workgroup-shared memory; the address + // is masked into bounds like the SPIR-V translator. + source.AppendLine($" threadgroup uint sharpemu_lds[{LdsDwordCount}];"); + } + else + { + // Graphics stages model LDS as per-invocation scratch (the + // SPIR-V translator's Private-array trick), sized smaller + // because only write-then-read correctness is needed. + source.AppendLine($" thread uint sharpemu_lds[{PrivateLdsDwordCount}] = {{}};"); + } + } + + if (_usesWaveScratch && _usesLds && _stage == Gen5MslStage.Compute) + { + // Reuse the final three dwords of the LDS allocation for the + // wave64 bridge. A separate threadgroup array would push total + // threadgroup memory past Metal's 32 KB limit for shaders that + // request the full LDS (mirrors the SPIR-V translator). Guest LDS + // accesses are bounds-masked into the same allocation, so this + // trades a rare top-of-LDS collision for a compilable pipeline. + source.AppendLine( + $" threadgroup uint* sharpemu_wave_scratch = &sharpemu_lds[{LdsDwordCount - 3}u];"); + } + + EmitRegisterFile(source); + if (_stage == Gen5MslStage.Pixel) + { + source.AppendLine(" Gen5PsOut sharpemu_out = {};"); + } + else if (_stage == Gen5MslStage.Vertex) + { + // Zero-initialized: param outputs the program never exports + // stay (0,0,0,0) to satisfy the fragment interface. + source.AppendLine(" Gen5VsOut sharpemu_out = {};"); + } + + EmitInitialState(source); + source.AppendLine(); + source.AppendLine(" while (active)"); + source.AppendLine(" {"); + source.AppendLine(" switch (pc)"); + source.AppendLine(" {"); + source.Append(_body); + source.AppendLine(" default:"); + source.AppendLine(" active = false;"); + source.AppendLine(" break;"); + source.AppendLine(" }"); + if (_maxDispatcherSteps > 0) + { + source.AppendLine($" if (++steps >= {_maxDispatcherSteps}u)"); + source.AppendLine(" {"); + source.AppendLine(" active = false;"); + source.AppendLine(" }"); + } + + source.AppendLine(" }"); + if (_stage == Gen5MslStage.Pixel) + { + // A lane still removed from EXEC when the guest shader exits is + // a killed fragment; it must not contribute color or blending. + source.AppendLine(" if (!exec)"); + source.AppendLine(" {"); + source.AppendLine(" discard_fragment();"); + source.AppendLine(" }"); + source.AppendLine(" return sharpemu_out;"); + } + else if (_stage == Gen5MslStage.Vertex) + { + source.AppendLine(" return sharpemu_out;"); + } + + source.AppendLine("}"); + } + + // Shared helpers: MSL allows free functions, so unaligned and subdword + // access is a byte-pointer cast instead of the manual word-combining + // the SPIR-V translator inlines at every site. All access is + // range-checked against the binding's byte length; loads outside the + // buffer produce zero and stores are dropped, matching the SPIR-V + // translator's robust-access behavior. The static text lives in + // Templates/prelude.msl; only the wave-ballot expression varies. + // + // Graphics stages model one logical wave lane (lane 0), so a ballot is + // just that lane's bit: "value ? 1 : 0". A real simd_ballot cannot be + // used there: the translated program runs inside the divergent + // while(active){switch(pc)} dispatcher, where Metal leaves cross-lane + // ops undefined, so lanes at different pc corrupt each other's EXEC/VCC + // reconstruction and kill whole quads (all fragments discarded). This + // is the SPIR-V translator's no-subgroup fallback model. Compute + // mirrors the SPIR-V translator's compute path instead: threads map + // one-to-one onto real simdgroup lanes and ballots are real, so masks + // parked in VCC/EXEC hold each lane's actual bit. + private void EmitPrelude(StringBuilder source) => + source.Append(MslTemplates.Render( + "prelude", + ("ballot_return", _stage == Gen5MslStage.Compute + ? "(uint)(uint64_t)simd_ballot(value)" + : "value ? 1u : 0u"))); + + // The GFX10 unified-format table is baked from the same authoritative + // decoder descriptor evaluation uses (dataFormat | numberFormat << 8); + // the descriptor is read at execution time, so decoding stays dynamic — + // compiled shaders may be reused with new SRDs. The static conversion + // functions live in Templates/format_prelude.msl. + private static void EmitFormatLoadPrelude(StringBuilder source) + { + var table = new StringBuilder(); + for (uint format = 0; format < 128; format++) + { + Gfx10UnifiedFormat.TryDecode(format, out var dataFormat, out var numberFormat); + if ((format & 15) == 0) + { + if (format != 0) + { + table.AppendLine(); + } + + table.Append(" "); + } + + table.Append($" 0x{dataFormat | (numberFormat << 8):X}u,"); + } + + var layoutCases = new StringBuilder(); + var first = true; + foreach (var (component, format, bytes, bitOffset, bitCount) in FormatComponentLayouts()) + { + if (!first) + { + layoutCases.AppendLine(); + } + + first = false; + layoutCases.Append( + $" case {component * 16 + format}u: byteOff = {bytes}u; bitOff = {bitOffset}u; bits = {bitCount}u; break;"); + } + + source.AppendLine(MslTemplates.Render( + "format_prelude", + ("format_table", table.ToString()), + ("layout_cases", layoutCases.ToString()))); + } + + /// + /// The legacy DATA_FORMAT component layouts the SPIR-V translator encodes + /// in LoadGfx10BufferFormatComponent, as (component, format, byteOffset, + /// bitOffset, bitCount) tuples. + /// + private static IEnumerable<(uint Component, uint Format, uint Bytes, uint BitOffset, uint BitCount)> FormatComponentLayouts() + { + // Component 0. + yield return (0, 1, 0, 0, 8); + yield return (0, 2, 0, 0, 16); + yield return (0, 3, 0, 0, 8); + yield return (0, 4, 0, 0, 32); + yield return (0, 5, 0, 0, 16); + yield return (0, 6, 0, 0, 10); + yield return (0, 7, 0, 0, 11); + yield return (0, 8, 0, 0, 10); + yield return (0, 9, 0, 0, 2); + yield return (0, 10, 0, 0, 8); + yield return (0, 11, 0, 0, 32); + yield return (0, 12, 0, 0, 16); + yield return (0, 13, 0, 0, 32); + yield return (0, 14, 0, 0, 32); + // Component 1. + yield return (1, 3, 1, 0, 8); + yield return (1, 5, 2, 0, 16); + yield return (1, 6, 0, 10, 11); + yield return (1, 7, 0, 11, 11); + yield return (1, 8, 0, 10, 10); + yield return (1, 9, 0, 2, 10); + yield return (1, 10, 1, 0, 8); + yield return (1, 11, 4, 0, 32); + yield return (1, 12, 2, 0, 16); + yield return (1, 13, 4, 0, 32); + yield return (1, 14, 4, 0, 32); + // Component 2. + yield return (2, 6, 0, 21, 11); + yield return (2, 7, 0, 22, 10); + yield return (2, 8, 0, 20, 10); + yield return (2, 9, 0, 12, 10); + yield return (2, 10, 2, 0, 8); + yield return (2, 12, 4, 0, 16); + yield return (2, 13, 8, 0, 32); + yield return (2, 14, 8, 0, 32); + // Component 3. + yield return (3, 8, 0, 30, 2); + yield return (3, 9, 0, 22, 10); + yield return (3, 10, 3, 0, 8); + yield return (3, 12, 6, 0, 16); + yield return (3, 14, 12, 0, 32); + } + + private void EmitRegisterFile(StringBuilder source) + { + source.AppendLine($" uint s[{ScalarRegisterFileCount}] = {{}};"); + source.AppendLine($" uint v[{VectorRegisterFileCount}] = {{}};"); + source.AppendLine(" bool exec = true;"); + source.AppendLine(" bool vcc = false;"); + source.AppendLine(" bool scc = false;"); + source.AppendLine(" uint pc = 0u;"); + source.AppendLine(" bool active = true;"); + source.AppendLine(" uint steps = 0u;"); + } + + private void EmitInitialState(StringBuilder source) + { + if (_initialScalarBufferIndex >= 0) + { + // Initial scalar registers arrive in a per-dispatch buffer so + // animated user data reuses one translation, mirroring the + // SPIR-V translator. Word 256+i of the same buffer carries the + // per-binding byte bias for suballocated guest buffers. + var consumed = Gen5ShaderTranslator.ComputeConsumedScalarMask(_state.Program); + for (uint index = 0; + index < _evaluation.InitialScalarRegisters.Count && + index < ScalarRegisterFileCount; + index++) + { + if (Gen5ShaderTranslator.IsScalarConsumed(consumed, index)) + { + source.AppendLine( + $" s[{index}] = b{_initialScalarBufferIndex}[{index}];"); + } + } + + var biasCount = _globalBufferBase + _evaluation.GlobalMemoryBindings.Count; + source.AppendLine($" uint bias[{Math.Max(biasCount, 1)}] = {{}};"); + for (var binding = 0; binding < biasCount; binding++) + { + source.AppendLine( + $" bias[{binding}] = b{_initialScalarBufferIndex}[{256 + binding}];"); + } + } + else + { + for (uint index = 0; + index < _evaluation.InitialScalarRegisters.Count && + index < ScalarRegisterFileCount; + index++) + { + var value = _evaluation.InitialScalarRegisters[(int)index]; + if (value != 0) + { + source.AppendLine($" s[{index}] = 0x{value:X}u;"); + } + } + + var biasCount = _globalBufferBase + _evaluation.GlobalMemoryBindings.Count; + source.AppendLine($" uint bias[{Math.Max(biasCount, 1)}] = {{}};"); + } + + if (_stage == Gen5MslStage.Compute) + { + source.AppendLine(" v[0] = sharpemu_local_id.x;"); + source.AppendLine(" v[1] = sharpemu_local_id.y;"); + source.AppendLine(" v[2] = sharpemu_local_id.z;"); + + // Partial-group guard: lanes whose global id falls outside the + // guest dispatch stay inactive, matching the SPIR-V bounds + // check driven by the same uniform. + source.AppendLine( + $" active = (sharpemu_group_id.x * {_localSizeX}u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x"); + source.AppendLine( + $" && (sharpemu_group_id.y * {_localSizeY}u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y"); + source.AppendLine( + $" && (sharpemu_group_id.z * {_localSizeZ}u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;"); + + if (_state.ComputeSystemRegisters is { } registers) + { + EmitComputeSystemRegister(source, registers.WorkGroupXRegister, "sharpemu_group_id.x"); + EmitComputeSystemRegister(source, registers.WorkGroupYRegister, "sharpemu_group_id.y"); + EmitComputeSystemRegister(source, registers.WorkGroupZRegister, "sharpemu_group_id.z"); + if (registers.ThreadGroupSizeRegister is { } sizeRegister && + sizeRegister < ScalarRegisterFileCount) + { + source.AppendLine( + $" s[{sizeRegister}] = {checked(_localSizeX * _localSizeY * _localSizeZ)}u;"); + } + } + } + else if (_stage == Gen5MslStage.Pixel) + { + EmitPixelInputState(source); + } + else if (_stage == Gen5MslStage.Vertex) + { + // Hardware-selected VGPRs for the vertex and instance indices. + source.AppendLine(" v[5] = sharpemu_vertex_id;"); + source.AppendLine(" v[8] = sharpemu_instance_id;"); + } + + // VCC/EXEC live in their architectural SGPRs (see StoreScalar); + // establish the entry state over whatever the initial-scalar block + // carried so the register file and the bool views agree from the + // first instruction. + source.AppendLine($" s[{VccLoRegister}] = 0u;"); + source.AppendLine($" s[{VccHiRegister}] = 0u;"); + if (IsWave64) + { + // All 64 lanes are active at entry (before the dispatcher masks + // any off), and this runs at a uniform point, so the bridge + // barriers are safe here too. + EmitBallotStoreAtEntry(source, ExecLoRegister, "true"); + } + else + { + source.AppendLine($" s[{ExecLoRegister}] = sharpemu_ballot(true);"); + source.AppendLine($" s[{ExecHiRegister}] = 0u;"); + } + } + + /// Entry-time form of writing to + /// at the fixed indent of the module prologue. + private void EmitBallotStoreAtEntry(StringBuilder source, uint loRegister, string condition) + { + if (!_usesWaveScratch) + { + // No cross-lane ops: the high half stays zero and the low half + // is this simdgroup's ballot, matching the wave-agnostic path. + source.AppendLine($" s[{loRegister}] = sharpemu_ballot({condition});"); + source.AppendLine($" s[{loRegister + 1}] = 0u;"); + return; + } + + source.AppendLine( + $" sharpemu_wave_scratch[(sharpemu_lane >> 5) & 1u] = sharpemu_ballot({condition});"); + source.AppendLine(" threadgroup_barrier(mem_flags::mem_threadgroup);"); + source.AppendLine($" s[{loRegister}] = sharpemu_wave_scratch[0];"); + source.AppendLine($" s[{loRegister + 1}] = sharpemu_wave_scratch[1];"); + source.AppendLine(" threadgroup_barrier(mem_flags::mem_threadgroup);"); + } + + private static void EmitComputeSystemRegister( + StringBuilder source, + uint? scalarRegister, + string expression) + { + if (scalarRegister is { } register && register < ScalarRegisterFileCount) + { + source.AppendLine($" s[{register}] = {expression};"); + } + } + + // ---- dispatcher blocks ---- + + private bool TryEmitBlock( + IReadOnlyList blocks, + int blockIndex, + out string error) + { + error = string.Empty; + var block = blocks[blockIndex]; + var instructions = _state.Program.Instructions; + for (var index = block.StartIndex; index < block.EndIndex; index++) + { + var instruction = instructions[index]; + var isTerminator = index == block.EndIndex - 1; + if (instruction.Opcode == "SEndpgm") + { + Line("active = false;"); + return true; + } + + if (instruction.Opcode == "SBranch") + { + // A branch to (or past) the program's end is an exit — the + // pattern sprite alpha-kill shaders use to skip their tail. + if (IsExitBranchTarget(instructions, instruction)) + { + Line("active = false;"); + return true; + } + + if (!TryGetBranchTargetBlock(blocks, instruction, out var target)) + { + error = $"branch target outside program at pc=0x{instruction.Pc:X}"; + return false; + } + + Line($"pc = {target}u;"); + return true; + } + + if (instruction.Opcode.StartsWith("SCbranch", StringComparison.Ordinal)) + { + if (!TryGetBranchCondition(instruction.Opcode, out var condition)) + { + error = $"unsupported conditional branch {instruction.Opcode}"; + return false; + } + + var fallthrough = blockIndex + 1; + if (IsExitBranchTarget(instructions, instruction)) + { + // Taken → exit; not taken → fall through (or exit when + // this is the last block anyway). + if (fallthrough >= blocks.Count) + { + Line("active = false;"); + } + else + { + Line($"pc = {fallthrough}u;"); + Line($"active = !({condition});"); + } + + return true; + } + + if (!TryGetBranchTargetBlock(blocks, instruction, out var target)) + { + error = $"branch target outside program at pc=0x{instruction.Pc:X}"; + return false; + } + + if (fallthrough >= blocks.Count) + { + Line($"pc = ({condition}) ? {target}u : 0xFFFFFFFFu;"); + Line($"active = ({condition});"); + } + else + { + Line($"pc = ({condition}) ? {target}u : {fallthrough}u;"); + } + + return true; + } + + if (!TryEmitInstruction(instruction, out error)) + { + error = $"pc=0x{instruction.Pc:X4} {instruction.Opcode}: {error}"; + return false; + } + + if (isTerminator) + { + // Fall through to the next block (or exit at program end). + if (blockIndex + 1 < blocks.Count) + { + Line($"pc = {blockIndex + 1}u;"); + } + else + { + Line("active = false;"); + } + } + } + + return true; + } + + private bool TryGetBranchCondition(string opcode, out string condition) + { + condition = opcode switch + { + "SCbranchScc0" => "!scc", + "SCbranchScc1" => "scc", + // VCCZ/EXECZ test the full architectural register pair, which + // also covers programs that parked plain data in VCC. + "SCbranchVccz" => $"(s[{VccLoRegister}] | s[{VccHiRegister}]) == 0u", + "SCbranchVccnz" => $"(s[{VccLoRegister}] | s[{VccHiRegister}]) != 0u", + "SCbranchExecz" => $"(s[{ExecLoRegister}] | s[{ExecHiRegister}]) == 0u", + "SCbranchExecnz" => $"(s[{ExecLoRegister}] | s[{ExecHiRegister}]) != 0u", + _ => string.Empty, + }; + return condition.Length != 0; + } + + private static bool TryGetBranchTargetBlock( + IReadOnlyList blocks, + Gen5ShaderInstruction instruction, + out int block) + { + block = -1; + return TryGetBranchTargetPc(instruction, out var targetPc) && + TryFindBlock(blocks, targetPc, out block); + } + + /// True when the branch lands at or past the last instruction's + /// end — an exit, matching the SPIR-V translator's handling. + private static bool IsExitBranchTarget( + IReadOnlyList instructions, + Gen5ShaderInstruction instruction) + { + if (instructions.Count == 0 || + !TryGetBranchTargetPc(instruction, out var targetPc)) + { + return false; + } + + var last = instructions[^1]; + var lastEndPc = last.Pc + (uint)(last.Words.Count * sizeof(uint)); + return targetPc >= lastEndPc; + } + + // ---- instruction dispatch ---- + + private bool TryEmitInstruction( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + switch (instruction.Opcode) + { + case "SNop": + case "SWaitcnt": + case "SInstPrefetch": + case "STtraceData": + case "SClause": + case "VNop": + // NGG shaders bracket their exports with s_sendmsg + // (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space; + // exports are translated directly, so the message is moot. + case "SSendmsg": + return true; + case "SBarrier": + Line("threadgroup_barrier(mem_flags::mem_threadgroup | mem_flags::mem_device);"); + return true; + } + + if (instruction.Control is Gen5ImageControl imageControl) + { + return TryEmitImage(instruction, imageControl, out error); + } + + if (instruction.Control is Gen5ExportControl exportControl) + { + return TryEmitExport(instruction, exportControl, out error); + } + + if (instruction.Control is Gen5InterpolationControl interpolationControl) + { + return TryEmitInterpolation(instruction, interpolationControl, out error); + } + + if (instruction.Control is Gen5DataShareControl dataShare) + { + return TryEmitDataShare(instruction, dataShare, out error); + } + + if (instruction.Control is Gen5ScalarMemoryControl scalarMemory) + { + return TryEmitScalarMemory(instruction, scalarMemory, out error); + } + + if (instruction.Control is Gen5GlobalMemoryControl globalMemory) + { + return TryEmitGlobalMemory(instruction, globalMemory, out error); + } + + if (instruction.Control is Gen5BufferMemoryControl bufferMemory) + { + return TryEmitBufferMemory(instruction, bufferMemory, out error); + } + + if (instruction.Opcode.StartsWith("V", StringComparison.Ordinal)) + { + return TryEmitVectorAlu(instruction, out error); + } + + if (instruction.Opcode.StartsWith("S", StringComparison.Ordinal)) + { + return TryEmitScalarAlu(instruction, out error); + } + + error = "unsupported instruction"; + return false; + } + + // ---- memory ---- + + private bool TryEmitScalarMemory( + Gen5ShaderInstruction instruction, + Gen5ScalarMemoryControl control, + out string error) + { + error = string.Empty; + var scalarAddress = instruction.Sources.Count != 0 && + instruction.Sources[0].Kind == Gen5OperandKind.ScalarRegister + ? instruction.Sources[0].Value + : uint.MaxValue; + if (!TryResolveDominatingBufferBinding( + instruction.Pc, + scalarAddress, + registerCount: instruction.Opcode.StartsWith( + "SBufferLoad", + StringComparison.Ordinal) ? 4u : 2u, + out var bindingIndex)) + { + foreach (var destination in instruction.Destinations) + { + if (destination.Kind == Gen5OperandKind.ScalarRegister) + { + StoreScalar(destination.Value, "0u"); + } + } + + return true; + } + + var offset = control.DynamicOffsetRegister is { } register + ? $"(s[{register}] + 0x{unchecked((uint)control.ImmediateOffsetBytes):X}u)" + : $"0x{unchecked((uint)control.ImmediateOffsetBytes):X}u"; + var address = Temp("uint", ApplyByteBias(bindingIndex, offset)); + for (var index = 0; index < instruction.Destinations.Count; index++) + { + var destination = instruction.Destinations[index]; + if (destination.Kind != Gen5OperandKind.ScalarRegister) + { + error = "invalid scalar-memory destination"; + return false; + } + + StoreScalar( + destination.Value, + LoadWord(bindingIndex, $"({address} + {index * 4}u)")); + } + + return true; + } + + private bool TryEmitGlobalMemory( + Gen5ShaderInstruction instruction, + Gen5GlobalMemoryControl control, + out string error) + { + error = string.Empty; + if (!TryResolveDominatingBufferBinding( + instruction.Pc, + control.ScalarAddress, + registerCount: 2, + out var bindingIndex)) + { + error = "missing global-memory binding"; + return false; + } + + var address = Temp( + "uint", + ApplyByteBias( + bindingIndex, + $"(v[{control.VectorAddress}] + 0x{unchecked((uint)control.OffsetBytes):X}u)")); + return TryEmitResolvedMemoryAccess( + instruction.Opcode, + bindingIndex, + address, + control.VectorData, + control.DwordCount, + control.Glc, + out error); + } + + private bool TryEmitBufferMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + out string error) + { + error = string.Empty; + if (_stage == Gen5MslStage.Vertex && + _vertexInputsByPc.TryGetValue(instruction.Pc, out var vertexInput)) + { + return TryEmitVertexInputFetch(control, vertexInput, out error); + } + + if (!TryResolveDominatingBufferBinding( + instruction.Pc, + control.ScalarResource, + registerCount: 4, + out var bindingIndex)) + { + error = "missing buffer-memory binding"; + return false; + } + + var scalarOffset = instruction.Sources.Count > 2 + ? SourceExpression(instruction.Sources[2], instruction) + : "0u"; + var stride = $"((s[{control.ScalarResource + 1}] >> 16) & 0x3FFFu)"; + var vectorIndex = control.IndexEnabled + ? $"v[{control.VectorAddress}]" + : "0u"; + var vectorOffset = control.OffsetEnabled + ? $"v[{control.VectorAddress + (control.IndexEnabled ? 1u : 0u)}]" + : "0u"; + var address = Temp( + "uint", + ApplyByteBias( + bindingIndex, + $"(0x{unchecked((uint)control.OffsetBytes):X}u + {scalarOffset} + {vectorOffset} + ({vectorIndex} * {stride}))")); + // Typed MUBUF/MTBUF loads convert through the descriptor's unified + // format; raw dword loads and every store take the byte path below + // (format stores write raw dwords, matching the SPIR-V translator). + if (IsFormatBufferLoad(instruction.Opcode) && + !instruction.Opcode.StartsWith("BufferStore", StringComparison.Ordinal)) + { + EmitBufferFormatLoad( + bindingIndex, + address, + control.ScalarResource, + control.VectorData, + control.DwordCount); + return true; + } + + return TryEmitResolvedMemoryAccess( + instruction.Opcode, + bindingIndex, + address, + control.VectorData, + control.DwordCount, + control.Glc, + out error); + } + + private void EmitBufferFormatLoad( + int bindingIndex, + string byteAddress, + uint scalarResource, + uint vectorData, + uint componentCount) + { + // Format and destination swizzle come from descriptor word 3 at + // execution time; the prelude table decodes the unified format the + // same way descriptor evaluation does. + var word3 = Temp("uint", ScalarExpression(scalarResource + 3)); + var entry = Temp("uint", $"sharpemu_gfx10_formats[({word3} >> 12) & 0x7Fu]"); + var dataFormat = Temp("uint", $"{entry} & 0xFFu"); + var numberFormat = Temp("uint", $"({entry} >> 8) & 0xFFu"); + var canonical = new string[4]; + for (var component = 0; component < 4; component++) + { + var byteOff = Temp("uint", "0u"); + var bitOff = Temp("uint", "0u"); + var bits = Temp("uint", "0u"); + Line($"sharpemu_format_layout({dataFormat}, {component}u, {byteOff}, {bitOff}, {bits});"); + var packed = Temp( + "uint", + LoadWord(bindingIndex, $"({byteAddress} + {byteOff})")); + var raw = Temp( + "uint", + $"{bits} == 0u ? 0u : extract_bits({packed}, {bitOff}, {bits})"); + var missing = component == 3 + ? $"sharpemu_format_one({numberFormat})" + : "0u"; + canonical[component] = Temp( + "uint", + $"{bits} == 0u ? {missing} : sharpemu_format_convert({raw}, {bits}, {numberFormat}, {dataFormat})"); + } + + for (uint destination = 0; destination < componentCount; destination++) + { + var selector = Temp("uint", $"({word3} >> {destination * 3}u) & 7u"); + StoreVector( + vectorData + destination, + $"{selector} == 1u ? sharpemu_format_one({numberFormat}) : " + + $"{selector} == 4u ? {canonical[0]} : " + + $"{selector} == 5u ? {canonical[1]} : " + + $"{selector} == 6u ? {canonical[2]} : " + + $"{selector} == 7u ? {canonical[3]} : 0u"); + } + } + + private bool TryEmitDataShare( + Gen5ShaderInstruction instruction, + Gen5DataShareControl control, + out string error) + { + error = string.Empty; + if (control.Gds) + { + error = "GDS data share is not implemented"; + return false; + } + + var ldsMask = _stage == Gen5MslStage.Compute + ? LdsDwordMask + : PrivateLdsDwordCount - 1; + string LdsIndex(string address, uint offsetBytes) => + offsetBytes == 0 + ? $"((({address}) >> 2) & {ldsMask}u)" + : $"(((({address}) + {offsetBytes}u) >> 2) & {ldsMask}u)"; + + void StoreLds(string index, string value) + { + // Exec-guarded like every other lane-visible write. + Line($"if (exec) {{ sharpemu_lds[{index}] = {value}; }}"); + } + + switch (instruction.Opcode) + { + case "DsAddU32": + { + var address = Temp("uint", RawSource(instruction, 0)); + var value = Temp("uint", RawSource(instruction, 1)); + Line("if (exec)"); + Line("{"); + _indent++; + Line($"atomic_fetch_add_explicit((threadgroup atomic_uint*)&sharpemu_lds[{LdsIndex(address, control.Offset0)}], {value}, memory_order_relaxed);"); + _indent--; + Line("}"); + return true; + } + case "DsWriteB32": + { + var address = Temp("uint", RawSource(instruction, 0)); + StoreLds(LdsIndex(address, control.Offset0), RawSource(instruction, 1)); + return true; + } + case "DsWriteB64": + { + var address = Temp("uint", RawSource(instruction, 0)); + StoreLds(LdsIndex(address, control.Offset0), RawSource(instruction, 1)); + StoreLds(LdsIndex(address, control.Offset0 + sizeof(uint)), RawSource(instruction, 2)); + return true; + } + case "DsWriteB96": + case "DsWriteB128": + { + var dwordCount = instruction.Opcode == "DsWriteB128" ? 4 : 3; + var address = Temp("uint", RawSource(instruction, 0)); + for (var dword = 0; dword < dwordCount; dword++) + { + StoreLds( + LdsIndex(address, control.Offset0 + (uint)(dword * sizeof(uint))), + RawSource(instruction, 1 + dword)); + } + + return true; + } + case "DsWrite2B32": + case "DsWrite2St64B32": + { + var st64 = instruction.Opcode == "DsWrite2St64B32"; + var address = Temp("uint", RawSource(instruction, 0)); + StoreLds( + LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset0, st64)), + RawSource(instruction, 1)); + StoreLds( + LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset1, st64)), + RawSource(instruction, 2)); + return true; + } + case "DsReadB32": + { + var address = Temp("uint", RawSource(instruction, 0)); + StoreVector( + instruction.Destinations[0].Value, + $"sharpemu_lds[{LdsIndex(address, control.Offset0)}]"); + return true; + } + case "DsReadB96": + case "DsReadB128": + { + var dwordCount = instruction.Opcode == "DsReadB128" ? 4 : 3; + if (instruction.Destinations.Count < dwordCount) + { + error = "missing LDS read operand"; + return false; + } + + var address = Temp("uint", RawSource(instruction, 0)); + for (var dword = 0; dword < dwordCount; dword++) + { + StoreVector( + instruction.Destinations[dword].Value, + $"sharpemu_lds[{LdsIndex(address, control.Offset0 + (uint)(dword * sizeof(uint)))}]"); + } + + return true; + } + case "DsRead2B32": + case "DsRead2St64B32": + { + if (instruction.Destinations.Count < 2) + { + error = "missing LDS read2 operand"; + return false; + } + + var st64 = instruction.Opcode == "DsRead2St64B32"; + var address = Temp("uint", RawSource(instruction, 0)); + StoreVector( + instruction.Destinations[0].Value, + $"sharpemu_lds[{LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset0, st64))}]"); + StoreVector( + instruction.Destinations[1].Value, + $"sharpemu_lds[{LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset1, st64))}]"); + return true; + } + default: + error = $"unsupported LDS opcode {instruction.Opcode}"; + return false; + } + } + + private static uint EffectiveDsPairOffsetBytes(uint offset, bool st64) => + offset * (st64 ? 256u : sizeof(uint)); + + private bool TryEmitResolvedMemoryAccess( + string opcode, + int bindingIndex, + string byteAddress, + uint vectorData, + uint dwordCount, + bool glc, + out string error) + { + error = string.Empty; + if (opcode is "GlobalAtomicAdd" or "BufferAtomicAdd" or + "GlobalAtomicUMax" or "BufferAtomicUMax") + { + var function = opcode.EndsWith("Add", StringComparison.Ordinal) + ? "atomic_fetch_add_explicit" + : "atomic_fetch_max_explicit"; + Line("if (exec)"); + Line("{"); + _indent++; + Line($"if ({byteAddress} + 4u <= {BufferBytes(bindingIndex)} && ({byteAddress} & 3u) == 0u)"); + Line("{"); + _indent++; + var original = Temp( + "uint", + $"{function}((device atomic_uint*)(b{bindingIndex} + ({byteAddress} >> 2)), v[{vectorData}], memory_order_relaxed)"); + if (glc) + { + Line($"v[{vectorData}] = {original};"); + } + + _indent--; + Line("}"); + _indent--; + Line("}"); + return true; + } + + if (opcode.StartsWith("GlobalStore", StringComparison.Ordinal) || + opcode.StartsWith("BufferStore", StringComparison.Ordinal)) + { + Line("if (exec)"); + Line("{"); + _indent++; + if (TryGetSubdwordStoreInfo(opcode, out var storeBytes, out var sourceShift)) + { + var source = sourceShift == 0 + ? $"v[{vectorData}]" + : $"(v[{vectorData}] >> {sourceShift})"; + Line($"sharpemu_store_bytes(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress}, {source}, {storeBytes}u);"); + } + else + { + for (uint index = 0; index < dwordCount; index++) + { + Line($"sharpemu_store_bytes(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress} + {index * 4}u, v[{vectorData + index}], 4u);"); + } + } + + _indent--; + Line("}"); + return true; + } + + if (TryGetSubdwordLoadInfo(opcode, out var loadBytes, out var signExtend, out var d16, out var d16High)) + { + var loaded = Temp( + "uint", + $"sharpemu_load_bytes(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress}, {loadBytes}u, {(signExtend ? "true" : "false")})"); + if (!d16) + { + StoreVector(vectorData, loaded); + return true; + } + + // D16 loads merge into one half of the destination register. + StoreVector( + vectorData, + d16High + ? $"(v[{vectorData}] & 0x0000FFFFu) | (({loaded} & 0xFFFFu) << 16)" + : $"(v[{vectorData}] & 0xFFFF0000u) | ({loaded} & 0xFFFFu)"); + return true; + } + + if (opcode.StartsWith("GlobalLoad", StringComparison.Ordinal) || + opcode.StartsWith("BufferLoad", StringComparison.Ordinal)) + { + for (uint index = 0; index < dwordCount; index++) + { + StoreVector( + vectorData + index, + LoadWord(bindingIndex, $"({byteAddress} + {index * 4}u)")); + } + + return true; + } + + error = $"unsupported memory opcode {opcode}"; + return false; + } + + private static bool TryGetSubdwordLoadInfo( + string opcode, + out uint byteCount, + out bool signExtend, + out bool d16, + out bool d16High) + { + byteCount = opcode.Contains("byte", StringComparison.OrdinalIgnoreCase) ? 1u : 2u; + signExtend = opcode.Contains("Sbyte", StringComparison.Ordinal) || + opcode.Contains("Sshort", StringComparison.Ordinal); + d16 = opcode.Contains("D16", StringComparison.Ordinal); + d16High = opcode.EndsWith("D16Hi", StringComparison.Ordinal); + return opcode.Contains("LoadUbyte", StringComparison.Ordinal) || + opcode.Contains("LoadSbyte", StringComparison.Ordinal) || + opcode.Contains("LoadUshort", StringComparison.Ordinal) || + opcode.Contains("LoadSshort", StringComparison.Ordinal) || + opcode.Contains("LoadShortD16", StringComparison.Ordinal); + } + + private static bool TryGetSubdwordStoreInfo( + string opcode, + out uint byteCount, + out uint sourceShift) + { + byteCount = opcode.Contains("StoreByte", StringComparison.Ordinal) ? 1u : 2u; + sourceShift = opcode.EndsWith("D16Hi", StringComparison.Ordinal) ? 16u : 0u; + return opcode.Contains("StoreByte", StringComparison.Ordinal) || + opcode.Contains("StoreShort", StringComparison.Ordinal); + } + + private static bool IsFormatBufferLoad(string opcode) => + opcode.StartsWith("BufferLoadFormat", StringComparison.Ordinal) || + opcode.StartsWith("TBufferLoad", StringComparison.Ordinal); + + private string BufferBytes(int bindingIndex) => + $"sharpemu_uniforms.buffer_bytes[{_globalBufferBase + bindingIndex}]"; + + private string LoadWord(int bindingIndex, string byteAddress) => + $"sharpemu_load_word(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress})"; + + private string ApplyByteBias(int bindingIndex, string byteAddress) => + $"({byteAddress} + bias[{_globalBufferBase + bindingIndex}])"; + + // ---- binding resolution (ports the SPIR-V dominating-definition scheme) ---- + + private bool TryResolveDominatingBufferBinding( + uint pc, + uint scalarAddress, + uint registerCount, + out int bindingIndex) + { + if (_bufferBindingByPc.TryGetValue(pc, out bindingIndex)) + { + return true; + } + + var candidates = _evaluation.GlobalMemoryBindings; + for (var index = 0; index < candidates.Count; index++) + { + var binding = candidates[index]; + foreach (var bindingPc in binding.InstructionPcs) + { + if (bindingPc == pc) + { + bindingIndex = index; + _bufferBindingByPc.Add(pc, index); + return true; + } + } + } + + // No direct PC match: accept a binding only when the descriptor + // registers hold the exact same definitions here as at one of the + // binding's own access points — the scalar-definition dataflow the + // SPIR-V translator uses for descriptors shared across sites. + for (var index = 0; index < candidates.Count; index++) + { + var binding = candidates[index]; + if (binding.ScalarAddress != scalarAddress) + { + continue; + } + + foreach (var candidatePc in binding.InstructionPcs) + { + if (!HasSameScalarDefinitions(candidatePc, pc, scalarAddress, registerCount)) + { + continue; + } + + bindingIndex = index; + _bufferBindingByPc.Add(pc, index); + return true; + } + } + + bindingIndex = -1; + return false; + } + + // ---- writer helpers ---- + + private void Line(string text) + { + for (var index = 0; index < _indent; index++) + { + _body.Append(" "); + } + + _body.AppendLine(text); + } + + private string Temp(string type, string expression) + { + var name = $"t{_nextTemp++}"; + Line($"{type} {name} = {expression};"); + return name; + } + + // VCC (s106:s107) and EXEC (s126:s127) are architectural SGPRs: programs + // freely use them as scratch data registers (s_buffer_load into s[106], + // then v_rcp_f32 of that value is real RDNA2 code). The register file + // holds their raw 32-bit values as the source of truth; the bools + // vcc/exec are cached per-lane views kept in sync at every write so + // control flow stays cheap. Reading them back as data returns the file. + private void StoreScalar(uint register, string expression) + { + switch (register) + { + case VccLoRegister: + { + var value = Temp("uint", expression); + Line($"s[{VccLoRegister}] = {value};"); + Line($"vcc = (({value}) >> sharpemu_lane & 1u) != 0u;"); + return; + } + + case ExecLoRegister: + { + var value = Temp("uint", expression); + Line($"s[{ExecLoRegister}] = {value};"); + Line($"exec = (({value}) >> sharpemu_lane & 1u) != 0u;"); + return; + } + + case VccHiRegister: + case ExecHiRegister: + // Wave32: the high halves carry no lanes, but keep the data. + Line($"s[{register}] = {expression};"); + return; + } + + if (register < ScalarRegisterFileCount) + { + Line($"s[{register}] = {expression};"); + } + } + + private void StoreVector(uint register, string expression, bool guardWithExec = true) + { + if (register >= VectorRegisterFileCount) + { + return; + } + + if (guardWithExec) + { + Line($"if (exec) {{ v[{register}] = {expression}; }}"); + } + else + { + Line($"v[{register}] = {expression};"); + } + } + + private string ScalarExpression(uint register) => + register < ScalarRegisterFileCount ? $"s[{register}]" : "0u"; + + private string SourceExpression( + Gen5Operand operand, + Gen5ShaderInstruction instruction) + { + switch (operand.Kind) + { + case Gen5OperandKind.ScalarRegister: + return ScalarExpression(operand.Value); + case Gen5OperandKind.VectorRegister: + return $"v[{operand.Value}]"; + case Gen5OperandKind.LiteralConstant: + return FormatUInt(operand.Value); + case Gen5OperandKind.EncodedConstant: + // 251/252/253 read the VCCZ/EXECZ/SCC status bits as data. + if (operand.Value == 251) + { + return $"((s[{VccLoRegister}] | s[{VccHiRegister}]) == 0u ? 1u : 0u)"; + } + + if (operand.Value == 252) + { + return $"((s[{ExecLoRegister}] | s[{ExecHiRegister}]) == 0u ? 1u : 0u)"; + } + + if (operand.Value == 253) + { + return "(scc ? 1u : 0u)"; + } + + if (Gen5InlineConstants.TryDecode(operand.Value, out var constant)) + { + return FormatUInt(constant); + } + + throw new NotSupportedException( + $"unsupported encoded constant {operand.Value} in {instruction.Opcode}"); + default: + throw new NotSupportedException($"unsupported operand kind {operand.Kind}"); + } + } + + private static string FormatUInt(uint value) => + value <= 9 ? $"{value}u" : $"0x{value.ToString("X", CultureInfo.InvariantCulture)}u"; + + private static string AsFloat(string expression) => $"as_type({expression})"; + + private static string AsUInt(string expression) => $"as_type({expression})"; + + // ---- basic blocks (ports BuildBasicBlocks from the SPIR-V translator) ---- + + private readonly record struct ShaderBlock( + uint StartPc, + int StartIndex, + int EndIndex); + + private static IReadOnlyList BuildBasicBlocks( + IReadOnlyList instructions) + { + if (instructions.Count == 0) + { + return []; + } + + var leaders = new SortedSet { instructions[0].Pc }; + for (var index = 0; index < instructions.Count; index++) + { + var instruction = instructions[index]; + if (IsBranch(instruction.Opcode) && + TryGetBranchTargetPc(instruction, out var targetPc)) + { + leaders.Add(targetPc); + } + + if ((IsBranch(instruction.Opcode) || instruction.Opcode == "SEndpgm") && + index + 1 < instructions.Count) + { + leaders.Add(instructions[index + 1].Pc); + } + } + + var starts = new List(leaders.Count); + foreach (var pc in leaders) + { + if (FindInstructionIndex(instructions, pc) >= 0) + { + starts.Add(pc); + } + } + + var blocks = new List(starts.Count); + for (var index = 0; index < starts.Count; index++) + { + var startIndex = FindInstructionIndex(instructions, starts[index]); + var endIndex = index + 1 < starts.Count + ? FindInstructionIndex(instructions, starts[index + 1]) + : instructions.Count; + if (startIndex >= 0 && endIndex > startIndex) + { + blocks.Add(new ShaderBlock(starts[index], startIndex, endIndex)); + } + } + + return blocks; + } + + private static bool IsBranch(string opcode) => + opcode == "SBranch" || + opcode.StartsWith("SCbranch", StringComparison.Ordinal); + + private static bool TryGetBranchTargetPc( + Gen5ShaderInstruction instruction, + out uint targetPc) + { + targetPc = 0; + if (instruction.Encoding != Gen5ShaderEncoding.Sopp || + instruction.Words.Count == 0) + { + return false; + } + + var offset = unchecked((short)(instruction.Words[0] & 0xFFFF)); + var nextPc = (long)instruction.Pc + + (instruction.Words.Count * sizeof(uint)); + var target = nextPc + (offset * sizeof(uint)); + if (target < 0 || target > uint.MaxValue) + { + return false; + } + + targetPc = (uint)target; + return true; + } + + private static int FindInstructionIndex( + IReadOnlyList instructions, + uint pc) + { + for (var index = 0; index < instructions.Count; index++) + { + if (instructions[index].Pc == pc) + { + return index; + } + } + + return -1; + } + + private static bool TryFindBlock( + IReadOnlyList blocks, + uint pc, + out int block) + { + for (var index = 0; index < blocks.Count; index++) + { + if (blocks[index].StartPc == pc) + { + block = index; + return true; + } + } + + block = -1; + return false; + } + + // ---- scalar-definition dataflow (ports BuildScalarDefinitionInfo) ---- + + private void BuildScalarDefinitionInfo( + IReadOnlyList blocks, + IReadOnlyList instructions) + { + var predecessors = new HashSet[blocks.Count]; + for (var index = 0; index < blocks.Count; index++) + { + predecessors[index] = []; + } + + void AddEdge(int source, int destination) + { + if (destination < 0 || destination >= blocks.Count) + { + return; + } + + predecessors[destination].Add(source); + } + + for (var blockIndex = 0; blockIndex < blocks.Count; blockIndex++) + { + var block = blocks[blockIndex]; + var terminator = instructions[block.EndIndex - 1]; + var hasFallthrough = blockIndex + 1 < blocks.Count; + if (terminator.Opcode == "SEndpgm") + { + continue; + } + + if (terminator.Opcode == "SBranch") + { + if (TryGetBranchTargetPc(terminator, out var targetPc) && + TryFindBlock(blocks, targetPc, out var targetBlock)) + { + AddEdge(blockIndex, targetBlock); + } + + continue; + } + + if (terminator.Opcode.StartsWith("SCbranch", StringComparison.Ordinal)) + { + if (TryGetBranchTargetPc(terminator, out var targetPc) && + TryFindBlock(blocks, targetPc, out var targetBlock)) + { + AddEdge(blockIndex, targetBlock); + } + + if (hasFallthrough) + { + AddEdge(blockIndex, blockIndex + 1); + } + + continue; + } + + if (hasFallthrough) + { + AddEdge(blockIndex, blockIndex + 1); + } + } + + var blockInputs = new long[blocks.Count][]; + var blockOutputs = new long[blocks.Count][]; + var hasOutput = new bool[blocks.Count]; + var initialDefinitions = new long[ScalarRegisterFileCount]; + Array.Fill(initialDefinitions, InitialScalarDefinition); + + static void MergeDefinitions( + long[] destination, + long[] source, + ref bool hasInput) + { + if (!hasInput) + { + Array.Copy(source, destination, (int)ScalarRegisterFileCount); + hasInput = true; + return; + } + + for (var register = 0; register < ScalarRegisterFileCount; register++) + { + if (destination[register] != source[register]) + { + destination[register] = ConflictingScalarDefinition; + } + } + } + + static void ApplyScalarDefinitions( + long[] definitions, + ShaderBlock block, + IReadOnlyList blockInstructions) + { + for (var instructionIndex = block.StartIndex; + instructionIndex < block.EndIndex; + instructionIndex++) + { + var instruction = blockInstructions[instructionIndex]; + foreach (var destination in instruction.Destinations) + { + if (destination.Kind == Gen5OperandKind.ScalarRegister && + destination.Value < ScalarRegisterFileCount) + { + definitions[destination.Value] = instruction.Pc + 1L; + } + } + } + } + + var changed = true; + while (changed) + { + changed = false; + for (var blockIndex = 0; blockIndex < blocks.Count; blockIndex++) + { + var input = new long[ScalarRegisterFileCount]; + Array.Fill(input, UnreachableScalarDefinition); + var hasInput = false; + if (blockIndex == 0) + { + MergeDefinitions(input, initialDefinitions, ref hasInput); + } + + foreach (var predecessor in predecessors[blockIndex]) + { + if (hasOutput[predecessor]) + { + MergeDefinitions( + input, + blockOutputs[predecessor], + ref hasInput); + } + } + + if (!hasInput) + { + continue; + } + + var output = (long[])input.Clone(); + ApplyScalarDefinitions(output, blocks[blockIndex], instructions); + if (!hasOutput[blockIndex] || + !blockInputs[blockIndex].AsSpan().SequenceEqual(input) || + !blockOutputs[blockIndex].AsSpan().SequenceEqual(output)) + { + blockInputs[blockIndex] = input; + blockOutputs[blockIndex] = output; + hasOutput[blockIndex] = true; + changed = true; + } + } + } + + _scalarDefinitionsBeforePc.Clear(); + for (var blockIndex = 0; blockIndex < blocks.Count; blockIndex++) + { + if (!hasOutput[blockIndex]) + { + continue; + } + + var definitions = (long[])blockInputs[blockIndex].Clone(); + var block = blocks[blockIndex]; + for (var instructionIndex = block.StartIndex; + instructionIndex < block.EndIndex; + instructionIndex++) + { + var instruction = instructions[instructionIndex]; + if (instruction.Control is Gen5ImageControl or + Gen5ScalarMemoryControl or + Gen5GlobalMemoryControl or + Gen5BufferMemoryControl) + { + _scalarDefinitionsBeforePc[instruction.Pc] = + (long[])definitions.Clone(); + } + + foreach (var destination in instruction.Destinations) + { + if (destination.Kind == Gen5OperandKind.ScalarRegister && + destination.Value < ScalarRegisterFileCount) + { + definitions[destination.Value] = instruction.Pc + 1L; + } + } + } + } + } + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs b/src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs new file mode 100644 index 0000000..ab30194 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs @@ -0,0 +1,81 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Globalization; +using System.Text; + +namespace SharpEmu.ShaderCompiler.Metal; + +/// +/// The fixed presenter shaders, mirroring SpirvFixedShaders semantically. The +/// MSL lives in Templates/*.msl (authored as real Metal source); this class +/// only substitutes the per-call parameters. Entry point names are stable +/// (Metal forbids "main"); textures and samplers bind at index 0; attributes +/// use the same user(locn) convention as the translated stages. +/// +public static class MslFixedShaders +{ + /// + /// Fullscreen triangle from the vertex index; every attribute location in + /// 0..attributeCount-1 carries (x, y, 0, 1) so paired fragment stages can + /// read a screen-space UV from any location. + /// + public static string CreateFullscreenVertex(uint attributeCount) + { + var fields = new StringBuilder(); + var stores = new StringBuilder(); + for (uint index = 0; index < attributeCount; index++) + { + if (index != 0) + { + fields.AppendLine(); + stores.AppendLine(); + } + + fields.Append($" float4 attr{index} [[user(locn{index})]];"); + stores.Append($" out.attr{index} = float4(x, y, 0.0f, 1.0f);"); + } + + return MslTemplates.Render( + "fullscreen_vertex", + ("attribute_fields", fields.ToString()), + ("attribute_stores", stores.ToString())); + } + + /// Samples texture 0 at the interpolated location-0 UV. + public static string CreateCopyFragment() => MslTemplates.Render("copy_fragment"); + + /// + /// The presenter's blit stage: samples texture 0 with V flipped, because + /// pairing the shared fullscreen triangle with Metal's y-up NDC puts UV + /// (0,0) at the bottom of the screen while textures keep v=0 at the top. + /// + public static string CreatePresentFragment() => MslTemplates.Render("present_fragment"); + + public static string CreateSolidFragment(float red, float green, float blue, float alpha) => + MslTemplates.Render( + "solid_fragment", + ("red", Format(red)), + ("green", Format(green)), + ("blue", Format(blue)), + ("alpha", Format(alpha))); + + /// + /// Diagnostic fragment stage exposing one interpolated vertex output + /// directly as color, isolating fragment translation from interface data. + /// + public static string CreateAttributeFragment(uint location) => + MslTemplates.Render( + "attribute_fragment", + ("location", location.ToString(CultureInfo.InvariantCulture))); + + /// + /// Output-free fragment stage for fixed-function depth-only passes: the + /// guest has no pixel shader, so no color may be written while depth + /// testing still runs for the translated vertex shader. + /// + public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment"); + + private static string Format(float value) => + value.ToString("0.0######", CultureInfo.InvariantCulture) + "f"; +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs b/src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs new file mode 100644 index 0000000..3146ed7 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Concurrent; +using System.Text; + +namespace SharpEmu.ShaderCompiler.Metal; + +/// +/// Loads the static MSL blocks from embedded Templates/*.msl resources and +/// substitutes {{placeholder}} tokens. The static prelude and fixed shaders +/// are authored as real Metal source files; only the per-instruction body +/// emission stays programmatic in the translator. +/// +internal static class MslTemplates +{ + private static readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + public static string Render(string name, params (string Key, string Value)[] substitutions) + { + var template = _cache.GetOrAdd(name, Load); + if (substitutions.Length == 0) + { + return template; + } + + var builder = new StringBuilder(template); + foreach (var (key, value) in substitutions) + { + builder.Replace("{{" + key + "}}", value); + } + + var rendered = builder.ToString(); + var marker = rendered.IndexOf("{{", StringComparison.Ordinal); + if (marker >= 0) + { + var end = rendered.IndexOf("}}", marker, StringComparison.Ordinal); + var token = end > marker ? rendered[marker..(end + 2)] : "{{..."; + throw new InvalidOperationException( + $"template '{name}' has an unsubstituted placeholder {token}"); + } + + return rendered; + } + + private static string Load(string name) + { + var assembly = typeof(MslTemplates).Assembly; + var resourceName = $"SharpEmu.ShaderCompiler.Metal.Templates.{name}.msl"; + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"missing embedded MSL template {resourceName}"); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj b/src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj new file mode 100644 index 0000000..907d384 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj @@ -0,0 +1,26 @@ + + + + + + + false + + + + + + + + + + + + diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl new file mode 100644 index 0000000..e414747 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl @@ -0,0 +1,13 @@ +#include + +using namespace metal; + +struct AttributeIn +{ + float4 attr{{location}} [[user(locn{{location}})]]; +}; + +fragment float4 attribute_fs(AttributeIn in [[stage_in]]) +{ + return in.attr{{location}}; +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl new file mode 100644 index 0000000..f60f104 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl @@ -0,0 +1,16 @@ +#include + +using namespace metal; + +struct CopyIn +{ + float4 attr0 [[user(locn0)]]; +}; + +fragment float4 copy_fs( + CopyIn in [[stage_in]], + texture2d tex0 [[texture(0)]], + sampler smp0 [[sampler(0)]]) +{ + return tex0.sample(smp0, in.attr0.xy); +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl new file mode 100644 index 0000000..a074f30 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl @@ -0,0 +1,7 @@ +#include + +using namespace metal; + +fragment void depth_only_fs() +{ +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl new file mode 100644 index 0000000..9c4e93f --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl @@ -0,0 +1,68 @@ +static constant uint sharpemu_gfx10_formats[128] = { +{{format_table}} +}; + +static inline void sharpemu_format_layout(uint dfmt, uint component, thread uint& byteOff, thread uint& bitOff, thread uint& bits) +{ + byteOff = 0u; bitOff = 0u; bits = 0u; + switch (component * 16u + dfmt) + { +{{layout_cases}} + default: break; + } +} + +static inline uint sharpemu_minifloat(uint raw, uint bits) +{ + uint mantissaBits = bits - 5u; + uint mantissa = raw & ((1u << mantissaBits) - 1u); + uint exponent = (raw >> mantissaBits) & 0x1Fu; + uint shift = 23u - mantissaBits; + if (exponent == 31u) + { + return 0x7F800000u | (mantissa << shift); + } + if (exponent == 0u) + { + float scale = mantissaBits == 6u ? (1.0f / 1048576.0f) : (1.0f / 524288.0f); + return as_type((float)mantissa * scale); + } + return ((exponent + 112u) << 23) | (mantissa << shift); +} + +static inline uint sharpemu_format_one(uint nfmt) +{ + return (nfmt == 4u || nfmt == 5u) ? 1u : 0x3F800000u; +} + +static inline uint sharpemu_format_convert(uint raw, uint bits, uint nfmt, uint dfmt) +{ + uint lowMask = bits >= 32u ? 0xFFFFFFFFu : ((1u << bits) - 1u); + int signedRaw = extract_bits(as_type(raw), 0u, bits); + switch (nfmt) + { + case 0u: return as_type((float)raw / (float)lowMask); + case 1u: + { + float snorm = (float)signedRaw / (float)(lowMask >> 1); + return as_type(fmax(snorm, -1.0f)); + } + case 2u: return as_type((float)raw); + case 3u: return as_type((float)signedRaw); + case 5u: return (uint)signedRaw; + case 7u: + { + // 10_11_11/11_11_10 packed floats are unsigned mini-floats. + if (dfmt == 6u || dfmt == 7u) + { + return sharpemu_minifloat(raw, bits); + } + if (bits == 16u) + { + return as_type((float)as_type((ushort)(raw & 0xFFFFu))); + } + return raw; + } + default: return raw; + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl new file mode 100644 index 0000000..d0fed40 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl @@ -0,0 +1,19 @@ +#include + +using namespace metal; + +struct FullscreenOut +{ + float4 position [[position]]; +{{attribute_fields}} +}; + +vertex FullscreenOut fullscreen_vs(uint vertex_id [[vertex_id]]) +{ + float x = (float)((vertex_id << 1) & 2u); + float y = (float)(vertex_id & 2u); + FullscreenOut out = {}; + out.position = float4(x * 2.0f - 1.0f, y * 2.0f - 1.0f, 0.0f, 1.0f); +{{attribute_stores}} + return out; +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl new file mode 100644 index 0000000..30aa4f4 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl @@ -0,0 +1,59 @@ +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return {{ballot_return}}; +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl new file mode 100644 index 0000000..9e6cda2 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl @@ -0,0 +1,16 @@ +#include + +using namespace metal; + +struct PresentIn +{ + float4 attr0 [[user(locn0)]]; +}; + +fragment float4 present_fs( + PresentIn in [[stage_in]], + texture2d tex0 [[texture(0)]], + sampler smp0 [[sampler(0)]]) +{ + return tex0.sample(smp0, float2(in.attr0.x, 1.0f - in.attr0.y)); +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl new file mode 100644 index 0000000..a98db4c --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl @@ -0,0 +1,8 @@ +#include + +using namespace metal; + +fragment float4 solid_fs() +{ + return float4({{red}}, {{green}}, {{blue}}, {{alpha}}); +} diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index eb49ad0..e90ecfc 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -1754,6 +1754,10 @@ public static partial class Gen5SpirvTranslator "SWaitcnt" or "SInstPrefetch" or "STtraceData" or + // NGG shaders bracket their exports with s_sendmsg + // (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space; + // exports are translated directly, so the message is moot. + "SSendmsg" or "VInterpMovF32") { return true; diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs new file mode 100644 index 0000000..4c755f1 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs @@ -0,0 +1,228 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using System.Text; +using SharpEmu.HLE; +using SharpEmu.Libs.SaveData; +using Xunit; + +namespace SharpEmu.Libs.Tests.SaveData; + +// Exercises the mount / event / param / delete exports end to end against a +// temp save root. Shares the environment-pinning collection so it never runs +// alongside other tests that mutate SHARPEMU_SAVEDATA_DIR. +[Collection("SaveDataMemoryState")] +public sealed class SaveDataExportsTests : IDisposable +{ + private const ulong Base = 0x2_0000_0000; + private const int UserId = 0x1001; + private const string TitleId = "SDEXPORTTEST"; + private const string MountPoint = "/savedata0"; + private const string DirName = "SAVE0000"; + + private const ulong MountParam = Base + 0x100; + private const ulong MountResult = Base + 0x200; + private const ulong DirNamePtr = Base + 0x300; + private const ulong MountPointStr = Base + 0x340; + private const ulong EventOut = Base + 0x400; + private const ulong ParamStruct = Base + 0x500; + private const ulong DeleteParam = Base + 0xC00; + private const ulong SyncParam = Base + 0xC80; + private const ulong SetupParam = Base + 0xD00; + private const ulong SetupResult = Base + 0xD80; + + private const int NoEvent = unchecked((int)0x809F0008); + private const int ParameterError = unchecked((int)0x809F0000); + private const uint MountModeCreate = 1u << 2; + + private readonly FakeCpuMemory _memory = new(Base, 0x10000); + private readonly CpuContext _ctx; + private readonly string _root; + private readonly string? _previousRoot; + + public SaveDataExportsTests() + { + _ctx = new CpuContext(_memory, Generation.Gen5); + _root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdexport-{Guid.NewGuid():N}"); + _previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR"); + Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root); + SaveDataExports.ConfigureApplicationInfo(TitleId); + } + + public void Dispose() + { + SaveDataExports.ConfigureApplicationInfo(null); + Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private string SlotDir => Path.Combine(_root, TitleId, DirName); + + private void WriteAscii(ulong address, string value) + { + var bytes = new byte[value.Length + 1]; + Encoding.ASCII.GetBytes(value).CopyTo(bytes, 0); + Assert.True(_memory.TryWrite(address, bytes)); + } + + private CpuContext Reg(ulong rdi = 0, ulong rsi = 0, ulong rdx = 0, ulong rcx = 0) + { + _ctx[CpuRegister.Rdi] = rdi; + _ctx[CpuRegister.Rsi] = rsi; + _ctx[CpuRegister.Rdx] = rdx; + _ctx[CpuRegister.Rcx] = rcx; + return _ctx; + } + + private int Mount(uint mountMode = MountModeCreate) + { + WriteAscii(DirNamePtr, DirName); + Span param = stackalloc byte[0x30]; + param.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(param, UserId); + BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], DirNamePtr); + BinaryPrimitives.WriteUInt32LittleEndian(param[0x20..], mountMode); + Assert.True(_memory.TryWrite(MountParam, param)); + return SaveDataExports.SaveDataMount3(Reg(rdi: MountParam, rsi: MountResult)); + } + + [Fact] + public void GetEventResult_WhenNoEvents_ReportsNoEvent() + { + Assert.Equal(NoEvent, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut))); + } + + [Fact] + public void GetEventResult_NullOut_ReturnsParameterError() + { + Assert.Equal(ParameterError, SaveDataExports.SaveDataGetEventResult(Reg(rsi: 0))); + } + + [Fact] + public void SyncSaveDataMemory_PostsSyncEndEvent_DrainedOnce() + { + // Setup the memory blob so sync succeeds. + Span setup = stackalloc byte[0x10]; + setup.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(setup[0x04..], UserId); + BinaryPrimitives.WriteUInt64LittleEndian(setup[0x08..], 0x1000); + Assert.True(_memory.TryWrite(SetupParam, setup)); + Assert.Equal(0, SaveDataExports.SaveDataSetupSaveDataMemory2(Reg(rdi: SetupParam, rdx: SetupResult))); + + Span sync = stackalloc byte[0x10]; + sync.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(sync, UserId); + Assert.True(_memory.TryWrite(SyncParam, sync)); + Assert.Equal(0, SaveDataExports.SaveDataSyncSaveDataMemory(Reg(rdi: SyncParam))); + + // The queued SAVE_DATA_MEMORY_SYNC_END (type 3) event is delivered once. + Assert.Equal(0, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut))); + Assert.True(_ctx.TryReadUInt32(EventOut + 0x00, out var type)); + Assert.Equal(3u, type); + Assert.True(_ctx.TryReadInt32(EventOut + 0x04, out var errorCode)); + Assert.Equal(0, errorCode); + + Assert.Equal(NoEvent, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut))); + } + + [Fact] + public void Mount_CreatesSlotDirectory_AndReportsMounted() + { + Assert.Equal(0, Mount()); + Assert.True(Directory.Exists(SlotDir)); + + Assert.Equal(0, SaveDataExports.SaveDataIsMounted(Reg(rsi: EventOut))); + Assert.True(_ctx.TryReadUInt32(EventOut, out var mounted)); + Assert.Equal(1u, mounted); + } + + [Fact] + public void Umount_RemovesMountTracking() + { + Assert.Equal(0, Mount()); + WriteAscii(MountPointStr, MountPoint); + Assert.Equal(0, SaveDataExports.SaveDataUmount2(Reg(rdi: MountPointStr))); + + Assert.Equal(0, SaveDataExports.SaveDataIsMounted(Reg(rsi: EventOut))); + Assert.True(_ctx.TryReadUInt32(EventOut, out var mounted)); + Assert.Equal(0u, mounted); + } + + [Fact] + public void SetParam_ThenGetParam_RoundTripsThroughMetadata() + { + Assert.Equal(0, Mount()); + WriteAscii(MountPointStr, MountPoint); + + Span param = stackalloc byte[0x530]; + param.Clear(); + Encoding.ASCII.GetBytes("My Save").CopyTo(param); // +0x00 title + Encoding.ASCII.GetBytes("Biome 2").CopyTo(param[0x80..]); // +0x80 subtitle + Encoding.ASCII.GetBytes("2h 30m").CopyTo(param[0x100..]); // +0x100 detail + BinaryPrimitives.WriteUInt32LittleEndian(param[0x500..], 7); // +0x500 userParam + Assert.True(_memory.TryWrite(ParamStruct, param)); + Assert.Equal(0, SaveDataExports.SaveDataSetParam(Reg(rdi: MountPointStr, rdx: ParamStruct))); + + Assert.True(File.Exists(SaveDataStorage.ParamPath(SlotDir))); + var meta = SaveDataStorage.ReadMetadata(SlotDir); + Assert.Equal("My Save", meta.Title); + Assert.Equal("Biome 2", meta.SubTitle); + Assert.Equal("2h 30m", meta.Detail); + Assert.Equal(7u, meta.UserParam); + + // Read it back through the export into a fresh buffer. + Span zero = stackalloc byte[0x530]; + zero.Clear(); + Assert.True(_memory.TryWrite(ParamStruct, zero)); + Assert.Equal(0, SaveDataExports.SaveDataGetParam(Reg(rdi: MountPointStr, rdx: ParamStruct))); + var title = new byte[7]; + Assert.True(_memory.TryRead(ParamStruct, title)); + Assert.Equal("My Save", Encoding.ASCII.GetString(title)); + } + + [Fact] + public void SetParam_WithoutMount_ReturnsBadMounted() + { + WriteAscii(MountPointStr, "/notmounted"); + Assert.True(_memory.TryWrite(ParamStruct, new byte[0x530])); + Assert.Equal( + unchecked((int)0x809F0013), + SaveDataExports.SaveDataSetParam(Reg(rdi: MountPointStr, rdx: ParamStruct))); + } + + [Fact] + public void Delete_RemovesTheSlotDirectory() + { + Assert.Equal(0, Mount()); + Assert.True(Directory.Exists(SlotDir)); + + WriteAscii(DirNamePtr, DirName); + Span del = stackalloc byte[0x10]; + del.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(del, UserId); + BinaryPrimitives.WriteUInt64LittleEndian(del[0x08..], DirNamePtr); + Assert.True(_memory.TryWrite(DeleteParam, del)); + + Assert.Equal(0, SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam))); + Assert.False(Directory.Exists(SlotDir)); + } + + [Fact] + public void Delete_MissingSlot_ReturnsNotFound() + { + WriteAscii(DirNamePtr, "NOSUCHSLOT"); + Span del = stackalloc byte[0x10]; + del.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(del, UserId); + BinaryPrimitives.WriteUInt64LittleEndian(del[0x08..], DirNamePtr); + Assert.True(_memory.TryWrite(DeleteParam, del)); + + Assert.Equal( + unchecked((int)0x809F0008), + SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam))); + } +} diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs index 11924a7..fddee7f 100644 --- a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs @@ -49,8 +49,9 @@ public sealed class SaveDataMemoryExportsTests : IDisposable SaveDataExports.ConfigureApplicationInfo(TitleId); } + // Saves are keyed by title id only (single-user layout): //... private string MemoryPath => - Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat"); + Path.Combine(_root, TitleId, "sce_sdmemory", "memory.dat"); public void Dispose() { diff --git a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs new file mode 100644 index 0000000..2dc1e8a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs @@ -0,0 +1,115 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.IO; +using SharpEmu.Libs.SaveData; +using Xunit; + +namespace SharpEmu.Libs.Tests; + +/// +/// Save data lives under ~/SharpEmu/Saves/<titleId>/<dirName>/ with UI +/// metadata in <slot>/sce_sys/param.json. These guard the pure path and +/// metadata logic that the SaveData HLE exports build on. +/// +public sealed class SaveDataStorageTests +{ + [Fact] + public void RootHonorsOverrideAndFallsBackToUserProfile() + { + Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves")); + + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root()); + } + + [Fact] + public void LayoutNestsTitleThenSlotThenSceSys() + { + var root = SaveDataStorage.Root("/saves"); + var titleRoot = SaveDataStorage.TitleRoot(root, "PPSA15552"); + var slot = SaveDataStorage.SlotDir(titleRoot, "SAVE0000"); + + Assert.Equal(Path.Combine(Path.GetFullPath("/saves"), "PPSA15552"), titleRoot); + Assert.Equal(Path.Combine(titleRoot, "SAVE0000"), slot); + Assert.Equal(Path.Combine(slot, "sce_sys", "param.json"), SaveDataStorage.ParamPath(slot)); + Assert.Equal(Path.Combine(slot, "sce_sys", "icon0.png"), SaveDataStorage.IconPath(slot)); + Assert.Equal(Path.Combine(titleRoot, "sce_sdmemory", "memory.dat"), SaveDataStorage.MemoryPath(titleRoot)); + } + + [Theory] + [InlineData("SAVE0000", "SAVE0000")] + [InlineData("../../etc/passwd", ".._.._etc_passwd")] // '/' separators -> '_', collapsing to one segment + [InlineData("a/b", "a_b")] + [InlineData("", "default")] + [InlineData(" ", "default")] + public void SanitizeNeutralizesPathSeparatorsAndEmpties(string input, string expected) + { + Assert.Equal(expected, SaveDataStorage.Sanitize(input)); + } + + [Fact] + public void SanitizedSlotStaysUnderTheTitleRoot() + { + // The dangerous part of a traversal is the separator; sanitizing it to a + // single segment keeps the slot a direct child of the title root. + var titleRoot = SaveDataStorage.TitleRoot(SaveDataStorage.Root("/saves"), "PPSA15552"); + var slot = SaveDataStorage.SlotDir(titleRoot, "../escape"); + Assert.Equal(titleRoot, Path.GetDirectoryName(slot)); + Assert.DoesNotContain(Path.DirectorySeparatorChar, Path.GetFileName(slot)); + } + + [Fact] + public void MetadataRoundTripsThroughParamJson() + { + var slot = Path.Combine(Path.GetTempPath(), "sharpemu-savetest-" + Path.GetRandomFileName()); + try + { + var written = new SaveDataMetadata + { + Title = "Dead Cells", + SubTitle = "The Prisoners' Quarters", + Detail = "Cell 1 - 3h 12m", + UserParam = 42, + }; + SaveDataStorage.WriteMetadata(slot, written); + + Assert.True(File.Exists(SaveDataStorage.ParamPath(slot))); + var read = SaveDataStorage.ReadMetadata(slot); + Assert.Equal(written.Title, read.Title); + Assert.Equal(written.SubTitle, read.SubTitle); + Assert.Equal(written.Detail, read.Detail); + Assert.Equal(written.UserParam, read.UserParam); + } + finally + { + if (Directory.Exists(slot)) + { + Directory.Delete(slot, recursive: true); + } + } + } + + [Fact] + public void ReadMetadataDefaultsWhenMissingOrCorrupt() + { + var slot = Path.Combine(Path.GetTempPath(), "sharpemu-savetest-" + Path.GetRandomFileName()); + try + { + var missing = SaveDataStorage.ReadMetadata(slot); + Assert.Equal(Path.GetFileName(slot), missing.Title); + + Directory.CreateDirectory(Path.GetDirectoryName(SaveDataStorage.ParamPath(slot))!); + File.WriteAllText(SaveDataStorage.ParamPath(slot), "{ not valid json"); + var corrupt = SaveDataStorage.ReadMetadata(slot); + Assert.Equal(Path.GetFileName(slot), corrupt.Title); + } + finally + { + if (Directory.Exists(slot)) + { + Directory.Delete(slot, recursive: true); + } + } + } +} diff --git a/tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs b/tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs new file mode 100644 index 0000000..6b823bc --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Native; +using Xunit; + +namespace SharpEmu.Libs.Tests; + +/// +/// The SSE4a EXTRQ+blend idiom raises #UD -> SIGILL under Rosetta 2, so the +/// loader rewrites it to SSE4.1 at boot. Sony's toolchain allocates both the +/// blend destination and the scratch source register freely (one Dead Cells +/// build uses dest=xmm0, another dest=xmm3), so the matcher must read both from +/// the encoding rather than assume fixed registers. +/// +public sealed class Sse4aExtrqBlendPatchTests +{ + // EXTRQ xmmSrc, 0x28, 0x00 ; VPBLENDD xmmDest, xmmDest, xmmSrc, 2. + private static byte[] Idiom(int dest, int src) => + [ + 0x66, 0x0F, 0x78, (byte)(0xC0 | src), 0x28, 0x00, + 0xC4, 0xE3, (byte)(((~dest & 0xF) << 3) | 0x01), 0x02, (byte)(0xC0 | (dest << 3) | src), 0x02, + ]; + + // PEXTRB eax, xmmSrc, 4 ; PINSRD xmmDest, eax, 1. + private static byte[] Replacement(int dest, int src) => + [ + 0x66, 0x0F, 0x3A, 0x14, (byte)(0xC0 | (src << 3)), 0x04, + 0x66, 0x0F, 0x3A, 0x22, (byte)(0xC0 | (dest << 3)), 0x01, + ]; + + [Fact] + public void MatchesEveryDestinationAndSourceCombination() + { + for (var dest = 0; dest <= 7; dest++) + { + for (var src = 0; src <= 7; src++) + { + Assert.True( + Sse4aExtrqBlendPatch.TryMatch(Idiom(dest, src), out var matchedDest, out var matchedSrc), + $"dest={dest} src={src}"); + Assert.Equal(dest, matchedDest); + Assert.Equal(src, matchedSrc); + } + } + } + + [Fact] + public void MatchesTheDeadCellsXmm3Xmm4Idiom() + { + // The exact bytes that faulted: EXTRQ xmm4,0x28,0x00 ; VPBLENDD xmm3,xmm3,xmm4,2. + byte[] bytes = [0x66, 0x0F, 0x78, 0xC4, 0x28, 0x00, 0xC4, 0xE3, 0x61, 0x02, 0xDC, 0x02]; + Assert.True(Sse4aExtrqBlendPatch.TryMatch(bytes, out var dest, out var src)); + Assert.Equal(3, dest); + Assert.Equal(4, src); + } + + [Fact] + public void RoundTripsEveryCombinationThroughMatchThenEncode() + { + for (var dest = 0; dest <= 7; dest++) + { + for (var src = 0; src <= 7; src++) + { + Assert.True(Sse4aExtrqBlendPatch.TryMatch(Idiom(dest, src), out var matchedDest, out var matchedSrc)); + var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength]; + Assert.True(Sse4aExtrqBlendPatch.TryEncode(matchedDest, matchedSrc, buffer)); + Assert.Equal(Replacement(dest, src), buffer); + } + } + } + + [Fact] + public void PreservesTheOriginalXmm0DestinationEncoding() + { + // Guards the behaviour the previous matcher (dest fixed to xmm0) produced. + var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength]; + Assert.True(Sse4aExtrqBlendPatch.TryEncode(destRegister: 0, srcRegister: 2, buffer)); + Assert.Equal( + new byte[] { 0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04, 0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01 }, + buffer); + } + + [Fact] + public void RejectsMismatchedSourceAcrossTheTwoInstructions() + { + // EXTRQ masks xmm1 but the blend reads xmm2 — not the paired idiom. + var mixed = Idiom(dest: 0, src: 1); + mixed[10] = 0xC0 | 2; + Assert.False(Sse4aExtrqBlendPatch.TryMatch(mixed, out _, out _)); + } + + [Theory] + [InlineData(0)] // wrong first byte + [InlineData(2)] // wrong opcode + [InlineData(4)] // wrong EXTRQ length immediate + [InlineData(9)] // wrong blend opcode + [InlineData(11)] // wrong blend immediate + public void RejectsSequencesThatDifferFromTheIdiom(int corruptIndex) + { + var bytes = Idiom(dest: 0, src: 2); + bytes[corruptIndex] ^= 0xFF; + Assert.False(Sse4aExtrqBlendPatch.TryMatch(bytes, out _, out _)); + } + + [Fact] + public void RejectsNonModRmRegisterEncodings() + { + // A memory-form ModRM (mod != 11) is not the register-to-register idiom. + var bytes = Idiom(dest: 0, src: 2); + bytes[3] = 0x02; // mod=00, rm=010 — memory operand, not xmm2 direct + Assert.False(Sse4aExtrqBlendPatch.TryMatch(bytes, out _, out _)); + } + + [Fact] + public void RejectsTooShortWindows() + { + Assert.False(Sse4aExtrqBlendPatch.TryMatch(Idiom(dest: 0, src: 2).AsSpan(0, 11), out _, out _)); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(8, 0)] + [InlineData(0, 8)] + public void EncodeRejectsRegistersOutsideXmm0Through7(int dest, int src) + { + var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength]; + Assert.False(Sse4aExtrqBlendPatch.TryEncode(dest, src, buffer)); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs new file mode 100644 index 0000000..c8775c8 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs @@ -0,0 +1,47 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +// Read-only guest memory holding hand-assembled instruction words for the decoder. +// Copied (not shared) from the ShaderDump tool on purpose: shader-codegen test projects +// stay self-contained so each backend's suite can serve as a standalone model. +internal sealed class FakeGuestMemory : ICpuMemory +{ + private readonly List<(ulong Base, byte[] Data)> _regions = []; + + public void AddRegion(ulong baseAddress, uint[] words) + { + var bytes = new byte[words.Length * sizeof(uint)]; + for (var index = 0; index < words.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian( + bytes.AsSpan(index * sizeof(uint)), + words[index]); + } + + _regions.Add((baseAddress, bytes)); + } + + public bool TryRead(ulong virtualAddress, Span destination) + { + foreach (var (baseAddress, data) in _regions) + { + if (virtualAddress >= baseAddress && + virtualAddress + (ulong)destination.Length <= baseAddress + (ulong)data.Length) + { + data.AsSpan( + (int)(virtualAddress - baseAddress), + destination.Length).CopyTo(destination); + return true; + } + } + + return false; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) => false; +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs new file mode 100644 index 0000000..6fb61a5 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs @@ -0,0 +1,289 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +internal sealed record Gen5ComputeFixture( + string Name, + uint[] Words, + uint StoreScalarResourceBase, + int StoreBackingBytes); + +/// +/// Hand-assembled Gen5 (gfx10) programs plus the decode -> (state, evaluation) -> MSL +/// pipeline the tests drive. Programs are synthetic by construction — shader binaries +/// captured from games are copyrighted content and must never land in fixtures. +/// +internal static class Gen5ComputeFixtures +{ + public const ulong ProgramAddress = 0x100000; + + // Straight-line ALU: VOP2 fmac + fmamk/fmaak literals + the VOP3 fmac form. + public static readonly Gen5ComputeFixture Fmac = new( + "fmac", + [ + 0x560A0501, // v_fmac_f32 v5, v1, v2 + 0x580A0501, 0x42280000, // v_fmamk_f32 v5, v1, 42.0, v2 + 0x5A0A0501, 0x42280000, // v_fmaak_f32 v5, v1, v2, 42.0 + 0xD52B0005, 0x00020501, // v_fmac_f32_e64 v5, v1, v2 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 0, + StoreBackingBytes: 0); + + // VOP3 integer multiplies, low and high halves, signed and unsigned. + public static readonly Gen5ComputeFixture Muls = new( + "muls", + [ + 0xD5690005, 0x00020501, // v_mul_lo_u32 v5, v1, v2 + 0xD56A0005, 0x00020501, // v_mul_hi_u32 v5, v1, v2 + 0xD56B0005, 0x00020501, // v_mul_lo_i32 v5, v1, v2 + 0xD56C0005, 0x00020501, // v_mul_hi_i32 v5, v1, v2 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 0, + StoreBackingBytes: 0); + + // End-to-end executable program: real ALU results stored to buffer 0 at dword + // offsets 0/4/8, then proof that a store with EXEC=0 does not land (offset 12 + // keeps its sentinel) and that stores work again once EXEC is restored (16). + public static readonly Gen5ComputeFixture ExecStore = new( + "exec-store", + [ + 0xBFA10001, // s_clause 0x1 (scheduling hint) + 0x7E0002FF, 0x3FC00000, // v_mov_b32 v0, 1.5f + 0x7E0202FF, 0x40100000, // v_mov_b32 v1, 2.25f + 0x7E0402FF, 0x41200000, // v_mov_b32 v2, 10.0f + 0x56040300, // v_fmac_f32 v2, v0, v1 -> v2 = fma(1.5, 2.25, 10.0) + 0x7E0602FF, 0x7FFFFFFF, // v_mov_b32 v3, 0x7FFFFFFF + 0x7E0802FF, 0x00010003, // v_mov_b32 v4, 0x00010003 + 0xD56C0005, 0x00020903, // v_mul_hi_i32 v5, v3, v4 + 0xD56B0006, 0x00020903, // v_mul_lo_i32 v6, v3, v4 + 0xE0700000, 0x80020200, // buffer_store_dword v2, off, s[8:11], 0 + 0xE0700004, 0x80020500, // buffer_store_dword v5, off, s[8:11], 0 offset:4 + 0xE0700008, 0x80020600, // buffer_store_dword v6, off, s[8:11], 0 offset:8 + 0xBEFE0380, // s_mov_b32 exec_lo, 0 -> lane inactive + 0xE070000C, 0x80020200, // buffer_store_dword v2 offset:12 (masked, must not land) + 0xBEFE03C1, // s_mov_b32 exec_lo, -1 -> lane active again + 0xE0700010, 0x80020000, // buffer_store_dword v0 offset:16 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 8, + StoreBackingBytes: 64); + + // Control flow through the PC dispatcher: a scalar loop counting down from 5, + // accumulating 5+4+3+2+1 = 15 into v1, with the result stored to buffer 0. + // (s_sub_i32's SCC is signed overflow, so the loop condition needs an + // explicit s_cmp_lg_u32 — which also exercises the scalar-compare path.) + public static readonly Gen5ComputeFixture Loop = new( + "loop", + [ + 0xBE800385, // s_mov_b32 s0, 5 + 0x7E020280, // v_mov_b32 v1, 0 + // loop: (pc=0x8) + 0x4A020200, // v_add_nc_u32 v1, s0, v1 + 0x81808100, // s_sub_i32 s0, s0, 1 (inline +1 = 0x81) + 0xBF078000, // s_cmp_lg_u32 s0, 0 + 0xBF85FFFC, // s_cbranch_scc1 loop + 0xE0700000, 0x80020100, // buffer_store_dword v1, off, s[8:11], 0 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 8, + StoreBackingBytes: 16); + + // LDS round trip: write a literal through workgroup-shared memory, barrier, + // read it back, and store it to buffer 0. + public static readonly Gen5ComputeFixture Lds = new( + "lds", + [ + 0x7E000280, // v_mov_b32 v0, 0 (LDS byte address) + 0x7E0402FF, 0x00001234, // v_mov_b32 v2, 0x1234 + 0xD8340000, 0x00000200, // ds_write_b32 v0, v2 + 0xBF8A0000, // s_barrier + 0xD8D80000, 0x03000000, // ds_read_b32 v3, v0 + 0xE0700000, 0x80020300, // buffer_store_dword v3, off, s[8:11], 0 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 8, + StoreBackingBytes: 16); + + // Wave64 cross-lane: v_readfirstlane routes through the translator's + // two-half threadgroup-scratch broadcast bridge. All lanes hold the same + // value (42), so the broadcast result is 42 on every lane regardless of + // which lane is "first" — the fixture's job is to prove the emitted wave64 + // MSL compiles and that a full 64-lane dispatch runs through the bridge + // barriers without deadlocking, returning the broadcast value. + public static readonly Gen5ComputeFixture Wave64Broadcast = new( + "wave64-broadcast", + [ + 0x7E0002FF, 0x0000002A, // v_mov_b32 v0, 42 + 0x7E000500, // v_readfirstlane_b32 s0, v0 + 0x7E020200, // v_mov_b32 v1, s0 + 0xE0700000, 0x80020100, // buffer_store_dword v1, off, s[8:11], 0 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 8, + StoreBackingBytes: 16); + + public static readonly Gen5ComputeFixture[] All = [Fmac, Muls, ExecStore, Loop, Lds]; + + // Minimal pixel program: two interpolated attribute channels plus two + // inline constants exported to MRT0 (done+vm). + public static readonly uint[] PixelWords = + [ + 0xC8020002, // v_interp_mov_f32 v0, p0, attr0.x + 0xC8060102, // v_interp_mov_f32 v1, p0, attr0.y + 0x7E0402F2, // v_mov_b32 v2, 1.0 + 0x7E0602F0, // v_mov_b32 v3, 0.5 + 0xF800180F, 0x03020100, // exp mrt0 v0, v1, v2, v3 done vm + 0xBF810000, // s_endpgm + ]; + + // Minimal vertex program: constant position export plus one param output. + public static readonly uint[] VertexWords = + [ + 0x7E000280, // v_mov_b32 v0, 0 + 0x7E0202F2, // v_mov_b32 v1, 1.0 + 0xF80008CF, 0x01000000, // exp pos0 v0, v0, v0, v1 done + 0xF800020F, 0x01000101, // exp param0 v1, v1, v0, v1 + 0xBF810000, // s_endpgm + ]; + + public static Gen5MslShader CompileVertexOrThrow(int requiredVertexOutputCount = 0) + { + var memory = new FakeGuestMemory(); + memory.AddRegion(ProgramAddress, VertexWords); + var ctx = new CpuContext(memory, Generation.Gen5); + if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError)) + { + throw new InvalidOperationException($"[vertex] decode failed: {decodeError}"); + } + + var state = new Gen5ShaderState(program!, new uint[16], Metadata: null); + var evaluation = new Gen5ShaderEvaluation( + new uint[128], + new uint[128], + Array.Empty(), + Array.Empty()); + if (!Gen5MslTranslator.TryCompileVertexShader( + state, + evaluation, + out var shader, + out var compileError, + requiredVertexOutputCount: requiredVertexOutputCount)) + { + throw new InvalidOperationException($"[vertex] MSL emit failed: {compileError}"); + } + + return shader; + } + + public static Gen5MslShader CompilePixelOrThrow( + Gen5PixelOutputKind outputKind = Gen5PixelOutputKind.Float) + { + var memory = new FakeGuestMemory(); + memory.AddRegion(ProgramAddress, PixelWords); + var ctx = new CpuContext(memory, Generation.Gen5); + if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError)) + { + throw new InvalidOperationException($"[pixel] decode failed: {decodeError}"); + } + + var state = new Gen5ShaderState(program!, new uint[16], Metadata: null); + var evaluation = new Gen5ShaderEvaluation( + new uint[128], + new uint[128], + Array.Empty(), + Array.Empty()); + if (!Gen5MslTranslator.TryCompilePixelShader( + state, + evaluation, + outputKind, + out var shader, + out var compileError)) + { + throw new InvalidOperationException($"[pixel] MSL emit failed: {compileError}"); + } + + return shader; + } + + /// Drives the real decoder and the MSL emitter for one fixture. + public static Gen5MslShader CompileOrThrow(Gen5ComputeFixture fixture) => + CompileOrThrow(fixture, waveLaneCount: 32, localSizeX: 32); + + public static Gen5MslShader CompileOrThrow( + Gen5ComputeFixture fixture, + uint waveLaneCount, + uint localSizeX) + { + var program = DecodeOrThrow(fixture); + + // Buffer stores need a global-memory binding; the emitter resolves them + // by instruction PC, so collect memory-access PCs from the decoded + // program itself. + var accessPcs = new List(); + foreach (var instruction in program.Instructions) + { + if (instruction.Control is Gen5BufferMemoryControl or Gen5GlobalMemoryControl) + { + accessPcs.Add(instruction.Pc); + } + } + + var globalBindings = accessPcs.Count != 0 + ? new[] + { + new Gen5GlobalMemoryBinding( + fixture.StoreScalarResourceBase, + 0UL, + accessPcs, + new byte[Math.Max(fixture.StoreBackingBytes, 4)], + Math.Max(fixture.StoreBackingBytes, 4), + DataPooled: false) + { + Writable = true, + }, + } + : Array.Empty(); + + var state = new Gen5ShaderState(program, new uint[16], Metadata: null); + var evaluation = new Gen5ShaderEvaluation( + new uint[128], + new uint[128], + Array.Empty(), + globalBindings); + + if (!Gen5MslTranslator.TryCompileComputeShader( + state, + evaluation, + localSizeX, + 1, + 1, + out var shader, + out var compileError, + waveLaneCount: waveLaneCount)) + { + throw new InvalidOperationException($"[{fixture.Name}] MSL emit failed: {compileError}"); + } + + return shader; + } + + public static Gen5ShaderProgram DecodeOrThrow(Gen5ComputeFixture fixture) + { + var memory = new FakeGuestMemory(); + memory.AddRegion(ProgramAddress, fixture.Words); + var ctx = new CpuContext(memory, Generation.Gen5); + + if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError)) + { + throw new InvalidOperationException($"[{fixture.Name}] decode failed: {decodeError}"); + } + + return program!; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl new file mode 100644 index 0000000..2ee949c --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl @@ -0,0 +1,169 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return (uint)(uint64_t)simd_ballot(value); +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +kernel void gen5_cs( + device uint* b0 [[buffer(0)]], + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]], + uint3 sharpemu_local_id [[thread_position_in_threadgroup]], + uint3 sharpemu_group_id [[threadgroup_position_in_grid]], + uint sharpemu_lane [[thread_index_in_simdgroup]]) +{ + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + uint bias[1] = {}; + v[0] = sharpemu_local_id.x; + v[1] = sharpemu_local_id.y; + v[2] = sharpemu_local_id.z; + active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x + && (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y + && (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + uint t0 = 0x3FC00000u; + if (exec) { v[0] = t0; } + uint t1 = 0x40100000u; + if (exec) { v[1] = t1; } + uint t2 = 0x41200000u; + if (exec) { v[2] = t2; } + uint t3 = as_type((fma(as_type(v[0]), as_type(v[1]), as_type(v[2])))); + if (exec) { v[2] = t3; } + uint t4 = 0x7FFFFFFFu; + if (exec) { v[3] = t4; } + uint t5 = 0x10003u; + if (exec) { v[4] = t5; } + uint t6 = as_type(mulhi(as_type(v[3]), as_type(v[4]))); + if (exec) { v[5] = t6; } + uint t7 = ((v[3]) * (v[4])); + if (exec) { v[6] = t7; } + uint t8 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t8 + 0u, v[2], 4u); + } + uint t9 = ((0x4u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t9 + 0u, v[5], 4u); + } + uint t10 = ((0x8u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t10 + 0u, v[6], 4u); + } + uint t11 = 0u; + uint t12 = t11; + s[126] = t12; + exec = ((t12) >> sharpemu_lane & 1u) != 0u; + uint t13 = ((0xCu + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t13 + 0u, v[2], 4u); + } + uint t14 = 0xFFFFFFFFu; + uint t15 = t14; + s[126] = t15; + exec = ((t15) >> sharpemu_lane & 1u) != 0u; + uint t16 = ((0x10u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t16 + 0u, v[0], 4u); + } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl new file mode 100644 index 0000000..af106b1 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl @@ -0,0 +1,127 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return (uint)(uint64_t)simd_ballot(value); +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +kernel void gen5_cs( + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]], + uint3 sharpemu_local_id [[thread_position_in_threadgroup]], + uint3 sharpemu_group_id [[threadgroup_position_in_grid]], + uint sharpemu_lane [[thread_index_in_simdgroup]]) +{ + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + uint bias[1] = {}; + v[0] = sharpemu_local_id.x; + v[1] = sharpemu_local_id.y; + v[2] = sharpemu_local_id.z; + active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x + && (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y + && (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + uint t0 = as_type((fma(as_type(v[1]), as_type(v[2]), as_type(v[5])))); + if (exec) { v[5] = t0; } + uint t1 = as_type((fma(as_type(v[1]), as_type(0x42280000u), as_type(v[2])))); + if (exec) { v[5] = t1; } + uint t2 = as_type((fma(as_type(v[1]), as_type(v[2]), as_type(0x42280000u)))); + if (exec) { v[5] = t2; } + uint t3 = as_type((fma(as_type(v[1]), as_type(v[2]), as_type(v[5])))); + if (exec) { v[5] = t3; } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl new file mode 100644 index 0000000..1720035 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl @@ -0,0 +1,135 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return (uint)(uint64_t)simd_ballot(value); +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +kernel void gen5_cs( + device uint* b0 [[buffer(0)]], + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]], + uint3 sharpemu_local_id [[thread_position_in_threadgroup]], + uint3 sharpemu_group_id [[threadgroup_position_in_grid]], + uint sharpemu_lane [[thread_index_in_simdgroup]]) +{ + threadgroup uint sharpemu_lds[8192]; + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + uint bias[1] = {}; + v[0] = sharpemu_local_id.x; + v[1] = sharpemu_local_id.y; + v[2] = sharpemu_local_id.z; + active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x + && (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y + && (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + uint t0 = 0u; + if (exec) { v[0] = t0; } + uint t1 = 0x1234u; + if (exec) { v[2] = t1; } + uint t2 = v[0]; + if (exec) { sharpemu_lds[(((t2) >> 2) & 8191u)] = v[2]; } + threadgroup_barrier(mem_flags::mem_threadgroup | mem_flags::mem_device); + uint t3 = v[0]; + if (exec) { v[3] = sharpemu_lds[(((t3) >> 2) & 8191u)]; } + uint t4 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t4 + 0u, v[3], 4u); + } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl new file mode 100644 index 0000000..38763e5 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl @@ -0,0 +1,149 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return (uint)(uint64_t)simd_ballot(value); +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +kernel void gen5_cs( + device uint* b0 [[buffer(0)]], + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]], + uint3 sharpemu_local_id [[thread_position_in_threadgroup]], + uint3 sharpemu_group_id [[threadgroup_position_in_grid]], + uint sharpemu_lane [[thread_index_in_simdgroup]]) +{ + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + uint bias[1] = {}; + v[0] = sharpemu_local_id.x; + v[1] = sharpemu_local_id.y; + v[2] = sharpemu_local_id.z; + active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x + && (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y + && (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + uint t0 = 5u; + s[0] = t0; + uint t1 = 0u; + if (exec) { v[1] = t1; } + pc = 1u; + } + break; + case 1u: + { + uint t2 = ((s[0]) + (v[1])); + if (exec) { v[1] = t2; } + uint t3 = s[0]; + uint t4 = 1u; + uint t5 = (t3 - t4); + s[0] = t5; + scc = ((((t3 ^ t4)) & (t3 ^ t5)) >> 31) != 0u; + uint t6 = s[0]; + uint t7 = 0u; + scc = (t6) != (t7); + pc = (scc) ? 1u : 2u; + } + break; + case 2u: + { + uint t8 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]); + if (exec) + { + sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t8 + 0u, v[1], 4u); + } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl new file mode 100644 index 0000000..bfecd0d --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl @@ -0,0 +1,127 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return (uint)(uint64_t)simd_ballot(value); +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +kernel void gen5_cs( + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]], + uint3 sharpemu_local_id [[thread_position_in_threadgroup]], + uint3 sharpemu_group_id [[threadgroup_position_in_grid]], + uint sharpemu_lane [[thread_index_in_simdgroup]]) +{ + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + uint bias[1] = {}; + v[0] = sharpemu_local_id.x; + v[1] = sharpemu_local_id.y; + v[2] = sharpemu_local_id.z; + active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x + && (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y + && (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + uint t0 = ((v[1]) * (v[2])); + if (exec) { v[5] = t0; } + uint t1 = mulhi(v[1], v[2]); + if (exec) { v[5] = t1; } + uint t2 = ((v[1]) * (v[2])); + if (exec) { v[5] = t2; } + uint t3 = as_type(mulhi(as_type(v[1]), as_type(v[2]))); + if (exec) { v[5] = t3; } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl new file mode 100644 index 0000000..48c4364 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl @@ -0,0 +1,136 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return value ? 1u : 0u; +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +struct Gen5PsIn +{ + float4 sharpemu_frag_coord [[position]]; + float4 attr0 [[user(locn0)]]; +}; + +struct Gen5PsOut +{ + float4 mrt0 [[color(0)]]; +}; + +fragment Gen5PsOut gen5_ps( + Gen5PsIn sharpemu_in [[stage_in]], + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]]) +{ + const uint sharpemu_lane = 0u; + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + Gen5PsOut sharpemu_out = {}; + uint bias[1] = {}; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + if (exec) { v[0] = as_type(sharpemu_in.attr0[0]); } + if (exec) { v[1] = as_type(sharpemu_in.attr0[1]); } + uint t0 = 0x3F800000u; + if (exec) { v[2] = t0; } + uint t1 = 0x3F000000u; + if (exec) { v[3] = t1; } + sharpemu_out.mrt0 = exec ? vec(as_type(v[0]), as_type(v[1]), as_type(v[2]), as_type(v[3])) : sharpemu_out.mrt0; + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } + if (!exec) + { + discard_fragment(); + } + return sharpemu_out; +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl new file mode 100644 index 0000000..b8b95f3 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl @@ -0,0 +1,129 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +using namespace metal; + +struct SharpEmuUniforms +{ + uint dispatch_limit_x; + uint dispatch_limit_y; + uint dispatch_limit_z; + uint reserved; + uint buffer_bytes[1]; +}; + +static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr) +{ + if ((addr & 3u) == 0u) + { + return addr + 4u <= bytes ? b[addr >> 2] : 0u; + } + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < 4u; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + return value; +} + +static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend) +{ + uint value = 0u; + device const uchar* p = (device const uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + value |= (uint)p[addr + i] << (i * 8u); + } + } + if (signExtend && count < 4u) + { + uint shift = 32u - (count * 8u); + value = (uint)(((int)(value << shift)) >> shift); + } + return value; +} + +static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count) +{ + device uchar* p = (device uchar*)b; + for (uint i = 0u; i < count; i++) + { + if (addr + i < bytes) + { + p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu); + } + } +} + +static inline uint sharpemu_ballot(bool value) +{ + return value ? 1u : 0u; +} + +static constant float sharpemu_off_i4_table[16] = +{ + 0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f, + -0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f, +}; + +struct Gen5VsOut +{ + float4 sharpemu_position [[position]]; + float4 param0 [[user(locn0)]]; +}; + +vertex Gen5VsOut gen5_vs( + constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]], + uint sharpemu_vertex_id [[vertex_id]], + uint sharpemu_instance_id [[instance_id]]) +{ + const uint sharpemu_lane = 0u; + uint s[128] = {}; + uint v[256] = {}; + bool exec = true; + bool vcc = false; + bool scc = false; + uint pc = 0u; + bool active = true; + uint steps = 0u; + Gen5VsOut sharpemu_out = {}; + uint bias[1] = {}; + v[5] = sharpemu_vertex_id; + v[8] = sharpemu_instance_id; + s[106] = 0u; + s[107] = 0u; + s[126] = sharpemu_ballot(true); + s[127] = 0u; + + while (active) + { + switch (pc) + { + case 0u: + { + uint t0 = 0u; + if (exec) { v[0] = t0; } + uint t1 = 0x3F800000u; + if (exec) { v[1] = t1; } + sharpemu_out.sharpemu_position = exec ? float4(as_type(v[0]), as_type(v[0]), as_type(v[0]), as_type(v[1])) : sharpemu_out.sharpemu_position; + sharpemu_out.param0 = exec ? float4(as_type(v[1]), as_type(v[1]), as_type(v[0]), as_type(v[1])) : sharpemu_out.param0; + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } + return sharpemu_out; +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs new file mode 100644 index 0000000..1fa7344 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs @@ -0,0 +1,240 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +/// +/// Minimal Metal.framework access via objc_msgSend — just enough to compile MSL source +/// with the OS runtime compiler and dispatch a single-thread compute kernel. Kept +/// dependency-free on purpose; object lifetimes lean on process teardown, which is fine +/// for a test host. +/// +internal static partial class MetalNative +{ + private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib"; + private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal"; + + [StructLayout(LayoutKind.Sequential)] + private struct MtlSize + { + public nuint Width; + public nuint Height; + public nuint Depth; + } + + [LibraryImport(MetalFramework)] + private static partial nint MTLCreateSystemDefaultDevice(); + + [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] + private static partial nint objc_getClass(string name); + + [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] + private static partial nint sel_registerName(string name); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial nint Send(nint receiver, nint selector); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial nint Send(nint receiver, nint selector, nint argument); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial nint Send(nint receiver, nint selector, nint argument, ref nint error); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial void SendVoid(nint receiver, nint selector); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial void SendVoid(nint receiver, nint selector, nint argument); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index); + + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + private static partial void SendDispatch(nint receiver, nint selector, MtlSize threadgroups, MtlSize threadsPerThreadgroup); + + private static readonly Lazy Device = new(() => + OperatingSystem.IsMacOS() ? MTLCreateSystemDefaultDevice() : 0); + + public static bool IsAvailable => Device.Value != 0; + + private static nint Selector(string name) => sel_registerName(name); + + private static nint NsString(string value) + { + var utf8 = Marshal.StringToCoTaskMemUTF8(value); + try + { + return Send(objc_getClass("NSString"), Selector("stringWithUTF8String:"), utf8); + } + finally + { + Marshal.FreeCoTaskMem(utf8); + } + } + + private static string DescribeError(nint error) + { + if (error == 0) + { + return "unknown error"; + } + + var description = Send(error, Selector("localizedDescription")); + var utf8 = Send(description, Selector("UTF8String")); + return Marshal.PtrToStringUTF8(utf8) ?? "unknown error"; + } + + /// Compiles MSL source with the OS runtime compiler. + public static bool TryCompileLibrary(string source, out nint library, out string error) + { + library = 0; + error = string.Empty; + + // Metal defaults to fast-math; GCN float semantics do not survive it, so the + // harness compiles the way a real Metal backend must: fast-math off. + var options = Send(Send(objc_getClass("MTLCompileOptions"), Selector("alloc")), Selector("init")); + SendVoidBool(options, Selector("setFastMathEnabled:"), false); + + nint nsError = 0; + library = Send( + Device.Value, + Selector("newLibraryWithSource:options:error:"), + NsString(source), + options, + ref nsError); + if (library == 0) + { + error = DescribeError(nsError); + return false; + } + + return true; + } + + /// + /// Runs one thread of a compute kernel with the guest data buffer and the + /// SharpEmuUniforms constant buffer bound at the caller-supplied indices + /// (per the Gen5MslTranslator contract the uniforms index equals the + /// global-buffer count), then returns the data buffer contents. + /// + public static bool TryExecuteSingleThread( + nint library, + string entryPoint, + byte[] bufferContents, + byte[] uniformsContents, + nuint dataIndex, + nuint uniformsIndex, + out byte[] result, + out string error) => + TryExecuteThreadgroup( + library, entryPoint, bufferContents, uniformsContents, + dataIndex, uniformsIndex, threadsPerThreadgroup: 1, out result, out error); + + /// Runs the kernel as a single threadgroup of + /// threads, so wave64 fixtures can + /// exercise both 32-wide simdgroups of one guest wave under a threadgroup + /// barrier. + public static bool TryExecuteThreadgroup( + nint library, + string entryPoint, + byte[] bufferContents, + byte[] uniformsContents, + nuint dataIndex, + nuint uniformsIndex, + uint threadsPerThreadgroup, + out byte[] result, + out string error) + { + result = []; + error = string.Empty; + + var function = Send(library, Selector("newFunctionWithName:"), NsString(entryPoint)); + if (function == 0) + { + error = $"entry point '{entryPoint}' not found in the compiled library"; + return false; + } + + nint nsError = 0; + var pipeline = Send( + Device.Value, + Selector("newComputePipelineStateWithFunction:error:"), + function, + ref nsError); + if (pipeline == 0) + { + error = $"pipeline creation failed: {DescribeError(nsError)}"; + return false; + } + + var queue = Send(Device.Value, Selector("newCommandQueue")); + nint buffer; + unsafe + { + fixed (byte* contents = bufferContents) + { + // options 0 = MTLResourceStorageModeShared: CPU-visible for readback. + buffer = SendBuffer( + Device.Value, + Selector("newBufferWithBytes:length:options:"), + (nint)contents, + (nuint)bufferContents.Length, + 0); + } + } + + nint uniforms; + unsafe + { + fixed (byte* contents = uniformsContents) + { + uniforms = SendBuffer( + Device.Value, + Selector("newBufferWithBytes:length:options:"), + (nint)contents, + (nuint)uniformsContents.Length, + 0); + } + } + + if (queue == 0 || buffer == 0 || uniforms == 0) + { + error = "failed to create command queue or buffer"; + return false; + } + + var commandBuffer = Send(queue, Selector("commandBuffer")); + var encoder = Send(commandBuffer, Selector("computeCommandEncoder")); + SendVoid(encoder, Selector("setComputePipelineState:"), pipeline); + SendSetBuffer(encoder, Selector("setBuffer:offset:atIndex:"), buffer, 0, dataIndex); + SendSetBuffer(encoder, Selector("setBuffer:offset:atIndex:"), uniforms, 0, uniformsIndex); + var oneGroup = new MtlSize { Width = 1, Height = 1, Depth = 1 }; + var threads = new MtlSize { Width = threadsPerThreadgroup, Height = 1, Depth = 1 }; + SendDispatch(encoder, Selector("dispatchThreadgroups:threadsPerThreadgroup:"), oneGroup, threads); + SendVoid(encoder, Selector("endEncoding")); + SendVoid(commandBuffer, Selector("commit")); + SendVoid(commandBuffer, Selector("waitUntilCompleted")); + + var contentsPointer = Send(buffer, Selector("contents")); + if (contentsPointer == 0) + { + error = "buffer contents unavailable after execution"; + return false; + } + + result = new byte[bufferContents.Length]; + Marshal.Copy(contentsPointer, result, 0, result.Length); + return true; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs new file mode 100644 index 0000000..4895cbc --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs @@ -0,0 +1,265 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using Xunit; +using Xunit.Abstractions; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +/// +/// Tier 2 and 3: the emitted MSL must compile with the OS runtime Metal compiler, and +/// the executable fixtures must produce bit-exact results on the GPU, including EXEC +/// masking and dispatcher control flow. These tests no-op (with a note) on hosts +/// without a Metal device so the suite stays green on Windows/Linux CI; the golden +/// and structural tiers still run everywhere. +/// +public sealed class MetalRuntimeTests(ITestOutputHelper output) +{ + [Fact] + public void AllFixturesCompileWithTheRuntimeMetalCompiler() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; compile validation skipped."); + return; + } + + foreach (var fixture in Gen5ComputeFixtures.All) + { + var shader = Gen5ComputeFixtures.CompileOrThrow(fixture); + Assert.True( + MetalNative.TryCompileLibrary(shader.Source, out _, out var error), + $"[{fixture.Name}] Metal rejected the emitted MSL: {error}\n{shader.Source}"); + } + } + + [Fact] + public void ExecStoreProgramExecutesWithExecMasking() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; execution test skipped."); + return; + } + + // Sentinel-filled buffer: any dword the program does not store must survive. + const uint Sentinel = 0xDEADBEEFu; + var buffer = new byte[64]; + for (var offset = 0; offset < buffer.Length; offset += sizeof(uint)) + { + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel); + } + + var result = ExecuteOrThrow(Gen5ComputeFixtures.ExecStore, buffer); + + // Reference results computed with the same semantics the program encodes. + var fmac = BitConverter.SingleToUInt32Bits(MathF.FusedMultiplyAdd(1.5f, 2.25f, 10.0f)); + var mulHiSigned = (uint)(((long)0x7FFFFFFF * 0x00010003) >> 32); + var mulLoSigned = unchecked(0x7FFFFFFFu * 0x00010003u); + var movBits = BitConverter.SingleToUInt32Bits(1.5f); + + Assert.Equal(fmac, ReadDword(result, 0)); + Assert.Equal(mulHiSigned, ReadDword(result, 4)); + Assert.Equal(mulLoSigned, ReadDword(result, 8)); + Assert.Equal(Sentinel, ReadDword(result, 12)); // EXEC=0: the store must not land. + Assert.Equal(movBits, ReadDword(result, 16)); + for (var offset = 20; offset < result.Length; offset += sizeof(uint)) + { + Assert.Equal(Sentinel, ReadDword(result, offset)); + } + } + + [Fact] + public void LoopProgramIteratesThroughTheDispatcher() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; execution test skipped."); + return; + } + + var result = ExecuteOrThrow(Gen5ComputeFixtures.Loop, new byte[16]); + + // 5 + 4 + 3 + 2 + 1, accumulated across five dispatcher round trips. + Assert.Equal(15u, ReadDword(result, 0)); + } + + [Fact] + public void PixelShaderCompilesWithTheRuntimeMetalCompiler() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; compile validation skipped."); + return; + } + + var shader = Gen5ComputeFixtures.CompilePixelOrThrow(); + Assert.True( + MetalNative.TryCompileLibrary(shader.Source, out _, out var error), + $"[pixel] Metal rejected the emitted MSL: {error}\n{shader.Source}"); + } + + [Fact] + public void VertexShaderCompilesWithTheRuntimeMetalCompiler() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; compile validation skipped."); + return; + } + + var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 2); + Assert.True( + MetalNative.TryCompileLibrary(shader.Source, out _, out var error), + $"[vertex] Metal rejected the emitted MSL: {error}\n{shader.Source}"); + } + + [Fact] + public void FixedShadersCompileWithTheRuntimeMetalCompiler() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; compile validation skipped."); + return; + } + + var sources = new (string Name, string Source)[] + { + ("fullscreen", MslFixedShaders.CreateFullscreenVertex(3)), + ("copy", MslFixedShaders.CreateCopyFragment()), + ("present", MslFixedShaders.CreatePresentFragment()), + ("solid", MslFixedShaders.CreateSolidFragment(0.25f, 0.5f, 0.75f, 1f)), + ("attribute", MslFixedShaders.CreateAttributeFragment(1)), + ("depth-only", MslFixedShaders.CreateDepthOnlyFragment()), + }; + foreach (var (name, source) in sources) + { + Assert.True( + MetalNative.TryCompileLibrary(source, out _, out var error), + $"[{name}] Metal rejected the fixed shader: {error}\n{source}"); + } + } + + [Fact] + public void LdsRoundTripExecutesThroughThreadgroupMemory() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; execution test skipped."); + return; + } + + var result = ExecuteOrThrow(Gen5ComputeFixtures.Lds, new byte[16]); + + // Written to LDS, barriered, read back, stored to the buffer. + Assert.Equal(0x1234u, ReadDword(result, 0)); + } + + [Fact] + public void Wave64CrossLaneEmitsValidMsl() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; compile validation skipped."); + return; + } + + // A wave64 program with a cross-lane op emits the threadgroup-scratch + // bridge and its barriers; the runtime Metal compiler must accept it. + var shader = Gen5ComputeFixtures.CompileOrThrow( + Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64); + Assert.Contains("sharpemu_wave_scratch", shader.Source); + Assert.Contains("threadgroup_barrier", shader.Source); + Assert.True( + MetalNative.TryCompileLibrary(shader.Source, out _, out var error), + $"Metal rejected the wave64 MSL: {error}\n{shader.Source}"); + } + + [Fact] + public void Wave64BroadcastExecutesAcrossBothHalvesWithoutDeadlock() + { + if (!MetalNative.IsAvailable) + { + output.WriteLine("No Metal device on this host; execution test skipped."); + return; + } + + // 64 threads = one guest wave (two 32-wide simdgroups). The read-first- + // lane bridge takes a threadgroup_barrier reached by all 64 lanes; if + // the two halves did not rendezvous this would deadlock (the command + // buffer would never complete). Every lane holds 42, so the broadcast + // result is 42 — proving the bridge runs to completion and returns the + // published value. + var shader = Gen5ComputeFixtures.CompileOrThrow( + Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64); + Assert.True( + MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError), + $"{compileError}\n{shader.Source}"); + + var buffer = new byte[16]; + var uniforms = new byte[16 + sizeof(uint)]; + // Dispatch limit 64: all 64 lanes active (see WriteDispatchLimit contract). + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 64); + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1); + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1); + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length); + + Assert.True( + MetalNative.TryExecuteThreadgroup( + library, + shader.EntryPoint, + buffer, + uniforms, + dataIndex: 0, + uniformsIndex: (nuint)shader.GlobalMemoryBindings.Count, + threadsPerThreadgroup: 64, + out var result, + out var runError), + runError); + + Assert.Equal(42u, ReadDword(result, 0)); + } + + private static byte[] ExecuteOrThrow(Gen5ComputeFixture fixture, byte[] buffer) + { + var shader = Gen5ComputeFixtures.CompileOrThrow(fixture); + if (!MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError)) + { + throw new InvalidOperationException($"[{fixture.Name}] {compileError}\n{shader.Source}"); + } + + // Per the translator contract, global buffers occupy indices + // 0..count-1 and SharpEmuUniforms sits at index count; the executable + // fixtures bind exactly one data buffer. + var bufferCount = shader.GlobalMemoryBindings.Count; + Assert.Equal(1, bufferCount); + + // SharpEmuUniforms: dispatch limit (one thread), reserved, then the + // byte length of each bound buffer (the struct's array never has fewer + // than one entry). + var uniforms = new byte[16 + (Math.Max(bufferCount, 1) * sizeof(uint))]; + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 1); + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1); + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1); + BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length); + + if (!MetalNative.TryExecuteSingleThread( + library, + shader.EntryPoint, + buffer, + uniforms, + dataIndex: 0, + uniformsIndex: (nuint)bufferCount, + out var result, + out var runError)) + { + throw new InvalidOperationException($"[{fixture.Name}] {runError}"); + } + + return result; + } + + private static uint ReadDword(byte[] buffer, int offset) => + BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(offset)); +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs new file mode 100644 index 0000000..5ec86ef --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs @@ -0,0 +1,88 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Xunit; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +/// +/// Pins the emitted MSL for the synthetic fixtures. Codegen changes show up as a +/// readable text diff instead of a runtime mystery. Regenerate with +/// SHARPEMU_UPDATE_GOLDENS=1 (writes into the source tree) after intentional +/// emitter changes, then review the diff like any other code change. +/// +public sealed class MslGoldenTests +{ + public static TheoryData FixtureNames() + { + var data = new TheoryData(); + foreach (var fixture in Gen5ComputeFixtures.All) + { + data.Add(fixture.Name); + } + + return data; + } + + [Fact] + public void PixelShaderMatchesGolden() + { + var shader = Gen5ComputeFixtures.CompilePixelOrThrow(); + AssertMatchesGolden("pixel", shader.Source); + } + + [Fact] + public void VertexShaderMatchesGolden() + { + var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 1); + AssertMatchesGolden("vertex", shader.Source); + } + + [Theory] + [MemberData(nameof(FixtureNames))] + public void EmittedMslMatchesGolden(string name) + { + Gen5ComputeFixture? fixture = null; + foreach (var candidate in Gen5ComputeFixtures.All) + { + if (candidate.Name == name) + { + fixture = candidate; + break; + } + } + + Assert.NotNull(fixture); + var shader = Gen5ComputeFixtures.CompileOrThrow(fixture); + AssertMatchesGolden(name, shader.Source); + } + + private static void AssertMatchesGolden(string name, string source) + { + var goldenPath = Path.Combine(AppContext.BaseDirectory, "Goldens", $"{name}.msl"); + + if (Environment.GetEnvironmentVariable("SHARPEMU_UPDATE_GOLDENS") == "1") + { + var sourcePath = Path.Combine( + FindSourceDirectory(), + "Goldens", + $"{name}.msl"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + File.WriteAllText(sourcePath, source); + return; + } + + Assert.True(File.Exists(goldenPath), $"missing golden {goldenPath}; run with SHARPEMU_UPDATE_GOLDENS=1 to create it"); + var expected = File.ReadAllText(goldenPath).ReplaceLineEndings(); + Assert.Equal(expected, source.ReplaceLineEndings()); + } + + private static string FindSourceDirectory([System.Runtime.CompilerServices.CallerFilePath] string sourcePath = "") + { + // Binaries land in artifacts/bin (outside the project directory), so + // walking up from the test binary never finds the csproj; the compiler + // records this file's source path instead. + return Path.GetDirectoryName(sourcePath) + ?? throw new InvalidOperationException("test project directory not found"); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs new file mode 100644 index 0000000..e044409 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs @@ -0,0 +1,163 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Xunit; + +namespace SharpEmu.ShaderCompiler.Metal.Tests; + +/// +/// Structural checks over the emitted MSL — these run on every platform because +/// translation is pure text generation; only the runtime tests need a Metal device. +/// +public sealed class MslTranslationTests +{ + [Fact] + public void EveryFixtureTranslates() + { + foreach (var fixture in Gen5ComputeFixtures.All) + { + var shader = Gen5ComputeFixtures.CompileOrThrow(fixture); + Assert.Equal(Gen5MslStage.Compute, shader.Stage); + Assert.Equal("gen5_cs", shader.EntryPoint); + Assert.Contains("kernel void gen5_cs(", shader.Source, StringComparison.Ordinal); + Assert.Contains("while (active)", shader.Source, StringComparison.Ordinal); + } + } + + [Fact] + public void ExecMaskedStoresAreGuarded() + { + var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.ExecStore); + + // Every buffer store must sit behind the per-lane EXEC guard. + Assert.Contains("if (exec)", shader.Source, StringComparison.Ordinal); + Assert.Contains("sharpemu_store_bytes(b0,", shader.Source, StringComparison.Ordinal); + + // s_mov_b32 exec_lo, 0 / -1 must drive the per-lane bool. + Assert.Contains("exec = ((", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void LoopFixtureProducesMultipleDispatcherBlocks() + { + var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.Loop); + + // The backward branch splits the program into at least three blocks and + // the conditional branch selects between loop head and fallthrough. + Assert.Contains("case 0u:", shader.Source, StringComparison.Ordinal); + Assert.Contains("case 1u:", shader.Source, StringComparison.Ordinal); + Assert.Contains("case 2u:", shader.Source, StringComparison.Ordinal); + Assert.Contains("pc = (scc) ?", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void DispatcherIsBoundedByDefault() + { + var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.Fmac); + Assert.Contains("if (++steps >=", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void UniformsCarryDispatchLimitAndBufferLengths() + { + var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.ExecStore); + Assert.Contains("struct SharpEmuUniforms", shader.Source, StringComparison.Ordinal); + Assert.Contains("dispatch_limit_x", shader.Source, StringComparison.Ordinal); + Assert.Contains("buffer_bytes[", shader.Source, StringComparison.Ordinal); + + // One global binding: b0 at [[buffer(0)]], uniforms at [[buffer(1)]]. + Assert.Contains("device uint* b0 [[buffer(0)]]", shader.Source, StringComparison.Ordinal); + Assert.Contains("[[buffer(1)]]", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void PixelStageEmitsFragmentInterface() + { + var shader = Gen5ComputeFixtures.CompilePixelOrThrow(); + + Assert.Equal(Gen5MslStage.Pixel, shader.Stage); + Assert.Equal("gen5_ps", shader.EntryPoint); + Assert.Equal(1u, shader.AttributeCount); + Assert.Contains("fragment Gen5PsOut gen5_ps(", shader.Source, StringComparison.Ordinal); + Assert.Contains("float4 attr0 [[user(locn0)]];", shader.Source, StringComparison.Ordinal); + Assert.Contains("[[color(0)]]", shader.Source, StringComparison.Ordinal); + Assert.Contains("[[position]]", shader.Source, StringComparison.Ordinal); + + // Interpolation reads land in VGPRs; the export writes MRT0 under EXEC + // and inactive lanes discard at the end. + Assert.Contains("as_type(sharpemu_in.attr0[0])", shader.Source, StringComparison.Ordinal); + Assert.Contains("sharpemu_out.mrt0 = exec ?", shader.Source, StringComparison.Ordinal); + Assert.Contains("discard_fragment();", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void PixelOutputKindsSelectTheAttachmentType() + { + var uintShader = Gen5ComputeFixtures.CompilePixelOrThrow(Gen5PixelOutputKind.Uint); + Assert.Contains("uint4 mrt0 [[color(0)]];", uintShader.Source, StringComparison.Ordinal); + + var sintShader = Gen5ComputeFixtures.CompilePixelOrThrow(Gen5PixelOutputKind.Sint); + Assert.Contains("int4 mrt0 [[color(0)]];", sintShader.Source, StringComparison.Ordinal); + } + + [Fact] + public void VertexStageEmitsVertexInterface() + { + var shader = Gen5ComputeFixtures.CompileVertexOrThrow(); + + Assert.Equal(Gen5MslStage.Vertex, shader.Stage); + Assert.Equal("gen5_vs", shader.EntryPoint); + Assert.Equal(1u, shader.AttributeCount); + Assert.Contains("vertex Gen5VsOut gen5_vs(", shader.Source, StringComparison.Ordinal); + Assert.Contains("float4 sharpemu_position [[position]];", shader.Source, StringComparison.Ordinal); + Assert.Contains("float4 param0 [[user(locn0)]];", shader.Source, StringComparison.Ordinal); + Assert.Contains("uint sharpemu_vertex_id [[vertex_id]],", shader.Source, StringComparison.Ordinal); + Assert.Contains("v[5] = sharpemu_vertex_id;", shader.Source, StringComparison.Ordinal); + Assert.Contains("v[8] = sharpemu_instance_id;", shader.Source, StringComparison.Ordinal); + Assert.Contains("sharpemu_out.sharpemu_position = exec ?", shader.Source, StringComparison.Ordinal); + Assert.Contains("sharpemu_out.param0 = exec ?", shader.Source, StringComparison.Ordinal); + Assert.Contains("return sharpemu_out;", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void RequiredVertexOutputsAreZeroFilledDeclarations() + { + // The paired fragment shader reads locations 0..2; the program only + // exports param0, so 1 and 2 must still be declared (zero-filled). + var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 3); + Assert.Equal(3u, shader.AttributeCount); + Assert.Contains("float4 param1 [[user(locn1)]];", shader.Source, StringComparison.Ordinal); + Assert.Contains("float4 param2 [[user(locn2)]];", shader.Source, StringComparison.Ordinal); + } + + [Fact] + public void FixedShadersCoverThePresenterSurface() + { + var fullscreen = MslFixedShaders.CreateFullscreenVertex(2); + Assert.Contains("vertex FullscreenOut fullscreen_vs(", fullscreen, StringComparison.Ordinal); + Assert.Contains("float4 attr1 [[user(locn1)]];", fullscreen, StringComparison.Ordinal); + + Assert.Contains("tex0.sample(smp0, in.attr0.xy)", MslFixedShaders.CreateCopyFragment(), StringComparison.Ordinal); + Assert.Contains("float4(1.0f, 0.0f, 1.0f, 1.0f)", MslFixedShaders.CreateSolidFragment(1f, 0f, 1f, 1f), StringComparison.Ordinal); + Assert.Contains("return in.attr3;", MslFixedShaders.CreateAttributeFragment(3), StringComparison.Ordinal); + Assert.Contains("fragment void depth_only_fs()", MslFixedShaders.CreateDepthOnlyFragment(), StringComparison.Ordinal); + } + + [Fact] + public void UnsupportedOpcodeFailsLoudlyWithPc() + { + // v_cubeid_f32 is real but outside the phase-1 ALU set: the translator + // must name the opcode and pc instead of emitting wrong code. + var fixture = new Gen5ComputeFixture( + "unsupported", + [ + 0xD5C40000, 0x04060501, // v_cubeid_f32 v0, v1, v2, v3 + 0xBF810000, // s_endpgm + ], + StoreScalarResourceBase: 0, + StoreBackingBytes: 0); + var exception = Assert.Throws( + () => Gen5ComputeFixtures.CompileOrThrow(fixture)); + Assert.Contains("pc=0x", exception.Message, StringComparison.Ordinal); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj b/tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj new file mode 100644 index 0000000..888a325 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj @@ -0,0 +1,32 @@ + + + + + false + false + + true + + + + + + + + + + + + + + + + + +