Compare commits

..

92 Commits

Author SHA1 Message Date
ParantezTech 2e5787de06 [CPU] Fix Sema ORBIS_GEN2_ERROR_BUSY loop 2026-07-25 14:35:28 +03:00
Andrew 8f9456229a fix(memory): reserve only large regions (#608)
* fix(memory): reserve only large regions

* Potential fix for pull request finding
2026-07-25 14:25:53 +03:00
Berk 5b602c0232 [GPU] Fix detiled cache key for VulkanDetilePass (#620) 2026-07-25 14:12:17 +03:00
Digote 26c502914c feat(audio): implement sceAudioOutOutputs (#605)
* feat(audio): implement batched output submission

* test(audio): cover batched output semantics

* test(audio): cover multi-port output batches

---------

Co-authored-by: diego <diego@DIGOTE-PC>
2026-07-24 20:13:30 +03:00
shadowbeat070 a158960c20 feat(gpu): GPU compute detile for guest tiled textures (Vulkan + Metal) (#592)
* feat(gpu): GPU compute detile for guest tiled textures (Vulkan + Metal)

Move RDNA2 exact-XOR deswizzle (swizzle modes 5/9/24/27, 4bpp) off the CPU
onto a GPU compute pass. GnmTiling.GetDetileParams resolves the shared
addressing into DetileParams; the CPU fallback and both GPU kernels consume
the same params so they never disagree.

Vulkan (verified bit-exact on NVIDIA): SpirvFixedShaders.CreateDetileCompute
hand-emits the SPIR-V kernel; VulkanDetilePass.RecordDetile records the
dispatch into the async batch command buffer (never a blocking submit on the
render thread) with transients retired via fence; VulkanDetileSelfTest
(SHARPEMU_DETILE_SELFTEST=1) checks both entry points against the CPU detile.

Metal (Mac-untested): detile_compute.msl (detile_cs) + MetalDetilePass mirror
the Vulkan pass. The active Metal path CPU-detiles via the new
GnmTiling.DetileWithParams when a texture arrives packaged (empty RgbaPixels +
TiledSource/Detile), keeping Metal correct under default-on with no regression;
wiring MetalDetilePass live is the remaining on-device step.

Flags: GPU detile is default-on (SHARPEMU_GPU_DETILE=0 disables);
[GPU-DETILE] diagnostics gated behind SHARPEMU_LOG_GPU_DETILE=1.

Tests: 17 detile unit tests pass, incl. DetileWithParams and GetDetileParams
each matching TryDetile bit-for-bit across all supported modes/bpp, plus a
SPIR-V structural-validity test.

* feat(gpu): GPU compute detile for guest tiled textures (Vulkan + Metal)

Move RDNA2 exact-XOR deswizzle (swizzle modes 5/9/24/27, 4bpp) off the CPU
onto a GPU compute pass. GnmTiling.GetDetileParams resolves the shared
addressing into DetileParams that the CPU fallback and both GPU kernels
consume, so they never disagree; everything else keeps the CPU path.

Vulkan (verified bit-exact on NVIDIA): SpirvFixedShaders.CreateDetileCompute
hand-emits the kernel; VulkanDetilePass.RecordDetile records into the async
batch command buffer (never a blocking submit on the render thread) with
transients retired via fence, falling back to CPU detile on failure.
VulkanDetileSelfTest (SHARPEMU_DETILE_SELFTEST=1) checks both entry points.

Metal (Mac-untested): detile_compute.msl + MetalDetilePass mirror the Vulkan
pass; the active Metal path CPU-detiles via GnmTiling.DetileWithParams so it
stays correct under default-on. Wiring MetalDetilePass live is a follow-up.

Flags: default-on (SHARPEMU_GPU_DETILE=0 disables); diagnostics behind
SHARPEMU_LOG_GPU_DETILE=1. Adds 17 passing detile unit tests.

* Fix: added support layered texture support for the GPU-Detiling.

* Fix: Added support for BlockTable (1 / 4 / 8 (Morton/Z-order))

* feat: added support for 8 and 16 bpp (bytes per element)

* Fixed a build failure specific to this branch

---------
2026-07-24 20:13:02 +03:00
MarcelMediaDev 5228335f15 fix(gpu): support Gen5 flat memory and 3D images (#587)
Vector-mesh UI text samples type-10 volume LUTs; treat MIMG DIM=2 as
Dim3D and transport depth through AGC and Vulkan so Z slices no longer
collapse into a single 2D plane.
2026-07-24 15:44:58 +03:00
Berk 21f964a0dc Update README 2026-07-24 03:00:05 +03:00
ParantezTech 6133313a83 [readme] added support SharpEmu section 2026-07-24 02:58:58 +03:00
ParantezTech 6db095ec82 revert: restore state before huge regression 2026-07-23 16:03:45 +03:00
CasualcoderDev 5a08a9bb43 fix: VulkanHostBufferPool deadlock, audio overflow crash, and log grouping (#564)
* fix: VulkanHostBufferPool deadlock, audio overflow crash, and log grouping

* fix: implement thread-safe buffer pool, refactor output handling, and use unchecked cast for audio conversion
2026-07-23 15:41:51 +03:00
MarcelMediaDev f9d92135a0 fix(agc): merge Prospero attrib-table formats onto IR vertex inputs (#556)
IR-discovered BufferLoadFormat often keeps a stale float sharp format;
patch DataFormat/offset from the AGC attrib table (semantic index),
allow offen fetches, and map quirks 113/121 through NarrowVk for host
vertex input.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:39:24 +03:00
Radu Ursache 8779c96c3a test(debugger): add C# unit tests for protocol and breakpoints (#568)
Cover DebugRequest parsing, BreakpointStore, and DebugCommandDispatcher
with a fake session. Wire the project into SharpEmu.slnx.
2026-07-23 15:38:08 +03:00
MarcelMediaDev 4c6cff1116 fix(agc): skip CB metadata draws for EliminateFastClear/Fmask/DCC (#553)
CB_COLOR_CONTROL modes 2/5/6 are colour-buffer metadata ops; applying
the bound shader as a normal colour draw corrupts subsequent composites.
Decode MODE from bits [6:4] and return before translate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:37:34 +03:00
MarcelMediaDev 82ab181861 fix(hle): enable GuestImageWriteTracker CPU sync on Windows (#550)
Windows previously hard-disabled the tracker, so CPU-written guest
planes never marked dirty and host textures stayed empty. Arm pages
with VirtualProtect, handle write AVs in VEH, and warm/test on
VirtualAlloc memory so protect cannot poison the CRT heap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:37:05 +03:00
MarcelMediaDev 007bf6fa73 fix(kernel): reject getdents on file fds and emit . / .. for empty dirs (#546)
Returning rax=0 for non-directory or empty listings looked like EOF and
let GTA treat the fd as a pointer (fiWriteAsyncDataWorker AV at 0xB1).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:36:28 +03:00
MarcelMediaDev 8e1e89c024 fix(agc): accept Gen5 hull shaders that omit PGM_LO/HI in CreateShader (#545)
Type-5 headers can start with RSRC1/RSRC2; rejecting them left null handles
and Main Thread AVs. Scan the SH table and skip PGM patch when absent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:36:19 +03:00
Kurt Himebauch 2764aaab3f feat: implement cosf, time, ctype tables, tracked heap access, IL2CPP lookup ABI paths (#542)
* fix: add Messenger CRT and AGC compatibility shims

* fix: keep Messenger IL2CPP bootstrap on HLE shims

* Fix Messenger compatibility ABI handling

* Make IL2CPP ABI regression tests portable
2026-07-23 15:35:59 +03:00
MarcelMediaDev 7b950166d7 fix(remoteplay): stub Initialize and GetConnectionStatus as disconnected (#536)
* fix(remoteplay): stub Initialize and GetConnectionStatus as disconnected

Titles probe Remote Play during pad/network bring-up; unresolved imports
returned NOT_FOUND. Report initialized + disconnected so callers take the
normal offline path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger gameplay CI for PR #536

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:35:28 +03:00
MarcelMediaDev eb1195e59a fix(kernel): implement APR ResolveFilepathsWithPrefixToIdsAndFileSizes (#534)
* fix(kernel): implement APR ResolveFilepathsWithPrefixToIdsAndFileSizes

Resource streamers resolve relative paths against a shared prefix; without
this HLE every call returned NOT_FOUND and assets never got real ids/sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger gameplay CI for PR #534

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:34:58 +03:00
MarcelMediaDev e13cb28267 fix(audio): harden AudioOut2 stack out-buffer writes against canary smash (#532)
Titles that stack-allocate AudioOut2 outs next to the frame canary were
corrupted by oversized or mistyped HLE writes; keep ContextPush pacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:34:40 +03:00
Radu Ursache 956da769a3 fix(kernel): finish Posix -1/errno for file ops and open EACCES (#567)
Map UnauthorizedAccess on open to PERMISSION_DENIED and route Posix
lseek/pread/pwrite/rename/etc failures through PosixFailure so libc-style
callers get RAX=-1 plus TLS errno, matching open/read/write.
2026-07-23 15:32:56 +03:00
h4sht d7bd814fb9 [Core/Dlsym] Restore sceKernelDlsym bootstrap argument normalization from PR #94 (#565)
* [Core/Dlsym] Restore normalize-dlsym-arguments and deferred bootstrap tracing from PR #94

PR #216 regressed two critical features from PR #94:
1. NormalizeKernelDynlibDlsymArguments — handles argument reordering
   when standalone bootstrap loaders call sceKernelDlsym through the
   bridge with (symbol_ptr, handle, out) instead of the standard
   (handle, symbol_ptr, out). Without this, payloads like elfldr-ps5
   and websrv-ps5 fail with a deterministic UnmanagedCallersOnly
   fail-fast.
2. Deferred bootstrap tracing — ring-buffered import logging that
   drains after the hot path, avoiding per-call Console.Error I/O.

Also restored:
- CompleteKernelDynlibDlsymFailure — centralized error handling
- IsPlausibleDynlibSymbolPointer — pointer bounds validation
- COW snapshot of _importEntries in ProbeReturnRip
- ResetLazyDlsymStubState and lazy-dlsym field infrastructure
- DraftDrainDeferredBootstrapTraces in Execute() finally block

Fixes #530, fixes #531

* fix: remove orphaned _importNidHashCache.Clear() reference

The field _importNidHashCache no longer exists on main (removed post PR #94).
The 3-way merge incorrectly restored the .Clear() call without the field
declaration, causing a build failure on all platforms.

---------

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-23 15:24:59 +03:00
Mike Saito 96fde5764f Astro Bot stack: VEH/TBB, title clear, swapchain fallback, Psml MFSR (#528)
* Cpu/Kernel: harden VEH trampoline and keep TBB on native workers

Route FastFail/CLR/stack-overflow around managed VEH, serialize managed
entry with a recursive spinlock, require native workers for tbb_thead,
and abandon pthread mutexes when a guest thread is torn down by worker
abort so splash waiters are not left holding locks forever.

* Cpu: soft-fail TBB native worker storms and cap concurrent Runs

Throwing on worker/prologue faults killed the process mid tbb_thead
burst (FailFast 0xC0000409). Soft-return 0x80020012, limit in-flight
native Runs (default 2), and keep prewarm small so back-to-back boots
do not need an artificial settle delay.

* Agc/VideoOut: poison-only empty-SRT reject and clear procedural ES/PS

Skip QueueSubmit only when Address-0 image slots remain; run the
Astro title clear pair via CmdClearColorImage so the pass executes
without descriptors that lose the device.

* VideoOut: recreate swapchain with fallback extent on 0x0 surface

Minimized Win32 surfaces report 0x0 / MaxImageExtent=0; deferring
recreate forever left an OutOfDate swapchain with no presents.
Clamp to last/default size and recreate instead of early-return.

* Psml: stub MFSR init/shared/context and dispatch packet size

Astro Bot asserts in GfxRenderStagePSSR when scePsmlMfsrInit is unresolved
(Mfsr initialized failed). Soft HLE for the MFSR shared-resource and
800M3_2 context path plus dispatch packet size lets boot pass splash to
first frame without claiming real upscaling.

* Psml: stub MFSR GetDispatchMfsrPacket900 for logo PSSR

Astro StartLevel ps_logo asserted GfxRenderStagePSSR.cpp:266 when
GetDispatchMfsrPacket900 (RUNLFro+qok) was unresolved. Return SCE_OK
from SizeInDwords so the 900 fill runs, soft-clear the guest packet
buffer, and register 1000/1100 siblings for the same ABI.
2026-07-23 12:37:44 +03:00
MarcelMediaDev 7a108c6f87 fix(hle): GameService Ok stubs plus NetInetPton and Json terminate (#560)
Resolve logo-accept-gate unresolved NIDs seen on PPSA04264: share/voice/
telemetry/content Ok stubs, sceNetInetPton, and Json Initializer::terminate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:33:39 +03:00
Kurt Himebauch eb252af7b3 unstub: preserve notice screen skip flag (#559)
* fix(system-service): preserve notice screen skip flag

* test(bink): avoid frame timing flake on CI
2026-07-23 12:32:49 +03:00
MarcelMediaDev 8e5a0bfb19 fix(agc): implement sceAgcDcbSetUcRegisterDirect (#558)
Emit the 3-dword SET_UCONFIG_REG packet from the packed {offset,value}
in RSI. Unresolved calls left GPU config registers unset during
RenderThread/Main bring-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:32:04 +03:00
ParantezTech d991e32b15 [readme] update screenshots 2026-07-23 03:14:26 +03:00
Berk 93829e3242 chore: bump version to 0.0.2-beta.5 (#555) 2026-07-23 03:07:14 +03:00
Berk 4191a9e12b [Bink2] rework bridge to use FFmpeg's native Bink2 decoder instead of a C bridge (#554)
* [Bink2] rework bridge to use FFmpeg's native Bink2 decoder instead of a C bridge

* [readme] update DeS screenshot
2026-07-23 03:06:14 +03:00
Mariano Zambelli 559b7f0a84 feat(voice): add QoS stubs (GetStatus, Terminate, SetMode) (#541)
* feat(voice): add QoS stubs (GetStatus, Terminate, SetMode)

Titles call these functions during voice/multiplayer setup to check
network availability and configure modes. Unresolved imports caused
WARN floods in the loader logs. Reporting initialized + disconnected
lets callers take their normal offline path.

* fix(voice): return success (0) from sceVoiceQoSGetStatus instead of disconnected state
2026-07-23 01:48:55 +03:00
MarcelMediaDev 2272b9b576 fix(ajm): silence BatchJobDecode/Start/Wait/Cancel hot-path stubs (#547)
Unresolved batch NIDs flooded Import WARNs on Bink/AJM. Claim input
consumed with silence produced; this is not a real codec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:44:07 +03:00
MarcelMediaDev 8dd3172c0f fix(systemservice): stub notice-screen skip flag setters (#549)
Settings probes Set/DisableNoticeScreenSkipFlagAutoSet; unresolved
NOT_FOUND can stall the SaveModTime/Load path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:41:08 +03:00
Berk e7ea186ea8 Enhance contribution guidelines with PR expectations
Added expectations for pull requests regarding observable behavior and testing requirements. Clarified guidelines for AI-assisted contributions.
2026-07-23 01:40:00 +03:00
MarcelMediaDev 74a519875b fix(agc): add missing Cb/Dcb GetSize stubs for packet sizing probes (#535)
Unresolved GetSize NIDs returned NOT_FOUND during RenderThread startup,
leaving null packet pointers and an immediate write AV. Return fixed
packet byte sizes in rax only — no guest memory writes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:18:47 +03:00
Andrey Modnov 4682e64e81 [CLI] Simplify mitigated child arguments (#529) 2026-07-23 00:18:11 +03:00
Kurt Himebauch 912883de05 fix(cmake): invalidate stale FFmpeg library cache (#543) 2026-07-22 23:56:37 +03:00
Berk f704586a8d [VideoOut] Add Bink2 support via FFMPEG bridge (#527)
* [VideoOut] Add Bink2 support via FFMPEG bridge

* [CMake] update commit

* [CMake] update commit
2026-07-22 21:41:41 +03:00
jute-ado d3600c9255 fix(ajm): accept Gen5 codec types (#526) 2026-07-22 18:24:06 +03:00
jute-ado 5f97031df5 shader: allow larger bounded Gen5 programs (#514) 2026-07-22 14:46:15 +03:00
h4sht 2a4da8c0a9 [Kernel/Semaphore] Close race between sceKernelWaitSema and sceKernelSignalSema (#504)
When sceKernelWaitSema finds the count insufficient it increments
WaitingThreads, releases the semaphore gate, and calls
RequestCurrentThreadBlock to set the thread-static block flags. A
signal arriving before the scheduler registers the block metadata
is missed by WakeBlockedThreads — the waiter has not been
registered yet and the signal's wake iteration skips it.

The scheduler's exit handler already re-checks TryWake() after
setting the thread to Blocked, but that requires the thread to
fully exit to the scheduler and back. Instead, re-check the
semaphore count under the gate immediately after the block request:
if the count is now sufficient, consume the tokens, cancel the
pending block via TryConsumeCurrentThreadBlock, and return without
ever yielding to the scheduler.

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-22 14:34:19 +03:00
h4sht 4c37e64c66 [NpWebApi2] Add sceNpWebApi2PushEventCreateFilter stub (#503)
Add sceNpWebApi2PushEventCreateFilter (NID: MsaFhR+lPE4) to the
libSceNpWebApi2 module. This function is called by Unity games
during initialization and was unresolved, causing an import warning
and returning ORBIS_GEN2_ERROR_NOT_FOUND.

The stub validates the library context and returns an incrementing
filter handle, following the same pattern as the existing
sceNpWebApi2PushEventCreateHandle.

NID sourced via:
  python scripts/aerolib_catalog.py lookup MsaFhR+lPE4

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-22 14:33:43 +03:00
kostyaff fc9e3ff393 fix: roll back earlier host allocations on later gap failure in TryBackFixedRange (#472) (#474)
When a fixed mapping spans multiple free runs and a later gap cannot be
backed, any earlier host allocations were leaked. Stage all allocations
during the walk and insert MemoryRegions only after every gap has been
backed successfully. On any failure, free all staged allocations.

Fixes #472

🤖 Generated with Hermes Agent
2026-07-22 14:28:25 +03:00
samto6 eb47d753f6 [Ampr] Implement the FW 4.00 write-address command exports (#510) 2026-07-22 03:00:30 +03:00
h4sht 6aa78bb55b [Loader] Fall back to fixed-range backfill when main image base is occupied (#493)
When TryAllocateAtExact fails for the main image base (0x800000000
for PS5, 0x400000 for PS4), the loader previously threw a fatal
InvalidOperationException with no recovery path. This happens when
the host OS has already claimed part of that address range — common
under Rosetta 2, with aggressive ASLR, or when another process maps
into the guest address space.

Instead of failing immediately, attempt TryBackFixedRange which
backs the range page by page, claiming any free gaps. If the
backfill also fails, Clear() rolls back partial allocations and
the exception now includes platform-specific recovery advice.

This prevents the most common emulator startup crash on affected
hosts.

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-21 18:18:58 +03:00
h4sht 9be6f85ef0 [Font] Implement sceFontGetVerticalLayout (#492)
Add sceFontGetVerticalLayout (NID: 3BrWWFU+4ts) to the Font module,
completing the vertical-text counterpart to the existing
GetHorizontalLayout. The SceFontVerticalLayout structure is three
floats (baseline, lineAdvance, decorationExtent) interpreted for
vertical writing such as CJK text rendered top-to-bottom.

- Write baseline=8.0f, lineAdvance=16.0f, decorationExtent=0.0f
- Validate output pointer and return INVALID_ARGUMENT on null
- Return MEMORY_FAULT when guest writes fail

Tests:
- GetVerticalLayout_WritesExactlyThreeFloats with sentinel guard
- GetVerticalLayout_NullBuffer_ReturnsInvalidArgument

NID sourced via: python scripts/aerolib_catalog.py lookup sceFontGetVerticalLayout

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-21 18:18:16 +03:00
Kurt Himebauch 4c8c67a3dd fix: Add ASTRO BOT compatibility stubs (#481)
* Add ASTRO BOT compatibility stubs

* Fix ASTRO BOT compatibility stubs
2026-07-21 18:17:40 +03:00
999sian ada67a1924 cpu: recover SSE4a EXTRQ/INSERTQ faults on Linux (#482)
The fault-time SSE4a fallback was Windows-only because the POSIX signal
bridge never carried XMM state: the CONTEXT scratch buffer only held the
17 general-purpose registers, so emulating EXTRQ/INSERTQ there would
have computed results from zeroed bytes and discarded the write. Bridge
the XMM registers on Linux by copying them between the mcontext's
FXSAVE image (kernel sigcontext ABI, libc-independent) and the CONTEXT
FltSave slots on capture and write-back, and gate the recovery on that
bridge instead of on Windows. Darwin still declines: its XMM area
remains unbridged.

With this, guest EXTRQ/INSERTQ on Linux hosts without SSE4a (any Intel
CPU) resumes with correct register state instead of dying on an
unrecovered SIGILL (#328).
2026-07-21 14:18:21 +03:00
Slick Daddy 2379e8988c [Loader] Collect stub-eligible NIDs in one pass over descriptors (#489)
BuildImportStubs filtered orderedImportNids by calling ShouldCreateImportStub
for each unique NID, and every call scanned the entire descriptor list
looking for a match. On a real module both the NID count and the descriptor
count run into the thousands, so the filter degraded to O(nids * descriptors)
ordinal string comparisons on the one-time load path.

Replace the per-NID rescan with a single pass over the descriptors that
builds a HashSet of eligible NIDs, then filter orderedImportNids with O(1)
membership. Eligibility is unchanged: a NID qualifies when any of its
descriptors is non-weak, or is weak but resolvable via the module manager.

ShouldCreateImportStub is retained (still used by the DEBUG self-checks), and
a self-check now asserts the set-based collector agrees with the per-NID rule.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:59:30 +03:00
Slick Daddy 105c58b380 [Tests] Isolate Gen5 scalar fallback test from parallel static mutation (#488)
ScalarLoadReadsTrackedFallbackMemory swaps the process-global static
Gen5ShaderScalarEvaluator.FallbackMemoryReader under a lock private to the
test class. The SharpEmu.Libs [ModuleInitializer] (AgcShaderCompilerHooks)
assigns the same static to TryReadShaderGuestMemory the first time any Libs
type is touched, and it does not take that lock. Under xUnit's default
cross-class parallelism a concurrent Libs test could fire the initializer
mid-test, clobbering the swapped-in reader — observed on CI (linux-x64) as
the fallback returning all zeros: Expected [1181044592, 4, 1319632096, 4],
Actual [0, 0, 0, 0].

Put the test in a DisableParallelization collection, matching the existing
convention for shared-mutable-static tests (KernelMemoryCompatState,
AjmState, AvPlayerPathState). The collection runs alone in the non-parallel
phase, so no other test can mutate the static while this one holds it.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:59:03 +03:00
Slick Daddy da35f0db47 [Audio] Hoist volume clamp out of the per-sample PCM loop (#487)
Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:58:35 +03:00
Slick Daddy 1f3963c543 [Gpu] Factor the exact-XOR swizzle equation in the texture detiler (#483)
TryDetile's exact-XOR fast path (PS5 swizzle modes 5/9/24/27) ran the
full AddrLib address equation per element: a 16-bit interleave with 32
PopCount calls for every pixel of textures that are millions of elements.

Each output bit is parity(x & XMask) XOR parity(y & YMask), and parity
distributes over XOR, so the offset factors into independent xTerm(x) ^
yTerm(y) fields. Precompute the per-column X term once and hoist the Y
term per row, collapsing the inner loop to one array load and one XOR.

Add GnmTilingDetileTests, which lays out a tiled buffer from an
independent re-derivation of the mode-27 equation and asserts TryDetile
reconstructs it byte-for-byte.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:57:39 +03:00
iExplosiveRage 4bb1af93d7 SaveData: avoid invalid DeS transaction resource pointer (#480)
Demon's Souls treats the small transaction-resource handle as a guest pointer during the fresh-save path. Return a null resource for the observed call shape to prevent the repeatable access violation at address 0x9.

Co-authored-by: RedDv <RedDv@DESKTOP-EVNB4S8>
2026-07-21 02:22:51 +03:00
Nicola Pomarico 0ae785c617 [VideoPresenter] Accept padded row pitch in guest image uploads (#475)
The guest can hand initial texture data whose rows are padded out to a
hardware alignment wider than the image width, so the total byte count
exceeds the tightly packed width*height*bpp we compute. The upload path
rejected any byte count that did not match exactly, silently dropping
these uploads and leaving the texture blank.

Recover the real source row length when the byte count is consistent
with a common padding alignment (8/16/32/64/128/256 texels) and pass it
through as BufferRowLength on the copy, instead of always hardcoding 0.
Uploads that do not match a recognised padded layout are still rejected
as before.

Verified against Dead Cells (PPSA15552): a loading-transition texture
upload that previously wedged the title now uploads correctly and the
game proceeds past the load screen, running stably past 1M draw calls
with no stalls. Dreaming Sarah (tightly packed path) still renders
normally, confirming no regression to the non-padded case.
2026-07-21 01:01:28 +03:00
Slick Daddy e01092aa38 Kernel FS: close guest→host sandbox escapes in the path resolver (#478)
* Kernel FS: default-deny unmapped guest paths (fixes absolute-path host escape)

ResolveGuestPath returned any unrecognized guest path verbatim as the host
path. Because absolute paths ("/etc/passwd", "C:\Windows\...") are already
fully qualified, they skipped the relative-path app0 fallback and were handed
straight to FileStream/File.Delete/etc., giving a malicious game arbitrary
host-file read/write/delete outside the sandbox.

Return string.Empty (deny) on fallthrough instead. Most callers already treat
a nonexistent host path as NOT_FOUND; open/truncate/rename get an explicit
empty-path guard so a denied path can't reach FileStream and throw an
ArgumentException their catch blocks don't cover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: contain built-in mounts (fixes Windows drive-letter injection)

The built-in mount branches (app0/temp0/download0/hostapp/devlog) combined
the mount-relative guest path onto the host root without re-checking
containment. NormalizeMountRelativePath clamps ./.. but splits only on
separators, so a drive-qualified token like "C:" survives as a segment and
Path.Combine then discards the mount root, yielding a raw host path such as
"C:\Windows\..." (arbitrary host read/write).

Route every built-in branch through a new CombineWithinMount helper that
re-resolves with Path.GetFullPath and verifies the result stays under the
mount root -- the same guard TryResolveRegisteredGuestMount already applied.
Denied paths return string.Empty, which callers treat as unresolved.

AprStreamingContractTests passed a raw Path.GetTempFileName() as the guest
path, relying on the now-removed absolute-path passthrough; updated it to
address the file through a registered mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: reject reparse points inside mounts (fixes symlink escape)

Lexical containment (Path.GetFullPath + StartsWith) proves the textual
path stays under the mount root but does not follow symlinks/junctions.
A malicious game dump could plant a reparse point inside app0/temp0/etc.
pointing outside it, so a contained-looking path resolved onto the host
filesystem. Walk each existing component from the mount root to the
candidate and refuse any reparse point, in both the built-in and
registered-mount resolution paths. Mirrors AvPlayer's existing defense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: fail closed when path containment cannot be verified

The reparse-point and drive-letter containment guards call Path.GetFullPath
and File.GetAttributes on untrusted guest paths. Both throw on crafted
over-long or invalid-char input, and ResolveGuestPath runs outside the file
syscalls' try blocks, so such a path was a guest-triggerable crash rather
than a denial.

Wrap the GetFullPath calls in CombineWithinMount and the registered-mount
path, and widen the GetAttributes catch, to treat any access/format failure
as an escape (deny) instead of propagating. Also tighten the ".." fallback
check so a legitimate file named "..foo" is not falsely rejected, and hoist
the repeated Path.GetFullPath(mountRoot) into a local.

Adds a regression test asserting the resolver returns without throwing for
an over-long and a NUL-embedded path under a mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: assert malformed paths resolve to empty, not just no-throw

The fail-closed regression test asserted only Assert.NotNull, which a
non-nullable string return can never violate via its value (only a throw,
which aborts the test earlier anyway). Tighten to Assert.Equal(string.Empty)
so it also locks in fail-CLOSED: a regression where a malformed path resolved
to a non-empty host path would now be caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: route new AMPR batch tests through a registered mount

Merging main brought in three AprStreamingContractTests that pass raw
Path.GetTempFileName()/temp host paths as guest paths. The default-deny
resolver from this branch rejects absolute host paths, so MissingMidBatch
failed at index 0 instead of the intended index 1. Address the present
file through a registered mount (as ResolveStatAndReadFile already does)
so entries 0 and 2 resolve and the batch fails at the genuinely-missing
entry. The two all-missing tests were unaffected but share the fix's intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: make a matched-mount denial terminal; fix Unix-only test asserts

A registered mount that claims a path by prefix but denies it (failed
containment or a reparse point inside the mount) now short-circuits in
ResolveGuestPath instead of falling through to the built-in mount branches.
The fall-through let an overlapping prefix (a registered "/app0" vs the
built-in SHARPEMU_APP0_DIR branch, which resolves against a cached root)
re-resolve a denied path and turn the denial back into a resolution -- the
reparse-point escape reappeared on Linux CI through exactly this path.

Also fix two tests that asserted Windows-specific behavior unconditionally:
a "C:\..." path is not absolute on Unix (it resolves contained under the
mount there), and that case is now pinned to Windows only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:58:34 +03:00
TarkusTK 224a36eba7 [Gpu] Stop retrying array uploads that overrun their allocation (#476)
A 2D-array texture whose Depth times the per-slice stride runs past its
real allocation fails a slice read partway through the upload loop, and
falls through to the single-slice path after already detiling the layers
it did read. That fall-through builds the texture with ArrayLayers
defaulting to 1, so the presenter caches it under a one-layer key while
the next draw looks it up with ArrayLayers = Depth. The two never match,
so the texture misses the cache and repeats the whole read-and-detile on
every draw, throwing the result away each time.

Detiling is per-texel swizzle math, so one such texture retried a few
times per frame is expensive: it measured 568-879 ms of every second in
Demon's Souls, against a 1.4 second frame.

An allocation that is too short stays too short, so remembering the
address and not retrying it costs nothing and repairs the cache key as a
side effect: with the array upload skipped, arrayUploadLayers is 1, which
is exactly what the fall-through texture reports.

Tested on Demon's Souls (PPSA01342): 0.7 fps to 3.6-4.1 fps, CPU detile
time per second from ~700 ms to 0, and arrayed textures go from missing
the cache on every draw to hitting it every time. 28 of the 29 array
uploads in that run already succeeded and are unaffected; only the one
overrunning texture now falls back to its base slice. 495 tests pass.
2026-07-20 19:22:19 +03:00
Berk ac883e44fa [VideoPresenter] Fix logical width/height calculation (#473) 2026-07-20 16:57:38 +03:00
TarkusTK 25d741b35b [Gpu] Sample 2D array textures with real layers (#471)
Texture arrays were uploaded and viewed as plain 2D images, so every
layer index in a shader resolved to slice 0. The guest-texture gate
also rejected array, 3D and cube descriptor types outright, sending
those resources to the 1x1 black fallback.

UI atlases hit this constantly, since they pack several sheets as array
slices and pick one per vertex. In Demon's Souls the settings menu
bottom bar stretched a mid-atlas crop across itself, and slider thumbs
and button prompts drew the wrong sheet.

The MIMG decoder already had Dimension and IsArray, so
IsArrayedImageBinding makes one rule out of them for the SPIR-V
translator and the Vulkan backend to share. Both have to agree or the
declared image type and the bound view type mismatch. Sample and gather
bindings with an array address now declare an arrayed image and pass
(u, v, slice). AgcExports reads every slice at the per-slice mip-chain
stride and passes the layers packed in one buffer, which uploads as a
2D array image in a single copy region.

Load and store bindings are unchanged. Arrayed bindings that resolve to
a fallback or to a single-layer guest image get a one-layer 2D array
view so the descriptor still matches the shader.

Tested on Demon's Souls (PPSA01342): the bottom bar, slider thumbs and
button prompts draw their correct sheets. 470 tests pass.
2026-07-20 15:44:06 +03:00
TarkusTK dce7c87c4d [AGC] Implement the owner-scoped resource unregister exports (#469)
sceAgcDriverUnregisterOwnerAndResources (ZLJk9r2+2Aw) and
sceAgcDriverUnregisterAllResourcesForOwner (SCoAN5fYlUM) were
unresolved. We already register owners and resources, and the guest
registers a resource owner per streaming batch, so with no way to
release one the fixed owner pool filled up: sceAgcDriverRegisterOwner
started failing and the guest logged its own "Agc registerOwner error:
0x80020003", after which it kept half-registering records. Demon's Souls
then crashed scanning that registry.

Owner-scoped teardown is straightforward because RegisteredAgcResource
already carries its owner, so both entry points share one sweep over the
resource table. UnregisterOwnerAndResources additionally drops the owner
itself and its compute queue, and reports INVALID_ARGUMENT for an owner
that was never registered. The existing single-resource
sceAgcDriverUnregisterResource (pWLG7WOpVcw) is unchanged.

Both NIDs are checked against their export names by the SHEM004
analyzer, which fails the build on a mismatch.

Tested on Demon's Souls (PPSA01342): the registerOwner error no longer
appears and the registry stays consistent across streaming batches. 470
tests pass.
2026-07-20 15:43:47 +03:00
TarkusTK 6ee445f0c2 [AGC] Read mip 0 from its GFX10 mip-chain offset (#470)
GFX10 stores a mip chain smallest-first: the mip tail packs into the
first swizzle block, the remaining mips follow in decreasing size, and
mip 0 ends up at the end of the allocation. We read the base level
straight from the descriptor address, so every mipped sampled texture
decoded as a collage of its own smaller mips - in Demon's Souls that
showed up as scrambled menu text and repeated controller icons.

GnmTiling.TryGetBaseMipPlacement ports the AddrLib chain-offset math
from Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled. It returns a
byte offset to mip 0, or, when the whole chain fits inside the tail
block, the element coordinates of mip 0 within that block.
TryCreateGuestDrawTexture applies the offset to the sampled and storage
guest reads, and TryDetileTextureSource lifts a tail-resident mip 0 out
of the detiled block as a sub-rectangle.

MAX_MIP is only decoded from extended descriptors, so resources without
one, and single-level resources, keep the current behaviour.

Tested on Demon's Souls (PPSA01342): menu text and icons decode
correctly instead of showing shrunken copies of themselves. Verified
offline by dumping the raw tiled bytes and the detiled output for a
4096x4096 UI atlas and checking the art lands at the sampled
coordinates.
2026-07-20 15:27:46 +03:00
StealUrKill 9d187dec55 Prevent AvPlayer movie startup failures across supported hosts (#456)
* Prevent AvPlayer movie startup failures across supported hosts

* Prevent GR2 startup stalls during APR file checks and adaptive mutex self-locks.
2026-07-20 15:27:37 +03:00
kuba 3574a3b145 Shader: lower VOP3P V_FMA_MIX_F32/LO/HI (was dropping Unity HDR shaders) (#466)
The decoder recognises the VOP3P mix ops (0x20 V_FMA_MIX_F32, 0x21
V_FMA_MIXLO_F16, 0x22 V_FMA_MIXHI_F16) but left them opaque
(Vop3pRaw20/21/22), so at SPIR-V emission they fell through the
vector-ALU switch to the default and failed with "unsupported vector
opcode". A single unhandled instruction fails the whole compile, so any
shader using fma_mix was dropped entirely. Unity's built-in-RP /
PostProcessing v2 HDR, tone-mapping and auto-exposure shaders emit
V_FMA_MIX_F32, so those passes never translated (this is what kept
Superliminal's auto-exposure luminance chain from running).

Name the three opcodes in DecodeVop3p (like the packed v_pk_* ops) and
lower them in the SPIR-V translator. Each mix op computes a single f32
fma(a, b, c) where every source is read *independently* as either a full
f32 register/constant or one f16 half widened to f32. Per operand,
op_sel_hi selects f16-vs-f32 and op_sel picks which f16 half; the neg_hi
field is repurposed as an absolute-value modifier and neg negates,
applied abs-then-neg. This reuses the VOP3P op_sel/op_sel_hi/neg/neg_hi
bit layout with the mix-specific meaning, not the packed-math meaning.
The result is a scalar f32 for V_FMA_MIX_F32; _MIXLO/_MIXHI narrow it
back to f16 (exact round-to-nearest-even, via the existing
EmitFloatToHalf) and write it into the low/high 16 bits of vdst,
preserving the other half. The clamp modifier saturates to [0, 1]
consistently with the other VOP3P ops. Per-operand F16/F32 select and
the abs/neg modifiers follow shadPS4's GetSrcMix, the authoritative
reference for the mix semantics.

Adds Gen5FmaMixSpirvTests: assembles V_FMA_MIX_F32 (with a representative
op_sel/op_sel_hi/neg/abs) and V_FMA_MIXLO_F16 compute shaders and asserts
they translate to GPU SPIR-V without hitting the drop path and emit a
GLSL.std.450 Fma (and an FAbs for the neg_hi modifier). Both fail against
the pre-fix tree with "unsupported vector opcode Vop3pRaw20/21".
2026-07-20 14:37:40 +03:00
Job Meijer a1cbff8a9c Fix NID BHouLQzh0X0, doubled StartupStaticTlsReservation memory. Both needed to launch GTA V. (#454)
* Increased StartupStaticTlsReservation (doubled) and fixed mistake in NID BHouLQzh0X0. Now GTA V RAGE engine seems to start loading.

* fixed NID BHouLQzh0X0, this had an issue causing GTA V not to load. Also doubled StartupStaticTlsReservation.

* Removed .vscode folder and reverted global.json
2026-07-20 14:37:30 +03:00
Spooks db9b20481c Add internal render resolution scale and fix DPI Issue (#468)
* Add internal render resolution scale and fix embedded surface DPI scaling

Adds a GUI-configurable internal resolution scale (Graphics tab) that
renders offscreen color/depth targets below native guest resolution
and upscales on present, trading image quality for GPU headroom.
Storage/UAV images and sampled asset textures are left untouched, and
texture-alias/feedback-loop lookups compare against each target's
logical (unscaled) size so scaled render targets are still found
correctly when sampled back.

Also fixes the embedded game surface not filling the window: the
isolated emulator child process had no declared DPI awareness, so
Windows silently downscaled every window-geometry query it made
against the GUI-owned surface HWND by the display's DPI factor,
leaving an unfilled black margin on scaled displays.

* Remove flaky Gen5ScalarMemoryFallbackTests

* Restore Gen5ScalarMemoryFallbackTests

---------

Co-authored-by: Spooks4576 <Spooks4576@users.noreply.github.com>
2026-07-20 14:37:20 +03:00
ParantezTech 8cd46243ab Merge branch 'main' of https://github.com/sharpemu/sharpemu 2026-07-20 14:23:24 +03:00
ParantezTech 3334707f7c [CI] fix rule name 2026-07-20 14:23:04 +03:00
kuba 20eda4443c Shader: test a wave mask consumed as a per-lane predicate at the lane bit (#465)
* Shader: read a wave mask consumed as a per-lane predicate at the lane bit

A VCC/EXEC wave mask consumed as a per-lane predicate (the VCndmask
condition, a VCC/EXEC branch, or the derived _vcc/_exec bool) was tested in
single-lane emulation with a whole-word non-zero test (IsNotZero64) instead
of the current lane's bit. That is correct for comparison results (only the
lane's own bit is ever set) but wrong for bitwise-complement wave-mask idioms
(S_NOT / S_ORN2 / S_ANDN2 / S_NAND / S_NOR), which set the unused upper 63
bits: a whole-word test then reports the lane active even when its bit is
clear.

Unity's PostProcessing NaN killer does exactly this: per channel it computes
isNaN = NLT AND NGT AND NEQ (against 0), then combines the channels as
anyNaN OR NOT(v3-is-finite) via S_ORN2_B64. The complement set the upper mask
bits, so every valid pixel read as NaN and was replaced with 0, zeroing the
whole HDR scene before Bloom/Uber/tonemap. The 3D scene therefore rendered
black behind the menu while the UI survived. Extract the current lane's bit in
both single-lane and subgroup modes so IsWaveMaskActive matches the hardware.

Fixes Superliminal (PPSA06084) black 3D scene: the storage room now renders
behind the menu with natural exposure and no forced values.

(cherry picked from commit 7af6f4b6f314fe302619c0d44f4db00971c5bf24)

* test: wave-mask predicate is tested at the current lane bit

Regression test for the wave-mask lane-bit fix. Compiles a shader that
writes VCC at run time (V_CMP_EQ_F32) and asserts the emitted SPIR-V tests
the wave mask at the current lane's bit (mask & lane_bit) rather than with a
whole-word non-zero test. Fails against the previous IsNotZero64(mask) path,
which zeroed complement wave-mask idioms (S_ORN2/S_NOT, e.g. Unity's NaN
killer) across every lane.
2026-07-20 14:18:20 +03:00
Slick Daddy bb3318a503 kernel: return -1/errno from POSIX file syscalls on failure (#461)
* kernel: return -1/errno from POSIX open and fstat on failure

The POSIX-named open (wuCroIGjt2g) and fstat (mqQMh1zPPT8) exports routed
straight to the raw sceKernel* implementations, which report failure via
the 0x8002xxxx OrbisGen2Result sentinel in the return value. libc callers
follow the POSIX ABI and expect -1 with errno set, so they stored the
sentinel as a valid fd. Unity's IL2CPP file layer did exactly this while
probing the absent /app0/Media/il2cpp.usym: open returned NOT_FOUND
(0x80020002), the guest kept the sentinel as an fd, passed it back into
fstat, and eventually dereferenced a null pointer (vmovups xmm0,[rdi],
rdi=0) deep in a native .prx, crashing with 0xC0000005.

Wrap both entry points to translate a failed raw result into -1/errno,
mirroring the existing PosixStat/PosixLseek convention. Add a shared
PosixFailure helper (fstat maps a bad handle to EBADF; path calls default
to ENOENT) and route it through PosixStat too. Covered by two regression
tests reproducing the missing-file and misused-sentinel-fd cases.

* kernel: return -1/errno from POSIX close, read and write on failure

Same defect class as open/fstat: the POSIX-named close (bY-PO6JhzhQ),
read (AqBioC2vF3I) and write (FN4gaPmuFV8) exports forwarded the raw
sceKernel* core result, leaking the 0x8002xxxx sentinel to libc callers
that expect -1/errno on a bad fd. close in particular is on the crashing
Unity path, invoked on the sentinel the guest mistook for an fd.

Wrap all three through PosixFailure with EBADF as the fd-not-found errno.
Add regression tests for each, and correct the socket test that had
locked in the old raw-sentinel contract for a double close.

---------

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-20 14:17:08 +03:00
ParantezTech d151e151c2 [CI] fix zip inside zip 2026-07-20 10:14:55 +03:00
João Victor Amorim 472fc96a37 [AGC] Support the clamp modifier on packed f16 VOP3P ops (#460)
The VOP3P emitter rejected any packed op with the clamp bit set. Clamp
saturates each f16 output half to [0, 1] (and flushes NaN to 0, matching
RDNA), so games that emit clamped packed arithmetic fell back to a loud
emit failure.

Apply the saturation to the f32 result of each lane, before it is
narrowed back to f16. Because 0.0 and 1.0 are exact in both f32 and f16
and the clamp is monotonic, clamping in f32 and then rounding to f16
yields the same value as clamping the f16 result directly; for the fused
multiply-add the pre-narrowing value is the round-to-odd f32, which
preserves that equivalence through the final round-to-nearest-even. The
saturation uses ordered compares so a NaN result collapses to 0 without a
separate IsNan test.

Verification:
- The local exact-reference harness now also clamps: add, mul, and fma
  each compared against an f16-domain clamp reference (NaN -> 0, else
  [0, 1]) over directed boundary inputs and 24M random cases. 0
  mismatches, alongside the existing 34M unclamped fma cases.
- ShaderDump pk-f16 gains a clamped add and a clamped fma; all decode and
  emit.
- The exec program computes the pinned fma with clamp (both lanes exceed
  1.0, so each saturates to 0x3C00) and stores it at offset 28;
  GpuConformance checks it on device. All values match on an AMD Radeon
  RX 7700 XT.
2026-07-20 09:09:07 +03:00
Slick Daddy 33be88bdf9 memory: back the free pages of a partially-overlapping fixed mapping (#458)
A SCE_KERNEL_MAP_FIXED request whose window partially overlaps an
existing allocation was failing outright: AllocateAt reserves the whole
range in one all-or-nothing VirtualAlloc, which returns 0 on partial
overlap. The mapping call then returned NOT_FOUND while leaving the free
tail unmapped, so the guest faulted (0xC0000005) writing into it.

Add IGuestAddressSpace.TryBackFixedRange, which walks the range via the
host Query (VirtualQuery reports contiguous same-state runs) and fills
only the free sub-ranges, leaving already-backed pages untouched. This
matches the fixed-mapping contract on hardware. Route the fixed
reservation path through it via a new backPartialOverlap flag.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-20 09:08:29 +03:00
kadu04t 184e24fbb6 PerGameSettings Null toggles (#453) 2026-07-20 01:30:13 +03:00
kuba 327018e80a Encode linear-float flips to sRGB at present (#448)
PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
light where 1.0 is SDR white; hardware scan-out applies the display
transfer function. vkCmdBlitImage converts numerically only, so
raw-blitting a linear-float guest frame into a UNORM swapchain crushes
dim scenes to near-black.

Blit float flip sources through a cached swapchain-sized sRGB
intermediate (the sRGB store performs the linear->sRGB encode), then
raw vkCmdCopyImage the encoded bytes into the same-compatibility-class
UNORM swapchain image. Swapchains that are already sRGB keep the
direct blit (their store encodes), and swapchain formats without an
sRGB counterpart keep today's raw blit unchanged.
2026-07-20 01:29:38 +03:00
kuba 04557fd250 Refresh CPU-rewritten guest textures by write generation (#447)
* Track guest CPU write generations

* Refresh CPU-rewritten guest textures by write generation
2026-07-20 01:29:30 +03:00
Spooks 90c72ebecf Fixes a Mutex Issue Preventing Some UE Titles From Booting (#451)
* Optimize guest import, memory, and pthread hot paths

* Fix UE adaptive mutex self-lock handling
2026-07-19 13:20:05 -06:00
Nekono 8ef5a54ee4 cpu: emulate AMD-only Zen 2 instructions in software (#449)
Handle immediate EXTRQ and INSERTQ as well as MONITORX and MWAITX when the host raises illegal-instruction faults. Add unit coverage for SSE4a bit-field semantics and preserve existing load-time patching.

Co-authored-by: zocomputer <help@zocomputer.com>
2026-07-19 21:57:42 +03:00
shadowbeat070 0c467e8c57 Add missing nids (#450)
* [Kernel] Implement clock_getres and the POSIX pthread_once alias

clock_getres (smIj7eqzZE8) was missing entirely. It reports 100ns, which
is the resolution clock_gettime here actually delivers via
DateTimeOffset.UtcNow, rather than claiming the 1ns a caller might
otherwise rely on. A null res pointer is accepted per POSIX.

pthread_once (Z4QosVuAsA0) needed no new logic: libKernel exports the
same routine under two NIDs and only scePthreadOnce (14bOACANTBo) was
registered. Shipped middleware links the plain name.

Both are imported by DOOM + DOOM II (PPSA21444): clock_getres blocked
party.prx from initialising, and pthread_once is used by libcohtml,
libPlayFabMultiplayer, party.prx and the eboot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 848f035827)

* [Libs] Implement sceAgcGetIsTrinityMode, NpReachability and Trophy2 info

Three exports DOOM + DOOM II (PPSA21444) imports and currently receives
unresolved-stub errors for.

sceAgcGetIsTrinityMode reports the base console this backend emulates. It
returns the flag in rax and writes no guest memory: the observed rdi at
the call site sits inside the AGC state block, immediately below the
shader handles the guest stores, so writing through it would corrupt live
state if that register is stale rather than an out-pointer.

sceNpRegisterNpReachabilityStateCallback accepts the callback and never
fires it, matching the existing sceNpRegisterStateCallback handling.
Reachability transitions only occur on a live PSN connection.

sceNpTrophy2GetTrophyInfo reports NOT_FOUND rather than success.
Succeeding requires filling SceNpTrophy2Details and SceNpTrophy2Data,
whose layouts are not confirmed here, and a title trusting zeroed details
would read an empty name and grade 0 as real data. NOT_FOUND is a
documented outcome callers already handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 181286f621)

* [Kernel] Implement the POSIX libKernel exports titles link directly

libKernel exports many routines under both sce-prefixed and plain POSIX
NIDs, and shipped middleware links the latter. These nine are imported by
DOOM + DOOM II (PPSA21444) and had no registration at all.

Aliases onto existing implementations, identical argument order:
  mprotect (YQOfxL4QfeU), munmap (UqDGjXA5yUM), setsockopt (fFxGkxF2bVo)

New:
  getpagesize reports OrbisPageSize (16 KiB), not the host 4 KiB. An
  allocator rounding to the host value produces sub-page offsets that
  every mapping call here rejects for misalignment.

  pthread_rwlock_tryrdlock/trywrlock get a dedicated non-blocking core.
  They deliberately do not reuse TryAcquireBlockedRwlock, which
  decrements WaitingWriters -- correct only for a thread that previously
  incremented it. A fresh try never did, so reusing it would consume
  another thread's waiter count and let a queued writer be skipped.

  getsockopt reads back the three options this backend tracks (SO_NBIO,
  SO_REUSEADDR, SO_ERROR) and rejects the rest rather than returning
  success with an untouched buffer the caller would treat as real.

  send maps WouldBlock onto the existing net error path.

  inet_ntop converts AF_INET/AF_INET6 and returns the destination
  pointer per POSIX, failing rather than truncating when it will not fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 98c6851840)

* [Kernel] Implement the POSIX mprotect, munmap and getpagesize aliases

mprotect and munmap forward to the existing sceKernelMprotect and
sceKernelMunmap; the argument order is identical, so they are plain
aliases rather than separate implementations.

getpagesize reports OrbisPageSize (16 KiB), the granularity this backend
maps and aligns against, not the host's 4 KiB. An allocator that rounded
to the host value would produce sub-page offsets that every mapping call
here then rejects for misalignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [Kernel] Implement the POSIX _nanosleep symbol

libKernel exports nanosleep under two NIDs: yS8U2TGCe1A for the plain
name and NhpspxdjEKU for the underscore-prefixed _nanosleep that libc
conventionally provides alongside it. Only the former was registered.

Both are POSIX-side symbols, so this shares NanosleepCore with posix:
true - reporting failure as -1 plus errno rather than returning an
OrbisGen2Result the way sceKernelNanosleep does.

Not exercised at runtime: no title currently on this branch imports
_nanosleep, so the choice of error convention rests on it being the
same libc routine as nanosleep, not on observed behaviour.

* [Kernel] Implement the POSIX-named pthread aliases

libKernel exports each of these routines under two NIDs: a scePthread*
name and the plain POSIX name. Only the scePthread* half was registered,
so middleware compiled against POSIX headers linked an unresolved stub.

Adds the POSIX-named export for fourteen routines, each delegating to
the existing implementation:

  pthread_setprio               pthread_attr_setschedpolicy
  pthread_getschedparam         pthread_attr_setdetachstate
  pthread_attr_getschedparam    pthread_attr_setschedparam
  pthread_attr_getstack         pthread_attr_setinheritsched
  pthread_attr_get_np           pthread_attr_setguardsize
  pthread_attr_getstacksize     pthread_attr_getguardsize
  pthread_attr_getdetachstate   pthread_rename_np

Arguments are identical in both forms, and per the convention set by
scePthreadOnce's alias the POSIX name returns the same OrbisGen2Result
rather than translating to errno.

The equivalent POSIX names for mkdir, listen, accept and recv are
deliberately not included here. Those pair with sceKernelMkdir and the
libSceNet entry points, whose error convention differs from the POSIX
one, so they need a decision about error translation rather than a
straight delegation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:57:05 +03:00
Youss 9ff60abb9b [Kernel] Clamp guest path traversal at the mount root (#437)
NormalizeMountRelativePath only stripped leading separators and swapped
slashes; it never resolved "." or ".." segments. The bare-relative fallback
in ResolveGuestPath did not even call it -- it combined the guest path
against the app0 root verbatim.

A guest path containing ".." therefore escaped its mount into the host
filesystem. Unreal Engine titles hit this constantly: their base directory
is <app>/binaries/<platform>, so they address content with "../../../"
prefixes that resolve back inside /app0 on real hardware. Here those walked
out of the game folder entirely -- The Invincible (PPSA06426) opened
"/app0/.." and enumerated the host's Downloads directory, listing unrelated
user files, and never located its own content tree.

Resolve "." and ".." while walking the segments and clamp the result at the
mount root, then route the bare-relative fallback through the same
normalizer. This both closes the sandbox escape and makes the engine's
relative content paths land where the title expects.

Verified on The Invincible: no resolved host path contains ".." any more,
and the title now enumerates its own content directories (content/paks,
content/movies, content/locale/*) instead of an unrelated host folder. No
regression on Dead Cells (PPSA15552): still reaches AGC rendering and
presents frames with zero mutex errors.
2026-07-19 20:34:00 +03:00
Youss 3ebfc56d4c [PlayGo] Derive the installed chunk set from the pak files on disk (#438)
A title that ships no PlayGo sidecar was reported as a single-chunk
package. That is wrong for any package whose content is split across
chunks: the title is told everything past chunk 0 is not installed, even
though a locally dumped title has all of its data present.

The Invincible (PPSA06426) ships pakchunk0..8 and asks PlayGo which of
those are available. Receiving BAD_CHUNK_ID for chunks 1..8, it re-queried
scePlayGoGetLocus for the same chunk in a tight loop that never terminated
(observed ~1000 consecutive dispatches with identical arguments).

Discover the chunk ids from the pakchunk<N>-<platform>.pak files present
under the app0 root instead. Those N are exactly the chunks the package
has, so the answer is derived from the install rather than assumed. Chunk 0
is always included, so a title with no pak files at all keeps the previous
single-chunk behaviour, and ids outside the discovered set still return
BAD_CHUNK_ID so title-side chunk enumeration still terminates.

Verified on The Invincible: the discovered set is [0..8], matching the nine
pak files, and the GetLocus retry loop no longer occurs. No regression on
Dead Cells (PPSA15552): still reaches AGC rendering and presents frames.
The existing metadata-free contract test still passes -- its app0 fixture
has no pak files, so the discovered set stays [0].
2026-07-19 20:33:30 +03:00
Youss 73e8821d5b [Kernel] Hand off mutex ownership directly to the head waiter on unlock (#439)
pthread_mutex_unlock cleared ownership (OwnerThreadId = 0) and only woke
the head waiter, relying on that woken thread to re-acquire the lock
itself. If the wake raced or was lost, the mutex was left "free but with
a queued waiter" — a state the fast-acquire path in PthreadMutexLockCore
explicitly refuses (OwnerThreadId == 0 && Waiters.Count == 0), so every
later locker, including the game's main thread, queued behind a head that
never advanced and the whole process wedged.

Grant the mutex to the head waiter directly inside unlock (the same
TryGrantMutexWaiterLocked hand-off the thread-exit cleanup already uses),
then wake it. The mutex is therefore never observable as free-with-waiter.

Verified against The Invincible (PPSA06426): forward progress jumps from
~3.5M to ~40M dispatched imports and the repeated unlock INVALID_ARGUMENT
errors disappear. No regression on Dead Cells (PPSA15552), which still
reaches AGC rendering with zero mutex errors.
2026-07-19 20:33:21 +03:00
StealUrKill bc51cc2c4d Prevent invalid SaveData writes from damaging guest memory (#444)
Add an optional write monitor so the team can find future memory damage on each supported desktop system.
2026-07-19 20:27:17 +03:00
Nicola Pomarico d7f6e3f578 [Kernel] Implement sceKernelMapDirectMemory2 (#433)
The "2" variant of sceKernelMapDirectMemory was unimplemented, so titles
that call it (seen in Gex Trilogy) got an unresolved import that returned
an error the guest then used as a mapped address.

v2 inserts a memoryType argument ahead of v1's protection, shifting the
remaining arguments down one register and pushing alignment onto the
stack. Extract v1's body into a shared MapDirectMemoryCore and route both
exports through it; v2 reads its shifted arguments and the stack alignment
and accepts the memoryType (which only selects cache/GPU attributes this
HLE does not model per mapping, so it does not affect placement).
2026-07-19 14:35:40 +03:00
cse.aadi e56e74f960 Fix space-in-path game launching on Windows (#432) 2026-07-19 14:25:43 +03:00
kadu04t 0f224ec036 Gui Settings Null list Entries (#430) 2026-07-19 13:53:56 +03:00
kostyaff 85dc98dedc test: add Fiber exports contract tests (13 tests) (#428) 2026-07-19 13:53:33 +03:00
wearr 5d7d8e0edd [Kernel] add NID B5GmVDKwpn0 (pthread_yield) (#426) 2026-07-19 13:49:51 +03:00
wearr a60bfc9c83 [Kernel] Implement pthread semaphore exports (#424) 2026-07-19 04:18:16 +03:00
Berk 0b83b34cda chore: bump version to 0.0.2-beta.4 (#423) 2026-07-19 03:42:52 +03:00
Adam salem 09812600a0 Add libc heap trace contract tests (#409) 2026-07-19 03:25:42 +03:00
João Victor Amorim 3005babab8 [AGC] Emit v_pk_fma_f16 with exact single rounding (#420)
Completes the fused-FMA slice deferred by the VOP3P first slice (#145).
v_pk_fma_f16 previously failed emission loudly because an f32
multiply-add followed by an f16 pack rounds twice; the pinned miss is
fma(0x4100, 0x7522, 0x04EA) = 0x7A6B fused vs 0x7A6A via f32.

The f32 product of two f16 values is exact, so only the addition needs
correcting: compute sum = RN(product + addend), recover the exact
residual with Knuth 2Sum, and if the sum is inexact with an even
significand, step one ulp towards the true value. That is round-to-odd,
and rounding the f32 result to f16 with round-to-nearest-even then
matches a true fused f16 FMA exactly (24 significand bits >= 11 + 2).
Inf/NaN inputs turn the residual into NaN, the ordered compare skips the
parity fix, and IEEE special behaviour passes through unchanged. The
op_sel/op_sel_hi/neg_lo/neg_hi source modifiers apply to src2 through
the existing operand path; clamp stays rejected like the other packed
ops.

Every op in the 2Sum chain is decorated NoContraction: without it the
AMD RDNA3 Windows driver folds the sequence, collapses the residual to
zero, and the midpoint case decays to the double-rounded result. This
was caught by running the emitted shader on a real device (see below).

Verification:
- A mirror of the emitted sequence was checked against an exact
  integer reference (every finite f16 is m * 2^-24, so a*b + c is an
  exact Int128 multiple of 2^-48, rounded once to f16 RNE) across 34M
  cases: directed midpoint pins, random sweeps over all operand
  classes, tiny-addend midpoint stress, subnormal products, and
  Inf/NaN propagation. 0 mismatches.
- ShaderDump gains a pk-f16 program covering all five packed opcodes,
  both fma modifier paths, and the pinned constants; all programs
  decode and emit.
- The executable exec program now computes the pinned fma and its
  negated-addend twin (0x7A6B7A6B / 0x7A6A7A6A, straddling an f16
  midpoint) and stores them at offsets 20/24; GpuConformance checks
  both on device. All values match on an AMD Radeon RX 7700 XT.
2026-07-19 03:24:42 +03:00
Nicola Pomarico 09bd4f028b [Kernel] Implement sceKernelSyncOnAddressWait/Wake (#422)
libKernel's address-wait primitives were unimplemented, so every wait
returned immediately and guest runtimes that build spinlocks/queues on
top busy-spun forever. Implement them over the existing cooperative
block scheduler, keyed on the address, with a per-address wake
generation so a wait stays parked until a matching wake bumps it, and a
bounded self-heal deadline so a genuinely missed wake re-polls instead
of hanging.
2026-07-19 03:12:29 +03:00
ParantezTech 2bda253927 [script] added aerolib_catalog.py and docs/aerolib-catalog.md, renamed scripts/RELEASE-USE.md to docs/release-use.md 2026-07-19 01:33:15 +03:00
Berk 71e5912c75 [dotnet] remove lock files (#419) 2026-07-19 01:26:01 +03:00
144 changed files with 18663 additions and 1281 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 104 KiB

+29 -26
View File
@@ -89,7 +89,6 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -121,24 +120,13 @@ jobs:
- name: Publish win-x64 CLI
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
- name: Create release archive
run: |
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
if (Test-Path $archivePath) {
Remove-Item $archivePath -Force
}
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
path: ${{ env.PUBLISH_DIR }}
if-no-files-found: error
include-hidden-files: true
build-posix:
name: Build ${{ matrix.rid }}
@@ -158,7 +146,6 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
SPIRV_TARGET_ENV: vulkan1.2
@@ -223,19 +210,13 @@ jobs:
if: matrix.rid == 'osx-x64'
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
- name: Create release archive
run: |
mkdir -p "$RELEASE_DIR"
# tar keeps the executable bit, which zip would drop.
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
-C "$PUBLISH_DIR" .
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
path: ${{ env.PUBLISH_DIR }}
if-no-files-found: error
include-hidden-files: true
release:
name: Publish GitHub Release
@@ -255,6 +236,28 @@ jobs:
with:
path: release
- name: Package release assets
shell: bash
env:
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
VERSION: ${{ needs.init.outputs.version }}
run: |
set -euo pipefail
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
test -d "${package_dir}"
done
mkdir -p release-assets
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
- name: Create release
shell: bash
env:
@@ -264,9 +267,9 @@ jobs:
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
VERSION: ${{ needs.init.outputs.version }}
run: |
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release assets found." >&2
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -ne 3 ]; then
echo "Expected 3 release assets, found ${#assets[@]}." >&2
exit 1
fi
+1
View File
@@ -42,3 +42,4 @@ ehthumbs.db
.vs/
.idea/
.vscode/
+19
View File
@@ -26,6 +26,25 @@ Before opening a pull request, please keep the following in mind:
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
## Pull Request Expectations
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
When applicable, PRs should include:
- The affected game or application.
- Relevant logs or failing imports.
- Behavior before and after the change.
- Real game testing and known limitations.
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
## AI-Assisted Contributions
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
+8 -1
View File
@@ -9,11 +9,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
+1
View File
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageVersion Include="Iced" Version="1.21.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
+20
View File
@@ -25,6 +25,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
---
<p align="center">
<a href="#support">
<img src="https://img.shields.io/badge/Support-GitHub%20Sponsors%20%26%20Crypto-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Support SharpEmu">
</a>
</p>
---
> [!NOTE]
> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
> can run the macOS x64 build through Rosetta 2, and Windows on ARM devices
@@ -136,6 +144,18 @@ Provided valuable references for filesystem handling and low-level C# implementa
- [**GPL-2.0 license**](https://github.com/sharpemu/sharpemu/blob/main/LICENSE)
## Support
Support SharpEmu via GitHub Sponsors or cryptocurrency. Every contribution helps fund ongoing development and long-term maintenance. GitHub Sponsors is the preferred way to support the project, but cryptocurrency donations are also appreciated.
### ETH/USDT
`0xF315F5d986c790bB3A58DbE60F1B2760997dEd82`
### BTC
`bc1qmr9k8899njys5ny63xsues4jgmkk96erslrkmv`
## Contributing
Before opening an issue or pull request, please read our contribution guidelines:
+1
View File
@@ -21,6 +21,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Folder Name="/tests/">
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Tests/SharpEmu.ShaderCompiler.Tests.csproj" />
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder>
</Solution>
+20
View File
@@ -0,0 +1,20 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Aerolib Catalog
```bash
# NID to export name
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
# Export name to NID
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
# Search export names
python scripts/aerolib_catalog.py search VideoOut --limit 20
# Export all NID/name pairs to artifacts/aerolib.txt
python scripts/aerolib_catalog.py export
```
+51 -22
View File
@@ -9,38 +9,67 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
decoder cannot observe or replace those frames.
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path.
Without an adapter, Bink files remain visible to the guest and the game's
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
explicitly testing a title whose cinematics are optional.
The default path decodes by calling FFmpeg's own C API directly from managed
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
bindings) against a custom FFmpeg build
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
libraries come from. No proprietary RAD SDK is needed to build or run
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
statically linked into the game instead. Set `skip` only when explicitly
testing a title whose cinematics are optional.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
only; it does not decode the movie or alter its game logic. Set
SHARPEMU_BINK_MODE=native to force native bridge mode.
only; it does not decode the movie or alter its game logic.
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
being explicit about it.
## Supplying the adapter
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
default path above: instead of calling into FFmpeg in-process, it spawns a
standalone `ffmpeg` executable and reads raw frames from its stdout
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
only recognizes the Bink container is not sufficient. Most users want the
default `native` mode instead, which always has Bink 2 support since it's
built against `ffmpeg-core` specifically.
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
The adapter deliberately contains only a three-function C ABI so the managed
emulator never depends on RAD's private binary ABI.
## Supplying the FFmpeg libraries
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
executable, or point to it explicitly:
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
need to agree on the same FFmpeg ABI) and copies its dynamically linked
libraries into a `plugins` folder next to the published executable. No C
toolchain is required to build SharpEmu; publishing just downloads a zip.
`plugins` is a loose, unpacked folder rather than something embedded in the
single-file bundle, so the OS loader can resolve the libraries' own
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
./SharpEmu /path/to/eboot.bin
A plain `dotnet publish` with no `-r` still works: it defaults to the host
machine's own RID (see `Directory.Build.props`), so it fetches the matching
`ffmpeg-core` archive and populates `plugins` without any extra flags.
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
Windows) still overrides that default normally.
The expected exports are sharpemu_bink2_open_utf8,
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
frame. The managed side validates dimensions and retains ownership of the
destination buffer.
To use a different set of FFmpeg libraries, drop them into the published
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
folder and does not otherwise care where the files came from.
If the bridge is absent in native mode, SharpEmu logs one informational line
and retains the existing guest rendering path.
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
could not open movie ...") and leaves the guest's own rendering path
untouched, rather than crashing.
+57
View File
@@ -0,0 +1,57 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Guest write watch
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
code and HLE code that damage guest memory. The tool starts only if you set one
or more `SHARPEMU_WATCH_*` environment variables.
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
does not monitor stores that native guest code makes directly. Use a platform
debugger or a hardware watchpoint to monitor these stores.
## Watch modes
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
block at the specified guest address.
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
monitors the first 64 direct mappings that have a size of 64 KiB and
protection value `0xF2`.
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
memory range. The tool logs a maximum of 128 entries for each process.
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
logs a maximum of 64 entries for each process.
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
specified upper 32 bits in the destination address.
For each match, the tool logs the destination address, the data pattern, and the
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
tag.
Use these variables together to scan bulk writes in the
`0x00000080xxxxxxxx` region.
macOS and Linux:
```sh
SHARPEMU_WATCH_BULK_TORN=1 \
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
SharpEmu /path/to/eboot.bin
```
Windows PowerShell:
```powershell
$env:SHARPEMU_WATCH_BULK_TORN = "1"
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
& .\SharpEmu.exe C:\path\to\game\eboot.bin
```
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
address from a crash dump.
@@ -1,66 +0,0 @@
/*
* Copyright (C) 2026 SharpEmu Emulator Project
* SPDX-License-Identifier: GPL-2.0-or-later
*
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
*/
#include <stdint.h>
#include "bink.h"
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
} sharpemu_bink2_info;
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
HBINK bink;
if (!path || !movie || !info) return 0;
*movie = NULL;
bink = BinkOpen(path, 0);
if (!bink) return 0;
if (bink->Width == 0 || bink->Height == 0) {
BinkClose(bink);
return 0;
}
*movie = bink;
info->width = bink->Width;
info->height = bink->Height;
info->frames_per_second_numerator = bink->FrameRate;
info->frames_per_second_denominator = bink->FrameRateDiv;
return 1;
}
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
uint32_t stride, uint32_t destination_bytes) {
uint64_t needed;
uint64_t min_stride;
if (!movie || !destination) return 0;
min_stride = (uint64_t)movie->Width * 4;
if ((uint64_t)stride < min_stride) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
if (!BinkDoFrame(movie)) return 0;
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
BinkNextFrame(movie);
return 1;
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
}
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import sys
from pathlib import Path
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
def compute_nid(export_name: str) -> str:
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
return encoded.rstrip("=").replace("/", "-")
def read_names(path: Path) -> list[str]:
try:
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except OSError as error:
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
def write_pair(nid: str, export_name: str) -> None:
print(f"{nid}\t{export_name}")
def lookup(args: argparse.Namespace) -> int:
value = args.value.strip()
if NID_PATTERN.fullmatch(value):
for export_name in read_names(args.names):
if compute_nid(export_name) == value:
write_pair(value, export_name)
return 0
print(f"NID not found in catalog: {value}", file=sys.stderr)
return 1
names = set(read_names(args.names))
write_pair(compute_nid(value), value)
if value not in names:
print("Warning: export name is not present in the catalog.", file=sys.stderr)
return 0
def search(args: argparse.Namespace) -> int:
names = read_names(args.names)
if args.regex:
try:
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
except re.error as error:
print(f"Invalid regular expression: {error}", file=sys.stderr)
return 2
matches = (name for name in names if pattern.search(name))
elif args.case_sensitive:
matches = (name for name in names if args.query in name)
else:
query = args.query.casefold()
matches = (name for name in names if query in name.casefold())
count = 0
for export_name in matches:
write_pair(compute_nid(export_name), export_name)
count += 1
if args.limit and count >= args.limit:
break
if count == 0:
print(f"No catalog names matched: {args.query}", file=sys.stderr)
return 1
return 0
def export_catalog(args: argparse.Namespace) -> int:
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
if args.sort == "nid":
pairs.sort(key=lambda pair: (pair[0], pair[1]))
elif args.sort == "name":
pairs.sort(key=lambda pair: pair[1])
args.output.parent.mkdir(parents=True, exist_ok=True)
try:
with args.output.open("w", encoding="utf-8", newline="\n") as output:
output.write("# NID\tExportName\n")
for nid, export_name in pairs:
output.write(f"{nid}\t{export_name}\n")
except OSError as error:
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
return 1
print(f"Wrote {len(pairs)} entries to {args.output}")
return 0
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
epilog=(
"Examples:\n"
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
" python scripts/aerolib_catalog.py export"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--names",
type=Path,
default=DEFAULT_NAMES_FILE,
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
)
subparsers = parser.add_subparsers(dest="command", required=True)
lookup_parser = subparsers.add_parser(
"lookup", help="resolve a NID or calculate the NID for an export name"
)
lookup_parser.add_argument("value", help="11-character NID or exact export name")
lookup_parser.set_defaults(handler=lookup)
search_parser = subparsers.add_parser(
"search", help="find export names and print matching NID/name pairs"
)
search_parser.add_argument("query", help="name substring or regular expression")
search_parser.add_argument(
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
)
search_parser.add_argument(
"--case-sensitive", action="store_true", help="match case exactly"
)
search_parser.add_argument(
"--regex", action="store_true", help="treat the query as a regular expression"
)
search_parser.set_defaults(handler=search)
export_parser = subparsers.add_parser(
"export", help="write every NID/name pair to a tab-separated text file"
)
export_parser.add_argument(
"output",
type=Path,
nargs="?",
default=DEFAULT_EXPORT_FILE,
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
)
export_parser.add_argument(
"--sort",
choices=("source", "nid", "name"),
default="nid",
help="output ordering (default: nid)",
)
export_parser.set_defaults(handler=export_catalog)
return parser
def main() -> int:
parser = create_parser()
args = parser.parse_args()
return args.handler(args)
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -153133,6 +153133,7 @@ scePsmlMfsrGetContextBufferRequirement800M3_2
scePsmlMfsrGetDispatchMfsrPacket1000
scePsmlMfsrGetDispatchMfsrPacket1100
scePsmlMfsrGetDispatchMfsrPacketSizeInDwords
scePsmlMfsrGetDispatchMfsrPacket900
scePsmlMfsrGetMipmapBias
scePsmlMfsrGetSharedResourcesInitRequirement
scePsmlMfsrInit
+25 -8
View File
@@ -64,6 +64,7 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
PreloadGlfw();
if (args.Length == 0)
{
@@ -213,6 +214,27 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
/// <summary>
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
/// than flat next to the executable, which falls outside the default OS
/// DLL/dlopen search path. Preloading it here by full path first means
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
/// library) finds it already loaded in the process and reuses it -- the
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
/// for the Vulkan loader.
/// </summary>
private static void PreloadGlfw()
{
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
: "libglfw.so.3";
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
if (File.Exists(candidate))
{
NativeLibrary.TryLoad(candidate, out _);
}
}
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -551,12 +573,7 @@ internal static partial class Program
return false;
}
var childArgs = new string[args.Length + 1];
childArgs[0] = MitigatedChildFlag;
for (var i = 0; i < args.Length; i++)
{
childArgs[i + 1] = args[i];
}
string[] childArgs = [MitigatedChildFlag, .. args];
var commandLine = BuildCommandLine(processPath, childArgs);
var startupInfoEx = new STARTUPINFOEX();
@@ -607,7 +624,7 @@ internal static partial class Program
nint jobHandle = 0;
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
var created = CreateProcessW(
processPath,
null,
cmdLineBuilder,
0,
0,
@@ -1433,7 +1450,7 @@ internal static partial class Program
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string applicationName,
string? applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
+66 -3
View File
@@ -20,6 +20,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
natively, so on Apple Silicon it runs under Rosetta 2. -->
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
the host's own RID; see Directory.Build.props, which is where that
default actually has to live (PublishDir's RID suffix is decided
there, evaluated before this file, so a default set only here would
be too late for it). -->
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
@@ -60,7 +65,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PropertyGroup>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="..\..\LICENSE.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
@@ -74,17 +79,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Content>
</ItemGroup>
<!-- Keep glfw as a loose file next to the executable; every other native
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
executable instead of sitting loose beside it, so the publish
directory stays uncluttered as more native deps get added. The folder
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
literal "plugins" folder name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<!-- Keep glfw as a loose file in the native subfolder; every other native
library is embedded into the single-file bundle. -->
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
</PropertyGroup>
<Target Name="FetchFfmpegRuntime"
BeforeTargets="Publish"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<DownloadFile
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
DestinationFolder="$(FfmpegRuntimeDir)"
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
<Unzip
SourceFiles="$(FfmpegRuntimeArchive)"
DestinationFolder="$(FfmpegRuntimeExtractDir)"
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
</Target>
<Target Name="PublishFfmpegRuntime"
AfterTargets="Publish"
DependsOnTargets="FetchFfmpegRuntime"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
e.g. linux-x64 from a Windows machine is a supported cross-publish,
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
depends on which platform's ffmpeg-core package was fetched. -->
<ItemGroup>
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
</ItemGroup>
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
SkipUnchangedFiles="true" />
</Target>
</Project>
+5
View File
@@ -13,4 +13,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
@@ -0,0 +1,71 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
/// (immediate-form) instructions.
///
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
/// a different register allocation, a title built with a different compiler version, and so on
/// - still aborts the title. This class ported from Kyty's
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
///
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
/// </summary>
public static class Sse4aBitFieldEmulator
{
public static bool IsValidBitField(int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
}
public static ulong ExtractBitField(ulong value, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return 0;
}
if (len == 0)
{
return value;
}
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
return (value >> idx) & mask;
}
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return destination;
}
if (len == 0)
{
return source;
}
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
var destinationClearMask = fieldMask << idx;
var sourceField = (source & fieldMask) << idx;
return (destination & ~destinationClearMask) | sourceField;
}
}
@@ -0,0 +1,199 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Threading;
using Iced.Intel;
using SharpEmu.Core.Cpu.Emulation;
namespace SharpEmu.Core.Cpu.Native;
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
// - SSE4a EXTRQ/INSERTQ, immediate form
// - MONITORX/MWAITX
//
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
// different compiler/register allocation) does not cover, complementing rather than replacing
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
// built for, while this method is the safety net for everything else.
//
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
// has already declined to handle the fault.
public sealed partial class DirectExecutionBackend
{
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
// if this offset (or anything before it) were wrong.
private const int Win64ContextXmm0Offset = 0x1A0;
private static int _sse4aSoftwareFallbackAnnounced;
private static long _sse4aInstructionsEmulated;
private static int _monitorxSoftwareFallbackAnnounced;
private static long _monitorxInstructionsEmulated;
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
{
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
{
return true;
}
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
// silently compute a result from stale bytes and then discard whatever it "wrote", so
// the recovery declines until that bridge exists.
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
TryRecoverSse4aExtractInsert(contextRecord, rip);
}
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
{
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
var opcode = new byte[3];
if (!TryReadHostBytes(rip, opcode) ||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
{
return false;
}
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
// the extension raise #UD on either one. We do not model the monitor itself, only its
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
// loop keeps making progress instead of executing an illegal opcode forever.
if (opcode[2] == 0xFB)
{
Thread.Yield();
}
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
Interlocked.Increment(ref _monitorxInstructionsEmulated);
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
{
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
!TryReadFaultingInstruction(rip, out var instruction))
{
return false;
}
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
if (!isExtrq && !isInsertq)
{
return false;
}
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
{
return false;
}
if (instruction.GetOpKind(0) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
{
return false;
}
var destLow = ReadCtxU64(contextRecord, destOffset);
if (isExtrq)
{
var length = (int)instruction.GetImmediate(1);
var index = (int)instruction.GetImmediate(2);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
else
{
if (instruction.GetOpKind(1) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
{
return false;
}
var length = (int)instruction.GetImmediate(2);
var index = (int)instruction.GetImmediate(3);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
Interlocked.Increment(ref _sse4aInstructionsEmulated);
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
// explicit switch (rather than arithmetic on the Register enum) to match the style already
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
private static bool TryGetXmmOffset(Register register, out int offset)
{
switch (register)
{
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
default:
offset = 0;
return false;
}
}
}
@@ -19,6 +19,9 @@ public sealed partial class DirectExecutionBackend
private static int _lazyCommitTraceCount;
private static int _guestAllocatorHoleRecoveries;
private static int _auxiliaryThreadExecuteFaultRecoveries;
private static int _auxiliaryThreadExecuteFaultSkips;
private nint _workerAbortStack;
private const uint WorkerAbortStackSize = 0x10000u;
private unsafe void SetupExceptionHandler()
{
@@ -133,6 +136,11 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverAmdCompatInstruction(contextRecord, rip))
{
return -1;
}
if (IsBenignHostDebugException(exceptionCode))
{
return -1;
@@ -430,18 +438,91 @@ public sealed partial class DirectExecutionBackend
void* contextRecord,
ulong rip)
{
if (exceptionRecord->ExceptionCode != 3221225477u ||
rip >= 0x0000000800000000UL ||
_activeGuestThreadState is not { Name: "tbb_thead" } activeThread)
if (exceptionRecord->ExceptionCode != 3221225477u)
{
return false;
}
// Prefer ThreadStatic active state; fall back to host-thread name when
// concurrent TBB AVs race logging (tLT61: recover skipped, then Fatal).
GuestThreadState? activeThread = _activeGuestThreadState;
if (activeThread is null || activeThread.Name != "tbb_thead")
{
var hostName = Thread.CurrentThread.Name;
if (hostName is null ||
!hostName.StartsWith("SharpEmu-tbb_thead", StringComparison.Ordinal))
{
return false;
}
activeThread = FindGuestThreadStateByHostThreadId(unchecked((int)GetCurrentThreadId()));
if (activeThread is null || activeThread.Name != "tbb_thead")
{
var skip = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultSkips);
if (skip <= 8 || skip % 64 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] tbb_recover skip #{skip}: rip=0x{rip:X16} " +
$"host='{hostName}' active={(activeThread?.Name ?? "null")}");
Console.Error.Flush();
}
return false;
}
}
var hostExit = ActiveEntryReturnSentinelRip;
if (hostExit < 0x10000)
{
hostExit = unchecked((ulong)_guestReturnStub);
}
// Prefer worker-abort (SetEvent + ExitThread) over host_exit→RunEpilogue:
// the latter FailFasts the process after TBB recover (tLT28/30 silent die).
// Do NOT abandon mutexes here — managed HLE from inside VEH can re-enter
// and Fatal (tLT73). NativeGuestExecutor.Run abandons after detecting abort.
var abortRip = unchecked((ulong)_workerAbortStub);
if (abortRip >= 0x10000)
{
// Do NOT SetEvent from managed VEH: that wakes the renter which may
// TerminateThread while this thread is still inside VEH return
// (tLTA2: recover logged, no respawning, process die). Abort stub
// SetEvent's only after CONTINUE_EXECUTION resumes at park.
// Prefer the entry-stub-saved host RSP (real CreateThread stack).
// Do not treat mid-range host stacks as guest — Astro worker stacks
// often sit in 0x02xxxxxx_xxxx and were wrongly replaced with a
// shared VirtualAlloc abort stack (concurrent TBB AV → die).
var hostRspSlot = TlsGetValue(_hostRspSlotTlsIndex);
ulong hostRsp = 0;
if (hostRspSlot != 0)
{
hostRsp = *(ulong*)hostRspSlot;
}
if (hostRsp < 0x10000)
{
hostRsp = EnsureWorkerAbortStackRsp();
}
if (hostRsp >= 0x10000)
{
WriteCtxU64(contextRecord, 152, hostRsp & ~0xFUL);
}
WriteCtxU64(contextRecord, 120, 0);
WriteCtxU64(contextRecord, 248, abortRip);
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
Console.Error.WriteLine(
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} " +
$"host_rsp=0x{hostRsp:X16} -> worker_abort=0x{abortRip:X16}");
Console.Error.WriteLine(
"[LOADER][INFO] tbb_recover: parking native worker (SetEvent+park); " +
"renter will TerminateThread+respawn — avoids ExitThread after VEH");
Console.Error.Flush();
return true;
}
if (hostExit < 0x10000)
{
Console.Error.WriteLine(
@@ -453,13 +534,57 @@ public sealed partial class DirectExecutionBackend
_ = TryPatchActiveGuestReturnSlot(hostExit);
WriteCtxU64(contextRecord, 120, 0);
WriteCtxU64(contextRecord, 248, hostExit);
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
var recoveryFallback = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
Console.Error.WriteLine(
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recoveryFallback}: " +
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} -> host_exit=0x{hostExit:X16}");
Console.Error.WriteLine(
"[LOADER][INFO] tbb_recover: resumed at host_exit (abort stub unavailable); " +
"subsequent FastFail/CLR must not re-enter managed VEH " +
"(live trampoline pre-filters 0xC0000409 / 0xE0434352)");
Console.Error.Flush();
return true;
}
private GuestThreadState? FindGuestThreadStateByHostThreadId(int hostThreadId)
{
if (hostThreadId == 0)
{
return null;
}
try
{
foreach (var thread in SnapshotGuestThreads())
{
if (Volatile.Read(ref thread.HostThreadId) == hostThreadId)
{
return thread;
}
}
}
catch
{
}
return null;
}
private unsafe ulong EnsureWorkerAbortStackRsp()
{
if (_workerAbortStack == 0)
{
_workerAbortStack = (nint)VirtualAlloc(null, WorkerAbortStackSize, 12288u, 4u);
if (_workerAbortStack == 0)
{
return 0;
}
}
// Grow-down stack: hand out near the top with alignment headroom.
return (ulong)(_workerAbortStack + (nint)WorkerAbortStackSize - 0x100) & ~0xFUL;
}
private unsafe bool TryRecoverGuestInt41(uint exceptionCode, void* contextRecord, ulong rip)
{
if (!_ignoreGuestInt41 || exceptionCode != 3221225477u || rip < 0x10000)
@@ -478,7 +603,7 @@ public sealed partial class DirectExecutionBackend
if (count <= 16 || count % 65536 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
Console.Error.Flush();
}
return true;
@@ -530,9 +530,12 @@ public sealed partial class DirectExecutionBackend
{
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
}
StoreImportVectorReturn(cpuContext, argPackPtr);
if (dispatchResolved &&
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
@@ -1326,9 +1329,12 @@ public sealed partial class DirectExecutionBackend
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
}
StoreImportVectorReturn(cpuContext, argPackPtr);
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
@@ -1398,11 +1404,13 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
@@ -1438,6 +1446,12 @@ public sealed partial class DirectExecutionBackend
var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
var expectedPollSemaBusy =
string.Equals(nid, "12wOHk8ywb0", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedNetAcceptWouldBlock =
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80410123);
@@ -1451,6 +1465,8 @@ public sealed partial class DirectExecutionBackend
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
!expectedMutexTrylockBusy &&
!expectedSemaphoreTrywaitAgain &&
!expectedPollSemaBusy &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
@@ -1544,11 +1560,13 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or
"sSAUCCU1dv4" or
"C+IEj+BsAFM" or
"4fgtGfXDrFc" or
"tZDDEo2tE5k" or
"GnxKOHEawhk" or
"gzndltBEzWc" or
"H896Pt-yB4I" or
"sJXyWHjP-F8" or
"j0+3uJMxYJY" or
"mPpPxv5CZt4" or
"1FZBKy8HeNU" or
"ASoW5WE-UPo" or
@@ -29,9 +29,29 @@ public sealed partial class DirectExecutionBackend
private static readonly bool NativeGuestWorkersDisabled =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_NATIVE_GUEST_WORKERS"), "1", StringComparison.Ordinal);
// Cap concurrent native-worker Runs. Astro's tbb_thead burst overlaps many
// UnmanagedCallersOnly prologues; a large prewarm + unbounded concurrency
// FailFasts (0xC0000409) mid-storm with no VEH breadcrumb. Pool size and
// in-flight Runs are separate knobs.
private static readonly int NativeWorkerMaxConcurrent = ReadNativeWorkerMaxConcurrent();
private static int ReadNativeWorkerMaxConcurrent()
{
if (int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_NATIVE_WORKER_MAX_CONCURRENT"),
out var parsed) &&
parsed > 0)
{
return Math.Clamp(parsed, 1, 64);
}
return 2;
}
private readonly object _nativeWorkerGate = new();
private readonly List<NativeGuestExecutor> _allNativeWorkers = new();
private readonly Stack<NativeGuestExecutor> _idleNativeWorkers = new();
private readonly SemaphoreSlim _nativeWorkerRunLimiter = new(NativeWorkerMaxConcurrent);
private bool _nativeWorkersDisposed;
private int _nativeWorkerCreationFailedLogged;
@@ -49,6 +69,9 @@ public sealed partial class DirectExecutionBackend
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool TerminateThread(nint hThread, uint dwExitCode);
// 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.
@@ -56,40 +79,148 @@ public sealed partial class DirectExecutionBackend
// 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)
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot, bool requireNativeWorker = false)
{
var worker = RentNativeGuestExecutor();
if (worker is null)
{
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
return CallNativeEntry(entryStub);
}
// Limit in-flight native Runs before renting so the idle pool is not
// drained by threads blocked on the concurrency gate.
_nativeWorkerRunLimiter.Wait();
NativeGuestExecutor? worker = null;
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;
// Astro can spawn a burst of tbb_thead while workers are still in
// TerminateThread+respawn. Wait for a native worker — never fall back
// to managed inline (FailFast) and never throw (uncaught throw mid-
// storm was a silent process die).
var maxAttempts = requireNativeWorker ? 500 : 48;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
worker = RentNativeGuestExecutor();
if (worker is not null)
{
break;
}
if (!requireNativeWorker)
{
break;
}
Thread.Sleep(attempt < 32 ? 1 : 4);
}
if (worker is null)
{
if (requireNativeWorker)
{
var n = Interlocked.Increment(ref _tbbNativeWorkerRefuseCount);
if (n <= 8 || n % 32 == 0)
{
Console.Error.WriteLine(
$"[LOADER][ERROR] tbb_native_worker unavailable #{n} after {maxAttempts} attempts; " +
"skipping run (no managed inline, no throw)");
Console.Error.Flush();
}
_activeGuestThreadYieldRequested = true;
_activeGuestThreadYieldReason = "tbb_native_worker_unavailable";
_activeForcedGuestExit = true;
return unchecked((int)0x80020012);
}
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
return CallNativeEntry(entryStub);
}
try
{
var state = _activeGuestThreadState;
if (state is { Name: "tbb_thead" })
{
var n = Interlocked.Increment(ref _tbbNativeRunEnterCount);
if (n <= 12 || n % 64 == 0)
{
Console.Error.WriteLine(
$"[LOADER][INFO] tbb_run_enter #{n} native_tid_pending handle=0x{state.ThreadHandle:X16} " +
$"max_concurrent={NativeWorkerMaxConcurrent}");
Console.Error.Flush();
}
}
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);
}
}
finally
{
ReturnNativeGuestExecutor(worker);
_nativeWorkerRunLimiter.Release();
}
}
private static int _tbbNativeRunEnterCount;
private static int _tbbNativeWorkerRefuseCount;
internal static int _tbbWorkerPrologueFaultCount;
private void PrewarmNativeGuestWorkers(int count)
{
if (!OperatingSystem.IsWindows() || NativeGuestWorkersDisabled || count <= 0)
{
return;
}
var warmed = new List<NativeGuestExecutor>(count);
for (var i = 0; i < count; i++)
{
var worker = NativeGuestExecutor.TryCreate(this);
if (worker is null)
{
break;
}
warmed.Add(worker);
}
lock (_nativeWorkerGate)
{
if (_nativeWorkersDisposed)
{
foreach (var worker in warmed)
{
worker.Dispose();
}
return;
}
foreach (var worker in warmed)
{
_allNativeWorkers.Add(worker);
_idleNativeWorkers.Push(worker);
}
}
Console.Error.WriteLine(
$"[LOADER][INFO] Native guest workers prewarmed: {warmed.Count}/{count} " +
$"max_concurrent={NativeWorkerMaxConcurrent}");
Console.Error.Flush();
}
private NativeGuestExecutor? RentNativeGuestExecutor()
{
// NativeGuestExecutor emits a Win32 wait loop and creates it with
@@ -400,6 +531,22 @@ public sealed partial class DirectExecutionBackend
return false;
}
FlushInstructionCache(GetCurrentProcess(), _loopStub, LoopStubSize);
return StartWorkerThread();
}
private bool RestartWorkerThread()
{
if (_loopStub == null || _controlBlock == null)
{
return false;
}
*(int*)_controlBlock = 0;
return StartWorkerThread();
}
private bool StartWorkerThread()
{
_threadHandle = CreateThread(
0,
WorkerStackReservation,
@@ -445,6 +592,49 @@ public sealed partial class DirectExecutionBackend
_runForcedExit = false;
SignalWorkAvailable();
WaitWorkCompleted();
// Normal path: RunEpilogue/ExitRun clears _entered before SetEvent(done).
// TBB abort stub SetEvent's without ExitRun — _entered stays true.
if (_entered)
{
var waitRc = WaitForSingleObject(_threadHandle, 500u);
Console.Error.WriteLine(
$"[LOADER][WARN] Native guest worker tid={_nativeThreadId} aborted during run; " +
$"wait_rc=0x{waitRc:X8} respawning");
Console.Error.Flush();
if (_runState is { } abortedState)
{
_ = GuestThreadExecution.NotifyGuestThreadAbandoned(
abortedState.ThreadHandle,
"tbb_worker_abort");
Volatile.Write(ref abortedState.HostThreadId, _prevHostThreadId);
}
_entered = false;
if (_threadHandle != 0)
{
// Abort stub parks (no ExitThread). Force-kill the parked OS
// thread so we can recreate the loop without process teardown.
if (waitRc != 0u)
{
_ = TerminateThread(_threadHandle, unchecked((uint)(-1)));
_ = WaitForSingleObject(_threadHandle, 1000u);
}
CloseHandle(_threadHandle);
_threadHandle = 0;
_nativeThreadId = 0;
}
if (!RestartWorkerThread())
{
_runPrologueFailed = true;
}
else
{
_runPrologueFailed = false;
_runForcedExit = true;
_runNativeResult = 0;
}
}
_runContext = null;
_runState = null;
yieldRequested = _runYieldRequested;
@@ -452,7 +642,22 @@ public sealed partial class DirectExecutionBackend
forcedExit = _runForcedExit;
if (_runPrologueFailed)
{
throw new InvalidOperationException("Native guest worker failed to bind the run ambient (prologue fault)");
// Never throw out of the native-worker rent path: an uncaught
// exception mid-TBB storm kills the process with no FailFast
// breadcrumb.
var n = Interlocked.Increment(ref _tbbWorkerPrologueFaultCount);
if (n <= 8 || n % 32 == 0)
{
Console.Error.WriteLine(
$"[LOADER][ERROR] tbb_worker prologue fault #{n}; soft-fail run " +
$"(tid={_nativeThreadId})");
Console.Error.Flush();
}
yieldRequested = true;
yieldReason = "tbb_worker_prologue_fault";
forcedExit = true;
return unchecked((int)0x80020012);
}
return _runNativeResult;
}
@@ -546,6 +751,18 @@ public sealed partial class DirectExecutionBackend
_activeGuestThreadState = _runState;
backend.BindTlsBase(_runContext!);
TlsSetValue(backend._hostRspSlotTlsIndex, _runHostRspSlot);
if (backend._workerDoneEventTlsIndex != uint.MaxValue)
{
nint doneHandle = OperatingSystem.IsWindows()
? _workCompleted!.SafeWaitHandle.DangerousGetHandle()
: _doneSemaphore;
TlsSetValue(backend._workerDoneEventTlsIndex, doneHandle);
}
if (backend._tbbAbortEligibleTlsIndex != uint.MaxValue)
{
nint eligible = _runState is { Name: "tbb_thead" } ? 1 : 0;
TlsSetValue(backend._tbbAbortEligibleTlsIndex, eligible);
}
if (_runState is { } state)
{
_prevHostThreadId = Volatile.Read(ref state.HostThreadId);
@@ -580,6 +797,14 @@ public sealed partial class DirectExecutionBackend
Volatile.Write(ref state.HostThreadId, _prevHostThreadId);
}
TlsSetValue(_backend._hostRspSlotTlsIndex, _prevHostRspSlot);
if (_backend._workerDoneEventTlsIndex != uint.MaxValue)
{
TlsSetValue(_backend._workerDoneEventTlsIndex, 0);
}
if (_backend._tbbAbortEligibleTlsIndex != uint.MaxValue)
{
TlsSetValue(_backend._tbbAbortEligibleTlsIndex, 0);
}
GuestThreadExecution.RestoreGuestThread(_prevGuestThreadHandle);
_activeExecutionBackend = _prevBackend;
_activeCpuContext = _prevContext;
@@ -50,6 +50,19 @@ public sealed unsafe partial class DirectExecutionBackend
private const int LinuxUcontextGregsOffset = 40;
private const int LinuxGregsErrOffset = 19 * 8;
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
// after the general registers it hands to the handler: err(152)
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
// is libc-independent. Inside the FXSAVE image the XMM registers start
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
// same relative position they occupy in the Win64 CONTEXT's FltSave
// area (Win64ContextXmm0Offset = 256 + 160).
private const int LinuxGregsFpstateOffset = 184;
private const int FxsaveXmmOffset = 160;
private const int XmmBlockSize = 16 * 16;
// Byte offsets of the general registers relative to GetPosixRegisterBase,
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
@@ -71,6 +84,15 @@ public sealed unsafe partial class DirectExecutionBackend
[ThreadStatic]
private static int _posixSignalHandlerDepth;
// True while the current thread's in-flight POSIX fault carries the real
// XMM registers in the CONTEXT scratch buffer and writes to them will
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
// INSERTQ) that would otherwise compute results from a zeroed XMM area
// and silently discard what they "wrote". Darwin is not bridged yet, so
// the flag stays false there.
[ThreadStatic]
private static bool _posixXmmContextBridged;
private void SetupPosixExceptionHandler()
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
@@ -252,6 +274,26 @@ public sealed unsafe partial class DirectExecutionBackend
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
}
// Bridge the XMM registers alongside the GPRs where the layout is
// known: on Linux the fpstate pointer and FXSAVE image are kernel
// ABI, so recovery paths that read or write XMM state (SSE4a
// EXTRQ/INSERTQ) see the live registers and their writes reach the
// guest through sigreturn.
byte* fpstate = null;
if (OperatingSystem.IsLinux())
{
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
if (fpstate != null)
{
Buffer.MemoryCopy(
fpstate + FxsaveXmmOffset,
contextRecord + Win64ContextXmm0Offset,
XmmBlockSize,
XmmBlockSize);
}
}
_posixXmmContextBridged = fpstate != null;
EXCEPTION_RECORD record = default;
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
if (signal == PosixSigIll)
@@ -317,6 +359,14 @@ public sealed unsafe partial class DirectExecutionBackend
{
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
}
if (fpstate != null)
{
Buffer.MemoryCopy(
contextRecord + Win64ContextXmm0Offset,
fpstate + FxsaveXmmOffset,
XmmBlockSize,
XmmBlockSize);
}
return true;
}
@@ -214,6 +214,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private nint _guestReturnStub;
private nint _workerAbortStub;
private nint _vehManagedEntryLock;
private uint _workerDoneEventTlsIndex = uint.MaxValue;
private uint _tbbAbortEligibleTlsIndex = uint.MaxValue;
private nint _setEventAddress;
private nint _rawExceptionHandler;
private nint _rawExceptionHandlerStub;
@@ -712,6 +721,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
// Import dispatch is the hottest managed path in UE titles. Most imports do
// not have an exception queued, so publish the dictionary population and let
// safe points skip _guestThreadGate entirely in the common case.
private int _pendingGuestExceptionCount;
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
private int _guestThreadPumpDepth;
@@ -1036,6 +1050,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_selfHandlePtr = GCHandle.ToIntPtr(_selfHandle);
_guestTlsBaseTlsIndex = TlsAlloc();
_hostRspSlotTlsIndex = TlsAlloc();
_workerDoneEventTlsIndex = OperatingSystem.IsWindows() ? TlsAlloc() : uint.MaxValue;
_tbbAbortEligibleTlsIndex = OperatingSystem.IsWindows() ? TlsAlloc() : uint.MaxValue;
if (_guestTlsBaseTlsIndex == uint.MaxValue || _hostRspSlotTlsIndex == uint.MaxValue)
{
throw new OutOfMemoryException("Failed to allocate native TLS slots");
@@ -1059,6 +1075,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
throw new InvalidOperationException("Failed to resolve kernel32 thread timing functions");
}
_setEventAddress = kernel32 != 0 ? GetProcAddress(kernel32, "SetEvent") : 0;
}
else
{
@@ -1082,13 +1099,29 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
throw new OutOfMemoryException("Failed to allocate host stack slot storage");
}
_vehManagedEntryLock = (nint)VirtualAlloc(null, 64u, 12288u, 4u);
if (_vehManagedEntryLock == 0)
{
throw new OutOfMemoryException("Failed to allocate VEH managed-entry lock");
}
// owner (nint) + depth (int); recursive — nested VEH on same thread must reenter.
*(nint*)_vehManagedEntryLock = 0;
*(int*)(_vehManagedEntryLock + sizeof(nint)) = 0;
_unresolvedReturnStub = CreateUnresolvedReturnStub();
_guestReturnStub = CreateGuestReturnStub();
if (_guestReturnStub == 0)
{
throw new OutOfMemoryException("Failed to allocate guest return stub");
}
_workerAbortStub = CreateWorkerAbortStub();
if (_workerAbortStub == 0 && OperatingSystem.IsWindows())
{
Console.Error.WriteLine(
"[LOADER][WARN] Worker abort stub unavailable; TBB execute-fault recover will use host_exit");
}
SetupExceptionHandler();
// Cover the Astro TBB spawn storm (often 812 concurrent tbb_thead).
PrewarmNativeGuestWorkers(Math.Max(NativeWorkerMaxConcurrent, 4));
}
public bool TryExecute(CpuContext context, ulong entryPoint, Generation generation, IReadOnlyDictionary<ulong, string> importStubs, IReadOnlyDictionary<string, ulong> runtimeSymbols, CpuExecutionOptions executionOptions, out OrbisGen2Result result)
@@ -1118,7 +1151,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_logStrlenBursts = _logStrlenImports ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
_ignoredGuestInt41Count = 0;
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
@@ -2402,9 +2437,147 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return (nint)ptr;
}
/// <summary>
/// After a TBB execute-fault, VEH redirects here on a host stack.
/// SetEvent(done) then park forever — ExitThread from VEH CONTINUE_EXECUTION
/// was taking down the whole process (recover logged, no respawning). The
/// renter TerminateThread's the parked worker and respawns a clean loop.
/// </summary>
private unsafe nint CreateWorkerAbortStub()
{
if (!OperatingSystem.IsWindows() ||
_workerDoneEventTlsIndex == uint.MaxValue ||
_tlsGetValueAddress == 0 ||
_setEventAddress == 0)
{
return 0;
}
nint kernel32 = GetModuleHandle("kernel32.dll");
nint getStdHandle = kernel32 != 0 ? GetProcAddress(kernel32, "GetStdHandle") : 0;
nint writeFile = kernel32 != 0 ? GetProcAddress(kernel32, "WriteFile") : 0;
nint flushFileBuffers = kernel32 != 0 ? GetProcAddress(kernel32, "FlushFileBuffers") : 0;
const uint stubSize = 256u;
void* ptr = VirtualAlloc(null, stubSize, 12288u, 4u);
if (ptr == null)
{
return 0;
}
byte* code = (byte*)ptr;
int offset = 0;
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28); // sub rsp, 0x28
EmitByte(code, ref offset, 0xB9);
EmitUInt32(code, ref offset, _workerDoneEventTlsIndex); // mov ecx, tls
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = _tlsGetValueAddress;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0); // call TlsGetValue
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
EmitByte(code, ref offset, 0xC0); // test rax, rax
EmitByte(code, ref offset, 0x74); EmitByte(code, ref offset, 0x0F); // jz skip SetEvent (15 bytes)
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xC1); // mov rcx, rax
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = _setEventAddress;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0); // call SetEvent
// Breadcrumb on host stack (survives silent teardown better than managed log).
int msgAbsSlot = -1;
if (getStdHandle != 0 && writeFile != 0)
{
ReadOnlySpan<byte> msg = "[LOADER][WARN] tbb_abort_stub SetEvent+park\n"u8;
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x20); // extra shadow for WriteFile args
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = getStdHandle;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xC1); // mov rcx, handle
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xC3); // mov rbx, handle (nonvolatile for flush)
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
msgAbsSlot = offset;
*(nint*)(code + offset) = 0;
offset += sizeof(nint);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xC2); // mov rdx, msg
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
EmitUInt32(code, ref offset, (uint)msg.Length);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); // lea r9, [rsp+0x20]
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x28); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = writeFile;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
if (flushFileBuffers != 0)
{
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xD9); // mov rcx, rbx
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = flushFileBuffers;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
}
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x20);
}
// Park: do not ExitThread (process-wide silent die after VEH redirect).
int parkOffset = offset;
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90); // pause
EmitByte(code, ref offset, 0xEB);
EmitByte(code, ref offset, unchecked((byte)(parkOffset - (offset + 1)))); // jmp park
if (msgAbsSlot >= 0)
{
ReadOnlySpan<byte> msgEmbed = "[LOADER][WARN] tbb_abort_stub SetEvent+park\n"u8;
*(nint*)(code + msgAbsSlot) = (nint)ptr + offset;
for (int i = 0; i < msgEmbed.Length; i++)
{
EmitByte(code, ref offset, msgEmbed[i]);
}
}
if (offset > (int)stubSize)
{
Console.Error.WriteLine(
$"[LOADER][ERROR] Worker abort stub overflow: used={offset} cap={stubSize}");
VirtualFree(ptr, 0u, 32768u);
return 0;
}
uint oldProtect = default;
if (!VirtualProtect(ptr, stubSize, 32u, &oldProtect))
{
VirtualFree(ptr, 0u, 32768u);
return 0;
}
FlushInstructionCache(GetCurrentProcess(), ptr, (nuint)offset);
return (nint)ptr;
}
private unsafe nint CreateExceptionHandlerTrampoline(nint managedHandler)
{
const uint stubSize = 256u;
// Live VEH trampoline used by SetupExceptionHandler. Must pre-filter
// FastFail / CLR / MSVC C++ / stack-overflow the same way as
// WindowsFaultHandling.CreateHandlerThunk: entering managed VEH while
// the thread is in cooperative GC mode fail-fasts with
// "UnmanagedCallersOnly method from managed code" (tLT1822).
// Extra headroom for native tbb abort + recursive managed-entry spinlock.
const uint stubSize = 2048u;
void* ptr = VirtualAlloc(null, stubSize, 12288u, 64u);
if (ptr == null)
{
@@ -2413,10 +2586,305 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
byte* code = (byte*)ptr;
int offset = 0;
ReadOnlySpan<uint> nonManagedExceptionCodes =
[
0xE0434352u, // CLR managed exception
0xE06D7363u, // MSVC C++ exception
0xC0000409u, // STATUS_STACK_BUFFER_OVERRUN / FailFast
0xC00000FDu, // STATUS_STACK_OVERFLOW
];
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx]
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] ExceptionCode
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
int fastFailJumpSlot = -1;
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
{
EmitByte(code, ref offset, 0x3D);
EmitUInt32(code, ref offset, nonManagedExceptionCodes[i]);
EmitByte(code, ref offset, 0x74);
passJumpOffsets[i] = offset;
EmitByte(code, ref offset, 0x00);
if (nonManagedExceptionCodes[i] == 0xC0000409u)
{
fastFailJumpSlot = i;
}
}
EmitByte(code, ref offset, 0xE9); // jmp mainBody (rel32; FastFail breadcrumb sits between)
var mainBodyJumpSlot = offset;
EmitUInt32(code, ref offset, 0u);
int passOffset = offset;
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // xor eax, eax
EmitByte(code, ref offset, 0xC3);
int fastFailPassOffset = offset;
var fastFailLogInstalled = false;
nint kernel32 = GetModuleHandle("kernel32.dll");
nint getStdHandle = kernel32 != 0 ? GetProcAddress(kernel32, "GetStdHandle") : 0;
nint writeFile = kernel32 != 0 ? GetProcAddress(kernel32, "WriteFile") : 0;
if (fastFailJumpSlot >= 0 && getStdHandle != 0 && writeFile != 0)
{
// Prefix + Context.Rip hex (AMD64 CONTEXT.Rip @ 0xF8) + newline.
// Keep in sync with WindowsFaultHandling.CreateHandlerThunk.
ReadOnlySpan<byte> msg =
"[LOADER][FATAL] VEH_PASS FastFail 0xC0000409 (live trampoline; skip managed VEH) rip=0x"u8;
ReadOnlySpan<byte> hexDigits = "0123456789ABCDEF"u8;
// rcx=EXCEPTION_POINTERS*: capture Rip into r10 before clobbering.
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x41);
EmitByte(code, ref offset, 0x08); // mov rax, [rcx+8] ContextRecord*
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x90);
EmitUInt32(code, ref offset, 0xF8u); // mov r10, [rax+0xF8] Rip
EmitByte(code, ref offset, 0x50);
EmitByte(code, ref offset, 0x51);
EmitByte(code, ref offset, 0x52);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x50);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x51);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x52); // push r10 (Rip)
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x40); // sub rsp, 0x40 (hex buf @ +0x30)
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = getStdHandle;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x28); // mov [rsp+0x28], rax stderr handle
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC1);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
var msgAbsSlot = offset;
*(nint*)(code + offset) = 0;
offset += sizeof(nint);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
EmitUInt32(code, ref offset, (uint)msg.Length);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0); // lpOverlapped slot
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = writeFile;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
// Hex-encode Rip. Stack after sub 0x40: [rsp+0x40]=saved Rip (push r10).
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x40); // mov r10, [rsp+0x40]
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB8);
var hexDigitsAbsSlot = offset;
*(nint*)(code + offset) = 0;
offset += sizeof(nint); // mov r8, hexDigits
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x5C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x30); // lea r11, [rsp+0x30] hex out
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, 16u); // ecx = 16 nibbles
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xD0); // mov rax, r10
int hexLoopOffset = offset;
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC1); EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0x04); // rol rax, 4
EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2); // mov edx, eax
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xE2); EmitByte(code, ref offset, 0x0F); // and edx, 0xF
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB6);
EmitByte(code, ref offset, 0x14); EmitByte(code, ref offset, 0x10); // movzx edx, byte [r8+rdx]
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x88); EmitByte(code, ref offset, 0x13); // mov [r11], dl
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC3); // inc r11
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC9); // dec ecx
EmitByte(code, ref offset, 0x75);
EmitByte(code, ref offset, unchecked((byte)(hexLoopOffset - (offset + 1)))); // jnz hexLoop (rel8)
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC6); EmitByte(code, ref offset, 0x03);
EmitByte(code, ref offset, 0x0A); // mov byte [r11], '\n'
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x28); // mov rcx, [rsp+0x28] stderr
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x30); // lea rdx, [rsp+0x30]
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
EmitUInt32(code, ref offset, 17u); // 16 hex + newline
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = writeFile;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
// Flush redirected stderr so FastFail rip survives process teardown.
nint flushFileBuffers = kernel32 != 0 ? GetProcAddress(kernel32, "FlushFileBuffers") : 0;
if (flushFileBuffers != 0)
{
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x28); // mov rcx, stderr
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = flushFileBuffers;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
}
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x40);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5A); // pop r10
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x59);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x58);
EmitByte(code, ref offset, 0x5A);
EmitByte(code, ref offset, 0x59);
EmitByte(code, ref offset, 0x58);
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0xC3);
var msgOffset = offset;
for (int i = 0; i < msg.Length; i++)
{
EmitByte(code, ref offset, msg[i]);
}
var hexDigitsOffset = offset;
for (int i = 0; i < hexDigits.Length; i++)
{
EmitByte(code, ref offset, hexDigits[i]);
}
*(nint*)(code + msgAbsSlot) = (nint)ptr + msgOffset;
*(nint*)(code + hexDigitsAbsSlot) = (nint)ptr + hexDigitsOffset;
code[passJumpOffsets[fastFailJumpSlot]] =
checked((byte)(fastFailPassOffset - (passJumpOffsets[fastFailJumpSlot] + 1)));
fastFailLogInstalled = true;
}
int mainBodyOffset = offset;
*(int*)(code + mainBodyJumpSlot) = mainBodyOffset - (mainBodyJumpSlot + sizeof(int));
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
{
if (i == fastFailJumpSlot && fastFailLogInstalled)
{
continue;
}
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
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xCD); // mov r13, rcx
// Native worker EXECUTE-AV abort without managed VEH.
// Do NOT catch read/write AVs — workers need managed lazy-commit (tLTJ
// silent-die when every worker AV was aborted). Execute faults on
// tbb_thead are the concurrent-managed FailFast case (tLTC).
int tbbFallthroughJump = -1;
if (_workerAbortStub != 0 &&
_tlsGetValueAddress != 0 &&
_hostRspSlotTlsIndex != uint.MaxValue)
{
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x45); EmitByte(code, ref offset, 0x00); // mov rax, [r13]
EmitByte(code, ref offset, 0x81); EmitByte(code, ref offset, 0x38);
EmitUInt32(code, ref offset, 0xC0000005u); // cmp dword [rax], AV
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
tbbFallthroughJump = offset;
EmitUInt32(code, ref offset, 0u); // jne fallthrough
// ExceptionInformation[0] == 8 → EXECUTE (DEP). Offset 32 on x64 RECORD.
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xB8); EmitUInt32(code, ref offset, 32u);
EmitByte(code, ref offset, 0x08); // cmp qword [rax+32], 8
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
var tbbNotExecuteJump = offset;
EmitUInt32(code, ref offset, 0u);
int tbbNotEligibleJump = -1;
if (_tbbAbortEligibleTlsIndex != uint.MaxValue)
{
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0xB9);
EmitUInt32(code, ref offset, _tbbAbortEligibleTlsIndex);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = _tlsGetValueAddress;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
tbbNotEligibleJump = offset;
EmitUInt32(code, ref offset, 0u);
}
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0xB9);
EmitUInt32(code, ref offset, _hostRspSlotTlsIndex);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = _tlsGetValueAddress;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
var tbbNoHostRspJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x00); // mov r8, [rax] hostRsp
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x85);
EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
var tbbZeroRspJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xE0); EmitByte(code, ref offset, 0xF0); // and r8, ~0xF
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x08); // mov r9, [r13+8]
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0x81); EmitUInt32(code, ref offset, 0x98u); // Context.Rsp
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = _workerAbortStub;
offset += sizeof(nint);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0x81); EmitUInt32(code, ref offset, 0xF8u); // Context.Rip
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x78);
EmitUInt32(code, ref offset, 0u); // Context.Rax = 0
EmitByte(code, ref offset, 0xB8); EmitUInt32(code, ref offset, unchecked((uint)-1));
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xE4); // mov rsp, r12
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5D);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5C);
EmitByte(code, ref offset, 0xC3);
int tbbFallthroughOffset = offset;
*(int*)(code + tbbFallthroughJump) = tbbFallthroughOffset - (tbbFallthroughJump + sizeof(int));
*(int*)(code + tbbNotExecuteJump) = tbbFallthroughOffset - (tbbNotExecuteJump + sizeof(int));
if (tbbNotEligibleJump >= 0)
{
*(int*)(code + tbbNotEligibleJump) = tbbFallthroughOffset - (tbbNotEligibleJump + sizeof(int));
}
*(int*)(code + tbbNoHostRspJump) = tbbFallthroughOffset - (tbbNoHostRspJump + sizeof(int));
*(int*)(code + tbbZeroRspJump) = tbbFallthroughOffset - (tbbZeroRspJump + sizeof(int));
}
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x48); // mov rax, gs:[8]
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x04); EmitByte(code, ref offset, 0x25);
EmitUInt32(code, ref offset, 8u);
@@ -2433,11 +2901,67 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
// Serialize managed VEH entry (recursive spinlock). Concurrent UnmanagedCallersOnly
// FailFast was the tLTQ silent mid-TBB pattern (enter without abort breadcrumb).
// Lock layout: [0]=owner UniqueThread (nint), [8]=depth (int).
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
*(nint*)(code + offset) = _vehManagedEntryLock;
offset += sizeof(nint); // mov r9, lock*
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x4C);
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x14);
EmitByte(code, ref offset, 0x25); EmitUInt32(code, ref offset, 0x48u); // mov r10, gs:[0x48]
int hostAcquireSpin = offset;
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [r9]
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xD0); // cmp rax, r10
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
int hostMineJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0); // test rax, rax
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
int hostPauseJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0xF0); EmitByte(code, ref offset, 0x4C);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB1); EmitByte(code, ref offset, 0x11); // lock cmpxchg [r9], r10
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
int hostRetryJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
EmitUInt32(code, ref offset, 1u); // mov dword [r9+8], 1
EmitByte(code, ref offset, 0xE9);
int hostGotJump = offset;
EmitUInt32(code, ref offset, 0u);
int hostPauseOffset = offset;
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90); // pause
EmitByte(code, ref offset, 0xE9);
int hostPauseBackJump = offset;
EmitUInt32(code, ref offset, 0u);
int hostMineOffset = offset;
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08); // inc dword [r9+8]
int hostGotOffset = offset;
*(int*)(code + hostMineJump) = hostMineOffset - (hostMineJump + sizeof(int));
*(int*)(code + hostPauseJump) = hostPauseOffset - (hostPauseJump + sizeof(int));
*(int*)(code + hostRetryJump) = hostAcquireSpin - (hostRetryJump + sizeof(int));
*(int*)(code + hostGotJump) = hostGotOffset - (hostGotJump + sizeof(int));
*(int*)(code + hostPauseBackJump) = hostAcquireSpin - (hostPauseBackJump + sizeof(int));
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = managedHandler;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
*(nint*)(code + offset) = _vehManagedEntryLock;
offset += sizeof(nint); // mov r9, lock*
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x08); // dec dword [r9+8]
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
int hostStillJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x01); EmitUInt32(code, ref offset, 0u); // mov qword [r9], 0
int hostStillOffset = offset;
*(int*)(code + hostStillJump) = hostStillOffset - (hostStillJump + sizeof(int));
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0xE9);
int hostRestoreJump = offset;
@@ -2463,11 +2987,64 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xDC); // mov rsp, r11
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
*(nint*)(code + offset) = _vehManagedEntryLock;
offset += sizeof(nint); // mov r9, lock*
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x4C);
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x14);
EmitByte(code, ref offset, 0x25); EmitUInt32(code, ref offset, 0x48u); // mov r10, gs:[0x48]
int guestAcquireSpin = offset;
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [r9]
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xD0); // cmp rax, r10
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
int guestMineJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
int guestPauseJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0xF0); EmitByte(code, ref offset, 0x4C);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB1); EmitByte(code, ref offset, 0x11);
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
int guestRetryJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
EmitUInt32(code, ref offset, 1u);
EmitByte(code, ref offset, 0xE9);
int guestGotJump = offset;
EmitUInt32(code, ref offset, 0u);
int guestPauseOffset = offset;
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90);
EmitByte(code, ref offset, 0xE9);
int guestPauseBackJump = offset;
EmitUInt32(code, ref offset, 0u);
int guestMineOffset = offset;
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
int guestGotOffset = offset;
*(int*)(code + guestMineJump) = guestMineOffset - (guestMineJump + sizeof(int));
*(int*)(code + guestPauseJump) = guestPauseOffset - (guestPauseJump + sizeof(int));
*(int*)(code + guestRetryJump) = guestAcquireSpin - (guestRetryJump + sizeof(int));
*(int*)(code + guestGotJump) = guestGotOffset - (guestGotJump + sizeof(int));
*(int*)(code + guestPauseBackJump) = guestAcquireSpin - (guestPauseBackJump + sizeof(int));
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = managedHandler;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
*(nint*)(code + offset) = _vehManagedEntryLock;
offset += sizeof(nint);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x08); // dec dword [r9+8]
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
int guestStillJump = offset;
EmitUInt32(code, ref offset, 0u);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x01); EmitUInt32(code, ref offset, 0u);
int guestStillOffset = offset;
*(int*)(code + guestStillJump) = guestStillOffset - (guestStillJump + sizeof(int));
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0xE9);
int guestRestoreJump = offset;
@@ -2488,6 +3065,18 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
*(int*)(code + missingHostStackJump) = passThroughOffset - (missingHostStackJump + sizeof(int));
*(int*)(code + guestRestoreJump) = restoreOffset - (guestRestoreJump + sizeof(int));
if (offset > (int)stubSize)
{
Console.Error.WriteLine(
$"[LOADER][ERROR] Exception handler trampoline overflow: used={offset} cap={stubSize}");
VirtualFree(ptr, 0, 0x8000u);
return 0;
}
Console.Error.WriteLine(
$"[LOADER][INFO] VEH trampoline built: bytes={offset} native_worker_abort=" +
$"{(_workerAbortStub != 0 && _hostRspSlotTlsIndex != uint.MaxValue)}");
uint oldProtect = default;
VirtualProtect(ptr, stubSize, 32u, &oldProtect);
FlushInstructionCache(GetCurrentProcess(), ptr, (nuint)offset);
@@ -3948,10 +4537,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// unwinding. Unity can begin its next stop-the-world cycle in
// that window; treating the new raise as part of the old delivery
// strands the collector waiting for an acknowledgement.
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase);
external.ExceptionStackBase));
return true;
}
@@ -3960,10 +4549,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// managed thread corrupts the worker's control state. Queue the
// request and let that exact executor consume it at its next HLE
// boundary, where the original guest thread is safely paused.
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase);
external.ExceptionStackBase));
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -4008,17 +4597,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
if (target.ExceptionDeliveryActive)
{
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
exceptionStackBase);
exceptionStackBase));
return true;
}
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
exceptionStackBase);
exceptionStackBase));
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -4179,7 +4768,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
RestoreInterruptedGuestThread();
if (target.State == GuestThreadRunState.Blocked &&
!target.ExecutorActive &&
_pendingGuestExceptions.Remove(threadHandle, out var queued))
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
{
followUp = queued;
}
@@ -4265,6 +4854,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
CpuContext currentContext,
GuestCpuContinuation interruptedContinuation)
{
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
{
return;
}
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (threadHandle == 0)
{
@@ -4278,7 +4872,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
{
return;
}
@@ -4340,6 +4934,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
}
private void QueuePendingGuestExceptionLocked(
ulong threadHandle,
PendingGuestException pending)
{
_pendingGuestExceptions[threadHandle] = pending;
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
}
private bool TryRemovePendingGuestExceptionLocked(
ulong threadHandle,
out PendingGuestException pending)
{
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
{
return false;
}
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
return true;
}
private static bool TryWriteGuestExceptionContext(
CpuContext context,
ulong address,
@@ -4434,6 +5049,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_guestThreads.Clear();
_externalGuestThreads.Clear();
_pendingGuestExceptions.Clear();
Volatile.Write(ref _pendingGuestExceptionCount, 0);
_activeGuestExceptionDeliveries.Clear();
}
@@ -5115,16 +5731,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return GuestNativeCallExitReason.Exception;
}
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
{
reason = "failed to bind host-RSP storage for guest thread stub";
return GuestNativeCallExitReason.Exception;
}
ActiveGuestThreadYieldRequested = false;
ActiveGuestThreadYieldReason = null;
try
{
var nativeReturn = CallNativeEntry(ptr);
// TBB execute-AV recover needs native-worker TLS (eligible/done).
// Other guests stay on CallNativeEntry — full native-worker migration
// increased splash hangs / UnmanagedCallersOnly (tLTN/tLTO).
int nativeReturn;
if (name == "tbb_thead")
{
nativeReturn = RunGuestEntryStub(ptr, hostRspSlot, requireNativeWorker: true);
}
else
{
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
{
reason = "failed to bind host-RSP storage for guest thread stub";
return GuestNativeCallExitReason.Exception;
}
nativeReturn = CallNativeEntry(ptr);
}
if (ActiveGuestThreadYieldRequested)
{
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
@@ -5270,16 +5897,24 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return GuestNativeCallExitReason.Exception;
}
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
{
reason = "failed to bind host-RSP storage for guest continuation stub";
return GuestNativeCallExitReason.Exception;
}
ActiveGuestThreadYieldRequested = false;
ActiveGuestThreadYieldReason = null;
try
{
var nativeReturn = CallNativeEntry(ptr);
int nativeReturn;
if (name == "tbb_thead")
{
nativeReturn = RunGuestEntryStub(ptr, hostRspSlot, requireNativeWorker: true);
}
else
{
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
{
reason = "failed to bind host-RSP storage for guest continuation stub";
return GuestNativeCallExitReason.Exception;
}
nativeReturn = CallNativeEntry(ptr);
}
if (ActiveGuestThreadYieldRequested)
{
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
@@ -6412,6 +7047,16 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
VirtualFree((void*)_hostRspSlotStorage, 0u, 32768u);
_hostRspSlotStorage = 0;
}
if (_vehManagedEntryLock != 0)
{
VirtualFree((void*)_vehManagedEntryLock, 0u, 32768u);
_vehManagedEntryLock = 0;
}
if (_workerAbortStack != 0)
{
VirtualFree((void*)_workerAbortStack, 0u, 32768u);
_workerAbortStack = 0;
}
if (_guestTlsBaseTlsIndex != uint.MaxValue)
{
TlsFree(_guestTlsBaseTlsIndex);
@@ -6422,6 +7067,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
TlsFree(_hostRspSlotTlsIndex);
_hostRspSlotTlsIndex = uint.MaxValue;
}
if (_workerDoneEventTlsIndex != uint.MaxValue)
{
TlsFree(_workerDoneEventTlsIndex);
_workerDoneEventTlsIndex = uint.MaxValue;
}
if (_unresolvedReturnStub != 0)
{
VirtualFree((void*)_unresolvedReturnStub, 0u, 32768u);
@@ -6432,6 +7082,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
VirtualFree((void*)_guestReturnStub, 0u, 32768u);
_guestReturnStub = 0;
}
if (_workerAbortStub != 0)
{
VirtualFree((void*)_workerAbortStub, 0u, 32768u);
_workerAbortStub = 0;
}
if (_guestContextTransferStub != 0)
{
VirtualFree((void*)_guestContextTransferStub, 0u, 32768u);
@@ -24,7 +24,7 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
{
const uint stubSize = 256u;
const uint stubSize = 1024u;
void* ptr = (void*)_memory.Allocate(0, stubSize, HostPageProtection.ReadWriteExecute);
if (ptr == null)
{
@@ -43,11 +43,15 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
// 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.
//
// FastFail (0xC0000409) is logged from this native path only: managed VEH never
// sees it (tLT1821 silent exits after TBB AV recovery).
ReadOnlySpan<uint> nonManagedExceptionCodes =
[WindowsFaultCodes.ClrManagedException, 0xE06D7363u, WindowsFaultCodes.FastFail, WindowsFaultCodes.StackOverflow];
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];
int fastFailJumpSlot = -1;
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
{
EmitByte(code, ref offset, 0x3D); // cmp eax, imm32
@@ -55,13 +59,162 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
EmitByte(code, ref offset, 0x74); // je pass
passJumpOffsets[i] = offset;
EmitByte(code, ref offset, 0x00);
if (nonManagedExceptionCodes[i] == WindowsFaultCodes.FastFail)
{
fastFailJumpSlot = i;
}
}
EmitByte(code, ref offset, 0xEB); EmitByte(code, ref offset, 0x03); // jmp over pass block
EmitByte(code, ref offset, 0xE9); // jmp mainBody rel32 (FastFail breadcrumb sits between)
var mainBodyJumpSlot = offset;
EmitUInt32(code, ref offset, 0u);
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
// FastFail: native stderr breadcrumb with Context.Rip (no managed entry), then CONTINUE_SEARCH.
// Keep in sync with DirectExecutionBackend.CreateExceptionHandlerTrampoline.
int fastFailPassOffset = offset;
var fastFailLogInstalled = false;
if (fastFailJumpSlot >= 0 &&
NativeLibrary.TryLoad("kernel32.dll", out var kernel32) &&
NativeLibrary.TryGetExport(kernel32, "GetStdHandle", out var getStdHandle) &&
NativeLibrary.TryGetExport(kernel32, "WriteFile", out var writeFile))
{
ReadOnlySpan<byte> msg =
"[LOADER][FATAL] VEH_PASS FastFail 0xC0000409 (native; no managed VEH) rip=0x"u8;
ReadOnlySpan<byte> hexDigits = "0123456789ABCDEF"u8;
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x41);
EmitByte(code, ref offset, 0x08); // mov rax, [rcx+8]
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x90);
EmitUInt32(code, ref offset, 0xF8u); // mov r10, [rax+0xF8]
EmitByte(code, ref offset, 0x50); // push rax
EmitByte(code, ref offset, 0x51); // push rcx
EmitByte(code, ref offset, 0x52); // push rdx
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x50); // push r8
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x51); // push r9
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x52); // push r10
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x40); // sub rsp, 0x40
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = getStdHandle;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x28); // mov [rsp+0x28], rax
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC1);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
var msgAbsSlot = offset;
*(nint*)(code + offset) = 0;
offset += sizeof(nint);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
EmitUInt32(code, ref offset, (uint)msg.Length);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = writeFile;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x40); // mov r10, [rsp+0x40]
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB8);
var hexDigitsAbsSlot = offset;
*(nint*)(code + offset) = 0;
offset += sizeof(nint);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x5C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x30); // lea r11, [rsp+0x30]
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, 16u);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xD0);
int hexLoopOffset = offset;
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC1); EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0x04);
EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xE2); EmitByte(code, ref offset, 0x0F);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB6);
EmitByte(code, ref offset, 0x14); EmitByte(code, ref offset, 0x10);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x88); EmitByte(code, ref offset, 0x13);
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC3);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC9);
EmitByte(code, ref offset, 0x75);
EmitByte(code, ref offset, unchecked((byte)(hexLoopOffset - (offset + 1)))); // jnz rel8
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC6); EmitByte(code, ref offset, 0x03);
EmitByte(code, ref offset, 0x0A);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x28);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x30);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
EmitUInt32(code, ref offset, 17u);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
*(nint*)(code + offset) = writeFile;
offset += sizeof(nint);
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x40);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5A);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x59);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x58);
EmitByte(code, ref offset, 0x5A);
EmitByte(code, ref offset, 0x59);
EmitByte(code, ref offset, 0x58);
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0);
EmitByte(code, ref offset, 0xC3);
var msgOffset = offset;
for (int i = 0; i < msg.Length; i++)
{
EmitByte(code, ref offset, msg[i]);
}
var hexDigitsOffset = offset;
for (int i = 0; i < hexDigits.Length; i++)
{
EmitByte(code, ref offset, hexDigits[i]);
}
*(nint*)(code + msgAbsSlot) = (nint)ptr + msgOffset;
*(nint*)(code + hexDigitsAbsSlot) = (nint)ptr + hexDigitsOffset;
code[passJumpOffsets[fastFailJumpSlot]] =
checked((byte)(fastFailPassOffset - (passJumpOffsets[fastFailJumpSlot] + 1)));
fastFailLogInstalled = true;
}
int mainBodyOffset = offset;
*(int*)(code + mainBodyJumpSlot) = mainBodyOffset - (mainBodyJumpSlot + sizeof(int));
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
{
if (i == fastFailJumpSlot && fastFailLogInstalled)
{
continue;
}
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
}
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
@@ -40,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
return result;
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
_inner.TryCopy(destinationAddress, sourceAddress, length);
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
if (_inner is IGuestMemoryAllocator allocator)
+70 -4
View File
@@ -199,9 +199,32 @@ public sealed class SelfLoader : ISelfLoader
{
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
{
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
// Exact allocation failed — the host may have already claimed
// part of this range (ASLR, Rosetta 2, or another process).
// Try backing the fixed range page by page to claim whatever
// free gaps exist. If the whole range is occupied the backfill
// returns false and we surface the original failure reason.
Console.Error.WriteLine(
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
{
// TryBackFixedRange may have partially backed pages before
// failing. The earlier Clear() already reset all regions, so
// this second Clear() is idempotent for everything except the
// partial backfill — it frees only those orphaned pages.
physicalVm.Clear();
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}): {reason}. " +
"Try closing other applications, rebooting, or " +
(OperatingSystem.IsWindows()
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
: "ensuring no other process maps into this address range."));
}
allocatedBase = imageBase;
}
imageBase = allocatedBase;
@@ -714,8 +737,9 @@ public sealed class SelfLoader : ISelfLoader
importedRelocations = BuildImportedRelocations(descriptors);
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
var stubImportNids = orderedImportNids
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
.Where(stubEligibleNids.Contains)
.ToArray();
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
@@ -1160,6 +1184,35 @@ public sealed class SelfLoader : ISelfLoader
isWeak);
}
// Collects every NID that needs a trap import stub in a single pass over the
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
// the O(nids * descriptors) rescan that filtering each unique NID against the
// full descriptor list would incur on large modules. A NID qualifies as soon as
// one of its descriptors is non-weak, or is weak but resolvable via the module
// manager.
private static HashSet<string> CollectStubEligibleNids(
IReadOnlyList<RelocationDescriptor> descriptors,
IModuleManager? moduleManager)
{
var eligible = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < descriptors.Count; i++)
{
var descriptor = descriptors[i];
var nid = descriptor.ImportNid;
if (nid is null || eligible.Contains(nid))
{
continue;
}
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
{
eligible.Add(nid);
}
}
return eligible;
}
private static bool ShouldCreateImportStub(
string nid,
IReadOnlyList<RelocationDescriptor> descriptors,
@@ -2431,6 +2484,19 @@ public sealed class SelfLoader : ISelfLoader
Debug.Assert(
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
"An unresolved weak symbol incorrectly received a trap import stub.");
var strong = new RelocationDescriptor(
TargetAddress: 0x3000,
Addend: 0,
ImportNid: "strong",
SymbolValue: 0,
RelocationValueKind.Pointer,
IsDataImport: false);
var mixed = new List<RelocationDescriptor> { weak, strong };
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
Debug.Assert(
eligible.Contains("strong") && !eligible.Contains("weak"),
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
}
private static ulong AlignUp(ulong value, ulong alignment)
+317 -40
View File
@@ -20,6 +20,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
private bool _disposed;
[ThreadStatic]
private static CommittedRangeCache? _committedRangeCache;
private long _mappingGeneration;
private const ulong PageSize = 0x1000;
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
private const ulong GuestAllocationArenaSize = 0x0100_0000;
@@ -28,6 +33,77 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private const ulong FullCommitRegionLimit = 4UL << 30;
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
private const int CommittedRangeCacheCapacity = 4;
private sealed class CommittedRangeCache
{
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
private PhysicalVirtualMemory? _owner;
private long _generation;
private int _count;
private int _nextReplacement;
public bool Contains(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
return false;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start >= range.Start && end <= range.End)
{
return true;
}
}
return false;
}
public void Add(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
_owner = owner;
_generation = generation;
_count = 0;
_nextReplacement = 0;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start <= range.End && end >= range.Start)
{
_ranges[index] = new CommittedRange(
Math.Min(start, range.Start),
Math.Max(end, range.End));
return;
}
}
if (_count < _ranges.Length)
{
_ranges[_count++] = new CommittedRange(start, end);
return;
}
_ranges[_nextReplacement] = new CommittedRange(start, end);
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
}
}
private readonly record struct CommittedRange(ulong Start, ulong End);
// Raw Windows PAGE_* values retained for the internal region/protection
// bookkeeping: regions and saved old-protection values always carry the raw
@@ -162,7 +238,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
// Reserve address space only for very large non-executable regions; commit is done lazily later.
var reservedOnly = !executable &&
alignedSize >= LargeDataReserveThreshold &&
alignedSize > FullCommitRegionLimit;
var result = reservedOnly
? _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite)
: _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
if (result == 0)
{
return false;
@@ -176,6 +260,8 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
var state = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
_gate.EnterWriteLock();
try
{
@@ -184,7 +270,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
VirtualAddress = actualAddress,
Size = alignedSize,
IsExecutable = executable,
IsReservedOnly = false,
IsReservedOnly = reservedOnly,
Protection = protection
});
}
@@ -193,6 +279,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
var allocationKind = executable ? "executable memory" : "data memory";
TraceVmem($"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} ({alignedSize} bytes)");
return true;
@@ -285,44 +372,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var actualAddress = result;
var lazyPrimeState = "n/a";
if (reservedOnly)
{
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
if (primeBytes != 0)
{
ulong committedBytes = 0;
while (committedBytes < primeBytes)
{
var remaining = primeBytes - committedBytes;
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
var commitAddress = actualAddress + committedBytes;
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
{
break;
}
committedBytes += chunkBytes;
}
if (committedBytes != 0)
{
lazyPrimeState = committedBytes == primeBytes
? $"ok:{committedBytes:X}"
: $"partial:{committedBytes:X}/{primeBytes:X}";
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
}
else
{
lazyPrimeState = $"fail:{primeBytes:X}";
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
}
}
else
{
lazyPrimeState = "skip:0";
}
}
var lazyPrimeState = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
_gate.EnterWriteLock();
try
@@ -349,6 +399,146 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return actualAddress;
}
private string ReserveRegion(ulong actualAddress, ulong alignedSize)
{
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
if (primeBytes == 0)
{
return "skip:0";
}
ulong committedBytes = 0;
while (committedBytes < primeBytes)
{
var remaining = primeBytes - committedBytes;
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
var commitAddress = actualAddress + committedBytes;
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
{
break;
}
committedBytes += chunkBytes;
}
if (committedBytes != 0)
{
var state = committedBytes == primeBytes
? $"ok:{committedBytes:X}"
: $"partial:{committedBytes:X}/{primeBytes:X}";
TraceVmem($"region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
return state;
}
TraceVmem($"Failed to reserve region at 0x{actualAddress:X16} ({primeBytes} bytes)!");
return $"fail:{primeBytes:X}";
}
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
{
if (size == 0)
{
return false;
}
var start = AlignDown(address, PageSize);
var end = AlignUp(address + size, PageSize);
if (end <= start)
{
return false;
}
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
// Walk the range page-run by page-run. VirtualQuery reports the largest run
// of same-state pages from the queried address, so a single query advances
// us over whole free or occupied stretches. Only free stretches get backed;
// stretches already reserved or committed by another allocation are left as
// they are, which is exactly what a fixed mapping does on hardware.
//
// Because backing may span several disjoint free runs, allocations are
// staged: host pages are reserved/committed first, and the corresponding
// MemoryRegions are inserted only once every gap in the range has been
// backed. If any gap fails to back, every earlier host allocation is freed
// and no region is inserted, so the address space is left untouched.
var stagedAllocations = new List<(ulong Address, ulong Size)>();
var cursor = start;
while (cursor < end)
{
if (!_hostMemory.Query(cursor, out var info))
{
goto Rollback;
}
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
? ulong.MaxValue
: info.BaseAddress + info.RegionSize;
var runEnd = Math.Min(end, queriedEnd);
if (runEnd <= cursor)
{
goto Rollback;
}
if (info.State == HostRegionState.Free)
{
var runSize = runEnd - cursor;
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
if (allocated != cursor)
{
if (allocated != 0)
{
_hostMemory.Free(allocated);
}
goto Rollback;
}
stagedAllocations.Add((cursor, runSize));
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
}
cursor = runEnd;
}
if (stagedAllocations.Count == 0)
{
return false;
}
// All gaps backed successfully — insert regions in one batch.
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
_gate.EnterWriteLock();
try
{
foreach (var (gapAddress, gapSize) in stagedAllocations)
{
InsertRegionSorted(new MemoryRegion
{
VirtualAddress = gapAddress,
Size = gapSize,
IsExecutable = executable,
IsReservedOnly = false,
Protection = protection
});
}
}
finally
{
_gate.ExitWriteLock();
}
return true;
Rollback:
foreach (var (gapAddress, _) in stagedAllocations)
{
_hostMemory.Free(gapAddress);
}
return false;
}
public bool TryAllocateAtOrAbove(
ulong desiredAddress,
ulong size,
@@ -440,6 +630,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
Interlocked.Increment(ref _mappingGeneration);
_hostMemory.Free(address);
}
@@ -611,6 +802,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
{
_allocationSearchHints.Clear();
}
Interlocked.Increment(ref _mappingGeneration);
}
finally
{
@@ -919,6 +1111,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
}
@@ -944,6 +1137,68 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
{
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
{
if (length == 0)
{
return true;
}
if (length > int.MaxValue)
{
return false;
}
// Match TryWrite's managed-write notification before touching an
// identity-mapped guest page protected by the image tracker.
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
_gate.EnterReadLock();
try
{
var sourceRegion = FindRegion(sourceAddress, length);
var destinationRegion = FindRegion(destinationAddress, length);
if (sourceRegion is null || destinationRegion is null ||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
{
return false;
}
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
if ((sourceRegion.IsReservedOnly &&
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
(destinationRegion.IsReservedOnly &&
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
{
return false;
}
// Span.CopyTo has memmove overlap semantics, so this allocation-free
// path safely serves both libc memcpy and libc memmove.
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
new Span<byte>((void*)destinationPointer, checked((int)length)));
NotifyGuestWriteWatch(
destinationAddress,
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
return true;
}
finally
{
_gate.ExitReadLock();
}
}
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
{
var region = FindRegion(virtualAddress, (ulong)destination.Length);
@@ -1016,6 +1271,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1040,6 +1296,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1281,6 +1538,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var startPage = AlignDown(address, PageSize);
var endPage = AlignUp(address + size, PageSize);
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
{
return true;
}
var commitProtection = GetCommitProtection(region);
var pageAddress = startPage;
@@ -1302,6 +1565,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
if (info.State == HostRegionState.Committed)
{
// The host query proved this whole range is committed. Retain
// that result instead of caching only the caller's small span.
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
pageAddress = rangeEnd;
continue;
}
@@ -1317,12 +1583,23 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
pageAddress = rangeEnd;
}
CacheCommittedRange(startPage, endPage, mappingGeneration);
return true;
}
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
{
(_committedRangeCache ??= new CommittedRangeCache()).Add(
this,
mappingGeneration,
startPage,
endPage);
}
private bool TryTemporarilyProtectForRead(
ulong address,
ulong size,
+8 -1
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Loader;
using SharpEmu.HLE;
namespace SharpEmu.Core.Memory;
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
}
CopyToRegions(virtualAddress, source, regionIndex);
return true;
}
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
return true;
}
private bool TryValidateRange(
+2 -2
View File
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
if (!CreateProcessW(
exePath,
null,
commandLine,
0,
0,
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+7
View File
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
{
Console.Error.WriteLine(
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
}
_surface.UpdatePixelSize(width, height);
if (!sizeChanged)
+37 -1
View File
@@ -53,6 +53,9 @@ public sealed class GuiSettings
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new();
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as
@@ -71,7 +74,7 @@ public sealed class GuiSettings
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
return NormalizeFromJson(json);
}
}
catch (Exception)
@@ -82,6 +85,39 @@ public sealed class GuiSettings
return new GuiSettings();
}
/// <summary>
/// Deserializes settings and normalizes null references and null or empty list
/// entries introduced by JSON. Empty scalar strings remain unchanged.
/// </summary>
internal static GuiSettings NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
settings.LogLevel ??= "Info";
settings.Language ??= "en";
settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{
settings.RenderResolutionScale = 1.0;
}
return settings;
}
// JSON can populate non-nullable lists with null references and entries.
private static List<string> FilterNullOrEmpty(List<string>? source)
{
if (source is null)
{
return [];
}
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
public void Save()
{
try
+23
View File
@@ -400,6 +400,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
+38 -2
View File
@@ -192,6 +192,18 @@ public partial class MainWindow : Window
// it is open already uses the new values.
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
RenderResolutionBox.SelectionChanged += (_, _) =>
{
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
double.TryParse(
tag,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var scale))
{
_settings.RenderResolutionScale = scale;
}
};
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
@@ -869,6 +881,13 @@ public partial class MainWindow : Window
_ => 2,
};
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
{
>= 0.875 => 0,
>= 0.625 => 1,
>= 0.375 => 2,
_ => 3,
};
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
@@ -1788,6 +1807,12 @@ public partial class MainWindow : Window
_appliedEnvironmentVariables.Add(name);
}
Environment.SetEnvironmentVariable(
"SHARPEMU_RENDER_SCALE",
_settings.RenderResolutionScale.ToString(
"0.###",
System.Globalization.CultureInfo.InvariantCulture));
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
{
SharpEmuLog.MinimumLevel = logLevel;
@@ -2030,8 +2055,6 @@ public partial class MainWindow : Window
RestoreGameViewToFull();
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
@@ -2040,6 +2063,19 @@ public partial class MainWindow : Window
LaunchBar.IsVisible = false;
HideSessionLoading();
UpdateSessionBarVisibility();
// Defer so the layout pass from the margin change above settles first.
Dispatcher.UIThread.Post(() =>
{
if (!_isRunning || _isStopping)
{
return;
}
_gameSurfaceHost?.RefreshSurfaceSize();
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
});
}
});
}
+13 -1
View File
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
var path = PathFor(titleId);
if (File.Exists(path))
{
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
return NormalizeFromJson(File.ReadAllText(path));
}
}
catch (Exception)
@@ -59,6 +59,18 @@ public sealed class PerGameSettings
return null;
}
// A null list inherits global settings; only entries in a present list are sanitized.
internal static PerGameSettings? NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
if (settings?.EnvironmentToggles is { } toggles)
{
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
return settings;
}
public void Save(string titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
+4
View File
@@ -24,6 +24,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
+43 -2
View File
@@ -32,6 +32,7 @@ public static unsafe class GuestImageWriteTracker
public int Armed;
public int FirstCpuWriteSeen;
public int PendingFirstCpuWrite;
public long WriteGeneration;
public bool TraceLifetime;
public long SourceSequence;
public long FirstCpuWriteTraceSequence;
@@ -155,10 +156,21 @@ public static unsafe class GuestImageWriteTracker
{
// Never resize an object that is still reachable from the
// signal handler's lock-free snapshot. Retire it and publish
// a fresh immutable range.
// a fresh immutable range, carrying the write generation so
// resizes do not hide guest CPU rewrites from cache owners.
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
DisarmLocked(range, "replace-range");
_rangesByAddress.Remove(address);
range = null;
range = new TrackedRange
{
Address = address,
ByteCount = byteCount,
Start = start,
End = start + length,
WriteGeneration = writeGeneration,
};
_rangesByAddress[address] = range;
RebuildSnapshotLocked();
}
if (range is null)
@@ -272,6 +284,31 @@ public static unsafe class GuestImageWriteTracker
}
}
/// <summary>
/// Returns the monotonic first-write generation for a tracked allocation.
/// Unlike the consuming dirty flag, this remains changed after another
/// cache owner consumes and re-arms the range.
/// </summary>
public static bool TryGetWriteGeneration(ulong address, out long generation)
{
generation = 0;
if (!_enabled)
{
return false;
}
lock (_gate)
{
if (!_rangesByAddress.TryGetValue(address, out var range))
{
return false;
}
generation = Volatile.Read(ref range.WriteGeneration);
return true;
}
}
/// <summary>
/// Prepares pages touched by a managed HLE memory write. Native guest
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
@@ -425,6 +462,10 @@ public static unsafe class GuestImageWriteTracker
}
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
if (wasArmed)
{
Interlocked.Increment(ref range.WriteGeneration);
}
if (wasArmed &&
range.TraceLifetime &&
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
+23
View File
@@ -221,6 +221,29 @@ public static class GuestThreadExecution
public static IGuestThreadScheduler? Scheduler { get; set; }
/// <summary>
/// Fired when a guest thread is torn down without a clean pthread_exit
/// (e.g. TBB execute-AV → worker_abort). Libs use this to abandon mutexes.
/// </summary>
public static event Func<ulong, string, int>? GuestThreadAbandoned;
public static int NotifyGuestThreadAbandoned(ulong threadHandle, string reason)
{
if (threadHandle == 0 || GuestThreadAbandoned is null)
{
return 0;
}
try
{
return GuestThreadAbandoned.Invoke(threadHandle, reason);
}
catch
{
return 0;
}
}
public static bool IsGuestThread => _currentGuestThreadHandle != 0;
public static ulong CurrentGuestThreadHandle => _currentGuestThreadHandle;
+1 -1
View File
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
// modules can require more than one host page of Variant II static TLS;
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
public const ulong StartupStaticTlsReservation = 0x10000UL;
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
private static readonly object _gate = new();
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
+203
View File
@@ -0,0 +1,203 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Globalization;
using System.Threading;
namespace SharpEmu.HLE;
// This tool monitors guest-memory writes only when a watch mode is active.
public static class GuestWriteWatch
{
private const ulong WatchBytes = 8;
private const int MaxBulkReports = 64;
private static readonly ulong WatchBase = Parse(
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
private static readonly ulong[] PoolSlots = new ulong[64];
private static int _poolSlotCount;
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
private const ulong DirectBandLow = 0x100_0000_0000;
private const ulong DirectBandHigh = 0x1000_0000_0000;
private static int _value1Reports;
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
private static readonly ulong BulkDestHigh = Parse(
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
private static int _bulkTornReports;
private static int _bulkShiftReports;
public static bool Armed =>
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
{
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
{
return;
}
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
if (index < PoolSlots.Length)
{
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
Console.Error.WriteLine(
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
}
}
public static void Check(ulong address, ReadOnlySpan<byte> data)
{
if (WatchBulkTorn &&
data.Length >= 8 &&
(BulkDestHigh != 0
? (address >> 32) == BulkDestHigh
: address >= DirectBandLow && address < DirectBandHigh))
{
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
{
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
var kind = ClassifyBulkValue(qword);
if (kind is not null && ReserveBulkReport(kind))
{
Console.Error.WriteLine(
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
Console.Error.Flush();
return;
}
}
}
if (WatchValue1 &&
address >= DirectBandLow && address < DirectBandHigh &&
data.Length is >= 1 and <= 8 &&
LittleEndianValue(data) == 1 &&
Interlocked.Increment(ref _value1Reports) <= 128)
{
Report(address, data);
return;
}
if (WatchValuePattern && data.Length == 8)
{
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
{
Report(address, data);
return;
}
}
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
{
Report(address, data);
return;
}
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
for (var i = 0; i < slots; i++)
{
var slot = Volatile.Read(ref PoolSlots[i]);
if (slot != 0 && Overlaps(address, data.Length, slot))
{
Report(address, data);
return;
}
}
}
internal static string? ClassifyBulkValue(ulong qword)
{
var low32 = qword & 0xFFFFFFFF;
var high32 = qword >> 32;
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
{
return "torn";
}
var prefix = low32 & 0xFF00_0000;
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
? "shift"
: null;
}
internal static int FirstAlignedOffset(ulong address) =>
(int)((8 - (address & 7)) & 7);
internal static bool IsPoolMapping(ulong length, int protection) =>
length == 0x10000 && protection == 0xF2;
internal static bool Overlaps(ulong address, int length, ulong slot)
{
if (length <= 0)
{
return false;
}
var writeLength = (ulong)length - 1;
var writeEnd = address > ulong.MaxValue - writeLength
? ulong.MaxValue
: address + writeLength;
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
? ulong.MaxValue
: slot + WatchBytes - 1;
return address <= slotEnd && slot <= writeEnd;
}
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
{
ulong value = 0;
for (var i = 0; i < data.Length; i++)
{
value |= (ulong)data[i] << (i * 8);
}
return value;
}
private static void Report(ulong address, ReadOnlySpan<byte> data)
{
Console.Error.WriteLine(
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
Console.Error.Flush();
}
private static bool IsEnabled(string name) =>
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
private static bool ReserveBulkReport(string kind) =>
kind == "torn"
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
internal static ulong Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return 0;
}
text = text.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
text = text[2..];
}
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
? value
: 0;
}
}
+2
View File
@@ -10,4 +10,6 @@ public interface ICpuMemory
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
}
+11
View File
@@ -15,6 +15,17 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
{
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
/// <summary>
/// Backs an entire fixed-address range, matching the guest's
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
/// reserves the range in one all-or-nothing host call, this walks the range and
/// fills only the sub-ranges that are not already backed. That keeps a fixed
/// mapping whole when part of the requested window is already occupied — the
/// partial-overlap case where the single-call reservation fails outright and
/// leaves the remainder unmapped for the guest to fault into.
/// </summary>
bool TryBackFixedRange(ulong address, ulong size, bool executable);
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
+44
View File
@@ -0,0 +1,44 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm;
public static class AcmExports
{
private static int _nextContextHandle;
[SysAbiExport(
Nid = "ZIXln2K3XMk",
ExportName = "sceAcmContextCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmContextCreate(CpuContext ctx)
{
var outContextAddress = ctx[CpuRegister.Rdi];
if (outContextAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "jBgBjAj02R8",
ExportName = "sceAcmContextDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx)
{
_ = ctx;
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
}
File diff suppressed because it is too large Load Diff
+507 -38
View File
@@ -1,8 +1,60 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace SharpEmu.Libs.Agc;
/// <summary>Which in-block address equation a <see cref="DetileParams"/> carries.</summary>
internal enum DetileEquation
{
/// <summary>Unsupported mode/format; caller must use the CPU path or raw upload.</summary>
None,
/// <summary>Exact AddrLib XOR equation (RDNA2 modes 5/9/24/27): factored X/Y terms.</summary>
ExactXor,
/// <summary>Other modes: a precomputed in-block Morton/standard element-offset table.</summary>
BlockTable,
}
/// <summary>
/// Backend-agnostic description of how to deswizzle one surface, produced by
/// <see cref="GnmTiling.GetDetileParams"/>. Holds only plain integers and small
/// int[] tables — no host graphics-API types — so it can cross the guest-GPU
/// backend seam and drive a Vulkan (SPIR-V) or Metal (MSL) detile compute kernel
/// identically to the CPU <see cref="GnmTiling.TryDetile"/> fallback. The single
/// shared addressing formula both consume is:
/// <code>
/// inBlockByte = Equation == ExactXor
/// ? XByteTerm[x &amp; XMask] ^ YByteTerm[y &amp; YMask]
/// : BlockTable[(y % BlockHeight) * BlockWidth + (x % BlockWidth)] * BytesPerElement;
/// srcByte = ((y / BlockHeight) * BlocksPerRow + (x / BlockWidth)) * BlockBytes + inBlockByte;
/// </code>
/// </summary>
internal readonly record struct DetileParams(
DetileEquation Equation,
int ElementsWide,
int ElementsHigh,
int BytesPerElement,
int BlockWidth,
int BlockHeight,
int BlockElements,
int BlockBytes,
int BlocksPerRow,
// ExactXor: within-block BYTE offset = XByteTerm[x & XMask] ^ YByteTerm[y & YMask].
int[] XByteTerm,
int XMask,
int[] YByteTerm,
int YMask,
// BlockTable: within-block ELEMENT offset = BlockTable[inBlockY * BlockWidth + inBlockX].
int[] BlockTable)
{
/// <summary>False when the mode/format is not GPU-portable (Equation == None).</summary>
public bool IsSupported => Equation != DetileEquation.None;
}
/// <summary>
/// Deswizzles RDNA2 (GFX10) tiled texture surfaces into linear layout so they
/// can be uploaded to Vulkan. PS5 stores most textures in a swizzled layout
@@ -16,8 +68,11 @@ namespace SharpEmu.Libs.Agc;
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
/// equations are being ported.
/// </summary>
internal static class GnmTiling
internal static unsafe class GnmTiling
{
private const int ParallelDetileElementThreshold = 512 * 512;
private const int MaxDetileWorkers = 4;
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
// topology. Each entry describes one address bit as an XOR of X/Y bits.
@@ -118,6 +173,14 @@ internal static class GnmTiling
StringComparison.Ordinal);
private static readonly HashSet<uint> _reportedModes = new();
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
_patternTermCache = new();
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
_blockTableCache = new();
private static readonly ParallelOptions _parallelDetileOptions = new()
{
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
};
public static bool Enabled => _enabled || !_disabled;
@@ -194,6 +257,164 @@ internal static class GnmTiling
}
}
public static bool TryGetBlockElementDimensions(
uint swizzleMode,
int bytesPerElement,
out int blockWidth,
out int blockHeight)
{
blockWidth = 0;
blockHeight = 0;
if (bytesPerElement <= 0 ||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
{
return false;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return false;
}
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
return blockWidth != 0 && blockHeight != 0;
}
/// <summary>
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
/// </summary>
public static bool TryGetBaseMipPlacement(
uint swizzleMode,
int elementsWide,
int elementsHigh,
int bytesPerElement,
uint resourceMipLevels,
out ulong byteOffset,
out bool inMipTail,
out int tailElementX,
out int tailElementY,
out ulong chainSliceBytes)
{
byteOffset = 0;
inMipTail = false;
tailElementX = 0;
tailElementY = 0;
chainSliceBytes = 0;
if (resourceMipLevels <= 1 ||
!ShouldDetile(swizzleMode) ||
elementsWide <= 0 ||
elementsHigh <= 0 ||
bytesPerElement <= 0 ||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
{
return false;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return false;
}
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
var blockSizeLog2 = BitLog2((uint)blockBytes);
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
{
return false;
}
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
: blockSizeLog2 <= 11
? 1 + (1 << (blockSizeLog2 - 9))
: blockSizeLog2 - 4;
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
var firstMipInTail = mipLevels;
var mipSizes = new ulong[mipLevels];
for (var i = 0; i < mipLevels; i++)
{
var mipWidth = Math.Max(elementsWide >> i, 1);
var mipHeight = Math.Max(elementsHigh >> i, 1);
if (maxMipsInTail > 0 &&
mipWidth <= tailWidth &&
mipHeight <= tailHeight &&
mipLevels - i <= maxMipsInTail)
{
firstMipInTail = i;
break;
}
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
}
if (firstMipInTail == 0)
{
var m = maxMipsInTail - 1;
var mipOffset = m > 6 ? 16 << m : m << 8;
var mipX = ((mipOffset >> 9) & 1) |
((mipOffset >> 10) & 2) |
((mipOffset >> 11) & 4) |
((mipOffset >> 12) & 8) |
((mipOffset >> 13) & 16) |
((mipOffset >> 14) & 32);
var mipY = ((mipOffset >> 8) & 1) |
((mipOffset >> 9) & 2) |
((mipOffset >> 10) & 4) |
((mipOffset >> 11) & 8) |
((mipOffset >> 12) & 16) |
((mipOffset >> 13) & 32);
if ((blockSizeLog2 & 1) != 0)
{
(mipX, mipY) = (mipY, mipX);
if ((bppLog2 & 1) != 0)
{
mipY = (mipY << 1) | (mipX & 1);
mipX >>= 1;
}
}
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
if (microWidth == 0 || microHeight == 0)
{
return false;
}
tailElementX = mipX * microWidth;
tailElementY = mipY * microHeight;
if (tailElementX + elementsWide > blockWidth ||
tailElementY + elementsHigh > blockHeight)
{
tailElementX = 0;
tailElementY = 0;
return false;
}
inMipTail = true;
chainSliceBytes = (ulong)blockBytes;
return true;
}
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
chainSliceBytes = byteOffset;
for (var i = firstMipInTail - 1; i >= 1; i--)
{
byteOffset += mipSizes[i];
}
for (var i = 0; i < firstMipInTail; i++)
{
chainSliceBytes += mipSizes[i];
}
return true;
}
/// <summary>
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
/// Elements are pixels for uncompressed formats and 4x4 blocks for
@@ -246,50 +467,218 @@ internal static class GnmTiling
return false;
}
// Precompute the within-block element offset for each (x, y) inside a
// single block. The swizzle equation only depends on the in-block
// coordinates, so this table is reused for every block — turning the
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
// Detiling a 2048x2048 texture is millions of elements; without this the
// per-pixel math makes DETILE unusably slow during asset streaming.
// Address tables depend only on the swizzle equation and element size,
// so retain them across textures instead of rebuilding them per upload.
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
var patternTerms = hasExactXorPattern
? _patternTermCache.GetOrAdd(
(swizzleMode, bppLog2),
_ => CreatePatternTerms(xorPattern))
: default;
var blockTable = hasExactXorPattern
? []
: _blockTableCache.GetOrAdd(
(kind, blockWidth, blockHeight),
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
// The XOR equation offset factors cleanly into independent X and Y
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
// Exact equations repeat at a small power-of-two period. Cached axis
// terms reduce the inner loop to two array loads and one XOR.
fixed (byte* tiledPointer = tiled)
fixed (byte* linearPointer = linear)
{
for (var bx = 0; bx < blockWidth; bx++)
var sourceAddress = (nint)tiledPointer;
var destinationAddress = (nint)linearPointer;
var sourceLength = tiled.Length;
var destinationLength = linear.Length;
var blockWidthShift = BitLog2((uint)blockWidth);
var blockWidthMask = blockWidth - 1;
var detileRow = (int y) =>
{
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
var blockY = y / blockHeight;
var inBlockY = y & (blockHeight - 1);
var rowBlockBase = (long)blockY * blocksPerRow;
var tableRowBase = inBlockY * blockWidth;
var destRowBase = (long)y * elementsWide * bytesPerElement;
var yTerm = hasExactXorPattern
? patternTerms.Y[y & patternTerms.YMask]
: 0;
for (var x = 0; x < elementsWide; x++)
{
var blockX = x >> blockWidthShift;
var inBlockX = x & blockWidthMask;
var blockIndex = rowBlockBase + blockX;
var sourceByte = hasExactXorPattern
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
(long)bytesPerElement;
var destByte = destRowBase + (long)x * bytesPerElement;
if (sourceByte < 0 ||
sourceByte + bytesPerElement > sourceLength ||
destByte + bytesPerElement > destinationLength)
{
continue;
}
CopyElement(
(byte*)sourceAddress + sourceByte,
(byte*)destinationAddress + destByte,
bytesPerElement);
}
};
var elementCount = (long)elementsWide * elementsHigh;
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
{
Parallel.For(
0,
elementsHigh,
_parallelDetileOptions,
detileRow);
}
else
{
for (var y = 0; y < elementsHigh; y++)
{
detileRow(y);
}
}
}
for (var y = 0; y < elementsHigh; y++)
{
var blockY = y / blockHeight;
var inBlockY = y % blockHeight;
var rowBlockBase = (long)blockY * blocksPerRow;
var tableRowBase = inBlockY * blockWidth;
var destRowBase = (long)y * elementsWide * bytesPerElement;
for (var x = 0; x < elementsWide; x++)
{
var blockX = x / blockWidth;
var inBlockX = x % blockWidth;
return true;
}
var blockIndex = rowBlockBase + blockX;
var sourceByte = hasExactXorPattern
? blockIndex * blockBytes + ComputePatternOffset((uint)x, (uint)y, xorPattern)
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
(long)bytesPerElement;
var destByte = destRowBase + (long)x * bytesPerElement;
if (sourceByte + bytesPerElement > tiled.Length ||
destByte + bytesPerElement > linear.Length)
/// <summary>
/// Computes the detile parameters for a surface without performing the copy,
/// so a GPU compute kernel can run the deswizzle instead of the CPU. Returns
/// <see cref="DetileParams.IsSupported"/> == false (Equation == None) when the
/// mode/format is not GPU-portable, so the caller keeps the CPU
/// <see cref="TryDetile"/> path or a raw upload. Reuses the same helpers and
/// caches as <see cref="TryDetile"/>, so the two never disagree on addressing.
/// </summary>
public static DetileParams GetDetileParams(
uint swizzleMode,
int bytesPerElement,
int elementsWide,
int elementsHigh)
{
if (!ShouldDetile(swizzleMode) ||
bytesPerElement <= 0 ||
elementsWide <= 0 ||
elementsHigh <= 0 ||
!TryGetSwizzleKind(swizzleMode, out var kind, out var blockBytes))
{
return default;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return default;
}
var blockElements = blockBytes >> bppLog2;
var (blockWidth, blockHeight) = SquareBlockDimensions(blockElements);
if (blockWidth == 0 || blockHeight == 0)
{
return default;
}
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
if (TryGetExactXorPattern(swizzleMode, bppLog2, out var pattern))
{
var terms = _patternTermCache.GetOrAdd(
(swizzleMode, bppLog2),
_ => CreatePatternTerms(pattern));
return new DetileParams(
DetileEquation.ExactXor,
elementsWide,
elementsHigh,
bytesPerElement,
blockWidth,
blockHeight,
blockElements,
blockBytes,
blocksPerRow,
terms.X,
terms.XMask,
terms.Y,
terms.YMask,
[]);
}
var blockTable = _blockTableCache.GetOrAdd(
(kind, blockWidth, blockHeight),
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
return new DetileParams(
DetileEquation.BlockTable,
elementsWide,
elementsHigh,
bytesPerElement,
blockWidth,
blockHeight,
blockElements,
blockBytes,
blocksPerRow,
[],
0,
[],
0,
blockTable);
}
/// <summary>
/// CPU deswizzle driven entirely by a resolved <see cref="DetileParams"/> — the
/// exact addressing the Vulkan/Metal compute kernel runs per texel, so a
/// backend that packaged <paramref name="parameters"/> for the GPU path can
/// fall back to this without re-deriving the swizzle. Copies
/// <c>ElementsWide * ElementsHigh</c> elements from <paramref name="tiled"/>
/// into <paramref name="linear"/>; returns false when unsupported or the output
/// span is too small. Out-of-range source elements are left zero (matching the
/// reference), so a truncated <paramref name="tiled"/> degrades gracefully.
/// </summary>
public static bool DetileWithParams(
in DetileParams parameters,
ReadOnlySpan<byte> tiled,
Span<byte> linear)
{
if (!parameters.IsSupported)
{
return false;
}
var width = parameters.ElementsWide;
var height = parameters.ElementsHigh;
var bpp = parameters.BytesPerElement;
var requiredLinear = (long)width * height * bpp;
if (width <= 0 || height <= 0 || bpp <= 0 || linear.Length < requiredLinear)
{
return false;
}
var isExactXor = parameters.Equation == DetileEquation.ExactXor;
for (var y = 0; y < height; y++)
{
var blockY = y / parameters.BlockHeight;
var inY = y % parameters.BlockHeight;
var yTerm = isExactXor ? parameters.YByteTerm[y & parameters.YMask] : 0;
for (var x = 0; x < width; x++)
{
var blockX = x / parameters.BlockWidth;
var inBlockByte = isExactXor
? parameters.XByteTerm[x & parameters.XMask] ^ yTerm
: parameters.BlockTable[inY * parameters.BlockWidth + (x % parameters.BlockWidth)] * bpp;
var srcByte = ((long)blockY * parameters.BlocksPerRow + blockX) * parameters.BlockBytes + inBlockByte;
var dstByte = ((long)y * width + x) * bpp;
if (srcByte < 0 || srcByte + bpp > tiled.Length)
{
continue;
}
tiled.Slice((int)sourceByte, bytesPerElement)
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
tiled.Slice((int)srcByte, bpp).CopyTo(linear.Slice((int)dstByte, bpp));
}
}
@@ -302,6 +691,80 @@ internal static class GnmTiling
ZOrder,
}
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
{
uint xMask = 0;
uint yMask = 0;
foreach (var bit in pattern)
{
xMask |= bit.XMask;
yMask |= bit.YMask;
}
var xLength = AxisTermPeriod(xMask);
var yLength = AxisTermPeriod(yMask);
var xTerms = new int[xLength];
var yTerms = new int[yLength];
for (var x = 0; x < xTerms.Length; x++)
{
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
}
for (var y = 0; y < yTerms.Length; y++)
{
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
}
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
}
private static int AxisTermPeriod(uint mask) =>
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
{
var table = new int[blockWidth * blockHeight];
for (var y = 0; y < blockHeight; y++)
{
for (var x = 0; x < blockWidth; x++)
{
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
}
}
return table;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
{
switch (bytesPerElement)
{
case 1:
*destination = *source;
break;
case 2:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
break;
case 4:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
break;
case 8:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
break;
case 16:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
break;
default:
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
break;
}
}
private static readonly AddressBit Zero = new(0, 0);
private static AddressBit X(int bit) => new(1u << bit, 0);
@@ -335,14 +798,20 @@ internal static class GnmTiling
return pattern.Length != 0;
}
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
// The AddrLib within-block byte offset is a per-bit XOR equation:
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
// Because parity distributes over XOR, that whole offset factors into two
// independent axis terms: PatternAxisTerm(x, useX: true) ^
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
// the X term per column and hoist the Y term per row instead of recomputing
// the full 16-bit interleave (32 PopCounts) for every element.
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
{
uint offset = 0;
for (var bit = 0; bit < pattern.Length; bit++)
{
var equation = pattern[bit];
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
offset |= (uint)parity << bit;
}
+39
View File
@@ -343,6 +343,45 @@ internal static class GpuWaitRegistry
return expired;
}
public static List<WaitingDcb>? CollectAllForMemory(object memory)
{
List<WaitingDcb>? collected = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var index = list.Count - 1; index >= 0; index--)
{
if (!ReferenceEquals(list[index].Memory, memory))
{
continue;
}
collected ??= new List<WaitingDcb>();
collected.Add(list[index]);
list.RemoveAt(index);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return collected;
}
/// <summary>Records the value a label producer wrote, for the deadlock
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
public static bool RecordProduced(object memory, ulong address, ulong value)
+38
View File
@@ -340,6 +340,18 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "4fgtGfXDrFc",
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
Target = Generation.Gen5,
LibraryName = "libSceAmpr")]
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
{
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "tZDDEo2tE5k",
ExportName = "sceAmprCommandBufferGetSize",
@@ -509,6 +521,32 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "j0+3uJMxYJY",
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
Target = Generation.Gen5,
LibraryName = "libSceAmpr")]
public static int CommandBufferWriteAddress0400(CpuContext ctx)
{
var commandBuffer = ctx[CpuRegister.Rdi];
var address = ctx[CpuRegister.Rsi];
var value = ctx[CpuRegister.Rdx];
if (commandBuffer == 0 || address == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
{
if (commandBuffer == 0)
+242 -1
View File
@@ -17,10 +17,11 @@ public static class AjmExports
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
private const uint MaxCodecType = 23;
private const uint MaxCodecType = 25;
private const int MaxInstanceIndex = 0x2FFF;
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
private static int _nextContextId;
private static int _nextBatchId;
private sealed class AjmContextState
{
@@ -227,10 +228,250 @@ public static class AjmExports
return 0;
}
/// <summary>
/// Enqueues a decode job on a batch. Titles call this on the Bink/AJM hot
/// path; leaving it unresolved floods Import WARN spam. This is a silence
/// stub, not a codec: advance the batch cursor and report the input as
/// consumed with silence produced so the title does not spin on the same
/// packet.
/// </summary>
[SysAbiExport(
Nid = "39WxhR-ePew",
ExportName = "sceAjmBatchJobDecode",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchJobDecode(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdi];
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
var inputAddress = ctx[CpuRegister.Rdx];
var inputSize = ctx[CpuRegister.Rcx];
var outputAddress = ctx[CpuRegister.R8];
var outputSize = ctx[CpuRegister.R9];
var resultAddress = ReadStackArg64(ctx, 0);
if (infoAddress == 0)
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
// Best-effort: bump the batch cursor when the guest filled AjmBatchInfo.
// Still succeed without it — the unresolved stub returned 0 and titles
// keep calling; failing here would reintroduce hot-path spam via retries.
_ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
// Silence: clear PCM out and claim full input consumed so the guest
// advances its bitstream cursor instead of re-submitting forever.
if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
{
ClearGuestMemory(ctx, outputAddress, outputSize);
}
WriteDecodeStreamResult(
ctx,
resultAddress,
inputConsumed: inputSize > int.MaxValue ? int.MaxValue : (int)inputSize,
outputWritten: 0,
totalDecodedSamples: 0,
frames: inputSize != 0 || outputSize != 0 ? 1u : 0u);
Trace(
$"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " +
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " +
$"result=0x{resultAddress:X16}");
return ctx.SetReturn(0);
}
/// <summary>
/// Submits a built batch. Instant-complete silence stub: publish a batch id
/// and clear any error out. Decode sidebands were already filled at
/// job-enqueue time.
/// </summary>
[SysAbiExport(
Nid = "5tOfnaClcqM",
ExportName = "sceAjmBatchStart",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchStart(CpuContext ctx)
{
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
var infoAddress = ctx[CpuRegister.Rsi];
var priority = unchecked((int)ctx[CpuRegister.Rdx]);
var errorAddress = ctx[CpuRegister.Rcx];
var batchOutAddress = ctx[CpuRegister.R8];
if (infoAddress == 0 || batchOutAddress == 0)
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
ClearAjmBatchError(ctx, errorAddress);
var batchId = unchecked((uint)Interlocked.Increment(ref _nextBatchId));
Span<byte> batchValue = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(batchValue, batchId);
if (!ctx.Memory.TryWrite(batchOutAddress, batchValue))
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
Trace(
$"batch_start context={contextId} info=0x{infoAddress:X16} " +
$"priority={priority} batch={batchId} error=0x{errorAddress:X16}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "-qLsfDAywIY",
ExportName = "sceAjmBatchWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchWait(CpuContext ctx)
{
// Batches complete synchronously in Start; Wait is a no-op success.
var errorAddress = ctx[CpuRegister.Rcx];
ClearAjmBatchError(ctx, errorAddress);
Trace(
$"batch_wait context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])} " +
$"timeout={unchecked((uint)ctx[CpuRegister.Rdx])}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "NVDXiUesSbA",
ExportName = "sceAjmBatchCancel",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchCancel(CpuContext ctx)
{
Trace(
$"batch_cancel context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])}");
return ctx.SetReturn(0);
}
internal static void ResetForTests()
{
Contexts.Clear();
Interlocked.Exchange(ref _nextContextId, 0);
Interlocked.Exchange(ref _nextBatchId, 0);
}
// AjmBatchInfo: buffer, offset, size, last_good_job, last_good_job_ra (5× u64).
private const ulong AjmBatchInfoOffsetField = 8;
private const ulong AjmBatchInfoSizeField = 16;
private const ulong AjmBatchInfoLastGoodJobField = 24;
private const ulong AjmJobRunSize = 64;
private const ulong MaxSilentPcmBytes = 1 << 20;
// AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8).
private const int DecodeSidebandBytes = 32;
private static bool TryAppendBatchJob(CpuContext ctx, ulong infoAddress, ulong jobSize)
{
if (!TryReadUInt64(ctx, infoAddress, out var buffer) ||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, out var offset) ||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoSizeField, out var size))
{
return false;
}
if (buffer == 0 || jobSize == 0 || offset > size || size - offset < jobSize)
{
return false;
}
var jobAddress = buffer + offset;
ClearGuestMemory(ctx, jobAddress, jobSize);
return TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobField, jobAddress) &&
TryWriteUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, offset + jobSize);
}
// AjmBatchError: int error_code; const void* job_addr; uint32_t cmd_offset; const void* job_ra;
private const int AjmBatchErrorBytes = 24;
private static void ClearAjmBatchError(CpuContext ctx, ulong errorAddress)
{
if (errorAddress == 0)
{
return;
}
Span<byte> error = stackalloc byte[AjmBatchErrorBytes];
error.Clear();
_ = ctx.Memory.TryWrite(errorAddress, error);
}
private static void WriteDecodeStreamResult(
CpuContext ctx,
ulong resultAddress,
int inputConsumed,
int outputWritten,
ulong totalDecodedSamples,
uint frames)
{
if (resultAddress == 0)
{
return;
}
Span<byte> sideband = stackalloc byte[DecodeSidebandBytes];
sideband.Clear();
// AjmSidebandResult.result / internal_result = 0 (OK)
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(8, 4), inputConsumed);
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(12, 4), outputWritten);
BinaryPrimitives.WriteUInt64LittleEndian(sideband.Slice(16, 8), totalDecodedSamples);
BinaryPrimitives.WriteUInt32LittleEndian(sideband.Slice(24, 4), frames);
_ = ctx.Memory.TryWrite(resultAddress, sideband);
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
{
if (address == 0 || byteCount == 0)
{
return;
}
var remaining = byteCount;
var cursor = address;
Span<byte> zero = stackalloc byte[256];
while (remaining > 0)
{
var chunk = (int)Math.Min(remaining, (ulong)zero.Length);
if (!ctx.Memory.TryWrite(cursor, zero[..chunk]))
{
return;
}
cursor += (ulong)chunk;
remaining -= (ulong)chunk;
}
}
private static ulong ReadStackArg64(CpuContext ctx, int index)
{
var address = ctx[CpuRegister.Rsp] + sizeof(ulong) + ((ulong)index * sizeof(ulong));
return TryReadUInt64(ctx, address, out var value) ? value : 0;
}
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt64LittleEndian(buffer);
return true;
}
private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
return ctx.Memory.TryWrite(address, buffer);
}
private static void Trace(string message)
+279 -14
View File
@@ -4,6 +4,7 @@
using SharpEmu.HLE;
using SharpEmu.HLE.Host;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics;
@@ -11,8 +12,17 @@ namespace SharpEmu.Libs.Audio;
public static class AudioOutExports
{
private const int AudioOutOutputParamSize = 16;
private const int AudioOutMaximumOutputCount = 25;
internal const int AudioOutErrorInvalidPort = unchecked((int)0x80260003);
internal const int AudioOutErrorInvalidPointer = unchecked((int)0x80260004);
internal const int AudioOutErrorPortFull = unchecked((int)0x80260005);
internal const int AudioOutErrorInvalidSize = unchecked((int)0x80260006);
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
private static int _nextPortHandle;
private static Func<uint, IHostAudioStream?>? _streamFactoryForTests;
// Diagnostic: confirm sceAudioOutOutput is actually called and whether the
// guest submits real samples or silence. Gated so it costs nothing when off.
@@ -56,6 +66,7 @@ public static class AudioOutExports
public int BytesPerSample { get; }
public bool IsFloat { get; }
public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f;
public int BufferByteLength =>
checked((int)BufferLength * Channels * BytesPerSample);
@@ -83,7 +94,24 @@ public static class AudioOutExports
}
}
public void Dispose() => Backend?.Dispose();
public void Dispose()
{
lock (SubmissionGate)
{
Backend?.Dispose();
}
}
}
private readonly record struct OutputDescriptor(int Handle, ulong SourceAddress);
private struct ResolvedOutput
{
public int Handle;
public ulong SourceAddress;
public PortState Port;
public byte[]? HostBuffer;
public int HostBufferLength;
}
[SysAbiExport(
@@ -115,9 +143,18 @@ public static class AudioOutExports
string backendName;
try
{
var audio = HostPlatform.Current.Audio;
backend = audio.OpenStereoPcm16Stream(frequency);
backendName = audio.BackendName;
var streamFactory = Volatile.Read(ref _streamFactoryForTests);
if (streamFactory is not null)
{
backend = streamFactory(frequency);
backendName = "test";
}
else
{
var audio = HostPlatform.Current.Audio;
backend = audio.OpenStereoPcm16Stream(frequency);
backendName = audio.BackendName;
}
}
catch (Exception exception)
{
@@ -192,6 +229,46 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "w3PdaSTSwGE",
ExportName = "sceAudioOutOutputs",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAudioOut")]
public static int AudioOutOutputs(CpuContext ctx)
{
var parameterAddress = ctx[CpuRegister.Rdi];
var outputCount = unchecked((uint)ctx[CpuRegister.Rsi]);
if (outputCount == 0 || outputCount > AudioOutMaximumOutputCount)
{
return ctx.SetReturn(AudioOutErrorPortFull);
}
if (parameterAddress == 0)
{
return ctx.SetReturn(AudioOutErrorInvalidPointer);
}
var count = checked((int)outputCount);
Span<byte> parameterBytes =
stackalloc byte[AudioOutMaximumOutputCount * AudioOutOutputParamSize];
parameterBytes = parameterBytes[..checked(count * AudioOutOutputParamSize)];
if (!ctx.Memory.TryRead(parameterAddress, parameterBytes))
{
return ctx.SetReturn(AudioOutErrorInvalidPointer);
}
Span<OutputDescriptor> descriptors = stackalloc OutputDescriptor[count];
for (var i = 0; i < count; i++)
{
var entry = parameterBytes.Slice(i * AudioOutOutputParamSize, AudioOutOutputParamSize);
descriptors[i] = new OutputDescriptor(
BinaryPrimitives.ReadInt32LittleEndian(entry),
BinaryPrimitives.ReadUInt64LittleEndian(entry[8..]));
}
return ctx.SetReturn(SubmitOutputs(ctx, descriptors));
}
[SysAbiExport(
Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput",
@@ -225,16 +302,7 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (_traceOutput)
{
var n = Interlocked.Increment(ref _outputCount);
if (n <= 8 || n % 200 == 0)
{
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
Console.Error.WriteLine(
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
}
}
TraceOutput(handle, port, source);
if (port.Backend is null)
{
@@ -272,6 +340,184 @@ public static class AudioOutExports
}
}
private static int SubmitOutputs(CpuContext ctx, ReadOnlySpan<OutputDescriptor> descriptors)
{
var resolvedArray = ArrayPool<ResolvedOutput>.Shared.Rent(descriptors.Length);
var resolved = resolvedArray.AsSpan(0, descriptors.Length);
resolved.Clear();
Span<int> lockOrder = stackalloc int[descriptors.Length];
var acquiredLocks = 0;
try
{
uint bufferLength = 0;
for (var i = 0; i < descriptors.Length; i++)
{
var descriptor = descriptors[i];
for (var previous = 0; previous < i; previous++)
{
if (resolved[previous].Handle == descriptor.Handle)
{
return AudioOutErrorInvalidPort;
}
}
if (!Ports.TryGetValue(descriptor.Handle, out var port))
{
return _shutdown ? 0 : AudioOutErrorInvalidPort;
}
if (i == 0)
{
bufferLength = port.BufferLength;
}
else if (port.BufferLength != bufferLength)
{
return AudioOutErrorInvalidSize;
}
resolved[i].Handle = descriptor.Handle;
resolved[i].SourceAddress = descriptor.SourceAddress;
resolved[i].Port = port;
lockOrder[i] = i;
}
// Every batch takes port locks in handle order. Two guest threads can
// submit overlapping batches in a different descriptor order without
// deadlocking each other.
for (var i = 1; i < lockOrder.Length; i++)
{
var index = lockOrder[i];
var position = i;
while (position > 0 &&
resolved[lockOrder[position - 1]].Handle > resolved[index].Handle)
{
lockOrder[position] = lockOrder[position - 1];
position--;
}
lockOrder[position] = index;
}
for (; acquiredLocks < lockOrder.Length; acquiredLocks++)
{
Monitor.Enter(resolved[lockOrder[acquiredLocks]].Port.SubmissionGate);
}
// AudioOutClose removes the handle before waiting for SubmissionGate.
// Recheck after acquiring all gates so a close racing this batch cannot
// turn a validated submission into a write to a disposed backend.
for (var i = 0; i < resolved.Length; i++)
{
if (!Ports.TryGetValue(resolved[i].Handle, out var current) ||
!ReferenceEquals(current, resolved[i].Port))
{
return _shutdown ? 0 : AudioOutErrorInvalidPort;
}
}
// Stage every guest buffer before the first host submission. A bad
// pointer in a later descriptor therefore cannot partially enqueue the
// earlier ports.
for (var i = 0; i < resolved.Length; i++)
{
ref var output = ref resolved[i];
if (output.SourceAddress == 0)
{
continue;
}
var sourceBuffer = ArrayPool<byte>.Shared.Rent(output.Port.BufferByteLength);
try
{
var source = sourceBuffer.AsSpan(0, output.Port.BufferByteLength);
if (!ctx.Memory.TryRead(output.SourceAddress, source))
{
return AudioOutErrorInvalidPointer;
}
TraceOutput(output.Handle, output.Port, source);
output.HostBufferLength = checked(
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength);
AudioPcmConversion.ConvertToStereoPcm16(
source,
output.HostBuffer.AsSpan(0, output.HostBufferLength),
checked((int)output.Port.BufferLength),
output.Port.Channels,
output.Port.BytesPerSample,
output.Port.IsFloat,
output.Port.Volume);
}
finally
{
ArrayPool<byte>.Shared.Return(sourceBuffer);
}
}
PortState? pacingPort = null;
for (var i = 0; i < resolved.Length; i++)
{
ref var output = ref resolved[i];
if (output.HostBuffer is null ||
output.Port.Backend is null ||
!output.Port.Backend.Submit(
output.HostBuffer.AsSpan(0, output.HostBufferLength)))
{
if (pacingPort is null ||
HasLongerBufferDuration(output.Port, pacingPort))
{
pacingPort = output.Port;
}
}
}
// A batch is one guest scheduling point. When one or more ports have
// no usable backend, pace once using the longest affected buffer rather
// than sleeping once per port.
pacingPort?.PaceSilence();
return checked((int)resolved[0].Port.BufferLength);
}
finally
{
for (var i = acquiredLocks - 1; i >= 0; i--)
{
Monitor.Exit(resolved[lockOrder[i]].Port.SubmissionGate);
}
for (var i = 0; i < resolved.Length; i++)
{
if (resolved[i].HostBuffer is { } hostBuffer)
{
ArrayPool<byte>.Shared.Return(hostBuffer);
}
}
ArrayPool<ResolvedOutput>.Shared.Return(resolvedArray, clearArray: true);
}
}
private static bool HasLongerBufferDuration(PortState candidate, PortState current) =>
(ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency;
private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
{
if (!_traceOutput)
{
return;
}
var n = Interlocked.Increment(ref _outputCount);
if (n <= 8 || n % 200 == 0)
{
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
Console.Error.WriteLine(
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
}
}
[SysAbiExport(
Nid = "b+uAV89IlxE",
ExportName = "sceAudioOutSetVolume",
@@ -362,6 +608,25 @@ public static class AudioOutExports
}
}
internal static void SetStreamFactoryForTests(Func<uint, IHostAudioStream?>? streamFactory) =>
Volatile.Write(ref _streamFactoryForTests, streamFactory);
internal static void ResetForTests()
{
foreach (var handle in Ports.Keys)
{
if (Ports.TryRemove(handle, out var port))
{
port.Dispose();
}
}
_nextPortHandle = 0;
_outputCount = 0;
Volatile.Write(ref _shutdown, false);
Volatile.Write(ref _streamFactoryForTests, null);
}
private static bool _shutdown;
private static bool TryGetFormat(
@@ -25,6 +25,10 @@ internal static class AudioPcmConversion
float volume)
{
var sourceFrameSize = checked(channels * bytesPerSample);
// Volume is constant for the whole submission, so clamp it once here
// rather than per sample inside the loop (this runs on every real-time
// audio buffer, hundreds of frames at a time).
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
for (var frame = 0; frame < frames; frame++)
{
var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize);
@@ -32,8 +36,8 @@ internal static class AudioPcmConversion
var right = channels == 1
? left
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
left = ApplyVolume(left, volume);
right = ApplyVolume(right, volume);
left = ApplyVolume(left, clampedVolume);
right = ApplyVolume(right, clampedVolume);
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
}
@@ -67,9 +71,10 @@ internal static class AudioPcmConversion
return checked((short)MathF.Round(value * scale));
}
// <paramref name="volume"/> is expected pre-clamped to [0, 1] by the caller.
private static short ApplyVolume(short sample, float volume)
{
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
var scaled = MathF.Round(sample * volume);
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
}
}
+107 -11
View File
@@ -17,7 +17,9 @@ public static class AvPlayerExports
private const int FrameBufferCount = 3;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
private const int StreamInfoSize = 40;
// This structure is 32 bytes. A larger write can damage the guest stack.
private const int StreamInfoSize = 32;
private const int StreamInfoExSize = 32;
private const int MaxGuestPathLength = 4096;
private static readonly object StateGate = new();
private static readonly Dictionary<ulong, PlayerState> Players = new();
@@ -404,7 +406,8 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerGetStreamInfoEx",
Target = Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoExSize);
[SysAbiExport(
Nid = "XC9wM+xULz8",
@@ -561,12 +564,48 @@ public static class AvPlayerExports
}
}
internal static void RegisterPlayerForTest(
ulong handle,
int width,
int height,
ulong durationMilliseconds)
{
PlayerState? previous;
lock (StateGate)
{
Players.Remove(handle, out previous);
Players[handle] = new PlayerState
{
Handle = handle,
Width = width,
Height = height,
DurationMilliseconds = durationMilliseconds,
};
}
previous?.Dispose();
}
internal static void RemovePlayerForTest(ulong handle)
{
PlayerState? player;
lock (StateGate)
{
Players.Remove(handle, out player);
}
player?.Dispose();
}
[SysAbiExport(
Nid = "d8FcbzfAdQw",
ExportName = "sceAvPlayerGetStreamInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfo(CpuContext ctx)
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoSize);
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
{
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoAddress = ctx[CpuRegister.Rdx];
@@ -578,7 +617,7 @@ public static class AvPlayerExports
return SetReturn(ctx, InvalidParameters);
}
Span<byte> info = stackalloc byte[StreamInfoSize];
Span<byte> info = stackalloc byte[infoSize];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
if (streamIndex == 0)
@@ -1009,7 +1048,7 @@ public static class AvPlayerExports
{
return false;
}
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
if (!File.Exists(ffprobe))
{
return false;
@@ -1092,13 +1131,50 @@ public static class AvPlayerExports
}
}
private static string? FindFfmpeg()
internal static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows,
string? baseDirectory = null)
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
if (!string.IsNullOrWhiteSpace(baseDirectory))
{
foreach (var candidate in new[]
{
Path.Combine(baseDirectory, executable),
Path.Combine(baseDirectory, "ffmpeg", executable),
})
{
if (File.Exists(candidate))
{
return candidate;
}
}
}
foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
if (File.Exists(candidate))
{
return candidate;
}
}
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
{
if (File.Exists(candidate))
@@ -1109,6 +1185,16 @@ public static class AvPlayerExports
return null;
}
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
Path.Combine(
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
isWindows ? "ffprobe.exe" : "ffprobe");
private static string RemovePathQuotes(string directory) =>
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
? directory[1..^1]
: directory;
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
@@ -1118,7 +1204,9 @@ public static class AvPlayerExports
var normalized = guestPath.Replace('\\', '/');
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
var unrealProjectRelative = false;
var unrealProjectRelative =
normalized.StartsWith("../", StringComparison.Ordinal) ||
normalized.StartsWith("./", StringComparison.Ordinal);
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
uri.IsFile)
@@ -1149,7 +1237,10 @@ public static class AvPlayerExports
if (unrealProjectRelative)
{
normalized = RemoveUnrealLeadingDotSegments(normalized);
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
{
return null;
}
}
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
@@ -1233,15 +1324,20 @@ public static class AvPlayerExports
}
}
private static string RemoveUnrealLeadingDotSegments(string guestPath)
private static bool TryRemoveUnrealLeadingDotSegments(
string guestPath,
out string normalized)
{
var removedParent = false;
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
guestPath.StartsWith("./", StringComparison.Ordinal))
{
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
}
return guestPath;
normalized = guestPath;
return !removedParent || guestPath.Contains('/');
}
private static bool TryDecodeFileReference(string encoded, out string decoded)
+406 -189
View File
@@ -18,15 +18,43 @@ namespace SharpEmu.Libs.Bink;
internal static class Bink2MovieBridge
{
private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
private static readonly object Gate = new();
private static NativeAdapter? _adapter;
private static string? _activePath;
private static IntPtr _activeMovie;
private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer;
private static bool _usingDummyMovie;
private static bool _loadAttempted;
private static bool _availabilityReported;
private static bool _frameBufferPresented;
private static BinkFramePlayback? _playback;
private static long _frameSerial;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
internal static bool IsHostPlaybackActive
{
get
{
lock (Gate)
{
return _playback is not null || _frameBuffer is not null;
}
}
}
internal static void SetPresentationSize(uint width, uint height)
{
if (width == 0 || height == 0)
{
return;
}
lock (Gate)
{
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
}
}
/// <summary>
/// Returns true only when movie skipping was explicitly requested. Without
@@ -37,109 +65,106 @@ internal static class Bink2MovieBridge
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
internal static void ObserveGuestMovie(string hostPath)
/// <summary>
/// Starts or queues host decoding. Decoded frames are only exposed as a
/// sampled guest texture; presentation and UI composition remain guest-owned.
/// </summary>
internal static bool ObserveGuestMovie(string hostPath)
{
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
{
return;
return false;
}
lock (Gate)
{
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
return;
return _playback is not null || _frameBuffer is not null;
}
var mode = ResolveMode();
if (mode == MovieMode.Dummy)
if (mode is MovieMode.Guest or MovieMode.Skip)
{
AttachDummyMovieLocked(hostPath);
return;
return false;
}
if (mode != MovieMode.Native)
if (_playback is not null || _frameBuffer is not null)
{
return;
if (PendingMoviePathSet.Add(hostPath))
{
PendingMoviePaths.Enqueue(hostPath);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge queued: " +
Path.GetFileName(hostPath));
}
return PendingMoviePathSet.Contains(hostPath);
}
var adapter = GetAdapterLocked();
if (adapter is null)
{
return;
}
CloseActiveLocked();
if (!adapter.TryOpen(hostPath, out var movie, out var info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
if (!IsValid(info))
{
adapter.Close(movie);
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
_activePath = hostPath;
_activeMovie = movie;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
AttachMovieLocked(hostPath, mode);
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
(_playback is not null || _frameBuffer is not null);
}
}
internal static bool TryDecodeNextFrame(
bool advanceClock,
out byte[] pixels,
out uint width,
out uint height)
out uint height,
out bool advanced,
out long frameSerial,
out string hostPath)
{
lock (Gate)
{
pixels = [];
width = 0;
height = 0;
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
advanced = false;
frameSerial = _frameSerial;
hostPath = _activePath ?? string.Empty;
if (_playback is not null)
{
if (_usingDummyMovie && _frameBuffer is not null)
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
{
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
return true;
if (_playback.IsFinished)
{
var completedPath = _activePath;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath));
AttachNextQueuedMovieLocked();
}
return false;
}
return false;
width = _activeInfo.Width;
height = _activeInfo.Height;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
}
unsafe
if (_frameBuffer is null)
{
fixed (byte* destination = _frameBuffer)
{
if (!_adapter.DecodeNextBgra(
_activeMovie,
(IntPtr)destination,
_activeInfo.Width * 4,
(uint)_frameBuffer.Length))
{
return false;
}
}
return false;
}
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
advanced = !_frameBufferPresented;
_frameBufferPresented = true;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
}
}
@@ -152,6 +177,52 @@ internal static class Bink2MovieBridge
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
checked((int)((ulong)info.Width * info.Height * 4));
private static void AttachMovieLocked(string hostPath, MovieMode mode)
{
switch (mode)
{
case MovieMode.Dummy:
AttachDummyMovieLocked(hostPath);
return;
case MovieMode.Ffmpeg:
AttachFfmpegMovieLocked(hostPath);
return;
case MovieMode.Native:
AttachNativeMovieLocked(hostPath);
return;
}
}
private static void AttachNativeMovieLocked(string hostPath)
{
if (!FfmpegNativeBinkFrameSource.TryOpen(
hostPath, _presentationWidth, _presentationHeight, out var source) ||
source is null)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
var info = new Bink2MovieInfo(
source.Width, source.Height, source.FramesPerSecondNumerator, source.FramesPerSecondDenominator);
if (!IsValid(info))
{
source.Dispose();
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static MovieMode ResolveMode()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
@@ -170,15 +241,22 @@ internal static class Bink2MovieBridge
return MovieMode.Skip;
}
// Prefer the optional host adapter when one is supplied. Otherwise let
// the game's statically linked Bink implementation consume the file.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists))
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Native;
return MovieMode.Guest;
}
return MovieMode.Guest;
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Ffmpeg;
}
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
// gracefully (falls back to the guest's own decode, logging one
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
// downloads next to the executable are genuinely unavailable, so
// defaulting to Native unconditionally is safe.
return MovieMode.Native;
}
private static void AttachDummyMovieLocked(string hostPath)
@@ -195,22 +273,61 @@ internal static class Bink2MovieBridge
_activePath = hostPath;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
_frameBufferPresented = false;
FillDummyFrame(_frameBuffer, info.Width, info.Height);
_usingDummyMovie = true;
Console.Error.WriteLine(
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + ".");
}
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
private static void AttachFfmpegMovieLocked(string hostPath)
{
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
Path.GetFileName(hostPath));
return;
}
if (!FfmpegBinkFrameSource.TryOpen(
hostPath,
info.Width,
info.Height,
info.FramesPerSecondNumerator,
info.FramesPerSecondDenominator,
out var source) || source is null)
{
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink FFmpeg source attached: " +
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static void AttachPlaybackLocked(
string hostPath,
Bink2MovieInfo info,
IBinkFrameDecoder decoder)
{
CloseActiveLocked();
_activePath = hostPath;
_activeInfo = info;
_playback = new BinkFramePlayback(decoder);
}
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
{
info = default;
Span<byte> header = stackalloc byte[32];
Span<byte> header = stackalloc byte[36];
try
{
using var stream = File.OpenRead(path);
if (stream.Read(header) != header.Length ||
!header[..4].SequenceEqual("KB2j"u8))
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
{
return false;
}
@@ -219,10 +336,11 @@ internal static class Bink2MovieBridge
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
1);
return true;
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
return info.FramesPerSecondNumerator != 0 &&
info.FramesPerSecondDenominator != 0;
}
catch (IOException)
catch (Exception exception) when (exception is IOException or EndOfStreamException)
{
return false;
}
@@ -244,80 +362,23 @@ internal static class Bink2MovieBridge
}
}
private static NativeAdapter? GetAdapterLocked()
{
if (_loadAttempted)
{
return _adapter;
}
_loadAttempted = true;
foreach (var candidate in EnumerateAdapterCandidates())
{
if (!NativeLibrary.TryLoad(candidate, out var library))
{
continue;
}
if (NativeAdapter.TryCreate(library, out var adapter))
{
_adapter = adapter;
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded: " + candidate);
return adapter;
}
NativeLibrary.Free(library);
}
if (!_availabilityReported)
{
_availabilityReported = true;
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge unavailable; install the licensed adapter and set SHARPEMU_BINK2_BRIDGE.");
}
return null;
}
private static IEnumerable<string> EnumerateAdapterCandidates()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE");
if (!string.IsNullOrWhiteSpace(configured))
{
yield return configured;
}
var baseDirectory = AppContext.BaseDirectory;
if (OperatingSystem.IsMacOS())
{
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.dylib");
}
else if (OperatingSystem.IsWindows())
{
yield return Path.Combine(baseDirectory, "sharpemu_bink2_bridge.dll");
}
else
{
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.so");
}
}
private static void CloseActiveLocked()
{
if (_activeMovie != IntPtr.Zero)
{
_adapter?.Close(_activeMovie);
}
_playback?.Dispose();
_playback = null;
_activePath = null;
_activeMovie = IntPtr.Zero;
_activeInfo = default;
_frameBuffer = null;
_usingDummyMovie = false;
_frameBufferPresented = false;
// Wake any guest _read() blocked in WaitForHostPlaybackToFinish: its
// movie either just finished or is being pre-empted by a new attach.
Monitor.PulseAll(Gate);
}
[StructLayout(LayoutKind.Sequential)]
private readonly struct Bink2MovieInfo
internal readonly struct Bink2MovieInfo
{
public readonly uint Width;
public readonly uint Height;
@@ -343,66 +404,222 @@ internal static class Bink2MovieBridge
Skip,
Dummy,
Native,
Ffmpeg,
}
private sealed class NativeAdapter
private static readonly Queue<string> PendingMoviePaths = new();
private static readonly HashSet<string> PendingMoviePathSet =
new(StringComparer.OrdinalIgnoreCase);
private static void AttachNextQueuedMovieLocked()
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CloseDelegate(IntPtr movie);
private readonly OpenUtf8Delegate _openUtf8;
private readonly DecodeNextBgraDelegate _decodeNextBgra;
private readonly CloseDelegate _close;
private NativeAdapter(
OpenUtf8Delegate openUtf8,
DecodeNextBgraDelegate decodeNextBgra,
CloseDelegate close)
while (PendingMoviePaths.Count > 0)
{
_openUtf8 = openUtf8;
_decodeNextBgra = decodeNextBgra;
_close = close;
var path = PendingMoviePaths.Dequeue();
PendingMoviePathSet.Remove(path);
if (!File.Exists(path))
{
continue;
}
AttachMovieLocked(path, ResolveMode());
if (_playback is not null || _frameBuffer is not null)
{
return;
}
}
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
}
// Longest a guest _read() will block waiting for real host playback to
// finish. A safety net, not a target: real movies finish well under
// this. Bounds the damage if a movie fails to attach/decode after being
// queued, so the guest thread doesn't hang forever.
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
/// <summary>
/// Blocks the calling (guest I/O) thread until the host has actually
/// finished presenting <paramref name="hostPath"/> — either because it
/// played through, or because something else took over the timeline.
///
/// The completion shim tells the guest's own Bink header parse "this
/// movie is one frame and already done" so its native decoder never
/// blocks the guest on real per-frame work. Without this wait, that lie
/// lands the instant the guest reads the header, so guest-side game
/// logic races far ahead of whatever the host is still showing on
/// screen: pressing a button lands on the (already-advanced) guest
/// state, but the video visibly keeps playing, and any real-time-gated
/// trigger later in the guest's own flow can fire against a clock that
/// no longer matches wall time. Gating the "done" read on real host
/// completion keeps guest pacing and on-screen playback in lockstep.
/// </summary>
internal static void WaitForHostPlaybackToFinish(string hostPath)
{
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
lock (Gate)
{
adapter = null;
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
while (IsTrackedLocked(hostPath))
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
}
}
}
private static bool IsTrackedLocked(string hostPath) =>
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
PendingMoviePathSet.Contains(hostPath);
internal static bool TryTakeOverGuestMovie(
string hostPath,
out BinkGuestCompletionShim completionShim,
out bool observed)
{
completionShim = default;
observed = ObserveGuestMovie(hostPath);
// Keep the real header visible so the guest creates its movie surface
// and draw. Host-decoded pixels replace that sampled image later; a
// one-frame completion shim would finish before the descriptor exists.
return false;
}
internal static void NotifyGuestMovieClosed(string hostPath)
{
lock (Gate)
{
if (PendingMoviePathSet.Remove(hostPath))
{
var retained = PendingMoviePaths
.Where(path => !string.Equals(
path,
hostPath,
StringComparison.OrdinalIgnoreCase))
.ToArray();
PendingMoviePaths.Clear();
foreach (var path in retained)
{
PendingMoviePaths.Enqueue(path);
}
}
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
Monitor.PulseAll(Gate);
return;
}
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
Path.GetFileName(hostPath));
CloseActiveLocked();
AttachNextQueuedMovieLocked();
}
}
internal static bool TryReadGuestCompletionShim(
string hostPath,
out BinkGuestCompletionShim completionShim)
{
completionShim = default;
Span<byte> header = stackalloc byte[48];
try
{
using var stream = File.OpenRead(hostPath);
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
{
return false;
}
adapter = new NativeAdapter(
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
if (frameCount < 2 || audioTrackCount > 256)
{
return false;
}
var revision = header[3];
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
if (revision == (byte)'m')
{
frameIndexOffset += 16;
}
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
{
frameIndexOffset += 4;
}
Span<byte> frameOffsets = stackalloc byte[8];
stream.Position = frameIndexOffset;
stream.ReadExactly(frameOffsets);
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
if (firstFrameOffset < frameIndexOffset + 8 ||
secondFrameOffset <= firstFrameOffset ||
secondFrameOffset > stream.Length)
{
return false;
}
completionShim = new BinkGuestCompletionShim(
secondFrameOffset - 8,
secondFrameOffset - firstFrameOffset);
return true;
}
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
catch (Exception exception) when (
exception is IOException or EndOfStreamException or OverflowException)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
try
{
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
return false;
}
}
internal readonly struct BinkGuestCompletionShim
{
private readonly uint _fileSizeMinusHeader;
private readonly uint _largestFrameSize;
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
{
_fileSizeMinusHeader = fileSizeMinusHeader;
_largestFrameSize = largestFrameSize;
}
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
/// <summary>
/// Rewrites the frame-count/size fields the guest's own Bink header
/// parse reads, if this read covers them. Returns true when the
/// NumFrames field (the field that tells the guest "this movie is
/// done") was in range, so the caller can gate that specific read on
/// the host's real playback actually finishing first.
/// </summary>
internal bool Patch(long fileOffset, Span<byte> bytes)
{
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
return touchedCompletionField;
}
internal void Close(IntPtr movie) => _close(movie);
private static bool PatchUInt32(
long fileOffset,
Span<byte> bytes,
long fieldOffset,
uint value)
{
var relativeOffset = fieldOffset - fileOffset;
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
{
return false;
}
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.Slice((int)relativeOffset, sizeof(uint)),
value);
return true;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Bink;
internal interface IBinkFrameDecoder : IDisposable
{
uint Width { get; }
uint Height { get; }
uint FramesPerSecondNumerator { get; }
uint FramesPerSecondDenominator { get; }
bool TryDecodeNextFrame(Span<byte> destination);
}
/// <summary>
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
/// </summary>
internal sealed class BinkFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
private readonly IBinkFrameDecoder _decoder;
private readonly Queue<byte[]> _freeBuffers = new();
private readonly Queue<DecodedFrame> _decodedFrames = new();
private readonly Thread _decoderThread;
private byte[]? _currentFrame;
private byte[]? _retiredFrame;
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
private bool _finished;
private int _disposed;
internal BinkFramePlayback(IBinkFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
Height = decoder.Height;
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
var frameBytes = checked((int)((ulong)Width * Height * 4));
for (var index = 0; index < BufferCount; index++)
{
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
}
_decoderThread = new Thread(DecodeLoop)
{
IsBackground = true,
Name = "SharpEmu Bink video decoder",
};
_decoderThread.Start();
}
internal uint Width { get; }
internal uint Height { get; }
internal uint FramesPerSecondNumerator { get; }
internal uint FramesPerSecondDenominator { get; }
internal bool IsFinished
{
get
{
lock (_gate)
{
return _finished;
}
}
}
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
out bool advanced)
{
lock (_gate)
{
pixels = [];
advanced = false;
if (_finished)
{
return false;
}
if (_currentFrame is null)
{
if (_decodedFrames.Count == 0)
{
if (_decoderCompleted)
{
_finished = true;
}
return false;
}
var first = _decodedFrames.Dequeue();
_currentFrame = first.Pixels;
_currentFrameIndex = first.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
_playbackClockStarted = true;
}
var elapsedSeconds = _playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0;
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
{
if (replacement is { } skipped)
{
_freeBuffers.Enqueue(skipped.Pixels);
}
replacement = _decodedFrames.Dequeue();
}
if (replacement is { } next)
{
if (_retiredFrame is not null)
{
_freeBuffers.Enqueue(_retiredFrame);
}
_retiredFrame = _currentFrame;
_currentFrame = next.Pixels;
_currentFrameIndex = next.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
var frameDurationSeconds =
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
if (_playbackClockStarted &&
_decoderCompleted &&
_decodedFrames.Count == 0 &&
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
{
_finished = true;
return false;
}
pixels = _currentFrame;
return true;
}
}
private void DecodeLoop()
{
try
{
while (true)
{
byte[] destination;
lock (_gate)
{
while (!_stopRequested && _freeBuffers.Count == 0)
{
Monitor.Wait(_gate);
}
if (_stopRequested)
{
return;
}
destination = _freeBuffers.Dequeue();
}
if (!_decoder.TryDecodeNextFrame(destination))
{
lock (_gate)
{
_freeBuffers.Enqueue(destination);
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
return;
}
lock (_gate)
{
_decodedFrames.Enqueue(new DecodedFrame(
_nextDecodedFrameIndex++, destination));
Monitor.PulseAll(_gate);
}
}
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
lock (_gate)
{
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock (_gate)
{
_stopRequested = true;
Monitor.PulseAll(_gate);
}
if (Thread.CurrentThread != _decoderThread &&
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
{
_decoder.Dispose();
_decoderThread.Join(TimeSpan.FromSeconds(2));
}
else
{
_decoder.Dispose();
}
}
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
}
@@ -0,0 +1,166 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.Libs.AvPlayer;
namespace SharpEmu.Libs.Bink;
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
{
private readonly Process _process;
private readonly Stream _output;
private int _errorLines;
private int _disposed;
private FfmpegBinkFrameSource(
Process process,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_process = process;
_output = process.StandardOutput.BaseStream;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
internal static bool TryOpen(
string path,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator,
out FfmpegBinkFrameSource? source)
{
source = null;
var ffmpeg = AvPlayerExports.FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("bgra");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
source = new FfmpegBinkFrameSource(
process,
width,
height,
framesPerSecondNumerator,
framesPerSecondDenominator);
process.ErrorDataReceived += source.OnErrorData;
process.BeginErrorReadLine();
return true;
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException or
System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
return false;
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
try
{
var offset = 0;
while (offset < destination.Length)
{
var read = _output.Read(destination[offset..]);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
{
if (Volatile.Read(ref _disposed) == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
}
return false;
}
}
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
Interlocked.Increment(ref _errorLines) > 20)
{
return;
}
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_output.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
_process.Dispose();
}
}
}
@@ -0,0 +1,355 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Bink;
/// <summary>
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
/// bridge of our own to build. See docs/bink2-bridge.md.
/// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private SwsContext* _swsContext;
private AVFrame* _frame;
private AVPacket* _packet;
private readonly int _videoStreamIndex;
private bool _draining;
private int _disposed;
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
private FfmpegNativeBinkFrameSource(
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
private static bool _rootPathInitialized;
/// <summary>
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
/// downloads next to the executable (see SharpEmu.CLI.csproj's
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
/// in the single-file bundle so the OS loader can resolve the normal
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
/// </summary>
private static void EnsureRootPathInitialized()
{
if (_rootPathInitialized)
{
return;
}
_rootPathInitialized = true;
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
// "plugins" subfolder next to the executable rather than flat beside
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
// itself, but that constructor fires on first touch of the ffmpeg type --
// which is the RootPath assignment above -- so it binds against the
// default (empty) RootPath before the assignment's own setter body runs.
// Every function resolved during that first pass permanently throws
// NotSupportedException. Re-running Initialize() now, with RootPath
// actually set, rebinds everything against the real search path.
DynamicallyLoadedBindings.Initialize();
}
internal static bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out FfmpegNativeBinkFrameSource? source)
{
source = null;
EnsureRootPathInitialized();
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
return false;
}
AVCodec* decoder = null;
var videoStreamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
if (videoStreamIndex < 0 || decoder is null)
{
return false;
}
var stream = formatContext->streams[videoStreamIndex];
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
if (codecContext is null)
{
return false;
}
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
{
return false;
}
codecContext->thread_count = 0;
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
{
return false;
}
if (codecContext->width <= 0 || codecContext->height <= 0)
{
return false;
}
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = stream->avg_frame_rate;
}
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = stream->r_frame_rate;
}
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = new AVRational { num = 30, den = 1 };
}
var outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
(outputWidth > maximumWidth || outputHeight > maximumHeight))
{
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
{
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
outputWidth = maximumWidth;
}
else
{
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
outputHeight = maximumHeight;
}
outputWidth = Math.Max(1, outputWidth);
outputHeight = Math.Max(1, outputHeight);
}
source = new FfmpegNativeBinkFrameSource(
formatContext,
codecContext,
videoStreamIndex,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
return true;
}
catch (DllNotFoundException)
{
return false;
}
finally
{
if (codecContext is not null)
{
ffmpeg.avcodec_free_context(&codecContext);
}
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
{
return false;
}
if (!TryReceiveFrame())
{
return false;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
}
}
private bool TryReceiveFrame()
{
while (true)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
if (receiveResult >= 0)
{
return true;
}
if (receiveResult == ffmpeg.AVERROR_EOF)
{
return false;
}
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
return false;
}
if (_draining)
{
return false;
}
if (!TryFeedPacket())
{
return false;
}
}
}
private bool TryFeedPacket()
{
while (true)
{
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
if (readResult < 0)
{
_draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null);
return true;
}
if (_packet->stream_index != _videoStreamIndex)
{
ffmpeg.av_packet_unref(_packet);
continue;
}
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
ffmpeg.av_packet_unref(_packet);
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
return false;
}
return true;
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
}
+31
View File
@@ -153,6 +153,37 @@ public static class FontExports
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "3BrWWFU+4ts",
ExportName = "sceFontGetVerticalLayout",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GetVerticalLayout(CpuContext ctx)
{
var layoutAddress = ctx[CpuRegister.Rsi];
if (layoutAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// Baseline (horizontal offset), line advance, decoration extent.
// Mirrors the same three-float layout as GetHorizontalLayout, but
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
var values = new[] { 8.0f, 16.0f, 0.0f };
for (var index = 0; index < values.Length; index++)
{
if (!TryWriteUInt32(
ctx,
layoutAddress + (ulong)(index * sizeof(float)),
BitConverter.SingleToUInt32Bits(values[index])))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "cKYtVmeSTcw",
ExportName = "sceFontOpenFontSet",
+23 -3
View File
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Agc;
namespace SharpEmu.Libs.Gpu;
// The types that cross the guest-GPU backend seam. Every field is either a neutral
@@ -10,7 +12,8 @@ namespace SharpEmu.Libs.Gpu;
// translation for its API.
/// <summary>A guest texture referenced by a draw or dispatch. Format/NumberType/
/// TileMode/DstSelect are raw guest descriptor codes.</summary>
/// TileMode/DstSelect/Type are raw guest descriptor codes. Depth is the
/// normalized volume depth (one for non-3D resources).</summary>
internal sealed record GuestDrawTexture(
ulong Address,
uint Width,
@@ -27,7 +30,20 @@ internal sealed record GuestDrawTexture(
uint Pitch = 0,
uint TileMode = 0,
uint DstSelect = 0xFAC,
GuestSampler Sampler = default);
GuestSampler Sampler = default,
// Guest CPU write-tracker generation of the memory RgbaPixels was read
// from; -1 when the range is untracked or the pixels were not read here.
long WriteGeneration = -1,
bool ArrayedView = false,
uint ArrayLayers = 1,
uint Type = 9,
uint Depth = 1,
// GPU-detile opt-in (SHARPEMU_GPU_DETILE): when Detile is non-null the AGC
// layer skipped the CPU deswizzle and shipped the raw TILED bytes here in
// TiledSource; the Vulkan backend detiles them on the GPU. RgbaPixels is
// empty in that case. Both are neutral (no host graphics-API values).
byte[]? TiledSource = null,
DetileParams? Detile = null);
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
internal readonly record struct GuestSampler(
@@ -48,7 +64,11 @@ internal readonly record struct TextureContentIdentity(
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler);
GuestSampler Sampler,
bool Arrayed = false,
uint ArrayLayers = 1,
uint Type = 9,
uint Depth = 1);
internal sealed record GuestMemoryBuffer(
ulong BaseAddress,
@@ -0,0 +1,313 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Numerics;
using SharpEmu.Libs.Agc;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Metal twin of <c>VulkanDetilePass</c>: runs the ExactXor detile equation from
/// <see cref="GnmTiling.GetDetileParams"/> as a Metal compute kernel
/// (<see cref="MslFixedShaders.CreateDetileCompute"/>), writing a linear buffer
/// and blitting it into the sampled texture.
///
/// <see cref="RecordDetile"/> records the compute dispatch + blit onto a caller's
/// command buffer and returns its transient buffers for the caller to release
/// once that command buffer completes — the async, non-blocking shape (Metal
/// hazard-tracks the compute-write → blit-read → sample dependency automatically,
/// so no manual barriers are needed).
///
/// Only ExactXor 4-bytes/element surfaces are handled. NOTE: authored on Windows;
/// the MSL and every Metal call here are <b>Mac-untested</b> — mirrors the
/// verified Vulkan logic and the existing Metal message-send conventions, but
/// must be validated on a real Metal device.
/// </summary>
internal sealed unsafe class MetalDetilePass : IDisposable
{
private const uint LocalSize = 8;
private const int PushConstantUints = 11;
private readonly nint _device;
private nint _pipelineState;
private bool _initialized;
private bool _disposed;
public MetalDetilePass(nint device)
{
_device = device;
}
public static bool Supports(in DetileParams parameters) =>
(parameters.Equation == DetileEquation.ExactXor ||
parameters.Equation == DetileEquation.BlockTable) &&
parameters.BytesPerElement is 4 or 8 or 16;
/// <summary>
/// Records the deswizzle of <paramref name="tiled"/> into
/// <paramref name="texture"/> (<paramref name="texelWidth"/> x
/// <paramref name="texelHeight"/> texels x <paramref name="layers"/> slices)
/// onto <paramref name="commandBuffer"/>. The kernel iterates the element grid
/// from <paramref name="parameters"/> (for block-compressed formats a 4x4 block
/// is one element). Does not commit; the caller releases
/// <paramref name="transientBuffers"/> when the command buffer completes.
/// Returns false (empty transients) when unsupported or the pipeline could not
/// be built.
/// </summary>
public bool RecordDetile(
nint commandBuffer,
nint texture,
uint texelWidth,
uint texelHeight,
uint layers,
ReadOnlySpan<byte> tiled,
in DetileParams parameters,
out nint[] transientBuffers)
{
transientBuffers = [];
var bytesPerElement = (uint)parameters.BytesPerElement;
if (_disposed || commandBuffer == 0 || texture == 0 ||
!Supports(parameters) || texelWidth == 0 || texelHeight == 0 || layers == 0 || tiled.IsEmpty ||
tiled.Length % (int)(layers * bytesPerElement) != 0 ||
!EnsurePipeline())
{
return false;
}
var elementsWide = (uint)parameters.ElementsWide;
var elementsHigh = (uint)parameters.ElementsHigh;
var uintsPerElement = bytesPerElement / sizeof(uint);
// Array slices are packed contiguously in the tiled buffer; each slice's
// element stride is the whole buffer split evenly by layer.
var srcSliceElements = (uint)((ulong)tiled.Length / bytesPerElement / layers);
// Binding 1 carries the within-block offset table. ExactXor: element-shifted
// X/Y byte terms. BlockTable: GetDetileParams' block table (already element
// offsets) in binding 1, a placeholder in binding 2. The two equations index
// different-sized buffers, so the kernel branches and reads only one.
uint[] xTerm;
uint[] yTerm;
uint equationValue;
if (parameters.Equation == DetileEquation.BlockTable)
{
xTerm = new uint[parameters.BlockTable.Length];
for (var index = 0; index < xTerm.Length; index++)
{
xTerm[index] = (uint)parameters.BlockTable[index];
}
yTerm = [0];
equationValue = 1;
}
else
{
var shift = BitOperations.TrailingZeroCount((uint)parameters.BytesPerElement);
xTerm = ToElementTerms(parameters.XByteTerm, shift);
yTerm = ToElementTerms(parameters.YByteTerm, shift);
equationValue = 0;
}
var newBufferWithBytes = MetalNative.Selector("newBufferWithBytes:length:options:");
var newBufferWithLength = MetalNative.Selector("newBufferWithLength:options:");
nint tiledBuffer;
nint xBuffer;
nint yBuffer;
fixed (byte* tiledPointer = tiled)
{
tiledBuffer = MetalNative.SendBuffer(
_device, newBufferWithBytes, (nint)tiledPointer, (nuint)tiled.Length, 0);
}
fixed (uint* xPointer = xTerm)
{
xBuffer = MetalNative.SendBuffer(
_device, newBufferWithBytes, (nint)xPointer, (nuint)xTerm.Length * sizeof(uint), 0);
}
fixed (uint* yPointer = yTerm)
{
yBuffer = MetalNative.SendBuffer(
_device, newBufferWithBytes, (nint)yPointer, (nuint)yTerm.Length * sizeof(uint), 0);
}
var outputBytes = (nuint)elementsWide * elementsHigh * bytesPerElement * layers;
var outputBuffer = MetalNative.SendNewBuffer(_device, newBufferWithLength, outputBytes, 0);
Span<uint> push =
[
elementsWide,
elementsHigh,
(uint)parameters.BlockWidth,
(uint)parameters.BlockHeight,
(uint)parameters.BlockElements,
(uint)parameters.BlocksPerRow,
(uint)parameters.XMask,
(uint)parameters.YMask,
srcSliceElements,
equationValue,
uintsPerElement,
];
nint paramsBuffer;
fixed (uint* pushPointer = push)
{
paramsBuffer = MetalNative.SendBuffer(
_device, newBufferWithBytes, (nint)pushPointer, (nuint)PushConstantUints * sizeof(uint), 0);
}
if (tiledBuffer == 0 || xBuffer == 0 || yBuffer == 0 || outputBuffer == 0 || paramsBuffer == 0)
{
ReleaseAll(tiledBuffer, xBuffer, yBuffer, outputBuffer, paramsBuffer);
return false;
}
// Compute encoder: one thread per texel.
var setBuffer = MetalNative.Selector("setBuffer:offset:atIndex:");
var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder"));
MetalNative.Send(encoder, MetalNative.Selector("setComputePipelineState:"), _pipelineState);
MetalNative.SendSetBuffer(encoder, setBuffer, tiledBuffer, 0, 0);
MetalNative.SendSetBuffer(encoder, setBuffer, xBuffer, 0, 1);
MetalNative.SendSetBuffer(encoder, setBuffer, yBuffer, 0, 2);
MetalNative.SendSetBuffer(encoder, setBuffer, outputBuffer, 0, 3);
MetalNative.SendSetBuffer(encoder, setBuffer, paramsBuffer, 0, 4);
// X is widened by uintsPerElement (each thread copies one word); one
// grid-Z layer per array slice.
var threadgroups = new MtlSize
{
Width = (nuint)((elementsWide * uintsPerElement + LocalSize - 1) / LocalSize),
Height = (nuint)((elementsHigh + LocalSize - 1) / LocalSize),
Depth = layers,
};
var threadsPerThreadgroup = new MtlSize { Width = LocalSize, Height = LocalSize, Depth = 1 };
MetalNative.SendDispatch(
encoder,
MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"),
threadgroups,
threadsPerThreadgroup);
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
// Blit the layer-major linear output buffer into the sampled texture, one
// slice per array layer (Metal copyFromBuffer targets a single slice). The
// buffer is element/block-packed (row stride = elementsWide*bpp); the copy
// region is in texels. Metal tracks the compute-write -> blit-read hazard.
var blit = MetalNative.Send(commandBuffer, MetalNative.Selector("blitCommandEncoder"));
var copySelector = MetalNative.Selector(
"copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:" +
"toTexture:destinationSlice:destinationLevel:destinationOrigin:");
var sliceBytes = (nuint)elementsWide * elementsHigh * bytesPerElement;
var rowBytes = (nuint)elementsWide * bytesPerElement;
for (uint layer = 0; layer < layers; layer++)
{
MetalNative.SendCopyBufferToTexture(
blit,
copySelector,
outputBuffer,
(nuint)layer * sliceBytes,
rowBytes,
sliceBytes,
new MtlSize { Width = texelWidth, Height = texelHeight, Depth = 1 },
texture,
layer,
0,
new MtlOrigin { X = 0, Y = 0, Z = 0 });
}
MetalNative.SendVoid(blit, MetalNative.Selector("endEncoding"));
transientBuffers = [tiledBuffer, xBuffer, yBuffer, outputBuffer, paramsBuffer];
return true;
}
private bool EnsurePipeline()
{
if (_initialized)
{
return _pipelineState != 0;
}
_initialized = true;
var options = MetalNative.Send(
MetalNative.Send(MetalNative.Class("MTLCompileOptions"), MetalNative.Selector("alloc")),
MetalNative.Selector("init"));
MetalNative.SendVoidBool(options, MetalNative.Selector("setFastMathEnabled:"), false);
nint libraryError = 0;
var library = MetalNative.Send(
_device,
MetalNative.Selector("newLibraryWithSource:options:error:"),
MetalNative.NsString(MslFixedShaders.CreateDetileCompute()),
options,
ref libraryError);
if (library == 0)
{
Console.Error.WriteLine(
$"[GPU-DETILE] Metal detile library compile failed: {MetalNative.DescribeError(libraryError)}");
return false;
}
var function = MetalNative.Send(
library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("detile_cs"));
if (function == 0)
{
Console.Error.WriteLine("[GPU-DETILE] Metal detile function 'detile_cs' not found.");
return false;
}
nint pipelineError = 0;
_pipelineState = MetalNative.Send(
_device,
MetalNative.Selector("newComputePipelineStateWithFunction:error:"),
function,
ref pipelineError);
if (_pipelineState == 0)
{
Console.Error.WriteLine(
$"[GPU-DETILE] Metal detile pipeline failed: {MetalNative.DescribeError(pipelineError)}");
return false;
}
return true;
}
private static uint[] ToElementTerms(int[] byteTerms, int shift)
{
var terms = new uint[byteTerms.Length];
for (var index = 0; index < byteTerms.Length; index++)
{
terms[index] = (uint)byteTerms[index] >> shift;
}
return terms;
}
private static void ReleaseAll(params nint[] objects)
{
var release = MetalNative.Selector("release");
foreach (var handle in objects)
{
if (handle != 0)
{
MetalNative.SendVoid(handle, release);
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_pipelineState != 0)
{
MetalNative.SendVoid(_pipelineState, MetalNative.Selector("release"));
_pipelineState = 0;
}
}
}
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Agc;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
@@ -1583,6 +1584,45 @@ internal static partial class MetalVideoPresenter
return cached;
}
// Default-on GPU detile packages the tiled source + resolved DetileParams
// with empty RgbaPixels so a backend can deswizzle on the GPU. The Metal
// GPU compute pass (MetalDetilePass / detile_compute.msl) is the intended
// equivalent of VulkanDetilePass, but it is Mac-untested, so the active
// Metal path CPU-detiles here via GnmTiling.DetileWithParams — the exact
// same DetileParams addressing the kernel runs — restoring the linear-upload
// behavior Metal had before GPU detile existed, with no regression.
if (texture.RgbaPixels.Length == 0 &&
texture.TiledSource is { } tiledSource &&
texture.Detile is { } detileParameters)
{
// The tiled source packs the array slices contiguously (one per layer);
// detile each into its layer-major linear region so the reconstructed
// pixels match what the CPU array-upload path produced pre-GPU-detile.
// A plain 2D texture is just one layer.
var layers = Math.Max((int)texture.ArrayLayers, 1);
var sliceLinearBytes =
detileParameters.ElementsWide * detileParameters.ElementsHigh * detileParameters.BytesPerElement;
var sliceTiledBytes = tiledSource.Length / layers;
var linear = new byte[sliceLinearBytes * layers];
var detiledAll = true;
for (var layer = 0; layer < layers; layer++)
{
if (!GnmTiling.DetileWithParams(
detileParameters,
tiledSource.AsSpan(layer * sliceTiledBytes, sliceTiledBytes),
linear.AsSpan(layer * sliceLinearBytes, sliceLinearBytes)))
{
detiledAll = false;
break;
}
}
if (detiledAll)
{
texture = texture with { RgbaPixels = linear };
}
}
// AGC ships the raw (detiled) source texels; create the texture in the
// guest's native format — Mac-family GPUs sample BC blocks directly —
// and size expectations with the same block-aware math AGC used.
+23
View File
@@ -80,6 +80,18 @@ public static class JsonExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "PR5k1penBLM",
ExportName = "_ZN3sce4Json11Initializer9terminateEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int InitializerTerminate(CpuContext ctx)
{
TraceJson("Initializer.terminate", ctx[CpuRegister.Rdi], 0);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "Cxwy7wHq4J0",
ExportName = "_ZN3sce4Json11Initializer10initializeEPKNS0_13InitParameterE",
@@ -119,6 +131,17 @@ public static class JsonExports
return SetReturn(ctx, 0);
}
// Catalog alias NID for the same callback setter.
#pragma warning disable SHEM004
[SysAbiExport(
Nid = "00oCq0RwSAY",
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
InitializerSetGlobalNullAccessCallback(ctx);
#pragma warning restore SHEM004
[SysAbiExport(
Nid = "WSOuge5IsCg",
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
+2 -2
View File
@@ -362,7 +362,7 @@ public static class KernelExports
ExportName = "open",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
[SysAbiExport(
Nid = "1G3lF1Gg1k8",
@@ -376,7 +376,7 @@ public static class KernelExports
ExportName = "fstat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
[SysAbiExport(
Nid = "hcuQgD53UxM",
@@ -267,6 +267,11 @@ public static partial class KernelMemoryCompatExports
}
var hostPath = ResolveGuestPath(guestPath);
if (string.IsNullOrEmpty(hostPath))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
@@ -310,6 +315,11 @@ public static partial class KernelMemoryCompatExports
var fromHost = ResolveGuestPath(fromGuest);
var toHost = ResolveGuestPath(toGuest);
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
if (Directory.Exists(fromHost))
File diff suppressed because it is too large Load Diff
@@ -41,18 +41,87 @@ public static class KernelPthreadCompatExports
private sealed class PthreadMutexState
{
public ulong OwnerThreadId { get; set; }
public int RecursionCount { get; set; }
private long _ownerThreadId;
private int _recursionCount;
private int _queuedWaiterCount;
public Lock SyncRoot { get; } = new();
public ulong OwnerThreadId
{
get => unchecked((ulong)Volatile.Read(ref _ownerThreadId));
set => Volatile.Write(ref _ownerThreadId, unchecked((long)value));
}
public int RecursionCount
{
get => Volatile.Read(ref _recursionCount);
set => Volatile.Write(ref _recursionCount, value);
}
public int QueuedWaiterCount => Volatile.Read(ref _queuedWaiterCount);
public int Type { get; set; } = MutexTypeErrorCheck;
public int Protocol { get; set; }
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
public bool TryAcquireUncontended(ulong threadId, bool allowWaiterBarge)
{
if (!allowWaiterBarge && QueuedWaiterCount != 0)
{
return false;
}
return TryAcquireOwner(threadId);
}
public bool TryAcquireOwner(ulong threadId)
{
if (Interlocked.CompareExchange(
ref _ownerThreadId,
unchecked((long)threadId),
0) != 0)
{
return false;
}
Volatile.Write(ref _recursionCount, 1);
return true;
}
public bool TryReleaseUncontended(ulong threadId)
{
if (QueuedWaiterCount != 0 || RecursionCount != 1)
{
return false;
}
Volatile.Write(ref _recursionCount, 0);
if (Interlocked.CompareExchange(
ref _ownerThreadId,
0,
unchecked((long)threadId)) == unchecked((long)threadId))
{
return true;
}
Volatile.Write(ref _recursionCount, 1);
return false;
}
public int IncrementRecursion() => Interlocked.Increment(ref _recursionCount);
public int DecrementRecursion() => Interlocked.Decrement(ref _recursionCount);
public void WaiterAddedLocked() => Interlocked.Increment(ref _queuedWaiterCount);
public void WaiterRemovedLocked() => Interlocked.Decrement(ref _queuedWaiterCount);
}
private sealed class PthreadMutexWaiter
{
public required ulong ThreadId { get; init; }
public required string WakeKey { get; init; }
public required bool Cooperative { get; init; }
public required bool Cooperative { get; set; }
public ManualResetEventSlim? HostSignal { get; set; }
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
public int Granted;
}
@@ -84,6 +153,64 @@ public static class KernelPthreadCompatExports
static KernelPthreadCompatExports()
{
RunSynchronizationSelfChecks();
GuestThreadExecution.GuestThreadAbandoned += AbandonMutexesOwnedByThread;
}
/// <summary>
/// Force-release mutexes still owned by a guest thread that is being torn
/// down without a clean unlock (TBB worker_abort, abrupt exit). Otherwise
/// waiters can spin forever and block splash→first GPU submit.
/// </summary>
public static int AbandonMutexesOwnedByThread(ulong threadId, string reason)
{
if (threadId == 0)
{
return 0;
}
var released = 0;
var wakeKeys = new List<string>();
foreach (var pair in _mutexStates)
{
var state = pair.Value;
string? wakeKey = null;
lock (state)
{
if (state.OwnerThreadId != threadId || state.RecursionCount <= 0)
{
continue;
}
state.OwnerThreadId = 0;
state.RecursionCount = 0;
wakeKey = state.Waiters.First?.Value.Cooperative == true
? state.Waiters.First.Value.WakeKey
: null;
Monitor.PulseAll(state);
released++;
Console.Error.WriteLine(
$"[LOADER][WARN] pthread_mutex_abandon mutex=0x{pair.Key:X16} " +
$"owner={KernelPthreadState.DescribeThreadHandle(threadId)} " +
$"reason={reason} waiters={state.Waiters.Count}");
}
if (wakeKey is not null)
{
wakeKeys.Add(wakeKey);
}
}
foreach (var wakeKey in wakeKeys)
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(wakeKey, 1);
}
if (released > 0)
{
Console.Error.Flush();
}
return released;
}
[SysAbiExport(
@@ -94,7 +221,10 @@ public static class KernelPthreadCompatExports
public static int PthreadSelf(CpuContext ctx)
{
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
{
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
}
ctx[CpuRegister.Rax] = currentThreadHandle;
TracePthreadSelf(ctx, currentThreadHandle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -139,6 +269,13 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "B5GmVDKwpn0",
ExportName = "pthread_yield",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx);
[SysAbiExport(
Nid = "GBUY7ywdULE",
ExportName = "scePthreadRename",
@@ -571,6 +708,30 @@ public static class KernelPthreadCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
/// <summary>
/// The POSIX-named alias of <see cref="PthreadOnce"/>. libKernel exports the
/// same routine under two NIDs, and shipped middleware links the plain name:
/// DOOM's libcohtml, PlayFab and party modules all import this one rather
/// than scePthreadOnce.
/// </summary>
[SysAbiExport(
Nid = "Z4QosVuAsA0",
ExportName = "pthread_once",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadOncePOSIX(CpuContext ctx) => PthreadOnce(ctx);
/// <summary>
/// The POSIX-named alias of <see cref="PthreadRename"/>, following the same
/// two-NID pattern as <see cref="PthreadOncePOSIX"/>.
/// </summary>
[SysAbiExport(
Nid = "9vyP6Z7bqzc",
ExportName = "pthread_rename_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadRenameNpPOSIX(CpuContext ctx) => PthreadRename(ctx);
private static int PthreadMutexInitCore(CpuContext ctx, ulong mutexAddress, ulong attrAddress)
{
if (mutexAddress == 0)
@@ -621,7 +782,7 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
lock (state)
lock (state.SyncRoot)
{
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
{
@@ -653,11 +814,63 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
if (state.TryAcquireUncontended(currentThreadId, allowWaiterBarge: tryOnly))
{
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.OwnerThreadId == currentThreadId)
{
if (state.Type == MutexTypeRecursive)
{
state.IncrementRecursion();
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
{
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (state.Type == MutexTypeAdaptiveNp)
{
var adaptiveResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, adaptiveResult);
return adaptiveResult;
}
if (state.Type == MutexTypeNormal)
{
if (tryOnly)
{
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
state.IncrementRecursion();
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
var canCooperativelyBlock = !tryOnly &&
GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
PthreadMutexWaiter? waiter = null;
lock (state)
var acquiredWhileQueueing = false;
lock (state.SyncRoot)
{
if (state.OwnerThreadId == currentThreadId)
{
@@ -668,7 +881,30 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
{
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (state.Type == MutexTypeAdaptiveNp)
{
if (tryOnly)
{
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
// Gen5 runtime wrappers can layer an adaptive lock call over
// scePthreadMutexLock for one logical acquisition, followed by
// only one unlock. Keep the duplicate acquisition idempotent so
// the matching unlock fully releases the HLE mutex.
TracePthreadMutex(ctx, "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.Type == MutexTypeNormal)
{
if (tryOnly)
{
@@ -677,7 +913,7 @@ public static class KernelPthreadCompatExports
}
// Several Gen5 runtimes layer their own owner/count bookkeeping
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
// over a NORMAL kernel mutex. Returning EDEADLK here
// leaves that guest bookkeeping out of sync with the HLE owner and
// turns the wrapper into a permanent lock/unlock retry loop. Keep
// the compatibility recursion used by the original implementation;
@@ -703,10 +939,10 @@ public static class KernelPthreadCompatExports
// waiter wedge a spin-on-trylock loop forever even though the mutex
// is free (owner==0). The blocking lock still honours FIFO so real
// blocked waiters are not starved by a barging locker.
if (state.OwnerThreadId == 0 && (tryOnly || state.Waiters.Count == 0))
if (state.OwnerThreadId == 0 &&
(tryOnly || state.Waiters.Count == 0) &&
state.TryAcquireOwner(currentThreadId))
{
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -718,6 +954,14 @@ public static class KernelPthreadCompatExports
}
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
acquiredWhileQueueing = TryGrantMutexWaiterLocked(state, waiter);
}
if (acquiredWhileQueueing)
{
waiter!.HostSignal?.Dispose();
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (canCooperativelyBlock && waiter is not null &&
@@ -751,8 +995,29 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
string? nextWakeKey = null;
lock (state)
if (state.OwnerThreadId == currentThreadId)
{
if (state.RecursionCount > 1)
{
state.DecrementRecursion();
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.TryReleaseUncontended(currentThreadId))
{
if (state.QueuedWaiterCount != 0)
{
WakeFirstMutexWaiter(state);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
PthreadMutexWaiter? nextWaiter = null;
lock (state.SyncRoot)
{
if (state.RecursionCount <= 0)
{
@@ -770,16 +1035,29 @@ public static class KernelPthreadCompatExports
if (state.RecursionCount == 0)
{
state.OwnerThreadId = 0;
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
? state.Waiters.First.Value.WakeKey
: null;
Monitor.PulseAll(state);
// Hand the mutex directly to the head waiter instead of only
// waking it and relying on it to re-acquire. A woken waiter that
// fails to self-grant (its wake races or is lost) would leave the
// mutex "free with a queued waiter"; the fast-acquire path refuses
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
// later locker — including the game's main thread — then queues
// behind a head that never advances and the process wedges.
if (state.Waiters.First is { } headNode &&
TryGrantMutexWaiterLocked(state, headNode.Value))
{
nextWaiter = headNode.Value;
if (!nextWaiter.Cooperative)
{
nextWaiter.HostSignal!.Set();
}
}
}
}
if (nextWakeKey is not null)
if (nextWaiter is { Cooperative: true })
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
@@ -1239,7 +1517,7 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState)
lock (mutexState.SyncRoot)
{
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
{
@@ -1252,10 +1530,10 @@ public static class KernelPthreadCompatExports
// mutex held (the unlock below is skipped), wedging every thread
// that later blocks on pthread_mutex_lock. Adopt ownership so the
// unlock/wait/re-lock cycle is balanced and releases the mutex.
mutexState.OwnerThreadId = currentThreadId;
mutexState.RecursionCount = 1;
_ = mutexState.TryAcquireOwner(currentThreadId);
}
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
return mutexState.OwnerThreadId == currentThreadId
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
@@ -1424,6 +1702,7 @@ public static class KernelPthreadCompatExports
if (node.Value.ThreadId == threadId)
{
state.Waiters.Remove(node);
state.WaiterRemovedLocked();
node.Value.Node = null;
}
@@ -1438,8 +1717,10 @@ public static class KernelPthreadCompatExports
WakeKey = cooperative
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
: string.Empty,
HostSignal = cooperative ? null : new ManualResetEventSlim(initialState: false),
};
waiter.Node = state.Waiters.AddLast(waiter);
state.WaiterAddedLocked();
return waiter;
}
@@ -1449,7 +1730,7 @@ public static class KernelPthreadCompatExports
var mutex = new PthreadMutexState();
PthreadMutexWaiter first;
PthreadMutexWaiter second;
lock (mutex)
lock (mutex.SyncRoot)
{
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
@@ -1493,26 +1774,72 @@ public static class KernelPthreadCompatExports
return false;
}
if (!state.TryAcquireOwner(waiter.ThreadId))
{
return false;
}
state.Waiters.Remove(waiter.Node);
state.WaiterRemovedLocked();
waiter.Node = null;
state.OwnerThreadId = waiter.ThreadId;
state.RecursionCount = 1;
Volatile.Write(ref waiter.Granted, 1);
Monitor.PulseAll(state);
return true;
}
private static void WakeFirstMutexWaiter(PthreadMutexState state)
{
PthreadMutexWaiter? nextWaiter;
lock (state.SyncRoot)
{
if (state.OwnerThreadId != 0)
{
return;
}
nextWaiter = state.Waiters.First?.Value;
if (nextWaiter is { Cooperative: false })
{
nextWaiter.HostSignal!.Set();
}
}
if (nextWaiter is { Cooperative: true })
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
}
}
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
{
lock (state)
ManualResetEventSlim? hostSignal = null;
try
{
while (!TryGrantMutexWaiterLocked(state, waiter))
while (true)
{
Monitor.Wait(state);
lock (state.SyncRoot)
{
if (waiter.HostSignal is null)
{
waiter.Cooperative = false;
waiter.HostSignal = new ManualResetEventSlim(initialState: false);
}
hostSignal = waiter.HostSignal;
if (TryGrantMutexWaiterLocked(state, waiter))
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
hostSignal.Reset();
}
hostSignal.Wait();
}
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
finally
{
hostSignal?.Dispose();
}
}
private static bool TryGrantBlockedMutexLock(
@@ -1523,7 +1850,7 @@ public static class KernelPthreadCompatExports
PthreadMutexWaiter waiter)
{
var granted = false;
lock (state)
lock (state.SyncRoot)
{
granted = TryGrantMutexWaiterLocked(state, waiter);
}
@@ -1557,6 +1884,10 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
private static bool IsGuestTrackedSelfLock(CpuContext ctx, ulong mutexAddress, ulong currentThreadId) =>
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress + 8, out var guestOwner) &&
guestOwner == currentThreadId;
private static bool CompleteCondWaiterLocked(
PthreadCondState state,
PthreadCondWaiter waiter,
@@ -1572,7 +1903,7 @@ public static class KernelPthreadCompatExports
waiter.TimeoutTimer?.Dispose();
waiter.TimeoutTimer = null;
lock (waiter.MutexState)
lock (waiter.MutexState.SyncRoot)
{
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
waiter.MutexState,
@@ -1620,7 +1951,7 @@ public static class KernelPthreadCompatExports
return false;
}
lock (waiter.MutexState)
lock (waiter.MutexState.SyncRoot)
{
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
}
@@ -860,6 +860,18 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
/// <summary>
/// The POSIX-named alias of <see cref="PthreadAttrGetschedparam"/>. libKernel
/// exports the same routine under two NIDs; middleware compiled against the
/// plain POSIX headers links this one rather than scePthreadAttrGetschedparam.
/// </summary>
[SysAbiExport(
Nid = "qlk9pSLsUmM",
ExportName = "pthread_attr_getschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetschedparamPOSIX(CpuContext ctx) => PthreadAttrGetschedparam(ctx);
[SysAbiExport(
Nid = "FXPWHNk8Of0",
ExportName = "scePthreadAttrGetschedparam",
@@ -1133,6 +1145,90 @@ public static class KernelPthreadExtendedCompatExports
LibraryName = "libKernel")]
public static int PosixPthreadRwlockWrlock(CpuContext ctx) => PthreadRwlockWrlock(ctx);
[SysAbiExport(
Nid = "SFxTMOfuCkE",
ExportName = "pthread_rwlock_tryrdlock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRwlockTryrdlock(CpuContext ctx) =>
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: false);
[SysAbiExport(
Nid = "XhWHn6P5R7U",
ExportName = "pthread_rwlock_trywrlock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRwlockTrywrlock(CpuContext ctx) =>
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: true);
/// <summary>
/// Non-blocking counterpart of <see cref="PthreadRwlockLockCore"/>: acquires
/// only if the lock is free right now, otherwise reports BUSY.
/// </summary>
/// <remarks>
/// Deliberately not routed through TryAcquireBlockedRwlock. That helper exists
/// for the scheduler resume path and decrements WaitingWriters on success,
/// which is correct only for a thread that previously incremented it. A fresh
/// try never did, so reusing it would silently consume another thread's
/// waiter count and let a queued writer be skipped.
/// </remarks>
private static int PthreadRwlockTryLockCore(CpuContext ctx, ulong rwlockAddress, bool write)
{
if (rwlockAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (rwlock.SyncRoot)
{
if (write)
{
if (rwlock.WriterThreadId == currentThreadId || rwlock.GetReaderCount(currentThreadId) > 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
// Mirrors the blocking path's re-entrant compat-writer grant so the
// two agree on what counts as already owning the lock.
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
{
rwlock.AddCompatWriter(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId != 0 ||
rwlock.ReaderTotalCount != 0 ||
rwlock.CompatWriterTotalCount != 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "trywrlock");
rwlock.WriterThreadId = currentThreadId;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId == currentThreadId)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
rwlock.AddReader(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
[SysAbiExport(
Nid = "+L98PIbGttk",
ExportName = "scePthreadRwlockUnlock",
@@ -1819,4 +1915,94 @@ public static class KernelPthreadExtendedCompatExports
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
return ctx.Memory.TryWrite(address, bytes);
}
// POSIX-named aliases. libKernel exports each of these routines under two
// NIDs -- a scePthread* name and the plain POSIX name -- and middleware
// compiled against POSIX headers links the latter. Both take identical
// arguments and, per the convention already used by scePthreadOnce's alias,
// return the same OrbisGen2Result rather than translating to errno.
[SysAbiExport(
Nid = "a2P9wYGeZvc",
ExportName = "pthread_setprio",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSetprioPOSIX(CpuContext ctx) => PthreadSetprio(ctx);
[SysAbiExport(
Nid = "FIs3-UQT9sg",
ExportName = "pthread_getschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadGetschedparamPOSIX(CpuContext ctx) => PthreadGetschedparam(ctx);
[SysAbiExport(
Nid = "vQm4fDEsWi8",
ExportName = "pthread_attr_getstack",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetstackPOSIX(CpuContext ctx) => PthreadAttrGetstack(ctx);
[SysAbiExport(
Nid = "Ucsu-OK+els",
ExportName = "pthread_attr_get_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetNpPOSIX(CpuContext ctx) => PthreadAttrGet(ctx);
[SysAbiExport(
Nid = "JarMIy8kKEY",
ExportName = "pthread_attr_setschedpolicy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetschedpolicyPOSIX(CpuContext ctx) => PthreadAttrSetschedpolicy(ctx);
[SysAbiExport(
Nid = "E+tyo3lp5Lw",
ExportName = "pthread_attr_setdetachstate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetdetachstatePOSIX(CpuContext ctx) => PthreadAttrSetdetachstate(ctx);
[SysAbiExport(
Nid = "euKRgm0Vn2M",
ExportName = "pthread_attr_setschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetschedparamPOSIX(CpuContext ctx) => PthreadAttrSetschedparam(ctx);
[SysAbiExport(
Nid = "7ZlAakEf0Qg",
ExportName = "pthread_attr_setinheritsched",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetinheritschedPOSIX(CpuContext ctx) => PthreadAttrSetinheritsched(ctx);
[SysAbiExport(
Nid = "0qOtCR-ZHck",
ExportName = "pthread_attr_getstacksize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetstacksizePOSIX(CpuContext ctx) => PthreadAttrGetstacksize(ctx);
[SysAbiExport(
Nid = "VUT1ZSrHT0I",
ExportName = "pthread_attr_getdetachstate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetdetachstatePOSIX(CpuContext ctx) => PthreadAttrGetdetachstate(ctx);
[SysAbiExport(
Nid = "JKyG3SWyA10",
ExportName = "pthread_attr_setguardsize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetguardsizePOSIX(CpuContext ctx) => PthreadAttrSetguardsize(ctx);
[SysAbiExport(
Nid = "JNkVVsVDmOk",
ExportName = "pthread_attr_getguardsize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetguardsizePOSIX(CpuContext ctx) => PthreadAttrGetguardsize(ctx);
}
+31 -3
View File
@@ -27,8 +27,12 @@ internal static class KernelPthreadState
internal static ulong GetCurrentThreadHandle()
{
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (guestThreadHandle != 0 && TryGetThreadIdentity(guestThreadHandle, out _))
// Prefer the bound guest handle even when it is not yet in Threads.
// Falling through to a synthetic ThreadStatic handle while a guest
// thread is bound causes mutex owner mismatches (unlock PERM → hang).
if (guestThreadHandle != 0)
{
EnsureGuestThreadIdentity(guestThreadHandle);
return guestThreadHandle;
}
@@ -39,15 +43,27 @@ internal static class KernelPthreadState
internal static ulong GetCurrentThreadUniqueId()
{
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (guestThreadHandle != 0 && TryGetThreadIdentity(guestThreadHandle, out var identity))
if (guestThreadHandle != 0)
{
return identity.UniqueId;
return EnsureGuestThreadIdentity(guestThreadHandle).UniqueId;
}
EnsureCurrentThreadRegistered();
return _currentThreadUniqueId;
}
internal static string DescribeThreadHandle(ulong threadHandle)
{
if (threadHandle == 0)
{
return "none";
}
return TryGetThreadIdentity(threadHandle, out var identity)
? $"0x{threadHandle:X16}('{identity.Name}')"
: $"0x{threadHandle:X16}";
}
internal static ulong CreateThreadHandle(string name)
{
var uniqueId = unchecked((ulong)Interlocked.Increment(ref _nextUniqueThreadId));
@@ -59,6 +75,18 @@ internal static class KernelPthreadState
return Threads.TryGetValue(threadHandle, out identity);
}
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
{
if (Threads.TryGetValue(guestThreadHandle, out var existing))
{
return existing;
}
var uniqueId = unchecked((ulong)Interlocked.Increment(ref _nextUniqueThreadId));
var identity = new ThreadIdentity(uniqueId, $"Guest-0x{guestThreadHandle:X}");
return Threads.GetOrAdd(guestThreadHandle, identity);
}
private static void EnsureCurrentThreadRegistered()
{
if (_currentThreadHandle != 0)
@@ -2058,6 +2058,13 @@ public static class KernelRuntimeCompatExports
LibraryName = "libKernel")]
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
[SysAbiExport(
Nid = "NhpspxdjEKU",
ExportName = "_nanosleep",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixNanosleepUnderscore(CpuContext ctx) => NanosleepCore(ctx, posix: true);
[SysAbiExport(
Nid = "yS8U2TGCe1A",
ExportName = "nanosleep",
@@ -191,6 +191,27 @@ public static class KernelSemaphoreCompatExports
WakePredicate,
deadline))
{
// A signal may have arrived between releasing the semaphore gate
// (after incrementing WaitingThreads) and the scheduler registering
// this block. When that happens WakeBlockedThreads cannot find the
// waiter yet and the exit-handler re-check runs later; a re-check
// here keeps the thread from yielding to the scheduler at all when
// the count is already sufficient.
lock (semaphore.Gate)
{
if (semaphore.Count >= needCount)
{
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
GuestThreadExecution.TryConsumeCurrentThreadBlock(out _);
if (_traceSema)
{
TraceSemaphore($"wait-recheck handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
@@ -428,6 +449,22 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "GEnUkDZoUwY",
ExportName = "scePthreadSemInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemInit(CpuContext ctx)
{
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
if (ctx[CpuRegister.Rsi] != 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return PosixSemInit(ctx);
}
[SysAbiExport(
Nid = "YCV5dGGBcCo",
ExportName = "sem_wait",
@@ -446,6 +483,13 @@ public static class KernelSemaphoreCompatExports
return KernelWaitSema(ctx);
}
[SysAbiExport(
Nid = "C36iRE0F5sE",
ExportName = "scePthreadSemWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
[SysAbiExport(
Nid = "WBWzsRifCEA",
ExportName = "sem_trywait",
@@ -463,6 +507,19 @@ public static class KernelSemaphoreCompatExports
return KernelPollSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "H2a+IN9TP0E",
ExportName = "scePthreadSemTrywait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemTryWait(CpuContext ctx)
{
var result = PosixSemTryWait(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
: result;
}
[SysAbiExport(
Nid = "w5IHyvahg-o",
ExportName = "sem_timedwait",
@@ -499,6 +556,13 @@ public static class KernelSemaphoreCompatExports
return KernelSignalSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "aishVAiFaYM",
ExportName = "scePthreadSemPost",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
[SysAbiExport(
Nid = "Bq+LRV-N6Hk",
ExportName = "sem_getvalue",
@@ -549,6 +613,13 @@ public static class KernelSemaphoreCompatExports
return result;
}
[SysAbiExport(
Nid = "Vwc+L05e6oE",
ExportName = "scePthreadSemDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
{
handle = 0;
@@ -0,0 +1,140 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Threading;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Kernel;
// libKernel's address-wait primitives (sceKernelSyncOnAddress*) are the PS5's
// futex-style wait/wake: a thread parks on a guest address until another thread
// wakes that address. Guest runtimes (seen driving Juicy Realm, PPSA19268)
// build their own spinlocks/queues on top of it and call the wait in a hot
// loop; left unimplemented, every wait returns immediately and the runtime
// busy-spins forever (millions of calls, no forward progress).
//
// This implements wait/wake over the existing cooperative-block scheduler,
// keyed on the address. The real primitive takes a compare value so the wait
// only sleeps while the address still holds the expected value; that exact
// value is not recovered here, so each wait is given a bounded deadline and
// treated as a spurious-wakeup-tolerant park: a genuinely missed wake
// self-heals when the deadline expires and the guest re-checks its own
// condition, which futex callers already tolerate. A matching wake releases
// waiters immediately through the same key.
public static class KernelSyncOnAddressCompatExports
{
// Safety-net poll interval. Real releases come from the wake side (generation
// bump + WakeBlockedThreads); this only bounds how long a wait that genuinely
// raced/missed its wake stays parked before the guest re-evaluates. Kept
// large: a short interval turns every parked waiter into a hot re-poll that
// steals scheduler bandwidth from the threads that actually make progress
// (including the ones that would issue the wake), so it must be a rare last
// resort, not a spin substitute.
private static readonly TimeSpan WaitSelfHealTimeout = TimeSpan.FromMilliseconds(100);
// Per-address host gate for the non-cooperative (host main thread) fallback,
// which cannot use the guest-thread scheduler's block mechanism.
private static readonly ConcurrentDictionary<ulong, object> _hostAddressGates = new();
// Per-address wake generation. A wait captures the current generation and
// its wake predicate stays unsatisfied (keeps the thread parked) until a
// wake bumps it. This is what actually holds the thread blocked: a bare
// "always satisfied" predicate is treated as an immediate late-arrival by
// the dispatcher's race guard and never yields, leaving the guest to
// busy-spin. The generation also closes the register-vs-park race for free:
// a wake landing in that window bumps the generation, so the predicate is
// already satisfied and the guest correctly resumes at once.
private static readonly ConcurrentDictionary<ulong, long> _wakeGenerations = new();
private static long CurrentGeneration(ulong address) =>
_wakeGenerations.TryGetValue(address, out var generation) ? generation : 0;
private static string WakeKey(ulong address) => $"sceKernelSyncOnAddress:{address:X16}";
[SysAbiExport(
Nid = "Hc4CaR6JBL0",
ExportName = "sceKernelSyncOnAddressWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int SyncOnAddressWait(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
if (address == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var observedGeneration = CurrentGeneration(address);
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(WaitSelfHealTimeout);
// Cooperative path: stay parked until a wake bumps this address's
// generation (or the deadline expires as a self-heal). The guest
// re-evaluates its own condition after resuming.
if (GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelSyncOnAddressWait",
WakeKey(address),
resumeHandler: () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
wakeHandler: () => CurrentGeneration(address) != observedGeneration,
deadline))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
// Non-cooperative caller (host main thread): bounded host wait so a
// missed wake self-heals instead of hanging.
var gate = _hostAddressGates.GetOrAdd(address, static _ => new object());
lock (gate)
{
if (CurrentGeneration(address) == observedGeneration)
{
Monitor.Wait(gate, WaitSelfHealTimeout);
}
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "q2y-wDIVWZA",
ExportName = "sceKernelSyncOnAddressWake",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int SyncOnAddressWake(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
if (address == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// rsi carries the number of waiters to release (1 = wake-one, a large
// value = wake-all); default to all if it looks unset.
var requested = unchecked((long)ctx[CpuRegister.Rsi]);
var wakeCount = requested is > 0 and < int.MaxValue ? (int)requested : int.MaxValue;
// Bump the generation first so a wait that has registered but not yet
// parked sees the change and resumes instead of missing this wake.
_wakeGenerations.AddOrUpdate(address, 1, static (_, current) => current + 1);
GuestThreadExecution.Scheduler?.WakeBlockedThreads(WakeKey(address), wakeCount);
if (_hostAddressGates.TryGetValue(address, out var gate))
{
lock (gate)
{
Monitor.PulseAll(gate);
}
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int SetReturn(CpuContext ctx, OrbisGen2Result result)
{
var value = (int)result;
ctx[CpuRegister.Rax] = unchecked((ulong)value);
return value;
}
}
@@ -18,7 +18,8 @@ internal static class KernelVirtualRangeAllocator
bool allowSearch,
bool allowAllocateAtAlternative,
string traceName,
out ulong mappedAddress)
out ulong mappedAddress,
bool backPartialOverlap = false)
{
mappedAddress = 0;
if (length == 0)
@@ -42,6 +43,18 @@ internal static class KernelVirtualRangeAllocator
return true;
}
// Fixed mappings must cover the whole requested window even when part of
// it is already backed by another allocation. The single-call AllocateAt
// below is all-or-nothing and fails outright on partial overlap, leaving
// the untouched pages unmapped for the guest to fault into. Fill the free
// pages directly instead.
if (backPartialOverlap &&
addressSpace.TryBackFixedRange(desiredAddress, length, executable))
{
mappedAddress = desiredAddress;
return true;
}
var allocated = addressSpace.AllocateAt(desiredAddress, length, executable, allowAllocateAtAlternative);
if (allocated == 0)
{
+252
View File
@@ -184,6 +184,212 @@ public static class NetExports
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
/// <summary>
/// POSIX alias of <see cref="NetSetsockopt"/>; identical
/// (fd, level, option, value, length) argument order.
/// </summary>
[SysAbiExport(
Nid = "fFxGkxF2bVo",
ExportName = "setsockopt",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixSetsockopt(CpuContext ctx) => NetSetsockopt(ctx);
/// <summary>
/// Reads back the socket options this backend actually tracks: SO_NBIO,
/// SO_REUSEADDR and SO_ERROR.
/// </summary>
/// <remarks>
/// Anything else returns EINVAL rather than a zero-filled buffer. A caller
/// that receives success for an option nobody stored would treat whatever
/// happens to be in its output buffer as the real setting, which is a harder
/// failure to trace than an explicit rejection.
/// </remarks>
[SysAbiExport(
Nid = "6O8EwYOgH9Y",
ExportName = "getsockopt",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixGetsockopt(CpuContext ctx)
{
var id = unchecked((int)ctx[CpuRegister.Rdi]);
var level = unchecked((int)ctx[CpuRegister.Rsi]);
var option = unchecked((int)ctx[CpuRegister.Rdx]);
var valueAddress = ctx[CpuRegister.Rcx];
var lengthAddress = ctx[CpuRegister.R8];
if (!_sockets.TryGetValue(id, out var socket))
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
if (valueAddress == 0 || lengthAddress == 0 || level != 0xFFFF)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
Span<byte> lengthBytes = stackalloc byte[sizeof(int)];
if (!ctx.Memory.TryRead(lengthAddress, lengthBytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (BinaryPrimitives.ReadInt32LittleEndian(lengthBytes) < sizeof(int))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
int value;
switch (option)
{
// ORBIS_NET_SO_NBIO: mirrors what sceNetSetsockopt stored.
case 0x1200:
value = socket.Blocking ? 0 : 1;
break;
case 0x0004:
value = (int)socket.GetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress)! != 0 ? 1 : 0;
break;
// ORBIS_NET_SO_ERROR: nothing here records per-socket async errors,
// so report "no pending error" rather than inventing one.
case 0x1007:
value = 0;
break;
default:
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
Span<byte> valueBytes = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(valueBytes, value);
BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, sizeof(int));
if (!ctx.Memory.TryWrite(valueAddress, valueBytes) ||
!ctx.Memory.TryWrite(lengthAddress, lengthBytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
TraceNet("socket.getsockopt", id, unchecked((uint)option), unchecked((uint)value), 0);
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "fZOeZIOEmLw",
ExportName = "send",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixSend(CpuContext ctx)
{
var id = unchecked((int)ctx[CpuRegister.Rdi]);
var bufferAddress = ctx[CpuRegister.Rsi];
var length = unchecked((int)ctx[CpuRegister.Rdx]);
if (!_sockets.TryGetValue(id, out var socket))
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
if (length < 0 || (length != 0 && bufferAddress == 0))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (length == 0)
{
return ctx.SetReturn(0);
}
var payload = new byte[length];
if (!ctx.Memory.TryRead(bufferAddress, payload))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
try
{
var sent = socket.Send(payload, SocketFlags.None);
TraceNet("socket.send", id, unchecked((uint)length), unchecked((uint)sent), 0);
return ctx.SetReturn(sent);
}
catch (SocketException exception)
when (exception.SocketErrorCode == SocketError.WouldBlock)
{
return SetNetError(ctx, NetErrorWouldBlock, NetErrnoWouldBlock);
}
catch (SocketException)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
catch (ObjectDisposedException)
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
}
/// <summary>
/// Formats a binary address as text. Pure conversion with no socket state,
/// so it behaves identically to the console version for AF_INET/AF_INET6.
/// </summary>
[SysAbiExport(
Nid = "5jRCs2axtr4",
ExportName = "inet_ntop",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixInetNtop(CpuContext ctx)
{
var family = unchecked((int)ctx[CpuRegister.Rdi]);
var sourceAddress = ctx[CpuRegister.Rsi];
var destinationAddress = ctx[CpuRegister.Rdx];
var destinationSize = unchecked((int)ctx[CpuRegister.Rcx]);
if (sourceAddress == 0 || destinationAddress == 0 || destinationSize <= 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
// ORBIS_NET_AF_INET / ORBIS_NET_AF_INET6, matching TryMapAddressFamily.
var addressLength = family switch
{
2 => 4,
28 => 16,
_ => 0,
};
if (addressLength == 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var rawAddress = new byte[addressLength];
if (!ctx.Memory.TryRead(sourceAddress, rawAddress))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var text = new IPAddress(rawAddress).ToString();
var encoded = Encoding.ASCII.GetBytes(text);
// POSIX requires the terminator to fit as well; a truncated address string
// is worse than a reported failure because the caller cannot detect it.
if (encoded.Length + 1 > destinationSize)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var buffer = new byte[encoded.Length + 1];
encoded.CopyTo(buffer, 0);
if (!ctx.Memory.TryWrite(destinationAddress, buffer))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// inet_ntop returns the destination pointer on success.
ctx[CpuRegister.Rax] = destinationAddress;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "bErx49PgxyY",
ExportName = "sceNetBind",
@@ -549,6 +755,52 @@ public static class NetExports
return true;
}
[SysAbiExport(
Nid = "8Kcp5d-q1Uo",
ExportName = "sceNetInetPton",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNet")]
public static int NetInetPton(CpuContext ctx)
{
var addressFamily = unchecked((int)ctx[CpuRegister.Rdi]);
var sourceAddress = ctx[CpuRegister.Rsi];
var destinationAddress = ctx[CpuRegister.Rdx];
if (sourceAddress == 0 || destinationAddress == 0)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (!TryReadUtf8Z(ctx, sourceAddress, MaxNameLength, out var source))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
var family = addressFamily switch
{
2 => AddressFamily.InterNetwork, // AF_INET
28 => AddressFamily.InterNetworkV6, // AF_INET6
_ => AddressFamily.Unknown,
};
if (family == AddressFamily.Unknown ||
!IPAddress.TryParse(source, out var parsed) ||
parsed.AddressFamily != family)
{
// Match BSD inet_pton: return 0 for a parseable-family miss.
ctx[CpuRegister.Rax] = 0;
return 0;
}
var bytes = parsed.GetAddressBytes();
if (!ctx.Memory.TryWrite(destinationAddress, bytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
TraceNet("inet_pton", addressFamily, sourceAddress, destinationAddress, (ulong)bytes.Length);
ctx[CpuRegister.Rax] = 1;
return 1;
}
private static void TraceNet(string operation, int id, ulong arg0, ulong arg1, ulong arg2)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NET"), "1", StringComparison.Ordinal))
+17
View File
@@ -69,6 +69,23 @@ public static class NpManagerExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
/// <summary>
/// Accepts the reachability callback and never invokes it. Reachability
/// transitions only ever fire on a real PSN connection, which an offline
/// session does not have, so registering successfully and staying silent is
/// the accurate emulation of a signed-out console rather than a stub.
/// </summary>
[SysAbiExport(
Nid = "hw5KNqAAels",
ExportName = "sceNpRegisterNpReachabilityStateCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpManager")]
public static int NpRegisterNpReachabilityStateCallback(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "qQJfO8HAiaY",
ExportName = "sceNpRegisterStateCallbackA",
+19
View File
@@ -80,6 +80,25 @@ public static class NpTrophy2Exports
LibraryName = "libSceNpTrophy2")]
public static int NpTrophy2ShowTrophyList(CpuContext ctx) => ReturnOk(ctx);
/// <summary>
/// Gen5 ABI: context, handle, trophy id, then SceNpTrophy2Details and
/// SceNpTrophy2Data output pointers.
/// </summary>
/// <remarks>
/// Reports "no such trophy" rather than succeeding. Succeeding would require
/// filling both output structures, and their exact layouts are not confirmed
/// here — a title that trusted zeroed details would read an empty name and a
/// grade of zero as real data. NOT_FOUND is a documented outcome that callers
/// must already handle, so it degrades along a path the game tests.
/// </remarks>
[SysAbiExport(
Nid = "EwNylPdWUTM",
ExportName = "sceNpTrophy2GetTrophyInfo",
Target = Generation.Gen5,
LibraryName = "libSceNpTrophy2")]
public static int NpTrophy2GetTrophyInfo(CpuContext ctx) =>
SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
private static int WriteIdAndReturn(CpuContext ctx, ulong outAddress, ref int nextId)
{
if (outAddress == 0)
+98 -8
View File
@@ -10,6 +10,11 @@ public static class NpWebApi2Exports
private const int NpWebApi2ErrorInvalidArgument = unchecked((int)0x80553402);
private static int _initialized;
private static int _nextLibraryContextHandle;
private static int _nextPushEventHandle;
private static int _nextUserContextHandle = 1000;
private static readonly object _contextGate = new();
private static readonly HashSet<int> _libraryContexts = [];
[SysAbiExport(
Nid = "+o9816YQhqQ",
@@ -26,9 +31,28 @@ public static class NpWebApi2Exports
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var libraryContextId = CreateLibraryContextId();
Interlocked.Exchange(ref _initialized, 1);
TraceNpWebApi2("init", httpContextId, poolSize);
return ctx.SetReturn(0);
return ctx.SetReturn(libraryContextId);
}
[SysAbiExport(
Nid = "MsaFhR+lPE4",
ExportName = "sceNpWebApi2PushEventCreateFilter",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2PushEventCreateFilter(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var filterHandle = Interlocked.Increment(ref _nextPushEventHandle);
TraceNpWebApi2("push-event-create-filter", libraryContextId, (ulong)filterHandle);
return ctx.SetReturn(filterHandle);
}
[SysAbiExport(
@@ -38,9 +62,16 @@ public static class NpWebApi2Exports
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2InitializeAlt(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var handle = CreatePushEventHandle();
Interlocked.Exchange(ref _initialized, 1);
TraceNpWebApi2("init-alt", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
return ctx.SetReturn(0);
TraceNpWebApi2("init-alt", libraryContextId, 0);
return ctx.SetReturn(handle);
}
[SysAbiExport(
@@ -50,10 +81,23 @@ public static class NpWebApi2Exports
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2CreateUserContext(CpuContext ctx)
{
// No PSN backend: refuse user-context creation so the title's online
// layer backs off instead of driving a half-created context handle.
TraceNpWebApi2("create-user-context", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
var userId = unchecked((int)ctx[CpuRegister.Rsi]);
TraceNpWebApi2(
"create-user-context",
libraryContextId,
unchecked((uint)userId));
if (Volatile.Read(ref _initialized) == 0 ||
!IsValidLibraryContextId(libraryContextId) ||
userId == -1)
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var userContextId = Interlocked.Increment(ref _nextUserContextHandle);
return ctx.SetReturn(userContextId);
}
[SysAbiExport(
@@ -64,11 +108,57 @@ public static class NpWebApi2Exports
public static int NpWebApi2Terminate(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
Interlocked.Exchange(ref _initialized, 0);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
RemoveLibraryContextId(libraryContextId);
TraceNpWebApi2("term", libraryContextId, 0);
return ctx.SetReturn(0);
}
private static int CreateLibraryContextId()
{
var handle = Interlocked.Increment(ref _nextLibraryContextHandle);
lock (_contextGate)
{
_libraryContexts.Add(handle);
}
return handle;
}
private static int CreatePushEventHandle()
{
return Interlocked.Increment(ref _nextPushEventHandle);
}
private static bool IsValidLibraryContextId(int libraryContextId)
{
if (libraryContextId <= 0 || libraryContextId >= 0x8000)
{
return false;
}
lock (_contextGate)
{
return _libraryContexts.Contains(libraryContextId);
}
}
private static void RemoveLibraryContextId(int libraryContextId)
{
lock (_contextGate)
{
_libraryContexts.Remove(libraryContextId);
if (_libraryContexts.Count == 0)
{
Interlocked.Exchange(ref _initialized, 0);
}
}
}
private static void TraceNpWebApi2(string operation, int id, ulong arg0)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP_WEB_API2"), "1", StringComparison.Ordinal))
+50 -7
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
@@ -698,14 +698,22 @@ public static class PlayGoExports
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
if (!hasMetadata)
{
// No PlayGo sidecar: report a fully-installed single chunk. Available must
// stay true or scePlayGoOpen fails with NotSupportPlayGo (fatal PS5-component
// init failure for UE titles); chunk 0 reports LocalFast and every other id
// returns BAD_CHUNK_ID, terminating title-side chunk enumeration.
TracePlayGo("metadata_missing; fully-installed single chunk");
// No PlayGo sidecar: derive the installed chunk set from the pak files
// actually present on disk. A locally dumped title has all of its data
// installed, and a package that splits content across chunks names them
// pakchunk<N>-<platform>.pak, so those N are exactly the chunks that
// exist. Reporting only chunk 0 told such a title its remaining content
// was missing: The Invincible (PPSA06426) ships pakchunk0..8 and spun
// forever re-querying scePlayGoGetLocus for a chunk that never became
// available. Available must stay true or scePlayGoOpen fails with
// NotSupportPlayGo (fatal PS5-component init failure for UE titles).
// Ids outside the discovered set still return BAD_CHUNK_ID, so
// title-side chunk enumeration still terminates.
var installedChunkIds = DiscoverInstalledChunkIds(app0Root);
TracePlayGo($"metadata_missing; fully-installed chunks=[{string.Join(',', installedChunkIds)}]");
return new PlayGoMetadata(
true,
[(ushort)0],
installedChunkIds,
PlayGoChunkIdKnowledge.Authoritative);
}
@@ -718,6 +726,41 @@ public static class PlayGoExports
: PlayGoChunkIdKnowledge.Authoritative);
}
// Chunk ids for a title that ships no PlayGo sidecar, taken from the
// pakchunk<N>-<platform>.pak files on disk. Chunk 0 is always included: it
// is the base chunk and must resolve even for a title with no pak files at
// all (which keeps the single-chunk behaviour for such titles).
private static ushort[] DiscoverInstalledChunkIds(string app0Root)
{
var ids = new SortedSet<ushort> { 0 };
try
{
foreach (var pakFile in Directory.EnumerateFiles(app0Root, "pakchunk*.pak", SearchOption.AllDirectories))
{
var name = Path.GetFileNameWithoutExtension(pakFile);
var digits = name.AsSpan("pakchunk".Length);
var length = 0;
while (length < digits.Length && char.IsAsciiDigit(digits[length]))
{
length++;
}
if (length > 0 && ushort.TryParse(digits[..length], out var chunkId))
{
ids.Add(chunkId);
}
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return ids.ToArray();
}
private static ushort[] LoadChunkIds(string chunkDefsXml)
{
if (!File.Exists(chunkDefsXml))
+365
View File
@@ -0,0 +1,365 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Psml;
public static class PsmlExports
{
// Empirically for Astro Bot (PPSA21567):
// [0] must be 0x80 (sizeThis / r9); any other value в†’ Allocate length=0
// AllocateMainDirectMemory(length=[0]*[16], alignment=[8], type=0xC)
// So put desired byte size in [8] (becomes alignment) and page size in [16].
private const ulong RequirementStructSize = 0x80;
private const ulong SharedResourcesPageSize = 0x10000;
private const ulong SharedResourcesBufferSizeBytes = 0x2000000;
private const ulong SharedResourcesContextSizeBytes = 0x100000;
private const ulong ContextBufferSizeBytes = 0x800000;
private const ulong ContextBufferAuxSizeBytes = 0x100000;
private static int _mfsrInitialized;
private static readonly Lock SharedResourcesGate = new();
private static readonly Dictionary<ulong, SharedResourcesState> SharedResourcesByDescriptor = new();
private static readonly Lock ContextGate = new();
private static readonly Dictionary<ulong, ContextState> ContextsByAddress = new();
[SysAbiExport(
Nid = "3WVD91e12ZQ",
ExportName = "scePsmlMfsrInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrInit(CpuContext ctx)
{
var arg0 = ctx[CpuRegister.Rdi];
var arg1 = ctx[CpuRegister.Rsi];
var arg2 = ctx[CpuRegister.Rdx];
Interlocked.Exchange(ref _mfsrInitialized, 1);
TracePsml($"mfsr_init arg0=0x{arg0:X} arg1=0x{arg1:X} arg2=0x{arg2:X}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "+2KpvixvL6E",
ExportName = "scePsmlMfsrGetSharedResourcesInitRequirement",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrGetSharedResourcesInitRequirement(CpuContext ctx)
{
var bufferRequirementAddress = ctx[CpuRegister.Rdi];
var contextRequirementAddress = ctx[CpuRegister.Rsi];
var flags = ctx[CpuRegister.Rdx];
var configAddress = ctx[CpuRegister.Rcx];
if (bufferRequirementAddress == 0 || contextRequirementAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!WriteMemoryRequirement(ctx, bufferRequirementAddress, SharedResourcesBufferSizeBytes) ||
!WriteMemoryRequirement(ctx, contextRequirementAddress, SharedResourcesContextSizeBytes))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TracePsml(
$"mfsr_get_shared_resources_init_requirement buf=0x{bufferRequirementAddress:X16} " +
$"ctx=0x{contextRequirementAddress:X16} flags=0x{flags:X} config=0x{configAddress:X16} " +
$"buf_size=0x{SharedResourcesBufferSizeBytes:X} ctx_size=0x{SharedResourcesContextSizeBytes:X}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "eWoKNeB6V-k",
ExportName = "scePsmlMfsrCreateSharedResources",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrCreateSharedResources(CpuContext ctx)
{
var descriptorAddress = ctx[CpuRegister.Rdi];
var contextRequirementAddress = ctx[CpuRegister.Rsi];
var directMemoryAddress = ctx[CpuRegister.Rdx];
if (descriptorAddress == 0 || contextRequirementAddress == 0 || directMemoryAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var contextSizeBytes = ReadRequirementSize(ctx, contextRequirementAddress, SharedResourcesContextSizeBytes);
var state = new SharedResourcesState(
DescriptorAddress: descriptorAddress,
DirectMemoryAddress: directMemoryAddress,
BufferSizeBytes: SharedResourcesBufferSizeBytes,
ContextSizeBytes: contextSizeBytes,
PageSizeBytes: SharedResourcesPageSize);
if (!WriteSharedResourcesDescriptor(ctx, state))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (SharedResourcesGate)
{
SharedResourcesByDescriptor[descriptorAddress] = state;
}
TracePsml(
$"mfsr_create_shared_resources desc=0x{descriptorAddress:X16} req=0x{contextRequirementAddress:X16} " +
$"direct=0x{directMemoryAddress:X16} buf_size=0x{state.BufferSizeBytes:X} " +
$"ctx_size=0x{state.ContextSizeBytes:X} page=0x{state.PageSizeBytes:X}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "ArakEpzsZo0",
ExportName = "scePsmlMfsrGetContextBufferRequirement800M3_2",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrGetContextBufferRequirement800M3_2(CpuContext ctx)
{
var bufferRequirementAddress = ctx[CpuRegister.Rdi];
var contextRequirementAddress = ctx[CpuRegister.Rsi];
var directMemoryAddress = ctx[CpuRegister.Rdx];
if (bufferRequirementAddress == 0 || contextRequirementAddress == 0 || directMemoryAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!WriteMemoryRequirement(ctx, bufferRequirementAddress, ContextBufferSizeBytes) ||
!WriteMemoryRequirement(ctx, contextRequirementAddress, ContextBufferAuxSizeBytes))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TracePsml(
$"mfsr_get_context_buffer_requirement_800m3_2 buf=0x{bufferRequirementAddress:X16} " +
$"ctx=0x{contextRequirementAddress:X16} direct=0x{directMemoryAddress:X16} " +
$"buf_size=0x{ContextBufferSizeBytes:X} aux_size=0x{ContextBufferAuxSizeBytes:X}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "gxv3i+MTEzU",
ExportName = "scePsmlMfsrCreateContext800M3_2",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrCreateContext800M3_2(CpuContext ctx)
{
var contextAddress = ctx[CpuRegister.Rdi];
var requirementAddress = ctx[CpuRegister.Rsi];
var structSize = ctx[CpuRegister.Rdx];
var sharedDirectMemory = ctx[CpuRegister.Rcx];
var pageSize = ctx[CpuRegister.R8];
if (contextAddress == 0 || sharedDirectMemory == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var sharedState = TryFindSharedResources(sharedDirectMemory, contextAddress);
var effectiveStructSize = structSize != 0 ? structSize : RequirementStructSize;
var effectivePageSize = pageSize != 0 ? pageSize : SharedResourcesPageSize;
var sharedDescriptor = sharedState?.DescriptorAddress ?? contextAddress - 0x30;
var bufferSize = sharedState?.BufferSizeBytes ?? ContextBufferSizeBytes;
var state = new ContextState(
ContextAddress: contextAddress,
SharedResourcesDescriptor: sharedDescriptor,
DirectMemoryAddress: sharedDirectMemory,
BufferSizeBytes: bufferSize,
PageSizeBytes: effectivePageSize,
StructSizeBytes: effectiveStructSize);
if (!WriteContextObject(ctx, state))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (ContextGate)
{
ContextsByAddress[contextAddress] = state;
}
TracePsml(
$"mfsr_create_context_800m3_2 ctx=0x{contextAddress:X16} req=0x{requirementAddress:X16} " +
$"struct=0x{effectiveStructSize:X} direct=0x{sharedDirectMemory:X16} " +
$"shared_desc=0x{sharedDescriptor:X16} buf_size=0x{bufferSize:X} page=0x{effectivePageSize:X}");
return ctx.SetReturn(0);
}
private static bool WriteMemoryRequirement(CpuContext ctx, ulong address, ulong sizeBytes)
{
return ctx.TryWriteUInt64(address, RequirementStructSize) &&
ctx.TryWriteUInt64(address + 0x08, sizeBytes) &&
ctx.TryWriteUInt64(address + 0x10, SharedResourcesPageSize);
}
private static ulong ReadRequirementSize(CpuContext ctx, ulong address, ulong fallback)
{
if (!ctx.TryReadUInt64(address + 0x08, out var sizeBytes) || sizeBytes == 0)
{
return fallback;
}
// Guest requirement structs use size @8; reject pointer-like garbage.
return sizeBytes > 0x1000_0000UL ? fallback : sizeBytes;
}
private static SharedResourcesState? TryFindSharedResources(ulong directMemoryAddress, ulong contextAddress)
{
lock (SharedResourcesGate)
{
foreach (var state in SharedResourcesByDescriptor.Values)
{
if (state.DirectMemoryAddress == directMemoryAddress)
{
return state;
}
}
var inferredDescriptor = contextAddress >= 0x30 ? contextAddress - 0x30 : 0;
if (inferredDescriptor != 0 &&
SharedResourcesByDescriptor.TryGetValue(inferredDescriptor, out var byDescriptor))
{
return byDescriptor;
}
}
return null;
}
private static bool WriteContextObject(CpuContext ctx, ContextState state)
{
return ctx.TryWriteUInt64(state.ContextAddress + 0x00, state.StructSizeBytes) &&
ctx.TryWriteUInt64(state.ContextAddress + 0x08, state.DirectMemoryAddress) &&
ctx.TryWriteUInt64(state.ContextAddress + 0x10, state.SharedResourcesDescriptor) &&
ctx.TryWriteUInt64(state.ContextAddress + 0x18, state.BufferSizeBytes) &&
ctx.TryWriteUInt64(state.ContextAddress + 0x20, state.PageSizeBytes) &&
ctx.TryWriteUInt64(state.ContextAddress + 0x28, state.ContextAddress) &&
ctx.TryWriteUInt64(state.ContextAddress + 0x30, state.DirectMemoryAddress);
}
private static bool WriteSharedResourcesDescriptor(CpuContext ctx, SharedResourcesState state)
{
// Stamp a compact self-describing blob so follow-up PSML calls can treat the
// descriptor as initialized guest memory instead of an all-zero placeholder.
return ctx.TryWriteUInt64(state.DescriptorAddress + 0x00, RequirementStructSize) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x08, state.DirectMemoryAddress) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x10, state.DirectMemoryAddress) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x18, state.BufferSizeBytes) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x20, state.ContextSizeBytes) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x28, state.PageSizeBytes) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x30, state.DirectMemoryAddress) &&
ctx.TryWriteUInt64(state.DescriptorAddress + 0x38, state.BufferSizeBytes + state.ContextSizeBytes);
}
// Astro logo path: SizeInDwords then GetDispatchMfsrPacket900 (RUNLFro+qok).
// Unresolved 900 returns non-zero and trips GfxRenderStagePSSR.cpp:266.
private const int SoftPacketSizeInDwords = 0x80;
private const ulong SoftPacketSizeBytes = SoftPacketSizeInDwords * 4UL;
[SysAbiExport(
Nid = "AHalTX9wFZY",
ExportName = "scePsmlMfsrGetDispatchMfsrPacketSizeInDwords",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrGetDispatchMfsrPacketSizeInDwords(CpuContext ctx)
{
// Logo/PSSR path: guest asserts ret == 0, then calls GetDispatchMfsrPacket900
// with rdi=SoftPacketSizeInDwords (observed 0x80). Returning the size in rax
// tripped :266 and skipped the 900 call (tDISP-s6). SCE_OK keeps the chain.
var arg0 = ctx[CpuRegister.Rdi];
TracePsml(
$"mfsr_get_dispatch_packet_size_dwords arg0=0x{arg0:X} " +
$"size=0x{SoftPacketSizeInDwords:X} ret=0");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "RUNLFro+qok",
ExportName = "scePsmlMfsrGetDispatchMfsrPacket900",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrGetDispatchMfsrPacket900(CpuContext ctx) =>
SoftGetDispatchMfsrPacket(ctx, "900");
[SysAbiExport(
Nid = "s2psNHUIdjk",
ExportName = "scePsmlMfsrGetDispatchMfsrPacket1000",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrGetDispatchMfsrPacket1000(CpuContext ctx) =>
SoftGetDispatchMfsrPacket(ctx, "1000");
[SysAbiExport(
Nid = "94iBp3KvIuI",
ExportName = "scePsmlMfsrGetDispatchMfsrPacket1100",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePsml")]
public static int PsmlMfsrGetDispatchMfsrPacket1100(CpuContext ctx) =>
SoftGetDispatchMfsrPacket(ctx, "1100");
private static int SoftGetDispatchMfsrPacket(CpuContext ctx, string variant)
{
var arg0 = ctx[CpuRegister.Rdi];
var arg1 = ctx[CpuRegister.Rsi];
var arg2 = ctx[CpuRegister.Rdx];
var arg3 = ctx[CpuRegister.Rcx];
// Logo call shape (tDISP-s3): rdi=size_dwords (0x80), rsi/rdx = guest
// packet/param buffers. Clear the first mapped buffer arg.
var packetAddress = 0UL;
foreach (var candidate in new[] { arg1, arg2, arg3 })
{
if (candidate >= 0x10000)
{
packetAddress = candidate;
break;
}
}
var cleared = packetAddress != 0 && TryClearGuestBuffer(ctx, packetAddress, SoftPacketSizeBytes);
TracePsml(
$"mfsr_get_dispatch_packet_{variant} a0=0x{arg0:X16} a1=0x{arg1:X16} " +
$"a2=0x{arg2:X16} a3=0x{arg3:X16} packet=0x{packetAddress:X16} cleared={cleared}");
return ctx.SetReturn(0);
}
private static bool TryClearGuestBuffer(CpuContext ctx, ulong address, ulong length)
{
Span<byte> zeroes = stackalloc byte[4096];
zeroes.Clear();
for (ulong offset = 0; offset < length;)
{
var chunkSize = (int)Math.Min((ulong)zeroes.Length, length - offset);
if (!ctx.Memory.TryWrite(address + offset, zeroes[..chunkSize]))
{
return false;
}
offset += unchecked((uint)chunkSize);
}
return true;
}
private static void TracePsml(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PSML"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] psml.{message}");
}
}
private readonly record struct SharedResourcesState(
ulong DescriptorAddress,
ulong DirectMemoryAddress,
ulong BufferSizeBytes,
ulong ContextSizeBytes,
ulong PageSizeBytes);
private readonly record struct ContextState(
ulong ContextAddress,
ulong SharedResourcesDescriptor,
ulong DirectMemoryAddress,
ulong BufferSizeBytes,
ulong PageSizeBytes,
ulong StructSizeBytes);
}
+123 -24
View File
@@ -28,6 +28,7 @@ public static class SaveDataExports
private const ulong ResultInfosOffset = 0x20;
private const uint SortKeyFreeBlocks = 5;
private const uint SortOrderDescent = 1;
private const uint MountModeReadOnly = 1u << 0;
private const uint MountModeCreate = 1u << 2;
private const uint MountModeCreate2 = 1u << 5;
private const int MountResultSize = 0x40;
@@ -713,16 +714,43 @@ public static class SaveDataExports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (userId < 0 || string.IsNullOrWhiteSpace(dirName))
return MountSaveData(
ctx,
"mount3",
userId,
ResolveConfiguredTitleId(),
dirName,
blocks,
systemBlocks,
mountMode,
resource,
mode,
resultAddress);
}
private static int MountSaveData(
CpuContext ctx,
string operation,
int userId,
string titleId,
string dirName,
ulong blocks,
ulong systemBlocks,
uint mountMode,
uint resource,
uint mode,
ulong resultAddress)
{
if (userId < 0 || string.IsNullOrWhiteSpace(titleId) || string.IsNullOrWhiteSpace(dirName))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
try
{
var titleId = ResolveConfiguredTitleId();
var sanitizedTitleId = SanitizePathSegment(titleId.Trim());
var savePath = Path.Combine(
ResolveTitleSaveRoot(userId, titleId),
ResolveTitleSaveRoot(userId, sanitizedTitleId),
SanitizePathSegment(dirName));
var existed = Directory.Exists(savePath);
var create = (mountMode & MountModeCreate) != 0;
@@ -760,7 +788,7 @@ public static class SaveDataExports
}
TraceSaveData(
$"mount3 user={userId} title={titleId} dir={dirName} blocks={blocks} " +
$"{operation} user={userId} title={sanitizedTitleId} dir={dirName} blocks={blocks} " +
$"system_blocks={systemBlocks} mount_mode=0x{mountMode:X} resource={resource} mode={mode} " +
$"mount_point={mountPoint} created={!existed} root='{savePath}'");
return SetReturn(ctx, 0);
@@ -779,6 +807,52 @@ public static class SaveDataExports
}
}
[SysAbiExport(
Nid = "WAzWTZm1H+I",
ExportName = "sceSaveDataTransferringMount",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataTransferringMount(CpuContext ctx)
{
var mountAddress = ctx[CpuRegister.Rdi];
var resultAddress = ctx[CpuRegister.Rsi];
if (mountAddress == 0 || resultAddress == 0)
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
if (!TryReadInt32(ctx, mountAddress, out var userId) ||
!ctx.TryReadUInt64(mountAddress + 0x08, out var titleIdAddress) ||
!ctx.TryReadUInt64(mountAddress + 0x10, out var dirNameAddress) ||
titleIdAddress == 0 ||
dirNameAddress == 0 ||
!TryReadFixedAscii(ctx, titleIdAddress, SaveDataTitleIdSize, out var titleId) ||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return MountSaveData(
ctx,
"transferring_mount",
userId,
titleId,
dirName,
0,
0,
MountModeReadOnly,
0,
0,
resultAddress);
}
[SysAbiExport(
Nid = "RjMlsR8EXrw",
ExportName = "sceSaveDataTransferringMountPs4",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataTransferringMountPs4(CpuContext ctx) => SaveDataTransferringMount(ctx);
private static int _nextTransactionResource;
[SysAbiExport(
Nid = "gjRZNnw0JPE",
@@ -787,33 +861,48 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")]
public static int SaveDataCreateTransactionResource(CpuContext ctx)
{
// Demon's Souls first-run call:
// RDI = 0xC0000, RSI = RDX + 8, RDX = resource output.
// Writing integer handle 1 makes the title dereference [1 + 8],
// causing the repeatable access violation at guest address 0x9.
var desWorkSize = ctx[CpuRegister.Rdi];
var desWorkAddress = ctx[CpuRegister.Rsi];
var desResourceAddress = ctx[CpuRegister.Rdx];
if (desWorkSize == 0xC0000 &&
desResourceAddress != 0 &&
desResourceAddress <= ulong.MaxValue - sizeof(ulong) &&
desWorkAddress == desResourceAddress + sizeof(ulong))
{
if (!ctx.TryWriteUInt64(desResourceAddress, 0))
{
return SetReturn(
ctx,
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceSaveData(
$"create_transaction_resource_des_guard " +
$"work_size=0x{desWorkSize:X} " +
$"work=0x{desWorkAddress:X} " +
$"resource_addr=0x{desResourceAddress:X} resource=0x0");
return SetReturn(ctx, 0);
}
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var reserved = ctx[CpuRegister.Rsi];
var id = (uint)Interlocked.Increment(ref _nextTransactionResource);
// The resource-out pointer's argument slot varies by SDK revision: some
// callers pass it in rdx, others in rcx (a 4-arg form where rdx holds a
// count/flag). Void Terrarium passes rdx=0x1 (not a pointer) and the
// real out-pointer in rcx. Probe the plausible candidates and write the
// handle to the first writable one instead of faulting on a bad rdx.
// This is a stub-level create (matches shadPS4's return-OK semantics);
// never return MEMORY_FAULT for it, or the guest treats savedata init as
// failed and never advances.
// A small RDX value is a flag, and RCX contains the output address.
// A larger RDX value is the output address for the older ABI.
var resourceAddress = 0UL;
foreach (var candidate in new[]
{
ctx[CpuRegister.Rdx],
ctx[CpuRegister.Rcx],
ctx[CpuRegister.R8],
ctx[CpuRegister.R9],
})
var selectedAddress = SelectTransactionResourceAddress(
ctx[CpuRegister.Rdx],
ctx[CpuRegister.Rcx]);
if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id))
{
if (candidate != 0 && TryWriteUInt32(ctx, candidate, id))
{
resourceAddress = candidate;
break;
}
resourceAddress = selectedAddress;
}
TraceSaveData(
@@ -822,6 +911,16 @@ public static class SaveDataExports
return SetReturn(ctx, 0);
}
internal static ulong SelectTransactionResourceAddress(ulong rdx, ulong rcx)
{
if (rdx == 0)
{
return 0;
}
return rdx <= ushort.MaxValue ? rcx : rdx;
}
[SysAbiExport(
Nid = "lJUQuaKqoKY",
ExportName = "sceSaveDataDeleteTransactionResource",
+53
View File
@@ -12,6 +12,9 @@ public static class ShareExports
private static int _initialized;
private static string _contentParam = string.Empty;
private static readonly object _callbackGate = new();
private static ulong _contentEventCallback;
private static ulong _contentEventCallbackArgument;
[SysAbiExport(
Nid = "nBDD66kiFW8",
@@ -62,6 +65,56 @@ public static class ShareExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "Sygnk9dr5WQ",
ExportName = "sceShareRegisterContentEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceShareUtility")]
public static int ShareRegisterContentEventCallback(CpuContext ctx)
{
var callback = ctx[CpuRegister.Rdi];
var argument = ctx[CpuRegister.Rsi];
if (callback == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
lock (_callbackGate)
{
_contentEventCallback = callback;
_contentEventCallbackArgument = argument;
}
TraceShare($"register_content_event_callback fn=0x{callback:X16} arg=0x{argument:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "KnsfHKmZqFA",
ExportName = "sceShareUnregisterContentEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceShareUtility")]
public static int ShareUnregisterContentEventCallback(CpuContext ctx)
{
var callback = ctx[CpuRegister.Rdi];
if (callback == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
lock (_callbackGate)
{
if (_contentEventCallback == callback)
{
_contentEventCallback = 0;
_contentEventCallbackArgument = 0;
}
}
TraceShare($"unregister_content_event_callback fn=0x{callback:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
{
Span<byte> bytes = stackalloc byte[maxLength];
+1
View File
@@ -25,6 +25,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" />
<PackageReference Include="Silk.NET.Input" />
<PackageReference Include="Silk.NET.Vulkan" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
@@ -59,4 +59,65 @@ public static class GameServiceStubs
public static int NpUniversalDataSystemCreateEvent(CpuContext ctx) => OkWithHandle(ctx, CpuRegister.Rdi);
public static int NpUniversalDataSystemPostEvent(CpuContext ctx) => Ok(ctx);
public static int NpUniversalDataSystemDestroyEvent(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "47UAEuQl+iI", ExportName = "sceNpUniversalDataSystemTerminate",
Target = Generation.Gen5, LibraryName = "libSceNpUniversalDataSystem")]
public static int NpUniversalDataSystemTerminate(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "0HBYxYAjmf0", ExportName = "sceNpGameIntentTerminate",
Target = Generation.Gen5, LibraryName = "libSceNpGameIntent")]
public static int NpGameIntentTerminate(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "jqb7HntFQFc", ExportName = "sceWebBrowserDialogInitialize",
Target = Generation.Gen5, LibraryName = "libSceWebBrowserDialog")]
public static int WebBrowserDialogInitialize(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "ocHtyBwHfys", ExportName = "sceWebBrowserDialogTerminate",
Target = Generation.Gen5, LibraryName = "libSceWebBrowserDialog")]
public static int WebBrowserDialogTerminate(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "kvYEw2lBndk", ExportName = "sceGameLiveStreamingInitialize",
Target = Generation.Gen5, LibraryName = "libSceGameLiveStreaming")]
public static int GameLiveStreamingInitialize(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "isruqthpYcw", ExportName = "sceSharePlayInitialize",
Target = Generation.Gen5, LibraryName = "libSceSharePlay")]
public static int SharePlayInitialize(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "0IL1keINExQ", ExportName = "sceShareTerminate",
Target = Generation.Gen5, LibraryName = "libSceShareUtility")]
public static int ShareTerminate(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "YBiIdcDPrxs", ExportName = "sceShareFeaturePermit",
Target = Generation.Gen5, LibraryName = "libSceShareUtility")]
public static int ShareFeaturePermit(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "9TrhuGzberQ", ExportName = "sceVoiceInit",
Target = Generation.Gen5, LibraryName = "libSceVoice")]
public static int VoiceInit(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "clyKUyi3RYU", ExportName = "sceVoiceSetThreadsParams",
Target = Generation.Gen5, LibraryName = "libSceVoice")]
public static int VoiceSetThreadsParams(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "dPj4ZtRcIWk", ExportName = "sceContentSearchInit",
Target = Generation.Gen5, LibraryName = "libSceContentSearch")]
public static int ContentSearchInit(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "zoxb0wEChEM", ExportName = "sceContentDeleteInitialize",
Target = Generation.Gen5, LibraryName = "libSceContentDelete")]
public static int ContentDeleteInitialize(CpuContext ctx) => Ok(ctx);
[SysAbiExport(Nid = "Fc8qxlKINYQ", ExportName = "sceVideoRecordingSetInfo",
Target = Generation.Gen5, LibraryName = "libSceVideoRecording")]
public static int VideoRecordingSetInfo(CpuContext ctx) => Ok(ctx);
// Captured from GTA V Enhanced (PPSA04264); not in the public NID catalog.
// Side-effect-free success — same as unresolved stub behavior that kept boot
// moving; reverse the ABI before writing guest memory.
#pragma warning disable SHEM006
[SysAbiExport(Nid = "Ikfdt-rIqCE", ExportName = "sceUnknownIkfdt",
Target = Generation.Gen5, LibraryName = "libKernel")]
public static int UnknownIkfdt(CpuContext ctx) => Ok(ctx);
#pragma warning restore SHEM006
}
@@ -18,6 +18,7 @@ public static class SystemServiceExports
private const int TitleIdFieldSize = 0x10;
private static string? _mainAppTitleId;
private static int _noticeScreenSkipFlag;
public static void ConfigureApplicationInfo(string? titleId)
{
@@ -37,14 +38,40 @@ public static class SystemServiceExports
return ctx.SetReturn(OrbisSystemServiceErrorParameter);
}
// No system notice screen to skip in the emulator; report "do not skip".
// Keep the flag state even though the emulator does not display the
// system notice screen. Titles use this service as a normal preference
// store and expect a later get to observe the value they set.
Span<byte> flagBytes = stackalloc byte[1];
flagBytes[0] = 0;
flagBytes[0] = unchecked((byte)Volatile.Read(ref _noticeScreenSkipFlag));
return ctx.Memory.TryWrite(flagAddress, flagBytes)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "8Lo6Zv94aho",
ExportName = "sceSystemServiceDisableNoticeScreenSkipFlagAutoSet",
Target = Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceDisableNoticeScreenSkipFlagAutoSet(CpuContext ctx) =>
ctx.SetReturn(0);
// Settings entry calls this immediately before spawning SaveModTime/Load
// threads. An unresolved stub returns NOT_FOUND and the title can stall in
// that path; accept the write and report success.
[SysAbiExport(
Nid = "Q3utJvma4Mo",
ExportName = "sceSystemServiceSetNoticeScreenSkipFlag",
Target = Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceSetNoticeScreenSkipFlag(CpuContext ctx)
{
// The native API takes the flag value in the first argument. Treat any
// non-zero value as true, matching the bool-like PS5 ABI.
Volatile.Write(ref _noticeScreenSkipFlag, ctx[CpuRegister.Rdi] != 0 ? 1 : 0);
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "4veE0XiIugA",
ExportName = "sceSystemServiceGetMainAppTitleId",
@@ -212,4 +239,7 @@ public static class SystemServiceExports
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceReportAbnormalTermination(CpuContext ctx) => ctx.SetReturn(0);
internal static void ResetForTests() =>
Volatile.Write(ref _noticeScreenSkipFlag, 0);
}
@@ -118,7 +118,7 @@ public static class UserServiceExports
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var nameAddress = ctx[CpuRegister.Rsi];
var capacity = ctx[CpuRegister.Rdx];
if (userId != PrimaryUserId)
if (userId != PrimaryUserId && userId != 1)
{
return SetReturn(ctx, OrbisUserServiceErrorInvalidParameter);
}
@@ -144,6 +144,16 @@ public static class UserServiceExports
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// Title-captured alias NID for the same username query.
#pragma warning disable SHEM004
[SysAbiExport(
Nid = "znaWI0gpuo8",
ExportName = "sceUserServiceGetUserName",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceUserService")]
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
#pragma warning restore SHEM004
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
#pragma warning disable SHEM006
[SysAbiExport(
@@ -0,0 +1,894 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Numerics;
using System.Runtime.CompilerServices;
using SharpEmu.Libs.Agc;
using SharpEmu.ShaderCompiler.Vulkan;
using Silk.NET.Vulkan;
using VkBuffer = Silk.NET.Vulkan.Buffer;
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// Self-contained GPU deswizzle pass: runs the ExactXor detile equation from
/// <see cref="GnmTiling.GetDetileParams"/> as a Vulkan compute shader
/// (<see cref="SpirvFixedShaders.CreateDetileCompute"/>), writing a linear buffer
/// and copying it into a sampled image — the GPU equivalent of the CPU
/// <c>GnmTiling.TryDetile</c> + staging upload.
///
/// Two entry points share the same (verified) recording:
/// <see cref="DetileIntoImage"/> is a self-contained one-shot (submit + wait) used
/// by the isolation self-test; <see cref="RecordDetile"/> records into a caller's
/// command buffer and hands back its transient buffers + descriptor pool for the
/// caller to retire with that command buffer's fence — the render-path variant,
/// which must never block the render thread.
///
/// Only ExactXor 4-bytes/element surfaces are handled; <see cref="Supports"/> lets
/// the caller fall back to the CPU path for everything else.
/// </summary>
internal sealed unsafe class VulkanDetilePass : IDisposable
{
private const uint LocalSize = 8;
private const uint PushConstantBytes = 11 * sizeof(uint);
private readonly Vk _vk;
private readonly Device _device;
private readonly Queue _queue;
private readonly PhysicalDevice _physicalDevice;
private readonly uint _queueFamilyIndex;
private ShaderModule _shaderModule;
private DescriptorSetLayout _descriptorSetLayout;
private PipelineLayout _pipelineLayout;
private Pipeline _pipeline;
private CommandPool _commandPool;
private bool _initialized;
private bool _disposed;
private PhysicalDeviceMemoryProperties _memoryProperties;
private bool _memoryPropertiesLoaded;
private readonly Dictionary<int[], TermBuffer> _xorTermBuffers = new(ReferenceComparer.Instance);
private readonly Dictionary<int[], TermBuffer> _blockTermBuffers = new(ReferenceComparer.Instance);
private TermBuffer _placeholderTermBuffer;
private readonly Dictionary<(ulong Bucket, bool HostVisible), Stack<Allocation>> _bufferPool = new();
private readonly List<Allocation> _allAllocations = new();
private readonly Stack<DescriptorSet> _freeDescriptorSets = new();
private readonly List<DescriptorPool> _descriptorPools = new();
private const uint DescriptorSetsPerPool = 64;
private const ulong MinimumBufferBucket = 4096;
public VulkanDetilePass(
Vk vk,
Device device,
Queue queue,
PhysicalDevice physicalDevice,
uint queueFamilyIndex)
{
_vk = vk;
_device = device;
_queue = queue;
_physicalDevice = physicalDevice;
_queueFamilyIndex = queueFamilyIndex;
}
/// <summary>
/// The kernel handles the exact-XOR and block-table modes at 4/8/16
/// bytes-per-element (one, two, or four 32-bit words per element). 1/2 bpp are
/// sub-word and stay on the CPU.
/// </summary>
public static bool Supports(in DetileParams parameters) =>
(parameters.Equation == DetileEquation.ExactXor ||
parameters.Equation == DetileEquation.BlockTable) &&
parameters.BytesPerElement is 4 or 8 or 16;
/// <summary>Opaque handle to the pooled resources one recorded detile is using.
/// The caller hands it back to <see cref="Retire"/> once the command buffer they
/// were recorded into has completed; nothing is destroyed, the buffers and the
/// descriptor set return to this pass's free lists for the next texture.</summary>
public sealed class Transients
{
internal static readonly Transients Empty = new();
internal Allocation Tiled;
internal Allocation Output;
internal DescriptorSet Set;
internal bool Rented;
}
internal readonly record struct Allocation(
VkBuffer Buffer,
DeviceMemory Memory,
ulong Capacity,
bool HostVisible);
private readonly record struct TermBuffer(VkBuffer Buffer, ulong ByteSize);
private struct DetileResources
{
public Allocation Tiled;
public Allocation Output;
public DescriptorSet Set;
public ulong OutputBytes;
public uint SrcSliceElements;
public uint EquationValue;
public uint UintsPerElement;
}
private sealed class ReferenceComparer : IEqualityComparer<int[]>
{
public static readonly ReferenceComparer Instance = new();
public bool Equals(int[]? x, int[]? y) => ReferenceEquals(x, y);
public int GetHashCode(int[] obj) => RuntimeHelpers.GetHashCode(obj);
}
/// <summary>
/// Records the deswizzle of <paramref name="tiled"/> into <paramref name="image"/>
/// (<paramref name="texelWidth"/> x <paramref name="texelHeight"/> texels x
/// <paramref name="layers"/> array slices, currently in
/// <paramref name="currentLayout"/>) onto <paramref name="commandBuffer"/>,
/// leaving the image <see cref="ImageLayout.ShaderReadOnlyOptimal"/>. The kernel
/// iterates the element grid from <paramref name="parameters"/> (for
/// block-compressed formats a 4x4 block is one element, so the element grid is
/// smaller than the texel grid). The tiled buffer holds the array slices packed
/// contiguously (each an independently tiled 2D surface). Does not submit; the
/// caller retires <paramref name="transients"/> with the command buffer's fence.
/// Returns false (with empty transients) when unsupported.
/// </summary>
public bool RecordDetile(
CommandBuffer commandBuffer,
Image image,
ImageLayout currentLayout,
uint texelWidth,
uint texelHeight,
uint layers,
ReadOnlySpan<byte> tiled,
in DetileParams parameters,
out Transients transients)
{
transients = Transients.Empty;
if (_disposed || !Supports(parameters) || texelWidth == 0 || texelHeight == 0 || layers == 0 ||
tiled.IsEmpty || tiled.Length % (int)(layers * (uint)parameters.BytesPerElement) != 0)
{
return false;
}
EnsurePipeline();
var resources = default(DetileResources);
try
{
PrepareResources(tiled, parameters, layers, ref resources);
RecordCommands(commandBuffer, in resources, image, currentLayout, texelWidth, texelHeight, layers, in parameters);
}
catch
{
ReleaseResources(in resources);
throw;
}
transients = new Transients
{
Tiled = resources.Tiled,
Output = resources.Output,
Set = resources.Set,
Rented = true,
};
return true;
}
public void Retire(Transients transients)
{
if (_disposed || transients is null || !transients.Rented)
{
return;
}
transients.Rented = false;
ReturnBuffer(transients.Tiled);
ReturnBuffer(transients.Output);
_freeDescriptorSets.Push(transients.Set);
}
/// <summary>
/// One-shot variant used by the isolation self-test: records the detile onto a
/// private command buffer, submits, waits, and frees every transient. Never
/// call this on the render thread — its blocking wait would deadlock the
/// present pipeline; use <see cref="RecordDetile"/> there.
/// </summary>
public bool DetileIntoImage(
Image image,
ImageLayout currentLayout,
uint texelWidth,
uint texelHeight,
uint layers,
ReadOnlySpan<byte> tiled,
in DetileParams parameters)
{
if (_disposed || !Supports(parameters) || texelWidth == 0 || texelHeight == 0 || layers == 0 ||
tiled.IsEmpty || tiled.Length % (int)(layers * (uint)parameters.BytesPerElement) != 0)
{
return false;
}
EnsurePipeline();
var resources = default(DetileResources);
CommandBuffer commandBuffer = default;
Fence fence = default;
try
{
PrepareResources(tiled, parameters, layers, ref resources);
commandBuffer = AllocateCommandBuffer();
BeginCommandBuffer(commandBuffer);
RecordCommands(commandBuffer, in resources, image, currentLayout, texelWidth, texelHeight, layers, in parameters);
Check(_vk.EndCommandBuffer(commandBuffer), "vkEndCommandBuffer(detile)");
fence = CreateFence();
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
};
Check(_vk.QueueSubmit(_queue, 1, &submitInfo, fence), "vkQueueSubmit(detile)");
Check(_vk.WaitForFences(_device, 1, &fence, true, ulong.MaxValue), "vkWaitForFences(detile)");
return true;
}
finally
{
if (fence.Handle != 0)
{
_vk.DestroyFence(_device, fence, null);
}
if (commandBuffer.Handle != 0)
{
_vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer);
}
ReleaseResources(in resources);
}
}
private void PrepareResources(ReadOnlySpan<byte> tiled, in DetileParams parameters, uint layers, ref DetileResources resources)
{
// Binding 1 carries the within-block offset table, binding 2 the Y terms.
// ExactXor: xTerm/yTerm are byte offsets; the kernel indexes a uint[], so it
// wants element offsets — for a power-of-two element size the low
// log2(bpp) bits of every term are 0, so the right shift is exact.
// BlockTable: GetDetileParams' block table is already element offsets; it
// goes in binding 1 and binding 2 is an unused placeholder.
TermBuffer xTerm;
TermBuffer yTerm;
if (parameters.Equation == DetileEquation.BlockTable)
{
xTerm = GetTermBuffer(_blockTermBuffers, parameters.BlockTable, shift: 0);
yTerm = GetPlaceholderTermBuffer();
resources.EquationValue = 1;
}
else
{
var shift = BitOperations.TrailingZeroCount((uint)parameters.BytesPerElement);
xTerm = GetTermBuffer(_xorTermBuffers, parameters.XByteTerm, shift);
yTerm = GetTermBuffer(_xorTermBuffers, parameters.YByteTerm, shift);
resources.EquationValue = 0;
}
// The array slices are packed contiguously in the tiled buffer, so each
// slice's element stride is the whole tiled buffer split evenly by layer.
// Element sizes are in bytes-per-element; the kernel moves bpp/4 words each.
var bytesPerElement = (uint)parameters.BytesPerElement;
resources.UintsPerElement = bytesPerElement / sizeof(uint);
resources.SrcSliceElements = (uint)((ulong)tiled.Length / bytesPerElement / layers);
resources.OutputBytes =
(ulong)parameters.ElementsWide * (ulong)parameters.ElementsHigh * bytesPerElement * layers;
resources.Tiled = RentBuffer((ulong)tiled.Length, hostVisible: true);
UploadBytes(resources.Tiled.Memory, tiled);
resources.Output = RentBuffer(resources.OutputBytes, hostVisible: false);
resources.Set = RentDescriptorSet();
WriteDescriptors(
resources.Set,
(resources.Tiled.Buffer, (ulong)tiled.Length),
(xTerm.Buffer, xTerm.ByteSize),
(yTerm.Buffer, yTerm.ByteSize),
(resources.Output.Buffer, resources.OutputBytes));
}
private TermBuffer GetTermBuffer(Dictionary<int[], TermBuffer> cache, int[] table, int shift)
{
if (cache.TryGetValue(table, out var cached))
{
return cached;
}
var terms = ToElementTerms(table, shift);
var byteSize = (ulong)terms.Length * sizeof(uint);
var allocation = CreateBuffer(byteSize, hostVisible: true);
UploadUInts(allocation.Memory, terms);
var termBuffer = new TermBuffer(allocation.Buffer, byteSize);
cache[table] = termBuffer;
return termBuffer;
}
private TermBuffer GetPlaceholderTermBuffer()
{
if (_placeholderTermBuffer.Buffer.Handle != 0)
{
return _placeholderTermBuffer;
}
var allocation = CreateBuffer(sizeof(uint), hostVisible: true);
UploadUInts(allocation.Memory, [0u]);
_placeholderTermBuffer = new TermBuffer(allocation.Buffer, sizeof(uint));
return _placeholderTermBuffer;
}
private static ulong BucketFor(ulong size)
{
var bucket = MinimumBufferBucket;
while (bucket < size)
{
bucket <<= 1;
}
return bucket;
}
private Allocation RentBuffer(ulong size, bool hostVisible)
{
var bucket = BucketFor(size);
if (_bufferPool.TryGetValue((bucket, hostVisible), out var free) && free.Count > 0)
{
return free.Pop();
}
return CreateBuffer(bucket, hostVisible);
}
private void ReturnBuffer(Allocation allocation)
{
if (allocation.Buffer.Handle == 0)
{
return;
}
var key = (allocation.Capacity, allocation.HostVisible);
if (!_bufferPool.TryGetValue(key, out var free))
{
free = new Stack<Allocation>();
_bufferPool[key] = free;
}
free.Push(allocation);
}
private DescriptorSet RentDescriptorSet()
{
if (_freeDescriptorSets.Count > 0)
{
return _freeDescriptorSets.Pop();
}
var poolSize = new DescriptorPoolSize
{
Type = DescriptorType.StorageBuffer,
DescriptorCount = 4 * DescriptorSetsPerPool,
};
var poolInfo = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = DescriptorSetsPerPool,
PoolSizeCount = 1,
PPoolSizes = &poolSize,
};
Check(
_vk.CreateDescriptorPool(_device, &poolInfo, null, out var pool),
"vkCreateDescriptorPool(detile)");
_descriptorPools.Add(pool);
var layouts = stackalloc DescriptorSetLayout[(int)DescriptorSetsPerPool];
for (var index = 0; index < DescriptorSetsPerPool; index++)
{
layouts[index] = _descriptorSetLayout;
}
var sets = stackalloc DescriptorSet[(int)DescriptorSetsPerPool];
var allocateInfo = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = pool,
DescriptorSetCount = DescriptorSetsPerPool,
PSetLayouts = layouts,
};
Check(
_vk.AllocateDescriptorSets(_device, &allocateInfo, sets),
"vkAllocateDescriptorSets(detile)");
for (var index = 0; index < DescriptorSetsPerPool; index++)
{
_freeDescriptorSets.Push(sets[index]);
}
return _freeDescriptorSets.Pop();
}
private void ReleaseResources(in DetileResources resources)
{
ReturnBuffer(resources.Tiled);
ReturnBuffer(resources.Output);
if (resources.Set.Handle != 0)
{
_freeDescriptorSets.Push(resources.Set);
}
}
private void RecordCommands(
CommandBuffer commandBuffer,
in DetileResources resources,
Image image,
ImageLayout currentLayout,
uint texelWidth,
uint texelHeight,
uint layers,
in DetileParams parameters)
{
// The kernel iterates the element grid (smaller than the texel grid for
// block-compressed formats); the image copy below uses the texel grid.
var elementsWide = (uint)parameters.ElementsWide;
var elementsHigh = (uint)parameters.ElementsHigh;
var descriptorSet = resources.Set;
_vk.CmdBindPipeline(commandBuffer, PipelineBindPoint.Compute, _pipeline);
_vk.CmdBindDescriptorSets(
commandBuffer, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &descriptorSet, 0, null);
Span<uint> push =
[
elementsWide,
elementsHigh,
(uint)parameters.BlockWidth,
(uint)parameters.BlockHeight,
(uint)parameters.BlockElements,
(uint)parameters.BlocksPerRow,
(uint)parameters.XMask,
(uint)parameters.YMask,
resources.SrcSliceElements,
resources.EquationValue,
resources.UintsPerElement,
];
fixed (uint* pushPointer = push)
{
_vk.CmdPushConstants(
commandBuffer, _pipelineLayout, ShaderStageFlags.ComputeBit, 0, PushConstantBytes, pushPointer);
}
// X is widened by uintsPerElement (each thread copies one word); one
// dispatch-Z layer per array slice.
_vk.CmdDispatch(
commandBuffer,
(elementsWide * resources.UintsPerElement + LocalSize - 1) / LocalSize,
(elementsHigh + LocalSize - 1) / LocalSize,
layers);
// Compute store -> transfer read on the linear output buffer.
var outputBarrier = new BufferMemoryBarrier
{
SType = StructureType.BufferMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Buffer = resources.Output.Buffer,
Offset = 0,
Size = resources.OutputBytes,
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.ComputeShaderBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
1,
&outputBarrier,
0,
null);
var initialized = currentLayout == ImageLayout.ShaderReadOnlyOptimal;
TransitionImage(
commandBuffer,
image,
currentLayout,
ImageLayout.TransferDstOptimal,
initialized ? AccessFlags.ShaderReadBit : 0,
AccessFlags.TransferWriteBit,
initialized ? PipelineStageFlags.FragmentShaderBit : PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
layers);
// The output buffer is layer-major, tightly packed (BufferRowLength 0 =>
// one element-row per texel-row, which for compressed formats is the block
// row), so a single copy fills every array layer. Extent is in texels.
var copyRegion = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, layers),
ImageOffset = default,
ImageExtent = new Extent3D(texelWidth, texelHeight, 1),
};
_vk.CmdCopyBufferToImage(
commandBuffer, resources.Output.Buffer, image, ImageLayout.TransferDstOptimal, 1, &copyRegion);
TransitionImage(
commandBuffer,
image,
ImageLayout.TransferDstOptimal,
ImageLayout.ShaderReadOnlyOptimal,
AccessFlags.TransferWriteBit,
AccessFlags.ShaderReadBit,
PipelineStageFlags.TransferBit,
PipelineStageFlags.FragmentShaderBit,
layers);
}
private void EnsurePipeline()
{
if (_initialized)
{
return;
}
var spirv = SpirvFixedShaders.CreateDetileCompute();
fixed (byte* code = spirv)
{
var moduleInfo = new ShaderModuleCreateInfo
{
SType = StructureType.ShaderModuleCreateInfo,
CodeSize = (nuint)spirv.Length,
PCode = (uint*)code,
};
Check(
_vk.CreateShaderModule(_device, &moduleInfo, null, out _shaderModule),
"vkCreateShaderModule(detile)");
}
var bindings = stackalloc DescriptorSetLayoutBinding[4];
for (uint index = 0; index < 4; index++)
{
bindings[index] = new DescriptorSetLayoutBinding
{
Binding = index,
DescriptorType = DescriptorType.StorageBuffer,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.ComputeBit,
};
}
var layoutInfo = new DescriptorSetLayoutCreateInfo
{
SType = StructureType.DescriptorSetLayoutCreateInfo,
BindingCount = 4,
PBindings = bindings,
};
Check(
_vk.CreateDescriptorSetLayout(_device, &layoutInfo, null, out _descriptorSetLayout),
"vkCreateDescriptorSetLayout(detile)");
var pushRange = new PushConstantRange
{
StageFlags = ShaderStageFlags.ComputeBit,
Offset = 0,
Size = PushConstantBytes,
};
var setLayout = _descriptorSetLayout;
var pipelineLayoutInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
SetLayoutCount = 1,
PSetLayouts = &setLayout,
PushConstantRangeCount = 1,
PPushConstantRanges = &pushRange,
};
Check(
_vk.CreatePipelineLayout(_device, &pipelineLayoutInfo, null, out _pipelineLayout),
"vkCreatePipelineLayout(detile)");
ReadOnlySpan<byte> entryPoint = "main\0"u8;
fixed (byte* entry = entryPoint)
{
var pipelineInfo = new ComputePipelineCreateInfo
{
SType = StructureType.ComputePipelineCreateInfo,
Layout = _pipelineLayout,
Stage = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.ComputeBit,
Module = _shaderModule,
PName = entry,
},
};
Check(
_vk.CreateComputePipelines(_device, default, 1, &pipelineInfo, null, out _pipeline),
"vkCreateComputePipelines(detile)");
}
var poolInfo = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = _queueFamilyIndex,
Flags = CommandPoolCreateFlags.ResetCommandBufferBit,
};
Check(
_vk.CreateCommandPool(_device, &poolInfo, null, out _commandPool),
"vkCreateCommandPool(detile)");
_initialized = true;
}
private static uint[] ToElementTerms(int[] byteTerms, int shift)
{
var terms = new uint[byteTerms.Length];
for (var index = 0; index < byteTerms.Length; index++)
{
terms[index] = (uint)byteTerms[index] >> shift;
}
return terms;
}
private Allocation CreateBuffer(ulong size, bool hostVisible)
{
var bufferInfo = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = size,
Usage = BufferUsageFlags.StorageBufferBit | BufferUsageFlags.TransferSrcBit,
SharingMode = SharingMode.Exclusive,
};
Check(_vk.CreateBuffer(_device, &bufferInfo, null, out var buffer), "vkCreateBuffer(detile)");
_vk.GetBufferMemoryRequirements(_device, buffer, out var requirements);
var required = hostVisible
? MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit
: MemoryPropertyFlags.DeviceLocalBit;
var allocateInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, required, hostVisible),
};
Check(_vk.AllocateMemory(_device, &allocateInfo, null, out var memory), "vkAllocateMemory(detile)");
Check(_vk.BindBufferMemory(_device, buffer, memory, 0), "vkBindBufferMemory(detile)");
var allocation = new Allocation(buffer, memory, size, hostVisible);
_allAllocations.Add(allocation);
return allocation;
}
private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags, bool hostVisible)
{
if (!_memoryPropertiesLoaded)
{
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out _memoryProperties);
_memoryPropertiesLoaded = true;
}
fixed (PhysicalDeviceMemoryProperties* properties = &_memoryProperties)
{
var memoryTypes = &properties->MemoryTypes.Element0;
for (uint index = 0; index < properties->MemoryTypeCount; index++)
{
if ((typeBits & (1u << (int)index)) != 0 &&
(memoryTypes[index].PropertyFlags & requiredFlags) == requiredFlags)
{
return index;
}
}
if (!hostVisible)
{
for (uint index = 0; index < properties->MemoryTypeCount; index++)
{
if ((typeBits & (1u << (int)index)) != 0)
{
return index;
}
}
}
}
throw new InvalidOperationException("No compatible Vulkan memory type for detile.");
}
private void UploadBytes(DeviceMemory memory, ReadOnlySpan<byte> data)
{
void* mapped;
Check(_vk.MapMemory(_device, memory, 0, (ulong)data.Length, 0, &mapped), "vkMapMemory(detile)");
data.CopyTo(new Span<byte>(mapped, data.Length));
_vk.UnmapMemory(_device, memory);
}
private void UploadUInts(DeviceMemory memory, uint[] data)
{
void* mapped;
var byteCount = (ulong)data.Length * sizeof(uint);
Check(_vk.MapMemory(_device, memory, 0, byteCount, 0, &mapped), "vkMapMemory(detile terms)");
data.AsSpan().CopyTo(new Span<uint>(mapped, data.Length));
_vk.UnmapMemory(_device, memory);
}
private void WriteDescriptors(
DescriptorSet descriptorSet,
(VkBuffer Buffer, ulong Size) binding0,
(VkBuffer Buffer, ulong Size) binding1,
(VkBuffer Buffer, ulong Size) binding2,
(VkBuffer Buffer, ulong Size) binding3)
{
var buffers = stackalloc DescriptorBufferInfo[4]
{
new DescriptorBufferInfo { Buffer = binding0.Buffer, Offset = 0, Range = binding0.Size },
new DescriptorBufferInfo { Buffer = binding1.Buffer, Offset = 0, Range = binding1.Size },
new DescriptorBufferInfo { Buffer = binding2.Buffer, Offset = 0, Range = binding2.Size },
new DescriptorBufferInfo { Buffer = binding3.Buffer, Offset = 0, Range = binding3.Size },
};
var writes = stackalloc WriteDescriptorSet[4];
for (uint index = 0; index < 4; index++)
{
writes[index] = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = descriptorSet,
DstBinding = index,
DstArrayElement = 0,
DescriptorCount = 1,
DescriptorType = DescriptorType.StorageBuffer,
PBufferInfo = &buffers[index],
};
}
_vk.UpdateDescriptorSets(_device, 4, writes, 0, null);
}
private CommandBuffer AllocateCommandBuffer()
{
var allocateInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = _commandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1,
};
Check(
_vk.AllocateCommandBuffers(_device, &allocateInfo, out var commandBuffer),
"vkAllocateCommandBuffers(detile)");
return commandBuffer;
}
private void BeginCommandBuffer(CommandBuffer commandBuffer)
{
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(_vk.BeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer(detile)");
}
private void TransitionImage(
CommandBuffer commandBuffer,
Image image,
ImageLayout oldLayout,
ImageLayout newLayout,
AccessFlags srcAccess,
AccessFlags dstAccess,
PipelineStageFlags srcStage,
PipelineStageFlags dstStage,
uint layers)
{
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = srcAccess,
DstAccessMask = dstAccess,
OldLayout = oldLayout,
NewLayout = newLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, layers),
};
_vk.CmdPipelineBarrier(commandBuffer, srcStage, dstStage, 0, 0, null, 0, null, 1, &barrier);
}
private Fence CreateFence()
{
var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo };
Check(_vk.CreateFence(_device, &fenceInfo, null, out var fence), "vkCreateFence(detile)");
return fence;
}
private void DestroyBuffer(VkBuffer buffer, DeviceMemory memory)
{
if (buffer.Handle != 0)
{
_vk.DestroyBuffer(_device, buffer, null);
}
if (memory.Handle != 0)
{
_vk.FreeMemory(_device, memory, null);
}
}
private void Check(Result result, string operation)
{
if (result != Result.Success)
{
throw new InvalidOperationException($"{operation} failed: {result}");
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var allocation in _allAllocations)
{
DestroyBuffer(allocation.Buffer, allocation.Memory);
}
_allAllocations.Clear();
_bufferPool.Clear();
_xorTermBuffers.Clear();
_blockTermBuffers.Clear();
_placeholderTermBuffer = default;
_freeDescriptorSets.Clear();
foreach (var pool in _descriptorPools)
{
_vk.DestroyDescriptorPool(_device, pool, null);
}
_descriptorPools.Clear();
if (_pipeline.Handle != 0)
{
_vk.DestroyPipeline(_device, _pipeline, null);
}
if (_pipelineLayout.Handle != 0)
{
_vk.DestroyPipelineLayout(_device, _pipelineLayout, null);
}
if (_descriptorSetLayout.Handle != 0)
{
_vk.DestroyDescriptorSetLayout(_device, _descriptorSetLayout, null);
}
if (_shaderModule.Handle != 0)
{
_vk.DestroyShaderModule(_device, _shaderModule, null);
}
if (_commandPool.Handle != 0)
{
_vk.DestroyCommandPool(_device, _commandPool, null);
}
}
}
@@ -0,0 +1,482 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Agc;
using Silk.NET.Vulkan;
using VkBuffer = Silk.NET.Vulkan.Buffer;
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// Opt-in GPU equivalence check for <see cref="VulkanDetilePass"/>. When
/// SHARPEMU_DETILE_SELFTEST=1 it builds a known tiled surface, deswizzles it on
/// the GPU into a real image, reads the image back, and compares against the CPU
/// <see cref="GnmTiling.TryDetile"/> — the same equivalence the unit test proves
/// for the params, now end-to-end through the actual Vulkan pass. It logs
/// [DETILE-SELFTEST] PASS/FAIL and never throws into startup (any failure is
/// caught and logged), so it is safe to leave wired.
/// </summary>
internal static unsafe class VulkanDetileSelfTest
{
private const uint Width = 256;
private const uint Height = 256;
// (swizzleMode, bytesPerElement, image format). Mode 27 is exact-XOR, mode 8
// (64 KiB Z) is block-table — both branches. bpp 4/8/16 exercises the
// one/two/four-words-per-element copy. These formats are non-block-compressed
// (element grid == texel grid), so Width/Height are both element and texel dims.
private static readonly (uint Mode, int Bpp, Format Format)[] Cases =
[
(27, 4, Format.R8G8B8A8Unorm),
(8, 4, Format.R8G8B8A8Unorm),
(27, 8, Format.R32G32Uint),
(27, 16, Format.R32G32B32A32Uint),
];
public static void RunIfRequested(
Vk vk,
Device device,
Queue queue,
PhysicalDevice physicalDevice,
uint queueFamilyIndex)
{
if (Environment.GetEnvironmentVariable("SHARPEMU_DETILE_SELFTEST") != "1")
{
return;
}
try
{
Run(vk, device, queue, physicalDevice, queueFamilyIndex);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[DETILE-SELFTEST] FAIL (exception): {exception.Message}");
}
}
private static void Run(
Vk vk,
Device device,
Queue queue,
PhysicalDevice physicalDevice,
uint queueFamilyIndex)
{
using var pass = new VulkanDetilePass(vk, device, queue, physicalDevice, queueFamilyIndex);
var commandPool = CreateCommandPool(vk, device, queueFamilyIndex);
try
{
foreach (var (mode, bpp, format) in Cases)
{
// A plain 2D texture (1 layer) and an array texture (2 layers) — the
// arrayed case exercises the kernel's dispatch-Z slice addressing.
RunCase(vk, device, physicalDevice, queue, commandPool, pass, mode, bpp, format, layers: 1);
RunCase(vk, device, physicalDevice, queue, commandPool, pass, mode, bpp, format, layers: 2);
}
}
finally
{
if (commandPool.Handle != 0)
{
vk.DestroyCommandPool(device, commandPool, null);
}
}
}
private static void RunCase(
Vk vk,
Device device,
PhysicalDevice physicalDevice,
Queue queue,
CommandPool commandPool,
VulkanDetilePass pass,
uint swizzleMode,
int bytesPerElement,
Format format,
uint layers)
{
var parameters = GnmTiling.GetDetileParams(swizzleMode, bytesPerElement, (int)Width, (int)Height);
if (!parameters.IsSupported || !VulkanDetilePass.Supports(parameters))
{
Console.Error.WriteLine(
$"[DETILE-SELFTEST] FAIL: mode {swizzleMode} bpp {bytesPerElement} not supported by the GPU pass.");
return;
}
// Whole-block tiled source with a per-layer-distinct deterministic pattern
// (so a slice mix-up is caught), the array slices packed contiguously.
var blocksHigh = ((int)Height + parameters.BlockHeight - 1) / parameters.BlockHeight;
var sliceTiledBytes = (int)((long)parameters.BlocksPerRow * blocksHigh * parameters.BlockBytes);
var sliceLinearBytes = (int)(Width * Height * bytesPerElement);
var tiled = new byte[sliceTiledBytes * layers];
var expected = new byte[sliceLinearBytes * layers];
for (var layer = 0; layer < layers; layer++)
{
for (var index = 0; index < sliceTiledBytes; index++)
{
tiled[layer * sliceTiledBytes + index] = (byte)((index * 31 + 7 + layer * 101) & 0xFF);
}
if (!GnmTiling.TryDetile(
tiled.AsSpan(layer * sliceTiledBytes, sliceTiledBytes),
expected.AsSpan(layer * sliceLinearBytes, sliceLinearBytes),
swizzleMode, (int)Width, (int)Height, bytesPerElement))
{
Console.Error.WriteLine("[DETILE-SELFTEST] FAIL: CPU TryDetile declined.");
return;
}
}
var tiledBytes = tiled;
var label = $"mode{swizzleMode} {bytesPerElement}bpp x{layers}";
// Phase 1: the one-shot DetileIntoImage (submit + wait in place).
VerifyPhase(
vk, device, physicalDevice, queue, commandPool, expected, layers, format, $"DetileIntoImage {label}",
image => pass.DetileIntoImage(image, ImageLayout.Undefined, Width, Height, layers, tiledBytes, parameters));
// Phase 2: RecordDetile — the exact code path the render loop uses
// (record into a command buffer, submit, retire the transients).
VerifyPhase(
vk, device, physicalDevice, queue, commandPool, expected, layers, format, $"RecordDetile {label}",
image => RecordDetileAndSubmit(vk, device, queue, commandPool, pass, image, layers, tiledBytes, parameters));
}
private static void VerifyPhase(
Vk vk,
Device device,
PhysicalDevice physicalDevice,
Queue queue,
CommandPool commandPool,
byte[] expected,
uint layers,
Format format,
string label,
Func<Image, bool> detile)
{
var image = CreateImage(vk, device, physicalDevice, format, layers, out var imageMemory);
var readback = CreateHostBuffer(
vk, device, physicalDevice, (ulong)expected.Length, BufferUsageFlags.TransferDstBit, out var readbackMemory);
try
{
if (!detile(image))
{
Console.Error.WriteLine($"[DETILE-SELFTEST] {label} FAIL: declined.");
return;
}
CopyImageToBuffer(vk, device, queue, commandPool, image, readback, layers);
void* mapped;
Check(
vk.MapMemory(device, readbackMemory, 0, (ulong)expected.Length, 0, &mapped),
$"vkMapMemory(selftest {label})");
var actual = new Span<byte>(mapped, expected.Length);
var firstMismatch = -1;
for (var index = 0; index < expected.Length; index++)
{
if (actual[index] != expected[index])
{
firstMismatch = index;
break;
}
}
vk.UnmapMemory(device, readbackMemory);
Console.Error.WriteLine(firstMismatch < 0
? $"[DETILE-SELFTEST] {label} PASS: {Width}x{Height}x{layers} matches CPU detile ({expected.Length} bytes)."
: $"[DETILE-SELFTEST] {label} FAIL: first mismatch at byte {firstMismatch}.");
}
finally
{
if (readback.Handle != 0)
{
vk.DestroyBuffer(device, readback, null);
}
if (readbackMemory.Handle != 0)
{
vk.FreeMemory(device, readbackMemory, null);
}
if (image.Handle != 0)
{
vk.DestroyImage(device, image, null);
}
if (imageMemory.Handle != 0)
{
vk.FreeMemory(device, imageMemory, null);
}
}
}
// Records the detile into a fresh command buffer, submits, waits, and retires
// the transients exactly as the presenter's batch does — verifying the render
// path's code (RecordDetile) without needing a game to trigger it.
private static bool RecordDetileAndSubmit(
Vk vk,
Device device,
Queue queue,
CommandPool commandPool,
VulkanDetilePass pass,
Image image,
uint layers,
ReadOnlySpan<byte> tiled,
in DetileParams parameters)
{
var commandBuffer = AllocateCommandBuffer(vk, device, commandPool);
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(vk.BeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer(selftest record)");
if (!pass.RecordDetile(
commandBuffer, image, ImageLayout.Undefined, Width, Height, layers, tiled, parameters, out var transients))
{
_ = vk.EndCommandBuffer(commandBuffer);
vk.FreeCommandBuffers(device, commandPool, 1, &commandBuffer);
return false;
}
Check(vk.EndCommandBuffer(commandBuffer), "vkEndCommandBuffer(selftest record)");
var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo };
Check(vk.CreateFence(device, &fenceInfo, null, out var fence), "vkCreateFence(selftest record)");
try
{
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
};
Check(vk.QueueSubmit(queue, 1, &submitInfo, fence), "vkQueueSubmit(selftest record)");
Check(vk.WaitForFences(device, 1, &fence, true, ulong.MaxValue), "vkWaitForFences(selftest record)");
}
finally
{
vk.DestroyFence(device, fence, null);
vk.FreeCommandBuffers(device, commandPool, 1, &commandBuffer);
pass.Retire(transients);
}
return true;
}
private static Image CreateImage(
Vk vk,
Device device,
PhysicalDevice physicalDevice,
Format format,
uint layers,
out DeviceMemory memory)
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = format,
Extent = new Extent3D(Width, Height, 1),
MipLevels = 1,
ArrayLayers = layers,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(vk.CreateImage(device, &imageInfo, null, out var image), "vkCreateImage(selftest)");
vk.GetImageMemoryRequirements(device, image, out var requirements);
var allocateInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
vk,
physicalDevice,
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(vk.AllocateMemory(device, &allocateInfo, null, out memory), "vkAllocateMemory(selftest image)");
Check(vk.BindImageMemory(device, image, memory, 0), "vkBindImageMemory(selftest)");
return image;
}
private static void CopyImageToBuffer(
Vk vk,
Device device,
Queue queue,
CommandPool commandPool,
Image image,
VkBuffer destination,
uint layers)
{
var commandBuffer = AllocateCommandBuffer(vk, device, commandPool);
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(vk.BeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer(selftest readback)");
// DetileIntoImage left the image ShaderReadOnly; move it to TransferSrc.
var toTransferSrc = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderReadBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, layers),
};
vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.FragmentShaderBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransferSrc);
// Layer-major readback: one copy pulls every array slice back into the
// buffer contiguously, matching the packed `expected` layout.
var region = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, layers),
ImageOffset = default,
ImageExtent = new Extent3D(Width, Height, 1),
};
vk.CmdCopyImageToBuffer(
commandBuffer,
image,
ImageLayout.TransferSrcOptimal,
destination,
1,
&region);
Check(vk.EndCommandBuffer(commandBuffer), "vkEndCommandBuffer(selftest readback)");
var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo };
Check(vk.CreateFence(device, &fenceInfo, null, out var fence), "vkCreateFence(selftest)");
try
{
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
};
Check(vk.QueueSubmit(queue, 1, &submitInfo, fence), "vkQueueSubmit(selftest readback)");
Check(
vk.WaitForFences(device, 1, &fence, true, ulong.MaxValue),
"vkWaitForFences(selftest readback)");
}
finally
{
vk.DestroyFence(device, fence, null);
vk.FreeCommandBuffers(device, commandPool, 1, &commandBuffer);
}
}
private static CommandPool CreateCommandPool(Vk vk, Device device, uint queueFamilyIndex)
{
var poolInfo = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = queueFamilyIndex,
Flags = CommandPoolCreateFlags.ResetCommandBufferBit,
};
Check(vk.CreateCommandPool(device, &poolInfo, null, out var pool), "vkCreateCommandPool(selftest)");
return pool;
}
private static CommandBuffer AllocateCommandBuffer(Vk vk, Device device, CommandPool commandPool)
{
var allocateInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = commandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1,
};
Check(
vk.AllocateCommandBuffers(device, &allocateInfo, out var commandBuffer),
"vkAllocateCommandBuffers(selftest)");
return commandBuffer;
}
private static VkBuffer CreateHostBuffer(
Vk vk,
Device device,
PhysicalDevice physicalDevice,
ulong size,
BufferUsageFlags usage,
out DeviceMemory memory)
{
var bufferInfo = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = size,
Usage = usage,
SharingMode = SharingMode.Exclusive,
};
Check(vk.CreateBuffer(device, &bufferInfo, null, out var buffer), "vkCreateBuffer(selftest)");
vk.GetBufferMemoryRequirements(device, buffer, out var requirements);
var allocateInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
vk,
physicalDevice,
requirements.MemoryTypeBits,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit),
};
Check(vk.AllocateMemory(device, &allocateInfo, null, out memory), "vkAllocateMemory(selftest buffer)");
Check(vk.BindBufferMemory(device, buffer, memory, 0), "vkBindBufferMemory(selftest)");
return buffer;
}
private static uint FindMemoryType(
Vk vk,
PhysicalDevice physicalDevice,
uint typeBits,
MemoryPropertyFlags requiredFlags)
{
vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out var properties);
var memoryTypes = &properties.MemoryTypes.Element0;
for (uint index = 0; index < properties.MemoryTypeCount; index++)
{
if ((typeBits & (1u << (int)index)) != 0 &&
(memoryTypes[index].PropertyFlags & requiredFlags) == requiredFlags)
{
return index;
}
}
throw new InvalidOperationException("No compatible Vulkan memory type for the detile self-test.");
}
private static void Check(Result result, string operation)
{
if (result != Result.Success)
{
throw new InvalidOperationException($"{operation} failed: {result}");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -16,4 +16,37 @@ public static class VoiceQoSExports
{
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "Trpt2QBZHCI",
ExportName = "sceVoiceQoSGetStatus",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVoiceQoS")]
public static int VoiceQoSGetStatus(CpuContext ctx)
{
// Returns 0 to indicate connected state (voice available)
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "FuXenJLkk-c",
ExportName = "sceVoiceQoSTerminate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVoiceQoS")]
public static int VoiceQoSTerminate(CpuContext ctx)
{
// No-op: cleanup is handled by emulator shutdown
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "+0lOiPZjnBI",
ExportName = "sceVoiceQoSSetMode",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVoiceQoS")]
public static int VoiceQoSSetMode(CpuContext ctx)
{
// No-op: mode configuration is not emulated
return ctx.SetReturn(0);
}
}
@@ -76,6 +76,14 @@ public static class MslFixedShaders
/// </summary>
public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment");
/// <summary>
/// Compute kernel that deswizzles one RDNA2 exact-XOR tiled surface (swizzle
/// modes 5/9/24/27) at 4 bytes/element into a linear output buffer — the MSL
/// twin of <c>SpirvFixedShaders.CreateDetileCompute</c>. Entry point
/// "detile_cs"; buffers 0=tiled, 1=xTerm, 2=yTerm, 3=out, 4=DetileParams.
/// </summary>
public static string CreateDetileCompute() => MslTemplates.Render("detile_compute");
private static string Format(float value) =>
value.ToString("0.0######", CultureInfo.InvariantCulture) + "f";
}
@@ -0,0 +1,70 @@
#include <metal_stdlib>
using namespace metal;
// GPU deswizzle for RDNA2 tiled surfaces at 4/8/16 bytes/element — the MSL twin of
// SpirvFixedShaders.CreateDetileCompute and a direct mirror of
// GnmTiling.GetDetileParams. Handles both supported equation families and both
// plain 2D textures and array textures (one grid-Z layer per slice; the caller
// packs the tiled slices contiguously). width/height are ELEMENT dims (for
// block-compressed formats a 4x4 block is one element); each element spans
// uintsPerElement = bpp/4 words, and the X grid is widened by that factor so each
// thread copies one word (elemX = gidX / upe, word = gidX % upe):
// inBlock = equation == 1 // BlockTable, modes 1/4/8
// ? blockTable[(y % blockHeight) * blockWidth + (elemX % blockWidth)]
// : xTerm[elemX & xMask] ^ yTerm[y & yMask]; // ExactXor 5/9/24/27
// srcElem = z * srcSliceElements
// + (y / blockHeight * blocksPerRow + elemX / blockWidth) * blockElements
// + inBlock;
// dstElem = z * width * height + y * width + elemX;
// out[dstElem * upe + word] = tiled[srcElem * upe + word];
// Term tables hold ELEMENT offsets. buffer(1) carries xTerm (ExactXor) OR the
// block table (BlockTable); the two equations index different-sized buffers, so
// exactly one branch runs. 1/2 bpp are sub-word and stay on the CPU.
struct DetileParams
{
uint width;
uint height;
uint blockWidth;
uint blockHeight;
uint blockElements;
uint blocksPerRow;
uint xMask;
uint yMask;
uint srcSliceElements;
uint equation;
uint uintsPerElement;
};
kernel void detile_cs(
device const uint* tiled [[buffer(0)]],
device const uint* xTermOrTable [[buffer(1)]],
device const uint* yTerm [[buffer(2)]],
device uint* outLinear [[buffer(3)]],
constant DetileParams& params [[buffer(4)]],
uint3 gid [[thread_position_in_grid]])
{
uint elemX = gid.x / params.uintsPerElement;
uint word = gid.x - elemX * params.uintsPerElement;
if (elemX >= params.width || gid.y >= params.height)
{
return;
}
uint blockIndex = (gid.y / params.blockHeight) * params.blocksPerRow + (elemX / params.blockWidth);
uint off;
if (params.equation != 0u)
{
uint inX = elemX - (elemX / params.blockWidth) * params.blockWidth;
uint inY = gid.y - (gid.y / params.blockHeight) * params.blockHeight;
off = xTermOrTable[inY * params.blockWidth + inX];
}
else
{
off = xTermOrTable[elemX & params.xMask] ^ yTerm[gid.y & params.yMask];
}
uint srcElem = gid.z * params.srcSliceElements + blockIndex * params.blockElements + off;
uint dstElem = gid.z * params.width * params.height + gid.y * params.width + elemX;
outLinear[dstElem * params.uintsPerElement + word] = tiled[srcElem * params.uintsPerElement + word];
}
@@ -953,22 +953,22 @@ public static partial class Gen5SpirvTranslator
case "VPkMulF16":
case "VPkMinF16":
case "VPkMaxF16":
case "VPkFmaF16":
if (!TryEmitPackedF16(instruction, out result, out error))
{
return false;
}
break;
case "VPkFmaF16":
// Deliberately loud: a fused f16 FMA rounds the product+add once,
// whereas doing the multiply-add in f32 and rounding to f16 at the
// end double-rounds. Concrete miss: fma(0x4100, 0x7522, 0x04EA) is
// 0x7A6B fused but 0x7A6A via f32. Exact emulation (round-to-odd
// f32 product then RNE pack) is a planned follow-up slice.
error =
$"unsupported vop3p opcode {instruction.Opcode} " +
"(fused f16 FMA requires single-rounding; deferred to a later slice)";
return false;
case "VFmaMixF32":
case "VFmaMixloF16":
case "VFmaMixhiF16":
if (!TryEmitFmaMix(instruction, destination, out result, out error))
{
return false;
}
break;
default:
error = $"unsupported vector opcode {instruction.Opcode}";
return false;
@@ -1008,8 +1008,9 @@ public static partial class Gen5SpirvTranslator
// even. For add and mul this is bit-exact to a true f16 op (the f32 result
// rounds losslessly to f16 by the double-rounding theorem; a f16 product even
// fits in f32 exactly). min/max carry no rounding, so they are exact once the
// conversions are. v_pk_fma_f16 is intentionally not routed here because a
// fused f16 FMA cannot be reproduced by an f32 multiply-add plus a pack.
// conversions are. v_pk_fma_f16 cannot be reproduced by a plain f32
// multiply-add plus a pack (that double-rounds), so it goes through the
// round-to-odd sequence in EmitPackedF16FusedMultiplyAdd instead.
private bool TryEmitPackedF16(
Gen5ShaderInstruction instruction,
out uint result,
@@ -1023,13 +1024,8 @@ public static partial class Gen5SpirvTranslator
return false;
}
if (control.Clamp)
{
error = $"unsupported vop3p modifiers (clamp) for {instruction.Opcode}";
return false;
}
for (var index = 0; index < 2; index++)
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2;
for (var index = 0; index < sourceCount; index++)
{
var source = instruction.Sources[index];
if (source.Kind is not (Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister))
@@ -1046,7 +1042,112 @@ public static partial class Gen5SpirvTranslator
return true;
}
// V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 /
// 0x22). Unlike the packed v_pk_* ops these compute a single f32
// fma(a, b, c): each of the three sources is *independently* read as
// either a full f32 register/constant or one f16 half widened to f32,
// selected per operand by op_sel_hi (read as f16 when set) and op_sel
// (which half feeds the f32). For the mix ops the VOP3P neg_hi field is
// the absolute-value modifier and neg negates, applied abs-then-neg to
// match the hardware and shadPS4's GetSrcMix. _MIXLO / _MIXHI round the
// f32 result back to f16 and write it into the low / high 16 bits of
// vdst, leaving the other half intact.
private bool TryEmitFmaMix(
Gen5ShaderInstruction instruction,
uint destination,
out uint result,
out string error)
{
result = 0;
error = string.Empty;
if (instruction.Control is not Gen5Vop3pControl control)
{
error = $"missing vop3p control for {instruction.Opcode}";
return false;
}
var product = Bitcast(
_uintType,
Ext(
50,
_floatType,
EmitFmaMixOperand(instruction, control, 0),
EmitFmaMixOperand(instruction, control, 1),
EmitFmaMixOperand(instruction, control, 2)));
if (control.Clamp)
{
product = EmitClampToUnitInterval(product);
}
if (instruction.Opcode == "VFmaMixF32")
{
result = product;
return true;
}
// _MIXLO / _MIXHI: narrow to f16 and merge into one half of vdst.
var half = EmitFloatToHalf(product);
var existing = LoadV(destination);
result = instruction.Opcode == "VFmaMixloF16"
? BitwiseOr(BitwiseAnd(existing, UInt(0xFFFF_0000)), half)
: BitwiseOr(
BitwiseAnd(existing, UInt(0x0000_FFFF)),
ShiftLeftLogical(half, UInt(16)));
return true;
}
// Reads one V_FMA_MIX source as an f32. op_sel_hi selects whether a
// register operand is taken as an f16 (the half picked by op_sel, widened
// exactly to f32) or as a full f32; inline constants are always f32. The
// per-operand neg_hi bit takes the absolute value and neg negates, in that
// order (abs-then-neg), reusing the VOP3P modifier fields the way the mix
// ops define them rather than the packed low/high-lane meaning.
private uint EmitFmaMixOperand(
Gen5ShaderInstruction instruction,
Gen5Vop3pControl control,
int index)
{
var source = instruction.Sources[index];
var readAsHalf =
((control.OpSelHiMask >> index) & 1) != 0 &&
source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister;
uint value;
if (readAsHalf)
{
var raw = GetRawSource(instruction, index);
var half = ((control.OpSelMask >> index) & 1) != 0
? ShiftRightLogical(raw, UInt(16))
: raw;
value = Bitcast(_floatType, EmitHalfToFloat(half));
}
else
{
value = GetFloatSource(instruction, index);
}
if (((control.NegHiMask >> index) & 1) != 0)
{
value = Ext(4, _floatType, value);
}
if (((control.NegLoMask >> index) & 1) != 0)
{
value = _module.AddInstruction(SpirvOp.FNegate, _floatType, value);
}
return value;
}
// Computes one result lane (low or high) as a packed 16-bit f16 value.
// The op runs in f32 and its result is narrowed back to f16 exactly (see
// EmitFloatToHalf). When the clamp modifier is set the pre-narrowing f32
// value is saturated to [0, 1] first; because 0.0 and 1.0 are exact in both
// f32 and f16 and the clamp is monotonic, clamping before the narrowing
// gives the same f16 the hardware produces by clamping the f16 result. For
// the fused multiply-add the pre-narrowing value is the round-to-odd f32
// from EmitPackedF16FusedMultiplyAdd, and round-to-odd preserves that
// equivalence through the final round-to-nearest-even.
private uint EmitPackedF16Lane(
Gen5ShaderInstruction instruction,
Gen5Vop3pControl control,
@@ -1054,15 +1155,113 @@ public static partial class Gen5SpirvTranslator
{
var left = EmitPackedF16Operand(instruction, control, 0, highLane);
var right = EmitPackedF16Operand(instruction, control, 1, highLane);
var value = instruction.Opcode switch
uint value;
if (instruction.Opcode == "VPkFmaF16")
{
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
_ => left,
};
return EmitFloatToHalf(Bitcast(_uintType, value));
var addend = EmitPackedF16Operand(instruction, control, 2, highLane);
value = EmitPackedF16FusedMultiplyAdd(left, right, addend);
}
else
{
value = Bitcast(_uintType, instruction.Opcode switch
{
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
_ => left,
});
}
if (control.Clamp)
{
value = EmitClampToUnitInterval(value);
}
return EmitFloatToHalf(value);
}
// Saturates an f32 bit pattern to [0, 1] the way the VOP3P clamp modifier
// does: below 0 (and NaN, since the ordered compare is false for it) becomes
// 0, above 1 becomes 1. Ordered compares match the hardware's NaN-to-zero
// behaviour without a separate IsNan test.
private uint EmitClampToUnitInterval(uint valueBits)
{
var value = Bitcast(_floatType, valueBits);
var aboveZero = _module.AddInstruction(SpirvOp.FOrdGreaterThan, _boolType, value, Float(0));
var lowerBounded = _module.AddInstruction(SpirvOp.Select, _floatType, aboveZero, value, Float(0));
var belowOne = _module.AddInstruction(SpirvOp.FOrdLessThan, _boolType, lowerBounded, Float(1));
var clamped = _module.AddInstruction(SpirvOp.Select, _floatType, belowOne, lowerBounded, Float(1));
return Bitcast(_uintType, clamped);
}
// Fused f16 multiply-add with a single rounding, emulated in f32 without the
// Float16 capability. The f32 product of two widened f16 values is exact
// (11-bit significands, and the exponent stays inside the f32 normal range:
// any non-zero product magnitude is in [2^-48, 2^33]), so only the addition
// rounds. An f32 add then an f16 pack would round twice; instead the add is
// corrected to round-to-odd, which a following round-to-nearest-even pack
// turns into the exactly-once-rounded fused result (innocuous double rounding
// holds because f32 carries 24 significand bits >= 11 + 2).
//
// sum = RN(product + addend); Knuth's 2Sum recovers the exact residual
// (product + addend) - sum from four more RN ops. 2Sum is exact for any two
// finite f32 inputs; no intermediate here can overflow (|product| < 2^33,
// |addend| < 2^16) and none can enter the f32 subnormal range (every finite
// value in play is a multiple of 2^-48 by construction), so implementation
// f32 denorm-flush modes never see a denormal. If the residual says the sum
// was inexact and the sum's significand is even, step one ulp towards the
// true value: consecutive floats have consecutive sign-magnitude encodings,
// so that neighbour is the enclosing float with the odd significand.
//
// Inf/NaN inputs make the residual NaN (e.g. sum - addend = Inf - Inf); the
// ordered compare below is then false and the IEEE sum passes through
// unchanged. A residual of zero also covers the exact-sum case, where the
// parity fix must not fire. Returns the round-to-odd f32 bit pattern.
private uint EmitPackedF16FusedMultiplyAdd(uint left, uint right, uint addend)
{
var product = EmitPreciseFloat(SpirvOp.FMul, left, right);
var sum = EmitPreciseFloat(SpirvOp.FAdd, product, addend);
var productPart = EmitPreciseFloat(SpirvOp.FSub, sum, addend);
var addendPart = EmitPreciseFloat(SpirvOp.FSub, sum, productPart);
var productError = EmitPreciseFloat(SpirvOp.FSub, product, productPart);
var addendError = EmitPreciseFloat(SpirvOp.FSub, addend, addendPart);
var residual = EmitPreciseFloat(SpirvOp.FAdd, productError, addendError);
var sumBits = Bitcast(_uintType, sum);
var residualBits = Bitcast(_uintType, residual);
var inexact = _module.AddInstruction(
SpirvOp.FOrdNotEqual, _boolType, residual, Float(0));
var evenSignificand = Equal(BitwiseAnd(sumBits, UInt(1)), 0);
var adjust = _module.AddInstruction(
SpirvOp.LogicalAnd, _boolType, inexact, evenSignificand);
// Residual sign relative to the sum picks the step direction: same sign
// means the true value lies away from zero (encoding + 1), opposite sign
// means towards zero (encoding - 1). The sum cannot be zero here (any
// inexact sum has magnitude >= 2^-48) and cannot be the largest finite
// value (its significand is odd), so the step never crosses zero or Inf.
var towardZero = IsNotZero(
BitwiseAnd(BitwiseXor(sumBits, residualBits), UInt(0x8000_0000)));
var stepped = SelectU(
towardZero,
ISubU(sumBits, UInt(1)),
IAdd(sumBits, UInt(1)));
return SelectU(adjust, stepped, sumBits);
}
// A float op the driver must evaluate exactly as written. The 2Sum
// residual above is error-free only op by op; without NoContraction
// driver compilers fold the sequence (e.g. contract product+sum into an
// f32 fma and simplify the rebuilt terms), collapsing the residual to
// zero. Observed on AMD RDNA3 Windows: the pinned midpoint case decays
// to the double-rounded result unless every op in the chain is marked.
private uint EmitPreciseFloat(SpirvOp operation, uint left, uint right)
{
var value = _module.AddInstruction(operation, _floatType, left, right);
_module.AddDecoration(value, SpirvDecoration.NoContraction);
return value;
}
// Reads source `index`, selects the half feeding this lane (op_sel / op_sel_hi),
@@ -313,7 +313,9 @@ public static partial class Gen5SpirvTranslator
uint ComponentType,
uint VectorType,
ImageComponentKind ComponentKind,
bool IsStorage);
bool IsStorage,
bool Arrayed,
SpirvImageDim Dimension);
private readonly record struct SpirvVertexInput(
uint Variable,
@@ -1000,11 +1002,17 @@ public static partial class Gen5SpirvTranslator
SpirvCapability.StorageImageExtendedFormats);
}
var dimension = binding.Control.Dimension == 2
? SpirvImageDim.Dim3D
: SpirvImageDim.Dim2D;
var isArrayed = dimension != SpirvImageDim.Dim3D &&
!isStorage &&
Gen5ShaderTranslator.IsArrayedImageBinding(binding);
var imageType = _module.TypeImage(
componentType,
SpirvImageDim.Dim2D,
dimension,
depth: false,
arrayed: false,
arrayed: isArrayed,
multisampled: false,
sampled: isStorage ? 2u : 1u,
isStorage ? format : SpirvImageFormat.Unknown);
@@ -1031,7 +1039,9 @@ public static partial class Gen5SpirvTranslator
componentType,
_module.TypeVector(componentType, 4),
componentKind,
isStorage));
isStorage,
isArrayed,
dimension));
_interfaces.Add(variable);
}
}
@@ -2255,20 +2265,39 @@ public static partial class Gen5SpirvTranslator
return false;
}
var memoryOpcode = control.UsesFlatAddress
? "Global" + instruction.Opcode["Flat".Length..]
: instruction.Opcode;
var vectorByteAddress = LoadV(control.VectorAddress);
if (control.UsesFlatAddress)
{
// FLAT instructions carry the complete 64-bit guest address
// in a VGPR pair. The scalar evaluator captures the buffer
// rooted at the inferred SGPR pair, so convert the low address
// dword back to a byte offset inside that binding. Subtraction
// modulo 2^32 also handles an address addition that carried
// into the high dword, while the bounded binding prevents an
// unrelated pointer from escaping into host storage.
vectorByteAddress = _module.AddInstruction(
SpirvOp.ISub,
_uintType,
vectorByteAddress,
LoadS(control.ScalarAddress));
}
var byteAddress = IAdd(
LoadV(control.VectorAddress),
vectorByteAddress,
UInt(unchecked((uint)control.OffsetBytes)));
byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress);
var dwordAddress = ShiftRightLogical(byteAddress, UInt(2));
if (instruction.Opcode is "GlobalAtomicAdd" or "GlobalAtomicUMax")
if (memoryOpcode is "GlobalAtomicAdd" or "GlobalAtomicUMax")
{
EmitExecConditional(() =>
{
EmitConditional(IsBufferWordInRange(bindingIndex, dwordAddress), () =>
{
var original = _module.AddInstruction(
instruction.Opcode == "GlobalAtomicAdd"
memoryOpcode == "GlobalAtomicAdd"
? SpirvOp.AtomicIAdd
: SpirvOp.AtomicUMax,
_uintType,
@@ -2285,12 +2314,12 @@ public static partial class Gen5SpirvTranslator
return true;
}
if (instruction.Opcode.StartsWith("GlobalStore", StringComparison.Ordinal))
if (memoryOpcode.StartsWith("GlobalStore", StringComparison.Ordinal))
{
EmitExecConditional(() =>
{
if (TryGetSubdwordStoreInfo(
instruction.Opcode,
memoryOpcode,
out var byteCount,
out var sourceShift))
{
@@ -2320,7 +2349,7 @@ public static partial class Gen5SpirvTranslator
}
if (TryGetSubdwordLoadInfo(
instruction.Opcode,
memoryOpcode,
out var loadByteCount,
out var signExtend,
out var d16,
@@ -3264,6 +3293,7 @@ public static partial class Gen5SpirvTranslator
var imageObject = Load(resource.ObjectType, resource.Variable);
if (instruction.Opcode == "ImageGetResinfo")
{
var sizeComponentCount = ImageCoordinateComponentCount(resource);
var queryImage = resource.IsStorage
? imageObject
: _module.AddInstruction(
@@ -3274,7 +3304,7 @@ public static partial class Gen5SpirvTranslator
resource.IsStorage
? SpirvOp.ImageQuerySize
: SpirvOp.ImageQuerySizeLod,
_module.TypeVector(_intType, 2),
_module.TypeVector(_intType, sizeComponentCount),
resource.IsStorage
? [queryImage]
: [queryImage, UInt(0)]);
@@ -3287,7 +3317,7 @@ public static partial class Gen5SpirvTranslator
}
uint value;
if (component < 2)
if (component < sizeComponentCount)
{
var signedValue = _module.AddInstruction(
SpirvOp.CompositeExtract,
@@ -3315,7 +3345,12 @@ public static partial class Gen5SpirvTranslator
return false;
}
var coordinates = BuildIntegerCoordinates(image, 0);
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var coordinates = BuildIntegerCoordinates(
image,
0,
coordinateComponentCount);
var components = new uint[4];
uint sourceIndex = 0;
for (var component = 0; component < components.Length; component++)
@@ -3351,13 +3386,14 @@ public static partial class Gen5SpirvTranslator
components);
var imageSize = _module.AddInstruction(
SpirvOp.ImageQuerySize,
_module.TypeVector(_intType, 2),
_module.TypeVector(_intType, coordinateComponentCount),
imageObject);
EmitBoundsCheckedImageWrite(
coordinates,
imageSize,
imageObject,
texel);
texel,
coordinateComponentCount);
return true;
}
@@ -3378,14 +3414,17 @@ public static partial class Gen5SpirvTranslator
}
var signed = resource.ComponentKind == ImageComponentKind.Sint;
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var atomicImageSize = _module.AddInstruction(
SpirvOp.ImageQuerySize,
_module.TypeVector(_intType, 2),
_module.TypeVector(_intType, coordinateComponentCount),
imageObject);
var coordinates = BuildClampedIntegerCoordinates(
image,
0,
atomicImageSize);
atomicImageSize,
coordinateComponentCount);
EmitExecConditional(() =>
{
var pointer = _module.AddInstruction(
@@ -3429,14 +3468,17 @@ public static partial class Gen5SpirvTranslator
{
if (resource.IsStorage)
{
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var imageSize = _module.AddInstruction(
SpirvOp.ImageQuerySize,
_module.TypeVector(_intType, 2),
_module.TypeVector(_intType, coordinateComponentCount),
imageObject);
var coordinates = BuildClampedIntegerCoordinates(
image,
0,
imageSize);
imageSize,
coordinateComponentCount);
sampled = _module.AddInstruction(
SpirvOp.ImageRead,
resource.VectorType,
@@ -3450,15 +3492,18 @@ public static partial class Gen5SpirvTranslator
SpirvOp.Image,
resource.ImageType,
imageObject);
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var imageSize = _module.AddInstruction(
SpirvOp.ImageQuerySizeLod,
_module.TypeVector(_intType, 2),
_module.TypeVector(_intType, coordinateComponentCount),
fetchedImage,
UInt(mipLevel));
var coordinates = BuildClampedIntegerCoordinates(
image,
0,
imageSize);
imageSize,
coordinateComponentCount);
sampled = _module.AddInstruction(
SpirvOp.ImageFetch,
resource.VectorType,
@@ -3490,12 +3535,19 @@ public static partial class Gen5SpirvTranslator
// lowering treated SAMPLE_D as body-first and consequently
// sampled gradients as coordinates in every captured
// derivative operation.
var spatialComponentCount =
ImageSpatialComponentCount(resource);
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var addressCursor = 0;
var offset = 0u;
if (hasOffset)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
offset = BuildImageOffset(image, addressCursor);
offset = BuildImageOffset(
image,
addressCursor,
spatialComponentCount);
addressCursor += ImageFullAddressSlots(image);
}
@@ -3519,22 +3571,33 @@ public static partial class Gen5SpirvTranslator
}
var gradientX = hasGradients
? BuildFloatCoordinates(image, addressCursor)
? BuildFloatCoordinates(
image,
addressCursor,
spatialComponentCount)
: 0u;
var gradientY = hasGradients
? BuildFloatCoordinates(image, addressCursor + 2)
? BuildFloatCoordinates(
image,
addressCursor + (int)spatialComponentCount,
spatialComponentCount)
: 0u;
if (hasGradients)
{
addressCursor += 4;
addressCursor += checked((int)(spatialComponentCount * 2));
}
var coordinates = BuildFloatCoordinates(image, addressCursor);
var coordinates = BuildFloatCoordinates(
image,
addressCursor,
coordinateComponentCount);
var explicitLod = hasGradients || hasZeroLod || hasLod;
var lod = hasZeroLod
? Float(0)
: hasLod
? LoadImageFloatAddress(image, addressCursor + 2)
? LoadImageFloatAddress(
image,
addressCursor + (int)coordinateComponentCount)
: lodOrBias;
if (hasOffset)
{
@@ -3600,11 +3663,18 @@ public static partial class Gen5SpirvTranslator
instruction.Opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare =
instruction.Opcode.Contains("Gather4C", StringComparison.Ordinal);
var spatialComponentCount =
ImageSpatialComponentCount(resource);
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var addressCursor = 0;
var offset = 0u;
if (hasOffset)
{
offset = BuildImageOffset(image, addressCursor);
offset = BuildImageOffset(
image,
addressCursor,
spatialComponentCount);
addressCursor += ImageFullAddressSlots(image);
}
@@ -3619,7 +3689,10 @@ public static partial class Gen5SpirvTranslator
addressCursor += ImageFullAddressSlots(image);
}
var coordinates = BuildFloatCoordinates(image, addressCursor);
var coordinates = BuildFloatCoordinates(
image,
addressCursor,
coordinateComponentCount);
var operands = new List<uint>
{
imageObject,
@@ -3812,15 +3885,31 @@ public static partial class Gen5SpirvTranslator
});
}
private uint BuildFloatCoordinates(Gen5ImageControl image, int start)
private static uint ImageSpatialComponentCount(
SpirvImageResource resource) =>
resource.Dimension == SpirvImageDim.Dim3D ? 3u : 2u;
private static uint ImageCoordinateComponentCount(
SpirvImageResource resource) =>
resource.Arrayed ? 3u : ImageSpatialComponentCount(resource);
private uint BuildFloatCoordinates(
Gen5ImageControl image,
int start,
uint componentCount)
{
var x = LoadImageFloatAddress(image, start);
var y = LoadImageFloatAddress(image, start + 1);
var components = new uint[checked((int)componentCount)];
for (var component = 0; component < components.Length; component++)
{
components[component] = LoadImageFloatAddress(
image,
start + component);
}
return _module.AddInstruction(
SpirvOp.CompositeConstruct,
_vec2Type,
x,
y);
_module.TypeVector(_floatType, componentCount),
components);
}
private static int ImageAddressRegister(
@@ -3925,47 +4014,49 @@ public static partial class Gen5SpirvTranslator
ShiftLeftLogical(BitwiseAnd(high, UInt(0xFFFF)), UInt(16)));
}
private uint BuildIntegerCoordinates(Gen5ImageControl image, int start)
private uint BuildIntegerCoordinates(
Gen5ImageControl image,
int start,
uint componentCount)
{
var ivec2 = _module.TypeVector(_intType, 2);
var x = Bitcast(_intType, LoadImageIntegerAddress(image, start));
var y = Bitcast(_intType, LoadImageIntegerAddress(image, start + 1));
var components = new uint[checked((int)componentCount)];
for (var component = 0; component < components.Length; component++)
{
components[component] = Bitcast(
_intType,
LoadImageIntegerAddress(image, start + component));
}
return _module.AddInstruction(
SpirvOp.CompositeConstruct,
ivec2,
x,
y);
_module.TypeVector(_intType, componentCount),
components);
}
private uint BuildClampedIntegerCoordinates(
Gen5ImageControl image,
int start,
uint imageSize)
uint imageSize,
uint componentCount)
{
var ivec2 = _module.TypeVector(_intType, 2);
var x = ClampSignedCoordinate(
Bitcast(
_intType,
LoadImageIntegerAddress(image, start)),
_module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
imageSize,
0));
var y = ClampSignedCoordinate(
Bitcast(
_intType,
LoadImageIntegerAddress(image, start + 1)),
_module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
imageSize,
1));
var components = new uint[checked((int)componentCount)];
for (var component = 0; component < components.Length; component++)
{
components[component] = ClampSignedCoordinate(
Bitcast(
_intType,
LoadImageIntegerAddress(image, start + component)),
_module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
imageSize,
(uint)component));
}
return _module.AddInstruction(
SpirvOp.CompositeConstruct,
ivec2,
x,
y);
_module.TypeVector(_intType, componentCount),
components);
}
private uint ClampSignedCoordinate(uint value, uint extent)
@@ -4004,69 +4095,47 @@ public static partial class Gen5SpirvTranslator
uint coordinates,
uint imageSize,
uint imageObject,
uint texel)
uint texel,
uint coordinateComponentCount)
{
var x = _module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
coordinates,
0);
var y = _module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
coordinates,
1);
var width = _module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
imageSize,
0);
var height = _module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
imageSize,
1);
var zero = _module.Constant(_intType, 0);
var xNonNegative = _module.AddInstruction(
SpirvOp.SGreaterThanEqual,
_boolType,
x,
zero);
var yNonNegative = _module.AddInstruction(
SpirvOp.SGreaterThanEqual,
_boolType,
y,
zero);
var xInRange = _module.AddInstruction(
SpirvOp.SLessThan,
_boolType,
x,
width);
var yInRange = _module.AddInstruction(
SpirvOp.SLessThan,
_boolType,
y,
height);
var lowerInRange = _module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
xNonNegative,
yNonNegative);
var upperInRange = _module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
xInRange,
yInRange);
var inRange = _module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
lowerInRange,
upperInRange);
inRange = _module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
Load(_boolType, _exec),
inRange);
var inRange = Load(_boolType, _exec);
for (uint component = 0;
component < coordinateComponentCount;
component++)
{
var coordinate = _module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
coordinates,
component);
var extent = _module.AddInstruction(
SpirvOp.CompositeExtract,
_intType,
imageSize,
component);
var nonNegative = _module.AddInstruction(
SpirvOp.SGreaterThanEqual,
_boolType,
coordinate,
zero);
var belowExtent = _module.AddInstruction(
SpirvOp.SLessThan,
_boolType,
coordinate,
extent);
var componentInRange = _module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
nonNegative,
belowExtent);
inRange = _module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
inRange,
componentInRange);
}
var writeLabel = _module.AllocateId();
var mergeLabel = _module.AllocateId();
_module.AddStatement(SpirvOp.SelectionMerge, mergeLabel, 0);
@@ -4085,30 +4154,30 @@ public static partial class Gen5SpirvTranslator
_module.AddLabel(mergeLabel);
}
private uint BuildImageOffset(Gen5ImageControl image, int component)
private uint BuildImageOffset(
Gen5ImageControl image,
int component,
uint componentCount)
{
var ivec2 = _module.TypeVector(_intType, 2);
var packed = Bitcast(
_intType,
LoadV(image.GetAddressRegister(
ImageAddressRegister(image, component))));
var x = _module.AddInstruction(
SpirvOp.BitFieldSExtract,
_intType,
packed,
UInt(0),
UInt(6));
var y = _module.AddInstruction(
SpirvOp.BitFieldSExtract,
_intType,
packed,
UInt(8),
UInt(6));
var components = new uint[checked((int)componentCount)];
for (var index = 0; index < components.Length; index++)
{
components[index] = _module.AddInstruction(
SpirvOp.BitFieldSExtract,
_intType,
packed,
UInt((uint)(index * 8)),
UInt(6));
}
return _module.AddInstruction(
SpirvOp.CompositeConstruct,
ivec2,
x,
y);
_module.TypeVector(_intType, componentCount),
components);
}
private uint ApplyDynamicSampleOffset(
@@ -4118,7 +4187,16 @@ public static partial class Gen5SpirvTranslator
uint texelOffset,
uint lod)
{
var ivec2 = _module.TypeVector(_intType, 2);
var spatialComponentCount = ImageSpatialComponentCount(resource);
var coordinateComponentCount =
ImageCoordinateComponentCount(resource);
var spatialIntegerType =
_module.TypeVector(_intType, spatialComponentCount);
var spatialFloatType =
_module.TypeVector(_floatType, spatialComponentCount);
var queryComponentCount = resource.Arrayed
? coordinateComponentCount
: spatialComponentCount;
var image = _module.AddInstruction(
SpirvOp.Image,
resource.ImageType,
@@ -4140,27 +4218,61 @@ public static partial class Gen5SpirvTranslator
signedLod);
var size = _module.AddInstruction(
SpirvOp.ImageQuerySizeLod,
ivec2,
_module.TypeVector(_intType, queryComponentCount),
image,
clampedLod);
if (resource.Arrayed)
{
size = _module.AddInstruction(
SpirvOp.VectorShuffle,
spatialIntegerType,
size,
size,
0u,
1u);
}
var sizeFloat = _module.AddInstruction(
SpirvOp.ConvertSToF,
_vec2Type,
spatialFloatType,
size);
var offsetFloat = _module.AddInstruction(
SpirvOp.ConvertSToF,
_vec2Type,
spatialFloatType,
texelOffset);
var normalizedOffset = _module.AddInstruction(
SpirvOp.FDiv,
_vec2Type,
spatialFloatType,
offsetFloat,
sizeFloat);
if (!resource.Arrayed)
{
return _module.AddInstruction(
SpirvOp.FAdd,
spatialFloatType,
coordinates,
normalizedOffset);
}
var arrayOffset = _module.AddInstruction(
SpirvOp.CompositeConstruct,
_vec3Type,
_module.AddInstruction(
SpirvOp.CompositeExtract,
_floatType,
normalizedOffset,
0u),
_module.AddInstruction(
SpirvOp.CompositeExtract,
_floatType,
normalizedOffset,
1u),
Float(0));
return _module.AddInstruction(
SpirvOp.FAdd,
_vec2Type,
_vec3Type,
coordinates,
normalizedOffset);
arrayOffset);
}
private bool TryEmitExport(
@@ -5290,10 +5402,20 @@ public static partial class Gen5SpirvTranslator
UInt(0x108));
}
// A wave-mask SGPR (VCC/EXEC) consumed as a per-lane predicate — the
// condition of VCndmask, a VCC/EXEC branch, or the derived _vcc/_exec
// bool — must be tested at the CURRENT lane's bit, exactly as the
// hardware does, not as "the 64-bit value is non-zero". The two coincide
// for comparison results (only the lane's own bit is ever set), so the
// single-lane path historically used a cheaper whole-word non-zero test.
// But bitwise-complement wave-mask idioms (S_NOT/S_ORN2/S_ANDN2/S_NAND/
// S_NOR on a 64-bit mask) set the unused upper 63 bits; a whole-word test
// then reports "lane active" even when this lane's bit is clear. Unity's
// PostProcessing NaN killer does exactly this (`anyNaN | ~allFinite`),
// which made every valid pixel read as NaN and get replaced with 0 —
// zeroing the whole scene before tonemap. Extract the lane bit always.
private uint IsWaveMaskActive(uint mask) =>
_subgroupInvocationIdInput == 0
? IsNotZero64(mask)
: IsCurrentLaneSet(mask);
IsCurrentLaneSet(mask);
private uint IsCurrentLaneSet(uint mask) =>
IsNotZero64(
@@ -260,4 +260,233 @@ public static class SpirvFixedShaders
module.AddExecutionMode(main, SpirvExecutionMode.OriginUpperLeft);
return module.Build();
}
/// <summary>
/// Compute kernel that deswizzles RDNA2 tiled surfaces at 4 bytes/element into
/// a linear output buffer — one GPU thread per texel, one dispatch-Z layer per
/// array slice. Mirrors <c>GnmTiling.GetDetileParams</c> so it is bit-identical
/// to the CPU fallback for both supported equation families:
/// <code>
/// z = layer;
/// inBlock = equation == BlockTable // modes 1/4/8
/// ? blockTable[(y % blockHeight) * blockWidth + (x % blockWidth)]
/// : xTerm[x &amp; xMask] ^ yTerm[y &amp; yMask]; // ExactXor 5/9/24/27
/// src = z * srcSliceElements
/// + (y / blockHeight * blocksPerRow + x / blockWidth) * blockElements
/// + inBlock;
/// out[z * width * height + y * width + x] = tiled[src];
/// </code>
/// Each array slice is an independently tiled 2D surface; the caller packs the
/// slices contiguously in the tiled buffer (stride <c>srcSliceElements</c>) and
/// the output ends up layer-major, matching a single multi-layer
/// buffer-&gt;image copy. For a non-arrayed texture the caller dispatches a
/// single Z layer with <c>srcSliceElements</c> unused (z == 0).
///
/// The term tables hold ELEMENT offsets. For ExactXor the caller pre-shifts the
/// byte-unit GetDetileParams terms right by log2(bytesPerElement) (exact at 4bpp
/// since the equation's low two byte-offset bits are 0); for BlockTable the
/// GetDetileParams block table is already in element units. Binding 1 carries
/// xTerm (ExactXor) OR blockTable (BlockTable) — the two equations index
/// different-sized buffers, so the kernel branches and evaluates exactly one.
///
/// width/height are ELEMENT dims (for block-compressed formats a 4x4 block is
/// one element). Each element spans uintsPerElement = bpp/4 words (4bpp -> 1,
/// 8bpp -> 2, 16bpp -> 4); the X dispatch is widened by that factor so each
/// thread copies one word (elemX = gidX / upe, word = gidX % upe). 1/2 bpp are
/// sub-word and stay on the CPU.
///
/// Descriptor set 0: binding 0 = tiled uint[], 1 = xTerm/blockTable uint[],
/// 2 = yTerm uint[], 3 = out uint[]. Push constants (11 x uint, offset i*4):
/// width, height, blockWidth, blockHeight, blockElements, blocksPerRow,
/// xMask, yMask, srcSliceElements, equation (0 = ExactXor, 1 = BlockTable),
/// uintsPerElement. Local size 8x8x1; dispatch X = ceil(width*upe/8),
/// Y = ceil(height/8), Z = arrayLayers.
/// </summary>
public static byte[] CreateDetileCompute()
{
var module = new SpirvModuleBuilder();
module.AddCapability(SpirvCapability.Shader);
var voidType = module.TypeVoid();
var boolType = module.TypeBool();
var uintType = module.TypeInt(32, signed: false);
var uvec3Type = module.TypeVector(uintType, 3);
// One shared Block-decorated storage-buffer struct: struct { uint data[]; }.
var runtimeArray = module.TypeRuntimeArray(uintType);
module.AddDecoration(runtimeArray, SpirvDecoration.ArrayStride, 4);
var bufferStruct = module.TypeStruct(runtimeArray);
module.AddDecoration(bufferStruct, SpirvDecoration.Block);
module.AddMemberDecoration(bufferStruct, 0, SpirvDecoration.Offset, 0);
var bufferPtrType = module.TypePointer(SpirvStorageClass.StorageBuffer, bufferStruct);
var uintStoragePtr = module.TypePointer(SpirvStorageClass.StorageBuffer, uintType);
uint MakeBuffer(uint binding, string name)
{
var variable = module.AddGlobalVariable(bufferPtrType, SpirvStorageClass.StorageBuffer);
module.AddName(variable, name);
module.AddDecoration(variable, SpirvDecoration.DescriptorSet, 0);
module.AddDecoration(variable, SpirvDecoration.Binding, binding);
return variable;
}
var tiledVar = MakeBuffer(0, "tiled");
var xTermVar = MakeBuffer(1, "xTerm");
var yTermVar = MakeBuffer(2, "yTerm");
var outVar = MakeBuffer(3, "outLinear");
// Push constants: struct { uint p0..p10; }, each member at offset i*4.
var pushStruct = module.TypeStruct(
uintType, uintType, uintType, uintType, uintType, uintType,
uintType, uintType, uintType, uintType, uintType);
module.AddDecoration(pushStruct, SpirvDecoration.Block);
for (uint member = 0; member < 11; member++)
{
module.AddMemberDecoration(pushStruct, member, SpirvDecoration.Offset, member * 4);
}
var pushPtrType = module.TypePointer(SpirvStorageClass.PushConstant, pushStruct);
var pushMemberPtrType = module.TypePointer(SpirvStorageClass.PushConstant, uintType);
var pushVar = module.AddGlobalVariable(pushPtrType, SpirvStorageClass.PushConstant);
module.AddName(pushVar, "pc");
var inputUvec3Ptr = module.TypePointer(SpirvStorageClass.Input, uvec3Type);
var gidVar = module.AddGlobalVariable(inputUvec3Ptr, SpirvStorageClass.Input);
module.AddName(gidVar, "gid");
module.AddDecoration(gidVar, SpirvDecoration.BuiltIn, (uint)SpirvBuiltIn.GlobalInvocationId);
var uintConst = new uint[11];
for (uint value = 0; value < 11; value++)
{
uintConst[value] = module.Constant(uintType, value);
}
var functionType = module.TypeFunction(voidType);
var main = module.BeginFunction(voidType, functionType);
module.AddName(main, "main");
module.AddLabel();
var gid = module.AddInstruction(SpirvOp.Load, uvec3Type, gidVar);
var gidX = module.AddInstruction(SpirvOp.CompositeExtract, uintType, gid, 0);
var y = module.AddInstruction(SpirvOp.CompositeExtract, uintType, gid, 1);
var z = module.AddInstruction(SpirvOp.CompositeExtract, uintType, gid, 2);
uint PushField(uint index)
{
var pointer = module.AddInstruction(
SpirvOp.AccessChain, pushMemberPtrType, pushVar, uintConst[index]);
return module.AddInstruction(SpirvOp.Load, uintType, pointer);
}
// width/height are ELEMENT dims (for BC, a 4x4 block is one element). Each
// element spans uintsPerElement 32-bit words (bpp/4: 4bpp->1, 8bpp->2,
// 16bpp->4). The X dispatch is widened by uintsPerElement so each thread
// copies exactly one word: elemX = gidX / upe, wordIndex = gidX % upe.
var width = PushField(0);
var height = PushField(1);
var blockWidth = PushField(2);
var blockHeight = PushField(3);
var blockElements = PushField(4);
var blocksPerRow = PushField(5);
var xMask = PushField(6);
var yMask = PushField(7);
var srcSliceElements = PushField(8);
var equation = PushField(9);
var uintsPerElement = PushField(10);
var elemX = module.AddInstruction(SpirvOp.UDiv, uintType, gidX, uintsPerElement);
var elemXTimesUpe = module.AddInstruction(SpirvOp.IMul, uintType, elemX, uintsPerElement);
var wordIndex = module.AddInstruction(SpirvOp.ISub, uintType, gidX, elemXTimesUpe);
var xInRange = module.AddInstruction(SpirvOp.ULessThan, boolType, elemX, width);
var yInRange = module.AddInstruction(SpirvOp.ULessThan, boolType, y, height);
var inRange = module.AddInstruction(SpirvOp.LogicalAnd, boolType, xInRange, yInRange);
var bodyLabel = module.AllocateId();
var mergeLabel = module.AllocateId();
module.AddStatement(SpirvOp.SelectionMerge, mergeLabel, 0);
module.AddStatement(SpirvOp.BranchConditional, inRange, bodyLabel, mergeLabel);
module.AddLabel(bodyLabel);
// blockIdx = (y / blockHeight) * blocksPerRow + (elemX / blockWidth)
var yDiv = module.AddInstruction(SpirvOp.UDiv, uintType, y, blockHeight);
var blockRow = module.AddInstruction(SpirvOp.IMul, uintType, yDiv, blocksPerRow);
var xDiv = module.AddInstruction(SpirvOp.UDiv, uintType, elemX, blockWidth);
var blockIdx = module.AddInstruction(SpirvOp.IAdd, uintType, blockRow, xDiv);
// off (element offset within the block) = equation == BlockTable
// ? blockTable[(y % blockHeight) * blockWidth + (elemX % blockWidth)]
// : xTerm[elemX & xMask] ^ yTerm[y & yMask]
// Binding 1 (xTermVar) doubles as the block table; the two equations index
// different-sized buffers, so exactly one branch executes (no OOB read).
var isBlockTable = module.AddInstruction(SpirvOp.INotEqual, boolType, equation, uintConst[0]);
var xorLabel = module.AllocateId();
var tableLabel = module.AllocateId();
var offMergeLabel = module.AllocateId();
module.AddStatement(SpirvOp.SelectionMerge, offMergeLabel, 0);
module.AddStatement(SpirvOp.BranchConditional, isBlockTable, tableLabel, xorLabel);
// ExactXor: xTerm[elemX & xMask] ^ yTerm[y & yMask]
module.AddLabel(xorLabel);
var xIdx = module.AddInstruction(SpirvOp.BitwiseAnd, uintType, elemX, xMask);
var xPtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, xTermVar, uintConst[0], xIdx);
var xTerm = module.AddInstruction(SpirvOp.Load, uintType, xPtr);
var yIdx = module.AddInstruction(SpirvOp.BitwiseAnd, uintType, y, yMask);
var yPtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, yTermVar, uintConst[0], yIdx);
var yTerm = module.AddInstruction(SpirvOp.Load, uintType, yPtr);
var offXor = module.AddInstruction(SpirvOp.BitwiseXor, uintType, xTerm, yTerm);
module.AddStatement(SpirvOp.Branch, offMergeLabel);
// BlockTable: blockTable[inY * blockWidth + inX], inX/inY = position in block
module.AddLabel(tableLabel);
var blockXBase = module.AddInstruction(SpirvOp.IMul, uintType, xDiv, blockWidth);
var inX = module.AddInstruction(SpirvOp.ISub, uintType, elemX, blockXBase);
var blockYBase = module.AddInstruction(SpirvOp.IMul, uintType, yDiv, blockHeight);
var inY = module.AddInstruction(SpirvOp.ISub, uintType, y, blockYBase);
var rowInBlock = module.AddInstruction(SpirvOp.IMul, uintType, inY, blockWidth);
var tableIdx = module.AddInstruction(SpirvOp.IAdd, uintType, rowInBlock, inX);
var tablePtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, xTermVar, uintConst[0], tableIdx);
var offTable = module.AddInstruction(SpirvOp.Load, uintType, tablePtr);
module.AddStatement(SpirvOp.Branch, offMergeLabel);
module.AddLabel(offMergeLabel);
var off = module.AddInstruction(SpirvOp.Phi, uintType, offXor, xorLabel, offTable, tableLabel);
// srcElem = z * srcSliceElements + blockIdx * blockElements + off (in elements)
// srcWord = srcElem * uintsPerElement + wordIndex
var srcSliceBase = module.AddInstruction(SpirvOp.IMul, uintType, z, srcSliceElements);
var blockBase = module.AddInstruction(SpirvOp.IMul, uintType, blockIdx, blockElements);
var srcInSlice = module.AddInstruction(SpirvOp.IAdd, uintType, blockBase, off);
var srcElem = module.AddInstruction(SpirvOp.IAdd, uintType, srcSliceBase, srcInSlice);
var srcElemWords = module.AddInstruction(SpirvOp.IMul, uintType, srcElem, uintsPerElement);
var src = module.AddInstruction(SpirvOp.IAdd, uintType, srcElemWords, wordIndex);
var srcPtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, tiledVar, uintConst[0], src);
var word = module.AddInstruction(SpirvOp.Load, uintType, srcPtr);
// dstElem = z * width * height + y * width + elemX (in elements)
// dstWord = dstElem * uintsPerElement + wordIndex
var sliceElements = module.AddInstruction(SpirvOp.IMul, uintType, width, height);
var dstSliceBase = module.AddInstruction(SpirvOp.IMul, uintType, z, sliceElements);
var rowBase = module.AddInstruction(SpirvOp.IMul, uintType, y, width);
var dstRow = module.AddInstruction(SpirvOp.IAdd, uintType, rowBase, elemX);
var dstElem = module.AddInstruction(SpirvOp.IAdd, uintType, dstSliceBase, dstRow);
var dstElemWords = module.AddInstruction(SpirvOp.IMul, uintType, dstElem, uintsPerElement);
var dstIdx = module.AddInstruction(SpirvOp.IAdd, uintType, dstElemWords, wordIndex);
var dstPtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, outVar, uintConst[0], dstIdx);
module.AddStatement(SpirvOp.Store, dstPtr, word);
module.AddStatement(SpirvOp.Branch, mergeLabel);
module.AddLabel(mergeLabel);
module.AddStatement(SpirvOp.Return);
module.EndFunction();
module.AddExecutionMode(main, SpirvExecutionMode.LocalSize, 8, 8, 1);
module.AddEntryPoint(
SpirvExecutionModel.GLCompute,
main,
"main",
[gidVar, tiledVar, xTermVar, yTermVar, outVar, pushVar]);
return module.Build();
}
}
@@ -238,6 +238,7 @@ public enum SpirvDecoration : uint
Binding = 33,
DescriptorSet = 34,
Offset = 35,
NoContraction = 42,
}
public enum SpirvBuiltIn : uint
+2 -1
View File
@@ -192,7 +192,8 @@ public sealed record Gen5GlobalMemoryControl(
uint ScalarAddress,
int OffsetBytes,
bool Glc,
bool Slc) : Gen5InstructionControl;
bool Slc,
bool UsesFlatAddress = false) : Gen5InstructionControl;
public sealed record Gen5BufferMemoryControl(
uint DwordCount,
@@ -4,6 +4,7 @@
using SharpEmu.HLE;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Numerics;
@@ -35,6 +36,14 @@ public static class Gen5ShaderScalarEvaluator
StringComparison.Ordinal);
private static readonly object _scalarFallbackTraceGate = new();
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
// Host submit of those translations has lost the Vulkan device; Agc skips
// them before QueueSubmit.
private static readonly ConcurrentDictionary<ulong, byte> _emptySrtScalarPointerFallbacks =
new();
public static bool WasEmptySrtScalarPointerFallback(ulong shaderAddress) =>
_emptySrtScalarPointerFallbacks.ContainsKey(shaderAddress);
// Uniform forward branches select material/resource bodies that remain
// statically present in the translated shader. Discover the skipped body's
@@ -349,7 +358,10 @@ public static class Gen5ShaderScalarEvaluator
if (globalMemory.ScalarAddress >= ScalarRegisterCount - 1)
{
error =
$"global-address-register-range pc=0x{instruction.Pc:X} " +
$"{(globalMemory.UsesFlatAddress
? "flat-address-base-unresolved"
: "global-address-register-range")} " +
$"pc=0x{instruction.Pc:X} " +
$"s{globalMemory.ScalarAddress}";
return false;
}
@@ -364,11 +376,18 @@ public static class Gen5ShaderScalarEvaluator
}
var key = (globalMemory.ScalarAddress, baseAddress);
var writable = instruction.Opcode.StartsWith(
var writable =
instruction.Opcode.StartsWith(
"GlobalStore",
StringComparison.Ordinal) ||
instruction.Opcode.StartsWith(
"GlobalAtomic",
StringComparison.Ordinal) ||
instruction.Opcode.StartsWith(
"FlatStore",
StringComparison.Ordinal) ||
instruction.Opcode.StartsWith(
"FlatAtomic",
StringComparison.Ordinal);
if (globalMemoryByAddress.TryGetValue(key, out var existingBinding))
{
@@ -2101,6 +2120,15 @@ public static class Gen5ShaderScalarEvaluator
$"dynamic={dynamicOffset} definitions=[{string.Join(';', definitions)}] " +
$"user_data=[{userData}] metadata=" +
$"{(state.Metadata is null ? "missing" : $"srt={state.Metadata.ShaderResourceTableSizeDwords},eud={state.Metadata.ExtendedUserDataSizeDwords}")}");
if (baseAddress == 0 &&
state.Metadata is
{
ShaderResourceTableSizeDwords: 0,
ExtendedUserDataSizeDwords: 0,
})
{
_emptySrtScalarPointerFallbacks.TryAdd(state.Program.Address, 0);
}
}
[Conditional("DEBUG")]
@@ -80,7 +80,7 @@ public static class Gen5ShaderTranslator
public static bool IsScalarConsumed(ulong[] mask, uint register) =>
register < 256 && (mask[register >> 6] & (1UL << (int)(register & 63))) != 0;
private const int MaxInstructions = 4096;
private const int MaxInstructions = 16384;
private const uint PsUserDataRegister = 0x0C;
private const uint VsUserDataRegister = 0x4C;
private const uint GsUserDataRegister = 0x8C;
@@ -305,6 +305,23 @@ public static class Gen5ShaderTranslator
count |= 0x20;
}
// Primary SH defaults leave SPI_SHADER_PGM_RSRC2_PS at 0. Draws that
// still wrote USER_DATA_n via SetShReg would otherwise translate with
// an empty SRT window (Astro title PS → Address-0 descriptors →
// device lost). Recover the window from contiguous live registers.
if (count == 0 &&
userDataBaseRegister is not ComputeUserDataRegister)
{
var probed = 0;
while (probed < MaximumHardwareUserSgprs &&
shaderRegisters.ContainsKey(userDataBaseRegister + (uint)probed))
{
probed++;
}
count = probed;
}
if (userDataBaseRegister is not (PsUserDataRegister or
VsUserDataRegister or
GsUserDataRegister or
@@ -454,7 +471,15 @@ public static class Gen5ShaderTranslator
}
}
instructions.Add(CreateInstruction(pc, encoding, name, words));
var instruction = CreateInstruction(pc, encoding, name, words);
if (instruction.Control is Gen5GlobalMemoryControl
{
UsesFlatAddress: true,
})
{
instruction = ResolveFlatAddressBase(instructions, instruction);
}
instructions.Add(instruction);
instructionCount++;
pc += sizeDwords * sizeof(uint);
@@ -1192,8 +1217,10 @@ public static class Gen5ShaderTranslator
// Opcode numbers taken from LLVM's AMDGPU VOP3PInstructions.td and the
// gfx9/gfx10 MC test encodings; they are unchanged across gfx9 and gfx10.
// Unhandled packed opcodes (integer, fma_mix, ...) stay opaque here and
// fail loudly at emission rather than being silently mis-emitted.
// The mix ops (0x20/0x21/0x22) are V_MAD_MIX_* on gfx9 and V_FMA_MIX_*
// (fused) on the gfx10 the PS5 targets; both share these opcodes. Any
// remaining packed opcode (integer, ...) stays opaque here and fails
// loudly at emission rather than being silently mis-emitted.
name = opcode switch
{
0x0E => "VPkFmaF16",
@@ -1201,6 +1228,9 @@ public static class Gen5ShaderTranslator
0x10 => "VPkMulF16",
0x11 => "VPkMinF16",
0x12 => "VPkMaxF16",
0x20 => "VFmaMixF32",
0x21 => "VFmaMixloF16",
0x22 => "VFmaMixhiF16",
_ => $"Vop3pRaw{opcode:X2}",
};
@@ -1373,35 +1403,42 @@ public static class Gen5ShaderTranslator
var opcode = (word >> 18) & 0x7F;
sizeDwords = 2;
error = string.Empty;
name = segment == 0x2
? opcode switch
{
0x08 => "GlobalLoadUbyte",
0x09 => "GlobalLoadSbyte",
0x0A => "GlobalLoadUshort",
0x0B => "GlobalLoadSshort",
0x0C => "GlobalLoadDword",
0x0D => "GlobalLoadDwordx2",
0x0E => "GlobalLoadDwordx4",
0x0F => "GlobalLoadDwordx3",
0x18 => "GlobalStoreByte",
0x19 => "GlobalStoreByteD16Hi",
0x1A => "GlobalStoreShort",
0x1B => "GlobalStoreShortD16Hi",
0x1C => "GlobalStoreDword",
0x1D => "GlobalStoreDwordx2",
0x1E => "GlobalStoreDwordx4",
0x1F => "GlobalStoreDwordx3",
0x20 => "GlobalLoadUbyteD16",
0x21 => "GlobalLoadUbyteD16Hi",
0x22 => "GlobalLoadSbyteD16",
0x23 => "GlobalLoadSbyteD16Hi",
0x24 => "GlobalLoadShortD16",
0x25 => "GlobalLoadShortD16Hi",
0x32 => "GlobalAtomicAdd",
0x38 => "GlobalAtomicUMax",
_ => string.Empty,
}
var prefix = segment switch
{
0x0 => "Flat",
0x2 => "Global",
_ => string.Empty,
};
var suffix = opcode switch
{
0x08 => "LoadUbyte",
0x09 => "LoadSbyte",
0x0A => "LoadUshort",
0x0B => "LoadSshort",
0x0C => "LoadDword",
0x0D => "LoadDwordx2",
0x0E => "LoadDwordx4",
0x0F => "LoadDwordx3",
0x18 => "StoreByte",
0x19 => "StoreByteD16Hi",
0x1A => "StoreShort",
0x1B => "StoreShortD16Hi",
0x1C => "StoreDword",
0x1D => "StoreDwordx2",
0x1E => "StoreDwordx4",
0x1F => "StoreDwordx3",
0x20 => "LoadUbyteD16",
0x21 => "LoadUbyteD16Hi",
0x22 => "LoadSbyteD16",
0x23 => "LoadSbyteD16Hi",
0x24 => "LoadShortD16",
0x25 => "LoadShortD16Hi",
0x32 => "AtomicAdd",
0x38 => "AtomicUMax",
_ => string.Empty,
};
name = prefix.Length != 0 && suffix.Length != 0
? prefix + suffix
: string.Empty;
return FinishDecode(
@@ -1607,6 +1644,11 @@ public static class Gen5ShaderTranslator
binding.ResourceDescriptor.SequenceEqual(candidate.ResourceDescriptor));
}
public static bool IsArrayedImageBinding(Gen5ImageBinding binding) =>
binding.Control.IsArray &&
(binding.Opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
binding.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal));
public static bool IsDataShareAtomic(string name) => name switch
{
"DsAddU32" or "DsSubU32" or "DsIncU32" or "DsDecU32" or
@@ -1619,6 +1661,82 @@ public static class Gen5ShaderTranslator
_ => false,
};
private static Gen5ShaderInstruction ResolveFlatAddressBase(
IReadOnlyList<Gen5ShaderInstruction> precedingInstructions,
Gen5ShaderInstruction instruction)
{
if (instruction.Control is not Gen5GlobalMemoryControl
{
UsesFlatAddress: true,
} control ||
!TryFindVectorDefinition(
precedingInstructions,
control.VectorAddress,
out var lowDefinition) ||
!TryFindVectorDefinition(
precedingInstructions,
control.VectorAddress + 1,
out var highDefinition))
{
return instruction;
}
foreach (var lowSource in lowDefinition.Sources)
{
if (lowSource.Kind != Gen5OperandKind.ScalarRegister)
{
continue;
}
foreach (var highSource in highDefinition.Sources)
{
if (highSource.Kind != Gen5OperandKind.ScalarRegister ||
highSource.Value != lowSource.Value + 1)
{
continue;
}
return instruction with
{
Sources =
[
.. instruction.Sources,
Gen5Operand.Scalar(lowSource.Value),
],
Control = control with
{
ScalarAddress = lowSource.Value,
},
};
}
}
return instruction;
}
private static bool TryFindVectorDefinition(
IReadOnlyList<Gen5ShaderInstruction> instructions,
uint register,
out Gen5ShaderInstruction definition)
{
for (var index = instructions.Count - 1; index >= 0; index--)
{
var candidate = instructions[index];
foreach (var destination in candidate.Destinations)
{
if (destination.Kind == Gen5OperandKind.VectorRegister &&
destination.Value == register)
{
definition = candidate;
return true;
}
}
}
definition = default!;
return false;
}
private static Gen5ShaderInstruction CreateInstruction(
uint pc,
Gen5ShaderEncoding encoding,
@@ -2030,7 +2148,13 @@ public static class Gen5ShaderTranslator
var vectorAddress = extra & 0xFF;
var vectorData = (extra >> 8) & 0xFF;
var scalarAddress = (extra >> 16) & 0x7F;
var dwordCount = opcode switch
var usesFlatAddress = opcode.StartsWith(
"Flat",
StringComparison.Ordinal);
var memoryOpcode = usesFlatAddress
? "Global" + opcode["Flat".Length..]
: opcode;
var dwordCount = memoryOpcode switch
{
"GlobalLoadUbyte" or
"GlobalLoadSbyte" or
@@ -2058,12 +2182,20 @@ public static class Gen5ShaderTranslator
"GlobalStoreDwordx4" => 4u,
_ => 0u,
};
sources =
[
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Scalar(scalarAddress),
];
destinations = opcode.StartsWith("GlobalLoad", StringComparison.Ordinal)
sources = usesFlatAddress
?
[
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Vector(vectorAddress + 1),
]
:
[
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Scalar(scalarAddress),
];
destinations = memoryOpcode.StartsWith(
"GlobalLoad",
StringComparison.Ordinal)
? Enumerable
.Range((int)vectorData, checked((int)dwordCount))
.Select(index => Gen5Operand.Vector((uint)index))
@@ -2073,10 +2205,11 @@ public static class Gen5ShaderTranslator
dwordCount,
vectorAddress,
vectorData,
scalarAddress,
usesFlatAddress ? uint.MaxValue : scalarAddress,
SignExtend(word & 0x1FFF, 13),
((word >> 16) & 1) != 0,
((word >> 17) & 1) != 0);
((word >> 17) & 1) != 0,
usesFlatAddress);
break;
}
case Gen5ShaderEncoding.Mubuf:
@@ -0,0 +1,93 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcPredicationTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong CommandBufferAddress = BaseAddress + 0x100;
private const ulong PacketAddress = BaseAddress + 0x400;
private const ulong PredicateAddress = BaseAddress + 0x800;
[Fact]
public void DcbSetPredication_EmitsGen5Packet()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 1;
ctx[CpuRegister.Rdx] = 3;
ctx[CpuRegister.Rcx] = 1;
ctx[CpuRegister.R8] = PredicateAddress + 7;
ctx[CpuRegister.R9] = 2;
var result = AgcExports.DcbSetPredication(ctx);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
Assert.Equal(0xC002_2000u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0003_1100u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(unchecked((uint)PredicateAddress), ReadUInt32(memory, PacketAddress + 8));
Assert.Equal((uint)(PredicateAddress >> 32), ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(PacketAddress + 16, ReadUInt64(memory, CommandBufferAddress + 0x10));
}
[Fact]
public void SetPacketPredication_TogglesPacketHeaderBit()
{
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
var ctx = new CpuContext(memory, Generation.Gen5);
const uint header = 0xC003_1500;
WriteUInt32(memory, PacketAddress, header);
ctx[CpuRegister.Rdi] = PacketAddress;
ctx[CpuRegister.Rsi] = 1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.SetPacketPredication(ctx));
Assert.Equal(header | 1u, ReadUInt32(memory, PacketAddress));
ctx[CpuRegister.Rsi] = 0;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.SetPacketPredication(ctx));
Assert.Equal(header, ReadUInt32(memory, PacketAddress));
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}

Some files were not shown because too many files have changed in this diff Show More