From 0a461f8d6925c141b222e19bbf6bee2a71ed9e83 Mon Sep 17 00:00:00 2001
From: Mathias <06mathias6@gmail.com>
Date: Fri, 11 Sep 2026 12:37:31 +0200
Subject: [PATCH 1/2] fix: improve Direct Execution guest runtime and AGC
compatibility (#880)
* test(cpu): verify VEH thunk preserves nonvolatile registers
* fix(memory): commit complete native guest mappings
* fix(cpu): treat time polling as import-loop progress
* fix(cli): preserve DLL path in mitigation relaunch
* refactor(cpu): remove obsolete inflate recovery hack
* fix(agc): accept shaders without embedded program registers
---
src/SharpEmu.CLI/Program.cs | 13 +-
.../DirectExecutionBackend.Exceptions.cs | 178 ------------------
.../Native/DirectExecutionBackend.Imports.cs | 8 +-
.../Memory/PhysicalVirtualMemory.cs | 19 ++
src/SharpEmu.HLE/IGuestAddressSpace.cs | 7 +
src/SharpEmu.Libs/Agc/AgcExports.cs | 34 ++--
.../Kernel/KernelMemoryCompatExports.cs | 6 +
.../Agc/AgcCreateShaderTests.cs | 167 ++++++++++++++++
.../Cpu/ImportLoopGuardBoundaryTests.cs | 26 +++
.../Cpu/VehTrampolineTests.cs | 91 +++++++++
.../Memory/PhysicalVirtualMemoryTests.cs | 19 ++
11 files changed, 365 insertions(+), 203 deletions(-)
create mode 100644 tests/SharpEmu.Libs.Tests/Agc/AgcCreateShaderTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/Cpu/ImportLoopGuardBoundaryTests.cs
diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index ceb17ee..7200085 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -520,7 +520,18 @@ internal static partial class Program
return false;
}
- string[] childArgs = [MitigatedChildFlag, .. args];
+ string[] childArgs;
+ var commandLineArgs = Environment.GetCommandLineArgs();
+ var entryAssembly = commandLineArgs.Length != 0 ? commandLineArgs[0] : null;
+ if (Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase) &&
+ !string.IsNullOrWhiteSpace(entryAssembly))
+ {
+ childArgs = [entryAssembly, MitigatedChildFlag, .. args];
+ }
+ else
+ {
+ childArgs = [MitigatedChildFlag, .. args];
+ }
var commandLine = BuildCommandLine(processPath, childArgs);
var startupInfoEx = new STARTUPINFOEX();
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
index ffa7b9a..656f53f 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
@@ -148,11 +148,6 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
- if (exceptionCode == 3221225477u &&
- TryRecoverInflateFastEpilogue(exceptionRecord, contextRecord, rip, rsp))
- {
- return -1;
- }
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverIllegalInstruction(contextRecord, rip))
{
@@ -676,179 +671,6 @@ public sealed partial class DirectExecutionBackend
return true;
}
- private unsafe bool TryRecoverInflateFastEpilogue(EXCEPTION_RECORD* exceptionRecord, void* contextRecord, ulong rip, ulong rsp)
- {
- // Only apply this compat recovery for Ghostrunner (PPSA03685)
- var currentTitleId = CurrentTitleId ?? SharpEmu.Libs.VideoOut.VideoOutExports.GetApplicationTitleId();
- if (!string.Equals(currentTitleId, "PPSA03685", StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- const ulong epilogueStoreRip = 0x0000000804A2C23EuL;
- const ulong epilogueStateStoreRip = 0x0000000804A2C25AuL;
-
- if (rip != epilogueStoreRip && rip != epilogueStateStoreRip)
- {
- return false;
- }
-
- // Check if this is the state fault at 0x804A2C25A (mov [rcx + 0x48], rax)
- if (rip == epilogueStateStoreRip)
- {
- ulong rcxFault = ReadCtxU64(contextRecord, CTX_RCX);
- if (rcxFault >= 0x10000UL && exceptionRecord->ExceptionInformation[1] != 0x48)
- {
- return false;
- }
-
- Console.Error.WriteLine("[LOADER][INFO] >>> INFLATE_FAST EPILOGUE STATE RECOVERY TRIGGERED AT 0x804A2C25A <<<");
-
- // Recover strm from rdi or caller frame
- ulong strm = ReadCtxU64(contextRecord, CTX_RDI);
- if (strm < 0x10000UL)
- {
- if (TryReadQword(rsp + 0x108, out ulong candidate) && candidate >= 0x10000)
- {
- strm = candidate;
- }
- else if (TryReadQword(rsp + 0x80, out ulong savedRbp))
- {
- if (TryReadQword((ulong)((long)savedRbp - 0x58), out ulong candidate2) && candidate2 >= 0x10000)
- {
- strm = candidate2;
- }
- else if (TryReadQword(savedRbp + 0x20, out ulong candidate3) && candidate3 >= 0x10000)
- {
- strm = candidate3;
- }
- }
- }
-
- if (strm < 0x10000UL)
- {
- Console.Error.WriteLine("[LOADER][ERROR] Could not locate strm for state recovery at 0x804A2C25A!");
- return false;
- }
-
- ulong state = *(ulong*)(strm + 0x38);
- if (state < 0x10000UL)
- {
- state = *(ulong*)(strm + 0x28);
- }
-
- if (state < 0x10000UL)
- {
- Console.Error.WriteLine($"[LOADER][ERROR] Invalid state pointer from strm (0x{strm:X16}): 0x{state:X16}");
- return false;
- }
-
- ulong raxVal = ReadCtxU64(contextRecord, CTX_RAX);
- ulong r8Val = ReadCtxU64(contextRecord, CTX_R8);
-
- *(ulong*)(state + 0x48) = raxVal;
- *(uint*)(state + 0x50) = (uint)r8Val;
-
- WriteCtxU64(contextRecord, CTX_RCX, state);
- WriteCtxU64(contextRecord, CTX_RIP, 0x0000000804A2C262uL);
-
- Console.Error.WriteLine($"[LOADER][INFO] Recovered state: 0x{state:X16}");
- Console.Error.WriteLine($"[LOADER][INFO] Executed state->hold = 0x{raxVal:X16}, state->bits = {r8Val & 0xFF}");
- Console.Error.WriteLine("[LOADER][INFO] >>> INFLATE_FAST EPILOGUE STATE RECOVERY COMPLETED - RESUMING <<<");
- Console.Error.Flush();
- return true;
- }
-
- // Otherwise, fault at 0x804A2C23E
- ulong rdiVal = ReadCtxU64(contextRecord, CTX_RDI);
- if (rdiVal != 0)
- {
- return false;
- }
-
- Console.Error.WriteLine("[LOADER][INFO] >>> INFLATE_FAST EPILOGUE RECOVERY TRIGGERED AT 0x804A2C23E <<<");
-
- // 1. Recover strm from caller frame:
- // In inflate_fast frame:
- // [rsp + 0x80] is caller's saved RBP
- // [rsp + 0x108] holds strm (0x00007FFFF01FF470)
- ulong strmVal = 0;
- if (TryReadQword(rsp + 0x108, out ulong cand) && cand >= 0x10000)
- {
- strmVal = cand;
- }
- if (strmVal == 0 && TryReadQword(rsp + 0x80, out ulong savedRbpVal))
- {
- if (TryReadQword((ulong)((long)savedRbpVal - 0x58), out ulong cand2) && cand2 >= 0x10000)
- {
- strmVal = cand2;
- }
- else if (TryReadQword(savedRbpVal + 0x20, out ulong cand3) && cand3 >= 0x10000)
- {
- strmVal = cand3;
- }
- }
-
- if (strmVal == 0)
- {
- Console.Error.WriteLine("[LOADER][ERROR] Could not locate strm in stack frame for inflate_fast recovery!");
- return false;
- }
-
- Console.Error.WriteLine($"[LOADER][INFO] Recovered strm: 0x{strmVal:X16}");
-
- // 2. Set rdi = strm in CPU context
- WriteCtxU64(contextRecord, CTX_RDI, strmVal);
-
- // 3. If r10 is invalid or underflowed, recover actual input position
- ulong r10Val = ReadCtxU64(contextRecord, CTX_R10);
- bool isInvalidInPointer = (r10Val < 0x10000UL) || (r10Val > 0x00007FFFFFFFFFFEUL);
- if (isInvalidInPointer)
- {
- ulong inEnd = 0;
- if (TryReadQword(rsp, out inEnd) && inEnd >= 0x10000)
- {
- ulong raxVal = ReadCtxU64(contextRecord, CTX_RAX);
- ulong unusedBytes = raxVal & 0xFFFFFFFFUL; // bits >> 3
- ulong recoveredNextIn = inEnd - unusedBytes + 1;
- r10Val = recoveredNextIn - 1;
- WriteCtxU64(contextRecord, CTX_R10, r10Val);
- WriteCtxU64(contextRecord, CTX_RDX, recoveredNextIn);
- Console.Error.WriteLine($"[LOADER][INFO] Recovered r10 (in): 0x{r10Val:X16} (in_end=0x{inEnd:X16}, unused={unusedBytes})");
- }
- }
-
- // 4. Recover rcx (strm->state) if needed so subsequent stores at 0x804A2C25A succeed
- ulong rcxVal = ReadCtxU64(contextRecord, CTX_RCX);
- if (rcxVal < 0x10000UL)
- {
- ulong state = *(ulong*)(strmVal + 0x38);
- if (state < 0x10000UL)
- {
- state = *(ulong*)(strmVal + 0x28);
- }
- if (state >= 0x10000UL)
- {
- WriteCtxU64(contextRecord, CTX_RCX, state);
- Console.Error.WriteLine($"[LOADER][INFO] Recovered rcx (strm->state): 0x{state:X16}");
- }
- }
-
- // 5. Perform the stores directly to ensure stream consistency
- ulong rdxVal = ReadCtxU64(contextRecord, CTX_RDX);
- ulong r9Val = ReadCtxU64(contextRecord, CTX_R9);
-
- *(ulong*)strmVal = rdxVal;
- *(ulong*)(strmVal + 0x18) = r9Val + 1;
-
- Console.Error.WriteLine($"[LOADER][INFO] Executed strm->next_in = 0x{rdxVal:X16}");
- Console.Error.WriteLine($"[LOADER][INFO] Executed strm->next_out = 0x{r9Val + 1:X16}");
- Console.Error.WriteLine("[LOADER][INFO] >>> INFLATE_FAST EPILOGUE RECOVERY COMPLETED - RESUMING <<<");
- Console.Error.Flush();
-
- return true;
- }
-
private static bool IsBenignHostDebugException(uint exceptionCode)
{
return exceptionCode is DBG_PRINTEXCEPTION_C or DBG_PRINTEXCEPTION_WIDE_C or MS_VC_THREADNAME_EXCEPTION;
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
index b5a4b8d..984498a 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
@@ -1880,13 +1880,17 @@ public sealed partial class DirectExecutionBackend
return elapsedTicks >= (long)(_importLoopGuardSeconds * Stopwatch.Frequency);
}
- private static bool IsImportLoopGuardBoundary(string nid) =>
+ internal static bool IsImportLoopGuardBoundary(string nid) =>
nid is
"1jfXLRVzisc" or // sceKernelUsleep
"WKAXJ4XBPQ4" or // scePthreadCondWait
"BmMjYxmew1w" or // scePthreadCondTimedwait
"Op8TBGY5KHg" or // pthread_cond_wait
- "27bAgiJmOh0"; // pthread_cond_timedwait
+ "27bAgiJmOh0" or // pthread_cond_timedwait
+ "n88vx3C5nW8" or // gettimeofday
+ "-2IRUCO--PM" or // clock_gettime
+ "0V5nU-Z6t4U" or // sceKernelGetProcessTime
+ "aI6lQW5v57k"; // sceKernelGetProcessTimeCounter
private void ResetImportLoopPattern()
{
diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
index 89c3cb9..f8227e9 100644
--- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
+++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
@@ -982,6 +982,25 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return _hostMemory.Protect(address, size, ResolveProtection(protection), out _);
}
+ public bool TryCommitRange(ulong address, ulong size)
+ {
+ if (size == 0 || ulong.MaxValue - address < size)
+ {
+ return false;
+ }
+
+ _gate.EnterReadLock();
+ try
+ {
+ var region = FindRegion(address, size);
+ return region is not null && EnsureRangeCommitted(address, size, region);
+ }
+ finally
+ {
+ _gate.ExitReadLock();
+ }
+ }
+
// Reproduces the decomposition KernelMemoryCompatExports.ResolveHostProtection
// performed before this seam existed; the Windows backend maps each case back
// to the identical PAGE_* value.
diff --git a/src/SharpEmu.HLE/IGuestAddressSpace.cs b/src/SharpEmu.HLE/IGuestAddressSpace.cs
index 53626dd..1b3d09a 100644
--- a/src/SharpEmu.HLE/IGuestAddressSpace.cs
+++ b/src/SharpEmu.HLE/IGuestAddressSpace.cs
@@ -26,6 +26,13 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
///
bool TryBackFixedRange(ulong address, ulong size, bool executable);
+ ///
+ /// Ensures every page in an existing guest mapping is committed for native
+ /// execution. This is required before returning a raw identity-mapped range
+ /// to guest code, which cannot pass through ICpuMemory on each access.
+ ///
+ bool TryCommitRange(ulong address, ulong size);
+
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index 9278fc3..89f4976 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -14590,7 +14590,12 @@ GuestImageWriteTracker.Track(
if (shRegistersAddress == 0 || registerCount < 2)
{
- return false;
+ TraceCreateShader(
+ 0,
+ headerAddress,
+ codeAddress,
+ $"skip-pgm-patch type={shaderType} shRegs=0x{shRegistersAddress:X16} regCount={registerCount}");
+ return true;
}
// Type bytes follow the Prospero half/fused enum used by fuse-shader
@@ -14619,9 +14624,10 @@ GuestImageWriteTracker.Track(
_ => 0u,
};
- // GTA V Enhanced hull shaders (type 5) put RSRC1/RSRC2 (0x10A/0x10B) at
- // the front of the SH default table; PGM_LO/HI sit elsewhere (or are
- // filled later via SetShRegisterDirect).
+ // Some shader headers (e.g. GTA V Enhanced hull shaders, GS front shaders,
+ // or UE4 mesh/task/vertex shaders where type=8 or user data registers are placed first)
+ // omit PGM_LO/HI from the default SH table. Still succeed: the code VA
+ // lives at ShaderCodeOffset and later binder paths republish it.
if (!TryFindShaderProgramRegisterPair(
ctx,
shRegistersAddress,
@@ -14634,28 +14640,12 @@ GuestImageWriteTracker.Track(
out var foundHi))
{
TryReadUInt32(ctx, shRegistersAddress, out var firstLo);
- // GTA V Enhanced HS headers start at RSRC1/RSRC2 (0x10A/0x10B) and
- // omit PGM_LO/HI from the default table. Still succeed: the code VA
- // lives at ShaderCodeOffset and later binder paths republish it.
- // GS front headers can likewise start at RSRC1_GS (0x8A) instead of
- // PGM_LO_GS (0x88) - same deal, skip the patch here.
- if ((shaderType == HsFrontShaderType && firstLo is SpiShaderPgmRsrc1Hs or SpiShaderPgmLoHs) ||
- (shaderType == GsFrontShaderType && firstLo is SpiShaderPgmRsrc1Gs or SpiShaderPgmLoGs))
- {
- TraceCreateShader(
- 0,
- headerAddress,
- codeAddress,
- $"skip-pgm-patch type={shaderType} first_lo=0x{firstLo:X8}");
- return true;
- }
-
TraceCreateShader(
0,
headerAddress,
codeAddress,
- $"unexpected-registers type={shaderType} expected_lo=0x{expectedLo:X8} first_lo=0x{firstLo:X8}");
- return false;
+ $"skip-pgm-patch type={shaderType} expected_lo=0x{expectedLo:X8} first_lo=0x{firstLo:X8}");
+ return true;
}
var loValue = (uint)((codeAddress >> 8) & 0xFFFF_FFFFUL);
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index 24472dd..ebc6d03 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -4919,6 +4919,12 @@ public static partial class KernelMemoryCompatExports
return false;
}
+ if (KernelVirtualRangeAllocator.TryResolveAddressSpace(ctx.Memory, out var addressSpace) &&
+ addressSpace.TryCommitRange(address, length))
+ {
+ return true;
+ }
+
Span probe = stackalloc byte[1];
return ctx.Memory.TryRead(address, probe) &&
ctx.Memory.TryRead(address + length - 1, probe);
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcCreateShaderTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcCreateShaderTests.cs
new file mode 100644
index 0000000..19dd59b
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcCreateShaderTests.cs
@@ -0,0 +1,167 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Agc;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+public sealed class AgcCreateShaderTests
+{
+ private const ulong BaseAddress = 0x3_0000_0000;
+ private const int MemorySize = 0x4000;
+
+ private const ulong DestinationAddress = BaseAddress + 0x0000;
+ private const ulong HeaderAddress = BaseAddress + 0x0100;
+ private const ulong CodeAddress = BaseAddress + 0x1000;
+ private const ulong RegistersAddress = BaseAddress + 0x0500;
+
+ private const uint ShaderFileHeader = 0x34333231;
+ private const uint ShaderVersion = 0x18;
+
+ private const ulong ShaderCodeOffset = 0x10;
+ private const ulong ShaderShRegistersOffset = 0x20;
+ private const ulong ShaderTypeOffset = 0x5A;
+ private const ulong ShaderNumShRegistersOffset = 0x5C;
+
+ private const uint SpiShaderPgmLoPs = 0x8;
+ private const uint SpiShaderPgmHiPs = 0x9;
+
+ [Fact]
+ public void CreateShader_StandardPsShader_PatchesPgmLoHi()
+ {
+ var (memory, ctx) = SetupContext();
+ SetupHeader(memory, shaderType: 1, registerCount: 2);
+
+ // Put PS PGM_LO and PGM_HI registers in the table
+ WriteUInt32(memory, RegistersAddress + 0, SpiShaderPgmLoPs);
+ WriteUInt32(memory, RegistersAddress + 4, 0);
+ WriteUInt32(memory, RegistersAddress + 8, SpiShaderPgmHiPs);
+ WriteUInt32(memory, RegistersAddress + 12, 0);
+
+ ctx[CpuRegister.Rdi] = DestinationAddress;
+ ctx[CpuRegister.Rsi] = HeaderAddress;
+ ctx[CpuRegister.Rdx] = CodeAddress;
+
+ var result = AgcExports.CreateShader(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(HeaderAddress, ReadUInt64(memory, DestinationAddress));
+ Assert.Equal(CodeAddress, ReadUInt64(memory, HeaderAddress + ShaderCodeOffset));
+
+ var expectedLo = (uint)((CodeAddress >> 8) & 0xFFFF_FFFFUL);
+ var expectedHi = (uint)((CodeAddress >> 40) & 0xFFUL);
+ Assert.Equal(expectedLo, ReadUInt32(memory, RegistersAddress + 4));
+ Assert.Equal(expectedHi, ReadUInt32(memory, RegistersAddress + 12));
+ }
+
+ [Fact]
+ public void CreateShader_ShaderType8WithoutPgmRegisters_SucceedsAndSkipsPatch()
+ {
+ var (memory, ctx) = SetupContext();
+ SetupHeader(memory, shaderType: 8, registerCount: 2);
+
+ // Registers are user data registers (e.g. 0x0 and 0x1), not PGM_LO
+ WriteUInt32(memory, RegistersAddress + 0, 0x0);
+ WriteUInt32(memory, RegistersAddress + 4, 0x1111);
+ WriteUInt32(memory, RegistersAddress + 8, 0x1);
+ WriteUInt32(memory, RegistersAddress + 12, 0x2222);
+
+ ctx[CpuRegister.Rdi] = DestinationAddress;
+ ctx[CpuRegister.Rsi] = HeaderAddress;
+ ctx[CpuRegister.Rdx] = CodeAddress;
+
+ var result = AgcExports.CreateShader(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(HeaderAddress, ReadUInt64(memory, DestinationAddress));
+ Assert.Equal(CodeAddress, ReadUInt64(memory, HeaderAddress + ShaderCodeOffset));
+ // Registers table is unchanged
+ Assert.Equal(0x1111u, ReadUInt32(memory, RegistersAddress + 4));
+ Assert.Equal(0x2222u, ReadUInt32(memory, RegistersAddress + 12));
+ }
+
+ [Fact]
+ public void CreateShader_ZeroRegisters_Succeeds()
+ {
+ var (memory, ctx) = SetupContext();
+ SetupHeader(memory, shaderType: 8, registerCount: 0);
+
+ ctx[CpuRegister.Rdi] = DestinationAddress;
+ ctx[CpuRegister.Rsi] = HeaderAddress;
+ ctx[CpuRegister.Rdx] = CodeAddress;
+
+ var result = AgcExports.CreateShader(ctx);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(HeaderAddress, ReadUInt64(memory, DestinationAddress));
+ Assert.Equal(CodeAddress, ReadUInt64(memory, HeaderAddress + ShaderCodeOffset));
+ }
+
+ private static (FakeCpuMemory Memory, CpuContext Ctx) SetupContext()
+ {
+ var memory = new FakeCpuMemory(BaseAddress, MemorySize);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+ return (memory, ctx);
+ }
+
+ private static void SetupHeader(FakeCpuMemory memory, byte shaderType, byte registerCount)
+ {
+ WriteUInt32(memory, HeaderAddress + 0, ShaderFileHeader);
+ WriteUInt32(memory, HeaderAddress + 4, ShaderVersion);
+
+ // Pointers are relative to their field offset
+ if (registerCount > 0)
+ {
+ var relativeRegisters = RegistersAddress - (HeaderAddress + ShaderShRegistersOffset);
+ WriteUInt64(memory, HeaderAddress + ShaderShRegistersOffset, relativeRegisters);
+ }
+ else
+ {
+ WriteUInt64(memory, HeaderAddress + ShaderShRegistersOffset, 0);
+ }
+
+ WriteByte(memory, HeaderAddress + ShaderTypeOffset, shaderType);
+ WriteByte(memory, HeaderAddress + ShaderNumShRegistersOffset, registerCount);
+ }
+
+ private static byte ReadByte(FakeCpuMemory memory, ulong address)
+ {
+ Span buf = stackalloc byte[1];
+ Assert.True(memory.TryRead(address, buf));
+ return buf[0];
+ }
+
+ private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
+ {
+ Span buf = stackalloc byte[sizeof(uint)];
+ Assert.True(memory.TryRead(address, buf));
+ return BinaryPrimitives.ReadUInt32LittleEndian(buf);
+ }
+
+ private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
+ {
+ Span buf = stackalloc byte[sizeof(ulong)];
+ Assert.True(memory.TryRead(address, buf));
+ return BinaryPrimitives.ReadUInt64LittleEndian(buf);
+ }
+
+ private static void WriteByte(FakeCpuMemory memory, ulong address, byte value) =>
+ Assert.True(memory.TryWrite(address, [value]));
+
+ private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
+ {
+ Span buf = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(buf, value);
+ Assert.True(memory.TryWrite(address, buf));
+ }
+
+ private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
+ {
+ Span buf = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(buf, value);
+ Assert.True(memory.TryWrite(address, buf));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Cpu/ImportLoopGuardBoundaryTests.cs b/tests/SharpEmu.Libs.Tests/Cpu/ImportLoopGuardBoundaryTests.cs
new file mode 100644
index 0000000..42c4e71
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Cpu/ImportLoopGuardBoundaryTests.cs
@@ -0,0 +1,26 @@
+// 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.Cpu;
+
+public sealed class ImportLoopGuardBoundaryTests
+{
+ [Theory]
+ [InlineData("n88vx3C5nW8")] // gettimeofday
+ [InlineData("-2IRUCO--PM")] // clock_gettime
+ [InlineData("0V5nU-Z6t4U")] // sceKernelGetProcessTime
+ [InlineData("aI6lQW5v57k")] // sceKernelGetProcessTimeCounter
+ public void TimeObservationResetsRepeatingImportPattern(string nid)
+ {
+ Assert.True(DirectExecutionBackend.IsImportLoopGuardBoundary(nid));
+ }
+
+ [Fact]
+ public void OrdinaryImportDoesNotResetRepeatingImportPattern()
+ {
+ Assert.False(DirectExecutionBackend.IsImportLoopGuardBoundary("ordinary-import"));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Cpu/VehTrampolineTests.cs b/tests/SharpEmu.Libs.Tests/Cpu/VehTrampolineTests.cs
index 35ca8f4..40f6db1 100644
--- a/tests/SharpEmu.Libs.Tests/Cpu/VehTrampolineTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Cpu/VehTrampolineTests.cs
@@ -91,6 +91,47 @@ public sealed class VehTrampolineTests
}
}
+ [Fact]
+ public unsafe void ManagedCallbackPreservesWin64NonvolatileRegisters()
+ {
+ if (!Supported) return;
+
+ using var backend = new DirectExecutionBackend(new ModuleManager());
+ Handler handler = _ =>
+ {
+ // Force ordinary managed work and volatile-register traffic while
+ // the generated thunk is active. The wrapper below checks the ABI
+ // registers after this reverse P/Invoke returns.
+ var text = string.Concat("VEH-", Environment.CurrentManagedThreadId, "-", DateTime.UtcNow.Ticks);
+ GC.KeepAlive(text);
+ return -1;
+ };
+ var thunk = backend.CreateExceptionHandlerTrampoline(Marshal.GetFunctionPointerForDelegate(handler));
+ Assert.NotEqual(0, thunk);
+ var wrapper = CreateRegisterProbe(thunk);
+ var output = stackalloc ulong[8];
+ var exceptionInfo = CreateExceptionInfo(0xC000001D);
+ try
+ {
+ var result = ((delegate* unmanaged)wrapper)(exceptionInfo, (nint)output);
+ Assert.Equal(-1, result);
+ Assert.Equal(0x1111111111111111UL, output[0]); // rbx
+ Assert.Equal(0x2222222222222222UL, output[1]); // rbp
+ Assert.Equal(0x3333333333333333UL, output[2]); // rsi
+ Assert.Equal(0x4444444444444444UL, output[3]); // rdi
+ Assert.Equal(0x5555555555555555UL, output[4]); // r12
+ Assert.Equal(0x6666666666666666UL, output[5]); // r13
+ Assert.Equal(0x7777777777777777UL, output[6]); // r14
+ Assert.Equal(0x8888888888888888UL, output[7]); // r15
+ }
+ finally
+ {
+ NativeMemory.Free((void*)exceptionInfo);
+ HostMemory.Free((void*)wrapper, 0, HostMemory.MEM_RELEASE);
+ GC.KeepAlive(handler);
+ }
+ }
+
private static Task StartCallback(nint thunk) => Task.Factory.StartNew(
() => Invoke(thunk, 0xC000001D), CancellationToken.None,
TaskCreationOptions.LongRunning, TaskScheduler.Default);
@@ -109,4 +150,54 @@ public sealed class VehTrampolineTests
pointers[1] = (nint)context;
return ((delegate* unmanaged)thunk)((nint)pointers);
}
+
+ private static unsafe nint CreateExceptionInfo(uint code)
+ {
+ // The wrapper only needs a valid ExceptionCode field and ContextRecord
+ // pointer; the thunk's native pre-filter reads exactly those members.
+ var pointers = (nint*)NativeMemory.Alloc((nuint)(2 * sizeof(nint) + 0xA0 + 0x4D0));
+ var record = (byte*)(pointers + 2);
+ var context = record + 0xA0;
+ new Span(record, 0xA0).Clear();
+ new Span(context, 0x4D0).Clear();
+ *(uint*)record = code;
+ pointers[0] = (nint)record;
+ pointers[1] = (nint)context;
+ return (nint)pointers;
+ }
+
+ private static unsafe nint CreateRegisterProbe(nint thunk)
+ {
+ var code = new List();
+ void U64(ulong value) { code.AddRange(BitConverter.GetBytes(value)); }
+ void MovReg(byte rex, byte opcode, ulong value) { code.Add(rex); code.Add(opcode); U64(value); }
+ // Preserve caller nonvolatiles, then install sentinels and call thunk.
+ code.AddRange(new byte[] { 0x53, 0x56, 0x57, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57 });
+ code.AddRange(new byte[] { 0x48, 0x83, 0xEC, 0x38 });
+ code.AddRange(new byte[] { 0x48, 0x89, 0x54, 0x24, 0x30 }); // [rsp+30] = output
+ MovReg(0x49, 0xBC, 0x5555555555555555UL);
+ MovReg(0x49, 0xBD, 0x6666666666666666UL);
+ MovReg(0x49, 0xBE, 0x7777777777777777UL);
+ MovReg(0x49, 0xBF, 0x8888888888888888UL);
+ MovReg(0x48, 0xBB, 0x1111111111111111UL);
+ MovReg(0x48, 0xBD, 0x2222222222222222UL);
+ MovReg(0x48, 0xBE, 0x3333333333333333UL);
+ MovReg(0x48, 0xBF, 0x4444444444444444UL);
+ code.AddRange(new byte[] { 0x48, 0xB8 }); U64((ulong)thunk); code.AddRange(new byte[] { 0xFF, 0xD0 });
+ code.AddRange(new byte[] { 0x89, 0x44, 0x24, 0x28 }); // [rsp+28] = callback result
+ code.AddRange(new byte[] { 0x48, 0x8B, 0x44, 0x24, 0x30 }); // rax = output
+ code.AddRange(new byte[] { 0x48, 0x89, 0x98, 0, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x48, 0x89, 0xA8, 8, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x48, 0x89, 0xB0, 16, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x48, 0x89, 0xB8, 24, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x4C, 0x89, 0xA0, 32, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x4C, 0x89, 0xA8, 40, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x4C, 0x89, 0xB0, 48, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x4C, 0x89, 0xB8, 56, 0, 0, 0 });
+ code.AddRange(new byte[] { 0x8B, 0x44, 0x24, 0x28 }); // eax = callback result
+ code.AddRange(new byte[] { 0x48, 0x83, 0xC4, 0x38, 0x41, 0x5F, 0x41, 0x5E, 0x41, 0x5D, 0x41, 0x5C, 0x5D, 0x5F, 0x5E, 0x5B, 0xC3 });
+ var ptr = (byte*)HostMemory.Alloc(null, (nuint)code.Count, HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE, HostMemory.PAGE_EXECUTE_READWRITE);
+ code.ToArray().AsSpan().CopyTo(new Span(ptr, code.Count));
+ return (nint)ptr;
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
index 1608dde..b7df756 100644
--- a/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
@@ -59,6 +59,25 @@ public sealed class PhysicalVirtualMemoryTests
Assert.Single(host.CommitCalls);
}
+ [Fact]
+ public void TryCommitRangeCommitsEveryPageInReserveOnlyMapping()
+ {
+ using var host = new LazyZeroedHostMemory();
+ using var memory = new PhysicalVirtualMemory(host);
+
+ var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
+ host.CommitCalls.Clear();
+
+ Assert.True(memory.TryCommitRange(address + 0x1000, 0x3000));
+ Assert.Equal(
+ [
+ (address + 0x1000, 0x1000UL, HostPageProtection.ReadWrite),
+ (address + 0x2000, 0x1000UL, HostPageProtection.ReadWrite),
+ (address + 0x3000, 0x1000UL, HostPageProtection.ReadWrite),
+ ],
+ host.CommitCalls);
+ }
+
[Fact]
public void TryCopyHandlesOverlappingIdentityMappedRanges()
{
From f84246cf909b75ec48729778acde947768cc9f8e Mon Sep 17 00:00:00 2001
From: Mathias <06mathias6@gmail.com>
Date: Fri, 11 Sep 2026 13:17:37 +0200
Subject: [PATCH 2/2] Fix/agc rendering and module loading (#881)
* test(cpu): verify VEH thunk preserves nonvolatile registers
* fix(memory): commit complete native guest mappings
* fix(cpu): treat time polling as import-loop progress
* fix(cli): preserve DLL path in mitigation relaunch
* refactor(cpu): remove obsolete inflate recovery hack
* fix(agc): accept shaders without embedded program registers
* fix(cpu): treat user event polling as import-loop progress
* fix(shader): lower unsigned sum of absolute differences
* fix(agc): emit direct shader register packets
* fix(agc): report native wait packet sizes
* fix(agc): propagate embedded vertex fetch offsets
* fix(vulkan): keep vertex attributes relative to upload
* fix(agc): avoid double applying embedded vertex offsets
* docs(agc): clarify embedded vertex offset handling
* fix(gpu): separate vertex IDs from stream offsets
* fix(shader): decode global load destinations
* fix(runtime): index application-root PRX modules
---
.../Native/DirectExecutionBackend.Imports.cs | 3 +
src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs | 5 +
src/SharpEmu.Libs/Agc/AgcExports.cs | 156 ++++++++++++++++--
src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs | 6 +-
.../Gpu/Metal/MetalVideoPresenter.Draws.cs | 9 +-
.../VideoOut/VulkanVideoPresenter.cs | 20 +--
.../Gen5SpirvTranslator.Alu.cs | 12 ++
.../Gen5ShaderTranslator.cs | 112 ++++++++++++-
.../Agc/AgcDcbDirectRegisterTests.cs | 82 +++++++++
.../Agc/AgcWaitRegMemTests.cs | 13 ++
.../Agc/Gen5SadSpirvTests.cs | 81 +++++++++
.../Agc/Gen5VertexOffsetTests.cs | 81 +++++++++
.../Gen5FlatMemoryTests.cs | 47 ++++++
13 files changed, 593 insertions(+), 34 deletions(-)
create mode 100644 tests/SharpEmu.Libs.Tests/Agc/AgcDcbDirectRegisterTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/Agc/Gen5SadSpirvTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/Agc/Gen5VertexOffsetTests.cs
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
index 984498a..9f92d94 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
@@ -1623,6 +1623,9 @@ public sealed partial class DirectExecutionBackend
"LtTouSCZjHM" or // sceAgcCbNop
"k3GhuSNmBLU" or // sceAgcCbDispatch
"UZbQjYAwwXM" or // sceAgcCbSetShRegistersDirect
+ "pFLArOT53+w" or // sceAgcDcbSetShRegisterDirect
+ "QhPDD513V0w" or // sceAgcDcbSetShRegisterDirectGetSize
+ "43WJ08sSugE" or // sceAgcDcbWaitOnAddressGetSize
"JrtiDtKeS38" or // sceAgcAcbResetQueue
"cFazmnXpJOE" or // sceAgcAcbEventWrite
"KT-hTp-Ch14" or // sceAgcAcbAcquireMem
diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
index 43ac6d6..7f5a524 100644
--- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
+++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
@@ -642,6 +642,11 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
(Path: Path.Combine(ebootDirectory, "sce_module"), StartAtBoot: true),
(Path: Path.Combine(ebootDirectory, "sce_modules"), StartAtBoot: true),
(Path: Path.Combine(ebootDirectory, "Media", "Modules"), StartAtBoot: true),
+ // Retail packages may keep application PRX files beside eboot.bin
+ // (for example, native middleware shipped at the app root). Index
+ // those modules too so their imports are available when the guest
+ // requests them through sceKernelLoadStartModule.
+ (Path: ebootDirectory, StartAtBoot: false),
// Unity native plugins are loaded later through sceKernelLoadStartModule. Map
// them up front so the HLE loader can return a real module handle and dlsym
// can resolve their exports, but defer DT_INIT until the guest requests them.
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index 89f4976..9cf3cdc 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -1488,6 +1488,7 @@ public static partial class AgcExports
uint VertexCount,
uint InstanceCount,
int BaseVertex,
+ int VertexBufferBaseVertex,
GuestIndexBuffer? IndexBuffer,
IReadOnlyList Textures,
IReadOnlyList GlobalMemoryBindings,
@@ -2918,6 +2919,25 @@ public static partial class AgcExports
public static int DcbSetShRegistersIndirect(CpuContext ctx) =>
DcbSetRegistersIndirect(ctx, RShRegsIndirect, "sh");
+ [SysAbiExport(
+ Nid = "pFLArOT53+w",
+ ExportName = "sceAgcDcbSetShRegisterDirect",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int DcbSetShRegisterDirect(CpuContext ctx) =>
+ DcbSetRegisterDirect(ctx, ItSetShReg, "sh");
+
+ [SysAbiExport(
+ Nid = "QhPDD513V0w",
+ ExportName = "sceAgcDcbSetShRegisterDirectGetSize",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int DcbSetShRegisterDirectGetSize(CpuContext ctx)
+ {
+ ctx[CpuRegister.Rax] = 3u * sizeof(uint);
+ return (int)ctx[CpuRegister.Rax];
+ }
+
[SysAbiExport(
Nid = "hvUfkUIQcOE",
ExportName = "sceAgcDcbSetUcRegistersIndirect",
@@ -3563,6 +3583,23 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress);
}
+ [SysAbiExport(
+ Nid = "43WJ08sSugE",
+ ExportName = "sceAgcDcbWaitOnAddressGetSize",
+ Target = Generation.Gen5,
+ LibraryName = "libSceAgc")]
+ public static int DcbWaitOnAddressGetSize(CpuContext ctx)
+ {
+ var size = (uint)ctx[CpuRegister.Rdi];
+ ctx[CpuRegister.Rax] = size switch
+ {
+ 0 => 14u * sizeof(uint),
+ 1 => 16u * sizeof(uint),
+ _ => 0,
+ };
+ return (int)ctx[CpuRegister.Rax];
+ }
+
[SysAbiExport(
Nid = "u2T2DiA5hRI",
ExportName = "sceAgcDcbStallCommandBufferParser",
@@ -5304,7 +5341,9 @@ public static partial class AgcExports
var globalMemoryBuffers =
CreateTranslatedDrawGlobalBuffers(pendingComposite);
var vertexBuffers =
- CreateGuestVertexBuffers(pendingComposite.VertexInputs);
+ CreateGuestVertexBuffers(
+ pendingComposite.VertexInputs,
+ pendingComposite.VertexBufferBaseVertex);
ProvideRenderTargetInitialData(ctx, pendingDisplayTarget);
GuestGpu.Current.SubmitOffscreenTranslatedDraw(
pendingComposite.PixelShader,
@@ -8232,7 +8271,9 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
var globalMemoryBuffers =
CreateTranslatedDrawGlobalBuffers(depthOnlyDraw);
var vertexBuffers =
- CreateGuestVertexBuffers(depthOnlyDraw.VertexInputs);
+ CreateGuestVertexBuffers(
+ depthOnlyDraw.VertexInputs,
+ depthOnlyDraw.VertexBufferBaseVertex);
var renderState = depthOnlyDraw.RenderState;
if (activeDepthTarget.ReadOnly && renderState.Depth.WriteEnable)
{
@@ -8427,7 +8468,9 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
var sharedGlobalMemoryBuffers =
CreateTranslatedDrawGlobalBuffers(translatedDraw);
var sharedVertexBuffers =
- CreateGuestVertexBuffers(translatedDraw.VertexInputs);
+ CreateGuestVertexBuffers(
+ translatedDraw.VertexInputs,
+ translatedDraw.VertexBufferBaseVertex);
TraceRectListVertices(translatedDraw, sharedVertexBuffers);
TraceGrassDrawVertices(translatedDraw, sharedTextures, sharedVertexBuffers);
TraceDrawCompact(
@@ -8484,7 +8527,9 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
var globalMemoryBuffers =
CreateTranslatedDrawGlobalBuffers(translatedDraw);
var vertexBuffers =
- CreateGuestVertexBuffers(translatedDraw.VertexInputs);
+ CreateGuestVertexBuffers(
+ translatedDraw.VertexInputs,
+ translatedDraw.VertexBufferBaseVertex);
var renderState = translatedDraw.RenderState;
if (translatedDepthTarget.ReadOnly && renderState.Depth.WriteEnable)
{
@@ -8656,8 +8701,18 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
SelectExportUserDataRegister(state.ShRegisters),
out var exportState,
out error,
- userDataScalarRegisterBase: NggUserDataScalarRegisterBase) ||
- !Gen5ShaderScalarEvaluator.TryEvaluate(
+ userDataScalarRegisterBase: NggUserDataScalarRegisterBase))
+ {
+ return false;
+ }
+
+ // Embedded fetch prologs add their own base to gl_VertexID. Keep the
+ // host draw offset at zero in that case; otherwise Vulkan applies the
+ // same first-vertex adjustment a second time.
+ var baseVertex = GetBaseVertex(state, exportState);
+ var vertexBufferBaseVertex = GetVertexBufferBaseVertex(exportState);
+ var recordBaseVertex = GetVertexRecordBaseVertex(state, exportState);
+ if (!Gen5ShaderScalarEvaluator.TryEvaluate(
ctx,
exportState,
out var exportEvaluation,
@@ -8668,6 +8723,7 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
state,
vertexCount,
indexed,
+ recordBaseVertex,
out var depthVertexRecords)
? depthVertexRecords
: null))
@@ -8816,7 +8872,8 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
AttributeCount: 0,
vertexCount,
state.InstanceCount,
- GetBaseVertex(state),
+ baseVertex,
+ vertexBufferBaseVertex,
indexed ? CreateGuestIndexBuffer(ctx, state, vertexCount) : null,
textures,
exportEvaluation.GlobalMemoryBindings,
@@ -8871,6 +8928,13 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
return false;
}
+ // Embedded fetch prologs add their own base to gl_VertexID. Keep the
+ // host draw offset at zero in that case; otherwise Vulkan applies the
+ // same first-vertex adjustment a second time.
+ var baseVertex = GetBaseVertex(state, exportState);
+ var vertexBufferBaseVertex = GetVertexBufferBaseVertex(exportState);
+ var recordBaseVertex = GetVertexRecordBaseVertex(state, exportState);
+
if (!Gen5ShaderScalarEvaluator.TryEvaluate(
ctx,
exportState,
@@ -8882,6 +8946,7 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
state,
vertexCount,
indexed,
+ recordBaseVertex,
out var vertexRecords)
? vertexRecords
: null))
@@ -9328,7 +9393,8 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
GetInterpolatedAttributeCount(pixelState),
vertexCount,
state.InstanceCount,
- GetBaseVertex(state),
+ baseVertex,
+ vertexBufferBaseVertex,
indexed ? CreateGuestIndexBuffer(ctx, state, vertexCount) : null,
textures,
globalMemoryBindings,
@@ -9945,14 +10011,64 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
AgcIndexHelpers.Decode(state.IndexSize);
///
- /// ResolveVertexOffset for the common UC path: GE_INDX_OFFSET is the
- /// DrawIndexed vertexOffset / DrawAuto firstVertex. Embedded-fetch SGPR
- /// fallback is not required when the game latches this register (GTA UI).
+ /// Resolve the Vulkan host vertex offset. GE_INDX_OFFSET is the
+ /// DrawIndexed vertexOffset / DrawAuto firstVertex when the translated
+ /// shader does not contain an embedded fetch prolog. Embedded prologs add
+ /// their user-SGPR offset to gl_VertexID themselves, so the host offset
+ /// must remain zero in that case.
///
- private static int GetBaseVertex(SubmittedDcbState state) =>
- state.UcRegisters.TryGetValue(GeIndxOffset, out var indexOffset)
- ? unchecked((int)indexOffset)
- : 0;
+ private static int GetBaseVertex(
+ SubmittedDcbState state,
+ Gen5ShaderState exportState)
+ {
+ if (Gen5ShaderTranslator.TryGetEmbeddedFetchVertexOffset(
+ exportState,
+ out _))
+ {
+ // The translated vertex shader already adds this value to
+ // gl_VertexID, so passing it through CmdDrawIndexed would double it.
+ return 0;
+ }
+
+ if (state.UcRegisters.TryGetValue(GeIndxOffset, out var indexOffset))
+ {
+ return unchecked((int)indexOffset);
+ }
+
+ return 0;
+ }
+
+ private static int GetVertexRecordBaseVertex(
+ SubmittedDcbState state,
+ Gen5ShaderState exportState)
+ {
+ if (Gen5ShaderTranslator.TryGetEmbeddedFetchVertexOffset(
+ exportState,
+ out var embeddedOffset))
+ {
+ return embeddedOffset;
+ }
+
+ if (state.UcRegisters.TryGetValue(GeIndxOffset, out var indexOffset))
+ {
+ return unchecked((int)indexOffset);
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Resolve the record offset for host vertex attributes when the guest
+ /// shader applies its first-vertex value itself. The host draw stays at
+ /// vertex zero so gl_VertexID remains relative, while the buffer binding
+ /// starts at the guest record selected by the fetch prolog.
+ ///
+ private static int GetVertexBufferBaseVertex(Gen5ShaderState exportState) =>
+ Gen5ShaderTranslator.TryGetEmbeddedFetchVertexOffset(
+ exportState,
+ out var embeddedOffset)
+ ? embeddedOffset
+ : 0;
private static GuestIndexBuffer? CreateGuestIndexBuffer(
CpuContext ctx,
@@ -10009,9 +10125,10 @@ var renderTargets = GetRenderTargets(state.CxRegisters);
SubmittedDcbState state,
uint drawCount,
bool indexed,
+ int resolvedBaseVertex,
out uint recordCount)
{
- var baseVertex = (uint)Math.Max(GetBaseVertex(state), 0);
+ var baseVertex = (uint)Math.Max(resolvedBaseVertex, 0);
recordCount = Math.Max(
baseVertex + drawCount,
Math.Max(state.InstanceCount, 1u));
@@ -11257,8 +11374,10 @@ private static long _indirectDrawProbeCount;
}
private static IReadOnlyList CreateGuestVertexBuffers(
- IReadOnlyList bindings)
+ IReadOnlyList bindings,
+ int baseVertex)
{
+ var baseRecord = baseVertex > 0 ? checked((uint)baseVertex) : 0;
var buffers = new GuestVertexBuffer[bindings.Count];
for (var index = 0; index < bindings.Count; index++)
{
@@ -11274,7 +11393,8 @@ private static long _indirectDrawProbeCount;
binding.Data,
binding.DataLength,
binding.DataPooled,
- binding.PerInstance);
+ binding.PerInstance,
+ baseRecord);
}
return buffers;
diff --git a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
index f2a64b6..ea88acf 100644
--- a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
+++ b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
@@ -90,7 +90,11 @@ internal sealed record GuestVertexBuffer(
byte[] Data,
int Length,
bool Pooled,
- bool PerInstance = false);
+ bool PerInstance = false,
+ uint BaseRecord = 0)
+{
+ public ulong BindingOffsetBytes => (ulong)BaseRecord * Stride;
+}
internal sealed record GuestIndexBuffer(
byte[] Data,
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
index 502d4be..eb27caf 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
@@ -51,7 +51,8 @@ internal static partial class MetalVideoPresenter
var candidate = vertexBuffers[prior];
if (candidate.BaseAddress == buffer.BaseAddress &&
candidate.Stride == buffer.Stride &&
- candidate.Length == buffer.Length)
+ candidate.Length == buffer.Length &&
+ candidate.BaseRecord == buffer.BaseRecord)
{
slots[index] = slots[prior];
shared = true;
@@ -981,7 +982,11 @@ internal static partial class MetalVideoPresenter
// selects the field inside the interleaved vertex; the bind
// offset is just the arena slice.
MetalNative.SendSetBuffer(
- encoder, selSetVertexBuffer, buffer, (nuint)offset, vertexSlots[index]);
+ encoder,
+ selSetVertexBuffer,
+ buffer,
+ (nuint)offset + (nuint)vertexBuffer.BindingOffsetBytes,
+ vertexSlots[index]);
}
}
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index 320a73b..faa7b39 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -3677,6 +3677,7 @@ internal static unsafe class VulkanVideoPresenter
public uint Stride;
public uint OffsetBytes;
public bool PerInstance;
+ public uint BaseRecord;
}
private const Format DepthFormat = Format.D32Sfloat;
@@ -10777,6 +10778,7 @@ internal static unsafe class VulkanVideoPresenter
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
PerInstance = guestBuffer.PerInstance,
+ BaseRecord = guestBuffer.BaseRecord,
};
}
@@ -10795,6 +10797,7 @@ internal static unsafe class VulkanVideoPresenter
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
PerInstance = guestBuffer.PerInstance,
+ BaseRecord = guestBuffer.BaseRecord,
};
private VkBuffer CreateHostBuffer(
@@ -11033,18 +11036,11 @@ internal static unsafe class VulkanVideoPresenter
_ => Format.R32Sfloat,
};
- private static ulong GetVertexBindingOffset(VertexBufferResource vertexBuffer)
- {
- if (vertexBuffer.OffsetBytes < vertexBuffer.Size)
- {
- return vertexBuffer.OffsetBytes;
- }
-
- TraceVulkanShader(
- $"vk.vertex_offset_oob loc={vertexBuffer.Location} " +
- $"offset={vertexBuffer.OffsetBytes} size={vertexBuffer.Size}");
- return 0;
- }
+ // OffsetBytes selects the field within each interleaved record. Some
+ // guest fetch prologs apply firstVertex to their own vertex ID, so the
+ // Vulkan draw stays relative and the host stream starts at BaseRecord.
+ private static ulong GetVertexBindingOffset(VertexBufferResource vertexBuffer) =>
+ (ulong)vertexBuffer.BaseRecord * vertexBuffer.Stride;
private static uint GetDrawVertexCount(
uint primitiveType,
diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs
index d7502cc..9ea07bb 100644
--- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs
+++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs
@@ -836,6 +836,18 @@ public static partial class Gen5SpirvTranslator
Ext(37, _floatType, high, right)));
break;
}
+ case "VSadU32":
+ {
+ // Unsigned absolute difference cannot overflow when it is
+ // expressed as max(a, b) - min(a, b).
+ var left = GetRawSource(instruction, 0);
+ var right = GetRawSource(instruction, 1);
+ var accumulator = GetRawSource(instruction, 2);
+ var maximum = Ext(41, _uintType, left, right);
+ var minimum = Ext(38, _uintType, left, right);
+ result = IAdd(ISubU(maximum, minimum), accumulator);
+ break;
+ }
case "VCubeidF32":
result = EmitCubeCoordinate(instruction, CubeCoordinate.Id);
break;
diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs
index 3638bd8..fddbdce 100644
--- a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs
+++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs
@@ -12,6 +12,112 @@ namespace SharpEmu.ShaderCompiler;
public static class Gen5ShaderTranslator
{
private static int _dppVectorsValidated;
+
+ ///
+ /// Finds the user SGPR that an embedded vertex-fetch prolog adds to its
+ /// vertex index. PS5 NGG shaders use v5 and V_SAD_U32; legacy fetch
+ /// shaders use v0 and V_ADD_I32. Only instructions before the first fetch
+ /// qualify, and conflicting candidates fail closed.
+ ///
+ public static bool TryGetEmbeddedFetchVertexOffsetRegister(
+ Gen5ShaderProgram program,
+ uint userDataBase,
+ int userDataCount,
+ out uint register)
+ {
+ register = 0;
+ uint? candidate = null;
+ foreach (var instruction in program.Instructions)
+ {
+ if (instruction.Control is Gen5BufferMemoryControl)
+ {
+ break;
+ }
+
+ if (instruction.Destinations.Count == 0 ||
+ instruction.Destinations[0] is not
+ { Kind: Gen5OperandKind.VectorRegister } destination)
+ {
+ continue;
+ }
+
+ var accumulator = destination.Value;
+ var isLegacyAdd =
+ accumulator == 0 &&
+ instruction.Opcode == "VAddI32" &&
+ instruction.Sources.Count >= 2 &&
+ instruction.Sources[1] is
+ { Kind: Gen5OperandKind.VectorRegister, Value: 0 };
+ var isNggSad =
+ userDataBase == 8 &&
+ accumulator == 5 &&
+ instruction.Opcode == "VSadU32" &&
+ instruction.Sources.Count >= 3 &&
+ instruction.Sources[2] is
+ { Kind: Gen5OperandKind.VectorRegister, Value: 5 } &&
+ TryGetInlineConstant(instruction.Sources[1], out var sadValue) &&
+ sadValue == 0;
+ if ((!isLegacyAdd && !isNggSad) ||
+ instruction.Sources[0] is not
+ { Kind: Gen5OperandKind.ScalarRegister } scalar ||
+ scalar.Value < userDataBase ||
+ scalar.Value - userDataBase >= userDataCount)
+ {
+ continue;
+ }
+
+ if (candidate.HasValue && candidate.Value != scalar.Value)
+ {
+ return false;
+ }
+
+ candidate = scalar.Value;
+ }
+
+ if (!candidate.HasValue)
+ {
+ return false;
+ }
+
+ register = candidate.Value;
+ return true;
+ }
+
+ public static bool TryGetEmbeddedFetchVertexOffset(
+ Gen5ShaderState state,
+ out int offset)
+ {
+ offset = 0;
+ if (!TryGetEmbeddedFetchVertexOffsetRegister(
+ state.Program,
+ state.UserDataScalarRegisterBase,
+ state.UserData.Count,
+ out var register))
+ {
+ return false;
+ }
+
+ var userDataIndex = register - state.UserDataScalarRegisterBase;
+ offset = unchecked((int)state.UserData[(int)userDataIndex]);
+ return true;
+ }
+
+ private static bool TryGetInlineConstant(Gen5Operand operand, out uint value)
+ {
+ if (operand.Kind == Gen5OperandKind.LiteralConstant)
+ {
+ value = operand.Value;
+ return true;
+ }
+
+ if (operand.Kind == Gen5OperandKind.EncodedConstant)
+ {
+ return Gen5InlineConstants.TryDecode(operand.Value, out value);
+ }
+
+ value = 0;
+ return false;
+ }
///
/// Bitmask (256 bits) of scalar registers whose values the program can
/// observe: scalar source operands (widened for 64-bit pairs), the
@@ -2187,7 +2293,11 @@ public static class Gen5ShaderTranslator
{
var extra = words[1];
var vectorAddress = extra & 0xFF;
- var vectorData = (extra >> 8) & 0xFF;
+ // FLAT/GLOBAL encodes the load destination in the high byte
+ // of the second dword. MUBUF uses bits [15:8] instead, so
+ // sharing that extraction here silently clobbers the wrong
+ // VGPR range for GLOBAL_LOAD_* instructions.
+ var vectorData = (extra >> 24) & 0xFF;
var scalarAddress = (extra >> 16) & 0x7F;
var usesFlatAddress = opcode.StartsWith(
"Flat",
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcDcbDirectRegisterTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcDcbDirectRegisterTests.cs
new file mode 100644
index 0000000..d142646
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcDcbDirectRegisterTests.cs
@@ -0,0 +1,82 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Agc;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+public sealed class AgcDcbDirectRegisterTests
+{
+ private const ulong BaseAddress = 0x4_0000_0000;
+ private const ulong CommandBufferAddress = BaseAddress + 0x1000;
+ private const ulong CommandsStart = BaseAddress + 0x2000;
+ private const ulong CommandsEnd = BaseAddress + 0x4000;
+
+ [Fact]
+ public void DcbSetShRegisterDirect_WritesPacketAndAdvancesCursor()
+ {
+ var memory = new FakeCpuMemory(BaseAddress, 0x10000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ WriteUInt64(memory, CommandBufferAddress + 0x10, CommandsStart);
+ WriteUInt64(memory, CommandBufferAddress + 0x18, CommandsEnd);
+ WriteUInt64(memory, CommandBufferAddress + 0x20, 0);
+ WriteUInt64(memory, CommandBufferAddress + 0x28, 0);
+ WriteUInt32(memory, CommandBufferAddress + 0x30, 0);
+
+ const uint registerOffset = 0x1234;
+ const uint registerValue = 0xDEADBEEF;
+ context[CpuRegister.Rdi] = CommandBufferAddress;
+ context[CpuRegister.Rsi] = registerOffset | ((ulong)registerValue << 32);
+
+ var result = AgcExports.DcbSetShRegisterDirect(context);
+
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(CommandsStart, context[CpuRegister.Rax]);
+ Assert.Equal(CommandsStart + 12, ReadUInt64(memory, CommandBufferAddress + 0x10));
+ Assert.Equal(0xC0017600u, ReadUInt32(memory, CommandsStart));
+ Assert.Equal(registerOffset, ReadUInt32(memory, CommandsStart + 4));
+ Assert.Equal(registerValue, ReadUInt32(memory, CommandsStart + 8));
+ }
+
+ [Fact]
+ public void DcbSetShRegisterDirectGetSize_ReturnsPacketSize()
+ {
+ var context = new CpuContext(
+ new FakeCpuMemory(BaseAddress, 0x1000),
+ Generation.Gen5);
+
+ Assert.Equal(12, AgcExports.DcbSetShRegisterDirectGetSize(context));
+ Assert.Equal(12UL, context[CpuRegister.Rax]);
+ }
+
+ private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
+ {
+ Span bytes = stackalloc byte[sizeof(uint)];
+ Assert.True(memory.TryRead(address, bytes));
+ return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
+ }
+
+ private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
+ {
+ Span bytes = stackalloc byte[sizeof(ulong)];
+ Assert.True(memory.TryRead(address, bytes));
+ return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
+ }
+
+ private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
+ {
+ Span bytes = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+
+ private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
+ {
+ Span bytes = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs
index 07efa6c..20b7cef 100644
--- a/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs
@@ -93,6 +93,19 @@ public sealed class AgcWaitRegMemTests
Assert.Equal(0xDEAD_BEEFu, ReadUInt32(memory, PacketAddress + 16));
}
+ [Theory]
+ [InlineData(0u, 56)]
+ [InlineData(1u, 64)]
+ [InlineData(2u, 0)]
+ public void DcbWaitOnAddressGetSize_ReturnsNativePacketSize(uint size, int expected)
+ {
+ CreateMemory(out var ctx);
+ ctx[CpuRegister.Rdi] = size;
+
+ Assert.Equal(expected, AgcExports.DcbWaitOnAddressGetSize(ctx));
+ Assert.Equal((ulong)expected, ctx[CpuRegister.Rax]);
+ }
+
private static FakeCpuMemory CreateMemory(out CpuContext ctx)
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
diff --git a/tests/SharpEmu.Libs.Tests/Agc/Gen5SadSpirvTests.cs b/tests/SharpEmu.Libs.Tests/Agc/Gen5SadSpirvTests.cs
new file mode 100644
index 0000000..b10ef92
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/Gen5SadSpirvTests.cs
@@ -0,0 +1,81 @@
+// 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 SharpEmu.ShaderCompiler.Vulkan;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+public sealed class Gen5SadSpirvTests
+{
+ private const ulong ShaderAddress = 0x1_0000_0000;
+
+ [Fact]
+ public void SadU32_EmitsUnsignedAbsoluteDifferenceAndAdd()
+ {
+ // V_SAD_U32 v3, v0, v1, v2
+ var spirv = Compile([0xD15D0003u, 0x040A0300u]);
+ var instructions = EnumerateInstructions(spirv).ToArray();
+
+ Assert.Contains(instructions, instruction =>
+ instruction.Op == (ushort)SpirvOp.ExtInst &&
+ ReadWord(spirv, instruction.Offset + 16) == 41); // UMax
+ Assert.Contains(instructions, instruction =>
+ instruction.Op == (ushort)SpirvOp.ExtInst &&
+ ReadWord(spirv, instruction.Offset + 16) == 38); // UMin
+ Assert.Contains(instructions, instruction => instruction.Op == (ushort)SpirvOp.ISub);
+ Assert.Contains(instructions, instruction => instruction.Op == (ushort)SpirvOp.IAdd);
+ }
+
+ private static byte[] Compile(uint[] programWords)
+ {
+ var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
+ var shaderRegisters = new Dictionary
+ {
+ [Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
+ };
+
+ Assert.True(
+ Gen5ShaderTranslator.TryCreateState(
+ context,
+ ShaderAddress,
+ 0,
+ shaderRegisters,
+ Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister,
+ out var state,
+ out var error),
+ error);
+ Assert.True(
+ Gen5ShaderScalarEvaluator.TryEvaluate(context, state, out var evaluation, out error),
+ error);
+ Assert.True(
+ Gen5SpirvTranslator.TryCompileComputeShader(
+ state, evaluation, 1, 1, 1, out var shader, out error),
+ error);
+ return shader.Spirv;
+ }
+
+ private static IEnumerable<(ushort Op, int Offset)> EnumerateInstructions(byte[] spirv)
+ {
+ for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
+ {
+ var word = ReadWord(spirv, offset);
+ var wordCount = (int)(word >> 16);
+ if (wordCount <= 0)
+ {
+ yield break;
+ }
+
+ yield return ((ushort)word, offset);
+ offset += wordCount * sizeof(uint);
+ }
+ }
+
+ private static uint ReadWord(byte[] spirv, int offset) =>
+ BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
+}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/Gen5VertexOffsetTests.cs b/tests/SharpEmu.Libs.Tests/Agc/Gen5VertexOffsetTests.cs
new file mode 100644
index 0000000..c93c874
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/Gen5VertexOffsetTests.cs
@@ -0,0 +1,81 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.ShaderCompiler;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+public sealed class Gen5VertexOffsetTests
+{
+ [Fact]
+ public void NggSadProlog_ResolvesVertexOffsetFromUserData()
+ {
+ var state = CreateState(
+ new Gen5ShaderInstruction(
+ 0,
+ Gen5ShaderEncoding.Vop3,
+ "VSadU32",
+ [],
+ [Gen5Operand.Scalar(18), Gen5Operand.Source(128), Gen5Operand.Vector(5)],
+ [Gen5Operand.Vector(5)],
+ null),
+ userData: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0]);
+
+ Assert.True(Gen5ShaderTranslator.TryGetEmbeddedFetchVertexOffset(state, out var offset));
+ Assert.Equal(8, offset);
+ }
+
+ [Fact]
+ public void NggSadProlog_WithNonzeroMiddleOperand_IsNotClassified()
+ {
+ var state = CreateState(
+ new Gen5ShaderInstruction(
+ 0,
+ Gen5ShaderEncoding.Vop3,
+ "VSadU32",
+ [],
+ [Gen5Operand.Scalar(18), Gen5Operand.Source(129), Gen5Operand.Vector(5)],
+ [Gen5Operand.Vector(5)],
+ null),
+ userData: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0]);
+
+ Assert.False(Gen5ShaderTranslator.TryGetEmbeddedFetchVertexOffset(state, out _));
+ }
+
+ [Fact]
+ public void VertexOffsetAfterFirstFetch_IsNotClassified()
+ {
+ var fetch = new Gen5ShaderInstruction(
+ 0,
+ Gen5ShaderEncoding.Mubuf,
+ "BufferLoadFormatX",
+ [],
+ [],
+ [],
+ new Gen5BufferMemoryControl(1, 0, 0, 0, 0, true, false, false, false));
+ var state = CreateState(
+ fetch,
+ userData: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0],
+ new Gen5ShaderInstruction(
+ 4,
+ Gen5ShaderEncoding.Vop3,
+ "VSadU32",
+ [],
+ [Gen5Operand.Scalar(18), Gen5Operand.Source(128), Gen5Operand.Vector(5)],
+ [Gen5Operand.Vector(5)],
+ null));
+
+ Assert.False(Gen5ShaderTranslator.TryGetEmbeddedFetchVertexOffset(state, out _));
+ }
+
+ private static Gen5ShaderState CreateState(
+ Gen5ShaderInstruction first,
+ IReadOnlyList userData,
+ params Gen5ShaderInstruction[] rest) =>
+ new(
+ new Gen5ShaderProgram(0, [first, .. rest]),
+ userData,
+ Metadata: null,
+ UserDataScalarRegisterBase: 8);
+}
diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5FlatMemoryTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5FlatMemoryTests.cs
index f9fafd5..791ecf4 100644
--- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5FlatMemoryTests.cs
+++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5FlatMemoryTests.cs
@@ -102,6 +102,53 @@ public sealed class Gen5FlatMemoryTests
ReadSpirvOpcodes(compiled.Spirv));
}
+ [Fact]
+ public void GlobalLoadUsesHighByteForVectorDestination()
+ {
+ var memory = new TestCpuMemory(ShaderAddress, 0x4000);
+ uint[] words =
+ [
+ // global_load_dwordx4 v[8:11], v9, s[16:17]
+ 0xDC388000,
+ 0x08100009,
+ SEndpgm,
+ ];
+ var shader = new byte[words.Length * sizeof(uint)];
+ for (var index = 0; index < words.Length; index++)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ shader.AsSpan(index * sizeof(uint)),
+ words[index]);
+ }
+ Assert.True(memory.TryWrite(ShaderAddress, shader));
+
+ var ctx = new CpuContext(memory, Generation.Gen5);
+ Assert.True(
+ Gen5ShaderTranslator.TryDecodeProgram(
+ ctx,
+ ShaderAddress,
+ out var program,
+ out var decodeError),
+ decodeError);
+
+ var instruction = Assert.Single(
+ program.Instructions,
+ item => item.Opcode == "GlobalLoadDwordx4");
+ var control = Assert.IsType(
+ instruction.Control);
+ Assert.Equal(9u, control.VectorAddress);
+ Assert.Equal(8u, control.VectorData);
+ Assert.Equal(16u, control.ScalarAddress);
+ Assert.Equal(
+ [
+ Gen5Operand.Vector(8),
+ Gen5Operand.Vector(9),
+ Gen5Operand.Vector(10),
+ Gen5Operand.Vector(11),
+ ],
+ instruction.Destinations);
+ }
+
private static IReadOnlyList ReadSpirvOpcodes(byte[] spirv)
{
Assert.Equal(0, spirv.Length % sizeof(uint));