Compare commits

..

1 Commits

90 changed files with 649 additions and 18593 deletions
-2
View File
@@ -8,8 +8,6 @@ path = [
"**/packages.lock.json", "**/packages.lock.json",
"scripts/ps5_names.txt", "scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**", "src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
"_logs/**", "_logs/**",
".github/images/**", ".github/images/**",
".github/pull_request_template.md", ".github/pull_request_template.md",
-2
View File
@@ -14,13 +14,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" /> <Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" /> <Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" /> <Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" /> <Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" /> <Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" /> <Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" /> <Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+3 -3
View File
@@ -14,9 +14,9 @@ available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path. the movie without trying to execute the PS5-specific Bink GPU decode path.
Without an adapter, Bink files remain visible to the guest and the game's Without an adapter, Bink movies are skipped by default: their open call returns
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when not-found so games that mark cinematics as optional progress to their next
explicitly testing a title whose cinematics are optional. state instead of waiting on an empty Bink GPU texture.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in, Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
+5
View File
@@ -45,6 +45,11 @@ internal static partial class Program
[STAThread] [STAThread]
private static int Main(string[] args) private static int Main(string[] args)
{ {
// Avoid blocking full collections while guest and render threads are
// running, and establish the GC mode before the runtime reserves the
// fixed guest address-space window.
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
try try
{ {
return Run(args); return Run(args);
-7
View File
@@ -253,7 +253,6 @@
"dependencies": { "dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )", "SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler": "[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, )", "SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )",
"Silk.NET.Input": "[2.23.0, )", "Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )", "Silk.NET.Vulkan": "[2.23.0, )",
@@ -271,12 +270,6 @@
"SharpEmu.HLE": "[0.0.2-beta.3, )" "SharpEmu.HLE": "[0.0.2-beta.3, )"
} }
}, },
"sharpemu.shadercompiler.metal": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.vulkan": { "sharpemu.shadercompiler.vulkan": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
@@ -1410,8 +1410,6 @@ public sealed partial class DirectExecutionBackend
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer "eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId "qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
"Q2V+iqvjgC0" or // vsnprintf "Q2V+iqvjgC0" or // vsnprintf
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"q1cHNfGycLI" or // scePadRead "q1cHNfGycLI" or // scePadRead
"xk0AcarP3V4" or // scePadOpen "xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent "yH17Q6NWtVg" or // sceUserServiceGetEvent
@@ -1573,8 +1571,6 @@ public sealed partial class DirectExecutionBackend
"WkkeywLJcgU" or // wcslen "WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp "Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp "aesyjrHVWy4" or // strncmp
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"pNtJdE3x49E" or // wcscmp "pNtJdE3x49E" or // wcscmp
"fV2xHER+bKE" or // wcscoll "fV2xHER+bKE" or // wcscoll
"E8wCoUEbfzk" or // wcsncmp "E8wCoUEbfzk" or // wcsncmp
@@ -1291,12 +1291,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address) private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
{ {
if (IsHlePreferredNid(nid))
{
address = 0;
return false;
}
if (nid == "1jfXLRVzisc" && if (nid == "1jfXLRVzisc" &&
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal)) string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
{ {
@@ -1408,54 +1402,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
0x75, 0xE7, 0x75, 0xE7,
0xC3, 0xC3,
], ],
"AV6ipCNa4Rw" =>
[
0x0F, 0xB6, 0x07,
0x0F, 0xB6, 0x16,
0x8D, 0x48, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC0, 0x20,
0x8D, 0x4A, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC2, 0x20,
0x29, 0xD0,
0x75, 0x0C,
0x85, 0xD2,
0x74, 0x08,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xC6,
0xEB, 0xD4,
0xC3,
],
"viiwFMaNamA" =>
[
0x0F, 0xB6, 0x16,
0x84, 0xD2,
0x74, 0x2D,
0x0F, 0xB6, 0x07,
0x84, 0xC0,
0x74, 0x2A,
0x38, 0xD0,
0x75, 0x1D,
0x4C, 0x8D, 0x47, 0x01,
0x4C, 0x8D, 0x4E, 0x01,
0x41, 0x0F, 0xB6, 0x09,
0x84, 0xC9,
0x74, 0x12,
0x41, 0x38, 0x08,
0x75, 0x08,
0x49, 0xFF, 0xC0,
0x49, 0xFF, 0xC1,
0xEB, 0xEB,
0x48, 0xFF, 0xC7,
0xEB, 0xD3,
0x48, 0x89, 0xF8,
0xC3,
0x31, 0xC0,
0xC3,
],
"pNtJdE3x49E" or "fV2xHER+bKE" => "pNtJdE3x49E" or "fV2xHER+bKE" =>
[ [
0x0F, 0xB7, 0x07, 0x0F, 0xB7, 0x07,
@@ -1520,14 +1466,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
"Q3VBxCXhUHs" => "Q3VBxCXhUHs" =>
[ [
0x48, 0x89, 0xF8, 0x48, 0x89, 0xF8,
0x48, 0x85, 0xD2, 0x48, 0x89, 0xD1,
0x74, 0x11, 0xF3, 0xA4,
0x44, 0x8A, 0x06,
0x44, 0x88, 0x07,
0x48, 0xFF, 0xC6,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xCA,
0x75, 0xEF,
0xC3, 0xC3,
], ],
"8zTFvBIAIN8" => "8zTFvBIAIN8" =>
@@ -1661,8 +1601,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static bool IsHlePreferredNid(string nid) private static bool IsHlePreferredNid(string nid)
{ {
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) || return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal);
string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal);
} }
private static bool IsLibcLibrary(string libraryName) private static bool IsLibcLibrary(string libraryName)
@@ -2602,22 +2541,28 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source) private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
{ {
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's // Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite // xmm2 to its low 40 bits, then copies the resulting second dword into
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in // xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes. // extract source byte 4 and insert the zero-extended value into lane 1.
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength); ReadOnlySpan<byte> pattern =
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister)) [
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
];
for (var i = 0; i < pattern.Length; i++)
{ {
return false; if (source[i] != pattern[i])
} {
return false;
Span<byte> replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength]; }
if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement))
{
return false;
} }
ReadOnlySpan<byte> replacement =
[
0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04,
0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01,
];
uint oldProtect = 0; uint oldProtect = 0;
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect)) if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
{ {
@@ -1,115 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.Core.Cpu.Native;
/// <summary>
/// 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.
/// </summary>
public static class Sse4aExtrqBlendPatch
{
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
public const int SequenceLength = 12;
/// <summary>
/// Matches the 12-byte idiom, extracting the destination register D and the
/// source (scratch) register N:
/// <code>
/// 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
/// </code>
/// 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.
/// </summary>
public static bool TryMatch(ReadOnlySpan<byte> 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 <W=0 vvvv=~D L=0 pp=01> 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;
}
/// <summary>
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
/// <code>
/// 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
/// </code>
/// 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.
/// </summary>
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> 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;
}
}
@@ -873,15 +873,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> 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; var requiresExclusiveAccess = false;
_gate.EnterReadLock(); _gate.EnterReadLock();
try try
+5 -40
View File
@@ -51,33 +51,9 @@ public static unsafe class GuestImageWriteTracker
private static readonly object _gate = new(); private static readonly object _gate = new();
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new(); private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
/// <summary>Immutable snapshot read lock-free from the signal handler and // Snapshot array read lock-free from the signal handler; rebuilt on every
/// the managed-write pre-visit; rebuilt on every mutation under the gate // mutation under the gate. Signal handlers must not take managed locks.
/// (signal handlers must not take managed locks). Carrying the overall private static TrackedRange[] _rangeSnapshot = [];
/// bounds inside the same object keeps the hot-path intersection test
/// consistent with the array it guards.</summary>
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() && private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0"; Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
@@ -290,17 +266,6 @@ public static unsafe class GuestImageWriteTracker
var end = address > ulong.MaxValue - byteCount var end = address > ulong.MaxValue - byteCount
? ulong.MaxValue ? ulong.MaxValue
: address + byteCount; : 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; var candidate = address;
while (candidate < end) while (candidate < end)
{ {
@@ -346,7 +311,7 @@ public static unsafe class GuestImageWriteTracker
return false; return false;
} }
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges; var ranges = Volatile.Read(ref _rangeSnapshot);
var writableStart = ulong.MaxValue; var writableStart = ulong.MaxValue;
var writableEnd = 0UL; var writableEnd = 0UL;
for (var index = 0; index < ranges.Length; index++) for (var index = 0; index < ranges.Length; index++)
@@ -493,7 +458,7 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked() private static void RebuildSnapshotLocked()
{ {
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray())); _rangeSnapshot = _rangesByAddress.Values.ToArray();
} }
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount) private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
+109 -332
View File
@@ -14,12 +14,6 @@ namespace SharpEmu.Libs.Agc;
public static partial class AgcExports 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 #if DEBUG
static AgcExports() static AgcExports()
{ {
@@ -568,8 +562,6 @@ public static partial class AgcExports
public ulong WorkSequence { get; set; } public ulong WorkSequence { get; set; }
public ulong SubmissionSequence { get; set; } public ulong SubmissionSequence { get; set; }
public bool WaitMonitorRunning { get; set; } public bool WaitMonitorRunning { get; set; }
public object WaitMonitorSignalGate { get; } = new();
public long WaitMonitorSignalVersion { get; set; }
} }
private readonly record struct RegisteredAgcResource( private readonly record struct RegisteredAgcResource(
@@ -2674,7 +2666,7 @@ public static partial class AgcExports
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}"); TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
} }
GuestGpu.Current.AttachGuestMemory(ctx.Memory); VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
@@ -2726,7 +2718,7 @@ public static partial class AgcExports
$"addr=0x{commandAddress:X16} dwords={dwordCount}"); $"addr=0x{commandAddress:X16} dwords={dwordCount}");
} }
GuestGpu.Current.AttachGuestMemory(ctx.Memory); VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
@@ -2954,7 +2946,7 @@ public static partial class AgcExports
// guest-memory writes have finished. Put the notification on that same // guest-memory writes have finished. Put the notification on that same
// logical graphics queue instead of approximating completion with a // logical graphics queue instead of approximating completion with a
// timer, which can wake Unity while its upload data is still stale. // timer, which can wake Unity while its upload data is still stale.
if (GuestGpu.Current.SubmitOrderedGuestAction( if (VulkanVideoPresenter.SubmitOrderedGuestAction(
TriggerCompletionEvents, TriggerCompletionEvents,
$"agc submit completion {submissionId}") == 0) $"agc submit completion {submissionId}") == 0)
{ {
@@ -2978,11 +2970,11 @@ public static partial class AgcExports
return false; return false;
} }
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue( using var guestQueueScope = VulkanVideoPresenter.EnterGuestQueue(
state.QueueName, state.QueueName,
state.ActiveSubmissionId); state.ActiveSubmissionId);
var windowByteCount = checked((int)(dwordCount * sizeof(uint))); var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
var rented = GuestDataPool.Shared.Rent(windowByteCount); var rented = VulkanVideoPresenter.GuestDataPool.Rent(windowByteCount);
try try
{ {
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount))) if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
@@ -3004,7 +2996,7 @@ public static partial class AgcExports
{ {
_dcbWindowBuffer = null; _dcbWindowBuffer = null;
_dcbWindowByteLength = 0; _dcbWindowByteLength = 0;
GuestDataPool.Shared.Return(rented); VulkanVideoPresenter.GuestDataPool.Return(rented);
} }
} }
@@ -3306,20 +3298,17 @@ public static partial class AgcExports
indexed: false); indexed: false);
} }
if (op is ItDispatchDirect or ItDispatchIndirect) if ((op is ItDispatchDirect or ItDispatchIndirect) &&
TryReadComputeDispatch(
ctx,
state,
currentAddress,
length,
op,
out var dispatch))
{ {
if (TryReadComputeDispatch( state.FrameDispatchCount++;
ctx, ObserveComputeDispatch(ctx, gpuState, state, dispatch);
state,
currentAddress,
length,
op,
out var dispatch,
out _))
{
state.FrameDispatchCount++;
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
}
} }
if (op == ItNop && if (op == ItNop &&
@@ -3328,7 +3317,7 @@ public static partial class AgcExports
TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) && TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) &&
TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex)) TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex))
{ {
var waitSequence = GuestGpu.Current.SubmitOrderedGuestFlipWait( var waitSequence = VulkanVideoPresenter.SubmitOrderedGuestFlipWait(
unchecked((int)waitVideoOutHandle), unchecked((int)waitVideoOutHandle),
unchecked((int)waitDisplayBufferIndex)); unchecked((int)waitDisplayBufferIndex));
TraceAgcShader( TraceAgcShader(
@@ -3657,11 +3646,27 @@ public static partial class AgcExports
void CompleteAndWake() void CompleteAndWake()
{ {
CompleteLabelProducer(producer); CompleteLabelProducer(producer);
lock (gpuState.WaitMonitorSignalGate) if (GpuWaitRegistry.Count == 0)
{ {
gpuState.WaitMonitorSignalVersion++; return;
Monitor.Pulse(gpuState.WaitMonitorSignalGate);
} }
// Resuming a DCB can enqueue another compute dispatch and wait for
// it. Never do that reentrantly on the Vulkan render thread.
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
var (resumeContext, resumeGpuState) = state;
lock (resumeGpuState.Gate)
{
DrainResumableDcbs(
resumeContext,
resumeGpuState,
tracePackets: _traceAgc);
}
},
(ctx, gpuState),
preferLocal: false);
} }
void ApplyAndQueueCompletion() void ApplyAndQueueCompletion()
@@ -3672,7 +3677,7 @@ public static partial class AgcExports
// wake another queue before that mirror is visible. Queue a // wake another queue before that mirror is visible. Queue a
// second same-queue ordered action after all immediate follow-up // second same-queue ordered action after all immediate follow-up
// writes; it fences those writes before publishing the producer. // writes; it fences those writes before publishing the producer.
if (GuestGpu.Current.SubmitOrderedGuestAction( if (VulkanVideoPresenter.SubmitOrderedGuestAction(
CompleteAndWake, CompleteAndWake,
$"{debugName} completion") == 0) $"{debugName} completion") == 0)
{ {
@@ -3680,7 +3685,7 @@ public static partial class AgcExports
} }
} }
if (GuestGpu.Current.SubmitOrderedGuestAction( if (VulkanVideoPresenter.SubmitOrderedGuestAction(
ApplyAndQueueCompletion, ApplyAndQueueCompletion,
debugName) == 0) debugName) == 0)
{ {
@@ -3890,11 +3895,11 @@ public static partial class AgcExports
TraceAgc( TraceAgc(
$"agc.acquire_mem_applied queue={queueName} " + $"agc.acquire_mem_applied queue={queueName} " +
$"submission={submissionId} packet=0x{packetAddress:X16} " + $"submission={submissionId} packet=0x{packetAddress:X16} " +
$"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}"); $"work_sequence={VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics}");
} }
} }
var sequence = GuestGpu.Current.SubmitOrderedGuestAction( var sequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
ApplyAcquire, ApplyAcquire,
debugName); debugName);
if (sequence == 0) if (sequence == 0)
@@ -4040,7 +4045,7 @@ public static partial class AgcExports
return; return;
} }
foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents()) foreach (var (address, width, height, byteCount) in VulkanVideoPresenter.GetGuestImageExtents())
{ {
if (scopeByteCount != ulong.MaxValue && if (scopeByteCount != ulong.MaxValue &&
!RangesOverlap(address, byteCount, scopeAddress, scopeByteCount)) !RangesOverlap(address, byteCount, scopeAddress, scopeByteCount))
@@ -4061,7 +4066,7 @@ public static partial class AgcExports
var pixels = new byte[byteCount]; var pixels = new byte[byteCount];
if (ctx.Memory.TryRead(address, pixels)) if (ctx.Memory.TryRead(address, pixels))
{ {
GuestGpu.Current.SubmitGuestImageWrite(address, pixels); VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64) if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
@@ -4104,7 +4109,7 @@ public static partial class AgcExports
ulong byteCount, ulong byteCount,
uint? fillValue) uint? fillValue)
{ {
var hasImage = GuestGpu.Current.TryGetGuestImageExtent( var hasImage = VulkanVideoPresenter.TryGetGuestImageExtent(
destinationAddress, destinationAddress,
out var width, out var width,
out var height, out var height,
@@ -4128,14 +4133,14 @@ public static partial class AgcExports
if (fillValue is { } fill) if (fillValue is { } fill)
{ {
GuestGpu.Current.SubmitGuestImageFill(destinationAddress, fill); VulkanVideoPresenter.SubmitGuestImageFill(destinationAddress, fill);
return; return;
} }
var pixels = new byte[imageBytes]; var pixels = new byte[imageBytes];
if (ctx.Memory.TryRead(destinationAddress, pixels)) if (ctx.Memory.TryRead(destinationAddress, pixels))
{ {
GuestGpu.Current.SubmitGuestImageWrite(destinationAddress, pixels); VulkanVideoPresenter.SubmitGuestImageWrite(destinationAddress, pixels);
} }
} }
@@ -4523,17 +4528,6 @@ public static partial class AgcExports
? fallbackMs ? fallbackMs
: 0L) * System.Diagnostics.Stopwatch.Frequency / 1000L; : 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 // 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 // function for both the AGC NOP-encapsulated (RWaitMem32/64) and the standard
// ItWaitRegMem packet layouts. // ItWaitRegMem packet layouts.
@@ -4598,85 +4592,6 @@ public static partial class AgcExports
// Returns true when the DCB should suspend parsing at this wait (its // Returns true when the DCB should suspend parsing at this wait (its
// continuation was registered into GpuWaitRegistry); false to keep parsing // continuation was registered into GpuWaitRegistry); false to keep parsing
// (already satisfied, unreadable, or legacy force-satisfy mode). // (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( private static bool HandleSubmittedWaitRegMem(
CpuContext ctx, CpuContext ctx,
SubmittedDcbState state, SubmittedDcbState state,
@@ -4840,45 +4755,38 @@ public static partial class AgcExports
SubmittedGpuState gpuState) SubmittedGpuState gpuState)
{ {
var delayMilliseconds = 1; var delayMilliseconds = 1;
long observedSignal;
lock (gpuState.WaitMonitorSignalGate)
{
observedSignal = gpuState.WaitMonitorSignalVersion;
}
while (true) while (true)
{ {
int resumed; var madeProgress = false;
int remaining;
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
resumed = DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc); var before = GpuWaitRegistry.CountForMemory(ctx.Memory);
remaining = GpuWaitRegistry.CountForMemory(ctx.Memory); if (before == 0)
if (_traceAgc && resumed != 0) {
gpuState.WaitMonitorRunning = false;
return;
}
DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc);
var after = GpuWaitRegistry.CountForMemory(ctx.Memory);
madeProgress = after < before;
if (madeProgress)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] agc.wait_monitor_resumed count={resumed} " + $"[LOADER][TRACE] agc.wait_monitor_resumed count={before - after} " +
$"remaining={remaining}"); $"remaining={after}");
} }
if (remaining == 0) if (after == 0)
{ {
gpuState.WaitMonitorRunning = false; gpuState.WaitMonitorRunning = false;
return; return;
} }
} }
delayMilliseconds = resumed != 0 delayMilliseconds = madeProgress
? 1 ? 1
: Math.Min(delayMilliseconds * 2, 16); : Math.Min(delayMilliseconds * 2, 16);
lock (gpuState.WaitMonitorSignalGate) Thread.Sleep(delayMilliseconds);
{
if (gpuState.WaitMonitorSignalVersion == observedSignal)
{
Monitor.Wait(gpuState.WaitMonitorSignalGate, delayMilliseconds);
}
observedSignal = gpuState.WaitMonitorSignalVersion;
}
} }
} }
@@ -4932,17 +4840,16 @@ public static partial class AgcExports
// guest memory (labels are advanced by ReleaseMem/WriteData/DmaData packets // guest memory (labels are advanced by ReleaseMem/WriteData/DmaData packets
// or direct CPU writes) and resumes the ones now satisfied. A resumed DCB // or direct CPU writes) and resumes the ones now satisfied. A resumed DCB
// can itself write labels that unblock others, so loop to a fixed point. // can itself write labels that unblock others, so loop to a fixed point.
private static int DrainResumableDcbs( private static void DrainResumableDcbs(
CpuContext ctx, CpuContext ctx,
SubmittedGpuState gpuState, SubmittedGpuState gpuState,
bool tracePackets) bool tracePackets)
{ {
if (!_gpuWaitSuspendEnabled) if (!_gpuWaitSuspendEnabled)
{ {
return 0; return;
} }
var resumedCount = 0;
for (var pass = 0; pass < 256; pass++) for (var pass = 0; pass < 256; pass++)
{ {
var woken = GpuWaitRegistry.CollectSatisfied(ctx.Memory, (address, is64Bit) => var woken = GpuWaitRegistry.CollectSatisfied(ctx.Memory, (address, is64Bit) =>
@@ -4950,50 +4857,7 @@ public static partial class AgcExports
? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null ? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null
: TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null); : TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null);
// Indirect-dispatch dimension retries whose deadline elapsed are if (woken is null)
// 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 && if (_gpuWaitStaleTicks > 0 &&
GpuWaitRegistry.CollectUnreportedStale( GpuWaitRegistry.CollectUnreportedStale(
@@ -5020,20 +4884,14 @@ public static partial class AgcExports
} }
} }
return resumedCount; return;
} }
if (woken is not null) foreach (var waiter in woken)
{ {
foreach (var waiter in woken) ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
{
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
resumedCount++;
}
} }
} }
return resumedCount;
} }
private static void ResumeSuspendedDcb( private static void ResumeSuspendedDcb(
@@ -5175,15 +5033,6 @@ public static partial class AgcExports
_ => false, _ => 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) if (tracePacket)
{ {
TraceAgc( TraceAgc(
@@ -5249,16 +5098,6 @@ public static partial class AgcExports
_ => false, _ => 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) if (tracePacket)
{ {
TraceAgc( TraceAgc(
@@ -5559,7 +5398,7 @@ public static partial class AgcExports
state.KnownRenderTargets[resolveSource.Address] = resolveSource; state.KnownRenderTargets[resolveSource.Address] = resolveSource;
state.KnownRenderTargets[resolveDestination.Address] = resolveDestination; state.KnownRenderTargets[resolveDestination.Address] = resolveDestination;
ProvideRenderTargetInitialData(ctx, resolveSource); ProvideRenderTargetInitialData(ctx, resolveSource);
if (GuestGpu.Current.TrySubmitGuestImageBlit( if (VulkanVideoPresenter.TrySubmitGuestImageBlit(
resolveSource.Address, resolveSource.Address,
resolveSource.Width, resolveSource.Width,
resolveSource.Height, resolveSource.Height,
@@ -5870,7 +5709,7 @@ public static partial class AgcExports
var cacheKey = ( var cacheKey = (
exportShaderAddress, exportShaderAddress,
exportFingerprint, exportFingerprint,
_storageBufferOffsetAlignment); VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
_depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader); _depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader);
if (vertexShader is null) if (vertexShader is null)
@@ -5895,7 +5734,7 @@ public static partial class AgcExports
: guestGlobalBufferCount + 1, : guestGlobalBufferCount + 1,
requiredVertexOutputCount: 0, requiredVertexOutputCount: 0,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
ReturnPooledEvaluationArrays(exportEvaluation); ReturnPooledEvaluationArrays(exportEvaluation);
return false; return false;
@@ -5907,7 +5746,7 @@ public static partial class AgcExports
exportFingerprint, exportFingerprint,
vertexShader!, vertexShader!,
exportState.Program); exportState.Program);
GuestGpu.Current.CountShaderCompilation(); VulkanVideoPresenter.CountSpirvCompilation();
_depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!); _depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!);
} }
@@ -6193,7 +6032,7 @@ public static partial class AgcExports
attributeCount, attributeCount,
psInputEna, psInputEna,
psInputAddr, psInputAddr,
_storageBufferOffsetAlignment); VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
var guestGlobalBuffers = var guestGlobalBuffers =
pixelEvaluation.GlobalMemoryBindings.Count + pixelEvaluation.GlobalMemoryBindings.Count +
@@ -6229,7 +6068,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna, pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr, pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment) || VulkanVideoPresenter.GuestStorageBufferOffsetAlignment) ||
!GuestGpu.Current.TryCompileVertexShader( !GuestGpu.Current.TryCompileVertexShader(
exportState, exportState,
exportEvaluation, exportEvaluation,
@@ -6241,7 +6080,7 @@ public static partial class AgcExports
scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1, scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1,
requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState), requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState),
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
ReturnPooledEvaluationArrays(exportEvaluation); ReturnPooledEvaluationArrays(exportEvaluation);
ReturnPooledEvaluationArrays(pixelEvaluation); ReturnPooledEvaluationArrays(pixelEvaluation);
@@ -6261,7 +6100,7 @@ public static partial class AgcExports
pixelStateFingerprint, pixelStateFingerprint,
compiled.Pixel, compiled.Pixel,
pixelState.Program); pixelState.Program);
GuestGpu.Current.CountShaderCompilation(); VulkanVideoPresenter.CountSpirvCompilation();
_graphicsShaderCache.TryAdd(shaderKey, compiled); _graphicsShaderCache.TryAdd(shaderKey, compiled);
} }
@@ -6539,7 +6378,7 @@ public static partial class AgcExports
var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort); var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort);
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex); var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var byteCount = checked((int)(indexCount * (uint)bytesPerIndex)); var byteCount = checked((int)(indexCount * (uint)bytesPerIndex));
var data = GuestDataPool.Shared.Rent(byteCount); var data = VulkanVideoPresenter.GuestDataPool.Rent(byteCount);
var span = data.AsSpan(0, byteCount); var span = data.AsSpan(0, byteCount);
var address = state.IndexBufferAddress + byteOffset; var address = state.IndexBufferAddress + byteOffset;
if (ctx.Memory.TryRead(address, span) || if (ctx.Memory.TryRead(address, span) ||
@@ -6548,7 +6387,7 @@ public static partial class AgcExports
return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true); return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true);
} }
GuestDataPool.Shared.Return(data); VulkanVideoPresenter.GuestDataPool.Return(data);
return null; return null;
} }
@@ -6575,7 +6414,7 @@ public static partial class AgcExports
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex); var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var address = state.IndexBufferAddress + byteOffset; var address = state.IndexBufferAddress + byteOffset;
const int chunkBytes = 64 * 1024; const int chunkBytes = 64 * 1024;
var scratch = GuestDataPool.Shared.Rent(chunkBytes); var scratch = VulkanVideoPresenter.GuestDataPool.Rent(chunkBytes);
var remaining = drawCount; var remaining = drawCount;
var maxIndex = 0u; var maxIndex = 0u;
var sawIndex = false; var sawIndex = false;
@@ -6617,7 +6456,7 @@ public static partial class AgcExports
} }
finally finally
{ {
GuestDataPool.Shared.Return(scratch); VulkanVideoPresenter.GuestDataPool.Return(scratch);
} }
var indexedRecords = sawIndex && maxIndex != uint.MaxValue var indexedRecords = sawIndex && maxIndex != uint.MaxValue
@@ -6741,7 +6580,7 @@ public static partial class AgcExports
{ {
hash = (hash ^ ( hash = (hash ^ (
binding.BaseAddress & binding.BaseAddress &
(_storageBufferOffsetAlignment - 1))) * prime; (VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))) * prime;
} }
if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters) if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters)
@@ -6831,8 +6670,7 @@ public static partial class AgcExports
scissor, scissor,
DecodeViewport(registers, target.Width, target.Height, scissor), DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers), DecodeRasterState(registers),
DecodeDepthState(registers), DecodeDepthState(registers));
DecodeBlendConstant(registers));
} }
private static GuestRenderState CreateRenderState( private static GuestRenderState CreateRenderState(
@@ -6865,8 +6703,7 @@ public static partial class AgcExports
scissor, scissor,
DecodeViewport(registers, target.Width, target.Height, scissor), DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers), DecodeRasterState(registers),
DecodeDepthState(registers), DecodeDepthState(registers));
DecodeBlendConstant(registers));
} }
// DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE // DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE
@@ -6971,22 +6808,6 @@ public static partial class AgcExports
return new GuestRasterState(cullFront, cullBack, frontFaceClockwise, wireframe); return new GuestRasterState(cullFront, cullBack, frontFaceClockwise, wireframe);
} }
/// <summary>CB_BLEND_RED..ALPHA carry the constant blend color as raw
/// float bits; unwritten registers read as the reset value (0.0).</summary>
private static GuestBlendConstant DecodeBlendConstant(
IReadOnlyDictionary<uint, uint> 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( private static GuestBlendState DecodeBlendState(
IReadOnlyDictionary<uint, uint> registers, IReadOnlyDictionary<uint, uint> registers,
uint slot) uint slot)
@@ -7499,7 +7320,7 @@ public static partial class AgcExports
IReadOnlyList<uint> registers, IReadOnlyList<uint> registers,
IReadOnlyList<Gen5GlobalMemoryBinding> bindings) IReadOnlyList<Gen5GlobalMemoryBinding> bindings)
{ {
var bytes = GuestDataPool.Shared.Rent( var bytes = VulkanVideoPresenter.GuestDataPool.Rent(
GetRuntimeScalarBufferLength(bindings.Count)); GetRuntimeScalarBufferLength(bindings.Count));
PackRuntimeScalarStateInto(bytes, registers, bindings); PackRuntimeScalarStateInto(bytes, registers, bindings);
return bytes; return bytes;
@@ -7525,7 +7346,7 @@ public static partial class AgcExports
{ {
var byteBias = checked((uint)( var byteBias = checked((uint)(
bindings[index].BaseAddress & bindings[index].BaseAddress &
(_storageBufferOffsetAlignment - 1))); (VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1)));
BinaryPrimitives.WriteUInt32LittleEndian( BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)), bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)),
byteBias); byteBias);
@@ -7568,7 +7389,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
@@ -7578,7 +7399,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
} }
@@ -7604,7 +7425,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
} }
@@ -7615,7 +7436,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
} }
@@ -7623,7 +7444,7 @@ public static partial class AgcExports
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer && if (index && draw.IndexBuffer is { Pooled: true } indexBuffer &&
returned.Add(indexBuffer.Data)) returned.Add(indexBuffer.Data))
{ {
GuestDataPool.Shared.Return(indexBuffer.Data); VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data);
} }
} }
@@ -7880,7 +7701,7 @@ public static partial class AgcExports
if (!isStorage && if (!isStorage &&
descriptor.Address != 0 && descriptor.Address != 0 &&
GuestGpu.Current.IsGpuGuestImageAvailable( VulkanVideoPresenter.IsGuestImageAvailable(
descriptor.Address, descriptor.Address,
descriptor.Format, descriptor.Format,
descriptor.NumberType)) descriptor.NumberType))
@@ -7909,7 +7730,7 @@ public static partial class AgcExports
{ {
var initialPixels = Array.Empty<byte>(); var initialPixels = Array.Empty<byte>();
var uploadKnown = descriptor.Address != 0 && var uploadKnown = descriptor.Address != 0 &&
GuestGpu.Current.IsGuestImageUploadKnown( VulkanVideoPresenter.IsGuestImageUploadKnown(
descriptor.Address, descriptor.Address,
descriptor.Format, descriptor.Format,
descriptor.NumberType); descriptor.NumberType);
@@ -7990,8 +7811,8 @@ public static partial class AgcExports
if (!_textureCopySkipDisabled && if (!_textureCopySkipDisabled &&
descriptor.Address != 0 && descriptor.Address != 0 &&
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) && !SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
GuestGpu.Current.IsTextureContentCached( VulkanVideoPresenter.IsTextureContentCached(
new TextureContentIdentity( new VulkanVideoPresenter.TextureContentIdentity(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
descriptor.Height, descriptor.Height,
@@ -8094,7 +7915,7 @@ public static partial class AgcExports
CpuContext ctx, CpuContext ctx,
RenderTargetDescriptor target) RenderTargetDescriptor target)
{ {
if (!GuestGpu.Current.GuestImageWantsInitialData(target.Address)) if (!VulkanVideoPresenter.GuestImageWantsInitialData(target.Address))
{ {
return; return;
} }
@@ -8120,7 +7941,7 @@ public static partial class AgcExports
if (nonZero) if (nonZero)
{ {
GuestGpu.Current.ProvideGuestImageInitialData(target.Address, initialData); VulkanVideoPresenter.ProvideGuestImageInitialData(target.Address, initialData);
} }
} }
@@ -8417,14 +8238,9 @@ public static partial class AgcExports
ulong packetAddress, ulong packetAddress,
uint packetLength, uint packetLength,
uint opcode, uint opcode,
out ComputeDispatch dispatch, out ComputeDispatch dispatch)
out ulong indirectDimsRetryAddress)
{ {
dispatch = default; 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; ulong dimensionsAddress;
uint initiator; uint initiator;
string dispatchSource; string dispatchSource;
@@ -8473,17 +8289,6 @@ public static partial class AgcExports
if (dispatchEndX == 0 || dispatchEndY == 0 || dispatchEndZ == 0) 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( return RejectComputeDispatch(
dimensionsAddress, dimensionsAddress,
initiator, initiator,
@@ -8818,7 +8623,7 @@ public static partial class AgcExports
// still queued, so the clear could erase newly constructed CPU // still queued, so the clear could erase newly constructed CPU
// objects. Waiting on the work sequence also retires preceding // objects. Waiting on the work sequence also retires preceding
// Vulkan writes before the next evaluator snapshot is captured. // Vulkan writes before the next evaluator snapshot is captured.
if (!GuestGpu.Current.WaitForGuestWork(semanticCopySequence)) if (!VulkanVideoPresenter.WaitForGuestWork(semanticCopySequence))
{ {
computeError = computeError =
$"semantic-global-write-sync-timeout sequence={semanticCopySequence}"; $"semantic-global-write-sync-timeout sequence={semanticCopySequence}";
@@ -8836,7 +8641,7 @@ public static partial class AgcExports
localSizeY, localSizeY,
localSizeZ, localSizeZ,
dispatch.WaveLaneCount, dispatch.WaveLaneCount,
_storageBufferOffsetAlignment); VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count; var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count;
var totalGlobalBufferCount = _bakeScalars var totalGlobalBufferCount = _bakeScalars
? guestGlobalBufferCount ? guestGlobalBufferCount
@@ -8858,7 +8663,7 @@ public static partial class AgcExports
: guestGlobalBufferCount, : guestGlobalBufferCount,
waveLaneCount: dispatch.WaveLaneCount, waveLaneCount: dispatch.WaveLaneCount,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
DumpCompiledShader( DumpCompiledShader(
"cs", "cs",
@@ -8878,7 +8683,7 @@ public static partial class AgcExports
out _); out _);
var globalMemoryBuffers = var globalMemoryBuffers =
CreateTranslatedComputeGlobalBuffers(evaluation); CreateTranslatedComputeGlobalBuffers(evaluation);
GuestGpu.Current.SubmitComputeDispatch( var workSequence = GuestGpu.Current.SubmitComputeDispatch(
shaderAddress, shaderAddress,
computeShader, computeShader,
textures, textures,
@@ -8897,9 +8702,12 @@ public static partial class AgcExports
dispatch.ThreadCountX, dispatch.ThreadCountX,
dispatch.ThreadCountY, dispatch.ThreadCountY,
dispatch.ThreadCountZ); dispatch.ThreadCountZ);
// Vulkan queue order keeps dependent dispatches coherent. CPU visibility is
// published by explicit PM4 release/write actions instead of per dispatch.
gpuDispatch = true; gpuDispatch = true;
if (writesGlobalMemory &&
!VulkanVideoPresenter.WaitForGuestWork(workSequence))
{
computeError = $"global-write-sync-timeout sequence={workSequence}";
}
} }
} }
@@ -9090,7 +8898,7 @@ public static partial class AgcExports
} }
var destinationAddress = destination.BaseAddress; var destinationAddress = destination.BaseAddress;
workSequence = GuestGpu.Current.SubmitOrderedGuestAction( workSequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
() => () =>
{ {
if (!ctx.Memory.TryWrite(destinationAddress, output)) if (!ctx.Memory.TryWrite(destinationAddress, output))
@@ -9104,7 +8912,7 @@ public static partial class AgcExports
GuestImageWriteTracker.Track( GuestImageWriteTracker.Track(
destinationAddress, destinationAddress,
(ulong)output.Length, (ulong)output.Length,
GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics, VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics,
"agc.masked-dword-copy"); "agc.masked-dword-copy");
}, },
$"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}"); $"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}");
@@ -9793,7 +9601,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna, pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr, pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
TraceAgcShader( TraceAgcShader(
$"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " + $"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " +
@@ -11417,35 +11225,4 @@ public static partial class AgcExports
TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}"); TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); 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);
}
} }
@@ -3,7 +3,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
@@ -27,8 +26,8 @@ internal static class AgcShaderCompilerHooks
internal static void Install() internal static void Install()
{ {
Gen5ShaderScalarEvaluator.FallbackMemoryReader = Gen5ShaderScalarEvaluator.FallbackMemoryReader =
KernelMemoryCompatExports.TryReadShaderGuestMemory; KernelMemoryCompatExports.TryReadTrackedLibcHeap;
Gen5ShaderScalarEvaluator.GlobalMemoryPool = Gen5ShaderScalarEvaluator.GlobalMemoryPool =
GuestDataPool.Shared; VulkanVideoPresenter.GuestDataPool;
} }
} }
+2 -181
View File
@@ -37,26 +37,10 @@ internal static class GpuWaitRegistry
public long RegisteredTicks; public long RegisteredTicks;
public bool StaleReported; public bool StaleReported;
public object? State; 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 object _gate = new();
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = new(); private static readonly Dictionary<ulong, List<WaitingDcb>> _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 public static int Count
{ {
@@ -130,14 +114,8 @@ internal static class GpuWaitRegistry
continue; continue;
} }
var satisfied = list[i].Latched; var value = readValue(address, list[i].Is64Bit);
if (!satisfied) if (value is null || !Compare(list[i], value.Value))
{
var value = readValue(address, list[i].Is64Bit);
satisfied = value is not null && Compare(list[i], value.Value);
}
if (!satisfied)
{ {
continue; continue;
} }
@@ -258,162 +236,6 @@ internal static class GpuWaitRegistry
return matches; return matches;
} }
/// <summary>
/// Records satisfaction for every waiter at <paramref name="address"/> whose
/// condition is met by <paramref name="value"/> — 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.
/// </summary>
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;
}
/// <summary>
/// Removes and returns waiters carrying a <see cref="WaitingDcb.RetryDeadlineTicks"/>
/// 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.
/// </summary>
public static List<WaitingDcb>? CollectExpiredRetries(object memory, long nowTicks)
{
List<WaitingDcb>? expired = null;
lock (_gate)
{
List<ulong>? 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<WaitingDcb>();
expired.Add(waiter);
list.RemoveAt(i);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return expired;
}
/// <summary>Records the value a label producer wrote, for the deadlock
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
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);
}
/// <summary>
/// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns
/// (and removes) waiters that have been stuck longer than
/// <paramref name="minAgeTicks"/> 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.
/// </summary>
public static List<WaitingDcb>? CollectDeadlockBroken(
object memory,
long nowTicks,
long minAgeTicks)
{
List<WaitingDcb>? broken = null;
lock (_gate)
{
List<ulong>? 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<WaitingDcb>();
broken.Add(waiter);
list.RemoveAt(i);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
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) public static bool Compare(in WaitingDcb waiter, ulong value)
{ {
var masked = value & waiter.Mask; var masked = value & waiter.Mask;
@@ -438,7 +260,6 @@ internal static class GpuWaitRegistry
lock (_gate) lock (_gate)
{ {
_waiters.Clear(); _waiters.Clear();
_lastProduced.Clear();
} }
} }
} }
+17 -10
View File
@@ -6,7 +6,6 @@ using SharpEmu.Libs.Kernel;
using System.Buffers; using System.Buffers;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Ampr; namespace SharpEmu.Libs.Ampr;
@@ -44,17 +43,17 @@ public static class AmprExports
{ {
public CachedHostFile(string path) public CachedHostFile(string path)
{ {
Handle = File.OpenHandle( Stream = new FileStream(
path, path,
FileMode.Open, FileMode.Open,
FileAccess.Read, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete, FileShare.ReadWrite | FileShare.Delete,
bufferSize: 1024 * 1024,
FileOptions.RandomAccess); FileOptions.RandomAccess);
Length = RandomAccess.GetLength(Handle);
} }
public SafeFileHandle Handle { get; } public object Gate { get; } = new();
public long Length { get; } public FileStream Stream { get; }
} }
[SysAbiExport( [SysAbiExport(
@@ -736,7 +735,13 @@ public static class AmprExports
return openResult; return openResult;
} }
if (fileOffset >= (ulong)cachedFile.Length) long fileLength;
lock (cachedFile.Gate)
{
fileLength = cachedFile.Stream.Length;
}
if (fileOffset >= (ulong)fileLength)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -755,10 +760,12 @@ public static class AmprExports
} }
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead); var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
var read = RandomAccess.Read( int read;
cachedFile.Handle, lock (cachedFile.Gate)
buffer.AsSpan(0, request), {
unchecked((long)absoluteOffset)); cachedFile.Stream.Position = unchecked((long)absoluteOffset);
read = cachedFile.Stream.Read(buffer, 0, request);
}
if (read <= 0) if (read <= 0)
{ {
@@ -121,33 +121,6 @@ public static class AppContentExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
// Download data is not emulated as a real quota; report a comfortable
// fixed amount of free space so titles never take the "storage full" path.
[SysAbiExport(
Nid = "Gl6w5i0JokY",
ExportName = "sceAppContentDownloadDataGetAvailableSpaceKb",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAppContent")]
public static int AppContentDownloadDataGetAvailableSpaceKb(CpuContext ctx)
{
const ulong availableSpaceKb = 1024UL * 1024UL; // 1 GiB
var availableSpaceAddress = ctx[CpuRegister.Rsi];
if (availableSpaceAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> spaceBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(spaceBytes, availableSpaceKb);
if (!ctx.Memory.TryWrite(availableSpaceAddress, spaceBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryReadUserDefinedParam(uint paramId, out int value) private static bool TryReadUserDefinedParam(uint paramId, out int value)
{ {
value = 0; value = 0;
+1 -88
View File
@@ -14,12 +14,6 @@ public static class AudioOutExports
private static readonly ConcurrentDictionary<int, PortState> Ports = new(); private static readonly ConcurrentDictionary<int, PortState> Ports = new();
private static int _nextPortHandle; 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 sealed class PortState : IDisposable
{ {
private readonly object _paceGate = new(); private readonly object _paceGate = new();
@@ -161,37 +155,6 @@ public static class AudioOutExports
return ctx.SetReturn(0); 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<byte> 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( [SysAbiExport(
Nid = "QOQtbeDqsT4", Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput", ExportName = "sceAudioOutOutput",
@@ -203,12 +166,7 @@ public static class AudioOutExports
var sourceAddress = ctx[CpuRegister.Rsi]; var sourceAddress = ctx[CpuRegister.Rsi];
if (!Ports.TryGetValue(handle, out var port)) if (!Ports.TryGetValue(handle, out var port))
{ {
// Host shutdown disposes the ports while guest audio threads are return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
// 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) if (sourceAddress == 0)
@@ -225,17 +183,6 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); 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) if (port.Backend is null)
{ {
port.PaceSilence(); port.PaceSilence();
@@ -319,40 +266,8 @@ public static class AudioOutExports
return ctx.SetReturn(0); 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<byte> 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() public static void ShutdownAllPorts()
{ {
Volatile.Write(ref _shutdown, true);
foreach (var handle in Ports.Keys) foreach (var handle in Ports.Keys)
{ {
if (Ports.TryRemove(handle, out var port)) if (Ports.TryRemove(handle, out var port))
@@ -362,8 +277,6 @@ public static class AudioOutExports
} }
} }
private static bool _shutdown;
private static bool TryGetFormat( private static bool TryGetFormat(
int rawFormat, int rawFormat,
out int channels, out int channels,
@@ -1,154 +0,0 @@
// 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);
}
+9 -14
View File
@@ -29,9 +29,10 @@ internal static class Bink2MovieBridge
private static bool _availabilityReported; private static bool _availabilityReported;
/// <summary> /// <summary>
/// Returns true only when movie skipping was explicitly requested. Without /// Returns true when the guest should receive a normal "file not found"
/// a host adapter the guest must be allowed to run the Bink implementation /// result for a Bink movie. This is the safe default without a decoder:
/// statically linked into its executable. /// games that treat movies as optional fall through to their next state
/// rather than submitting an empty Bink GPU texture forever.
/// </summary> /// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) => internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) && hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
@@ -52,18 +53,12 @@ internal static class Bink2MovieBridge
return; return;
} }
var mode = ResolveMode(); if (ResolveMode() == MovieMode.Dummy)
if (mode == MovieMode.Dummy)
{ {
AttachDummyMovieLocked(hostPath); AttachDummyMovieLocked(hostPath);
return; return;
} }
if (mode != MovieMode.Native)
{
return;
}
var adapter = GetAdapterLocked(); var adapter = GetAdapterLocked();
if (adapter is null) if (adapter is null)
{ {
@@ -170,15 +165,16 @@ internal static class Bink2MovieBridge
return MovieMode.Skip; return MovieMode.Skip;
} }
// Prefer the optional host adapter when one is supplied. Otherwise let // With no SDK adapter present, returning "not found" makes optional
// the game's statically linked Bink implementation consume the file. // cinematics advance. Supplying either an explicit path or the normal
// side-by-side adapter enables native playback automatically.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) || if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists)) EnumerateAdapterCandidates().Any(File.Exists))
{ {
return MovieMode.Native; return MovieMode.Native;
} }
return MovieMode.Guest; return MovieMode.Skip;
} }
private static void AttachDummyMovieLocked(string hostPath) private static void AttachDummyMovieLocked(string hostPath)
@@ -339,7 +335,6 @@ internal static class Bink2MovieBridge
private enum MovieMode private enum MovieMode
{ {
Guest,
Skip, Skip,
Dummy, Dummy,
Native, Native,
-140
View File
@@ -1,140 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.Gpu;
/// <summary>
/// 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.
/// </summary>
internal static class GuestDataPool
{
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
maxArrayLength: 16 * 1024 * 1024,
maxCachedBytes: 256UL * 1024 * 1024,
maxArraysPerBucket: 8);
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
private sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _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<byte[]>();
_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;
}
}
+2 -31
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Metal;
using SharpEmu.Libs.Gpu.Vulkan; using SharpEmu.Libs.Gpu.Vulkan;
namespace SharpEmu.Libs.Gpu; namespace SharpEmu.Libs.Gpu;
@@ -9,39 +8,11 @@ namespace SharpEmu.Libs.Gpu;
/// <summary> /// <summary>
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the /// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the
/// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>. /// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>.
/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal /// Vulkan is the only backend today; Metal/DX12 slot in here.
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
/// once the presenter reaches parity.
/// </summary> /// </summary>
internal static class GuestGpu internal static class GuestGpu
{ {
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create); private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
public static IGuestGpuBackend Current => Instance.Value; 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();
}
} }
+1 -25
View File
@@ -36,20 +36,6 @@ internal readonly record struct GuestSampler(
uint Word2, uint Word2,
uint Word3); uint Word3);
/// <summary>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.</summary>
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( internal sealed record GuestMemoryBuffer(
ulong BaseAddress, ulong BaseAddress,
byte[] Data, byte[] Data,
@@ -136,22 +122,12 @@ internal readonly record struct GuestBlendState(
WriteMask: 0xFu); WriteMask: 0xFu);
} }
/// <summary>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.</summary>
internal readonly record struct GuestBlendConstant(
float Red,
float Green,
float Blue,
float Alpha);
internal sealed record GuestRenderState( internal sealed record GuestRenderState(
IReadOnlyList<GuestBlendState> Blends, IReadOnlyList<GuestBlendState> Blends,
GuestRect? Scissor, GuestRect? Scissor,
GuestViewport? Viewport, GuestViewport? Viewport,
GuestRasterState Raster, GuestRasterState Raster,
GuestDepthState Depth, GuestDepthState Depth)
GuestBlendConstant BlendConstant = default)
{ {
public static GuestRenderState Default { get; } = new( public static GuestRenderState Default { get; } = new(
[GuestBlendState.Default], [GuestBlendState.Default],
-71
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu; namespace SharpEmu.Libs.Gpu;
@@ -18,10 +17,6 @@ namespace SharpEmu.Libs.Gpu;
/// </summary> /// </summary>
internal interface IGuestGpuBackend internal interface IGuestGpuBackend
{ {
/// <summary>Human-readable name of this backend ("Metal", "Vulkan"), shown in
/// the window title on macOS where either backend can run.</summary>
string BackendName { get; }
/// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary> /// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
void EnsureStarted(uint width, uint height); void EnsureStarted(uint width, uint height);
@@ -193,70 +188,4 @@ internal interface IGuestGpuBackend
/// the guest codes cross the seam and each backend maps them internally. /// the guest codes cross the seam and each backend maps them internally.
/// </summary> /// </summary>
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind); 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.
/// <summary>Scopes subsequent submissions on this thread to a named guest queue.</summary>
IDisposable EnterGuestQueue(string queueName, ulong submissionId);
/// <summary>Enqueues an action at its exact position in the current guest queue;
/// returns its work sequence, or 0 when nothing could be enqueued.</summary>
long SubmitOrderedGuestAction(Action action, string debugName);
/// <summary>Preserves sceAgcDcbWaitUntilSafeForRendering in queue order.</summary>
long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex);
/// <summary>Blocks until the given work sequence completes; false on timeout,
/// close, or a non-positive sequence.</summary>
bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite);
/// <summary>Sequence currently executing on the guest-work consumer; diagnostics only.</summary>
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.
/// <summary>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).</summary>
bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType);
/// <summary>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).</summary>
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();
/// <summary>Whether the backend's texture cache already holds this content; lets
/// the AGC layer skip copying texels out of guest memory on every draw.</summary>
bool IsTextureContentCached(in TextureContentIdentity identity);
/// <summary>Guest memory handle for backend self-healing (cache misses re-read
/// texels directly instead of showing a fallback pattern).</summary>
void AttachGuestMemory(ICpuMemory memory);
/// <summary>Alignment the AGC layer must apply to storage-buffer offsets before
/// they cross the seam.</summary>
ulong GuestStorageBufferOffsetAlignment { get; }
/// <summary>Counts a guest shader translation for the perf overlay.</summary>
void CountShaderCompilation();
(long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters();
/// <summary>Asks a running presenter to close its window.</summary>
void RequestClose();
} }
@@ -1,28 +0,0 @@
// 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;
/// <summary>
/// The Metal backend's compiled shader: MSL source plus the reflection data
/// (<see cref="Gen5MslShader"/>) 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.
/// </summary>
internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader
{
private byte[]? _payload;
public Gen5MslShader Shader { get; } = shader;
/// <summary>MTLLibrary handle cached by the presenter after the first
/// runtime compile; the render loop is its only reader and writer.</summary>
internal nint CachedLibrary;
public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source);
public string PayloadFileExtension => "msl";
}
@@ -1,270 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// 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.
/// </summary>
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,
}
/// <summary>A sampled-texture format: the Metal pixel format plus the byte
/// layout the upload path needs. <see cref="BlockBytes"/> is nonzero for
/// block-compressed formats (bytes per 4x4 block); otherwise
/// <see cref="BytesPerPixel"/> applies.</summary>
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,
};
}
/// <summary>
/// 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.
/// </summary>
internal static class MetalGuestFormats
{
/// <summary>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.</summary>
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);
}
/// <summary>Source byte footprint of a sampled texture, block-aware —
/// the same math the AGC layer uses to size the texel copy it ships.</summary>
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;
}
}
@@ -1,426 +0,0 @@
// 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;
/// <summary>
/// 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.
/// </summary>
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<Gen5PixelOutputBinding> 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<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint width,
uint height,
uint attributeCount,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? 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<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
GuestDepthTarget depthTarget,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? 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<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
IReadOnlyList<GuestRenderTarget> targets,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? 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<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> 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<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> 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();
}
@@ -1,182 +0,0 @@
// 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;
/// <summary>
/// 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.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
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);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
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<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
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;
}
}
-430
View File
@@ -1,430 +0,0 @@
// 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;
}
/// <summary>MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output
/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5).</summary>
[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;
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary>
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);
/// <summary>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.</summary>
[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);
/// <summary>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.</summary>
[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);
/// <summary>One-argument BOOL sends, e.g. respondsToSelector:.</summary>
[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);
/// <summary>Two-object-argument void sends, e.g. setObject:forKey:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[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);
/// <summary>Autoreleased NSString — only valid inside an autorelease pool
/// unless the caller retains it.</summary>
public static nint NsString(string value)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
try
{
return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
/// <summary>Reads an NSString's UTF-8 contents, or null if the handle is nil.</summary>
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";
}
}
@@ -1,50 +0,0 @@
// 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;
/// <summary>Returns the open batch command buffer, opening one on first
/// use. Render thread only, like the drain it serves.</summary>
private static nint BeginBatchedGuestCommands(nint queue)
{
if (_batchOpen)
{
return _batchCommandBuffer;
}
_batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
_batchOpen = _batchCommandBuffer != 0;
return _batchCommandBuffer;
}
/// <summary>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.</summary>
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;
}
}
@@ -1,424 +0,0 @@
// 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<MetalCompiledGuestShader, nint> _computePipelineCache = new();
public static long SubmitComputeDispatch(
ulong shaderAddress,
MetalCompiledGuestShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> 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<nint> textureHandles = stackalloc nint[dispatch.Textures.Length];
Span<bool> 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<uint> 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);
}
/// <summary>The live, shared storage image for a guest address: dispatches,
/// draws, blits, and flips of the same address all see one texture.</summary>
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<byte> 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);
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,180 +0,0 @@
// 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;
/// <summary>Texture identity (unused for buffers).</summary>
public uint Format;
public uint Width;
public uint Height;
public nint Usage;
/// <summary>Buffer capacity in bytes (unused for textures).</summary>
public nuint Capacity;
/// <summary>Retained handle of the command buffer that samples this
/// snapshot; the resource is reusable once it completes.</summary>
public nint LastCommandBuffer;
}
private static readonly List<PooledSnapshotResource> _retiredSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _pendingSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _freeSnapshotResources = [];
/// <summary>Returns completed snapshot resources to the free list; called
/// once per render-loop drain, next to the upload-page recycler.</summary>
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"));
}
}
}
/// <summary>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.</summary>
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;
}
/// <summary>Pops a pooled private-storage staging buffer of at least
/// <paramref name="minimumBytes"/>, or creates one. Pool-owned like
/// <see cref="AcquireSnapshotTexture"/>.</summary>
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;
}
/// <summary>Marks every snapshot resource acquired since the previous tag
/// as owing its lifetime to <paramref name="commandBuffer"/>. Called at the
/// same commit sites as <see cref="TagUploadPages"/>; a resource acquired
/// for a draw that never committed is tagged by the next commit, which is
/// conservative but safe.</summary>
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();
}
}
@@ -1,179 +0,0 @@
// 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;
/// <summary>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.</summary>
private static readonly Dictionary<TextureContentIdentity, nint> _drawTextureCache = new();
/// <summary>Identities the AGC submit thread may skip texel copies for.
/// Read from the submit thread, written by the render thread.</summary>
private static readonly ConcurrentDictionary<TextureContentIdentity, byte> _cachedDrawTextureIdentities = new();
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
_cachedDrawTextureIdentities.ContainsKey(identity);
/// <summary>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.</summary>
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);
/// <summary>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.</summary>
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");
}
/// <summary>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.</summary>
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<ulong>? 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<TextureContentIdentity>? 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);
}
}
/// <summary>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.</summary>
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;
}
}
@@ -1,162 +0,0 @@
// 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;
/// <summary>Retained handle of the last command buffer that consumed
/// data from this page; the page is reusable once it completes.</summary>
public nint LastCommandBuffer;
/// <summary>Stamp of the last TagUploadPages call that saw this page,
/// so a commit only re-tags pages it actually touched.</summary>
public int TouchStamp;
}
private static readonly List<UploadPage> _retiredUploadPages = [];
private static readonly Stack<UploadPage> _freeUploadPages = new();
private static readonly List<UploadPage> _touchedUploadPages = [];
private static UploadPage? _currentUploadPage;
private static int _uploadTouchStamp;
/// <summary>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.</summary>
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"));
}
}
}
/// <summary>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.</summary>
private static unsafe Span<byte> 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<byte>((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,
};
}
/// <summary>Marks every page touched since the previous tag as owing its
/// lifetime to <paramref name="commandBuffer"/>. Called after each commit
/// that consumed arena data.</summary>
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++;
}
}
File diff suppressed because it is too large Load Diff
@@ -15,8 +15,6 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
/// </summary> /// </summary>
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
{ {
public string BackendName => "Vulkan";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader = private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment()); new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
@@ -345,60 +343,6 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
return false; 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) => private static byte[] Spirv(IGuestCompiledShader shader) =>
shader is VulkanCompiledGuestShader vulkanShader shader is VulkanCompiledGuestShader vulkanShader
? vulkanShader.Spirv ? vulkanShader.Spirv
@@ -224,21 +224,6 @@ public static partial class KernelMemoryCompatExports
} }
} }
/// <summary>Removes a guest mount registered by <see cref="RegisterGuestPathMount"/>.</summary>
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( internal static bool TryAllocateHleData(
CpuContext ctx, CpuContext ctx,
ulong length, ulong length,
@@ -1111,7 +1096,6 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
ctx[CpuRegister.Rax] = destination;
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -6010,7 +5994,7 @@ public static partial class KernelMemoryCompatExports
return highWaterMark; return highWaterMark;
} }
private static unsafe bool TryReadHostMemory(ulong address, Span<byte> destination) private static bool TryReadHostMemory(ulong address, Span<byte> destination)
{ {
if (destination.IsEmpty || !IsHostRangeAccessible(address, (ulong)destination.Length, writeAccess: false)) if (destination.IsEmpty || !IsHostRangeAccessible(address, (ulong)destination.Length, writeAccess: false))
{ {
@@ -6019,7 +6003,9 @@ public static partial class KernelMemoryCompatExports
try try
{ {
new ReadOnlySpan<byte>((void*)address, destination.Length).CopyTo(destination); var temporary = new byte[destination.Length];
Marshal.Copy((nint)address, temporary, 0, temporary.Length);
temporary.AsSpan().CopyTo(destination);
return true; return true;
} }
catch catch
@@ -6059,41 +6045,6 @@ public static partial class KernelMemoryCompatExports
return false; return false;
} }
internal static bool TryReadShaderGuestMemory(
ulong address,
Span<byte> destination)
{
if (destination.IsEmpty)
{
return true;
}
if (TryReadTrackedLibcHeap(address, destination))
{
return true;
}
var length = (ulong)destination.Length;
lock (_memoryGate)
{
if (TryFindVirtualQueryRegionLocked(
address,
findNext: false,
out var region) &&
length <= region.Length &&
address >= region.Address &&
length <= region.Address + region.Length - address)
{
return TryReadHostMemory(address, destination);
}
}
// Direct execution uses guest virtual addresses as host virtual addresses.
// Some native mmap paths predate _mappedRegions tracking, so retain the same
// committed/readable-page fallback used by the libc compatibility layer.
return TryReadHostMemory(address, destination);
}
internal static bool TryReadTrackedLibcHeapGpuAlias( internal static bool TryReadTrackedLibcHeapGpuAlias(
ulong packedAddress, ulong packedAddress,
Span<byte> destination) Span<byte> destination)
@@ -6373,7 +6324,7 @@ public static partial class KernelMemoryCompatExports
return value != 0 && (value & (value - 1)) == 0; return value != 0 && (value & (value - 1)) == 0;
} }
private static unsafe bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source) private static bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source)
{ {
if (source.IsEmpty || !IsHostRangeAccessible(address, (ulong)source.Length, writeAccess: true)) if (source.IsEmpty || !IsHostRangeAccessible(address, (ulong)source.Length, writeAccess: true))
{ {
@@ -6382,7 +6333,8 @@ public static partial class KernelMemoryCompatExports
try try
{ {
source.CopyTo(new Span<byte>((void*)address, source.Length)); var temporary = source.ToArray();
Marshal.Copy(temporary, 0, (nint)address, temporary.Length);
return true; return true;
} }
catch catch
@@ -6409,37 +6361,20 @@ public static partial class KernelMemoryCompatExports
return false; return false;
} }
var endAddress = address + length - 1; if (!TryQueryHostPage(address, out var startInfo) || !HasRequiredProtection(startInfo.Protect, writeAccess))
var currentAddress = address;
while (currentAddress <= endAddress)
{ {
if (!TryQueryHostPage(currentAddress, out var info) || return false;
!HasRequiredProtection(info.Protect, writeAccess)) }
{
return false;
}
var regionBase = unchecked((ulong)info.BaseAddress); var endAddress = address + length - 1;
var regionSize = (ulong)info.RegionSize; if (endAddress == address)
if (regionSize == 0 || {
regionBase > currentAddress || return true;
ulong.MaxValue - regionBase < regionSize) }
{
return false;
}
var regionEnd = regionBase + regionSize; if (!TryQueryHostPage(endAddress, out var endInfo) || !HasRequiredProtection(endInfo.Protect, writeAccess))
if (regionEnd <= currentAddress) {
{ return false;
return false;
}
if (regionEnd > endAddress)
{
return true;
}
currentAddress = regionEnd;
} }
return true; return true;
@@ -696,14 +696,7 @@ public static class KernelPthreadCompatExports
} }
} }
// pthread_mutex_trylock succeeds whenever the mutex is not currently if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
// 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.OwnerThreadId = currentThreadId;
state.RecursionCount = 1; state.RecursionCount = 1;
@@ -1241,21 +1234,7 @@ public static class KernelPthreadCompatExports
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle(); var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState) lock (mutexState)
{ {
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0) if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
// 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 return mutexState.OwnerThreadId == currentThreadId
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
@@ -1406,31 +1385,6 @@ public static class KernelPthreadCompatExports
bool cooperative, bool cooperative,
string? wakeKey = null) 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 var waiter = new PthreadMutexWaiter
{ {
ThreadId = threadId, ThreadId = threadId,
+103 -448
View File
@@ -3,7 +3,6 @@
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Threading; using System.Threading;
@@ -26,44 +25,24 @@ public static class Ngs2Exports
private static long _nextUid; private static long _nextUid;
private static long _renderCount; private static long _renderCount;
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender. private sealed record SystemState(uint Uid);
// 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;
private sealed class SystemState
{
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 record RackState(ulong SystemHandle, uint RackId);
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
private sealed class VoiceState [SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx)
{ {
public VoiceState(ulong rackHandle, uint voiceIndex) var bufferInfoAddress = ctx[CpuRegister.Rsi];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{ {
RackHandle = rackHandle; return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
VoiceIndex = voiceIndex;
} }
public ulong RackHandle { get; } return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
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( [SysAbiExport(
@@ -79,34 +58,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); 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 SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
lock (StateGate) return CreateSystem(ctx, outHandleAddress, handle);
{
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( [SysAbiExport(
Nid = "u-WrYDaJA3k", Nid = "u-WrYDaJA3k",
ExportName = "sceNgs2SystemDestroy", ExportName = "sceNgs2SystemDestroy",
@@ -135,6 +94,27 @@ public static class Ngs2Exports
return SetReturn(ctx, 0); 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( [SysAbiExport(
Nid = "U546k6orxQo", Nid = "U546k6orxQo",
ExportName = "sceNgs2RackCreateWithAllocator", ExportName = "sceNgs2RackCreateWithAllocator",
@@ -158,29 +138,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); 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 SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
lock (StateGate) return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
{
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( [SysAbiExport(
Nid = "lCqD7oycmIM", Nid = "lCqD7oycmIM",
ExportName = "sceNgs2RackDestroy", ExportName = "sceNgs2RackDestroy",
@@ -255,217 +220,14 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")] LibraryName = "libSceNgs2")]
public static int Ngs2VoiceControl(CpuContext ctx) public static int Ngs2VoiceControl(CpuContext ctx)
{ {
var voiceHandle = ctx[CpuRegister.Rdi];
var paramList = ctx[CpuRegister.Rsi];
lock (StateGate) lock (StateGate)
{ {
if (!Voices.ContainsKey(voiceHandle)) return SetReturn(
{ ctx,
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle); Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : 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<byte> 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<byte>.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<byte>.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<byte> 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( [SysAbiExport(
Nid = "AbYvTOZ8Pts", Nid = "AbYvTOZ8Pts",
ExportName = "sceNgs2VoiceRunCommands", ExportName = "sceNgs2VoiceRunCommands",
@@ -511,32 +273,11 @@ public static class Ngs2Exports
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); 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<byte> 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); var count = Interlocked.Increment(ref _renderCount);
if (ShouldTrace() && (count <= 4 || count % 200 == 0)) if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}"); $"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
@@ -545,135 +286,6 @@ public static class Ngs2Exports
return SetReturn(ctx, 0); 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<float>.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<float>.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<byte>.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<byte>.Shared.Return(bytes);
}
}
[SysAbiExport( [SysAbiExport(
Nid = "pgFAiLR5qT4", Nid = "pgFAiLR5qT4",
ExportName = "sceNgs2SystemQueryBufferSize", ExportName = "sceNgs2SystemQueryBufferSize",
@@ -711,25 +323,7 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetGrainSamples", ExportName = "sceNgs2SystemSetGrainSamples",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")] LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(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( [SysAbiExport(
Nid = "-tbc2SxQD60", Nid = "-tbc2SxQD60",
@@ -818,6 +412,67 @@ 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) private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
{ {
handle = 0; handle = 0;
-150
View File
@@ -1,150 +0,0 @@
// 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<byte> 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<byte> 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<byte> 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);
}
}
@@ -58,36 +58,6 @@ public static class NpEntitlementAccessExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); 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<byte> 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) private static void TraceNpEntitlementAccess(string message)
{ {
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal)) if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal))
@@ -197,16 +197,4 @@ public static class NpUniversalDataSystemExports
{ {
return ctx.SetReturn(0, typeof(long)); 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));
}
} }
-61
View File
@@ -65,35 +65,6 @@ public static class PadExports
LibraryName = "libScePad")] LibraryName = "libScePad")]
public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true); 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 // 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). // ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected).
private static int PadOpenCore(CpuContext ctx, bool extended) private static int PadOpenCore(CpuContext ctx, bool extended)
@@ -245,38 +216,6 @@ public static class PadExports
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); : 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<byte> 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( [SysAbiExport(
Nid = "YndgXqQVV7c", Nid = "YndgXqQVV7c",
ExportName = "scePadReadState", ExportName = "scePadReadState",
+24 -565
View File
@@ -45,530 +45,6 @@ public static class SaveDataExports
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim()); _titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
_preparedTransactionResources.Clear(); _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<SaveDataEvent> _events = new();
private static readonly object _mountGate = new();
// mountPoint -> live mount, for umount/IsMounted/GetMountInfo.
private static readonly Dictionary<string, MountEntry> _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<byte> 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<byte> 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<byte> 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<byte>.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<byte>.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<byte> 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<byte> 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( [SysAbiExport(
@@ -745,10 +221,6 @@ public static class SaveDataExports
const string mountPoint = "/savedata0"; const string mountPoint = "/savedata0";
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath); KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath);
lock (_mountGate)
{
_mounts[mountPoint] = new MountEntry(savePath, dirName, userId);
}
Span<byte> result = stackalloc byte[MountResultSize]; Span<byte> result = stackalloc byte[MountResultSize];
result.Clear(); result.Clear();
@@ -846,20 +318,10 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")] LibraryName = "libSceSaveData")]
public static int SaveDataUmount2(CpuContext ctx) public static int SaveDataUmount2(CpuContext ctx)
{ {
// rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2. // Unmounting a save directory always succeeds in the stub filesystem;
var mountPointAddress = ctx[CpuRegister.Rdi]; // returning an error here makes the game's save flow stall before it
if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) && // hands control to the title/gameplay state.
!string.IsNullOrEmpty(mountPoint)) TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}");
{
lock (_mountGate)
{
_mounts.Remove(mountPoint);
}
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
TraceSaveData($"umount2 mount='{mountPoint}'");
}
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
@@ -943,12 +405,9 @@ public static class SaveDataExports
private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry) private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry)
{ {
var metadata = SaveDataStorage.ReadMetadata(entry.Path);
var param = new byte[SaveDataParamSize]; var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), metadata.Title); WriteAscii(param.AsSpan(0x00, 128), "Saved Data");
WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle); WriteAscii(param.AsSpan(0x100, 1024), entry.Name);
WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam);
BinaryPrimitives.WriteInt64LittleEndian( BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508, sizeof(long)), param.AsSpan(0x508, sizeof(long)),
new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds()); new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds());
@@ -1015,14 +474,11 @@ public static class SaveDataExports
return false; return false;
} }
// Saves are keyed by title id only (single-user emulation) under
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) => private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId); Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
private static string ResolveSaveDataMemoryPath(int userId) => private static string ResolveSaveDataMemoryPath(int userId) =>
SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId())); Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
private static bool TryReadMemoryData( private static bool TryReadMemoryData(
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset) CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
@@ -1034,7 +490,14 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset); ctx.TryReadUInt64(address + 0x10, out offset);
} }
private static string ResolveSaveDataRoot() => SaveDataStorage.Root(); 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 ResolveConfiguredTitleId() private static string ResolveConfiguredTitleId()
{ {
@@ -1062,7 +525,12 @@ public static class SaveDataExports
return "default"; return "default";
} }
private static string SanitizePathSegment(string value) => SaveDataStorage.Sanitize(value); 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 bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value) private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value)
{ {
@@ -1322,17 +790,8 @@ public static class SaveDataExports
return ctx.SetReturn(OrbisSaveDataErrorParameter); return ctx.SetReturn(OrbisSaveDataErrorParameter);
} }
if (!File.Exists(ResolveSaveDataMemoryPath(userId))) return ctx.SetReturn(
{ File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
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) private static int TransferSaveDataMemory(CpuContext ctx, bool write)
@@ -1,130 +0,0 @@
// 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;
/// <summary>
/// Host-side layout and metadata for PS5 save data. Saves live under
/// <c>~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/</c> (overridable via
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
/// <c>&lt;slot&gt;/sce_sys/</c>. This type is pure filesystem logic with no guest
/// interop so the path and metadata handling can be unit-tested.
/// </summary>
public static class SaveDataStorage
{
/// <summary>Root of all saves: the env override, else <c>~/SharpEmu/Saves</c>.</summary>
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);
}
/// <summary>Per-title directory: <c>&lt;root&gt;/&lt;titleId&gt;</c>.</summary>
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
/// <summary>A single save slot: <c>&lt;titleRoot&gt;/&lt;dirName&gt;</c>.</summary>
public static string SlotDir(string titleRoot, string dirName) =>
Path.Combine(titleRoot, Sanitize(dirName));
/// <summary>The SaveDataMemory blob shared by a title.</summary>
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");
/// <summary>
/// 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.
/// </summary>
public static string Sanitize(string value)
{
if (string.IsNullOrEmpty(value))
{
return "default";
}
var invalid = Path.GetInvalidFileNameChars();
Span<char> 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;
}
/// <summary>Reads a slot's metadata, or defaults if none has been written.</summary>
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)));
}
/// <summary>Writes a slot's metadata, creating <c>sce_sys/</c> as needed.</summary>
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));
}
}
/// <summary>PS5 save-slot metadata surfaced by sceSaveDataGetParam / the save UI.</summary>
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
{
}
-1
View File
@@ -7,7 +7,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" /> <ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" /> <ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" /> <ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" />
<!-- SysAbi export generator + analyzers (compile-time registry, NID validation). --> <!-- SysAbi export generator + analyzers (compile-time registry, NID validation). -->
<ProjectReference Include="..\SharpEmu.SourceGenerators\SharpEmu.SourceGenerators.csproj" <ProjectReference Include="..\SharpEmu.SourceGenerators\SharpEmu.SourceGenerators.csproj"
@@ -0,0 +1,122 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.VideoOut;
internal sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _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<byte[]>();
_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;
}
+7 -10
View File
@@ -36,7 +36,6 @@ public static class PerfOverlay
private static long _presentedInWindow; private static long _presentedInWindow;
private static long _submittedInWindow; private static long _submittedInWindow;
private static long _drawsInWindow; private static long _drawsInWindow;
private static long _guestBufferCacheBytes;
// Refreshed once per second so per-frame fills never allocate. // Refreshed once per second so per-frame fills never allocate.
private static long _statsWindowStart = Stopwatch.GetTimestamp(); private static long _statsWindowStart = Stopwatch.GetTimestamp();
@@ -75,8 +74,11 @@ public static class PerfOverlay
if (last != 0) if (last != 0)
{ {
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency; var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
_frameMilliseconds[_frameHistoryIndex] = milliseconds; if (milliseconds < 1000.0)
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize; {
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
}
} }
} }
@@ -86,9 +88,6 @@ public static class PerfOverlay
/// <summary>Called per translated draw/dispatch executed.</summary> /// <summary>Called per translated draw/dispatch executed.</summary>
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow); public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
public static void SetGuestBufferCacheBytes(ulong bytes) =>
Interlocked.Exchange(ref _guestBufferCacheBytes, checked((long)bytes));
/// <summary> /// <summary>
/// Rasterizes the panel into a BGRA byte span of PanelWidth x PanelHeight. /// Rasterizes the panel into a BGRA byte span of PanelWidth x PanelHeight.
/// Runs on the render thread. /// Runs on the render thread.
@@ -166,7 +165,7 @@ public static class PerfOverlay
Environment.ProcessorCount; Environment.ProcessorCount;
_lastCpuTime = cpuTime; _lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0 ? _drawsPerSecond / _fps : 0; var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
var sessionStart = Interlocked.Read(ref _sessionStartTimestamp); var sessionStart = Interlocked.Read(ref _sessionStartTimestamp);
var elapsedSeconds = sessionStart == 0 var elapsedSeconds = sessionStart == 0
? 0L ? 0L
@@ -177,9 +176,7 @@ public static class PerfOverlay
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS"; _line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}"; _line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}"; _line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024); _line4 = $"CPU {_cpuPercent:0}% HEAP {GC.GetTotalMemory(false) / (1024 * 1024)} MB F1 HIDE";
var guestBufferMb = Interlocked.Read(ref _guestBufferCacheBytes) / (1024 * 1024);
_line4 = $"MEM {heapMb}M BUF {guestBufferMb}M CPU {_cpuPercent:0}%";
_line5 = $"TIME {elapsedHours:00}:{elapsedMinutes:00}:{elapsedRemainingSeconds:00}"; _line5 = $"TIME {elapsedHours:00}:{elapsedMinutes:00}:{elapsedRemainingSeconds:00}";
} }
} }
+8 -20
View File
@@ -131,14 +131,9 @@ public static class VideoOutExports
return; 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) lock (_stateGate)
{ {
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}"; _windowTitle = $"{_windowTitle} · {gpuName.Trim()}";
} }
} }
@@ -171,12 +166,11 @@ public static class VideoOutExports
HostSessionControl.RequestShutdown(reason); HostSessionControl.RequestShutdown(reason);
// A hosted game can still be issuing AGC work after it requests its // A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the presenter's resources alive until the GUI // own shutdown. Keep the Vulkan resources alive until the GUI session
// session reaches its guest-safe exit path and disposes the host // reaches its guest-safe exit path and disposes the host surface.
// surface.
if (!embedded) if (!embedded)
{ {
GuestGpu.Current.RequestClose(); VulkanVideoPresenter.RequestClose();
} }
// The embedded GUI owns the process lifetime. A guest shutdown should // The embedded GUI owns the process lifetime. A guest shutdown should
@@ -1076,12 +1070,6 @@ public static class VideoOutExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; 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)) if (!TryGetPort(handle, out var port))
{ {
return OrbisVideoOutErrorInvalidHandle; return OrbisVideoOutErrorInvalidHandle;
@@ -1102,7 +1090,7 @@ public static class VideoOutExports
return OrbisVideoOutErrorInvalidValue; return OrbisVideoOutErrorInvalidValue;
} }
if (category > 1 || option != 0) if (categoryRaw != 0 || option != 0)
{ {
return OrbisVideoOutErrorInvalidValue; return OrbisVideoOutErrorInvalidValue;
} }
@@ -1221,7 +1209,7 @@ public static class VideoOutExports
{ {
TriggerFlipEvents(); TriggerFlipEvents();
} }
else if (GuestGpu.Current.SubmitOrderedGuestAction( else if (VulkanVideoPresenter.SubmitOrderedGuestAction(
TriggerFlipEvents, TriggerFlipEvents,
$"videoout flip complete handle={handle} index={bufferIndex}") == 0) $"videoout flip complete handle={handle} index={bufferIndex}") == 0)
{ {
@@ -1275,7 +1263,7 @@ public static class VideoOutExports
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency; var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0); var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0);
var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0); var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0);
var (draws, drawMs, pipelines, spirvCompiles) = GuestGpu.Current.ReadAndResetPerfCounters(); var (draws, drawMs, pipelines, spirvCompiles) = VulkanVideoPresenter.ReadAndResetPerfCounters();
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " + $"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
$"presented_fps={presentedCount / elapsedSeconds:F1} " + $"presented_fps={presentedCount / elapsedSeconds:F1} " +
@@ -1714,7 +1702,7 @@ public static class VideoOutExports
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq; SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags // Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
// the backend keys its guest-image registry on (see the presenter's // the backend keys its guest-image registry on (see VulkanVideoPresenter.
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit). // 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 // Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
// display something rather than silently failing the flip pipeline. // display something rather than silently failing the flip pipeline.
File diff suppressed because it is too large Load Diff
@@ -1,58 +0,0 @@
// 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,
}
/// <summary>
/// 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 <see cref="Gen5MslTranslator"/>: global memory buffers
/// occupy [[buffer(globalBufferBase + i)]] in <see cref="GlobalMemoryBindings"/>
/// 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.
/// </summary>
/// <remarks>
/// <para><see cref="UniformsBufferIndex"/> 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.</para>
/// <para>Texture slots are global across a draw's stages ([[texture(
/// ImageBindingBase + i)]]). Samplers live in a per-stage argument buffer
/// bound at <see cref="SamplerArgBufferIndex"/> (Metal caps direct
/// [[sampler(N)]] slots at 16 per stage, but shaders sample more), holding one
/// sampler per sampled image. <see cref="SamplerSlots"/> maps this stage's
/// image binding index to its [[id(N)]] entry in that argument buffer, -1 for
/// storage images that take none; <see cref="SamplerCount"/> is the entry
/// count.</para>
/// </remarks>
public sealed record Gen5MslShader(
string Source,
string EntryPoint,
Gen5MslStage Stage,
IReadOnlyList<Gen5GlobalMemoryBinding> GlobalMemoryBindings,
IReadOnlyList<Gen5ImageBinding> ImageBindings,
uint AttributeCount,
IReadOnlyList<Gen5VertexInputBinding> VertexInputs,
uint ThreadgroupSizeX = 1,
uint ThreadgroupSizeY = 1,
uint ThreadgroupSizeZ = 1,
int UniformsBufferIndex = -1,
int ImageBindingBase = 0,
IReadOnlyList<int>? SamplerSlots = null,
int SamplerCount = 0,
int SamplerArgBufferIndex = -1);
File diff suppressed because it is too large Load Diff
@@ -1,888 +0,0 @@
// 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 ----
/// <summary>
/// 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.
/// </summary>
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++;
}
}
/// <summary>Records that <paramref name="opcode"/> reads and/or writes the
/// storage image at <paramref name="bindingIndex"/>, so EmitImageArguments
/// can pick the minimal Metal access qualifier.</summary>
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;
}
}
/// <summary>"float", "int", or "uint" from the descriptor's unified format.</summary>
private static string DecodeImageComponentKind(IReadOnlyList<uint> 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",
};
}
/// <summary>Per image binding: its sampler's [[id(N)]] inside the sampler
/// argument buffer, or -1 for storage images. Set by DeclareImageKinds.</summary>
private int[] _samplerSlots = [];
/// <summary>Number of sampled images (= sampler argument-buffer entries).</summary>
private int _samplerCount;
/// <summary>Buffer slot the sampler argument buffer binds to, past this
/// stage's global buffers, uniforms, and scalar-state buffer.</summary>
private int SamplerArgBufferIndex =>
Math.Max(UniformsBufferIndex, _initialScalarBufferIndex) + 1;
/// <summary>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.</summary>
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})]],");
}
}
/// <summary>Declares the sampler argument-buffer struct at file scope (one
/// sampler per sampled image). Empty when the stage samples nothing.</summary>
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<int>({ImageIntegerAddress(image, 0)})");
var y = Temp("int", $"as_type<int>({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<int>({ImageIntegerAddress(image, 0)}), 0, (int){widthQuery} - 1)");
var y = Temp(
"uint",
$"(uint)clamp(as_type<int>({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<string>(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<int>(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<float>(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<int>(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<float>(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;
/// <summary>Float address component, unpacking A16 half pairs.</summary>
private string ImageFloatAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"(float)as_type<half2>(v[{register}])[{component & 1}]"
: $"as_type<float>(v[{register}])";
}
/// <summary>Integer address component, unpacking A16 16-bit pairs.</summary>
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}]";
}
/// <summary>One store-source component, unpacking D16 halves.</summary>
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<half2>({packed})[{component & 1}]");
}
var low = $"(({packed} >> {(component & 1) * 16}) & 0xFFFFu)";
return kind == "int"
? $"(uint)extract_bits(as_type<int>({low}), 0u, 16u)"
: low;
}
private static string ImageTexelComponent(string kind, string raw) => kind switch
{
"int" => $"as_type<int>({raw})",
"uint" => raw,
_ => $"as_type<float>({raw})",
};
private string PackImageD16(string kind, string low, string high)
{
if (kind == "float")
{
return $"(((uint)as_type<ushort>(half(as_type<float>({low})))) | (((uint)as_type<ushort>(half(as_type<float>({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<half2>({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<int>({raw})",
_ => $"as_type<float>({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<half2>({packed})[{component & 1}]";
continue;
}
values[component] = $"as_type<float>(v[{instruction.Sources[component].Value}])";
}
Line($"{field} = exec ? float4({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<uint>(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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,81 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// The fixed presenter shaders, mirroring SpirvFixedShaders semantically. The
/// MSL lives in Templates/*.msl (authored as real Metal source); this class
/// only substitutes the per-call parameters. Entry point names are stable
/// (Metal forbids "main"); textures and samplers bind at index 0; attributes
/// use the same user(locn) convention as the translated stages.
/// </summary>
public static class MslFixedShaders
{
/// <summary>
/// Fullscreen triangle from the vertex index; every attribute location in
/// 0..attributeCount-1 carries (x, y, 0, 1) so paired fragment stages can
/// read a screen-space UV from any location.
/// </summary>
public static string CreateFullscreenVertex(uint attributeCount)
{
var fields = new StringBuilder();
var stores = new StringBuilder();
for (uint index = 0; index < attributeCount; index++)
{
if (index != 0)
{
fields.AppendLine();
stores.AppendLine();
}
fields.Append($" float4 attr{index} [[user(locn{index})]];");
stores.Append($" out.attr{index} = float4(x, y, 0.0f, 1.0f);");
}
return MslTemplates.Render(
"fullscreen_vertex",
("attribute_fields", fields.ToString()),
("attribute_stores", stores.ToString()));
}
/// <summary>Samples texture 0 at the interpolated location-0 UV.</summary>
public static string CreateCopyFragment() => MslTemplates.Render("copy_fragment");
/// <summary>
/// The presenter's blit stage: samples texture 0 with V flipped, because
/// pairing the shared fullscreen triangle with Metal's y-up NDC puts UV
/// (0,0) at the bottom of the screen while textures keep v=0 at the top.
/// </summary>
public static string CreatePresentFragment() => MslTemplates.Render("present_fragment");
public static string CreateSolidFragment(float red, float green, float blue, float alpha) =>
MslTemplates.Render(
"solid_fragment",
("red", Format(red)),
("green", Format(green)),
("blue", Format(blue)),
("alpha", Format(alpha)));
/// <summary>
/// Diagnostic fragment stage exposing one interpolated vertex output
/// directly as color, isolating fragment translation from interface data.
/// </summary>
public static string CreateAttributeFragment(uint location) =>
MslTemplates.Render(
"attribute_fragment",
("location", location.ToString(CultureInfo.InvariantCulture)));
/// <summary>
/// Output-free fragment stage for fixed-function depth-only passes: the
/// guest has no pixel shader, so no color may be written while depth
/// testing still runs for the translated vertex shader.
/// </summary>
public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment");
private static string Format(float value) =>
value.ToString("0.0######", CultureInfo.InvariantCulture) + "f";
}
@@ -1,55 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// Loads the static MSL blocks from embedded Templates/*.msl resources and
/// substitutes {{placeholder}} tokens. The static prelude and fixed shaders
/// are authored as real Metal source files; only the per-instruction body
/// emission stays programmatic in the translator.
/// </summary>
internal static class MslTemplates
{
private static readonly ConcurrentDictionary<string, string> _cache = new(StringComparer.Ordinal);
public static string Render(string name, params (string Key, string Value)[] substitutions)
{
var template = _cache.GetOrAdd(name, Load);
if (substitutions.Length == 0)
{
return template;
}
var builder = new StringBuilder(template);
foreach (var (key, value) in substitutions)
{
builder.Replace("{{" + key + "}}", value);
}
var rendered = builder.ToString();
var marker = rendered.IndexOf("{{", StringComparison.Ordinal);
if (marker >= 0)
{
var end = rendered.IndexOf("}}", marker, StringComparison.Ordinal);
var token = end > marker ? rendered[marker..(end + 2)] : "{{...";
throw new InvalidOperationException(
$"template '{name}' has an unsubstituted placeholder {token}");
}
return rendered;
}
private static string Load(string name)
{
var assembly = typeof(MslTemplates).Assembly;
var resourceName = $"SharpEmu.ShaderCompiler.Metal.Templates.{name}.msl";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"missing embedded MSL template {resourceName}");
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
@@ -1,26 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<!-- The Metal codegen backend: consumes the backend-neutral shader IR from
SharpEmu.ShaderCompiler and emits Metal Shading Language source text.
Deliberately has no dependency on Metal bindings — emitters produce text;
renderers own APIs (the Metal backend compiles the source via MTLLibrary). -->
<PropertyGroup>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Static MSL blocks (prelude helpers, fixed shaders) authored as real
Metal source; the emitter renders them with placeholder substitution. -->
<EmbeddedResource Include="Templates\**\*.msl" />
</ItemGroup>
</Project>
@@ -1,13 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct AttributeIn
{
float4 attr{{location}} [[user(locn{{location}})]];
};
fragment float4 attribute_fs(AttributeIn in [[stage_in]])
{
return in.attr{{location}};
}
@@ -1,16 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct CopyIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 copy_fs(
CopyIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, in.attr0.xy);
}
@@ -1,7 +0,0 @@
#include <metal_stdlib>
using namespace metal;
fragment void depth_only_fs()
{
}
@@ -1,68 +0,0 @@
static constant uint sharpemu_gfx10_formats[128] = {
{{format_table}}
};
static inline void sharpemu_format_layout(uint dfmt, uint component, thread uint& byteOff, thread uint& bitOff, thread uint& bits)
{
byteOff = 0u; bitOff = 0u; bits = 0u;
switch (component * 16u + dfmt)
{
{{layout_cases}}
default: break;
}
}
static inline uint sharpemu_minifloat(uint raw, uint bits)
{
uint mantissaBits = bits - 5u;
uint mantissa = raw & ((1u << mantissaBits) - 1u);
uint exponent = (raw >> mantissaBits) & 0x1Fu;
uint shift = 23u - mantissaBits;
if (exponent == 31u)
{
return 0x7F800000u | (mantissa << shift);
}
if (exponent == 0u)
{
float scale = mantissaBits == 6u ? (1.0f / 1048576.0f) : (1.0f / 524288.0f);
return as_type<uint>((float)mantissa * scale);
}
return ((exponent + 112u) << 23) | (mantissa << shift);
}
static inline uint sharpemu_format_one(uint nfmt)
{
return (nfmt == 4u || nfmt == 5u) ? 1u : 0x3F800000u;
}
static inline uint sharpemu_format_convert(uint raw, uint bits, uint nfmt, uint dfmt)
{
uint lowMask = bits >= 32u ? 0xFFFFFFFFu : ((1u << bits) - 1u);
int signedRaw = extract_bits(as_type<int>(raw), 0u, bits);
switch (nfmt)
{
case 0u: return as_type<uint>((float)raw / (float)lowMask);
case 1u:
{
float snorm = (float)signedRaw / (float)(lowMask >> 1);
return as_type<uint>(fmax(snorm, -1.0f));
}
case 2u: return as_type<uint>((float)raw);
case 3u: return as_type<uint>((float)signedRaw);
case 5u: return (uint)signedRaw;
case 7u:
{
// 10_11_11/11_11_10 packed floats are unsigned mini-floats.
if (dfmt == 6u || dfmt == 7u)
{
return sharpemu_minifloat(raw, bits);
}
if (bits == 16u)
{
return as_type<uint>((float)as_type<half>((ushort)(raw & 0xFFFFu)));
}
return raw;
}
default: return raw;
}
}
@@ -1,19 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct FullscreenOut
{
float4 position [[position]];
{{attribute_fields}}
};
vertex FullscreenOut fullscreen_vs(uint vertex_id [[vertex_id]])
{
float x = (float)((vertex_id << 1) & 2u);
float y = (float)(vertex_id & 2u);
FullscreenOut out = {};
out.position = float4(x * 2.0f - 1.0f, y * 2.0f - 1.0f, 0.0f, 1.0f);
{{attribute_stores}}
return out;
}
@@ -1,59 +0,0 @@
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return {{ballot_return}};
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
@@ -1,16 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct PresentIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 present_fs(
PresentIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, float2(in.attr0.x, 1.0f - in.attr0.y));
}
@@ -1,8 +0,0 @@
#include <metal_stdlib>
using namespace metal;
fragment float4 solid_fs()
{
return float4({{red}}, {{green}}, {{blue}}, {{alpha}});
}
@@ -1754,10 +1754,6 @@ public static partial class Gen5SpirvTranslator
"SWaitcnt" or "SWaitcnt" or
"SInstPrefetch" or "SInstPrefetch" or
"STtraceData" or "STtraceData" or
// NGG shaders bracket their exports with s_sendmsg
// (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space;
// exports are translated directly, so the message is moot.
"SSendmsg" or
"VInterpMovF32") "VInterpMovF32")
{ {
return true; return true;
@@ -2350,8 +2350,7 @@ public static class Gen5ShaderScalarEvaluator
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value) private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
{ {
Span<byte> bytes = stackalloc byte[sizeof(uint)]; Span<byte> bytes = stackalloc byte[sizeof(uint)];
if (!ctx.Memory.TryRead(address, bytes) && if (!ctx.Memory.TryRead(address, bytes))
FallbackMemoryReader?.Invoke(address, bytes) != true)
{ {
value = 0; value = 0;
return false; return false;
@@ -1,99 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class Gen5ScalarMemoryFallbackTests
{
private const ulong ScalarTableAddress = 0x4_4665_4FD0;
private static readonly object FallbackReaderGate = new();
[Fact]
public void ScalarLoadReadsTrackedFallbackMemory()
{
var expected = new uint[]
{
0x4665_4F70,
0x0000_0004,
0x4EA7_FCE0,
0x0000_0004,
};
var table = new byte[expected.Length * sizeof(uint)];
for (var index = 0; index < expected.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
table.AsSpan(index * sizeof(uint), sizeof(uint)),
expected[index]);
}
var load = new Gen5ShaderInstruction(
0,
Gen5ShaderEncoding.Smem,
"SLoadDwordx4",
[],
[Gen5Operand.Scalar(0)],
[
Gen5Operand.Scalar(16),
Gen5Operand.Scalar(17),
Gen5Operand.Scalar(18),
Gen5Operand.Scalar(19),
],
new Gen5ScalarMemoryControl(4, 0, null));
var end = new Gen5ShaderInstruction(
8,
Gen5ShaderEncoding.Sopp,
"SEndpgm",
[],
[],
[],
null);
var state = new Gen5ShaderState(
new Gen5ShaderProgram(0, [load, end]),
[unchecked((uint)ScalarTableAddress), (uint)(ScalarTableAddress >> 32)],
null);
var ctx = new CpuContext(new FakeCpuMemory(0x1000, 0x100), Generation.Gen5);
lock (FallbackReaderGate)
{
var previousReader = Gen5ShaderScalarEvaluator.FallbackMemoryReader;
try
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader = ReadFallback;
Assert.True(
Gen5ShaderScalarEvaluator.TryEvaluate(
ctx,
state,
out var evaluation,
out var error),
error);
Assert.Equal(expected, evaluation.ScalarRegisters.Skip(16).Take(4));
}
finally
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader = previousReader;
}
}
bool ReadFallback(ulong address, Span<byte> destination)
{
if (address < ScalarTableAddress)
{
return false;
}
var offset = address - ScalarTableAddress;
if (offset + (ulong)destination.Length > (ulong)table.Length)
{
return false;
}
table.AsSpan((int)offset, destination.Length).CopyTo(destination);
return true;
}
}
}
@@ -1,153 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class MemcpyHleRoutingTests
{
private const string MemcpyNid = "Q3VBxCXhUHs";
private const string MemsetNid = "QrZZdJ8XsX0";
private const string RdtscNid = "-2IRUCO--PM";
[Fact]
public void IsHlePreferredNid_PrefersHleForMemcpy_OnEveryPlatform()
{
Assert.True(
InvokeIsHlePreferredNid(MemcpyNid),
$"memcpy ({MemcpyNid}) must route through HLE on every platform. It was previously " +
"gated behind OperatingSystem.IsWindows(), which left Linux and macOS on the LLE " +
"intrinsic stub and faulted in guest code. Do not reintroduce an OS condition here.");
}
[Fact]
public void IsHlePreferredNid_PrefersHleForMemset()
{
Assert.True(
InvokeIsHlePreferredNid(MemsetNid),
$"memset ({MemsetNid}) must route through HLE on every platform.");
}
[Fact]
public void TryCreateNativeImportIntrinsic_DoesNotClaimMemcpy()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
var claimed = InvokeTryCreateNativeImportIntrinsic(MemcpyNid, out var address);
Assert.False(
claimed,
$"memcpy ({MemcpyNid}) must fall through to the HLE trampoline. SetupImportStubs tries " +
"the intrinsic stub before the trampoline, so without an IsHlePreferredNid guard here " +
"the intrinsic claims memcpy and the HLE routing never takes effect.");
Assert.Equal(0, address);
}
[Fact]
public void TryCreateNativeImportIntrinsic_StillClaimsNonHleNids()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
var claimed = InvokeTryCreateNativeImportIntrinsic(RdtscNid, out var address);
Assert.True(
claimed,
$"rdtsc ({RdtscNid}) has no HLE handler and must still receive an intrinsic stub. If " +
"this fails the memcpy assertions above may be passing vacuously.");
Assert.NotEqual(0, address);
unsafe
{
Assert.True(HostMemory.Free((void*)address, 0, HostMemory.MEM_RELEASE));
}
}
[Fact]
public void IsHlePreferredNid_DoesNotBranchOnHostOperatingSystem()
{
var method = typeof(DirectExecutionBackend).GetMethod(
"IsHlePreferredNid",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var callees = ResolveCallees(method);
Assert.DoesNotContain(callees, static name =>
name is "IsWindows" or "IsLinux" or "IsMacOS" or "IsOSPlatform" or "IsFreeBSD");
}
private static HashSet<string> ResolveCallees(MethodBase method)
{
var il = method.GetMethodBody()?.GetILAsByteArray();
Assert.NotNull(il);
var module = method.Module;
var generic = method.DeclaringType?.GetGenericArguments();
var callees = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i + 4 < il.Length; i++)
{
if (il[i] is not (0x28 or 0x6F))
{
continue;
}
var token = BitConverter.ToInt32(il, i + 1);
try
{
var callee = module.ResolveMethod(token, generic, null);
if (callee?.Name is { } name)
{
callees.Add(name);
}
}
catch (ArgumentException)
{
}
}
return callees;
}
private static bool InvokeIsHlePreferredNid(string nid)
{
var method = typeof(DirectExecutionBackend).GetMethod(
"IsHlePreferredNid",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (bool)method.Invoke(null, [nid])!;
}
private static bool InvokeTryCreateNativeImportIntrinsic(string nid, out nint address)
{
var backend = (DirectExecutionBackend)RuntimeHelpers.GetUninitializedObject(
typeof(DirectExecutionBackend));
var trampolineList = typeof(DirectExecutionBackend).GetField(
"_importHandlerTrampolines",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(trampolineList);
trampolineList.SetValue(backend, new List<nint>());
var method = typeof(DirectExecutionBackend).GetMethod(
"TryCreateNativeImportIntrinsic",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
object?[] args = [nid, null];
var claimed = (bool)method.Invoke(backend, args)!;
address = (nint)args[1]!;
return claimed;
}
}
@@ -1,180 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
public sealed class KernelEventQueueCompatExportsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const int MemorySize = 0x4000;
[Fact]
public void CreateEqueue_WritesNonZeroHandleAndSucceeds()
{
var (context, outAddress) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = outAddress;
var result = KernelEventQueueCompatExports.KernelCreateEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.True(context.TryReadUInt64(outAddress, out var handle));
Assert.NotEqual(0UL, handle);
Assert.True(KernelEventQueueCompatExports.IsValidEqueue(handle));
}
[Fact]
public void CreateEqueue_NullOutAddressReturnsInvalidArgument()
{
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = 0;
var result = KernelEventQueueCompatExports.KernelCreateEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void DeleteEqueue_RemovesQueueFromRegistry()
{
var (context, outAddress) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = outAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelEventQueueCompatExports.KernelCreateEqueue(context));
Assert.True(context.TryReadUInt64(outAddress, out var handle));
context[CpuRegister.Rdi] = handle;
var result = KernelEventQueueCompatExports.KernelDeleteEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.False(KernelEventQueueCompatExports.IsValidEqueue(handle));
}
[Fact]
public void AddUserEvent_OnUnknownQueueReturnsNotFound()
{
var (context, _) = NewContextWithOutSlot();
const ulong unknownHandle = 0xDEAD_BEEF;
context[CpuRegister.Rdi] = unknownHandle;
context[CpuRegister.Rsi] = 42;
var result = KernelEventQueueCompatExports.KernelAddUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void AddUserEvent_OnValidQueueSucceeds()
{
var handle = CreateEqueue();
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = 0x1234;
var result = KernelEventQueueCompatExports.KernelAddUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
}
[Fact]
public void TriggerUserEvent_OnUnknownQueueReturnsNotFound()
{
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = 0xDEAD_BEEF;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = 0;
var result = KernelEventQueueCompatExports.KernelTriggerUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void TriggerUserEvent_OnUnregisteredEventReturnsNotFound()
{
var handle = CreateEqueue();
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = 0xABCD; // never registered
context[CpuRegister.Rdx] = 0;
var result = KernelEventQueueCompatExports.KernelTriggerUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
// Full lifecycle: create -> register user event -> trigger -> wait delivers
// the queued event with the registered ident/filter and the trigger data.
// DequeueEvents runs before the blocking path, so a pre-triggered queue
// returns immediately without touching the guest thread scheduler.
[Fact]
public void CreateAddTriggerWait_DeliversTriggeredUserEvent()
{
const ulong eventIdent = 0x4242;
const ulong triggerData = 0x55AA_55AA;
var handle = CreateEqueue();
// Register the user event on the queue.
var (addCtx, _) = NewContextWithOutSlot();
addCtx[CpuRegister.Rdi] = handle;
addCtx[CpuRegister.Rsi] = eventIdent;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelAddUserEvent(addCtx));
// Trigger it with a distinct data payload.
var (triggerCtx, _) = NewContextWithOutSlot();
triggerCtx[CpuRegister.Rdi] = handle;
triggerCtx[CpuRegister.Rsi] = eventIdent;
triggerCtx[CpuRegister.Rdx] = triggerData;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelTriggerUserEvent(triggerCtx));
// Wait should deliver the single pending event without blocking.
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var waitContext = new CpuContext(memory, Generation.Gen5);
const ulong eventsAddress = MemoryBase + 0x100;
const ulong outCountAddress = MemoryBase + 0x300;
waitContext[CpuRegister.Rdi] = handle;
waitContext[CpuRegister.Rsi] = eventsAddress;
waitContext[CpuRegister.Rdx] = 1; // capacity
waitContext[CpuRegister.Rcx] = outCountAddress;
waitContext[CpuRegister.R8] = 0; // no timeout -> would block, but event is pending
var result = KernelEventQueueCompatExports.KernelWaitEqueue(waitContext);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.True(waitContext.TryReadUInt32(outCountAddress, out var delivered));
Assert.Equal(1u, delivered);
// KernelEvent layout (0x20): ident(0x00) filter(0x08) flags(0x0A)
// fflags(0x0C) data(0x10) userdata(0x18).
Span<byte> evt = stackalloc byte[0x20];
Assert.True(memory.TryRead(eventsAddress, evt));
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
}
private static ulong CreateEqueue()
{
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
const ulong outAddress = MemoryBase + 0x10;
context[CpuRegister.Rdi] = outAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(context));
Assert.True(context.TryReadUInt64(outAddress, out var handle));
return handle;
}
private static (CpuContext Context, ulong OutAddress) NewContextWithOutSlot()
{
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
return (context, MemoryBase + 0x10);
}
}
@@ -164,149 +164,4 @@ public sealed class KernelMemoryCompatExportsTests
Assert.Equal(0, KernelMemoryCompatExports.KernelReleaseDirectMemory(context)); Assert.Equal(0, KernelMemoryCompatExports.KernelReleaseDirectMemory(context));
} }
[Fact]
public void MapNamedFlexibleMemory_NullInOutPointerReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0x1000;
context[CpuRegister.Rdx] = 0x03; // CPU read|write
context[CpuRegister.Rcx] = 0;
var result = KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void MapNamedFlexibleMemory_ZeroLengthReturnsInvalidArgument()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong inOutAddress = memoryBase + 0x100;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.TryWrite(inOutAddress, BitConverter.GetBytes(0UL));
context[CpuRegister.Rdi] = inOutAddress;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = 0x03;
context[CpuRegister.Rcx] = 0;
var result = KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void MapNamedFlexibleMemory_UnreadableInOutPointerReturnsMemoryFault()
{
// The in-out pointer points outside the FakeCpuMemory backing store, so
// the first TryReadUInt64 must fail before any reservation is attempted.
const ulong memoryBase = 0x1_0000_0000;
const ulong unreachableInOut = memoryBase + 0x10_0000;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = unreachableInOut;
context[CpuRegister.Rsi] = 0x1000;
context[CpuRegister.Rdx] = 0x03;
context[CpuRegister.Rcx] = 0;
var result = KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
}
[Fact]
public void Mprotect_ZeroAddressReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0x4000;
context[CpuRegister.Rdx] = 0x03;
var result = KernelMemoryCompatExports.KernelMprotect(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Mprotect_ZeroLengthReturnsInvalidArgument()
{
const ulong memoryBase = 0x1_0000_0000;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = memoryBase;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = 0x03;
var result = KernelMemoryCompatExports.KernelMprotect(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Mprotect_UnmappedRangeReturnsNotFound()
{
// A plausible guest address that FakeCpuMemory does not back and that
// has no host reservation. TryProtectHostRange calls VirtualProtect,
// which fails on an unmapped range, yielding NOT_FOUND rather than
// mutating protection or throwing.
const ulong unmappedAddress = 0x2_0000_0000;
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = unmappedAddress;
context[CpuRegister.Rsi] = 0x4000;
context[CpuRegister.Rdx] = 0x03;
var result = KernelMemoryCompatExports.KernelMprotect(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void Munmap_ZeroAddressReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0x4000;
var result = KernelMemoryCompatExports.KernelMunmap(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Munmap_OverflowRangeReturnsInvalidArgument()
{
// address + length would overflow; KernelMunmap guards this explicitly
// before touching any region accounting.
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = ulong.MaxValue - 0x10;
context[CpuRegister.Rsi] = 0x20;
var result = KernelMemoryCompatExports.KernelMunmap(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Munmap_UnmappedRangeReturnsNotFound()
{
// No flexible region is registered at this address and FakeCpuMemory
// does not back it, so both physicallyBacked and removedRegions are
// empty and the export reports NOT_FOUND.
const ulong unmappedAddress = 0x2_0000_0000;
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = unmappedAddress;
context[CpuRegister.Rsi] = 0x4000;
var result = KernelMemoryCompatExports.KernelMunmap(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
} }
@@ -48,85 +48,6 @@ public sealed class SelfLoaderTests
new SelfLoader().Load(imageData, new VirtualMemory())); new SelfLoader().Load(imageData, new VirtualMemory()));
} }
[Theory]
[InlineData(0xDEADBEEF)]
[InlineData(0x7F454C47)] // bare ELF magic read big-endian as a "SELF" candidate is not a SELF
public void Load_RejectsUnrecognizedLeadingMagic(uint magic)
{
var imageData = new byte[SelfHeaderSize + ElfHeaderSize];
BinaryPrimitives.WriteUInt32BigEndian(imageData, magic);
Assert.Throws<InvalidDataException>(() =>
new SelfLoader().Load(imageData, new VirtualMemory()));
}
[Fact]
public void Load_RejectsImageSmallerThanElfHeader()
{
// A few bytes short of ElfHeaderSize (0x40); ParseLayout guards this
// before any magic dispatch, so the error is deterministic for both
// SELF and ELF inputs.
var imageData = new byte[ElfHeaderSize - 1];
Assert.Throws<InvalidDataException>(() =>
new SelfLoader().Load(imageData, new VirtualMemory()));
}
[Fact]
public void Load_RejectsTruncatedSelfHeader()
{
// SELF magic is present and recognized, but the image ends before the
// SELF header + embedded ELF header can be read. ParseLayout computes
// elfOffset = SelfHeaderSize + segments*SelfSegmentSize and then
// EnsureRange must fail.
var imageData = new byte[SelfHeaderSize + ElfHeaderSize];
BinaryPrimitives.WriteUInt32BigEndian(imageData, Ps5SelfMagic);
imageData[0x05] = 0x01;
imageData[0x06] = 0x01;
imageData[0x07] = 0x12;
var truncated = imageData.AsSpan(0, SelfHeaderSize + 0x10).ToArray();
Assert.Throws<InvalidDataException>(() =>
new SelfLoader().Load(truncated, new VirtualMemory()));
}
[Fact]
public void Load_ParsesEmbeddedElfHeaderFromSelfContainer()
{
var imageData = CreateSelfImage(Ps5SelfMagic, 0x10, 0x1000_0101, 0x32);
var image = new SelfLoader().Load(imageData, new VirtualMemory());
Assert.True(image.IsSelf);
// The ELF header parsed out of the SELF container must be a valid x86-64
// ELF64 little-endian image with the PS5 ABI marker that drives Gen5
// selection in SharpEmuRuntime.
Assert.True(image.ElfHeader.HasElfMagic);
Assert.True(image.ElfHeader.Is64Bit);
Assert.True(image.ElfHeader.IsLittleEndian);
Assert.Equal(2, image.ElfHeader.AbiVersion);
Assert.Equal(62, image.ElfHeader.Machine);
}
[Fact]
public void Load_AcceptsBareDecryptedElf()
{
// A decrypted eboot that has already been stripped of its SELF wrapper
// is accepted directly; IsSelf must be false and the ELF header is read
// from offset 0.
var imageData = new byte[ElfHeaderSize];
WriteMinimalElfHeader(imageData);
var image = new SelfLoader().Load(imageData, new VirtualMemory());
Assert.False(image.IsSelf);
Assert.True(image.ElfHeader.HasElfMagic);
Assert.True(image.ElfHeader.Is64Bit);
Assert.Equal(62, image.ElfHeader.Machine);
Assert.Empty(image.ProgramHeaders);
Assert.Empty(image.MappedRegions);
}
private static byte[] CreateSelfImage(uint magic, byte version, uint keyType, ushort flags) private static byte[] CreateSelfImage(uint magic, byte version, uint keyType, ushort flags)
{ {
var imageData = new byte[SelfHeaderSize + ElfHeaderSize]; var imageData = new byte[SelfHeaderSize + ElfHeaderSize];
@@ -1,228 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Text;
using SharpEmu.HLE;
using SharpEmu.Libs.SaveData;
using Xunit;
namespace SharpEmu.Libs.Tests.SaveData;
// Exercises the mount / event / param / delete exports end to end against a
// temp save root. Shares the environment-pinning collection so it never runs
// alongside other tests that mutate SHARPEMU_SAVEDATA_DIR.
[Collection("SaveDataMemoryState")]
public sealed class SaveDataExportsTests : IDisposable
{
private const ulong Base = 0x2_0000_0000;
private const int UserId = 0x1001;
private const string TitleId = "SDEXPORTTEST";
private const string MountPoint = "/savedata0";
private const string DirName = "SAVE0000";
private const ulong MountParam = Base + 0x100;
private const ulong MountResult = Base + 0x200;
private const ulong DirNamePtr = Base + 0x300;
private const ulong MountPointStr = Base + 0x340;
private const ulong EventOut = Base + 0x400;
private const ulong ParamStruct = Base + 0x500;
private const ulong DeleteParam = Base + 0xC00;
private const ulong SyncParam = Base + 0xC80;
private const ulong SetupParam = Base + 0xD00;
private const ulong SetupResult = Base + 0xD80;
private const int NoEvent = unchecked((int)0x809F0008);
private const int ParameterError = unchecked((int)0x809F0000);
private const uint MountModeCreate = 1u << 2;
private readonly FakeCpuMemory _memory = new(Base, 0x10000);
private readonly CpuContext _ctx;
private readonly string _root;
private readonly string? _previousRoot;
public SaveDataExportsTests()
{
_ctx = new CpuContext(_memory, Generation.Gen5);
_root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdexport-{Guid.NewGuid():N}");
_previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root);
SaveDataExports.ConfigureApplicationInfo(TitleId);
}
public void Dispose()
{
SaveDataExports.ConfigureApplicationInfo(null);
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot);
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private string SlotDir => Path.Combine(_root, TitleId, DirName);
private void WriteAscii(ulong address, string value)
{
var bytes = new byte[value.Length + 1];
Encoding.ASCII.GetBytes(value).CopyTo(bytes, 0);
Assert.True(_memory.TryWrite(address, bytes));
}
private CpuContext Reg(ulong rdi = 0, ulong rsi = 0, ulong rdx = 0, ulong rcx = 0)
{
_ctx[CpuRegister.Rdi] = rdi;
_ctx[CpuRegister.Rsi] = rsi;
_ctx[CpuRegister.Rdx] = rdx;
_ctx[CpuRegister.Rcx] = rcx;
return _ctx;
}
private int Mount(uint mountMode = MountModeCreate)
{
WriteAscii(DirNamePtr, DirName);
Span<byte> param = stackalloc byte[0x30];
param.Clear();
BinaryPrimitives.WriteInt32LittleEndian(param, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], DirNamePtr);
BinaryPrimitives.WriteUInt32LittleEndian(param[0x20..], mountMode);
Assert.True(_memory.TryWrite(MountParam, param));
return SaveDataExports.SaveDataMount3(Reg(rdi: MountParam, rsi: MountResult));
}
[Fact]
public void GetEventResult_WhenNoEvents_ReportsNoEvent()
{
Assert.Equal(NoEvent, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut)));
}
[Fact]
public void GetEventResult_NullOut_ReturnsParameterError()
{
Assert.Equal(ParameterError, SaveDataExports.SaveDataGetEventResult(Reg(rsi: 0)));
}
[Fact]
public void SyncSaveDataMemory_PostsSyncEndEvent_DrainedOnce()
{
// Setup the memory blob so sync succeeds.
Span<byte> setup = stackalloc byte[0x10];
setup.Clear();
BinaryPrimitives.WriteInt32LittleEndian(setup[0x04..], UserId);
BinaryPrimitives.WriteUInt64LittleEndian(setup[0x08..], 0x1000);
Assert.True(_memory.TryWrite(SetupParam, setup));
Assert.Equal(0, SaveDataExports.SaveDataSetupSaveDataMemory2(Reg(rdi: SetupParam, rdx: SetupResult)));
Span<byte> sync = stackalloc byte[0x10];
sync.Clear();
BinaryPrimitives.WriteInt32LittleEndian(sync, UserId);
Assert.True(_memory.TryWrite(SyncParam, sync));
Assert.Equal(0, SaveDataExports.SaveDataSyncSaveDataMemory(Reg(rdi: SyncParam)));
// The queued SAVE_DATA_MEMORY_SYNC_END (type 3) event is delivered once.
Assert.Equal(0, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut)));
Assert.True(_ctx.TryReadUInt32(EventOut + 0x00, out var type));
Assert.Equal(3u, type);
Assert.True(_ctx.TryReadInt32(EventOut + 0x04, out var errorCode));
Assert.Equal(0, errorCode);
Assert.Equal(NoEvent, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut)));
}
[Fact]
public void Mount_CreatesSlotDirectory_AndReportsMounted()
{
Assert.Equal(0, Mount());
Assert.True(Directory.Exists(SlotDir));
Assert.Equal(0, SaveDataExports.SaveDataIsMounted(Reg(rsi: EventOut)));
Assert.True(_ctx.TryReadUInt32(EventOut, out var mounted));
Assert.Equal(1u, mounted);
}
[Fact]
public void Umount_RemovesMountTracking()
{
Assert.Equal(0, Mount());
WriteAscii(MountPointStr, MountPoint);
Assert.Equal(0, SaveDataExports.SaveDataUmount2(Reg(rdi: MountPointStr)));
Assert.Equal(0, SaveDataExports.SaveDataIsMounted(Reg(rsi: EventOut)));
Assert.True(_ctx.TryReadUInt32(EventOut, out var mounted));
Assert.Equal(0u, mounted);
}
[Fact]
public void SetParam_ThenGetParam_RoundTripsThroughMetadata()
{
Assert.Equal(0, Mount());
WriteAscii(MountPointStr, MountPoint);
Span<byte> param = stackalloc byte[0x530];
param.Clear();
Encoding.ASCII.GetBytes("My Save").CopyTo(param); // +0x00 title
Encoding.ASCII.GetBytes("Biome 2").CopyTo(param[0x80..]); // +0x80 subtitle
Encoding.ASCII.GetBytes("2h 30m").CopyTo(param[0x100..]); // +0x100 detail
BinaryPrimitives.WriteUInt32LittleEndian(param[0x500..], 7); // +0x500 userParam
Assert.True(_memory.TryWrite(ParamStruct, param));
Assert.Equal(0, SaveDataExports.SaveDataSetParam(Reg(rdi: MountPointStr, rdx: ParamStruct)));
Assert.True(File.Exists(SaveDataStorage.ParamPath(SlotDir)));
var meta = SaveDataStorage.ReadMetadata(SlotDir);
Assert.Equal("My Save", meta.Title);
Assert.Equal("Biome 2", meta.SubTitle);
Assert.Equal("2h 30m", meta.Detail);
Assert.Equal(7u, meta.UserParam);
// Read it back through the export into a fresh buffer.
Span<byte> zero = stackalloc byte[0x530];
zero.Clear();
Assert.True(_memory.TryWrite(ParamStruct, zero));
Assert.Equal(0, SaveDataExports.SaveDataGetParam(Reg(rdi: MountPointStr, rdx: ParamStruct)));
var title = new byte[7];
Assert.True(_memory.TryRead(ParamStruct, title));
Assert.Equal("My Save", Encoding.ASCII.GetString(title));
}
[Fact]
public void SetParam_WithoutMount_ReturnsBadMounted()
{
WriteAscii(MountPointStr, "/notmounted");
Assert.True(_memory.TryWrite(ParamStruct, new byte[0x530]));
Assert.Equal(
unchecked((int)0x809F0013),
SaveDataExports.SaveDataSetParam(Reg(rdi: MountPointStr, rdx: ParamStruct)));
}
[Fact]
public void Delete_RemovesTheSlotDirectory()
{
Assert.Equal(0, Mount());
Assert.True(Directory.Exists(SlotDir));
WriteAscii(DirNamePtr, DirName);
Span<byte> del = stackalloc byte[0x10];
del.Clear();
BinaryPrimitives.WriteInt32LittleEndian(del, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(del[0x08..], DirNamePtr);
Assert.True(_memory.TryWrite(DeleteParam, del));
Assert.Equal(0, SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
Assert.False(Directory.Exists(SlotDir));
}
[Fact]
public void Delete_MissingSlot_ReturnsNotFound()
{
WriteAscii(DirNamePtr, "NOSUCHSLOT");
Span<byte> del = stackalloc byte[0x10];
del.Clear();
BinaryPrimitives.WriteInt32LittleEndian(del, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(del[0x08..], DirNamePtr);
Assert.True(_memory.TryWrite(DeleteParam, del));
Assert.Equal(
unchecked((int)0x809F0008),
SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
}
}
@@ -49,9 +49,8 @@ public sealed class SaveDataMemoryExportsTests : IDisposable
SaveDataExports.ConfigureApplicationInfo(TitleId); SaveDataExports.ConfigureApplicationInfo(TitleId);
} }
// Saves are keyed by title id only (single-user layout): <root>/<titleId>/...
private string MemoryPath => private string MemoryPath =>
Path.Combine(_root, TitleId, "sce_sdmemory", "memory.dat"); Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat");
public void Dispose() public void Dispose()
{ {
@@ -1,115 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO;
using SharpEmu.Libs.SaveData;
using Xunit;
namespace SharpEmu.Libs.Tests;
/// <summary>
/// Save data lives under ~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/ with UI
/// metadata in &lt;slot&gt;/sce_sys/param.json. These guard the pure path and
/// metadata logic that the SaveData HLE exports build on.
/// </summary>
public sealed class SaveDataStorageTests
{
[Fact]
public void RootHonorsOverrideAndFallsBackToUserProfile()
{
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root());
}
[Fact]
public void LayoutNestsTitleThenSlotThenSceSys()
{
var root = SaveDataStorage.Root("/saves");
var titleRoot = SaveDataStorage.TitleRoot(root, "PPSA15552");
var slot = SaveDataStorage.SlotDir(titleRoot, "SAVE0000");
Assert.Equal(Path.Combine(Path.GetFullPath("/saves"), "PPSA15552"), titleRoot);
Assert.Equal(Path.Combine(titleRoot, "SAVE0000"), slot);
Assert.Equal(Path.Combine(slot, "sce_sys", "param.json"), SaveDataStorage.ParamPath(slot));
Assert.Equal(Path.Combine(slot, "sce_sys", "icon0.png"), SaveDataStorage.IconPath(slot));
Assert.Equal(Path.Combine(titleRoot, "sce_sdmemory", "memory.dat"), SaveDataStorage.MemoryPath(titleRoot));
}
[Theory]
[InlineData("SAVE0000", "SAVE0000")]
[InlineData("../../etc/passwd", ".._.._etc_passwd")] // '/' separators -> '_', collapsing to one segment
[InlineData("a/b", "a_b")]
[InlineData("", "default")]
[InlineData(" ", "default")]
public void SanitizeNeutralizesPathSeparatorsAndEmpties(string input, string expected)
{
Assert.Equal(expected, SaveDataStorage.Sanitize(input));
}
[Fact]
public void SanitizedSlotStaysUnderTheTitleRoot()
{
// The dangerous part of a traversal is the separator; sanitizing it to a
// single segment keeps the slot a direct child of the title root.
var titleRoot = SaveDataStorage.TitleRoot(SaveDataStorage.Root("/saves"), "PPSA15552");
var slot = SaveDataStorage.SlotDir(titleRoot, "../escape");
Assert.Equal(titleRoot, Path.GetDirectoryName(slot));
Assert.DoesNotContain(Path.DirectorySeparatorChar, Path.GetFileName(slot));
}
[Fact]
public void MetadataRoundTripsThroughParamJson()
{
var slot = Path.Combine(Path.GetTempPath(), "sharpemu-savetest-" + Path.GetRandomFileName());
try
{
var written = new SaveDataMetadata
{
Title = "Dead Cells",
SubTitle = "The Prisoners' Quarters",
Detail = "Cell 1 - 3h 12m",
UserParam = 42,
};
SaveDataStorage.WriteMetadata(slot, written);
Assert.True(File.Exists(SaveDataStorage.ParamPath(slot)));
var read = SaveDataStorage.ReadMetadata(slot);
Assert.Equal(written.Title, read.Title);
Assert.Equal(written.SubTitle, read.SubTitle);
Assert.Equal(written.Detail, read.Detail);
Assert.Equal(written.UserParam, read.UserParam);
}
finally
{
if (Directory.Exists(slot))
{
Directory.Delete(slot, recursive: true);
}
}
}
[Fact]
public void ReadMetadataDefaultsWhenMissingOrCorrupt()
{
var slot = Path.Combine(Path.GetTempPath(), "sharpemu-savetest-" + Path.GetRandomFileName());
try
{
var missing = SaveDataStorage.ReadMetadata(slot);
Assert.Equal(Path.GetFileName(slot), missing.Title);
Directory.CreateDirectory(Path.GetDirectoryName(SaveDataStorage.ParamPath(slot))!);
File.WriteAllText(SaveDataStorage.ParamPath(slot), "{ not valid json");
var corrupt = SaveDataStorage.ReadMetadata(slot);
Assert.Equal(Path.GetFileName(slot), corrupt.Title);
}
finally
{
if (Directory.Exists(slot))
{
Directory.Delete(slot, recursive: true);
}
}
}
}
@@ -1,130 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using Xunit;
namespace SharpEmu.Libs.Tests;
/// <summary>
/// The SSE4a EXTRQ+blend idiom raises #UD -> SIGILL under Rosetta 2, so the
/// loader rewrites it to SSE4.1 at boot. Sony's toolchain allocates both the
/// blend destination and the scratch source register freely (one Dead Cells
/// build uses dest=xmm0, another dest=xmm3), so the matcher must read both from
/// the encoding rather than assume fixed registers.
/// </summary>
public sealed class Sse4aExtrqBlendPatchTests
{
// EXTRQ xmmSrc, 0x28, 0x00 ; VPBLENDD xmmDest, xmmDest, xmmSrc, 2.
private static byte[] Idiom(int dest, int src) =>
[
0x66, 0x0F, 0x78, (byte)(0xC0 | src), 0x28, 0x00,
0xC4, 0xE3, (byte)(((~dest & 0xF) << 3) | 0x01), 0x02, (byte)(0xC0 | (dest << 3) | src), 0x02,
];
// PEXTRB eax, xmmSrc, 4 ; PINSRD xmmDest, eax, 1.
private static byte[] Replacement(int dest, int src) =>
[
0x66, 0x0F, 0x3A, 0x14, (byte)(0xC0 | (src << 3)), 0x04,
0x66, 0x0F, 0x3A, 0x22, (byte)(0xC0 | (dest << 3)), 0x01,
];
[Fact]
public void MatchesEveryDestinationAndSourceCombination()
{
for (var dest = 0; dest <= 7; dest++)
{
for (var src = 0; src <= 7; src++)
{
Assert.True(
Sse4aExtrqBlendPatch.TryMatch(Idiom(dest, src), out var matchedDest, out var matchedSrc),
$"dest={dest} src={src}");
Assert.Equal(dest, matchedDest);
Assert.Equal(src, matchedSrc);
}
}
}
[Fact]
public void MatchesTheDeadCellsXmm3Xmm4Idiom()
{
// The exact bytes that faulted: EXTRQ xmm4,0x28,0x00 ; VPBLENDD xmm3,xmm3,xmm4,2.
byte[] bytes = [0x66, 0x0F, 0x78, 0xC4, 0x28, 0x00, 0xC4, 0xE3, 0x61, 0x02, 0xDC, 0x02];
Assert.True(Sse4aExtrqBlendPatch.TryMatch(bytes, out var dest, out var src));
Assert.Equal(3, dest);
Assert.Equal(4, src);
}
[Fact]
public void RoundTripsEveryCombinationThroughMatchThenEncode()
{
for (var dest = 0; dest <= 7; dest++)
{
for (var src = 0; src <= 7; src++)
{
Assert.True(Sse4aExtrqBlendPatch.TryMatch(Idiom(dest, src), out var matchedDest, out var matchedSrc));
var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength];
Assert.True(Sse4aExtrqBlendPatch.TryEncode(matchedDest, matchedSrc, buffer));
Assert.Equal(Replacement(dest, src), buffer);
}
}
}
[Fact]
public void PreservesTheOriginalXmm0DestinationEncoding()
{
// Guards the behaviour the previous matcher (dest fixed to xmm0) produced.
var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength];
Assert.True(Sse4aExtrqBlendPatch.TryEncode(destRegister: 0, srcRegister: 2, buffer));
Assert.Equal(
new byte[] { 0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04, 0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01 },
buffer);
}
[Fact]
public void RejectsMismatchedSourceAcrossTheTwoInstructions()
{
// EXTRQ masks xmm1 but the blend reads xmm2 — not the paired idiom.
var mixed = Idiom(dest: 0, src: 1);
mixed[10] = 0xC0 | 2;
Assert.False(Sse4aExtrqBlendPatch.TryMatch(mixed, out _, out _));
}
[Theory]
[InlineData(0)] // wrong first byte
[InlineData(2)] // wrong opcode
[InlineData(4)] // wrong EXTRQ length immediate
[InlineData(9)] // wrong blend opcode
[InlineData(11)] // wrong blend immediate
public void RejectsSequencesThatDifferFromTheIdiom(int corruptIndex)
{
var bytes = Idiom(dest: 0, src: 2);
bytes[corruptIndex] ^= 0xFF;
Assert.False(Sse4aExtrqBlendPatch.TryMatch(bytes, out _, out _));
}
[Fact]
public void RejectsNonModRmRegisterEncodings()
{
// A memory-form ModRM (mod != 11) is not the register-to-register idiom.
var bytes = Idiom(dest: 0, src: 2);
bytes[3] = 0x02; // mod=00, rm=010 — memory operand, not xmm2 direct
Assert.False(Sse4aExtrqBlendPatch.TryMatch(bytes, out _, out _));
}
[Fact]
public void RejectsTooShortWindows()
{
Assert.False(Sse4aExtrqBlendPatch.TryMatch(Idiom(dest: 0, src: 2).AsSpan(0, 11), out _, out _));
}
[Theory]
[InlineData(-1, 0)]
[InlineData(8, 0)]
[InlineData(0, 8)]
public void EncodeRejectsRegistersOutsideXmm0Through7(int dest, int src)
{
var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength];
Assert.False(Sse4aExtrqBlendPatch.TryEncode(dest, src, buffer));
}
}
@@ -1,47 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
// Read-only guest memory holding hand-assembled instruction words for the decoder.
// Copied (not shared) from the ShaderDump tool on purpose: shader-codegen test projects
// stay self-contained so each backend's suite can serve as a standalone model.
internal sealed class FakeGuestMemory : ICpuMemory
{
private readonly List<(ulong Base, byte[] Data)> _regions = [];
public void AddRegion(ulong baseAddress, uint[] words)
{
var bytes = new byte[words.Length * sizeof(uint)];
for (var index = 0; index < words.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(index * sizeof(uint)),
words[index]);
}
_regions.Add((baseAddress, bytes));
}
public bool TryRead(ulong virtualAddress, Span<byte> destination)
{
foreach (var (baseAddress, data) in _regions)
{
if (virtualAddress >= baseAddress &&
virtualAddress + (ulong)destination.Length <= baseAddress + (ulong)data.Length)
{
data.AsSpan(
(int)(virtualAddress - baseAddress),
destination.Length).CopyTo(destination);
return true;
}
}
return false;
}
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) => false;
}
@@ -1,289 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
internal sealed record Gen5ComputeFixture(
string Name,
uint[] Words,
uint StoreScalarResourceBase,
int StoreBackingBytes);
/// <summary>
/// Hand-assembled Gen5 (gfx10) programs plus the decode -> (state, evaluation) -> MSL
/// pipeline the tests drive. Programs are synthetic by construction — shader binaries
/// captured from games are copyrighted content and must never land in fixtures.
/// </summary>
internal static class Gen5ComputeFixtures
{
public const ulong ProgramAddress = 0x100000;
// Straight-line ALU: VOP2 fmac + fmamk/fmaak literals + the VOP3 fmac form.
public static readonly Gen5ComputeFixture Fmac = new(
"fmac",
[
0x560A0501, // v_fmac_f32 v5, v1, v2
0x580A0501, 0x42280000, // v_fmamk_f32 v5, v1, 42.0, v2
0x5A0A0501, 0x42280000, // v_fmaak_f32 v5, v1, v2, 42.0
0xD52B0005, 0x00020501, // v_fmac_f32_e64 v5, v1, v2
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 0,
StoreBackingBytes: 0);
// VOP3 integer multiplies, low and high halves, signed and unsigned.
public static readonly Gen5ComputeFixture Muls = new(
"muls",
[
0xD5690005, 0x00020501, // v_mul_lo_u32 v5, v1, v2
0xD56A0005, 0x00020501, // v_mul_hi_u32 v5, v1, v2
0xD56B0005, 0x00020501, // v_mul_lo_i32 v5, v1, v2
0xD56C0005, 0x00020501, // v_mul_hi_i32 v5, v1, v2
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 0,
StoreBackingBytes: 0);
// End-to-end executable program: real ALU results stored to buffer 0 at dword
// offsets 0/4/8, then proof that a store with EXEC=0 does not land (offset 12
// keeps its sentinel) and that stores work again once EXEC is restored (16).
public static readonly Gen5ComputeFixture ExecStore = new(
"exec-store",
[
0xBFA10001, // s_clause 0x1 (scheduling hint)
0x7E0002FF, 0x3FC00000, // v_mov_b32 v0, 1.5f
0x7E0202FF, 0x40100000, // v_mov_b32 v1, 2.25f
0x7E0402FF, 0x41200000, // v_mov_b32 v2, 10.0f
0x56040300, // v_fmac_f32 v2, v0, v1 -> v2 = fma(1.5, 2.25, 10.0)
0x7E0602FF, 0x7FFFFFFF, // v_mov_b32 v3, 0x7FFFFFFF
0x7E0802FF, 0x00010003, // v_mov_b32 v4, 0x00010003
0xD56C0005, 0x00020903, // v_mul_hi_i32 v5, v3, v4
0xD56B0006, 0x00020903, // v_mul_lo_i32 v6, v3, v4
0xE0700000, 0x80020200, // buffer_store_dword v2, off, s[8:11], 0
0xE0700004, 0x80020500, // buffer_store_dword v5, off, s[8:11], 0 offset:4
0xE0700008, 0x80020600, // buffer_store_dword v6, off, s[8:11], 0 offset:8
0xBEFE0380, // s_mov_b32 exec_lo, 0 -> lane inactive
0xE070000C, 0x80020200, // buffer_store_dword v2 offset:12 (masked, must not land)
0xBEFE03C1, // s_mov_b32 exec_lo, -1 -> lane active again
0xE0700010, 0x80020000, // buffer_store_dword v0 offset:16
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 64);
// Control flow through the PC dispatcher: a scalar loop counting down from 5,
// accumulating 5+4+3+2+1 = 15 into v1, with the result stored to buffer 0.
// (s_sub_i32's SCC is signed overflow, so the loop condition needs an
// explicit s_cmp_lg_u32 — which also exercises the scalar-compare path.)
public static readonly Gen5ComputeFixture Loop = new(
"loop",
[
0xBE800385, // s_mov_b32 s0, 5
0x7E020280, // v_mov_b32 v1, 0
// loop: (pc=0x8)
0x4A020200, // v_add_nc_u32 v1, s0, v1
0x81808100, // s_sub_i32 s0, s0, 1 (inline +1 = 0x81)
0xBF078000, // s_cmp_lg_u32 s0, 0
0xBF85FFFC, // s_cbranch_scc1 loop
0xE0700000, 0x80020100, // buffer_store_dword v1, off, s[8:11], 0
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 16);
// LDS round trip: write a literal through workgroup-shared memory, barrier,
// read it back, and store it to buffer 0.
public static readonly Gen5ComputeFixture Lds = new(
"lds",
[
0x7E000280, // v_mov_b32 v0, 0 (LDS byte address)
0x7E0402FF, 0x00001234, // v_mov_b32 v2, 0x1234
0xD8340000, 0x00000200, // ds_write_b32 v0, v2
0xBF8A0000, // s_barrier
0xD8D80000, 0x03000000, // ds_read_b32 v3, v0
0xE0700000, 0x80020300, // buffer_store_dword v3, off, s[8:11], 0
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 16);
// Wave64 cross-lane: v_readfirstlane routes through the translator's
// two-half threadgroup-scratch broadcast bridge. All lanes hold the same
// value (42), so the broadcast result is 42 on every lane regardless of
// which lane is "first" — the fixture's job is to prove the emitted wave64
// MSL compiles and that a full 64-lane dispatch runs through the bridge
// barriers without deadlocking, returning the broadcast value.
public static readonly Gen5ComputeFixture Wave64Broadcast = new(
"wave64-broadcast",
[
0x7E0002FF, 0x0000002A, // v_mov_b32 v0, 42
0x7E000500, // v_readfirstlane_b32 s0, v0
0x7E020200, // v_mov_b32 v1, s0
0xE0700000, 0x80020100, // buffer_store_dword v1, off, s[8:11], 0
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 16);
public static readonly Gen5ComputeFixture[] All = [Fmac, Muls, ExecStore, Loop, Lds];
// Minimal pixel program: two interpolated attribute channels plus two
// inline constants exported to MRT0 (done+vm).
public static readonly uint[] PixelWords =
[
0xC8020002, // v_interp_mov_f32 v0, p0, attr0.x
0xC8060102, // v_interp_mov_f32 v1, p0, attr0.y
0x7E0402F2, // v_mov_b32 v2, 1.0
0x7E0602F0, // v_mov_b32 v3, 0.5
0xF800180F, 0x03020100, // exp mrt0 v0, v1, v2, v3 done vm
0xBF810000, // s_endpgm
];
// Minimal vertex program: constant position export plus one param output.
public static readonly uint[] VertexWords =
[
0x7E000280, // v_mov_b32 v0, 0
0x7E0202F2, // v_mov_b32 v1, 1.0
0xF80008CF, 0x01000000, // exp pos0 v0, v0, v0, v1 done
0xF800020F, 0x01000101, // exp param0 v1, v1, v0, v1
0xBF810000, // s_endpgm
];
public static Gen5MslShader CompileVertexOrThrow(int requiredVertexOutputCount = 0)
{
var memory = new FakeGuestMemory();
memory.AddRegion(ProgramAddress, VertexWords);
var ctx = new CpuContext(memory, Generation.Gen5);
if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError))
{
throw new InvalidOperationException($"[vertex] decode failed: {decodeError}");
}
var state = new Gen5ShaderState(program!, new uint[16], Metadata: null);
var evaluation = new Gen5ShaderEvaluation(
new uint[128],
new uint[128],
Array.Empty<Gen5ImageBinding>(),
Array.Empty<Gen5GlobalMemoryBinding>());
if (!Gen5MslTranslator.TryCompileVertexShader(
state,
evaluation,
out var shader,
out var compileError,
requiredVertexOutputCount: requiredVertexOutputCount))
{
throw new InvalidOperationException($"[vertex] MSL emit failed: {compileError}");
}
return shader;
}
public static Gen5MslShader CompilePixelOrThrow(
Gen5PixelOutputKind outputKind = Gen5PixelOutputKind.Float)
{
var memory = new FakeGuestMemory();
memory.AddRegion(ProgramAddress, PixelWords);
var ctx = new CpuContext(memory, Generation.Gen5);
if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError))
{
throw new InvalidOperationException($"[pixel] decode failed: {decodeError}");
}
var state = new Gen5ShaderState(program!, new uint[16], Metadata: null);
var evaluation = new Gen5ShaderEvaluation(
new uint[128],
new uint[128],
Array.Empty<Gen5ImageBinding>(),
Array.Empty<Gen5GlobalMemoryBinding>());
if (!Gen5MslTranslator.TryCompilePixelShader(
state,
evaluation,
outputKind,
out var shader,
out var compileError))
{
throw new InvalidOperationException($"[pixel] MSL emit failed: {compileError}");
}
return shader;
}
/// <summary>Drives the real decoder and the MSL emitter for one fixture.</summary>
public static Gen5MslShader CompileOrThrow(Gen5ComputeFixture fixture) =>
CompileOrThrow(fixture, waveLaneCount: 32, localSizeX: 32);
public static Gen5MslShader CompileOrThrow(
Gen5ComputeFixture fixture,
uint waveLaneCount,
uint localSizeX)
{
var program = DecodeOrThrow(fixture);
// Buffer stores need a global-memory binding; the emitter resolves them
// by instruction PC, so collect memory-access PCs from the decoded
// program itself.
var accessPcs = new List<uint>();
foreach (var instruction in program.Instructions)
{
if (instruction.Control is Gen5BufferMemoryControl or Gen5GlobalMemoryControl)
{
accessPcs.Add(instruction.Pc);
}
}
var globalBindings = accessPcs.Count != 0
? new[]
{
new Gen5GlobalMemoryBinding(
fixture.StoreScalarResourceBase,
0UL,
accessPcs,
new byte[Math.Max(fixture.StoreBackingBytes, 4)],
Math.Max(fixture.StoreBackingBytes, 4),
DataPooled: false)
{
Writable = true,
},
}
: Array.Empty<Gen5GlobalMemoryBinding>();
var state = new Gen5ShaderState(program, new uint[16], Metadata: null);
var evaluation = new Gen5ShaderEvaluation(
new uint[128],
new uint[128],
Array.Empty<Gen5ImageBinding>(),
globalBindings);
if (!Gen5MslTranslator.TryCompileComputeShader(
state,
evaluation,
localSizeX,
1,
1,
out var shader,
out var compileError,
waveLaneCount: waveLaneCount))
{
throw new InvalidOperationException($"[{fixture.Name}] MSL emit failed: {compileError}");
}
return shader;
}
public static Gen5ShaderProgram DecodeOrThrow(Gen5ComputeFixture fixture)
{
var memory = new FakeGuestMemory();
memory.AddRegion(ProgramAddress, fixture.Words);
var ctx = new CpuContext(memory, Generation.Gen5);
if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError))
{
throw new InvalidOperationException($"[{fixture.Name}] decode failed: {decodeError}");
}
return program!;
}
}
@@ -1,169 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
device uint* b0 [[buffer(0)]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 0x3FC00000u;
if (exec) { v[0] = t0; }
uint t1 = 0x40100000u;
if (exec) { v[1] = t1; }
uint t2 = 0x41200000u;
if (exec) { v[2] = t2; }
uint t3 = as_type<uint>((fma(as_type<float>(v[0]), as_type<float>(v[1]), as_type<float>(v[2]))));
if (exec) { v[2] = t3; }
uint t4 = 0x7FFFFFFFu;
if (exec) { v[3] = t4; }
uint t5 = 0x10003u;
if (exec) { v[4] = t5; }
uint t6 = as_type<uint>(mulhi(as_type<int>(v[3]), as_type<int>(v[4])));
if (exec) { v[5] = t6; }
uint t7 = ((v[3]) * (v[4]));
if (exec) { v[6] = t7; }
uint t8 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t8 + 0u, v[2], 4u);
}
uint t9 = ((0x4u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t9 + 0u, v[5], 4u);
}
uint t10 = ((0x8u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t10 + 0u, v[6], 4u);
}
uint t11 = 0u;
uint t12 = t11;
s[126] = t12;
exec = ((t12) >> sharpemu_lane & 1u) != 0u;
uint t13 = ((0xCu + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t13 + 0u, v[2], 4u);
}
uint t14 = 0xFFFFFFFFu;
uint t15 = t14;
s[126] = t15;
exec = ((t15) >> sharpemu_lane & 1u) != 0u;
uint t16 = ((0x10u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t16 + 0u, v[0], 4u);
}
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -1,127 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(v[5]))));
if (exec) { v[5] = t0; }
uint t1 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(0x42280000u), as_type<float>(v[2]))));
if (exec) { v[5] = t1; }
uint t2 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(0x42280000u))));
if (exec) { v[5] = t2; }
uint t3 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(v[5]))));
if (exec) { v[5] = t3; }
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -1,135 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
device uint* b0 [[buffer(0)]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
threadgroup uint sharpemu_lds[8192];
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 0u;
if (exec) { v[0] = t0; }
uint t1 = 0x1234u;
if (exec) { v[2] = t1; }
uint t2 = v[0];
if (exec) { sharpemu_lds[(((t2) >> 2) & 8191u)] = v[2]; }
threadgroup_barrier(mem_flags::mem_threadgroup | mem_flags::mem_device);
uint t3 = v[0];
if (exec) { v[3] = sharpemu_lds[(((t3) >> 2) & 8191u)]; }
uint t4 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t4 + 0u, v[3], 4u);
}
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -1,149 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
device uint* b0 [[buffer(0)]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 5u;
s[0] = t0;
uint t1 = 0u;
if (exec) { v[1] = t1; }
pc = 1u;
}
break;
case 1u:
{
uint t2 = ((s[0]) + (v[1]));
if (exec) { v[1] = t2; }
uint t3 = s[0];
uint t4 = 1u;
uint t5 = (t3 - t4);
s[0] = t5;
scc = ((((t3 ^ t4)) & (t3 ^ t5)) >> 31) != 0u;
uint t6 = s[0];
uint t7 = 0u;
scc = (t6) != (t7);
pc = (scc) ? 1u : 2u;
}
break;
case 2u:
{
uint t8 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t8 + 0u, v[1], 4u);
}
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -1,127 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = ((v[1]) * (v[2]));
if (exec) { v[5] = t0; }
uint t1 = mulhi(v[1], v[2]);
if (exec) { v[5] = t1; }
uint t2 = ((v[1]) * (v[2]));
if (exec) { v[5] = t2; }
uint t3 = as_type<uint>(mulhi(as_type<int>(v[1]), as_type<int>(v[2])));
if (exec) { v[5] = t3; }
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -1,136 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return value ? 1u : 0u;
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
struct Gen5PsIn
{
float4 sharpemu_frag_coord [[position]];
float4 attr0 [[user(locn0)]];
};
struct Gen5PsOut
{
float4 mrt0 [[color(0)]];
};
fragment Gen5PsOut gen5_ps(
Gen5PsIn sharpemu_in [[stage_in]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]])
{
const uint sharpemu_lane = 0u;
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
Gen5PsOut sharpemu_out = {};
uint bias[1] = {};
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
if (exec) { v[0] = as_type<uint>(sharpemu_in.attr0[0]); }
if (exec) { v[1] = as_type<uint>(sharpemu_in.attr0[1]); }
uint t0 = 0x3F800000u;
if (exec) { v[2] = t0; }
uint t1 = 0x3F000000u;
if (exec) { v[3] = t1; }
sharpemu_out.mrt0 = exec ? vec<float, 4>(as_type<float>(v[0]), as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(v[3])) : sharpemu_out.mrt0;
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
if (!exec)
{
discard_fragment();
}
return sharpemu_out;
}
@@ -1,129 +0,0 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return value ? 1u : 0u;
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
struct Gen5VsOut
{
float4 sharpemu_position [[position]];
float4 param0 [[user(locn0)]];
};
vertex Gen5VsOut gen5_vs(
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]],
uint sharpemu_vertex_id [[vertex_id]],
uint sharpemu_instance_id [[instance_id]])
{
const uint sharpemu_lane = 0u;
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
Gen5VsOut sharpemu_out = {};
uint bias[1] = {};
v[5] = sharpemu_vertex_id;
v[8] = sharpemu_instance_id;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 0u;
if (exec) { v[0] = t0; }
uint t1 = 0x3F800000u;
if (exec) { v[1] = t1; }
sharpemu_out.sharpemu_position = exec ? float4(as_type<float>(v[0]), as_type<float>(v[0]), as_type<float>(v[0]), as_type<float>(v[1])) : sharpemu_out.sharpemu_position;
sharpemu_out.param0 = exec ? float4(as_type<float>(v[1]), as_type<float>(v[1]), as_type<float>(v[0]), as_type<float>(v[1])) : sharpemu_out.param0;
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
return sharpemu_out;
}
@@ -1,240 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Minimal Metal.framework access via objc_msgSend — just enough to compile MSL source
/// with the OS runtime compiler and dispatch a single-thread compute kernel. Kept
/// dependency-free on purpose; object lifetimes lean on process teardown, which is fine
/// for a test host.
/// </summary>
internal static partial class MetalNative
{
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
[StructLayout(LayoutKind.Sequential)]
private struct MtlSize
{
public nuint Width;
public nuint Height;
public nuint Depth;
}
[LibraryImport(MetalFramework)]
private static partial nint MTLCreateSystemDefaultDevice();
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint objc_getClass(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendVoid(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendVoid(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendDispatch(nint receiver, nint selector, MtlSize threadgroups, MtlSize threadsPerThreadgroup);
private static readonly Lazy<nint> Device = new(() =>
OperatingSystem.IsMacOS() ? MTLCreateSystemDefaultDevice() : 0);
public static bool IsAvailable => Device.Value != 0;
private static nint Selector(string name) => sel_registerName(name);
private static nint NsString(string value)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
try
{
return Send(objc_getClass("NSString"), Selector("stringWithUTF8String:"), utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
private static string DescribeError(nint error)
{
if (error == 0)
{
return "unknown error";
}
var description = Send(error, Selector("localizedDescription"));
var utf8 = Send(description, Selector("UTF8String"));
return Marshal.PtrToStringUTF8(utf8) ?? "unknown error";
}
/// <summary>Compiles MSL source with the OS runtime compiler.</summary>
public static bool TryCompileLibrary(string source, out nint library, out string error)
{
library = 0;
error = string.Empty;
// Metal defaults to fast-math; GCN float semantics do not survive it, so the
// harness compiles the way a real Metal backend must: fast-math off.
var options = Send(Send(objc_getClass("MTLCompileOptions"), Selector("alloc")), Selector("init"));
SendVoidBool(options, Selector("setFastMathEnabled:"), false);
nint nsError = 0;
library = Send(
Device.Value,
Selector("newLibraryWithSource:options:error:"),
NsString(source),
options,
ref nsError);
if (library == 0)
{
error = DescribeError(nsError);
return false;
}
return true;
}
/// <summary>
/// Runs one thread of a compute kernel with the guest data buffer and the
/// SharpEmuUniforms constant buffer bound at the caller-supplied indices
/// (per the Gen5MslTranslator contract the uniforms index equals the
/// global-buffer count), then returns the data buffer contents.
/// </summary>
public static bool TryExecuteSingleThread(
nint library,
string entryPoint,
byte[] bufferContents,
byte[] uniformsContents,
nuint dataIndex,
nuint uniformsIndex,
out byte[] result,
out string error) =>
TryExecuteThreadgroup(
library, entryPoint, bufferContents, uniformsContents,
dataIndex, uniformsIndex, threadsPerThreadgroup: 1, out result, out error);
/// <summary>Runs the kernel as a single threadgroup of
/// <paramref name="threadsPerThreadgroup"/> threads, so wave64 fixtures can
/// exercise both 32-wide simdgroups of one guest wave under a threadgroup
/// barrier.</summary>
public static bool TryExecuteThreadgroup(
nint library,
string entryPoint,
byte[] bufferContents,
byte[] uniformsContents,
nuint dataIndex,
nuint uniformsIndex,
uint threadsPerThreadgroup,
out byte[] result,
out string error)
{
result = [];
error = string.Empty;
var function = Send(library, Selector("newFunctionWithName:"), NsString(entryPoint));
if (function == 0)
{
error = $"entry point '{entryPoint}' not found in the compiled library";
return false;
}
nint nsError = 0;
var pipeline = Send(
Device.Value,
Selector("newComputePipelineStateWithFunction:error:"),
function,
ref nsError);
if (pipeline == 0)
{
error = $"pipeline creation failed: {DescribeError(nsError)}";
return false;
}
var queue = Send(Device.Value, Selector("newCommandQueue"));
nint buffer;
unsafe
{
fixed (byte* contents = bufferContents)
{
// options 0 = MTLResourceStorageModeShared: CPU-visible for readback.
buffer = SendBuffer(
Device.Value,
Selector("newBufferWithBytes:length:options:"),
(nint)contents,
(nuint)bufferContents.Length,
0);
}
}
nint uniforms;
unsafe
{
fixed (byte* contents = uniformsContents)
{
uniforms = SendBuffer(
Device.Value,
Selector("newBufferWithBytes:length:options:"),
(nint)contents,
(nuint)uniformsContents.Length,
0);
}
}
if (queue == 0 || buffer == 0 || uniforms == 0)
{
error = "failed to create command queue or buffer";
return false;
}
var commandBuffer = Send(queue, Selector("commandBuffer"));
var encoder = Send(commandBuffer, Selector("computeCommandEncoder"));
SendVoid(encoder, Selector("setComputePipelineState:"), pipeline);
SendSetBuffer(encoder, Selector("setBuffer:offset:atIndex:"), buffer, 0, dataIndex);
SendSetBuffer(encoder, Selector("setBuffer:offset:atIndex:"), uniforms, 0, uniformsIndex);
var oneGroup = new MtlSize { Width = 1, Height = 1, Depth = 1 };
var threads = new MtlSize { Width = threadsPerThreadgroup, Height = 1, Depth = 1 };
SendDispatch(encoder, Selector("dispatchThreadgroups:threadsPerThreadgroup:"), oneGroup, threads);
SendVoid(encoder, Selector("endEncoding"));
SendVoid(commandBuffer, Selector("commit"));
SendVoid(commandBuffer, Selector("waitUntilCompleted"));
var contentsPointer = Send(buffer, Selector("contents"));
if (contentsPointer == 0)
{
error = "buffer contents unavailable after execution";
return false;
}
result = new byte[bufferContents.Length];
Marshal.Copy(contentsPointer, result, 0, result.Length);
return true;
}
}
@@ -1,265 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using Xunit;
using Xunit.Abstractions;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Tier 2 and 3: the emitted MSL must compile with the OS runtime Metal compiler, and
/// the executable fixtures must produce bit-exact results on the GPU, including EXEC
/// masking and dispatcher control flow. These tests no-op (with a note) on hosts
/// without a Metal device so the suite stays green on Windows/Linux CI; the golden
/// and structural tiers still run everywhere.
/// </summary>
public sealed class MetalRuntimeTests(ITestOutputHelper output)
{
[Fact]
public void AllFixturesCompileWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
foreach (var fixture in Gen5ComputeFixtures.All)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"[{fixture.Name}] Metal rejected the emitted MSL: {error}\n{shader.Source}");
}
}
[Fact]
public void ExecStoreProgramExecutesWithExecMasking()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
// Sentinel-filled buffer: any dword the program does not store must survive.
const uint Sentinel = 0xDEADBEEFu;
var buffer = new byte[64];
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
{
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
}
var result = ExecuteOrThrow(Gen5ComputeFixtures.ExecStore, buffer);
// Reference results computed with the same semantics the program encodes.
var fmac = BitConverter.SingleToUInt32Bits(MathF.FusedMultiplyAdd(1.5f, 2.25f, 10.0f));
var mulHiSigned = (uint)(((long)0x7FFFFFFF * 0x00010003) >> 32);
var mulLoSigned = unchecked(0x7FFFFFFFu * 0x00010003u);
var movBits = BitConverter.SingleToUInt32Bits(1.5f);
Assert.Equal(fmac, ReadDword(result, 0));
Assert.Equal(mulHiSigned, ReadDword(result, 4));
Assert.Equal(mulLoSigned, ReadDword(result, 8));
Assert.Equal(Sentinel, ReadDword(result, 12)); // EXEC=0: the store must not land.
Assert.Equal(movBits, ReadDword(result, 16));
for (var offset = 20; offset < result.Length; offset += sizeof(uint))
{
Assert.Equal(Sentinel, ReadDword(result, offset));
}
}
[Fact]
public void LoopProgramIteratesThroughTheDispatcher()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
var result = ExecuteOrThrow(Gen5ComputeFixtures.Loop, new byte[16]);
// 5 + 4 + 3 + 2 + 1, accumulated across five dispatcher round trips.
Assert.Equal(15u, ReadDword(result, 0));
}
[Fact]
public void PixelShaderCompilesWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"[pixel] Metal rejected the emitted MSL: {error}\n{shader.Source}");
}
[Fact]
public void VertexShaderCompilesWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 2);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"[vertex] Metal rejected the emitted MSL: {error}\n{shader.Source}");
}
[Fact]
public void FixedShadersCompileWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
var sources = new (string Name, string Source)[]
{
("fullscreen", MslFixedShaders.CreateFullscreenVertex(3)),
("copy", MslFixedShaders.CreateCopyFragment()),
("present", MslFixedShaders.CreatePresentFragment()),
("solid", MslFixedShaders.CreateSolidFragment(0.25f, 0.5f, 0.75f, 1f)),
("attribute", MslFixedShaders.CreateAttributeFragment(1)),
("depth-only", MslFixedShaders.CreateDepthOnlyFragment()),
};
foreach (var (name, source) in sources)
{
Assert.True(
MetalNative.TryCompileLibrary(source, out _, out var error),
$"[{name}] Metal rejected the fixed shader: {error}\n{source}");
}
}
[Fact]
public void LdsRoundTripExecutesThroughThreadgroupMemory()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
var result = ExecuteOrThrow(Gen5ComputeFixtures.Lds, new byte[16]);
// Written to LDS, barriered, read back, stored to the buffer.
Assert.Equal(0x1234u, ReadDword(result, 0));
}
[Fact]
public void Wave64CrossLaneEmitsValidMsl()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
// A wave64 program with a cross-lane op emits the threadgroup-scratch
// bridge and its barriers; the runtime Metal compiler must accept it.
var shader = Gen5ComputeFixtures.CompileOrThrow(
Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64);
Assert.Contains("sharpemu_wave_scratch", shader.Source);
Assert.Contains("threadgroup_barrier", shader.Source);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"Metal rejected the wave64 MSL: {error}\n{shader.Source}");
}
[Fact]
public void Wave64BroadcastExecutesAcrossBothHalvesWithoutDeadlock()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
// 64 threads = one guest wave (two 32-wide simdgroups). The read-first-
// lane bridge takes a threadgroup_barrier reached by all 64 lanes; if
// the two halves did not rendezvous this would deadlock (the command
// buffer would never complete). Every lane holds 42, so the broadcast
// result is 42 — proving the bridge runs to completion and returns the
// published value.
var shader = Gen5ComputeFixtures.CompileOrThrow(
Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError),
$"{compileError}\n{shader.Source}");
var buffer = new byte[16];
var uniforms = new byte[16 + sizeof(uint)];
// Dispatch limit 64: all 64 lanes active (see WriteDispatchLimit contract).
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 64);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length);
Assert.True(
MetalNative.TryExecuteThreadgroup(
library,
shader.EntryPoint,
buffer,
uniforms,
dataIndex: 0,
uniformsIndex: (nuint)shader.GlobalMemoryBindings.Count,
threadsPerThreadgroup: 64,
out var result,
out var runError),
runError);
Assert.Equal(42u, ReadDword(result, 0));
}
private static byte[] ExecuteOrThrow(Gen5ComputeFixture fixture, byte[] buffer)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
if (!MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError))
{
throw new InvalidOperationException($"[{fixture.Name}] {compileError}\n{shader.Source}");
}
// Per the translator contract, global buffers occupy indices
// 0..count-1 and SharpEmuUniforms sits at index count; the executable
// fixtures bind exactly one data buffer.
var bufferCount = shader.GlobalMemoryBindings.Count;
Assert.Equal(1, bufferCount);
// SharpEmuUniforms: dispatch limit (one thread), reserved, then the
// byte length of each bound buffer (the struct's array never has fewer
// than one entry).
var uniforms = new byte[16 + (Math.Max(bufferCount, 1) * sizeof(uint))];
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length);
if (!MetalNative.TryExecuteSingleThread(
library,
shader.EntryPoint,
buffer,
uniforms,
dataIndex: 0,
uniformsIndex: (nuint)bufferCount,
out var result,
out var runError))
{
throw new InvalidOperationException($"[{fixture.Name}] {runError}");
}
return result;
}
private static uint ReadDword(byte[] buffer, int offset) =>
BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(offset));
}
@@ -1,88 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Xunit;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Pins the emitted MSL for the synthetic fixtures. Codegen changes show up as a
/// readable text diff instead of a runtime mystery. Regenerate with
/// SHARPEMU_UPDATE_GOLDENS=1 (writes into the source tree) after intentional
/// emitter changes, then review the diff like any other code change.
/// </summary>
public sealed class MslGoldenTests
{
public static TheoryData<string> FixtureNames()
{
var data = new TheoryData<string>();
foreach (var fixture in Gen5ComputeFixtures.All)
{
data.Add(fixture.Name);
}
return data;
}
[Fact]
public void PixelShaderMatchesGolden()
{
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
AssertMatchesGolden("pixel", shader.Source);
}
[Fact]
public void VertexShaderMatchesGolden()
{
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 1);
AssertMatchesGolden("vertex", shader.Source);
}
[Theory]
[MemberData(nameof(FixtureNames))]
public void EmittedMslMatchesGolden(string name)
{
Gen5ComputeFixture? fixture = null;
foreach (var candidate in Gen5ComputeFixtures.All)
{
if (candidate.Name == name)
{
fixture = candidate;
break;
}
}
Assert.NotNull(fixture);
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
AssertMatchesGolden(name, shader.Source);
}
private static void AssertMatchesGolden(string name, string source)
{
var goldenPath = Path.Combine(AppContext.BaseDirectory, "Goldens", $"{name}.msl");
if (Environment.GetEnvironmentVariable("SHARPEMU_UPDATE_GOLDENS") == "1")
{
var sourcePath = Path.Combine(
FindSourceDirectory(),
"Goldens",
$"{name}.msl");
Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!);
File.WriteAllText(sourcePath, source);
return;
}
Assert.True(File.Exists(goldenPath), $"missing golden {goldenPath}; run with SHARPEMU_UPDATE_GOLDENS=1 to create it");
var expected = File.ReadAllText(goldenPath).ReplaceLineEndings();
Assert.Equal(expected, source.ReplaceLineEndings());
}
private static string FindSourceDirectory([System.Runtime.CompilerServices.CallerFilePath] string sourcePath = "")
{
// Binaries land in artifacts/bin (outside the project directory), so
// walking up from the test binary never finds the csproj; the compiler
// records this file's source path instead.
return Path.GetDirectoryName(sourcePath)
?? throw new InvalidOperationException("test project directory not found");
}
}
@@ -1,163 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Xunit;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Structural checks over the emitted MSL — these run on every platform because
/// translation is pure text generation; only the runtime tests need a Metal device.
/// </summary>
public sealed class MslTranslationTests
{
[Fact]
public void EveryFixtureTranslates()
{
foreach (var fixture in Gen5ComputeFixtures.All)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
Assert.Equal(Gen5MslStage.Compute, shader.Stage);
Assert.Equal("gen5_cs", shader.EntryPoint);
Assert.Contains("kernel void gen5_cs(", shader.Source, StringComparison.Ordinal);
Assert.Contains("while (active)", shader.Source, StringComparison.Ordinal);
}
}
[Fact]
public void ExecMaskedStoresAreGuarded()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.ExecStore);
// Every buffer store must sit behind the per-lane EXEC guard.
Assert.Contains("if (exec)", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_store_bytes(b0,", shader.Source, StringComparison.Ordinal);
// s_mov_b32 exec_lo, 0 / -1 must drive the per-lane bool.
Assert.Contains("exec = ((", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void LoopFixtureProducesMultipleDispatcherBlocks()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.Loop);
// The backward branch splits the program into at least three blocks and
// the conditional branch selects between loop head and fallthrough.
Assert.Contains("case 0u:", shader.Source, StringComparison.Ordinal);
Assert.Contains("case 1u:", shader.Source, StringComparison.Ordinal);
Assert.Contains("case 2u:", shader.Source, StringComparison.Ordinal);
Assert.Contains("pc = (scc) ?", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void DispatcherIsBoundedByDefault()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.Fmac);
Assert.Contains("if (++steps >=", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void UniformsCarryDispatchLimitAndBufferLengths()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.ExecStore);
Assert.Contains("struct SharpEmuUniforms", shader.Source, StringComparison.Ordinal);
Assert.Contains("dispatch_limit_x", shader.Source, StringComparison.Ordinal);
Assert.Contains("buffer_bytes[", shader.Source, StringComparison.Ordinal);
// One global binding: b0 at [[buffer(0)]], uniforms at [[buffer(1)]].
Assert.Contains("device uint* b0 [[buffer(0)]]", shader.Source, StringComparison.Ordinal);
Assert.Contains("[[buffer(1)]]", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void PixelStageEmitsFragmentInterface()
{
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
Assert.Equal(Gen5MslStage.Pixel, shader.Stage);
Assert.Equal("gen5_ps", shader.EntryPoint);
Assert.Equal(1u, shader.AttributeCount);
Assert.Contains("fragment Gen5PsOut gen5_ps(", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 attr0 [[user(locn0)]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("[[color(0)]]", shader.Source, StringComparison.Ordinal);
Assert.Contains("[[position]]", shader.Source, StringComparison.Ordinal);
// Interpolation reads land in VGPRs; the export writes MRT0 under EXEC
// and inactive lanes discard at the end.
Assert.Contains("as_type<uint>(sharpemu_in.attr0[0])", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_out.mrt0 = exec ?", shader.Source, StringComparison.Ordinal);
Assert.Contains("discard_fragment();", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void PixelOutputKindsSelectTheAttachmentType()
{
var uintShader = Gen5ComputeFixtures.CompilePixelOrThrow(Gen5PixelOutputKind.Uint);
Assert.Contains("uint4 mrt0 [[color(0)]];", uintShader.Source, StringComparison.Ordinal);
var sintShader = Gen5ComputeFixtures.CompilePixelOrThrow(Gen5PixelOutputKind.Sint);
Assert.Contains("int4 mrt0 [[color(0)]];", sintShader.Source, StringComparison.Ordinal);
}
[Fact]
public void VertexStageEmitsVertexInterface()
{
var shader = Gen5ComputeFixtures.CompileVertexOrThrow();
Assert.Equal(Gen5MslStage.Vertex, shader.Stage);
Assert.Equal("gen5_vs", shader.EntryPoint);
Assert.Equal(1u, shader.AttributeCount);
Assert.Contains("vertex Gen5VsOut gen5_vs(", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 sharpemu_position [[position]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 param0 [[user(locn0)]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("uint sharpemu_vertex_id [[vertex_id]],", shader.Source, StringComparison.Ordinal);
Assert.Contains("v[5] = sharpemu_vertex_id;", shader.Source, StringComparison.Ordinal);
Assert.Contains("v[8] = sharpemu_instance_id;", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_out.sharpemu_position = exec ?", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_out.param0 = exec ?", shader.Source, StringComparison.Ordinal);
Assert.Contains("return sharpemu_out;", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void RequiredVertexOutputsAreZeroFilledDeclarations()
{
// The paired fragment shader reads locations 0..2; the program only
// exports param0, so 1 and 2 must still be declared (zero-filled).
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 3);
Assert.Equal(3u, shader.AttributeCount);
Assert.Contains("float4 param1 [[user(locn1)]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 param2 [[user(locn2)]];", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void FixedShadersCoverThePresenterSurface()
{
var fullscreen = MslFixedShaders.CreateFullscreenVertex(2);
Assert.Contains("vertex FullscreenOut fullscreen_vs(", fullscreen, StringComparison.Ordinal);
Assert.Contains("float4 attr1 [[user(locn1)]];", fullscreen, StringComparison.Ordinal);
Assert.Contains("tex0.sample(smp0, in.attr0.xy)", MslFixedShaders.CreateCopyFragment(), StringComparison.Ordinal);
Assert.Contains("float4(1.0f, 0.0f, 1.0f, 1.0f)", MslFixedShaders.CreateSolidFragment(1f, 0f, 1f, 1f), StringComparison.Ordinal);
Assert.Contains("return in.attr3;", MslFixedShaders.CreateAttributeFragment(3), StringComparison.Ordinal);
Assert.Contains("fragment void depth_only_fs()", MslFixedShaders.CreateDepthOnlyFragment(), StringComparison.Ordinal);
}
[Fact]
public void UnsupportedOpcodeFailsLoudlyWithPc()
{
// v_cubeid_f32 is real but outside the phase-1 ALU set: the translator
// must name the opcode and pc instead of emitting wrong code.
var fixture = new Gen5ComputeFixture(
"unsupported",
[
0xD5C40000, 0x04060501, // v_cubeid_f32 v0, v1, v2, v3
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 0,
StoreBackingBytes: 0);
var exception = Assert.Throws<InvalidOperationException>(
() => Gen5ComputeFixtures.CompileOrThrow(fixture));
Assert.Contains("pc=0x", exception.Message, StringComparison.Ordinal);
}
}
@@ -1,32 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- The Metal runtime harness pins buffers and uses source-generated interop. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- Deliberately self-contained: this project is the model for how per-backend shader
codegen gets tested, so it depends only on the codegen under test (which brings
the shared decoder and HLE contract transitively) — never on other test projects
or the CLI tools. Helpers that exist elsewhere are copied, not referenced. -->
<ItemGroup>
<ProjectReference Include="..\..\src\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<None Include="Goldens\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>