From e1cf5b13efc922a77c28d4984c46d107049a61e1 Mon Sep 17 00:00:00 2001 From: Foued Attar Date: Sat, 11 Jul 2026 23:22:48 +0200 Subject: [PATCH] [AGC] Quake rendering progress: WAIT_REG_MEM, draw fixes, VideoOut, and HLE improvements (#68) * [agc] WAIT_REG_MEM suspend/resume, draw packet fixes, new HLE exports, debug cleanup Rebased onto upstream 79a7437 (par274/sharpemu, rewritten history). - GpuWaitRegistry: DCBs suspended on unsatisfied WAIT_REG_MEM are re-polled against guest memory on every submit; fixed 64-bit and standard packet parse offsets, apply the mask, treat PM4 compare function 0 as "always". - TryReadSubmittedDrawCount: accept the 5-dword ItDrawIndex2 form emitted by DcbDrawIndex (count at +4); menu draws were silently discarded before. - sceAgcDriverSubmitMultiDcbs: reversed ABI (rdi=address array, rsi=dword sizes, rdx=count). - VideoOut: vblank events, sceVideoOutGetFlipStatus, buffers registered via sceVideoOutRegisterBuffers are valid flip targets. - New HLE: libc stdio (fopen/fread/fseek/ftell/fclose/fgets), Dinkumware _Getpctype ctype table, NpTrophy2 stubs, AMPR PAK sequential-read tracker, MsgDialog lifecycle, NGS2 alt NIDs + dummy vtable for handle objects, guarded memset intrinsic, abort()/strcasecmp null-arg recovery. - Removed investigation-only code (INT3 breakpoints, qfont/mcpp dumps, error-candidate printf traces, unconditional debug logs). First rendered frame: Quake (PPSA01880) presents a 1920x1080 guest frame. * Implemented a guarded native intrinsic (rep movsb) in DirectExecutionBackend to bypass HLE dispatch overhead, while preserving memory safety checks. --- .gitignore | 1 + .../Native/DirectExecutionBackend.Imports.cs | 7 +- .../Cpu/Native/DirectExecutionBackend.cs | 92 +++- src/SharpEmu.Libs/Agc/AgcExports.cs | 384 ++++++++++---- src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs | 102 ++++ src/SharpEmu.Libs/Ampr/AmprExports.cs | 42 +- src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs | 156 ++++++ src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs | 36 ++ .../CommonDialog/CommonDialogExports.cs | 12 +- .../CommonDialog/MsgDialogExports.cs | 166 ++++++- src/SharpEmu.Libs/CxxAbiExports.cs | 4 +- src/SharpEmu.Libs/Kernel/KernelExports.cs | 93 +++- .../Kernel/KernelMemoryCompatExports.cs | 349 ++++++++++++- src/SharpEmu.Libs/LibcStdioExports.cs | 467 ++++++++++++++++++ src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs | 46 +- src/SharpEmu.Libs/Np/NpTrophy2Exports.cs | 108 ++++ .../SystemGesture/SystemGestureExports.cs | 11 + .../SystemService/SystemServiceExports.cs | 7 + .../UserService/UserServiceExports.cs | 28 +- src/SharpEmu.Libs/VideoOut/VideoOutExports.cs | 174 ++++++- .../VideoOut/VulkanVideoPresenter.cs | 110 ++++- 21 files changed, 2204 insertions(+), 191 deletions(-) create mode 100644 src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs create mode 100644 src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs create mode 100644 src/SharpEmu.Libs/LibcStdioExports.cs create mode 100644 src/SharpEmu.Libs/Np/NpTrophy2Exports.cs diff --git a/.gitignore b/.gitignore index 1addfc4..6f78331 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ Desktop.ini ehthumbs.db .vs/ +.idea/ diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs index 32b0902..dbec96e 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs @@ -860,7 +860,12 @@ public sealed partial class DirectExecutionBackend "vz+pg2zdopI" or // sceKernelGetEventUserData "mJ7aghmgvfc" or // sceKernelGetEventId "23CPPI1tyBY" or // sceKernelGetEventFilter - "kwGyyjohI50"; // sceKernelGetEventData + "kwGyyjohI50" or // sceKernelGetEventData + // _Getpctype is a per-character ctype table lookup; the embedded mcpp preprocessor + // re-fetches the table pointer on every isdigit()/isalpha() check, so classifying a + // large input legitimately calls it hundreds of thousands of times back-to-back - + // real, finite work that would otherwise trip the loop guard. + "sUP1hBaouOw"; // _Getpctype } private long NextImportDispatchIndex() diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index c17bb7b..377d85f 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -1122,21 +1122,72 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I 0x75, 0xF5, 0xC3, ], + // memcpy: guarded native copy. The first "rep movsb" intrinsic had no bounds/null + // checking and crashed with a read at -1 right after a null-dst memset recovery in + // the NGS2 audio streaming code path, so it was temporarily pulled in favor of the + // safe C# HLE memcpy. That detour costs a full import dispatch per call - far too + // slow for a function this hot - so this stub keeps the native leaf path and adds + // the same guards as memset below: it silently returns dst without copying when dst + // or src is null/low-page (< 0x10000) or outside canonical user space, or when len + // is 0 or absurd (> 512MB). "Q3VBxCXhUHs" => [ - 0x48, 0x89, 0xF8, - 0x48, 0x89, 0xD1, - 0xF3, 0xA4, - 0xC3, + 0x48, 0x89, 0xF8, // mov rax, rdi (return dst) + 0x48, 0x81, 0xFF, 0x00, 0x00, 0x01, 0x00, // cmp rdi, 0x10000 + 0x72, 0x31, // jb done + 0x49, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, // mov r8, 0x800000000000 + 0x4C, 0x39, 0xC7, // cmp rdi, r8 + 0x73, 0x22, // jae done + 0x48, 0x81, 0xFE, 0x00, 0x00, 0x01, 0x00, // cmp rsi, 0x10000 + 0x72, 0x19, // jb done + 0x4C, 0x39, 0xC6, // cmp rsi, r8 + 0x73, 0x14, // jae done + 0x48, 0x81, 0xFA, 0x00, 0x00, 0x00, 0x20, // cmp rdx, 0x20000000 + 0x77, 0x0B, // ja done + 0x48, 0x85, 0xD2, // test rdx, rdx + 0x74, 0x06, // jz done + 0x48, 0x89, 0xD1, // mov rcx, rdx + 0xFC, // cld + 0xF3, 0xA4, // rep movsb + 0xC3, // done: ret + ], + // memset: guarded native fill. An earlier unguarded version crashed with a write AV + // at address 0 (NGS2 audio streaming init memsets a never-populated buffer field), + // so this one mirrors the HLE guards and silently returns dst without writing when + // dst is null/low-page (< 0x10000), dst is outside canonical user space, or len is + // absurd (> 512MB, e.g. the 0x27060035 / sign-extended values NGS2 passes). Routing + // memset through the HLE trampoline instead is not viable: parse/streaming loops + // issue hundreds of thousands of small memsets back-to-back, which crawls at + // dispatch speed and looks like a repeating-import hang to the loop guard. + // _sigprocmask: the HLE handler (KernelRuntimeCompatExports.Sigprocmask) is a pure + // no-op returning 0 that never writes oldset, so this is behavior-identical. The + // game's bundled libc queries the mask (set=NULL) once per iteration in its font/ + // parse loops - hundreds of thousands of back-to-back calls that both crawl at + // dispatch speed and read as a repeating-import hang to the loop guard. + "6xVpy0Fdq+I" => + [ + 0x31, 0xC0, // xor eax, eax + 0xC3, // ret ], "8zTFvBIAIN8" => [ - 0x49, 0x89, 0xF8, - 0x48, 0x89, 0xF0, - 0x48, 0x89, 0xD1, - 0xF3, 0xAA, - 0x4C, 0x89, 0xC0, - 0xC3, + 0x48, 0x89, 0xF8, // mov rax, rdi (return dst) + 0x48, 0x81, 0xFF, 0x00, 0x00, 0x01, 0x00, // cmp rdi, 0x10000 + 0x72, 0x2B, // jb done + 0x49, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, // mov r8, 0x800000000000 + 0x4C, 0x39, 0xC7, // cmp rdi, r8 + 0x73, 0x1C, // jae done + 0x48, 0x81, 0xFA, 0x00, 0x00, 0x00, 0x20, // cmp rdx, 0x20000000 + 0x77, 0x13, // ja done + 0x48, 0x85, 0xD2, // test rdx, rdx + 0x74, 0x0E, // jz done + 0x48, 0x89, 0xD1, // mov rcx, rdx + 0x49, 0x89, 0xF9, // mov r9, rdi + 0x89, 0xF0, // mov eax, esi + 0xFC, // cld + 0xF3, 0xAA, // rep stosb + 0x4C, 0x89, 0xC8, // mov rax, r9 + 0xC3, // done: ret ], _ => default, }; @@ -1355,10 +1406,25 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I { return exportName switch { - "memcpy" or "memmove" or - "memset" or - "memcmp" => true, + // memset/memcpy excluded: the raw LLE routines have no null/bounds guard and crash + // with an access violation on bad pointers (observed hit during Quake's CL_Init, + // where a still-unidentified upstream bug calls memcpy/memset with a null + // destination). Both are instead served by the guarded native intrinsics in + // TryCreateNativeImportIntrinsic, which fail safely without leaving the leaf path. + "memcmp" or + // _Getpctype must come from the game's own Dinkumware libc when one is bundled: + // it returns a pointer to that CRT's ctype bitmask table, whose bit layout + // (_DI=0x20, _SP=0x04, _BB=0x80, ...) differs from the MSVC-style table the HLE + // fallback used to serve. Serving the wrong layout made the bundled printf engine + // render every Sys_Error message as an empty string (isdigit misfired during + // %-directive parsing) and made mcpp drop 'a'-'f' from identifiers ("texture" -> + // "txtur", the 0x80 bit reads as _BB/control there). _Getptolower/_Getptoupper + // already resolve to the bundled module because no HLE export shadows them; this + // keeps _Getpctype consistent with them. It is a pure accessor returning a pointer + // to a const table, so it is also the cheapest possible LLE call - important + // because parsers hit it once per input character. + "_Getpctype" => true, _ => false, }; } diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 223c1d4..368facd 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -179,7 +179,6 @@ public static class AgcExports private static long _createShaderTraceCount; private static long _packetPayloadTraceCount; private static bool _tracedMissingPixelShaderBindings; - private static long _unsatisfiedWaitTraceCount; private static long _shaderTranslationMissTraceCount; private static long _translatedDrawTraceCount; private static long _standardDmaTraceCount; @@ -2001,6 +2000,57 @@ public static class AgcExports lock (gpuState.Gate) { ParseSubmittedDcb(ctx, gpuState, gpuState.Graphics, commandAddress, dwordCount, tracePackets); + DrainResumableDcbs(ctx, gpuState, tracePackets); + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + // ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each), + // rsi = array of DCB sizes in dwords (u32 each), rdx = buffer count. + [SysAbiExport( + Nid = "6UzEidRZwkg", + ExportName = "sceAgcDriverSubmitMultiDcbs", + Target = Generation.Gen5, + LibraryName = "libSceAgcDriver")] + public static int DriverSubmitMultiDcbs(CpuContext ctx) + { + var addressArray = ctx[CpuRegister.Rdi]; + var sizeArray = ctx[CpuRegister.Rsi]; + var bufferCount = (uint)ctx[CpuRegister.Rdx]; + if (addressArray == 0 || sizeArray == 0 || bufferCount == 0 || bufferCount > 4096) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + var tracePackets = string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC"), "1", StringComparison.Ordinal); + + var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); + lock (gpuState.Gate) + { + for (uint i = 0; i < bufferCount; i++) + { + if (!ctx.TryReadUInt64(addressArray + i * 8, out var commandAddress) || + commandAddress == 0 || + !ctx.TryReadUInt32(sizeArray + i * 4, out var dwordCount) || + dwordCount == 0) + { + continue; + } + + if (tracePackets) + { + TraceAgc( + $"agc.driver_submit_multi_dcbs index={i}/{bufferCount} " + + $"addr=0x{commandAddress:X16} dwords={dwordCount}"); + } + + ParseSubmittedDcb(ctx, gpuState, gpuState.Graphics, commandAddress, dwordCount, tracePackets); + } + + DrainResumableDcbs(ctx, gpuState, tracePackets); } ctx[CpuRegister.Rax] = 0; @@ -2049,6 +2099,7 @@ public static class AgcExports } ParseSubmittedDcb(ctx, gpuState, queueState, commandAddress, dwordCount, tracePackets); + DrainResumableDcbs(ctx, gpuState, tracePackets); } ctx[CpuRegister.Rax] = 0; @@ -2170,6 +2221,63 @@ public static class AgcExports return ReturnPointer(ctx, commandAddress); } + // WAIT_REG_MEM packets whose condition is not met suspend their DCB into + // GpuWaitRegistry. Each submit re-checks every suspended DCB against current guest + // memory (labels are advanced by ReleaseMem/WriteData/DmaData packets or by direct + // CPU writes) and resumes the ones whose condition is now satisfied. A resumed DCB + // can itself write labels that unblock others, so loop until a fixed point. + private static void DrainResumableDcbs( + CpuContext ctx, + SubmittedGpuState gpuState, + bool tracePackets) + { + for (var pass = 0; pass < 256; pass++) + { + var woken = GpuWaitRegistry.CollectSatisfied((address, is64Bit) => + { + if (is64Bit) + { + return ctx.TryReadUInt64(address, out var value64) + ? value64 + : (ulong?)null; + } + + return ctx.TryReadUInt32(address, out var value32) + ? value32 + : (ulong?)null; + }); + if (woken is null) + { + return; + } + + foreach (var waiter in woken) + { + var remainingDwords = waiter.TotalDwords - waiter.ResumeOffset; + if (remainingDwords == 0) + { + continue; + } + + if (tracePackets) + { + TraceAgc( + $"agc.dcb.resumed addr=0x{waiter.WaitAddress:X16} " + + $"resume=0x{waiter.ResumeAddress:X16} dwords={remainingDwords}"); + } + + var state = waiter.State as SubmittedDcbState ?? gpuState.Graphics; + ParseSubmittedDcb( + ctx, + gpuState, + state, + waiter.ResumeAddress, + remainingDwords, + tracePackets); + } + } + } + private static void ParseSubmittedDcb( CpuContext ctx, SubmittedGpuState gpuState, @@ -2201,7 +2309,6 @@ public static class AgcExports $"agc.dcb.packet dw={offset} addr=0x{currentAddress:X16} " + $"header=0x{header:X8} len=1 type=2"); } - offset++; continue; } @@ -2311,16 +2418,91 @@ public static class AgcExports state.InstanceCount = Math.Max(instanceCount, 1); } - if (op == ItNop && - register is RWaitMem32 or RWaitMem64 && + // WAIT_REG_MEM (AGC NOP-encapsulated 32/64-bit variants): when the condition is + // not yet satisfied, suspend this DCB; DrainResumableDcbs resumes it once the + // watched memory advances. + if (op == ItNop && register is RWaitMem32 or RWaitMem64 && length >= (register == RWaitMem32 ? 6u : 9u)) { - ObserveSubmittedWaitRegMem(ctx, currentAddress, register == RWaitMem64, tracePackets); + if (TryParseWaitRegMem(ctx, currentAddress, register == RWaitMem64, + out var waitAddr, out var refVal, out var waitMask, out var cmpFunc)) + { + ulong curVal = 0; + bool hasCurVal; + if (register == RWaitMem64) + { + hasCurVal = ctx.TryReadUInt64(waitAddr, out curVal); + } + else if (ctx.TryReadUInt32(waitAddr, out var curVal32)) + { + curVal = curVal32; + hasCurVal = true; + } + else + { + hasCurVal = false; + } + + var waiter = new GpuWaitRegistry.WaitingDcb + { + CommandBufferAddress = commandAddress, + ResumeAddress = currentAddress + ((ulong)length * sizeof(uint)), + TotalDwords = dwordCount, + ResumeOffset = offset + length, + ReferenceValue = refVal, + Mask = waitMask, + CompareFunction = cmpFunc, + Is64Bit = register == RWaitMem64, + State = state, + }; + if (hasCurVal && !GpuWaitRegistry.Compare(waiter, curVal)) + { + GpuWaitRegistry.Register(waitAddr, waiter); + + if (tracePackets) + { + TraceAgc( + $"agc.dcb.suspended addr=0x{waitAddr:X16} ref=0x{refVal:X16} " + + $"mask=0x{waitMask:X16} cur=0x{curVal:X16} cmp={cmpFunc}"); + } + + return; // suspend parsing of this DCB + } + } } if (op == ItWaitRegMem && length >= 7) { - ObserveSubmittedStandardWaitRegMem(ctx, currentAddress, tracePackets); + if (TryParseStandardWaitRegMem(ctx, currentAddress, + out var waitAddr, out var refVal, out var waitMask, out var cmpFunc) && + ctx.TryReadUInt32(waitAddr, out var curVal)) + { + var waiter = new GpuWaitRegistry.WaitingDcb + { + CommandBufferAddress = commandAddress, + ResumeAddress = currentAddress + ((ulong)length * sizeof(uint)), + TotalDwords = dwordCount, + ResumeOffset = offset + length, + ReferenceValue = refVal, + Mask = waitMask, + CompareFunction = cmpFunc, + Is64Bit = false, + State = state, + }; + if (!GpuWaitRegistry.Compare(waiter, curVal)) + { + GpuWaitRegistry.Register(waitAddr, waiter); + + if (tracePackets) + { + TraceAgc( + $"agc.dcb.suspended_std addr=0x{waitAddr:X16} ref=0x{refVal:X16} " + + $"mask=0x{waitMask:X16} cur=0x{curVal:X16} cmp={cmpFunc}"); + } + + return; + } + } } if (TryReadSubmittedDrawCount( @@ -2641,104 +2823,6 @@ public static class AgcExports } } - private static void ObserveSubmittedWaitRegMem( - CpuContext ctx, - ulong packetAddress, - bool is64Bit, - bool tracePacket) - { - if (!ctx.TryReadUInt64(packetAddress + 4, out var address) || - !ctx.TryReadUInt32(packetAddress + (is64Bit ? 28u : 16u), out var control)) - { - return; - } - - ulong mask; - ulong reference; - ulong value; - if (is64Bit) - { - if (!ctx.TryReadUInt64(packetAddress + 12, out mask) || - !ctx.TryReadUInt64(packetAddress + 20, out reference) || - !ctx.TryReadUInt64(address, out value)) - { - return; - } - } - else - { - if (!ctx.TryReadUInt32(packetAddress + 12, out var mask32) || - !ctx.TryReadUInt32(packetAddress + 20, out var reference32) || - !ctx.TryReadUInt32(address, out var value32)) - { - return; - } - - mask = mask32; - reference = reference32; - value = value32; - } - - var compareFunction = control & 0xFFu; - TraceSubmittedWait( - address, - value, - mask, - reference, - compareFunction, - is64Bit ? 64 : 32, - tracePacket); - } - - private static void ObserveSubmittedStandardWaitRegMem( - CpuContext ctx, - ulong packetAddress, - bool tracePacket) - { - if (!ctx.TryReadUInt32(packetAddress + 4, out var control) || - !ctx.TryReadUInt64(packetAddress + 8, out var address) || - !ctx.TryReadUInt32(packetAddress + 16, out var reference) || - !ctx.TryReadUInt32(packetAddress + 20, out var mask) || - !ctx.TryReadUInt32(address, out var value)) - { - return; - } - - TraceSubmittedWait(address, value, mask, reference, control & 0x7u, 32, tracePacket); - } - - private static void TraceSubmittedWait( - ulong address, - ulong value, - ulong mask, - ulong reference, - uint compareFunction, - int bits, - bool tracePacket) - { - var maskedValue = value & mask; - var satisfied = compareFunction switch - { - 0 => false, - 1 => maskedValue < reference, - 2 => maskedValue <= reference, - 3 => maskedValue == reference, - 4 => maskedValue != reference, - 5 => maskedValue >= reference, - 6 => maskedValue > reference, - _ => true, - }; - if (!tracePacket && (satisfied || !ShouldTraceHotPath(ref _unsatisfiedWaitTraceCount))) - { - return; - } - - TraceAgc( - $"agc.dcb.wait_reg_mem bits={bits} addr=0x{address:X16} " + - $"value=0x{value:X16} mask=0x{mask:X16} ref=0x{reference:X16} " + - $"compare={compareFunction} satisfied={satisfied}"); - } - private static void ApplySubmittedReleaseMem( CpuContext ctx, ulong packetAddress, @@ -2756,10 +2840,11 @@ public static class AgcExports var dataSelection = (control >> 16) & 0xFFu; var destinationAddress = ((ulong)destinationHi << 32) | destinationLo; var data = ((ulong)dataHi << 32) | dataLo; + var wroteData = dataSelection switch { - 1 or 2 => ctx.TryWriteUInt32(destinationAddress, dataLo), - 3 => ctx.TryWriteUInt64(destinationAddress, data), + 1 => ctx.TryWriteUInt32(destinationAddress, dataLo), + 2 or 3 => ctx.TryWriteUInt64(destinationAddress, data), _ => false, }; @@ -2855,6 +2940,11 @@ public static class AgcExports case ItDrawIndex2 when packetLength >= 6: state.DrawIndexOffset = 0; return ctx.TryReadUInt32(packetAddress + 16, out drawCount); + // 5-dword form emitted by DcbDrawIndex (count at +4, ItIndexBase/ + // ItIndexBufferSize carried by separate preceding packets). + case ItDrawIndex2 when packetLength >= 5: + state.DrawIndexOffset = 0; + return ctx.TryReadUInt32(packetAddress + 4, out drawCount); case ItDrawIndexOffset2 when packetLength >= 5: if (!ctx.TryReadUInt32(packetAddress + 8, out var indexOffset)) { @@ -5635,4 +5725,86 @@ public static class AgcExports Console.Error.WriteLine( $"[LOADER][TRACE] agc.create_shader dst=0x{destinationAddress:X16} header=0x{headerAddress:X16} code=0x{codeAddress:X16} {detail}"); } + + // Packet layouts mirror what DcbWaitRegMem emits. + // 32-bit (ItNop/RWaitMem32): +4 addrLo, +8 addrHi, +12 mask, +16 cmp|op<<8, +20 ref. + // 64-bit (ItNop/RWaitMem64): +4 addrLo, +8 addrHi, +12 maskLo, +16 maskHi, + // +20 refLo, +24 refHi, +28 cmp|op<<8, +32 poll. + private static bool TryParseWaitRegMem( + CpuContext ctx, + ulong addr, + bool is64, + out ulong waitAddr, + out ulong refVal, + out ulong mask, + out uint cmpFunc) + { + waitAddr = refVal = mask = 0; + cmpFunc = 0; + + if (!ctx.TryReadUInt32(addr + 4, out var lo) || + !ctx.TryReadUInt32(addr + 8, out var hi)) + { + return false; + } + + waitAddr = ((ulong)hi << 32) | lo; + if (!is64) + { + if (!ctx.TryReadUInt32(addr + 12, out var mask32) || + !ctx.TryReadUInt32(addr + 16, out var cmpRaw) || + !ctx.TryReadUInt32(addr + 20, out var refVal32)) + { + return false; + } + + mask = mask32; + refVal = refVal32; + cmpFunc = cmpRaw & 0x7; + return true; + } + + if (!ctx.TryReadUInt32(addr + 12, out var maskLo) || + !ctx.TryReadUInt32(addr + 16, out var maskHi) || + !ctx.TryReadUInt32(addr + 20, out var refLo) || + !ctx.TryReadUInt32(addr + 24, out var refHi) || + !ctx.TryReadUInt32(addr + 28, out var cmpRaw64)) + { + return false; + } + + mask = ((ulong)maskHi << 32) | maskLo; + refVal = ((ulong)refHi << 32) | refLo; + cmpFunc = cmpRaw64 & 0x7; + return true; + } + + // Standard ItWaitRegMem (7 dwords, 32-bit compare): + // +4 cmp|(op&1)<<8, +8 addrLo, +12 addrHi, +16 ref, +20 mask, +24 poll. + private static bool TryParseStandardWaitRegMem( + CpuContext ctx, + ulong addr, + out ulong waitAddr, + out ulong refVal, + out ulong mask, + out uint cmpFunc) + { + waitAddr = refVal = mask = 0; + cmpFunc = 0; + + if (!ctx.TryReadUInt32(addr + 4, out var cmpRaw) || + !ctx.TryReadUInt32(addr + 8, out var lo) || + !ctx.TryReadUInt32(addr + 12, out var hi) || + !ctx.TryReadUInt32(addr + 16, out var reference) || + !ctx.TryReadUInt32(addr + 20, out var mask32)) + { + return false; + } + + cmpFunc = cmpRaw & 0x7; + waitAddr = ((ulong)hi << 32) | lo; + refVal = reference; + mask = mask32; + return true; + } } diff --git a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs new file mode 100644 index 0000000..80c4822 --- /dev/null +++ b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs @@ -0,0 +1,102 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Libs.Agc; + +// Holds DCBs whose parsing was suspended on an unsatisfied WAIT_REG_MEM condition. +// AgcExports re-checks every waiter against guest memory on each submit and resumes +// the ones whose condition became true (labels are advanced by ReleaseMem/WriteData/ +// DmaData packets or by direct CPU writes). +internal static class GpuWaitRegistry +{ + public struct WaitingDcb + { + public ulong CommandBufferAddress; + public ulong ResumeAddress; + public uint TotalDwords; + public uint ResumeOffset; + public ulong WaitAddress; + public ulong ReferenceValue; + public ulong Mask; + public uint CompareFunction; + public bool Is64Bit; + public object? State; + } + + private static readonly object _gate = new(); + private static readonly Dictionary> _waiters = new(); + + public static void Register(ulong address, WaitingDcb waiter) + { + waiter.WaitAddress = address; + lock (_gate) + { + if (!_waiters.TryGetValue(address, out var list)) + { + list = new List(); + _waiters.Add(address, list); + } + + list.Add(waiter); + } + } + + // Re-evaluates every registered waiter. readValue receives (address, is64Bit) and + // returns null when the memory is unreadable; such waiters are kept registered. + public static List? CollectSatisfied(Func readValue) + { + List? woken = null; + lock (_gate) + { + List? emptied = null; + foreach (var (address, list) in _waiters) + { + for (var i = list.Count - 1; i >= 0; i--) + { + var value = readValue(address, list[i].Is64Bit); + if (value is null || !Compare(list[i], value.Value)) + { + continue; + } + + woken ??= new List(); + woken.Add(list[i]); + list.RemoveAt(i); + } + + if (list.Count == 0) + { + emptied ??= new List(); + emptied.Add(address); + } + } + + if (emptied != null) + { + foreach (var address in emptied) + { + _waiters.Remove(address); + } + } + } + + return woken; + } + + public static bool Compare(in WaitingDcb waiter, ulong value) + { + var masked = value & waiter.Mask; + return waiter.CompareFunction switch + { + 1 => masked < waiter.ReferenceValue, + 2 => masked <= waiter.ReferenceValue, + 3 => masked == waiter.ReferenceValue, + 4 => masked != waiter.ReferenceValue, + 5 => masked >= waiter.ReferenceValue, + 6 => masked > waiter.ReferenceValue, + // 0 is "always" in the PM4 encoding and 7 is reserved; treating both as + // satisfied keeps a malformed packet from suspending its DCB forever. + _ => true, + }; + } +} diff --git a/src/SharpEmu.Libs/Ampr/AmprExports.cs b/src/SharpEmu.Libs/Ampr/AmprExports.cs index 96f468e..826dbe6 100644 --- a/src/SharpEmu.Libs/Ampr/AmprExports.cs +++ b/src/SharpEmu.Libs/Ampr/AmprExports.cs @@ -267,19 +267,53 @@ public static class AmprExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } - if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath)) + ulong bytesRead = 0; + + // Unregistered/missing files are zero-filled instead of failing: games queue + // speculative reads and only consume the bytes on success paths. + if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath) || !File.Exists(hostPath)) { - 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; + if (destination != 0 && size > 0) + { + int chunkSize = (int)Math.Min(size, 4096); + Span zeros = stackalloc byte[chunkSize]; + zeros.Clear(); + while (bytesRead < size) + { + int currentChunk = (int)Math.Min((ulong)chunkSize, size - bytesRead); + if (!ctx.Memory.TryWrite(destination + bytesRead, zeros[..currentChunk])) + { + break; + } + + bytesRead += (ulong)currentChunk; + } + } + + TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead, "(missing)", (int)OrbisGen2Result.ORBIS_GEN2_OK); + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } - var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out var bytesRead); + // Offset -1 means "continue after the previous read of this file id". + if (fileOffset == unchecked((ulong)(long)-1)) + { + fileOffset = PakDirectoryTracker.ResolveSequentialOffset(fileId, size); + } + else if (fileOffset > long.MaxValue) + { + fileOffset = 0; + } + + var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out bytesRead); if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK) { TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead, hostPath, result); return result; } + PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, fileOffset, bytesRead); + if (!AppendReadFileRecord(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead)) { return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; diff --git a/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs b/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs new file mode 100644 index 0000000..e12ea16 --- /dev/null +++ b/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs @@ -0,0 +1,156 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Text; +using SharpEmu.HLE; + +namespace SharpEmu.Libs.Ampr; + +/// +/// sceAmprAprCommandBufferReadFile carries -1 instead of an absolute offset whenever the guest +/// wants "the next sequential chunk" of a file. For an id Software PACK archive that sequence is +/// always header -> directory table -> individual archived files, and the guest never tells us +/// which archived file it wants next - only the byte count it expects, taken from the very +/// directory entry it already parsed on its side. This tracks a per-fileId read cursor, and once +/// the directory table has streamed past, parses it so later reads can be matched back to their +/// real absolute offset by the size the guest asks for. +/// +internal static class PakDirectoryTracker +{ + private const int DirectoryEntrySize = 64; + private const int DirectoryEntryNameLength = 56; + + private static readonly bool _trace = + string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal) || + string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR_READS"), "1", StringComparison.Ordinal); + + private sealed class DirectoryEntry(string name, uint filePos, uint fileLen) + { + public string Name { get; } = name; + public uint FilePos { get; } = filePos; + public uint FileLen { get; } = fileLen; + public bool Consumed { get; set; } + } + + private sealed class FileState + { + public ulong NextOffset; + public bool ExpectingDirectory; + public List? Directory; + } + + private static readonly ConcurrentDictionary _state = new(); + + /// + /// Resolves what "read the next sequential chunk of size " + /// really means for this fileId: an unconsumed directory entry whose length matches, if the + /// directory has already been parsed, otherwise the tracked linear cursor. + /// + public static ulong ResolveSequentialOffset(uint fileId, ulong requestedSize) + { + var state = _state.GetOrAdd(fileId, static _ => new FileState()); + + if (state.Directory is { } entries) + { + foreach (var entry in entries) + { + if (!entry.Consumed && entry.FileLen == requestedSize) + { + entry.Consumed = true; + if (_trace) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] pak.directory_match: id=0x{fileId:X8} name='{entry.Name}' " + + $"filepos=0x{entry.FilePos:X8} filelen=0x{entry.FileLen:X8}"); + } + + return entry.FilePos; + } + } + } + + return state.NextOffset; + } + + /// + /// Feeds back a completed read so the tracker can advance its cursor, recognize a PACK + /// header and jump straight to its directory table, or parse the directory table itself + /// once it streams past. + /// + public static void OnReadCompleted(CpuContext ctx, uint fileId, ulong destination, ulong fileOffset, ulong bytesRead) + { + if (bytesRead == 0) + { + return; + } + + var state = _state.GetOrAdd(fileId, static _ => new FileState()); + + if (state.ExpectingDirectory && fileOffset == state.NextOffset) + { + state.Directory = TryParseDirectory(ctx, destination, bytesRead); + state.ExpectingDirectory = false; + state.NextOffset = fileOffset + bytesRead; + if (_trace) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] pak.directory_parsed: id=0x{fileId:X8} entries={state.Directory?.Count ?? 0}"); + } + + return; + } + + if (fileOffset == 0 && bytesRead >= 12 && TryReadPackHeader(ctx, destination, out var dirOffset)) + { + state.NextOffset = dirOffset; + state.ExpectingDirectory = true; + return; + } + + state.NextOffset = fileOffset + bytesRead; + } + + private static bool TryReadPackHeader(CpuContext ctx, ulong destination, out ulong dirOffset) + { + dirOffset = 0; + Span header = stackalloc byte[12]; + if (!ctx.Memory.TryRead(destination, header) || + header[0] != (byte)'P' || header[1] != (byte)'A' || header[2] != (byte)'C' || header[3] != (byte)'K') + { + return false; + } + + dirOffset = BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]); + return true; + } + + private static List? TryParseDirectory(CpuContext ctx, ulong destination, ulong length) + { + var count = (int)(length / DirectoryEntrySize); + if (count <= 0) + { + return null; + } + + var entries = new List(count); + Span record = stackalloc byte[DirectoryEntrySize]; + for (var i = 0; i < count; i++) + { + if (!ctx.Memory.TryRead(destination + (ulong)(i * DirectoryEntrySize), record)) + { + break; + } + + var nameBytes = record[..DirectoryEntryNameLength]; + var nullIndex = nameBytes.IndexOf((byte)0); + var name = Encoding.ASCII.GetString(nullIndex >= 0 ? nameBytes[..nullIndex] : nameBytes); + var filePos = BinaryPrimitives.ReadUInt32LittleEndian(record.Slice(56, 4)); + var fileLen = BinaryPrimitives.ReadUInt32LittleEndian(record.Slice(60, 4)); + entries.Add(new DirectoryEntry(name, filePos, fileLen)); + } + + return entries; + } +} diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs index 77cd6b6..834a805 100644 --- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs +++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs @@ -35,6 +35,42 @@ public static class AvPlayerExports return unchecked((int)handle); } + [SysAbiExport( + Nid = "JdksQu8pNdQ", + ExportName = "sceAvPlayerGetVideoDataEx", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAvPlayer")] + public static int AvPlayerGetVideoDataEx(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + // "UbQoYawOsfY" is sceAvPlayerIsActive, not sceAvPlayerGetVideoDataEx (the previous NID here + // was wrong - verified by hashing both names against scripts/ps5_names.txt). No player is + // ever actually active in this HLE implementation, so report false/inactive. + [SysAbiExport( + Nid = "UbQoYawOsfY", + ExportName = "sceAvPlayerIsActive", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAvPlayer")] + public static int AvPlayerIsActive(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 0; + return 0; + } + + [SysAbiExport( + Nid = "Wnp1OVcrZgk", + ExportName = "sceAvPlayerGetAudioData", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceAvPlayer")] + public static int AvPlayerGetAudioData(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + [SysAbiExport( Nid = "HD1YKVU26-M", ExportName = "sceAvPlayerPostInit", diff --git a/src/SharpEmu.Libs/CommonDialog/CommonDialogExports.cs b/src/SharpEmu.Libs/CommonDialog/CommonDialogExports.cs index ba1e46d..2a6b0ef 100644 --- a/src/SharpEmu.Libs/CommonDialog/CommonDialogExports.cs +++ b/src/SharpEmu.Libs/CommonDialog/CommonDialogExports.cs @@ -8,7 +8,6 @@ namespace SharpEmu.Libs.CommonDialog; public static class CommonDialogExports { - private const int AlreadySystemInitialized = unchecked((int)0x80B80002); private static int _initialized; [SysAbiExport( @@ -18,11 +17,12 @@ public static class CommonDialogExports LibraryName = "libSceCommonDialog")] public static int CommonDialogInitialize(CpuContext ctx) { - var result = Interlocked.Exchange(ref _initialized, 1) == 0 - ? 0 - : AlreadySystemInitialized; - ctx[CpuRegister.Rax] = unchecked((ulong)result); - return result; + // Games can initialize the common-dialog service defensively from more than one + // subsystem. Keeping this HLE initialization idempotent avoids turning an error + // reporting path into a second, unrelated failure. + Interlocked.Exchange(ref _initialized, 1); + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } [SysAbiExport( diff --git a/src/SharpEmu.Libs/CommonDialog/MsgDialogExports.cs b/src/SharpEmu.Libs/CommonDialog/MsgDialogExports.cs index a8946f5..e50ac27 100644 --- a/src/SharpEmu.Libs/CommonDialog/MsgDialogExports.cs +++ b/src/SharpEmu.Libs/CommonDialog/MsgDialogExports.cs @@ -8,8 +8,13 @@ namespace SharpEmu.Libs.CommonDialog; public static class MsgDialogExports { - private const int AlreadyInitialized = unchecked((int)0x80B80002); + private const int StatusNone = 0; + private const int StatusInitialized = 1; + private const int StatusFinished = 3; + private const int ResultSize = 0x20; + private static int _initialized; + private static int _status; [SysAbiExport( Nid = "lDqxaY1UbEo", @@ -18,9 +23,162 @@ public static class MsgDialogExports LibraryName = "libSceMsgDialog")] public static int MsgDialogInitialize(CpuContext ctx) { - var result = Interlocked.Exchange(ref _initialized, 1) == 0 - ? 0 - : AlreadyInitialized; + // Treat repeated initialization as success. The dialog service is process-global in + // this HLE implementation and has no per-call resources to recreate. + Interlocked.Exchange(ref _initialized, 1); + Interlocked.Exchange(ref _status, StatusInitialized); + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + [SysAbiExport( + Nid = "ePw-kqZmelo", + ExportName = "sceMsgDialogTerminate", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceMsgDialog")] + public static int MsgDialogTerminate(CpuContext ctx) + { + Interlocked.Exchange(ref _initialized, 0); + Interlocked.Exchange(ref _status, StatusNone); + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + } + + [SysAbiExport( + Nid = "b06Hh0DPEaE", + ExportName = "sceMsgDialogOpen", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceMsgDialog")] + public static int MsgDialogOpen(CpuContext ctx) + { + LogDialogMessage(ctx, ctx[CpuRegister.Rdi]); + + // There is no host popup to actually show. Complete immediately with "finished" so a + // guest polling loop (GetStatus/UpdateStatus -> GetResult -> Close) sees a dismissed + // dialog on its very first poll instead of spinning forever waiting for user input. + Interlocked.Exchange(ref _status, StatusFinished); + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + } + + // Best-effort extraction of the dialog text so fatal-error popups are visible in the + // log even though no host dialog is shown. The PS5 SceMsgDialogParam layout is not + // fully known, so chase every qword in the struct that points at readable text - one + // level deep, then a second level for nested sub-param structs. + private static void LogDialogMessage(CpuContext ctx, ulong paramAddress) + { + if (paramAddress == 0) + { + Console.Error.WriteLine("[LOADER][INFO] sceMsgDialogOpen: param=NULL"); + return; + } + + Console.Error.WriteLine($"[LOADER][INFO] sceMsgDialogOpen: param=0x{paramAddress:X12}"); + + Span head = stackalloc byte[0xA0]; + if (ctx.Memory.TryRead(paramAddress, head)) + { + DumpPointerStrings(ctx, paramAddress, head, "param", chaseNested: true); + } + } + + private static void DumpPointerStrings(CpuContext ctx, ulong baseAddress, ReadOnlySpan bytes, string label, bool chaseNested) + { + Span nested = stackalloc byte[0x40]; + for (var offset = 0; offset + 8 <= bytes.Length; offset += 8) + { + var value = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(bytes[offset..]); + if (value < 0x10000 || value == baseAddress) + { + continue; + } + + var text = TryReadPrintableText(ctx, value); + if (text is not null) + { + Console.Error.WriteLine($"[LOADER][INFO] {label}+0x{offset:X2} -> 0x{value:X12} text=\"{text}\""); + } + else if (chaseNested && ctx.Memory.TryRead(value, nested)) + { + DumpPointerStrings(ctx, value, nested, $"{label}+0x{offset:X2}", chaseNested: false); + } + } + } + + private static string? TryReadPrintableText(CpuContext ctx, ulong address) + { + Span bytes = stackalloc byte[256]; + if (!ctx.Memory.TryRead(address, bytes)) + { + return null; + } + + var nullIndex = bytes.IndexOf((byte)0); + if (nullIndex < 2) + { + return null; + } + + var candidate = bytes[..nullIndex]; + foreach (var b in candidate) + { + if (b is < 0x20 or > 0x7E && b != (byte)'\n' && b != (byte)'\r' && b != (byte)'\t') + { + return null; + } + } + + return System.Text.Encoding.ASCII.GetString(candidate).Replace("\n", "\\n").Replace("\r", "\\r"); + } + + [SysAbiExport( + Nid = "CWVW78Qc3fI", + ExportName = "sceMsgDialogGetStatus", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceMsgDialog")] + public static int MsgDialogGetStatus(CpuContext ctx) => SetReturn(ctx, Volatile.Read(ref _status)); + + [SysAbiExport( + Nid = "6fIC3XKt2k0", + ExportName = "sceMsgDialogUpdateStatus", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceMsgDialog")] + public static int MsgDialogUpdateStatus(CpuContext ctx) => SetReturn(ctx, Volatile.Read(ref _status)); + + [SysAbiExport( + Nid = "Lr8ovHH9l6A", + ExportName = "sceMsgDialogGetResult", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceMsgDialog")] + public static int MsgDialogGetResult(CpuContext ctx) + { + var resultAddress = ctx[CpuRegister.Rdi]; + if (resultAddress == 0) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + Span result = stackalloc byte[ResultSize]; + result.Clear(); + if (!ctx.Memory.TryWrite(resultAddress, result)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + } + + [SysAbiExport( + Nid = "HTrcDKlFKuM", + ExportName = "sceMsgDialogClose", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceMsgDialog")] + public static int MsgDialogClose(CpuContext ctx) + { + Interlocked.Exchange(ref _status, StatusFinished); + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + } + + private static int SetReturn(CpuContext ctx, int result) + { ctx[CpuRegister.Rax] = unchecked((ulong)result); return result; } diff --git a/src/SharpEmu.Libs/CxxAbiExports.cs b/src/SharpEmu.Libs/CxxAbiExports.cs index b696427..1d174dc 100644 --- a/src/SharpEmu.Libs/CxxAbiExports.cs +++ b/src/SharpEmu.Libs/CxxAbiExports.cs @@ -93,7 +93,7 @@ public static class CxaGuardExports } [SysAbiExport( - Nid = "", + Nid = "9rAeANT2tyE", ExportName = "__cxa_guard_release", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libc")] @@ -142,7 +142,7 @@ public static class CxaGuardExports } [SysAbiExport( - Nid = "", + Nid = "2emaaluWzUw", ExportName = "__cxa_guard_abort", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libc")] diff --git a/src/SharpEmu.Libs/Kernel/KernelExports.cs b/src/SharpEmu.Libs/Kernel/KernelExports.cs index f6beba2..b1f0f38 100644 --- a/src/SharpEmu.Libs/Kernel/KernelExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelExports.cs @@ -73,6 +73,21 @@ public static class KernelExports LibraryName = "libKernel")] public static int Exit(CpuContext ctx) => RequestProcessExit(ctx, "exit"); + [SysAbiExport( + Nid = "L1SBTkC+Cvw", + ExportName = "abort", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Abort(CpuContext ctx) + { + // Route through the same graceful guest-entry-exit path as exit(): letting the call + // fall through to the host's native abort() does not unwind the guest thread cleanly. + Console.Error.WriteLine("[LOADER][INFO] abort() called by guest - terminating"); + GuestThreadExecution.RequestCurrentEntryExit("abort", -1); + ctx[CpuRegister.Rax] = unchecked((ulong)(-1L)); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + [SysAbiExport( Nid = "XKRegsFpEpk", ExportName = "catchReturnFromMain", @@ -263,20 +278,14 @@ public static class KernelExports ExportName = "pthread_create", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] - public static int PosixPthreadCreate(CpuContext ctx) - { - return PthreadCreate(ctx); - } + public static int PosixPthreadCreate(CpuContext ctx) => PthreadCreate(ctx); [SysAbiExport( Nid = "Jmi+9w9u0E4", ExportName = "pthread_create_name_np", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] - public static int PosixPthreadCreateNameNp(CpuContext ctx) - { - return PthreadCreate(ctx); - } + public static int PosixPthreadCreateNameNp(CpuContext ctx) => PthreadCreate(ctx); [SysAbiExport( Nid = "3kg7rT0NQIs", @@ -353,10 +362,7 @@ public static class KernelExports ExportName = "pthread_join", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] - public static int PosixPthreadJoin(CpuContext ctx) - { - return PthreadJoin(ctx); - } + public static int PosixPthreadJoin(CpuContext ctx) => PthreadJoin(ctx); [SysAbiExport( Nid = "wuCroIGjt2g", @@ -454,4 +460,67 @@ public static class KernelExports ctx[CpuRegister.Rax] = unchecked((ulong)status); return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + + // Unidentified kernel NIDs observed at runtime; stubbed to return 0 until they are + // resolved against scripts/ps5_names.txt. + [SysAbiExport( + Nid = "9T2pDF2Ryqg", + ExportName = "sceKernelUnknown9T2p", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknown9T2p(CpuContext ctx) => SetZeroReturn(ctx); + + [SysAbiExport( + Nid = "TU-d9PfIHPM", + ExportName = "sceKernelUnknownTU", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknownTU(CpuContext ctx) => SetZeroReturn(ctx); + + [SysAbiExport( + Nid = "iWQWrwiSt8A", + ExportName = "sceKernelUnknowniWQW", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknowniWQW(CpuContext ctx) => SetZeroReturn(ctx); + + [SysAbiExport( + Nid = "KuOmgKoqCdY", + ExportName = "sceKernelUnknownKuOmg", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknownKuOmg(CpuContext ctx) => SetZeroReturn(ctx); + + [SysAbiExport( + Nid = "RenI1lL1WFk", + ExportName = "sceKernelUnknownRenI", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknownRenI(CpuContext ctx) => SetZeroReturn(ctx); + + [SysAbiExport( + Nid = "pQGpHYopAIY", + ExportName = "sceKernelUnknownpQGp", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknownpQGp(CpuContext ctx) => SetZeroReturn(ctx); + + [SysAbiExport( + Nid = "Rbvt+5Y2iEw", + ExportName = "sceKernelUnknownRbvt", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelUnknownRbvt(CpuContext ctx) => SetZeroReturn(ctx); + + // NOTE: "SHtAad20YYM"/"DIxvoy7Ngvk" are sce::Json::Value::getType/getInteger, and + // "3GPpjQdAMTw"/"9rAeANT2tyE"/"tsvEmnenz48" are __cxa_guard_acquire/__cxa_guard_release/ + // __cxa_atexit (verified by hashing against scripts/ps5_names.txt). Do not register them + // here as kernel mutex functions: the cxa guards are implemented in CxxAbiExports.cs and + // shadowing them breaks every C++ static-init guard in the game. + + private static int SetZeroReturn(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 0; + return 0; + } } diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 8f3c391..379b884 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -53,6 +53,8 @@ public static class KernelMemoryCompatExports private const int OrbisVirtualQueryInfoSize = 72; private const int OrbisKernelMaximumNameLength = 32; private const uint MemCommit = 0x1000; + private const uint MemReserve = 0x2000; + private const uint MemRelease = 0x8000; private const uint HostPageNoAccess = 0x01; private const uint HostPageReadOnly = 0x02; private const uint HostPageReadWrite = 0x04; @@ -121,6 +123,7 @@ public static class KernelMemoryCompatExports private static int _hostMemoryWriteFallbackCount; private static int _hostMemoryReadFallbackCount; private static int _nullWcscpyRecoveryCount; + private static int _nullStrcasecmpRecoveryCount; private static string? _cachedApp0Root; private static string? _cachedDownload0Root; @@ -143,6 +146,13 @@ public static class KernelMemoryCompatExports [return: MarshalAs(UnmanagedType.Bool)] private static extern bool VirtualProtect(nint lpAddress, nuint dwSize, uint flNewProtect, out uint lpflOldProtect); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint VirtualAlloc(nint lpAddress, nuint dwSize, uint flAllocationType, uint flProtect); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool VirtualFree(nint lpAddress, nuint dwSize, uint dwFreeType); + private sealed class OpenDirectory { public required string Path { get; init; } @@ -151,7 +161,7 @@ public static class KernelMemoryCompatExports } private readonly record struct DirectAllocation(ulong Start, ulong Length, int MemoryType); - private readonly record struct LibcHeapAllocation(nint BaseAddress, nuint Size, nuint Alignment); + private readonly record struct LibcHeapAllocation(nint BaseAddress, nuint Size, nuint Alignment, bool IsGuarded); private readonly record struct MappedRegion(ulong Address, ulong Length, int Protection, bool IsFlexible, bool IsDirect, ulong DirectStart); private readonly record struct BatchMapEntry(ulong Start, ulong Offset, ulong Length, byte Protection, byte Type, int Operation); @@ -186,6 +196,14 @@ public static class KernelMemoryCompatExports ulong length, ulong alignment, out ulong address) + => TryAllocateHleData(ctx, length, alignment, OrbisProtCpuReadWrite, out address); + + internal static bool TryAllocateHleData( + CpuContext ctx, + ulong length, + ulong alignment, + int protection, + out ulong address) { address = 0; if (length == 0 || length > int.MaxValue) @@ -200,7 +218,7 @@ public static class KernelMemoryCompatExports var desiredAddress = AlignUp( _nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress, effectiveAlignment); - if (!TryReserveGuestVirtualRange(ctx, desiredAddress, mappedLength, OrbisProtCpuReadWrite, effectiveAlignment, out address) || + if (!TryReserveGuestVirtualRange(ctx, desiredAddress, mappedLength, protection, effectiveAlignment, out address) || address == 0) { return false; @@ -210,7 +228,7 @@ public static class KernelMemoryCompatExports _mappedRegions[address] = new MappedRegion( address, mappedLength, - OrbisProtCpuReadWrite, + protection, IsFlexible: false, IsDirect: false, DirectStart: 0); @@ -231,6 +249,69 @@ public static class KernelMemoryCompatExports return true; } + private static ulong _dummyVtableAddress; + private const int DummyVtableSlotCount = 64; + + // Some KexEngine objects (mutex wrappers, NGS2 system/rack/voice handles, etc.) are treated + // by guest code as C++ instances with a vtable pointer at offset 0. When an HLE constructor + // leaves that slot zeroed, the game's own virtual dispatch (call [ [obj]+N ]) reads a null + // vtable and jumps through address N, crashing (e.g. "CALL qword ptr [RAX+0x58]" with RAX=0). + // This allocates one shared, executable no-op stub plus a vtable of pointers to it, so any + // HLE object constructor can make virtual calls on its output land safely. + internal static bool TryWriteDummyVtable(CpuContext ctx, ulong objectAddress) + { + if (objectAddress == 0 || !TryEnsureDummyVtable(ctx, out var vtableAddress)) + { + return false; + } + + return ctx.TryWriteUInt64(objectAddress, vtableAddress); + } + + private static bool TryEnsureDummyVtable(CpuContext ctx, out ulong vtableAddress) + { + lock (_memoryGate) + { + if (_dummyVtableAddress != 0) + { + vtableAddress = _dummyVtableAddress; + return true; + } + + const int executableReadWrite = OrbisProtCpuRead | OrbisProtCpuWrite | OrbisProtCpuExec; + if (!TryAllocateHleData(ctx, 0x1000, 0x1000, executableReadWrite, out var block)) + { + vtableAddress = 0; + return false; + } + + // xor eax, eax; ret - every dummy virtual method just returns 0 and does nothing else. + if (!ctx.Memory.TryWrite(block, new byte[] { 0x31, 0xC0, 0xC3 })) + { + vtableAddress = 0; + return false; + } + + var table = new byte[DummyVtableSlotCount * sizeof(ulong)]; + for (var i = 0; i < DummyVtableSlotCount; i++) + { + BinaryPrimitives.WriteUInt64LittleEndian(table.AsSpan(i * sizeof(ulong), sizeof(ulong)), block); + } + + var tableAddress = block + 0x100; + if (!ctx.Memory.TryWrite(tableAddress, table)) + { + vtableAddress = 0; + return false; + } + + Console.Error.WriteLine($"[LOADER][INFO] Dummy vtable ready: stub=0x{block:X16} vtable=0x{tableAddress:X16} slots={DummyVtableSlotCount}"); + _dummyVtableAddress = tableAddress; + vtableAddress = tableAddress; + return true; + } + } + internal static void RegisterReservedVirtualRange(ulong address, ulong length) { if (address == 0 || length == 0) @@ -281,6 +362,18 @@ public static class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // Longer null-dst memsets are unrecoverable, but RAX must still be set - leaving it + // stale here previously let callers that do `buf = memset(...)` carry on with a + // garbage "buffer" pointer instead of a clean NULL, causing a *different*, + // confusingly-located crash further downstream. + var largeRecoveryIndex = Interlocked.Increment(ref _nullMemsetRecoveryCount); + if (largeRecoveryIndex <= 8) + { + Console.Error.WriteLine( + $"[LOADER][WARNING] memset null-dst (len>0x20) recovery#{largeRecoveryIndex}: rip=0x{ctx.Rip:X16} len=0x{length:X} val=0x{value:X2}"); + } + + ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; } @@ -304,6 +397,7 @@ public static class KernelMemoryCompatExports Console.WriteLine("!!! CRITICAL: Bad Memset Call !!!"); Console.WriteLine($"Called from RIP: 0x{ctx.Rip:X}"); Console.WriteLine($"dst=0x{destination:X} val=0x{value:X2} len=0x{length:X}"); + ctx[CpuRegister.Rax] = destination; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } @@ -536,6 +630,80 @@ public static class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport( + Nid = "AV6ipCNa4Rw", + ExportName = "strcasecmp", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Strcasecmp(CpuContext ctx) + { + var left = ctx[CpuRegister.Rdi]; + var right = ctx[CpuRegister.Rsi]; + if (left == 0 || right == 0) + { + var recoveryIndex = Interlocked.Increment(ref _nullStrcasecmpRecoveryCount); + if (recoveryIndex <= 16) + { + var otherAddress = left == 0 ? right : left; + var otherText = otherAddress != 0 && TryReadNullTerminatedUtf8(ctx, otherAddress, 256, out var text) + ? text + : ""; + _ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip); + Console.Error.WriteLine( + $"[LOADER][WARNING] strcasecmp null-arg recovery#{recoveryIndex}: ret=0x{returnRip:X16} left=0x{left:X16} right=0x{right:X16} other=\"{otherText}\""); + } + + // Real strcasecmp(NULL, x) is undefined behaviour and previously crashed inside the + // LLE-routed implementation. Treat it as "not equal" instead so callers doing + // `if (strcasecmp(a, b) == 0)` degrade gracefully rather than taking down the guest. + ctx[CpuRegister.Rax] = left == right ? 0uL : 1uL; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + if (!TryCompareStringsCaseInsensitive(ctx, left, right, limit: ulong.MaxValue, out var compare)) + { + ctx[CpuRegister.Rax] = 1; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + ctx[CpuRegister.Rax] = unchecked((ulong)compare); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + private static bool TryCompareStringsCaseInsensitive(CpuContext ctx, ulong left, ulong right, ulong limit, out int compare) + { + compare = 0; + if (left == 0 || right == 0) + { + return false; + } + + var max = limit == ulong.MaxValue ? 1_048_576UL : Math.Min(limit, 1_048_576UL); + Span leftByte = stackalloc byte[1]; + Span rightByte = stackalloc byte[1]; + for (ulong i = 0; i < max; i++) + { + if (!TryReadCompat(ctx, left + i, leftByte) || + !TryReadCompat(ctx, right + i, rightByte)) + { + return false; + } + + var leftLower = ToAsciiLower(leftByte[0]); + var rightLower = ToAsciiLower(rightByte[0]); + compare = leftLower - rightLower; + if (compare != 0 || leftByte[0] == 0 || rightByte[0] == 0) + { + return true; + } + } + + compare = 0; + return true; + } + + private static byte ToAsciiLower(byte value) => value is >= (byte)'A' and <= (byte)'Z' ? (byte)(value + 32) : value; + [SysAbiExport( Nid = "0nV21JjYCH8", ExportName = "wcsncpy", @@ -587,6 +755,54 @@ public static class KernelMemoryCompatExports return VsnprintfCore(ctx); } + // sprintf/vsprintf are served from the same HLE formatting engine as snprintf/vsnprintf + // instead of falling through to the game's bundled libc. + [SysAbiExport( + Nid = "tcVi5SivF7Q", + ExportName = "sprintf", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Sprintf(CpuContext ctx) + { + var destination = ctx[CpuRegister.Rdi]; + var formatAddress = ctx[CpuRegister.Rsi]; + + if (!TryReadCString(ctx, formatAddress, 1_048_576, out var formatBytes)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + var format = Encoding.UTF8.GetString(formatBytes); + var rendered = FormatStringFromVarArgs(ctx, format, firstGpArgIndex: 2); + return WriteSnprintfOutput(ctx, destination, ulong.MaxValue, rendered); + } + + [SysAbiExport( + Nid = "jbz9I9vkqkk", + ExportName = "vsprintf", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Vsprintf(CpuContext ctx) + { + var destination = ctx[CpuRegister.Rdi]; + var formatAddress = ctx[CpuRegister.Rsi]; + var vaListAddress = ctx[CpuRegister.Rdx]; + + if (!TryReadCString(ctx, formatAddress, 1_048_576, out var formatBytes)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + var format = Encoding.UTF8.GetString(formatBytes); + if (!TryCreateVaListCursor(ctx, vaListAddress, out var vaCursor)) + { + return WriteSnprintfOutput(ctx, destination, ulong.MaxValue, formatBytes); + } + + var rendered = FormatString(ctx, format, ref vaCursor); + return WriteSnprintfOutput(ctx, destination, ulong.MaxValue, rendered); + } + [SysAbiExport( Nid = "nJz16JE1txM", ExportName = "swprintf", @@ -945,15 +1161,27 @@ public static class KernelMemoryCompatExports { var destination = ctx[CpuRegister.Rdi]; var source = ctx[CpuRegister.Rsi]; - var count = (int)Math.Min(ctx[CpuRegister.Rdx], int.MaxValue); - if (count < 0) + var rawCount = ctx[CpuRegister.Rdx]; + + // A garbage/absurd count (observed as e.g. 0xA7560035 from the same still-unidentified + // upstream bug that also feeds bad lengths to memset) must not reach + // GC.AllocateUninitializedArray: attempting a multi-GB allocation from a guest-thread + // call context corrupted the CLR outright ("Invalid Program: attempted to call a + // UnmanagedCallersOnly method from managed code") instead of throwing a normal + // exception. Reject anything above a sane bound before allocating. + const ulong maxSaneCount = 512UL * 1024 * 1024; + if (rawCount > maxSaneCount) { + Console.Error.WriteLine($"[LOADER][WARNING] memcpy oversized count rejected: dst=0x{destination:X16} src=0x{source:X16} count=0x{rawCount:X}"); + ctx[CpuRegister.Rax] = destination; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; } + var count = (int)rawCount; var payload = GC.AllocateUninitializedArray(count); if (count > 0 && (!TryReadCompat(ctx, source, payload) || !TryWriteCompat(ctx, destination, payload))) { + ctx[CpuRegister.Rax] = destination; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } @@ -1196,6 +1424,24 @@ public static class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // Was unresolved (returning the 0x80020002 sentinel, then crashing when the guest + // dereferenced it) - the game's own heap instrumentation calls this hook when it + // detects a corrupted/invalid block, not the emulator's allocator, so this is purely + // a diagnostic sink: log what was reported and return success so the caller continues. + [SysAbiExport( + Nid = "al3JzFI9MQ0", + ExportName = "sceLibcInternalHeapErrorReportForGame", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceLibcInternal")] + public static int LibcInternalHeapErrorReportForGame(CpuContext ctx) + { + Console.Error.WriteLine( + $"[LOADER][WARN] sceLibcInternalHeapErrorReportForGame: rdi=0x{ctx[CpuRegister.Rdi]:X16} " + + $"rsi=0x{ctx[CpuRegister.Rsi]:X16} rdx=0x{ctx[CpuRegister.Rdx]:X16} rcx=0x{ctx[CpuRegister.Rcx]:X16}"); + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + [SysAbiExport( Nid = "DfivPArhucg", ExportName = "memcmp", @@ -4151,7 +4397,7 @@ public static class KernelMemoryCompatExports return FileMode.Open; } - private static string ResolveGuestPath(string guestPath) + public static string ResolveGuestPath(string guestPath) { if (string.IsNullOrWhiteSpace(guestPath)) { @@ -4475,7 +4721,7 @@ public static class KernelMemoryCompatExports private static bool IsMutatingOpen(int flags) => (flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND)) != 0; - private static bool IsReadOnlyGuestMutationPath(string guestPath) + public static bool IsReadOnlyGuestMutationPath(string guestPath) { var normalized = NormalizeGuestStatCachePath(guestPath); return normalized is not null && @@ -4754,7 +5000,7 @@ public static class KernelMemoryCompatExports return destination == 0 || destinationCount == 0 || TryWriteWideTerminator(ctx, destination); } - private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value) + public static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value) { value = string.Empty; if (address == 0 || maxLength <= 0) @@ -5543,6 +5789,26 @@ public static class KernelMemoryCompatExports alignment = NormalizeLibcAlignment(alignment); var actualSize = requestedSize == 0 ? 1u : requestedSize; + // This intentionally uses host guard pages for allocations made through the libc HLE. + // It turns an overrun that reaches the next page into an immediate access violation at + // the offending guest instruction, rather than allowing it to poison a later import. + var guardHeap = string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_GUARD_HEAP"), + "1", + StringComparison.Ordinal); + + if (guardHeap && TryAllocateGuardedLibcHeap(actualSize, alignment, zeroFill, out address)) + { + return true; + } + + // Do not silently fall back to an unguarded allocation when guard heap was explicitly + // requested: a failed setup must remain visible to the caller/reproducer. + if (guardHeap) + { + return false; + } + nuint totalSize; try { @@ -5578,7 +5844,7 @@ public static class KernelMemoryCompatExports var alignedAddress = AlignUp(unchecked((ulong)baseAddress) + (ulong)IntPtr.Size, (ulong)alignment); lock (_libcAllocGate) { - _libcAllocations[alignedAddress] = new LibcHeapAllocation(baseAddress, actualSize, alignment); + _libcAllocations[alignedAddress] = new LibcHeapAllocation(baseAddress, actualSize, alignment, IsGuarded: false); } try @@ -5598,6 +5864,51 @@ public static class KernelMemoryCompatExports return true; } + private static unsafe bool TryAllocateGuardedLibcHeap(nuint actualSize, nuint alignment, bool zeroFill, out ulong address) + { + address = 0; + const nuint pageSize = 0x1000; + + try + { + var effectiveAlignment = Math.Max(alignment, pageSize); + var usableSize = checked((nuint)AlignUp((ulong)actualSize, (ulong)pageSize)); + var reservationSize = checked(pageSize + effectiveAlignment - 1 + usableSize + pageSize); + var baseAddress = VirtualAlloc(0, reservationSize, MemCommit | MemReserve, HostPageReadWrite); + if (baseAddress == 0) + { + return false; + } + + var alignedAddress = AlignUp(unchecked((ulong)baseAddress) + (ulong)pageSize, (ulong)effectiveAlignment); + var guardAddress = alignedAddress + (ulong)usableSize; + if (!VirtualProtect((nint)guardAddress, pageSize, HostPageNoAccess, out _)) + { + _ = VirtualFree(baseAddress, 0, MemRelease); + return false; + } + + lock (_libcAllocGate) + { + _libcAllocations[alignedAddress] = new LibcHeapAllocation(baseAddress, actualSize, alignment, IsGuarded: true); + } + + if (zeroFill) + { + NativeMemory.Clear((void*)alignedAddress, actualSize); + } + + Console.Error.WriteLine( + $"[LOADER][TRACE] guard-heap alloc: ptr=0x{alignedAddress:X16} size=0x{actualSize:X} guard=0x{guardAddress:X16}"); + address = alignedAddress; + return true; + } + catch (OverflowException) + { + return false; + } + } + private static unsafe bool TryReallocateLibcHeap(ulong existingAddress, ulong requestedSize, out ulong resizedAddress) { resizedAddress = 0; @@ -5706,7 +6017,14 @@ public static class KernelMemoryCompatExports } } - Marshal.FreeHGlobal(allocation.BaseAddress); + if (allocation.IsGuarded) + { + _ = VirtualFree(allocation.BaseAddress, 0, MemRelease); + } + else + { + Marshal.FreeHGlobal(allocation.BaseAddress); + } } private static bool TryMultiplyAllocationSize(ulong left, ulong right, out nuint size) @@ -6312,4 +6630,15 @@ public static class KernelMemoryCompatExports sum = left + right; return sum >= left; } + + [SysAbiExport( + Nid = "KMcEa+rHsIo", + ExportName = "sceKernelMapMemory", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int KernelMapMemory(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } } diff --git a/src/SharpEmu.Libs/LibcStdioExports.cs b/src/SharpEmu.Libs/LibcStdioExports.cs new file mode 100644 index 0000000..b69e88b --- /dev/null +++ b/src/SharpEmu.Libs/LibcStdioExports.cs @@ -0,0 +1,467 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using SharpEmu.HLE; +using SharpEmu.Libs.Kernel; + +namespace SharpEmu.Libs.LibcStdio; + +public static class LibcStdioExports +{ + private const int MaxPathLength = 4096; + private const int MaxModeLength = 16; + private const int ReadChunkSize = 1024 * 1024; + + private static readonly ConcurrentDictionary _fileHandles = new(); + private static long _nextHandle = 0x1000 - 8; + + private static readonly bool _traceStdio = + string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STDIO"), "1", StringComparison.Ordinal); + + private const int CtypeTableLowerBound = -128; + private const int CtypeTableUpperBound = 255; + private const int CtypeTableEntryCount = CtypeTableUpperBound - CtypeTableLowerBound + 1; // 384 entries * 2 bytes = 768 bytes + + // Mirrors the Dinkumware ctype bitmask layout (the CRT Sony's libc is based on; + // _Getpctype/_Getptolower/_Getptoupper are Dinkumware accessor names). This is NOT the + // MSVC/UCRT layout: e.g. Dinkumware puts digit at 0x20 where UCRT puts control, so + // serving a UCRT-shaped table made the game's bundled printf engine misparse every + // %-directive (fatal-error messages rendered empty) and made its mcpp preprocessor + // treat 'a'-'f' as control characters (UCRT _HEX=0x80 reads as Dinkumware _BB) and + // drop them from identifiers ("texture" -> "txtur"). Titles that bundle their own + // libc bypass this table entirely (see IsSafeLleLibcExport in DirectExecutionBackend); + // this fallback only serves titles that import _Getpctype without shipping one. + private const ushort CtypeXDigit = 0x001; // _XD '0'-'9', 'A'-'F', 'a'-'f' + private const ushort CtypeUpper = 0x002; // _UP 'A'-'Z' + private const ushort CtypeSpace = 0x004; // _SP ' ' (isspace = _CN|_SP|_XS) + private const ushort CtypePunct = 0x008; // _PU punctuation + private const ushort CtypeLower = 0x010; // _LO 'a'-'z' + private const ushort CtypeDigit = 0x020; // _DI '0'-'9' + private const ushort CtypeControlSpace = 0x040; // _CN '\t','\n','\v','\f','\r' + private const ushort CtypeControl = 0x080; // _BB other control characters + private const ushort CtypeBlank = 0x400; // _XB ' ' and '\t' + + private static readonly object _ctypeTableGate = new(); + private static nint _ctypeTableBase; + + [SysAbiExport( + Nid = "xeYO4u7uyJ0", + ExportName = "fopen", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Fopen(CpuContext ctx) + { + var pathAddress = ctx[CpuRegister.Rdi]; + var modeAddress = ctx[CpuRegister.Rsi]; + + if (pathAddress == 0 || modeAddress == 0 || + !KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, pathAddress, MaxPathLength, out var guestPath) || + !KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, modeAddress, MaxModeLength, out var mode) || + !TryParseFopenMode(mode, out var fileMode, out var fileAccess)) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + var hostPath = KernelMemoryCompatExports.ResolveGuestPath(guestPath); + if (fileAccess != FileAccess.Read && KernelMemoryCompatExports.IsReadOnlyGuestMutationPath(guestPath)) + { + if (_traceStdio) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] fopen: guest='{guestPath}' host='{hostPath}' mode='{mode}' -> PERMISSION_DENIED (read-only path)"); + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED; + } + + try + { + if (fileAccess != FileAccess.Read) + { + var parentDirectory = Path.GetDirectoryName(hostPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + } + + var stream = new FileStream(hostPath, fileMode, fileAccess, FileShare.ReadWrite); + if (mode.StartsWith('a') && fileAccess == FileAccess.ReadWrite) + { + stream.Seek(0, SeekOrigin.End); + } + + var handle = unchecked((ulong)Interlocked.Add(ref _nextHandle, 8)); + _fileHandles[handle] = stream; + + if (_traceStdio) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] fopen: guest='{guestPath}' host='{hostPath}' mode='{mode}' -> OK handle=0x{handle:X} length={stream.Length}"); + } + + ctx[CpuRegister.Rax] = handle; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (_traceStdio) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] fopen: guest='{guestPath}' host='{hostPath}' mode='{mode}' -> FAILED {ex.GetType().Name}: {ex.Message}"); + } + + ctx[CpuRegister.Rax] = 0; + return ex is UnauthorizedAccessException + ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED + : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; + } + } + + [SysAbiExport( + Nid = "rQFVBXp-Cxg", + ExportName = "fseek", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Fseek(CpuContext ctx) + { + var handle = ctx[CpuRegister.Rdi]; + var offset = unchecked((long)ctx[CpuRegister.Rsi]); + var whence = unchecked((int)ctx[CpuRegister.Rdx]); + + if (!_fileHandles.TryGetValue(handle, out var stream) || !TryGetSeekOrigin(whence, out var origin)) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(int)-1); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + try + { + stream.Seek(offset, origin); + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + catch (IOException) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(int)-1); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + } + + [SysAbiExport( + Nid = "Qazy8LmXTvw", + ExportName = "ftell", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Ftell(CpuContext ctx) + { + var handle = ctx[CpuRegister.Rdi]; + + if (!_fileHandles.TryGetValue(handle, out var stream)) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(long)-1); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + try + { + ctx[CpuRegister.Rax] = unchecked((ulong)stream.Position); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + catch (IOException) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(long)-1); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + } + + [SysAbiExport( + Nid = "uodLYyUip20", + ExportName = "fclose", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Fclose(CpuContext ctx) + { + var handle = ctx[CpuRegister.Rdi]; + + if (!_fileHandles.TryRemove(handle, out var stream)) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(int)-1); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + try + { + stream.Dispose(); + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + catch (IOException) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(int)-1); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + } + + [SysAbiExport( + Nid = "lbB+UlZqVG0", + ExportName = "fread", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Fread(CpuContext ctx) + { + var destination = ctx[CpuRegister.Rdi]; + var elementSize = ctx[CpuRegister.Rsi]; + var elementCount = ctx[CpuRegister.Rdx]; + var handle = ctx[CpuRegister.Rcx]; + + if (elementSize == 0 || elementCount == 0) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + if (destination == 0 || !_fileHandles.TryGetValue(handle, out var stream)) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + var totalRequested = elementSize * elementCount; + var buffer = ArrayPool.Shared.Rent((int)Math.Min((ulong)ReadChunkSize, totalRequested)); + ulong totalRead = 0; + + try + { + while (totalRead < totalRequested) + { + var request = (int)Math.Min((ulong)buffer.Length, totalRequested - totalRead); + var read = stream.Read(buffer, 0, request); + if (read <= 0) + { + break; + } + + if (!ctx.Memory.TryWrite(destination + totalRead, buffer.AsSpan(0, read))) + { + ctx[CpuRegister.Rax] = totalRead / elementSize; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + totalRead += (ulong)read; + } + } + catch (IOException) + { + ctx[CpuRegister.Rax] = totalRead / elementSize; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + if (_traceStdio) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] fread: handle=0x{handle:X} requested={totalRequested} read={totalRead} pos={stream.Position}"); + } + + ctx[CpuRegister.Rax] = totalRead / elementSize; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + [SysAbiExport( + Nid = "KdP-nULpuGw", + ExportName = "fgets", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int Fgets(CpuContext ctx) + { + var destination = ctx[CpuRegister.Rdi]; + var maxCount = unchecked((int)ctx[CpuRegister.Rsi]); + var handle = ctx[CpuRegister.Rdx]; + + if (destination == 0 || maxCount <= 0 || !_fileHandles.TryGetValue(handle, out var stream)) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + var buffer = ArrayPool.Shared.Rent(maxCount - 1); + var count = 0; + + try + { + while (count < maxCount - 1) + { + var b = stream.ReadByte(); + if (b < 0) + { + break; + } + + buffer[count++] = (byte)b; + if (b == '\n') + { + break; + } + } + + if (count == 0) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; + } + + Span withNul = stackalloc byte[count + 1]; + buffer.AsSpan(0, count).CopyTo(withNul); + withNul[count] = 0; + + if (!ctx.Memory.TryWrite(destination, withNul)) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + ctx[CpuRegister.Rax] = destination; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + [SysAbiExport( + Nid = "sUP1hBaouOw", + ExportName = "_Getpctype", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libc")] + public static int GetPctype(CpuContext ctx) + { + ctx[CpuRegister.Rax] = unchecked((ulong)EnsureCtypeTable()); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + private static unsafe nint EnsureCtypeTable() + { + lock (_ctypeTableGate) + { + if (_ctypeTableBase != 0) + { + return _ctypeTableBase; + } + + var storage = Marshal.AllocHGlobal(CtypeTableEntryCount * sizeof(ushort)); + var entries = new Span((void*)storage, CtypeTableEntryCount); + for (var i = 0; i < CtypeTableEntryCount; i++) + { + var c = i + CtypeTableLowerBound; + entries[i] = c is >= 0 and <= 0x7F ? ComputeCtypeFlags(c) : (ushort)0; + } + + // Table is indexed as base[c] for c in [-128, 255], so the pointer handed to the + // guest must point at the c == 0 entry, not the start of the allocation. + _ctypeTableBase = storage - (CtypeTableLowerBound * sizeof(ushort)); + return _ctypeTableBase; + } + } + + private static ushort ComputeCtypeFlags(int c) + { + var isUpper = c is >= 'A' and <= 'Z'; + var isLower = c is >= 'a' and <= 'z'; + var isDigit = c is >= '0' and <= '9'; + var isHex = isDigit || (c is >= 'A' and <= 'F') || (c is >= 'a' and <= 'f'); + var isAlnum = isUpper || isLower || isDigit; + // In the Dinkumware table '\t'..'\r' carry _CN (isspace and iscntrl both match via + // _CN) while plain ' ' carries _SP; keeping them distinct is what keeps isprint(' ') + // true and isprint('\t') false. + var isControlSpace = c is >= 0x09 and <= 0x0D; + var isControl = (c is >= 0x00 and <= 0x08) || (c is >= 0x0E and <= 0x1F) || c == 0x7F; + var isPrintable = c is >= 0x20 and <= 0x7E; + var isPunct = isPrintable && !isAlnum && c != ' '; + + ushort flags = 0; + if (isUpper) flags |= CtypeUpper; + if (isLower) flags |= CtypeLower; + if (isDigit) flags |= CtypeDigit; + if (isHex) flags |= CtypeXDigit; + if (c == ' ') flags |= CtypeSpace | CtypeBlank; + if (c == '\t') flags |= CtypeBlank; + if (isControlSpace) flags |= CtypeControlSpace; + if (isPunct) flags |= CtypePunct; + if (isControl) flags |= CtypeControl; + return flags; + } + + private static bool TryParseFopenMode(string mode, out FileMode fileMode, out FileAccess fileAccess) + { + fileMode = FileMode.Open; + fileAccess = FileAccess.Read; + + if (string.IsNullOrEmpty(mode)) + { + return false; + } + + var plus = mode.Contains('+'); + switch (mode[0]) + { + case 'r': + fileMode = FileMode.Open; + fileAccess = plus ? FileAccess.ReadWrite : FileAccess.Read; + return true; + + case 'w': + fileMode = FileMode.Create; + fileAccess = plus ? FileAccess.ReadWrite : FileAccess.Write; + return true; + + case 'a': + if (plus) + { + fileMode = FileMode.OpenOrCreate; + fileAccess = FileAccess.ReadWrite; + } + else + { + fileMode = FileMode.Append; + fileAccess = FileAccess.Write; + } + + return true; + + default: + return false; + } + } + + private static bool TryGetSeekOrigin(int whence, out SeekOrigin origin) + { + switch (whence) + { + case 0: + origin = SeekOrigin.Begin; + return true; + + case 1: + origin = SeekOrigin.Current; + return true; + + case 2: + origin = SeekOrigin.End; + return true; + + default: + origin = default; + return false; + } + } +} diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs index f309a96..a21fced 100644 --- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs +++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs @@ -13,7 +13,7 @@ public static class Ngs2Exports private const int OrbisNgs2ErrorInvalidSystemHandle = unchecked((int)0x804A0230); private const int OrbisNgs2ErrorInvalidRackHandle = unchecked((int)0x804A0261); private const int OrbisNgs2ErrorInvalidVoiceHandle = unchecked((int)0x804A0300); - private const ulong HandleStorageSize = 0x20; + private const ulong HandleStorageSize = 0x1000; private const int RenderBufferInfoSize = 0x18; private const ulong MaximumRenderBufferSize = 16 * 1024 * 1024; @@ -197,12 +197,19 @@ public static class Ngs2Exports lock (StateGate) { return ctx.SetReturn( - Voices.ContainsKey(ctx[CpuRegister.Rdi]) - ? 0 + Voices.ContainsKey(ctx[CpuRegister.Rdi]) + ? 0 : OrbisNgs2ErrorInvalidVoiceHandle); } } + [SysAbiExport( + Nid = "-4GCfYdNF1s", + ExportName = "sceNgs2VoiceControl", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNgs2")] + public static int Ngs2VoiceControlAlt(CpuContext ctx) => Ngs2VoiceControl(ctx); + [SysAbiExport( Nid = "AbYvTOZ8Pts", ExportName = "sceNgs2VoiceRunCommands", @@ -210,6 +217,27 @@ public static class Ngs2Exports LibraryName = "libSceNgs2")] public static int Ngs2VoiceRunCommands(CpuContext ctx) => Ngs2VoiceControl(ctx); + [SysAbiExport( + Nid = "-TOuuAQ-buE", + ExportName = "sceNgs2VoiceRunCommands", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNgs2")] + public static int Ngs2VoiceRunCommandsAlt(CpuContext ctx) => Ngs2VoiceControl(ctx); + + [SysAbiExport( + Nid = "x8qnXqh-tiM", + ExportName = "sceNgs2VoiceGetState", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNgs2")] + public static int Ngs2VoiceGetState(CpuContext ctx) => ctx.SetReturn(0); + + [SysAbiExport( + Nid = "wPJGwI2RM2I", + ExportName = "sceNgs2VoiceGetStateFlags", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNgs2")] + public static int Ngs2VoiceGetStateFlags(CpuContext ctx) => ctx.SetReturn(0); + [SysAbiExport( Nid = "i0VnXM-C9fc", ExportName = "sceNgs2SystemRender", @@ -263,7 +291,6 @@ public static class Ngs2Exports private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle) { - handle = 0; if (!KernelMemoryCompatExports.TryAllocateHleData(ctx, HandleStorageSize, 16, out handle)) { return false; @@ -271,11 +298,18 @@ public static class Ngs2Exports Span data = stackalloc byte[(int)HandleStorageSize]; data.Clear(); - BinaryPrimitives.WriteUInt64LittleEndian(data[0..8], handle); BinaryPrimitives.WriteUInt64LittleEndian(data[8..16], ownerHandle); BinaryPrimitives.WriteUInt32LittleEndian(data[16..20], 1); BinaryPrimitives.WriteUInt32LittleEndian(data[24..28], type); - return ctx.Memory.TryWrite(handle, data); + if (!ctx.Memory.TryWrite(handle, data)) + { + return false; + } + + // Offset 0 doubles as a vtable slot for guest code that treats this handle as a C++ + // object; stamp it with the shared dummy vtable instead of a self-referential value. + KernelMemoryCompatExports.TryWriteDummyVtable(ctx, handle); + return true; } private static bool TryClearGuestBuffer(CpuContext ctx, ulong address, ulong length) diff --git a/src/SharpEmu.Libs/Np/NpTrophy2Exports.cs b/src/SharpEmu.Libs/Np/NpTrophy2Exports.cs new file mode 100644 index 0000000..4a978db --- /dev/null +++ b/src/SharpEmu.Libs/Np/NpTrophy2Exports.cs @@ -0,0 +1,108 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using System.Buffers.Binary; + +namespace SharpEmu.Libs.Np; + +public static class NpTrophy2Exports +{ + private static int _nextContext = 1; + private static int _nextHandle = 1; + + [SysAbiExport( + Nid = "Bagshr7OQ6Q", + ExportName = "sceNpTrophy2CreateContext", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2CreateContext(CpuContext ctx) + { + return WriteIdAndReturn(ctx, ctx[CpuRegister.Rdi], ref _nextContext); + } + + [SysAbiExport( + Nid = "Gz1rmUZpROM", + ExportName = "sceNpTrophy2CreateHandle", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2CreateHandle(CpuContext ctx) + { + return WriteIdAndReturn(ctx, ctx[CpuRegister.Rdi], ref _nextHandle); + } + + [SysAbiExport( + Nid = "sysY2FHYff4", + ExportName = "sceNpTrophy2DestroyContext", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2DestroyContext(CpuContext ctx) => ReturnOk(ctx); + + [SysAbiExport( + Nid = "d8P11CI40KE", + ExportName = "sceNpTrophy2DestroyHandle", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2DestroyHandle(CpuContext ctx) => ReturnOk(ctx); + + [SysAbiExport( + Nid = "fYapWA9xVmA", + ExportName = "sceNpTrophy2AbortHandle", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2AbortHandle(CpuContext ctx) => ReturnOk(ctx); + + [SysAbiExport( + Nid = "bIDov3wBu5Q", + ExportName = "sceNpTrophy2RegisterContext", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2RegisterContext(CpuContext ctx) => ReturnOk(ctx); + + [SysAbiExport( + Nid = "sUXGfNMalIo", + ExportName = "sceNpTrophy2RegisterUnlockCallback", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2RegisterUnlockCallback(CpuContext ctx) => ReturnOk(ctx); + + [SysAbiExport( + Nid = "wVqxM58sIKs", + ExportName = "sceNpTrophy2UnregisterUnlockCallback", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2UnregisterUnlockCallback(CpuContext ctx) => ReturnOk(ctx); + + [SysAbiExport( + Nid = "EHQEDVXZ0TI", + ExportName = "sceNpTrophy2ShowTrophyList", + Target = Generation.Gen5, + LibraryName = "libSceNpTrophy2")] + public static int NpTrophy2ShowTrophyList(CpuContext ctx) => ReturnOk(ctx); + + private static int WriteIdAndReturn(CpuContext ctx, ulong outAddress, ref int nextId) + { + if (outAddress == 0) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + Span idBytes = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(idBytes, nextId); + if (!ctx.Memory.TryWrite(outAddress, idBytes)) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + nextId++; + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); + } + + private static int ReturnOk(CpuContext ctx) => SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); + + private static int SetReturn(CpuContext ctx, OrbisGen2Result result) + { + ctx[CpuRegister.Rax] = unchecked((ulong)(int)result); + return (int)result; + } +} diff --git a/src/SharpEmu.Libs/SystemGesture/SystemGestureExports.cs b/src/SharpEmu.Libs/SystemGesture/SystemGestureExports.cs index 2832e06..f3d3a48 100644 --- a/src/SharpEmu.Libs/SystemGesture/SystemGestureExports.cs +++ b/src/SharpEmu.Libs/SystemGesture/SystemGestureExports.cs @@ -72,4 +72,15 @@ public static class SystemGestureExports ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + + [SysAbiExport( + Nid = "1MMK0W-kMgA", + ExportName = "sceSystemGestureAppendTouchRecognizer", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSystemGesture")] + public static int SystemGestureAppendTouchRecognizer(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } } diff --git a/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs b/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs index 89365d2..f5d632f 100644 --- a/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs +++ b/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs @@ -96,4 +96,11 @@ public static class SystemServiceExports VulkanVideoPresenter.HideSplashScreen(); return ctx.SetReturn(0); } + + [SysAbiExport( + Nid = "3s8cHiCBKBE", + ExportName = "sceSystemServiceReportAbnormalTermination", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSystemService")] + public static int SystemServiceReportAbnormalTermination(CpuContext ctx) => ctx.SetReturn(0); } diff --git a/src/SharpEmu.Libs/UserService/UserServiceExports.cs b/src/SharpEmu.Libs/UserService/UserServiceExports.cs index faee600..3738dc9 100644 --- a/src/SharpEmu.Libs/UserService/UserServiceExports.cs +++ b/src/SharpEmu.Libs/UserService/UserServiceExports.cs @@ -18,6 +18,9 @@ public static class UserServiceExports private const string PrimaryUserName = "SharpEmu"; private static int _loginEventDelivered; + private static readonly bool _traceUserService = + string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USER_SERVICE"), "1", StringComparison.Ordinal); + [SysAbiExport( Nid = "j3YMu1MVNNo", ExportName = "sceUserServiceInitialize", @@ -42,9 +45,11 @@ public static class UserServiceExports return ctx.SetReturn(OrbisUserServiceErrorInvalidArgument); } - return ctx.TryWriteInt32(userIdAddress, PrimaryUserId) - ? ctx.SetReturn(0) - : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + var result = ctx.TryWriteInt32(userIdAddress, PrimaryUserId) + ? 0 + : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + TraceUserService($"get_initial_user out=0x{userIdAddress:X16} value={PrimaryUserId} result=0x{result:X8}"); + return ctx.SetReturn(result); } [SysAbiExport( @@ -124,9 +129,12 @@ public static class UserServiceExports Span output = stackalloc byte[nameBytes.Length + 1]; nameBytes.CopyTo(output); - return ctx.Memory.TryWrite(nameAddress, output) - ? ctx.SetReturn(0) - : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + var result = ctx.Memory.TryWrite(nameAddress, output) + ? 0 + : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + TraceUserService( + $"get_user_name user={userId} out=0x{nameAddress:X16} capacity=0x{capacity:X} value='{PrimaryUserName}' result=0x{result:X8}"); + return ctx.SetReturn(result); } [SysAbiExport( @@ -152,4 +160,12 @@ public static class UserServiceExports ? ctx.SetReturn(0) : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + + private static void TraceUserService(string message) + { + if (_traceUserService) + { + Console.Error.WriteLine($"[LOADER][TRACE] user_service.{message}"); + } + } } diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 0933e8c..1793823 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -38,6 +38,7 @@ public static class VideoOutExports private const ulong SceVideoOutPixelFormatA2R10G10B10 = 0x88060000; private const ulong SceVideoOutPixelFormatA2R10G10B10Srgb = 0x88000000; private const ulong SceVideoOutPixelFormatA2R10G10B10Bt2020Pq = 0x88740000; + private const ulong SceVideoOutInternalEventVblank = 0x5; private const ulong SceVideoOutInternalEventFlip = 0x6; private const short OrbisKernelEventFilterVideoOut = -13; @@ -53,9 +54,15 @@ public static class VideoOutExports Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"), "1", StringComparison.Ordinal); + private static readonly bool _logVideoOutSync = string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_SYNC"), + "1", + StringComparison.Ordinal); private static long _frameRateWindowStart = Stopwatch.GetTimestamp(); private static long _submittedFrameCount; private static long _presentedFrameCount; + private static long _vblankSignalCount; + private static long _flipSubmitCount; public static void ConfigureApplicationInfo(string? title, string? titleId, string? version) { @@ -99,6 +106,7 @@ public static class VideoOutExports public float Gamma { get; set; } = 1.0f; public VideoOutBufferGroup?[] Groups { get; } = new VideoOutBufferGroup?[MaxDisplayBufferGroups]; public VideoOutBufferSlot[] BufferSlots { get; } = CreateBufferSlots(); + public List VblankEvents { get; } = new(); public List FlipEvents { get; } = new(); } @@ -312,11 +320,48 @@ public static class VideoOutExports } Thread.Sleep(1); - lock (_stateGate) + SignalVblank(port); + + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + [SysAbiExport( + Nid = "Xru92wHJRmg", + ExportName = "sceVideoOutAddVblankEvent", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceVideoOut")] + public static int VideoOutAddVblankEvent(CpuContext ctx) + { + var equeue = ctx[CpuRegister.Rdi]; + var handle = unchecked((int)ctx[CpuRegister.Rsi]); + if (!TryGetPort(handle, out var port)) { - port.VblankCount++; + return OrbisVideoOutErrorInvalidHandle; } + if (!KernelEventQueueCompatExports.IsValidEqueue(equeue)) + { + return OrbisVideoOutErrorInvalidEventQueue; + } + + var userData = ctx[CpuRegister.Rdx]; + lock (_stateGate) + { + var existingIndex = port.VblankEvents.FindIndex(registration => registration.Equeue == equeue); + if (existingIndex >= 0) + { + port.VblankEvents[existingIndex] = new FlipEventRegistration(equeue, userData); + } + else + { + port.VblankEvents.Add(new FlipEventRegistration(equeue, userData)); + } + } + + // Some engines wait on this queue before issuing their first flip. Provide a first + // edge now; later calls to WaitVblank advance the same notification sequence. + SignalVblank(port); + TraceVideoOut($"videoout.add_vblank_event eq=0x{equeue:X16} handle={handle} udata=0x{userData:X16}"); return (int)OrbisGen2Result.ORBIS_GEN2_OK; } @@ -371,6 +416,53 @@ public static class VideoOutExports return SubmitFlip(ctx, handle, bufferIndex, flipMode, flipArg, submitGpuImage: true); } + // Struct layout matches the classic SceVideoOutFlipStatus (40 bytes): + // count, processTime, tsc, flipArg, currentBuffer, flipPendingNum. + [SysAbiExport( + Nid = "SbU3dwp80lQ", + ExportName = "sceVideoOutGetFlipStatus", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceVideoOut")] + public static int VideoOutGetFlipStatus(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + var statusAddress = ctx[CpuRegister.Rsi]; + if (statusAddress == 0) + { + return OrbisVideoOutErrorInvalidAddress; + } + + VideoOutPortState? port; + lock (_stateGate) + { + _ports.TryGetValue(handle, out port); + } + + if (port is null) + { + return OrbisVideoOutErrorInvalidHandle; + } + + ulong count; + long flipArg; + uint currentBuffer; + lock (_stateGate) + { + count = port.FlipCount; + flipArg = 0; + currentBuffer = unchecked((uint)port.CurrentBuffer); + } + + KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x00, count); + KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x08, 0); + KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x10, 0); + KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x18, unchecked((ulong)flipArg)); + KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x20, currentBuffer); + + TraceVideoOut($"videoout.get_flip_status handle={handle} count={count} currentBuffer={currentBuffer}"); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + [SysAbiExport( Nid = "zgXifHT9ErY", ExportName = "sceVideoOutIsFlipPending", @@ -407,7 +499,8 @@ public static class VideoOutExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } - if (filter != OrbisKernelEventFilterVideoOut || ident != SceVideoOutInternalEventFlip) + if (filter != OrbisKernelEventFilterVideoOut || + ident is not (SceVideoOutInternalEventVblank or SceVideoOutInternalEventFlip)) { return OrbisVideoOutErrorInvalidEvent; } @@ -436,7 +529,8 @@ public static class VideoOutExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } - if (filter != OrbisKernelEventFilterVideoOut || ident != SceVideoOutInternalEventFlip) + if (filter != OrbisKernelEventFilterVideoOut || + ident is not (SceVideoOutInternalEventVblank or SceVideoOutInternalEventFlip)) { return OrbisVideoOutErrorInvalidEvent; } @@ -756,6 +850,38 @@ public static class VideoOutExports return groupIndex < 0 ? groupIndex : setIndex; } + private static void SignalVblank(VideoOutPortState port) + { + List vblankEvents; + ulong eventHint; + lock (_stateGate) + { + port.VblankCount++; + eventHint = SceVideoOutInternalEventVblank | + ((port.VblankCount & 0x0000_FFFF_FFFF_FFFFUL) << 16); + vblankEvents = new List(port.VblankEvents); + } + + var signalCount = Interlocked.Increment(ref _vblankSignalCount); + + foreach (var vblankEvent in vblankEvents) + { + _ = KernelEventQueueCompatExports.TriggerDisplayEvent( + vblankEvent.Equeue, + SceVideoOutInternalEventVblank, + OrbisKernelEventFilterVideoOut, + eventHint, + vblankEvent.UserData); + } + + if (_logVideoOutSync && (signalCount <= 8 || signalCount % 60 == 0)) + { + Console.Error.WriteLine( + $"[LOADER][SYNC] vblank#{signalCount} handle={port.Handle} count={port.VblankCount} " + + $"queues={vblankEvents.Count} hint=0x{eventHint:X16}"); + } + } + private static int SubmitFlip( CpuContext ctx, int handle, @@ -790,11 +916,14 @@ public static class VideoOutExports flipEvents = new List(port.FlipEvents); } + var guestImageSubmitted = false; + ulong guestImageAddress = 0; if (submitGpuImage && bufferIndex >= 0 && TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer)) { - _ = VulkanVideoPresenter.TrySubmitGuestImage( + guestImageAddress = displayBuffer.Address; + guestImageSubmitted = VulkanVideoPresenter.TrySubmitGuestImage( displayBuffer.Address, displayBuffer.Width, displayBuffer.Height, @@ -819,6 +948,15 @@ public static class VideoOutExports flipEvent.UserData); } + var flipCount = Interlocked.Increment(ref _flipSubmitCount); + if (_logVideoOutSync && (flipCount <= 8 || flipCount % 60 == 0)) + { + Console.Error.WriteLine( + $"[LOADER][SYNC] flip#{flipCount} handle={handle} buffer={bufferIndex} " + + $"addr=0x{guestImageAddress:X16} submitted={guestImageSubmitted} " + + $"flipQueues={flipEvents.Count}"); + } + TraceVideoOut($"videoout.submit_flip handle={handle} index={bufferIndex} mode={flipMode} arg={flipArg} events={flipEvents.Count}"); ReportFrameRate(presented: false); return (int)OrbisGen2Result.ORBIS_GEN2_OK; @@ -902,6 +1040,16 @@ public static class VideoOutExports TraceVideoOut( $"videoout.register_buffers handle={port.Handle} group={groupIndex} start={startIndex} count={addresses.Length} fmt=0x{attribute.PixelFormat:X} tile={attribute.TilingMode} {attribute.Width}x{attribute.Height} pitch={attribute.PitchInPixel}"); VulkanVideoPresenter.EnsureStarted(attribute.Width, attribute.Height); + + var guestFormat = MapPixelFormatToGuestTextureFormat(attribute.PixelFormat); + if (guestFormat != 0) + { + foreach (var address in addresses) + { + VulkanVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat); + } + } + return groupIndex; } } @@ -1123,6 +1271,22 @@ public static class VideoOutExports ? 4u : 0u; + // Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags + // VulkanVideoPresenter._availableGuestImages keys on (see VulkanVideoPresenter. + // GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit). + private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat) => + NormalizePixelFormat(pixelFormat) switch + { + SceVideoOutPixelFormatA8R8G8B8Srgb or + SceVideoOutPixelFormatA8B8G8R8Srgb or + SceVideoOutPixelFormatB8G8R8A8Unorm or + SceVideoOutPixelFormatR8G8B8A8Unorm => 56u, + SceVideoOutPixelFormatA2R10G10B10 or + SceVideoOutPixelFormatA2R10G10B10Srgb or + SceVideoOutPixelFormatA2R10G10B10Bt2020Pq => 9u, + _ => 0u, + }; + private static ulong NormalizePixelFormat(ulong pixelFormat) { if (GetBytesPerPixel(pixelFormat) != 0) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 8a3935a..9a6f279 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -182,6 +182,13 @@ internal static unsafe class VulkanVideoPresenter private static long _enqueuedGuestWorkSequence; private static long _completedGuestWorkSequence; + private static bool ShouldTracePresentedGuestImageContentsForDiagnostics() + { + var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"); + return string.Equals(mode, "1", StringComparison.Ordinal) || + string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase); + } + public static void EnsureStarted(uint width, uint height) { if (width == 0 || height == 0) @@ -279,6 +286,11 @@ internal static unsafe class VulkanVideoPresenter return; } + if (ShouldTracePresentedGuestImageContentsForDiagnostics()) + { + Console.Error.WriteLine($"[LOADER][TRACE] vk.submit_call kind=Submit {width}x{height}"); + } + lock (_gate) { if (_closed) @@ -319,6 +331,11 @@ internal static unsafe class VulkanVideoPresenter return; } + if (ShouldTracePresentedGuestImageContentsForDiagnostics()) + { + Console.Error.WriteLine($"[LOADER][TRACE] vk.submit_call kind=SubmitGuestDraw({drawKind}) {width}x{height}"); + } + lock (_gate) { if (_closed || @@ -376,6 +393,12 @@ internal static unsafe class VulkanVideoPresenter return; } + if (ShouldTracePresentedGuestImageContentsForDiagnostics()) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] vk.submit_call kind=SubmitTranslatedDraw {width}x{height} textures={textures.Count}"); + } + lock (_gate) { if (_closed) @@ -442,6 +465,13 @@ internal static unsafe class VulkanVideoPresenter return; } + if (ShouldTracePresentedGuestImageContentsForDiagnostics()) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] vk.submit_call kind=SubmitOffscreenTranslatedDraw " + + $"target=0x{target.Address:X16} {target.Width}x{target.Height} textures={textures.Count}"); + } + lock (_gate) { if (_closed) @@ -569,9 +599,31 @@ internal static unsafe class VulkanVideoPresenter var traceSubmission = false; lock (_gate) { - if (_closed || - !_availableGuestImages.ContainsKey(address)) + var known = _availableGuestImages.ContainsKey(address); + if (ShouldTracePresentedGuestImageContentsForDiagnostics()) { + Console.Error.WriteLine( + $"[LOADER][TRACE] vk.submit_call kind=TrySubmitGuestImage addr=0x{address:X16} " + + $"{width}x{height} known={known}"); + } + + if (_closed) + { + return false; + } + + // The caller (VideoOutExports.SubmitFlip) reports the flip as successful either + // way, so an unregistered address means the frame is dropped silently; warn once + // per address so that shows up in the log. + if (!known) + { + if (_tracedGuestImageSubmissions.Add((address, width, height))) + { + Console.Error.WriteLine( + $"[LOADER][WARN] vk.submit_guest_image_unknown addr=0x{address:X16} " + + $"{width}x{height} - flip target was never registered as a render output"); + } + return false; } @@ -588,6 +640,19 @@ internal static unsafe class VulkanVideoPresenter RequiredGuestWorkSequence: 0, IsSplash: false, GuestImageAddress: address); + if (_thread is not null) + { + return true; + } + + _windowWidth = width; + _windowHeight = height; + _thread = new Thread(Run) + { + IsBackground = true, + Name = "SharpEmu Vulkan VideoOut", + }; + _thread.Start(); } if (traceSubmission) @@ -601,6 +666,21 @@ internal static unsafe class VulkanVideoPresenter return true; } + // Display buffers registered through sceVideoOutRegisterBuffers are valid flip targets + // even when no AGC render-target write to them was ever observed. + internal static void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) + { + if (address == 0 || guestFormat == 0) + { + return; + } + + lock (_gate) + { + _availableGuestImages[address] = guestFormat; + } + } + internal static bool IsGpuGuestImageAvailable( ulong address, uint format, @@ -5033,14 +5113,19 @@ internal static unsafe class VulkanVideoPresenter _renderPass, _extent); if (ShouldTracePresentedGuestImageContentsForDiagnostics() && - !_firstGuestDrawPresented && - translatedResources.Textures is - [ - { GuestImage: { } guestImage }, - ] && - _tracedGuestImageContents.Add(guestImage.Address)) + !_firstGuestDrawPresented) { - TraceGuestImageContents(guestImage); + Console.Error.WriteLine( + $"[LOADER][TRACE] vk.translated_draw kind={presentation.DrawKind} " + + $"textures={translatedResources.Textures.Length}"); + foreach (var boundTexture in translatedResources.Textures) + { + if (boundTexture.GuestImage is { } guestImage && + _tracedGuestImageContents.Add(guestImage.Address)) + { + TraceGuestImageContents(guestImage); + } + } } } catch (Exception exception) @@ -5810,13 +5895,6 @@ internal static unsafe class VulkanVideoPresenter return false; } - private static bool ShouldTracePresentedGuestImageContentsForDiagnostics() - { - var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"); - return string.Equals(mode, "1", StringComparison.Ordinal) || - string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase); - } - private static bool ShouldTraceVulkanResources() => string.Equals( Environment.GetEnvironmentVariable("SHARPEMU_LOG_VK_RESOURCES"),