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