diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs index 17ebcfd..c590886 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs @@ -365,7 +365,13 @@ public sealed partial class DirectExecutionBackend Console.Error.WriteLine("[LOADER][ERROR] - Guest code accessed unmapped memory"); Console.Error.WriteLine("[LOADER][ERROR] - Need to implement HLE for this NID"); byte[] code = new byte[16]; - if (TryReadHostBytes(rip, code)) + DumpHostRegion("RIP", rip); + // Guest code can be mapped execute-only, which makes every + // host-side read of it fail. The guest memory view still + // resolves those pages, so prefer it here: otherwise the + // whole call-site report below is silently skipped on + // exactly the faults that need it. + if (TryReadGuestOrHostBytes(rip, code)) { Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(code).Replace("-", " ")); if (code[0] == 100) @@ -383,12 +389,12 @@ public sealed partial class DirectExecutionBackend Console.Error.WriteLine($"[LOADER][INFO] RSP: 0x{rsp:X16} (mod 16 = {rsp % 16})"); } byte[] before = new byte[16]; - if (rip > 16 && TryReadHostBytes(rip - 16, before)) + if (rip > 16 && TryReadGuestOrHostBytes(rip - 16, before)) { Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + BitConverter.ToString(before).Replace("-", " ")); } byte[] window = new byte[64]; - if (rip > 32 && TryReadHostBytes(rip - 32, window)) + if (rip > 32 && TryReadGuestOrHostBytes(rip - 32, window)) { Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + BitConverter.ToString(window).Replace("-", " ")); } @@ -405,7 +411,7 @@ public sealed partial class DirectExecutionBackend continue; } byte[] callSiteWindow = new byte[48]; - if (TryReadHostBytes(candidate - 24, callSiteWindow)) + if (TryReadGuestOrHostBytes(candidate - 24, callSiteWindow)) { Console.Error.WriteLine( $"[LOADER][INFO] Stack guest-code candidate [rsp+0x{stackIndex * 8:X2}]=0x{candidate:X16}, bytes [-0x18..]: " + @@ -417,6 +423,11 @@ public sealed partial class DirectExecutionBackend { Console.Error.WriteLine("[LOADER][ERROR] Could not read code at RIP"); } + // The faulting instruction is the first thing an access + // violation report has to answer, so decode it through guest + // memory as well: that path still works when the host code + // page refuses a plain read. + DumpGuestInstructionStream("fault", rip, 4); DumpRecentImportTrace(); DumpGuestDisasmDiagnostics(rip, rbp, rsp); DumpGuestRegisterWindowDiagnostics( @@ -1463,6 +1474,44 @@ public sealed partial class DirectExecutionBackend return true; } + /// + /// Reads guest bytes for a crash report, preferring the guest memory view + /// over a raw host read. Guest code pages are commonly committed + /// PAGE_EXECUTE, which no host read can satisfy. + /// + private bool TryReadGuestOrHostBytes(ulong address, byte[] buffer) + { + if (_cpuContext is { } context) + { + try + { + if (context.Memory.TryRead(address, buffer)) + { + return true; + } + } + catch + { + // Fall through to the host reader. + } + } + + return TryReadHostBytes(address, buffer); + } + + private unsafe static void DumpHostRegion(string name, ulong address) + { + if (VirtualQuery((void*)address, out var mbi, (nuint)sizeof(MEMORY_BASIC_INFORMATION64)) == 0) + { + Console.Error.WriteLine($"[LOADER][INFO] {name} region: address=0x{address:X16}"); + return; + } + + Console.Error.WriteLine( + $"[LOADER][INFO] {name} region: base=0x{mbi.BaseAddress:X16} size=0x{mbi.RegionSize:X16} " + + $"state=0x{mbi.State:X08} protect=0x{mbi.Protect:X08} type=0x{mbi.Type:X08}"); + } + private unsafe static bool IsReadableHostRange(ulong address, int byteCount, bool allowExecuteOnly = false) { if (byteCount < 0 || !IsCanonicalUserAddress(address)) diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index b707afc..3af1142 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -151,6 +151,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private static readonly ulong GuestThreadTlsBaseAddress = OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL; private const ulong GuestThreadStackSize = 0x0020_0000UL; + private const ulong GuestThreadStackHeadroom = 0x0010_0000UL; + private const ulong GuestThreadStackSizeLimit = 0x0400_0000UL; private const ulong GuestThreadTlsSize = 0x0001_0000UL; @@ -285,7 +287,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private readonly List _importHandlerTrampolines = new List(); - private const int GuestContextTransferFrameQwords = 20; + private const int GuestContextTransferFrameQwords = 22; private readonly object _guestContextTransferStubGate = new(); @@ -1833,7 +1835,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private static bool IsHlePreferredNid(string nid) { - return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) || + // The shipped libc memset body contains a PS5-specific int 45h path + // which is not a Windows host instruction. Use the bounds-aware HLE + // implementation instead of redirecting this import to LLE. + return string.Equals(nid, "8zTFvBIAIN8", StringComparison.Ordinal) || + string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) || string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal); } @@ -2140,6 +2146,13 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I error = $"guest context transfer slot is not writable at 0x{target.Rsp - sizeof(ulong):X16}"; return false; } + ulong stackBottom = 0; + ulong stackTop = 0; + if (OperatingSystem.IsWindows() && !TryGetGuestStackBounds(target.Rsp, out stackBottom, out stackTop)) + { + error = $"guest context transfer target rsp=0x{target.Rsp:X16} has no committed host stack mapping"; + return false; + } transferStub = GetOrCreateGuestContextTransferStub(); if (transferStub == 0) @@ -2176,6 +2189,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I frame[17] = target.Mxcsr == 0 ? 0x1F80u : target.Mxcsr; frame[18] = target.FpuControlWord == 0 ? 0x037Fu : target.FpuControlWord; frame[19] = target.RestoreFullFpuState ? 1u : 0u; + // The TEB stores NT_TIB.StackBase (the high address) at +0x08 and + // NT_TIB.StackLimit (the low address) at +0x10. Keep the frame in that + // same order so the transfer stub can store the slots straight through. + frame[20] = stackTop; + frame[21] = stackBottom; return true; } @@ -2243,6 +2261,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I } Emit(0x49); Emit(0x89); Emit(0xC3); // mov r11, rax + // Fiber switches replace the guest stack without passing through a + // guest-entry stub, so keep the Windows TEB bounds in sync here. + EmitLoadFromR11Disp32(10, 160); // r10 = target stack top + EmitStackBound(code, ref offset, 10, 8, store: true); + EmitLoadFromR11Disp32(10, 168); // r10 = target stack bottom + EmitStackBound(code, ref offset, 10, 16, store: true); // A new >=3.50 fiber receives the SDK-defined MXCSR verbatim. A // resumed fiber follows _sceFiberLongJmp: preserve status bits 0-5 // while restoring the saved control bits. @@ -5342,7 +5366,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I error = "creator context memory is not backed by IVirtualMemory"; return false; } - if (!TryMapGuestThreadRegion(virtualMemory, GuestThreadStackBaseAddress, GuestThreadStackSize, ProgramHeaderFlags.Read | ProgramHeaderFlags.Write, out var stackBase, out error)) + var requestedStackSize = request.StackSize; + if (requestedStackSize > GuestThreadStackSizeLimit) + { + requestedStackSize = GuestThreadStackSizeLimit; + } + + var stackSize = requestedStackSize == 0 + ? GuestThreadStackSize + : AlignUp(requestedStackSize + GuestThreadStackHeadroom, 0x1000UL); + if (stackSize < GuestThreadStackSize) + { + stackSize = GuestThreadStackSize; + } + + if (!TryMapGuestThreadRegion(virtualMemory, GuestThreadStackBaseAddress, stackSize, ProgramHeaderFlags.Read | ProgramHeaderFlags.Write, out var stackBase, out error)) { return false; } @@ -5359,7 +5397,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I FsBase = tlsBase, GsBase = tlsBase, }; - context[CpuRegister.Rsp] = stackBase + GuestThreadStackSize - sizeof(ulong); + context[CpuRegister.Rsp] = stackBase + stackSize - sizeof(ulong); context[CpuRegister.Rdi] = request.Argument; context[CpuRegister.Rsi] = 0; context[CpuRegister.Rdx] = 0; @@ -5382,7 +5420,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I AffinityMask = request.AffinityMask, Context = context, StackBase = stackBase, - StackSize = GuestThreadStackSize, + StackSize = stackSize, State = GuestThreadRunState.Ready, }; error = null; @@ -6774,6 +6812,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I MarkExecutionProgress(); continue; } + if (AreAllLiveGuestThreadsExpectedlyBlocked()) + { + Console.Error.WriteLine( + $"[LOADER][WARN] No import progress for {stallWatchdogSeconds}s, but every live guest thread is parked in an expected blocking primitive; continuing."); + LogStallWatchdogSnapshot(); + Console.Error.Flush(); + MarkExecutionProgress(); + continue; + } if (Interlocked.Exchange(ref _stallWatchdogTriggered, 1) != 0) { continue; @@ -6841,6 +6888,41 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I return false; } + private bool AreAllLiveGuestThreadsExpectedlyBlocked() + { + using (LockGate("AreAllLiveGuestThreadsExpectedlyBlocked")) + { + var sawBlockedThread = false; + foreach (var thread in _guestThreads.Values) + { + if (thread.State is GuestThreadRunState.Exited or GuestThreadRunState.Faulted) + { + continue; + } + + if (thread.State != GuestThreadRunState.Blocked) + { + return false; + } + + if (Volatile.Read(ref thread.LastImportNid) is not ( + "Op8TBGY5KHg" or // pthread_cond_wait + "27bAgiJmOh0" or // pthread_cond_timedwait + "WKAXJ4XBPQ4" or // scePthreadCondWait + "BmMjYxmew1w" or // scePthreadCondTimedwait + "fzyMKs9kim0" or // sceKernelWaitEqueue + "Zxa0VhQVTsk")) // sceKernelWaitSema + { + return false; + } + + sawBlockedThread = true; + } + + return sawBlockedThread; + } + } + private void StopStallWatchdog() { _stallWatchdogStop = true; diff --git a/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs b/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs index e48c32e..d541b3c 100644 --- a/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs +++ b/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs @@ -20,6 +20,15 @@ internal static class GuestRedZonePatcher private const ulong AllocationAlignment = 0x10000; private const ulong MaximumRelativeJumpDistance = 0x7FFF_FFFF; + internal enum SpanRefusal + { + None, + ControlFlow, + BranchTargetAfter, + StackAfter, + TooShort, + } + public static PatchResult Patch( IVirtualMemory memory, PhysicalVirtualMemory physicalMemory, @@ -58,7 +67,8 @@ internal static class GuestRedZonePatcher if (sites.Count == 0) { Console.Error.WriteLine( - $"[LOADER] {hostName} red-zone scan: functions={scan.Functions} red_zone={scan.RedZoneFunctions} sites=0."); + $"[LOADER] {hostName} red-zone scan: functions={scan.Functions} red_zone={scan.RedZoneFunctions} " + + $"sites=0 unrelocatable={scan.UnrelocatableSites}."); return scan; } @@ -83,14 +93,28 @@ internal static class GuestRedZonePatcher var trampolineEnd = trampolineBase + requiredBytes; var patched = 0; var failed = 0; + var previousEnd = 0UL; foreach (var site in sites) { + // Sites are produced in increasing address order. Patching two that + // overlap would write one jump over another and send the trampoline + // into whatever follows, so refuse rather than corrupt guest code. + if (site.Address < previousEnd) + { + Console.Error.WriteLine( + $"[LOADER][WARN] {hostName} red-zone site 0x{site.Address:X16} overlaps the previous one " + + $"ending at 0x{previousEnd:X16}; left unpatched."); + failed++; + continue; + } + if (!TryPatchSite(memory, site, ref trampolineCursor, trampolineEnd, splitVectorStores, rewriteSha)) { failed++; continue; } + previousEnd = site.Address + (ulong)site.ByteLength; patched++; } @@ -99,11 +123,19 @@ internal static class GuestRedZonePatcher CandidateSites = sites.Count, PatchedSites = patched, FailedSites = failed, + UnrelocatableSites = scan.UnrelocatableSites, + ControlFlowRefusals = scan.ControlFlowRefusals, + BranchTargetRefusals = scan.BranchTargetRefusals, + StackRefusals = scan.StackRefusals, + TooShortRefusals = scan.TooShortRefusals, TrampolineBytes = trampolineCursor - trampolineBase, }; Console.Error.WriteLine( $"[LOADER] {hostName} red-zone patch: functions={result.Functions} red_zone={result.RedZoneFunctions} " + $"sites={result.PatchedSites}/{result.CandidateSites} failed={result.FailedSites} " + + $"unrelocatable={result.UnrelocatableSites} " + + $"(control_flow={result.ControlFlowRefusals} branch_target_after={result.BranchTargetRefusals} " + + $"stack_after={result.StackRefusals} too_short={result.TooShortRefusals}) " + $"rosetta_vector_stores={result.VectorStoreCount} sha_rewrites={result.ShaInstructionCount} " + $"trampolines=0x{trampolineBase:X16}+0x{result.TrampolineBytes:X}."); return result; @@ -124,6 +156,8 @@ internal static class GuestRedZonePatcher var redZoneFunctionCount = 0; var instructionCount = 0; var vectorStoreCount = 0; + var unrelocatableSites = 0; + var refusalCounts = new int[5]; var shaInstructionCount = 0; foreach (var header in programHeaders) @@ -174,6 +208,7 @@ internal static class GuestRedZonePatcher functionCount++; instructionCount += decoded.Count; var usesRedZone = protectRedZone && decoded.Any(static entry => UsesRedZone(entry.Instruction)); + if (!usesRedZone && !splitVectorStores && !rewriteSha) { continue; @@ -184,19 +219,43 @@ internal static class GuestRedZonePatcher redZoneFunctionCount++; } var branchTargets = CollectBranchTargets(decoded); + + var lastSiteEnd = 0UL; + for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++) { var instruction = decoded[instructionIndex].Instruction; var isShaSite = rewriteSha && ShaInstructionRewrite.CanRewrite(instruction); - if ((!isShaSite && - ((!usesRedZone && !(splitVectorStores && RosettaVectorStorePatch.RequiresStoreSplit(instruction))) || - !IsFaultableGuestMemoryInstruction(instruction))) || - !TryBuildPatchSpan(decoded, instructionIndex, branchTargets, out var span)) + if (!isShaSite && + ((!usesRedZone && !(splitVectorStores && RosettaVectorStorePatch.RequiresStoreSplit(instruction))) || + !IsFaultableGuestMemoryInstruction(instruction))) { continue; } + // A backward span reaches instructions that an earlier site + // may already have replaced with its own jump. Two jumps + // written over each other corrupt control flow, so a span is + // only usable when it starts at or after the end of the last + // one. Forward spans cannot overlap by construction; the + // check costs nothing and documents the invariant. + var backward = false; + if (!TryBuildPatchSpan(decoded, instructionIndex, branchTargets, out var span, out var refusal)) + { + backward = TryBuildEnclosingPatchSpan(decoded, instructionIndex, branchTargets, out span); + } + + if ((!backward && refusal != SpanRefusal.None) || span.Address < lastSiteEnd) + { + // Refused spans never enter the site list, so they are + // invisible in FailedSites. Count them separately. + unrelocatableSites++; + refusalCounts[(int)refusal]++; + continue; + } + sites.Add(span); + lastSiteEnd = span.Address + (ulong)span.ByteLength; if (splitVectorStores) { vectorStoreCount += span.Instructions.Count(static instruction => RosettaVectorStorePatch.RequiresStoreSplit(instruction)); @@ -205,7 +264,13 @@ internal static class GuestRedZonePatcher { shaInstructionCount += span.Instructions.Count(static instruction => ShaInstructionRewrite.CanRewrite(instruction)); } - instructionIndex += span.Instructions.Count - 1; + + // A backward span already ends at the current instruction, so + // only a forward one consumes the instructions that follow. + if (!backward) + { + instructionIndex += span.Instructions.Count - 1; + } } } } @@ -217,6 +282,11 @@ internal static class GuestRedZonePatcher Instructions = instructionCount, CandidateSites = sites.Count, VectorStoreCount = vectorStoreCount, + UnrelocatableSites = unrelocatableSites, + ControlFlowRefusals = refusalCounts[(int)SpanRefusal.ControlFlow], + BranchTargetRefusals = refusalCounts[(int)SpanRefusal.BranchTargetAfter], + StackRefusals = refusalCounts[(int)SpanRefusal.StackAfter], + TooShortRefusals = refusalCounts[(int)SpanRefusal.TooShort], ShaInstructionCount = shaInstructionCount, }; return sites; @@ -274,20 +344,199 @@ internal static class GuestRedZonePatcher return targets; } + /// + /// Runs the enclosing-span search over a raw instruction range, so the + /// behaviour can be pinned on the byte sequences that occur in real guest + /// code rather than on a mocked decode. + /// + internal static bool TryBuildEnclosingSpan( + byte[] code, + ulong baseAddress, + ulong faultAddress, + out ulong spanAddress, + out int spanLength, + out int coreStart, + out int coreCount) + { + spanAddress = 0; + spanLength = 0; + coreStart = 0; + coreCount = 0; + + var decoded = DecodeFunction(code, baseAddress, baseAddress, baseAddress + (ulong)code.Length); + var faultIndex = -1; + for (var index = 0; index < decoded.Count; index++) + { + if (decoded[index].Instruction.IP == faultAddress) + { + faultIndex = index; + break; + } + } + + if (faultIndex < 0) + { + return false; + } + + if (!TryBuildEnclosingPatchSpan(decoded, faultIndex, CollectBranchTargets(decoded), out var site)) + { + return false; + } + + spanAddress = site.Address; + spanLength = site.ByteLength; + coreStart = site.CoreStart; + coreCount = site.CoreCount; + return true; + } + + /// + /// Finds the faultable instructions of a span. The RSP shift has to bracket + /// all of them and nothing that reads or writes through RSP, because the + /// shift would move such an access by 128 bytes. + /// + private static bool TryComputeShiftedCore(IList instructions, out int coreStart, out int coreCount) + { + coreStart = -1; + var coreEnd = -1; + for (var index = 0; index < instructions.Count; index++) + { + if (!IsFaultableGuestMemoryInstruction(instructions[index])) + { + continue; + } + + if (coreStart < 0) + { + coreStart = index; + } + + coreEnd = index; + } + + if (coreStart < 0) + { + coreCount = 0; + return false; + } + + for (var index = coreStart; index <= coreEnd; index++) + { + if (TouchesStackPointer(instructions[index])) + { + coreCount = 0; + return false; + } + } + + coreCount = coreEnd - coreStart + 1; + return true; + } + + /// + /// Builds a span that ends at the faulting instruction, taking the + /// bytes it needs from the instructions before it. The instruction after the + /// site is never overwritten, so branches into it keep working - which is + /// what the forward search cannot offer when that instruction is a branch + /// target or touches RSP. + /// + private static bool TryBuildEnclosingPatchSpan( + IReadOnlyList decoded, + int faultIndex, + IReadOnlySet branchTargets, + out PatchSite site) + { + site = default; + var faulting = decoded[faultIndex].Instruction; + if (faulting.FlowControl != FlowControl.Next || TouchesStackPointer(faulting)) + { + return false; + } + + // Starting before the faulting instruction only works when nothing + // branches directly to it: such a branch would land inside the new jump. + if (branchTargets.Contains(faulting.IP)) + { + return false; + } + + var byteLength = faulting.Length; + for (var startIndex = faultIndex - 1; startIndex >= 0; startIndex--) + { + var candidate = decoded[startIndex].Instruction; + if (candidate.FlowControl != FlowControl.Next) + { + return false; + } + + byteLength += candidate.Length; + + // A branch target on the first instruction is fine: it lands on the + // jump that replaces the span. One in the middle is not, so stop as + // soon as the span is long enough rather than growing past it. + if (byteLength < MinimumJumpBytes) + { + if (branchTargets.Contains(candidate.IP)) + { + return false; + } + + continue; + } + + var instructions = new List(faultIndex - startIndex + 1); + for (var index = startIndex; index <= faultIndex; index++) + { + instructions.Add(decoded[index].Instruction); + } + + if (!TryComputeShiftedCore(instructions, out var coreStart, out var coreCount)) + { + return false; + } + + site = new PatchSite(candidate.IP, byteLength, instructions, coreStart, coreCount); + return true; + } + + return false; + } + private static bool TryBuildPatchSpan( IReadOnlyList decoded, int startIndex, IReadOnlySet branchTargets, - out PatchSite site) + out PatchSite site) => + TryBuildPatchSpan(decoded, startIndex, branchTargets, out site, out _); + + private static bool TryBuildPatchSpan( + IReadOnlyList decoded, + int startIndex, + IReadOnlySet branchTargets, + out PatchSite site, + out SpanRefusal refusal) { + refusal = SpanRefusal.None; var instructions = new List(3); var byteLength = 0; for (var index = startIndex; index < decoded.Count && byteLength < MinimumJumpBytes; index++) { var instruction = decoded[index].Instruction; - if (instruction.FlowControl != FlowControl.Next || - (index != startIndex && branchTargets.Contains(instruction.IP)) || - TouchesStackPointer(instruction)) + if (instruction.FlowControl != FlowControl.Next) + { + refusal = SpanRefusal.ControlFlow; + } + else if (index != startIndex && branchTargets.Contains(instruction.IP)) + { + refusal = SpanRefusal.BranchTargetAfter; + } + else if (TouchesStackPointer(instruction)) + { + refusal = SpanRefusal.StackAfter; + } + + if (refusal != SpanRefusal.None) { site = default; return false; @@ -299,11 +548,19 @@ internal static class GuestRedZonePatcher if (byteLength < MinimumJumpBytes) { + refusal = SpanRefusal.TooShort; site = default; return false; } - site = new PatchSite(decoded[startIndex].Instruction.IP, byteLength, instructions); + if (!TryComputeShiftedCore(instructions, out var coreStart, out var coreCount)) + { + refusal = SpanRefusal.TooShort; + site = default; + return false; + } + + site = new PatchSite(decoded[startIndex].Instruction.IP, byteLength, instructions, coreStart, coreCount); return true; } @@ -385,6 +642,30 @@ internal static class GuestRedZonePatcher return false; } + private static bool TryEncodeSegment( + IList instructions, + int start, + int count, + ulong address, + out byte[]? encoded) + { + encoded = null; + if (count <= 0) + { + return true; + } + + var writer = new ListCodeWriter(); + var segment = instructions.Skip(start).Take(count).ToList(); + if (!BlockEncoder.TryEncode(64, new InstructionBlock(writer, segment, address), out _, out _, BlockEncoderOptions.None)) + { + return false; + } + + encoded = writer.ToArray(); + return true; + } + private static bool TryPatchSite( IVirtualMemory memory, PatchSite site, @@ -393,9 +674,25 @@ internal static class GuestRedZonePatcher bool splitVectorStores, bool rewriteSha) { + // A span that starts before the faulting instruction may relocate an + // RSP-relative access, which must run before the shift or it would read + // 128 bytes away. Emit such a span as prefix / shifted core / suffix. + // A forward span starts at the faulting instruction and never steals a + // stack-dependent one, so it keeps the original single-block shape. + byte[]? prefix = null; + if (site.CoreStart > 0 && + !TryEncodeSegment(site.Instructions, 0, site.CoreStart, trampolineCursor, out prefix)) + { + return false; + } + + var prefixLength = prefix?.Length ?? 0; var writer = new ListCodeWriter(); - var relocatedAddress = trampolineCursor + 5; - var instructions = splitVectorStores ? RosettaVectorStorePatch.SplitVectorStores(site.Instructions) : site.Instructions; + var relocatedAddress = trampolineCursor + (ulong)prefixLength + 5; + var core = prefix is null + ? site.Instructions + : site.Instructions.Skip(site.CoreStart).Take(site.CoreCount).ToList(); + var instructions = splitVectorStores ? RosettaVectorStorePatch.SplitVectorStores(core) : core; if (rewriteSha) { instructions = ShaInstructionRewrite.Expand(instructions); @@ -407,21 +704,22 @@ internal static class GuestRedZonePatcher } var relocated = writer.ToArray(); - var trampolineLength = checked(5 + relocated.Length + 8 + 5); + var trampolineLength = checked(prefixLength + 5 + relocated.Length + 8 + 5); if (trampolineCursor > trampolineEnd || (ulong)trampolineLength > trampolineEnd - trampolineCursor) { return false; } var trampoline = new byte[trampolineLength]; + prefix?.CopyTo(trampoline, 0); // lea rsp,[rsp-128] -- LEA preserves guest flags. - trampoline[0] = 0x48; - trampoline[1] = 0x8D; - trampoline[2] = 0x64; - trampoline[3] = 0x24; - trampoline[4] = 0x80; - relocated.CopyTo(trampoline, 5); - var restoreOffset = 5 + relocated.Length; + trampoline[prefixLength] = 0x48; + trampoline[prefixLength + 1] = 0x8D; + trampoline[prefixLength + 2] = 0x64; + trampoline[prefixLength + 3] = 0x24; + trampoline[prefixLength + 4] = 0x80; + relocated.CopyTo(trampoline, prefixLength + 5); + var restoreOffset = prefixLength + 5 + relocated.Length; // lea rsp,[rsp+128] trampoline[restoreOffset] = 0x48; trampoline[restoreOffset + 1] = 0x8D; @@ -560,7 +858,19 @@ internal static class GuestRedZonePatcher private readonly record struct DecodedInstruction(Instruction Instruction); - private readonly record struct PatchSite(ulong Address, int ByteLength, IList Instructions); + /// + /// A run of guest instructions relocated into one trampoline. + /// and delimit the + /// faultable instructions the RSP shift must bracket. Instructions outside + /// that core run unshifted, which is what lets a span start before the + /// faulting instruction and still relocate an RSP-relative one correctly. + /// + private readonly record struct PatchSite( + ulong Address, + int ByteLength, + IList Instructions, + int CoreStart, + int CoreCount); internal readonly record struct PatchResult { @@ -580,6 +890,22 @@ internal static class GuestRedZonePatcher public int FailedSites { get; init; } + /// + /// Faultable instructions inside a red-zone function that no trampoline + /// span could cover. These stay unprotected and are not counted by + /// , which only tracks sites that were selected + /// and then failed to patch. + /// + public int UnrelocatableSites { get; init; } + + public int ControlFlowRefusals { get; init; } + + public int BranchTargetRefusals { get; init; } + + public int StackRefusals { get; init; } + + public int TooShortRefusals { get; init; } + public ulong TrampolineBytes { get; init; } } diff --git a/src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs b/src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs new file mode 100644 index 0000000..0703365 --- /dev/null +++ b/src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.GpuMemory; + +// One bit per tracked block: set while the block has at least one CPU-dirty page. +// Regions update their bit under their own lock whenever their CPU-dirty mask +// changes, so a clear bit lets a reader skip the block without taking any lock. +// A reader that must observe a CPU write is ordered after it by the guest's own +// submission, which happens after the write fault published the bit. +public sealed class CpuDirtySummary +{ + private const int WordBits = 64; + private readonly long[] _words = new long[(TrackerLayout.BlockCount + WordBits - 1) / WordBits]; + + public void Set(ulong block, bool dirty) + { + var bit = 1L << (int)(block % WordBits); + ref var word = ref _words[block / WordBits]; + if (dirty) + { + Interlocked.Or(ref word, bit); + } + else + { + Interlocked.And(ref word, ~bit); + } + } + + public bool IsDirty(ulong block) => (Volatile.Read(ref _words[block / WordBits]) & (1L << (int)(block % WordBits))) != 0; +} diff --git a/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs b/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs index be7b5b8..e234511 100644 --- a/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs +++ b/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs @@ -16,12 +16,18 @@ public sealed class GuestPageTracker private readonly PageGuard _pages; private readonly TrackedRegion?[] _regions = new TrackedRegion?[TrackerLayout.BlockCount]; private readonly object _regionGate = new(); + private readonly CpuDirtySummary _cpuDirtySummary = new(); public GuestPageTracker(PageGuard pages) => _pages = pages; public bool HasCpuDirtyPages(ulong vaddr, ulong size) { RejectUploadCallbackReentry(); + if (IsKnownCpuClean(vaddr, size)) + { + return false; + } + return VisitRegions(vaddr, size, create: true, (region, offset, bytes) => { using var _ = region.Lock.Hold(); @@ -341,6 +347,23 @@ public sealed class GuestPageTracker } } + // True when every block of the range is tracked and has no CPU-dirty page. A missing + // region is not known clean: the precise path creates it, fully dirty. + private bool IsKnownCpuClean(ulong vaddr, ulong size) + { + ValidateRange(vaddr, size); + var last = (vaddr + size - 1) / BlockBytes; + for (var index = vaddr / BlockBytes; index <= last; index++) + { + if (Volatile.Read(ref _regions[index]) == null || _cpuDirtySummary.IsDirty(index)) + { + return false; + } + } + + return true; + } + // Visits (region, offset, bytes) per 4 MiB chunk; a true result stops the walk early. private bool VisitRegions(ulong vaddr, ulong size, bool create, Func visit) { @@ -379,7 +402,7 @@ public sealed class GuestPageTracker return existing; } - var created = new TrackedRegion(_pages, index * BlockBytes); + var created = new TrackedRegion(_pages, index * BlockBytes, _cpuDirtySummary); Volatile.Write(ref _regions[index], created); return created; } diff --git a/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs b/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs index ae2f6b9..8e4d87a 100644 --- a/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs +++ b/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs @@ -17,8 +17,9 @@ public sealed class TrackedRegion private PageMask _recentCpuUploads; private PageMask _repeatedCpuWrites; private PageMask _hotCpuWrites; + private readonly CpuDirtySummary? _summary; - public TrackedRegion(PageGuard pages, ulong baseAddress) + public TrackedRegion(PageGuard pages, ulong baseAddress, CpuDirtySummary? summary = null) { if (baseAddress % BlockBytes != 0) { @@ -26,10 +27,12 @@ public sealed class TrackedRegion } _pages = pages; + _summary = summary; BaseAddress = baseAddress; _cpuDirty.Fill(); _writable.Fill(); _readable.Fill(); + _summary?.Set(BaseAddress / BlockBytes, dirty: true); } public RegionLock Lock { get; } = new(); @@ -205,8 +208,10 @@ public sealed class TrackedRegion private ref PageMask GetDirtyMask(WriteOrigin side) => ref side == WriteOrigin.Cpu ? ref _cpuDirty : ref _gpuDirty; + // Every change to the CPU-dirty mask ends here, so it also publishes the block summary. private void UpdateCpuProtection(bool track) { + _summary?.Set(BaseAddress / BlockBytes, _cpuDirty.Any); var mask = _cpuDirty ^ _writable; _writable = _cpuDirty; if (mask.None) diff --git a/src/SharpEmu.HLE/GuestThreadExecution.cs b/src/SharpEmu.HLE/GuestThreadExecution.cs index 595de45..b7b2a81 100644 --- a/src/SharpEmu.HLE/GuestThreadExecution.cs +++ b/src/SharpEmu.HLE/GuestThreadExecution.cs @@ -12,7 +12,8 @@ public readonly record struct GuestThreadStartRequest( ulong AttributeAddress, string Name, int Priority, - ulong AffinityMask); + ulong AffinityMask, + ulong StackSize = 0); public readonly record struct GuestThreadSnapshot( ulong ThreadHandle, diff --git a/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs b/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs index 8bb0251..b629263 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs @@ -31,7 +31,10 @@ public static partial class AgcExports private const ulong ShaderNumShRegistersOffset = 0x5C; private const int ShaderStructBytes = 0x60; private const uint MaximumDeclaredShaderSizeBytes = 1024 * 1024; - private const int MaximumEmbeddedFusedScanBytes = 64 * 1024; + // Embedded GS/HS front/back descriptors can sit beyond the first 64 KiB of + // the upload. Scan the full shader object size accepted by AGC so the + // continuation is registered before resource planning sees the front half. + private const int MaximumEmbeddedFusedScanBytes = 1024 * 1024; private const ulong FusedShaderImageAlignment = 4; private const byte ComputeShaderType = 0; private const byte PsShaderType = 1; @@ -166,27 +169,6 @@ public static partial class AgcExports return false; } - var upload = new byte[MaximumEmbeddedFusedScanBytes]; - var bytesRead = 0; - const int readChunkBytes = 4 * 1024; - while (bytesRead < upload.Length) - { - var chunkLength = Math.Min(readChunkBytes, upload.Length - bytesRead); - if (!ctx.Memory.TryRead( - entryCodeAddress + (ulong)bytesRead, - upload.AsSpan(bytesRead, chunkLength))) - { - break; - } - - bytesRead += chunkLength; - } - - if (bytesRead < ShaderStructBytes) - { - return false; - } - var requiredContinuationType = entryType == GsFrontShaderType ? GsBackShaderType : HsBackShaderType; @@ -201,57 +183,95 @@ public static partial class AgcExports ulong bestCodeAddress = 0; ulong bestHeaderAddress = 0; var bestDistance = ulong.MaxValue; - for (var offset = 0; - offset <= bytesRead - ShaderStructBytes; - offset += sizeof(uint)) + + bool ScanForContinuation(ulong scanAddress) { - var descriptor = upload.AsSpan(offset, ShaderStructBytes); - if (BinaryPrimitives.ReadUInt32LittleEndian(descriptor) != ShaderFileHeader || - BinaryPrimitives.ReadUInt32LittleEndian(descriptor[sizeof(uint)..]) != ShaderVersion || - descriptor[(int)ShaderTypeOffset] != requiredContinuationType) + var upload = new byte[MaximumEmbeddedFusedScanBytes]; + var bytesRead = 0; + const int readChunkBytes = 4 * 1024; + while (bytesRead < upload.Length) { - continue; + var chunkLength = Math.Min(readChunkBytes, upload.Length - bytesRead); + if (!ctx.Memory.TryRead( + scanAddress + (ulong)bytesRead, + upload.AsSpan(bytesRead, chunkLength))) + { + break; + } + + bytesRead += chunkLength; } - var continuationCodeAddress = BinaryPrimitives.ReadUInt64LittleEndian( - descriptor[(int)ShaderCodeOffset..]); - var continuationSize = BinaryPrimitives.ReadUInt32LittleEndian( - descriptor[(int)ShaderSizeOffset..]); - if (continuationCodeAddress <= entryCodeAddress || - continuationCodeAddress - entryCodeAddress > uint.MaxValue || - !IsValidDeclaredShaderSize(continuationSize) || - !CanReadShaderRange(ctx, continuationCodeAddress, continuationSize)) + if (bytesRead < ShaderStructBytes) { - continue; + return false; } - var continuationSpecialsAddress = BinaryPrimitives.ReadUInt64LittleEndian( - descriptor[(int)ShaderSpecialsOffset..]); - if (entrySpecialsAddress != 0 && continuationSpecialsAddress != 0) + var found = false; + for (var offset = 0; + offset <= bytesRead - ShaderStructBytes; + offset += sizeof(uint)) { - if (!TryReadUInt32( - ctx, - entrySpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), - out var entryStages) || - !TryReadUInt32( - ctx, - continuationSpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), - out var continuationStages) || - ((entryStages ^ continuationStages) & waveSizeBit) != 0) + var descriptor = upload.AsSpan(offset, ShaderStructBytes); + if (BinaryPrimitives.ReadUInt32LittleEndian(descriptor) != ShaderFileHeader || + BinaryPrimitives.ReadUInt32LittleEndian(descriptor[sizeof(uint)..]) != ShaderVersion || + descriptor[(int)ShaderTypeOffset] != requiredContinuationType) { continue; } + + var continuationCodeAddress = BinaryPrimitives.ReadUInt64LittleEndian( + descriptor[(int)ShaderCodeOffset..]); + var continuationSize = BinaryPrimitives.ReadUInt32LittleEndian( + descriptor[(int)ShaderSizeOffset..]); + if (continuationCodeAddress <= entryCodeAddress || + continuationCodeAddress - entryCodeAddress > uint.MaxValue || + !IsValidDeclaredShaderSize(continuationSize) || + !CanReadShaderRange(ctx, continuationCodeAddress, continuationSize)) + { + continue; + } + + var continuationSpecialsAddress = BinaryPrimitives.ReadUInt64LittleEndian( + descriptor[(int)ShaderSpecialsOffset..]); + if (entrySpecialsAddress != 0 && continuationSpecialsAddress != 0) + { + if (!TryReadUInt32( + ctx, + entrySpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), + out var entryStages) || + !TryReadUInt32( + ctx, + continuationSpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), + out var continuationStages) || + ((entryStages ^ continuationStages) & waveSizeBit) != 0) + { + continue; + } + } + + var distance = continuationCodeAddress - entryCodeAddress; + if (distance >= bestDistance) + { + continue; + } + + bestDistance = distance; + bestCodeAddress = continuationCodeAddress; + bestHeaderAddress = scanAddress + (ulong)offset; + found = true; } - var distance = continuationCodeAddress - entryCodeAddress; - if (distance >= bestDistance) - { - continue; - } + return found; + } - bestDistance = distance; - bestCodeAddress = continuationCodeAddress; - bestHeaderAddress = entryCodeAddress + (ulong)offset; + // Most uploads place the second descriptor beside the code. Some + // titles keep both descriptors in the header allocation instead, so + // inspect that region as a fallback. + ScanForContinuation(entryCodeAddress); + if (bestHeaderAddress == 0) + { + ScanForContinuation(entryHeaderAddress); } if (bestHeaderAddress == 0) @@ -422,6 +442,13 @@ public static partial class AgcExports return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + // Both halves share one user-data block, so the fused USER_SGPR count is + // the larger of the two. + if (!MergeFusedUserScalarCount(ctx, frontRegistersAddress, frontRegisterCount, fusedRegistersAddress, registerCount)) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + for (var occurrence = 0; occurrence < 2; occurrence++) { if (!TryFindShaderRegister(ctx, fusedRegistersAddress, registerCount, SpiShaderPgmChksumGs, occurrence, out var fusedEntry) || @@ -1216,6 +1243,27 @@ public static partial class AgcExports (frontType == GsFrontShaderType && backType == GsBackShaderType) || (frontType == HsFrontShaderType && backType == HsBackShaderType); + private static uint UserScalarCount(uint rsrc2) => ((rsrc2 >> 1) & 0x1Fu) | (((rsrc2 >> 27) & 1u) << 5); + + private static bool MergeFusedUserScalarCount(CpuContext ctx, ulong frontRegisters, int frontCount, ulong fusedRegisters, int fusedCount) + { + if (!TryFindShaderRegister(ctx, frontRegisters, frontCount, SpiShaderPgmRsrc2Gs, 0, out var frontEntry) || + !TryFindShaderRegister(ctx, fusedRegisters, fusedCount, SpiShaderPgmRsrc2Gs, 0, out var fusedEntry)) + { + return true; + } + + if (!TryReadUInt32(ctx, frontEntry + sizeof(uint), out var front) || + !TryReadUInt32(ctx, fusedEntry + sizeof(uint), out var fused)) + { + return false; + } + + var count = Math.Max(UserScalarCount(front), UserScalarCount(fused)); + var merged = (fused & ~((0x1Fu << 1) | (1u << 27))) | ((count & 0x1Fu) << 1) | ((count >> 5) << 27); + return merged == fused || TryWriteUInt32(ctx, fusedEntry + sizeof(uint), merged); + } + private static bool TryFindShaderRegister( CpuContext ctx, ulong registersAddress, diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index e7af284..60f29fe 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -99,6 +99,7 @@ public static partial class AgcExports private const uint SpiShaderPgmLoGs = 0x88; private const uint SpiShaderPgmHiGs = 0x89; private const uint SpiShaderPgmRsrc1Gs = 0x8A; + private const uint SpiShaderPgmRsrc2Gs = 0x8B; private const uint SpiShaderPgmChksumGs = 0x80; private const uint SpiShaderPgmChksumPs = 0x06; private const uint SpiPsInputEna = 0x1B3; diff --git a/src/SharpEmu.Libs/Ampr/AmprExports.cs b/src/SharpEmu.Libs/Ampr/AmprExports.cs index e940a1d..355762f 100644 --- a/src/SharpEmu.Libs/Ampr/AmprExports.cs +++ b/src/SharpEmu.Libs/Ampr/AmprExports.cs @@ -965,6 +965,8 @@ public static class AmprExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + private static int _unknownReadFileIdWarnings; + private static int CompleteReadFileRecord(CpuContext ctx, ulong commandBuffer, ReadFileCommand command) { var fileId = command.FileId; @@ -982,6 +984,13 @@ public static class AmprExports if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath)) { + // An unknown id stalls the guest's stream silently, so warn even without tracing. + if (Interlocked.Increment(ref _unknownReadFileIdWarnings) <= 16) + { + Console.Error.WriteLine( + $"[LOADER][WARN] ampr.read_unknown_file_id id=0x{fileId:X8} size=0x{size:X} offset=0x{fileOffset:X}"); + } + TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; } diff --git a/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs b/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs index f24fe97..3e77db0 100644 --- a/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs +++ b/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs @@ -38,6 +38,9 @@ internal static class AmprFileRegistry private static readonly object _resolvedFileGate = new(); private static readonly Dictionary _resolvedIdsByPath = new(HostFsPath.Comparer); private static readonly ConcurrentDictionary _resolvedPathsById = new(); + // Ids already handed to the guest; alias publishes must not re-poison them. + private static readonly ConcurrentDictionary _aprResolvedPathsById = new(); + private static readonly ConcurrentDictionary _loggedAprCollisionIds = new(); // Resolved handles use a separate range from the 31-bit compatibility hashes. // Never reuse a handle while queued reads can still refer to it. private static uint _nextResolvedId = 0x80000000; @@ -67,24 +70,52 @@ internal static class AmprFileRegistry public static uint RegisterAprResolvedPath(string guestPath, string hostPath) { - if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0) - { - RegisterApp0Relative(relative, hostPath); - } - // APR file ids are part of the guest ABI: ResolveFilepathsToIds returns // the 31-bit FNV-1a hash of the guest path. Keep the collision-safe // process-local handles used by Register() separate from this path so a // title can compare resolved ids with ids baked into its asset tables. + // A hash shared by two files (13 /app0/ pairs in Demon's Souls) falls back to a handle. var fileId = ComputeFileId(guestPath); - PublishCompatibilityPath(fileId, hostPath, AliasRank.Resolved); - return fileId; + lock (_resolvedFileGate) + { + if (_aprResolvedPathsById.TryGetValue(fileId, out var claimedPath)) + { + if (IsSameHostFile(claimedPath, hostPath)) + return fileId; + } + else if (!_hostPathsById.TryGetValue(fileId, out var indexed) || + (!indexed.Ambiguous && IsSameHostFile(indexed.HostPath, hostPath))) + { + if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0) + { + RegisterApp0Relative(relative, hostPath); + } + + PublishCompatibilityPath(fileId, hostPath, AliasRank.Resolved); + _aprResolvedPathsById[fileId] = hostPath; + return fileId; + } + } + + var handle = Register(guestPath, hostPath); + if (_loggedAprCollisionIds.TryAdd(fileId, 0)) + { + Console.Error.WriteLine( + $"[LOADER][WARN] ampr.apr_id_collision id=0x{fileId:X8} path='{guestPath}' handle=0x{handle:X8}"); + } + + return handle; } + private static bool IsSameHostFile(string left, string right) => + HostFsPath.Comparer.Equals(Path.GetFullPath(left), Path.GetFullPath(right)); + public static bool TryGetHostPath(uint id, out string hostPath) { if ((id & 0x80000000) != 0) return _resolvedPathsById.TryGetValue(id, out hostPath!); + if (_aprResolvedPathsById.TryGetValue(id, out hostPath!)) + return true; if (_hostPathsById.TryGetValue(id, out var entry) && !entry.Ambiguous) { hostPath = entry.HostPath; @@ -101,8 +132,10 @@ internal static class AmprFileRegistry lock (_indexGate) { _hostPathsById.Clear(); + _loggedAprCollisionIds.Clear(); lock (_resolvedFileGate) { + _aprResolvedPathsById.Clear(); _resolvedIdsByPath.Clear(); _resolvedPathsById.Clear(); _nextResolvedId = 0x80000000; @@ -417,7 +450,23 @@ internal static class AmprFileRegistry Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SharpEmu", "ampr-index"); - Directory.CreateDirectory(cacheDir); + try + { + Directory.CreateDirectory(cacheDir); + var probePath = Path.Combine(cacheDir, $".write-test-{Environment.ProcessId}"); + using (File.Create(probePath)) { } + File.Delete(probePath); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + // Sandboxed launches and mitigated child processes can lose access + // to the profile AppData directory. Keep the index beside the + // executable so the next launch can still reuse it. + cacheDir = Path.Combine(AppContext.BaseDirectory, "user", "ampr-index"); + Directory.CreateDirectory(cacheDir); + Console.Error.WriteLine( + $"[LOADER][WARN] ampr.app0_index_cache_fallback path={cacheDir}: {exception.Message}"); + } // Distinct roots must not share a cache file. Folding case is only // correct where the host filesystem folds it too. diff --git a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs index 4e1e683..7ac5d65 100644 --- a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs +++ b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs @@ -497,18 +497,23 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable var index = _registry.FindFirstOverlappingIndex(guestAddress); for (; index < _registry.RegisteredCount && _registry.GetRegisteredAddress(index) < end; index++) { - var buffer = _registry.GetBuffer(_registry.GetRegisteredIdentifier(index)); + var identifier = _registry.GetRegisteredIdentifier(index); + var buffer = _registry.GetBuffer(identifier); var start = Math.Max(buffer.CpuAddress, guestAddress); var finish = Math.Min(buffer.CpuAddress + buffer.Size, end); if (start < finish) { if (GuestGpuMemoryHook.Traces(start, finish - start)) GuestGpuMemoryHook.Trace(start, finish - start, - $"device-address-touch buffer={_registry.GetRegisteredIdentifier(index)} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); + $"device-address-touch buffer={identifier} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); // Clean buffers remain in use through their device addresses. - TouchBuffer(buffer); + TouchBuffer(identifier); // Device-address reads reuse persistent buffers; track writes after each upload. - _ = SynchronizeBuffer(buffer, start, finish - start, false, false, preserveCpuWriteHotPages: false); + // A range without CPU-dirty pages has nothing to upload (the same early exit + // SynchronizeBuffer takes for this read-only call), and the block summary + // answers that without a lock for the common all-clean case. + if (_tracker.HasCpuDirtyPages(start, finish - start)) + _ = SynchronizeBuffer(buffer, start, finish - start, false, false, preserveCpuWriteHotPages: false); } } } @@ -1052,7 +1057,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable BufferUploadProfile.Record(guestAddress, size, copies.Count, totalSize, hotBytes, elapsedTicks); } - if (isTexelBuffer && !isWritten) + if (isTexelBuffer) { return RequireImageCache().TrySynchronizeBufferFromImage(buffer, guestAddress, size); } diff --git a/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs b/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs index 4516ef0..47af475 100644 --- a/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs +++ b/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs @@ -190,12 +190,27 @@ public sealed partial class GpuCommandInterpreter SubmitFlipWithInterrupt(payload[4], payload[5], Address(payload[1], payload[2]), payload[3]); break; default: - throw _host.Fatal($"The marker is unknown: offset=0x{packet.Offset:X5} marker=0x{markerId:X} address=0x{packet.PacketAddress:X16}."); + // A marker id this interpreter has no side effect for (profiling/debug annotations such as + // 0x6EC and 0x80C in Demon's Souls). The packet carries no work, so it is consumed; the id is + // reported once so a marker that later turns out to matter is still visible. + NoteUnknownMarker(markerId, packet); + break; } return packet.Length - 1; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary _reportedUnknownMarkers = new(); + + private static void NoteUnknownMarker(uint markerId, in PacketContext packet) + { + if (_reportedUnknownMarkers.TryAdd(markerId, true)) + { + Console.Error.WriteLine( + $"[LOADER][WARN] command_stream.marker_ignored marker=0x{markerId:X} offset=0x{packet.Offset:X5} address=0x{packet.PacketAddress:X16}"); + } + } + internal uint PushMarkerPacket(in PacketContext packet, ReadOnlySpan payload) => packet.Length - 1; internal uint PopMarkerPacket(in PacketContext packet, ReadOnlySpan payload) => packet.Length - 1; diff --git a/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs b/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs index 398d061..7bf81b4 100644 --- a/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs +++ b/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs @@ -68,6 +68,7 @@ public sealed unsafe partial class CachedImage : IDisposable private bool _maybeHashValid; private bool _gpuModified; private bool _bufferModified; + private bool _bufferHoldsGpuContents; public ImageDescription Description; public readonly ImageBacking Backing = new(); @@ -130,9 +131,10 @@ public sealed unsafe partial class CachedImage : IDisposable Backing.Usage = create.Usage; var vk = device.Vk; - if (vk.CreateImage(device.Device, &create, null, out Backing.Handle) != Result.Success) + var createResult = vk.CreateImage(device.Device, &create, null, out Backing.Handle); + if (createResult != Result.Success) { - throw CreateFailure(create); + throw CreateFailure(create, "vkCreateImage", createResult, 0); } vk.GetImageMemoryRequirements(device.Device, Backing.Handle, out var requirements); @@ -158,21 +160,33 @@ public sealed unsafe partial class CachedImage : IDisposable } } - if (allocated != Result.Success || vk.BindImageMemory(device.Device, Backing.Handle, Backing.Memory, 0) != Result.Success) + if (allocated != Result.Success) { vk.DestroyImage(device.Device, Backing.Handle, null); device.FreeMemory(Backing.Memory); Backing.Handle = default; Backing.Memory = default; - throw CreateFailure(create); + throw CreateFailure(create, "vkAllocateMemory", allocated, requirements.Size); + } + + var bindResult = vk.BindImageMemory(device.Device, Backing.Handle, Backing.Memory, 0); + if (bindResult != Result.Success) + { + vk.DestroyImage(device.Device, Backing.Handle, null); + device.FreeMemory(Backing.Memory); + Backing.Handle = default; + Backing.Memory = default; + throw CreateFailure(create, "vkBindImageMemory", bindResult, requirements.Size); } Backing.AllocationSize = requirements.Size; } - private static Exception CreateFailure(in ImageCreateInfo create) => + private static Exception CreateFailure(in ImageCreateInfo create, string operation, Result result, ulong requiredBytes) => SubmissionScheduler.Fatal( - $"The image could not be created: extent={create.Extent.Width}x{create.Extent.Height}x{create.Extent.Depth} format={(int)create.Format} layers={create.ArrayLayers} levels={create.MipLevels}."); + $"The image could not be created: operation={operation} result={result} required_bytes={requiredBytes} " + + $"extent={create.Extent.Width}x{create.Extent.Height}x{create.Extent.Depth} format={create.Format}({(int)create.Format}) " + + $"layers={create.ArrayLayers} levels={create.MipLevels} usage=0x{(uint)create.Usage:X} flags=0x{(uint)create.Flags:X}."); internal static bool TrySelectSupportedImageConfiguration(IImageFormatSupport device, ref ImageCreateInfo configuration, bool allowCompressedImageFallback) { @@ -351,12 +365,21 @@ public sealed unsafe partial class CachedImage : IDisposable public bool IsGpuModified => _gpuModified; - public void MarkGpuModified() => _gpuModified = true; + public void MarkGpuModified() + { + _gpuModified = true; + _bufferHoldsGpuContents = false; + } public void ClearGpuModified() => _gpuModified = false; public bool IsBufferModified => _bufferModified; + // The guest buffer cache holds this image's current GPU contents. + public bool BufferHoldsGpuContents => _bufferHoldsGpuContents; + + public void MarkBufferHoldsGpuContents() => _bufferHoldsGpuContents = true; + public void MarkBufferModified() => _bufferModified = true; public void ClearBufferModified() => _bufferModified = false; diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs index 0b31a83..1e6bbf5 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs @@ -349,6 +349,11 @@ public sealed partial class GuestImageCache case ImageRole.Texture: recreate |= requested.IsDepth && !cachedInfo.IsDepth; recreate |= rawD16Texture; + // A color texture that reads neither the depth nor the stencil plane + // reinterprets the memory as color, so its bytes move to a color image. + recreate |= cachedInfo.IsDepth && !requested.IsDepth && + !ViewFormatRules.IsDepthCompatible(requested.PixelFormat) && + !ViewFormatRules.IsStencilViewFormat(requested.PixelFormat); break; case ImageRole.StorageImage: case ImageRole.ColorTarget: @@ -440,23 +445,15 @@ public sealed partial class GuestImageCache ref var cachedInfo = ref cached.Description; var currentTick = _scheduler.CurrentTick; var safeToDelete = currentTick - Math.Min(currentTick, cached.LastAccessTick) > TicksBeforeRemoval; + var requestedBlock = requested.BytesPerBlock * requested.Samples; + var cachedBlock = cachedInfo.BytesPerBlock * cachedInfo.Samples; + var requestedBlockExtent = requested.BlockExtent; + var cachedBlockExtent = cachedInfo.BlockExtent; - if (requested.Data.Address == cachedInfo.Data.Address) + if (requested.Data.Address == cachedInfo.Data.Address && + requestedBlockExtent.Width == cachedBlockExtent.Width && requestedBlockExtent.Height == cachedBlockExtent.Height && + requestedBlock == cachedBlock) { - var requestedBlock = requested.BytesPerBlock * requested.Samples; - var cachedBlock = cachedInfo.BytesPerBlock * cachedInfo.Samples; - var requestedBlockExtent = requested.BlockExtent; - var cachedBlockExtent = cachedInfo.BlockExtent; - if (requestedBlockExtent.Width != cachedBlockExtent.Width || requestedBlockExtent.Height != cachedBlockExtent.Height || requestedBlock != cachedBlock) - { - if (safeToDelete) - { - ReleaseImage(cachedImageIdentifier); - } - - return new OverlapResolution(mergedImageIdentifier); - } - var depthImageIdentifier = ResolveDepthOverlap(requested, role, cachedImageIdentifier); if (depthImageIdentifier.IsValid) { @@ -509,50 +506,32 @@ public sealed partial class GuestImageCache $"type={(uint)requested.Type}/{(uint)cachedInfo.Type} tile={(uint)requested.TileMode}/{(uint)cachedInfo.TileMode}."); } - if (requested.Data.Address > cachedInfo.Data.Address) + var requestedMip = requested.FindMatchingMipLevel(cachedInfo); + if (requestedMip >= 0) { - var mip = requested.FindMatchingMipLevel(cachedInfo); - if (mip >= 0) - { - var layer = requested.FindMatchingArraySlice(cachedInfo, mip); - if (layer >= 0) - { - return new OverlapResolution(cachedImageIdentifier, mip, layer); - } - } - - if (safeToDelete) - { - ReleaseImage(cachedImageIdentifier); - } - - return new OverlapResolution(ResourceSlotIdentifier.Invalid); + var layer = requested.FindMatchingArraySlice(cachedInfo, requestedMip); + return new OverlapResolution(cachedImageIdentifier, requestedMip, layer); } var containedMip = cachedInfo.FindMatchingMipLevel(requested); if (containedMip >= 0) { var containedLayer = cachedInfo.FindMatchingArraySlice(requested, containedMip); - if (containedLayer >= 0) + if (!mergedImageIdentifier.IsValid) { - if (cached.Binding.IsTarget) - { - cached.Binding.NeedsRebind = true; - if (mergedImageIdentifier.IsValid) - { - _slots[mergedImageIdentifier].Binding.IsTarget = true; - } - - ReleaseImage(cachedImageIdentifier); - return new OverlapResolution(mergedImageIdentifier); - } - - if (mergedImageIdentifier.IsValid) - { - CopyIntoMip(mergedImageIdentifier, cachedImageIdentifier, (uint)containedMip, (uint)containedLayer); - ReleaseImage(cachedImageIdentifier); - } + return new OverlapResolution(GrowImage(requested, cachedImageIdentifier)); } + + cached.Binding.NeedsRebind |= cached.Binding.IsBound || cached.Binding.IsTarget; + _slots[mergedImageIdentifier].Binding.IsTarget |= cached.Binding.IsTarget; + CopyIntoMip(mergedImageIdentifier, cachedImageIdentifier, (uint)containedMip, (uint)containedLayer); + ReleaseImage(cachedImageIdentifier); + return new OverlapResolution(mergedImageIdentifier); + } + + if (requested.Data.Address >= cachedInfo.Data.Address && safeToDelete) + { + ReleaseImage(cachedImageIdentifier); } return new OverlapResolution(mergedImageIdentifier); @@ -572,7 +551,17 @@ public sealed partial class GuestImageCache } PopulateFromGuest(expandedImageIdentifier, ImageRequest.Refresh(description, UploadRole(source)), "expand"); - CopyWholeImage(expandedImageIdentifier, sourceImageIdentifier); + var mip = source.Description.FindMatchingMipLevel(description); + var layer = source.Description.FindMatchingArraySlice(description, mip); + if (layer >= 0) + { + CopyIntoMip(expandedImageIdentifier, sourceImageIdentifier, (uint)mip, (uint)layer); + } + else + { + CopyWholeImage(expandedImageIdentifier, sourceImageIdentifier); + } + ReleaseImage(sourceImageIdentifier); return expandedImageIdentifier; } diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs index 0255c37..08a4fd9 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs @@ -653,6 +653,7 @@ public sealed unsafe partial class GuestImageCache } DownloadToBuffer(image, buffer, bufferOffset, copySize, plan); + image.MarkBufferHoldsGpuContents(); return true; } diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs b/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs index ea973fb..50a0f04 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs @@ -315,6 +315,7 @@ public static class GuestPixelFormats new(GuestPixelFormat.Bits16Float, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8UNorm, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8SNorm, 2, 0, 2, true, false), + new(GuestPixelFormat.Bits8_8UScaled, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8UInt, 2, 0, 2, true, true), new(GuestPixelFormat.Bits8_8SInt, 2, 0, 2, true, false, true), new(GuestPixelFormat.Bits32UInt, 4, 0, 4, true, true), @@ -325,6 +326,9 @@ public static class GuestPixelFormats new(GuestPixelFormat.Bits16_16UInt, 4, 0, 4, true, true), new(GuestPixelFormat.Bits16_16SInt, 4, 0, 4, true, false, true), new(GuestPixelFormat.Bits16_16Float, 4, 0, 4, true, false), + // Vulkan has no normalized 11:11:10 format. Keep the guest packing sampled + // through the native 32-bit packed-float view, matching the host backend. + new(GuestPixelFormat.Bits11_11_10UNorm, 4, 0, 4, true, false), new(GuestPixelFormat.Bits11_11_10UInt, 4, 0, 4, true, true), new(GuestPixelFormat.Bits11_11_10Float, 4, 0, 4, true, false), new(GuestPixelFormat.Bits10_10_10_2UNorm, 4, 0, 4, true, false), @@ -347,8 +351,8 @@ public static class GuestPixelFormats new(GuestPixelFormat.Bits32_32_32_32UInt, 16, 0, 16, true, true), new(GuestPixelFormat.Bits32_32_32_32SInt, 16, 0, 16, true, false, true), new(GuestPixelFormat.Bits32_32_32_32Float, 16, 0, 16, true, false), - new(GuestPixelFormat.Bits8Srgb, 1, 0, 0, true, false), - new(GuestPixelFormat.Bits8_8Srgb, 2, 0, 0, true, false), + new(GuestPixelFormat.Bits8Srgb, 1, 0, 1, true, false), + new(GuestPixelFormat.Bits8_8Srgb, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8_8_8Srgb, 4, 0, 4, true, false), new(GuestPixelFormat.Bits9_9_9_5Float, 4, 0, 0, true, false), new(GuestPixelFormat.Bits5_6_5UNorm, 2, 0, 2, true, false), @@ -432,6 +436,7 @@ public static class GuestPixelFormats (GuestPixelFormat.Bits16Float, Format.R16Sfloat), (GuestPixelFormat.Bits8_8UNorm, Format.R8G8Unorm), (GuestPixelFormat.Bits8_8SNorm, Format.R8G8SNorm), + (GuestPixelFormat.Bits8_8UScaled, Format.R8G8Uscaled), (GuestPixelFormat.Bits8_8UInt, Format.R8G8Uint), (GuestPixelFormat.Bits8_8SInt, Format.R8G8Sint), (GuestPixelFormat.Bits32UInt, Format.R32Uint), @@ -442,6 +447,7 @@ public static class GuestPixelFormats (GuestPixelFormat.Bits16_16UInt, Format.R16G16Uint), (GuestPixelFormat.Bits16_16SInt, Format.R16G16Sint), (GuestPixelFormat.Bits16_16Float, Format.R16G16Sfloat), + (GuestPixelFormat.Bits11_11_10UNorm, Format.B10G11R11UfloatPack32), (GuestPixelFormat.Bits11_11_10Float, Format.B10G11R11UfloatPack32), (GuestPixelFormat.Bits10_10_10_2UNorm, Format.A2B10G10R10UnormPack32), (GuestPixelFormat.Bits10_10_10_2UInt, Format.A2B10G10R10UintPack32), diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs b/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs index 3c45d77..ce58daa 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs @@ -131,8 +131,29 @@ public struct ImageDescription // Return the matching mip level in the containing image, or -1 if none matches. public readonly int FindMatchingMipLevel(in ImageDescription container) { - if (!IsCompatible(container) || TileMode != container.TileMode || Resources.Levels != 1 || - container.Resources.Layers == 0 || container.Resources.Levels > MaxLevels) + if (container.Resources.Levels > MaxLevels) + { + return -1; + } + + for (uint level = 0; level < container.Resources.Levels; level++) + { + if (FindMatchingArraySlice(container, (int)level) >= 0) + { + return (int)level; + } + } + + return -1; + } + + // Return the matching slice in the containing mip level, or -1 if none matches. + public readonly int FindMatchingArraySlice(in ImageDescription container, int mip) + { + if (!IsCompatible(container) || TileMode != container.TileMode || Type != container.Type || IsVolume || + Resources.Levels != 1 || Resources.Layers == 0 || container.Resources.Layers == 0 || mip < 0 || + (uint)mip >= container.Resources.Levels || container.Resources.Levels > MaxLevels || + !IsValidRange(Data) || !IsValidRange(container.Data) || Data.Address < container.Data.Address || Data.End > container.Data.End) { return -1; } @@ -143,95 +164,38 @@ public struct ImageDescription return -1; } - var mip = -1; - for (uint level = 0; level < container.Resources.Levels; level++) - { - var layout = container.MipLayout[(int)level]; - if (layout.Size == 0 || layout.Size % container.Resources.Layers != 0 || container.Data.Address > ulong.MaxValue - layout.Offset) - { - continue; - } - - var mipBase = container.Data.Address + layout.Offset; - var sliceSize = layout.Size / container.Resources.Layers; - if (sliceSize == 0 || mipBase > ulong.MaxValue - layout.Size) - { - continue; - } - - var mipEnd = mipBase + layout.Size; - if (Data.Address >= mipBase && Data.Address < mipEnd && (Data.Address - mipBase) % sliceSize == 0) - { - mip = (int)level; - break; - } - } - - if (mip < 0) - { - return -1; - } - - if (Extent.Width != Math.Max(container.Extent.Width >> mip, 1) || Extent.Height != Math.Max(container.Extent.Height >> mip, 1)) - { - return -1; - } - - var mipDepth = Math.Max(container.Extent.Depth >> mip, 1); - if (container.Type == GuestImageType.Color3D && Type == GuestImageType.Color2D) - { - if (Resources.Layers != mipDepth) - { - return -1; - } - } - else if (Type != container.Type) - { - return -1; - } - - return mip; - } - - // Return the matching slice in the containing mip level, or -1 if none matches. - public readonly int FindMatchingArraySlice(in ImageDescription container, int mip) - { - if (!IsCompatible(container) || Type != container.Type || mip < 0 || (uint)mip >= container.Resources.Levels || - container.Resources.Levels > MaxLevels || container.Resources.Layers == 0 || Data.Size == 0) - { - return -1; - } - - if (Extent.Width != Math.Max(container.Extent.Width >> mip, 1) || Extent.Height != Math.Max(container.Extent.Height >> mip, 1)) - { - return -1; - } - var layout = container.MipLayout[mip]; - if (layout.Size == 0 || layout.Size % container.Resources.Layers != 0 || container.Data.Address > ulong.MaxValue - layout.Offset) + if (Extent.Width != Math.Max(container.Extent.Width >> mip, 1) || Extent.Height != Math.Max(container.Extent.Height >> mip, 1) || + Extent.Depth != container.Extent.Depth || MipLayout[0].Pitch != layout.Pitch || MipLayout[0].Height != layout.Height || + layout.Size == 0 || layout.Size % container.Resources.Layers != 0 || + container.Data.Size % container.Resources.Layers != 0 || Data.Size % Resources.Layers != 0) { return -1; } - var sliceSize = layout.Size / container.Resources.Layers; - if (sliceSize == 0 || Data.Size % sliceSize != 0) + // PS5 array slices contain the whole reversed mip chain, rather than + // storing all slices of one mip contiguously. + var sliceStride = container.Data.Size / container.Resources.Layers; + var childStride = Data.Size / Resources.Layers; + if (childStride != layout.Size / container.Resources.Layers || layout.Offset >= sliceStride || + childStride > sliceStride - layout.Offset || (Resources.Layers > 1 && childStride != sliceStride)) { return -1; } - var mipBase = container.Data.Address + layout.Offset; - if (Data.Address < mipBase) + var addressDelta = Data.Address - container.Data.Address; + if (addressDelta < layout.Offset || (addressDelta - layout.Offset) % sliceStride != 0) { return -1; } - var delta = Data.Address - mipBase; - if (delta % Data.Size != 0 || delta / Data.Size > int.MaxValue) + var layer = (addressDelta - layout.Offset) / sliceStride; + if (layer > int.MaxValue || layer >= container.Resources.Layers || Resources.Layers > container.Resources.Layers - layer) { return -1; } - return (int)(delta / Data.Size); + return (int)layer; } public readonly bool IsSupportedDepthTarget diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs index 816d7fc..1be295f 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs @@ -122,6 +122,17 @@ public static partial class ImageRequestBuilders } var depth = volume ? words.Depth + 1 : 1; + // Some Prospero titles encode a full 3D attachment with SliceMax as + // the exclusive depth (one past the last slice). The hardware accepts + // that form, while Vulkan needs the actual slice count for a 2D-array + // view. Normalize only this exact one-past range; other invalid views + // remain rejected below. + if (volume && view.BaseLayer == 0 && view.LayerCount == depth + 1) + { + view = new TargetViewRange(0, depth, depth); + Console.Error.WriteLine( + $"[LOADER][INFO] normalized an exclusive 3D color-target slice range to depth={depth}"); + } var tileMode = words.TileMode; var standard4 = tileMode == GuestTileMode.Standard4KB; var standard64 = tileMode == GuestTileMode.Standard64KB; diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs index 9212e10..82e9a4f 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs @@ -240,7 +240,8 @@ public static partial class ImageRequestBuilders { pixelFormat = depthFormat.DepthAttachmentFormat; } - var storageViewFormat = storage && format == GuestPixelFormat.Bits32SInt ? Format.R32Uint : ViewFormatRules.SrgbStorageFormat(pixelFormat); + // Atomic storage images are declared as UINT in SPIR-V, including float atomics. + var storageViewFormat = storage && (shape.Atomic || format == GuestPixelFormat.Bits32SInt) ? Format.R32Uint : ViewFormatRules.SrgbStorageFormat(pixelFormat); var viewFormat = storage && storageViewFormat != Format.Undefined ? storageViewFormat : pixelFormat; var blockBytes = GuestPixelFormats.BlockCompressedBytes(format); var description = ImageDescription.Create(); diff --git a/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs b/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs index c2f8e20..466023e 100644 --- a/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs +++ b/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs @@ -42,28 +42,30 @@ public enum SamplerBorderColor : uint FromTable = 3, } -// One Vulkan sampler per distinct guest sampler descriptor, created on first use. +// One Vulkan sampler per distinct guest sampler descriptor and view numeric class, +// created on first use. The guest border color has no numeric class; Vulkan's has to +// match the sampled view, and integer views cannot be filtered. public sealed unsafe class SamplerStore : IDisposable { private readonly GpuDeviceInfo _device; - private readonly Dictionary<(uint, uint, uint, uint), Sampler> _samplers = new(); + private readonly Dictionary<(uint, uint, uint, uint, bool), Sampler> _samplers = new(); private readonly object _gate = new(); public SamplerStore(GpuDeviceInfo device) => _device = device; public int Count => _samplers.Count; - public Sampler GetSampler(in SamplerDescriptorWords words) + public Sampler GetSampler(in SamplerDescriptorWords words, bool integerView) { lock (_gate) { - var key = (words[0], words[1], words[2], words[3]); + var key = (words[0], words[1], words[2], words[3], integerView); if (_samplers.TryGetValue(key, out var existing)) { return existing; } - var sampler = CreateSampler(words); + var sampler = CreateSampler(words, integerView); _samplers.Add(key, sampler); return sampler; } @@ -96,10 +98,10 @@ public sealed unsafe class SamplerStore : IDisposable _ => throw SubmissionScheduler.Fatal($"The sampler clamp mode is unknown: clamp={clamp}."), }; - private Sampler CreateSampler(in SamplerDescriptorWords words) + private Sampler CreateSampler(in SamplerDescriptorWords words, bool integerView) { - var magnify = words.MagnifyFilter; - var minify = words.MinifyFilter; + var magnify = integerView ? (uint)SamplerFilter.Point : words.MagnifyFilter; + var minify = integerView ? (uint)SamplerFilter.Point : words.MinifyFilter; var anisotropic = IsAnisotropic(magnify) || IsAnisotropic(minify); var anisotropyRatio = 1.0f; if (anisotropic) @@ -117,6 +119,11 @@ public sealed unsafe class SamplerStore : IDisposable } var mipFilter = (SamplerMipFilter)words.MipFilter; + if (integerView && mipFilter == SamplerMipFilter.Linear) + { + mipFilter = SamplerMipFilter.Point; + } + var minLod = 0.0f; var maxLod = 0.0f; if (mipFilter != SamplerMipFilter.None) @@ -125,26 +132,21 @@ public sealed unsafe class SamplerStore : IDisposable maxLod = words.MaxLod / 256.0f; } - BorderColor border; - switch ((SamplerBorderColor)words.BorderColorType) + var borderType = (SamplerBorderColor)words.BorderColorType; + if (borderType == SamplerBorderColor.FromTable) { - case SamplerBorderColor.TransparentBlack: - border = BorderColor.IntTransparentBlack; - break; - case SamplerBorderColor.OpaqueBlack: - border = BorderColor.IntOpaqueBlack; - break; - case SamplerBorderColor.OpaqueWhite: - border = BorderColor.IntOpaqueWhite; - break; - case SamplerBorderColor.FromTable: - Console.Error.WriteLine($"[LOADER][WARN] A table border color is approximated as transparent black: index={words.BorderColorIndex}."); - border = BorderColor.IntTransparentBlack; - break; - default: - throw SubmissionScheduler.Fatal($"The border color type is unknown: type={words.BorderColorType}."); + Console.Error.WriteLine($"[LOADER][WARN] A table border color is approximated as transparent black: index={words.BorderColorIndex}."); + borderType = SamplerBorderColor.TransparentBlack; } + var border = borderType switch + { + SamplerBorderColor.TransparentBlack => integerView ? BorderColor.IntTransparentBlack : BorderColor.FloatTransparentBlack, + SamplerBorderColor.OpaqueBlack => integerView ? BorderColor.IntOpaqueBlack : BorderColor.FloatOpaqueBlack, + SamplerBorderColor.OpaqueWhite => integerView ? BorderColor.IntOpaqueWhite : BorderColor.FloatOpaqueWhite, + _ => throw SubmissionScheduler.Fatal($"The border color type is unknown: type={words.BorderColorType}."), + }; + var info = new SamplerCreateInfo { SType = StructureType.SamplerCreateInfo, diff --git a/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs b/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs index 592d979..1b3fbb9 100644 --- a/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs +++ b/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs @@ -106,10 +106,10 @@ public readonly record struct DisplaySurfaceWords( bool Compressed); // The shape the shader module was compiled with; the view type must match it. -public readonly record struct ShaderImageShape(bool Volume, bool Arrayed, bool Cube, bool Storage, bool DynamicMip, TextureNumericClass NumericClass, bool OneDimensional = false, bool R128 = false, bool Multisampled = false, bool DepthCompare = false) +public readonly record struct ShaderImageShape(bool Volume, bool Arrayed, bool Cube, bool Storage, bool DynamicMip, TextureNumericClass NumericClass, bool OneDimensional = false, bool R128 = false, bool Multisampled = false, bool DepthCompare = false, bool Atomic = false) { public ShaderImageShape(bool volume, bool arrayed, bool storage, bool dynamicMip, TextureNumericClass numericClass) - : this(volume, arrayed, false, storage, dynamicMip, numericClass, false, false, false) + : this(volume, arrayed, false, storage, dynamicMip, numericClass, false, false, false, false, false) { } } diff --git a/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs b/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs index 3d1e646..4485662 100644 --- a/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs +++ b/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs @@ -173,63 +173,57 @@ public static partial class TileGeometry switch (bytesPerElement) { case 1: - offset ^= (y << 2) & 0x0008; - offset ^= (y << 4) & 0x0010; - offset ^= (y << 3) & 0x00a0; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 6) & 0x1000; - offset ^= (y << 7) & 0x4000; - offset ^= x & 0x0007; - offset ^= (x << 3) & 0x0040; - offset ^= (x << 5) & 0x0300; - offset ^= (x << 4) & 0x0400; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 7) & 0x2000; - offset ^= (x << 8) & 0x8000; + offset ^= Bit(x, 0, 0) ^ Bit(x, 1, 1) ^ Bit(x, 2, 2) ^ Bit(x, 3, 3); + offset ^= Bit(y, 0, 4) ^ Bit(y, 1, 5) ^ Bit(y, 2, 6) ^ Bit(y, 3, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(x, 6, 12) ^ Bit(y, 6, 13); + offset ^= Bit(x, 7, 14) ^ Bit(y, 8, 14); + offset ^= Bit(x, 8, 15) ^ Bit(y, 7, 15); return offset; case 2: - offset ^= (y << 4) & 0x0070; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 8) & 0x5000; - offset ^= (x << 1) & 0x000e; - offset ^= (x << 4) & 0x0480; - offset ^= (x << 5) & 0x0300; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 7) & 0x2000; - offset ^= (x << 8) & 0x8000; + offset ^= Bit(x, 0, 1) ^ Bit(x, 1, 2) ^ Bit(x, 2, 3); + offset ^= Bit(y, 0, 4) ^ Bit(y, 1, 5) ^ Bit(y, 2, 6) ^ Bit(x, 3, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(y, 3, 12) ^ Bit(x, 6, 13); + offset ^= Bit(x, 7, 14) ^ Bit(y, 7, 14); + offset ^= Bit(x, 8, 15) ^ Bit(y, 6, 15); return offset; case 4: - offset ^= (y << 4) & 0x0070; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 9) & 0x1000; - offset ^= (y << 8) & 0x4000; - offset ^= (x << 2) & 0x000c; - offset ^= (x << 5) & 0x0380; - offset ^= (x << 4) & 0x0400; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 9) & 0xa000; + offset ^= Bit(x, 0, 2) ^ Bit(x, 1, 3); + offset ^= Bit(y, 0, 4) ^ Bit(y, 1, 5) ^ Bit(x, 2, 6) ^ Bit(y, 2, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(x, 3, 12) ^ Bit(y, 3, 13); + offset ^= Bit(x, 6, 14) ^ Bit(y, 7, 14); + offset ^= Bit(x, 7, 15) ^ Bit(y, 6, 15); return offset; case 8: - offset ^= (y << 4) & 0x0010; - offset ^= (y << 6) & 0x0080; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 10) & 0x5000; - offset ^= (x << 3) & 0x0008; - offset ^= (x << 4) & 0x0460; - offset ^= (x << 5) & 0x0300; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 10) & 0x2000; - offset ^= (x << 9) & 0x8000; + offset ^= Bit(x, 0, 3) ^ Bit(y, 0, 4) ^ Bit(x, 1, 5) ^ Bit(x, 2, 6) ^ Bit(y, 1, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(y, 2, 12) ^ Bit(x, 3, 13); + offset ^= Bit(x, 7, 14) ^ Bit(y, 3, 14); + offset ^= Bit(x, 6, 15) ^ Bit(y, 6, 15); return offset; case 16: - offset ^= (x << 4) & 0x0410; - offset ^= (x << 5) & 0x0340; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 11) & 0xa000; - offset ^= (y << 5) & 0x0f20; - offset ^= (y << 6) & 0x0080; - offset ^= (y << 10) & 0x1000; - offset ^= (y << 11) & 0x4000; + offset ^= Bit(x, 0, 4) ^ Bit(y, 0, 5) ^ Bit(x, 1, 6) ^ Bit(y, 1, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(x, 2, 12) ^ Bit(y, 2, 13); + offset ^= Bit(x, 6, 14) ^ Bit(y, 3, 14); + offset ^= Bit(x, 3, 15) ^ Bit(y, 6, 15); return offset; default: throw SubmissionScheduler.Fatal($"The render-target tile block does not support this element size: bytes={bytesPerElement}."); diff --git a/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs b/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs index f2a81c3..bd51d5c 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs @@ -130,6 +130,19 @@ public static class ViewFormatRules _ => None, }; + // Integer views sample raw integers: no filtering, integer border colors. + public static bool IsIntegerFormat(Format format) => format is + Format.R8Uint or Format.R8Sint or Format.R8G8Uint or Format.R8G8Sint or + Format.R8G8B8A8Uint or Format.R8G8B8A8Sint or Format.B8G8R8A8Uint or Format.B8G8R8A8Sint or + Format.A8B8G8R8UintPack32 or Format.A8B8G8R8SintPack32 or + Format.A2B10G10R10UintPack32 or Format.A2B10G10R10SintPack32 or + Format.A2R10G10B10UintPack32 or Format.A2R10G10B10SintPack32 or + Format.R16Uint or Format.R16Sint or Format.R16G16Uint or Format.R16G16Sint or + Format.R16G16B16A16Uint or Format.R16G16B16A16Sint or + Format.R32Uint or Format.R32Sint or Format.R32G32Uint or Format.R32G32Sint or + Format.R32G32B32Uint or Format.R32G32B32Sint or Format.R32G32B32A32Uint or Format.R32G32B32A32Sint or + Format.S8Uint; + // The bytes of one texel block of a host format; 0 for a format outside the compatibility classes. public static uint BlockBytes(Format format) { diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs index c84cc52..d456471 100644 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs @@ -164,6 +164,9 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel bool IShaderPipelineHost.GraphicsSubgroupOperationsEnabled => true; + // Metal has no 64-bit workgroup atomics here; keep the non-atomic 32-bit pair. + bool IShaderPipelineHost.SharedInt64AtomicsEnabled => false; + RenderHostLimits IShaderPipelineHost.Limits => new(MaxDimension, MaxDimension, MaxDimension, MaxDimension); SampleCountFlags IShaderPipelineHost.NoAttachmentSampleCounts => @@ -174,6 +177,9 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel bool IShaderPipelineHost.TryReadCleanGuestWord(ulong address, out uint word) => TryReadWord(address, out word); + bool IShaderPipelineHost.TryReadResidentGuestBytes(ulong address, Span destination, bool clean) => + Memory.TryRead(address, destination); + private bool TryReadWord(ulong address, out uint word) { Span bytes = stackalloc byte[sizeof(uint)]; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs new file mode 100644 index 0000000..bc1f494 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.Libs.Gpu.Pipelines; + +// A compute program that stores one dword, read from a constant buffer, into every +// record of a buffer: the element index is group * 64 + thread. +// v_lshl_add_u32 v0, s, 6, v0 +// s_buffer_load_dword s, s[:+3], 0 +// s_waitcnt +// v_mov_b32 v1, s +// buffer_store_format_x v1, v0, s[:+3], 0 idxen +// s_endpgm +// Unreal clears DCC metadata this way, so the dispatch must reach the image cache as a +// clear; the metadata is not emulated and the stores alone would leave the image stale. +public sealed record ConstantFill(uint GroupScalarRegister, uint DestinationScalarResource, uint SourceScalarResource); + +public static class ConstantFillDetector +{ + private const uint Shift64 = 134; // inline constant 6 + private const uint ZeroConstant = 128; + private const uint NullScalar = 125; + + public static ConstantFill? Detect(Gen5ShaderProgram program) + { + var instructions = program.Instructions.Where(instruction => instruction.Opcode != "SWaitcnt").ToArray(); + if (instructions.Length != 5) + { + return null; + } + + var index = instructions[0]; + if (index.Opcode != "VLshlAddU32" || index.Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } indexRegister] || + index.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } group, { Kind: Gen5OperandKind.EncodedConstant, Value: Shift64 }, + { Kind: Gen5OperandKind.VectorRegister } thread] || thread.Value != indexRegister.Value) + { + return null; + } + + var load = instructions[1]; + if (load.Opcode != "SBufferLoadDword" || load.Control is not Gen5ScalarMemoryControl { ImmediateOffsetBytes: 0, DynamicOffsetRegister: null } || + load.Destinations is not [{ Kind: Gen5OperandKind.ScalarRegister } value] || + load.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } source, { Kind: Gen5OperandKind.EncodedConstant, Value: NullScalar or ZeroConstant }]) + { + return null; + } + + var move = instructions[2]; + if (move.Opcode != "VMovB32" || move.Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } data] || + move.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } moved] || moved.Value != value.Value) + { + return null; + } + + var store = instructions[3]; + if (store.Opcode != "BufferStoreFormatX" || + store.Control is not Gen5BufferMemoryControl { DwordCount: 1, OffsetBytes: 0, IndexEnabled: true, OffsetEnabled: false, Typed: false } control || + control.VectorAddress != indexRegister.Value || control.VectorData != data.Value || + store.Sources is not [_, _, { Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }]) + { + return null; + } + + return instructions[4].Opcode == "SEndpgm" ? new ConstantFill(group.Value, control.ScalarResource, source.Value) : null; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs b/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs index aac1816..e5f0a20 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs @@ -38,6 +38,9 @@ internal interface IShaderPipelineHost bool GraphicsSubgroupOperationsEnabled { get; } + // The device supports shaderSharedInt64Atomics, so LDS 64-bit atomics can be + // emitted as real 64-bit atomics instead of a non-atomic 32-bit pair. + bool SharedInt64AtomicsEnabled { get; } bool PerVertexPixelInputsSupported => true; RenderHostLimits Limits { get; } @@ -53,6 +56,11 @@ internal interface IShaderPipelineHost // Reads one guest dword only when no GPU work may still own the range. bool TryReadCleanGuestWord(ulong address, out uint word); + // Copies guest bytes the CPU already holds, without synchronizing. False when the GPU + // may own the range (or, for a clean read, when a clean word read would be refused); + // the resource cache then re-materializes instead of trusting a stale copy. + bool TryReadResidentGuestBytes(ulong address, Span destination, bool clean) => false; + // Creates the host module of one compiled permutation and returns its handle. ulong CreateShaderModule(IGuestCompiledShader shader, ShaderStage stage, ulong hash, ulong programId); diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs b/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs index 27d8aed..1421bc5 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs @@ -43,19 +43,44 @@ public static class PipelineCacheSignature public static bool TryUnwrap(string signature, ReadOnlySpan file, out byte[] payload) { payload = []; - var prefix = Encoding.ASCII.GetBytes(signature); - if (file.Length < prefix.Length + sizeof(ulong) || file.Length > int.MaxValue) + if (file.Length > int.MaxValue) { return false; } - if (!file[..prefix.Length].SequenceEqual(prefix)) + var newline = file.IndexOf((byte)'\n'); + if (newline < 0) { return false; } - var expectedHash = BinaryPrimitives.ReadUInt64LittleEndian(file[prefix.Length..]); - var data = file[(prefix.Length + sizeof(ulong))..]; + var actualSignature = Encoding.ASCII.GetString(file[..(newline + 1)]); + if (!string.Equals(actualSignature, signature, StringComparison.Ordinal)) + { + // Pipeline binaries are keyed by the Vulkan device and driver; + // the SharpEmu build version only controls which shader records + // may be present. A newer build can safely reuse an older driver + // cache because Vulkan validates each pipeline key and ignores + // stale records. Keep the cache when the hardware suffix matches. + var expectedParts = signature.TrimEnd('\n').Split(':'); + var actualParts = actualSignature.TrimEnd('\n').Split(':'); + if (expectedParts.Length != actualParts.Length || + expectedParts.Length < 6 || + !string.Equals(expectedParts[0], actualParts[0], StringComparison.Ordinal) || + !expectedParts[2..].SequenceEqual(actualParts[2..], StringComparer.Ordinal)) + { + return false; + } + } + + var hashOffset = newline + 1; + if (file.Length < hashOffset + sizeof(ulong)) + { + return false; + } + + var expectedHash = BinaryPrimitives.ReadUInt64LittleEndian(file[hashOffset..]); + var data = file[(hashOffset + sizeof(ulong))..]; if (XxHash3.HashToUInt64(data) != expectedHash) { return false; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs index d2143c5..a74faff 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs @@ -15,7 +15,7 @@ internal static class ShaderPermutationDump private static long _sequence; public static Action? CreateFailureCapture(ShaderSource source) => - CompiledShaderDump.ShouldWrite(source.Address) ? CreateEnabledFailureCapture(source) : null; + CompiledShaderDump.ShouldWrite(source.Address, source.Hash) ? CreateEnabledFailureCapture(source) : null; // Keep captured shader state out of the disabled per-draw path. private static Action CreateEnabledFailureCapture(ShaderSource source) => @@ -32,7 +32,7 @@ internal static class ShaderPermutationDump uint pushDataCursor, ulong programId) { - if (!CompiledShaderDump.ShouldWrite(source.Address)) return null; + if (!CompiledShaderDump.ShouldWrite(source.Address, source.Hash)) return null; try { var sequence = Interlocked.Increment(ref _sequence); @@ -90,7 +90,7 @@ internal static class ShaderPermutationDump public static void WriteIndirectImageFailure(ShaderSource source, IndirectImageFailure failure) { - if (!CompiledShaderDump.ShouldWrite(source.Address)) return; + if (!CompiledShaderDump.ShouldWrite(source.Address, source.Hash)) return; try { var sequence = Interlocked.Increment(ref _sequence); diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs index 8660f20..43a8a09 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs @@ -384,6 +384,7 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider RenderTrace.Write( $"PipelineCache create graphics vertex=0x{vertexProgram.Id:X16} pixel=0x{key.PixelProgramId:X16} colors={key.Rendering.ColorCount} " + $"depth={description.StaticParameters.WithDepth} samples={description.StaticParameters.Samples}"); + } var created = _host.CreateGraphicsPipeline(description); @@ -443,7 +444,15 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider // host output would otherwise write an undefined value (e.g. depth-only passes that // leave a color target bound and export only to the null target). var exported = pixelStage is null || ((pixelStage.PixelColorExportMasks >> (int)(color.Slot * 4)) & 0xFu) != 0; - parameters.SetColorMask(index, exported ? color.Resolution.ExportMapping.ApplyMask(context.RenderTargetMaskForSlot(color.Slot)) : 0); + var colorMask = exported ? color.Resolution.ExportMapping.ApplyMask(context.RenderTargetMaskForSlot(color.Slot)) : 0; + parameters.SetColorMask(index, colorMask); + if (RenderTrace.Enabled && RenderTrace.Pipeline()) + { + RenderTrace.Write( + $"PipelineCache output slot={color.Slot} guestMask=0x{context.RenderTargetMaskForSlot(color.Slot):X} " + + $"exported={(exported ? 1 : 0)} shaderMask=0x{pixelStage?.PixelColorExportMasks ?? 0:X8} " + + $"mapping=0x{color.Resolution.ExportMapping.Packed:X2} hostMask=0x{colorMask:X} format={(int)format}"); + } } var withDepth = depth.HasTarget; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs index 2debf3e..7f73a85 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs @@ -67,7 +67,7 @@ internal static class ShaderPlanningDump private static void Write(ShaderSource source, string suffix, Action write) { - if (!CompiledShaderDump.ShouldWrite(source.Address)) return; + if (!CompiledShaderDump.ShouldWrite(source.Address, source.Hash)) return; try { var path = $"{CompiledShaderDump.GetBasePath(source.Label, source.Address, source.Hash)}.{suffix}"; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs index ef29e5f..0f8c87b 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs @@ -85,6 +85,7 @@ internal sealed class ProgramSourceEntry public required ShaderResourcePlan Plan { get; init; } public required Gen5ShaderProgram Program { get; init; } public required bool HasBitwiseExclusiveOr { get; init; } + public ConstantFill? ConstantFill { get; init; } public EmbeddedVertexFetchPlan? EmbeddedFetch { get; init; } public ShaderVertexInput[] VertexInputs { get; init; } = []; public List Permutations { get; } = new(8); @@ -102,8 +103,14 @@ internal sealed class ShaderProgramCache private readonly Dictionary _programs = new(); private readonly Dictionary<(ulong Hash, uint CodeSize), Gen5ShaderProgram> _decoded = new(); private readonly List _staticState = new(StageStaticKey.MaxWords); + // Draws that re-bind unchanged resources reuse the last materialization. + // SHARPEMU_RESOURCE_CACHE=0 materializes every draw, for A/B comparisons. + private readonly ResourceMaterializationCache? _materializations = + Environment.GetEnvironmentVariable("SHARPEMU_RESOURCE_CACHE") == "0" ? null : new(); private ulong _nextProgramId; + public ResourceMaterializationCache? Materializations => _materializations; + public ShaderProgramCache(CpuContext context, IGuestGpuBackend compiler, IShaderPipelineHost host) { _context = context; @@ -211,9 +218,15 @@ internal sealed class ShaderProgramCache var captureIndirectImageFailure = ShaderPermutationDump.CreateFailureCapture(source); using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ResourceMaterialization)) { - if (!ResourceMaterializer.Materialize(entry.Plan, inputs, ref snapshot, ref specialization, out var materializationFailure, captureIndirectImageFailure)) + // Failure capture needs the full walk, so a dump run bypasses the cache. + var materialized = _materializations is not null && captureIndirectImageFailure is null + ? _materializations.Materialize(entry.Plan, inputs, _host.TryReadResidentGuestBytes, ref snapshot, ref specialization, + out var materializationFailure) + : ResourceMaterializer.Materialize(entry.Plan, inputs, ref snapshot, ref specialization, out materializationFailure, + captureIndirectImageFailure); + if (!materialized) { - var message = $"The shader resources could not be materialized: stage={source.Label} hash=0x{source.Hash:X16} shader=0x{source.Address:X16}."; + var message = $"The shader resources could not be materialized: stage={source.Label} hash=0x{source.Hash:X16} shader=0x{source.Address:X16} reason={materializationFailure}."; if (materializationFailure is ResourceMaterializationFailure.IncompatibleImageCandidates or ResourceMaterializationFailure.ImageCapacityExceeded) throw new ShaderProgramRejectedException(message); throw SubmissionScheduler.Fatal(message); @@ -283,7 +296,7 @@ internal sealed class ShaderProgramCache private ProgramSourceEntry CreateEntry(ShaderSource source, StageCompileOptions options) { var program = Decode(source); - var dumpPlanning = CompiledShaderDump.ShouldWrite(source.Address); + var dumpPlanning = CompiledShaderDump.ShouldWrite(source.Address, source.Hash); if (dumpPlanning) ShaderPlanningDump.WriteInput(source, program); EmbeddedVertexFetchPlan? fetch = null; ShaderVertexInput[] vertexInputs = []; @@ -326,6 +339,7 @@ internal sealed class ShaderProgramCache Plan = plan, Program = program, HasBitwiseExclusiveOr = exclusiveOr, + ConstantFill = source.Stage == ShaderStage.Compute ? ConstantFillDetector.Detect(program) : null, EmbeddedFetch = fetch, VertexInputs = vertexInputs, }; @@ -521,6 +535,7 @@ internal sealed class ShaderProgramCache BindingLayout layout) { var enableGraphicsSubgroups = _host.GraphicsSubgroupOperationsEnabled; + var sharedInt64Atomics = _host.SharedInt64AtomicsEnabled; switch (source.Stage) { case ShaderStage.Vertex: @@ -532,6 +547,7 @@ internal sealed class ShaderProgramCache TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, ScratchDwords = info.ScratchDwords, EnableGraphicsSubgroupOperations = enableGraphicsSubgroups, + SupportsSharedInt64Atomics = sharedInt64Atomics, RequiredVertexOutputCount = options.RequiredVertexOutputCount, VertexInputs = entry.VertexInputs, PositionExportControl = info.PositionExportControl, @@ -557,6 +573,7 @@ internal sealed class ShaderProgramCache TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, ScratchDwords = info.ScratchDwords, EnableGraphicsSubgroupOperations = enableGraphicsSubgroups, + SupportsSharedInt64Atomics = sharedInt64Atomics, PixelOutputs = options.PixelOutputs, PixelInputEnable = options.PixelInputEnable, PixelCustomInterpolationMask = info.CustomInterpolationMask, @@ -574,6 +591,7 @@ internal sealed class ShaderProgramCache WaveSize = info.WaveSize, TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, ScratchDwords = info.ScratchDwords, + SupportsSharedInt64Atomics = sharedInt64Atomics, ComputeSystemRegisters = options.ComputeSystemRegisters, LocalSizeX = Math.Max(info.ThreadsX, 1), LocalSizeY = Math.Max(info.ThreadsY, 1), @@ -637,6 +655,7 @@ internal sealed class ShaderProgramCache InstanceOffsetScalarRegister = entry.EmbeddedFetch?.InstanceOffsetScalarRegister ?? ShaderProgramInfo.NoScalarRegister, UsesDeviceAddresses = info.UsesDeviceAddresses, HasBitwiseExclusiveOr = entry.HasBitwiseExclusiveOr, + ConstantFill = entry.ConstantFill, Buffers = buffers, Images = images, SamplerCount = info.Samplers.Count, @@ -652,7 +671,7 @@ internal sealed class ShaderProgramCache // Writes each compiled module and its decoded listing when the dump switch is on. internal static class CompiledShaderDump { - internal static bool ShouldWrite(ulong shaderAddress) + internal static bool ShouldWrite(ulong shaderAddress, ulong shaderHash) { if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DUMP_SPIRV"), "1", StringComparison.Ordinal)) { @@ -675,6 +694,27 @@ internal static class CompiledShaderDump } } + var hashFilter = Environment.GetEnvironmentVariable("SHARPEMU_DUMP_SPIRV_HASH"); + if (!string.IsNullOrWhiteSpace(hashFilter)) + { + foreach (var filter in hashFilter.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var span = filter.AsSpan(); + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + span = span[2..]; + } + + if (ulong.TryParse(span, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var filteredHash) && + shaderHash == filteredHash) + { + return true; + } + } + + return false; + } + return true; } @@ -692,7 +732,7 @@ internal static class CompiledShaderDump public static void Write(string stage, ulong shaderAddress, ulong hash, IGuestCompiledShader shader, Gen5ShaderProgram program) { - if (shader.Payload.Length == 0 || !ShouldWrite(shaderAddress)) return; + if (shader.Payload.Length == 0 || !ShouldWrite(shaderAddress, hash)) return; var basePath = GetBasePath(stage, shaderAddress, hash); File.WriteAllBytes($"{basePath}.{shader.PayloadFileExtension}", shader.Payload); var lines = new List(program.Instructions.Count + 2) diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs b/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs index 24e532c..4265201 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs @@ -41,13 +41,17 @@ public static class VertexInputResolver var attributes = new List(); VertexInputBuffer[] buffers = []; - if (metadata.VertexAttributeRegister >= 0) + // A shader without input semantics never reads its vertex tables, which may be null. + if (metadata.VertexAttributeRegister >= 0 && metadata.InputSemantics.Length != 0) { var attributeTable = ReadTablePointer(userData, metadata.VertexAttributeRegister); var bufferTable = ReadTablePointer(userData, metadata.VertexBufferRegister); if (attributeTable == 0 || bufferTable == 0) { - throw SubmissionScheduler.Fatal($"The vertex table pointer is null: shader=0x{shader.CodeAddress:X16} attributes=0x{attributeTable:X16} buffers=0x{bufferTable:X16}."); + throw SubmissionScheduler.Fatal( + $"The vertex table pointer is null: shader=0x{shader.CodeAddress:X16} attributes=0x{attributeTable:X16} buffers=0x{bufferTable:X16} " + + $"attributeRegister={metadata.VertexAttributeRegister} bufferRegister={metadata.VertexBufferRegister} " + + $"user_data=[{string.Join(",", userData.ToArray().Select(word => $"{word:X8}"))}]."); } attributes = ApplySemantics(context, metadata.InputSemantics, attributeTable, bufferTable, shader.CodeAddress); diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs index c32885e..c94f419 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs @@ -3,6 +3,7 @@ using SharpEmu.Libs.Gpu.GpuCommands.Registers; using SharpEmu.Libs.Gpu.Scheduling; +using SharpEmu.ShaderCompiler.Vulkan; using Silk.NET.Vulkan; using ResourceSnapshot = SharpEmu.ShaderCompiler.Resources.ResourceSnapshot; @@ -18,6 +19,7 @@ public sealed partial class RenderExecutor private const uint DispatchInitiatorKnownMask = DispatchInitiatorBaseBits | DispatchInitiatorModifierBits; private const uint ImageClearDispatchInitiator = 0x61; private const uint ImageClearWaveSize = 64; + private const uint Format32UInt = 20; private const uint ImageClearStride = 16; private const uint ImageClearUserDataCount = 8; @@ -82,6 +84,12 @@ public sealed partial class RenderExecutor return; } + if (indirectArgumentsAddress == 0 && TryConsumeConstantFill(input, groupsX, groupsY, groupsZ)) + { + _host.ResetBindings(); + return; + } + if (indirectArgumentsAddress == 0 && TryConsumeImageClear(input, groupsX, groupsY, groupsZ, dispatchInitiator)) { _host.ResetBindings(); @@ -143,9 +151,25 @@ public sealed partial class RenderExecutor } _host.BindPipeline(PipelineBindPoint.Compute, in pipeline); + // The shader's local workgroup axes may have been remapped at compile time + // (see Gen5SpirvTranslator.ComputeWorkgroupAxisOrder) so the largest NUM_THREAD + // axis lands on a physical axis Vulkan actually allows it on. The dispatch group + // counts must be permuted the same way, or vkCmdDispatch would hand group counts + // for the wrong physical axis to the pipeline it built with the remapped sizes. + var axisOrder = Gen5SpirvTranslator.ComputeWorkgroupAxisOrder( + input.ThreadsX, + input.ThreadsY, + input.ThreadsZ); + var logicalGroups = new[] { groupsX, groupsY, groupsZ }; + var physicalGroups = new uint[3]; + for (var logical = 0; logical < 3; logical++) + { + physicalGroups[axisOrder[logical]] = logicalGroups[logical]; + } + if (indirectArgumentsAddress == 0 || !_host.TryDispatchIndirect(indirectArgumentsAddress)) { - _host.Dispatch(groupsX, groupsY, groupsZ); + _host.Dispatch(physicalGroups[0], physicalGroups[1], physicalGroups[2]); } _host.ShaderAccessBarrier(); } @@ -276,6 +300,46 @@ public sealed partial class RenderExecutor return new ComputeImageClear(descriptor, clear, size); } + // A constant fill that covers a whole image or DCC metadata becomes a clear. Anything + // else, including a fill of plain buffer memory, runs as the guest wrote it. + private bool TryConsumeConstantFill(ComputeInputInfo input, uint groupsX, uint groupsY, uint groupsZ) + { + var program = input.Stage.Program!; + if (program.ConstantFill is not { } fill || program.UserDataBase != 0) + { + return false; + } + + var userData = input.Stage.Resources.UserData; + if (fill.GroupScalarRegister != (uint)input.WorkgroupRegister || !input.GroupIdX || input.GroupIdY || input.GroupIdZ || + input.ThreadsX != ImageClearWaveSize || input.ThreadsY != 1 || input.ThreadsZ != 1 || groupsY != 1 || groupsZ != 1 || + fill.DestinationScalarResource + 4 > userData.Length || fill.SourceScalarResource + 4 > userData.Length) + { + return false; + } + + var destination = BufferDescriptorWords.From(userData.AsSpan((int)fill.DestinationScalarResource, 4).ToArray()); + var source = BufferDescriptorWords.From(userData.AsSpan((int)fill.SourceScalarResource, 4).ToArray()); + Span valueBytes = stackalloc byte[sizeof(uint)]; + if (destination.Format != Format32UInt || destination.Stride != sizeof(uint) || destination.SwizzleEnabled || destination.AddThreadId || + (ulong)groupsX * input.ThreadsX != destination.RecordCount || source.RecordCount == 0 || + !_host.TryReadGuest(source.Address, valueBytes)) + { + return false; + } + + var value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(valueBytes); + var size = (ulong)destination.RecordCount * sizeof(uint); + var consumed = _host.TryClearImageFromBuffer(destination.Address, size, value) || + _host.TryAbsorbDccFill(destination.Address, size, value); + if (RenderTrace.Enabled && RenderTrace.MetadataClear()) + { + RenderTrace.Write($"Constant fill: shader=0x{program.Hash:X16} address=0x{destination.Address:X16} size=0x{size:X} value=0x{value:X8} consumed={consumed}"); + } + + return consumed; + } + private bool TryConsumeImageClear(ComputeInputInfo input, uint groupsX, uint groupsY, uint groupsZ, uint dispatchInitiator) { if (TryDecodeImageClear(input, groupsX, groupsY, groupsZ, dispatchInitiator) is not { } clear) diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs index ddfcaaa..57b7eb7 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs @@ -37,7 +37,16 @@ public enum GuestIndexType : uint // Resolves draw and dispatch state from the register banks and records it through the host. public sealed partial class RenderExecutor { - private const uint PrimitiveShaderStageMask = 0x02002000; + // Prospero uses both the full NGG mask and compact primitive-shader masks + // when the task/mesh front end has already folded the upper enable bits + // away. Little Nightmares also emits 0x2030 for a primitive path with no + // geometry shader; the remaining bits describe the fixed NGG setup. + // GS_W32_EN (bit 22) and VS_W32_EN (bit 23) only select the wave size; + // graphics stages always compile as wave32, so they do not change the path. + private const uint VgtShaderStagesWaveSizeBits = (1u << 22) | (1u << 23); + + private static bool IsPrimitiveShaderStageMask(uint stages) => + (stages & ~VgtShaderStagesWaveSizeBits) is 0x02002000 or 0x00002000 or 0x00002030; private const uint MaxOutputPerSubgroupLimit = 0x40; private static int _geometryWarningShown; @@ -400,15 +409,16 @@ public sealed partial class RenderExecutor var vertex = banks.Shader.Vertex; var stages = context.ShaderStages; var primitiveShaderVertexPath = - stages == PrimitiveShaderStageMask && vertex.ExportAddress != 0 && - shaderInterface.GeometryMaxVerticesOut == 0 && IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType); - var unsupportedStageMask = stages != 0 && stages != PrimitiveShaderStageMask; + IsPrimitiveShaderStageMask(stages) && vertex.ExportAddress != 0 && + (vertex.GeometryAddress == 0 || + (shaderInterface.GeometryMaxVerticesOut == 0 && IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType))); + var unsupportedStageMask = stages != 0 && !IsPrimitiveShaderStageMask(stages); var unsupportedGeometryStage = vertex.ExportAddress != 0 && vertex.GeometryAddress != 0 && !primitiveShaderVertexPath; - var geometryRegisters = - (shaderInterface.PrimitiveShaderSubgroupControl != 0 && shaderInterface.PrimitiveShaderSubgroupControl != 1) || - shaderInterface.GeometryMaxVerticesOut != 0 || - !IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType) || - shaderInterface.MaxOutputPerSubgroup > MaxOutputPerSubgroupLimit; + var geometryRegisters = !primitiveShaderVertexPath && + ((shaderInterface.PrimitiveShaderSubgroupControl != 0 && shaderInterface.PrimitiveShaderSubgroupControl != 1) || + shaderInterface.GeometryMaxVerticesOut != 0 || + !IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType) || + shaderInterface.MaxOutputPerSubgroup > MaxOutputPerSubgroupLimit); if (!unsupportedStageMask && !unsupportedGeometryStage && !geometryRegisters) { return false; @@ -416,7 +426,15 @@ public sealed partial class RenderExecutor if (Interlocked.Exchange(ref _geometryWarningShown, 1) == 0) { - Console.Error.WriteLine("Warning: the title uses unsupported graphics pipelines; some draw calls were skipped."); + Console.Error.WriteLine( + "Warning: the title uses unsupported graphics pipelines; some draw calls were skipped. " + + $"stages=0x{stages:X8} subgroup=0x{shaderInterface.PrimitiveShaderSubgroupControl:X8} " + + $"maxOutput=0x{shaderInterface.MaxOutputPerSubgroup:X8} " + + $"maxVerticesOut=0x{shaderInterface.GeometryMaxVerticesOut:X8} " + + $"outputPrimitive=0x{shaderInterface.GeometryOutputPrimitiveType:X8} " + + $"export=0x{vertex.ExportAddress:X16} geometry=0x{vertex.GeometryAddress:X16} " + + $"unsupportedMask={unsupportedStageMask} unsupportedGeometry={unsupportedGeometryStage} " + + $"unsupportedRegisters={geometryRegisters}."); } if (RenderTrace.Enabled && RenderTrace.GeometryStageSkip()) diff --git a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs index caddd5a..4da0543 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs @@ -52,6 +52,7 @@ public class ShaderProgramInfo public int InstanceOffsetScalarRegister { get; init; } = NoScalarRegister; public bool UsesDeviceAddresses { get; init; } public bool HasBitwiseExclusiveOr { get; init; } + public Pipelines.ConstantFill? ConstantFill { get; init; } public BufferResourceInfo[] Buffers { get; init; } = []; public ImageResourceInfo[] Images { get; init; } = []; public int SamplerCount { get; init; } diff --git a/src/SharpEmu.Libs/Ime/ImeExports.cs b/src/SharpEmu.Libs/Ime/ImeExports.cs index 7235731..84d92cd 100644 --- a/src/SharpEmu.Libs/Ime/ImeExports.cs +++ b/src/SharpEmu.Libs/Ime/ImeExports.cs @@ -33,6 +33,24 @@ public static class ImeExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport( + Nid = "VkqLPArfFdc", + ExportName = "sceImeKeyboardGetInfo", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceIme")] + public static int ImeKeyboardGetInfo(CpuContext ctx) + { + var infoAddress = ctx[CpuRegister.Rsi]; + if (infoAddress != 0) + { + Span info = stackalloc byte[0x88]; + _ = ctx.Memory.TryWrite(infoAddress, info); + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + [SysAbiExport( Nid = "dKadqZFgKKQ", ExportName = "sceImeKeyboardGetResourceId", diff --git a/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs index d993c96..cb1edc7 100644 --- a/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs @@ -60,8 +60,16 @@ public static class KernelEventFlagCompatExports return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } - if (!TryReadNullTerminatedUtf8(ctx, nameAddress, MaxEventFlagNameLength + 1, out var name)) + // Event flag names are commonly stored in executable/read-only image + // pages. Use the kernel compatibility reader so those pages can use + // its guarded host-read fallback when the guest mapping's protection + // metadata temporarily rejects a normal TryRead call. + if (!KernelMemoryCompatExports.TryReadNullTerminatedUtf8( + ctx, nameAddress, MaxEventFlagNameLength + 1, out var name)) { + Console.Error.WriteLine( + $"[LOADER][WARN] event_flag name read failed address=0x{nameAddress:X16} " + + $"range={ctx.Memory.DescribeReadRange(nameAddress, (ulong)(MaxEventFlagNameLength + 1))}"); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } @@ -78,8 +86,11 @@ public static class KernelEventFlagCompatExports Bits = initialPattern, }; - if (!ctx.TryWriteUInt64(outAddress, handle)) + if (!KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, outAddress, handle)) { + Console.Error.WriteLine( + $"[LOADER][WARN] event_flag handle write failed address=0x{outAddress:X16} " + + $"range={ctx.Memory.DescribeReadRange(outAddress, sizeof(ulong))}"); _eventFlags.TryRemove(handle, out _); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } diff --git a/src/SharpEmu.Libs/Kernel/KernelExports.cs b/src/SharpEmu.Libs/Kernel/KernelExports.cs index a935ef5..e32c545 100644 --- a/src/SharpEmu.Libs/Kernel/KernelExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelExports.cs @@ -11,6 +11,8 @@ public static class KernelExports private static readonly object _cxaGate = new(); private static readonly List _cxaDestructors = new(); private static readonly object _coredumpGate = new(); + private const string RepeatedGuestAssertion = "Assertion failed: 0"; + private static int _repeatedGuestAssertionCount; private static ulong _coredumpHandler; private static ulong _coredumpHandlerContext; @@ -30,7 +32,8 @@ public static class KernelExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } - + // KytyPS5 libKernel.cpp KernelGetOperationMode: *mode = 2 (PS5 base console, not Pro), + // *submode = 0 (none). Games poll this at high frequency. [SysAbiExport( Nid = "NH6xARDOVv8", ExportName = "sceKernelGetOperationMode", @@ -38,8 +41,19 @@ public static class KernelExports LibraryName = "libKernel")] public static int KernelGetOperationMode(CpuContext ctx) { - // SCE_KERNEL_MODE_RELEASE = 0. Reporting retail/release mode keeps guest - // code from taking devkit/tool-only branches we do not emulate. + var modeAddress = ctx[CpuRegister.Rdi]; + var submodeAddress = ctx[CpuRegister.Rsi]; + + if (modeAddress != 0 && !ctx.TryWriteUInt32(modeAddress, 2)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + if (submodeAddress != 0 && !ctx.TryWriteUInt32(submodeAddress, 0)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } @@ -223,7 +237,8 @@ public static class KernelExports ctx, attrAddress, out var priority, - out var affinityMask); + out var affinityMask, + out var requestedStackSize); KernelPthreadExtendedCompatExports.RegisterThreadStart( threadHandle, name, @@ -252,7 +267,8 @@ public static class KernelExports attrAddress, name, priority, - affinityMask); + affinityMask, + requestedStackSize); if (!scheduler.TryStartThread(ctx, request, out var error)) { Console.Error.WriteLine( @@ -402,19 +418,42 @@ public static class KernelExports ulong fmtPtr = ctx[CpuRegister.Rdi]; string fmt = ReadCString(ctx, fmtPtr, 4096); string outStr = KernelMemoryCompatExports.FormatStringFromVarArgs(ctx, fmt, firstGpArgIndex: 1); - if (outStr.EndsWith('\n') || outStr.EndsWith('\r')) + var byteCount = System.Text.Encoding.UTF8.GetByteCount(outStr); + if (ShouldWriteGuestPrintf(outStr)) { - Console.Write($"[DEBUG][PRINF] {outStr}"); - } - else - { - Console.WriteLine($"[DEBUG][PRINF] {outStr}"); + if (outStr.EndsWith('\n') || outStr.EndsWith('\r')) + { + Console.Write($"[DEBUG][PRINF] {outStr}"); + } + else + { + Console.WriteLine($"[DEBUG][PRINF] {outStr}"); + } } - ctx[CpuRegister.Rax] = (ulong)System.Text.Encoding.UTF8.GetByteCount(outStr); + ctx[CpuRegister.Rax] = (ulong)byteCount; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // Demon\'s Souls can print this same guest-side diagnostic thousands of times during + // bootstrap. Printing it does not convey new state and serializes the emulation threads, + // but printf must still report its original byte count to the guest. + private static bool ShouldWriteGuestPrintf(string text) + { + if (!string.Equals(text.TrimEnd('\r', '\n'), RepeatedGuestAssertion, StringComparison.Ordinal)) + { + return true; + } + + var count = Interlocked.Increment(ref _repeatedGuestAssertionCount); + if (count == 2) + { + Console.WriteLine("[DEBUG][PRINF] Further repeated 'Assertion failed: 0' messages are suppressed."); + } + + return count == 1; + } + [SysAbiExport( Nid = "EMutwaQ34Jo", ExportName = "perror", @@ -521,4 +560,5 @@ public static class KernelExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + } diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs index 37fd893..e563f75 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs @@ -13,6 +13,16 @@ public static partial class KernelMemoryCompatExports private const int MemoryAccessDenied = unchecked((int)0x8002000D); private const int MemoryFault = unchecked((int)0x8002000E); private const int MemoryInvalidArgument = unchecked((int)0x80020016); + // Titles use sceKernelReserveVirtualRange as an address-space reservation + // primitive. Some of them ask for hundreds of GiB even though only small + // portions are ever mapped. Windows cannot always create one contiguous + // placeholder that large after the executable and runtime are loaded. + private const ulong SparseReservationThreshold = 0x10_0000_0000UL; + // macOS reserves 0x10_0000_0000..0x70_0000_0000 for graphics memory (see + // DefaultMapSearchBase), so sparse reservations start above that window there. + private static ulong SparseReservationBase => + OperatingSystem.IsMacOS() ? 0x70_0000_0000UL : 0x40_0000_0000UL; + private const ulong UserAddressLimit = 0xFC_0000_0000UL; private static FlexibleBackingPool _flexibleBacking = new(GuestMemoryLayout.FlexibleOffset, GuestMemoryLayout.FlexibleBytes); private static IGuestBackedSpace? _backingOwner; @@ -249,7 +259,9 @@ public static partial class KernelMemoryCompatExports { if (requested >= 0x10_0000_0000UL && requested < 0xFC_0000_0000UL && Interlocked.Exchange(ref _prtMapWarning, 1) == 0) - Console.Error.WriteLine("[LOADER][WARN] Shared mapping cannot replace the reserved aperture."); + Console.Error.WriteLine( + $"[LOADER][WARN] Shared mapping cannot replace the reserved aperture: address=0x{requested:X16} size=0x{length:X} flags=0x{flags:X} " + + $"regions=[{string.Join(",", GetMappingSlices(requested, length).Select(region => $"0x{region.Address:X}+0x{region.Length:X}:{(region.IsReserved ? "reserved" : region.IsDirect ? "direct" : region.IsFlexible ? "flexible" : "other")}:backed={space.IsBackedView(region.Address)}"))}]."); return false; } address = requested; @@ -335,7 +347,10 @@ public static partial class KernelMemoryCompatExports if (pointer == 0 || !IsValidMapRange(length, alignment)) return MemoryInvalidArgument; if (!ctx.TryReadUInt64(pointer, out var requested)) + { + Console.Error.WriteLine($"[LOADER][WARN] reserve_virtual pointer read failed pointer=0x{pointer:X16} size=0x{length:X}"); return MemoryFault; + } alignment = alignment == 0 ? OrbisPageSize : alignment; if ((flags & OrbisKernelMapFixed) != 0 && (requested == 0 || !IsAligned(requested, OrbisPageSize) || requested > ulong.MaxValue - length)) @@ -343,15 +358,107 @@ public static partial class KernelMemoryCompatExports lock (_memoryGate) { var space = ResolveBackingSpace(ctx); - if (space is null || !TrySelectBackingAddress(space, requested, length, alignment, flags, out var address, reuseReservation: false)) + var address = 0UL; + var sparseSelected = (flags & OrbisKernelMapFixed) == 0 && + TrySelectSparseReservationAddress(requested, length, alignment, flags, out address); + var selected = sparseSelected; + if (!selected && space is not null) + { + selected = TrySelectBackingAddress(space, requested, length, alignment, flags, + out address, reuseReservation: false); + } + + // A sparse reservation is only a guest mapping-table entry and + // intentionally does not need a host backing space yet. The host + // range is acquired later when a subrange is committed or mapped. + if (!selected || (!sparseSelected && space is null)) + { + Console.Error.WriteLine($"[LOADER][WARN] reserve_virtual address selection failed requested=0x{requested:X16} size=0x{length:X} sparse={sparseSelected} backing={space is not null}"); return MemoryNoSpace; + } + if (ShouldTraceDirectMemory()) + Console.Error.WriteLine($"[LOADER][TRACE] reserve_virtual selected address=0x{address:X16} requested=0x{requested:X16} size=0x{length:X} sparse={sparseSelected}"); ReplaceMappedRegionRangeLocked(new MappedRegion(address, length, 0, false, false, 0, IsReserved: true)); if (ShouldTraceDirectMemory()) Console.Error.WriteLine($"[LOADER][TRACE] reserve_virtual address=0x{address:X} requested=0x{requested:X} size=0x{length:X} flags=0x{flags:X}"); - return ctx.TryWriteUInt64(pointer, address) ? 0 : MemoryFault; + if (!ctx.TryWriteUInt64(pointer, address)) + { + Console.Error.WriteLine($"[LOADER][WARN] reserve_virtual pointer write failed pointer=0x{pointer:X16} address=0x{address:X16}"); + return MemoryFault; + } + return 0; } } + // Keep very large virtual reservations in the guest mapping table without + // requiring one equally large host placeholder. Actual backed mappings still + // reserve their individual host ranges when they are committed below this + // reservation. Fixed reservations never take this path: they must preserve + // the exact host address semantics of the original request. + private static bool TrySelectSparseReservationAddress( + ulong requested, + ulong length, + ulong alignment, + ulong flags, + out ulong address) + { + address = 0; + if ((flags & OrbisKernelMapFixed) != 0 || length < SparseReservationThreshold || + length > UserAddressLimit || alignment == 0) + { + return false; + } + + var candidate = requested == 0 + ? SparseReservationBase + : Math.Max(requested, SparseReservationBase); + candidate = AlignUp(candidate, alignment); + if (candidate == 0) + { + return false; + } + + // The requested address is a hint for a non-fixed reservation. If the + // hint is too high to contain the complete range, restart from the + // sparse aperture instead of treating the hint as a lower bound. + if (candidate > UserAddressLimit - length) + { + candidate = AlignUp(SparseReservationBase, alignment); + if (candidate == 0 || candidate > UserAddressLimit - length) + return false; + } + + foreach (var region in _mappedRegions.Values) + { + if (region.Address >= candidate + length) + { + break; + } + + var regionEnd = region.Address + region.Length; + if (regionEnd <= candidate) + { + continue; + } + + candidate = AlignUp(regionEnd, alignment); + if (candidate == 0 || candidate > UserAddressLimit - length) + { + return false; + } + } + + if (candidate == 0 || candidate > UserAddressLimit - length || + _mappedRegions.Values.Any(region => + region.Address < candidate + length && candidate < region.Address + region.Length)) + { + return false; + } + + address = candidate; + return true; + } + private static bool HasPhysicalSpan(ulong start, ulong length) => _directAllocations.ContainsAllocatedRange(start, length); diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 5b32193..fd59b42 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -2064,6 +2064,13 @@ public static partial class KernelMemoryCompatExports } } + [SysAbiExport( + Nid = "VAzswvTOCzI", + ExportName = "unlink", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int PosixUnlink(CpuContext ctx) => KernelUnlink(ctx); + [SysAbiExport( Nid = "1-LFLmRFxxM", ExportName = "sceKernelMkdir", @@ -2281,6 +2288,16 @@ public static partial class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; } + // A host movie watchdog may have closed the decoder while the guest + // still owns its Bink file descriptor. Return EOF so the guest closes + // that descriptor and proceeds to the next movie instead of waiting + // forever on a stale native decoder. + if (HostMovieBridge.ShouldForceGuestMovieEof(stream.Name)) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + long positionBefore; try { diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs index c731b1c..3cea481 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs @@ -39,6 +39,11 @@ public static class KernelPthreadCompatExports private static readonly Dictionary _condStates = new(); private static readonly Dictionary _onceGates = new(); private static readonly Dictionary _condAttrStates = new(); + // Only these exact allocation bases are owned by the pthread compatibility + // layer. Guest pthread objects are commonly stored inside reusable blocks; + // never pass one of those storage addresses to TryFreeGuestMemory when a + // stale handle is destroyed. + private static readonly ConcurrentDictionary _opaqueAllocations = new(); private static readonly bool _tracePthreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREADS"), "1", StringComparison.Ordinal); private static readonly bool _tracePthreadConds = @@ -283,7 +288,11 @@ public static class KernelPthreadCompatExports public static int PthreadYield(CpuContext ctx) { _ = ctx; - Thread.Yield(); + if (!Thread.Yield()) + { + Thread.Sleep(0); + } + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } @@ -294,6 +303,13 @@ public static class KernelPthreadCompatExports LibraryName = "libKernel")] public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx); + [SysAbiExport( + Nid = "6XG4B33N09g", + ExportName = "sched_yield", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int SchedYield(CpuContext ctx) => PthreadYield(ctx); + [SysAbiExport( Nid = "GBUY7ywdULE", ExportName = "scePthreadRename", @@ -1696,6 +1712,8 @@ public static class KernelPthreadCompatExports return false; } + _opaqueAllocations[address] = 0; + Span initialData = stackalloc byte[size]; initialData.Clear(); if (ctx.Memory.TryWrite(address, initialData)) @@ -1710,7 +1728,9 @@ public static class KernelPthreadCompatExports private static void FreeOpaqueObject(CpuContext ctx, ulong address) { - if (address != 0 && ctx.Memory is IGuestMemoryAllocator allocator) + if (address != 0 && + _opaqueAllocations.TryRemove(address, out _) && + ctx.Memory is IGuestMemoryAllocator allocator) { _ = allocator.TryFreeGuestMemory(address); } diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs index 5d0bc4f..1a09063 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs @@ -20,6 +20,7 @@ public static class KernelPthreadExtendedCompatExports private const ulong NativeGuestStackSize = 0x20_0000UL; private const ulong NativeGuestStackStride = 0x100_0000UL; private const int DefaultInheritSched = 4; + private const int DefaultSoloSched = 0; private const int DefaultSchedPolicy = 1; private const int DefaultSchedPriority = DefaultThreadPriority; private const ulong SyntheticRwlockHandleBase = 0x00006003_0000_0000; @@ -44,12 +45,14 @@ public static class KernelPthreadExtendedCompatExports CpuContext ctx, ulong attrAddress, out int priority, - out ulong affinityMask) + out ulong affinityMask, + out ulong stackSize) { if (attrAddress == 0) { priority = DefaultThreadPriority; affinityMask = DefaultThreadAffinityMask; + stackSize = DefaultStackSize; return; } @@ -59,6 +62,7 @@ public static class KernelPthreadExtendedCompatExports var attributes = GetOrCreateAttrStateLocked(resolvedAddress); priority = attributes.SchedPriority; affinityMask = attributes.AffinityMask; + stackSize = attributes.StackSize; } } @@ -189,7 +193,8 @@ public static class KernelPthreadExtendedCompatExports ulong GuardSize, int InheritSched, int SchedPolicy, - int SchedPriority) + int SchedPriority, + int SoloSched) { public static PthreadAttrState Default => new( @@ -200,7 +205,8 @@ public static class KernelPthreadExtendedCompatExports DefaultGuardSize, DefaultInheritSched, DefaultSchedPolicy, - DefaultSchedPriority); + DefaultSchedPriority, + DefaultSoloSched); } [SysAbiExport( @@ -860,6 +866,59 @@ public static class KernelPthreadExtendedCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport( + Nid = "Dk6FC-TI+7Q", + ExportName = "scePthreadAttrSetsolosched", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int PthreadAttrSetsolosched(CpuContext ctx) + { + var attrAddress = ctx[CpuRegister.Rdi]; + var soloSched = unchecked((int)ctx[CpuRegister.Rsi]); + if (attrAddress == 0) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + lock (_stateGate) + { + var state = GetOrCreateAttrStateLocked(attrAddress); + _attrStates[attrAddress] = state with { SoloSched = soloSched }; + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + [SysAbiExport( + Nid = "9RnL-m0+diQ", + ExportName = "scePthreadAttrGetsolosched", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int PthreadAttrGetsolosched(CpuContext ctx) + { + var attrAddress = ctx[CpuRegister.Rdi]; + var outSoloSchedAddress = ctx[CpuRegister.Rsi]; + if (attrAddress == 0 || outSoloSchedAddress == 0) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + PthreadAttrState state; + lock (_stateGate) + { + state = GetOrCreateAttrStateLocked(attrAddress); + } + + if (!TryWriteInt32(ctx, outSoloSchedAddress, state.SoloSched)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + /// /// The POSIX-named alias of . libKernel /// exports the same routine under two NIDs; middleware compiled against the diff --git a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs index 5eacd84..5dfe103 100644 --- a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs @@ -52,7 +52,8 @@ public static class KernelSemaphoreCompatExports return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } - if (!TryReadNullTerminatedUtf8(ctx, nameAddress, MaxSemaphoreNameLength, out var name)) + if (!KernelMemoryCompatExports.TryReadNullTerminatedUtf8( + ctx, nameAddress, MaxSemaphoreNameLength, out var name)) { return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } diff --git a/src/SharpEmu.Libs/Media/HostMovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs index bc8ac84..e91c310 100644 --- a/src/SharpEmu.Libs/Media/HostMovieBridge.cs +++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs @@ -42,6 +42,7 @@ internal static class HostMovieBridge private static byte[]? _frameBuffer; private static bool _frameBufferPresented; private static MediaFramePlayback? _playback; + private static Timer? _playbackWatchdog; private static long _frameSerial; private static uint _presentationWidth = MaxHostVideoWidth; private static uint _presentationHeight = MaxHostVideoHeight; @@ -287,6 +288,7 @@ internal static class HostMovieBridge _frameBuffer = GC.AllocateUninitializedArray(GetFrameBufferLength(info)); _frameBufferPresented = false; FillDummyFrame(_frameBuffer, info.Width, info.Height); + ArmPlaybackWatchdogLocked(hostPath); Console.Error.WriteLine( "[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + "."); @@ -301,6 +303,53 @@ internal static class HostMovieBridge _activePath = hostPath; _activeInfo = info; _playback = new MediaFramePlayback(decoder); + ArmPlaybackWatchdogLocked(hostPath); + } + + private static void ArmPlaybackWatchdogLocked(string hostPath) + { + _playbackWatchdog?.Dispose(); + var timeout = TryReadBinkInfo(hostPath, out var info) + ? GetPlaybackWatchdogTimeout(info) + : TimeSpan.FromSeconds(150); + _playbackWatchdog = new Timer( + static state => + { + var path = (string)state!; + lock (Gate) + { + if (!string.Equals(_activePath, path, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + Console.Error.WriteLine( + "[LOADER][WARN] Bink2 host watchdog expired for " + + Path.GetFileName(path) + "; advancing to the next movie."); + TimedOutMoviePaths.Add(path); + CloseActiveLocked(); + AttachNextQueuedMovieLocked(); + } + }, + hostPath, + timeout, + Timeout.InfiniteTimeSpan); + } + + // Must outlast the whole movie: Demon's Souls ships a 171 s intro and 756 s credits. + internal static TimeSpan GetPlaybackWatchdogTimeout(Bink2MovieInfo info) + { + if (info.FrameCount == 0 || + info.FramesPerSecondNumerator == 0 || + info.FramesPerSecondDenominator == 0) + { + return TimeSpan.FromSeconds(150); + } + + var duration = (double)info.FrameCount * + info.FramesPerSecondDenominator / + info.FramesPerSecondNumerator; + return TimeSpan.FromSeconds(Math.Max(90, duration + 60)); } internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info) @@ -319,6 +368,7 @@ internal static class HostMovieBridge info = new Bink2MovieInfo( BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(8, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4))); return info.FramesPerSecondNumerator != 0 && @@ -349,6 +399,8 @@ internal static class HostMovieBridge private static void CloseActiveLocked() { + _playbackWatchdog?.Dispose(); + _playbackWatchdog = null; _playback?.Dispose(); _playback = null; _activePath = null; @@ -364,6 +416,7 @@ internal static class HostMovieBridge [StructLayout(LayoutKind.Sequential)] internal readonly struct Bink2MovieInfo { + public readonly uint FrameCount; public readonly uint Width; public readonly uint Height; public readonly uint FramesPerSecondNumerator; @@ -372,14 +425,25 @@ internal static class HostMovieBridge internal Bink2MovieInfo( uint width, uint height, + uint frameCount, uint framesPerSecondNumerator, uint framesPerSecondDenominator) { Width = width; Height = height; + FrameCount = frameCount; FramesPerSecondNumerator = framesPerSecondNumerator; FramesPerSecondDenominator = framesPerSecondDenominator; } + + internal Bink2MovieInfo( + uint width, + uint height, + uint framesPerSecondNumerator, + uint framesPerSecondDenominator) + : this(width, height, 0, framesPerSecondNumerator, framesPerSecondDenominator) + { + } } private enum MovieMode @@ -393,6 +457,16 @@ internal static class HostMovieBridge private static readonly Queue PendingMoviePaths = new(); private static readonly HashSet PendingMoviePathSet = new(StringComparer.OrdinalIgnoreCase); + private static readonly HashSet TimedOutMoviePaths = + new(StringComparer.OrdinalIgnoreCase); + + internal static bool ShouldForceGuestMovieEof(string hostPath) + { + lock (Gate) + { + return TimedOutMoviePaths.Contains(hostPath); + } + } private static void AttachNextQueuedMovieLocked() { while (PendingMoviePaths.Count > 0) @@ -475,6 +549,7 @@ internal static class HostMovieBridge { lock (Gate) { + TimedOutMoviePaths.Remove(hostPath); if (PendingMoviePathSet.Remove(hostPath)) { var retained = PendingMoviePaths diff --git a/src/SharpEmu.Libs/Network/HttpExports.cs b/src/SharpEmu.Libs/Network/HttpExports.cs index 9c01e0e..06f8a15 100644 --- a/src/SharpEmu.Libs/Network/HttpExports.cs +++ b/src/SharpEmu.Libs/Network/HttpExports.cs @@ -18,7 +18,12 @@ public static partial class HttpExports private sealed record HttpContext(int NetMemoryId, int SslContextId, ulong PoolSize); - private sealed record HttpTemplate(int ContextId, ulong UserAgentAddress, int HttpVersion, bool AutoProxyConfig); + private sealed record HttpTemplate( + int ContextId, + ulong UserAgentAddress, + int HttpVersion, + bool AutoProxyConfig, + uint ConnectTimeoutMicroseconds = 30_000_000); [SysAbiExport( Nid = "A9cVMUtEp4Y", @@ -78,6 +83,30 @@ public static partial class HttpExports : ctx.SetReturn(HttpErrorInvalidId); } + [SysAbiExport( + Nid = "0S9tTH0uqTU", + ExportName = "sceHttpSetConnectTimeOut", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceHttp")] + public static int HttpSetConnectTimeOut(CpuContext ctx) + { + var id = unchecked((int)ctx[CpuRegister.Rdi]); + var timeoutMicroseconds = unchecked((uint)ctx[CpuRegister.Rsi]); + if (timeoutMicroseconds == 0) + { + return ctx.SetReturn(HttpErrorInvalidValue); + } + + if (!Templates.TryGetValue(id, out var template)) + { + return ctx.SetReturn(HttpErrorInvalidId); + } + + Templates[id] = template with { ConnectTimeoutMicroseconds = timeoutMicroseconds }; + TraceHttp("set_connect_timeout", id, timeoutMicroseconds, 0, 0, 0); + return ctx.SetReturn(0); + } + [SysAbiExport( Nid = "Ik-KpLTlf7Q", ExportName = "sceHttpTerm", diff --git a/src/SharpEmu.Libs/Network/NetExports.cs b/src/SharpEmu.Libs/Network/NetExports.cs index 1ecb14a..2f01884 100644 --- a/src/SharpEmu.Libs/Network/NetExports.cs +++ b/src/SharpEmu.Libs/Network/NetExports.cs @@ -25,6 +25,7 @@ public static class NetExports private const int NetErrnoAddressInUse = 48; private const int NetErrnoNotInitialized = 200; private const int MaxNameLength = 256; + private static ReadOnlySpan OfflineMacAddress => [0x02, 0x53, 0x48, 0x41, 0x52, 0x50]; private static readonly ConcurrentDictionary _pools = new(); private static readonly ConcurrentDictionary _resolvers = new(); @@ -117,6 +118,24 @@ public static class NetExports return ctx.SetReturn(0); } + [SysAbiExport( + Nid = "6Oc0bLsIYe0", + ExportName = "sceNetGetMacAddress", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNet")] + public static int NetGetMacAddress(CpuContext ctx) + { + var destinationAddress = ctx[CpuRegister.Rdi]; + var flags = unchecked((int)ctx[CpuRegister.Rsi]); + if (destinationAddress == 0 || flags != 0 || !ctx.Memory.TryWrite(destinationAddress, OfflineMacAddress)) + { + return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument); + } + + TraceNet("get_mac_address", 0, destinationAddress, unchecked((ulong)flags), 0); + return ctx.SetReturn(0); + } + [SysAbiExport( Nid = "Q4qBuN-c0ZM", ExportName = "sceNetSocket", diff --git a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs index 21f42db..5c60943 100644 --- a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs +++ b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs @@ -23,15 +23,22 @@ public static class NpEntitlementAccessExports private const int NpEntitlementAccessErrorParameter = unchecked((int)0x817D0002); private const int NpEntitlementAccessErrorNoEntitlement = unchecked((int)0x817D0007); + private const int EntitlementKeySize = 16; - // Offline add-on entitlements titles query through NpEntitlementAccess. + // Offline entitlements queried by titles through NpEntitlementAccess. // GTA V Enhanced (PPSA04264) gates Story Mode on these three labels; without // them the frontend offers "Buy GTAV Story Mode" despite a full dump. + // Ghost of Yotei (PPSA26344) separately checks its application and base + // game entitlements before it creates the frontend. These represent the + // locally installed main package only; optional editions and add-ons remain + // unowned. private static readonly AddcontEntitlement[] OwnedAddcontEntitlements = [ new("85y-je", PackageTypePsal, DownloadStatusInstalled), new("5d5c48", PackageTypePsal, DownloadStatusInstalled), new("_mtqu6", PackageTypePsal, DownloadStatusInstalled), + new("GHOST2APP0000000", PackageTypePsal, DownloadStatusInstalled), + new("GHOST2BASE000000", PackageTypePsal, DownloadStatusInstalled), ]; private readonly record struct AddcontEntitlement( @@ -193,6 +200,40 @@ public static class NpEntitlementAccessExports return ctx.SetReturn(NpEntitlementAccessErrorNoEntitlement); } + [SysAbiExport( + Nid = "5LiMEPuW0DQ", + ExportName = "sceNpEntitlementAccessGetEntitlementKey", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNpEntitlementAccess")] + public static int NpEntitlementAccessGetEntitlementKey(CpuContext ctx) + { + var labelAddress = ctx[CpuRegister.Rsi]; + var keyAddress = ctx[CpuRegister.Rdx]; + if (labelAddress == 0 || keyAddress == 0) + { + return ctx.SetReturn(NpEntitlementAccessErrorParameter); + } + + Span labelBytes = stackalloc byte[EntitlementLabelSize]; + if (!ctx.Memory.TryRead(labelAddress, labelBytes)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + var label = ReadEntitlementLabel(labelBytes); + Span key = stackalloc byte[EntitlementKeySize]; + key.Clear(); + if (!ctx.Memory.TryWrite(keyAddress, key)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + return ctx.SetReturn(OwnedAddcontEntitlements.Any(entitlement => + string.Equals(label, entitlement.Label, StringComparison.Ordinal)) + ? (int)OrbisGen2Result.ORBIS_GEN2_OK + : NpEntitlementAccessErrorNoEntitlement); + } + private static bool TryWriteAddcontEntitlementInfo( CpuContext ctx, ulong address, diff --git a/src/SharpEmu.Libs/Np/NpManagerExports.cs b/src/SharpEmu.Libs/Np/NpManagerExports.cs index ab43b2c..4f69258 100644 --- a/src/SharpEmu.Libs/Np/NpManagerExports.cs +++ b/src/SharpEmu.Libs/Np/NpManagerExports.cs @@ -11,6 +11,7 @@ public static class NpManagerExports private const int NpTitleIdSize = 16; private const int NpTitleSecretSize = 128; private const int NpErrorInvalidArgument = unchecked((int)0x80550003); + private static int _nextNpRequestId; [SysAbiExport( Nid = "3Zl8BePTh9Y", @@ -23,6 +24,48 @@ public static class NpManagerExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport(Nid = "eiqMCt9UshI", ExportName = "sceNpCreateAsyncRequest", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpCreateAsyncRequest(CpuContext ctx) => SetReturn(ctx, NextRequestId()); + + [SysAbiExport(Nid = "KfGZg2y73oM", ExportName = "sceNpCheckNpReachability", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpCheckNpReachability(CpuContext ctx) => + SetReturn(ctx, unchecked((int)ctx[CpuRegister.Rdi]) > 0 ? (int)OrbisGen2Result.ORBIS_GEN2_OK : NpErrorInvalidArgument); + + [SysAbiExport(Nid = "GpLQDNKICac", ExportName = "sceNpCreateRequest", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpCreateRequest(CpuContext ctx) => SetReturn(ctx, NextRequestId()); + + [SysAbiExport(Nid = "OzKvTvg3ZYU", ExportName = "sceNpAbortRequest", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpAbortRequest(CpuContext ctx) => SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + + [SysAbiExport(Nid = "uqcPJLWL08M", ExportName = "sceNpPollAsync", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpPollAsync(CpuContext ctx) + { + var requestId = unchecked((int)ctx[CpuRegister.Rdi]); + var resultAddress = ctx[CpuRegister.Rsi]; + if (requestId <= 0) + { + return SetReturn(ctx, NpErrorInvalidArgument); + } + + if (resultAddress != 0) + { + Span result = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(result, 0); + if (!ctx.Memory.TryWrite(resultAddress, result)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + } + + private static int NextRequestId() + { + var id = Interlocked.Increment(ref _nextNpRequestId) & 0x7FFF_FFFF; + return id == 0 ? 1 : id; + } + [SysAbiExport( Nid = "S7QTn72PrDw", ExportName = "sceNpDeleteRequest", diff --git a/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs b/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs index 015d259..49eb507 100644 --- a/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs +++ b/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs @@ -7,6 +7,8 @@ namespace SharpEmu.Libs.Np; public static class NpSessionSignalingExports { + private static int _nextContextId; + [SysAbiExport( Nid = "ysmw6J-P8Ak", ExportName = "sceNpSessionSignalingInitialize", @@ -17,4 +19,16 @@ public static class NpSessionSignalingExports ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + + [SysAbiExport( + Nid = "aBuX0PX-T7I", + ExportName = "sceNpSessionSignalingCreateContext2", + Target = Generation.Gen5, + LibraryName = "libSceNpSessionSignaling")] + public static int NpSessionSignalingCreateContext2(CpuContext ctx) + { + var contextId = Interlocked.Increment(ref _nextContextId); + ctx[CpuRegister.Rax] = unchecked((ulong)contextId); + return contextId; + } } diff --git a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs index a497fe4..ba07186 100644 --- a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs +++ b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs @@ -134,6 +134,13 @@ public static class NpWebApi2Exports return Interlocked.Increment(ref _nextPushEventHandle); } + [SysAbiExport( + Nid = "QafxeZM3WK4", + ExportName = "sceNpWebApi2PushEventDeletePushContext", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNpWebApi2")] + public static int NpWebApi2PushEventDeletePushContext(CpuContext ctx) => ctx.SetReturn(0); + private static bool IsValidLibraryContextId(int libraryContextId) { if (libraryContextId <= 0 || libraryContextId >= 0x8000) diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 6668fed..b6225a5 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -652,6 +652,32 @@ public static class PadExports return ctx.SetReturn(0); } + [SysAbiExport( + Nid = "rIZnR6eSpvk", + ExportName = "scePadResetOrientation", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadResetOrientation(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + return IsPrimaryPadHandle(handle) + ? ctx.SetReturn(0) + : ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + [SysAbiExport( + Nid = "fCWdlnmB1Ks", + ExportName = "scePadIsRemoteController", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadIsRemoteController(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + return IsPrimaryPadHandle(handle) + ? ctx.SetReturn(0) + : ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + private static bool WriteNeutralPadData(CpuContext ctx, ulong dataAddress) { Span data = stackalloc byte[PadDataSize]; diff --git a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs index 1b92cfa..5a27739 100644 --- a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs +++ b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs @@ -464,6 +464,7 @@ internal static class RenderPhaseProfile ReportImageUploads(); BufferUploadProfile.Report(); + Console.Error.WriteLine(SharpEmu.ShaderCompiler.Resources.ResourceMaterializationCache.TakeReport()); SharpEmu.ShaderCompiler.Resources.ResourceMaterializationProfile.WriteReport(); SharpEmu.Libs.Diagnostics.AgcRegisterPacketProfile.WriteReport(); SharpEmu.HLE.GpuMemory.GpuMemoryAccessProfile.WriteReport(); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs b/src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs new file mode 100644 index 0000000..bc0fdc6 --- /dev/null +++ b/src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs @@ -0,0 +1,53 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu; + +namespace SharpEmu.Libs.VideoOut; + +// Keeps a storage image out of the Vulkan color-attachment list when the guest +// disabled color writes. A storage image with active color writes still needs a +// pass split; silently binding both views would make the result undefined. +internal static class VulkanStorageFeedbackTargetResolver +{ + public static bool TryResolve( + IReadOnlyList targets, + IReadOnlyList textures, + IReadOnlyList blends, + out GuestRenderTarget[] resolvedTargets, + out bool usedCompatibilityAttachment, + out ulong unsupportedAddress) + { + if (targets.Count != blends.Count) + { + throw new ArgumentException( + "color attachment and blend-state counts must match", + nameof(blends)); + } + + resolvedTargets = targets.ToArray(); + usedCompatibilityAttachment = false; + unsupportedAddress = 0; + + for (var targetIndex = 0; targetIndex < targets.Count; targetIndex++) + { + var target = targets[targetIndex]; + if (target.Address == 0 || !textures.Any(texture => + texture.IsStorage && texture.Address == target.Address)) + { + continue; + } + + if (targets.Count != 1 || blends[targetIndex].WriteMask != 0) + { + unsupportedAddress = target.Address; + return false; + } + + resolvedTargets[targetIndex] = target with { Address = 0 }; + usedCompatibilityAttachment = true; + } + + return true; + } +} diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs index 11208bf..ae527a9 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs @@ -14,6 +14,18 @@ using VkBuffer = Silk.NET.Vulkan.Buffer; internal static unsafe partial class VulkanVideoPresenter { + private readonly record struct FeedbackSnapshotKey( + Format Format, + uint Width, + uint Height, + uint MipLevels, + uint Layers, + uint Samples, + ImageAspectFlags Aspect); + + private const int FeedbackSnapshotPoolMaxEntriesPerKey = 2; + private const ulong FeedbackSnapshotPoolMaxBytes = 256UL * 1024 * 1024; + // Display buffers the guest registered; the only image addresses a flip may capture. private static readonly HashSet _knownDisplayBuffers = new(); @@ -115,12 +127,17 @@ internal static unsafe partial class VulkanVideoPresenter public uint DestinationSelect; // One view per mip level for a storage image the program indexes by mip; empty otherwise. public ImageView[] MipViews = []; + // A draw-local copy used when the guest samples a depth aspect while the + // same image is attached for writes. It retires with the submission. + public bool IsFeedbackSnapshot; } private sealed partial class Presenter { private readonly List _trackedImageBindings = new(); private static int _guestImageDumpSequence; + private readonly Dictionary> _feedbackSnapshotPool = []; + private ulong _feedbackSnapshotPoolBytes; // Shader images for work without color targets: resolve, acquire, transition and release the bindings. private TextureResource[] ResolveDrawTextures(IReadOnlyList textures) @@ -514,10 +531,10 @@ internal static unsafe partial class VulkanVideoPresenter }; } - private Sampler ResolveSampler(in GuestSampler sampler) + private Sampler ResolveSampler(in GuestSampler sampler, Format viewFormat) { Span words = stackalloc uint[4] { sampler.Word0, sampler.Word1, sampler.Word2, sampler.Word3 }; - return _samplerStore.GetSampler(new SamplerDescriptorWords(words)); + return _samplerStore.GetSampler(new SamplerDescriptorWords(words), ViewFormatRules.IsIntegerFormat(viewFormat)); } // Views for every cached binding after the targets are bound; a stale image is found again first. @@ -571,12 +588,12 @@ internal static unsafe partial class VulkanVideoPresenter binding.Image = image.Backing.Handle; if (!binding.IsStorage) { - binding.Sampler = ResolveSampler(binding.SamplerState); + binding.Sampler = ResolveSampler(binding.SamplerState, binding.Request.View.Format); } } } - private CachedImage AcquireStencilStorage(TextureResource binding, CachedImage attachment) + private CachedImage AcquireStencilStorage(TextureResource binding, CachedImage attachment, bool writeBack = true) { var description = binding.Request.Description; if (description.Data.Address != attachment.Description.Stencil.Address || description.Data.Size > attachment.Description.Stencil.Size || @@ -586,7 +603,7 @@ internal static unsafe partial class VulkanVideoPresenter throw SubmissionScheduler.Fatal("The stencil storage request does not cover its attachment's stencil layout."); } - if (_hasBoundDepth && _boundDepth.Image == binding.ImageIdentifier && + if (writeBack && _hasBoundDepth && _boundDepth.Image == binding.ImageIdentifier && (_boundDepthLoadState.StencilTestEnabled || _boundDepthLoadState.StencilClearEnabled)) { throw SubmissionScheduler.Fatal("A stencil storage write cannot share an active stencil attachment."); @@ -595,7 +612,16 @@ internal static unsafe partial class VulkanVideoPresenter var preparation = RequirePreparation(); var stencilImages = preparation.StencilStorageImages ??= new(); if (stencilImages.TryGetValue(attachment, out var storage)) + { + if (writeBack) + { + preparation.StencilStorageWriteBackImages ??= new(); + preparation.StencilStorageWriteBackImages.Add(attachment); + storage.Binding.ShaderWrite = true; + } + return storage; + } var sampledRequest = binding.Request with { @@ -603,10 +629,19 @@ internal static unsafe partial class VulkanVideoPresenter View = binding.Request.View with { Usage = ImageUsageFlags.SampledBit }, }; _imageCache.AcquireTextureView(binding.ImageIdentifier, sampledRequest); - _imageCache.MarkGpuWritten(binding.ImageIdentifier); + if (writeBack) + { + _imageCache.MarkGpuWritten(binding.ImageIdentifier); + } + storage = attachment.CreateStencilStorageImage(); stencilImages.Add(attachment, storage); - storage.Binding.ShaderWrite = true; + storage.Binding.ShaderWrite = writeBack; + if (writeBack) + { + preparation.StencilStorageWriteBackImages ??= new(); + preparation.StencilStorageWriteBackImages.Add(attachment); + } attachment.CopyStencilStorage(storage, _bufferCache.GetUtilityBuffer(GpuBufferUsage.DeviceLocal), writeBack: false); if (_hasBoundDepth && _boundDepth.Image == binding.ImageIdentifier) { @@ -619,6 +654,182 @@ internal static unsafe partial class VulkanVideoPresenter return storage; } + private CachedImage CreateDepthFeedbackSnapshot(CachedImage source, ImageAspectFlags aspect) + { + if (source.Backing.ImageType != ImageType.Type2D || source.Backing.MipLevels == 0 || + source.Backing.Samples == 0) + { + throw SubmissionScheduler.Fatal( + $"A depth feedback snapshot needs a 2D image: type={source.Backing.ImageType} " + + $"mips={source.Backing.MipLevels} samples={source.Backing.Samples}."); + } + + var key = new FeedbackSnapshotKey( + source.Backing.Format, + source.Backing.Extent.Width, + source.Backing.Extent.Height, + source.Backing.MipLevels, + source.Backing.Layers, + source.Backing.Samples, + ImageAspectFlags.None); + var snapshot = RentFeedbackSnapshot(key); + if (snapshot is null) + { + var description = ImageDescription.Create(); + description.PixelFormat = source.Backing.Format; + description.GuestFormat = source.Description.GuestFormat; + description.Type = GuestImageType.Color2D; + description.Extent = new Extent3D(source.Backing.Extent.Width, source.Backing.Extent.Height, 1); + description.Resources = new SubresourceCount(source.Backing.MipLevels, source.Backing.Layers); + description.BytesPerBlock = source.Description.BytesPerBlock == 0 ? 1 : source.Description.BytesPerBlock; + description.Samples = source.Backing.Samples; + // The snapshot is host-owned. Keeping guest placement empty prevents + // the image cache from treating it as guest memory that needs upload. + description.Pitch = 0; + description.TileMode = GuestTileMode.Linear; + snapshot = new CachedImage(_deviceInfo, _scheduler, _guestBacking, description); + } + var range = new SubresourceRange(0, source.Backing.MipLevels, 0, source.Backing.Layers); + if ((aspect & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit)) != 0) + { + snapshot.CopyDepthStencilFrom( + source, + range, + source.Backing.Extent, + aspect & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit)); + var depthView = _boundDepth.Target.Target.Request.View; + var writes = _boundDepthLoadState.AttachmentWriteAspects(_boundDepth.Target.Target.Format); + source.Transition( + _boundDepthLayout, + AccessFlags.DepthStencilAttachmentReadBit | + (writes != 0 ? AccessFlags.DepthStencilAttachmentWriteBit : 0), + new SubresourceRange(depthView.BaseLevel, depthView.LevelCount, depthView.BaseLayer, depthView.LayerCount), + BeginBatchedGuestCommands()); + snapshot.Transition( + ImageLayout.DepthStencilReadOnlyOptimal, + AccessFlags.ShaderReadBit, + range, + BeginBatchedGuestCommands()); + } + else + { + snapshot.CopyFrom(source); + snapshot.Transition( + ImageLayout.ShaderReadOnlyOptimal, + AccessFlags.ShaderReadBit, + range, + BeginBatchedGuestCommands()); + } + + var preparation = RequirePreparation(); + preparation.FeedbackSnapshots ??= new(); + preparation.FeedbackSnapshots.Add(snapshot); + return snapshot; + } + + private CachedImage? RentFeedbackSnapshot(FeedbackSnapshotKey key) + { + if (!_feedbackSnapshotPool.TryGetValue(key, out var entries) || !entries.TryPop(out var snapshot)) + { + return null; + } + + _feedbackSnapshotPoolBytes -= snapshot.Backing.AllocationSize; + if (entries.Count == 0) + { + _feedbackSnapshotPool.Remove(key); + } + + return snapshot; + } + + private void RetireFeedbackSnapshot(CachedImage snapshot) + { + var key = new FeedbackSnapshotKey( + snapshot.Backing.Format, + snapshot.Backing.Extent.Width, + snapshot.Backing.Extent.Height, + snapshot.Backing.MipLevels, + snapshot.Backing.Layers, + snapshot.Backing.Samples, + ImageAspectFlags.None); + var allocation = snapshot.Backing.AllocationSize; + _feedbackSnapshotPool.TryGetValue(key, out var entries); + if (entries is null && _feedbackSnapshotPoolBytes + allocation <= FeedbackSnapshotPoolMaxBytes) + { + entries = new Stack(); + _feedbackSnapshotPool[key] = entries; + } + + if (entries is not null && entries.Count < FeedbackSnapshotPoolMaxEntriesPerKey && + _feedbackSnapshotPoolBytes + allocation <= FeedbackSnapshotPoolMaxBytes) + { + entries.Push(snapshot); + _feedbackSnapshotPoolBytes += allocation; + return; + } + + snapshot.Dispose(); + } + + private void DestroyFeedbackSnapshotPool() + { + foreach (var entries in _feedbackSnapshotPool.Values) + { + foreach (var snapshot in entries) + { + snapshot.Dispose(); + } + } + + _feedbackSnapshotPool.Clear(); + _feedbackSnapshotPoolBytes = 0; + } + + // Vulkan core does not permit a sampled depth aspect to alias a depth + // attachment that the same draw may write. Preserve the guest's + // read-before-write semantics with a private copy for this draw. + private void PrepareDepthFeedback(TextureResource[] bindings) + { + if (!_hasBoundDepth) + { + return; + } + + var depthImage = _imageCache.GetImage(_boundDepth.Image); + var attachmentView = _boundDepth.Target.Target.Request.View; + var writes = _boundDepthLoadState.AttachmentWriteAspects(_boundDepth.Target.Target.Format); + var writableDepth = writes & ImageAspectFlags.DepthBit; + if (writableDepth == 0) + { + return; + } + + foreach (var binding in bindings) + { + if (binding.IsHostMovie || binding.IsStorage || binding.CachedImage is not { } image || + !ReferenceEquals(image, depthImage) || !ViewsOverlap(binding.Request.View, attachmentView)) + { + continue; + } + + var sampledAspect = ViewFormatRules.IsStencilViewFormat(binding.Request.View.Format) + ? ImageAspectFlags.StencilBit + : ImageAspectFlags.DepthBit; + if ((sampledAspect & writableDepth) == 0) + { + continue; + } + + var snapshot = CreateDepthFeedbackSnapshot(image, sampledAspect); + binding.CachedImage = snapshot; + binding.Image = snapshot.Backing.Handle; + binding.View = snapshot.GetOrCreateView(binding.Request.View); + binding.MipViews = []; + binding.IsFeedbackSnapshot = true; + } + } + // The layout each binding reads through; a target read by its own draw uses the general layout. private void RecordTextureTransitions(TextureResource[] bindings) => RecordDrawTextureTransitions(bindings, null, GuestDepthState.Default); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs index c3a273a..143755c 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs @@ -95,7 +95,8 @@ internal static unsafe partial class VulkanVideoPresenter OneDimensional: image.Dimension is ImageDimension.Dim1D or ImageDimension.Dim1DArray, R128: image.R128, Multisampled: image.Dimension is ImageDimension.Dim2DMsaa or ImageDimension.Dim2DMsaaArray, - DepthCompare: image.DepthCompare); + DepthCompare: image.DepthCompare, + Atomic: image.Atomic); // Render-state discovery for one shader image; the view is acquired later with the draw. private TextureResource ResolveImageBinding(ImageResource image, uint[] words, ShaderProgramInfo program, int index) @@ -114,6 +115,20 @@ internal static unsafe partial class VulkanVideoPresenter imageIdentifier = ImageRequestBuilders.ValidateTextureOwner(_imageCache, imageIdentifier, resolution); BindImage(imageIdentifier, storage); var descriptor = new TextureDescriptorWords(words); + if (ShouldTraceTextureBindings()) + { + var cached = _imageCache.GetImage(imageIdentifier); + var description = cached.Description; + Console.Error.WriteLine( + $"TextureBinding stage={program.Stage} hash=0x{program.Hash:X16} index={index} " + + $"address=0x{new TextureDescriptorWords(words).BaseAddress:X16} " + + $"descriptor={descriptor.BaseAddress:X16} size={descriptor.Width + 1}x{descriptor.Height + 1} " + + $"format={(uint)descriptor.Format} tile={(uint)descriptor.TileMode} " + + $"image=0x{description.Data.Address:X16} size=0x{description.Data.Size:X} " + + $"extent={description.Extent.Width}x{description.Extent.Height} pitch={description.Pitch} " + + $"guestFormat={(uint)description.GuestFormat} imageTile={(uint)description.TileMode} " + + $"backing={cached.Backing.Extent.Width}x{cached.Backing.Extent.Height} format={cached.Backing.Format}"); + } return new TextureResource { Address = descriptor.BaseAddress, @@ -128,7 +143,31 @@ internal static unsafe partial class VulkanVideoPresenter } // Compare bits stay only on depth-compare samplers; a forced point sampler drops its filters. - private Sampler ResolveSampler(SamplerResource sampler, uint[] words, ShaderProgramInfo program, int index, ShaderStageResources stage) + // A sampler takes the numeric class of the views it samples; integer only when every + // paired view is integer, since a float view needs a float border and filtering. + private static bool SamplesIntegerViews(ShaderResourceInfo info, TextureResource[] images, int sampler) + { + var paired = false; + foreach (var pair in info.SampledPairs) + { + if (pair.Sampler != sampler || pair.Image >= images.Length || images[pair.Image].IsHostMovie) + { + continue; + } + + if (!ViewFormatRules.IsIntegerFormat(images[pair.Image].Request.View.Format)) + { + return false; + } + + paired = true; + } + + return paired; + } + + private Sampler ResolveSampler(SamplerResource sampler, uint[] words, ShaderProgramInfo program, int index, ShaderStageResources stage, + bool integerView) { if (words.Length < 4) { @@ -164,7 +203,7 @@ internal static unsafe partial class VulkanVideoPresenter $"user_data=[{string.Join(",", stage.Resources.UserData.Select(word => $"{word:X8}"))}]"); } - return _samplerStore.GetSampler(descriptor); + return _samplerStore.GetSampler(descriptor, integerView); } // The guest textures the movie path matches; built only while a decoded frame is active. @@ -216,7 +255,8 @@ internal static unsafe partial class VulkanVideoPresenter descriptors.Samplers = new Sampler[info.Samplers.Count]; for (var index = 0; index < info.Samplers.Count; index++) { - descriptors.Samplers[index] = ResolveSampler(info.Samplers[index], snapshot.Samplers[index], program, index, stage); + descriptors.Samplers[index] = ResolveSampler(info.Samplers[index], snapshot.Samplers[index], program, index, stage, + SamplesIntegerViews(info, descriptors.Images, index)); } var shaderData = new uint[layout.ShaderDataDwordCount]; @@ -852,6 +892,7 @@ internal static unsafe partial class VulkanVideoPresenter { DebugName = bindPoint == PipelineBindPoint.Compute ? "SharpEmu dispatch" : "SharpEmu draw", Textures = textures, + FeedbackSnapshots = preparation.FeedbackSnapshots?.ToArray() ?? [], OverflowBuffers = preparation.OverflowBuffers.Count == 0 ? null : preparation.OverflowBuffers.ToArray(), }); preparation.OverflowBuffers.Clear(); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs index e84876f..f638a4f 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs @@ -50,6 +50,9 @@ internal static unsafe partial class VulkanVideoPresenter private string? _pipelineCachePath; private bool _pipelineCacheDirty; private long _lastPipelineCacheSaveTick; + // Reading a mature driver cache can itself take several seconds. Keep the + // render thread focused on pipeline warm-up; teardown still saves eagerly. + private const long PipelineCacheCheckpointIntervalMs = 300_000; private Queue _queue; private uint _queueFamilyIndex; @@ -669,6 +672,7 @@ internal static unsafe partial class VulkanVideoPresenter VertexPipelineStoresAndAtomics = supportedFeatures.VertexPipelineStoresAndAtomics, FragmentStoresAndAtomics = supportedFeatures.FragmentStoresAndAtomics, ShaderInt64 = supportedFeatures.ShaderInt64, + ShaderFloat64 = supportedFeatures.ShaderFloat64, ShaderImageGatherExtended = supportedFeatures.ShaderImageGatherExtended, ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats, ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat, @@ -699,6 +703,15 @@ internal static unsafe partial class VulkanVideoPresenter "The device lacks the shaderInt64 feature, which the device-address programs need."); } + if (!supportedFeatures.ShaderFloat64) + { + Console.Error.WriteLine( + "[LOADER][WARN] GPU does not support shaderFloat64 " + + "translated shaders using double precision will fail."); + } + + + if (!supportedFeatures.VertexPipelineStoresAndAtomics || !supportedFeatures.FragmentStoresAndAtomics) { Console.Error.WriteLine( @@ -786,19 +799,33 @@ internal static unsafe partial class VulkanVideoPresenter SType = StructureType.PhysicalDeviceBufferDeviceAddressFeatures, PNext = &timelineSemaphoreFeatures, }; + var atomicInt64Features = new PhysicalDeviceShaderAtomicInt64Features + { + SType = StructureType.PhysicalDeviceShaderAtomicInt64Features, + PNext = &addressFeatures, + }; var featuresQuery = new PhysicalDeviceFeatures2 { SType = StructureType.PhysicalDeviceFeatures2, - PNext = &addressFeatures, + PNext = &atomicInt64Features, }; _vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery); var supportsTimelineSemaphore = timelineSemaphoreFeatures.TimelineSemaphore; var supportsBufferDeviceAddress = addressFeatures.BufferDeviceAddress; + var supportsSharedInt64Atomics = atomicInt64Features.ShaderSharedInt64Atomics; var supportsMaintenance8 = maintenance8Features.Maintenance8; var supportsRobustBufferAccess2 = robustness2Features.RobustBufferAccess2; var supportsRobustImageAccess2 = robustness2Features.RobustImageAccess2; var supportsNullDescriptor = robustness2Features.NullDescriptor; var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor; + SetSharedInt64AtomicsCapability(supportsSharedInt64Atomics); + if (!supportsSharedInt64Atomics) + { + Console.Error.WriteLine( + "[LOADER][WARN] GPU does not support shaderSharedInt64Atomics " + + "64-bit LDS atomics fall back to a non-atomic pair of 32-bit atomics."); + } + _vk.GetPhysicalDeviceProperties(_physicalDevice, out var deviceProperties); var deviceName = SilkMarshal.PtrToString((nint)deviceProperties.DeviceName) ?? "unknown"; RequireRenderingFeature(vulkan13Features.DynamicRendering, "Vulkan 1.3 dynamicRendering", deviceName); @@ -911,6 +938,17 @@ internal static unsafe partial class VulkanVideoPresenter PNext = &timelineSemaphoreFeatures, }; void* renderingChain = &addressFeatures; + if (supportsSharedInt64Atomics) + { + atomicInt64Features = new PhysicalDeviceShaderAtomicInt64Features + { + SType = StructureType.PhysicalDeviceShaderAtomicInt64Features, + ShaderSharedInt64Atomics = true, + PNext = renderingChain, + }; + renderingChain = &atomicInt64Features; + } + if (_supportsFragmentShaderBarycentric) { barycentricFeatures.PNext = renderingChain; @@ -1143,7 +1181,7 @@ internal static unsafe partial class VulkanVideoPresenter // frame, so saving after every slow creation compounds a warm-up // hitch into a multi-minute stall. Coalesce all creations into one // periodic snapshot; shutdown still forces a final save. - if (Environment.TickCount64 - _lastPipelineCacheSaveTick >= 30_000) + if (Environment.TickCount64 - _lastPipelineCacheSaveTick >= PipelineCacheCheckpointIntervalMs) { SavePipelineCache(force: false); } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs index bca9f04..cd3fa85 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs @@ -29,6 +29,7 @@ internal static unsafe partial class VulkanVideoPresenter SavePipelineCache(force: true); DrainFrameSlots(); CollectCompletedGuestSubmissions(waitForOldest: false); + DestroyFeedbackSnapshotPool(); DestroyRenderPipelines(); _descriptorHeap.Dispose(); _imageCache.Dispose(); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs index ed3a3de..24b12e0 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs @@ -90,6 +90,11 @@ internal static unsafe partial class VulkanVideoPresenter Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC_SHADER"), "1", StringComparison.Ordinal); + private static readonly bool _traceTextureBindingsEnabled = + string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TEXTURE_BINDINGS"), + "1", + StringComparison.Ordinal); private static readonly uint _traceGuestImageWidth = uint.TryParse( Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_WIDTH"), @@ -259,6 +264,9 @@ internal static unsafe partial class VulkanVideoPresenter private static bool ShouldTraceVulkanResources() => _traceVulkanResourcesEnabled; + private static bool ShouldTraceTextureBindings() => + _traceTextureBindingsEnabled; + private void RecycleSubmissionUploads(SubmissionUploadResources resources) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ResourceDestroy); @@ -270,6 +278,11 @@ internal static unsafe partial class VulkanVideoPresenter } } + foreach (var snapshot in resources.FeedbackSnapshots) + { + RetireFeedbackSnapshot(snapshot); + } + foreach (var (buffer, memory) in resources.OverflowBuffers ?? []) { RecycleHostBuffer(buffer, memory); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs index ad4a40e..b5aa427 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs @@ -137,8 +137,12 @@ internal static unsafe partial class VulkanVideoPresenter { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.MovieFramePolling); if (!HostMovieBridge.TryDecodeNextFrame( - advanceClock: _hostMovieLumaTextureAddress != 0 && - _hostMovieChromaTextureAddress != 0, + // Once the host bridge owns a movie, advance its clock on + // every presenter tick. Texture discovery can lag behind + // the first decoded frame; tying playback to those two + // addresses leaves the guest stuck forever on attract + // movies when Vulkan selects a different binding layout. + advanceClock: true, out var pixels, out var width, out var height, diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs index b286fc9..d8f66d1 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs @@ -66,6 +66,7 @@ internal static unsafe partial class VulkanVideoPresenter bool IShaderPipelineHost.GraphicsSubgroupOperationsEnabled => GraphicsSubgroupOperationsEnabled; + bool IShaderPipelineHost.SharedInt64AtomicsEnabled => SharedInt64AtomicsEnabled; bool IShaderPipelineHost.PerVertexPixelInputsSupported => _supportsPerVertexPixelInputs; RenderHostLimits IShaderPipelineHost.Limits => _renderHostLimits; @@ -77,6 +78,16 @@ internal static unsafe partial class VulkanVideoPresenter { using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.GuestRead); word = 0; + // Resource planning can inspect a dynamic descriptor before the draw has + // supplied a valid guest address. Do not pass an invalid range to the + // page tracker: it treats that as an emulator invariant violation and + // terminates the process. A failed read lets the materializer reject or + // specialize the source normally. + if (!_guestMemory.CanRead(address, sizeof(uint))) + { + return false; + } + if (!_bufferCache.TrySynchronizeCpuRead(address, sizeof(uint), SharpEmu.HLE.GuestMemory.GuestMemoryProfile.ReadbackSource.ShaderResourceRead)) { @@ -98,6 +109,11 @@ internal static unsafe partial class VulkanVideoPresenter { using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.CleanGuestRead); word = 0; + if (!_guestMemory.CanRead(address, sizeof(uint))) + { + return false; + } + if (_bufferCache.HasGpuDirtyPages(address, sizeof(uint)) || _bufferCache.HasGpuDirtyBytes(address, sizeof(uint)) || _imageCache.HasGpuModifiedImageBytes(address, sizeof(uint))) @@ -115,6 +131,19 @@ internal static unsafe partial class VulkanVideoPresenter return true; } + // The same ownership rules as the two word readers, checked once for a whole range. + public bool TryReadResidentGuestBytes(ulong address, Span destination, bool clean) + { + var size = (ulong)destination.Length; + if (_bufferCache.HasGpuDirtyPages(address, size) || + (clean && (_bufferCache.HasGpuDirtyBytes(address, size) || _imageCache.HasGpuModifiedImageBytes(address, size)))) + { + return false; + } + + return _guestMemory.TryRead(address, destination); + } + public ulong CreateShaderModule(IGuestCompiledShader shader, ShaderStage stage, ulong hash, ulong programId) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramCompile); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs index 2f3d3da..bce1b6a 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs @@ -50,6 +50,10 @@ internal static unsafe partial class VulkanVideoPresenter public Dictionary? StencilStorageImages { get; set; } + public HashSet? StencilStorageWriteBackImages { get; set; } + + public List? FeedbackSnapshots { get; set; } + public bool Committed { get; set; } public bool CommandsRecorded { get; set; } @@ -69,7 +73,8 @@ internal static unsafe partial class VulkanVideoPresenter { try { - if (CommandsRecorded) + if (CommandsRecorded && StencilStorageWriteBackImages is not null && + StencilStorageWriteBackImages.Contains(attachment)) attachment.CopyStencilStorage(storage, owner._bufferCache.GetUtilityBuffer(GpuBufferUsage.DeviceLocal), writeBack: true); } finally @@ -79,6 +84,14 @@ internal static unsafe partial class VulkanVideoPresenter } } + if (!Committed && FeedbackSnapshots is { } feedbackSnapshots) + { + foreach (var snapshot in feedbackSnapshots) + { + snapshot.Dispose(); + } + } + if (Committed) { return; @@ -367,6 +380,40 @@ internal static unsafe partial class VulkanVideoPresenter ? entry : throw SubmissionScheduler.Fatal($"The pipeline handle is unknown: pipeline={pipeline.Pipeline} layout={pipeline.Layout}."); + // Vulkan cannot sample a stencil aspect while the same aspect is attached for writes. + // Use a per-draw storage copy for read-only stencil feedback and keep the attachment authoritative. + private void PrepareStencilFeedback(TextureResource[] bindings) + { + if (!_hasBoundDepth) + { + return; + } + + var depthImage = _imageCache.GetImage(_boundDepth.Image); + var attachmentView = _boundDepth.Target.Target.Request.View; + var writes = _boundDepthLoadState.AttachmentWriteAspects(_boundDepth.Target.Target.Format); + if ((writes & ImageAspectFlags.StencilBit) == 0) + { + return; + } + + foreach (var binding in bindings) + { + if (binding.IsHostMovie || binding.CachedImage is not { } image || + !ReferenceEquals(image, depthImage) || !ViewFormatRules.IsStencilViewFormat(binding.Request.View.Format) || + !ViewsOverlap(binding.Request.View, attachmentView)) + { + continue; + } + + var storage = AcquireStencilStorage(binding, image, writeBack: false); + binding.CachedImage = storage; + binding.Image = storage.Backing.Handle; + binding.View = storage.GetOrCreateView(binding.Request.View with { Aspect = ImageAspectFlags.ColorBit }); + binding.MipViews = []; + } + } + // The layout each sampled image reads through; a target read by its own draw uses the general layout. private void RecordStageTextureTransitions(TextureResource[] bindings) @@ -404,6 +451,11 @@ internal static unsafe partial class VulkanVideoPresenter } } + // Create feedback copies after load clears have been materialized; + // otherwise a shader would sample the pre-clear contents. + PrepareDepthFeedback(bindings); + PrepareStencilFeedback(bindings); + var command = BeginBatchedGuestCommands(); foreach (var binding in bindings) { diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index c549231..8fd8978 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -9,6 +9,7 @@ using SharpEmu.Libs.AvPlayer; using SharpEmu.Libs.Media; using SharpEmu.Libs.Gpu; using SharpEmu.Libs.Gpu.GpuCommands; +using SharpEmu.Libs.Gpu.Images; using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler.Vulkan; using Silk.NET.Vulkan; @@ -79,6 +80,15 @@ internal static unsafe partial class VulkanVideoPresenter private static int _nativeSubgroupSize; private static int _nativeSubgroupShaderStages; + private static int _sharedInt64AtomicsSupported; + + // True when the device supports shaderSharedInt64Atomics, so LDS 64-bit + // atomics can be emitted as real 64-bit atomics. + internal static bool SharedInt64AtomicsEnabled => + Volatile.Read(ref _sharedInt64AtomicsSupported) != 0; + + private static void SetSharedInt64AtomicsCapability(bool supported) => + Volatile.Write(ref _sharedInt64AtomicsSupported, supported ? 1 : 0); internal static bool GraphicsSubgroupOperationsEnabled => VulkanGraphicsSubgroupPolicy.ShouldUseNativeGraphicsSubgroups( @@ -233,6 +243,9 @@ internal static unsafe partial class VulkanVideoPresenter { public string DebugName = "SharpEmu translated"; public TextureResource[] Textures = []; + // Draw-local feedback copies stay alive until the GPU has consumed + // the descriptors that reference them. + public CachedImage[] FeedbackSnapshots = []; // Host buffers that took uploads the stream ring could not hold; recycled with the draw. public (VkBuffer Buffer, DeviceMemory Memory)[]? OverflowBuffers; } diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs index 936be1d..fb3dd60 100644 --- a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs @@ -203,7 +203,7 @@ public static partial class Gen5MslTranslator // form is fma(src0, src1, src2) exactly like the SPIR-V translator. "VFmaF32" or "VMadF32" or "VMadAkF32" or "VMadMkF32" or "VFmaAkF32" or "VFmaMkF32" => FloatResult(instruction, $"fma({F(instruction, 0)}, {F(instruction, 1)}, {F(instruction, 2)})"), - "VFmaF16" => Float16Result( + "VFmaF16" or "VFmaMkF16" or "VFmaAkF16" => Float16Result( instruction, destination, $"fma({F16(instruction, 0)}, {F16(instruction, 1)}, {F16(instruction, 2)})"), @@ -216,6 +216,17 @@ public static partial class Gen5MslTranslator "VFractF32" => FloatResult(instruction, $"fract({F(instruction, 0)})"), "VSqrtF32" => FloatResult(instruction, $"sqrt({F(instruction, 0)})"), "VRsqF32" => FloatResult(instruction, $"rsqrt({F(instruction, 0)})"), + "VRcpF16" => Float16Result(instruction, destination, $"(1.0f / {F16(instruction, 0)})"), + "VSqrtF16" => Float16Result(instruction, destination, $"sqrt({F16(instruction, 0)})"), + "VLogF16" => Float16Result(instruction, destination, $"log2({F16(instruction, 0)})"), + "VExpF16" => Float16Result(instruction, destination, $"exp2({F16(instruction, 0)})"), + "VFloorF16" => Float16Result(instruction, destination, $"floor({F16(instruction, 0)})"), + "VCeilF16" => Float16Result(instruction, destination, $"ceil({F16(instruction, 0)})"), + "VTruncF16" => Float16Result(instruction, destination, $"trunc({F16(instruction, 0)})"), + "VRndneF16" => Float16Result(instruction, destination, $"rint({F16(instruction, 0)})"), + "VFractF16" => Float16Result(instruction, destination, $"fract({F16(instruction, 0)})"), + "VSinF16" => Float16Result(instruction, destination, $"sin({F16(instruction, 0)} * {TauLiteral})"), + "VCosF16" => Float16Result(instruction, destination, $"cos({F16(instruction, 0)} * {TauLiteral})"), "VRsqF16" => Float16Result( instruction, destination, @@ -228,6 +239,12 @@ public static partial class Gen5MslTranslator "VCosF32" => FloatResult(instruction, $"cos({F(instruction, 0)} * {TauLiteral})"), "VLdexpF32" => FloatResult(instruction, $"ldexp({F(instruction, 0)}, as_type({RawSource(instruction, 1)}))"), + "VMin3F16" => Float16Result(instruction, destination, + $"fmin(fmin({F16(instruction, 0)}, {F16(instruction, 1)}), {F16(instruction, 2)})"), + "VMax3F16" => Float16Result(instruction, destination, + $"fmax(fmax({F16(instruction, 0)}, {F16(instruction, 1)}), {F16(instruction, 2)})"), + "VMed3F16" => Float16Result(instruction, destination, + $"fmax(fmin({F16(instruction, 0)}, {F16(instruction, 1)}), fmin(fmax({F16(instruction, 0)}, {F16(instruction, 1)}), {F16(instruction, 2)}))"), "VMin3F32" => FloatResult(instruction, $"fmin(fmin({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)})"), "VMax3F32" => @@ -422,8 +439,10 @@ public static partial class Gen5MslTranslator } case "VSubbU32": case "VSubbrevU32": + case "VSubCoCiU32": + case "VSubrevCoCiU32": { - var reverse = instruction.Opcode == "VSubbrevU32"; + var reverse = instruction.Opcode is "VSubbrevU32" or "VSubrevCoCiU32"; var left = Temp("uint", RawSource(instruction, reverse ? 1 : 0)); var right = Temp("uint", RawSource(instruction, reverse ? 0 : 1)); var borrowIn = instruction.Sources.Count > 2 @@ -1107,7 +1126,18 @@ public static partial class Gen5MslTranslator return true; } + if (instruction.Opcode == "SFlbitI32B64") + { + var wide = Temp("ulong", RawSource64(instruction, 0)); + var result = Temp( + "uint", + $"{wide} == 0ul ? 0xFFFFFFFFu : (uint)clz({wide})"); + StoreScalar(destination, result); + return true; + } + if (instruction.Opcode.EndsWith("B64", StringComparison.Ordinal) || + instruction.Opcode == "SAshrI64" || instruction.Opcode is "SBfeU64" or "SBfeI64") { return TryEmitScalar64(instruction, destination, out error); @@ -1240,6 +1270,14 @@ public static partial class Gen5MslTranslator resultExpression = $"({left} - {right})"; sccStatement = $"(((({left} ^ {right})) & ({left} ^ RESULT)) >> 31) != 0u"; break; + case "SAddF32": + resultExpression = $"as_type(as_type({left}) + as_type({right}))"; + sccStatement = string.Empty; + break; + case "SSubF32": + resultExpression = $"as_type(as_type({left}) - as_type({right}))"; + sccStatement = string.Empty; + break; case "SAddcU32": { var partial = Temp("uint", $"{left} + {right}"); @@ -1479,6 +1517,16 @@ public static partial class Gen5MslTranslator out string error) { error = string.Empty; + + // S_SWAPPC_B64 is a dynamic call/return operation. The Gen5 + // translator has already linearized the reachable shader body, + // so there is no host program counter to exchange here. Its + // single pair operand must not be treated as a binary 64-bit op. + if (instruction.Opcode == "SSwappcB64") + { + return true; + } + var left = Temp("ulong", RawSource64(instruction, 0)); if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal)) { @@ -1536,12 +1584,15 @@ public static partial class Gen5MslTranslator value = $"({quadAny} * 0xFul)"; break; } - case "SLshlB64" or "SLshrB64": + case "SLshlB64" or "SLshrB64" or "SAshrI64": { var shift = Temp("uint", $"({RawSource(instruction, 1)}) & 63u"); - value = instruction.Opcode == "SLshlB64" - ? $"({left} << {shift})" - : $"({left} >> {shift})"; + value = instruction.Opcode switch + { + "SLshlB64" => $"({left} << {shift})", + "SLshrB64" => $"({left} >> {shift})", + _ => $"(ulong)(as_type({left}) >> {shift})", + }; break; } case "SBfmB64": diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index d9e5f95..3a41b8d 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -243,6 +243,169 @@ public static partial class Gen5SpirvTranslator case "VCvtOffF32I4": result = EmitCvtOffF32I4(instruction); break; + case "VCvtF64I32": + case "VCvtF64U32": + { + var source = GetRawSource(instruction, 0); + var converted = _module.AddInstruction( + instruction.Opcode == "VCvtF64I32" + ? SpirvOp.ConvertSToF + : SpirvOp.ConvertUToF, + DoubleType(), + instruction.Opcode == "VCvtF64I32" + ? Bitcast(_intType, source) + : source); + if (!TryStoreDoubleResult( + instruction, + destination, + converted, + out result, + out error)) + { + return false; + } + + break; + } + case "VCvtF32F64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + result = Bitcast( + _uintType, + _module.AddInstruction(SpirvOp.FConvert, _floatType, source)); + break; + } + case "VRcpF64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + var reciprocal = _module.AddInstruction( + SpirvOp.FDiv, + DoubleType(), + Double(1.0), + source); + if (!TryStoreDoubleResult( + instruction, + destination, + reciprocal, + out result, + out error)) + { + return false; + } + + break; + } + case "VRsqF64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + var root = Ext(31, DoubleType(), source); + var reciprocal = _module.AddInstruction( + SpirvOp.FDiv, + DoubleType(), + Double(1.0), + root); + if (!TryStoreDoubleResult( + instruction, + destination, + reciprocal, + out result, + out error)) + { + return false; + } + + break; + } + case "VSqrtF64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + if (!TryStoreDoubleResult( + instruction, + destination, + Ext(31, DoubleType(), source), + out result, + out error)) + { + return false; + } + + break; + } + case "VMulF64": + { + if (!TryGetDoubleSource(instruction, 0, out var left, out error)) + { + return false; + } + + if (!TryGetDoubleSource(instruction, 1, out var right, out error)) + { + return false; + } + + var product = _module.AddInstruction( + SpirvOp.FMul, + DoubleType(), + left, + right); + if (!TryStoreDoubleResult( + instruction, + destination, + product, + out result, + out error)) + { + return false; + } + + break; + } + case "VFmaF64": + { + if (!TryGetDoubleSource(instruction, 0, out var first, out error)) + { + return false; + } + + if (!TryGetDoubleSource(instruction, 1, out var second, out error)) + { + return false; + } + + if (!TryGetDoubleSource(instruction, 2, out var third, out error)) + { + return false; + } + + var fused = Ext(50, DoubleType(), first, second, third); + if (!TryStoreDoubleResult( + instruction, + destination, + fused, + out result, + out error)) + { + return false; + } + + break; + } case "VCvtPkU8F32": { var converted = _module.AddInstruction( @@ -295,6 +458,39 @@ public static partial class Gen5SpirvTranslator instruction, Ext(32, _floatType, GetFloatSource(instruction, 0))); break; + case "VRcpF16": + result = EmitFloat16Result(instruction, destination, _module.AddInstruction(SpirvOp.FDiv, _floatType, Float(1), GetFloat16Source(instruction, 0))); + break; + case "VSqrtF16": + result = EmitFloat16Result(instruction, destination, Ext(31, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VLogF16": + result = EmitFloat16Result(instruction, destination, Ext(30, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VExpF16": + result = EmitFloat16Result(instruction, destination, Ext(29, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VFloorF16": + result = EmitFloat16Result(instruction, destination, Ext(8, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VCeilF16": + result = EmitFloat16Result(instruction, destination, Ext(9, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VTruncF16": + result = EmitFloat16Result(instruction, destination, Ext(3, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VRndneF16": + result = EmitFloat16Result(instruction, destination, Ext(2, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VFractF16": + result = EmitFloat16Result(instruction, destination, Ext(10, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VSinF16": + result = EmitFloat16Result(instruction, destination, Ext(13, _floatType, _module.AddInstruction(SpirvOp.FMul, _floatType, GetFloat16Source(instruction, 0), Float(MathF.Tau)))); + break; + case "VCosF16": + result = EmitFloat16Result(instruction, destination, Ext(14, _floatType, _module.AddInstruction(SpirvOp.FMul, _floatType, GetFloat16Source(instruction, 0), Float(MathF.Tau)))); + break; case "VRsqF16": result = EmitFloat16Result( instruction, @@ -395,6 +591,20 @@ public static partial class Gen5SpirvTranslator case "VMaxF16": result = EmitFloat16ExtBinary(instruction, destination, 40); break; + case "VFmaF16": + case "VFmaMkF16": + case "VFmaAkF16": + { + var fusedBits = EmitPackedF16FusedMultiplyAdd( + GetFloat16Source(instruction, 0), + GetFloat16Source(instruction, 1), + GetFloat16Source(instruction, 2)); + result = EmitFloat16Result( + instruction, + destination, + Bitcast(_floatType, fusedBits)); + break; + } case "VMadF32": case "VFmaF32": case "VMadMkF32": @@ -410,17 +620,6 @@ public static partial class Gen5SpirvTranslator GetFloatSource(instruction, 1), GetFloatSource(instruction, 2))); break; - case "VFmaF16": - result = EmitFloat16Result( - instruction, - destination, - Ext( - 50, - _floatType, - GetFloat16Source(instruction, 0), - GetFloat16Source(instruction, 1), - GetFloat16Source(instruction, 2))); - break; case "VMacF32": case "VFmacF32": { @@ -435,6 +634,47 @@ public static partial class Gen5SpirvTranslator addend)); break; } + case "VDot2cF32F16": + { + // The two packed half pairs are widened before the + // products are accumulated in f32. The c form clamps + // the final dot product to the normalized [0, 1] range. + var left = GetRawSource(instruction, 0); + var right = GetRawSource(instruction, 1); + var lowProduct = _module.AddInstruction( + SpirvOp.FMul, + _floatType, + EmitPackedF16Half(left, highLane: false), + EmitPackedF16Half(right, highLane: false)); + var highProduct = _module.AddInstruction( + SpirvOp.FMul, + _floatType, + EmitPackedF16Half(left, highLane: true), + EmitPackedF16Half(right, highLane: true)); + var dot = _module.AddInstruction(SpirvOp.FAdd, _floatType, lowProduct, highProduct); + result = EmitFloatResult( + instruction, + Bitcast(_floatType, EmitClampToUnitInterval(Bitcast(_uintType, dot)))); + break; + } + case "VMin3F16": + case "VMax3F16": + case "VMed3F16": + { + // Every f16 value widens to f32 exactly, and min/max pick one + // input, so the f32 result narrows back without rounding. + var a = GetFloat16Source(instruction, 0); + var b = GetFloat16Source(instruction, 1); + var c = GetFloat16Source(instruction, 2); + var value = instruction.Opcode switch + { + "VMin3F16" => Ext(37, _floatType, Ext(37, _floatType, a, b), c), + "VMax3F16" => Ext(40, _floatType, Ext(40, _floatType, a, b), c), + _ => Ext(40, _floatType, Ext(37, _floatType, a, b), Ext(37, _floatType, Ext(40, _floatType, a, b), c)), + }; + result = EmitFloat16Result(instruction, destination, value); + break; + } case "VMin3F32": result = EmitFloatTernaryExt(instruction, 37); break; @@ -671,6 +911,19 @@ public static partial class Gen5SpirvTranslator BitwiseAnd(GetRawSource(instruction, 0), lowBitsMask))); break; } + case "VLshlrevB64": + { + // D.u64 = S1.u64 << (S0.u32 & 0x3F). + var shiftAmount = _module.AddInstruction( + SpirvOp.UConvert, + _ulongType, + BitwiseAnd(GetRawSource(instruction, 0), UInt(63))); + var shifted = ShiftLeftLogical64(GetRawSource64(instruction, 1), shiftAmount); + result = Narrow(shifted); + StoreV(destination + 1, Narrow(ShiftRightLogical64( + shifted, _module.Constant64(_ulongType, 32)))); + break; + } case "VBfmB32": { var width = BitwiseAnd(GetRawSource(instruction, 0), UInt(31)); @@ -682,6 +935,30 @@ public static partial class Gen5SpirvTranslator result = ShiftLeftLogical(lowMask, GetRawSource(instruction, 1)); break; } + case "VMadI32I24": + { + // D = S0 * S1 + S2 with S0 and S1 taken as signed 24-bit + // values; the product wraps in 32 bits before the add. + var signedLeft = _module.AddInstruction( + SpirvOp.BitFieldSExtract, + _intType, + Bitcast(_intType, GetRawSource(instruction, 0)), + UInt(0), + UInt(24)); + var signedRight = _module.AddInstruction( + SpirvOp.BitFieldSExtract, + _intType, + Bitcast(_intType, GetRawSource(instruction, 1)), + UInt(0), + UInt(24)); + var product = _module.AddInstruction( + SpirvOp.IMul, + _uintType, + Bitcast(_uintType, signedLeft), + Bitcast(_uintType, signedRight)); + result = IAdd(product, GetRawSource(instruction, 2)); + break; + } case "VMadU32U24": { var left = BitwiseAnd( @@ -1156,6 +1433,15 @@ public static partial class Gen5SpirvTranslator return false; } + break; + case "VPkAddI16": + result = EmitPackedI16Arithmetic(instruction, subtract: false); + break; + case "VPkSubI16": + result = EmitPackedI16Arithmetic(instruction, subtract: true); + break; + case "VPkFmacF16": + result = EmitPackedF16Fmac(instruction, destination); break; case "VFmaMixF32": case "VFmaMixloF16": @@ -1166,6 +1452,66 @@ public static partial class Gen5SpirvTranslator } break; + case "VAddNcI16": + case "VSubNcU16": + case "VSubNcI16": + case "VLshrrevB16": + case "VLshlrevB16": + case "VAshrrevI16": + case "VMaxU16": + case "VMinU16": + case "VMaxI16": + case "VMinI16": + { + if (instruction.Control is Gen5Vop3Control { Clamp: true }) + { + error = $"{instruction.Opcode}: clamped 16-bit integer results are not supported"; + return false; + } + + var signed = instruction.Opcode.EndsWith("I16", StringComparison.Ordinal); + var left = GetInt16Source(instruction, 0, signed); + var right = GetInt16Source(instruction, 1, signed); + uint value; + switch (instruction.Opcode) + { + case "VAddNcU16": + case "VAddNcI16": + value = _module.AddInstruction(SpirvOp.IAdd, _uintType, left, right); + break; + case "VSubNcU16": + case "VSubNcI16": + value = _module.AddInstruction(SpirvOp.ISub, _uintType, left, right); + break; + case "VLshrrevB16": + value = ShiftRightLogical(right, BitwiseAnd(left, UInt(15))); + break; + case "VLshlrevB16": + value = ShiftLeftLogical(right, BitwiseAnd(left, UInt(15))); + break; + case "VAshrrevI16": + value = ShiftRightArithmetic(right, BitwiseAnd(left, UInt(15))); + break; + case "VMaxU16": + value = Ext(41, _uintType, left, right); + break; + case "VMinU16": + value = Ext(38, _uintType, left, right); + break; + default: + value = Bitcast( + _uintType, + Ext( + instruction.Opcode == "VMaxI16" ? 42u : 39u, + _intType, + Bitcast(_intType, left), + Bitcast(_intType, right))); + break; + } + + result = EmitInt16Result(instruction, destination, value); + break; + } default: error = $"unsupported vector opcode {instruction.Opcode}"; return false; @@ -1297,6 +1643,37 @@ public static partial class Gen5SpirvTranslator return true; } + // V_PK_ADD/SUB_I16 operates on the selected 16-bit halves of src0 and + // src1. The packed integer result is modulo 16 bits in each lane; the + // signed and unsigned forms therefore share the same bit-level + // implementation. + private uint EmitPackedI16Arithmetic(Gen5ShaderInstruction instruction, bool subtract) + { + var control = (Gen5Vop3pControl)instruction.Control!; + var left = GetRawSource(instruction, 0); + var right = GetRawSource(instruction, 1); + uint Arithmetic(uint a, uint b) => subtract ? ISubU(a, b) : IAdd(a, b); + + uint SelectHalf(uint value, uint mask, int source) + { + return ((mask >> source) & 1) != 0 + ? ShiftRightLogical(value, UInt(16)) + : value; + } + + var low = BitwiseAnd( + Arithmetic( + BitwiseAnd(SelectHalf(left, control.OpSelMask, 0), UInt(0xFFFF)), + BitwiseAnd(SelectHalf(right, control.OpSelMask, 1), UInt(0xFFFF))), + UInt(0xFFFF)); + var high = BitwiseAnd( + Arithmetic( + BitwiseAnd(SelectHalf(left, control.OpSelHiMask, 0), UInt(0xFFFF)), + BitwiseAnd(SelectHalf(right, control.OpSelHiMask, 1), UInt(0xFFFF))), + UInt(0xFFFF)); + return BitwiseOr(low, ShiftLeftLogical(high, UInt(16))); + } + // V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 / // 0x22). Unlike the packed v_pk_* ops these compute a single f32 // fma(a, b, c): each of the three sources is *independently* read as @@ -1436,6 +1813,37 @@ public static partial class Gen5SpirvTranslator return EmitFloatToHalf(value); } + // V_PK_FMAC_F16 (VOP2 0x3C): each result half is a fused f16 multiply-add + // whose addend is the matching half of the old destination, so the packed + // register is read as D = S0 * S1 + D for both lanes at once. + private uint EmitPackedF16Fmac( + Gen5ShaderInstruction instruction, + uint destination) + { + var accumulator = LoadV(destination); + var low = EmitPackedF16FmacLane(instruction, accumulator, highLane: false); + var high = EmitPackedF16FmacLane(instruction, accumulator, highLane: true); + return BitwiseOr(low, ShiftLeftLogical(high, UInt(16))); + } + + private uint EmitPackedF16FmacLane( + Gen5ShaderInstruction instruction, + uint accumulator, + bool highLane) + { + var left = EmitPackedF16Half(GetRawSource(instruction, 0), highLane); + var right = EmitPackedF16Half(GetRawSource(instruction, 1), highLane); + var addend = EmitPackedF16Half(accumulator, highLane); + return EmitFloatToHalf( + EmitPackedF16FusedMultiplyAdd(left, right, addend)); + } + + private uint EmitPackedF16Half(uint raw, bool highLane) + { + var half = highLane ? ShiftRightLogical(raw, UInt(16)) : raw; + return Bitcast(_floatType, EmitHalfToFloat(half)); + } + // Saturates an f32 bit pattern to [0, 1] the way the VOP3P clamp modifier // does: below 0 (and NaN, since the ordered compare is false for it) becomes // 0, above 1 becomes 1. Ordered compares match the hardware's NaN-to-zero @@ -1527,6 +1935,22 @@ public static partial class Gen5SpirvTranslator int index, bool highLane) { + var operand = instruction.Sources[index]; + if (operand.Kind is Gen5OperandKind.EncodedConstant or Gen5OperandKind.LiteralConstant) + { + // An inline constant or literal is a single f32 value, not a + // packed register: both lanes take the exactly-converted f16 of + // the same constant, so there is no half to select. + var constant = GetFloatSource(instruction, index); + var constantNegate = highLane ? control.NegHiMask : control.NegLoMask; + if (((constantNegate >> index) & 1) != 0) + { + constant = _module.AddInstruction(SpirvOp.FNegate, _floatType, constant); + } + + return constant; + } + var raw = GetRawSource(instruction, index); var selectMask = highLane ? control.OpSelHiMask : control.OpSelMask; var half = ((selectMask >> index) & 1) != 0 @@ -1869,6 +2293,70 @@ public static partial class Gen5SpirvTranslator condition = _module.AddInstruction(operation, _boolType, left, right); } + else if (opcode.EndsWith("U64", StringComparison.Ordinal)) + { + if (opcode is "VCmpFU64" or "VCmpxFU64") + { + condition = _module.ConstantBool(false); + } + else if (opcode is "VCmpTU64" or "VCmpxTU64") + { + condition = _module.ConstantBool(true); + } + else + { + var wideLeft = GetRawSource64(instruction, 0); + var wideRight = GetRawSource64(instruction, 1); + var operation = opcode switch + { + "VCmpEqU64" or "VCmpxEqU64" => SpirvOp.IEqual, + "VCmpNeU64" or "VCmpxNeU64" => SpirvOp.INotEqual, + "VCmpLtU64" or "VCmpxLtU64" => SpirvOp.ULessThan, + "VCmpLeU64" or "VCmpxLeU64" => SpirvOp.ULessThanEqual, + "VCmpGtU64" or "VCmpxGtU64" => SpirvOp.UGreaterThan, + "VCmpGeU64" or "VCmpxGeU64" => SpirvOp.UGreaterThanEqual, + _ => SpirvOp.Nop, + }; + if (operation == SpirvOp.Nop) + { + error = $"unsupported u64 compare {opcode}"; + return false; + } + + condition = _module.AddInstruction(operation, _boolType, wideLeft, wideRight); + } + } + else if (opcode.EndsWith("U16", StringComparison.Ordinal) || opcode.EndsWith("I16", StringComparison.Ordinal)) + { + // VOPC 16-bit integer comparisons consume the low half of each VGPR. + // Signed comparisons sign-extend that half before the SPIR-V operation. + var signed = opcode.EndsWith("I16", StringComparison.Ordinal); + var left = GetInteger16Source(instruction, 0, signed); + var right = GetInteger16Source(instruction, 1, signed); + var operation = opcode switch + { + "VCmpEqI16" or "VCmpxEqI16" or + "VCmpEqU16" or "VCmpxEqU16" => SpirvOp.IEqual, + "VCmpNeI16" or "VCmpxNeI16" or + "VCmpNeU16" or "VCmpxNeU16" => SpirvOp.INotEqual, + "VCmpLtI16" or "VCmpxLtI16" => SpirvOp.SLessThan, + "VCmpLeI16" or "VCmpxLeI16" => SpirvOp.SLessThanEqual, + "VCmpGtI16" or "VCmpxGtI16" => SpirvOp.SGreaterThan, + "VCmpGeI16" or "VCmpxGeI16" => SpirvOp.SGreaterThanEqual, + "VCmpLtU16" or "VCmpxLtU16" => SpirvOp.ULessThan, + "VCmpLeU16" or "VCmpxLeU16" => SpirvOp.ULessThanEqual, + "VCmpGtU16" or "VCmpxGtU16" => SpirvOp.UGreaterThan, + "VCmpGeU16" or "VCmpxGeU16" => SpirvOp.UGreaterThanEqual, + _ => SpirvOp.Nop, + }; + if (operation == SpirvOp.Nop) + { + error = $"unsupported 16-bit integer compare {opcode}"; + return false; + } + + condition = _module.AddInstruction(operation, _boolType, left, right); + } else if (opcode is not ("VCmpClassF32" or "VCmpxClassF32")) { var compare64 = opcode.EndsWith("U64", StringComparison.Ordinal) || @@ -2116,13 +2604,62 @@ public static partial class Gen5SpirvTranslator return true; } + if (instruction.Opcode == "SFlbitI32B64") + { + var wide = GetRawSource64(instruction, 0); + var low = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + wide); + var high = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + ShiftRightLogical64( + wide, + _module.Constant64(_ulongType, 32))); + var highClz = _module.AddInstruction( + SpirvOp.ISub, + _uintType, + UInt(31), + Ext(74, _uintType, high)); + var lowClz = _module.AddInstruction( + SpirvOp.ISub, + _uintType, + UInt(31), + Ext(74, _uintType, low)); + var lowResult = IAdd(lowClz, UInt(32)); + var leadingZeroResult = _module.AddInstruction( + SpirvOp.Select, + _uintType, + IsNotZero(high), + highClz, + _module.AddInstruction( + SpirvOp.Select, + _uintType, + IsNotZero(low), + lowResult, + UInt(uint.MaxValue))); + StoreS(destination, leadingZeroResult); + return true; + } + if (instruction.Opcode.EndsWith("B64", StringComparison.Ordinal) || + instruction.Opcode == "SAshrI64" || instruction.Opcode is "SWqmB64" or "SBfeU64" or "SBfeI64") { return TryEmitScalar64(instruction, destination, out error); } var left = GetRawSource(instruction, 0); + if (instruction.Opcode == "SBitreplicateB64B32") + { + // S_BITREPLICATE_B64_B32 broadcasts the source dword into both + // halves of its 64-bit SGPR destination and does not update SCC. + StoreS(destination, left); + StoreS(destination + 1, left); + return true; + } + if (instruction.Opcode.EndsWith("SaveexecB32", StringComparison.Ordinal)) { var oldExec64 = BooleanToWaveMask(Load(_boolType, _exec)); @@ -2227,8 +2764,21 @@ public static partial class Gen5SpirvTranslator result = Ext(73, _uintType, left); StoreS(destination, result); return true; + case "SFlbitI32B32": + { + // Count leading zero bits, 0xFFFFFFFF when the source is zero. + var msb = Ext(74, _uintType, left); + var clz = _module.AddInstruction(SpirvOp.ISub, _uintType, UInt(31), msb); + result = _module.AddInstruction( + SpirvOp.Select, _uintType, IsNotZero(left), clz, UInt(0xFFFFFFFFu)); + StoreS(destination, result); + return true; + } case "SBitset0B32": case "SBitset1B32": + // S_BITSET*_B32 is a read-modify-write of the destination: + // it sets the single bit selected by SSRC0[4:0]. Neither + // form writes SCC. result = _module.AddInstruction( SpirvOp.BitFieldInsert, _uintType, @@ -2281,6 +2831,24 @@ public static partial class Gen5SpirvTranslator right); Store(_scc, SignedSubOverflow(left, right, result)); break; + case "SAddF32": + result = Bitcast( + _uintType, + _module.AddInstruction( + SpirvOp.FAdd, + _floatType, + Bitcast(_floatType, left), + Bitcast(_floatType, right))); + break; + case "SSubF32": + result = Bitcast( + _uintType, + _module.AddInstruction( + SpirvOp.FSub, + _floatType, + Bitcast(_floatType, left), + Bitcast(_floatType, right))); + break; case "SAddcU32": { var carryIn = _module.AddInstruction( @@ -2362,6 +2930,33 @@ public static partial class Gen5SpirvTranslator left, right); break; + case "SMulHiI32": + { + var wideLeft = _module.AddInstruction( + SpirvOp.SConvert, + _longType, + Bitcast(_intType, left)); + var wideRight = _module.AddInstruction( + SpirvOp.SConvert, + _longType, + Bitcast(_intType, right)); + var product = _module.AddInstruction( + SpirvOp.IMul, + _longType, + wideLeft, + wideRight); + result = Bitcast( + _uintType, + _module.AddInstruction( + SpirvOp.SConvert, + _intType, + _module.AddInstruction( + SpirvOp.ShiftRightArithmetic, + _longType, + product, + _module.Constant64(_longType, 32)))); + break; + } case "SMulHiU32": { var product = _module.AddInstruction( @@ -2710,6 +3305,43 @@ public static partial class Gen5SpirvTranslator out string error) { error = string.Empty; + + // S_SETPC_B64 and S_SWAPPC_B64 are dynamic call/return operations. The Gen5 + // translator has already linearized the reachable shader body, + // so there is no host program counter to exchange here. Keep + // the instruction as a control-flow marker and continue with the + // linearized body; unlike the binary scalar operations it has a + // single pair operand (s[dst:dst+1] <- s[src:src+1]). + if (instruction.Opcode is "SSetpcB64" or "SSwappcB64") + { + return true; + } + + // S_BITSET0/1_B64 use a 32-bit bit index and update the 64-bit + // value already held at the destination; they do not have a + // scalar 64-bit source operand. + if (instruction.Opcode is "SBitset0B64" or "SBitset1B64") + { + var bitIndex = Widen(BitwiseAnd(GetRawSource(instruction, 0), UInt(63))); + var selected = ShiftLeftLogical64( + _module.Constant64(_ulongType, 1), + bitIndex); + var current = LoadS64(destination); + var updated = instruction.Opcode == "SBitset1B64" + ? _module.AddInstruction( + SpirvOp.BitwiseOr, + _ulongType, + current, + selected) + : _module.AddInstruction( + SpirvOp.BitwiseAnd, + _ulongType, + current, + _module.AddInstruction(SpirvOp.Not, _ulongType, selected)); + StoreS64(destination, updated); + return true; + } + var left = GetRawSource64(instruction, 0); if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal)) { @@ -2782,13 +3414,20 @@ public static partial class Gen5SpirvTranslator return false; } + if (!_emulateWave64 && _waveLaneCount != 64) + { + newExec = BitwiseAnd( + newExec, + _module.Constant64(_ulongType, 0xFFFF_FFFFUL)); + } + StoreS64(destination, oldExec); StoreS64(126, newExec); Store(_scc, IsNotZero64(newExec)); return true; } - if (instruction.Opcode is "SLshlB64" or "SLshrB64") + if (instruction.Opcode is "SLshlB64" or "SLshrB64" or "SAshrI64") { if (instruction.Sources.Count < 2) { @@ -2800,9 +3439,12 @@ public static partial class Gen5SpirvTranslator SpirvOp.UConvert, _ulongType, GetRawSource(instruction, 1)); - var shiftedValue = instruction.Opcode == "SLshlB64" - ? ShiftLeftLogical64(left, shift) - : ShiftRightLogical64(left, shift); + var shiftedValue = instruction.Opcode switch + { + "SLshlB64" => ShiftLeftLogical64(left, shift), + "SLshrB64" => ShiftRightLogical64(left, shift), + _ => ShiftRightArithmetic64(left, shift), + }; StoreS64(destination, shiftedValue); Store(_scc, IsNotZero64(shiftedValue)); return true; @@ -3443,6 +4085,17 @@ public static partial class Gen5SpirvTranslator sourceAllowsWrite)); } + private uint GetInteger16Source( + Gen5ShaderInstruction instruction, + int sourceIndex, + bool signed) + { + var value = BitwiseAnd(GetRawSource(instruction, sourceIndex), UInt(0xFFFF)); + return signed + ? ShiftRightArithmetic(ShiftLeftLogical(value, UInt(16)), UInt(16)) + : value; + } + private uint GetFloat16Source( Gen5ShaderInstruction instruction, int sourceIndex) @@ -3594,6 +4247,12 @@ public static partial class Gen5SpirvTranslator high); } + if (operand.Kind == Gen5OperandKind.EncodedConstant && + Gen5InlineConstants.IsAperture(operand.Value)) + { + return _module.Constant64(_ulongType, Gen5InlineConstants.DecodeAperture64(operand.Value)); + } + // Scalar inline negative constants are signed immediates. B64 // consumers sign-extend them, so -1 denotes a full 64-bit mask. if (operand.Kind == Gen5OperandKind.EncodedConstant && @@ -3631,6 +4290,117 @@ public static partial class Gen5SpirvTranslator _module.AddInstruction(SpirvOp.UConvert, _uintType, high)); } + private uint DoubleType() + { + _module.AddCapability(SpirvCapability.Float64); + return _module.TypeFloat(64); + } + + private uint Double(double value) => + _module.Constant64( + DoubleType(), + unchecked((ulong)BitConverter.DoubleToInt64Bits(value))); + + // FP64 operands live in a register pair. The gfx10 inline-constant + // and literal encodings follow different expansion rules than the + // integer 64-bit forms, so refuse them instead of reading the wrong + // bits as a double. + private bool TryGetDoubleSource( + Gen5ShaderInstruction instruction, + int sourceIndex, + out uint value, + out string error) + { + error = string.Empty; + value = 0; + var operand = instruction.Sources[sourceIndex]; + if (operand.Kind != Gen5OperandKind.VectorRegister && + operand.Kind != Gen5OperandKind.ScalarRegister) + { + error = $"FP64 source {sourceIndex} of {instruction.Opcode} is not a register pair"; + return false; + } + + value = Bitcast(DoubleType(), GetRawSource64(instruction, sourceIndex)); + + var absoluteMask = 0u; + var negateMask = 0u; + switch (instruction.Control) + { + case Gen5Vop3Control vop3: + absoluteMask = vop3.AbsoluteMask; + negateMask = vop3.NegateMask; + break; + case Gen5SdwaControl sdwa: + absoluteMask = sdwa.AbsoluteMask; + negateMask = sdwa.NegateMask; + break; + } + + if ((absoluteMask & (1u << sourceIndex)) != 0) + { + value = Ext(4, DoubleType(), value); + } + + if ((negateMask & (1u << sourceIndex)) != 0) + { + value = _module.AddInstruction(SpirvOp.FNegate, DoubleType(), value); + } + + return true; + } + + // Applies the VOP3 output modifier and clamp to a double result, then + // splits it across the destination register pair. The low word is + // returned for the caller's common destination store; the high word is + // stored here so both words share the same EXEC guard. + private bool TryStoreDoubleResult( + Gen5ShaderInstruction instruction, + uint destination, + uint doubleValue, + out uint low, + out string error) + { + error = string.Empty; + low = 0; + var outputModifier = 0u; + var clamp = false; + switch (instruction.Control) + { + case Gen5Vop3Control control: + outputModifier = control.OutputModifier; + clamp = control.Clamp; + break; + case null: + break; + default: + error = $"FP64 output control {instruction.Control.GetType().Name} is not implemented in {instruction.Opcode}"; + return false; + } + + var type = DoubleType(); + doubleValue = outputModifier switch + { + 1 => _module.AddInstruction(SpirvOp.FMul, type, doubleValue, Double(2.0)), + 2 => _module.AddInstruction(SpirvOp.FMul, type, doubleValue, Double(4.0)), + 3 => _module.AddInstruction(SpirvOp.FMul, type, doubleValue, Double(0.5)), + _ => doubleValue, + }; + if (clamp) + { + doubleValue = Ext(43, type, doubleValue, Double(0.0), Double(1.0)); + } + + var bits = Bitcast(_ulongType, doubleValue); + var high = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + ShiftRightLogical64(bits, _module.Constant64(_ulongType, 32))); + StoreV(destination + 1, high); + low = _module.AddInstruction(SpirvOp.UConvert, _uintType, bits); + return true; + } + private uint EmitFloat16Binary( Gen5ShaderInstruction instruction, uint destination, @@ -4015,6 +4785,24 @@ public static partial class Gen5SpirvTranslator return result; } + // A lane selector reduced to the guest wave: bits [5:0] for wave64, [4:0] for wave32. + private uint GuestLaneSelect(uint selector) => + BitwiseAnd(selector, UInt(_waveLaneCount == 64 ? 63u : RdnaWaveLaneCount - 1)); + + // An emulated wave64 spans two host subgroups, so a subgroup broadcast cannot reach + // the other half. The selected guest lane publishes through workgroup scratch, like + // BroadcastFirstWave64Active; the trailing barrier keeps the next use from racing. + private uint BroadcastWave64Lane(uint value, uint lane) + { + EmitConditional( + _module.AddInstruction(SpirvOp.IEqual, _boolType, GuestWaveLane(), lane), + () => Store(WaveBroadcastScratchPointer(), value)); + EmitWave64Barrier(); + var result = Load(_uintType, WaveBroadcastScratchPointer()); + EmitWave64Barrier(); + return result; + } + private uint BroadcastFirstWave64Active(uint value) { var lane = GuestWaveLane(); @@ -4215,6 +5003,30 @@ public static partial class Gen5SpirvTranslator UInt(0)); } + private uint GetInt16Source(Gen5ShaderInstruction instruction, int sourceIndex, bool signed) + { + var raw = GetRawSource(instruction, sourceIndex, applySdwaIntegerModifiers: false); + if (instruction.Control is Gen5Vop3Control control && (control.OperandSelect & (1u << sourceIndex)) != 0) + { + raw = ShiftRightLogical(raw, UInt(16)); + } + + var half = BitwiseAnd(raw, UInt(0xFFFF)); + return signed + ? ShiftRightArithmetic(ShiftLeftLogical(half, UInt(16)), UInt(16)) + : half; + } + + private uint EmitInt16Result(Gen5ShaderInstruction instruction, uint destination, uint value) + { + var control = instruction.Control as Gen5Vop3Control; + var half = BitwiseAnd(value, UInt(0xFFFF)); + var current = LoadV(destination); + return ((control?.OperandSelect ?? 0) & 8) != 0 + ? BitwiseOr(BitwiseAnd(current, UInt(0x0000_FFFF)), ShiftLeftLogical(half, UInt(16))) + : BitwiseOr(BitwiseAnd(current, UInt(0xFFFF_0000)), half); + } + private uint EmitFloat16Result( Gen5ShaderInstruction instruction, uint destination, diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs new file mode 100644 index 0000000..0e2bedf --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs @@ -0,0 +1,324 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Vulkan; + +// IMAGE_BVH_INTERSECT_RAY tests one ray against one BVH node; the guest shader owns the +// traversal loop and stack. This is the GFX10 (RT IP 1.1) node test as AMD's GPURT +// software fallback defines it (IntersectCommon.hlsl, image_bvh64_intersect_ray_base), +// with node layouts from GPURT's gfx10 TriangleNode1_0 and BoxNode1_0. +public static partial class Gen5SpirvTranslator +{ + private sealed partial class CompilationContext + { + private const uint InvalidNode = 0xFFFF_FFFF; + + // Node pointer: the byte offset divided by eight above bits 2:0, which hold the type. + private const uint NodeTypeTriangle1 = 1; + private const uint NodeTypeBoxFloat16 = 4; + private const uint NodeTypeBoxFloat32 = 5; + + // Triangle node: four float3 vertices, then flags/indices, the triangle id at dword 15. + private const uint TriangleIdDword = 15; + private const int TriangleIdBitStride = 8; + private const int TriangleIdISourceShift = 0; + private const int TriangleIdJSourceShift = 2; + + // Box nodes: four child pointers, then per child a float3 min/max pair (six floats) + // or six packed halves (three dwords). + private const uint BoxBoundsDword = 4; + + private uint[]? _rayResult; + + private void EmitRayIntersect(Gen5RayIntersectControl ray, bool bvh64) + { + _rayResult ??= DeclareRayResult(); + EmitExecConditional(() => EmitRayIntersectActive(ray, bvh64)); + } + + private uint[] DeclareRayResult() + { + var result = new uint[Gen5RayIntersectControl.ResultDwords]; + for (var component = 0; component < result.Length; component++) + { + result[component] = _module.AddGlobalVariable(_privateUintPointer, SpirvStorageClass.Private, UInt(InvalidNode)); + _module.AddName(result[component], $"rayIntersectResult{component}"); + _interfaces.Add(result[component]); + } + + return result; + } + + private void EmitRayIntersectActive(Gen5RayIntersectControl ray, bool bvh64) + { + var result = _rayResult!; + var nodeLow = LoadV(ray.GetAddressRegister(0)); + var nodePointer = bvh64 ? Pair64(nodeLow, LoadV(ray.GetAddressRegister(1))) : Widen(nodeLow); + var nodeType = BitwiseAnd(nodeLow, UInt(7)); + var nodeIndex = ShiftRightLogical64(nodePointer, ULong(3)); + + // The BVH T#: base_address[39:0] in 256-byte units, box_grow_value [62:55], + // box_sort_en [63], size [105:64] (the last valid 64-byte node index), + // triangle_return_mode [120]. + var word0 = LoadS(ray.ScalarResource); + var word1 = LoadS(ray.ScalarResource + 1); + var word2 = LoadS(ray.ScalarResource + 2); + var word3 = LoadS(ray.ScalarResource + 3); + var baseUnits = Pair64(word0, BitwiseAnd(word1, UInt(0xFF))); + var bvhBase = ShiftLeftLogical64(baseUnits, ULong(8)); + var lastNode = Pair64(word2, BitwiseAnd(word3, UInt(0x3FF))); + var boxGrow = BitwiseAnd(ShiftRightLogical(word1, UInt(23)), UInt(0xFF)); + var boxSort = IsNotZero(BitwiseAnd(word1, UInt(0x8000_0000))); + var barycentrics = IsNotZero(BitwiseAnd(word3, UInt(1u << 24))); + var nodeAddress = IAdd64(bvhBase, ShiftLeftLogical64(And64(nodePointer, ULong(~7ul)), ULong(3))); + + var present = _module.AddInstruction(SpirvOp.INotEqual, _boolType, baseUnits, ULong(0)); + var inRange = _module.AddInstruction(SpirvOp.ULessThanEqual, _boolType, nodeIndex, lastNode); + // A float32 box node spans two 64-byte units. + var wideInRange = _module.AddInstruction(SpirvOp.ULessThan, _boolType, nodeIndex, lastNode); + var valid = LogicalAnd(present, inRange); + var isTriangle = LogicalAnd(valid, _module.AddInstruction(SpirvOp.ULessThanEqual, _boolType, nodeType, UInt(NodeTypeTriangle1))); + var isBox16 = LogicalAnd(valid, _module.AddInstruction(SpirvOp.IEqual, _boolType, nodeType, UInt(NodeTypeBoxFloat16))); + var isBox32 = LogicalAnd(LogicalAnd(present, wideInRange), + _module.AddInstruction(SpirvOp.IEqual, _boolType, nodeType, UInt(NodeTypeBoxFloat32))); + + var extent = Bitcast(_floatType, LoadV(ray.GetAddressRegister(bvh64 ? 2 : 1))); + var origin = new uint[3]; + for (var axis = 0; axis < 3; axis++) + origin[axis] = Bitcast(_floatType, LoadV(ray.GetAddressRegister((bvh64 ? 3 : 2) + axis))); + var direction = LoadRayVector(ray, bvh64, inverse: false); + var inverseDirection = LoadRayVector(ray, bvh64, inverse: true); + + // Other node types (user nodes 6 and 7, unused 2 and 3), a null BVH or an + // out-of-range node return four invalid dwords. + foreach (var variable in result) + Store(variable, UInt(InvalidNode)); + EmitConditional(isTriangle, () => + { + var values = EmitRayTriangle(nodeAddress, nodeType, barycentrics, origin, direction); + for (var component = 0; component < values.Length; component++) + Store(result[component], values[component]); + }); + // Each box format reads only its own node bytes. + void EmitBoxes(bool fp16) + { + var children = EmitRayBoxes(nodeAddress, fp16, boxGrow, boxSort, extent, origin, inverseDirection); + for (var component = 0; component < children.Length; component++) + Store(result[component], children[component]); + } + + EmitConditional(isBox16, () => EmitBoxes(fp16: true)); + EmitConditional(isBox32, () => EmitBoxes(fp16: false)); + + for (var component = 0u; component < Gen5RayIntersectControl.ResultDwords; component++) + StoreV(ray.VectorData + component, Load(_uintType, result[component])); + } + + // Direction and inverse direction follow the origin; A16 packs both as six halves + // into three dwords. + private uint[] LoadRayVector(Gen5RayIntersectControl ray, bool bvh64, bool inverse) + { + var first = bvh64 ? 6 : 5; + var values = new uint[3]; + if (!ray.A16) + { + for (var axis = 0; axis < 3; axis++) + values[axis] = Bitcast(_floatType, LoadV(ray.GetAddressRegister(first + (inverse ? 3 : 0) + axis))); + return values; + } + + for (var axis = 0; axis < 3; axis++) + { + var half = (inverse ? 3 : 0) + axis; + var word = LoadV(ray.GetAddressRegister(first + half / 2)); + var bits = half % 2 == 0 ? BitwiseAnd(word, UInt(0xFFFF)) : ShiftRightLogical(word, UInt(16)); + values[axis] = Bitcast(_floatType, EmitHalfToFloat(bits)); + } + + return values; + } + + private uint LoadNodeDword(uint nodeAddress, uint dword) => + LoadDeviceDword(IAdd64(nodeAddress, ULong(dword * 4ul))); + + private uint LoadNodeFloat(uint nodeAddress, uint dword) => + Bitcast(_floatType, LoadNodeDword(nodeAddress, dword)); + + // fast_intersect_triangle and SwizzleBarycentrics. Node type 0 tests (v0, v1, v2), + // type 1 tests (v1, v3, v2). A miss returns t_num = +inf over t_denom = 1. The + // barycentric return mode yields the i/j numerators the builder's rotation maps + // back; the other mode yields the triangle id and a hit flag. + private uint[] EmitRayTriangle(uint nodeAddress, uint nodeType, uint barycentrics, + IReadOnlyList origin, IReadOnlyList direction) + { + var second = _module.AddInstruction(SpirvOp.IEqual, _boolType, nodeType, UInt(NodeTypeTriangle1)); + var v1 = new uint[3]; + var v2 = new uint[3]; + var v3 = new uint[3]; + for (var axis = 0u; axis < 3; axis++) + { + var first = LoadNodeFloat(nodeAddress, axis); + var vertex1 = LoadNodeFloat(nodeAddress, 3 + axis); + var vertex2 = LoadNodeFloat(nodeAddress, 6 + axis); + var vertex3 = LoadNodeFloat(nodeAddress, 9 + axis); + v1[axis] = SelectF(second, vertex1, first); + v2[axis] = SelectF(second, vertex3, vertex1); + v3[axis] = vertex2; + } + + var e1 = Subtract(v2, v1); + var e2 = Subtract(v3, v1); + var e3 = Subtract(origin, v1); + var s1 = Cross(direction, e2); + var s2 = Cross(e3, e1); + var tNum = Dot(e2, s2); + var tDenom = Dot(s1, e1); + var iNum = Dot(e3, s1); + var jNum = Dot(direction, s2); + + var t = FDiv(tNum, tDenom); + var u = FDiv(iNum, tDenom); + var v = FDiv(jNum, tDenom); + var zero = Float(0f); + var one = Float(1f); + var missed = Any( + FCompare(SpirvOp.FOrdLessThan, u, zero), + FCompare(SpirvOp.FOrdGreaterThan, u, one), + FCompare(SpirvOp.FOrdLessThan, v, zero), + FCompare(SpirvOp.FOrdGreaterThan, FAdd(u, v), one), + FCompare(SpirvOp.FOrdLessThan, t, zero)); + tNum = SelectF(missed, Float(float.PositiveInfinity), tNum); + tDenom = SelectF(missed, one, tDenom); + + var triangleId = LoadNodeDword(nodeAddress, TriangleIdDword); + var shift = ShiftLeftLogical(nodeType, UInt(3)); + uint Barycentric(int sourceShift) + { + var source = BitwiseAnd(ShiftRightLogical(triangleId, IAdd(shift, UInt((uint)sourceShift))), UInt(3)); + return SelectF(_module.AddInstruction(SpirvOp.IEqual, _boolType, source, UInt(1)), iNum, + SelectF(_module.AddInstruction(SpirvOp.IEqual, _boolType, source, UInt(2)), jNum, + FSub(FSub(tDenom, iNum), jNum))); + } + + var hit = LogicalNot(missed); + return + [ + Bitcast(_uintType, tNum), + Bitcast(_uintType, tDenom), + SelectU(barycentrics, Bitcast(_uintType, Barycentric(TriangleIdISourceShift)), triangleId), + SelectU(barycentrics, Bitcast(_uintType, Barycentric(TriangleIdJSourceShift)), SelectU(hit, UInt(1), UInt(0))), + ]; + } + + // IntersectNodeBvh4 with fast_intersect_bbox: slab test clipped to [0, extent], a + // NaN interval misses, box_grow_value widens the exit time by that many 2^-24 + // steps, and the optional sort orders hit children by entry time. + private uint[] EmitRayBoxes(uint nodeAddress, bool fp16, uint boxGrow, uint boxSort, uint extent, + IReadOnlyList origin, IReadOnlyList inverseDirection) + { + var zero = Float(0f); + var growFactor = FAdd(Float(1f), FMul( + _module.AddInstruction(SpirvOp.ConvertUToF, _floatType, boxGrow), Float(5.960464478e-8f))); + var children = new uint[4]; + var keys = new uint[4]; + for (var child = 0u; child < 4; child++) + { + var pointer = LoadNodeDword(nodeAddress, child); + var enter = Float(float.NegativeInfinity); + var leave = Float(float.PositiveInfinity); + for (var axis = 0u; axis < 3; axis++) + { + var min = BoxBound(nodeAddress, fp16, child, axis); + var max = BoxBound(nodeAddress, fp16, child, axis + 3); + var planeMin = FMul(FSub(min, origin[(int)axis]), inverseDirection[(int)axis]); + var planeMax = FMul(FSub(max, origin[(int)axis]), inverseDirection[(int)axis]); + var positive = FCompare(SpirvOp.FOrdGreaterThanEqual, inverseDirection[(int)axis], zero); + var near = SelectF(positive, planeMin, planeMax); + var far = SelectF(positive, planeMax, planeMin); + // HLSL max3/min3: NaN operands propagate, so the NaN check below sees them. + enter = axis == 0 ? near : NanMax(enter, near); + leave = axis == 0 ? far : NanMin(leave, far); + } + + var nan = _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, enter), + _module.AddInstruction(SpirvOp.IsNan, _boolType, leave)); + var minT = SelectF(nan, Float(float.PositiveInfinity), Ext(40, _floatType, enter, zero)); + var maxT = SelectF(nan, Float(float.NegativeInfinity), Ext(37, _floatType, leave, extent)); + var hit = FCompare(SpirvOp.FOrdLessThanEqual, minT, FMul(maxT, growFactor)); + children[child] = SelectU(hit, pointer, UInt(InvalidNode)); + keys[child] = minT; + } + + // The hardware sorting network; an invalid child always sinks. + var sorted = (uint[])children.Clone(); + (int, int)[] network = [(0, 2), (1, 3), (0, 1), (2, 3), (1, 2)]; + foreach (var (a, b) in network) + { + var bValid = _module.AddInstruction(SpirvOp.INotEqual, _boolType, sorted[b], UInt(InvalidNode)); + var aInvalid = _module.AddInstruction(SpirvOp.IEqual, _boolType, sorted[a], UInt(InvalidNode)); + var swap = _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + LogicalAnd(bValid, FCompare(SpirvOp.FOrdLessThan, keys[b], keys[a])), aInvalid); + (sorted[a], sorted[b]) = (SelectU(swap, sorted[b], sorted[a]), SelectU(swap, sorted[a], sorted[b])); + (keys[a], keys[b]) = (SelectF(swap, keys[b], keys[a]), SelectF(swap, keys[a], keys[b])); + } + + for (var child = 0; child < 4; child++) + children[child] = SelectU(boxSort, sorted[child], children[child]); + return children; + } + + // Bound 0-2 is the minimum, 3-5 the maximum of one child box. + private uint BoxBound(uint nodeAddress, bool fp16, uint child, uint bound) + { + var index = child * 6 + bound; + if (!fp16) + return LoadNodeFloat(nodeAddress, BoxBoundsDword + index); + + var packed = LoadNodeDword(nodeAddress, BoxBoundsDword + index / 2); + var bits = index % 2 == 0 ? BitwiseAnd(packed, UInt(0xFFFF)) : ShiftRightLogical(packed, UInt(16)); + return Bitcast(_floatType, EmitHalfToFloat(bits)); + } + + private uint NanMax(uint left, uint right) => SelectF( + _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, left), + FCompare(SpirvOp.FOrdGreaterThan, left, right)), left, right); + + private uint NanMin(uint left, uint right) => SelectF( + _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, left), + FCompare(SpirvOp.FOrdLessThan, left, right)), left, right); + + private uint[] Subtract(IReadOnlyList left, IReadOnlyList right) => + [FSub(left[0], right[0]), FSub(left[1], right[1]), FSub(left[2], right[2])]; + + private uint[] Cross(IReadOnlyList a, IReadOnlyList b) => + [ + FSub(FMul(a[1], b[2]), FMul(a[2], b[1])), + FSub(FMul(a[2], b[0]), FMul(a[0], b[2])), + FSub(FMul(a[0], b[1]), FMul(a[1], b[0])), + ]; + + private uint Dot(IReadOnlyList a, IReadOnlyList b) => + FAdd(FAdd(FMul(a[0], b[0]), FMul(a[1], b[1])), FMul(a[2], b[2])); + + private uint Any(params uint[] conditions) + { + var result = conditions[0]; + for (var index = 1; index < conditions.Length; index++) + result = _module.AddInstruction(SpirvOp.LogicalOr, _boolType, result, conditions[index]); + return result; + } + + private uint FCompare(SpirvOp op, uint left, uint right) => _module.AddInstruction(op, _boolType, left, right); + private uint FAdd(uint left, uint right) => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right); + private uint FSub(uint left, uint right) => _module.AddInstruction(SpirvOp.FSub, _floatType, left, right); + private uint FMul(uint left, uint right) => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right); + private uint FDiv(uint left, uint right) => _module.AddInstruction(SpirvOp.FDiv, _floatType, left, right); + private uint SelectF(uint condition, uint whenTrue, uint whenFalse) => + _module.AddInstruction(SpirvOp.Select, _floatType, condition, whenTrue, whenFalse); + } +} diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs index 2ccf92c..34df42b 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs @@ -115,6 +115,7 @@ public static partial class Gen5SpirvTranslator _localSizeX = Math.Max(request.LocalSizeX, 1); _localSizeY = Math.Max(request.LocalSizeY, 1); _localSizeZ = Math.Max(request.LocalSizeZ, 1); + _physicalAxisOfLogical = ComputeWorkgroupAxisOrder(_localSizeX, _localSizeY, _localSizeZ); _emulateWave64 = _stage == Gen5SpirvStage.Compute && _waveLaneCount == 64 && (ulong)_localSizeX * _localSizeY * _localSizeZ == 64; _requiredVertexOutputCount = request.RequiredVertexOutputCount; _pixelInputEnable = request.PixelInputEnable; @@ -322,31 +323,41 @@ public static partial class Gen5SpirvTranslator } var isStorage = resourceClass == ImageResourceClass.Storage; - var kind = numericClass switch - { - ImageNumericClass.Uint => ImageComponentKind.Uint, - ImageNumericClass.Sint => ImageComponentKind.Sint, - _ => ImageComponentKind.Float, - }; + // Every atomic image (integer or float) is declared with the R32ui + // format below, so its OpTypeImage Sampled Type must be uint too - + // VUID-StandaloneSpirv-Image-04965 requires the two to match. The + // float atomics (Fmin/Fmax) still operate on the real float bits; + // they just get there through a bitcast + integer compare-exchange + // (see Gen5SpirvTranslator.cs's ImageAtomicFmax/Fmin case) instead + // of through the declared image/sampled type. + var kind = atomic + ? ImageComponentKind.Uint + : numericClass switch + { + ImageNumericClass.Uint => ImageComponentKind.Uint, + ImageNumericClass.Sint => ImageComponentKind.Sint, + _ => ImageComponentKind.Float, + }; var componentType = kind switch { ImageComponentKind.Sint => _intType, ImageComponentKind.Uint => _uintType, _ => _floatType, }; + // The image request builder binds guest 1D textures through 1D views, + // so the shader declaration must be 1D as well. var spirvDimension = dimension switch { ImageDimension.Dim1D or ImageDimension.Dim1DArray => SpirvImageDim.Dim1D, ImageDimension.Dim3D => SpirvImageDim.Dim3D, _ => SpirvImageDim.Dim2D, }; - var arrayed = dimension is ImageDimension.Dim1DArray or ImageDimension.Dim2DArray or ImageDimension.Dim2DMsaaArray; - var multisampled = dimension is ImageDimension.Dim2DMsaa or ImageDimension.Dim2DMsaaArray; if (spirvDimension == SpirvImageDim.Dim1D) { _module.AddCapability(isStorage ? SpirvCapability.Image1D : SpirvCapability.Sampled1D); } - + var arrayed = dimension is ImageDimension.Dim1DArray or ImageDimension.Dim2DArray or ImageDimension.Dim2DMsaaArray; + var multisampled = dimension is ImageDimension.Dim2DMsaa or ImageDimension.Dim2DMsaaArray; var format = atomic ? SpirvImageFormat.R32ui : SpirvImageFormat.Unknown; if (isStorage && !atomic) { @@ -772,16 +783,44 @@ public static partial class Gen5SpirvTranslator } else if (entry.Kind == MemoryResourceKind.ScalarBuffer) { - if (entry.Resource == MemoryAccessInfo.NoResource) + if (entry.Resource != MemoryAccessInfo.NoResource) { - error = "scalar buffer load has no dense buffer"; - return false; + var bindingIndex = (int)entry.Resource; + var byteAddress = IAdd(dynamicOffset, UInt(unchecked((uint)control.ImmediateOffsetBytes + (uint)component * sizeof(uint)))); + byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress); + value = LoadBufferWord(bindingIndex, ShiftRightLogical(byteAddress, UInt(2))); } + else + { + // A runtime SRT V#: its SGPRs hold the descriptor, so build the + // guest address from the base and go through the device-address + // page table, bounded by the descriptor's stride and record count. + if (instruction.Sources.Count == 0 || instruction.Sources[0].Kind != Gen5OperandKind.ScalarRegister) + { + error = "runtime scalar buffer load has no scalar base"; + return false; + } - var bindingIndex = (int)entry.Resource; - var byteAddress = IAdd(dynamicOffset, UInt(unchecked((uint)control.ImmediateOffsetBytes + (uint)component * sizeof(uint)))); - byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress); - value = LoadBufferWord(bindingIndex, ShiftRightLogical(byteAddress, UInt(2))); + var baseRegister = instruction.Sources[0].Value; + var descriptorWord1 = LoadS(baseRegister + 1); + var stride = BitwiseAnd(ShiftRightLogical(descriptorWord1, UInt(16)), UInt(0x3FFF)); + var records = LoadS(baseRegister + 2); + var size = _module.AddInstruction( + SpirvOp.Select, + _uintType, + _module.AddInstruction(SpirvOp.IEqual, _boolType, stride, UInt(0)), + records, + _module.AddInstruction(SpirvOp.IMul, _uintType, stride, records)); + var baseAddress = Pair64(LoadS(baseRegister), BitwiseAnd(descriptorWord1, UInt(0xFFFF))); + var byteOffset = IAdd(dynamicOffset, UInt(unchecked((uint)control.ImmediateOffsetBytes + (uint)component * sizeof(uint)))); + var address = And64(IAdd64(baseAddress, Widen(byteOffset)), ULong(DeviceAddressMask & ~3ul)); + var inRange = _module.AddInstruction( + SpirvOp.ULessThan, + _boolType, + ShiftRightLogical(byteOffset, UInt(2)), + ShiftRightLogical(size, UInt(2))); + value = LoadBoundedDeviceDword(address, inRange); + } } else { @@ -846,6 +885,11 @@ public static partial class Gen5SpirvTranslator address = IAdd64(address, SignedOffset64(control.OffsetBytes)); var entry = request.Memory[memoryIndex]; + if (control.UsesFlatAddress && entry.AddressSpace != FlatAddressSpace.Global) + { + return TryEmitFlatLocalMemory(instruction, control, memoryOpcode, entry.AddressSpace, address, out error); + } + var writes = entry.Access is MemoryAccess.Write or MemoryAccess.Atomic; var accessBytes = Math.Max((entry.DataBits + 7) / 8, 1u) * Math.Max(entry.DataDwords, 1u); var allowed = writes ? IsWrittenAccessAllowed(memoryIndex, address, accessBytes) : _module.ConstantBool(true); @@ -927,6 +971,100 @@ public static partial class Gen5SpirvTranslator return true; } + // Routes each lane of an aperture-derived FLAT access like the hardware: LDS or + // scratch at the low dword when the high dword is in an aperture, otherwise a + // global access through the page table. + private bool TryEmitFlatLocalMemory( + Gen5ShaderInstruction instruction, + Gen5GlobalMemoryControl control, + string memoryOpcode, + FlatAddressSpace addressSpace, + uint address, + out string error) + { + error = string.Empty; + var store = memoryOpcode.StartsWith("GlobalStoreDword", StringComparison.Ordinal); + var load = memoryOpcode.StartsWith("GlobalLoadDword", StringComparison.Ordinal); + if (!store && !load) + { + error = $"{instruction.Opcode} through the {addressSpace} flat aperture is not implemented"; + return false; + } + + var canShare = addressSpace is FlatAddressSpace.Shared or FlatAddressSpace.SharedOrPrivate; + var canPrivate = addressSpace is FlatAddressSpace.Private or FlatAddressSpace.SharedOrPrivate; + if (canShare && _lds == 0) + { + error = "a shared flat aperture access has no LDS storage"; + return false; + } + + if (canPrivate && (_scratch == 0 || _scratchDwordCount == 0)) + { + error = "a private flat aperture access has no scratch storage"; + return false; + } + + var offset = _module.AddInstruction(SpirvOp.UConvert, _uintType, address); + var aperture = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + ShiftRightLogical64(address, _module.Constant64(_ulongType, 32 + Gen5InlineConstants.ApertureShift))); + var isShared = _module.AddInstruction(SpirvOp.IEqual, _boolType, aperture, + UInt(Gen5InlineConstants.SharedApertureHigh >> Gen5InlineConstants.ApertureShift)); + var isPrivate = _module.AddInstruction(SpirvOp.IEqual, _boolType, aperture, + UInt(Gen5InlineConstants.PrivateApertureHigh >> Gen5InlineConstants.ApertureShift)); + + void Shared() + { + for (uint index = 0; index < control.DwordCount; index++) + { + var pointer = LdsPointer(offset, index * sizeof(uint)); + if (store) + Store(pointer, LoadV(control.SourceVectorRegister + index)); + else + StoreV(control.DestinationVectorRegister + index, Load(_uintType, pointer)); + } + } + + void Private() + { + for (uint index = 0; index < control.DwordCount; index++) + { + var pointer = ScratchPointer(offset, index * sizeof(uint)); + if (store) + Store(pointer, LoadV(control.SourceVectorRegister + index)); + else + StoreV(control.DestinationVectorRegister + index, Load(_uintType, pointer)); + } + } + + void Global() + { + var aligned = And64(address, ULong(~3ul)); + for (uint index = 0; index < control.DwordCount; index++) + { + var dwordAddress = index == 0 ? aligned : IAdd64(aligned, ULong((ulong)index * sizeof(uint))); + if (store) + StoreDeviceDword(dwordAddress, LoadV(control.SourceVectorRegister + index), _module.ConstantBool(true)); + else + StoreV(control.DestinationVectorRegister + index, LoadDeviceDword(dwordAddress)); + } + } + + // Inactive lanes must not touch any of the three storages. + EmitExecConditional(() => + { + if (canShare && canPrivate) + EmitConditional(isShared, Shared, () => EmitConditional(isPrivate, Private, Global)); + else if (canShare) + EmitConditional(isShared, Shared, Global); + else + EmitConditional(isPrivate, Private, Global); + }); + return true; + } + private bool TryEmitScratchMemory( Gen5ShaderInstruction instruction, Gen5GlobalMemoryControl control, @@ -942,6 +1080,10 @@ public static partial class Gen5SpirvTranslator var address = control.ScalarAddress < 125 ? LoadS(control.ScalarAddress) : LoadV(control.VectorAddress); + if (control.DynamicOffsetRegister is { } dynamicOffsetRegister) + { + address = IAdd(address, LoadS(dynamicOffsetRegister)); + } if (control.OffsetBytes != 0) { address = IAdd(address, UInt(unchecked((uint)control.OffsetBytes))); @@ -961,7 +1103,9 @@ public static partial class Gen5SpirvTranslator { Store( ScratchPointer(address, index * sizeof(uint)), - LoadV(control.SourceVectorRegister + index)); + control.SourceIsScalar + ? LoadS(control.SourceVectorRegister + index) + : LoadV(control.SourceVectorRegister + index)); } }); return true; @@ -1085,6 +1229,160 @@ public static partial class Gen5SpirvTranslator return true; } + // BufferLoweringStrategy.PhysicalStorageBuffer: a raw buffer access whose + // V# was read from a runtime scalar address. The descriptor dwords live in + // the SGPRs named by the instruction, so build the guest address from the + // runtime base and go through the device-address page table. + // num_records/stride bound the access, so a null or empty descriptor binds + // nothing, matching the static path's range check. Formatted/typed/atomic + // accesses never reach here; the chooser escalates them to + // BoundedCandidateTable. + private bool TryEmitPhysicalStorageBufferMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + out string error) + { + error = string.Empty; + if (control.Typed || instruction.Opcode.Contains("Format", StringComparison.Ordinal)) + { + error = $"runtime buffer descriptor does not support the formatted access {instruction.Opcode}"; + return false; + } + + if (instruction.Opcode.StartsWith("BufferAtomic", StringComparison.Ordinal)) + { + error = $"runtime buffer descriptor does not support the atomic access {instruction.Opcode}"; + return false; + } + + if (instruction.Sources.Count < 2 || + instruction.Sources[1].Kind != Gen5OperandKind.ScalarRegister) + { + error = "runtime buffer descriptor has no scalar resource base"; + return false; + } + + var srsrc = instruction.Sources[1].Value; + var scalarOffset = instruction.Sources.Count > 2 + ? GetRawSource(instruction, 2) + : UInt(0); + var vectorIndex = control.IndexEnabled ? LoadV(control.VectorAddress) : UInt(0); + var vectorOffset = control.OffsetEnabled + ? LoadV(control.VectorAddress + (control.IndexEnabled ? 1u : 0u)) + : UInt(0); + + var descriptorWord1 = LoadS(srsrc + 1); + var stride = BitwiseAnd(ShiftRightLogical(descriptorWord1, UInt(16)), UInt(0x3FFF)); + var byteOffset = IAdd(UInt(unchecked((uint)control.OffsetBytes)), scalarOffset); + byteOffset = IAdd(byteOffset, vectorOffset); + byteOffset = IAdd( + byteOffset, + _module.AddInstruction(SpirvOp.IMul, _uintType, vectorIndex, stride)); + + // size = stride == 0 ? num_records : stride * num_records, in bytes. + var records = LoadS(srsrc + 2); + var size = _module.AddInstruction( + SpirvOp.Select, + _uintType, + _module.AddInstruction(SpirvOp.IEqual, _boolType, stride, UInt(0)), + records, + _module.AddInstruction(SpirvOp.IMul, _uintType, stride, records)); + + var dwordOffset = ShiftRightLogical(byteOffset, UInt(2)); + var dwordSize = ShiftRightLogical(size, UInt(2)); + var baseAddress = Pair64( + LoadS(srsrc), + BitwiseAnd(descriptorWord1, UInt(0xFFFF))); + var address = IAdd64(baseAddress, Widen(byteOffset)); + + uint InRange(uint index) + { + var dword = index == 0 ? dwordOffset : IAdd(dwordOffset, UInt(index)); + return _module.AddInstruction(SpirvOp.ULessThan, _boolType, dword, dwordSize); + } + + if (instruction.Opcode.StartsWith("BufferStoreDword", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("BufferStoreByte", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("BufferStoreShort", StringComparison.Ordinal)) + { + EmitExecConditional(() => + { + if (TryGetSubdwordStoreInfo(instruction.Opcode, out var byteCount, out var sourceShift)) + { + StoreDeviceBytes( + address, + LoadV(control.VectorData), + byteCount, + sourceShift, + InRange(0)); + return; + } + + for (uint index = 0; index < control.DwordCount; index++) + { + StoreDeviceDword( + index == 0 ? address : IAdd64(address, ULong((ulong)index * sizeof(uint))), + LoadV(control.VectorData + index), + InRange(index)); + } + }); + return true; + } + + if (TryGetSubdwordLoadInfo( + instruction.Opcode, + out var loadByteCount, + out var signExtend, + out var d16, + out var d16High)) + { + StoreV( + control.VectorData, + LoadSubdwordDeviceValue( + address, + LoadV(control.VectorData), + loadByteCount, + signExtend, + d16, + d16High)); + return true; + } + + if (!instruction.Opcode.StartsWith("BufferLoad", StringComparison.Ordinal) && + !instruction.Opcode.StartsWith("TBufferLoad", StringComparison.Ordinal)) + { + error = $"unsupported runtime buffer opcode {instruction.Opcode}"; + return false; + } + + for (uint index = 0; index < control.DwordCount; index++) + { + StoreV( + control.VectorData + index, + LoadBoundedDeviceDword( + index == 0 ? address : IAdd64(address, ULong((ulong)index * sizeof(uint))), + InRange(index))); + } + + return true; + } + + private uint LoadBoundedDeviceDword(uint address64, uint inRange) + { + var (pointer, valid) = ResolveDeviceAddress(address64); + Store(_deviceWordScratch, UInt(0)); + EmitConditional(LogicalAnd(inRange, valid), () => + Store( + _deviceWordScratch, + _module.AddInstruction( + SpirvOp.Load, + _uintType, + DeviceWordPointer(pointer), + 2u, + 4u))); + return Load(_uintType, _deviceWordScratch); + } + // ---- images ---- // An access over several descriptors takes one constant-element case per descriptor: diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index 81cf6fe..4c4a7b9 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -23,6 +23,38 @@ public static partial class Gen5SpirvTranslator uint numberType) => CompilationContext.DecodeStorageImageFormat(dataFormat, numberType); + // Vulkan only guarantees maxComputeWorkGroupSize >= (1024, 1024, 64) and + // maxComputeWorkGroupInvocations >= 1024, but a GNM NUM_THREAD layout can put its + // largest axis on Z (real PS5 hardware has no such asymmetry between axes). Remap + // logical GNM axes onto physical SPIR-V/dispatch axes so the largest logical size + // always lands on the physical axis with the most headroom (X), and the smallest + // lands on Z, which is the only axis with a tight guaranteed limit. This is a pure + // function of the three thread-group sizes so the host dispatch path (which permutes + // vkCmdDispatch's group counts the same way) can recompute it independently without + // any shader-compiler metadata plumbing. + // + // Returns physicalAxisOfLogical: physicalAxisOfLogical[logical 0=X/1=Y/2=Z] is the + // physical axis (0=X/1=Y/2=Z) that logical axis is remapped onto. + public static int[] ComputeWorkgroupAxisOrder(uint sizeX, uint sizeY, uint sizeZ) + { + if (sizeZ <= 64) + { + return [0, 1, 2]; + } + + var sizes = new[] { sizeX, sizeY, sizeZ }; + var byDescendingSize = new[] { 0, 1, 2 }; + Array.Sort(byDescendingSize, (a, b) => sizes[b].CompareTo(sizes[a])); + + var physicalAxisOfLogical = new int[3]; + for (var physical = 0; physical < 3; physical++) + { + physicalAxisOfLogical[byDescendingSize[physical]] = physical; + } + + return physicalAxisOfLogical; + } + private sealed partial class CompilationContext { private const int ScalarRegisterCount = 128; @@ -80,6 +112,13 @@ public static partial class Gen5SpirvTranslator private readonly uint _localSizeX; private readonly uint _localSizeY; private readonly uint _localSizeZ; + + // Vulkan only guarantees maxComputeWorkGroupSize >= (1024, 1024, 64), but a GNM + // NUM_THREAD layout can put its largest axis on Z (real hardware has no such asymmetry). + // physicalAxisOfLogical[logical 0=X/1=Y/2=Z] gives which physical SPIR-V axis (and which + // vkCmdDispatch group-count slot) that logical axis is remapped onto, so the largest size + // always lands on the physical axis with the most headroom. + private readonly int[] _physicalAxisOfLogical; private readonly uint _pixelInputEnable; private readonly uint _pixelInputAddress; private readonly uint[] _pixelInputCntl; @@ -122,6 +161,7 @@ public static partial class Gen5SpirvTranslator private uint _storageUintPointer; private uint _lds; private uint _ldsElementPointer; + private uint _lds64ElementPointer; private uint _ldsDwordMask; private uint _scratch; private uint _scratchElementPointer; @@ -172,11 +212,11 @@ public static partial class Gen5SpirvTranslator ImageComponentKind ComponentKind, bool IsStorage, bool Arrayed, - bool Cube, - bool Multisampled, - SpirvImageDim Dimension, - uint ConversionFormat, - uint ShaderSwizzle, + bool Cube = false, + bool Multisampled = false, + SpirvImageDim Dimension = SpirvImageDim.Dim2D, + uint ConversionFormat = 0, + uint ShaderSwizzle = 0, int EmulatedCompareFunction = -1); private readonly record struct SpirvVertexInput( @@ -428,12 +468,19 @@ public static partial class Gen5SpirvTranslator } else if (_stage == Gen5SpirvStage.Compute) { + var logicalSizes = new[] { _localSizeX, _localSizeY, _localSizeZ }; + var physicalSizes = new uint[3]; + for (var logical = 0; logical < 3; logical++) + { + physicalSizes[_physicalAxisOfLogical[logical]] = logicalSizes[logical]; + } + _module.AddExecutionMode( main, SpirvExecutionMode.LocalSize, - _localSizeX, - _localSizeY, - _localSizeZ); + physicalSizes[0], + physicalSizes[1], + physicalSizes[2]); } var attributeCount = _stage == Gen5SpirvStage.Vertex @@ -576,7 +623,9 @@ public static partial class Gen5SpirvTranslator private void DeclareScratch() { if (!_request.Program.Instructions.Any(static instruction => - instruction.Opcode.StartsWith("Scratch", StringComparison.Ordinal))) + instruction.Opcode.StartsWith("Scratch", StringComparison.Ordinal)) && + !_request.Memory.Entries.Any(static memory => + memory.AddressSpace is FlatAddressSpace.Private or FlatAddressSpace.SharedOrPrivate)) { return; } @@ -661,6 +710,7 @@ public static partial class Gen5SpirvTranslator var ldsArrayType = _module.TypeArray(_uintType, dwordCount); var ldsPointer = _module.TypePointer(storageClass, ldsArrayType); _ldsElementPointer = _module.TypePointer(storageClass, _uintType); + _lds64ElementPointer = _module.TypePointer(storageClass, _ulongType); _lds = storageClass == SpirvStorageClass.Workgroup ? _module.AddGlobalVariable(ldsPointer, storageClass) : _module.AddGlobalVariable( @@ -855,6 +905,8 @@ public static partial class Gen5SpirvTranslator for (var index = 0; index < attributes.Length; index++) { var attribute = attributes[index]; + // V_INTERP_MOV reads one vertex of the primitive, so its attribute is a + // per-vertex array rather than an interpolated value. var variable = _module.AddGlobalVariable( _perVertexAttributes.Contains(attribute) ? _module.TypePointer(SpirvStorageClass.Input, _module.TypeArray(_vec4Type, 3)) @@ -1074,18 +1126,23 @@ public static partial class Gen5SpirvTranslator var invocationInBounds = _module.ConstantBool(true); for (uint component = 0; component < 3; component++) { + // gl_LocalInvocationId/gl_WorkGroupId are indexed by the physical SPIR-V + // axis, which can differ from the logical GNM axis when the workgroup was + // remapped to respect the device's tighter Z-axis limit (see + // ComputeWorkgroupAxisOrder). + var physicalComponent = (uint)_physicalAxisOfLogical[component]; var localComponent = _module.AddInstruction( SpirvOp.CompositeExtract, _uintType, localId, - component); + physicalComponent); StoreV(component, localComponent, guardWithExec: false); var groupComponent = _module.AddInstruction( SpirvOp.CompositeExtract, _uintType, workGroupId, - component); + physicalComponent); var localSize = component switch { 0 => _localSizeX, @@ -1120,15 +1177,15 @@ public static partial class Gen5SpirvTranslator StoreComputeSystemRegister( registers.WorkGroupXRegister, workGroupId, - 0); + (uint)_physicalAxisOfLogical[0]); StoreComputeSystemRegister( registers.WorkGroupYRegister, workGroupId, - 1); + (uint)_physicalAxisOfLogical[1]); StoreComputeSystemRegister( registers.WorkGroupZRegister, workGroupId, - 2); + (uint)_physicalAxisOfLogical[2]); if (registers.ThreadGroupSizeRegister is { } sizeRegister) { StoreS( @@ -1172,6 +1229,8 @@ public static partial class Gen5SpirvTranslator { if ((_pixelInputAddress & (1u << bit)) != 0) { + // Shaders that interpolate by hand (P1/P2 on per-vertex attributes) read + // the I/J barycentrics from these registers. if (_barycentricInputs.TryGetValue(bit, out var barycentricInput)) { var coordinates = LoadBarycentricCoordinates(bit, barycentricInput); @@ -1182,6 +1241,7 @@ public static partial class Gen5SpirvTranslator StoreV(vgpr + component, Bitcast(_uintType, coordinate), guardWithExec: false); } } + vgpr += dwordCount; } } @@ -1433,8 +1493,11 @@ public static partial class Gen5SpirvTranslator { if (_stage == Gen5SpirvStage.Compute) { + // s_waitcnt vmcnt(0) + s_barrier also publishes buffer and image + // stores to the workgroup: AcquireRelease over uniform, workgroup + // and image memory. var workgroup = UInt(2); - var semantics = UInt(0x108); + var semantics = UInt(0x8 | 0x40 | 0x100 | 0x800); _module.AddStatement( SpirvOp.ControlBarrier, workgroup, @@ -1461,7 +1524,8 @@ public static partial class Gen5SpirvTranslator if (instruction.Control is Gen5RayIntersectControl rayIntersect) { - EmitRayIntersectMiss(rayIntersect); + _deviceAddressInstructionPc = instruction.Pc; + EmitRayIntersect(rayIntersect, instruction.Opcode == "ImageBvh64IntersectRay"); return true; } @@ -1540,6 +1604,8 @@ public static partial class Gen5SpirvTranslator case "DsAppend": case "DsConsume": return TryEmitDataShareWaveCounter(instruction, control, out error); + case "DsMskorB32": + return TryEmitDataShareAtomic(instruction, control, out error); case "DsWriteB32": case "DsWriteAddtidB32": { @@ -1575,6 +1641,92 @@ public static partial class Gen5SpirvTranslator GetRawSource(instruction, 2)); return true; } + case "DsAddU64": + case "DsOrB64": + { + if (instruction.Sources.Count < 3) + { + error = "missing LDS 64-bit atomic source"; + return false; + } + + var atomicAddress = GetRawSource(instruction, 0); + var isOr = instruction.Opcode == "DsOrB64"; + // The LDS allocation is a uint array. Reinterpreting its + // pointer as ulong with OpBitcast is invalid SPIR-V, so use + // the dword fallback even when native 64-bit atomics exist. + // The pair is not atomic against concurrent 64-bit use. + var lowPointer = LdsPointer( + atomicAddress, + control.SingleOffsetBytes); + var highPointer = LdsPointer( + atomicAddress, + control.SingleOffsetBytes + sizeof(uint)); + EmitExecConditional(() => + { + var lowValue = GetRawSource(instruction, 1); + var highValue = GetRawSource(instruction, 2); + if (isOr) + { + // OR is bitwise-independent: no dword influences the + // other, so two 32-bit ORs are the 64-bit OR. + EmitAtomic( + SpirvOp.AtomicOr, + _uintType, + lowPointer, + scope: 2, + semantics: 0x108, + value: () => lowValue, + comparator: () => lowValue); + EmitAtomic( + SpirvOp.AtomicOr, + _uintType, + highPointer, + scope: 2, + semantics: 0x108, + value: () => highValue, + comparator: () => highValue); + return; + } + + // ADD needs the carry out of the low dword folded into + // the high one. Each lane detects its own wrap from the + // pre-add value its atomic returned and adds exactly one + // carry, so the high dword ends up with the true count of + // wraps however the lanes interleave. + var originalLow = EmitAtomic( + SpirvOp.AtomicIAdd, + _uintType, + lowPointer, + scope: 2, + semantics: 0x108, + value: () => lowValue, + comparator: () => lowValue); + var sumLow = IAdd(originalLow, lowValue); + var wrapped = _module.AddInstruction( + SpirvOp.ULessThan, + _boolType, + sumLow, + originalLow); + var carry = _module.AddInstruction( + SpirvOp.Select, + _uintType, + wrapped, + UInt(1), + UInt(0)); + var highWithCarry = IAdd(highValue, carry); + EmitAtomic( + SpirvOp.AtomicIAdd, + _uintType, + highPointer, + scope: 2, + semantics: 0x108, + value: () => highWithCarry, + comparator: () => highWithCarry); + }); + + return true; + } case "DsWriteB96": case "DsWriteB128": { @@ -1898,6 +2050,40 @@ public static partial class Gen5SpirvTranslator out string error) { error = string.Empty; + if (instruction.Opcode == "DsMskorB32") + { + if (instruction.Sources.Count < 3) + { + error = "missing LDS masked-OR source"; + return false; + } + + var maskedPointer = LdsPointer( + GetRawSource(instruction, 0), + control.SingleOffsetBytes); + EmitExecConditional(() => + { + var original = Load(_uintType, maskedPointer); + var updated = BitwiseOr( + BitwiseAnd( + original, + _module.AddInstruction( + SpirvOp.Not, + _uintType, + GetRawSource(instruction, 1))), + GetRawSource(instruction, 2)); + EmitAtomic( + SpirvOp.AtomicCompareExchange, + _uintType, + maskedPointer, + scope: 2, + semantics: 0x108, + value: () => updated, + comparator: () => original); + }); + return true; + } + if (instruction.Opcode is "DsMinF32" or "DsMaxF32") { if (instruction.Sources.Count < 3) @@ -2208,6 +2394,13 @@ public static partial class Gen5SpirvTranslator out string error) { error = string.Empty; + if (instruction.Opcode is "BufferWbinvl1" or "BufferWbinvl1Vol") + { + // The host storage path is already coherent; the guest instruction + // invalidates a cache level and does not access the buffer itself. + return true; + } + if (control.Typed && instruction.Opcode.Contains("D16", StringComparison.Ordinal)) { error = $"unsupported buffer opcode {instruction.Opcode}"; @@ -2225,23 +2418,65 @@ public static partial class Gen5SpirvTranslator return TryEmitDeviceDescriptorBufferMemory(instruction, control, out error); } - int bindingIndex; - uint stride; - uint descriptorWord3; - uint descriptorFormat; + if (_request.Memory.TryGetIndex(instruction.Pc, 0, out var accessMemoryIndex)) { - // The dense buffer, its stride and its format come from the specialization. - if (!TryResolveLayoutBuffer(instruction.Pc, out bindingIndex, out var specialized)) + var accessMemory = _request.Memory[accessMemoryIndex]; + if (accessMemory.PlanningOnly) { - error = "missing buffer-memory binding"; - return false; + // The access belongs to the linearized tail of a fused + // shader. Its descriptor is supplied by the continuation + // object and is intentionally absent from this plan; keep + // the dead linear instruction side-effect free. + return true; } - stride = UInt(specialized.PackedStride & 0x3FFF); - descriptorFormat = specialized.DescriptorFormat; - descriptorWord3 = UInt((specialized.DescriptorFormat << 12) | (specialized.DescriptorSwizzle & 0xFFF)); + var strategy = accessMemory.BufferDescriptor?.ChooseStrategy( + control.Typed, + accessMemory.Formatted, + accessMemory.Access == MemoryAccess.Atomic) + ?? BufferLoweringStrategy.NativeBinding; + if (strategy == BufferLoweringStrategy.PhysicalStorageBuffer) + { + return TryEmitPhysicalStorageBufferMemory(instruction, control, out error); + } + + if (strategy == BufferLoweringStrategy.BoundedCandidateTable) + { + return TryEmitBoundedCandidateTableMemory(instruction, control, accessMemoryIndex, out error); + } } + if (!TryResolveLayoutBuffer(instruction.Pc, out var bindingIndex, out _)) + { + error = "missing buffer-memory binding"; + return false; + } + + return EmitResolvedBufferMemory(instruction, control, bindingIndex, out error); + } + + // One buffer operation against a resolved candidate binding: the dense binding path + // and every arm of a bounded candidate table share it, so the operation itself + // stays independent of how its descriptor was selected. + private bool EmitResolvedBufferMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + int bindingIndex, + out string error) + { + error = string.Empty; + var info = _request.Resources.Info; + if (bindingIndex < 0 || bindingIndex >= info.Buffers.Count) + { + error = $"buffer binding {bindingIndex} is out of range"; + return false; + } + + var specialized = info.Buffers[bindingIndex]; + var stride = UInt(specialized.PackedStride & 0x3FFF); + var descriptorFormat = specialized.DescriptorFormat; + var descriptorWord3 = UInt((specialized.DescriptorFormat << 12) | (specialized.DescriptorSwizzle & 0xFFF)); + var scalarOffset = instruction.Sources.Count > 2 ? GetRawSource(instruction, 2) : UInt(0); @@ -2261,6 +2496,44 @@ public static partial class Gen5SpirvTranslator byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress); var dwordAddress = ShiftRightLogical(byteAddress, UInt(2)); + if (instruction.Opcode is "BufferAtomicSwapX2" or "BufferAtomicOrX2") + { + EmitExecConditional(() => + { + var firstInRange = IsBufferWordInRange(bindingIndex, dwordAddress); + var secondAddress = IAdd(dwordAddress, UInt(1)); + var secondInRange = IsBufferWordInRange(bindingIndex, secondAddress); + EmitConditional(LogicalAnd(firstInRange, secondInRange), () => + { + var atomicOp = instruction.Opcode == "BufferAtomicSwapX2" + ? SpirvOp.AtomicExchange + : SpirvOp.AtomicOr; + var first = EmitAtomic( + atomicOp, + _uintType, + BufferWordPointer(bindingIndex, dwordAddress), + scope: 1, + semantics: 0x48, + value: () => LoadV(control.VectorData), + comparator: () => UInt(0)); + var second = EmitAtomic( + atomicOp, + _uintType, + BufferWordPointer(bindingIndex, secondAddress), + scope: 1, + semantics: 0x48, + value: () => LoadV(control.VectorData + 1), + comparator: () => UInt(0)); + if (control.Glc) + { + StoreV(control.VectorData, first); + StoreV(control.VectorData + 1, second); + } + }); + }); + return true; + } + if (instruction.Opcode.StartsWith("BufferAtomic", StringComparison.Ordinal)) { var atomicSuffix = instruction.Opcode["BufferAtomic".Length..]; @@ -2481,6 +2754,114 @@ public static partial class Gen5SpirvTranslator return true; } + // BufferLoweringStrategy.BoundedCandidateTable: a runtime V# whose descriptors cannot + // be reconstructed from its raw words. The runtime V# sits in the scalar resource + // registers; its base-address dword is the probe key into the flattened candidate + // mapping, and each arm binds a native candidate and runs the ordinary buffer op. + private bool TryEmitBoundedCandidateTableMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + int memoryIndex, + out string error) + { + error = string.Empty; + if (!_request.BufferCandidateTableByMemoryIndex.TryGetValue(memoryIndex, out var table)) + { + // A formatted load needs a statically known Vulkan view format. Most + // runtime V#s prove a bounded SRT candidate set above, but a few Yotei + // material-lookup paths merge descriptors through control flow and have + // no finite, safe candidate table. Do not reinterpret an arbitrary + // device address with a guessed format: a null read is the defined + // fallback, matching the bindless-image fallback in ResourceTracker. + if (instruction.Opcode.StartsWith("BufferLoadFormat", StringComparison.Ordinal)) + { + for (uint index = 0; index < control.DwordCount; index++) + { + StoreV(control.VectorData + index, UInt(0)); + } + + return true; + } + + error = $"runtime buffer descriptor has no candidate table for {instruction.Opcode}"; + return false; + } + + if (table.CandidateCount == 0) + { + error = "runtime buffer descriptor candidate table is empty"; + return false; + } + + if (table.CandidateCount == 1) + { + return EmitResolvedBufferMemory(instruction, control, (int)table.FirstCandidate, out error); + } + + if (!HasFlattenedTable) + { + error = "runtime buffer descriptor candidate table without a flattened table binding"; + return false; + } + + if (instruction.Sources.Count < 2 || instruction.Sources[1].Kind != Gen5OperandKind.ScalarRegister) + { + error = "runtime buffer descriptor has no scalar resource base"; + return false; + } + + var probeKey = LoadS(instruction.Sources[1].Value); + var selector = SelectBufferCandidate(table, probeKey); + var emitted = true; + var caseError = string.Empty; + for (uint index = 0; index < table.CandidateCount && emitted; index++) + { + var candidate = table.FirstCandidate + index; + EmitConditional(_module.AddInstruction(SpirvOp.IEqual, _boolType, selector, UInt(index)), () => + { + if (!EmitResolvedBufferMemory(instruction, control, (int)candidate, out caseError)) + { + emitted = false; + } + }); + } + + error = caseError; + return emitted; + } + + // Searches the sorted base-address mapping of a candidate table for the runtime V#'s + // probe key; the result is the candidate-local index, candidate 0 when absent. + private uint SelectBufferCandidate(BufferCandidateTableUse table, uint key) + { + var mapping = UInt(table.MappingOffset); + var count = LoadFlattenedWord(mapping); + var low = UInt(0); + var high = count; + for (uint iteration = 0; iteration < table.SearchIterations; iteration++) + { + var span = _module.AddInstruction(SpirvOp.ISub, _uintType, high, low); + var middle = IAdd(low, ShiftRightLogical(span, UInt(1))); + var probeSlot = IAdd(IAdd(mapping, UInt(1)), ShiftLeftLogical(middle, UInt(1))); + var probeKey = LoadFlattenedWord(probeSlot); + var moveUp = LogicalAnd( + _module.AddInstruction(SpirvOp.ULessThan, _boolType, probeKey, key), + _module.AddInstruction(SpirvOp.ULessThan, _boolType, low, high)); + low = _module.AddInstruction(SpirvOp.Select, _uintType, moveUp, IAdd(middle, UInt(1)), low); + high = _module.AddInstruction(SpirvOp.Select, _uintType, moveUp, high, middle); + } + + var foundSlot = IAdd(IAdd(mapping, UInt(1)), ShiftLeftLogical(low, UInt(1))); + var found = LogicalAnd( + _module.AddInstruction(SpirvOp.ULessThan, _boolType, low, count), + _module.AddInstruction(SpirvOp.IEqual, _boolType, LoadFlattenedWord(foundSlot), key)); + var mapped = LoadFlattenedWord(IAdd(foundSlot, UInt(1))); + var inRange = LogicalAnd( + found, + _module.AddInstruction(SpirvOp.ULessThan, _boolType, mapped, UInt(table.CandidateCount))); + return _module.AddInstruction(SpirvOp.Select, _uintType, inRange, mapped, UInt(0)); + } + private bool TryEmitDeviceDescriptorBufferMemory( Gen5ShaderInstruction instruction, Gen5BufferMemoryControl control, @@ -3695,8 +4076,10 @@ public static partial class Gen5SpirvTranslator } // Compare-exchange loop: the merge runs on the observed word until the - // exchange succeeds. - private void EmitAtomicWordUpdate(uint pointer, Func merge) + // exchange succeeds. Returns the observed (pre-exchange) word at the + // point the loop exits, which is a valid use here because it is + // defined by the OpPhi in `header`, and `header` dominates `mergeLabel`. + private uint EmitAtomicWordUpdate(uint pointer, Func merge) { var preheader = _module.AllocateId(); var header = _module.AllocateId(); @@ -3733,6 +4116,7 @@ public static partial class Gen5SpirvTranslator _module.AddLabel(continueLabel); _module.AddStatement(SpirvOp.Branch, header); _module.AddLabel(mergeLabel); + return observed; } private static bool TryGetSubdwordLoadInfo( @@ -3833,48 +4217,12 @@ public static partial class Gen5SpirvTranslator return true; } - // BVH nodes are not traversed yet: every ray misses the node it is tested against. - // The node type sits in the low three bits of the node pointer. A box node (4-7) - // returns four invalid child pointers, a triangle node (0-3) returns t = +inf over - // a denominator of 1, so the guest's traversal loop ends with no hit. - private void EmitRayIntersectMiss(Gen5RayIntersectControl ray) - { - var nodeType = BitwiseAnd(LoadV(ray.GetAddressRegister(0)), UInt(7)); - var isTriangle = _module.AddInstruction(SpirvOp.ULessThan, _boolType, nodeType, UInt(4)); - uint[] triangleMiss = [0x7F800000u, 0x3F800000u, 0u, 0u]; - for (var component = 0u; component < Gen5RayIntersectControl.ResultDwords; component++) - { - var value = _module.AddInstruction( - SpirvOp.Select, - _uintType, - isTriangle, - UInt(triangleMiss[component]), - UInt(0xFFFFFFFFu)); - StoreV(ray.VectorData + component, value); - } - } - private bool TryEmitImage( Gen5ShaderInstruction instruction, Gen5ImageControl image, out string error) { error = string.Empty; - if (instruction.Opcode is "ImageBvhIntersectRay" or "ImageBvh64IntersectRay") - { - // The host path does not expose Vulkan ray-query or an - // acceleration-structure descriptor for GFX10's raw BVH - // texture. Return a deterministic miss instead of rejecting - // the complete compute shader. The instruction always writes - // four DWORD result registers. - for (uint component = 0; component < 4; component++) - { - StoreV(image.VectorData + component, UInt(0)); - } - - return true; - } - SpirvImageResource resource; uint imageObject; uint dstSelect; @@ -3954,7 +4302,8 @@ public static partial class Gen5SpirvTranslator resource.IsStorage || resource.Multisampled ? [queryImage] : [queryImage, LoadImageIntegerAddress(image, 0)]); - var levels = !resource.IsStorage && !resource.Multisampled + // NVIDIA's compiler crashes on an unused level query of a 3D image. + var levels = (image.Dmask & 0x8u) != 0 && !resource.IsStorage && !resource.Multisampled ? _module.AddInstruction( SpirvOp.ImageQueryLevels, _uintType, @@ -4097,6 +4446,50 @@ public static partial class Gen5SpirvTranslator return false; } + // IMAGE_ATOMIC_FMIN/FMAX target float-format storage images and + // have no native SPIR-V storage-image atomic without pulling in + // SPV_EXT_shader_atomic_float_min_max. Lower them as a + // compare-and-swap loop on the underlying bit pattern instead + // (same technique used by other float-atomic emulations that + // avoid that extension dependency). + if (instruction.Opcode is "ImageAtomicFmax" or "ImageAtomicFmin") + { + var isMax = instruction.Opcode == "ImageAtomicFmax"; + var floatCoordinateCount = ImageCoordinateComponentCount(resource); + var floatCoordinates = BuildIntegerCoordinates( + image, + 0, + floatCoordinateCount); + EmitExecConditional(() => + { + var pointer = _module.AddInstruction( + SpirvOp.ImageTexelPointer, + _module.TypePointer(SpirvStorageClass.Image, _uintType), + resource.Variable, + floatCoordinates, + UInt(0)); + var srcBits = Bitcast(_uintType, LoadV(image.VectorData)); + var old = EmitAtomicWordUpdate(pointer, observed => + { + var oldFloat = Bitcast(_floatType, observed); + var srcFloat = Bitcast(_floatType, srcBits); + var pickSrc = _module.AddInstruction( + isMax ? SpirvOp.FOrdLessThan : SpirvOp.FOrdGreaterThan, + _boolType, + oldFloat, + srcFloat); + var chosen = _module.AddInstruction( + SpirvOp.Select, _floatType, pickSrc, srcFloat, oldFloat); + return Bitcast(_uintType, chosen); + }); + if (image.Glc) + { + StoreV(image.VectorData, old); + } + }); + return true; + } + if (resource.ComponentKind == ImageComponentKind.Float || !TryGetAtomicOp(instruction.Opcode["ImageAtomic".Length..], out var atomicOp)) { @@ -4903,6 +5296,12 @@ public static partial class Gen5SpirvTranslator ? _intType : _module.TypeVector(_intType, componentCount); + private uint IntegerTypeForComponents(uint componentCount) => + componentCount == 1 ? _intType : _module.TypeVector(_intType, componentCount); + + private uint FloatTypeForComponents(uint componentCount) => + componentCount == 1 ? _floatType : _module.TypeVector(_floatType, componentCount); + private uint BuildFloatCoordinates( Gen5ImageControl image, int start, @@ -6143,9 +6542,8 @@ public static partial class Gen5SpirvTranslator value = 0; var packedSource = exportInstruction.Sources[component >> 1]; var tracePackedExport = - Environment.GetEnvironmentVariable( - "SHARPEMU_TRACE_PACKED_EXPORT") == "1" && - _request.Program.Address == 0x0000000500781200ul; + Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PACKED_EXPORT") == "1" && + TraceShaderAddressMatches("SHARPEMU_TRACE_PACKED_EXPORT_ADDRESS"); if (tracePackedExport) { Console.Error.WriteLine( @@ -6261,6 +6659,28 @@ public static partial class Gen5SpirvTranslator return false; } + private bool TraceShaderAddressMatches(string environmentVariable) + { + var filter = Environment.GetEnvironmentVariable(environmentVariable); + if (string.IsNullOrWhiteSpace(filter)) + { + return true; + } + + var span = filter.AsSpan(); + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + span = span[2..]; + } + + return ulong.TryParse( + span, + System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, + out var address) && + _request.Program.Address == address; + } + private uint GetPixelOutputType(Gen5PixelOutputKind kind) => kind switch { @@ -6536,6 +6956,15 @@ public static partial class Gen5SpirvTranslator left, BitwiseAnd64(right, _module.Constant64(_ulongType, 63))); + private uint ShiftRightArithmetic64(uint left, uint right) => + Bitcast( + _ulongType, + _module.AddInstruction( + SpirvOp.ShiftRightArithmetic, + _longType, + Bitcast(_longType, left), + BitwiseAnd64(right, _module.Constant64(_ulongType, 63)))); + private uint BitwiseAnd(uint left, uint right) => _module.AddInstruction(SpirvOp.BitwiseAnd, _uintType, left, right); @@ -6845,7 +7274,9 @@ public static partial class Gen5SpirvTranslator private bool UsesLds() => _request.Program.Instructions.Any(instruction => - instruction.Control is Gen5DataShareControl); + instruction.Control is Gen5DataShareControl) || + _request.Memory.Entries.Any(static memory => + memory.AddressSpace is FlatAddressSpace.Shared or FlatAddressSpace.SharedOrPrivate); private bool UsesSubgroupShuffle() => _request.Program.Instructions.Any(instruction => diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs index 4fcd834..485a002 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs @@ -197,6 +197,7 @@ public enum SpirvCapability : uint Float16 = 9, Float64 = 10, Int64 = 11, + Int64Atomics = 12, Int16 = 22, ImageGatherExtended = 25, StorageImageExtendedFormats = 49, @@ -207,6 +208,8 @@ public enum SpirvCapability : uint GroupNonUniformVote = 62, GroupNonUniformBallot = 64, GroupNonUniformShuffle = 65, + ShaderLayer = 5253, + ShaderViewportIndex = 5254, ShaderViewportIndexLayerExt = 5254, RuntimeDescriptorArray = 5302, PhysicalStorageBufferAddresses = 5347, diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs b/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs index 269e038..0662b24 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs @@ -201,14 +201,55 @@ public static class TilerShaders _ => [X(6, 0x0c0), X(7, 0x200), X(8, 0x800), X(9, 0x2000), X(10, 0x8000), Y(4, 0x030), Y(6, 0x100), Y(7, 0x400), Y(8, 0x1000), Y(9, 0x4000)], }; - private static Term[] RenderTargetTerms(uint bytes) => bytes switch + private readonly record struct AddressBit(Axis Source, byte Bit); + + private static AddressBit BX(byte bit) => new(Axis.X, bit); + + private static AddressBit BY(byte bit) => new(Axis.Y, bit); + + private static AddressBit[] BXY(byte x, byte y) => [BX(x), BY(y)]; + + // PS5 Oberon RB+ R_X addressing. The destination bit index is the array + // index. Keep this in lockstep with GnmTiling.RbPlus64KRenderX and + // TileGeometry.RenderTargetOffset. + private static readonly AddressBit[][][] RenderTargetAddressBits = + [ + [ + [BX(0)], [BX(1)], [BX(2)], [BX(3)], [BY(0)], [BY(1)], [BY(2)], [BY(3)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BX(6)], [BY(6)], BXY(7, 8), BXY(8, 7), + ], + [ + [], [BX(0)], [BX(1)], [BX(2)], [BY(0)], [BY(1)], [BY(2)], [BX(3)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BY(3)], [BX(6)], BXY(7, 7), BXY(8, 6), + ], + [ + [], [], [BX(0)], [BX(1)], [BY(0)], [BY(1)], [BX(2)], [BY(2)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BX(3)], [BY(3)], BXY(6, 7), BXY(7, 6), + ], + [ + [], [], [], [BX(0)], [BY(0)], [BX(1)], [BX(2)], [BY(1)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BY(2)], [BX(3)], BXY(7, 3), BXY(6, 6), + ], + [ + [], [], [], [], [BX(0)], [BY(0)], [BX(1)], [BY(1)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BX(2)], [BY(2)], BXY(6, 3), BXY(3, 6), + ], + ]; + + private static uint RenderTargetOffset(ShaderModuleContext shaderModule, uint x, uint y, uint bytes) { - 1 => [Y(2, 0x008), Y(4, 0x010), Y(3, 0x0a0), Y(5, 0xf00), Y(6, 0x1000), Y(7, 0x4000), X(0, 7), X(3, 0x040), X(5, 0x300), X(4, 0x400), X(6, 0x800), X(7, 0x2000), X(8, 0x8000)], - 2 => [Y(4, 0x070), Y(5, 0xf00), Y(8, 0x5000), X(1, 0x00e), X(4, 0x480), X(5, 0x300), X(6, 0x800), X(7, 0x2000), X(8, 0x8000)], - 4 => [Y(4, 0x070), Y(5, 0xf00), Y(9, 0x1000), Y(8, 0x4000), X(2, 0x00c), X(5, 0x380), X(4, 0x400), X(6, 0x800), X(9, 0xa000)], - 8 => [Y(4, 0x010), Y(6, 0x080), Y(5, 0xf00), Y(10, 0x5000), X(3, 0x008), X(4, 0x460), X(5, 0x300), X(6, 0x800), X(10, 0x2000), X(9, 0x8000)], - _ => [X(4, 0x410), X(5, 0x340), X(6, 0x800), X(11, 0xa000), Y(5, 0xf20), Y(6, 0x080), Y(10, 0x1000), Y(11, 0x4000)], - }; + uint offset = shaderModule.GetUnsignedConstant(0); + var table = RenderTargetAddressBits[Log2(bytes)]; + for (uint destination = 0; destination < table.Length; destination++) + { + foreach (var bit in table[destination]) + { + offset = shaderModule.Xor(offset, Bit(shaderModule, bit.Source == Axis.X ? x : y, bit.Bit, destination)); + } + } + + return offset; + } private static Term[] DepthTerms(uint bytes) => bytes switch { @@ -306,7 +347,7 @@ public static class TilerShaders return shaderModule.Xor(Standard64KB3DOffset(shaderModule, x, y, z, bytes), delta); } case TilerBlockShape.RenderTarget64KB: - return shaderModule.Xor(XorTerms(shaderModule, x, y, z, RenderTargetTerms(bytes)), ZSpread(shaderModule, z)); + return shaderModule.Xor(RenderTargetOffset(shaderModule, x, y, bytes), ZSpread(shaderModule, z)); case TilerBlockShape.Depth64KB: return shaderModule.Xor(ZSpread(shaderModule, z), XorTerms(shaderModule, x, y, z, DepthTerms(bytes))); default: diff --git a/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs b/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs index 672d80b..ece91a8 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs @@ -9,8 +9,52 @@ namespace SharpEmu.ShaderCompiler; /// public static class Gen5InlineConstants { + public const uint SharedBase = 235; + public const uint SharedLimit = 236; + public const uint PrivateBase = 237; + public const uint PrivateLimit = 238; + + // The PS5 FLAT apertures, as title shaders hard-code them: a high dword of + // 0x7xxxxxxx selects LDS and 0x8xxxxxxx scratch. Guest device addresses fit in + // 40 bits and never reach them. + public const uint SharedApertureHigh = 0x7000_0000; + public const uint PrivateApertureHigh = 0x8000_0000; + + // Each aperture is the window of high dwords sharing its top nibble. + public const int ApertureShift = 28; + + public static bool IsSharedApertureHigh(uint high) => high >> ApertureShift == SharedApertureHigh >> ApertureShift; + + public static bool IsPrivateApertureHigh(uint high) => high >> ApertureShift == PrivateApertureHigh >> ApertureShift; + + public static bool IsAperture(uint encoded) => encoded is >= SharedBase and <= PrivateLimit; + + public static bool IsSharedAperture(uint encoded) => encoded is SharedBase or SharedLimit; + + // The 64-bit aperture value: a base has a zero low dword, a limit spans the + // whole 32-bit offset range. A 32-bit read returns the high dword. + public static ulong DecodeAperture64(uint encoded) + { + var high = IsSharedAperture(encoded) ? SharedApertureHigh : PrivateApertureHigh; + var low = encoded is SharedLimit or PrivateLimit ? uint.MaxValue : 0u; + return ((ulong)high << 32) | low; + } + public static bool TryDecode(uint encoded, out uint value) { + if (IsAperture(encoded)) + { + value = (uint)(DecodeAperture64(encoded) >> 32); + return true; + } + + // POPS_EXITING_WAVE_ID: no primitive-ordered pixel shading is modeled. + if (encoded == 239) + { + value = 0; + return true; + } + if (encoded == 125) { value = 0; diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs index 52d5116..fb48f84 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs @@ -160,7 +160,11 @@ public readonly record struct Gen5Operand(Gen5OperandKind Kind, uint Value) return new(Gen5OperandKind.LiteralConstant, literal.Value); } - if (encoded <= 105 || encoded is 106 or 107 or 124 or 126 or 127) + // 108..123 are the temporary SGPRs (TTMP0..TTMP15). They use the + // same scalar register file as ordinary SGPRs; treating them as + // encoded constants loses values in shader continuations that spill + // through a temporary register. + if (encoded <= 105 || encoded is 106 or 107 or (>= 108 and <= 123) or 124 or 126 or 127) { return Scalar(encoded); } @@ -223,7 +227,9 @@ public sealed record Gen5GlobalMemoryControl( int OffsetBytes, bool Glc, bool Slc, - bool UsesFlatAddress = false) : Gen5InstructionControl; + bool UsesFlatAddress = false, + uint? DynamicOffsetRegister = null, + bool SourceIsScalar = false) : Gen5InstructionControl; // A typed access carries the unified format from the instruction; a formatted // untyped access reads the descriptor format when it executes. diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs index d54f618..e4c1f3f 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs @@ -14,7 +14,10 @@ public static partial class Gen5ShaderTranslator { private static int _dppVectorsValidated; - private const int MaxInstructions = 16384; + // Some shipped Gen5 shaders exceed 64 KiB of encoded instructions before + // reaching S_ENDPGM. Keep the malformed-shader guard, but size it to the + // largest declared shader object accepted by the decoder (1 MiB). + private const int MaxInstructions = (int)(MaximumDeclaredShaderSizeBytes / sizeof(uint)); private const ulong ShaderSizeOffset = 0x44; private const uint MaximumDeclaredShaderSizeBytes = 1024 * 1024; private static readonly ConditionalWeakTable _fusedProgramsByMemory = new(); @@ -120,7 +123,13 @@ public static partial class Gen5ShaderTranslator ctx, address, maximumBytes: null, - stopAtSetProgramCounter: false, + // An unregistered S_SETPC transfers control to a separately + // allocated code object. The bytes that follow it in this + // object are padding or unrelated data, so decoding through them + // produces bogus ALU instructions and can reject an otherwise + // valid shader. Registered fused programs take the dedicated + // path above and splice their continuation explicitly. + stopAtSetProgramCounter: true, out program, out _, out error); @@ -266,6 +275,8 @@ public static partial class Gen5ShaderTranslator var instructions = new List(); var instructionCount = 0; + var lastPc = 0u; + var lastWord = 0u; uint furthestForwardBranchTarget = 0; for (uint pc = 0; instructionCount < MaxInstructions && @@ -283,6 +294,9 @@ public static partial class Gen5ShaderTranslator return false; } + lastPc = pc; + lastWord = word; + if (!TryDecodeInstruction( ctx, address, @@ -329,7 +343,12 @@ public static partial class Gen5ShaderTranslator if (Gen5IrBranchResolver.Instance.TryGetBranchTarget( instruction, out var branchTargetPc) && - branchTargetPc > instruction.Pc) + branchTargetPc > instruction.Pc && + // S_CBRANCH_CDBG* only fire under an attached GPU debugger, and + // the translators emit them as never taken. Their targets are + // debugger stubs placed after S_ENDPGM that read devkit-only + // addresses, so they must not extend the decoded program. + !instruction.Opcode.StartsWith("SCbranchCdbg", StringComparison.Ordinal)) { furthestForwardBranchTarget = Math.Max( furthestForwardBranchTarget, @@ -337,7 +356,12 @@ public static partial class Gen5ShaderTranslator } pc += sizeDwords * sizeof(uint); - if (string.Equals(name, "SEndpgm", StringComparison.Ordinal) && + // An unconditional backward S_BRANCH with no forward target + // pending is the last reachable instruction too: compilers place + // out-of-line loop blocks after S_ENDPGM, and the code object's + // metadata trailer follows them. + if ((string.Equals(name, "SEndpgm", StringComparison.Ordinal) || + string.Equals(name, "SBranch", StringComparison.Ordinal)) && pc > furthestForwardBranchTarget) { program = new Gen5ShaderProgram(address, instructions); @@ -355,8 +379,8 @@ public static partial class Gen5ShaderTranslator } error = maximumBytes.HasValue - ? $"unterminated limit=0x{maximumBytes.Value:X}" - : "unterminated"; + ? $"unterminated limit=0x{maximumBytes.Value:X} pc=0x{lastPc:X} word=0x{lastWord:X8} count={instructionCount}" + : $"unterminated pc=0x{lastPc:X} word=0x{lastWord:X8} count={instructionCount}"; return false; } @@ -589,8 +613,12 @@ public static partial class Gen5ShaderTranslator 0x10 => "SBcnt1I32B64", 0x13 => "SFF1I32B32", 0x14 => "SFF1I32B64", + 0x15 => "SFlbitI32B32", + 0x16 => "SFlbitI32B64", 0x1B => "SBitset0B32", + 0x1C => "SBitset0B64", 0x1D => "SBitset1B32", + 0x1E => "SBitset1B64", 0x1F => "SGetpcB64", 0x20 => "SSetpcB64", 0x21 => "SSwappcB64", @@ -605,6 +633,7 @@ public static partial class Gen5ShaderTranslator 0x34 => "SAbsI32", 0x37 => "SAndn1SaveexecB64", 0x38 => "SOrn1SaveexecB64", + 0x3B => "SBitreplicateB64B32", 0x3C => "SAndSaveexecB32", 0x3D => "SOrSaveexecB32", 0x3E => "SXorSaveexecB32", @@ -681,6 +710,11 @@ public static partial class Gen5ShaderTranslator 0x34 => "SPackHhB32B16", 0x35 => "SMulHiU32", 0x36 => "SMulHiI32", + // RDNA scalar floating-point forms use the extended SOP2 opcode + // range. They keep the raw SGPR bit representation and do not + // alter SCC. + 0x40 => "SAddF32", + 0x41 => "SSubF32", _ => string.Empty, }; @@ -798,6 +832,7 @@ public static partial class Gen5ShaderTranslator 0x00 => "VNop", 0x01 => "VMovB32", 0x02 => "VReadfirstlaneB32", + 0x04 => "VCvtF64I32", 0x05 => "VCvtF32I32", 0x06 => "VCvtF32U32", 0x07 => "VCvtU32F32", @@ -807,10 +842,12 @@ public static partial class Gen5ShaderTranslator 0x0C => "VCvtRpiI32F32", 0x0D => "VCvtFlrI32F32", 0x0E => "VCvtOffF32I4", + 0x0F => "VCvtF32F64", 0x11 => "VCvtF32Ubyte0", 0x12 => "VCvtF32Ubyte1", 0x13 => "VCvtF32Ubyte2", 0x14 => "VCvtF32Ubyte3", + 0x16 => "VCvtF64U32", 0x20 => "VFractF32", 0x21 => "VTruncF32", 0x22 => "VCeilF32", @@ -821,7 +858,10 @@ public static partial class Gen5ShaderTranslator 0x2A => "VRcpF32", 0x2B => "VRcpIflagF32", 0x2E => "VRsqF32", + 0x2F => "VRcpF64", + 0x30 => "VRsqF64", 0x33 => "VSqrtF32", + 0x34 => "VSqrtF64", 0x35 => "VSinF32", 0x36 => "VCosF32", 0x37 => "VNotB32", @@ -833,7 +873,18 @@ public static partial class Gen5ShaderTranslator 0x44 => "VMovrelsdB32", 0x48 => "VMovrelsd2B32", 0x52 => "VCvtU16F16", + 0x54 => "VRcpF16", + 0x55 => "VSqrtF16", 0x56 => "VRsqF16", + 0x57 => "VLogF16", + 0x58 => "VExpF16", + 0x5B => "VFloorF16", + 0x5C => "VCeilF16", + 0x5D => "VTruncF16", + 0x5E => "VRndneF16", + 0x5F => "VFractF16", + 0x60 => "VSinF16", + 0x61 => "VCosF16", _ => string.Empty, }; @@ -854,11 +905,15 @@ public static partial class Gen5ShaderTranslator } var src0 = word & 0x1FF; - sizeDwords = opcode is 0x20 or 0x21 or 0x2C or 0x2D || + sizeDwords = opcode is 0x20 or 0x21 or 0x2C or 0x2D or 0x37 or 0x38 || src0 is 0xE9 or 0xEA or 0xF9 or 0xFA or 0xFF ? 2u : 1u; error = string.Empty; name = opcode switch { + // Some Gen5 shader streams use the VOP2 opcode-zero form as a + // padding/no-op instruction. Keep it as VNop so the translator + // can consume the instruction without manufacturing a result. + 0x00 => "VNop", 0x01 => "VCndmaskB32", 0x02 => "VDot2cF32F16", 0x03 => "VAddF32", @@ -906,8 +961,13 @@ public static partial class Gen5ShaderTranslator 0x33 => "VSubF16", 0x34 => "VSubrevF16", 0x35 => "VMulF16", + // RDNA encodes FMAMK/FMAAK as VOP2 instructions with a literal + // dword immediately following the opcode word. + 0x37 => "VFmaMkF16", + 0x38 => "VFmaAkF16", 0x39 => "VMaxF16", 0x3A => "VMinF16", + 0x3C => "VPkFmacF16", _ => string.Empty, }; @@ -979,6 +1039,24 @@ public static partial class Gen5ShaderTranslator 0x8D => "VCmpNeI16", 0x8E => "VCmpGeI16", 0x98 => "VCmpxClassF32", + 0xA9 => "VCmpLtU16", + 0xAA => "VCmpEqU16", + 0xAB => "VCmpLeU16", + 0xAC => "VCmpGtU16", + 0xAD => "VCmpNeU16", + 0xAE => "VCmpGeU16", + 0x99 => "VCmpxLtI16", + 0x9A => "VCmpxEqI16", + 0x9B => "VCmpxLeI16", + 0x9C => "VCmpxGtI16", + 0x9D => "VCmpxNeI16", + 0x9E => "VCmpxGeI16", + 0xB9 => "VCmpxLtU16", + 0xBA => "VCmpxEqU16", + 0xBB => "VCmpxLeU16", + 0xBC => "VCmpxGtU16", + 0xBD => "VCmpxNeU16", + 0xBE => "VCmpxGeU16", 0x90 => "VCmpxFI32", 0x91 => "VCmpxLtI32", 0x92 => "VCmpxEqI32", @@ -987,12 +1065,6 @@ public static partial class Gen5ShaderTranslator 0x95 => "VCmpxNeI32", 0x96 => "VCmpxGeI32", 0x97 => "VCmpxTI32", - 0x99 => "VCmpxLtI16", - 0x9A => "VCmpxEqI16", - 0x9B => "VCmpxLeI16", - 0x9C => "VCmpxGtI16", - 0x9D => "VCmpxNeI16", - 0x9E => "VCmpxGeI16", 0xA0 => "VCmpFI64", 0xA1 => "VCmpLtI64", 0xA2 => "VCmpEqI64", @@ -1122,6 +1194,7 @@ public static partial class Gen5ShaderTranslator 0x12B => "VFmacF32", 0x12F => "VCvtPkrtzF16F32", 0x141 => "VMadF32", + 0x142 => "VMadI32I24", 0x143 => "VMadU32U24", 0x144 => "VCubeidF32", 0x145 => "VCubescF32", @@ -1131,6 +1204,7 @@ public static partial class Gen5ShaderTranslator 0x14E => "VAlignbitB32", 0x14F => "VAlignbyteB32", 0x14B => "VFmaF32", + 0x14C => "VFmaF64", 0x151 => "VMin3F32", 0x152 => "VMin3I32", 0x153 => "VMin3U32", @@ -1147,12 +1221,37 @@ public static partial class Gen5ShaderTranslator 0x15E => "VCvtPkU8F32", 0x148 => "VBfeU32", 0x149 => "VBfeI32", + 0x165 => "VMulF64", 0x169 => "VMulLoU32", 0x16A => "VMulHiU32", 0x16B => "VMulLoI32", 0x16C => "VMulHiI32", + // VOP3-encoded VOP1 (opcode + 0x180) for the double-precision + // conversions and transcendentals. + 0x184 => "VCvtF64I32", + 0x18F => "VCvtF32F64", + 0x196 => "VCvtF64U32", + 0x1AF => "VRcpF64", + 0x1B0 => "VRsqF64", + 0x1B4 => "VSqrtF64", + // VOP3-encoded half-precision transcendentals and rounding. + 0x1D4 => "VRcpF16", + 0x1D5 => "VSqrtF16", + 0x1D7 => "VLogF16", + 0x1D8 => "VExpF16", + 0x1DB => "VFloorF16", + 0x1DC => "VCeilF16", + 0x1DD => "VTruncF16", + 0x1DE => "VRndneF16", + 0x1DF => "VFractF16", + 0x1E0 => "VSinF16", + 0x1E1 => "VCosF16", + 0x1D6 => "VRsqF16", 0x303 => "VAddNcU16", 0x34B => "VFmaF16", + 0x351 => "VMin3F16", + 0x354 => "VMax3F16", + 0x357 => "VMed3F16", 0x360 => "VReadlaneB32", 0x361 => "VWritelaneB32", 0x362 => "VLdexpF32", @@ -1164,6 +1263,9 @@ public static partial class Gen5ShaderTranslator 0x369 => "VCvtPknormU16F32", 0x36A => "VCvtPkU16U32", 0x36B => "VCvtPkI16I32", + // GFX10 reverse 64-bit shifts. + 0x2FF => "VLshlrevB64", + 0x300 => "VLshrrevB64", 0x373 => "VMadU32U16", 0x346 => "VLshlAddU32", 0x345 => "VXadU32", @@ -1173,11 +1275,38 @@ public static partial class Gen5ShaderTranslator 0x178 => "VXor3B32", // VOP1 opcode 0x52 is available through VOP3 as opcode 0x1D2. 0x1D2 => "VCvtU16F16", - 0x300 => "VLshrrevB64", 0x371 => "VAndOrB32", 0x372 => "VOr3U32", 0x377 => "VPermlane16B32", 0x378 => "VPermlanex16B32", + // GFX10 16-bit integer ALU, VOP3-only. + 0x304 => "VSubNcU16", + 0x307 => "VLshrrevB16", + 0x308 => "VAshrrevI16", + 0x309 => "VMaxU16", + 0x30A => "VMaxI16", + 0x30B => "VMinU16", + 0x30C => "VMinI16", + 0x30D => "VAddNcI16", + 0x30E => "VSubNcI16", + 0x314 => "VLshlrevB16", + // VOP3-encoded 64-bit VOPC (opcode < 0x100): V_CMP_*_U64 / V_CMPX_*_U64. + 0x0E0 => "VCmpFU64", + 0x0E1 => "VCmpLtU64", + 0x0E2 => "VCmpEqU64", + 0x0E3 => "VCmpLeU64", + 0x0E4 => "VCmpGtU64", + 0x0E5 => "VCmpNeU64", + 0x0E6 => "VCmpGeU64", + 0x0E7 => "VCmpTU64", + 0x0F0 => "VCmpxFU64", + 0x0F1 => "VCmpxLtU64", + 0x0F2 => "VCmpxEqU64", + 0x0F3 => "VCmpxLeU64", + 0x0F4 => "VCmpxGtU64", + 0x0F5 => "VCmpxNeU64", + 0x0F6 => "VCmpxGeU64", + 0x0F7 => "VCmpxTU64", _ => $"Vop3Raw{opcode:X3}", }; @@ -1222,6 +1351,8 @@ public static partial class Gen5ShaderTranslator // loudly at emission rather than being silently mis-emitted. name = opcode switch { + 0x02 => "VPkAddI16", + 0x03 => "VPkSubI16", 0x0E => "VPkFmaF16", 0x0F => "VPkAddF16", 0x10 => "VPkMulF16", @@ -1284,6 +1415,13 @@ public static partial class Gen5ShaderTranslator 0x39 => "DsReadI8", 0x3D => "DsConsume", 0x3E => "DsAppend", + // gfx10 groups the 64-bit LDS atomics at 0x40..0x4C, directly ahead + // of DS_WRITE_B64 (0x4D). Only the no-return forms are named here: + // the RTN variants cannot be split into two 32-bit atomics without + // losing the atomicity of the returned pair, so they stay unknown + // and keep failing loudly. + 0x40 => "DsAddU64", + 0x4A => "DsOrB64", 0x4D => "DsWriteB64", 0x4E => "DsWrite2B64", 0x4F => "DsWrite2St64B64", @@ -1409,8 +1547,21 @@ public static partial class Gen5ShaderTranslator 0x3B => "BufferAtomicXor", 0x3C => "BufferAtomicInc", 0x3D => "BufferAtomicDec", + 0x3E => "BufferWbinvl1", 0x3F => "BufferAtomicFmin", 0x40 => "BufferAtomicFmax", + // GFX9 buffer atomic encodings used by the guest shaders. + 0x41 => "BufferAtomicCmpswap", + 0x42 => "BufferAtomicAdd", + 0x43 => "BufferAtomicSub", + 0x45 => "BufferAtomicSmin", + 0x46 => "BufferAtomicUmin", + 0x47 => "BufferAtomicSmax", + 0x48 => "BufferAtomicUmax", + 0x49 => "BufferAtomicAnd", + 0x4A => "BufferAtomicOr", + 0x4B => "BufferAtomicXor", + 0x4C => "BufferAtomicInc", 0x50 => "BufferAtomicSwapX2", 0x5A => "BufferAtomicOrX2", _ => $"MubufRaw{opcode:X2}", @@ -1494,10 +1645,13 @@ public static partial class Gen5ShaderTranslator 0x0A => "SBufferLoadDwordx4", 0x0B => "SBufferLoadDwordx8", 0x0C => "SBufferLoadDwordx16", + 0x15 => "ScratchStoreDword", + 0x16 => "ScratchStoreDwordx2", + 0x17 => "ScratchStoreDwordx4", _ => string.Empty, }; - return FinishDecode(name, $"unknown-smrd op=0x{opcode:X2}", out error); + return FinishDecode(name, $"unknown-smrd op=0x{opcode:X2} word=0x{word:X8}", out error); } private static bool DecodeSmem(uint word, out string name, out uint sizeDwords, out string error) @@ -1558,6 +1712,13 @@ public static partial class Gen5ShaderTranslator 0x1A => "ImageAtomicXor", 0x1B => "ImageAtomicInc", 0x1C => "ImageAtomicDec", + // The float image atomics close the gfx10 atomic block. No + // backend emits them yet, so they still fail - but naming them + // lets the decoder finish the program, which is what makes the + // shader's bindings and global-memory usage visible in the trace. + 0x1D => "ImageAtomicFcmpswap", + 0x1E => "ImageAtomicFmin", + 0x1F => "ImageAtomicFmax", 0x20 => "ImageSample", 0x21 => "ImageSampleCl", 0x22 => "ImageSampleD", @@ -1593,6 +1754,7 @@ public static partial class Gen5ShaderTranslator 0x40 => "ImageGather4", 0x47 => "ImageGather4Lz", 0x48 => "ImageGather4C", + 0x4E => "ImageGather4CBCl", 0x4F => "ImageGather4CLz", 0x57 => "ImageGather4LzO", 0x58 => "ImageGather4CO", @@ -1775,7 +1937,8 @@ public static partial class Gen5ShaderTranslator "DsAddRtnU32" or "DsSubRtnU32" or "DsIncRtnU32" or "DsDecRtnU32" or "DsMinRtnI32" or "DsMaxRtnI32" or "DsMinRtnU32" or "DsMaxRtnU32" or "DsAndRtnB32" or "DsOrRtnB32" or "DsXorRtnB32" or - "DsWrxchgRtnB32" or "DsCmpstRtnB32" => true, + "DsWrxchgRtnB32" or "DsCmpstRtnB32" or + "DsAddU64" or "DsOrB64" => true, _ => false, }; @@ -1932,6 +2095,50 @@ public static partial class Gen5ShaderTranslator var scalarDestination = (word >> 15) & 0x7F; var immediate = ((word >> 8) & 1) != 0; var offset = word & 0xFF; + if (opcode.StartsWith("ScratchStore", StringComparison.Ordinal)) + { + var storeCount = ScalarMemoryDwordCount(opcode); + uint? storeDynamicOffsetRegister = null; + var storeImmediateOffsetBytes = 0; + if (immediate) + { + storeImmediateOffsetBytes = checked((int)(offset * sizeof(uint))); + } + else if (offset == 0xFF && literal.HasValue) + { + storeImmediateOffsetBytes = unchecked((int)literal.Value); + } + else + { + storeDynamicOffsetRegister = offset; + } + + sources = [ + Gen5Operand.Scalar(scalarBase), + .. Enumerable.Range(0, checked((int)storeCount)).Select(index => + Gen5Operand.Scalar(scalarDestination + (uint)index)), + ]; + if (storeDynamicOffsetRegister.HasValue) + { + sources = [.. sources, Gen5Operand.Scalar(storeDynamicOffsetRegister.Value)]; + } + + destinations = []; + control = new Gen5GlobalMemoryControl( + storeCount, + 0, + scalarDestination, + 0, + scalarBase, + storeImmediateOffsetBytes, + false, + false, + false, + storeDynamicOffsetRegister, + true); + break; + } + var count = ScalarLoadDwordCount(opcode); uint? dynamicOffsetRegister = null; var immediateOffsetBytes = 0; @@ -2075,7 +2282,7 @@ public static partial class Gen5ShaderTranslator Gen5Operand.Source(word & 0x1FF, literal), Gen5Operand.Vector((word >> 9) & 0xFF), ]; - if ((opcode is "VMadMkF32" or "VFmaMkF32") && literal.HasValue) + if ((opcode is "VMadMkF32" or "VFmaMkF32" or "VFmaMkF16") && literal.HasValue) { sources = [ @@ -2084,7 +2291,7 @@ public static partial class Gen5ShaderTranslator sources[1], ]; } - else if ((opcode is "VMadAkF32" or "VFmaAkF32") && literal.HasValue) + else if ((opcode is "VMadAkF32" or "VFmaAkF32" or "VFmaAkF16") && literal.HasValue) { sources = [ @@ -2277,6 +2484,17 @@ public static partial class Gen5ShaderTranslator Gen5Operand.Vector(vectorData0), Gen5Operand.Vector(vectorData1), ], + "DsMskorB32" => [ + Gen5Operand.Vector(vectorAddress), + Gen5Operand.Vector(vectorData0), + Gen5Operand.Vector(vectorData1), + ], + // 64-bit LDS atomics take ADDR plus a DATA0 register pair. + "DsAddU64" or "DsOrB64" => [ + Gen5Operand.Vector(vectorAddress), + Gen5Operand.Vector(vectorData0), + Gen5Operand.Vector(vectorData0 + 1), + ], // GFX10 DS_MIN/MAX_F32 use DATA0 as the replacement value and // DATA1 as the floating-point compare operand. "DsMinF32" or "DsMaxF32" => [ @@ -2687,6 +2905,14 @@ public static partial class Gen5ShaderTranslator _ => 0, }; + private static uint ScalarMemoryDwordCount(string opcode) => opcode switch + { + "ScratchStoreDword" => 1, + "ScratchStoreDwordx2" => 2, + "ScratchStoreDwordx4" => 4, + _ => 0, + }; + private static int SignExtend(uint value, int bits) { var shift = 32 - bits; diff --git a/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs new file mode 100644 index 0000000..ea8f6f6 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs @@ -0,0 +1,124 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; + +namespace SharpEmu.ShaderCompiler.Resources; + +// A runtime V# whose four dwords are read from a static scalar buffer (the SRT) at an +// offset driven by a canonical unsigned induction loop. The loop's guard bounds the set +// of descriptors the access can ever select, so the backend can enumerate that set into +// a flat table of native candidates and dispatch with a run-time selector. +// +// The plan is deliberately a pure analysis result: it names the guest SRT source and the +// offset/index expressions, and records the proven byte range. It does not invent a +// descriptor, and it never treats the safety extent of the SRT as a semantic proof of +// which entries are valid descriptors; that proof is the loop guard, and each candidate +// is decoded and validated again at materialisation. +public sealed class BufferCandidateTablePlan +{ + // A hard, generic ceiling on the number of native candidates one table may bind. + // Exceeding it is an explicit unsupported case, never a silent truncation. + public const int DefaultCap = 64; + + public const uint DescriptorByteSize = 16; + + // The descriptor source of the static guest SRT buffer the V# dwords are read from. + public uint SourceSrtResource { get; set; } = DescriptorConstants.NoIndex; + + // The guest SRT handle itself, kept for diagnostics and equivalence. + public ScalarValue SrtHandle { get; init; } = null!; + + // The full dynamic byte offset fed to the scalar-buffer read: base + index * stride. + public ScalarValue OffsetExpression { get; init; } = null!; + + // The induction variable whose loop guard bounds the candidate set. + public ScalarValue IndexExpression { get; init; } = null!; + + // The induction's first value and per-iteration increment. + public ScalarValue Initial { get; init; } = null!; + public uint Step { get; init; } + + // The guard's exclusive/inclusive limit; null when the bound is a compile-time constant. + public ScalarValue? Limit { get; init; } + public bool LimitInclusive { get; init; } + + // The byte distance between two consecutive candidates and the constant base offset. + public uint Stride { get; init; } + public uint BaseOffset { get; init; } + + // The proven candidate byte span: MinOffset is the first candidate, MaxOffset is one + // past the final candidate's descriptor, so MaxOffset - MinOffset is the accessed span. + public uint MinOffset { get; init; } + public uint MaxOffset { get; init; } + + // The statically proven candidate count, or -1 when the guard limit is run-time. + public int Count { get; init; } = -1; + + // The SRT's declared byte size when it is a compile-time constant, else uint.MaxValue. + // This is a memory-safety bound, not the set of valid descriptors. + public uint SrtByteExtent { get; init; } = uint.MaxValue; + + // The memory access records this table serves. + public IReadOnlyList MemoryIndices { get; init; } = []; + + public int Cap { get; init; } = DefaultCap; + + public bool IsStaticallyBounded => Count >= 0; + + // The byte distance between two consecutive candidates: the induction step times the + // descriptor stride. + public uint CandidateSpacing => unchecked(Step * Stride); + + // The byte offset of candidate index, valid only for a statically bounded table. + public uint StaticCandidateOffset(int index) => unchecked(MinOffset + (uint)index * CandidateSpacing); + + // The candidate count for a run-time limit, clamped to the cap. Returns -1 when the + // limit is below the initial value (an empty loop) or overflows. + public bool TryResolveCount(uint limit, out int count) + { + count = 0; + if (!Initial.IsConstant) + { + return false; + } + + var first = Initial.ConstantU32; + var bound = LimitInclusive ? (ulong)limit + 1ul : limit; + if (bound <= first) + { + count = 0; + return true; + } + + var span = bound - first; + var iterations = (span + Step - 1ul) / Step; + if (iterations > int.MaxValue) + { + return false; + } + + count = (int)iterations; + return true; + } + + public BufferCandidateTablePlan WithMemoryIndex(int memoryIndex) => new() + { + SourceSrtResource = SourceSrtResource, + SrtHandle = SrtHandle, + OffsetExpression = OffsetExpression, + IndexExpression = IndexExpression, + Initial = Initial, + Step = Step, + Limit = Limit, + LimitInclusive = LimitInclusive, + Stride = Stride, + BaseOffset = BaseOffset, + MinOffset = MinOffset, + MaxOffset = MaxOffset, + Count = Count, + SrtByteExtent = SrtByteExtent, + MemoryIndices = [.. MemoryIndices, memoryIndex], + Cap = Cap, + }; +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs new file mode 100644 index 0000000..993360a --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs @@ -0,0 +1,370 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; + +namespace SharpEmu.ShaderCompiler.Resources; + +// Recognises a runtime V# whose four dwords come from one static scalar buffer (the SRT) +// at a dynamic offset driven by a canonical unsigned induction loop, and proves that the +// loop's guard bounds the descriptors the access can select. The result is a pure +// description of the candidate set; no descriptor is invented and no memory bound is +// promoted into a semantic proof. +internal static class BufferCandidateTablePlanner +{ + public static bool TryPlan(ShaderResourcePlan plan, ScalarValue handle, int memoryIndex, out BufferCandidateTablePlan table) + { + table = null!; + if (handle.Kind != ScalarValueKind.BufferHandle || handle.Operands.Length != 4) + { + return false; + } + + + // Every dword must be the same-width scalar-buffer read at a shared dynamic offset, + // with the component immediates 0,4,8,12 that one dwordx4 read produces. + ScalarValue? srt = null; + ScalarValue? dynamicOffset = null; + for (var word = 0; word < 4; word++) + { + var read = handle.Operands[word]; + if (read.Kind != ScalarValueKind.ScalarBufferWord || read.MemoryIndex < 0 || read.MemoryIndex >= plan.Memory.Count) + { + return false; + } + + var memory = plan.Memory[read.MemoryIndex]; + if (memory.Kind != MemoryResourceKind.ScalarBuffer || memory.DataBits != 32 || memory.DataDwords != 1 || + memory.Offset != (uint)word * sizeof(uint)) + { + return false; + } + + if (srt is null) + { + srt = read.Operands[0]; + } + else if (!plan.Graph.Equivalent(srt, read.Operands[0])) + { + return false; + } + + if (dynamicOffset is null) + { + dynamicOffset = read.Operands[1]; + } + else if (!plan.Graph.Equivalent(dynamicOffset, read.Operands[1])) + { + return false; + } + } + + // offset = base + index * stride + var offset = dynamicOffset!; + var baseOffset = 0u; + if (offset.Kind == ScalarValueKind.Operation && offset.Operation == ScalarOperation.IAdd32) + { + if (offset.Operands[0].IsConstant) + { + baseOffset = offset.Operands[0].ConstantU32; + offset = offset.Operands[1]; + } + else if (offset.Operands[1].IsConstant) + { + baseOffset = offset.Operands[1].ConstantU32; + offset = offset.Operands[0]; + } + } + + if (offset.Kind != ScalarValueKind.Operation || offset.Operation != ScalarOperation.IMul32) + { + return false; + } + + ScalarValue index; + uint stride; + if (offset.Operands[0].IsConstant) + { + stride = offset.Operands[0].ConstantU32; + index = offset.Operands[1]; + } + else if (offset.Operands[1].IsConstant) + { + stride = offset.Operands[1].ConstantU32; + index = offset.Operands[0]; + } + else + { + return false; + } + + if (stride < BufferCandidateTablePlan.DescriptorByteSize) + { + return false; + } + + if (!TryProveInductionBound(plan, index, out var initial, out var step, out var limit, out var inclusive) || limit is null) + { + return false; + } + + if (!initial.IsConstant) + { + return false; + } + + var srtExtent = SrtByteExtent(plan, srt!); + var minOffset = unchecked(baseOffset + initial.ConstantU32 * stride); + var candidate = new BufferCandidateTablePlan + { + SrtHandle = srt!, + OffsetExpression = dynamicOffset!, + IndexExpression = index, + Initial = initial, + Step = step, + Limit = limit.IsConstant ? null : limit, + LimitInclusive = inclusive, + Stride = stride, + BaseOffset = baseOffset, + MinOffset = minOffset, + SrtByteExtent = srtExtent, + MemoryIndices = [memoryIndex], + }; + + int count; + uint maxOffset; + if (limit.IsConstant) + { + if (!candidate.TryResolveCount(limit.ConstantU32, out count)) + { + return false; + } + + if (count <= 0 || count > candidate.Cap) + { + return false; + } + + maxOffset = unchecked(candidate.StaticCandidateOffset(count - 1) + BufferCandidateTablePlan.DescriptorByteSize); + } + else + { + if (!plan.ValidateRuntimeValue(limit)) + { + return false; + } + + // A run-time limit is capped and memory-checked at materialisation; the static + // start of the range still has to stay inside the declared SRT extent. + count = -1; + maxOffset = uint.MaxValue; + } + + if (maxOffset < minOffset || (maxOffset != uint.MaxValue && maxOffset > srtExtent)) + { + return false; + } + + table = new BufferCandidateTablePlan + { + SrtHandle = srt!, + OffsetExpression = dynamicOffset!, + IndexExpression = index, + Initial = initial, + Step = step, + Limit = limit.IsConstant ? null : limit, + LimitInclusive = inclusive, + Stride = stride, + BaseOffset = baseOffset, + MinOffset = minOffset, + MaxOffset = maxOffset, + Count = count, + SrtByteExtent = srtExtent, + MemoryIndices = [memoryIndex], + Cap = candidate.Cap, + }; + return true; + } + + // A canonical unsigned induction: the value is a phi with a constant initial and an + // add-recurrence, and a branch guarded by an unsigned comparison against the phi + // lives at the loop header or one of its predecessors. + private static bool TryProveInductionBound( + ShaderResourcePlan plan, + ScalarValue index, + out ScalarValue initial, + out uint step, + out ScalarValue? limit, + out bool inclusive) + { + initial = null!; + step = 0; + limit = null; + inclusive = false; + if (index.Kind != ScalarValueKind.Phi) + { + return false; + } + + ScalarValue? found = null; + var initialCount = 0; + foreach (var operand in index.Operands) + { + if (operand.Kind == ScalarValueKind.Operation && operand.Operation == ScalarOperation.IAdd32) + { + if (ReferenceEquals(operand.Operands[0], index) && operand.Operands[1].IsConstant) + { + step = operand.Operands[1].ConstantU32; + } + else if (ReferenceEquals(operand.Operands[1], index) && operand.Operands[0].IsConstant) + { + step = operand.Operands[0].ConstantU32; + } + else + { + continue; + } + } + else if (!ReferenceEquals(operand, index)) + { + found = operand; + initialCount++; + } + } + + if (step == 0 || initialCount != 1 || found is null) + { + return false; + } + + initial = found; + + var flow = plan.Graph.ControlFlow; + var guardBlock = (int?)null; + foreach (var pair in plan.Graph.BranchConditions) + { + var condition = pair.Value; + if (condition.Kind != ScalarValueKind.Operation) + { + continue; + } + + ScalarValue? bound; + bool boundInclusive; + switch (condition.Operation) + { + case ScalarOperation.ULessThan32 when MatchesIndex(plan, condition.Operands[0], index, step): + bound = condition.Operands[1]; + boundInclusive = false; + break; + case ScalarOperation.ULessThanEqual32 when MatchesIndex(plan, condition.Operands[0], index, step): + bound = condition.Operands[1]; + boundInclusive = true; + break; + case ScalarOperation.UGreaterThan32 when MatchesIndex(plan, condition.Operands[1], index, step): + bound = condition.Operands[0]; + boundInclusive = false; + break; + case ScalarOperation.UGreaterThanEqual32 when MatchesIndex(plan, condition.Operands[1], index, step): + bound = condition.Operands[0]; + boundInclusive = true; + break; + default: + continue; + } + + if (!bound.IsConstant && !plan.ValidateRuntimeValue(bound)) + { + continue; + } + + var block = BlockOf(plan, pair.Key); + if (block < 0) + { + continue; + } + + // The guard must live at the loop header that owns the phi or one of the + // blocks feeding it, so it really controls the recurrence. + var header = index.PhiBlock; + if (block != header && !flow.Predecessors[header].Contains(block)) + { + continue; + } + + if (limit is not null && !plan.Graph.Equivalent(limit, bound)) + { + return false; + } + + limit = bound; + inclusive = boundInclusive; + guardBlock = block; + } + + return limit is not null && guardBlock is not null; + } + + // The guard compares either the induction value itself or its next value; both bound + // the same executed set, so a one-step add of the phi is accepted. + private static bool MatchesIndex(ShaderResourcePlan plan, ScalarValue operand, ScalarValue index, uint step) + { + if (plan.Graph.Equivalent(operand, index)) + { + return true; + } + + if (operand.Kind != ScalarValueKind.Operation || operand.Operation != ScalarOperation.IAdd32) + { + return false; + } + + if (plan.Graph.Equivalent(operand.Operands[0], index) && operand.Operands[1].IsConstant) + { + return operand.Operands[1].ConstantU32 == step; + } + + if (plan.Graph.Equivalent(operand.Operands[1], index) && operand.Operands[0].IsConstant) + { + return operand.Operands[0].ConstantU32 == step; + } + + return false; + } + + private static int BlockOf(ShaderResourcePlan plan, uint pc) + { + var blocks = plan.Graph.ControlFlow.Blocks; + for (var index = 0; index < blocks.Count; index++) + { + if (pc >= blocks[index].StartPc && pc < blocks[index].EndPc) + { + return index; + } + } + + return -1; + } + + // The SRT's declared byte size when both its stride and record count are constants. + // This is a memory-safety bound, never evidence that any entry is a valid descriptor. + private static uint SrtByteExtent(ShaderResourcePlan plan, ScalarValue srt) + { + if (srt.Operands.Length != 4) + { + return uint.MaxValue; + } + + var strideWord = plan.Graph.ResolveInvariantPhi(srt.Operands[1]) ?? srt.Operands[1]; + var recordsWord = plan.Graph.ResolveInvariantPhi(srt.Operands[2]) ?? srt.Operands[2]; + if (!strideWord.IsConstant || !recordsWord.IsConstant) + { + return uint.MaxValue; + } + + var stride = (strideWord.ConstantU32 >> 16) & 0x3FFFu; + var records = recordsWord.ConstantU32; + var size = stride == 0 ? records : (ulong)stride * records; + return size > uint.MaxValue ? uint.MaxValue : (uint)size; + } +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs b/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs index 19ff040..9b7cfbf 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs @@ -46,6 +46,17 @@ public static class DeviceAddressRangePlanner continue; } + // A FLAT address built from an aperture base is an LDS or scratch + // pointer; the backend routes it per lane, so it owns no device range. + if (memory.Kind == MemoryResourceKind.Flat) + { + memory.AddressSpace = ClassifyFlatAddress(plan, handle.Operands[1], plan.Accesses[index]!.Active); + if (memory.AddressSpace != FlatAddressSpace.Global) + { + continue; + } + } + var slot = handles.FindIndex(existing => plan.Graph.Equivalent(existing, handle)); if (slot < 0) { @@ -79,6 +90,132 @@ public static class DeviceAddressRangePlanner return ranges; } + // A FLAT address is local when its high dword provably lies in an aperture. + // Two shapes occur: the high dword is clamped between constants inside the + // apertures (v_med3_u32 with the hard-coded PS5 bounds), or it derives from an + // aperture operand, which shaders often merge into the upper half of a + // computed dword with SDWA WORD1. + private static FlatAddressSpace ClassifyFlatAddress(ShaderResourcePlan plan, ScalarValue high, ScalarValue? active) + { + var range = UnsignedRange(plan, high, active, []); + var (low, highBound) = IsEmpty(range) ? (0u, uint.MaxValue) : range; + var lowShared = Gen5InlineConstants.IsSharedApertureHigh(low); + var lowPrivate = Gen5InlineConstants.IsPrivateApertureHigh(low); + var highShared = Gen5InlineConstants.IsSharedApertureHigh(highBound); + var highPrivate = Gen5InlineConstants.IsPrivateApertureHigh(highBound); + // The two apertures are adjacent windows, so bounds inside them keep every + // value in between inside them too. + if ((lowShared || lowPrivate) && (highShared || highPrivate)) + { + return (lowShared || highShared, lowPrivate || highPrivate) switch + { + (true, true) => FlatAddressSpace.SharedOrPrivate, + (true, false) => FlatAddressSpace.Shared, + _ => FlatAddressSpace.Private, + }; + } + + return ClassifyByApertureOperand(high); + } + + // Conservative unsigned bounds of a 32-bit value as the access sees it. Phi, Select, + // UMin and UMax yield one of their inputs, so a cycle adds nothing (Empty); a select + // on the access's own EXEC mask contributes only its active arm. + // The mask is the same node when EXEC was not written in between, even when its + // value is unknown (an undefined node is never structurally equivalent). + private static readonly (uint Low, uint High) Empty = (uint.MaxValue, 0u); + + private static bool IsEmpty((uint Low, uint High) range) => range.Low > range.High; + + private static (uint Low, uint High) Union((uint Low, uint High) left, (uint Low, uint High) right) => + IsEmpty(left) ? right : IsEmpty(right) ? left : (Math.Min(left.Low, right.Low), Math.Max(left.High, right.High)); + + private static (uint Low, uint High) UnsignedRange(ShaderResourcePlan plan, ScalarValue value, ScalarValue? active, + HashSet visiting) + { + if (value.Kind == ScalarValueKind.Constant && value.Type == ScalarValueType.U32) + return (value.ConstantU32, value.ConstantU32); + if (value.Kind == ScalarValueKind.MemoryAperture) + { + var aperture = (uint)(Gen5InlineConstants.DecodeAperture64((uint)value.Payload) >> 32); + return (aperture, aperture); + } + + if (!visiting.Add(value)) + return Empty; + try + { + switch (value.Kind) + { + case ScalarValueKind.Select when value.Operands.Length == 3 && + active is not null && (ReferenceEquals(value.Operands[0], active) || plan.Graph.Equivalent(value.Operands[0], active)): + return UnsignedRange(plan, value.Operands[1], active, visiting); + case ScalarValueKind.Select when value.Operands.Length == 3: + return Union(UnsignedRange(plan, value.Operands[1], active, visiting), + UnsignedRange(plan, value.Operands[2], active, visiting)); + case ScalarValueKind.Phi: + { + var range = Empty; + foreach (var operand in value.Operands) + range = Union(range, UnsignedRange(plan, operand, active, visiting)); + return range; + } + case ScalarValueKind.Operation when value.Operation is ScalarOperation.UMin32 or ScalarOperation.UMax32: + { + var left = UnsignedRange(plan, value.Operands[0], active, visiting); + var right = UnsignedRange(plan, value.Operands[1], active, visiting); + if (IsEmpty(left) || IsEmpty(right)) + return IsEmpty(left) ? right : left; + return value.Operation == ScalarOperation.UMin32 + ? (Math.Min(left.Low, right.Low), Math.Min(left.High, right.High)) + : (Math.Max(left.Low, right.Low), Math.Max(left.High, right.High)); + } + default: + return (0, uint.MaxValue); + } + } + finally + { + visiting.Remove(value); + } + } + + private static FlatAddressSpace ClassifyByApertureOperand(ScalarValue high) + { + var shared = false; + var @private = false; + var pending = new Stack(); + var visited = new HashSet(); + pending.Push(high); + while (pending.TryPop(out var value)) + { + if (!visited.Add(value)) + { + continue; + } + + if (value.Kind == ScalarValueKind.MemoryAperture) + { + if (Gen5InlineConstants.IsSharedAperture((uint)value.Payload)) + shared = true; + else + @private = true; + continue; + } + + foreach (var operand in value.Operands) + pending.Push(operand); + } + + return (shared, @private) switch + { + (true, true) => FlatAddressSpace.SharedOrPrivate, + (true, false) => FlatAddressSpace.Shared, + (false, true) => FlatAddressSpace.Private, + _ => FlatAddressSpace.Global, + }; + } + // An access is bounded when its only run-time term is the record's immediate offset: // a scalar read, or a global access whose vector offset is a constant zero. private static bool IsBoundedOffset(ShaderResourcePlan plan, int memoryIndex) diff --git a/src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs b/src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs new file mode 100644 index 0000000..6f05cfc --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.ShaderCompiler.Resources; + +// Where a guest V#'s dwords come from. A static descriptor is evaluated ahead of +// dispatch into the dense buffer table; a runtime descriptor is fetched by the +// shader itself from guest memory (a descriptor array indexed at run time). +public enum BufferDescriptorProvenance : byte +{ + Static, + Runtime, +} + +// How the Vulkan backend lowers a guest buffer descriptor. This is backend +// policy, deliberately kept out of the guest descriptor representation: the +// same guest V# could be lowered differently depending on the access, the +// device capabilities or cost, without changing what the guest descriptor is. +public enum BufferLoweringStrategy : byte +{ + // A compile-time V# bound as a native storage-buffer descriptor. + NativeBinding, + + // A runtime V# addressed through the physical-storage-buffer page table. + // Base, stride and byte bounds are read from the descriptor's raw words at + // run time, so an untyped/raw access needs no compile-time value at all. + PhysicalStorageBuffer, + + // A runtime V# enumerated into a bounded set of native candidate bindings + // and selected with an OpSwitch. Required when the access's format/type + // cannot be reconstructed from the raw words alone. + BoundedCandidateTable, +} + +// The guest buffer view (V#) as the hardware sees it, independent of how the +// host binds it. Static descriptors reference a dense buffer already +// materialised by the host; runtime descriptors are loaded by the shader. +public sealed class GuestBufferDescriptor +{ + public BufferDescriptorProvenance Provenance { get; init; } + + // A static descriptor's index in ShaderResourceInfo.Buffers. + public uint StaticResource { get; init; } = DescriptorConstants.NoIndex; + + // Chooses the backend lowering for one access through this descriptor. + // A static descriptor always binds natively. A runtime descriptor is + // addressed through the page table when the access is a raw dword/subword + // access, and otherwise needs candidate enumeration because Vulkan fixes + // the resource's format/type at pipeline creation. + public BufferLoweringStrategy ChooseStrategy(bool typed, bool formatted, bool atomic) => + Provenance == BufferDescriptorProvenance.Static + ? BufferLoweringStrategy.NativeBinding + : typed || formatted || atomic + ? BufferLoweringStrategy.BoundedCandidateTable + : BufferLoweringStrategy.PhysicalStorageBuffer; +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs b/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs index 4954f58..4589323 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs @@ -34,9 +34,9 @@ public static class GuestImageFormat [ (1, ImageNumericClass.Float), (5, ImageNumericClass.Uint), (7, ImageNumericClass.Float), (8, ImageNumericClass.Float), (11, ImageNumericClass.Uint), (12, ImageNumericClass.Sint), (13, ImageNumericClass.Float), (14, ImageNumericClass.Float), - (15, ImageNumericClass.Float), (18, ImageNumericClass.Uint), (19, ImageNumericClass.Sint), (20, ImageNumericClass.Uint), + (15, ImageNumericClass.Float), (16, ImageNumericClass.Float), (18, ImageNumericClass.Uint), (19, ImageNumericClass.Sint), (20, ImageNumericClass.Uint), (21, ImageNumericClass.Sint), (22, ImageNumericClass.Float), (23, ImageNumericClass.Float), (24, ImageNumericClass.Float), - (27, ImageNumericClass.Uint), (28, ImageNumericClass.Sint), (29, ImageNumericClass.Float), (34, ImageNumericClass.Uint), + (27, ImageNumericClass.Uint), (28, ImageNumericClass.Sint), (29, ImageNumericClass.Float), (30, ImageNumericClass.Float), (34, ImageNumericClass.Uint), (36, ImageNumericClass.Float), (50, ImageNumericClass.Float), (54, ImageNumericClass.Uint), (56, ImageNumericClass.Float), (57, ImageNumericClass.Float), (60, ImageNumericClass.Uint), (61, ImageNumericClass.Sint), (62, ImageNumericClass.Uint), (63, ImageNumericClass.Sint), (64, ImageNumericClass.Float), (65, ImageNumericClass.Float), (66, ImageNumericClass.Float), diff --git a/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs b/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs index 7c252a0..67ff6fe 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs @@ -5,6 +5,20 @@ using System.Collections.Generic; namespace SharpEmu.ShaderCompiler.Resources; +// Where a FLAT access can land: LDS or scratch when its high dword lies in the shared +// or private aperture, otherwise global memory. +public enum FlatAddressSpace : byte +{ + // No aperture reaches the address: an ordinary device address. + Global, + // The address is built from SHARED_BASE/SHARED_LIMIT. + Shared, + // The address is built from PRIVATE_BASE/PRIVATE_LIMIT. + Private, + // Both apertures reach the address; the aperture is chosen per lane. + SharedOrPrivate, +} + public enum MemoryResourceKind : byte { None, @@ -90,10 +104,20 @@ public sealed class MemoryAccessInfo public bool IndexEnabled { get; init; } public bool OffsetEnabled { get; init; } public bool PlanningOnly { get; set; } + // A FLAT access whose address derives from an LDS/scratch aperture. Set by the + // device-address planner; such an access is routed per lane like the hardware + // and has no device-address range of its own. + public FlatAddressSpace AddressSpace { get; set; } // A scalar buffer load whose descriptor only exists on the device: the shader reads // through the descriptor in its registers instead of a host-bound buffer. public bool DeviceDescriptor { get; set; } + // The guest V# behind a buffer access, filled in by the resource tracker. + // Null until then. Its provenance tells the backend which lowering + // strategy to pick; the access shape (Typed/Formatted/Access) completes + // the choice via GuestBufferDescriptor.ChooseStrategy. + public GuestBufferDescriptor? BufferDescriptor { get; set; } + public bool IsAddressKind => Kind is MemoryResourceKind.ScalarAddress or MemoryResourceKind.Flat or MemoryResourceKind.Global or MemoryResourceKind.Scratch; @@ -110,7 +134,7 @@ public sealed class MemoryAccessInfo ImageHasMip == other.ImageHasMip && ImageR128 == other.ImageR128 && Glc == other.Glc && Slc == other.Slc && IndexEnabled == other.IndexEnabled && OffsetEnabled == other.OffsetEnabled && Resource == other.Resource && Sampler == other.Sampler && PlanningOnly == other.PlanningOnly && - DeviceDescriptor == other.DeviceDescriptor; + DeviceDescriptor == other.DeviceDescriptor && AddressSpace == other.AddressSpace; } // Every memory access of a program, indexed by program counter and component. @@ -271,9 +295,8 @@ public sealed class MemoryAccessTable var opcode = instruction.Opcode; if (opcode is "ImageBvhIntersectRay" or "ImageBvh64IntersectRay") { - // The current backends return a deterministic miss for raw GFX10 - // BVH traversal. Do not materialize its descriptor as a texture; - // BVH descriptors do not use the regular image descriptor layout. + // A BVH T# is not an image descriptor: the backend reads the nodes + // through device addresses, so nothing is materialized here. return new MemoryAccessInfo { Pc = instruction.Pc, diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs new file mode 100644 index 0000000..5d795af --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs @@ -0,0 +1,256 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.CompilerServices; + +namespace SharpEmu.ShaderCompiler.Resources; + +// Copies guest bytes only when they are already resident on the CPU side. It never +// synchronizes: a range the GPU may still own, or that a clean read would refuse, +// returns false so the caller falls back to a full materialization. +public delegate bool ResidentGuestBytesReader(ulong address, Span destination, bool clean); + +// Materialization is a pure function of the plan, the draw's user data, the shader +// base, the compute state and the guest words it reads. This cache records exactly +// those words and reuses the result while every one of them is unchanged, so a draw +// that re-binds the same resources skips the descriptor walk. Any failed read, any +// GPU-owned range and any byte difference make the entry miss; nothing is guessed. +public sealed class ResourceMaterializationCache +{ + // Two generations approximate LRU: a full young generation becomes the old one, + // and an old entry that is used again is promoted back. + private readonly int _generationCapacity; + private Dictionary _young = new(); + private Dictionary _old = new(); + private byte[] _scratch = new byte[256]; + + public ResourceMaterializationCache(int generationCapacity = 16384) + { + _generationCapacity = Math.Max(1, generationCapacity); + } + + private static long _totalHits; + private static long _totalMisses; + private static long _totalUncacheable; + + public long Hits { get; private set; } + public long Misses { get; private set; } + public long Uncacheable { get; private set; } + + // Process-wide counters since the previous call, for the periodic render report. + public static string TakeReport() + { + var hits = Interlocked.Exchange(ref _totalHits, 0); + var misses = Interlocked.Exchange(ref _totalMisses, 0); + var uncacheable = Interlocked.Exchange(ref _totalUncacheable, 0); + var total = hits + misses; + return FormattableString.Invariant( + $"[PERF][RESOURCE_CACHE] hits={hits} misses={misses} uncacheable={uncacheable} hit_rate={(total == 0 ? 0 : hits * 100.0 / total):F1}%"); + } + + public bool Materialize( + ShaderResourcePlan plan, + ResourceRuntimeInputs inputs, + ResidentGuestBytesReader residentReader, + ref ResourceSnapshot snapshot, + ref ResourceSpecialization specialization, + out ResourceMaterializationFailure failure) + { + var key = KeyOf(plan, inputs); + if (TryFind(key, plan, inputs, out var cached) && Validate(cached, residentReader)) + { + Hits++; + Interlocked.Increment(ref _totalHits); + snapshot = cached.Snapshot; + specialization = cached.Specialization; + failure = default; + return true; + } + + Misses++; + Interlocked.Increment(ref _totalMisses); + var recorder = new ReadRecorder(); + var recording = new ResourceRuntimeInputs + { + UserData = inputs.UserData, + ShaderBase = inputs.ShaderBase, + ReadMemory = recorder.Wrap(inputs.ReadMemory, clean: false), + ReadCleanMemory = recorder.Wrap(inputs.ReadCleanMemory, clean: true), + ComputeState = inputs.ComputeState, + }; + if (!ResourceMaterializer.Materialize(plan, recording, ref snapshot, ref specialization, out failure)) + return false; + + if (recorder.Failed) + { + Uncacheable++; + Interlocked.Increment(ref _totalUncacheable); + return true; + } + + Store(key, recorder.Build(plan, inputs, snapshot, specialization)); + return true; + } + + private static ulong KeyOf(ShaderResourcePlan plan, ResourceRuntimeInputs inputs) + { + var hash = new HashCode(); + hash.Add(RuntimeHelpers.GetHashCode(plan)); + hash.Add(inputs.ShaderBase); + hash.Add(inputs.ComputeState); + var userData = inputs.UserData; + hash.Add(userData.Count); + for (var index = 0; index < userData.Count; index++) + hash.Add(userData[index]); + var low = (uint)hash.ToHashCode(); + // A second, independent mix keeps accidental collisions out of the 64-bit key. + var high = 0x9E3779B9u; + for (var index = 0; index < userData.Count; index++) + high = (high ^ userData[index]) * 0x01000193u; + return ((ulong)high << 32) | low; + } + + private bool TryFind(ulong key, ShaderResourcePlan plan, ResourceRuntimeInputs inputs, out Entry entry) + { + if (_young.TryGetValue(key, out entry!)) + return entry.Matches(plan, inputs); + if (!_old.TryGetValue(key, out entry!) || !entry.Matches(plan, inputs)) + return false; + _old.Remove(key); + Store(key, entry); + return true; + } + + private void Store(ulong key, Entry entry) + { + if (_young.Count >= _generationCapacity && !_young.ContainsKey(key)) + { + _old = _young; + _young = new Dictionary(_generationCapacity); + } + + _young[key] = entry; + } + + private bool Validate(Entry entry, ResidentGuestBytesReader residentReader) + { + for (var index = 0; index < entry.RangeAddresses.Length; index++) + { + var length = entry.RangeLengths[index]; + if (_scratch.Length < length) + _scratch = new byte[Math.Max(length, _scratch.Length * 2)]; + var current = _scratch.AsSpan(0, length); + if (!residentReader(entry.RangeAddresses[index], current, entry.RangeClean[index]) || + !current.SequenceEqual(entry.Bytes.AsSpan(entry.RangeOffsets[index], length))) + return false; + } + + return true; + } + + private sealed class Entry + { + public required ShaderResourcePlan Plan { get; init; } + public required uint[] UserData { get; init; } + public required ulong ShaderBase { get; init; } + public required ComputeSelectorState? ComputeState { get; init; } + public required ulong[] RangeAddresses { get; init; } + public required int[] RangeOffsets { get; init; } + public required int[] RangeLengths { get; init; } + public required bool[] RangeClean { get; init; } + public required byte[] Bytes { get; init; } + public required ResourceSnapshot Snapshot { get; init; } + public required ResourceSpecialization Specialization { get; init; } + + public bool Matches(ShaderResourcePlan plan, ResourceRuntimeInputs inputs) + { + if (!ReferenceEquals(Plan, plan) || ShaderBase != inputs.ShaderBase || + !Nullable.Equals(ComputeState, inputs.ComputeState) || UserData.Length != inputs.UserData.Count) + return false; + for (var index = 0; index < UserData.Length; index++) + if (UserData[index] != inputs.UserData[index]) + return false; + return true; + } + } + + private sealed class ReadRecorder + { + private readonly List<(ulong Address, uint Word, bool Clean)> _reads = new(); + + public bool Failed { get; private set; } + + public GuestWordReader? Wrap(GuestWordReader? inner, bool clean) + { + if (inner is null) + return null; + return (ulong address, out uint word) => + { + if (!inner(address, out word)) + { + Failed = true; + return false; + } + + _reads.Add((address, word, clean)); + return true; + }; + } + + public Entry Build(ShaderResourcePlan plan, ResourceRuntimeInputs inputs, ResourceSnapshot snapshot, + ResourceSpecialization specialization) + { + _reads.Sort((left, right) => left.Address.CompareTo(right.Address)); + var addresses = new List(); + var offsets = new List(); + var lengths = new List(); + var clean = new List(); + var bytes = new List(_reads.Count * sizeof(uint)); + ulong end = 0; + var previous = ulong.MaxValue; + foreach (var (address, word, wordClean) in _reads) + { + if (address == previous) + { + clean[^1] |= wordClean; + continue; + } + + previous = address; + if (addresses.Count == 0 || address != end) + { + addresses.Add(address); + offsets.Add(bytes.Count); + lengths.Add(0); + clean.Add(false); + } + + lengths[^1] += sizeof(uint); + clean[^1] |= wordClean; + bytes.Add((byte)word); + bytes.Add((byte)(word >> 8)); + bytes.Add((byte)(word >> 16)); + bytes.Add((byte)(word >> 24)); + end = address + sizeof(uint); + } + + var userData = new uint[inputs.UserData.Count]; + for (var index = 0; index < userData.Length; index++) + userData[index] = inputs.UserData[index]; + return new Entry + { + Plan = plan, + UserData = userData, + ShaderBase = inputs.ShaderBase, + ComputeState = inputs.ComputeState, + RangeAddresses = [.. addresses], + RangeOffsets = [.. offsets], + RangeLengths = [.. lengths], + RangeClean = [.. clean], + Bytes = [.. bytes], + Snapshot = snapshot, + Specialization = specialization, + }; + } + } +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs index 2b2303c..ff4da93 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs @@ -39,6 +39,17 @@ public static class ResourceMaterializer public uint[] FlattenedTable = []; public uint[] UserData = []; public List IndirectImages = []; + public List BufferCandidateTables = []; + } + + // One bounded runtime V# table resolved for this draw: the distinct candidate descriptors + // and the run-time offset -> candidate mapping the emitter searches. + private sealed class BufferCandidateTable + { + public int MemoryIndex; + public List Keys = []; + public List Candidates = []; + public List Descriptors = []; } public static bool Materialize( @@ -142,7 +153,10 @@ public static class ResourceMaterializer if (indirect.DirectCandidates is { } directCandidates) { if (!RuntimeValueEvaluator.EvaluateSources(plan, directCandidates.Select(candidate => candidate.Source).ToArray(), - cleanInputs, [], evaluateTable: false, out var descriptors, out _)) return false; + cleanInputs, [], evaluateTable: false, out var descriptors, out _)) + { + return false; + } var directTable = new IndirectImageTable { Resource = (uint)imageIndex }; for (var candidateIndex = 0; candidateIndex < descriptors.Count; candidateIndex++) { @@ -163,6 +177,21 @@ public static class ResourceMaterializer if (directTable.Descriptors.Count > 1) snapshot.IndirectImages.Add(directTable); continue; } + if (indirect.WaveIndexed is { } waveIndexed) + { + if (!RuntimeValueEvaluator.EvaluateSources(plan, [indirect.HeapSource], cleanInputs, [], evaluateTable: false, + out var waveSources, out _)) + return false; + if (!MaterializeWaveIndexedImage(indirect, waveIndexed, waveSources[0], image, image.R128, inputs, out var waveTable, out failure)) + return false; + snapshot.Images[imageIndex] = waveTable.Descriptors[(int)waveTable.Candidates[0]].Dwords; + if (waveTable.Descriptors.Count > 1) + { + waveTable.Resource = (uint)imageIndex; + snapshot.IndirectImages.Add(waveTable); + } + continue; + } if (indirect.Dense) { if (!RuntimeValueEvaluator.EvaluateSources(plan, [indirect.HeapSource], cleanInputs, [], evaluateTable: false, @@ -211,10 +240,114 @@ public static class ResourceMaterializer snapshot.Samplers = new uint[plan.Info.Samplers.Count][]; for (var index = 0; index < snapshot.Samplers.Length; index++) snapshot.Samplers[index] = values[cursor++].Dwords; + + // Bounded runtime V# tables: the whole table is read and validated before it is + // published, so a single unreadable candidate leaves the previous snapshot intact. + foreach (var candidatePlan in plan.BufferCandidateTables) + { + if (candidatePlan.SourceSrtResource == DescriptorConstants.NoIndex || + !RuntimeValueEvaluator.EvaluateSources(plan, [candidatePlan.SourceSrtResource], inputs, [], evaluateTable: false, + out var srtSources, out _) || srtSources.Count == 0) + { + return false; + } + + if (!MaterializeBufferCandidateTable(plan, candidatePlan, srtSources[0], inputs, out var candidateTable)) + { + return false; + } + + snapshot.BufferCandidateTables.Add(candidateTable); + } + snapshot.UserData = inputs.UserData.ToArray(); return true; } + // Reads every candidate the loop guard can select from the guest SRT, validates it and + // records the run-time probe key (its base-address low dword) -> candidate mapping. + // Distinct keys are required so the mapping is unambiguous; identical descriptors share + // one candidate. + private static bool MaterializeBufferCandidateTable( + ShaderResourcePlan plan, + BufferCandidateTablePlan table, + DescriptorWords srt, + ResourceRuntimeInputs inputs, + out BufferCandidateTable result) + { + result = new BufferCandidateTable { MemoryIndex = table.MemoryIndices.Count != 0 ? table.MemoryIndices[0] : -1 }; + if (srt.DwordCount != 4) + { + return false; + } + + int count; + if (table.IsStaticallyBounded) + { + count = table.Count; + } + else + { + if (table.Limit is null) + { + return false; + } + + using var scratch = RuntimeEvaluationScratch.Rent(); + var evaluator = new RuntimeValueEvaluator(scratch, plan, inputs); + if (!evaluator.Evaluate(table.Limit, out var limit) || !table.TryResolveCount(limit, out count)) + { + return false; + } + } + + if (count <= 0 || count > table.Cap) + { + return false; + } + + var keyToCandidate = new Dictionary(); + for (var index = 0; index < count; index++) + { + var offset = unchecked(table.MinOffset + (uint)index * table.CandidateSpacing); + var words = new uint[4]; + for (uint dword = 0; dword < 4; dword++) + { + if (!ReadScalarBufferWord(srt.Dwords, offset, dword * sizeof(uint), inputs, out words[dword])) + { + return false; + } + } + + // A descriptor whose reserved bits are set is unbound and reads as zero. + if ((words[3] >> 30) != 0) + { + Array.Clear(words); + } + + var descriptor = new DescriptorWords(words); + if (keyToCandidate.TryGetValue(words[0], out var existing)) + { + // Two different descriptors sharing one probe key cannot be told apart at + // run time; reject precisely instead of guessing. + if (!result.Descriptors[(int)existing].SameAs(descriptor)) + { + return false; + } + + continue; + } + + var candidate = (uint)result.Descriptors.Count; + result.Descriptors.Add(descriptor); + keyToCandidate[words[0]] = candidate; + } + + result.Keys = [.. keyToCandidate.Keys]; + result.Candidates = result.Keys.Select(key => keyToCandidate[key]).ToList(); + return true; + } + private static string DiagnoseSnapshotEvaluationFailure( ShaderResourcePlan plan, ResourceRuntimeInputs inputs, @@ -569,6 +702,72 @@ public static class ResourceMaterializer out failure); } + private static bool MaterializeWaveIndexedImage( + IndirectImageSelector indirect, + WaveIndexedImageSelector wave, + DescriptorWords heap, + ImageResource image, + bool r128, + ResourceRuntimeInputs inputs, + out IndirectImageTable result, + out ResourceMaterializationFailure failure) + { + failure = ResourceMaterializationFailure.Other; + result = new IndirectImageTable(); + if (heap.DwordCount != 2 || inputs.ReadCleanMemory is null || wave.IndexStride == 0) + return false; + + var baseAddress = (((ulong)heap.Dwords[1] << 32) | heap.Dwords[0]) & AddressMask; + if (!TryReadCleanWord(baseAddress, wave.MaskOffset, inputs, out var activeMask)) + return false; + + var keys = new SortedSet(); + for (uint bit = 0; bit < 32; bit++) + { + if ((activeMask & (1u << (int)bit)) == 0) + continue; + var indexOffset = (ulong)wave.IndexTableOffset + (ulong)bit * wave.IndexStride; + if (!TryReadCleanWord(baseAddress, indexOffset, inputs, out var key)) + return false; + // VCmpxLeI32 0, key suppresses signed-negative indices before ReadFirstLane. + if ((key & 0x8000_0000u) == 0) + keys.Add(key); + } + + var probed = new List(Math.Max(1, keys.Count)); + var offsets = new List(Math.Max(1, keys.Count)); + foreach (var key in keys) + { + var candidate = new uint[8]; + var descriptorOffset = (ulong)indirect.TableOffset + ((ulong)key << 5); + for (uint dword = 0; dword < candidate.Length; dword++) + { + if (!TryReadCleanWord(baseAddress, descriptorOffset + dword * sizeof(uint), inputs, out candidate[dword])) + return false; + } + + if (NullImageDescriptor(candidate) || !ValidImageDescriptor(candidate, r128) || !ReservedImageBitsClear(candidate)) + Array.Clear(candidate); + probed.Add(candidate); + offsets.Add(unchecked(indirect.DynamicOffsetBase + (key << 5))); + } + + if (probed.Count == 0) + { + probed.Add(new uint[8]); + offsets.Add(0); + } + + return FinishIndirectImage(probed, offsets, out result, out failure); + } + + private static bool TryReadCleanWord(ulong baseAddress, ulong offset, ResourceRuntimeInputs inputs, out uint word) + { + word = 0; + return offset <= AddressMask && baseAddress <= AddressMask - offset && + inputs.ReadCleanMemory is not null && inputs.ReadCleanMemory(baseAddress + offset, out word); + } + private static bool FinishIndirectImage( IReadOnlyList probed, IEnumerable keys, @@ -687,6 +886,11 @@ public static class ResourceMaterializer mappingWordCount = checked(mappingWordCount + 1 + table.Keys.Count * 2); } + foreach (var table in snapshot.BufferCandidateTables) + { + mappingWordCount = checked(mappingWordCount + 1 + table.Keys.Count * 2); + } + // Each draw owns these arrays. Only indirect candidates require a larger table. Array.Resize(ref snapshot.Images, imageCount); var mappingCursor = snapshot.FlattenedTable.Length; @@ -794,7 +998,10 @@ public static class ResourceMaterializer { images[index] = image with { - NumericClass = baseImage.Atomic ? ImageNumericClass.Uint : ImageNumericClass.Float, + // A null-bound atomic keeps its declared numeric class + // (Uint or, since float image atomics were added, Float) + // instead of assuming every atomic image is Uint. + NumericClass = baseImage.Atomic ? baseImage.NumericClass : ImageNumericClass.Float, Dimension = ImageDimension.Dim2D, Cube = false, }; @@ -810,7 +1017,14 @@ public static class ResourceMaterializer } var format = GuestImageFormat.FormatOf(descriptor); - if (baseImage.Atomic && format != GuestImageFormat.Format32Uint) + // Integer image atomics (Add/Smax/And/...) only ever operate on a + // 32-bit uint UAV. The float image atomics (Fmin/Fmax/Fcmpswap, + // MIMG op 0x1D-0x1F) are lowered as a compare-and-swap loop on the + // raw bit pattern (Gen5SpirvTranslator) and legitimately target a + // 32-bit float-format UAV instead - reject only formats that are + // neither. + if (baseImage.Atomic && format != GuestImageFormat.Format32Uint && + GuestImageFormat.SampledNumericClass(format) != ImageNumericClass.Float) { return Fail($"atomic image descriptor {index} uses unsupported format {format}"); } @@ -827,7 +1041,8 @@ public static class ResourceMaterializer return Fail($"storage image descriptor {index} uses unsupported format {format}"); } - if (rawSintStorage) + // Atomics use the uint view; float min/max compare-exchange its bits. + if (rawSintStorage || (baseImage.Atomic && numericClass == ImageNumericClass.Float)) { numericClass = ImageNumericClass.Uint; } @@ -961,7 +1176,71 @@ public static class ResourceMaterializer } } - specialization = new ResourceSpecialization { Buffers = buffers, Images = images }; + // Bounded runtime V# candidates are appended after the plan's buffers; each draw + // owns their words and the run-time key mapping sits in the flattened table. + var baseBufferCount = buffers.Count; + var bufferCursor = snapshot.Buffers.Length; + var candidateTables = new List(snapshot.BufferCandidateTables.Count); + foreach (var table in snapshot.BufferCandidateTables) + { + if (table.Descriptors.Count == 0 || table.Keys.Count == 0) + { + return Fail("buffer candidate table has no candidates"); + } + + Array.Resize(ref snapshot.Buffers, bufferCursor + table.Descriptors.Count); + for (var candidate = 0; candidate < table.Descriptors.Count; candidate++) + { + var words = table.Descriptors[candidate].Dwords; + if ((words[3] >> 30) != 0) + { + Array.Clear(words); + } + + snapshot.Buffers[bufferCursor + candidate] = words; + var stride = (words[1] >> 16) & 0x3FFF; + var swizzleEnabled = (words[1] >> 31) != 0; + var indexStride = (words[3] >> 21) & 0x3; + var addThreadId = (words[3] >> 23) & 0x1; + var packedStride = stride | ((swizzleEnabled ? 1u : 0u) << 14) | (indexStride << 16) | (addThreadId << 20); + var swizzle = stride != 0 && ((packedStride >> 14) & 1) != 0; + if (stride == 0) + { + packedStride &= ~((1u << 14) | (3u << 16)); + } + else if (!swizzle) + { + packedStride &= ~(3u << 16); + } + + buffers.Add(new BufferSpecialization(packedStride, (words[3] >> 12) & 0x7F, words[3] & 0xFFF)); + } + + var mappingOffset = (uint)mappingCursor; + var order = Enumerable.Range(0, table.Keys.Count).OrderBy(index => table.Keys[index]).ToArray(); + snapshot.FlattenedTable[(int)mappingOffset] = (uint)table.Keys.Count; + for (var entry = 0; entry < order.Length; entry++) + { + var source = order[entry]; + var offset = (int)mappingOffset + 1 + entry * 2; + snapshot.FlattenedTable[offset] = table.Keys[source]; + snapshot.FlattenedTable[offset + 1] = table.Candidates[source]; + } + + mappingCursor += 1 + table.Keys.Count * 2; + candidateTables.Add(new BufferCandidateTableSpecialization( + (uint)bufferCursor, (uint)table.Descriptors.Count, mappingOffset, + (uint)BitOperations.Log2((uint)table.Keys.Count) + 1)); + bufferCursor += table.Descriptors.Count; + } + + specialization = new ResourceSpecialization + { + BaseBufferCount = baseBufferCount, + Buffers = buffers, + Images = images, + BufferCandidateTables = candidateTables, + }; specializedSnapshot = snapshot; return true; } @@ -1026,11 +1305,11 @@ public static class ResourceMaterializer public static SpecializedResourceInfo ApplyTo(ShaderResourcePlan plan, ResourceSpecialization specialization) { var source = plan.Info; - if (source.Buffers.Count != specialization.Buffers.Count || source.Images.Count > specialization.Images.Count) + if (source.Buffers.Count != specialization.BaseBufferCount || source.Images.Count > specialization.Images.Count) { throw new ResourcePlanException( $"shader resource specialization does not match the plan: hash=0x{plan.Hash:X16} stage={plan.Stage} " + - $"buffers={source.Buffers.Count}/{specialization.Buffers.Count} images={source.Images.Count}/{specialization.Images.Count}"); + $"buffers={source.Buffers.Count}/{specialization.BaseBufferCount} images={source.Images.Count}/{specialization.Images.Count}"); } var info = source.Clone(); @@ -1041,6 +1320,32 @@ public static class ResourceMaterializer info.Buffers[index].DescriptorSwizzle = specialization.Buffers[index].DescriptorSwizzle; } + // Candidate buffers follow the plan's buffers, one native binding each. + for (var index = source.Buffers.Count; index < specialization.Buffers.Count; index++) + { + var specialized = specialization.Buffers[index]; + info.Buffers.Add(new BufferResource + { + Source = DescriptorConstants.NoIndex, + Read = true, + PackedStride = specialized.PackedStride, + DescriptorFormat = specialized.DescriptorFormat, + DescriptorSwizzle = specialized.DescriptorSwizzle, + }); + } + + for (var index = 0; index < specialization.BufferCandidateTables.Count; index++) + { + var table = specialization.BufferCandidateTables[index]; + info.BufferCandidateTables.Add(new BufferCandidateTableInfo + { + FirstCandidate = table.FirstCandidate, + CandidateCount = table.CandidateCount, + MappingOffset = table.MappingOffset, + SearchIterations = table.SearchIterations, + }); + } + for (var index = 0; index < specialization.Images.Count; index++) { var specialized = specialization.Images[index]; diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs index 37c7c06..f1fbcd9 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs @@ -8,6 +8,15 @@ namespace SharpEmu.ShaderCompiler.Resources; public readonly record struct BufferSpecialization(uint PackedStride, uint DescriptorFormat, uint DescriptorSwizzle); +// The module-affecting layout of one bounded runtime V# table. Candidate descriptors +// themselves are appended to Buffers; this names their contiguous run and the flattened +// key mapping, so the switch body does not have to embed per-draw addresses. +public readonly record struct BufferCandidateTableSpecialization( + uint FirstCandidate, + uint CandidateCount, + uint MappingOffset, + uint SearchIterations); + public readonly record struct ImageSpecialization( ImageNumericClass NumericClass, ImageDimension Dimension, @@ -24,17 +33,22 @@ public readonly record struct ImageSpecialization( // stay in the snapshot, so they never create a permutation. public sealed class ResourceSpecialization : IEquatable { + // The plan's base buffer count; Buffers beyond it are materialized candidates. + public int BaseBufferCount { get; init; } public List Buffers { get; init; } = []; public List Images { get; init; } = []; + public List BufferCandidateTables { get; init; } = []; public bool Equals(ResourceSpecialization? other) => - other is not null && Buffers.SequenceEqual(other.Buffers) && Images.SequenceEqual(other.Images); + other is not null && BaseBufferCount == other.BaseBufferCount && Buffers.SequenceEqual(other.Buffers) && + Images.SequenceEqual(other.Images) && BufferCandidateTables.SequenceEqual(other.BufferCandidateTables); public override bool Equals(object? obj) => Equals(obj as ResourceSpecialization); public override int GetHashCode() { var hash = new HashCode(); + hash.Add(BaseBufferCount); foreach (var buffer in Buffers) { hash.Add(buffer); @@ -45,14 +59,26 @@ public sealed class ResourceSpecialization : IEquatable hash.Add(image); } + foreach (var table in BufferCandidateTables) + { + hash.Add(table); + } + return hash.ToHashCode(); } - public ResourceSpecialization Clone() => new() { Buffers = [.. Buffers], Images = [.. Images] }; + public ResourceSpecialization Clone() => new() + { + BaseBufferCount = BaseBufferCount, + Buffers = [.. Buffers], + Images = [.. Images], + BufferCandidateTables = [.. BufferCandidateTables], + }; // The specialization of a plan before any draw: raw buffers and the tracked image classes. public static ResourceSpecialization Default(ShaderResourceInfo info) => new() { + BaseBufferCount = info.Buffers.Count, Buffers = info.Buffers.Select(_ => new BufferSpecialization(0, DescriptorConstants.InvalidFormat, DescriptorConstants.IdentityDestinationSelect)).ToList(), Images = info.Images.Select(image => new ImageSpecialization( image.NumericClass == ImageNumericClass.Unsupported ? (image.Atomic ? ImageNumericClass.Uint : ImageNumericClass.Float) : image.NumericClass, diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs index 64f0dd9..fab01fe 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs @@ -113,8 +113,11 @@ public sealed partial class ResourceTracker var key = scaled.Operands[0]; var bound = DenseKeyBound(key); - if (bound == 0) + var waveIndexed = TryCreateWaveIndexedImageSelector(key, reads); + if (bound == 0 && waveIndexed is null) + { return false; + } foreach (var read in reads) if (!UsesOnly(read, [handle])) @@ -135,6 +138,7 @@ public sealed partial class ResourceTracker TableOffset = tableOffset, DynamicOffsetBase = unchecked(tableOffset - tableImmediate), KeyBound = bound, + WaveIndexed = waveIndexed, }, }; @@ -174,4 +178,253 @@ public sealed partial class ResourceTracker return 0; } + + // Post-process kernels can select a descriptor per active bit in a scalar mask: + // mask -> s_ff1 -> v_mov -> (bit * stride + table) -> global_load -> + // v_readfirstlane -> key << 5 -> s_load_dwordx8. The global load itself is + // lane-varying, so it deliberately stays undefined in the scalar graph. The host + // can nevertheless resolve the same finite key set from the mask and index table. + private WaveIndexedImageSelector? TryCreateWaveIndexedImageSelector( + ScalarValue key, + IReadOnlyList reads) + { + if (key.Kind != ScalarValueKind.FirstLane || key.Operands.Length != 2 || reads.Count == 0) + { + return null; + } + + var instructions = _graph.Program.Instructions; + var firstLaneIndex = FindInstructionIndex(instructions, (uint)key.Payload); + if (firstLaneIndex < 0 || instructions[firstLaneIndex] is not { Opcode: "VReadfirstlaneB32", Sources.Count: 1, Destinations.Count: 1 } firstLane || + firstLane.Sources[0] is not { Kind: Gen5OperandKind.VectorRegister } vectorSource || + firstLane.Destinations[0] is not { Kind: Gen5OperandKind.ScalarRegister } scalarDestination) + { + return null; + } + + var globalIndex = FindLastDefinition(instructions, firstLaneIndex, vectorSource); + if (globalIndex < 0 || instructions[globalIndex].Control is not Gen5GlobalMemoryControl + { + DwordCount: 1, + UsesFlatAddress: false, + OffsetBytes: 0, + } global || global.DestinationVectorRegister != vectorSource.Value) + { + return null; + } + + var descriptorMemoryIndex = reads[0].MemoryIndex; + if (descriptorMemoryIndex < 0 || descriptorMemoryIndex >= _plan.Memory.Count) + { + return null; + } + + var descriptorLoadIndex = FindInstructionIndex(instructions, _plan.Memory[descriptorMemoryIndex].Pc); + if (descriptorLoadIndex < 0 || instructions[descriptorLoadIndex] is not + { + Control: Gen5ScalarMemoryControl + { + DestinationCount: >= 8, + DynamicOffsetRegister: { } dynamicOffsetRegister, + }, + } descriptorLoad || dynamicOffsetRegister != scalarDestination.Value || + descriptorLoad.Sources.Count == 0 || descriptorLoad.Sources[0] != Gen5Operand.Scalar(global.ScalarAddress)) + { + return null; + } + + var address = Gen5Operand.Vector(global.VectorAddress); + var addressDefinitionIndex = FindLastDefinition(instructions, globalIndex, address); + if (addressDefinitionIndex < 0 || !TryGetAddedConstant(instructions[addressDefinitionIndex], out var indexDataOffset)) + { + return null; + } + + var heapAddress = Gen5Operand.Scalar(global.ScalarAddress); + if (TryGetSelfAddedConstant(instructions[addressDefinitionIndex], address, out _) && + TryGetWaveIndexedStride(instructions, addressDefinitionIndex, address, heapAddress, + out var maskRegister, out var bitRegister, out var maskOffset, out var indexStride) && + indexDataOffset != 0 && indexStride != 0 && instructions.Any(instruction => instruction.Opcode == "SBitset0B32" && + instruction.Destinations.Contains(maskRegister) && instruction.Sources.Contains(bitRegister))) + { + return new(maskOffset, indexDataOffset, indexStride); + } + + // Some game kernels reuse the vector address register while preparing the + // lane mask. The address arithmetic no longer has a single SSA-looking + // chain, but the scalar data dependency is still exact: one clean mask word, + // its FF1 result, the matching bit clear, and a scalar multiply that gives + // the global index stride. This is the same finite runtime-table model; it + // merely avoids making register allocation part of the proof. + return TryGetWaveIndexedMaskStride(instructions, addressDefinitionIndex, heapAddress, indexDataOffset); + } + + private static WaveIndexedImageSelector? TryGetWaveIndexedMaskStride( + IReadOnlyList instructions, + int before, + Gen5Operand heapAddress, + uint indexTableOffset) + { + if (indexTableOffset == 0) + return null; + + for (var loadIndex = before - 1; loadIndex >= 0; loadIndex--) + { + if (instructions[loadIndex] is not + { + Control: Gen5ScalarMemoryControl { DestinationCount: 1, ImmediateOffsetBytes: >= 0, DynamicOffsetRegister: null } maskControl, + Sources.Count: >= 1, + Destinations.Count: 1, + } maskLoad || maskLoad.Sources[0] != heapAddress || + maskLoad.Destinations[0] is not { Kind: Gen5OperandKind.ScalarRegister } maskRegister) + continue; + + for (var scanIndex = loadIndex + 1; scanIndex < before; scanIndex++) + { + if (instructions[scanIndex] is not { Opcode: "SFF1I32B32", Sources.Count: 1, Destinations.Count: 1 } scan || + scan.Sources[0] != maskRegister || scan.Destinations[0] is not { Kind: Gen5OperandKind.ScalarRegister } bitRegister || + !instructions.Skip(scanIndex + 1).Take(before - scanIndex - 1) + .Any(instruction => instruction.Opcode == "SBitset0B32" && instruction.Destinations.Contains(maskRegister) && + instruction.Sources.Contains(bitRegister))) + continue; + + for (var multiplyIndex = scanIndex + 1; multiplyIndex < before; multiplyIndex++) + { + var multiply = instructions[multiplyIndex]; + if (multiply.Opcode != "SMulI32" || multiply.Sources.Count != 2 || !multiply.Sources.Contains(bitRegister)) + continue; + var strideOperand = multiply.Sources[0] == bitRegister ? multiply.Sources[1] : multiply.Sources[0]; + if (!TryGetConstant(strideOperand, out var stride) || stride == 0) + continue; + return new((uint)maskControl.ImmediateOffsetBytes, indexTableOffset, stride); + } + } + } + + return null; + } + + private static bool TryGetWaveIndexedStride( + IReadOnlyList instructions, + int addressAddIndex, + Gen5Operand address, + Gen5Operand heapAddress, + out Gen5Operand maskRegister, + out Gen5Operand bitRegister, + out uint maskOffset, + out uint stride) + { + maskRegister = default; + bitRegister = default; + maskOffset = 0; + stride = 0; + var strideAddIndex = FindLastDefinition(instructions, addressAddIndex, address); + if (strideAddIndex < 0 || instructions[strideAddIndex] is not { Opcode: "VLshlAddU32", Sources.Count: 3 } strideAdd || + strideAdd.Sources[0] != address || strideAdd.Sources[2] != address || !TryGetConstant(strideAdd.Sources[1], out var outerShift) || outerShift >= 31) + return false; + + var innerShiftIndex = FindLastDefinition(instructions, strideAddIndex, address); + if (innerShiftIndex < 0 || instructions[innerShiftIndex] is not { Opcode: "VLshlrevB32", Sources.Count: 2 } innerShift || + !TryGetConstant(innerShift.Sources[0], out var innerAmount) || innerAmount >= 31 || + innerShift.Sources[1] is not { Kind: Gen5OperandKind.VectorRegister } bitVector) + return false; + + var moveIndex = FindLastDefinition(instructions, innerShiftIndex, bitVector); + if (moveIndex < 0 || instructions[moveIndex] is not { Opcode: "VMovB32", Sources.Count: 1 } move || + move.Sources[0] is not { Kind: Gen5OperandKind.ScalarRegister } bitScalar) + return false; + + var bitScanIndex = FindLastDefinition(instructions, moveIndex, bitScalar); + if (bitScanIndex < 0 || instructions[bitScanIndex] is not { Opcode: "SFF1I32B32", Sources.Count: 1 } bitScan || + bitScan.Sources[0] is not { Kind: Gen5OperandKind.ScalarRegister } mask) + return false; + + var maskLoadIndex = FindLastDefinition(instructions, bitScanIndex, mask); + if (maskLoadIndex < 0 || instructions[maskLoadIndex] is not + { + Control: Gen5ScalarMemoryControl { DestinationCount: 1, ImmediateOffsetBytes: >= 0, DynamicOffsetRegister: null } maskControl, + Sources.Count: >= 1, + } maskLoad || maskLoad.Sources[0] != heapAddress) + return false; + + var combinedStride = (ulong)(1u << (int)innerAmount) * ((1u << (int)outerShift) + 1u); + if (combinedStride == 0 || combinedStride > uint.MaxValue) + return false; + + maskRegister = mask; + bitRegister = bitScalar; + maskOffset = (uint)maskControl.ImmediateOffsetBytes; + stride = (uint)combinedStride; + return true; + } + + private static int FindLastDefinition(IReadOnlyList instructions, int before, Gen5Operand destination) + { + for (var index = before - 1; index >= 0; index--) + { + if (instructions[index].Destinations.Contains(destination)) + { + return index; + } + } + + return -1; + } + + private static int FindInstructionIndex(IReadOnlyList instructions, uint pc) + { + for (var index = 0; index < instructions.Count; index++) + { + if (instructions[index].Pc == pc) + { + return index; + } + } + + return -1; + } + + private static bool TryGetSelfAddedConstant(Gen5ShaderInstruction instruction, Gen5Operand destination, out uint constant) + { + constant = 0; + if (instruction.Opcode is not ("VAddI32" or "VAddU32") || instruction.Sources.Count != 2 || + !instruction.Destinations.Contains(destination)) + { + return false; + } + + var left = instruction.Sources[0]; + var right = instruction.Sources[1]; + if (left == destination) + { + return TryGetConstant(right, out constant); + } + + return right == destination && TryGetConstant(left, out constant); + } + + private static bool TryGetAddedConstant(Gen5ShaderInstruction instruction, out uint constant) + { + constant = 0; + if (instruction.Opcode is not ("VAddI32" or "VAddU32") || instruction.Sources.Count != 2) + return false; + return TryGetConstant(instruction.Sources[0], out constant) || TryGetConstant(instruction.Sources[1], out constant); + } + + private static bool TryGetConstant(Gen5Operand operand, out uint constant) + { + if (operand.Kind == Gen5OperandKind.LiteralConstant) + { + constant = operand.Value; + return true; + } + + if (operand.Kind == Gen5OperandKind.EncodedConstant && Gen5InlineConstants.TryDecode(operand.Value, out constant)) + { + return true; + } + + constant = 0; + return false; + } } diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs index 698e1f6..57ea04b 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs @@ -19,6 +19,7 @@ public sealed partial class ResourceTracker private readonly List _sources = []; private readonly List<(int Index, uint Resource, uint Sampler, bool HasSampler)> _memoryPatches = []; private readonly List _indirectImages = []; + private readonly List _bufferCandidateTables = []; private readonly Dictionary> _uses; private readonly Dictionary> _readsByMemory = []; @@ -38,7 +39,8 @@ public sealed partial class ResourceTracker IReadOnlyList Sources, ShaderResourceInfo Info, IReadOnlyList IndirectImages, - IReadOnlySet IndirectReads); + IReadOnlySet IndirectReads, + IReadOnlyList BufferCandidateTables); private ResourceTracker(ShaderResourcePlan plan) { @@ -134,12 +136,80 @@ public sealed partial class ResourceTracker } } - return new Result(_sources, _info, indirectAccesses, indirectReads); + return new Result(_sources, _info, indirectAccesses, indirectReads, _bufferCandidateTables); } private ResourcePlanException Failure(uint pc, string reason) => new($"shader resource tracking: hash=0x{_plan.Hash:X16} stage={_plan.Stage} pc=0x{pc:X8} {reason}"); + /// + /// Names the instructions that produced the undefined leaves + /// rests on, so a rejected descriptor dword points at an opcode. + /// + private string DescribeUndefinedLeaves(ScalarValue value) + { + var origins = new List(); + var seen = new HashSet(); + var pending = new Stack(); + pending.Push(value); + while (pending.Count != 0 && origins.Count < 8) + { + var current = pending.Pop(); + if (!seen.Add(current)) + { + continue; + } + + if (current.Kind == ScalarValueKind.Undefined && + _graph.TryGetUndefinedOrigin(current, out var origin)) + { + var described = $"{origin.Opcode}@0x{origin.Pc:X}"; + if (!origins.Contains(described)) + { + origins.Add(described); + } + + continue; + } + + foreach (var operand in current.Operands) + { + pending.Push(operand); + } + } + + return origins.Count == 0 ? string.Empty : $" (undefined from: {string.Join(", ", origins)})"; + } + + private bool HasUndefinedOrigin(ScalarValue value, string opcodePrefix) + { + var seen = new HashSet(); + var pending = new Stack(); + pending.Push(value); + while (pending.Count != 0) + { + var current = pending.Pop(); + if (!seen.Add(current)) + { + continue; + } + + if (current.Kind == ScalarValueKind.Undefined && + _graph.TryGetUndefinedOrigin(current, out var origin) && + origin.Opcode.StartsWith(opcodePrefix, StringComparison.Ordinal)) + { + return true; + } + + foreach (var operand in current.Operands) + { + pending.Push(operand); + } + } + + return false; + } + // ---- descriptor sources ---- // Copies a handle's dwords into a source. A sampler that no clamp axis sets to @@ -221,12 +291,20 @@ public sealed partial class ResourceTracker } } - private bool ValidateSource(DescriptorSource source, out uint badDword) + private bool ValidateSource(DescriptorSource source, out uint badDword) => ValidateSource(source, out badDword, out _); + + private bool ValidateSource(DescriptorSource source, out uint badDword, out bool controlDependent) { + controlDependent = false; for (badDword = 0; badDword < source.DwordCount; badDword++) { var dword = source.Dwords[badDword]; - if (dword.Type != ScalarValueType.U32 || !_plan.ValidateRuntimeValue(dword)) + if (dword.Type != ScalarValueType.U32) + { + return false; + } + + if (!_plan.ValidateRuntimeValue(dword, out controlDependent)) { return false; } @@ -261,6 +339,42 @@ public sealed partial class ResourceTracker return (uint)(_sources.Count - 1); } + // Records a proven bounded SRT candidate table, reusing an equivalent table so several + // accesses share one native candidate layout. Returns false when the SRT root itself is + // not a valid runtime source, in which case the access stays on its old lowering. + private bool InternBufferCandidateTable(BufferCandidateTablePlan table, uint pc, int memoryIndex) + { + table.SourceSrtResource = InternRuntimeSource(table.SrtHandle, pc); + if (table.SourceSrtResource == DescriptorConstants.NoIndex) + { + return false; + } + + for (var existing = 0; existing < _bufferCandidateTables.Count; existing++) + { + var current = _bufferCandidateTables[existing]; + if (!_graph.Equivalent(current.SrtHandle, table.SrtHandle) || + !_graph.Equivalent(current.OffsetExpression, table.OffsetExpression)) + { + continue; + } + + _bufferCandidateTables[existing] = current.WithMemoryIndex(memoryIndex); + return true; + } + + _bufferCandidateTables.Add(table); + return true; + } + + // A fully-resolvable descriptor source for the static SRT root a candidate table reads + // through. Its dwords stay runtime values; this only records them for the materialiser. + private uint InternRuntimeSource(ScalarValue handle, uint pc) + { + var source = MakeSource(handle, 4, false, false, pc); + return ValidateSource(source, out _) ? InternSource(source) : DescriptorConstants.NoIndex; + } + private bool IsHostBufferHandle(ScalarValue? handle) => handle is { Kind: ScalarValueKind.BufferHandle, Operands.Length: 4 } && handle.Operands.All(dword => dword.Type == ScalarValueType.U32 && _plan.ValidateRuntimeValue(dword)); @@ -298,7 +412,15 @@ public sealed partial class ResourceTracker return false; } - private uint GetHandleSource(ScalarValue? handle, ScalarValueKind expected, uint width, uint pc, bool sampler = false, bool sampleAdjust = false) + private uint GetHandleSource( + ScalarValue? handle, + ScalarValueKind expected, + uint width, + uint pc, + bool sampler = false, + bool sampleAdjust = false, + string? memoryOpcode = null, + MemoryAccess memoryAccess = MemoryAccess.Read) { if (handle is null || handle.Kind != expected) { @@ -306,23 +428,248 @@ public sealed partial class ResourceTracker } var source = MakeSource(handle, width, sampler, sampleAdjust, pc); - if (expected == ScalarValueKind.ImageHandle) + var nonContiguousImage = expected == ScalarValueKind.ImageHandle && !IsContiguousScalarBufferRecord(source); + + // Image descriptors loaded straight from a scalar buffer at a dynamically-uniform + // offset (e.g. a bindless material heap entry read via S_BUFFER_LOAD, without going + // through the explicit TryMakeIndirectImage/TryMakeDirectImage heap-record shapes) + // used to be rejected outright here. But RuntimeValueValidator/RuntimeValueEvaluator + // already handle ScalarBufferWord dwords generically (the same mechanism buffer + // descriptors rely on via MaterializationSources), so let ValidateSource below be the + // single source of truth instead of a narrower, ImageHandle-specific blanket ban. + var badDword = 0u; + var controlDependent = false; + if (nonContiguousImage || !ValidateSource(source, out badDword, out controlDependent)) { - for (uint dword = 0; dword < source.DwordCount; dword++) + // A bindless image/sampler descriptor whose dwords resolve through a + // control-dependent phi (e.g. a hash-table/linear-probe material lookup, as seen + // in Ghost of Yotei) has no single compile-time source: real support needs + // GPU-side dynamic descriptor indexing, which this resource tracker doesn't + // implement. Rather than fail shader recompilation outright, degrade to a null + // descriptor for that one access and let it read as a null/black texture, + // mirroring KytyPS5's fallback for the same case (feat/shader-control-dependent- + // descriptor). Buffer/sampler-adjacent handles or any other validation failure + // still hard-fail, since those aren't safe to silently zero. + var dynamicImageFallback = expected is (ScalarValueKind.ImageHandle or ScalarValueKind.SamplerHandle) && + (controlDependent || HasUndefinedOrigin(source.Dwords[badDword], "BufferLoadFormat") || + (nonContiguousImage && source.Dwords.Any(dword => HasUndefinedOrigin(dword, "SAndB32")))); + if (dynamicImageFallback) { - if (source.Dwords[dword].Kind == ScalarValueKind.ScalarBufferWord) + source = new DescriptorSource { - throw Failure(pc, $"{expected} dword {dword} is not a valid runtime value"); - } + Dwords = Enumerable.Repeat(_graph.Constant(0u), (int)source.DwordCount).ToArray(), + }; + } + else + { + throw Failure( + pc, + $"{memoryOpcode ?? "memory"} ({memoryAccess}) {expected} dword {badDword} is not a valid runtime value" + + DescribeUndefinedLeaves(source.Dwords[badDword]) + + $" (value: {DescribeValueShape(source.Dwords[badDword])})"); } } - if (!ValidateSource(source, out var badDword)) + return InternSource(source); + } + + private string DescribeValueShape(ScalarValue value, int depth = 0) + { + if (depth >= 4) { - throw Failure(pc, $"{expected} dword {badDword} is not a valid runtime value"); + return $"{value.Kind}#{value.Id}"; } - return InternSource(source); + if (value.IsConstant) + { + return value.Type == ScalarValueType.U64 + ? $"0x{value.ConstantU64:X}" + : value.Type == ScalarValueType.Bool ? (value.ConstantBool ? "true" : "false") : $"0x{value.ConstantU32:X}"; + } + + if (value.Kind == ScalarValueKind.Operation) + { + return $"{value.Operation}#{value.Id}({string.Join(',', value.Operands.Select(operand => DescribeValueShape(operand, depth + 1)))})"; + } + + if (value.Kind == ScalarValueKind.Undefined && _graph.TryGetUndefinedOrigin(value, out var origin)) + { + return $"Undefined#{value.Id}({origin.Opcode}@0x{origin.Pc:X})"; + } + + return $"{value.Kind}#{value.Id}"; + } + + // An image descriptor read from a scalar buffer must be one contiguous record. + private bool IsContiguousScalarBufferRecord(DescriptorSource source) + { + if (!source.Dwords.Any(dword => dword.Kind == ScalarValueKind.ScalarBufferWord)) + { + return true; + } + + var first = ScalarReadMemory(source.Dwords[0], out _); + for (var dword = 0; dword < source.Dwords.Length; dword++) + { + var read = source.Dwords[dword]; + var memory = ScalarReadMemory(read, out _); + if (first is null || memory is null || + memory.Offset != first.Offset + (uint)dword * sizeof(uint) || + !_graph.Equivalent(read.Operands[0], source.Dwords[0].Operands[0]) || + !_graph.Equivalent(read.Operands[1], source.Dwords[0].Operands[1])) + { + return false; + } + } + + return true; + } + + // A runtime V# is one whose four dwords cannot be resolved at plan time, but + // whose non-constant leaves are all reads from guest memory. This covers a + // descriptor read straight from a device address and one read from the SRT + // buffer at a dynamic offset, including the phi that merges an SRT read + // across a loop. A handle that is fully resolvable (constants and flattened + // table words only) is not a runtime descriptor and keeps the native binding. + private bool IsRuntimeDescriptorHandle(ScalarValue? handle, bool allowComputedWords = false) + { + if (handle is null || handle.Kind != ScalarValueKind.BufferHandle || + handle.Operands.Length != 4) + { + return false; + } + + // Descriptor words loaded by the shader can point at a memory entry that + // is intentionally omitted from the host-side table (for example a + // vertex-fetch record created after fixed-function lowering). The + // device-address lowering does not need to evaluate that word on the + // host, so keep the descriptor runtime when the word is still a real + // scalar memory read. Other values retain the stricter derivability + // check, which keeps divergent first-lane handles rejected. + if (!handle.Operands.All(IsRuntimeDescriptorDword)) + { + // Some vertex-fetch shaders combine one scalar address load with + // descriptor words produced in the vector path. Those words are + // not host-evaluable, but the shader still owns the complete + // descriptor in its registers. Once a scalar memory word is part + // of the handle, lower the whole descriptor through device + // addresses instead of trying to bind a partial host source. + return allowComputedWords && handle.Operands.Any(operand => HasRuntimeReadKind(operand, ScalarValueKind.ScalarAddressWord)); + } + + return handle.Operands.Any(operand => HasRuntimeRead(operand)); + } + + private bool IsRuntimeDescriptorDword(ScalarValue value) => IsRuntimeDescriptorDword(value, []); + + private bool IsRuntimeDescriptorDword(ScalarValue value, HashSet visiting) + { + if (!visiting.Add(value)) + { + return value.Kind == ScalarValueKind.Phi; + } + + try + { + if (value.Kind is ScalarValueKind.ScalarAddressWord or ScalarValueKind.ScalarBufferWord) + { + // The backend consumes this as a device-address word. It may + // not have a corresponding host materialization record. + return value.MemoryIndex >= 0; + } + + return IsRuntimeDerivable(value, visiting); + } + finally + { + visiting.Remove(value); + } + } + + // True when a value is known at plan time (constant, user data, shader base, + // a flattened resource-table word) or is ultimately a guest-memory read. + // A phi, select or operation qualifies when every operand does, so a + // loop-carried descriptor merge stays a runtime descriptor. A cycle through + // a phi is accepted: its non-cyclic inputs are checked on the path that + // reached it, the same way the read planner treats cyclic phis. + private bool IsRuntimeDerivable(ScalarValue value) => IsRuntimeDerivable(value, []); + + private bool IsRuntimeDerivable(ScalarValue value, HashSet visiting) + { + if (!visiting.Add(value)) + { + return value.Kind == ScalarValueKind.Phi; + } + + try + { + switch (value.Kind) + { + case ScalarValueKind.Constant: + case ScalarValueKind.ResourceTableWord: + case ScalarValueKind.UserData: + case ScalarValueKind.ShaderBase: + case ScalarValueKind.MemoryAperture: + return true; + case ScalarValueKind.ScalarAddressWord: + case ScalarValueKind.ScalarBufferWord: + return value.MemoryIndex < _graph.Memory.Count; + case ScalarValueKind.Phi: + case ScalarValueKind.Select: + case ScalarValueKind.Operation: + case ScalarValueKind.FirstLane: + return value.Operands.All(operand => IsRuntimeDerivable(operand, visiting)); + default: + return false; + } + } + finally + { + visiting.Remove(value); + } + } + + private static bool HasRuntimeRead(ScalarValue value) => HasRuntimeRead(value, []); + + private static bool HasRuntimeReadKind(ScalarValue value, ScalarValueKind kind) => HasRuntimeReadKind(value, kind, []); + + private static bool HasRuntimeReadKind(ScalarValue value, ScalarValueKind kind, HashSet visiting) + { + if (!visiting.Add(value)) + { + return false; + } + + try + { + return value.Kind == kind || value.Operands.Any(operand => HasRuntimeReadKind(operand, kind, visiting)); + } + finally + { + visiting.Remove(value); + } + } + + private static bool HasRuntimeRead(ScalarValue value, HashSet visiting) + { + if (!visiting.Add(value)) + { + return false; + } + + try + { + if (value.Kind is ScalarValueKind.ScalarAddressWord or ScalarValueKind.ScalarBufferWord) + { + return true; + } + + return value.Operands.Any(operand => HasRuntimeRead(operand, visiting)); + } + finally + { + visiting.Remove(value); + } } // ---- dense tables ---- @@ -490,6 +837,14 @@ public sealed partial class ResourceTracker var isBuffer = memory.Kind is MemoryResourceKind.Buffer or MemoryResourceKind.ScalarBuffer; var isAddress = memory.Kind is MemoryResourceKind.ScalarAddress or MemoryResourceKind.Flat or MemoryResourceKind.Global or MemoryResourceKind.Scratch; var isImage = memory.Kind == MemoryResourceKind.Image; + if (memory.Opcode is "ImageBvhIntersectRay" or "ImageBvh64IntersectRay") + { + // BVH nodes are read through the device-address page table: traversal + // reaches bottom-level trees whose bases only exist in guest memory. + _info.UsesDeviceAddresses = true; + return; + } + if (!isBuffer && !isAddress && !isImage) { return; @@ -497,6 +852,12 @@ public sealed partial class ResourceTracker if (access is null) { + if (HasIndirectPcTransferBefore(memory.Pc)) + { + memory.PlanningOnly = true; + return; + } + throw Failure(memory.Pc, "memory operation has no resource handle"); } @@ -507,6 +868,85 @@ public sealed partial class ResourceTracker if (isBuffer) { + // A front GS/HS shader can carry a second code object after + // S_SETPC_B64. Until AGC supplies its continuation descriptor, + // linear decoding sees that unreachable tail with undefined SGPRs. + // Do not turn that dead instruction into a descriptor failure; the + // live path has already transferred control through the PC pair. + if (access.Handle is { Kind: ScalarValueKind.BufferHandle } unreachableHandle && + unreachableHandle.Operands.Any(value => value.IsUndefined) && + HasIndirectPcTransferBefore(memory.Pc)) + { + memory.PlanningOnly = true; + return; + } + + // A buffer access can carry a descriptor assembled in vector lanes. Its + // record count/stride is not a host-evaluable scalar (the A6FE Little + // Nightmares path is one example), but the Vulkan physical-storage-buffer + // lowering can read the four descriptor words directly from shader + // registers. Keep this narrow to vector-derived accesses; ordinary + // malformed handles must still fail validation below. + if (memory.Access is MemoryAccess.Read or MemoryAccess.Write && + access.Handle is { Kind: ScalarValueKind.BufferHandle } writeHandle) + { + var writeSource = MakeSource(writeHandle, 4, false, false, memory.Pc); + if (writeSource.Dwords.Any(dword => + HasUndefinedOrigin(dword, "VAdd3U32") || + HasUndefinedOrigin(dword, "VCvtU32F32") || + HasUndefinedOrigin(dword, "VRcpIflagF32"))) + { + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Runtime, + }; + _info.UsesDeviceAddresses = true; + return; + } + } + + // A V# whose dwords are all read from a runtime scalar-memory address + // (e.g. a descriptor-array entry indexed by a loop counter) cannot be + // bound ahead of time. Record it as a runtime guest descriptor so the + // backend can choose a lowering strategy for each access. + // A vector access through a V# the draw can evaluate keeps the explicit binding. + var allowComputedFormatDescriptor = memory.Formatted && + memory.Opcode.StartsWith("BufferLoadFormat", StringComparison.Ordinal); + if (IsRuntimeDescriptorHandle(access.Handle, allowComputedFormatDescriptor) && + (memory.Kind != MemoryResourceKind.Buffer || + !ValidateSource(MakeSource(access.Handle!, 4, false, false, memory.Pc), out _))) + { + // A formatted vector access can enumerate its bounded candidates; a scalar + // buffer read only needs the descriptor's base, which its SGPRs already + // hold, so it is addressed through the device-address page table. + if (memory.Kind == MemoryResourceKind.Buffer && + BufferCandidateTablePlanner.TryPlan(_plan, access.Handle!, index, out var table) && + InternBufferCandidateTable(table, memory.Pc, index)) + { + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Runtime, + }; + return; + } + + // Without a bounded candidate table, a V# loaded from scalar-buffer + // data is read through its descriptor registers on the device. + if (memory.Kind == MemoryResourceKind.Buffer && IsDeviceLoadedBufferHandle(access.Handle)) + { + memory.DeviceDescriptor = true; + _info.UsesDeviceAddresses = true; + return; + } + + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Runtime, + }; + _info.UsesDeviceAddresses = true; + return; + } + // Scalar loads can address buffers that have no host descriptor binding, while // vector buffer descriptors loaded from scalar-buffer data must stay device-side. if ((memory.Kind == MemoryResourceKind.ScalarBuffer && !IsHostBufferHandle(access.Handle)) || @@ -517,13 +957,35 @@ public sealed partial class ResourceTracker return; } - var source = GetHandleSource(access.Handle, ScalarValueKind.BufferHandle, 4, memory.Pc); + var source = GetHandleSource( + access.Handle, + ScalarValueKind.BufferHandle, + 4, + memory.Pc, + memoryOpcode: memory.Opcode, + memoryAccess: memory.Access); var resource = AddBuffer(source, memory, memory.Pc); if (resource == DescriptorConstants.NoIndex) { - throw Failure(memory.Pc, "buffer resource limit exceeded"); + // Past the binding budget the access reads its V# from its SGPRs when it + // runs, as the hardware does, and goes through the device-address table. + // That path has no typed (tbuffer) or formatted store lowering. + if (memory.Opcode.StartsWith("TBuffer", StringComparison.Ordinal) || + (memory.Formatted && memory.Access != MemoryAccess.Read)) + { + throw Failure(memory.Pc, "buffer resource limit exceeded"); + } + + memory.DeviceDescriptor = true; + _info.UsesDeviceAddresses = true; + return; } + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Static, + StaticResource = resource, + }; AddMemoryPatch(index, resource, 0, false, memory.Pc); return; } @@ -551,6 +1013,22 @@ public sealed partial class ResourceTracker return; } + // A fused shader resumes through S_SETPC_B64. The continuation can + // legitimately consume the image descriptor carried in the incoming + // SGPR state, while the standalone resource graph only sees the first + // code object and therefore has no provenance for those dwords. Do + // not reject the whole program for that cross-object bookkeeping gap; + // leave the access unplanned so the continuation path can supply its + // descriptor at runtime. + if (isImage && + access.Handle is { Kind: ScalarValueKind.ImageHandle } continuationImage && + continuationImage.Operands.Any(value => value.IsUndefined) && + HasIndirectPcTransferBefore(memory.Pc)) + { + memory.PlanningOnly = true; + return; + } + if (memory.ImageClass == ImageResourceClass.None) { throw Failure(memory.Pc, "image operation has invalid resource kind"); @@ -633,6 +1111,35 @@ public sealed partial class ResourceTracker private bool IsIndirectPlanningMemory(int index) => _indirectImages.Any(plan => plan.SuppressMemoryReads && plan.Memory.Contains(index)); + private bool HasIndirectPcTransferBefore(uint pc) + { + var previousEnd = 0u; + foreach (var instruction in _graph.Program.Instructions) + { + if (instruction.Pc >= pc) + { + break; + } + + if (instruction.Opcode is "SSetpcB64" or "SRfeB64") + { + return true; + } + + // Fused shaders replace the S_SETPC marker with a NOP and append + // the continuation at its real address. The PC discontinuity is + // the remaining reliable boundary when the marker is gone. + if (previousEnd != 0 && instruction.Pc > previousEnd) + { + return true; + } + + previousEnd = instruction.Pc + (uint)(instruction.Words.Count * sizeof(uint)); + } + + return false; + } + private void PlanIndirectImages() { for (var index = 0; index < _plan.Memory.Count; index++) @@ -662,7 +1169,8 @@ public sealed partial class ResourceTracker } var memory = _plan.Memory[index]; - return memory.Kind == MemoryResourceKind.ScalarBuffer && memory.DataBits == 32 && memory.DataDwords == 1 ? memory : null; + return memory.Kind is MemoryResourceKind.ScalarBuffer or MemoryResourceKind.Buffer && + memory.DataBits == 32 && memory.DataDwords == 1 ? memory : null; } private bool MemoryIndexBelongsTo(int index, ScalarValue owner) => diff --git a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs index 2b238be..668af94 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs @@ -116,6 +116,9 @@ public sealed class RuntimeValueEvaluator { case ScalarValueKind.Undefined: return false; + case ScalarValueKind.MemoryAperture: + result = Gen5InlineConstants.DecodeAperture64((uint)value.Payload) >> 32; + return true; case ScalarValueKind.UserData: { var register = value.UserDataRegister; @@ -235,7 +238,9 @@ public sealed class RuntimeValueEvaluator var size = stride == 0 ? (ulong)(uint)records : (ulong)stride * (uint)records; if (aligned > size || size - aligned < sizeof(uint)) { - return false; + // An unbound (empty) V# reads as zero; overrunning a bound buffer stays a failure. + result = 0; + return (uint)records == 0; } address = ((baseAddress & ~3ul) + byteOffset) & ~3ul; @@ -354,7 +359,10 @@ public sealed class RuntimeValueEvaluator { for (var index = 0; index < words.Length; index++) { - if (!evaluator.Evaluate(source.Dwords[index], out words[index])) return false; + if (!evaluator.Evaluate(source.Dwords[index], out words[index])) + { + return false; + } } } diff --git a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs index 274dd6c..7df4bb3 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs @@ -16,6 +16,14 @@ public sealed class RuntimeValueValidator private readonly ScalarValue? _activeMask; private readonly HashSet _visiting = []; + // Set when a failure traces back to a phi that isn't loop-invariant (e.g. a + // hash-table/linear-probe bindless lookup): the value genuinely has no single + // compile-time source, as opposed to using an operation or memory shape we just + // don't recognize. Callers use this to decide whether degrading to a null + // descriptor is reasonable instead of failing outright (KytyPS5 does the same + // for image/sampler handles specifically). + public bool ControlDependent { get; private set; } + public RuntimeValueValidator(ScalarValueGraph graph, uint userDataBase, uint userDataCount, int tableReadCount, ScalarValue? activeMask = null) { _graph = graph; @@ -58,7 +66,8 @@ public sealed class RuntimeValueValidator try { - return ValidateNode(value); + var result = ValidateNode(value); + return result; } finally { @@ -81,15 +90,35 @@ public sealed class RuntimeValueValidator case ScalarValueKind.UserData: return value.UserDataRegister >= _userDataBase && value.UserDataRegister - _userDataBase < _userDataCount; case ScalarValueKind.ShaderBase: + case ScalarValueKind.MemoryAperture: return true; case ScalarValueKind.Phi: { var invariant = _graph.ResolveInvariantPhi(value); - return invariant is not null && Validate(invariant); + if (invariant is null) + { + ControlDependent = true; + return false; + } + + return Validate(invariant); } case ScalarValueKind.FirstLane: - return value.Operands.Length == 2 && - new RuntimeValueValidator(_graph, _userDataBase, _userDataCount, _tableReadCount, value.Operands[1]).Validate(value.Operands[0]); + { + if (value.Operands.Length != 2) + { + return false; + } + + var nested = new RuntimeValueValidator(_graph, _userDataBase, _userDataCount, _tableReadCount, value.Operands[1]); + var ok = nested.Validate(value.Operands[0]); + if (nested.ControlDependent) + { + ControlDependent = true; + } + + return ok; + } case ScalarValueKind.ResourceTableWord: return value.Payload < (ulong)_tableReadCount; case ScalarValueKind.ScalarAddressWord: @@ -168,6 +197,6 @@ public sealed class RuntimeValueValidator var kind = _graph.Memory[value.MemoryIndex].Kind; return (value.Kind == ScalarValueKind.ScalarAddressWord && kind == MemoryResourceKind.ScalarAddress) || - (value.Kind == ScalarValueKind.ScalarBufferWord && kind == MemoryResourceKind.ScalarBuffer); + (value.Kind == ScalarValueKind.ScalarBufferWord && kind is MemoryResourceKind.ScalarBuffer or MemoryResourceKind.Buffer); } } diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs index 4fe7977..f398e7f 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs @@ -32,6 +32,8 @@ public enum ScalarValueKind : byte ScalarAddressWord, ScalarBufferWord, ResourceTableWord, + // The high dword of a GFX10 LDS/scratch aperture (payload: the inline operand). + MemoryAperture, } // Operations a value node can apply to its operands. The validator accepts only the @@ -154,6 +156,9 @@ public sealed class ScalarValue public static ScalarValue ConstantOf(bool value) => new(ScalarValueKind.Constant, ScalarValueType.Bool, ScalarOperation.None, []) { Payload = value ? 1u : 0u }; + public static ScalarValue MemoryAperture(uint operand) => + new(ScalarValueKind.MemoryAperture, ScalarValueType.U32, ScalarOperation.None, []) { Payload = operand }; + public static ScalarValue Undefined(ScalarValueType type) => new(ScalarValueKind.Undefined, type, ScalarOperation.None, []); @@ -197,6 +202,7 @@ public sealed class ScalarValue { ScalarValueKind.Constant => Type == ScalarValueType.U64 ? $"0x{Payload:X}ul" : Type == ScalarValueType.Bool ? (Payload != 0 ? "true" : "false") : $"0x{(uint)Payload:X}", ScalarValueKind.UserData => $"UserData(s{Payload})", + ScalarValueKind.MemoryAperture => $"Aperture({Payload})", ScalarValueKind.Operation => $"{Operation}({string.Join(", ", Operands.Select(operand => operand.ToString()))})", ScalarValueKind.Phi => $"Phi#{Id}(block {PhiBlock})", _ => $"{Kind}#{Id}", @@ -232,7 +238,7 @@ public static class ScalarValueEquivalence return false; } - if (left.Kind is ScalarValueKind.Constant or ScalarValueKind.UserData or ScalarValueKind.ResourceTableWord) + if (left.Kind is ScalarValueKind.Constant or ScalarValueKind.UserData or ScalarValueKind.ResourceTableWord or ScalarValueKind.MemoryAperture) { return left.Payload == right.Payload; } diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs index 4b051e2..4db3ae1 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs @@ -297,6 +297,8 @@ public sealed partial class ScalarValueGraph private void Apply(Gen5ShaderInstruction instruction, RegisterState state) { + // Lets ScalarValueGraph.Undefined record which instruction gave up. + _graph.BuilderInstruction = (instruction.Pc, instruction.Opcode); switch (instruction.Encoding) { case Gen5ShaderEncoding.Sop1: @@ -357,6 +359,13 @@ public sealed partial class ScalarValueGraph case "SMovB32": state.WriteScalar(destinationRegister, Read(instruction.Sources[0], state)); return; + case "SBitreplicateB64B32": + { + var replicated = Read(instruction.Sources[0], state); + state.WriteScalar(destinationRegister, replicated); + state.WriteScalar(destinationRegister + 1, replicated); + return; + } case "SMovkI32": state.WriteScalar(destinationRegister, _graph.Constant(unchecked((uint)(short)instruction.Sources[0].Value))); return; @@ -1029,9 +1038,12 @@ public sealed partial class ScalarValueGraph return; } - var hasModifiers = instruction.Control is Gen5Vop3Control { AbsoluteMask: not 0 } or Gen5Vop3Control { NegateMask: not 0 } or - Gen5Vop3Control { Clamp: true } or Gen5Vop3Control { OutputModifier: not 0 } or Gen5Vop3Control { OperandSelect: not 0 } or - Gen5SdwaControl or Gen5DppControl or Gen5Dpp8Control or Gen5Vop3pControl; + var hasModifiers = (instruction.Control is Gen5Vop3Control { AbsoluteMask: not 0 } or Gen5Vop3Control { NegateMask: not 0 } or + Gen5Vop3Control { Clamp: true } or Gen5Vop3Control { OutputModifier: not 0 } or Gen5Vop3Control { OperandSelect: not 0 }) || + (instruction.Control is Gen5SdwaControl sdwa && + (sdwa.AbsoluteMask != 0 || sdwa.NegateMask != 0 || sdwa.OutputModifier != 0 || sdwa.Clamp || + sdwa.DestinationSelect == 7 || sdwa.Source0Select == 7 || sdwa.Source1Select == 7 || sdwa.DestinationUnused == 3)) || + instruction.Control is Gen5DppControl or Gen5Dpp8Control or Gen5Vop3pControl; var value = hasModifiers ? _graph.Undefined(ScalarValueType.U32) : VectorResult(instruction, state); if (instruction.Control is Gen5Vop3Control { ScalarDestination: { } carryDestination } && !hasModifiers && opcode is "VAddCoU32" or "VSubCoU32" or "VSubrevCoU32" or "VAddCoCiU32" or "VSubCoCiU32" or "VSubrevCoCiU32" or "VMadU64U32") @@ -1049,7 +1061,10 @@ public sealed partial class ScalarValueGraph { if (destination.Kind == Gen5OperandKind.VectorRegister) { - state.WriteVector(destination.Value, value); + var result = instruction.Control is Gen5SdwaControl destinationSdwa + ? ApplySdwaDestination(destinationSdwa, value, state.ReadVector(destination.Value)) + : value; + state.WriteVector(destination.Value, result); // Multi-dword results are not modelled; the second dword is undefined. value = _graph.Undefined(ScalarValueType.U32); } @@ -1064,7 +1079,9 @@ public sealed partial class ScalarValueGraph { var opcode = instruction.Opcode; var sources = instruction.Sources; - ScalarValue Source(int index) => index < sources.Count ? ReadVectorOperand(sources[index], state) : _graph.Undefined(ScalarValueType.U32); + ScalarValue Source(int index) => index < sources.Count + ? ApplySdwaSource(instruction.Control as Gen5SdwaControl, index, ReadVectorOperand(sources[index], state)) + : _graph.Undefined(ScalarValueType.U32); ScalarValue Shift(ScalarValue value, ScalarValue count, ScalarOperation operation) => Binary(operation, value, Binary(ScalarOperation.And32, count, _graph.Constant(31u))); ScalarValue Low24(ScalarValue value) => @@ -1105,6 +1122,26 @@ public sealed partial class ScalarValueGraph state.CarryOut = Bool(ScalarOperation.LogicalAnd, state.Exec, NotZero(Binary(ScalarOperation.Or32, Extract(first, 1), Extract(second, 1)))); return Extract(second, 0); } + case "VSubCoCiU32": + case "VSubrevCoCiU32": + { + var left = opcode == "VSubCoCiU32" ? Source(0) : Source(1); + var right = opcode == "VSubCoCiU32" ? Source(1) : Source(0); + var borrowIn = _graph.Select( + instruction.Sources.Count > 2 ? MaskOf(sources[2], state) : state.Vcc, + _graph.Constant(1u), + _graph.Constant(0u)); + var partial = Binary(ScalarOperation.ISub32, left, right); + var result = Binary(ScalarOperation.ISub32, partial, borrowIn); + state.CarryOut = Bool( + ScalarOperation.LogicalAnd, + state.Exec, + Bool( + ScalarOperation.LogicalOr, + Bool(ScalarOperation.UGreaterThan32, right, left), + Bool(ScalarOperation.UGreaterThan32, borrowIn, partial))); + return result; + } case "VMulLoU32": case "VMulLoI32": return Binary(ScalarOperation.IMul32, Source(0), Source(1)); @@ -1165,6 +1202,15 @@ public sealed partial class ScalarValueGraph return Binary(ScalarOperation.UMax32, Source(0), Source(1)); case "VMinI32": return Binary(ScalarOperation.SMin32, Source(0), Source(1)); + case "VMed3U32": + // median(a, b, c) = max(min(a, b), min(max(a, b), c)) + return Binary(ScalarOperation.UMax32, + Binary(ScalarOperation.UMin32, Source(0), Source(1)), + Binary(ScalarOperation.UMin32, Binary(ScalarOperation.UMax32, Source(0), Source(1)), Source(2))); + case "VMed3I32": + return Binary(ScalarOperation.SMax32, + Binary(ScalarOperation.SMin32, Source(0), Source(1)), + Binary(ScalarOperation.SMin32, Binary(ScalarOperation.SMax32, Source(0), Source(1)), Source(2))); case "VMaxI32": return Binary(ScalarOperation.SMax32, Source(0), Source(1)); case "VBfeU32": @@ -1238,6 +1284,68 @@ public sealed partial class ScalarValueGraph } } + private ScalarValue ApplySdwaSource(Gen5SdwaControl? control, int sourceIndex, ScalarValue value) + { + if (control is null || sourceIndex > 1) + { + return value; + } + + var selector = sourceIndex == 0 ? control.Source0Select : control.Source1Select; + if (selector == 6) + { + return value; + } + + if (selector > 5) + { + return _graph.Undefined(ScalarValueType.U32); + } + + var width = selector <= 3 ? 8u : 16u; + var shift = selector <= 3 ? selector * 8u : (selector - 4u) * 16u; + var extracted = Binary(ScalarOperation.And32, + Binary(ScalarOperation.ShiftRightLogical32, value, _graph.Constant(shift)), + _graph.Constant(width == 8 ? 0xFFu : 0xFFFFu)); + + return (sourceIndex == 0 ? control.Source0SignExtend : control.Source1SignExtend) + ? _graph.Operation(ScalarOperation.BitFieldSExtract, ScalarValueType.U32, extracted, + _graph.Constant(0u), _graph.Constant(width)) + : extracted; + } + + private ScalarValue ApplySdwaDestination(Gen5SdwaControl control, ScalarValue value, ScalarValue previous) + { + if (control.DestinationSelect == 6) + { + return value; + } + + if (control.DestinationSelect > 5 || control.DestinationUnused == 3) + { + return _graph.Undefined(ScalarValueType.U32); + } + + var width = control.DestinationSelect <= 3 ? 8u : 16u; + var shift = control.DestinationSelect <= 3 ? control.DestinationSelect * 8u : (control.DestinationSelect - 4u) * 16u; + var lowMask = width == 8 ? 0xFFu : 0xFFFFu; + var fieldMask = lowMask << (int)shift; + var positioned = Binary(ScalarOperation.ShiftLeft32, + Binary(ScalarOperation.And32, value, _graph.Constant(lowMask)), _graph.Constant(shift)); + + return control.DestinationUnused switch + { + 0 => positioned, + 1 => Binary(ScalarOperation.Or32, positioned, + _graph.Select(NotZero(Binary(ScalarOperation.And32, positioned, + _graph.Constant(1u << (int)(shift + width - 1)))), + _graph.Constant(uint.MaxValue << (int)(shift + width)), _graph.Constant(0u))), + 2 => Binary(ScalarOperation.Or32, + Binary(ScalarOperation.And32, previous, _graph.Constant(~fieldMask)), positioned), + _ => _graph.Undefined(ScalarValueType.U32), + }; + } + private void ApplyVectorCompare(Gen5ShaderInstruction instruction, RegisterState state) { var opcode = instruction.Opcode; @@ -1245,6 +1353,27 @@ public sealed partial class ScalarValueGraph var right = instruction.Sources.Count > 1 ? ReadVectorOperand(instruction.Sources[1], state) : _graph.Undefined(ScalarValueType.U32); var updatesExecutionMask = opcode.StartsWith("VCmpx", StringComparison.Ordinal); var suffix = opcode[(updatesExecutionMask ? "VCmpx".Length : "VCmp".Length)..]; + + // Integer 16-bit compares consume the low word of their operands. Signed forms + // sign-extend it before comparison; floating-point F16 compares use their own path. + if (suffix.EndsWith("16", StringComparison.Ordinal) && !suffix.EndsWith("F16", StringComparison.Ordinal)) + { + var signed16 = suffix.EndsWith("I16", StringComparison.Ordinal); + ScalarValue Narrow16(ScalarValue value) + { + var low = Binary(ScalarOperation.And32, value, _graph.Constant(0xffffu)); + return signed16 + ? Binary( + ScalarOperation.ShiftRightArithmetic32, + Binary(ScalarOperation.ShiftLeft32, low, _graph.Constant(16u)), + _graph.Constant(16u)) + : low; + } + + left = Narrow16(left); + right = Narrow16(right); + } + var result = instruction.Control is Gen5SdwaControl or Gen5Vop3Control { AbsoluteMask: not 0 } or Gen5Vop3Control { NegateMask: not 0 } ? _graph.Undefined(ScalarValueType.Bool) : suffix switch @@ -1259,6 +1388,16 @@ public sealed partial class ScalarValueGraph "GtI32" => Bool(ScalarOperation.SGreaterThan32, left, right), "LeI32" => Bool(ScalarOperation.SLessThanEqual32, left, right), "GeI32" => Bool(ScalarOperation.SGreaterThanEqual32, left, right), + "EqU16" or "EqI16" => Bool(ScalarOperation.IEqual32, left, right), + "NeU16" or "NeI16" => Bool(ScalarOperation.INotEqual32, left, right), + "LtU16" => Bool(ScalarOperation.ULessThan32, left, right), + "LeU16" => Bool(ScalarOperation.ULessThanEqual32, left, right), + "GtU16" => Bool(ScalarOperation.UGreaterThan32, left, right), + "GeU16" => Bool(ScalarOperation.UGreaterThanEqual32, left, right), + "LtI16" => Bool(ScalarOperation.SLessThan32, left, right), + "LeI16" => Bool(ScalarOperation.SLessThanEqual32, left, right), + "GtI16" => Bool(ScalarOperation.SGreaterThan32, left, right), + "GeI16" => Bool(ScalarOperation.SGreaterThanEqual32, left, right), "LeF32" => Bool(ScalarOperation.FLessThanEqual, left, right), "GeF32" => Bool(ScalarOperation.FGreaterThanEqual, left, right), "LtF32" => Bool(ScalarOperation.FLessThan, left, right), @@ -1341,9 +1480,10 @@ public sealed partial class ScalarValueGraph private void ApplyMemory(Gen5ShaderInstruction instruction, RegisterState state) { + ScalarValue? vectorRead = null; if (_recording && _graph.Memory.TryGetIndex(instruction.Pc, 0, out var memoryIndex)) { - _graph.Accesses[memoryIndex] = instruction.Control switch + var binding = instruction.Control switch { Gen5BufferMemoryControl buffer => new MemoryAccessBinding( _graph.Handle(ScalarValueKind.BufferHandle, state.Read(buffer.ScalarResource), state.Read(buffer.ScalarResource + 1), @@ -1360,9 +1500,26 @@ public sealed partial class ScalarValueGraph AddressHandleOf(global, state), null, null, - global.UsesFlatAddress ? null : state.ReadVector(global.VectorAddress)), + global.UsesFlatAddress ? null : state.ReadVector(global.VectorAddress), + state.Exec), _ => null, }; + + if (binding is not null && instruction.Control is Gen5BufferMemoryControl bufferControl && + !instruction.Opcode.Contains("Store", StringComparison.Ordinal) && + instruction.Destinations.FirstOrDefault(destination => destination.Kind == Gen5OperandKind.VectorRegister) is { } vectorDestination) + { + var handle = binding.Handle!; + var offset = state.ReadVector(bufferControl.VectorAddress); + // A lane-varying address loads a lane-varying value. + if (!offset.IsUndefined) + { + vectorRead = _graph.MemoryRead(ScalarValueKind.ScalarBufferWord, handle, offset, memoryIndex); + binding = binding with { Read = vectorRead }; + } + } + + _graph.Accesses[memoryIndex] = binding; } foreach (var destination in instruction.Destinations) @@ -1370,7 +1527,7 @@ public sealed partial class ScalarValueGraph if (destination.Kind == Gen5OperandKind.VectorRegister) { state.ClearLanes(destination.Value); - state.WriteVector(destination.Value, _graph.Undefined(ScalarValueType.U32)); + state.WriteVector(destination.Value, vectorRead ?? _graph.Undefined(ScalarValueType.U32)); } else if (destination.Kind == Gen5OperandKind.ScalarRegister) { @@ -1411,6 +1568,13 @@ public sealed partial class ScalarValueGraph return _graph.Select(state.Scc, _graph.Constant(1u), _graph.Constant(0u)); } + // An aperture keeps its identity: a FLAT address built from it + // targets LDS or scratch, not the device-address page table. + if (Gen5InlineConstants.IsAperture(operand.Value)) + { + return _graph.MemoryAperture(operand.Value); + } + return Gen5InlineConstants.TryDecode(operand.Value, out var value) ? _graph.Constant(value) : _graph.Undefined(ScalarValueType.U32); @@ -1429,6 +1593,14 @@ public sealed partial class ScalarValueGraph return (state.Read(operand.Value), state.Read(operand.Value + 1)); } + if (operand.Kind == Gen5OperandKind.EncodedConstant && Gen5InlineConstants.IsAperture(operand.Value)) + { + // A 64-bit aperture read: the offset dword is a constant, the high + // dword carries the aperture. + return (_graph.Constant((uint)Gen5InlineConstants.DecodeAperture64(operand.Value)), + _graph.MemoryAperture(operand.Value)); + } + var low = Read(operand, state); var negative = operand.Kind == Gen5OperandKind.EncodedConstant && operand.Value is >= 193 and <= 208; return (low, _graph.Constant(negative ? uint.MaxValue : 0u)); @@ -1549,7 +1721,11 @@ public sealed partial class ScalarValueGraph ThreadBits.Remove(register - 1); if (register == ExecLow) { - Exec = _graph.Undefined(ScalarValueType.Bool); + // An all-or-nothing constant keeps the mask known, so s_cbranch_execz stays decidable. + Exec = value.IsConstant && Scalars[ExecHigh] is { IsConstant: true, ConstantU32: 0 } && + value.ConstantU32 is 0 or FullWaveMask + ? _graph.Constant(value.ConstantU32 != 0) + : _graph.Undefined(ScalarValueType.Bool); } else if (register == VccLow) { diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs index 0500c84..f934e5b 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs @@ -8,7 +8,10 @@ namespace SharpEmu.ShaderCompiler.Resources; // The graph values one memory access reads its descriptor from: the handles, the read // node of a scalar load, and the uniform vector offset of a global access. -public sealed record MemoryAccessBinding(ScalarValue? Handle, ScalarValue? SamplerHandle, ScalarValue? Read, ScalarValue? Offset = null); +// Active is the EXEC mask the access runs under: a lane outside it performs no access, +// so a lane-masked register write under the same mask contributes only its new value. +public sealed record MemoryAccessBinding(ScalarValue? Handle, ScalarValue? SamplerHandle, ScalarValue? Read, ScalarValue? Offset = null, + ScalarValue? Active = null); // The uniform value graph of one program: every value a descriptor can be assembled // from, symbolic in user data and in the shader base. @@ -61,12 +64,48 @@ public sealed partial class ScalarValueGraph // constants fold, so revisiting a block reproduces its values exactly. internal ScalarValue Constant(uint value) => Intern($"c32:{value}", () => ScalarValue.ConstantOf(value)); + internal ScalarValue MemoryAperture(uint operand) => Intern($"aperture:{operand}", () => ScalarValue.MemoryAperture(operand)); + internal ScalarValue Constant(ulong value) => Intern($"c64:{value}", () => ScalarValue.ConstantOf(value)); internal ScalarValue Constant(bool value) => Intern($"c1:{value}", () => ScalarValue.ConstantOf(value)); - // One undefined node per type keeps every value built over it stable across visits. - internal ScalarValue Undefined(ScalarValueType type) => Intern($"undef:{type}", () => ScalarValue.Undefined(type)); + /// + /// The instruction the builder is translating, for diagnostics only. + /// + /// + /// Undefined nodes are interned per type, so a single node stands for every + /// instruction the builder could not model and carries no origin of its own. + /// A resource plan that fails on an undefined dword therefore names no + /// culprit, and the builder has about twenty places that produce one. This + /// records which instructions actually gave up, so the failure points at an + /// opcode instead of a list of candidates. + /// + internal (uint Pc, string Opcode) BuilderInstruction { get; set; } + + private readonly Dictionary _undefinedOrigins = []; + + /// + /// The instruction an undefined value came from, when origins are tracked. + /// + internal bool TryGetUndefinedOrigin(ScalarValue value, out (uint Pc, string Opcode) origin) => + _undefinedOrigins.TryGetValue(value, out origin); + + // Undefined nodes are interned per instruction. Revisiting a block reaches + // the same instruction, so graph values stay stable while resource planning + // can distinguish a known safe fallback from an unrelated malformed input. + internal ScalarValue Undefined(ScalarValueType type) + { + if (BuilderInstruction.Opcode is null) + { + return Intern($"undef:{type}", () => ScalarValue.Undefined(type)); + } + + var instruction = BuilderInstruction; + var value = Intern($"undef:{type}:{instruction.Pc:X}", () => ScalarValue.Undefined(type)); + _undefinedOrigins.TryAdd(value, instruction); + return value; + } internal ScalarValue UserData(uint register) => Intern($"ud:{register}", () => ScalarValue.UserData(register)); @@ -115,11 +154,25 @@ public sealed partial class ScalarValueGraph internal ScalarValue Operation(ScalarOperation operation, ScalarValueType type, params ScalarValue[] operands) { + // Some ALU identities produce a deterministic value even when the other + // operand has no tracked provenance. This matters for descriptor setup code + // that masks an unused or hardware-defined register before using it. Keep + // these identities ahead of the general undefined propagation below. + if (TryFoldWithUndefined(operation, type, operands, out var undefinedFolded)) + { + return undefinedFolded; + } + foreach (var operand in operands) { if (operand.IsUndefined) { - return Undefined(type); + // Keep the operation shape when one input lacks provenance. The + // runtime validator will still reject the undefined leaf, but + // preserving the node lets later symbolic identities eliminate + // only the bits that are provably independent of that input and + // keeps diagnostics attached to the real instruction graph. + return Intern($"op:{operation}:{type}:{Ids(operands)}", () => ScalarValue.MakeOperation(operation, type, operands)); } } @@ -146,6 +199,53 @@ public sealed partial class ScalarValueGraph return Intern($"op:{operation}:{type}:{Ids(operands)}", () => ScalarValue.MakeOperation(operation, type, operands)); } + private bool TryFoldWithUndefined(ScalarOperation operation, ScalarValueType type, ScalarValue[] operands, out ScalarValue folded) + { + folded = null!; + + if (operands.Length != 2) + { + return false; + } + + if (operation == ScalarOperation.And32 && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.U32 && operand.ConstantU32 == 0)) + { + folded = Constant(0u); + return true; + } + + if (operation == ScalarOperation.And64 && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.U64 && operand.ConstantU64 == 0)) + { + folded = Constant(0ul); + return true; + } + + if (operation == ScalarOperation.Or32 && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.U32 && operand.ConstantU32 == uint.MaxValue)) + { + folded = Constant(uint.MaxValue); + return true; + } + + if (operation == ScalarOperation.LogicalAnd && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.Bool && !operand.ConstantBool)) + { + folded = Constant(false); + return true; + } + + if (operation == ScalarOperation.LogicalOr && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.Bool && operand.ConstantBool)) + { + folded = Constant(true); + return true; + } + + return false; + } + private bool TryFold(ScalarOperation operation, ScalarValueType type, ScalarValue[] operands, out ScalarValue folded) { folded = null!; diff --git a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs index f2413bd..9154e70 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs @@ -141,6 +141,18 @@ public sealed record StageInput(StageInputKind Kind, uint Location, uint Compone public sealed record StageOutput(StageOutputKind Kind, uint Index, uint Location, string DebugName); +// One bounded runtime V# table lowered to a contiguous run of native buffer candidates, +// selected through a key mapping in the flattened table. +public sealed class BufferCandidateTableInfo +{ + public uint FirstCandidate { get; set; } + public uint CandidateCount { get; set; } + public uint MappingOffset { get; set; } + public uint SearchIterations { get; set; } + + public BufferCandidateTableInfo Clone() => (BufferCandidateTableInfo)MemberwiseClone(); +} + // The dense resource tables of a program plus the facts the pipeline layout needs. public sealed class ShaderResourceInfo { @@ -154,6 +166,7 @@ public sealed class ShaderResourceInfo public List Images { get; set; } = []; public List Samplers { get; set; } = []; public List SampledPairs { get; set; } = []; + public List BufferCandidateTables { get; set; } = []; public List Inputs { get; set; } = []; public List Outputs { get; set; } = []; public byte[] VertexFetchComponents { get; set; } = new byte[32]; @@ -168,6 +181,7 @@ public sealed class ShaderResourceInfo Images = Images.Select(image => image.Clone()).ToList(), Samplers = Samplers.Select(sampler => sampler.Clone()).ToList(), SampledPairs = SampledPairs.Select(pair => pair.Clone()).ToList(), + BufferCandidateTables = BufferCandidateTables.Select(table => table.Clone()).ToList(), Inputs = [.. Inputs], Outputs = [.. Outputs], VertexFetchComponents = (byte[])VertexFetchComponents.Clone(), @@ -192,12 +206,20 @@ public sealed record IndirectImageSelector( public uint TableOffset { get; init; } public uint DynamicOffsetBase { get; init; } public uint KeyBound { get; init; } + public WaveIndexedImageSelector? WaveIndexed { get; init; } + // The key read's immediate offset. The hardware adds it after the 32-bit selector offset, without wrapping. public uint MaterialImmediate { get; init; } } public sealed record DirectImageCandidate(uint Offset, uint Source); +// A wave-uniform descriptor selector. The guest derives each descriptor key from +// a set bit in one scalar mask, through a compact global index table. Keeping this +// shape explicit lets the host materialize only those keys, rather than treating a +// lane value as an unknowable descriptor address. +public sealed record WaveIndexedImageSelector(uint MaskOffset, uint IndexTableOffset, uint IndexStride); + // The graph values one descriptor is assembled from, up to eight dwords. public sealed class DescriptorSource { diff --git a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs index 8a349a1..8089357 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs @@ -38,6 +38,7 @@ public sealed class ShaderResourcePlan public IReadOnlyList DynamicReads { get; private set; } = []; public IReadOnlyList CleanFlatSlots { get; private set; } = []; public IReadOnlyList IndirectImages { get; private set; } = []; + public IReadOnlyList BufferCandidateTables { get; private set; } = []; public bool RequiresSpecializationMemory { get; private set; } public ShaderResourceInfo Info { get; private set; } = new(); @@ -77,7 +78,8 @@ public sealed class ShaderResourcePlan access.Handle is null ? null : Rewrite(access.Handle), access.SamplerHandle is null ? null : Rewrite(access.SamplerHandle), access.Read is null ? null : (reads.Replacements.ContainsKey(access.Read) ? RewriteRead(access.Read) : Rewrite(access.Read)), - access.Offset is null ? null : Rewrite(access.Offset)); + access.Offset is null ? null : Rewrite(access.Offset), + access.Active is null ? null : Rewrite(access.Active)); } // Diagnostics observe rewritten values before descriptor validation. @@ -86,6 +88,7 @@ public sealed class ShaderResourcePlan plan.DescriptorSources = tracked.Sources; plan.Info = tracked.Info; plan.IndirectImages = tracked.IndirectImages; + plan.BufferCandidateTables = tracked.BufferCandidateTables; plan.DynamicReads = plan.DynamicReads.Where(read => !tracked.IndirectReads.Contains(read)).ToList(); var materialization = new List(); @@ -129,6 +132,8 @@ public sealed class ShaderResourcePlan else if (indirect.Dense) { plan.MarkCleanFlatSlots(plan.DescriptorSources[(int)indirect.HeapSource], cleanSlots); + if (indirect.WaveIndexed is not null) + plan.MarkHeapReadSlots(plan.DescriptorSources[(int)indirect.HeapSource], cleanSlots); } else { @@ -168,6 +173,21 @@ public sealed class ShaderResourcePlan // The flattened table the host fills per draw: table reads, then the written ranges. public int FlattenedTableReservedCount => TableReads.Count + WrittenRangeCount * WrittenRangeDwordCount; + // A wave-indexed table picks its keys from a mask word read off the same heap. + private void MarkHeapReadSlots(DescriptorSource heap, byte[] slots) + { + for (var slot = 0; slot < TableReads.Count && slot < slots.Length; slot++) + { + var value = TableReads[slot].Value; + if (value.Kind == ScalarValueKind.ScalarAddressWord && value.Operands.Length != 0 && + value.Operands[0].Kind == ScalarValueKind.AddressHandle && + value.Operands[0].Operands.SequenceEqual(heap.Dwords)) + { + slots[slot] = 1; + } + } + } + // Every flattened slot an indirect table's descriptor depends on must be read // through the clean reader, including the slots those reads depend on. private void MarkCleanFlatSlots(DescriptorSource source, byte[] slots) @@ -201,6 +221,13 @@ public sealed class ShaderResourcePlan } } - public bool ValidateRuntimeValue(ScalarValue value) => - new RuntimeValueValidator(Graph, UserDataBase, UserDataCount, TableReads.Count).Validate(value); + public bool ValidateRuntimeValue(ScalarValue value) => ValidateRuntimeValue(value, out _); + + public bool ValidateRuntimeValue(ScalarValue value, out bool controlDependent) + { + var validator = new RuntimeValueValidator(Graph, UserDataBase, UserDataCount, TableReads.Count); + var ok = validator.Validate(value); + controlDependent = validator.ControlDependent; + return ok; + } } diff --git a/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs b/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs index bb80cc6..b4ccb8c 100644 --- a/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs +++ b/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs @@ -54,6 +54,14 @@ public readonly record struct ShaderClipSpaceTransform( float HalfExtentX, float HalfExtentY); +// One bounded runtime V# table as the emitter sees it: a contiguous run of native buffer +// candidates plus the flattened key mapping that selects among them. +public readonly record struct BufferCandidateTableUse( + uint FirstCandidate, + uint CandidateCount, + uint MappingOffset, + uint SearchIterations); + // Everything an emitter needs to compile one permutation of a program: the decoded // program, its resource plan applied to one specialization, and the binding layout. public sealed class ShaderCompileRequest @@ -97,11 +105,26 @@ public sealed class ShaderCompileRequest } WrittenRangeSlotByMemoryIndex = writtenSlots; + + var candidateTables = new Dictionary(); + for (var index = 0; index < plan.BufferCandidateTables.Count && index < resources.Info.BufferCandidateTables.Count; index++) + { + var info = resources.Info.BufferCandidateTables[index]; + var use = new BufferCandidateTableUse(info.FirstCandidate, info.CandidateCount, info.MappingOffset, info.SearchIterations); + foreach (var memoryIndex in plan.BufferCandidateTables[index].MemoryIndices) + { + candidateTables[memoryIndex] = use; + } + } + + BufferCandidateTableByMemoryIndex = candidateTables; } - // The flattened table is bound when host reads, written ranges or indirect mappings fill it. + // The flattened table is bound when host reads, written ranges, indirect mappings or + // bounded buffer candidate mappings fill it. public static bool RequiresFlattenedTable(ShaderResourcePlan plan, SpecializedResourceInfo resources) => plan.TableReads.Count != 0 || plan.WrittenRangeCount != 0 || + plan.BufferCandidateTables.Count != 0 || resources.Info.Images.Any(image => image.IndirectSearchIterations != 0); public Gen5ShaderProgram Program { get; } @@ -127,12 +150,21 @@ public sealed class ShaderCompileRequest // Indirect image accesses: memory index → the memory index of the key read. public IReadOnlyDictionary IndirectRootByMemoryIndex { get; } + // Bounded runtime V# accesses: memory index → the candidate run and its key mapping. + public IReadOnlyDictionary BufferCandidateTableByMemoryIndex { get; } + // Written device-address accesses: memory index → the flattened slot of their range. public IReadOnlyDictionary WrittenRangeSlotByMemoryIndex { get; } public uint WaveSize { get; init; } = 32; public uint ScratchDwords { get; init; } public bool EnableGraphicsSubgroupOperations { get; init; } = true; + + // The device supports 64-bit integer atomics on workgroup memory + // (VkPhysicalDeviceFeatures.shaderSharedInt64Atomics). When set, the LDS + // 64-bit atomics are emitted as real 64-bit atomics instead of a pair of + // 32-bit ones, which is not atomic as a pair. + public bool SupportsSharedInt64Atomics { get; init; } public Gen5ComputeSystemRegisters? ComputeSystemRegisters { get; init; } public IReadOnlyList PixelOutputs { get; init; } = []; diff --git a/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs b/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs index 1c316bc..0d80a55 100644 --- a/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs @@ -14,7 +14,7 @@ public sealed class Gen5ShaderDecoderBoundaryTests private const uint Export = 0xF8000000; private const uint Nop = 0xBF800000; private const uint EndPgm = 0xBF810000; - private const int MaximumInstructionCount = 16384; + private const int MaximumInstructionCount = 1024 * 1024 / sizeof(uint); [Fact] public void MissingAddress_IsRejectedWithoutReadingGuestMemory() @@ -89,7 +89,7 @@ public sealed class Gen5ShaderDecoderBoundaryTests Assert.False(decoded); Assert.Empty(program.Instructions); - Assert.Equal("unterminated", error); + Assert.StartsWith("unterminated", error, StringComparison.Ordinal); Assert.Equal(MaximumInstructionCount, memory.Reads.Count); Assert.All(memory.Reads, read => Assert.True(read.Succeeded)); Assert.Equal( @@ -117,6 +117,43 @@ public sealed class Gen5ShaderDecoderBoundaryTests Assert.Equal(words.Length, memory.Reads.Count); } + [Fact] + public void TrailingBackwardBranch_StopsBeforeMetadataTrailer() + { + // Astro Bot: an out-of-line loop block after S_ENDPGM ends with an + // S_BRANCH back into the loop, followed by the "sl00" metadata block. + const uint CBranchScc0OverEndPgm = 0xBF840001u; + const uint BranchBackToStart = 0xBF82FFFCu; + const uint MetadataWord = 0x5D000040u; + var memory = RecordingCpuMemory.FromWords( + ShaderAddress, + [CBranchScc0OverEndPgm, EndPgm, Nop, BranchBackToStart, MetadataWord]); + + var decoded = Decode(memory, ShaderAddress, out var program, out var error); + + Assert.True(decoded, error); + Assert.Equal(4, program.Instructions.Count); + Assert.Equal("SBranch", program.Instructions[^1].Opcode); + } + + [Fact] + public void DebuggerBranchPastEndPgm_DoesNotDecodeDebuggerStub() + { + // Astro Bot: S_CBRANCH_CDBGSYS jumps to a debugger stub after + // S_ENDPGM that loads descriptors from a devkit-only address. + const uint CBranchCdbgsysOverEndPgm = 0xBF970001u; + const uint StubMove = 0xBE8003FFu; + var memory = RecordingCpuMemory.FromWords( + ShaderAddress, + [CBranchCdbgsysOverEndPgm, EndPgm, StubMove]); + + var decoded = Decode(memory, ShaderAddress, out var program, out var error); + + Assert.True(decoded, error); + Assert.Equal(2, program.Instructions.Count); + Assert.Equal("SEndpgm", program.Instructions[^1].Opcode); + } + private static bool Decode( RecordingCpuMemory memory, ulong address, diff --git a/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs b/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs index 8f3e656..ca5b3a7 100644 --- a/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs @@ -89,6 +89,49 @@ public class AmprFileRegistryTests Assert.Equal(firstHost, resolved); } + [Fact] + public void AprResolve_UniquePath_ReturnsGuestPathHash() + { + AmprFileRegistry.ClearForTests(); + const string guestPath = "/app0/levels/m08_tutorial/a.cmsh"; + var host = Path.Combine(Path.GetTempPath(), "apr-unique.bin"); + + var id = AmprFileRegistry.RegisterAprResolvedPath(guestPath, host); + + Assert.Equal(AmprFileRegistry.ComputeFileId(guestPath), id); + Assert.Equal(id, AmprFileRegistry.RegisterAprResolvedPath(guestPath, host)); + Assert.True(AmprFileRegistry.TryGetHostPath(id, out var result)); + Assert.Equal(host, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AprResolve_CollidingPaths_ReadTheirOwnFiles(bool indexFirst) + { + AmprFileRegistry.ClearForTests(); + const string firstPath = "$/assets/bce5a816.bin"; + const string secondPath = "$/assets/1b9e7058.bin"; + var firstHost = Path.Combine(Path.GetTempPath(), "apr-first.bin"); + var secondHost = Path.Combine(Path.GetTempPath(), "apr-second.bin"); + if (indexFirst) + { + AmprFileRegistry.RegisterApp0RelativeForTests("assets/bce5a816.bin", firstHost); + AmprFileRegistry.RegisterApp0RelativeForTests("assets/1b9e7058.bin", secondHost); + } + + var firstId = AmprFileRegistry.RegisterAprResolvedPath(firstPath, firstHost); + var secondId = AmprFileRegistry.RegisterAprResolvedPath(secondPath, secondHost); + // A later alias publish of the partner must not re-poison either id. + AmprFileRegistry.RegisterApp0RelativeForTests("assets/1b9e7058.bin", secondHost); + + Assert.NotEqual(firstId, secondId); + Assert.True(AmprFileRegistry.TryGetHostPath(firstId, out var firstResult)); + Assert.True(AmprFileRegistry.TryGetHostPath(secondId, out var secondResult)); + Assert.Equal(firstHost, firstResult); + Assert.Equal(secondHost, secondResult); + } + [Fact] public void ComputeFileId_matches_utf8_fnv1a() { diff --git a/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs b/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs index 9b73ef2..e750a5e 100644 --- a/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs @@ -15,6 +15,7 @@ namespace SharpEmu.Libs.Tests.Fiber; /// validation and layout behaviour of ; they do not /// exercise a live guest thread scheduler. /// +[Collection(FiberStateCollection.Name)] public sealed class FiberExportsTests { private const ulong Base = 0x3_0000_0000UL; diff --git a/tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs b/tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs new file mode 100644 index 0000000..510eb2b --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs @@ -0,0 +1,18 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Xunit; + +namespace SharpEmu.Libs.Tests.Fiber; + +/// +/// FiberExports keeps its continuations, stack ranges and thread states in +/// static maps, and the tests reset them between cases. xUnit runs separate +/// classes in parallel, so every fiber test class has to share one collection +/// or a reset in one class wipes the state another is midway through using. +/// +[CollectionDefinition(Name)] +public sealed class FiberStateCollection +{ + public const string Name = "fiber-runtime-state"; +} diff --git a/tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs b/tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs new file mode 100644 index 0000000..605357d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs @@ -0,0 +1,313 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; + +using SharpEmu.HLE; +using SharpEmu.Libs.Fiber; + +using Xunit; + +namespace SharpEmu.Libs.Tests.Fiber; + +/// +/// Continuation contract for a job-system style fiber loop: two fibers that +/// hand control back and forth many times from the same resume point. Each +/// resume has to restore exactly the state its own suspension captured, so a +/// stale or cross-fiber restore shows up here instead of as a null dereference +/// inside guest code several switches later. +/// +[Collection(FiberStateCollection.Name)] +public sealed class FiberSwitchLoopTests : IDisposable +{ + private const ulong Base = 0x3_0000_0000UL; + private const int RegionSize = 0x8000; + + private const ulong FiberA = Base + 0x0000; + private const ulong FiberB = Base + 0x0100; + private const ulong NameA = Base + 0x0300; + private const ulong NameB = Base + 0x0340; + private const ulong ArgSlotRoot = Base + 0x0380; + private const ulong ArgSlotA = Base + 0x0388; + private const ulong ArgSlotB = Base + 0x0390; + private const ulong ContextA = Base + 0x1000; + private const ulong ContextB = Base + 0x3000; + private const ulong ContextSize = 0x1000; + + private const ulong EntryA = 0x4_0000_1000UL; + private const ulong EntryB = 0x4_0000_2000UL; + private const ulong ArgOnInitializeA = 0xA11AUL; + private const ulong ArgOnInitializeB = 0xB22BUL; + + // The guest side of a job worker calls sceFiberSwitch from one place, so + // every suspension of a given fiber reports the same return address and the + // same resume stack. Only the payload registers differ between rounds. + private const ulong ResumeRipA = 0x8_0084_1FD1UL; + private const ulong ResumeRipB = 0x8_0084_1FD1UL; + private const ulong ResumeRspA = 0x2_0858_FF10UL; + private const ulong ResumeRspB = 0x2_085A_F3A0UL; + private const ulong ReturnSlotA = ResumeRspA - 0x10UL; + private const ulong ReturnSlotB = ResumeRspB - 0x10UL; + + private const ulong RootReturnRip = 0x8_0084_0000UL; + private const ulong RootResumeRsp = 0x2_0850_0000UL; + private const ulong RootReturnSlot = RootResumeRsp - 0x10UL; + + private const ulong ThreadHandle = 0x2E45_16AD_090UL; + + private const uint StateRun = 1; + private const uint StateIdle = 2; + + private readonly IGuestThreadScheduler? _previousScheduler; + private readonly ulong _previousThread; + private readonly ulong _previousFiber; + + public FiberSwitchLoopTests() + { + FiberExports.ResetRuntimeState(); + _previousScheduler = GuestThreadExecution.Scheduler; + GuestThreadExecution.Scheduler = new ContextTransferScheduler(); + _previousThread = GuestThreadExecution.EnterGuestThread(ThreadHandle); + _previousFiber = GuestThreadExecution.EnterFiber(0); + } + + public void Dispose() + { + GuestThreadExecution.EnterFiber(_previousFiber); + GuestThreadExecution.RestoreGuestThread(_previousThread); + GuestThreadExecution.Scheduler = _previousScheduler; + FiberExports.ResetRuntimeState(); + } + + [Fact] + public void RepeatedSwitch_RestoresEachFiberOwnResumePoint() + { + var memory = new FakeCpuMemory(Base, RegionSize); + var context = new CpuContext(memory, Generation.Gen5); + + InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); + InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); + + // The thread enters the fiber world through sceFiberRun. + GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); + context[CpuRegister.Rdi] = FiberA; + context[CpuRegister.Rsi] = 0x1000UL; + context[CpuRegister.Rdx] = ArgSlotRoot; + Assert.Equal(0, FiberExports.FiberRun(context)); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toA)); + Assert.Equal(EntryA, toA.Rip); + Assert.Equal(ArgOnInitializeA, toA.Rdi); + Assert.Equal(0x1000UL, toA.Rsi); + Assert.Equal(0UL, toA.Rax); + Assert.Equal(FiberA, GuestThreadExecution.CurrentFiberAddress); + Assert.Equal(StateRun, ReadUInt32(memory, FiberA + 4)); + + // Round 0 suspends A for the first time and starts B at its entry point. + var sentinelsA = Sentinels(0xA0); + Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, + ArgSlotA, argOnRun: 0xB000UL, sentinelsA); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toB)); + Assert.Equal(EntryB, toB.Rip); + Assert.Equal(ArgOnInitializeB, toB.Rdi); + Assert.Equal(0xB000UL, toB.Rsi); + + var expectedA = new FiberExpectation(ResumeRipA, ResumeRspA, ReturnSlotA, sentinelsA); + FiberExpectation? expectedB = null; + + // Eight full round trips over the same pair of resume points. + for (var round = 1; round <= 8; round++) + { + var sentinelsB = Sentinels((ulong)round << 32 | 0xB0); + var argToA = 0xA000UL + (ulong)round; + Switch(memory, context, from: FiberB, to: FiberA, ResumeRipB, ResumeRspB, ReturnSlotB, + ArgSlotB, argToA, sentinelsB); + expectedB = new FiberExpectation(ResumeRipB, ResumeRspB, ReturnSlotB, sentinelsB); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var resumedA)); + AssertResume(resumedA, expectedA, $"round {round}: fiber A"); + Assert.Equal(argToA, ReadUInt64(memory, ArgSlotA)); + Assert.Equal(StateRun, ReadUInt32(memory, FiberA + 4)); + Assert.Equal(StateIdle, ReadUInt32(memory, FiberB + 4)); + + var nextSentinelsA = Sentinels((ulong)round << 32 | 0xA0); + var argToB = 0xB000UL + (ulong)round; + Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, + ArgSlotA, argToB, nextSentinelsA); + expectedA = new FiberExpectation(ResumeRipA, ResumeRspA, ReturnSlotA, nextSentinelsA); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var resumedB)); + AssertResume(resumedB, expectedB, $"round {round}: fiber B"); + Assert.Equal(argToB, ReadUInt64(memory, ArgSlotB)); + Assert.Equal(StateRun, ReadUInt32(memory, FiberB + 4)); + Assert.Equal(StateIdle, ReadUInt32(memory, FiberA + 4)); + } + } + + [Fact] + public void RepeatedSwitch_KeepsResumeStackInsideTheOwningFiberContext() + { + var memory = new FakeCpuMemory(Base, RegionSize); + var context = new CpuContext(memory, Generation.Gen5); + + InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); + InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); + + GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); + context[CpuRegister.Rdi] = FiberA; + context[CpuRegister.Rsi] = 0; + context[CpuRegister.Rdx] = ArgSlotRoot; + Assert.Equal(0, FiberExports.FiberRun(context)); + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toA)); + + // A fresh fiber must start on its own context block, not on the stack of + // the thread that ran it. + Assert.InRange(toA.Rsp, ContextA, ContextA + ContextSize); + Assert.InRange(toA.ReturnSlotAddress, ContextA, ContextA + ContextSize); + + Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, + ArgSlotA, argOnRun: 0, Sentinels(0xA0)); + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toB)); + + Assert.InRange(toB.Rsp, ContextB, ContextB + ContextSize); + Assert.InRange(toB.ReturnSlotAddress, ContextB, ContextB + ContextSize); + Assert.NotEqual(toA.Rsp, toB.Rsp); + } + + private static void Switch( + FakeCpuMemory memory, + CpuContext context, + ulong from, + ulong to, + ulong resumeRip, + ulong resumeRsp, + ulong returnSlot, + ulong argOnReturnAddress, + ulong argOnRun, + IReadOnlyDictionary sentinels) + { + Assert.Equal(from, GuestThreadExecution.CurrentFiberAddress); + + // The guest reaches sceFiberSwitch through the import trampoline, which + // is what publishes the resume point for the calling fiber. + GuestThreadExecution.EnterImportCallFrame(resumeRip, resumeRsp, returnSlot); + foreach (var (register, value) in sentinels) + { + context[register] = value; + } + + context[CpuRegister.Rdi] = to; + context[CpuRegister.Rsi] = argOnRun; + context[CpuRegister.Rdx] = argOnReturnAddress; + + Assert.Equal(0, FiberExports.FiberSwitch(context)); + Assert.Equal(to, GuestThreadExecution.CurrentFiberAddress); + _ = memory; + } + + private static void AssertResume(GuestCpuContinuation actual, FiberExpectation? expected, string because) + { + Assert.NotNull(expected); + Assert.Equal(expected.Rip, actual.Rip); + Assert.Equal(expected.Rsp, actual.Rsp); + Assert.Equal(expected.ReturnSlotAddress, actual.ReturnSlotAddress); + // sceFiberSwitch returns SCE_OK to the fiber it resumes. + Assert.Equal(0UL, actual.Rax); + + foreach (var (register, value) in expected.Sentinels) + { + var restored = register switch + { + CpuRegister.Rbx => actual.Rbx, + CpuRegister.Rbp => actual.Rbp, + CpuRegister.R12 => actual.R12, + CpuRegister.R13 => actual.R13, + CpuRegister.R14 => actual.R14, + CpuRegister.R15 => actual.R15, + _ => throw new InvalidOperationException($"Unexpected sentinel register {register}."), + }; + + Assert.True(value == restored, $"{because}: {register} was 0x{restored:X16}, expected 0x{value:X16}."); + } + } + + private static IReadOnlyDictionary Sentinels(ulong seed) => + new Dictionary + { + [CpuRegister.Rbx] = 0x1111_0000_0000_0000UL | seed, + [CpuRegister.Rbp] = 0x2222_0000_0000_0000UL | seed, + [CpuRegister.R12] = 0x3333_0000_0000_0000UL | seed, + [CpuRegister.R13] = 0x4444_0000_0000_0000UL | seed, + [CpuRegister.R14] = 0x5555_0000_0000_0000UL | seed, + [CpuRegister.R15] = 0x6666_0000_0000_0000UL | seed, + }; + + private static void InitializeFiber( + FakeCpuMemory memory, + CpuContext context, + ulong fiber, + ulong nameAddress, + ulong entry, + ulong argOnInitialize, + ulong contextAddress) + { + memory.WriteCString(nameAddress, $"F{fiber:X}"); + context[CpuRegister.Rdi] = fiber; + context[CpuRegister.Rsi] = nameAddress; + context[CpuRegister.Rdx] = entry; + context[CpuRegister.Rcx] = argOnInitialize; + context[CpuRegister.R8] = contextAddress; + context[CpuRegister.R9] = ContextSize; + Assert.Equal(0, FiberExports.FiberInitialize(context)); + } + + private static uint ReadUInt32(FakeCpuMemory memory, ulong address) + { + Span buffer = stackalloc byte[sizeof(uint)]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); + } + + private static ulong ReadUInt64(FakeCpuMemory memory, ulong address) + { + Span buffer = stackalloc byte[sizeof(ulong)]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt64LittleEndian(buffer); + } + + private sealed record FiberExpectation( + ulong Rip, + ulong Rsp, + ulong ReturnSlotAddress, + IReadOnlyDictionary Sentinels); + + private sealed class ContextTransferScheduler : IGuestThreadScheduler + { + public bool SupportsGuestContextTransfer => true; + + public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context) { } + public bool TryStartThread(CpuContext creatorContext, GuestThreadStartRequest request, out string? error) => throw new NotSupportedException(); + public bool TryJoinThread(CpuContext callerContext, ulong threadHandle, out ulong returnValue, out string? error) => throw new NotSupportedException(); + public void Pump(CpuContext callerContext, string reason) { } + public int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue) => 0; + public bool HasPendingGuestExceptionForCurrentThread() => false; + public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) => throw new NotSupportedException(); + public bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask) => throw new NotSupportedException(); + public IReadOnlyList SnapshotThreads() => Array.Empty(); + public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, + ulong stackAddress, ulong stackSize, string reason, out string? error) => throw new NotSupportedException(); + public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, + ulong arg2, ulong stackAddress, ulong stackSize, string reason, out ulong returnValue, + out string? error) => throw new NotSupportedException(); + public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, + ulong arg2, ulong arg3, ulong stackAddress, ulong stackSize, string reason, out ulong returnValue, + out string? error) => throw new NotSupportedException(); + public bool TryCallGuestContinuation(CpuContext callerContext, GuestCpuContinuation continuation, string reason, + out string? error) => throw new NotSupportedException(); + public bool TryRaiseGuestException(CpuContext callerContext, ulong threadHandle, ulong handler, int exceptionType, + out string? error) => throw new NotSupportedException(); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs index fdfe0ec..fdc234a 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs @@ -322,7 +322,10 @@ public sealed class GpuCommandInterpreterLabelTests runner.Run(StreamRunner.CustomPacket(Nop, 0, 0x6875_0781, StreamRunner.Low(Label), StreamRunner.High(Label), 0x9A, 0x04, 0x38)); Assert.Equal(new[] { "prepare_flip 7 2 1 68", "eop FlipWithInterruptWriteBack32", "flush" }, runner.Host.Calls); Assert.Contains("flip event type is unknown", runner.RunExpectingFatal(StreamRunner.CustomPacket(Nop, 0, 0x6875_0781, 0, 0, 0, 0x28, 0x38)).Message); - Assert.Contains("marker is unknown", runner.RunExpectingFatal(StreamRunner.CustomPacket(Nop, 0, 0x6875_0001)).Message); + // Marker ids with no side effect (Demon's Souls emits 0x6EC and 0x80C) are consumed, not fatal. + runner.Host.Calls.Clear(); + runner.Run(StreamRunner.CustomPacket(Nop, 0, 0x6875_06EC), StreamRunner.CustomPacket(Nop, 0, 0x6875_080C)); + Assert.Empty(runner.Host.Calls); } [Fact] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs index dfa092f..4e540e3 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs @@ -14,6 +14,7 @@ public sealed class GuestPixelFormatsTests { Assert.Equal(4u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits8_8_8_8UNorm)); Assert.Equal(8u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits16_16_16_16Float)); + Assert.Equal(2u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits8_8UScaled)); Assert.Equal(0u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits8SNorm)); Assert.Equal(0u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Invalid)); Assert.Equal(0u, GuestPixelFormats.BytesPerElement((GuestPixelFormat)200)); @@ -35,10 +36,13 @@ public sealed class GuestPixelFormatsTests Assert.Equal(TextureNumericClass.Uint, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8_8_8UInt)); Assert.Equal(TextureNumericClass.Sint, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8_8_8SInt)); Assert.Equal(TextureNumericClass.Float, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8_8_8UNorm)); + Assert.Equal(TextureNumericClass.Float, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8UScaled)); Assert.Equal(TextureNumericClass.Unsupported, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8SNorm)); Assert.Equal(GuestPixelFormat.Bits32UInt, GuestPixelFormats.RemapTextureFormat(GuestPixelFormat.Bits11_11_10UInt)); Assert.Equal(GuestPixelFormat.Bc7Srgb, GuestPixelFormats.RemapTextureFormat(GuestPixelFormat.Bc7Srgb)); Assert.Equal(Format.R8G8B8A8Unorm, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits8_8_8_8UNorm)); + Assert.Equal(Format.R8G8Uscaled, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits8_8UScaled)); + Assert.Equal(Format.B10G11R11UfloatPack32, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits11_11_10UNorm)); Assert.Equal(Format.R8G8B8A8Srgb, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits8_8_8_8Srgb)); Assert.Equal(Format.BC1RgbaUnormBlock, GuestPixelFormats.HostFormat(GuestPixelFormat.Bc1UNorm)); Assert.Equal(Format.Undefined, GuestPixelFormats.HostFormat(GuestPixelFormat.Invalid)); diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs index 4678cf4..ac84d0c 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs @@ -24,6 +24,7 @@ public sealed class ImageDescriptionTests description.Resources = new SubresourceCount(levels, layers); description.Pitch = width; description.BytesPerBlock = 4; + description.MipLayout[0] = new MipLevelLayout { Size = size, Pitch = width, Height = height }; return description; } @@ -70,10 +71,24 @@ public sealed class ImageDescriptionTests volume.Type = GuestImageType.Color3D; volume.Extent = new Extent3D(32, 32, 8); volume.MipLayout[0] = new MipLevelLayout { Offset = 0, Size = 32768, Pitch = 32, Height = 32 }; - Assert.Equal(0, Color(Base, 32768, 32, 32, layers: 8).FindMatchingMipLevel(volume)); + Assert.Equal(-1, Color(Base, 32768, 32, 32, layers: 8).FindMatchingMipLevel(volume)); Assert.Equal(-1, Color(Base, 32768, 32, 32, layers: 4).FindMatchingMipLevel(volume)); } + [Fact] + public void FindMatchingArraySlice_UsesTheFullMipChainStrideForEachArrayLayer() + { + const ulong sliceStride = 21504; + var container = Color(Base, 2 * sliceStride, 64, 64, levels: 3, layers: 2); + container.MipLayout[0] = new MipLevelLayout { Offset = 0, Size = 2 * 16384, Pitch = 64, Height = 64 }; + container.MipLayout[1] = new MipLevelLayout { Offset = 16384, Size = 2 * 4096, Pitch = 32, Height = 32 }; + container.MipLayout[2] = new MipLevelLayout { Offset = 20480, Size = 2 * 1024, Pitch = 16, Height = 16 }; + + var secondLayerMip = Color(Base + sliceStride + 16384, 4096, 32, 32); + Assert.Equal(1, secondLayerMip.FindMatchingMipLevel(container)); + Assert.Equal(1, secondLayerMip.FindMatchingArraySlice(container, 1)); + } + [Fact] public void DepthRules_ResolveAttachmentAndTransferFormats() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs index f00fefc..31b1a2e 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs @@ -126,6 +126,25 @@ public sealed class ImageRequestBuildersTests : IClassFixture } [Fact] - public void GetSampler_CachesByTheFourWords() + public void GetSampler_CachesByTheFourWordsAndTheViewClass() { if (!GatePrerequisites.Ready(_vulkan, samplerAnisotropy: true)) return; using var fatal = new FatalScope(); using var store = new SamplerStore(_vulkan.DeviceInfo); var linear = Words(0, 0, (1u << 22) | (1u << 20)); - var first = store.GetSampler(linear); + var first = store.GetSampler(linear, false); Assert.NotEqual(0UL, first.Handle); - Assert.Equal(first, store.GetSampler(linear)); + Assert.Equal(first, store.GetSampler(linear, false)); Assert.Equal(1, store.Count); var anisotropic = Words(2u << 9, 0, (3u << 22) | (3u << 20) | (2u << 26)); - Assert.NotEqual(first, store.GetSampler(anisotropic)); + Assert.NotEqual(first, store.GetSampler(anisotropic, false)); var unnormalized = Words(1u << 15, 0, (1u << 22) | (1u << 20) | (2u << 26)); - Assert.NotEqual(first, store.GetSampler(unnormalized)); + Assert.NotEqual(first, store.GetSampler(unnormalized, false)); var tableBorder = Words(4, 0, 0, (3u << 30) | 5); - Assert.NotEqual(0UL, store.GetSampler(tableBorder).Handle); + Assert.NotEqual(0UL, store.GetSampler(tableBorder, false).Handle); Assert.Equal(4, store.Count); - Assert.Throws(() => store.GetSampler(Words(5u << 9, 0, (3u << 22) | (3u << 20)))); + // An integer view gets its own sampler: integer border, no filtering. + Assert.NotEqual(first, store.GetSampler(linear, true)); + Assert.Equal(5, store.Count); + + Assert.Throws(() => store.GetSampler(Words(5u << 9, 0, (3u << 22) | (3u << 20)), false)); Assert.Contains(fatal.Messages, message => message.Contains("anisotropy ratio is unknown")); - Assert.Equal(4, store.Count); + Assert.Equal(5, store.Count); _vulkan.AssertNoValidationMessages(); } } diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs index b409f47..0feefa2 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs @@ -44,6 +44,8 @@ internal sealed class FakePipelineHost(ICpuMemory memory) : IShaderPipelineHost public bool GraphicsSubgroupOperationsEnabled => true; + public bool SharedInt64AtomicsEnabled => false; + public RenderHostLimits Limits => new(16384, 16384, 16384, 16384); public bool TryResolveColorOutput(uint dataFormat, uint numberType, uint componentSwap, out Gen5PixelOutputKind outputKind, out Gen5ColorComponentMapping componentMapping) diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs index 08021c8..3b9262c 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs @@ -165,12 +165,14 @@ public sealed class RenderExecutorDrawTests : IDisposable } [Theory] - [InlineData(0u)] - [InlineData(1u)] - public void PrimitiveShaderVertexPath_WithDefaultGeometryStateDraws(uint subgroupControl) + [InlineData(0x02002000u, 0u)] + [InlineData(0x02002000u, 1u)] + [InlineData(0x00002000u, 0u)] + [InlineData(0x00002000u, 1u)] + public void PrimitiveShaderVertexPath_WithDefaultGeometryStateDraws(uint stageMask, uint subgroupControl) { var banks = Banks(); - banks.Context.ShaderStages = 0x02002000; + banks.Context.ShaderStages = stageMask; banks.Shader.Vertex.GeometryAddress = 0x3000; banks.Context.ShaderInterface.PrimitiveShaderSubgroupControl = subgroupControl; banks.Context.ShaderInterface.MaxOutputPerSubgroup = 0x40; @@ -179,6 +181,21 @@ public sealed class RenderExecutorDrawTests : IDisposable Assert.Contains(_host.Calls, c => c.StartsWith("draw_indexed", StringComparison.Ordinal)); } + [Fact] + public void PrimitiveShaderVertexPath_WithFixedNggStateAndNoGeometryShaderDraws() + { + var banks = Banks(); + banks.Context.ShaderStages = 0x00002030; + banks.Context.ShaderInterface.PrimitiveShaderSubgroupControl = 1; + banks.Context.ShaderInterface.MaxOutputPerSubgroup = 0xC0; + banks.Context.ShaderInterface.GeometryMaxVerticesOut = 3; + banks.Context.ShaderInterface.GeometryOutputPrimitiveType = 2; + + _executor.DrawIndexed(1, banks, Indexed(3)); + + Assert.Contains(_host.Calls, c => c.StartsWith("draw_indexed", StringComparison.Ordinal)); + } + [Theory] [InlineData(1u, PrimitiveTopology.PointList)] [InlineData(2u, PrimitiveTopology.LineList)] diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs index da3478a..b92d680 100644 --- a/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs @@ -297,6 +297,22 @@ public sealed class KernelBackedMemoryTests Assert.Equal((second, second + 0x10000), test.Query(second)); } + [Fact] + public void LargeVirtualReservationFallsBackToSparseGuestRange() + { + using var test = new BackedKernelMemory(); + const ulong size = 0x8000000000; + test.Host.FailReserveHoleWhen = (_, requestedSize) => requestedSize >= size; + + var address = test.Reserve(size); + Assert.NotEqual(0UL, address); + Assert.Equal((address, address + size), test.Query(address)); + + test.Allocate(0, 0x4000); + Assert.Equal(address, test.Map(0, 0x4000, address)); + Assert.Equal((address, address + 0x4000), test.Query(address)); + } + [Fact] public void AddressSearchUsesTheFullReservationExtent() { diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs new file mode 100644 index 0000000..9a18d84 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs @@ -0,0 +1,58 @@ +// 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 KernelOperationModeTests +{ + private const ulong BaseAddress = 0x1_0000; + private const ulong ModeAddress = BaseAddress + 0x10; + private const ulong SubmodeAddress = BaseAddress + 0x20; + + private static uint Read(FakeCpuMemory memory, ulong address) + { + Span bytes = stackalloc byte[sizeof(uint)]; + Assert.True(memory.TryRead(address, bytes)); + return BinaryPrimitives.ReadUInt32LittleEndian(bytes); + } + + [Fact] + public void ReportsThePs5BaseConsoleWithNoSubmode() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + ctx[CpuRegister.Rdi] = ModeAddress; + ctx[CpuRegister.Rsi] = SubmodeAddress; + + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelExports.KernelGetOperationMode(ctx)); + Assert.Equal(2u, Read(memory, ModeAddress)); + Assert.Equal(0u, Read(memory, SubmodeAddress)); + } + + [Fact] + public void NullOutputPointersAreSkipped() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + ctx[CpuRegister.Rdi] = 0; + ctx[CpuRegister.Rsi] = 0; + + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelExports.KernelGetOperationMode(ctx)); + } + + [Fact] + public void UnmappedOutputPointerFaults() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + ctx[CpuRegister.Rdi] = 0xDEAD_0000; + ctx[CpuRegister.Rsi] = SubmodeAddress; + + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, KernelExports.KernelGetOperationMode(ctx)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs b/tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs new file mode 100644 index 0000000..a281d2f --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Loader; + +using Xunit; + +namespace SharpEmu.Libs.Tests.Loader; + +/// +/// The forward span search anchors at the faulting instruction, so it gives up +/// whenever the next instruction is a branch target, touches RSP, or changes +/// control flow. These are the three shapes that actually occur around the +/// faulting sites of a red-zone function in a shipped title; the enclosing +/// search has to take its bytes from before the faulting instruction instead, +/// and must never let the RSP shift cover an RSP-relative access. +/// +public sealed class GuestRedZoneEnclosingSpanTests +{ + private const ulong Base = 0x8_0000_0000UL; + + [Fact] + public void TakesBytesFromBeforeWhenTheNextInstructionIsABranchTarget() + { + // jne +8 -> makes the instruction after the site a target + // shr r11, 12 -> 4 bytes, no memory operand + // and r10d,[rcx+8] -> 4 bytes, faultable: the site + // mov rax,[rcx] -> branch target, must stay untouched + byte[] code = + [ + 0x75, 0x08, + 0x49, 0xC1, 0xEB, 0x0C, + 0x44, 0x23, 0x51, 0x08, + 0x48, 0x8B, 0x01, + ]; + + Assert.True(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 6, out var address, out var length, out var coreStart, out var coreCount)); + + Assert.Equal(Base + 2, address); + Assert.Equal(8, length); + // The span ends exactly at the branch target, which is never rewritten. + Assert.Equal(Base + 10, address + (ulong)length); + Assert.Equal(1, coreStart); + Assert.Equal(1, coreCount); + } + + [Fact] + public void KeepsAnRspRelativeInstructionOutsideTheShiftedCore() + { + // jne +8 + // mov rax,[rsp-0x10] -> reads the red zone; must run before the shift + // mov [rax+0x18],ebp -> 3 bytes, faultable: the site + // pop rbx -> branch target and stack dependent + byte[] code = + [ + 0x75, 0x08, + 0x48, 0x8B, 0x44, 0x24, 0xF0, + 0x89, 0x68, 0x18, + 0x5B, + ]; + + Assert.True(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 7, out var address, out var length, out var coreStart, out var coreCount)); + + Assert.Equal(Base + 2, address); + Assert.Equal(8, length); + // coreStart == 1 is what keeps the shift off the [rsp-0x10] load: shifting + // it would move the access 128 bytes and silently read the wrong slot. + Assert.Equal(1, coreStart); + Assert.Equal(1, coreCount); + } + + [Fact] + public void BracketsEveryFaultableInstructionOfTheSpan() + { + // mov eax,[rcx+8] -> faultable, absorbed by the span + // test [rcx+8],eax -> faultable: the site + // je +2 -> control flow, cannot be absorbed + byte[] code = + [ + 0x8B, 0x41, 0x08, + 0x85, 0x41, 0x08, + 0x74, 0x02, + ]; + + Assert.True(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 3, out var address, out var length, out var coreStart, out var coreCount)); + + Assert.Equal(Base, address); + Assert.Equal(6, length); + // A stolen instruction that can fault too has to sit inside the shift, + // otherwise it recreates the very corruption this pass prevents. + Assert.Equal(0, coreStart); + Assert.Equal(2, coreCount); + } + + [Fact] + public void RefusesWhenTheFaultingInstructionIsItselfABranchTarget() + { + // jne +6 lands on the faulting instruction, so a jump placed before it + // would be entered in the middle. + byte[] code = + [ + 0x75, 0x04, + 0x49, 0xC1, 0xEB, 0x0C, + 0x44, 0x23, 0x51, 0x08, + 0x48, 0x8B, 0x01, + ]; + + Assert.False(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 6, out _, out _, out _, out _)); + } + + [Fact] + public void RefusesWhenControlFlowSitsBetweenTheStartAndTheSite() + { + // A conditional branch cannot be relocated as part of a span, and the + // bytes before the site are not otherwise sufficient. + byte[] code = + [ + 0x74, 0x02, + 0x44, 0x23, 0x51, 0x08, + 0x48, 0x8B, 0x01, + ]; + + Assert.False(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 2, out _, out _, out _, out _)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs index 7e921c1..1bc9ea7 100644 --- a/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs +++ b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs @@ -46,6 +46,19 @@ public sealed class HostMovieBridgeTests : IDisposable Assert.True(HostMovieBridge.TryReadBinkInfo(path, out _)); } + [Theory] + // Demon's Souls history_of_the_world.bk2 (171 s) and credits (756 s). + [InlineData(5_125u, 30_000u, 1_001u, 231)] + [InlineData(22_685u, 30u, 1u, 816)] + [InlineData(255u, 30u, 1u, 90)] + public void PlaybackWatchdogOutlastsTheWholeMovie(uint frames, uint numerator, uint denominator, int expectedSeconds) + { + var timeout = HostMovieBridge.GetPlaybackWatchdogTimeout( + new HostMovieBridge.Bink2MovieInfo(1920, 1080, frames, numerator, denominator)); + + Assert.Equal(expectedSeconds, (int)timeout.TotalSeconds); + } + [Fact] public void HeaderRejectsMissingFrameRateDenominator() { diff --git a/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs index 21b181c..294c8f0 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs @@ -153,6 +153,41 @@ public sealed class GuestPageTrackerTests : IDisposable Release(address, Page * 2); } + // The lock-free block summary must agree with the precise page masks through every + // CPU-dirty transition: upload, explicit mark, write-fault invalidation and GPU writes. + [NativePageProtectionFact] + public void CpuDirtySummaryFollowsEveryTransitionOfTheBlock() + { + var address = AllocateAligned(Region, Region); + Assert.True(_tracker.HasCpuDirtyPages(address, Region)); + + _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); + Assert.False(_tracker.HasCpuDirtyPages(address, Region)); + Assert.False(_tracker.HasCpuDirtyPages(address + Region / 2, Page)); + + _tracker.MarkCpuDirtyPages(address + Page * 3, 8); + Assert.True(_tracker.HasCpuDirtyPages(address, Region)); + Assert.True(_tracker.HasCpuDirtyPages(address + Page * 3, Page)); + // A clean page inside a block that has a dirty page takes the precise path. + Assert.False(_tracker.HasCpuDirtyPages(address + Page * 9, Page)); + + _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); + Assert.False(_tracker.HasCpuDirtyPages(address, Region)); + + Assert.True(_tracker.InvalidateRegion(address + Page * 5, 4, () => { })); + Assert.True(_tracker.HasCpuDirtyPages(address + Page * 5, Page)); + + _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); + _tracker.ForEachUploadRange(address + Page, Page, true, NoRange, NoUpload); + Assert.False(_tracker.HasCpuDirtyPages(address, Region)); + Assert.True(_tracker.HasGpuDirtyPages(address + Page, Page)); + _tracker.ForEachDownloadRange(address + Page, Page, clear: true, null, NoRange); + + _tracker.UntrackMemory(address, Region); + Assert.True(_tracker.HasCpuDirtyPages(address, Region)); + Release(address, Region); + } + [NativePageProtectionFact] public void RangeInvalidationBatchesOwnershipTransferAcrossRegions() { diff --git a/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs b/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs index 834a4f3..16923dc 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs @@ -41,6 +41,8 @@ internal sealed class FailingHostViews : IHostViewMemory public Action? BeforeReserveHole { get; set; } + public Func? FailReserveHoleWhen { get; set; } + public Action? BeforeChangeAccess { get; set; } public ulong PageSize => _inner.PageSize; @@ -69,6 +71,8 @@ internal sealed class FailingHostViews : IHostViewMemory public ulong ReserveHole(ulong address, ulong size) { BeforeReserveHole?.Invoke(address, size); + if (FailReserveHoleWhen?.Invoke(address, size) == true) + return 0; return ShouldFail(Op.ReserveHole) ? 0 : _inner.ReserveHole(address, size); } diff --git a/tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs b/tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs new file mode 100644 index 0000000..7cf2556 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Np; +using Xunit; + +namespace SharpEmu.Libs.Tests.Np; + +public sealed class NpEntitlementAccessExportsTests +{ + private const ulong MemoryBase = 0x1_0000_0000; + + [Theory] + [InlineData("GHOST2APP0000000")] + [InlineData("GHOST2BASE000000")] + public void GhostOfYoteiMainEntitlement_IsReportedAsInstalled(string label) + { + var memory = new FakeCpuMemory(MemoryBase, 0x1_000); + var context = new CpuContext(memory, Generation.Gen5); + var labelAddress = MemoryBase + 0x100; + var infoAddress = MemoryBase + 0x200; + memory.WriteCString(labelAddress, label); + context[CpuRegister.Rsi] = labelAddress; + context[CpuRegister.Rdx] = infoAddress; + + Assert.Equal(0, NpEntitlementAccessExports.NpEntitlementAccessGetAddcontEntitlementInfo(context)); + + Span info = stackalloc byte[0x1C]; + Assert.True(memory.TryRead(infoAddress, info)); + Assert.Equal(label, ReadCString(info[..17])); + Assert.Equal(3U, BinaryPrimitives.ReadUInt32LittleEndian(info[20..])); + Assert.Equal(4U, BinaryPrimitives.ReadUInt32LittleEndian(info[24..])); + } + + [Fact] + public void GhostOfYoteiOptionalEntitlement_RemainsUnowned() + { + var memory = new FakeCpuMemory(MemoryBase, 0x1_000); + var context = new CpuContext(memory, Generation.Gen5); + var labelAddress = MemoryBase + 0x100; + var infoAddress = MemoryBase + 0x200; + memory.WriteCString(labelAddress, "GHOST2DDE0000000"); + context[CpuRegister.Rsi] = labelAddress; + context[CpuRegister.Rdx] = infoAddress; + + Assert.Equal( + unchecked((int)0x817D0007), + NpEntitlementAccessExports.NpEntitlementAccessGetAddcontEntitlementInfo(context)); + } + + private static string ReadCString(ReadOnlySpan value) + { + var length = value.IndexOf((byte)0); + return System.Text.Encoding.ASCII.GetString(value[..(length < 0 ? value.Length : length)]); + } +} diff --git a/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs b/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs index 5c1b218..0ab0652 100644 --- a/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs +++ b/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs @@ -12,6 +12,106 @@ public sealed class Gen5ShaderTranslatorTests { private const ulong ProgramAddress = 0x1_0000_0000; + [Fact] + public void Vop2OpcodeZeroDecodesAsNoop() + { + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, 0x00000000u, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Vop2, instruction.Encoding); + Assert.Equal("VNop", instruction.Opcode); + Assert.Equal(0u, instruction.Pc); + } + + [Fact] + public void Vop2FmamkF16ConsumesLiteralDword() + { + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, 0x6F104E10u, 0x3C003C00u, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Vop2, instruction.Encoding); + Assert.Equal("VFmaMkF16", instruction.Opcode); + Assert.Equal(2, instruction.Words.Count); + Assert.Equal(3, instruction.Sources.Count); + Assert.Equal(Gen5OperandKind.LiteralConstant, instruction.Sources[1].Kind); + Assert.Equal(0x3C003C00u, instruction.Sources[1].Value); + } + + [Fact] + public void Sop2ScalarFloatSubtractDecodes() + { + const uint instructionWord = + (2u << 30) | (0x41u << 23) | (3u << 16) | (2u << 8) | 1u; + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, instructionWord, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Sop2, instruction.Encoding); + Assert.Equal("SSubF32", instruction.Opcode); + Assert.Equal(3u, instruction.Destinations[0].Value); + } + + [Fact] + public void ScalarScratchStoreDwordx2DecodesAsStore() + { + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, 0xC59B2040u, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Smrd, instruction.Encoding); + Assert.Equal("ScratchStoreDwordx2", instruction.Opcode); + Assert.Empty(instruction.Destinations); + Assert.Equal(4, instruction.Sources.Count); + Assert.Equal(32u, instruction.Sources[0].Value); + Assert.Equal(54u, instruction.Sources[1].Value); + Assert.Equal(55u, instruction.Sources[2].Value); + Assert.Equal(64u, instruction.Sources[3].Value); + var control = Assert.IsType(instruction.Control); + Assert.Equal(2u, control.DwordCount); + Assert.Equal(32u, control.ScalarAddress); + Assert.Equal(54u, control.SourceVectorRegister); + Assert.Equal(64u, control.DynamicOffsetRegister); + Assert.True(control.SourceIsScalar); + } + [Theory] [InlineData(0xD7600005u, 5u)] [InlineData(0xD7600065u, 101u)] diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs new file mode 100644 index 0000000..aa88fbf --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs @@ -0,0 +1,81 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu; +using SharpEmu.Libs.VideoOut; +using Xunit; + +namespace SharpEmu.Libs.Tests.VideoOut; + +public sealed class VulkanStorageFeedbackTargetResolverTests +{ + private const ulong TargetAddress = 0x1044_500000; + + [Fact] + public void ZeroWriteSingleTargetAliasUsesCompatibilityAttachment() + { + var target = Target(); + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [target], [Texture(TargetAddress, true)], [Blend(0)], + out var resolved, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.True(supported); + Assert.True(usedCompatibilityAttachment); + Assert.Equal(0UL, unsupportedAddress); + Assert.Equal(0UL, resolved[0].Address); + Assert.Equal(target.Width, resolved[0].Width); + Assert.Equal(target.Height, resolved[0].Height); + Assert.Equal(target.Format, resolved[0].Format); + Assert.Equal(target.NumberType, resolved[0].NumberType); + } + + [Fact] + public void WritableColorAliasRemainsUnsupported() + { + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [Target()], [Texture(TargetAddress, true)], [Blend(0xF)], + out var resolved, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.False(supported); + Assert.False(usedCompatibilityAttachment); + Assert.Equal(TargetAddress, unsupportedAddress); + Assert.Equal(TargetAddress, resolved[0].Address); + } + + [Fact] + public void NonStorageAliasDoesNotNeedCompatibilityAttachment() + { + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [Target()], [Texture(TargetAddress, false)], [Blend(0xF)], + out var resolved, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.True(supported); + Assert.False(usedCompatibilityAttachment); + Assert.Equal(0UL, unsupportedAddress); + Assert.Equal(TargetAddress, resolved[0].Address); + } + + [Fact] + public void AliasedMrtRemainsUnsupported() + { + const ulong otherAddress = 0x1042_F00000; + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [Target(), Target(otherAddress)], + [Texture(TargetAddress, true)], [Blend(0), Blend(0)], + out _, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.False(supported); + Assert.False(usedCompatibilityAttachment); + Assert.Equal(TargetAddress, unsupportedAddress); + } + + private static GuestRenderTarget Target(ulong address = TargetAddress) => + new(address, 2048, 2048, Format: 10, NumberType: 6); + + private static GuestDrawTexture Texture(ulong address, bool isStorage) => + new(address, 2048, 2048, Format: 10, NumberType: 0, RgbaPixels: [], + IsFallback: false, IsStorage: isStorage); + + private static GuestBlendState Blend(uint writeMask) => + GuestBlendState.Default with { WriteMask = writeMask }; +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs index faf10c7..7d4fb4a 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs @@ -50,6 +50,55 @@ public sealed class Gen5Float16ArithmeticTests Assert.DoesNotContain((ushort)SpirvCapability.Float16, ReadCapabilities(shader.Spirv)); } + [Theory] + [InlineData(0x54u, "VRcpF16")] + [InlineData(0x55u, "VSqrtF16")] + [InlineData(0x57u, "VLogF16")] + [InlineData(0x58u, "VExpF16")] + [InlineData(0x5Bu, "VFloorF16")] + [InlineData(0x5Cu, "VCeilF16")] + [InlineData(0x5Du, "VTruncF16")] + [InlineData(0x5Eu, "VRndneF16")] + [InlineData(0x5Fu, "VFractF16")] + [InlineData(0x60u, "VSinF16")] + [InlineData(0x61u, "VCosF16")] + public void Float16UnaryDecodesAndCompilesInBothEncodings(uint opcode, string name) + { + // VOP1 v0, v1 (Silent Hill f: v_log_f16), then the VOP3 form v2, v3. + var program = Decode( + [ + 0x7E000000u | (opcode << 9) | 257u, + (0x35u << 26) | ((0x180u + opcode) << 16) | 2u, + 259u, + SEndpgm, + ]); + + Assert.Equal([name, name, "SEndpgm"], program.Instructions.Select(instruction => instruction.Opcode)); + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.DoesNotContain((ushort)SpirvCapability.Float16, ReadCapabilities(shader.Spirv)); + } + + [Theory] + [InlineData(0x351u, "VMin3F16")] + [InlineData(0x354u, "VMax3F16")] + [InlineData(0x357u, "VMed3F16")] + public void Vop3Float16ThreeOperandMinMaxDecodesAndCompiles(uint opcode, string name) + { + // Silent Hill's pixel shaders use v_min3_f16. + var program = Decode( + [ + (0x35u << 26) | (opcode << 16) | 122u, + 261u | (262u << 9) | (263u << 18), + SEndpgm, + ]); + + Assert.Equal(name, program.Instructions[0].Opcode); + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.DoesNotContain((ushort)SpirvCapability.Float16, ReadCapabilities(shader.Spirv)); + } + [Fact] public void Vop3Float16FmaDecodesAndCompiles() { diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs index b19ff7a..eee1963 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs @@ -144,6 +144,15 @@ public sealed class Gen5ImageTests Assert.DoesNotContain(instructions, item => item.Opcode == SpirvOp.ImageSampleExplicitLod); } + [Fact] + public void ImageGatherCompareLzUsesNativeDepthGather() + { + var instructions = ReadSpirvInstructions( + CompileImageOperation("ImageGather4CLz", dimension: 1, unifiedFormat: 22u)); + + Assert.Contains(instructions, item => item.Opcode == SpirvOp.ImageDrefGather); + } + [Theory] [InlineData(0xFACu, 0xFu, 4, 5, 6, 7)] [InlineData(0x9F5u, 0xFu, 7, 4, 5, 6)] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs new file mode 100644 index 0000000..8d2fbbc --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs @@ -0,0 +1,97 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler.Tests.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +public sealed class Gen5Int16AluTests +{ + private const ulong ShaderAddress = 0x1_0000_0000; + private const uint SEndpgm = 0xBF810000; + + public static TheoryData Opcodes = new() + { + { 0x303, "VAddNcU16" }, + { 0x304, "VSubNcU16" }, + { 0x307, "VLshrrevB16" }, + { 0x308, "VAshrrevI16" }, + { 0x309, "VMaxU16" }, + { 0x30A, "VMaxI16" }, + { 0x30B, "VMinU16" }, + { 0x30C, "VMinI16" }, + { 0x30D, "VAddNcI16" }, + { 0x30E, "VSubNcI16" }, + { 0x314, "VLshlrevB16" }, + }; + + [Theory] + [MemberData(nameof(Opcodes))] + public void SixteenBitIntegerOpcodeDecodesAndCompiles(uint opcode, string expectedName) + { + var program = Decode(Vop3(opcode)); + Assert.Equal([expectedName, "SEndpgm"], program.Instructions.Select(instruction => instruction.Opcode)); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(ResourceTestProgram.Request(program, userDataCount: 0), out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + } + + private static uint[] Vop3(uint opcode) => [(0x35u << 26) | (opcode << 16) | 3u, (0x102u << 9) | 0x101u, SEndpgm]; + + private static Gen5ShaderProgram Decode(IReadOnlyList words) + { + var memory = new TestCpuMemory(ShaderAddress, words.Count * sizeof(uint)); + var bytes = new byte[words.Count * sizeof(uint)]; + for (var index = 0; index < words.Count; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(ShaderAddress, bytes)); + var context = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, ShaderAddress, out var program, out var error), error); + return program; + } + + private sealed class TestCpuMemory(ulong baseAddress, int size) : ICpuMemory + { + private readonly byte[] _storage = new byte[size]; + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + private bool TryResolve(ulong address, int length, out int offset) + { + offset = 0; + if (address < baseAddress || address - baseAddress > int.MaxValue) + { + return false; + } + + offset = (int)(address - baseAddress); + return offset <= _storage.Length - length; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs index 3dce05a..02c51be 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs @@ -51,6 +51,54 @@ public sealed class Gen5VopcF16Tests { 0xFF, "VCmpxTruF16" }, }; + [Theory] + [InlineData(0xA9u, "VCmpLtU16")] + [InlineData(0xAAu, "VCmpEqU16")] + [InlineData(0xABu, "VCmpLeU16")] + [InlineData(0xACu, "VCmpGtU16")] + [InlineData(0xADu, "VCmpNeU16")] + [InlineData(0xAEu, "VCmpGeU16")] + [InlineData(0xB9u, "VCmpxLtU16")] + [InlineData(0xBAu, "VCmpxEqU16")] + [InlineData(0xBBu, "VCmpxLeU16")] + [InlineData(0xBCu, "VCmpxGtU16")] + [InlineData(0xBDu, "VCmpxNeU16")] + [InlineData(0xBEu, "VCmpxGeU16")] + public void U16CompareOpcodeDecodesAndLowersToSpirv(uint opcode, string expectedName) + { + var memory = new TestCpuMemory(ShaderAddress, 0x100); + Span shader = stackalloc byte[2 * sizeof(uint)]; + var word = (0x3Eu << 25) | (opcode << 17) | (1u << 9); + BinaryPrimitives.WriteUInt32LittleEndian(shader, word); + BinaryPrimitives.WriteUInt32LittleEndian(shader[sizeof(uint)..], SEndpgm); + Assert.True(memory.TryWrite(ShaderAddress, shader)); + + var ctx = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(ctx, ShaderAddress, out var program, out var error), error); + var instruction = Assert.Single(program.Instructions, candidate => candidate.Encoding == Gen5ShaderEncoding.Vopc); + Assert.Equal(expectedName, instruction.Opcode); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(ResourceTestProgram.Request(program, userDataCount: 0), out var lowered, out error), error); + Assert.NotEmpty(lowered.Spirv); + } + + [Theory] + [InlineData(0x89u, "VCmpLtI16")] + [InlineData(0x8Au, "VCmpEqI16")] + [InlineData(0x8Bu, "VCmpLeI16")] + [InlineData(0x8Cu, "VCmpGtI16")] + [InlineData(0x8Du, "VCmpNeI16")] + [InlineData(0x8Eu, "VCmpGeI16")] + [InlineData(0x99u, "VCmpxLtI16")] + [InlineData(0x9Au, "VCmpxEqI16")] + [InlineData(0x9Bu, "VCmpxLeI16")] + [InlineData(0x9Cu, "VCmpxGtI16")] + [InlineData(0x9Du, "VCmpxNeI16")] + [InlineData(0x9Eu, "VCmpxGeI16")] + public void I16CompareOpcodeDecodesAndLowersToSpirv(uint opcode, string expectedName) + { + U16CompareOpcodeDecodesAndLowersToSpirv(opcode, expectedName); + } + [Theory] [MemberData(nameof(Opcodes))] public void F16CompareOpcodeDecodes(uint opcode, string expectedName) diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs new file mode 100644 index 0000000..800dec4 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs @@ -0,0 +1,221 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// Planning of bounded runtime V# candidate tables: a canonical unsigned induction loop +// that reads its descriptor from a static SRT must yield a table; every unproven or +// out-of-range shape must be rejected and stay on the old lowering. +public sealed class BufferCandidateTablePlannerTests +{ + // preheader: s0..s3 = SRT V#, s10 = 0; branch to header + // header: s12 = s10 * stride; dwordx4 read; formatted load; s10 += 1; guard loop + private static Gen5ShaderProgram CandidateProgram( + uint stride = 16, + uint records = 4, + string guard = "SCmpLtU32", + uint guardRegister = 10, + uint limit = 4, + bool runtimeLimit = false) + { + var instructions = new List(); + uint pc = 0; + void Add(Gen5ShaderInstruction instruction) + { + instructions.Add(instruction); + pc += (uint)instruction.Words.Count * sizeof(uint); + } + + Add(MoveScalar(pc, 0, 0x3000)); + Add(MoveScalar(pc, 1, (stride & 0x3FFF) << 16)); + Add(MoveScalar(pc, 2, records)); + Add(MoveScalar(pc, 3, 0)); + Add(MoveScalar(pc, 10, 0)); + Add(Branch(pc, "SBranch", 0)); + var headerPc = pc; + + Add(Sop2(pc, "SMulI32", 12, Gen5Operand.Scalar(10), Operand(stride))); + Add(ScalarBufferLoad(pc, 0, destination: 16, count: 4, dynamicOffsetRegister: 12)); + Add(BufferLoad(pc, 16, formatted: true)); + Add(Sop2(pc, "SAddU32", 10, Gen5Operand.Scalar(10), Operand(1))); + var bound = runtimeLimit ? Gen5Operand.Scalar(7) : Operand(limit); + Add(Sopc(pc, guard, Gen5Operand.Scalar(guardRegister), bound)); + var branchPc = pc; + var target = (int)headerPc; + Add(Branch(branchPc, "SCbranchScc1", (short)((target - (int)(branchPc + 4)) / 4))); + Add(EndProgram(pc)); + return Program([.. instructions]); + } + + private static MemoryAccessInfo BufferAccessOf(ShaderResourcePlan plan) + { + foreach (var access in plan.Memory.Entries) + { + if (access.Opcode.StartsWith("BufferLoadFormat", System.StringComparison.Ordinal)) + { + return access; + } + } + + throw new Xunit.Sdk.XunitException("the program has no formatted buffer load"); + } + + [Fact] + public void CanonicalUnsignedLoop_ProducesCandidateTable() + { + var plan = Extract(CandidateProgram()); + + var table = Assert.Single(plan.BufferCandidateTables); + Assert.True(table.IsStaticallyBounded); + Assert.Equal(4, table.Count); + Assert.Equal(16u, table.Stride); + Assert.Equal(0u, table.MinOffset); + Assert.Equal(64u, table.MaxOffset); + Assert.NotEqual(DescriptorConstants.NoIndex, table.SourceSrtResource); + Assert.Equal(64u, table.SrtByteExtent); + Assert.Equal(BufferDescriptorProvenance.Runtime, BufferAccessOf(plan).BufferDescriptor!.Provenance); + Assert.False(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void RuntimeLimit_ProducesCandidateTableWithoutStaticCount() + { + var plan = Extract(CandidateProgram(runtimeLimit: true)); + + var table = Assert.Single(plan.BufferCandidateTables); + Assert.False(table.IsStaticallyBounded); + Assert.Equal(-1, table.Count); + Assert.NotNull(table.Limit); + } + + [Fact] + public void PhiWithoutProvableGuard_IsRejected() + { + var plan = Extract(CandidateProgram(guardRegister: 7)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void SignedGuard_IsRejected() + { + var plan = Extract(CandidateProgram(guard: "SCmpLtI32")); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void StrideSmallerThanDescriptor_IsRejected() + { + var plan = Extract(CandidateProgram(stride: 8)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void RangeExceedingSrtExtent_IsRejected() + { + var plan = Extract(CandidateProgram(records: 2)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void CandidateCountBeyondTheCap_IsRejected() + { + var plan = Extract(CandidateProgram(records: 0x100, limit: 0x100)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + private static TestWordMemory CandidateMemory() + { + var memory = new TestWordMemory { Base = 0x3000, Words = new uint[0x2000 / 4], RequireAlignment = true }; + for (var index = 0; index < 4; index++) + { + var address = 0x3000 + (ulong)index * 16; + memory.At(address + 0) = 0x4000u + (uint)index * 0x100; + memory.At(address + 4) = 16u << 16; + memory.At(address + 8) = 4; + memory.At(address + 12) = 0; + } + + return memory; + } + + [Fact] + public void CanonicalTable_MaterializesCandidatesTransactionally() + { + var plan = Extract(CandidateProgram()); + var memory = CandidateMemory(); + var inputs = Inputs([], readCleanMemory: memory.Read); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + + var table = Assert.Single(specialization.BufferCandidateTables); + Assert.Equal(4u, table.CandidateCount); + Assert.Equal(plan.Info.Buffers.Count + 4, specialization.Buffers.Count); + Assert.Equal(4u, snapshot.FlattenedResourceTable[(int)table.MappingOffset]); + + var applied = ResourceMaterializer.ApplyTo(plan, specialization); + Assert.Equal(plan.Info.Buffers.Count + 4, applied.Info.Buffers.Count); + var info = Assert.Single(applied.Info.BufferCandidateTables); + Assert.Equal(4u, info.CandidateCount); + Assert.Equal((uint)plan.Info.Buffers.Count, info.FirstCandidate); + + // One unreadable candidate must leave the previous snapshot and specialization intact. + var priorSnapshot = snapshot; + var priorSpecialization = specialization; + memory.FailAddress = 0x3004; + Assert.False(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + Assert.Same(priorSnapshot, snapshot); + Assert.Same(priorSpecialization, specialization); + } + + [Fact] + public void IdenticalCandidatesShareOneBindingAndProbeKey() + { + var plan = Extract(CandidateProgram()); + var memory = CandidateMemory(); + for (var index = 1; index < 4; index++) + { + var address = 0x3000 + (ulong)index * 16; + memory.At(address + 0) = memory.At(0x3000); + } + + var inputs = Inputs([], readCleanMemory: memory.Read); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + + var table = Assert.Single(specialization.BufferCandidateTables); + Assert.Equal(1u, table.CandidateCount); + Assert.Equal(1u, snapshot.FlattenedResourceTable[(int)table.MappingOffset]); + } + + [Fact] + public void DistinctDescriptorsSharingAProbeKey_AreRejected() + { + var plan = Extract(CandidateProgram()); + var memory = CandidateMemory(); + memory.At(0x3010) = memory.At(0x3000); + memory.At(0x3018) = 8; + + var inputs = Inputs([], readCleanMemory: memory.Read); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.False(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs new file mode 100644 index 0000000..887db2c --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs @@ -0,0 +1,91 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// Lowering of a bounded runtime V# table: the emitted module must bind one native candidate +// per descriptor and dispatch the normal buffer operation through a flattened key mapping. +public sealed class BufferCandidateTableSpirvTests +{ + private static Gen5ShaderProgram CandidateProgram(uint guardRegister = 10) + { + var instructions = new List(); + uint pc = 0; + void Add(Gen5ShaderInstruction instruction) + { + instructions.Add(instruction); + pc += (uint)instruction.Words.Count * sizeof(uint); + } + + Add(MoveScalar(pc, 0, 0x3000)); + Add(MoveScalar(pc, 1, 16u << 16)); + Add(MoveScalar(pc, 2, 4)); + Add(MoveScalar(pc, 3, 0)); + Add(MoveScalar(pc, 10, 0)); + Add(Branch(pc, "SBranch", 0)); + var headerPc = pc; + + Add(Sop2(pc, "SMulI32", 12, Gen5Operand.Scalar(10), Operand(16))); + Add(ScalarBufferLoad(pc, 0, destination: 16, count: 4, dynamicOffsetRegister: 12)); + Add(BufferLoad(pc, 16, formatted: true)); + Add(Sop2(pc, "SAddU32", 10, Gen5Operand.Scalar(10), Operand(1))); + Add(Sopc(pc, "SCmpLtU32", Gen5Operand.Scalar(guardRegister), Operand(4))); + var branchPc = pc; + Add(Branch(branchPc, "SCbranchScc1", (short)(((int)headerPc - (int)(branchPc + 4)) / 4))); + Add(EndProgram(pc)); + return Program([.. instructions]); + } + + private static TestWordMemory CandidateMemory() + { + var memory = new TestWordMemory { Base = 0x3000, Words = new uint[0x2000 / 4], RequireAlignment = true }; + for (var index = 0; index < 4; index++) + { + var address = 0x3000 + (ulong)index * 16; + memory.At(address + 0) = 0x4000u + (uint)index * 0x100; + memory.At(address + 4) = 16u << 16; + memory.At(address + 8) = 4; + memory.At(address + 12) = 77u << 12; + } + + return memory; + } + + private static ShaderCompileRequest CompileRequest(ShaderResourcePlan plan, SpecializedResourceInfo resources) + { + var layout = BindingLayout.Allocate( + resources.Info, + BindingLayout.CollectUserDataRegisters(plan.Graph.Program, plan.UserDataBase, plan.UserDataCount), + BindingLayout.UsesGlobalDataShare(plan.Graph.Program), + ShaderCompileRequest.RequiresFlattenedTable(plan, resources), + BindingLayout.ReadsShaderBase(plan.Graph.Program)); + return new ShaderCompileRequest(plan, resources, layout); + } + + [Fact] + public void BoundedCandidateTable_CompilesToValidSpirv() + { + var plan = ShaderResourcePlan.Extract(CandidateProgram(), ShaderStage.Compute, Hash, 0, 64); + Assert.Single(plan.BufferCandidateTables); + + var memory = CandidateMemory(); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([], readCleanMemory: memory.Read), ref snapshot, ref specialization)); + + var resources = ResourceMaterializer.ApplyTo(plan, specialization); + var request = CompileRequest(plan, resources); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + + var module = new SpirvModuleInspector(shader.Spirv); + Assert.Contains((0u, BindingLayout.NativeBindingIndex(ShaderStage.Compute, DescriptorBindingKind.FlattenedResourceTable)), module.DescriptorBindings); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs new file mode 100644 index 0000000..daecb59 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs @@ -0,0 +1,64 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler.Resources; +using Xunit; +using Xunit.Abstractions; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +public sealed class DemonsSoulsProbeTests(ITestOutputHelper output) +{ + private const ulong ShaderAddress = 0x1000; + + [Theory] + [InlineData("59775BB47AD2848A")] + [InlineData("E005B9EB0E74B0A3")] + public void Probe(string hash) + { + var path = Environment.GetEnvironmentVariable("SHARPEMU_PROBE_DIR"); + if (string.IsNullOrEmpty(path)) return; + var words = File.ReadAllText(Path.Combine(path, hash + ".words.txt")).Split(',') + .Select(word => Convert.ToUInt32(word, 16)).ToArray(); + var bytes = new byte[words.Length * 4]; + for (var index = 0; index < words.Length; index++) + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * 4), words[index]); + var memory = new ProbeMemory(bytes); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(new CpuContext(memory, Generation.Gen5), ShaderAddress, out var program, out var error), error); + var writer = new StringWriter(); + var previous = Console.Error; + Console.SetError(writer); + try + { + var plan = ShaderResourcePlan.Extract(program!, ShaderStage.Compute, Convert.ToUInt64(hash, 16), 0, 2); + output.WriteLine($"OK images={plan.Info.Images.Count}"); + } + catch (Exception exception) + { + output.WriteLine("FAIL " + exception.Message); + } + finally + { + Console.SetError(previous); + output.WriteLine(writer.ToString()); + } + } + + private sealed class ProbeMemory(byte[] bytes) : ICpuMemory + { + public bool TryRead(ulong address, Span destination) + { + if (address < ShaderAddress || address - ShaderAddress + (ulong)destination.Length > (ulong)bytes.Length) + return false; + bytes.AsSpan((int)(address - ShaderAddress), destination.Length).CopyTo(destination); + return true; + } + + public bool CanRead(ulong address, ulong size) => + address >= ShaderAddress && size <= (ulong)bytes.Length && address - ShaderAddress <= (ulong)bytes.Length - size; + + public bool TryWrite(ulong address, ReadOnlySpan source) => false; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs index 5202de6..6e0ec7a 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs @@ -10,6 +10,87 @@ namespace SharpEmu.ShaderCompiler.Tests.Resources; public sealed class DeviceAddressRangePlannerTests { + // flat_store through (aperture_hi << 32) | offset: the high dword comes from an + // aperture operand, so the store targets LDS or scratch and owns no device range. + [Theory] + [InlineData(Gen5InlineConstants.SharedBase, FlatAddressSpace.Shared)] + [InlineData(Gen5InlineConstants.PrivateBase, FlatAddressSpace.Private)] + public void FlatAddressFromAnAperture_IsLocalAndHasNoDeviceRange(uint aperture, FlatAddressSpace expected) + { + var plan = Extract(Program( + Sop1(0, "SMovB32", 1, Gen5Operand.Source(aperture)), + Vop1(4, "VMovB32", 1, Gen5Operand.Scalar(1)), + Vop1(8, "VMovB32", 0, Operand(16)), + GlobalAccess(12, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(20))); + + Assert.Empty(plan.DeviceAddressRanges); + Assert.True(plan.Memory.TryGetIndex(12, 0, out var index)); + Assert.Equal(expected, plan.Memory[index].AddressSpace); + } + + // A high dword clamped between the hard-coded PS5 aperture bounds + // (v_med3_u32 0x80000000, 0x70000000, sp) always lands in LDS or scratch. + [Fact] + public void FlatAddressClampedIntoTheApertures_IsLocal() + { + var plan = Extract(Program( + Vop3(0, "VMed3U32", 1, Operand(0x8000_0000), Operand(0x7000_0000), Gen5Operand.Vector(7)), + Vop1(8, "VMovB32", 0, Operand(16)), + GlobalAccess(12, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(20))); + + Assert.Empty(plan.DeviceAddressRanges); + Assert.True(plan.Memory.TryGetIndex(12, 0, out var index)); + Assert.Equal(FlatAddressSpace.SharedOrPrivate, plan.Memory[index].AddressSpace); + } + + // After v_cmpx the clamp is a lane-masked write whose inactive lanes keep an + // unknown value; those lanes do not store, so the address is still local. + [Fact] + public void ClampedAddressWrittenUnderTheStoresExecMask_IsLocal() + { + var plan = Extract(Program( + Vopc(0, "VCmpxLtU32", Operand(3), 5), + Vop3(4, "VMed3U32", 1, Operand(0x8000_0000), Operand(0x7000_0000), Gen5Operand.Vector(7)), + Vop1(12, "VMovB32", 0, Operand(16)), + GlobalAccess(16, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(24))); + + Assert.Empty(plan.DeviceAddressRanges); + Assert.True(plan.Memory.TryGetIndex(16, 0, out var index)); + Assert.Equal(FlatAddressSpace.SharedOrPrivate, plan.Memory[index].AddressSpace); + } + + // Without an aperture the same flat store is a device access and keeps its range. + [Fact] + public void FlatAddressWithoutAperture_StaysGlobal() + { + var plan = Extract(Program( + Vop1(0, "VMovB32", 1, Operand(0)), + Vop1(4, "VMovB32", 0, Operand(0x1000)), + GlobalAccess(8, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(16))); + + var range = Assert.Single(plan.DeviceAddressRanges); + Assert.True(range.Written); + Assert.True(plan.Memory.TryGetIndex(8, 0, out var index)); + Assert.Equal(FlatAddressSpace.Global, plan.Memory[index].AddressSpace); + } + + // A 32-bit read returns the aperture's high dword; a 64-bit read the full address. + [Fact] + public void ApertureOperands_DecodeConsistentlyAsHighDwordAndAddress() + { + Assert.True(Gen5InlineConstants.TryDecode(Gen5InlineConstants.SharedBase, out var shared)); + Assert.Equal(Gen5InlineConstants.SharedApertureHigh, shared); + Assert.True(Gen5InlineConstants.TryDecode(Gen5InlineConstants.PrivateLimit, out var privateLimit)); + Assert.Equal(Gen5InlineConstants.PrivateApertureHigh, privateLimit); + Assert.Equal(0x7000_0000_0000_0000ul, Gen5InlineConstants.DecodeAperture64(Gen5InlineConstants.SharedBase)); + Assert.Equal(0x7000_0000_FFFF_FFFFul, Gen5InlineConstants.DecodeAperture64(Gen5InlineConstants.SharedLimit)); + Assert.Equal(0x8000_0000_0000_0000ul, Gen5InlineConstants.DecodeAperture64(Gen5InlineConstants.PrivateBase)); + } + [Fact] public void BoundedOffsets_GiveTheLargestExtent() { diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs index 37e4e17..c7ef505 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs @@ -11,6 +11,25 @@ namespace SharpEmu.ShaderCompiler.Tests.Resources; public sealed class DirectImageTableTests { + internal static Gen5ShaderProgram CreateWaveIndexedDescriptorProgram() + { + return Program( + ScalarLoad(0, 0, 16, immediateOffset: 0x80), + Sop1(8, "SFF1I32B32", 18, Gen5Operand.Scalar(16)), + Sop2(12, "SMulI32", 106, Gen5Operand.Scalar(18), new Gen5Operand(Gen5OperandKind.LiteralConstant, 0x90)), + MoveVectorFromScalar(16, 34, 18), + Vop2(20, "VLshlrevB32", 15, Operand(4), Gen5Operand.Vector(34)), + Vop3(24, "VLshlAddU32", 16, Gen5Operand.Vector(15), Operand(3), Gen5Operand.Vector(15)), + Sop1(32, "SBitset0B32", 16, Gen5Operand.Scalar(18)), + Vop2(36, "VAddI32", 15, new Gen5Operand(Gen5OperandKind.LiteralConstant, 0x40), Gen5Operand.Vector(16)), + GlobalMemory(40, "GlobalLoadDword", 0, 15, 22, 0), + ReadFirstLane(48, 106, 22), + Sop2(52, "SLshlB32", 106, Gen5Operand.Scalar(106), Operand(5)), + ScalarLoad(56, 0, 4, 8, immediateOffset: 0x100, dynamicOffsetRegister: 106), + Image(64, "ImageLoad", 4, dmask: 1, vectorAddress: 1), + EndProgram(72)); + } + public static Gen5ShaderProgram CreateProgram(uint mask = 1, bool split = true, bool bitScan = true) { var instructions = new List @@ -79,6 +98,52 @@ public sealed class DirectImageTableTests return (plan, snapshot, new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 }); } + [Fact] + public void WaveIndexedDescriptorTableMaterializesOnlyActiveMaskKeys() + { + var plan = ShaderResourcePlan.Extract(CreateWaveIndexedDescriptorProgram(), ShaderStage.Compute, Hash, 0, 2); + var selector = plan.DescriptorSources[(int)plan.Info.Images[0].Source].IndirectImage!; + Assert.True(selector.Dense); + Assert.Equal(0u, selector.KeyBound); + Assert.Equal(new WaveIndexedImageSelector(0x80, 0x40, 0x90), selector.WaveIndexed); + + bool Read(ulong address, out uint word) + { + word = 0; + if (address == 0x1000 + 0x80) + { + word = (1u << 1) | (1u << 4); + return true; + } + + if (address == 0x1000 + 0x40 + 0x90 || address == 0x1000 + 0x40 + 4 * 0x90) + { + word = address == 0x1000 + 0x40 + 0x90 ? 2u : 5u; + return true; + } + + if (address < 0x1000 + 0x100 || address >= 0x1000 + 0x100 + 6 * 32) + return false; + var relative = address - 0x1000 - 0x100; + var record = relative / 32; + if (record is not (2 or 5)) + return false; + word = (relative % 32 / 4) switch + { + 0 => (record & 1) == 0 ? 0x2000u : 0x1000u, + 1 => 20u << 20, + 3 => 0xFACu | (9u << 28), + _ => 0, + }; + return true; + } + + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([0x1000, 0], readCleanMemory: Read), ref snapshot, ref specialization)); + Assert.Equal(2, snapshot.Images.Length); + } + public static (ResourceSnapshot Snapshot, ShaderCompileRequest Request) PrepareMixedDimensions(uint mask, bool arrayFirst) { var program = CreateGuardedProgram(mask); diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs new file mode 100644 index 0000000..b9f126a --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs @@ -0,0 +1,175 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler.Resources; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +public sealed class ResourceMaterializationCacheTests +{ + private const ulong HeapBase = 0x1000; + + // A mask word, a two-entry index table and six 32-byte image records. + private sealed class Heap + { + public readonly Dictionary Words = new(); + public int Reads; + public bool GpuOwned; + + public Heap() + { + Words[HeapBase + 0x80] = (1u << 1) | (1u << 4); + Words[HeapBase + 0x40 + 0x90] = 2; + Words[HeapBase + 0x40 + 4 * 0x90] = 5; + foreach (var record in new uint[] { 2, 5 }) + { + var entry = HeapBase + 0x100 + record * 32; + for (uint dword = 0; dword < 8; dword++) + Words[entry + dword * 4] = dword switch + { + 0 => (record & 1) == 0 ? 0x2000u : 0x1000u, + 1 => 20u << 20, + 3 => 0xFACu | (9u << 28), + _ => 0, + }; + } + } + + public bool Read(ulong address, out uint word) + { + Reads++; + return Words.TryGetValue(address, out word); + } + + public bool ReadResident(ulong address, Span destination, bool clean) + { + if (GpuOwned) + return false; + for (var offset = 0; offset < destination.Length; offset += 4) + { + if (!Words.TryGetValue(address + (ulong)offset, out var word)) + return false; + BitConverter.TryWriteBytes(destination[offset..], word); + } + + return true; + } + } + + private static ShaderResourcePlan Plan() => + ShaderResourcePlan.Extract(DirectImageTableTests.CreateWaveIndexedDescriptorProgram(), ShaderStage.Compute, Hash, 0, 2); + + private static bool Run(ResourceMaterializationCache cache, ShaderResourcePlan plan, Heap heap, uint[] userData, + out ResourceSnapshot snapshot, out ResourceSpecialization specialization, ulong shaderBase = 0) + { + snapshot = new ResourceSnapshot(); + specialization = new ResourceSpecialization(); + return cache.Materialize(plan, Inputs(userData, readCleanMemory: heap.Read, shaderBase: shaderBase), heap.ReadResident, + ref snapshot, ref specialization, out _); + } + + [Fact] + public void UnchangedMemoryReusesTheMaterialization() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var first, out var firstSpecialization)); + var readsAfterFirst = heap.Reads; + Assert.True(readsAfterFirst > 0); + + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var second, out var secondSpecialization)); + Assert.Equal(readsAfterFirst, heap.Reads); + Assert.Same(first, second); + Assert.Same(firstSpecialization, secondSpecialization); + Assert.Equal((1, 1), (cache.Hits, cache.Misses)); + } + + [Fact] + public void AChangedDescriptorWordMaterializesAgain() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var first, out _)); + + heap.Words[HeapBase + 0x100 + 5 * 32] = 0x3000; + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var second, out _)); + Assert.NotSame(first, second); + Assert.Equal((0, 2), (cache.Hits, cache.Misses)); + + // An uncached full walk gives the same result as the re-materialization. + var reference = new ResourceSnapshot(); + var referenceSpecialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([0x1000, 0], readCleanMemory: heap.Read), + ref reference, ref referenceSpecialization)); + Assert.Equal(reference.Images.Select(image => image.ToArray()), second.Images.Select(image => image.ToArray())); + } + + [Fact] + public void AChangedMaskMaterializesAgain() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + heap.Words[HeapBase + 0x80] = 1u << 1; + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.Equal(0, cache.Hits); + } + + [Fact] + public void GpuOwnedMemoryIsNeverTrusted() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + heap.GpuOwned = true; + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.Equal(0, cache.Hits); + } + + [Fact] + public void ADifferentDrawKeyIsADifferentEntry() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var first, out _)); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var second, out _, shaderBase: 0x100)); + Assert.NotSame(first, second); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var third, out _)); + Assert.Same(first, third); + Assert.Equal((1, 2), (cache.Hits, cache.Misses)); + } + + [Fact] + public void AFailedReadIsNotCached() + { + var plan = Plan(); + var heap = new Heap(); + heap.Words.Remove(HeapBase + 0x100 + 5 * 32 + 4); + var cache = new ResourceMaterializationCache(); + var ok = Run(cache, plan, heap, [0x1000, 0], out _, out _); + var reads = heap.Reads; + Assert.Equal(ok, Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.True(heap.Reads > reads); + Assert.Equal(0, cache.Hits); + } + + [Fact] + public void AFullGenerationKeepsRecentEntries() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(generationCapacity: 1); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _, shaderBase: 0x100)); + // The first entry moved to the old generation and is still found. + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.Equal(1, cache.Hits); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs index 693b286..2d368ef 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs @@ -13,6 +13,7 @@ public sealed class ResourceMaterializerTests private const uint Format32Float = 22; private const uint Format32Sint = 21; private const uint Format11x2x10Uint = 34; + private const uint Format8x2Uscaled = 16; private const uint ImageType2D = 9; [Theory] @@ -245,6 +246,25 @@ public sealed class ResourceMaterializerTests Assert.NotEqual(snapshot.Samplers[0], snapshot.Samplers[2]); } + [Fact] + public void ScaledImage_MaterializesAsAFloatSampledImage() + { + var instructions = new List(); + uint pc = 0; + instructions.AddRange(ImageWords(ref pc, 16, 0x1000, Format8x2Uscaled)); + instructions.AddRange(SamplerWords(ref pc, 24, 0)); + instructions.Add(Image(pc, "ImageSample", 16, 24)); + instructions.Add(EndProgram(pc + 8)); + var plan = Extract(Program([.. instructions])); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([]), ref snapshot, ref specialization)); + var applied = ResourceMaterializer.ApplyTo(plan, specialization); + var image = Assert.Single(applied.Info.Images); + Assert.Equal(ImageNumericClass.Float, image.NumericClass); + } + // Three images share one sampler; the packed and signed ones need point filtering, // so the sampler splits and their accesses sample through the duplicate. [Fact] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs index 43b8724..1f273db 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs @@ -3,6 +3,7 @@ using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; using Xunit; using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; @@ -501,6 +502,42 @@ public sealed class ResourceTrackerTests Assert.Equal(layout.MemoryOffsetDword + 1, layout.ShaderDataDwordCount); } + [Fact] + public void RuntimeScalarBufferLoad_UsesDeviceAddresses() + { + var program = Program( + ScalarLoad(0, 0, destination: 8, count: 4, dynamicOffsetRegister: 2), + ScalarBufferLoad(8, 8, destination: 12, count: 1), + EndProgram(16)); + var plan = Extract(program); + + Assert.True(plan.Info.UsesDeviceAddresses); + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Memory.TryGetIndex(8, 0, out var index)); + Assert.Equal(BufferDescriptorProvenance.Runtime, plan.Memory[index].BufferDescriptor!.Provenance); + Assert.Equal(MemoryAccessInfo.NoResource, plan.Memory[index].Resource); + + var request = Request(program); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + } + + [Fact] + public void UnboundedRuntimeFormattedBufferLoad_UsesNullReadFallback() + { + var program = Program( + ScalarLoad(0, 0, destination: 8, count: 4, dynamicOffsetRegister: 2), + ScalarBufferLoad(8, 8, destination: 12, count: 1), + BufferLoad(16, 8, formatted: true), + EndProgram(20)); + var plan = Extract(program); + + Assert.Empty(plan.BufferCandidateTables); + var request = Request(program); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + } + [Fact] public void PhiValidation() { @@ -605,7 +642,7 @@ public sealed class ResourceTrackerTests } [Fact] - public void ResourceLimits_FailBeforeAnyTableIsWritten() + public void BuffersPastTheBindingBudget_ReadTheirDescriptorsOnTheDevice() { var instructions = new List(); uint pc = 0; @@ -622,8 +659,11 @@ public sealed class ResourceTrackerTests } instructions.Add(EndProgram(pc)); - var error = Assert.Throws(() => Extract(Program([.. instructions]))); - Assert.Contains("buffer resource limit exceeded", error.Message); + var plan = Extract(Program([.. instructions])); + + Assert.Equal(ShaderResourceInfo.MaxBuffers, plan.Info.Buffers.Count); + Assert.True(plan.Info.UsesDeviceAddresses); + Assert.Single(plan.Memory.Entries, memory => memory.DeviceDescriptor); } [Fact] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs index 094e602..6674193 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs @@ -323,6 +323,37 @@ public sealed class ScalarValueGraphTests Assert.Contains("not a valid runtime value", error.Message); } + [Fact] + public void BitwiseMaskCanResolveUndefinedDescriptorBits() + { + var plan = Extract(Program( + Sop2(0, "SAndB32", 0, Gen5Operand.Scalar(20), Operand(0)), + MoveScalar(4, 1, 0), + MoveScalar(8, 2, 0), + MoveScalar(12, 3, 0), + BufferLoad(16, 0), + EndProgram(24)), userDataCount: 0); + + Assert.True(RuntimeValueEvaluator.EvaluateDescriptorSource(plan, plan.Info.Buffers[0].Source, Inputs([]), out var descriptor)); + Assert.Equal([0u, 0u, 0u, 0u], descriptor.Dwords); + } + + [Fact] + public void DirectExecutionMaskWriteKeepsBranchConditionDefined() + { + var program = Program( + MoveScalar(0, 126, 0), + Branch(4, "SCbranchExecz", 0), + EndProgram(8)); + + var graph = ScalarValueGraph.Build(program, 0, 0); + + Assert.True(graph.BranchConditions.TryGetValue(4, out var condition)); + Assert.False(condition.IsUndefined); + Assert.True(condition.IsConstant); + Assert.Equal(1u, condition.ConstantU32); + } + // ---- Requirement A: lane provenance and uniform vector values ---- [Fact] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs index 921f38a..38ac9c6 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; using Xunit; using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; @@ -10,59 +11,29 @@ namespace SharpEmu.ShaderCompiler.Tests.Resources; public sealed class VectorCompareResourceTests { [Fact] - public void ExecutionComparePreservesResourcePointerWithUnknownVectorInputs() + public void BitreplicateB64B32Compiles() { var program = Program( - ScalarLoad(0, 0, 106, count: 2), - Vopc(8, "VCmpxLtU32", Gen5Operand.Vector(0), 1), - Branch(12, "SCbranchExecz", 4), - ScalarLoad(16, 106, 8, count: 4), - BufferLoad(24, 8), - EndProgram(32)); - var plan = Extract(program, userDataCount: 2); - var memory = new TestWordMemory - { - Base = 0x2_00001000, - Words = [0x1010, 2, 0, 0, 0x3000, 0, 256, 0], - }; - var snapshot = new ResourceSnapshot(); - var specialization = new ResourceSpecialization(); + Sop1(0, "SBitreplicateB64B32", 0, Operand(7)), + EndProgram(8)); - Assert.True(ResourceMaterializer.Materialize(plan, Inputs([0x1000, 2], memory.Read), - ref snapshot, ref specialization, out var failure)); - Assert.Equal(ResourceMaterializationFailure.None, failure); - Assert.Equal(new uint[] { 0x3000, 0, 256, 0 }, Assert.Single(snapshot.Buffers)); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(Request(program, userDataCount: 0), out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); } [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public void CompareUpdatesOnlyItsDestinationMask(bool updatesExecutionMask, bool comparisonPasses) + [InlineData("VCmpEqU16")] + [InlineData("VCmpLtU16")] + [InlineData("VCmpGeU16")] + [InlineData("VCmpEqI16")] + [InlineData("VCmpLtI16")] + public void Integer16BitCompareCompilesToSpirv(string opcode) { var program = Program( - MoveScalar(0, 106, 0x12345678), - MoveScalar(4, 107, 0x87654321), - MoveVector(8, 0, comparisonPasses ? 2u : 0u), - Vopc(12, updatesExecutionMask ? "VCmpxLtU32" : "VCmpLtU32", Operand(1), 0), - MoveScalarRegister(16, 8, 106), - MoveScalarRegister(20, 9, 107), - MoveScalarRegister(24, 10, 126), - MoveScalarRegister(28, 11, 127), - BufferLoad(32, 8), - EndProgram(40)); - var plan = Extract(program, userDataCount: 0); + Vopc(0, opcode, Gen5Operand.Vector(0), 1), + EndProgram(8)); - Assert.True(RuntimeValueEvaluator.EvaluateDescriptorSource(plan, - Assert.Single(plan.Info.Buffers).Source, Inputs([]), out var descriptor)); - var comparisonMask = comparisonPasses ? 1u : 0u; - Assert.Equal(new uint[] - { - updatesExecutionMask ? 0x12345678u : comparisonMask, - updatesExecutionMask ? 0x87654321u : 0u, - updatesExecutionMask ? comparisonMask : uint.MaxValue, - 0, - }, descriptor.Dwords); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(Request(program, userDataCount: 0), out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); } }