Compare commits

..

19 Commits

Author SHA1 Message Date
kuba 6e2878f2ff Add commit hash to video window title (#93) 2026-07-13 11:21:41 +03:00
ParantezTech 2e09b015bc Merge branch 'main' of https://github.com/par274/sharpemu 2026-07-13 01:10:08 +03:00
ParantezTech 7b9efd1539 [readme] update screenshot for dreaming sarah 2026-07-13 01:09:45 +03:00
Kaotic fbf2c2d00a [GUI] Add detachable console window (#91)
Add a Split button to detach the emulator console into a resizable window.

The detached console keeps search, auto-scroll, copy, and clear controls. The main console hides while detached and restores when the split window closes if it was previously open.
2026-07-13 00:16:21 +03:00
kuba 8c1507777c [agc] Reset transparent Chowdren effect-layer fills (#83)
Treat the exact untextured transparent-black premultiplied fill used by Chowdren as an overwrite. This prevents Dreaming Sarah fog and vignette render targets from accumulating across frames; SHARPEMU_DISABLE_TRANSPARENT_FILL_CLEAR=1 restores prior behavior.
2026-07-12 19:26:48 +03:00
Berk 8ebc43e758 [github] Fix placeholder formatting in game compatibility template (#84) 2026-07-12 18:46:03 +03:00
j92580498-max 5aadb7495a Libs: add libSceDiscMap HLE exports (ported from Kyty) (#79)
Port the libSceDiscMap stubs from Kyty (InoriRus/Kyty, MIT) into the
SysAbiExport model. Disc-installed titles probe these NIDs on most file
accesses to decide whether a read must be redirected to the disc drive;
answering that every request is already resident on internal storage
keeps I/O on the regular file system path instead of failing with
unresolved-import errors.

- sceDiscMapIsRequestOnHDD (lbQKqsERhtE): validates args, writes 1 to
  the result pointer, returns 0
- fJgP+wqifno / ioKMruft1ek: zero-fill the three output pointers,
  return 0 (names not present in ps5_names.txt; kept as descriptive
  Unknown exports like the existing sceKernelUnknown* convention)
- DISC_MAP_ERROR_INVALID_ARGUMENT (0x81100001) on null pointers,
  matching the documented libSceDiscMap error range
- optional tracing via SHARPEMU_LOG_DISCMAP=1

Co-authored-by: j92580498-max <252151737+j92580498-max@users.noreply.github.com>
2026-07-12 18:24:11 +03:00
Foued Attar cdee77521e Fix UnmanagedCallersOnly boot crash & Core Engine Improvements (CPU, HLE, AGC) (#81)
* [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.

* [hle] clock_gettime clock ids, AudioOut2 canary fix, NID rebinds, new offline stubs

- clock_gettime (lLMT9vJAck0): support CLOCK_SECOND and the *_PRECISE/*_FAST
  variants instead of returning EINVAL, which games treated as fatal and
  retried in a tight loop.
- AudioOut2: context param writes shrunk to the guest-observed layout (the
  old 0x80-byte reset smashed the stack canary at +0x60 and killed audio
  init); ContextQueryMemory writes the single u64 the caller expects.
- NGS2: dropped wrong alt-NID aliases (they hash to sceImeUpdate,
  sceMouseRead, sceSystemGestureUpdateAllTouchRecognizer - now bound in
  their real libraries); added sceNgs2PanInit; fixed VoiceGetState NIDs.
- New verified stubs: sceUltInitialize, sceNpUniversalDataSystemDestroyHandle,
  sceNpGetOnlineId, sceNpGetNpReachabilityState, sceImeKeyboardOpen,
  sceImeKeyboardGetResourceId, sceMouseOpen, sceKernelAprGetFileSize.
- Import gateway: unwind guest workers at dispatch during backend teardown;
  env-gated SHARPEMU_LOG_THREAD_MODE tracing.

* [cpu] Isolate guest execution on native worker threads

Guest entry stubs no longer run above CLR-managed frames: each run is handed
to a pooled raw OS thread whose loop is emitted native code. While guest code
executes there is not a single managed frame on the thread and it stays in
preemptive GC mode, so the GC never walks a frame chain interleaved with
guest stubs that carry no CLR unwind info (the ReversePInvokeBadTransition /
UnmanagedCallersOnly FailFast class of crashes on pumped guest threads).

- NativeGuestExecutor: CreateThread + emitted run loop (WaitForSingleObject,
  UnmanagedCallersOnly prologue/epilogue, entry stub call, SetEvent). The
  prologue rebinds guest TLS, the host-RSP slot, thread affinity and the
  Active* ambient per run, so workers carry no guest identity and pool
  freely; the orchestrating managed thread parks in a preemptive wait.
- All three entry sites route through RunGuestEntryStub: guest thread
  entries, blocked-continuation resumes, and the main ExecuteEntry.
- Teardown stops workers before any executable stub or TLS index they
  reference is freed; a worker that will not stop leaks its loop instead of
  freeing running code.
- Kill switch: SHARPEMU_DISABLE_NATIVE_GUEST_WORKERS=1 restores the inline
  calli path.
2026-07-12 18:04:12 +03:00
RedBlackAka d2fca37716 [GUI] Basic hotkey and full-screen support (#75) 2026-07-12 12:54:37 +03:00
GamingChelsea 5bee81df70 [GUI] Console Search & Log Path Selector (#74)
* Add console search filter and log file path selector

* Add console search filter and log file path selector

* Increase max width of ConsoleSearchBox
2026-07-12 02:31:20 +03:00
Brando d4bb282c49 GUI: Discord Rich Presence (#73)
Publish launcher status to Discord over its local IPC named pipe with
no external dependency: framed-JSON handshake and SET_ACTIVITY, a
background worker with reconnection and latest-state dedup, and silent
no-op behavior when Discord is not running.

- "Browsing the library" (game count, session elapsed) while idle;
  "Playing <game>" with title id and elapsed time during emulation.
  Activities always carry timestamps: Discord accepts but does not
  render activities without them
- Presence flips back to browsing immediately on Stop instead of
  waiting for process exit: a game wedged in a GPU driver call can
  outlive termination for a while. Stop also terminates the job
  object so the whole child tree dies even in that state
- Toggle in the Options panel (persisted); client id configurable in
  gui-settings.json; SHARPEMU_LOG_DISCORD=1 traces queue/send/failure
2026-07-12 00:54:42 +03:00
Foued Attar e1cf5b13ef [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.
2026-07-12 00:22:48 +03:00
j92580498-max de4fc1e1a8 Add sceKernelNanosleep to libKernel (#72)
Implements the sceKernelNanosleep export (NID QvsZxomvUHs) for both Gen4
and Gen5 targets. Reads the requested timespec from guest memory,
validates the pointer and tv_nsec range, sleeps for the requested
duration, and zeroes the optional remaining-time struct on completion.

Also fixes: reading rqtp as a guest pointer to a timespec (tv_sec/tv_nsec
int64 pair) instead of raw register values, and keeps the optimized
sceKernelUsleep short-sleep path untouched.

Co-authored-by: par274 <par274@users.noreply.github.com>
2026-07-11 23:42:26 +03:00
Mike Saito 5e76554514 core: unify clock dispatch logic, add precise clocks, and enforce coalesced time writes (#71)
Comprehensive refactoring of the system time subsystem to unify clock dispatching, support precise clock extensions, and secure memory boundaries against partial state corruption.

Centralized Clock Dispatch Engine:
- Extracted shared elapsed-tick calculation and clock-routing math into a unified internal static bool ResolveClockTime() dispatch engine under KernelRuntimeCompatExports.cs.
- Moved all clock identifiers from KernelMemoryCompatExports to KernelRuntimeCompatExports as internal const int constants to eliminate cross-file duplication while preserving raw compiler switch-case layout optimizations.
- Added native alias mapping support for CLOCK_REALTIME_PRECISE (9) and CLOCK_MONOTONIC_PRECISE (11).
- Hardened the Orbis sceKernelClockGettime path by routing it through the new dispatcher, resolving a pre-existing logic flaw where any non-zero clock_id incorrectly fell back to monotonic time. Invalid IDs now properly fail with ORBIS_GEN2_ERROR_INVALID_ARGUMENT.

Coalesced Single-Transaction Memory Writes:
- Replaced consecutive isolated 8-byte scalar writes across POSIX clock_gettime, gettimeofday, and Orbis sceKernelClockGettime/sceKernelGettimeofday with safe single-transaction 16-byte stackalloc byte buffer writes via BinaryPrimitives and ctx.Memory.TryWrite. This entirely prevents partial memory state corruption on virtual page boundaries.
- Implemented a single 8-byte coalesced zero-fill transaction for the deprecated/legacy timezone buffer (timezoneAddress != 0), aligning it with standard FreeBSD stub behavior.
- Standardized POSIX failure path routines. Write faults cleanly issue TrySetErrno(ctx, Efault) while safely omitting explicit manual Rax writes, letting the import dispatcher natively sign-extend the return -1 value to 0xFFFFFFFFFFFFFFFF.

Zero-Alloc Host RDTSC Execution Stub:
- Patched CreateRdtscReader() to stream native architecture opcodes out of stack-allocated spans directly into host executable memory zones (VirtualAlloc) via unsafe { Buffer.MemoryCopy(...) }, completely removing the high-frequency .ToArray() runtime allocation overhead on the hot path.

Files: KernelRuntimeCompatExports.cs, KernelMemoryCompatExports.cs
2026-07-11 23:39:44 +03:00
Mike Saito 3a24db567f core: implement coalesced writes for gettimeofday and set POSIX EFAULT (#70)
Follow-up task to enforce coalesced guest memory writes within the gettimeofday subsystem, removing remaining partial-write risks on virtual memory page boundaries.

* sceKernelGettimeofday Hardening: Replaced consecutive isolated 8-byte scalar writes with a single 16-byte coalesced transaction buffer using stackalloc byte[16] and BinaryPrimitives. It preserves native Orbis semantics by returning ORBIS_GEN2_ERROR_MEMORY_FAULT on failure states without side-effect partial-writes.
* POSIX gettimeofday Compliance:
  - Applied the identical single-transaction 16-byte write pattern for the timeval structure.
  - Implemented a single 8-byte coalesced zero-fill transaction for the deprecated/legacy timezone buffer (timezoneAddress != 0) using BinaryPrimitives.WriteInt32LittleEndian, aligning it with standard FreeBSD stub behavior.
  - Integrated proper TrySetErrno(ctx, Efault) tracking upon write failures. The method safely omits explicit manual Rax writes on error paths, allowing the import dispatcher to cleanly sign-extend the return -1 value to 0xFFFFFFFFFFFFFFFF.

Out of scope: Subsystem clock and timeval validation is now fully complete; no further temporal partial-write vulnerabilities remain within the core runtime memory compat layers.

Files: KernelRuntimeCompatExports.cs
2026-07-11 23:05:36 +03:00
kostyaff a9b06974be [docs] Improve game compatibility report form (#69)
Signed-off-by: kostyaff <filipchukks@gmail.com>
2026-07-11 22:51:10 +03:00
Mike Saito 19added142 core: implement sceKernelGetCompiledSdkVersion based on target generation (#66)
Replaced the no-op stub for sceKernelGetCompiledSdkVersion with a proper runtime compliance implementation.

Runtime Validation: Added explicit NULL pointer verification for the destination buffer address (versionAddress == 0). It returns ORBIS_GEN2_ERROR_INVALID_ARGUMENT and sign-extends the target Rax register to 0xFFFFFFFF80020003, strictly mirroring the PthreadJoin error-handling pattern of this subsystem.
Target-Based SDK Fallback: Implemented deterministic fallback version routing based on ctx.TargetGeneration (0x05000000 for Gen4 and 0x09000000 for Gen5 standard Orbis layout). This ensures guest applications pass early firmware checks until native metadata extraction is implemented.
Atomic Memory Write: Secured the state write sequence via the native ctx.TryWriteUInt32 layer, correctly catching virtual memory page faults, propagating ORBIS_GEN2_ERROR_MEMORY_FAULT to Rax, and safely bypassing partial-write state corruption.
Out of scope (follow-up): Native parsing of the compiled SDK version flags directly out of the guest ELF note/metadata sections.
2026-07-11 22:18:26 +03:00
kostyaff 8a40251a1c [logging] Migrate SelfLoader and DirectExecutionBackend.Diagnostics to SharpEmuLog (#65)
Phase 2 of Console.Error.WriteLine → structured logging migration.

SelfLoader.cs (30 sites):
- Category: "Loader"
- TLS load_start/load_done, Segment info, DTPMOD64 patching → Debug
- ELF alignment mismatch, invalid symbol value skips, CRITICAL invalid patch → Warning
- Runtime symbol index populated, Initializers discovered → Info
- [FOCUS][SCAN/SKIP] relocation trace, [RELOC] target trace → Debug
- TryLoadTableBytes diagnostics → Debug (FAILED → Warning)
- ResolveMappedAddressOrFallback trace → Debug

DirectExecutionBackend.Diagnostics.cs (17 sites):
- Category: "Native" (Log field in main DirectExecutionBackend.cs partial)
- DumpRecentImportTrace → Info
- Suspicious unresolved pointer hits/cap → Warning
- ProbeReturnRip return-rip bytes/slots/PLT trace → Debug

Level mapping: [TRACE]/[FOCUS]/[RELOC]/[TEST] → Debug; [INFO] → Info;
[WARNING]/WARNING/CRITICAL/Skipping/FAILED → Warning.

[LOADER] prefix dropped — category is in LogEntry.

Console.WriteLine (stdout, ~25 sites in SelfLoader.cs) intentionally
left untouched — only Console.Error.WriteLine was in scope.

Build: 0 errors, 0 warnings.

Co-authored-by: Hermes Atlas <hermesatlas@example.com>
2026-07-11 22:17:40 +03:00
PandaCatz edb4eb86a2 [kernel] Wake blocked waiters on semaphore signal, cancel, and delete (#67)
sceKernelWaitSema parks a guest thread on the scheduler when the count is not
yet available, but sceKernelSignalSema only incremented the count and returned:
there was no WakeBlockedThreads call anywhere in the file, so a thread blocked
in WaitSema was never woken and the game hung there. sceKernelCancelSema and
sceKernelDeleteSema left parked waiters stranded the same way.

Give each semaphore a per-handle wake key and each waiter a small record with
the count it needs and a result slot. Signal, cancel, and delete wake the
waiters through the scheduler after releasing the semaphore lock, matching the
lock order the event flag and event queue paths already use. The wake handler
runs under the scheduler gate and consumes the count under the semaphore lock,
so a waiter needing more than is available stays parked while a smaller waiter
can still proceed; the resume handler hands the recorded result back as the
guest's return value.

Cancel bumps an epoch and delete sets a flag so woken waiters return what the
kernel returns in those cases: ECANCELED (0x80020055) for a canceled wait and
the EACCES-class 0x8002000D for a deleted semaphore. Delete succeeds even with
waiters present. Only the woken waiter's own handler adjusts the waiting-thread
count, so a waiter that parks during a cancel is not double-counted, and the
create path now wakes a waiter that raced onto the handle if the handle
write-back fails instead of stranding it.

This does not change the immediate paths: an available count is still consumed
inline, and a wait with a timeout pointer still returns immediately (honoring
the timeout through the scheduler is a separate change).

Verified with a block/wake harness that drives real guest threads through the
real import trampolines: signal-after-block, signal racing the park,
multi-waiter signal, need-count gating with a smaller waiter slipping past, and
cancel and delete with parked waiters including the reported waiter count, plus
event flag and event queue regression checks. Builds clean on Windows and
Linux.
2026-07-11 22:17:24 +03:00
45 changed files with 4635 additions and 537 deletions
+18 -3
View File
@@ -16,6 +16,11 @@ body:
Example title:
Demons Souls (PPSA01341)
**Before reporting:**
- Test the game on a **fresh build from `main`**, not an old release.
- Copy the **exact commit SHA** shown at the top of the log.
- `DEBUG` and `TRACE` lines are diagnostic context, not errors by themselves; report the last milestone or the first `WARNING`, `ERROR`, or `CRITICAL` line connected to the stop.
- type: input
id: game_name
attributes:
@@ -73,11 +78,20 @@ body:
id: logs
attributes:
label: Log File
description: Drag and drop your log file here, or click to browse
description: Attach the **full** log file (`.log`, `.txt`, or `.zip`). Do not paste only a fragment — the maintainer needs the complete log from launch to crash.
validations:
required: false
required: true
accept: ".log,.txt,.zip"
- type: input
id: last_milestone_first_error
attributes:
label: Last Milestone / First Error
description: Paste the **last useful milestone line** or the **first `WARNING`, `ERROR`, or `CRITICAL` line connected to the stop** from the log. `DEBUG`/`TRACE` lines do not count unless a real error follows them.
placeholder: "e.g. CpuEngine: native-only OR [ERROR] Native backend FAILED: ..."
validations:
required: true
- type: dropdown
id: operating_system
attributes:
@@ -119,7 +133,8 @@ body:
id: emulator_version
attributes:
label: Emulator Version / Commit
placeholder: e.g. v0.0.1 / a1b2c3d
description: Copy the **exact short SHA** from the top of the log. Do not enter just `0.0.1` without a commit hash.
placeholder: e.g. a1b2c3d
validations:
required: true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 101 KiB

+1
View File
@@ -39,3 +39,4 @@ Desktop.ini
ehthumbs.db
.vs/
.idea/
@@ -8,6 +8,7 @@ using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Cpu.Native;
@@ -43,7 +44,7 @@ public sealed partial class DirectExecutionBackend
{
return;
}
Console.Error.WriteLine($"[LOADER][INFO] Recent import calls ({_recentImportTraceCount}):");
Log.Info($" Recent import calls ({_recentImportTraceCount}):");
int num = (_recentImportTraceWriteIndex - _recentImportTraceCount + _recentImportTrace.Length) % _recentImportTrace.Length;
for (int i = 0; i < _recentImportTraceCount; i++)
{
@@ -51,8 +52,8 @@ public sealed partial class DirectExecutionBackend
var entry = _recentImportTrace[num2];
if (!string.IsNullOrEmpty(entry.Nid))
{
Console.Error.WriteLine(
$"[LOADER][INFO] #{entry.DispatchIndex} nid={entry.Nid} ret=0x{entry.ReturnRip:X16} " +
Log.Info(
$" #{entry.DispatchIndex} nid={entry.Nid} ret=0x{entry.ReturnRip:X16} " +
$"rdi=0x{entry.Arg0:X16} rsi=0x{entry.Arg1:X16} rdx=0x{entry.Arg2:X16}");
}
}
@@ -91,12 +92,12 @@ public sealed partial class DirectExecutionBackend
list.Add(num7);
if (num2 < 32)
{
Console.Error.WriteLine($"[LOADER][INFO] Suspicious unresolved pointer: slot=0x{num7:X16} value=0x{value2:X16}");
Log.Info($"Suspicious unresolved pointer: slot=0x{num7:X16} value=0x{value2:X16}");
num2++;
}
if (num >= 16384)
{
Console.Error.WriteLine($"[LOADER][WARNING] Suspicious unresolved pointer scan reached cap ({16384}); truncating.");
Log.Warning($"Suspicious unresolved pointer scan reached cap ({16384}); truncating.");
return list;
}
}
@@ -106,7 +107,7 @@ public sealed partial class DirectExecutionBackend
}
if (num != 0)
{
Console.Error.WriteLine($"[LOADER][WARNING] Suspicious unresolved pointer hits: {num}");
Log.Warning($"Suspicious unresolved pointer hits: {num}");
}
return list;
}
@@ -121,18 +122,18 @@ public sealed partial class DirectExecutionBackend
Span<byte> destination = stackalloc byte[128];
if (!cpuContext.Memory.TryRead(returnRip, destination))
{
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} return-rip probe: unreadable @0x{returnRip:X16}");
Log.Debug($"Import#{dispatchIndex} return-rip probe: unreadable @0x{returnRip:X16}");
return;
}
string value = BitConverter.ToString(destination.ToArray()).Replace("-", " ");
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} return-rip bytes @0x{returnRip:X16}: {value}");
Log.Debug($"Import#{dispatchIndex} return-rip bytes @0x{returnRip:X16}: {value}");
if (destination[0] == byte.MaxValue && (destination[1] == 21 || destination[1] == 37))
{
int num = BitConverter.ToInt32(destination.Slice(2, 4));
ulong num2 = returnRip + 6 + (ulong)num;
if (cpuContext.TryReadUInt64(num2, out var value2))
{
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} return-rip slot: [0x{num2:X16}] = 0x{value2:X16}");
Log.Debug($"Import#{dispatchIndex} return-rip slot: [0x{num2:X16}] = 0x{value2:X16}");
}
}
if (destination[0] == 72 && destination[1] == 139 && destination[2] == 5)
@@ -141,7 +142,7 @@ public sealed partial class DirectExecutionBackend
ulong num4 = returnRip + 7 + (ulong)num3;
if (cpuContext.TryReadUInt64(num4, out var value3))
{
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} return-rip mov-slot: [0x{num4:X16}] = 0x{value3:X16}");
Log.Debug($"Import#{dispatchIndex} return-rip mov-slot: [0x{num4:X16}] = 0x{value3:X16}");
}
}
for (int i = 0; i + 6 <= destination.Length; i++)
@@ -153,7 +154,7 @@ public sealed partial class DirectExecutionBackend
ulong num7 = num6 + 6 + (ulong)num5;
if (cpuContext.TryReadUInt64(num7, out var value4))
{
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} near-indirect @{num6:X16}: slot=0x{num7:X16} val=0x{value4:X16}");
Log.Debug($"Import#{dispatchIndex} near-indirect @{num6:X16}: slot=0x{num7:X16} val=0x{value4:X16}");
}
}
}
@@ -168,7 +169,7 @@ public sealed partial class DirectExecutionBackend
int rel32 = BitConverter.ToInt32(destination.Slice(i + 1, 4));
ulong callRip = returnRip + (ulong)i;
ulong target = unchecked((ulong)((long)(callRip + 5) + rel32));
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} near-call @{callRip:X16}: target=0x{target:X16}");
Log.Debug($"Import#{dispatchIndex} near-call @{callRip:X16}: target=0x{target:X16}");
for (int importIndex = 0; importIndex < _importEntries.Length; importIndex++)
{
if (_importEntries[importIndex].Address != target)
@@ -179,21 +180,21 @@ public sealed partial class DirectExecutionBackend
string nid = _importEntries[importIndex].Nid;
if (_moduleManager.TryGetExport(nid, out var export))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex} near-call import: index={importIndex} {export.LibraryName}:{export.Name} ({nid})");
Log.Debug(
$"Import#{dispatchIndex} near-call import: index={importIndex} {export.LibraryName}:{export.Name} ({nid})");
}
else
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex} near-call import: index={importIndex} nid={nid}");
Log.Debug(
$"Import#{dispatchIndex} near-call import: index={importIndex} nid={nid}");
}
break;
}
if (cpuContext.Memory.TryRead(target, targetBytes))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex} near-call target bytes @0x{target:X16}: " +
Log.Debug(
$"Import#{dispatchIndex} near-call target bytes @0x{target:X16}: " +
BitConverter.ToString(targetBytes.ToArray()).Replace("-", " "));
if (targetBytes[0] == 0xFF && targetBytes[1] == 0x25)
{
@@ -201,8 +202,8 @@ public sealed partial class DirectExecutionBackend
ulong slot = unchecked((ulong)((long)(target + 6) + slotRel32));
if (cpuContext.TryReadUInt64(slot, out var slotTarget))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex} near-call PLT slot: [0x{slot:X16}] = 0x{slotTarget:X16}");
Log.Debug(
$"Import#{dispatchIndex} near-call PLT slot: [0x{slot:X16}] = 0x{slotTarget:X16}");
for (int importIndex = 0; importIndex < _importEntries.Length; importIndex++)
{
if (_importEntries[importIndex].Address != slotTarget)
@@ -213,13 +214,13 @@ public sealed partial class DirectExecutionBackend
string nid = _importEntries[importIndex].Nid;
if (_moduleManager.TryGetExport(nid, out var export))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex} near-call PLT import: index={importIndex} {export.LibraryName}:{export.Name} ({nid})");
Log.Debug(
$"Import#{dispatchIndex} near-call PLT import: index={importIndex} {export.LibraryName}:{export.Name} ({nid})");
}
else
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex} near-call PLT import: index={importIndex} nid={nid}");
Log.Debug(
$"Import#{dispatchIndex} near-call PLT import: index={importIndex} nid={nid}");
}
break;
}
@@ -103,6 +103,15 @@ public sealed partial class DirectExecutionBackend
ulong rip = ReadCtxU64(contextRecord, 248);
ulong rsp = ReadCtxU64(contextRecord, 152);
// Thread-mode probe: a hardware exception raised while this thread is inside
// the managed import gateway means the VEH->managed reentry happened from
// cooperative GC mode — a ReversePInvokeBadTransition candidate.
if (LogThreadMode && _threadModeGatewayDepth > 0)
{
TraceThreadMode(
$"veh_in_gateway code=0x{exceptionCode:X8} rip=0x{rip:X16} gateway_depth={_threadModeGatewayDepth}");
}
if (exceptionCode == 3221225477u && TryHandleLazyCommittedPage(exceptionRecord, rip, rsp))
{
return -1;
@@ -19,6 +19,16 @@ public sealed partial class DirectExecutionBackend
private static ulong ImportDispatchGatewayManaged(nint backendHandle, int importIndex, nint argPackPtr)
{
if (LogThreadMode)
{
_threadModeGatewayCalls++;
_threadModeGatewayDepth++;
if (!_threadModeGatewayFirstLogged)
{
_threadModeGatewayFirstLogged = true;
TraceThreadMode($"gateway_first import={importIndex} total={_threadModeGatewayCalls}");
}
}
try
{
if (!(GCHandle.FromIntPtr(backendHandle).Target is DirectExecutionBackend directExecutionBackend))
@@ -36,6 +46,13 @@ public sealed partial class DirectExecutionBackend
$"[LOADER][ERROR] ImportDispatchGatewayManaged exception: {ex.GetType().Name}: {ex.Message}");
return 18446744071562199298uL;
}
finally
{
if (LogThreadMode)
{
_threadModeGatewayDepth--;
}
}
}
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
@@ -69,6 +86,12 @@ public sealed partial class DirectExecutionBackend
WriteCtxU64(contextRecord, 152, nextRsp);
WriteCtxU64(contextRecord, 248, returnRip);
Interlocked.Increment(ref _rawSentinelRecoveries);
if (LogThreadMode)
{
TraceThreadMode(
$"sentinel_recover rip=0x{value:X16} -> 0x{returnRip:X16} rsp=0x{rsp:X16} -> 0x{nextRsp:X16} " +
$"gateway_depth={_threadModeGatewayDepth}");
}
return -1;
}
return 0;
@@ -204,6 +227,17 @@ public sealed partial class DirectExecutionBackend
cpuContext[CpuRegister.Rax] = 1uL;
return 1uL;
}
// Backend teardown in progress: unwind this worker to the host instead of
// dispatching. RunGuestThread parks it as Blocked with no wake handler, its
// host thread exits, and RequestGuestThreadTeardown can finish before any
// executable memory is freed.
if (isGuestWorker &&
_guestTeardownRequested &&
TryYieldGuestThreadToHostStub(argPackPtr, num, num7, importStubEntry.Nid, "backend teardown"))
{
cpuContext[CpuRegister.Rax] = 0uL;
return 0uL;
}
bool flag0 = ShouldSuppressStrlenTrace(importStubEntry.Nid);
bool flag = num7 >= 2156221920u && num7 <= 2156225024u;
bool flag2 = num7 >= 2156351360u && num7 <= 2156352080u;
@@ -860,7 +894,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()
@@ -0,0 +1,582 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using SharpEmu.HLE;
namespace SharpEmu.Core.Cpu.Native;
public sealed partial class DirectExecutionBackend
{
// Guest entry stubs must not run above CLR-managed frames on a CLR-created thread:
// the suspension/stack-walk machinery then has to traverse a frame chain that is
// interleaved with guest stubs carrying no CLR unwind info, and any window in which
// the thread-mode bookkeeping disagrees with the actual stack (import dispatch, VEH
// redirection) fail-fasts the runtime — the audio_output_thread ~7th-cycle
// "attempted to call a UnmanagedCallersOnly method from managed code" crash.
//
// Guest execution therefore runs on dedicated raw OS threads whose run loop is
// emitted native code. While guest code executes there is not a single managed
// frame on the thread and it sits in preemptive mode, so the GC ignores it
// entirely. The only managed activity on the thread is the reverse-P/Invoke import
// dispatch and the per-run prologue/epilogue, which the CLR supports on foreign
// threads (lazy attach, preemptive between calls). The managed orchestrator
// (RunGuestThread / the main execution thread) parks in a preemptive wait for the
// duration of the run, so its own frame chain is never walked across guest frames.
private static readonly bool NativeGuestWorkersDisabled =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_NATIVE_GUEST_WORKERS"), "1", StringComparison.Ordinal);
private readonly object _nativeWorkerGate = new();
private readonly List<NativeGuestExecutor> _allNativeWorkers = new();
private readonly Stack<NativeGuestExecutor> _idleNativeWorkers = new();
private bool _nativeWorkersDisposed;
private int _nativeWorkerCreationFailedLogged;
private const uint StackSizeParamIsAReservation = 0x00010000u;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern nint CreateThread(
nint lpThreadAttributes,
nuint dwStackSize,
nint lpStartAddress,
nint lpParameter,
uint dwCreationFlags,
out uint lpThreadId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
// Runs an emitted guest entry stub. Preferred path is a pooled native worker
// thread; falls back to the historical inline calli (guest frames above this
// thread's managed frames) when workers are disabled or unavailable.
//
// Callers set the Active* thread-statics before emitting the stub and read the
// yield/forced-exit flags right after this returns, so the worker outcome is
// copied back into this thread's statics before returning.
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot)
{
var worker = RentNativeGuestExecutor();
if (worker is null)
{
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
return CallNativeEntry(entryStub);
}
try
{
var state = _activeGuestThreadState;
var nativeReturn = worker.Run(
_activeCpuContext!,
state,
GuestThreadExecution.CurrentGuestThreadHandle,
_activeEntryReturnSentinelRip,
_activeGuestReturnSlotAddress,
(nint)hostRspSlot,
(nint)entryStub,
state?.AffinityMask ?? 0,
out var yieldRequested,
out var yieldReason,
out var forcedExit);
_activeGuestThreadYieldRequested = yieldRequested;
_activeGuestThreadYieldReason = yieldReason;
_activeForcedGuestExit = forcedExit;
return nativeReturn;
}
finally
{
ReturnNativeGuestExecutor(worker);
}
}
private NativeGuestExecutor? RentNativeGuestExecutor()
{
if (NativeGuestWorkersDisabled)
{
return null;
}
lock (_nativeWorkerGate)
{
if (_nativeWorkersDisposed)
{
return null;
}
if (_idleNativeWorkers.Count > 0)
{
return _idleNativeWorkers.Pop();
}
}
var worker = NativeGuestExecutor.TryCreate(this);
if (worker is null)
{
if (Interlocked.Exchange(ref _nativeWorkerCreationFailedLogged, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][WARN] Failed to create a native guest worker thread; falling back to inline guest execution.");
}
return null;
}
lock (_nativeWorkerGate)
{
if (_nativeWorkersDisposed)
{
worker.Dispose();
return null;
}
_allNativeWorkers.Add(worker);
}
return worker;
}
private void ReturnNativeGuestExecutor(NativeGuestExecutor worker)
{
lock (_nativeWorkerGate)
{
if (!_nativeWorkersDisposed)
{
_idleNativeWorkers.Push(worker);
return;
}
}
worker.Dispose();
}
private void DisposeNativeGuestExecutors()
{
NativeGuestExecutor[] workers;
lock (_nativeWorkerGate)
{
if (_nativeWorkersDisposed)
{
return;
}
_nativeWorkersDisposed = true;
workers = _allNativeWorkers.ToArray();
_allNativeWorkers.Clear();
_idleNativeWorkers.Clear();
}
foreach (var worker in workers)
{
worker.Dispose();
}
}
// A pooled raw OS thread that executes guest entry stubs. The run loop is emitted
// native code (no CLR unwind info required — nothing ever unwinds through it):
//
// loop: WaitForSingleObject(work);
// if (*stopFlag) { SetEvent(done); ExitThread(0); }
// rax = RunPrologue(self); // managed: binds per-run ambient, returns stub
// if (rax != 0) eax = rax(); // guest entry stub — zero managed frames below
// RunEpilogue(self, eax); // managed: captures outcome, restores ambient
// SetEvent(done); goto loop;
//
// Workers carry no per-guest identity of their own: the prologue rebinds guest TLS,
// the host-RSP slot, the Active* thread-statics and the GuestThreadExecution ambient
// on every run, so a worker can be reused for any guest thread.
private sealed unsafe class NativeGuestExecutor : IDisposable
{
private const uint LoopStubSize = 512u;
private const uint WorkerStackReservation = 4u * 1024u * 1024u;
private static nint _waitForSingleObjectAddress;
private static nint _setEventAddress;
private static nint _exitThreadAddress;
private readonly DirectExecutionBackend _backend;
private readonly AutoResetEvent _workAvailable = new(false);
private readonly AutoResetEvent _workCompleted = new(false);
private GCHandle _selfHandle;
private void* _controlBlock;
private void* _loopStub;
private nint _threadHandle;
private uint _nativeThreadId;
// Single in-flight run; publication is ordered by the work/done event pair.
private CpuContext? _runContext;
private GuestThreadState? _runState;
private ulong _runGuestThreadHandle;
private ulong _runSentinelRip;
private ulong _runReturnSlotAddress;
private nint _runHostRspSlot;
private nint _runEntryStub;
private ulong _runAffinityMask;
private int _runNativeResult;
private bool _runYieldRequested;
private string? _runYieldReason;
private bool _runForcedExit;
private bool _runPrologueFailed;
// Prologue -> epilogue carry, only touched on the worker thread.
private DirectExecutionBackend? _prevBackend;
private CpuContext? _prevContext;
private ulong _prevSentinel;
private ulong _prevReturnSlot;
private bool _prevForcedExit;
private bool _prevYieldRequested;
private string? _prevYieldReason;
private GuestThreadState? _prevState;
private ulong _prevGuestThreadHandle;
private nint _prevHostRspSlot;
private int _prevHostThreadId;
private bool _entered;
private NativeGuestExecutor(DirectExecutionBackend backend)
{
_backend = backend;
}
public static NativeGuestExecutor? TryCreate(DirectExecutionBackend backend)
{
if (!EnsureKernel32Exports())
{
return null;
}
var executor = new NativeGuestExecutor(backend);
if (!executor.Initialize())
{
executor.Dispose();
return null;
}
return executor;
}
private static bool EnsureKernel32Exports()
{
if (_exitThreadAddress != 0)
{
return _waitForSingleObjectAddress != 0 && _setEventAddress != 0;
}
nint kernel32 = GetModuleHandle("kernel32.dll");
if (kernel32 == 0)
{
return false;
}
_waitForSingleObjectAddress = GetProcAddress(kernel32, "WaitForSingleObject");
_setEventAddress = GetProcAddress(kernel32, "SetEvent");
_exitThreadAddress = GetProcAddress(kernel32, "ExitThread");
return _waitForSingleObjectAddress != 0 && _setEventAddress != 0 && _exitThreadAddress != 0;
}
private bool Initialize()
{
_selfHandle = GCHandle.Alloc(this);
_controlBlock = VirtualAlloc(null, 4096u, 12288u, 4u);
if (_controlBlock == null)
{
return false;
}
_loopStub = VirtualAlloc(null, LoopStubSize, 12288u, 64u);
if (_loopStub == null)
{
return false;
}
var prologuePtr = (nint)(delegate* unmanaged<nint, nint>)&RunPrologue;
var epiloguePtr = (nint)(delegate* unmanaged<nint, int, void>)&RunEpilogue;
var executorHandle = GCHandle.ToIntPtr(_selfHandle);
var workHandle = _workAvailable.SafeWaitHandle.DangerousGetHandle();
var doneHandle = _workCompleted.SafeWaitHandle.DangerousGetHandle();
byte* code = (byte*)_loopStub;
int offset = 0;
void Emit(byte value) => code[offset++] = value;
void EmitMovRcxImm64(ulong value)
{
Emit(0x48); Emit(0xB9);
*(ulong*)(code + offset) = value;
offset += sizeof(ulong);
}
void EmitMovRaxImm64(ulong value)
{
Emit(0x48); Emit(0xB8);
*(ulong*)(code + offset) = value;
offset += sizeof(ulong);
}
void EmitCallRax()
{
Emit(0xFF); Emit(0xD0);
}
void EmitSetDoneEvent()
{
EmitMovRcxImm64((ulong)doneHandle);
EmitMovRaxImm64((ulong)_setEventAddress);
EmitCallRax();
}
// Thread entry leaves RSP ≡ 8 (mod 16); after this every call below happens
// at RSP ≡ 0 with shadow space available.
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x28); // sub rsp, 0x28
int loopStart = offset;
EmitMovRcxImm64((ulong)workHandle);
Emit(0xBA); // mov edx, INFINITE
*(uint*)(code + offset) = 0xFFFFFFFFu;
offset += sizeof(uint);
EmitMovRaxImm64((ulong)_waitForSingleObjectAddress);
EmitCallRax();
EmitMovRaxImm64((ulong)_controlBlock);
Emit(0x83); Emit(0x38); Emit(0x00); // cmp dword [rax], 0
Emit(0x0F); Emit(0x85); // jne stop
int stopJump = offset;
offset += sizeof(int);
EmitMovRcxImm64((ulong)executorHandle);
EmitMovRaxImm64((ulong)prologuePtr);
EmitCallRax(); // rax = entry stub, or 0 on failure
Emit(0x48); Emit(0x85); Emit(0xC0); // test rax, rax
Emit(0x0F); Emit(0x84); // je skipEntry
int skipJump = offset;
offset += sizeof(int);
EmitCallRax(); // guest entry stub -> eax
int skipEntryOffset = offset;
Emit(0x89); Emit(0xC2); // mov edx, eax
EmitMovRcxImm64((ulong)executorHandle);
EmitMovRaxImm64((ulong)epiloguePtr);
EmitCallRax();
EmitSetDoneEvent();
Emit(0xE9); // jmp loop
*(int*)(code + offset) = loopStart - (offset + sizeof(int));
offset += sizeof(int);
int stopOffset = offset;
// Signal done so a Run() racing teardown cannot park forever; a stopped
// worker is never re-rented, so the stale signal is unobservable.
EmitSetDoneEvent();
Emit(0x31); Emit(0xC9); // xor ecx, ecx
EmitMovRaxImm64((ulong)_exitThreadAddress);
EmitCallRax();
Emit(0xCC); // int3 (ExitThread never returns)
*(int*)(code + stopJump) = stopOffset - (stopJump + sizeof(int));
*(int*)(code + skipJump) = skipEntryOffset - (skipJump + sizeof(int));
uint oldProtect = 0;
if (!VirtualProtect(_loopStub, LoopStubSize, 32u, &oldProtect))
{
return false;
}
FlushInstructionCache(GetCurrentProcess(), _loopStub, LoopStubSize);
_threadHandle = CreateThread(
0,
WorkerStackReservation,
(nint)_loopStub,
0,
StackSizeParamIsAReservation,
out _nativeThreadId);
if (_threadHandle == 0)
{
return false;
}
if (LogThreadMode)
{
TraceThreadMode($"worker_created native_tid={_nativeThreadId} loop=0x{(ulong)_loopStub:X16}");
}
return true;
}
public int Run(
CpuContext context,
GuestThreadState? state,
ulong guestThreadHandle,
ulong sentinelRip,
ulong returnSlotAddress,
nint hostRspSlot,
nint entryStub,
ulong affinityMask,
out bool yieldRequested,
out string? yieldReason,
out bool forcedExit)
{
_runContext = context;
_runState = state;
_runGuestThreadHandle = guestThreadHandle;
_runSentinelRip = sentinelRip;
_runReturnSlotAddress = returnSlotAddress;
_runHostRspSlot = hostRspSlot;
_runEntryStub = entryStub;
_runAffinityMask = affinityMask;
_runPrologueFailed = true;
_runYieldRequested = false;
_runYieldReason = null;
_runForcedExit = false;
_workAvailable.Set();
_workCompleted.WaitOne();
_runContext = null;
_runState = null;
yieldRequested = _runYieldRequested;
yieldReason = _runYieldReason;
forcedExit = _runForcedExit;
if (_runPrologueFailed)
{
throw new InvalidOperationException("Native guest worker failed to bind the run ambient (prologue fault)");
}
return _runNativeResult;
}
[UnmanagedCallersOnly]
private static nint RunPrologue(nint executorHandle)
{
try
{
var executor = (NativeGuestExecutor)GCHandle.FromIntPtr(executorHandle).Target!;
return executor.EnterRun();
}
catch (Exception ex)
{
try
{
Console.Error.WriteLine(
$"[LOADER][ERROR] Native guest worker prologue failed: {ex.GetType().Name}: {ex.Message}");
}
catch
{
}
return 0;
}
}
[UnmanagedCallersOnly]
private static void RunEpilogue(nint executorHandle, int nativeResult)
{
try
{
var executor = (NativeGuestExecutor)GCHandle.FromIntPtr(executorHandle).Target!;
executor.ExitRun(nativeResult);
}
catch (Exception ex)
{
try
{
Console.Error.WriteLine(
$"[LOADER][ERROR] Native guest worker epilogue failed: {ex.GetType().Name}: {ex.Message}");
}
catch
{
}
}
}
private nint EnterRun()
{
var backend = _backend;
_prevBackend = _activeExecutionBackend;
_prevContext = _activeCpuContext;
_prevSentinel = _activeEntryReturnSentinelRip;
_prevReturnSlot = _activeGuestReturnSlotAddress;
_prevForcedExit = _activeForcedGuestExit;
_prevYieldRequested = _activeGuestThreadYieldRequested;
_prevYieldReason = _activeGuestThreadYieldReason;
_prevState = _activeGuestThreadState;
_prevHostRspSlot = TlsGetValue(backend._hostRspSlotTlsIndex);
_prevGuestThreadHandle = GuestThreadExecution.EnterGuestThread(_runGuestThreadHandle);
_entered = true;
_activeExecutionBackend = backend;
_activeCpuContext = _runContext;
_activeEntryReturnSentinelRip = _runSentinelRip;
_activeGuestReturnSlotAddress = _runReturnSlotAddress;
_activeForcedGuestExit = false;
_activeGuestThreadYieldRequested = false;
_activeGuestThreadYieldReason = null;
_activeGuestThreadState = _runState;
backend.BindTlsBase(_runContext!);
TlsSetValue(backend._hostRspSlotTlsIndex, _runHostRspSlot);
if (_runState is { } state)
{
_prevHostThreadId = Volatile.Read(ref state.HostThreadId);
Volatile.Write(ref state.HostThreadId, unchecked((int)GetCurrentThreadId()));
}
if (_runAffinityMask != 0)
{
backend.ApplyGuestThreadAffinity(_runAffinityMask);
}
_runPrologueFailed = false;
if (LogThreadMode)
{
TraceThreadMode(
$"worker_enter guest=0x{_runGuestThreadHandle:X16} stub=0x{(ulong)_runEntryStub:X16}");
}
return _runEntryStub;
}
private void ExitRun(int nativeResult)
{
_runNativeResult = nativeResult;
_runYieldRequested = _activeGuestThreadYieldRequested;
_runYieldReason = _activeGuestThreadYieldReason;
_runForcedExit = _activeForcedGuestExit;
if (!_entered)
{
return;
}
_entered = false;
if (_runState is { } state)
{
Volatile.Write(ref state.HostThreadId, _prevHostThreadId);
}
TlsSetValue(_backend._hostRspSlotTlsIndex, _prevHostRspSlot);
GuestThreadExecution.RestoreGuestThread(_prevGuestThreadHandle);
_activeExecutionBackend = _prevBackend;
_activeCpuContext = _prevContext;
_activeEntryReturnSentinelRip = _prevSentinel;
_activeGuestReturnSlotAddress = _prevReturnSlot;
_activeForcedGuestExit = _prevForcedExit;
_activeGuestThreadYieldRequested = _prevYieldRequested;
_activeGuestThreadYieldReason = _prevYieldReason;
_activeGuestThreadState = _prevState;
_prevBackend = null;
_prevContext = null;
_prevState = null;
_prevYieldReason = null;
if (LogThreadMode)
{
TraceThreadMode(
$"worker_exit guest=0x{_runGuestThreadHandle:X16} result=0x{nativeResult:X8} yield={_runYieldRequested}");
}
}
public void Dispose()
{
if (_controlBlock != null)
{
*(int*)_controlBlock = 1;
}
try
{
_workAvailable.Set();
}
catch (ObjectDisposedException)
{
}
var exited = _threadHandle == 0;
if (_threadHandle != 0)
{
exited = WaitForSingleObject(_threadHandle, 1000u) == 0u;
CloseHandle(_threadHandle);
_threadHandle = 0;
}
if (!exited)
{
// The worker is still parked in guest code (teardown should have unwound
// it first). Leak the stub, control block, events and GC handle rather
// than have the thread execute freed memory.
Console.Error.WriteLine(
$"[LOADER][WARN] Native guest worker tid={_nativeThreadId} did not stop; leaking its run loop.");
return;
}
if (_loopStub != null)
{
VirtualFree(_loopStub, 0u, 32768u);
_loopStub = null;
}
if (_controlBlock != null)
{
VirtualFree(_controlBlock, 0u, 32768u);
_controlBlock = null;
}
if (_selfHandle.IsAllocated)
{
_selfHandle.Free();
}
_workAvailable.Dispose();
_workCompleted.Dispose();
}
}
}
@@ -11,11 +11,13 @@ using SharpEmu.Core.Cpu;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Cpu.Native;
public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, IGuestThreadScheduler, IDisposable
{
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Native");
private const int ImportLoopHistoryLength = 2048;
private const int ImportLoopWideDiversityWindow = 768;
@@ -422,6 +424,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private void ThreadMain()
{
var previousGuestThreadHandle = GuestThreadExecution.EnterGuestThread(_guestThreadHandle);
if (LogThreadMode)
{
TraceThreadMode($"runner_start guest=0x{_guestThreadHandle:X16}");
}
try
{
while (true)
@@ -432,6 +438,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (LogThreadMode)
{
_threadModeCycleId = Interlocked.Increment(ref _threadModeCycleCounter);
TraceThreadMode($"runner_run guest=0x{_guestThreadHandle:X16}");
}
try
{
_work?.Invoke();
@@ -444,6 +455,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
finally
{
if (LogThreadMode)
{
TraceThreadMode($"runner_stop guest=0x{_guestThreadHandle:X16}");
}
GuestThreadExecution.RestoreGuestThread(previousGuestThreadHandle);
}
}
@@ -467,6 +482,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private readonly Queue<GuestThreadState> _readyGuestThreads = new Queue<GuestThreadState>();
// Once set, guest worker threads are unwound to the host at their next import
// dispatch and Pump refuses to start new ones. This must happen before the
// runtime frees trampolines or guest memory: workers that keep running native
// guest code during teardown execute freed pages (observed as an execute-AV in
// a MEM_FREE module region plus a CLR "UnmanagedCallersOnly" fatal from a Pump
// thread entering a freed stub).
private volatile bool _guestTeardownRequested;
private int _readyGuestThreadCount;
private readonly Dictionary<ulong, GuestThreadState> _guestThreads = new Dictionary<ulong, GuestThreadState>();
@@ -565,6 +588,33 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static int _nestedVehTraceCount;
// SHARPEMU_LOG_THREAD_MODE=1 — GC thread-mode corruption investigation. Traces
// every managed<->guest transition per guest-thread run cycle so the last event
// before a ReversePInvokeBadTransition FailFast identifies the corrupting path.
private static readonly bool LogThreadMode =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_THREAD_MODE"), "1", StringComparison.Ordinal);
private static long _threadModeCycleCounter;
[ThreadStatic]
private static long _threadModeCycleId;
[ThreadStatic]
private static int _threadModeGatewayDepth;
[ThreadStatic]
private static long _threadModeGatewayCalls;
[ThreadStatic]
private static bool _threadModeGatewayFirstLogged;
private static void TraceThreadMode(string message)
{
Console.Error.WriteLine(
$"[THREADMODE] {message} cycle={_threadModeCycleId} tid={GetCurrentThreadId()} managed={Environment.CurrentManagedThreadId}");
Console.Error.Flush();
}
private const uint MEM_COMMIT = 4096u;
private const uint MEM_RESERVE = 8192u;
@@ -623,7 +673,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe static int CallNativeEntry(void* entry)
{
var nativeEntry = (delegate* unmanaged[Cdecl]<int>)entry;
return nativeEntry();
if (!LogThreadMode)
{
return nativeEntry();
}
_threadModeGatewayFirstLogged = false;
var dispatchesBefore = _threadModeGatewayCalls;
TraceThreadMode($"native_enter entry=0x{(ulong)entry:X16}");
var result = nativeEntry();
TraceThreadMode($"native_exit result=0x{result:X8} dispatches={_threadModeGatewayCalls - dispatchesBefore}");
return result;
}
private unsafe static void WriteCtxU64(void* contextRecord, int offset, ulong value)
@@ -822,6 +882,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_importLoopGuardSeconds = GetImportLoopGuardSeconds();
_entryReturnSentinelRip = 0uL;
_forcedGuestExit = false;
_guestTeardownRequested = false;
_importLoopSignatureCount = 0;
_importLoopSignatureWriteIndex = 0;
_importLoopPatternHits = 0;
@@ -1120,21 +1181,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,
};
@@ -1353,10 +1465,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,
};
}
@@ -1869,6 +1996,36 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
byte* code = (byte*)ptr;
int offset = 0;
// Native pre-filter: these exception codes are raised while the thread can be in
// cooperative GC mode (a C# throw is RaiseException(0xE0434352) on the throwing
// thread; FailFast/stack-overflow arrive mid-runtime-failure). Entering the managed
// handler then trips the CLR's reverse-P/Invoke check and kills the process with
// "Invalid Program: attempted to call a UnmanagedCallersOnly method from managed
// code" — this is why no managed throw (even one with a catch handler) ever
// survived inside the emulator. Continue the handler search without touching
// managed code; the CLR's own VEH handles its exceptions. MSVC C++ exceptions
// (Vulkan drivers, host CRT) are excluded too: the managed handler only ever
// returned CONTINUE_SEARCH for them.
ReadOnlySpan<uint> nonManagedExceptionCodes = [0xE0434352u, MSVC_CPP_EXCEPTION, 0xC0000409u, 0xC00000FDu];
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx] (ExceptionRecord*)
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] (ExceptionCode)
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
{
EmitByte(code, ref offset, 0x3D); // cmp eax, imm32
EmitUInt32(code, ref offset, nonManagedExceptionCodes[i]);
EmitByte(code, ref offset, 0x74); // je pass
passJumpOffsets[i] = offset;
EmitByte(code, ref offset, 0x00);
}
EmitByte(code, ref offset, 0xEB); EmitByte(code, ref offset, 0x03); // jmp over pass block
int passOffset = offset;
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // pass: xor eax, eax (EXCEPTION_CONTINUE_SEARCH)
EmitByte(code, ref offset, 0xC3); // ret
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
{
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
}
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x55); // push r13
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE4); // mov r12, rsp
@@ -2553,6 +2710,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
public void Pump(CpuContext callerContext, string reason)
{
_ = callerContext;
if (_guestTeardownRequested)
{
return;
}
var runSynchronously = string.Equals(reason, "entry_return", StringComparison.Ordinal);
WakeExpiredBlockedGuestThreads();
if (Volatile.Read(ref _readyGuestThreadCount) == 0)
@@ -3077,6 +3238,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
continuationThread.Join();
}
/// <summary>
/// Parks every guest worker thread before the caller starts freeing executable
/// memory. Workers unwind to the host at their next import dispatch (see the
/// teardown check in DispatchImport); this waits for their host threads to
/// finish within <paramref name="timeoutMs"/>. Returns false when at least one
/// worker is still running — the caller must then leak its executable stubs
/// rather than free memory a live thread may still execute.
/// </summary>
private bool RequestGuestThreadTeardown(int timeoutMs)
{
_guestTeardownRequested = true;
Thread[] hostThreads;
lock (_guestThreadGate)
{
_readyGuestThreads.Clear();
Interlocked.Exchange(ref _readyGuestThreadCount, 0);
hostThreads = _guestThreads.Values
.Select(static thread => thread.HostThread)
.Where(static host => host is not null && host != Thread.CurrentThread && host.IsAlive)
.Cast<Thread>()
.ToArray();
}
var deadline = Environment.TickCount64 + timeoutMs;
var allStopped = true;
foreach (var host in hostThreads)
{
var remaining = (int)Math.Max(1L, deadline - Environment.TickCount64);
if (!host.Join(remaining) && host.IsAlive)
{
allStopped = false;
Console.Error.WriteLine(
$"[LOADER][WARN] Guest worker host thread '{host.Name}' still running after teardown wait.");
}
}
return allStopped;
}
private void ClearGuestThreads()
{
GuestContinuationRunner[] runners;
@@ -3356,6 +3556,13 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
ApplyGuestThreadAffinity(thread.AffinityMask);
Volatile.Write(ref thread.HostThreadId, unchecked((int)GetCurrentThreadId()));
_activeGuestThreadState = thread;
if (LogThreadMode)
{
_threadModeCycleId = Interlocked.Increment(ref _threadModeCycleCounter);
TraceThreadMode(
$"cycle_start name='{thread.Name}' guest=0x{thread.ThreadHandle:X16} reason={reason} " +
$"rsp_slot=0x{(ulong)TlsGetValue(_hostRspSlotTlsIndex):X}");
}
try
{
LastError = null;
@@ -3430,6 +3637,13 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
finally
{
if (LogThreadMode)
{
TraceThreadMode(
$"cycle_end name='{thread.Name}' state={thread.State} " +
$"imports={Interlocked.Read(ref thread.ImportCount)} " +
$"rsp_slot=0x{(ulong)TlsGetValue(_hostRspSlotTlsIndex):X}");
}
_activeGuestThreadState = previousGuestThreadState;
Volatile.Write(ref thread.HostThreadId, 0);
GuestThreadExecution.RestoreGuestThread(previousGuestThreadHandle);
@@ -3504,173 +3718,175 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
var previousYieldRequested = _activeGuestThreadYieldRequested;
var previousYieldReason = _activeGuestThreadYieldReason;
nint previousHostRspSlotValue = TlsGetValue(_hostRspSlotTlsIndex);
if (LogThreadMode)
{
TraceThreadMode(
$"entry_setup name='{name}' entry=0x{entryPoint:X16} stub=0x{(ulong)ptr:X16} " +
$"guest_rsp=0x{context[CpuRegister.Rsp]:X16} rsp_slot_prev=0x{(ulong)previousHostRspSlotValue:X}");
}
try
{
_activeExecutionBackend = this;
_activeCpuContext = context;
_activeEntryReturnSentinelRip = 0;
_activeGuestReturnSlotAddress = 0;
_activeForcedGuestExit = false;
_activeGuestThreadYieldRequested = false;
_activeGuestThreadYieldReason = null;
BindTlsBase(context);
byte* ptr2 = (byte*)ptr;
ulong hostRspSlot = (ulong)ptr + stubSize - 16uL;
int offset = 0;
ptr2[offset++] = 83;
ptr2[offset++] = 85;
ptr2[offset++] = 87;
ptr2[offset++] = 86;
ptr2[offset++] = 65;
ptr2[offset++] = 84;
ptr2[offset++] = 65;
ptr2[offset++] = 85;
ptr2[offset++] = 65;
ptr2[offset++] = 86;
ptr2[offset++] = 65;
ptr2[offset++] = 87;
EmitHostNonvolatileXmmSave(ptr2, ref offset);
ptr2[offset++] = 73;
ptr2[offset++] = 186;
*(ulong*)(ptr2 + offset) = hostRspSlot;
offset += 8;
ptr2[offset++] = 73;
ptr2[offset++] = 137;
ptr2[offset++] = 34;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rsp];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 196;
ptr2[offset++] = 72;
ptr2[offset++] = 131;
ptr2[offset++] = 236;
ptr2[offset++] = 8;
ptr2[offset++] = 72;
ptr2[offset++] = 189;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rbp];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rdi];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 199;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rsi];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 198;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rdx];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 194;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rcx];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 193;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = entryPoint;
offset += 8;
ptr2[offset++] = byte.MaxValue;
ptr2[offset++] = 208;
int sentinelOffset = offset + 4;
ptr2[offset++] = 72;
ptr2[offset++] = 131;
ptr2[offset++] = 196;
ptr2[offset++] = 8;
ptr2[offset++] = 73;
ptr2[offset++] = 186;
*(ulong*)(ptr2 + offset) = hostRspSlot;
offset += 8;
ptr2[offset++] = 73;
ptr2[offset++] = 139;
ptr2[offset++] = 34;
EmitHostNonvolatileXmmRestore(ptr2, ref offset);
ptr2[offset++] = 65;
ptr2[offset++] = 95;
ptr2[offset++] = 65;
ptr2[offset++] = 94;
ptr2[offset++] = 65;
ptr2[offset++] = 93;
ptr2[offset++] = 65;
ptr2[offset++] = 92;
ptr2[offset++] = 94;
ptr2[offset++] = 95;
ptr2[offset++] = 93;
ptr2[offset++] = 91;
ptr2[offset++] = 195;
ulong sentinel = (ulong)ptr + (ulong)sentinelOffset;
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
_activeGuestReturnSlotAddress = context[CpuRegister.Rsp] - 16uL;
if (!context.TryWriteUInt64(context[CpuRegister.Rsp], sentinel))
{
reason = $"failed to patch guest thread return sentinel at 0x{context[CpuRegister.Rsp]:X16}";
return GuestNativeCallExitReason.Exception;
}
uint oldProtect = default(uint);
if (!VirtualProtect(ptr, stubSize, 64u, &oldProtect))
{
reason = $"VirtualProtect failed for guest thread entry stub at 0x{(nint)ptr:X16}";
return GuestNativeCallExitReason.Exception;
}
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
ActiveGuestThreadYieldRequested = false;
ActiveGuestThreadYieldReason = null;
try
{
var nativeReturn = CallNativeEntry(ptr);
if (ActiveGuestThreadYieldRequested)
{
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
return GuestNativeCallExitReason.Blocked;
}
if (ActiveForcedGuestExit)
{
reason = LastError ?? "guest thread forced exit";
return GuestNativeCallExitReason.ForcedExit;
}
reason = $"returned 0x{nativeReturn:X8}";
return GuestNativeCallExitReason.Returned;
}
catch (AccessViolationException ex)
{
reason = "access violation: " + ex.Message;
return GuestNativeCallExitReason.Exception;
}
catch (Exception ex)
{
reason = ex.GetType().Name + ": " + ex.Message;
return GuestNativeCallExitReason.Exception;
}
}
finally
{
TlsSetValue(_hostRspSlotTlsIndex, previousHostRspSlotValue);
RestoreActiveExecutionThread(
previousActiveBackend,
previousActiveContext,
previousSentinel,
previousReturnSlotAddress,
previousForcedExit,
previousYieldRequested,
previousYieldReason);
VirtualFree(ptr, 0u, 32768u);
}
}
{
_activeExecutionBackend = this;
_activeCpuContext = context;
_activeEntryReturnSentinelRip = 0;
_activeGuestReturnSlotAddress = 0;
_activeForcedGuestExit = false;
_activeGuestThreadYieldRequested = false;
_activeGuestThreadYieldReason = null;
BindTlsBase(context);
byte* ptr2 = (byte*)ptr;
ulong hostRspSlot = (ulong)ptr + stubSize - 16uL;
int offset = 0;
ptr2[offset++] = 83;
ptr2[offset++] = 85;
ptr2[offset++] = 87;
ptr2[offset++] = 86;
ptr2[offset++] = 65;
ptr2[offset++] = 84;
ptr2[offset++] = 65;
ptr2[offset++] = 85;
ptr2[offset++] = 65;
ptr2[offset++] = 86;
ptr2[offset++] = 65;
ptr2[offset++] = 87;
EmitHostNonvolatileXmmSave(ptr2, ref offset);
ptr2[offset++] = 73;
ptr2[offset++] = 186;
*(ulong*)(ptr2 + offset) = hostRspSlot;
offset += 8;
ptr2[offset++] = 73;
ptr2[offset++] = 137;
ptr2[offset++] = 34;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rsp];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 196;
ptr2[offset++] = 72;
ptr2[offset++] = 131;
ptr2[offset++] = 236;
ptr2[offset++] = 8;
ptr2[offset++] = 72;
ptr2[offset++] = 189;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rbp];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rdi];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 199;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rsi];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 198;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rdx];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 194;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = context[CpuRegister.Rcx];
offset += 8;
ptr2[offset++] = 72;
ptr2[offset++] = 137;
ptr2[offset++] = 193;
ptr2[offset++] = 72;
ptr2[offset++] = 184;
*(ulong*)(ptr2 + offset) = entryPoint;
offset += 8;
ptr2[offset++] = byte.MaxValue;
ptr2[offset++] = 208;
int sentinelOffset = offset + 4;
ptr2[offset++] = 72;
ptr2[offset++] = 131;
ptr2[offset++] = 196;
ptr2[offset++] = 8;
ptr2[offset++] = 73;
ptr2[offset++] = 186;
*(ulong*)(ptr2 + offset) = hostRspSlot;
offset += 8;
ptr2[offset++] = 73;
ptr2[offset++] = 139;
ptr2[offset++] = 34;
EmitHostNonvolatileXmmRestore(ptr2, ref offset);
ptr2[offset++] = 65;
ptr2[offset++] = 95;
ptr2[offset++] = 65;
ptr2[offset++] = 94;
ptr2[offset++] = 65;
ptr2[offset++] = 93;
ptr2[offset++] = 65;
ptr2[offset++] = 92;
ptr2[offset++] = 94;
ptr2[offset++] = 95;
ptr2[offset++] = 93;
ptr2[offset++] = 91;
ptr2[offset++] = 195;
ulong sentinel = (ulong)ptr + (ulong)sentinelOffset;
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
_activeGuestReturnSlotAddress = context[CpuRegister.Rsp] - 16uL;
if (!context.TryWriteUInt64(context[CpuRegister.Rsp], sentinel))
{
reason = $"failed to patch guest thread return sentinel at 0x{context[CpuRegister.Rsp]:X16}";
return GuestNativeCallExitReason.Exception;
}
uint oldProtect = default(uint);
if (!VirtualProtect(ptr, stubSize, 64u, &oldProtect))
{
reason = $"VirtualProtect failed for guest thread entry stub at 0x{(nint)ptr:X16}";
return GuestNativeCallExitReason.Exception;
}
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
ActiveGuestThreadYieldRequested = false;
ActiveGuestThreadYieldReason = null;
var nativeReturn = RunGuestEntryStub(ptr, hostRspSlot);
if (ActiveGuestThreadYieldRequested)
{
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
return GuestNativeCallExitReason.Blocked;
}
if (ActiveForcedGuestExit)
{
reason = LastError ?? "guest thread forced exit";
return GuestNativeCallExitReason.ForcedExit;
}
reason = $"returned 0x{nativeReturn:X8}";
return GuestNativeCallExitReason.Returned;
}
catch (AccessViolationException ex)
{
reason = "access violation: " + ex.Message;
return GuestNativeCallExitReason.Exception;
}
catch (Exception ex)
{
reason = ex.GetType().Name + ": " + ex.Message;
return GuestNativeCallExitReason.Exception;
}
finally
{
TlsSetValue(_hostRspSlotTlsIndex, previousHostRspSlotValue);
RestoreActiveExecutionThread(
previousActiveBackend,
previousActiveContext,
previousSentinel,
previousReturnSlotAddress,
previousForcedExit,
previousYieldRequested,
previousYieldReason);
VirtualFree(ptr, 0u, 32768u);
}
}
private unsafe GuestNativeCallExitReason ExecuteGuestContinuationEntry(
CpuContext context,
@@ -3700,6 +3916,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
var previousYieldRequested = _activeGuestThreadYieldRequested;
var previousYieldReason = _activeGuestThreadYieldReason;
nint previousHostRspSlotValue = TlsGetValue(_hostRspSlotTlsIndex);
if (LogThreadMode)
{
TraceThreadMode(
$"continuation_setup name='{name}' resume=0x{entryPoint:X16} stub=0x{(ulong)ptr:X16} " +
$"guest_rsp=0x{context[CpuRegister.Rsp]:X16} rsp_slot_prev=0x{(ulong)previousHostRspSlotValue:X}");
}
try
{
_activeExecutionBackend = this;
@@ -3768,12 +3990,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return GuestNativeCallExitReason.Exception;
}
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
ActiveGuestThreadYieldRequested = false;
ActiveGuestThreadYieldReason = null;
try
{
var nativeReturn = CallNativeEntry(ptr);
var nativeReturn = RunGuestEntryStub(ptr, hostRspSlot);
if (ActiveGuestThreadYieldRequested)
{
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
@@ -4049,7 +4271,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
int num6 = -1;
try
{
num6 = CallNativeEntry(ptr);
num6 = RunGuestEntryStub(ptr, num2);
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
PumpUntilGuestThreadsIdle(context, "entry_return");
}
@@ -4076,6 +4298,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
LastError = "Detected repeating import loop and forced guest unwind to host.";
}
Console.Error.WriteLine("[LOADER][ERROR] " + LastError);
RequestGuestThreadTeardown(3000);
return false;
}
if (num6 == 0)
@@ -4090,6 +4313,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
LastError = $"Guest entry point returned non-zero: {num6}";
}
Console.Error.WriteLine("[LOADER][ERROR] " + LastError);
RequestGuestThreadTeardown(3000);
return false;
}
finally
@@ -4414,6 +4638,18 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
public unsafe void Dispose()
{
if (!RequestGuestThreadTeardown(2000))
{
// A guest worker is still executing native code; freeing the trampolines,
// exception-handler stubs, or GC handles under it turns process exit into
// an execute-AV / CLR fatal. Leak them — the process is going away anyway.
Console.Error.WriteLine(
"[LOADER][WARN] Skipping executable stub teardown: guest worker threads are still running.");
return;
}
// Native guest workers park idle once every guest thread has unwound; stop
// them before any executable stub or TLS index they reference is freed.
DisposeNativeGuestExecutors();
ClearImportHandlerTrampolines();
_importEntries = Array.Empty<ImportStubEntry>();
_runtimeSymbolsByName.Clear();
+41 -41
View File
@@ -10,11 +10,13 @@ using SharpEmu.Core;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Loader;
public sealed class SelfLoader : ISelfLoader
{
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Loader");
private const uint SelfMagic = 0x4F153D1D;
private const ulong SelfSegmentFlag = 0x800;
private const int PageSize = 0x1000;
@@ -155,7 +157,7 @@ public sealed class SelfLoader : ISelfLoader
}
var tlsModuleId = _nextTlsModuleId == 0 ? 1u : _nextTlsModuleId;
Console.Error.WriteLine($"[LOADER][TLS] load_start clear={clearVirtualMemory} next={_nextTlsModuleId} assigned={tlsModuleId}");
Log.Debug($"TLS load_start clear={clearVirtualMemory} next={_nextTlsModuleId} assigned={tlsModuleId}");
var loadContext = ParseLayout(imageData);
var elfHeader = ReadUnmanaged<ElfHeader>(imageData, loadContext.ElfOffset);
@@ -255,7 +257,7 @@ public sealed class SelfLoader : ISelfLoader
{
_nextTlsModuleId++;
}
Console.Error.WriteLine($"[LOADER][TLS] load_done assigned={tlsModuleId} next={_nextTlsModuleId}");
Log.Debug($"TLS load_done assigned={tlsModuleId} next={_nextTlsModuleId}");
return new SelfImage(
loadContext.IsSelf,
@@ -403,15 +405,15 @@ public sealed class SelfLoader : ISelfLoader
var virtualAddress = header.VirtualAddress + imageBase;
Console.Error.WriteLine($"[LOADER] Segment {index}: VAddr=0x{virtualAddress:X16}, FileSize=0x{header.FileSize:X}, MemSize=0x{header.MemorySize:X}, Align=0x{header.Alignment:X}");
Log.Debug($"Segment {index}: VAddr=0x{virtualAddress:X16}, FileSize=0x{header.FileSize:X}, MemSize=0x{header.MemorySize:X}, Align=0x{header.Alignment:X}");
if (header.Alignment > 1)
{
var vaddrMod = virtualAddress % header.Alignment;
var offsetMod = header.Offset % header.Alignment;
if (vaddrMod != offsetMod)
{
Console.Error.WriteLine(
$"[LOADER] WARNING: Segment {index} ELF alignment mismatch! " +
Log.Warning(
$"Segment {index} ELF alignment mismatch! " +
$"VAddr=0x{virtualAddress:X}, Offset=0x{header.Offset:X}, Align=0x{header.Alignment:X}, " +
$"VAddr%Align=0x{vaddrMod:X}, Offset%Align=0x{offsetMod:X}");
}
@@ -671,13 +673,13 @@ public sealed class SelfLoader : ISelfLoader
{
if (descriptor.ValueKind == RelocationValueKind.TlsModuleId)
{
Console.Error.WriteLine(
$"[LOADER][TLS] Patching DTPMOD64 at 0x{descriptor.TargetAddress:X} with module id 0x{targetValue:X}");
Log.Debug(
$"Patching DTPMOD64 at 0x{descriptor.TargetAddress:X} with module id 0x{targetValue:X}");
}
else
{
Console.Error.WriteLine($"[LOADER] !!! CRITICAL !!! Patching address 0x{descriptor.TargetAddress:X} with INVALID value 0x{targetValue:X} for NID {descriptor.ImportNid ?? "(null)"}");
Console.Error.WriteLine($"[LOADER] SymbolValue=0x{descriptor.SymbolValue:X}, Addend=0x{descriptor.Addend:X}, StubAddress=0x{(addressesByNid.TryGetValue(descriptor.ImportNid ?? "", out var sa) ? sa : 0):X}");
Log.Warning($"!!! CRITICAL !!! Patching address 0x{descriptor.TargetAddress:X} with INVALID value 0x{targetValue:X} for NID {descriptor.ImportNid ?? "(null)"}");
Log.Warning($" SymbolValue=0x{descriptor.SymbolValue:X}, Addend=0x{descriptor.Addend:X}, StubAddress=0x{(addressesByNid.TryGetValue(descriptor.ImportNid ?? "", out var sa) ? sa : 0):X}");
}
}
@@ -689,8 +691,8 @@ public sealed class SelfLoader : ISelfLoader
if (descriptor.TargetAddress >= 0x00000008030FC300UL &&
descriptor.TargetAddress <= 0x00000008030FC3F0UL)
{
Console.Error.WriteLine(
$"[LOADER][RELOC] target=0x{descriptor.TargetAddress:X16} value=0x{targetValue:X16} addend=0x{descriptor.Addend:X} nid={(descriptor.ImportNid ?? "<sym>")}");
Log.Debug(
$"[RELOC] target=0x{descriptor.TargetAddress:X16} value=0x{targetValue:X16} addend=0x{descriptor.Addend:X} nid={(descriptor.ImportNid ?? "<sym>")}");
}
}
@@ -783,15 +785,15 @@ public sealed class SelfLoader : ISelfLoader
{
if (IsFocusRelocationOffset(relocation.Offset, imageBase))
{
Console.Error.WriteLine(
$"[LOADER][FOCUS][SCAN] off=0x{relocation.Offset:X16} type={relocation.Type} sym={relocation.SymbolIndex} addend=0x{relocation.Addend:X}");
Log.Debug(
$"[FOCUS][SCAN] off=0x{relocation.Offset:X16} type={relocation.Type} sym={relocation.SymbolIndex} addend=0x{relocation.Addend:X}");
}
if (!IsSupportedRelocationType(relocation.Type))
{
if (IsFocusRelocationOffset(relocation.Offset, imageBase))
{
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] unsupported type={relocation.Type}");
Log.Debug($"[FOCUS][SKIP] unsupported type={relocation.Type}");
}
continue;
}
@@ -800,7 +802,7 @@ public sealed class SelfLoader : ISelfLoader
{
if (IsFocusRelocationOffset(relocation.Offset, imageBase))
{
Console.Error.WriteLine("[LOADER][FOCUS][SKIP] target address not mapped");
Log.Debug("[FOCUS][SKIP] target address not mapped");
}
continue;
}
@@ -840,7 +842,7 @@ public sealed class SelfLoader : ISelfLoader
{
if (targetAddress >= FocusRelocGuestStart && targetAddress <= FocusRelocGuestEnd)
{
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] symbol read failed index={symbolIndex}");
Log.Debug($"[FOCUS][SKIP] symbol read failed index={symbolIndex}");
}
continue;
}
@@ -852,10 +854,9 @@ public sealed class SelfLoader : ISelfLoader
var symbolAddress = ResolveMappedAddressOrFallback(virtualMemory, symbol.Value, imageBase);
if (symbolAddress == 0)
{
Console.Error.WriteLine(
$"[LOADER] Skipping local relocation with invalid symbol value 0x{symbol.Value:X} " +
Log.Warning(
$"Skipping local relocation with invalid symbol value 0x{symbol.Value:X} " +
$"at target 0x{targetAddress:X16}, type={relocation.Type}, sym={symbolIndex}");
continue;
}
descriptors.Add(new RelocationDescriptor(
@@ -873,10 +874,9 @@ public sealed class SelfLoader : ISelfLoader
var symbolAddress = ResolveMappedAddressOrFallback(virtualMemory, symbol.Value, imageBase);
if (symbolAddress == 0)
{
Console.Error.WriteLine(
$"[LOADER] Skipping relocation with invalid symbol value 0x{symbol.Value:X} " +
Log.Warning(
$"Skipping relocation with invalid symbol value 0x{symbol.Value:X} " +
$"at target 0x{targetAddress:X16}, type={relocation.Type}, sym={symbolIndex}");
continue;
}
descriptors.Add(new RelocationDescriptor(
@@ -893,7 +893,7 @@ public sealed class SelfLoader : ISelfLoader
{
if (targetAddress >= FocusRelocGuestStart && targetAddress <= FocusRelocGuestEnd)
{
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] bind={symbolBind} not importable");
Log.Debug($"[FOCUS][SKIP] bind={symbolBind} not importable");
}
continue;
}
@@ -902,7 +902,7 @@ public sealed class SelfLoader : ISelfLoader
{
if (targetAddress >= FocusRelocGuestStart && targetAddress <= FocusRelocGuestEnd)
{
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] symbol name read failed offset={symbol.NameOffset}");
Log.Debug($"[FOCUS][SKIP] symbol name read failed offset={symbol.NameOffset}");
}
continue;
}
@@ -950,8 +950,8 @@ public sealed class SelfLoader : ISelfLoader
if (sectionSymbols > 0 || dynamicSymbols > 0)
{
Console.Error.WriteLine(
$"[LOADER] Runtime symbol index populated: section={sectionSymbols}, dynamic={dynamicSymbols}, total={runtimeSymbols.Count}");
Log.Info(
$"Runtime symbol index populated: section={sectionSymbols}, dynamic={dynamicSymbols}, total={runtimeSymbols.Count}");
}
}
@@ -1025,8 +1025,8 @@ public sealed class SelfLoader : ISelfLoader
if (preInitializers.Count != 0 || initializers.Count != 0)
{
Console.Error.WriteLine(
$"[LOADER] Initializers discovered: preinit={preInitializers.Count}, init={initializers.Count}");
Log.Info(
$"Initializers discovered: preinit={preInitializers.Count}, init={initializers.Count}");
}
}
@@ -1409,8 +1409,8 @@ public sealed class SelfLoader : ISelfLoader
var requiredBytes = checked((ulong)orderedImportNids.Count * ImportStubSlotSize);
var mapSize = AlignUp(Math.Max(requiredBytes, (ulong)PageSize), PageSize);
Console.Error.WriteLine(
$"[LOADER] CreateImportStubMapping: nids={orderedImportNids.Count}, required=0x{requiredBytes:X}, map_size=0x{mapSize:X}");
Log.Debug(
$"CreateImportStubMapping: nids={orderedImportNids.Count}, required=0x{requiredBytes:X}, map_size=0x{mapSize:X}");
if (mapSize > int.MaxValue)
{
throw new NotSupportedException("Import stub mapping exceeds 2 GB and is not supported.");
@@ -1842,16 +1842,16 @@ public sealed class SelfLoader : ISelfLoader
tableBytes = GC.AllocateUninitializedArray<byte>((int)size);
var guestAddr = location + imageBase;
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: trying guest address 0x{guestAddr:X}");
Log.Debug($"TryLoadTableBytes: trying guest address 0x{guestAddr:X}");
if (virtualMemory.TryRead(guestAddr, tableBytes))
{
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: loaded from guest memory at 0x{guestAddr:X}");
Log.Debug($"TryLoadTableBytes: loaded from guest memory at 0x{guestAddr:X}");
return true;
}
if (virtualMemory.TryRead(location, tableBytes))
{
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: loaded from absolute guest address 0x{location:X}");
Log.Debug($"TryLoadTableBytes: loaded from absolute guest address 0x{location:X}");
return true;
}
@@ -1859,11 +1859,11 @@ public sealed class SelfLoader : ISelfLoader
{
var slice = elfData.Slice((int)location, (int)size);
tableBytes = slice.ToArray();
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: loaded from elfData as file offset at 0x{location:X}");
Log.Debug($"TryLoadTableBytes: loaded from elfData as file offset at 0x{location:X}");
return true;
}
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: FAILED for location 0x{location:X}");
Log.Warning($"TryLoadTableBytes: FAILED for location 0x{location:X}");
tableBytes = Array.Empty<byte>();
return false;
}
@@ -1920,17 +1920,17 @@ public sealed class SelfLoader : ISelfLoader
private static ulong ResolveMappedAddressOrFallback(IVirtualMemory virtualMemory, ulong address, ulong imageBase)
{
Console.Error.WriteLine($"[LOADER][TEST] ResolveMappedAddressOrFallback addr=0x{address:X} imageBase=0x{imageBase:X16}");
Log.Debug($"ResolveMappedAddressOrFallback addr=0x{address:X} imageBase=0x{imageBase:X16}");
if (address == 0)
{
Console.Error.WriteLine("[LOADER][TEST] -> return 0 (null)");
Log.Debug("-> return 0 (null)");
return 0;
}
if (TryResolveMappedAddress(virtualMemory, address, imageBase, 1, out var resolved))
{
Console.Error.WriteLine($"[LOADER][TEST] -> resolved raw 0x{resolved:X16}");
Log.Debug($"-> resolved raw 0x{resolved:X16}");
return resolved;
}
@@ -1939,18 +1939,18 @@ public sealed class SelfLoader : ISelfLoader
var rebased = address + imageBase;
if (TryResolveMappedAddress(virtualMemory, rebased, imageBase, 1, out var resolvedRebased))
{
Console.Error.WriteLine($"[LOADER][TEST] -> resolved rebased 0x{resolvedRebased:X16}");
Log.Debug($"-> resolved rebased 0x{resolvedRebased:X16}");
return resolvedRebased;
}
}
if (address < 0x10000)
{
Console.Error.WriteLine($"[LOADER][TEST] -> reject small 0x{address:X}");
Log.Debug($"-> reject small 0x{address:X}");
return 0;
}
Console.Error.WriteLine($"[LOADER][TEST] -> fallback raw 0x{address:X}");
Log.Debug($"-> fallback raw 0x{address:X}");
return address;
}
+1 -1
View File
@@ -140,7 +140,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
LastMilestoneLog = null;
KernelModuleRegistry.Reset();
var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version, BuildInfo.CommitSha);
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
LogAppBundleInfo(normalizedEbootPath, image);
RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
+150
View File
@@ -0,0 +1,150 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using System.Collections.Specialized;
namespace SharpEmu.GUI;
public sealed class ConsoleWindow : Window
{
private readonly AvaloniaList<LogLine> _sourceLines;
private readonly AvaloniaList<LogLine> _visibleLines = new();
private readonly ListBox _list;
private readonly TextBox _searchBox;
private readonly CheckBox _autoScrollCheck;
public ConsoleWindow(
AvaloniaList<LogLine> lines,
Action clear,
bool autoScroll)
{
_sourceLines = lines;
Title = "SharpEmu Console";
Width = 980;
Height = 620;
MinWidth = 520;
MinHeight = 320;
Background = new SolidColorBrush(Color.Parse("#0D1017"));
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico")));
_searchBox = new TextBox { Watermark = "Search...", Width = 320, Margin = new Thickness(0, 0, 12, 0) };
_autoScrollCheck = new CheckBox
{
Content = "Auto-scroll",
IsChecked = autoScroll,
FontSize = 12,
Margin = new Thickness(0, 0, 12, 0),
VerticalAlignment = VerticalAlignment.Center,
};
var copyButton = new Button
{
Classes = { "ghost" },
Content = "Copy",
Padding = new Thickness(10, 4),
Margin = new Thickness(0, 0, 8, 0),
};
var clearButton = new Button { Classes = { "ghost" }, Content = "Clear", Padding = new Thickness(10, 4) };
copyButton.Click += async (_, _) => await CopyAsync();
clearButton.Click += (_, _) => clear();
_searchBox.TextChanged += (_, _) => RefreshVisibleLines();
_list = new ListBox
{
Classes = { "console" },
ItemsSource = _visibleLines,
BorderThickness = new Thickness(1),
BorderBrush = new SolidColorBrush(Color.Parse("#232B3A")),
ItemTemplate = new FuncDataTemplate<LogLine>((_, _) =>
{
var text = new TextBlock { TextWrapping = TextWrapping.NoWrap };
text.Bind(TextBlock.TextProperty, new Binding(nameof(LogLine.Text)));
text.Bind(TextBlock.ForegroundProperty, new Binding(nameof(LogLine.Brush)));
return text;
}),
};
Content = new Grid
{
Margin = new Thickness(12),
RowDefinitions = new RowDefinitions("Auto,*"),
Children =
{
new Grid
{
Margin = new Thickness(0, 0, 0, 8),
ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto,Auto"),
Children =
{
new TextBlock
{
Classes = { "sectionTitle" },
Text = "CONSOLE",
VerticalAlignment = VerticalAlignment.Center,
},
_searchBox.WithGridColumn(1),
_autoScrollCheck.WithGridColumn(2),
copyButton.WithGridColumn(3),
clearButton.WithGridColumn(4),
},
},
_list.WithGridRow(1),
},
};
lines.CollectionChanged += OnLinesChanged;
Closed += (_, _) => lines.CollectionChanged -= OnLinesChanged;
RefreshVisibleLines();
}
private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RefreshVisibleLines();
if (_autoScrollCheck.IsChecked == true)
{
Dispatcher.UIThread.Post(() => (_list.Scroll as ScrollViewer)?.ScrollToEnd());
}
}
private void RefreshVisibleLines()
{
var query = _searchBox.Text ?? string.Empty;
_visibleLines.Clear();
_visibleLines.AddRange(string.IsNullOrWhiteSpace(query)
? _sourceLines
: _sourceLines.Where(line => line.Text.Contains(query, StringComparison.OrdinalIgnoreCase)));
}
private async Task CopyAsync()
{
if (_visibleLines.Count == 0 || Clipboard is null)
{
return;
}
await Clipboard.SetTextAsync(string.Join(Environment.NewLine, _visibleLines.Select(line => line.Text)));
}
}
file static class GridExtensions
{
public static T WithGridColumn<T>(this T control, int column) where T : Control
{
Grid.SetColumn(control, column);
return control;
}
public static T WithGridRow<T>(this T control, int row) where T : Control
{
Grid.SetRow(control, row);
return control;
}
}
+252
View File
@@ -0,0 +1,252 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
namespace SharpEmu.GUI;
/// <summary>
/// Minimal Discord Rich Presence client. Speaks Discord's local IPC
/// protocol (framed JSON over the discord-ipc-N named pipe) directly, so no
/// external dependency is needed. All failures are silent: no Discord, no
/// presence, no impact on the launcher.
/// </summary>
public sealed class DiscordRichPresence : IDisposable
{
private const int OpHandshake = 0;
private const int OpFrame = 1;
private const int MaxFrameBytes = 64 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
private readonly object _gate = new();
private readonly string _clientId;
private readonly AutoResetEvent _signal = new(false);
private readonly Thread _worker;
private NamedPipeClientStream? _pipe;
private PresenceState? _desired;
private PresenceState? _sent;
private volatile bool _disposed;
private sealed record PresenceState(string Details, string? State, long? StartUnixSeconds);
public DiscordRichPresence(string clientId)
{
_clientId = clientId;
_worker = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "DiscordRichPresence",
};
_worker.Start();
}
/// <summary>
/// Requests the given presence; the worker applies it asynchronously and
/// retries (with reconnection) until it is delivered or replaced.
/// </summary>
public void SetPresence(string details, string? state = null, long? startUnixSeconds = null)
{
lock (_gate)
{
_desired = new PresenceState(details, state, startUnixSeconds);
}
Trace($"queued details=\"{details}\" state=\"{state}\"");
_signal.Set();
}
public void Dispose()
{
_disposed = true;
_signal.Set();
ClosePipe();
}
private void WorkerLoop()
{
while (!_disposed)
{
// Woken immediately on presence changes; the periodic tick
// retries after connection failures (e.g. Discord starts later).
_signal.WaitOne(TimeSpan.FromSeconds(15));
if (_disposed || _clientId.Length == 0)
{
continue;
}
PresenceState? desired;
lock (_gate)
{
desired = _desired;
}
if (desired is null || desired == _sent)
{
continue;
}
try
{
EnsureConnected();
SendActivity(desired);
_sent = desired;
Trace($"sent details=\"{desired.Details}\" state=\"{desired.State}\"");
}
catch (Exception exception)
{
// Connection died (or never existed); reconnect on a later tick.
Trace($"send failed: {exception.Message}");
ClosePipe();
_sent = null;
}
}
}
private void EnsureConnected()
{
if (_pipe is { IsConnected: true })
{
return;
}
ClosePipe();
Exception? lastError = null;
for (var index = 0; index < 10; index++)
{
var pipe = new NamedPipeClientStream(
".",
$"discord-ipc-{index}",
PipeDirection.InOut,
PipeOptions.Asynchronous);
try
{
pipe.Connect(500);
_pipe = pipe;
WriteFrame(OpHandshake, JsonSerializer.Serialize(new
{
v = 1,
client_id = _clientId,
}));
// Discord replies with a READY (or error) frame; consume it
// so the pipe buffer stays drained.
_ = ReadFrame();
return;
}
catch (Exception exception)
{
lastError = exception;
pipe.Dispose();
_pipe = null;
}
}
throw lastError ?? new IOException("Discord IPC pipe not found.");
}
private void SendActivity(PresenceState presence)
{
var payload = new Dictionary<string, object?>
{
["cmd"] = "SET_ACTIVITY",
["nonce"] = Guid.NewGuid().ToString(),
["args"] = new Dictionary<string, object?>
{
["pid"] = Environment.ProcessId,
["activity"] = new Dictionary<string, object?>
{
["details"] = presence.Details,
["state"] = presence.State,
["timestamps"] = presence.StartUnixSeconds is { } start
? new Dictionary<string, object?> { ["start"] = start }
: null,
["assets"] = new Dictionary<string, object?>
{
["large_image"] = "logo",
["large_text"] = "SharpEmu",
},
},
},
};
WriteFrame(OpFrame, JsonSerializer.Serialize(payload, JsonOptions));
// Consume Discord's acknowledgement to keep the pipe drained.
_ = ReadFrame();
}
private void WriteFrame(int opcode, string json)
{
var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected.");
var payload = Encoding.UTF8.GetBytes(json);
var frame = new byte[8 + payload.Length];
BitConverter.TryWriteBytes(frame.AsSpan(0, 4), opcode);
BitConverter.TryWriteBytes(frame.AsSpan(4, 4), payload.Length);
payload.CopyTo(frame, 8);
pipe.Write(frame, 0, frame.Length);
pipe.Flush();
}
private string ReadFrame()
{
var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected.");
var header = ReadExactly(pipe, 8);
var length = BitConverter.ToInt32(header, 4);
if (length is < 0 or > MaxFrameBytes)
{
throw new IOException($"Discord IPC frame length {length} is out of range.");
}
return Encoding.UTF8.GetString(ReadExactly(pipe, length));
}
private static byte[] ReadExactly(NamedPipeClientStream pipe, int count)
{
var buffer = new byte[count];
var read = 0;
while (read < count)
{
var chunk = pipe.Read(buffer, read, count - read);
if (chunk <= 0)
{
throw new IOException("Discord IPC pipe closed.");
}
read += chunk;
}
return buffer;
}
private static void Trace(string message)
{
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_DISCORD"),
"1",
StringComparison.Ordinal))
{
Console.Error.WriteLine($"[DISCORD] {message}");
}
}
private void ClosePipe()
{
var pipe = _pipe;
_pipe = null;
try
{
pipe?.Dispose();
}
catch (Exception)
{
// Best effort; the pipe may already be broken.
}
}
}
+12
View File
@@ -85,6 +85,14 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
// Prefer terminating the job: it kills the whole tree, including
// any children the emulator spawned, even when the main process
// is wedged in a GPU driver call.
if (_jobHandle != 0)
{
_ = TerminateJobObject(_jobHandle, 1);
}
if (_processHandle != 0)
{
_ = TerminateProcess(_processHandle, 1);
@@ -639,6 +647,10 @@ internal sealed class EmulatorProcess : IDisposable
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TerminateProcess(nint process, uint exitCode);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TerminateJobObject(nint job, uint exitCode);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(nint handle);
+22 -1
View File
@@ -23,14 +23,35 @@ public sealed class GuiSettings
public bool StrictDynlibResolution { get; set; }
/// <summary>Mirror emulator output to user/logs/&lt;titleId&gt;-&lt;timestamp&gt;.log.</summary>
/// <summary>
/// Mirror emulator output to user/logs/&lt;titleId&gt;-&lt;timestamp&gt;.log, if <see cref="LogFilePath"/> is null.
/// </summary>
public bool LogToFile { get; set; }
/// <summary>If <see cref="LogToFile"/> is true it logs to this file path.</summary>
public string? LogFilePath { get; set; }
/// <summary>
/// If <see cref="OverrideLogFile"/> is false it appends &lt;titleId&gt;-&lt;timestamp&gt; to the filename specified by
/// <see cref="LogFilePath"/>. Otherwise it uses the exact filename from <see cref="LogFilePath"/>
/// </summary>
public bool OverrideLogFile { get; set; }
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
public bool PlayTitleMusic { get; set; } = true;
public string? EmulatorPath { get; set; }
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
public bool DiscordRichPresence { get; set; } = true;
/// <summary>
/// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as
/// "Playing …" (register at discord.com/developers/applications).
/// </summary>
public string DiscordClientId { get; set; } = "1525606762248540221";
// The emulator is portable and keeps its data next to the executable;
// the GUI follows the same convention.
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json");
+25 -7
View File
@@ -14,7 +14,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="PreferSystemChrome"
ExtendClientAreaTitleBarHeightHint="44"
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico">
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown">
<Grid RowDefinitions="44,*,32">
@@ -159,13 +160,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,12,16,8">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
FontSize="12" Margin="0,0,12,0" />
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
Watermark="Search..." Width="320" />
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
FontSize="12" Margin="0,0,12,0" />
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
Padding="10,4" Margin="0,0,8,0" />
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
<Button Grid.Column="4" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
Padding="10,4" Margin="0,0,8,0" />
<Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
Padding="10,4" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
@@ -252,10 +257,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TextBlock Classes="fieldLabel" Text="Log to file" />
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log file path" />
<Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Override log file" />
<ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Title music" />
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
</StackPanel>
<StackPanel>
<TextBlock Classes="fieldLabel" Text="Discord presence" />
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off" />
</StackPanel>
</WrapPanel>
</StackPanel>
</Border>
+274 -30
View File
@@ -1,22 +1,24 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Reflection;
using System.Text.Json;
using System.Diagnostics;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Libs.Pad;
using SharpEmu.Logging;
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
namespace SharpEmu.GUI;
@@ -33,17 +35,26 @@ public partial class MainWindow : Window
private readonly List<GameEntry> _allGames = new();
private readonly ObservableCollection<GameEntry> _visibleGames = new();
private readonly ObservableCollection<LogLine> _consoleLines = new();
private readonly AvaloniaList<LogLine> _consoleLines = new();
private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer;
private GuiSettings _settings = new();
private EmulatorProcess? _emulator;
private ConsoleWindow? _consoleWindow;
private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath;
private bool _isRunning;
private int _autoScrollTicks;
// Discord Rich Presence state.
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private DiscordRichPresence? _discord;
private string? _runningGameName;
private string? _runningGameTitleId;
private long _runningSinceUnixSeconds;
private int _detailLoadGeneration;
private int _backdropGeneration;
@@ -77,17 +88,27 @@ public partial class MainWindow : Window
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
GameList.DoubleTapped += (_, _) => LaunchSelected();
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected();
StopButton.Click += (_, _) => _emulator?.Stop();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
SelectLogFilePathButton.Click += async (_, _) => await SelectFilePathAsync();
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
DiscordToggle.IsCheckedChanged += (_, _) =>
{
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
UpdateDiscordPresence();
};
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
@@ -164,7 +185,7 @@ public partial class MainWindow : Window
if ((pressed & 0x2000) != 0) // Circle
{
_emulator?.Stop();
StopEmulator();
}
_previousPadButtons = pad.Buttons;
@@ -235,9 +256,73 @@ public partial class MainWindow : Window
_settings = GuiSettings.Load();
ApplySettingsToControls();
LocateEmulator();
UpdateDiscordPresence();
await RescanLibraryAsync();
}
// ---- Discord Rich Presence ----
/// <summary>
/// Publishes the launcher state to Discord: browsing while idle, the
/// running game (with elapsed time) during emulation. No-ops when
/// disabled or when no Discord application ID is configured.
/// </summary>
private void UpdateDiscordPresence()
{
if (!_settings.DiscordRichPresence || _settings.DiscordClientId.Length == 0)
{
_discord?.Dispose();
_discord = null;
return;
}
_discord ??= new DiscordRichPresence(_settings.DiscordClientId);
if (_isRunning && _runningGameName is { } gameName)
{
_discord.SetPresence(
$"Playing {gameName}",
_runningGameTitleId,
_runningSinceUnixSeconds);
}
else
{
// Discord does not render activities without timestamps, so the
// browsing state carries the launcher's start time.
_discord.SetPresence(
"Browsing the library",
$"{_allGames.Count} game(s)",
_launcherStartUnixSeconds);
}
}
private void OnKeyDown(object sender, KeyEventArgs args)
{
args.Handled = true;
switch (args.Key)
{
case Key.F11:
OnWindowFullScreen(this, new RoutedEventArgs());
break;
default:
args.Handled = false;
break;
}
}
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
{
if (WindowState == WindowState.FullScreen)
{
WindowState = WindowState.Normal;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
}
else
{
WindowState = WindowState.FullScreen;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
}
}
private void OnWindowClosing()
{
ReadControlsIntoSettings();
@@ -245,6 +330,8 @@ public partial class MainWindow : Window
_consoleFlushTimer.Stop();
_gamepadTimer.Stop();
_sndPreview.Stop();
_discord?.Dispose();
_consoleWindow?.Close();
_emulator?.Dispose();
DropFileLog();
}
@@ -274,7 +361,10 @@ public partial class MainWindow : Window
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
ToolTip.SetTip(SelectLogFilePathButton, string.IsNullOrWhiteSpace(_settings.LogFilePath) ? "No path selected" : _settings.LogFilePath);
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
}
private void ReadControlsIntoSettings()
@@ -283,7 +373,9 @@ public partial class MainWindow : Window
_settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
_settings.LogToFile = LogToFileToggle.IsChecked == true;
_settings.OverrideLogFile = OverrideLogFileToggle.IsChecked == true;
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
}
private string SelectedLogLevel()
@@ -382,6 +474,7 @@ public partial class MainWindow : Window
_allGames.AddRange(games);
RefreshVisibleGames();
LoadGameDetailsInBackground(games);
UpdateDiscordPresence();
StatusBarRight.Text = folders.Length == 0
? "Add a game folder to populate the library."
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
@@ -976,16 +1069,51 @@ public partial class MainWindow : Window
// Mirror everything the console pane shows into a log file for the
// duration of the run, regardless of the emulator's log level.
DropFileLog();
if (_settings.LogToFile && BuildLogFilePath(titleId) is { } logFilePath)
if (_settings.LogToFile)
{
try
string filePath;
if (!string.IsNullOrWhiteSpace(_settings.LogFilePath))
{
_fileLog = new StreamWriter(logFilePath, append: false);
AppendConsoleLine($"Log file: {logFilePath}", DimLineBrush);
if (_settings.OverrideLogFile)
{
filePath = _settings.LogFilePath;
}
else
{
string path = _settings.LogFilePath;
string id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
foreach (var invalidChar in Path.GetInvalidFileNameChars())
{
id = id.Replace(invalidChar.ToString(), "");
}
string identifier = $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}";
string? dir = Path.GetDirectoryName(path);
string? fileName = Path.GetFileNameWithoutExtension(path);
string? extension = Path.GetExtension(path);
string newFileName = $"{fileName}-{identifier}{extension}";
filePath = string.IsNullOrEmpty(dir)
? newFileName
: Path.Combine(dir, newFileName);
}
}
catch (Exception ex)
else
{
AppendConsoleLine($"Could not open the log file: {ex.Message}", WarningLineBrush);
filePath = BuildLogFilePath(titleId) ?? string.Empty;
}
if (!string.IsNullOrEmpty(filePath))
{
try
{
_fileLog = new StreamWriter(filePath, append: false);
AppendConsoleLine($"Log file: {filePath}", DimLineBrush);
}
catch (Exception ex)
{
AppendConsoleLine($"Could not open the log file: {ex.Message}", WarningLineBrush);
}
}
}
@@ -1009,10 +1137,38 @@ public partial class MainWindow : Window
_emulator = emulator;
_isRunning = true;
_runningGameName = displayName;
_runningGameTitleId = _allGames
.FirstOrDefault(game => game.Path.Equals(ebootPath, StringComparison.OrdinalIgnoreCase))?
.TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = $"Running — {displayName}";
StatusBarRight.Text = $"Running {displayName}";
UpdateRunButtons();
UpdateDiscordPresence();
}
/// <summary>
/// Stops the running game and updates status/presence immediately. The
/// process-exit path still runs when the corpse is collected, but a game
/// wedged in a GPU driver call can keep its process alive for a long
/// time after termination — the launcher should not look (or tell
/// Discord it is) "playing" during that window.
/// </summary>
private void StopEmulator()
{
if (!_isRunning)
{
return;
}
_emulator?.Stop();
_runningGameName = null;
_runningGameTitleId = null;
StatusText.Text = "Stopping…";
StatusBarRight.Text = "Stopping…";
UpdateDiscordPresence();
}
/// <summary>
@@ -1069,7 +1225,10 @@ public partial class MainWindow : Window
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
StatusBarRight.Text = "Idle";
_runningGameName = null;
_runningGameTitleId = null;
UpdateRunButtons();
UpdateDiscordPresence();
}
private void UpdateRunButtons()
@@ -1079,6 +1238,27 @@ public partial class MainWindow : Window
OpenFileButton.IsEnabled = !_isRunning;
}
private async Task SelectFilePathAsync()
{
SaveFilePickerResult result = await StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions
{
Title = "Select where to save the Log file",
SuggestedFileName = "SharpEmuLog",
DefaultExtension = "log",
FileTypeChoices =
[
new FilePickerFileType("Plain Text Files") { Patterns = ["*.txt"] },
new FilePickerFileType("Log Files") { Patterns = ["*.log"] }
]
});
if (result.File is not null)
{
_settings.LogFilePath = result.File.Path.LocalPath;
ToolTip.SetTip(SelectLogFilePathButton, _settings.LogFilePath);
}
}
// ---- Console ----
private void FlushPendingConsoleLines()
@@ -1097,27 +1277,28 @@ public partial class MainWindow : Window
FlushFileLog();
if (incoming.Count >= MaxConsoleLines)
_allConsoleLines.AddRange(incoming);
string query = ConsoleSearchBox.Text ?? string.Empty;
IEnumerable<LogLine> linesToAdd = incoming;
if (!string.IsNullOrWhiteSpace(query))
{
// A burst larger than the cap: keep only the newest lines.
_consoleLines.Clear();
for (var i = incoming.Count - MaxConsoleLines; i < incoming.Count; i++)
{
_consoleLines.Add(incoming[i]);
}
linesToAdd = incoming.Where(line =>
line.Text != null &&
line.Text.Contains(query, StringComparison.OrdinalIgnoreCase));
}
else
_consoleLines.AddRange(linesToAdd);
var overflow = _consoleLines.Count - MaxConsoleLines;
while (_allConsoleLines.Count > MaxConsoleLines)
{
var overflow = _consoleLines.Count + incoming.Count - MaxConsoleLines;
for (var i = 0; i < overflow; i++)
var droppedLine = _allConsoleLines[0];
_allConsoleLines.RemoveAt(0);
if (_consoleLines.Count > 0 && _consoleLines[0] == droppedLine)
{
_consoleLines.RemoveAt(0);
}
foreach (var line in incoming)
{
_consoleLines.Add(line);
}
}
_autoScrollTicks = 3;
@@ -1127,11 +1308,50 @@ public partial class MainWindow : Window
{
WriteFileLog(text);
FlushFileLog();
_consoleLines.Add(new LogLine(text, brush));
var line = new LogLine(text, brush);
_allConsoleLines.Add(line);
string query = ConsoleSearchBox.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(query) || (text != null && text.Contains(query, StringComparison.OrdinalIgnoreCase)))
{
_consoleLines.Add(line);
}
while (_allConsoleLines.Count > MaxConsoleLines)
{
var droppedLine = _allConsoleLines[0];
_allConsoleLines.RemoveAt(0);
if (_consoleLines.Count > 0 && _consoleLines[0] == droppedLine)
{
_consoleLines.RemoveAt(0);
}
}
_autoScrollTicks = 3;
MaybeAutoScroll();
}
private void RefreshVisibleConsoleLines()
{
string query = ConsoleSearchBox.Text ?? string.Empty;
_consoleLines.Clear();
if (string.IsNullOrWhiteSpace(query))
{
_consoleLines.AddRange(_allConsoleLines);
}
else
{
var filtered = _allConsoleLines.Where(line =>
line.Text != null &&
line.Text.Contains(query, StringComparison.OrdinalIgnoreCase));
_consoleLines.AddRange(filtered);
}
}
// ---- Console-to-file mirroring ----
private void WriteFileLog(string text)
@@ -1253,4 +1473,28 @@ public partial class MainWindow : Window
var text = string.Join(Environment.NewLine, _consoleLines.Select(line => line.Text));
await Clipboard.SetTextAsync(text);
}
private void ShowConsoleWindow()
{
if (_consoleWindow is { } window)
{
window.Activate();
return;
}
ConsoleSearchBox.Text = string.Empty;
ConsoleToggle.IsChecked = false;
ConsolePanel.IsVisible = false;
_consoleWindow = new ConsoleWindow(
_consoleLines,
() => { _consoleLines.Clear(); _allConsoleLines.Clear(); },
AutoScrollCheck.IsChecked == true);
_consoleWindow.Closed += (_, _) =>
{
_consoleWindow = null;
ConsoleToggle.IsChecked = true;
ConsolePanel.IsVisible = true;
};
_consoleWindow.Show(this);
}
}
+14
View File
@@ -40,6 +40,13 @@ public enum OrbisGen2Result : int
/// </summary>
ORBIS_GEN2_ERROR_DEADLOCK = unchecked((int)0x8002000B),
/// <summary>
/// Indicates that the waited-on object was deleted while the caller was
/// blocked on it. Matches the SCE kernel EACCES code that waiters of a
/// deleted semaphore observe.
/// </summary>
ORBIS_GEN2_ERROR_DELETED = unchecked((int)0x8002000D),
/// <summary>
/// Indicates that the target resource is busy.
/// </summary>
@@ -50,6 +57,13 @@ public enum OrbisGen2Result : int
/// </summary>
ORBIS_GEN2_ERROR_TRY_AGAIN = unchecked((int)0x80020023),
/// <summary>
/// Indicates that a blocked wait was canceled (e.g. sceKernelCancelSema).
/// Matches the SCE kernel ECANCELED code that canceled semaphore waiters
/// observe.
/// </summary>
ORBIS_GEN2_ERROR_CANCELED = unchecked((int)0x80020055),
/// <summary>
/// Indicates that behavior is recognized but not implemented yet.
/// </summary>
+329 -107
View File
@@ -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))
{
@@ -3206,10 +3296,60 @@ public static class AgcExports
globalMemoryBindings,
vertexInputs,
renderTargets,
CreateRenderState(state.CxRegisters, renderTargets.FirstOrDefault()));
ApplyTransparentPremultipliedFillClear(
CreateRenderState(state.CxRegisters, renderTargets.FirstOrDefault()),
textures,
vertexInputs,
pixelEvaluation.InitialScalarRegisters));
return true;
}
private static readonly bool _transparentFillClearEnabled = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_TRANSPARENT_FILL_CLEAR"),
"1",
StringComparison.Ordinal);
/// <summary>
/// Chowdren resets its effect layers with an untextured transparent-black
/// fill using premultiplied blending. With One/OneMinusSrcAlpha that draw
/// is otherwise a no-op, causing fog and vignette layers to accumulate.
/// Treat precisely that draw shape as an overwrite.
/// </summary>
private static VulkanGuestRenderState ApplyTransparentPremultipliedFillClear(
VulkanGuestRenderState renderState,
IReadOnlyList<TranslatedImageBinding> textures,
IReadOnlyList<Gen5VertexInputBinding> vertexInputs,
IReadOnlyList<uint> pixelUserData)
{
if (!_transparentFillClearEnabled ||
textures.Count != 0 ||
vertexInputs.Count != 0 ||
pixelUserData.Count < 4 ||
renderState.Blend is not
{
Enable: true,
ColorSrcFactor: 1,
ColorDstFactor: 5,
ColorFunc: 0,
})
{
return renderState;
}
for (var index = 0; index < 4; index++)
{
if ((pixelUserData[index] & 0x7FFF_FFFFu) != 0)
{
return renderState;
}
}
return renderState with
{
Blend = renderState.Blend with { Enable = false },
};
}
private static VulkanGuestIndexBuffer? CreateVulkanIndexBuffer(
CpuContext ctx,
SubmittedDcbState state,
@@ -5635,4 +5775,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;
}
}
+102
View File
@@ -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<ulong, List<WaitingDcb>> _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<WaitingDcb>();
_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<WaitingDcb>? CollectSatisfied(Func<ulong, bool, ulong?> readValue)
{
List<WaitingDcb>? woken = null;
lock (_gate)
{
List<ulong>? 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<WaitingDcb>();
woken.Add(list[i]);
list.RemoveAt(i);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
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,
};
}
}
+38 -4
View File
@@ -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<byte> 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;
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<DirectoryEntry>? Directory;
}
private static readonly ConcurrentDictionary<uint, FileState> _state = new();
/// <summary>
/// Resolves what "read the next sequential chunk of size <paramref name="requestedSize"/>"
/// really means for this fileId: an unconsumed directory entry whose length matches, if the
/// directory has already been parsed, otherwise the tracked linear cursor.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<byte> 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<DirectoryEntry>? TryParseDirectory(CpuContext ctx, ulong destination, ulong length)
{
var count = (int)(length / DirectoryEntrySize);
if (count <= 0)
{
return null;
}
var entries = new List<DirectoryEntry>(count);
Span<byte> 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;
}
}
+11 -13
View File
@@ -8,9 +8,14 @@ namespace SharpEmu.Libs.Audio;
public static class AudioOut2Exports
{
private const int AudioOut2ContextParamSize = 0x80;
// Sized from guest evidence, not SDK headers: Quake keeps its
// SceAudioOut2ContextParam on the stack with the frame canary at param+0x60,
// and an earlier 0x80-byte ResetParam write zeroed that canary
// (__stack_chk_fail right after sceAudioOut2UserCreate, which silently killed
// the whole audio init). Stay well below 0x60 and only write the prefix we
// populate.
private const int AudioOut2ContextParamSize = 0x30;
private const int AudioOut2ContextMemorySize = 0x10000;
private const int AudioOut2ContextMemoryAlignment = 0x10000;
private static long _nextContextHandle = 1;
private static long _nextUserHandle = 1;
private static int _nextPortId;
@@ -59,20 +64,13 @@ public static class AudioOut2Exports
public static int AudioOut2ContextQueryMemory(CpuContext ctx)
{
var paramAddress = ctx[CpuRegister.Rdi];
var memoryInfoAddress = ctx[CpuRegister.Rsi];
if (paramAddress == 0 || memoryInfoAddress == 0)
var outMemorySizeAddress = ctx[CpuRegister.Rsi];
if (paramAddress == 0 || outMemorySizeAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
Span<byte> memoryInfo = stackalloc byte[0x20];
memoryInfo.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x00..], AudioOut2ContextMemorySize);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x08..], AudioOut2ContextMemoryAlignment);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x10..], AudioOut2ContextMemorySize);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x18..], AudioOut2ContextMemoryAlignment);
return ctx.Memory.TryWrite(memoryInfoAddress, memoryInfo)
return ctx.TryWriteUInt64(outMemorySizeAddress, AudioOut2ContextMemorySize)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
@@ -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",
@@ -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(
@@ -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<byte> 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<byte> bytes, string label, bool chaseNested)
{
Span<byte> 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<byte> 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<byte> 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;
}
+2 -2
View File
@@ -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")]
+104
View File
@@ -0,0 +1,104 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.DiscMap;
/// <summary>
/// HLE implementation of libSceDiscMap, ported from Kyty's LibDiscMap.cpp
/// (https://github.com/InoriRus/Kyty, MIT). Titles installed to internal
/// storage query these entry points on nearly every file access to decide
/// whether a read must be redirected to the Blu-ray drive. Reporting every
/// request as already resident on HDD lets the regular file system path
/// service the read.
/// </summary>
public static class DiscMapExports
{
private const int DiscMapErrorInvalidArgument = unchecked((int)0x81100001);
private const int DiscMapErrorLocationNotMapped = unchecked((int)0x81100002);
private const int DiscMapErrorFileNotFound = unchecked((int)0x81100003);
private const int DiscMapErrorNoBitmapInfo = unchecked((int)0x81100004);
[SysAbiExport(
Nid = "lbQKqsERhtE",
ExportName = "sceDiscMapIsRequestOnHDD",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceDiscMap")]
public static int DiscMapIsRequestOnHDD(CpuContext ctx)
{
var pathAddress = ctx[CpuRegister.Rdi];
var offset = ctx[CpuRegister.Rsi];
var size = ctx[CpuRegister.Rdx];
var resultAddress = ctx[CpuRegister.Rcx];
if (pathAddress == 0 || resultAddress == 0)
{
return DiscMapErrorInvalidArgument;
}
if (!ctx.TryWriteInt32(resultAddress, 1))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
TraceDiscMap(ctx, "sceDiscMapIsRequestOnHDD", pathAddress, offset, size);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "fJgP+wqifno",
ExportName = "sceDiscMapUnknownFJgP",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceDiscMap")]
public static int DiscMapUnknownFJgP(CpuContext ctx) => WriteMappingTriple(ctx, "sceDiscMapUnknownFJgP");
[SysAbiExport(
Nid = "ioKMruft1ek",
ExportName = "sceDiscMapUnknownIoKM",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceDiscMap")]
public static int DiscMapUnknownIoKM(CpuContext ctx) => WriteMappingTriple(ctx, "sceDiscMapUnknownIoKM");
private static int WriteMappingTriple(CpuContext ctx, string exportName)
{
var pathAddress = ctx[CpuRegister.Rdi];
var offset = ctx[CpuRegister.Rsi];
var size = ctx[CpuRegister.Rdx];
var flagsAddress = ctx[CpuRegister.Rcx];
var outAddress1 = ctx[CpuRegister.R8];
var outAddress2 = ctx[CpuRegister.R9];
if (pathAddress == 0 || flagsAddress == 0 || outAddress1 == 0 || outAddress2 == 0)
{
return DiscMapErrorInvalidArgument;
}
if (!ctx.TryWriteUInt64(flagsAddress, 0) ||
!ctx.TryWriteUInt64(outAddress1, 0) ||
!ctx.TryWriteUInt64(outAddress2, 0))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
TraceDiscMap(ctx, exportName, pathAddress, offset, size);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static void TraceDiscMap(CpuContext ctx, string exportName, ulong pathAddress, ulong offset, ulong size)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_DISCMAP"), "1", StringComparison.Ordinal))
{
return;
}
var path = ctx.TryReadNullTerminatedUtf8(pathAddress, 1024, out var value)
? value
: $"<unreadable 0x{pathAddress:X16}>";
Console.Error.WriteLine($"[HLE][DISCMAP] {exportName} path={path} offset=0x{offset:X} size=0x{size:X}");
}
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Ime;
public static class ImeExports
{
// Quake (KEX) calls this from its main loop and from the audio bring-up path with
// an event-handler pointer. No IME session ever exists here, so report success
// without invoking the handler ("no pending IME events"). This NID was previously
// misbound as an sceNgs2VoiceControl alias, which fed the game NGS2 errors.
[SysAbiExport(
Nid = "-4GCfYdNF1s",
ExportName = "sceImeUpdate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceIme")]
public static int ImeUpdate(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "eaFXjfJv3xs",
ExportName = "sceImeKeyboardOpen",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceIme")]
public static int ImeKeyboardOpen(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "dKadqZFgKKQ",
ExportName = "sceImeKeyboardGetResourceId",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceIme")]
public static int ImeKeyboardGetResourceId(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
@@ -149,6 +149,19 @@ public static class KernelAprCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Success stub: the argument layout is unknown and callers tolerate the
// empty answer (Quake streams fine), so no output payload is written until
// the real signature is reversed.
[SysAbiExport(
Nid = "WvEu7yl3Ivg",
ExportName = "sceKernelAprGetFileSize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int KernelAprGetFileSize(CpuContext ctx)
{
return ctx.SetReturn(0);
}
private static bool TryWriteAprResult(CpuContext ctx, ulong resultAddress)
{
Span<byte> result = stackalloc byte[sizeof(ulong)];
+101 -13
View File
@@ -13,6 +13,8 @@ public static class KernelExports
private static readonly object _coredumpGate = new();
private static ulong _coredumpHandler;
private static ulong _coredumpHandlerContext;
private const uint Gen4CompiledSdkVersion = 0x05000000;
private const uint Gen5CompiledSdkVersion = 0x09000000;
private readonly record struct CxaDestructorEntry(
ulong Function,
@@ -26,7 +28,24 @@ public static class KernelExports
LibraryName = "libKernel")]
public static int KernelGetCompiledSdkVersion(CpuContext ctx)
{
_ = ctx;
var versionAddress = ctx[CpuRegister.Rdi];
if (versionAddress == 0)
{
ctx[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var sdkVersion = ctx.TargetGeneration == Generation.Gen5
? Gen5CompiledSdkVersion
: Gen4CompiledSdkVersion;
if (!ctx.TryWriteUInt32(versionAddress, sdkVersion))
{
ctx[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -54,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",
@@ -244,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",
@@ -334,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",
@@ -435,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;
}
}
@@ -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;
@@ -67,13 +69,6 @@ public static class KernelMemoryCompatExports
private const int Einval = 22;
private const int Erange = 34;
private const int Struncate = 80;
private const int ClockRealtime = 0;
private const int ClockVirtual = 1;
private const int ClockProf = 2;
private const int ClockMonotonic = 4;
private const int ClockUptime = 5;
private const int ClockRealtimeFast = 10;
private const int ClockMonotonicFast = 12;
private const nuint DefaultLibcHeapAlignment = 16;
private const ushort KernelStatModeDirectory = 0x41FF;
private const ushort KernelStatModeRegular = 0x81FF;
@@ -128,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;
@@ -150,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; }
@@ -158,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);
@@ -193,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)
@@ -207,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;
@@ -217,7 +228,7 @@ public static class KernelMemoryCompatExports
_mappedRegions[address] = new MappedRegion(
address,
mappedLength,
OrbisProtCpuReadWrite,
protection,
IsFlexible: false,
IsDirect: false,
DirectStart: 0);
@@ -238,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)
@@ -288,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;
}
@@ -311,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;
}
@@ -543,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
: "<unreadable>";
_ = 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<byte> leftByte = stackalloc byte[1];
Span<byte> 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",
@@ -594,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",
@@ -952,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<byte>(count);
if (count > 0 && (!TryReadCompat(ctx, source, payload) || !TryWriteCompat(ctx, destination, payload)))
{
ctx[CpuRegister.Rax] = destination;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
@@ -1203,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",
@@ -2011,29 +2250,11 @@ public static class KernelMemoryCompatExports
long seconds;
long nanoseconds;
switch (clockId)
if (!KernelRuntimeCompatExports.ResolveClockTime(clockId, out seconds, out nanoseconds))
{
case ClockRealtime:
case ClockRealtimeFast:
case ClockVirtual:
case ClockProf:
{
var now = DateTimeOffset.UtcNow;
seconds = now.ToUnixTimeSeconds();
nanoseconds = (now.Ticks % TimeSpan.TicksPerSecond) * 100;
break;
}
case ClockMonotonic:
case ClockMonotonicFast:
case ClockUptime:
KernelRuntimeCompatExports.GetProcessMonotonicTime(out seconds, out nanoseconds);
break;
default:
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
ctx[CpuRegister.Rax] = unchecked((ulong)-1);
return -1;
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
ctx[CpuRegister.Rax] = unchecked((ulong)-1);
return -1;
}
Span<byte> timespecBuffer = stackalloc byte[16];
@@ -4176,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))
{
@@ -4500,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 &&
@@ -4779,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)
@@ -5568,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
{
@@ -5603,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
@@ -5623,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;
@@ -5731,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)
@@ -6337,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;
}
}
@@ -14,6 +14,22 @@ namespace SharpEmu.Libs.Kernel;
public static class KernelRuntimeCompatExports
{
internal const int ClockRealtime = 0;
internal const int ClockVirtual = 1;
internal const int ClockProf = 2;
internal const int ClockMonotonic = 4;
internal const int ClockUptime = 5;
internal const int ClockUptimePrecise = 7;
internal const int ClockUptimeFast = 8;
internal const int ClockRealtimePrecise = 9;
internal const int ClockRealtimeFast = 10;
internal const int ClockMonotonicPrecise = 11;
internal const int ClockMonotonicFast = 12;
internal const int ClockSecond = 13;
internal const int ClockThreadCputimeId = 14;
internal const int ClockProcTime = 15;
private const int Efault = 14;
private const int Einval = 22;
private const ulong TlsErrnoOffset = 0x40;
private const ulong TlsStackChkGuardBaseOffset = 0x800;
private const ulong StackChkGuardFieldOffset = 0x10;
@@ -64,6 +80,77 @@ public static class KernelRuntimeCompatExports
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate ulong RdtscDelegate();
[SysAbiExport(
Nid = "QvsZxomvUHs",
ExportName = "sceKernelNanosleep",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int KernelNanosleep(CpuContext ctx)
{
var requestAddress = ctx[CpuRegister.Rdi];
var remainAddress = ctx[CpuRegister.Rsi];
if (requestAddress == 0)
{
ctx[CpuRegister.Rax] = Einval;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> timespecBuffer = stackalloc byte[16];
if (!ctx.Memory.TryRead(requestAddress, timespecBuffer))
{
ctx[CpuRegister.Rax] = Efault;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var tvSec = BinaryPrimitives.ReadInt64LittleEndian(timespecBuffer);
var tvNsec = BinaryPrimitives.ReadInt64LittleEndian(timespecBuffer[sizeof(long)..]);
if (tvSec < 0 || tvNsec < 0 || tvNsec >= 1_000_000_000L)
{
ctx[CpuRegister.Rax] = Einval;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (tvSec == 0 && tvNsec == 0)
{
WriteRemainingTime(ctx, remainAddress, 0, 0);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
GuestThreadExecution.Scheduler?.Pump(ctx, "sceKernelNanosleep");
// TimeSpan resolution is 100 ns ticks, so sub-100 ns requests round up to
// a single tick rather than collapsing to a zero-length (no-op) sleep.
var totalTicks = tvSec * TimeSpan.TicksPerSecond + Math.Max(tvNsec / 100L, 1L);
try
{
Thread.Sleep(TimeSpan.FromTicks(totalTicks));
}
catch (ArgumentOutOfRangeException)
{
Thread.Sleep(TimeSpan.FromMilliseconds(int.MaxValue));
}
WriteRemainingTime(ctx, remainAddress, 0, 0);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static void WriteRemainingTime(CpuContext ctx, ulong remainAddress, long seconds, long nanoseconds)
{
if (remainAddress == 0)
{
return;
}
Span<byte> remainBuffer = stackalloc byte[16];
BinaryPrimitives.WriteInt64LittleEndian(remainBuffer, seconds);
BinaryPrimitives.WriteInt64LittleEndian(remainBuffer[sizeof(long)..], nanoseconds);
ctx.Memory.TryWrite(remainAddress, remainBuffer);
}
[SysAbiExport(
Nid = "1jfXLRVzisc",
ExportName = "sceKernelUsleep",
@@ -186,15 +273,9 @@ public static class KernelRuntimeCompatExports
long seconds;
long nanoseconds;
if (clockId == 0)
if (!ResolveClockTime(clockId, out seconds, out nanoseconds))
{
var now = DateTimeOffset.UtcNow;
seconds = now.ToUnixTimeSeconds();
nanoseconds = (now.Ticks % TimeSpan.TicksPerSecond) * 100;
}
else
{
GetProcessMonotonicTime(out seconds, out nanoseconds);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> timespecBuffer = stackalloc byte[16];
@@ -226,8 +307,11 @@ public static class KernelRuntimeCompatExports
var now = DateTimeOffset.UtcNow;
var seconds = now.ToUnixTimeSeconds();
var microseconds = (now.Ticks % TimeSpan.TicksPerSecond) / 10;
if (!ctx.TryWriteUInt64(timeAddress, unchecked((ulong)seconds)) ||
!ctx.TryWriteUInt64(timeAddress + sizeof(long), unchecked((ulong)microseconds)))
Span<byte> timevalBuffer = stackalloc byte[16];
BinaryPrimitives.WriteInt64LittleEndian(timevalBuffer, seconds);
BinaryPrimitives.WriteInt64LittleEndian(timevalBuffer[sizeof(long)..], microseconds);
if (!ctx.Memory.TryWrite(timeAddress, timevalBuffer))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
@@ -249,18 +333,28 @@ public static class KernelRuntimeCompatExports
var seconds = now.ToUnixTimeSeconds();
var microseconds = (now.Ticks % TimeSpan.TicksPerSecond) / 10;
if (timeAddress != 0 &&
(!ctx.TryWriteUInt64(timeAddress, unchecked((ulong)seconds)) ||
!ctx.TryWriteUInt64(timeAddress + sizeof(long), unchecked((ulong)microseconds))))
if (timeAddress != 0)
{
return -1;
Span<byte> timevalBuffer = stackalloc byte[16];
BinaryPrimitives.WriteInt64LittleEndian(timevalBuffer, seconds);
BinaryPrimitives.WriteInt64LittleEndian(timevalBuffer[sizeof(long)..], microseconds);
if (!ctx.Memory.TryWrite(timeAddress, timevalBuffer))
{
TrySetErrno(ctx, Efault);
return -1;
}
}
if (timezoneAddress != 0 &&
(!ctx.TryWriteInt32(timezoneAddress, 0) ||
!ctx.TryWriteInt32(timezoneAddress + sizeof(int), 0)))
if (timezoneAddress != 0)
{
return -1;
Span<byte> timezoneBuffer = stackalloc byte[8];
BinaryPrimitives.WriteInt32LittleEndian(timezoneBuffer, 0);
BinaryPrimitives.WriteInt32LittleEndian(timezoneBuffer[sizeof(int)..], 0);
if (!ctx.Memory.TryWrite(timezoneAddress, timezoneBuffer))
{
TrySetErrno(ctx, Efault);
return -1;
}
}
ctx[CpuRegister.Rax] = 0;
@@ -618,6 +712,50 @@ public static class KernelRuntimeCompatExports
return address != 0 && ctx.TryWriteInt32(address, value);
}
internal static bool ResolveClockTime(int clockId, out long seconds, out long nanoseconds)
{
switch (clockId)
{
case ClockRealtime:
case ClockRealtimePrecise:
case ClockRealtimeFast:
case ClockVirtual:
case ClockProf:
{
var now = DateTimeOffset.UtcNow;
seconds = now.ToUnixTimeSeconds();
nanoseconds = (now.Ticks % TimeSpan.TicksPerSecond) * 100;
return true;
}
// CLOCK_SECOND is FreeBSD's cached whole-second realtime clock (Quake's
// audio_output_thread polls it and treated the previous EINVAL as a fatal
// init failure, exiting and getting respawned in a loop).
case ClockSecond:
seconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
nanoseconds = 0;
return true;
case ClockMonotonic:
case ClockMonotonicPrecise:
case ClockMonotonicFast:
case ClockUptime:
case ClockUptimePrecise:
case ClockUptimeFast:
// Per-thread/process CPU time approximated with the monotonic clock; games
// use these for profiling deltas where monotonicity matters, not absolutes.
case ClockThreadCputimeId:
case ClockProcTime:
GetProcessMonotonicTime(out seconds, out nanoseconds);
return true;
default:
seconds = 0;
nanoseconds = 0;
return false;
}
}
internal static void GetProcessMonotonicTime(out long seconds, out long nanoseconds)
{
var elapsedTicks = Stopwatch.GetTimestamp() - _processStartCounter;
@@ -19,9 +19,22 @@ public static class KernelSemaphoreCompatExports
public required int MaxCount { get; init; }
public int Count { get; set; }
public int WaitingThreads { get; set; }
public int CancelEpoch { get; set; }
public bool Deleted { get; set; }
public object Gate { get; } = new();
}
private sealed class SemaphoreWaiter
{
public required int NeedCount { get; init; }
public required int CancelEpochAtBlock { get; init; }
// Written and read only under the owning semaphore's Gate.
public int? Result { get; set; }
}
private static string GetSemaphoreWakeKey(uint handle) => $"kernel_sema:0x{handle:X8}";
[SysAbiExport(
Nid = "188x57JYp0g",
ExportName = "sceKernelCreateSema",
@@ -58,17 +71,27 @@ public static class KernelSemaphoreCompatExports
handle = unchecked((uint)Interlocked.Increment(ref _nextSemaphoreHandle));
}
_semaphores[handle] = new KernelSemaphoreState
var state = new KernelSemaphoreState
{
Name = name,
InitialCount = initialCount,
MaxCount = maxCount,
Count = initialCount,
};
_semaphores[handle] = state;
if (!ctx.TryWriteUInt32(semaphoreAddress, handle))
{
_semaphores.TryRemove(handle, out _);
// Handles are sequential and guest-predictable, so a hostile guest can
// race a WaitSema onto the handle between publication above and this
// rollback. Strand-proof that waiter exactly like DeleteSema does.
lock (state.Gate)
{
state.Deleted = true;
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
@@ -118,7 +141,17 @@ public static class KernelSemaphoreCompatExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
if (!GuestThreadExecution.RequestCurrentThreadBlock(ctx, "sceKernelWaitSema"))
var waiter = new SemaphoreWaiter
{
NeedCount = needCount,
CancelEpochAtBlock = semaphore.CancelEpoch,
};
if (!GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelWaitSema",
GetSemaphoreWakeKey(handle),
resumeHandler: () => CompleteBlockedSemaWait(semaphore, waiter),
wakeHandler: () => TryConsumeBlockedSemaWait(semaphore, waiter)))
{
TraceSemaphore($"wait-would-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN);
@@ -193,8 +226,14 @@ public static class KernelSemaphoreCompatExports
semaphore.Count += signalCount;
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
// Wake after releasing the gate (lock order: scheduler gate -> semaphore gate).
// Wake everyone; the wake handler consumes the count per waiter, so a waiter
// whose needCount exceeds the remaining count stays parked while a smaller
// waiter can proceed.
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
@@ -226,10 +265,16 @@ public static class KernelSemaphoreCompatExports
}
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
semaphore.WaitingThreads = 0;
semaphore.CancelEpoch++;
// WaitingThreads is NOT zeroed here: each canceled waiter decrements it
// exactly once in its wake handler. Zeroing here as well would double-count
// and silently absorb the increment of a waiter that parks between this
// gate release and the wake-all below.
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
@@ -245,10 +290,84 @@ public static class KernelSemaphoreCompatExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
}
// Delete succeeds even with blocked waiters; they wake with the deleted
// result (the SCE kernel wakes waiters with the EACCES-class code).
lock (semaphore.Gate)
{
semaphore.Deleted = true;
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
// Wake handler: runs under the scheduler's guest-thread gate (lock order:
// scheduler gate -> semaphore gate). Returns true iff the waiter has a final
// result and should be re-readied; false leaves it parked.
private static bool TryConsumeBlockedSemaWait(KernelSemaphoreState semaphore, SemaphoreWaiter waiter)
{
lock (semaphore.Gate)
{
return TryConsumeBlockedSemaWaitLocked(semaphore, waiter);
}
}
private static bool TryConsumeBlockedSemaWaitLocked(KernelSemaphoreState semaphore, SemaphoreWaiter waiter)
{
if (waiter.Result is not null)
{
return true;
}
if (semaphore.Deleted)
{
waiter.Result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
TraceSemaphore($"wake-deleted name='{semaphore.Name}' need={waiter.NeedCount}");
return true;
}
if (semaphore.CancelEpoch != waiter.CancelEpochAtBlock)
{
waiter.Result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_CANCELED;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
TraceSemaphore($"wake-canceled name='{semaphore.Name}' need={waiter.NeedCount}");
return true;
}
if (semaphore.Count >= waiter.NeedCount)
{
semaphore.Count -= waiter.NeedCount;
waiter.Result = (int)OrbisGen2Result.ORBIS_GEN2_OK;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
TraceSemaphore($"wake-consume name='{semaphore.Name}' need={waiter.NeedCount} count={semaphore.Count} waiters={semaphore.WaitingThreads}");
return true;
}
return false;
}
// Resume handler: runs on the woken guest thread outside the scheduler gate;
// its return value becomes the guest's RAX for the resumed sceKernelWaitSema.
private static int CompleteBlockedSemaWait(KernelSemaphoreState semaphore, SemaphoreWaiter waiter)
{
lock (semaphore.Gate)
{
if (waiter.Result is null && !TryConsumeBlockedSemaWaitLocked(semaphore, waiter))
{
// Nothing readies a parked semaphore waiter without the wake handler
// resolving it, so reaching here means the scheduler contract changed.
Console.Error.WriteLine(
$"[LOADER][GAP] sema.resume-no-outcome name='{semaphore.Name}' need={waiter.NeedCount} count={semaphore.Count}");
waiter.Result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
}
return waiter.Result!.Value;
}
}
private static void TraceSemaphore(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_SEMA"), "1", StringComparison.Ordinal))
+467
View File
@@ -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<ulong, FileStream> _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<byte>.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<byte>.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<byte>.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<byte> 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<byte>.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<ushort>((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;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Mouse;
public static class MouseExports
{
// Returns 0 read entries: no mouse is connected. This NID was previously misbound
// as an sceNgs2VoiceGetState alias.
[SysAbiExport(
Nid = "x8qnXqh-tiM",
ExportName = "sceMouseRead",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceMouse")]
public static int MouseRead(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "RaqxZIf6DvE",
ExportName = "sceMouseOpen",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceMouse")]
public static int MouseOpen(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
+39 -6
View File
@@ -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,18 @@ public static class Ngs2Exports
lock (StateGate)
{
return ctx.SetReturn(
Voices.ContainsKey(ctx[CpuRegister.Rdi])
? 0
Voices.ContainsKey(ctx[CpuRegister.Rdi])
? 0
: OrbisNgs2ErrorInvalidVoiceHandle);
}
}
// The earlier "alt NID" guesses here were wrong: an NID is the aerolib hash of one
// symbol name, and hashing scripts/ps5_names.txt shows the previous alt bindings
// actually belonged to sceImeUpdate (-4GCfYdNF1s), sceMouseRead (x8qnXqh-tiM) and
// sceSystemGestureUpdateAllTouchRecognizer (wPJGwI2RM2I) — Quake's audio thread was
// receiving an NGS2 error from what it thought was sceImeUpdate. Those now live in
// their real libraries; the NIDs below are verified against the hash.
[SysAbiExport(
Nid = "AbYvTOZ8Pts",
ExportName = "sceNgs2VoiceRunCommands",
@@ -210,6 +216,27 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")]
public static int Ngs2VoiceRunCommands(CpuContext ctx) => Ngs2VoiceControl(ctx);
[SysAbiExport(
Nid = "-TOuuAQ-buE",
ExportName = "sceNgs2VoiceGetState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2VoiceGetState(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport(
Nid = "rEh728kXk3w",
ExportName = "sceNgs2VoiceGetStateFlags",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2VoiceGetStateFlags(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport(
Nid = "xa8oL9dmXkM",
ExportName = "sceNgs2PanInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2PanInit(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport(
Nid = "i0VnXM-C9fc",
ExportName = "sceNgs2SystemRender",
@@ -263,7 +290,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 +297,18 @@ public static class Ngs2Exports
Span<byte> 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)
+24
View File
@@ -33,6 +33,30 @@ public static class NpManagerExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Offline profile: the online id payload is left untouched and the call
// reports success, matching the other offline NpManager stubs here.
[SysAbiExport(
Nid = "XDncXQIJUSk",
ExportName = "sceNpGetOnlineId",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpManager")]
public static int NpGetOnlineId(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "e-ZuhGEoeC4",
ExportName = "sceNpGetNpReachabilityState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpManager")]
public static int NpGetNpReachabilityState(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "VfRSmPmj8Q8",
ExportName = "sceNpRegisterStateCallback",
+108
View File
@@ -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<byte> 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;
}
}
@@ -76,4 +76,14 @@ public static class NpUniversalDataSystemExports
{
return ctx.SetReturn(0, typeof(long));
}
[SysAbiExport(
Nid = "AUIHb7jUX3I",
ExportName = "sceNpUniversalDataSystemDestroyHandle",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpUniversalDataSystem")]
public static int NpUniversalDataSystemDestroyHandle(CpuContext ctx)
{
return ctx.SetReturn(0, typeof(long));
}
}
@@ -72,4 +72,26 @@ 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;
}
[SysAbiExport(
Nid = "wPJGwI2RM2I",
ExportName = "sceSystemGestureUpdateAllTouchRecognizer",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSystemGesture")]
public static int SystemGestureUpdateAllTouchRecognizer(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
@@ -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);
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Ult;
public static class UltExports
{
// Games initialize the ULT (user-level thread) runtime before spinning up
// their job systems; an unresolved import here (0x80020002) makes engines
// treat the whole task system as unavailable. The emulator schedules guest
// pthreads natively, so accepting the initialization is sufficient.
[SysAbiExport(
Nid = "hZIg1EWGsHM",
ExportName = "sceUltInitialize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceUlt")]
public static int UltInitialize(CpuContext ctx)
{
return ctx.SetReturn(0, typeof(long));
}
}
@@ -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<byte> 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}");
}
}
}
+172 -7
View File
@@ -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,11 +54,17 @@ 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)
public static void ConfigureApplicationInfo(string? title, string? titleId, string? version, string? emulatorCommitSha)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(title))
@@ -72,9 +79,10 @@ public static class VideoOutExports
var application = parts.Count == 0 ? "VideoOut" : string.Join(' ', parts);
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
var commitSuffix = string.IsNullOrWhiteSpace(emulatorCommitSha) ? string.Empty : $" \u00b7 {emulatorCommitSha.Trim()}";
lock (_stateGate)
{
_windowTitle = $"SharpEmu - {application}{versionSuffix}";
_windowTitle = $"SharpEmu{commitSuffix} - {application}{versionSuffix}";
}
}
@@ -99,6 +107,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<FlipEventRegistration> VblankEvents { get; } = new();
public List<FlipEventRegistration> FlipEvents { get; } = new();
}
@@ -312,11 +321,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 +417,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 +500,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 +530,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 +851,38 @@ public static class VideoOutExports
return groupIndex < 0 ? groupIndex : setIndex;
}
private static void SignalVblank(VideoOutPortState port)
{
List<FlipEventRegistration> vblankEvents;
ulong eventHint;
lock (_stateGate)
{
port.VblankCount++;
eventHint = SceVideoOutInternalEventVblank |
((port.VblankCount & 0x0000_FFFF_FFFF_FFFFUL) << 16);
vblankEvents = new List<FlipEventRegistration>(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 +917,14 @@ public static class VideoOutExports
flipEvents = new List<FlipEventRegistration>(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 +949,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 +1041,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 +1272,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)
@@ -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"),