Compare commits

..

68 Commits

Author SHA1 Message Date
ParantezTech 5f37dd85e0 [CMake] update commit 2026-07-22 20:28:36 +03:00
ParantezTech 38621e7be9 [CMake] update commit 2026-07-22 19:46:19 +03:00
ParantezTech c9c0793059 [VideoOut] Add Bink2 support via FFMPEG bridge 2026-07-22 18:31:29 +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
Berk a030cb5a5d Gpu runtime stalls (#410)
* [runtime] restore default GC mode

* [cpu] add string leaf stubs

* [ampr] allow concurrent reads

* [bink] keep guest decode path

* [kernel] streamline host memory access

* [shader] add scalar memory fallback

* [gpu] bound guest data pool

* [gpu] reduce queue stalls

* [video] stabilize guest resources

* revert lock file
2026-07-19 00:31:50 +03:00
Dafenx 336286e588 CPU: scan final TLS access pattern offset (#414)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-19 00:07:32 +03:00
Berk cab001f265 [GUI] Fixes click the controller B/O close button to close the game (#415) 2026-07-18 23:56:56 +03:00
Berk bab965e394 [HLE] Add RandomExports HLE (#413) 2026-07-18 23:44:57 +03:00
Spooks daaeb6213e Fix Massive Bug Preventing UE5 Titles From Booting (#406)
* Fix cross platform memcpy bug
2026-07-18 12:50:59 -06:00
Gutemberg Ribeiro 94153955b0 [Gpu] Metal backend: complete IGuestGpuBackend implementation on AppKit + Metal (#283)
* [ShaderCompiler.Metal] MSL translator core: dispatcher, EXEC model, compute stage

The Metal codegen backend, rebuilt on the merged backend-neutral
abstractions (replacing the pre-abstraction spike): consumes
(Gen5ShaderState, Gen5ShaderEvaluation) and emits MSL text; the renderer
owns MTLLibrary compilation, mirroring the emitters-produce-bytes rule
the Vulkan sibling documents.

The execution model mirrors Gen5SpirvTranslator: one invocation per GCN
lane (wave32 — natively the Apple simdgroup width), a typeless uint
register file with as_type<float> bitcasts, EXEC/VCC as per-lane bools
whose guest-visible mask registers materialize via simd_ballot, and the
same PC-dispatcher loop over basic blocks with the
SHARPEMU_SHADER_MAX_STEPS iteration guard and the dominating-scalar-
definition dataflow for buffer binding resolution. Unlike SPIR-V, MSL
permits shared prelude functions, so unaligned/subdword buffer access
is a range-checked device-uchar* helper instead of per-site inlining;
buffer byte lengths and the compute dispatch limit travel in one
reserved SharpEmuUniforms constant buffer (Metal has no OpArrayLength).

This first slice covers the compute entry point end to end: scalar/
vector ALU core (moves, int/float arithmetic, FMA family, shifts,
bitfield ops, min/max/med3, conversions, transcendentals with the Tau
scale on sin/cos), the full VCmp/VCmpx compare matrix writing VCC/EXEC,
the saveexec family, scalar compares and SCC-updating SOP2 forms, lane
ops (readfirstlane, mbcnt), VOP3 abs/neg/clamp/omod modifiers, scalar
memory, and raw global/buffer loads, stores, and atomics with EXEC
guards. Unsupported opcodes fail loudly with pc + mnemonic. SDWA/DPP,
typed format loads, LDS, images, and the pixel/vertex stages follow in
the next phases.

Tests live in their own self-contained project (the per-backend model:
depends only on the codegen under test): hand-assembled synthetic
fixtures drive the real decoder end to end, structural assertions and
golden-MSL comparisons run on every platform since translation is pure
text generation, and goldens regenerate via SHARPEMU_UPDATE_GOLDENS=1.

* [ShaderCompiler.Metal] Real-device runtime tests: compile + execute on the GPU

Lifts the spike's objc_msgSend LibraryImport harness (MTLDevice /
MTLCompileOptions with fast-math off, as a real Metal backend must
compile) onto the new translator contract: the guest data buffer binds
at index 0 and the SharpEmuUniforms constant buffer (dispatch limit +
buffer byte lengths) at index 1.

Three runtime tiers on hosts with a Metal device (no-op elsewhere so
Windows/Linux CI stays green): every fixture's emitted MSL must be
accepted by the OS runtime Metal compiler; the exec-store program must
produce bit-exact GPU results including the EXEC-masked store that must
not land; and a scalar countdown loop must iterate through the PC
dispatcher (s_cmp_lg_u32 + s_cbranch_scc1 across five round trips).
The loop fixture also fixes its own hand-assembly: s_sub_i32 sets SCC
to signed overflow, not result-nonzero, so the loop condition uses an
explicit compare.

* [ShaderCompiler.Metal] Phase 2: scalar/vector ALU parity with the SPIR-V translator

Ports the remaining ALU semantics from Gen5SpirvTranslator.Alu so the two
codegens cannot disagree on instruction behavior:

- Carry/borrow family (v_add_co/_ci, v_sub_co/_rev, v_subb/_rev) with the
  carry mask written to the VOP3 scalar destination or VCC, ANDed with
  EXEC; v_mad_u64_u32 with the 64-bit pair result and carry-out.
- Full SDWA support: byte/word source selects with sign-extension,
  integer abs/neg modifiers, and destination-select merge (zero-fill,
  sign-extend, preserve) into the previous register value.
- DPP16/DPP8: quad permute, row shl/shr/ror, mirror/half-mirror,
  broadcast and xor controls via simd_shuffle, bound-control and
  row/bank write-enable masks, fetch-inactive handling; DPP-predicated
  compares merge into VCC.
- Lane ops: readfirstlane from the first EXEC-active lane via
  ballot+ctz, readlane/writelane, permlane16/permlanex16.
- 64-bit scalar ops over SGPR pairs in real ulong arithmetic (logic
  family, shifts, bfe/bfm with width clamping, wqm quad expansion,
  cselect, mov, s_getpc) plus the B32/B64 saveexec families.
- Sopk forms decode the signed 16-bit immediate and s_cmpk compares the
  destination register; SOPC scalar compares including s_bitcmp0/1.
- Conversions: f16<->f32 via as_type<half>, pkrtz with round-to-zero
  mantissa truncation, pknorm via pack_float_to_{s,u}norm2x16, pk_u8
  byte insert, off_f32_i4 table, rpi/flr rounding; cube id/sc/tc/ma
  decision trees; v_cmp_class_f32; VCCZ/EXECZ/SCC readable as data.

Also fixes a real phase-1 bug the reference surfaced: fmamk/fmaak
sources arrive in natural order from the decoder, so all MAD/FMA forms
are fma(src0, src1, src2) — the previous operand swap computed
v1*v2+K for v_fmamk (should be v1*K+v2). The regenerated fmac golden
shows the corrected expansion, and mirroring the SPIR-V translator,
v_mul_u32_u24 is a full 32-bit multiply (only the hi/mad forms mask).

All 13 Metal tests pass including the real-GPU execution tier.

* [ShaderCompiler.Metal] Phase 3: typed format loads, LDS, and D16 subdword memory

Typed MUBUF/MTBUF loads convert through the descriptor's GFX10 unified
format at execution time, mirroring the SPIR-V translator: the prelude
bakes a 128-entry format table from the shared Gfx10UnifiedFormat
decoder (compiled shaders may be reused with new SRDs, so decoding must
stay dynamic), per-component layouts for the legacy DATA_FORMAT values
drive range-checked unaligned loads, NUM_FORMAT conversion handles
unorm/snorm (clamped at -1)/uscaled/sscaled/uint/sint/float including
f16 and the 10/11-bit unsigned mini-floats of 10_11_11 / 11_11_10, the
missing-component default is one in the format's domain, and dst_sel
swizzling comes from descriptor word 3. Format stores stay raw dword
stores like the reference.

LDS lands as 32 KB of threadgroup memory (gated on the program actually
using DS ops so occupancy is not taxed): ds_read/write b32/b64/b96/b128,
the write2/read2 pairs including st64 scaling, and ds_add_u32 as a
relaxed threadgroup atomic, with EXEC-guarded writes and the address
masked into bounds. Subdword loads/stores gain the D16/D16Hi variants
that merge into one half of the destination register (and shift the
source for high stores), classified the same way as the reference.

New GPU-executed fixture: an LDS round trip (write literal, s_barrier,
read back, store to the buffer) passes bit-exact on a real Metal device
alongside the existing tiers.

* [ShaderCompiler.Metal] Phase 4: pixel stage, images, and interpolation

The pixel entry points land with the same contract as the SPIR-V
translator (single-target and MRT forms, validated for unique guest
slots and dense host locations): the emitted fragment function takes a
stage_in struct carrying [[position]] plus the interpolated attributes
discovered from the program's V_INTERP controls, writes an output
struct with one [[color(hostLocation)]] attachment per binding typed by
its Float/Sint/Uint kind, seeds pixel-input VGPRs in SPI_PS_INPUT_ADDR
compact order from the fragment coordinate, keeps EXEC masking through
translation, and discards lanes that exit with EXEC off. Exports write
MRT targets per component under EXEC (disabled components keep their
previous value) including compressed half-pair exports; vertex-target
exports no-op until the vertex stage.

Images arrive as texture2d<float|int|uint> arguments (storage bindings
as access::read_write) with samplers alongside, classified from the
descriptor's unified format via the shared Gfx10UnifiedFormat decoder
and resolved per instruction with the same dominating-scalar-definition
scheme as buffers. The sample matrix covers implicit LOD, SampleL/Lz,
SampleB, SampleD gradients, PCF compare (manual reference<=texel,
broadcast r,r,r,1), per-lane texel offsets folded into normalized
coordinates by the selected mip extent (Metal sample offsets must be
constants), gather4 including compare and offset forms, clamped
ImageLoad/Mip, bounds-checked EXEC-guarded ImageStore, GetResinfo, and
A16 packed addresses / D16 packed data in both directions.

Graphics stages model LDS as per-invocation scratch (the SPIR-V
Private-array trick) instead of threadgroup memory. Wave ops keep the
invocation's real simdgroup in every stage — Apple fragment simdgroups
make that the same model as compute, where the SPIR-V translator
instead emulates a single logical lane; both round-trip EXEC masks
consistently.

The pixel fixture (interpolated attr0.xy plus inline constants exported
to MRT0) is golden-pinned, structurally asserted, and accepted by the
OS Metal compiler on a real device.

* [ShaderCompiler.Metal] Phase 5: vertex stage and fixed presenter shaders

The vertex entry point completes the four-entry-point contract: the
emitted vertex function takes fetched attributes as a stage_in struct
([[attribute(location)]], bound by the backend via MTLVertexDescriptor
from the reflected vertex inputs), returns [[position]] plus one
[[user(locnN)]] param output per export target 32..63 — unioned with
requiredVertexOutputCount so Metal's exact vertex-out/fragment-in
interface match succeeds, with unexported locations zero-filled —
seeds v5/v8 from [[vertex_id]]/[[instance_id]], intercepts buffer loads
the evaluator captured as fixed-function vertex inputs, and applies the
same EXEC-selected component rules to position/param exports (disabled
components default to 0,0,0,1) including compressed half pairs.

MSL vertex functions have no simdgroup attributes, so the vertex stage
models a single logical wave lane exactly like the SPIR-V translator's
graphics path: lane 0, ballot degrades to 0/1, and lane-shuffle ops
would fail Metal compilation loudly (no real guest vertex shader uses
them).

MslFixedShaders mirrors SpirvFixedShaders for the presenter surface:
the fullscreen-triangle vertex stage (position from the vertex index,
screen-space UV broadcast to every requested attribute location), the
copy/solid/attribute diagnostic fragments, and the output-free
depth-only fragment. Metal forbids "main", so each carries a stable
entry name.

The vertex fixture (constant position + one param export) is
golden-pinned and structurally asserted; it and all five fixed shaders
are accepted by the OS Metal compiler on a real device.

* [ShaderCompiler.Metal] Author static MSL blocks as template files

The prelude helpers (buffer access, ballot, tables), the format-load
conversion functions, and all five fixed presenter shaders move out of
AppendLine walls into Templates/*.msl embedded resources — real Metal
source with syntax highlighting and reviewable diffs — rendered by a
small {{placeholder}} substituter that fails loudly on any
unsubstituted token. Substitution points are deliberately few: the
stage-dependent ballot expression (vertex has no simdgroup attributes),
the baked GFX10 format table and layout cases, and the fixed shaders'
parameters. Per-instruction body emission stays programmatic, where a
template cannot express it.

Behavior-identical by construction: the golden files are untouched and
the whole suite — including the real-device execution and compile
tiers over the templated output — passes against them unchanged. The
.msl files carry no license headers (they would leak into every emitted
shader), so REUSE.toml annotates the Templates directory instead.

* [ShaderCompiler.Metal] Cover the MSL goldens in REUSE.toml

The golden files are verbatim emitter output regenerated by the test
suite; license headers inside them would either break the byte-exact
comparison or force the emitter to write SPDX text into every shader.
Annotate the Goldens directory like the Templates one.

* [ShaderCompiler.Metal] Address review: dominating-binding parity and harness binding indices

The buffer-binding fallback now mirrors the SPIR-V translator: a candidate
binding is accepted only when the descriptor registers hold the exact same
scalar definitions at the target PC as at one of the binding's own access
points (HasSameScalarDefinitions), instead of merely being non-conflicting
at the target. Resolutions are cached per PC like the reference.

The runtime test harness no longer hardcodes buffer indices 0/1 and a
20-byte uniforms blob: TryExecuteSingleThread takes the data/uniforms bind
indices, and ExecuteOrThrow derives them plus the uniforms size from the
compiled shader's GlobalMemoryBindings per the translator contract.

* [Gpu] Add the Metal guest-GPU backend: shader compilation and formats

First phase of the Metal backend behind the IGuestGpuBackend seam. The
backend compiles all three shader stages through Gen5MslTranslator and
exposes the guest render-target format table (mirroring the Vulkan table
case for case; guest format 9 maps to BGR10A2, the Metal layout matching
Vulkan's A2R10G10B10 pack). Wave64 compute is rejected with a clear error
until the two-pass emulation exists.

SHARPEMU_GPU_BACKEND=metal opts in on macOS; Vulkan stays the default on
every platform until the Metal presenter reaches parity. Presenter-side
methods fail loudly instead of dropping guest frames silently.

* [Gpu] Add the Metal presenter core: AppKit window, CAMetalLayer, CPU-frame path

The presenter opens an NSWindow hosting a CAMetalLayer and drives a manually
pumped NSApplication event loop, structured like the Vulkan presenter's
poll-and-render loop and posted onto HostMainThread the same way (AppKit
traps off the process main thread). All OS access goes through objc_msgSend
LibraryImport bindings declared locally — no windowing or binding packages on
this path, which is what keeps it NativeAOT-clean. Struct-returning ObjC
calls are avoided entirely so one calling convention works under Rosetta.

Presents CPU-produced BGRA frames and the splash through a fullscreen
triangle with a dedicated present fragment stage that flips V: with Metal's
y-up NDC the shared fullscreen triangle puts UV (0,0) at the bottom of the
screen while textures keep v=0 at the top. Frames letterbox via the viewport,
and nextDrawable paces the loop at presentation rate.

Guest-image submission now returns false (callers use their CPU-readback
fallback, which the presenter can show); draw and compute submission still
fail loudly pending later phases.

* [Gpu] Complete the guest-GPU seam: lift the AGC bypass surface onto the backend

The abstraction left AGC and VideoOut calling VulkanVideoPresenter statics
directly for guest work ordering (EnterGuestQueue, SubmitOrderedGuestAction,
SubmitOrderedGuestFlipWait, WaitForGuestWork), guest-image lifecycle (initial
data seeding, writes, fills, extents, upload tracking), the texture-content
cache probe, guest memory attachment, storage-offset alignment, perf
counters, and presenter close. With a non-Vulkan backend selected those
calls silently hit a never-started Vulkan presenter.

All of it now crosses IGuestGpuBackend: the Vulkan backend delegates to the
existing presenter statics (no behavior change), and the Metal backend
answers exactly like a presenter that is not running (sequence 0, image
unknown), which keeps callers on the same inline/CPU fallbacks they take
today. TextureContentIdentity moves to the seam types, and the bounded
AGC-to-presenter transfer pool becomes the backend-neutral GuestDataPool
(one pool by necessity: the AGC layer rents, the presenter returns).

AgcExports snapshots the backend's offset alignment once — it was a const
before and is read in per-draw loops (shader-key hashing, offset rounding).

* [Gpu] Metal guest work queue and guest images: ordered flips, writes, fills, blits

Mirrors the Vulkan presenter's execution model. AGC submissions become work
items consumed by the render loop in logical-guest-queue order: FIFO within
each guest queue, ready queues scheduled round-robin, completion tracked as
a contiguous sequence plus an out-of-order set, and producer backpressure
(count and payload caps) that consumer-enqueued follow-ups bypass to avoid
self-deadlock. The drain is budgeted (12ms, 256 items) so a backlog cannot
starve the Cocoa event pump or the present.

Guest images are Metal textures keyed by guest address, created on first
use from the registered display-buffer format tag (byte-identical encoding
to the Vulkan backend) and seeded once from pending initial data or guest
memory, since PS5 render targets alias guest memory. Coherence mirrors the
Vulkan design: DMA-style writes swap in a freshly written texture (never
mutating one an in-flight present may sample), fills clear through a
hazard-tracked render pass, and same-extent blits copy on the GPU.

Ordered flips capture the named image into an immutable version at their
exact queue position, so later work cannot change the frame a flip
selected; flip waits complete by queue position alone. Presentation picks
the newest ready queued guest frame (retiring superseded captures),
re-resolving mutable address-keyed textures at encode time so a write swap
never leaves a stale handle.

* [Gpu] Metal translated draws: pipelines, render state, bindings, write-back

Executes the seam's translated-draw surface on Metal. Offscreen, depth-only,
and storage draws are ordered guest work rendering into guest-addressed
images (published targets register as flip sources exactly like the Vulkan
backend); onscreen draws and recognized fixed-function draws ride the
presentation and render at present time into a pooled target.

Pipelines are built from GuestRenderState and cached by shader identity plus
a state hash: guest CB blend factor/op codes, write masks (bit-reversed for
MTLColorWriteMask), depth ZFUNC (bit-identical to MTLCompareFunction),
vertex attribute formats decoded from the same guest (dataFormat,
numberFormat) table the Vulkan backend uses, and RDNA 2:10:10:10 mapped to
Metal's R-low-bits 1010102 layout. Guest viewports pass through unchanged —
Metal accepts the negative heights PS5 games program, which is also how the
Vulkan backend inherits its orientation. Rect lists draw as 4-vertex strips;
Metal has no triangle fans, so those degrade to lists with a one-time warn.

Bindings follow the Gen5MslTranslator contract: global buffers at their flat
slot on both stages, SharpEmuUniforms (dispatch limit + buffer byte lengths)
after them, textures/samplers at the image slots with samplers decoded from
the raw guest descriptor words, and vertex streams at slot 26+ so they never
collide. Writable global buffers write back to guest memory before the work
item completes, preserving the CPU-visible GPU-write ordering point that
WaitForGuestWork promises. Feedback reads of a live render target sample a
blit snapshot; pooled guest data returns to GuestDataPool after upload.

Known simplifications for follow-up: textures upload a single mip level, and
the texture-content cache stays unclaimed (IsTextureContentCached=false)
until write-tracker-driven eviction exists, trading upload bandwidth for
correctness.

* [Gpu] Metal compute dispatch: the last seam gap

Guest compute dispatches are ordered guest work like draws. The uniforms
contract carries the per-axis dispatch limit (explicit thread counts when
the guest supplied them, groups x threadgroup size otherwise) so the
kernel's bounds guard clamps the overshoot threads of the last threadgroup;
threadgroup dimensions come from the translated shader, which bakes them at
compile time. Compute pipeline states cache per shader handle.

Storage images are shared live through the guest-image registry: a
dispatch's writes are visible to later draws, blits, and flips of the same
address, the address registers as a flip source at submit, and writer
sequences keep presentation waiting on exactly the work that produced the
frame. Writable buffers write back to guest memory before the work item
completes — the CPU-visible ordering point the returned sequence promises
through WaitForGuestWork.

Metal has no dispatch-base; nonzero base groups execute without the offset
behind a one-time warning until the emitted kernel grows base support.
SHARPEMU_SKIP_ALL_COMPUTE=1 skips all dispatches for hang isolation, same
as the Vulkan backend. With this the Metal backend implements the entire
IGuestGpuBackend surface — nothing throws.

* [Gpu] Address review: real bytes-per-pixel in guest-image uploads

Guest-image uploads hard-coded 4 bytes per texel, which mis-strided
Rgba16*/Rg32Float/Rgba32Float images and, in the guest-memory seed and
storage-snapshot paths, could make replaceRegion read past the managed
buffer. Texel width now comes from the pixel format, and
ReplaceTextureContents clamps the row count to what the source buffer
actually holds, so no caller can overread regardless of pitch and format.
RGBA8 initial data seeds only 4-byte-texel images; wider formats seed from
guest memory, whose layout is the image's native one. Extent byte counts
use the real texel width too.

Also restores the reference's comment on the deliberate single-item
backpressure admit: with no payload outstanding, refusing an oversized item
would wait forever since nothing is left to drain.

* [ShaderCompiler] First real-game fixes: SSendmsg no-op, scalar-state buffer declaration

Bring-up against a real title (2D engine, NGG shaders) found every draw
rejected at translation: RDNA2 NGG shaders bracket their exports with
s_sendmsg (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space, and
neither translator handled the opcode — it fell through to the scalar-ALU
guard and failed with 'missing scalar destination'. Both translators now
treat SSendmsg as a no-op alongside SNop/SWaitcnt: exports are translated
directly, so the hardware message is moot. This was a shared gap, not a
backend one; the Vulkan path would reject the same shaders.

With translation unblocked, the OS Metal compiler rejected the emitted MSL:
the body reads the per-dispatch scalar-state buffer (initial SGPRs plus
per-binding byte biases) as b{initialScalarBufferIndex}, but the kernel
signature only declared the stage's own global bindings, so the name never
existed. The signature now declares it (const device — it is only read) at
its flat slot.

The presenter also logs one line when it first presents real content,
making 'window up but nothing shown' diagnosable from the log alone.
Verified: the title goes from 100% draw misses and a black screen to
~58k translated draws per minute and 4K frames presenting.

* [Gpu] Metal presenter: NSTimer-driven render loop under [NSApp run]

Replaces the hand-pumped event loop with a real running main loop. The
presenter now creates the NSApplication, orders the CAMetalLayer-backed
window on screen, and calls [NSApp run] so Core Animation's run-loop observer
actually commits presented drawables to the window server — without a running
loop the layer never composites and the window stays black regardless of what
is rendered into the drawable.

The per-frame work moves into RenderFrame, driven by a repeating NSTimer on
the main run loop (a tiny NSObject subclass whose onFrame: is an
UnmanagedCallersOnly callback, registered via the ObjC runtime — no binding
package). CADisplayLink is the natural choice and was tried first, but its
callback never fires in this process; proven in isolation against a bare
AppKit harness where a timer fires and composites and the display link does
not — the emulator runs as x86-64 under Rosetta and the display-server-backed
link is not serviced there. nextDrawable still blocks to the display, so the
timer only needs to keep up, not pace precisely.

Also fixes window sizing (the fixed 1280x720 window was being sized from the
guest 4K display mode, which macOS clamps while the layer keeps 4K geometry —
nothing visible), makes the metal layer the view's backing layer (wantsLayer
before setLayer) with an explicit frame, and stops both the AppKit loop and
the CFRunLoop on window close.

* [Gpu] Metal draws: normalize inverted viewports, resolve flips to drawn content

Two correctness fixes surfaced bringing a real title up. Guests program
Vulkan-style negative-height viewports for y-up rendering; Metal's NDC is
already y-up and rasterizes nothing for a negative height, so the viewport is
converted to the equivalent non-inverted rect (origin shifted, height
negated) with the same on-screen mapping.

Ordered flips now prefer produced content. A flip names the display buffer's
start address, but games render into the pixel surface past the buffer's
metadata block; the resolver takes the exact-address image when GPU work
wrote it, else the nearest same-extent GPU-written image within the buffer's
plausible metadata window, else the exact-address image even if only
seeded — so a flip presents the drawn frame rather than an empty seed. A
GpuWritten flag on guest images (set by draws, writes, blits, and dispatch
storage) distinguishes produced content from a speculative guest-memory
seed.

* [ShaderCompiler] Metal translator: per-stage uniforms slot, VCC/EXEC as data, exit branches

Three correctness fixes found by running a real game against the Vulkan
backend's behavior:

- Gen5MslShader carries UniformsBufferIndex: each stage emits its
  SharpEmuUniforms argument at globalBufferBase + totalGlobalBufferCount,
  and stages sharing a draw can disagree, so the presenter must bind the
  buffer per stage (Metal API validation: "missing Buffer binding at
  index 7 for sharpemu_uniforms").
- VCC (s106:s107) and EXEC (s126:s127) live in the scalar register file
  as raw 32-bit values with the per-lane bools as synced views. Programs
  legally park plain data in VCC (s_buffer_load into s[106] and then
  v_rcp_f32 of it); the bool-only model returned ballot masks instead.
- A branch to (or past) the program's end is an exit, matching the
  SPIR-V translator: sprite alpha-kill shaders use this to skip their
  tail and were rejected ("branch target outside program"), silently
  dropping every draw that used them.

Also: pixel-stage ballots use the per-lane form (this thread's own bit),
since simd_ballot is undefined inside the divergent dispatcher loop, and
v_readfirstlane returns the lane's own value under that model.

* [Gpu] Metal presenter: per-stage uniforms bind, keyboard input, perf overlay, title parity

- Bind SharpEmuUniforms at each stage's declared slot (see the paired
  translator change); one shared index left the vertex stage's slot
  unbound, zeroing its bounds-checked loads and killing interpolants.
- Vertex attribute byte offsets move onto the vertex descriptor (buffers
  bind at zero) and join the pipeline cache key, which they were silently
  missing from once baked into the pipeline.
- Unresolvable draw textures log a throttled warning instead of silently
  binding nothing.
- Keyboard input: an NSView subclass records keyDown/keyUp and feeds the
  POSIX host-input seam with a Windows-VK to macOS-keycode map covering
  the keys pad emulation polls; SHARPEMU_METAL_AUTOKEY scripts key
  presses for headless runs.
- Perf overlay (F1) drawn like the Vulkan presenter: CPU-rasterized
  panel uploaded to a small texture and composited with the present
  pipeline, with RecordPresent/RecordDraw feeding real numbers.
- Window title gains the selected GPU suffix and refreshes when the
  guest registers its application name; the layer is marked opaque so
  guest alpha never reaches the compositor.

* [Audio] Quiet sceAudioOutOutput on ports disposed by host shutdown

Closing the window disposes audio ports while guest audio threads are
still draining their last buffers; every remaining output then failed
the port lookup and logged a WARN per buffer (~190/s) until process
exit. Report success for missing ports once shutdown has begun; a bad
handle during normal operation still returns INVALID_ARGUMENT.

* [ShaderCompiler] Metal graphics stages model a single logical wave lane

The pixel stage used the real thread_index_in_simdgroup with all-ones
ballots while the vertex stage modeled lane 0 with 1-bit ballots, and
VReadlaneB32 still emitted a real simd_shuffle — reading another
fragment's register. Metal leaves simdgroup ops undefined inside the
divergent while(active){switch(pc)} dispatcher (empirically they
corrupted EXEC reconstruction), so graphics stages cannot use them.

Unify vertex and pixel on the SPIR-V translator's no-subgroup fallback:
one logical wave lane (lane 0), ballots degrade to bit 0, and the
shuffle-select family (readlane, readfirstlane, DPP16/DPP8 selects,
permlane16) resolves to the lane's own value. Writelane keeps the
lane-compare against the constant lane, matching the reference
fallback. Compute is untouched: its threads map one-to-one onto real
simdgroup lanes and still shuffle for real.

Entry parameter lists now always emit trailing commas and are closed by
one helper, so stage-specific trailing parameters no longer dictate
ordering.

* [ShaderCompiler] Metal compute mirrors the SPIR-V translator's wave semantics

Compute threads map one-to-one onto real simdgroup lanes, so restore
real simd_ballot for the compute prelude (the per-lane form was a
graphics fix that swept compute along) — masks parked in VCC/EXEC now
hold each lane's actual bit, and mbcnt/cndmask/saveexec read real
masks. VReadfirstlaneB32 broadcasts from the first guest-active lane
(ballot of EXEC, then ctz), matching the SPIR-V translator's explicit
first-active-lane broadcast rather than SPIR-V BroadcastFirst's
first-host-active semantics.

The wave64 gate moves into the translator and only rejects programs
that contain wave-sensitive operations (the SPIR-V translator's
subgroup-usage predicates: shuffle family, readfirstlane, wave control,
mbcnt, or VCC/EXEC operands). A wave64 kernel without them executes
identically per-thread on 32-wide Apple simdgroups, so it now
translates instead of being dropped.

* [Gpu] Metal draw textures resolve like the Vulkan presenter

Sampling a live guest target previously required the exact current
image at the descriptor's address; anything else silently bound
nothing. Mirror the Vulkan presenter's resolution chain:

- A descriptor naming a guest depth target's write or read address
  samples the depth image (identity channel select). Depth32Float
  cannot blit to a color format, so the ordered snapshot round-trips
  through a private staging buffer into an R32Float texture.
- Replacing a render target at the same guest address (new extent or
  format) retires the old image into a bounded variant cache instead of
  releasing it, and resolution scores the current image plus variants
  by descriptor match — exact extent over view format over
  initialization, active image breaking ties. Larger images qualify
  only for tiled descriptors, matching IsCompatibleGuestImageAlias.
- The throttled unresolved-texture warning remains the detector for
  anything the chain still cannot resolve.

* [ShaderCompiler] Document the Metal translator's wave-size model

Audit outcome for wave64 fidelity, no behavior change: every B64 mask
op, saveexec, and VCCZ/EXECZ test already reads and writes the full
register pair, lane indices never exceed 31 by construction, and the
GPU-executing runtime tests cover 64-bit exec save/restore. Record the
model in the class header.

* [Gpu] Plumb CB_BLEND constant color through both backends

The CONSTANT_COLOR / CONSTANT_ALPHA blend factors were mapped by both
backends but nothing ever supplied the constant, so any draw using them
blended against transparent black. Decode CB_BLEND_RED..ALPHA (the
constants existed unused) into GuestRenderState.BlendConstant, set it
dynamically per draw on both sides: Vulkan declares the blend-constants
dynamic state and calls CmdSetBlendConstants beside the viewport,
Metal calls setBlendColorRed:green:blue:alpha: in EncodeRenderState.

* [Gpu] Metal per-draw uploads bump-allocate from shared arena pages

Every draw created one MTLBuffer and one managed copy per binding
(padded guest globals, uniforms, vertex and index bytes), which
dominated allocation churn at hundreds of MB/s of garbage and held the
guest flip rate well under the display rate. Uploads now bump-allocate
256-aligned slices from 8 MiB shared-storage arena pages and bind by
offset; pages recycle once the last command buffer that referenced
them reports completion, polled at each drain so the ObjC interop
stays block-free.

Write-backs carry the slice's data pointer directly (the page outlives
the command buffer the caller waits on), the alignment-bias contract is
preserved by placing data at the bias inside its slice, and the padded
copies, per-draw buffer releases, and per-draw byte arrays are gone.

In-game on the test title: allocation rate ~795 to ~564 MB/s (the
remainder is the AGC-side per-draw guest snapshots), GC per stats
window ~30/30/17 to 16/16/15, CPU ~150 to ~131%. Metal validation
stays clean.

* [Gpu] Metal draw-texture cache: skip per-draw guest texel copies

Mirror the Vulkan presenter's identity-keyed texture cache: once the
render thread decodes a draw texture, the AGC submit thread skips the
guest-memory read/detile/copy for that identity entirely (the generic
IsTextureContentCached hook, which the Metal backend previously
hardcoded to false) and the render thread serves the cached MTLTexture
without re-uploading. GuestImageWriteTracker write-protects the source
pages; a guest CPU write evicts the entry at the next drain, and the
skip/eviction race self-heals by reading the texels directly.

Eviction differs from Vulkan in one deliberate way: dirty entries are
collected by address rather than identity, since ConsumeDirty clears
the flag on first read and several identities (same texels, different
samplers) can share one address.

Dreaming Sarah in-game on an M5 Max: guest flips 47 -> 60 (display
rate, matching Vulkan), ALLOC 564 -> 41 MB/s, gen0 GC 16 -> 5 per
second, CPU 131% -> 72%. Metal API validation clean; all 25 shader
compiler tests pass.

* [Gpu] Metal snapshot pool: recycle feedback-read textures and staging

Feedback reads created and destroyed an MTLTexture per draw (and for
depth sampling a private staging MTLBuffer too). Pool both with the
upload-arena lifecycle: acquisitions are tagged with the command buffer
that samples them at commit, and return to a bounded free list once it
reports completion. The command queue is serial, so the earlier
snapshot-blit command buffer is necessarily complete by then as well.

Dreaming Sarah renders correctly in-game; Metal API validation clean;
all 25 shader compiler tests pass. (The depth-sample path is exercised
only by inspection — no testable title samples depth yet.)

* [Gpu] Metal batched guest commands: one command buffer per drain

Draws and compute dispatches encode into a shared batch command buffer
committed once per drain instead of one commit per work item, mirroring
the Vulkan presenter's batched guest commands. Ordering inside the
batch is by encoder sequence: draw textures are now pre-resolved before
the consuming render or compute encoder opens, so feedback-read
snapshot blits encode into the batch (after the passes that rendered
the source) rather than committing ahead of them in separate command
buffers. Flips, image writes/blits, ordered actions, CPU-visible
write-backs, and every drain exit flush the batch first, preserving
the serial-queue ordering and WaitForGuestWork contracts.

Dreaming Sarah in-game on an M5 Max: CPU 72% -> 59% at a steady 60
guest flips; Metal API validation clean; all 25 shader compiler tests
pass.

* [Gpu] Metal vertex streams: share buffer slots, reject overflow gracefully

void Terrarium aborts with '-[MTLVertexAttributeDescriptorInternal
setBufferIndex:]: buffer index (31) must be < 31': every vertex
attribute got its own buffer slot from base 26, so six streams walk
past Metal's last vertex-stage buffer index (30) and the framework
assertion kills the process (reported by vladdenisov on PR #283).

Attributes of an interleaved vertex arrive from AGC as one stream
each, all reading the same guest buffer — assign slots by unique
(base address, stride, length) so those share one slot and one
upload. A draw whose unique streams still overflow the range is
skipped with a throttled warning instead of aborting. The assigned
slot keys the pipeline cache alongside the attribute offset, since
aliasing changes the baked vertex descriptor.

Dreaming Sarah renders correctly in-game at 60 flips with Metal API
validation clean; all 25 shader compiler tests pass. (void Terrarium
itself is not testable here — no decrypted copy.)

* [Gpu] Metal: drain guest work on enqueue, not only at render ticks

The Vulkan presenter's render loop is pulsed when guest work arrives
and waits at most a few milliseconds; the Metal render loop drained
guest work only inside its NSTimer tick, so every guest submit-then-
wait round-trip (release-mem labels, event writes, CPU-visible write-
backs) cost up to a full frame interval. Games that chain several such
waits per frame crawl: void Terrarium ran at 14 guest flips against
Vulkan's display rate, and input-to-effect latency suffered everywhere.

Enqueueing guest work now schedules a coalesced onGuestWork: message
onto the main run loop via performSelectorOnMainThread (block-free,
matching the NSTimer trampoline pattern), which drains the queue
immediately. A producer blocked on a full queue schedules the same
wake before waiting. void Terrarium's title menu: 14 -> 59 flips/s;
Dreaming Sarah unchanged at 60 with validation clean.

* [ShaderCompiler] Metal samplers: per-stage compact slots, not texture slots

Sampler argument indices copied the global texture slot (image binding
base + index), but Metal exposes only 16 sampler slots per stage
against 31 texture slots — a draw whose stages sample more than 16
images total emitted [[sampler(16+)]] and the MSL failed to compile
('sampler attribute parameter is out of bounds'), dropping the draw
(void Terrarium's in-game scenes).

Samplers now count sampled (non-storage) images from zero within each
stage, and Gen5MslShader carries the image-index -> sampler-slot map
plus the stage's image binding base so the presenter binds each
stage's samplers exactly where its shader declared them. A stage that
samples more than 16 images fails translation loudly. All 25 shader
compiler tests pass; goldens unchanged (single-texture fixtures keep
sampler 0).

* [Gpu] Metal draw textures: native guest formats, BC blocks, channel select

The draw-texture path assumed every texture was RGBA8: created
Rgba8Unorm, uploaded 4 bytes per pixel, and rejected anything whose
texel copy was smaller than W*H*4 as undersized. Games shipping
BC-compressed atlases (void Terrarium's entire in-game art) rendered
black, and because the rejected textures were never created they were
never content-cached — the AGC layer re-read and re-detiled megabytes
per draw (1.6 GB/s allocation, gen2 collections every second, 8 guest
flips).

Map guest texture formats to Metal case for case with the Vulkan
table (BC1-BC7 upload raw blocks — Mac-family GPUs sample them
natively — plus the 8/16/32-bit linear formats), size expectations
with the same block-aware byte math AGC uses, and honor the
descriptor's DST_SEL channel select through the texture swizzle,
mirroring Vulkan's component mapping. Unmapped codes keep the RGBA8
fallback.

void Terrarium now reaches gameplay past New Game: 49-54 guest
flips (from 8), no undersized-texture warnings, validation clean.
Dreaming Sarah unchanged at 60. All 25 shader compiler tests pass.

* [Gpu] Metal feedback reads: one snapshot per content version

Every draw sampling a live guest image blitted a fresh full-texture
snapshot, so compositing games that sample their render target on
most draws (void Terrarium: ~100 of ~105 draws per frame) pushed
gigabytes per second of blit traffic through the driver.

Guest images now carry a content version, bumped by every draw that
targets them, image write, blit destination, storage dispatch, and
guest-memory seed. The feedback-read path reuses one cached snapshot
until the version moves, so the blit happens per content change
instead of per draw. The image holds the snapshot's retain; consuming
command buffers keep replaced snapshots alive until they complete,
and retire/replace/write paths release the cache with the image.

void Terrarium in-game: 49 -> 58 guest flips at higher draw
throughput (Vulkan reference runs the same scene at 17-20 fps).
Dreaming Sarah unchanged at 60; validation clean; 25/25 tests pass.

* [Core] Pre-visit tracked texture pages before managed guest writes

A managed write into a page the guest-image write tracker has
protected dies with a fatal AccessViolation: the runtime surfaces
SIGSEGV in managed code as an exception before the resumable signal
bridge can restore access, unlike native guest stores which recover
through TryHandleWriteFault. Dead Cells crashed exactly there — an
AGC release-mem label write (CpuContext.TryWriteUInt64 on the render
thread) landing on a page the texture cache tracks.

TryWrite now calls GuestImageWriteTracker.NotifyManagedWrite up
front, unprotecting and dirtying any tracked pages in the span before
the copy — the hook existed for precisely this but had no callers.
Since this puts the tracker on every managed guest-write path, the
range snapshot now carries its overall bounds (one immutable object,
so the intersection test is always consistent with the array), letting
the common no-texture-pages case reject in a few instructions.

Dead Cells no longer crashes; Dreaming Sarah and void Terrarium
unaffected; all 25 shader compiler tests pass.

* [VideoOut] Name the active GPU backend in the macOS window title

macOS can run either backend — Vulkan through MoltenVK or native Metal
via SHARPEMU_GPU_BACKEND — so the window title now ends with the one in
use, e.g. "... · Apple M5 Max (Metal)" or "(Vulkan)". The suffix is
appended in SetSelectedGpuName (the single point both presenters call
to fold in the GPU name) and gated to macOS, so Windows and Linux
titles are unchanged. The name comes from a new BackendName on the
guest-GPU seam.

* [Gpu] Metal window: resizable, native full-screen, live drawable sizing

Add NSWindowStyleMaskResizable so the window can be dragged to any size
and set NSWindowCollectionBehaviorFullScreenPrimary so the green button
enters native full-screen instead of zooming. CAMetalLayer does not
track its drawable size to bounds on its own (even as a view's backing
layer), so the render loop matches drawableSize to the layer's current
bounds x contentsScale before each nextDrawable — a no-op on the common
unchanged tick. The present pass already aspect-fit letterboxes into the
drawable, so any window aspect ratio scales the frame without distortion.

Reading -bounds needs the x86-64 stret ABI for its 32-byte CGRect
return, added as SendStretRect. Verified live: drag-resize and
full-screen both scale correctly with Metal API validation clean.

* [ShaderCompiler] Metal wave64 compute: emulate cross-lane ops via scratch bridge

Replace the wave64 loud rejection with emulation, mirroring the SPIR-V
translator. A 64-lane guest wave is two 32-wide Apple simdgroups
co-resident in one threadgroup (Metal packs thread_index_in_threadgroup
0-31 into simdgroup 0, 32-63 into simdgroup 1), so sharpemu_lane becomes
thread_index_in_threadgroup & 63 and cross-lane ops that span the full
wave rendezvous the two halves through threadgroup scratch:

- ballot into EXEC/VCC/SGPR pairs: each half's simd_ballot is written to
  its scratch slot, a threadgroup_barrier syncs, and all lanes recombine
  the 64-bit mask into the low/high register pair (centralized in
  EmitBallotStore, which the wave32 path shares).
- read-first-lane: broadcasts the lowest active lane's value across both
  halves through a scratch slot (EmitWave64ReadFirstLane).
- mbcnt lo/hi: 64-lane thread-mask math (no cross-lane op, just correct
  per-lane masks; lanes >= 32 would overflow a 32-bit shift, so split).

The barriers are safe because the guest's scalar PC keeps all 64 lanes
lockstep through the dispatcher. Scope matches the SPIR-V reference: the
scratch is indexed by half, so correct for a one-wave (64-thread)
workgroup, and readlane across halves stays a 32-wide shuffle. Wave-
agnostic wave64 kernels still translate per-thread unchanged.

Verified on the real GPU (MetalRuntimeTests): the emitted wave64 MSL
compiles, and a 64-lane dispatch runs through the bridge barriers
without deadlocking, returning the broadcast value. All 27 tests pass;
Dreaming Sarah (60/60) and void Terrarium (in-game, 58 flips) show the
shared wave32 ballot path is unaffected.

* [Gpu] Metal samplers: bind through an argument buffer, lifting the 16-slot cap

Metal exposes only 16 direct [[sampler(N)]] slots per stage, but real
shaders sample more (void Terrarium's scene shader: 17 images) and were
dropped at translation. Route samplers through a per-stage argument
buffer instead: the MSL declares a Gen5Samplers struct (one sampler per
sampled image, [[id(N)]]) taken as constant& at a buffer slot past the
stage's globals/uniforms/scalar-state, and the runtime writes each
sampler's Tier 2 gpuResourceID into an arena slice bound there. Textures
stay on direct [[texture(N)]] slots (31 is enough). One sampler per
image keeps them distinct, matching the SPIR-V/Vulkan path — no dedup,
so no wrong-sampler artifacts.

Verified argument buffers lift the limit on Apple Silicon (20-sampler
pipeline probe). Dreaming Sarah renders correctly at 60/60 with Metal
API validation clean; the void Terrarium scene shader that exceeded the
limit now compiles and runs (draws 74 -> 102/frame); all 27 shader
compiler tests pass, goldens unchanged (fixtures sample nothing).

* [Gpu] Metal: Shared storage for CPU-populated, GPU-sampled textures

The MTLTextureDescriptor default is Managed, which on unified memory needs
an explicit host->device sync we never issue after replaceRegion, so the
GPU can sample stale texels. These textures are CPU-uploaded and GPU-read,
so Shared (coherent, no sync on Apple Silicon) is the correct mode.

* [HLE] Add missing AGC/AudioOut/Pad exports blocking Unity+FMOD titles

Four exports were unresolved and hard-stalled GPU/audio/input init in
Unity titles (Lunar Lander Beyond froze there before opening VideoOut):

- sceAgcDriverSetTFRing / sceAgcDriverSetHsOffchipParam: tessellation-ring
  and hull-shader off-chip config. We translate shaders directly, so these
  only need to report success for init to proceed.
- sceAudioOutGetPortState: report a connected primary output at full volume.
- scePadDeviceClassGetExtendedInformation: report a standard pad (no special
  peripheral) so device-class probes resolve.

Generic HLE, backend-agnostic (helps the Vulkan path equally).

* [VideoOut] RegisterBuffers2: mask the 32-bit category, accept COMPRESSED

sceVideoOutRegisterBuffers2's category is a 32-bit SceVideoOutBufferCategory
passed on the stack, but we read the full 64-bit slot — whose upper word
carries stale GNM magic (0xC0DEC0DE...) the caller never cleared. The old
check then rejected every call as INVALID_VALUE, so buffer registration
failed and no frame ever presented. Mask to 32 bits and accept both
UNCOMPRESSED (0) and COMPRESSED (1); we present either identically.

Fixes Lunar Lander Beyond reaching its window (now presents 3840x2160).

* [HLE] Stub sceAudioPropagation (3D-audio) so Astro Bot boots past its assert

Astro Bot hard-crashed right after the splash: it calls
sceAudioPropagationSystemQueryMemory during audio init, and because the
whole libSceAudioPropagation module was unimplemented the call failed, so
the game asserted (AudioPropagationContext.cpp:43) and executed int 0x41 to
abort — an unrecoverable trap that kills the process.

We don't model acoustic propagation (geometry-driven reverb/occlusion is a
quality feature, not a correctness gate). The API is placement-style, so
QueryMemory reports a buffer size and the rest succeed as no-ops: the system
lives in the caller's own buffer. All 39 entry points stubbed; the game now
boots past the assert to the presenter. Backend-agnostic HLE.

* [Kernel] pthread_cond_wait: don't spuriously EPERM an untracked mutex

pthread_cond_wait/timedwait required our host-side mutex tracking to show
the calling thread as the owner, else it returned EPERM. But libkernel's
uncontended mutex fast-path locks the mutex word in guest memory directly,
without an HLE call, so we often never observe the lock and see owner==0.

Real pthread_cond_wait requires the caller to hold the mutex but does not
verify it for normal mutexes, so EPERM here is doubly wrong: it spins the
guest (Hades hammered this millions of times/sec) and, worse, skips the
unlock — leaving the mutex held and wedging every thread that later blocks
on pthread_mutex_lock. When the mutex reads as untracked (owner==0), adopt
ownership so the unlock/wait/re-lock cycle is balanced and actually releases
it. Genuine ownership violations (owned by another thread) still error.

Eliminates the EPERM storm and converts the resulting livelock into correct
blocking; no effect on games that lock through the HLE (owner already set).

* [Core] SSE4a EXTRQ patch: read the xmm register from ModRM, not xmm2

The loader rewrites Sony's AMD-only SSE4a EXTRQ+blend idiom into SSE4.1 at
boot, because Rosetta 2 and Intel hosts raise #UD -> SIGILL on EXTRQ. The
matcher hard-coded the source register to xmm2 (ModRM 0xC2), but the compiler
allocates it freely: Dead Cells (PPSA15552) emits the identical idiom against
xmm1, so it slipped through unpatched and the game died with SIGILL right
after the first frame.

Read the register from the ModRM r/m field instead, covering xmm0-xmm7, and
require it to be consistent across the EXTRQ and the blend. The pure
match/encode logic is extracted into Sse4aExtrqBlendPatch, isolated from the
native page-patching, and unit-tested for every register plus the round trip
and rejection cases; DirectExecutionBackend just applies it.

Dead Cells now patches its xmm1 idioms and boots past the first frame.

* [Ngs2] Implement non-allocator sceNgs2SystemCreate / sceNgs2RackCreate

Dead Cells uses the non-allocator NGS2 create entry points, which were
unimplemented. sceNgs2SystemCreate came back as an unresolved import, so the
game got a garbage system handle; every downstream sceNgs2RackCreate /
sceNgs2RackGetVoiceHandle then failed, the voice handle stayed null, and once
gameplay started the audio path polled sceNgs2VoiceGetState/VoiceControl on
the null voice forever — freezing the game in-level at FLIP 0.

The non-allocator forms differ only in a caller buffer (rsi/rcx) vs an
allocator callback; the system/option and out-handle arguments sit at the
same positions, so they alias the existing WithAllocator implementations.
Resolves the NGS2 InvalidVoiceHandle storm (591+/run -> 0).

* [SaveData] Real save subsystem: ~/SharpEmu/Saves/<titleId>, events, full CRUD

Rework the SaveData HLE from a partial stub into a working subsystem:

- Storage moves to ~/SharpEmu/Saves/<titleId>/<dirName>/ (was next to the
  exe under user/savedata/<userId>/<titleId>), overridable via
  SHARPEMU_SAVEDATA_DIR. Metadata (title/subtitle/detail/userParam) and icon
  live in <slot>/sce_sys/. Pure path + param.json logic is isolated in a new
  SaveDataStorage type and unit-tested.
- Async event model: sceSaveDataGetEventResult now resolves (was an
  unresolved import a save worker polled forever), returning queued completion
  events or a clean 'no event' status; SyncSaveDataMemory posts a
  SAVE_DATA_MEMORY_SYNC_END event. Plus GetEventInfo/SetEventInfo/register
  callbacks.
- New exports: Mount/Mount2/Mount5/Umount, Delete/Delete5, GetParam/SetParam,
  SaveIcon/SaveIconByPath/LoadIcon, GetAllSize/GetProgress/GetMountInfo/
  IsMounted/GetSaveDataCount/GetMountedSaveDataCount/Abort, Initialize/
  Initialize2/Terminate, SaveDataMemory v1 aliases.
- Mounts are tracked so Umount2 really unregisters the /savedata0 mapping
  (new KernelMemoryCompatExports.UnregisterGuestPathMount) and params/icons
  resolve against the live mount; DirNameSearch surfaces param.json titles.

15 new unit tests (storage layout/sanitize/metadata + mount/event/param/delete
exports); full suite 277 passing.

* [Gpu] Metal: Cmd+F1 toggles Apple's Metal Performance HUD

Plain F1 keeps the built-in CPU-rasterized perf overlay; Cmd+F1 now toggles
the system Metal Performance HUD on the CAMetalLayer, Metal backend only.

Command-modified keys never reach keyDown: (AppKit routes them through the
key-equivalent chain), so the input view gains a performKeyEquivalent:
override that claims Cmd+F1 (also silencing the system beep) and leaves
everything else to the responder chain.

Configured per Apple's 'Customizing Metal Performance HUD':
developerHUDProperties with mode=default + logging=default, plus
MTL_HUD_LOG_SHADER_ENABLED=1 passed directly in the dictionary — HUD,
per-frame statistics logging, and shader-compile logging all enabled from
one property set; mode=disabled hides it again. Guarded by a
respondsToSelector: check for older macOS.

* [Gpu] Metal: also catch Cmd+F1 in keyDown: for the HUD toggle

Function keys reach keyDown: even with Command held (AppKit only reroutes
some chords through performKeyEquivalent:), so the HUD toggle was never
firing there. Handle Cmd+F1 in both the keyDown: and performKeyEquivalent:
paths, and keep it out of MetalHostInput so it can't also flip the plain-F1
perf overlay.

* [Audio] Diagnostics: NGS2 voice-param dump + AudioOut peak-amplitude trace

Two gated traces (idiomatic SHARPEMU_LOG_* style) that pinpoint where audio
dies for NGS2-based games:

- SHARPEMU_LOG_NGS2 now walks the sceNgs2VoiceControl param list and logs each
  {size,id} block header + payload bytes, confirming the real layout
  (header = u32 size, u32 id; waveform-block param id=0x10000001 carries the
  guest PCM pointer at +8; rate param id=0x10000005 carries the resample ratio).
- SHARPEMU_LOG_AUDIO_OUT logs sceAudioOutOutput call count and the peak
  amplitude of each submitted buffer.

Finding on void Terrarium: sceAudioOutOutput is called thousands of times on
both 8ch/float32 ports, but every buffer has peak=0.0 — the guest submits pure
silence. The host path (AudioOut -> PCM convert -> CoreAudio) is proven correct;
the silence originates in Ngs2SystemRender, which zeroes the output buffer
instead of mixing voices. Restoring audio for NGS2 games requires a real NGS2
software mixer (next).

* [Audio] NGS2 software mixer: decode + mix PS-ADPCM voices

NGS2-based games were silent because sceNgs2SystemRender only zeroed the
output buffer. This adds a real software mixer:

- Ngs2VagDecoder: clean-room PS-ADPCM ("VAGp") decoder producing mono PCM16
  with loop points resolved from the exact per-frame flag values (3=loop
  start, 6=loop end, 1/7=one-shot end).
- Voice control now parses the SceNgs2VoiceParamHead command list, decodes the
  waveform-blocks param's VAGp container once, and arms the voice.
- sceNgs2SystemRender mixes every armed voice belonging to the system into the
  leading grain of the render buffer as interleaved float32 (nearest-sample
  resample from the source rate to 48 kHz, additive into the front L/R pair),
  which is exactly what games copy to sceAudioOutOutput.

Verified on void Terrarium: previously peak=0.0 silence at AudioOut, now real
audible SFX/music. Voices are still armed on waveform assignment rather than an
explicit kick, so pooled/duplicate voices can overlap — trigger-state handling
is a follow-up.

* [Gpu] AGC: latch GPU-wait satisfaction to the produced value

Fixes a lost-wakeup race that stalled games at a black/splash screen. When a
RELEASE_MEM packet writes a completion label, the guest frequently resets that
label to 0 immediately to reuse it next frame. Our wake path
(GpuWaitRegistry.CollectSatisfied) re-reads *current* guest memory, so if the
reset lands before the wake pass runs, the transient satisfied window is missed
and the suspended DCB waits forever — even though the producing write executed
(traced as wrote=True) and its producer is marked completed.

RELEASE_MEM producers now call GpuWaitRegistry.LatchSatisfiedByValue with the
value they actually wrote, recording satisfaction at the moment of the write for
any waiter that value satisfies. CollectSatisfied honors the latch regardless of
the current (possibly-reset) memory value. This is fail-closed: a waiter only
latches when a real producer wrote a genuinely satisfying value.

Verified: Astro Bot's DEADBEEF sentinel wait (dcb.graphics waiting on a
release_mem label) that was permanently stuck is now resolved; void Terrarium is
unregressed (runs, audio intact, no producerless stalls). Astro still has
separate unresolved blockers (producer-behind-its-own-wait cascades and
producer=none-observed labels) tracked for follow-up; WRITE_DATA/DMA_DATA
producers could latch too but are left out until there is evidence they race.

* [Gpu] AGC: retry indirect dispatches whose GPU-computed dims aren't ready

GPU-driven games (Astro Bot) build their frame on the GPU: a compute dispatch
writes the thread-group dimensions for the next DISPATCH_INDIRECT into a guest
buffer. Our AGC parser reads those dimensions on the CPU at parse time, which
runs before the producing dispatch has executed on the render thread — so it
read 0/0/0 and dropped the work (agc.dispatch_reject zero-dimension), leaving
the scene unrendered (black) and cascading into stuck cross-queue fence waits.

Instead of dropping a zero-dimension INDIRECT dispatch, suspend the DCB on its
dimensions buffer (reusing the WAIT_REG_MEM suspend/resume + GpuWaitRegistry
machinery) until the producer writes non-zero dims, then re-parse and dispatch.
A bounded per-wait deadline (150 ms) resumes-and-drops a genuinely empty
indirect dispatch so it can never stall the queue, making the change
non-regressive: worst case matches the old drop behavior after a short wait.
Direct dispatches (dims inline) are unaffected.

Result: Astro Bot goes from a permanent black screen to actually rendering
(the presenter reports "Metal VideoOut presenting 3840x2160"). void Terrarium —
which issues no indirect dispatches — is unregressed (runs, audio intact, zero
rejects). Astro then hits a separate, newly-reached downstream crash (guest
TBB worker thread_set_state failure) tracked for follow-up.

* [ShaderCompiler] Metal: keep compute shaders within read_write and LDS limits

Two Metal limits made real Astro Bot compute shaders fail to compile/create,
which dropped their dispatches and cascaded into stuck GPU waits (splash hang):

- Textures with access::read_write are capped at 8 per function, but every
  storage image was declared read_write. Track each binding's actual access
  during body emission (ImageLoad->read, ImageStore->write, ImageAtomic and a
  load+store sharing one binding->read_write) and emit the minimal qualifier,
  so read-only/write-only storage images no longer count against the cap.

- Threadgroup memory is capped at 32 KB. A shader requesting the full 32 KB of
  LDS plus the separate 3-dword wave64 bridge overflowed by 12 bytes. Alias the
  bridge into the top of the LDS allocation when both are used, mirroring the
  SPIR-V translator's _waveScratchInLds path, keeping the total at 32 KB.

Verified on Astro Bot: "read_write access exceeds maximum (8)" and "Threadgroup
memory size (32780) exceeds maximum (32768)" are both gone; the 27 MSL golden
tests still pass (no golden used a storage image or LDS+wave64 shader).

* [Gpu] AGC: break cross-queue GPU wait deadlocks with a produced-value fallback

Real GPU-driven titles (Astro Bot) drive graphics and compute queues with
mutually dependent WAIT_REG_MEM fences: graphics waits on a compute EOP label,
compute waits on a graphics label. On hardware the two queues run concurrently
so the cycle resolves, but our submission parser is serial, so a label that gets
written -> reset for reuse -> re-waited across queues can wedge forever. The
latch fix helped the write-then-consume race but the cycle re-formed each frame
(graphics stuck at 3 flips, compute queues permanently suspended).

Producers now record the last value they wrote to each label
(GpuWaitRegistry.RecordProduced). A new deadlock breaker
(CollectDeadlockBroken, run from DrainResumableDcbs) releases any waiter stuck
past a 500 ms deadline whose condition is satisfied by that recorded value —
i.e. a real producer signalled the label at least once, guest memory has just
since been reset. It never fabricates a value, and the long deadline means
legitimate fences (which complete within a frame) never trip it.

Verified: Astro Bot goes from 3 flips (wedged on splash) to 25, loads its
splash level ("LevelDocument Loaded: ps_logo") and produces 2432x1368 frame
content. void Terrarium is untouched — 0 deadlock-break events, 1020 flips,
audio intact (its waits resolve far under the deadline). Tunable via
SHARPEMU_GPU_DEADLOCK_BREAK_MS.

* [Cpu] SSE4a EXTRQ patch: cover any blend destination register, not just xmm0

The EXTRQ+VPBLENDD idiom rewrite only matched when the blend destination was
xmm0 (VEX.vvvv byte 0x79). Sony's toolchain allocates that register freely: a
Dead Cells build emits `EXTRQ xmm4,0x28,0x00 ; VPBLENDD xmm3,xmm3,xmm4,2`
(VEX byte 0x61, dest xmm3). That instance stayed unpatched, so the AMD-only
EXTRQ reached Rosetta 2 and raised #UD -> SIGILL (0xC000001D) the moment the
game entered gameplay (loading level PrisonStart).

Read the destination register from VPBLENDD's VEX.vvvv / ModRM.reg as well as
the source from the ModRM r/m field, and emit PINSRD into that destination. Both
are still constrained to xmm0-xmm7 by the fixed VEX prefix. Match/encode stay in
the unit-tested helper.

Verified: Dead Cells now patches 14 EXTRQ blends (previously 0 on this build),
no SIGILL, and reaches PrisonStart. 15 patch unit tests pass, including the exact
xmm3/xmm4 bytes that faulted.

* [HLE] Implement Dead Cells' remaining unresolved imports

Three imports Dead Cells calls during boot/level-load were unresolved, so they
returned no defined value:
- scePadGetHandle (libScePad): returns the primary pad's handle (polled every
  frame for input); same validation as scePadOpen.
- sceNpEntitlementAccessGetAddcontEntitlementInfo (libSceNpEntitlementAccess):
  singular add-on-content lookup; we own no DLC, so zero the info out and return
  OK, matching the existing list variant.
- sceNpUniversalDataSystemEventPropertyArraySetString: telemetry setter, dropped.

Dead Cells now boots with zero unresolved imports. (It still stalls later at
PrisonStart level-load — a separate GPU/threading issue, not an import gap.)

* [Kernel] Fix pthread mutex deadlock: trylock semantics + stale-waiter clog

Hades hard-froze during boot on a "free but reserved" mutex: owner==0 yet
every acquisition failed forever. Two independent defects in the pthread
mutex compat layer combined to wedge it, both traced from real runs.

1. trylock incorrectly required an empty wait queue. POSIX
   pthread_mutex_trylock succeeds whenever the mutex is not currently held
   and owes no fairness to queued waiters; gating it on Waiters.Count==0 made
   a spin-on-trylock loop (which the game runs) spin forever against a single
   undrainable waiter even though owner==0. trylock now acquires on owner==0;
   the blocking lock still honours FIFO so genuine blocked waiters are not
   starved by a barging locker.

2. cond_timedwait timeouts leaked mutex re-acquire waiters. A cond wait's
   timeout enqueues a re-acquire waiter whose wake hand-off can be lost,
   orphaning it in the mutex queue. Multiple orphans from one thread piled at
   the FIFO head; the unlock hand-off then woke a dead wake-key and the mutex
   never drained. A thread can hold at most one pending acquisition on a
   mutex, so EnqueueMutexWaiterLocked now prunes any prior waiter for the same
   thread before enqueueing — collapsing the leaked pile.

Verified: Hades advances from a hard freeze at ~4.9M HLE calls (main and a
worker both blocked on the same free mutex) to 24.9M calls with no stall,
reaching the save-data/user-service boot stage. void tRrLM behaves
identically with and without the change (no regression); all 268 Libs tests
pass.
2026-07-18 20:32:00 +03:00
kostyaff 6dda6589d0 test: add Kernel/Loader unit tests (22 tests) (#373)
- SelfLoader: reject unknown magic, truncated headers; parse PS5 SELF embedded ELF
- KernelMemory: MapNamedFlexibleMemory/mprotect/munmap argument validation
- KernelEventQueue: create/delete/add/trigger/wait lifecycle

Co-authored-by: OMP <omp@local>
2026-07-18 20:19:55 +03:00
Aurélien Vivet 2ced3af114 AppContent: stub sceAppContentDownloadDataGetAvailableSpaceKb (#398)
Download data is not emulated as a real quota, so report a fixed 1 GiB
of free space and let titles skip the "storage full" path.
2026-07-18 18:44:30 +03:00
Berk 18708aa2d3 [GUI] Fixes and improvements for the GUI, including new image assets and updates to language files. (#400) 2026-07-18 17:44:04 +03:00
Berk a709ccca17 [shader_recompiler] Fix guest image byte count calculation for Vulkan video presenter (#395) 2026-07-18 16:09:26 +03:00
199 changed files with 30352 additions and 1951 deletions
+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/
+1 -1
View File
@@ -9,7 +9,7 @@ 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.4</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+11 -4
View File
@@ -108,10 +108,17 @@ release includes the MoltenVK Vulkan implementation.
## Build
1. Install the .NET SDK version specified in [`global.json`](./global.json).
2. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
4. Build the project: `dotnet build` or `dotnet publish`
5. Build artifacts will be located in the `artifacts` directory.
2. `dotnet publish` also builds the Bink 2 bridge
(`native/bink2-bridge/sharpemu_bink2_bridge.c`) from source, so also
install:
* **Windows:** [CMake](https://cmake.org/download/), [Ninja](https://github.com/ninja-build/ninja/releases), and [LLVM](https://github.com/llvm/llvm-project/releases) (for `clang-cl`)
* **Linux/macOS:** CMake and a C compiler toolchain (e.g. `build-essential` on Linux, Xcode Command Line Tools on macOS)
`dotnet build` alone doesn't need these; it skips the bridge.
3. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
4. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
5. Build the project: `dotnet build` or `dotnet publish`
6. Build artifacts will be located in the `artifacts` directory.
## Disclaimer
+2
View File
@@ -8,6 +8,8 @@ path = [
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
"_logs/**",
".github/images/**",
".github/pull_request_template.md",
+2
View File
@@ -14,11 +14,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder>
<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.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder>
</Solution>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

+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
```
+32 -14
View File
@@ -14,31 +14,49 @@ 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 movies are skipped by default: their open call returns
not-found so games that mark cinematics as optional progress to their next
state instead of waiting on an empty Bink GPU texture.
The default path decodes through the bundled FFmpeg-backed native bridge
(`native/bink2-bridge/sharpemu_bink2_bridge.c`); see "Supplying the adapter"
below for where that binary comes from. 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.
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override forces a host FFmpeg
source. SharpEmu searches
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
and then `PATH`. The FFmpeg build must contain the Bink 2 decoder; stock FFmpeg
builds that only recognize the Bink container are not sufficient.
## Supplying the adapter
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.
The adapter (`native/bink2-bridge/sharpemu_bink2_bridge.c`) links against a
custom FFmpeg build (`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a
Bink 2 decoder to FFmpeg 7.1.2; no proprietary RAD SDK is needed to build or
run SharpEmu.
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
executable, or point to it explicitly:
`dotnet publish` builds it from source with CMake + Ninja + clang-cl (Windows)
or the platform's default C compiler (Linux/macOS), targeting win-x64,
linux-x64, osx-x64, or osx-arm64, then embeds the result in the published
single-file executable. Publishing SharpEmu therefore requires that
toolchain locally (the same one the CI runners already ship with); there is
no prebuilt/download fallback. A downloaded release needs no such setup: the
compiled adapter is already inside `SharpEmu.exe`.
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
To use a different build of the adapter, point to it explicitly:
SHARPEMU_BINK2_BRIDGE=/absolute/path/sharpemu_bink2_bridge.dll \
./SharpEmu /path/to/eboot.bin
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
sharpemu_bink2_open_scaled_utf8, sharpemu_bink2_decode_next_bgra, and
sharpemu_bink2_close. The adapter opens one movie, optionally scaling it down
to a maximum size, exposes BGRA pixels, and advances after each decoded
frame. The managed side validates dimensions and retains ownership of the
destination buffer.
+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 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "disable"
"rollForward": "latestFeature"
}
}
+128
View File
@@ -0,0 +1,128 @@
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
cmake_minimum_required(VERSION 3.21)
if(NOT DEFINED SHARPEMU_TARGET_RID)
message(FATAL_ERROR "SHARPEMU_TARGET_RID is required")
endif()
if(SHARPEMU_TARGET_RID STREQUAL "osx-x64")
set(CMAKE_OSX_ARCHITECTURES x86_64 CACHE STRING "" FORCE)
elseif(SHARPEMU_TARGET_RID STREQUAL "osx-arm64")
set(CMAKE_OSX_ARCHITECTURES arm64 CACHE STRING "" FORCE)
endif()
project(sharpemu_bink2_bridge LANGUAGES C)
set(SHARPEMU_FFMPEG_TAG "6a6861e")
set(SHARPEMU_FFMPEG_COMMIT "6a6861e357a263edee51d2f4894941f50aed59f5")
# Keyed by tag so bumping SHARPEMU_FFMPEG_TAG always fetches fresh instead of
# silently reusing a previous tag's cached download.
set(SHARPEMU_FFMPEG_ROOT "${CMAKE_BINARY_DIR}/ffmpeg-core-${SHARPEMU_FFMPEG_TAG}")
if(SHARPEMU_TARGET_RID STREQUAL "win-x64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-windows-x64.zip")
elseif(SHARPEMU_TARGET_RID STREQUAL "linux-x64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-linux-x64.zip")
elseif(SHARPEMU_TARGET_RID STREQUAL "osx-x64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-macos-x64.zip")
elseif(SHARPEMU_TARGET_RID STREQUAL "osx-arm64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-macos-arm64.zip")
else()
message(FATAL_ERROR "Unsupported Bink2 bridge RID: ${SHARPEMU_TARGET_RID}")
endif()
set(SHARPEMU_FFMPEG_ARCHIVE "${SHARPEMU_FFMPEG_ROOT}/${SHARPEMU_FFMPEG_PACKAGE}")
set(SHARPEMU_FFMPEG_LIB_DIR "${SHARPEMU_FFMPEG_ROOT}/lib")
set(SHARPEMU_FFMPEG_SOURCE_ARCHIVE "${SHARPEMU_FFMPEG_ROOT}/source.tar.gz")
set(SHARPEMU_FFMPEG_SOURCE_DIR
"${SHARPEMU_FFMPEG_ROOT}/source/ffmpeg-core-${SHARPEMU_FFMPEG_COMMIT}")
if(NOT EXISTS "${SHARPEMU_FFMPEG_ARCHIVE}")
file(MAKE_DIRECTORY "${SHARPEMU_FFMPEG_ROOT}")
file(DOWNLOAD
"https://github.com/sharpemu/ffmpeg-core/releases/download/${SHARPEMU_FFMPEG_TAG}/${SHARPEMU_FFMPEG_PACKAGE}"
"${SHARPEMU_FFMPEG_ARCHIVE}"
SHOW_PROGRESS
STATUS SHARPEMU_DOWNLOAD_STATUS)
list(GET SHARPEMU_DOWNLOAD_STATUS 0 SHARPEMU_DOWNLOAD_CODE)
if(NOT SHARPEMU_DOWNLOAD_CODE EQUAL 0)
message(FATAL_ERROR "Failed to download ${SHARPEMU_FFMPEG_PACKAGE}: ${SHARPEMU_DOWNLOAD_STATUS}")
endif()
endif()
if(NOT EXISTS "${SHARPEMU_FFMPEG_LIB_DIR}")
file(MAKE_DIRECTORY "${SHARPEMU_FFMPEG_LIB_DIR}")
file(ARCHIVE_EXTRACT
INPUT "${SHARPEMU_FFMPEG_ARCHIVE}"
DESTINATION "${SHARPEMU_FFMPEG_LIB_DIR}")
endif()
if(NOT EXISTS "${SHARPEMU_FFMPEG_SOURCE_DIR}/include/libavcodec/avcodec.h")
file(MAKE_DIRECTORY "${SHARPEMU_FFMPEG_ROOT}/source")
file(DOWNLOAD
"https://github.com/sharpemu/ffmpeg-core/archive/${SHARPEMU_FFMPEG_COMMIT}.tar.gz"
"${SHARPEMU_FFMPEG_SOURCE_ARCHIVE}"
SHOW_PROGRESS
STATUS SHARPEMU_SOURCE_STATUS)
list(GET SHARPEMU_SOURCE_STATUS 0 SHARPEMU_SOURCE_CODE)
if(NOT SHARPEMU_SOURCE_CODE EQUAL 0)
message(FATAL_ERROR "Failed to download FFmpeg headers: ${SHARPEMU_SOURCE_STATUS}")
endif()
file(ARCHIVE_EXTRACT
INPUT "${SHARPEMU_FFMPEG_SOURCE_ARCHIVE}"
DESTINATION "${SHARPEMU_FFMPEG_ROOT}/source")
endif()
add_library(sharpemu_bink2_bridge SHARED sharpemu_bink2_bridge.c)
target_compile_features(sharpemu_bink2_bridge PRIVATE c_std_11)
target_include_directories(sharpemu_bink2_bridge PRIVATE
"${SHARPEMU_FFMPEG_SOURCE_DIR}/include")
function(sharpemu_link_ffmpeg_library target library_name)
find_library(SHARPEMU_${library_name}_LIBRARY
NAMES "${library_name}" "lib${library_name}"
PATHS "${SHARPEMU_FFMPEG_LIB_DIR}"
NO_DEFAULT_PATH
REQUIRED)
target_link_libraries(${target} PRIVATE "${SHARPEMU_${library_name}_LIBRARY}")
endfunction()
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge avformat)
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge avcodec)
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge swscale)
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge avutil)
if(WIN32)
set_property(TARGET sharpemu_bink2_bridge PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_link_libraries(sharpemu_bink2_bridge PRIVATE
bcrypt ole32 oleaut32 psapi shlwapi strmiids user32 uuid ws2_32)
elseif(APPLE)
target_link_libraries(sharpemu_bink2_bridge PRIVATE
"-framework AppKit"
"-framework AudioToolbox"
"-framework CoreAudio"
"-framework CoreFoundation"
"-framework CoreMedia"
"-framework CoreServices"
"-framework CoreVideo"
"-framework Security"
"-framework VideoToolbox")
else()
target_link_libraries(sharpemu_bink2_bridge PRIVATE dl m pthread)
endif()
set_target_properties(sharpemu_bink2_bridge PROPERTIES
C_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/out"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/out")
foreach(configuration Debug Release RelWithDebInfo MinSizeRel)
string(TOUPPER "${configuration}" configuration_upper)
set_target_properties(sharpemu_bink2_bridge PROPERTIES
LIBRARY_OUTPUT_DIRECTORY_${configuration_upper} "${CMAKE_BINARY_DIR}/out"
RUNTIME_OUTPUT_DIRECTORY_${configuration_upper} "${CMAKE_BINARY_DIR}/out")
endforeach()
+279 -45
View File
@@ -1,66 +1,300 @@
/*
* 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 <errno.h>
#include <stdint.h>
#include "bink.h"
#include <stdio.h>
#include <stdlib.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
#include <libswscale/swscale.h>
#if defined(_WIN32)
#define SHARPEMU_EXPORT __declspec(dllexport)
#else
#define SHARPEMU_EXPORT __attribute__((visibility("default")))
#endif
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
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;
typedef struct sharpemu_bink2_movie {
AVFormatContext *format;
AVCodecContext *codec;
struct SwsContext *converter;
AVFrame *frame;
AVPacket *packet;
int video_stream;
uint32_t output_width;
uint32_t output_height;
int draining;
} sharpemu_bink2_movie;
*movie = NULL;
static void sharpemu_bink2_log_error(const char *operation, int error) {
char message[AV_ERROR_MAX_STRING_SIZE];
if (av_strerror(error, message, sizeof(message)) < 0) {
snprintf(message, sizeof(message), "FFmpeg error %d", error);
}
fprintf(stderr, "[BINK2][ERROR] %s: %s\n", operation, message);
}
bink = BinkOpen(path, 0);
if (!bink) return 0;
static void sharpemu_bink2_destroy(sharpemu_bink2_movie *movie) {
if (!movie) {
return;
}
if (bink->Width == 0 || bink->Height == 0) {
BinkClose(bink);
sws_freeContext(movie->converter);
av_packet_free(&movie->packet);
av_frame_free(&movie->frame);
avcodec_free_context(&movie->codec);
avformat_close_input(&movie->format);
free(movie);
}
static AVRational sharpemu_bink2_frame_rate(AVFormatContext *format, AVStream *stream) {
AVRational rate = av_guess_frame_rate(format, stream, NULL);
if (rate.num <= 0 || rate.den <= 0) {
rate = stream->avg_frame_rate;
}
if (rate.num <= 0 || rate.den <= 0) {
rate = stream->r_frame_rate;
}
if (rate.num <= 0 || rate.den <= 0) {
rate = (AVRational){30, 1};
}
return rate;
}
static int sharpemu_bink2_open_internal(
const char *path,
uint32_t maximum_width,
uint32_t maximum_height,
void **movie_out,
sharpemu_bink2_info *info) {
sharpemu_bink2_movie *movie;
const AVCodec *decoder = NULL;
AVStream *stream;
AVRational frame_rate;
int result;
if (!path || !movie_out || !info) {
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;
*movie_out = NULL;
movie = (sharpemu_bink2_movie *)calloc(1, sizeof(*movie));
if (!movie) {
return 0;
}
result = avformat_open_input(&movie->format, path, NULL, NULL);
if (result < 0) {
sharpemu_bink2_log_error("open", result);
sharpemu_bink2_destroy(movie);
return 0;
}
result = avformat_find_stream_info(movie->format, NULL);
if (result < 0) {
sharpemu_bink2_log_error("stream info", result);
sharpemu_bink2_destroy(movie);
return 0;
}
result = av_find_best_stream(
movie->format, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
if (result < 0 || !decoder) {
sharpemu_bink2_log_error("video stream", result);
sharpemu_bink2_destroy(movie);
return 0;
}
movie->video_stream = result;
stream = movie->format->streams[movie->video_stream];
movie->codec = avcodec_alloc_context3(decoder);
if (!movie->codec) {
sharpemu_bink2_destroy(movie);
return 0;
}
result = avcodec_parameters_to_context(movie->codec, stream->codecpar);
if (result < 0) {
sharpemu_bink2_log_error("codec parameters", result);
sharpemu_bink2_destroy(movie);
return 0;
}
movie->codec->thread_count = 0;
movie->codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
result = avcodec_open2(movie->codec, decoder, NULL);
if (result < 0) {
sharpemu_bink2_log_error("codec open", result);
sharpemu_bink2_destroy(movie);
return 0;
}
movie->frame = av_frame_alloc();
movie->packet = av_packet_alloc();
if (!movie->frame || !movie->packet ||
movie->codec->width <= 0 || movie->codec->height <= 0) {
sharpemu_bink2_destroy(movie);
return 0;
}
frame_rate = sharpemu_bink2_frame_rate(movie->format, stream);
movie->output_width = (uint32_t)movie->codec->width;
movie->output_height = (uint32_t)movie->codec->height;
if (maximum_width > 0 && maximum_height > 0 &&
(movie->output_width > maximum_width ||
movie->output_height > maximum_height)) {
if ((uint64_t)movie->output_width * maximum_height >
(uint64_t)movie->output_height * maximum_width) {
movie->output_height = (uint32_t)((uint64_t)movie->output_height *
maximum_width /
movie->output_width);
movie->output_width = maximum_width;
} else {
movie->output_width = (uint32_t)((uint64_t)movie->output_width *
maximum_height /
movie->output_height);
movie->output_height = maximum_height;
}
if (movie->output_width == 0) {
movie->output_width = 1;
}
if (movie->output_height == 0) {
movie->output_height = 1;
}
}
info->width = movie->output_width;
info->height = movie->output_height;
info->frames_per_second_numerator = (uint32_t)frame_rate.num;
info->frames_per_second_denominator = (uint32_t)frame_rate.den;
*movie_out = movie;
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;
SHARPEMU_EXPORT int sharpemu_bink2_open_utf8(
const char *path,
void **movie_out,
sharpemu_bink2_info *info) {
return sharpemu_bink2_open_internal(path, 0, 0, movie_out, info);
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
SHARPEMU_EXPORT int sharpemu_bink2_open_scaled_utf8(
const char *path,
uint32_t maximum_width,
uint32_t maximum_height,
void **movie_out,
sharpemu_bink2_info *info) {
return sharpemu_bink2_open_internal(
path, maximum_width, maximum_height, movie_out, info);
}
static int sharpemu_bink2_receive_frame(sharpemu_bink2_movie *movie) {
int result;
for (;;) {
result = avcodec_receive_frame(movie->codec, movie->frame);
if (result >= 0) {
return 1;
}
if (result == AVERROR_EOF) {
return 0;
}
if (result != AVERROR(EAGAIN)) {
sharpemu_bink2_log_error("decode", result);
return 0;
}
if (movie->draining) {
return 0;
}
for (;;) {
result = av_read_frame(movie->format, movie->packet);
if (result < 0) {
movie->draining = 1;
result = avcodec_send_packet(movie->codec, NULL);
if (result < 0 && result != AVERROR_EOF) {
sharpemu_bink2_log_error("decoder drain", result);
return 0;
}
break;
}
if (movie->packet->stream_index != movie->video_stream) {
av_packet_unref(movie->packet);
continue;
}
result = avcodec_send_packet(movie->codec, movie->packet);
av_packet_unref(movie->packet);
if (result < 0 && result != AVERROR(EAGAIN)) {
sharpemu_bink2_log_error("packet submit", result);
return 0;
}
break;
}
}
}
SHARPEMU_EXPORT int sharpemu_bink2_decode_next_bgra(
void *handle,
uint8_t *destination,
uint32_t stride,
uint32_t destination_bytes) {
sharpemu_bink2_movie *movie = (sharpemu_bink2_movie *)handle;
uint8_t *destination_planes[4] = {destination, NULL, NULL, NULL};
int destination_strides[4] = {(int)stride, 0, 0, 0};
uint64_t required_bytes;
int converted_rows;
if (!movie || !destination || stride < movie->output_width * 4) {
return 0;
}
required_bytes = (uint64_t)stride * movie->output_height;
if (required_bytes > destination_bytes || !sharpemu_bink2_receive_frame(movie)) {
return 0;
}
movie->converter = sws_getCachedContext(
movie->converter,
movie->frame->width,
movie->frame->height,
(enum AVPixelFormat)movie->frame->format,
(int)movie->output_width,
(int)movie->output_height,
AV_PIX_FMT_BGRA,
SWS_FAST_BILINEAR,
NULL,
NULL,
NULL);
if (!movie->converter) {
av_frame_unref(movie->frame);
return 0;
}
converted_rows = sws_scale(
movie->converter,
(const uint8_t *const *)movie->frame->data,
movie->frame->linesize,
0,
movie->frame->height,
destination_planes,
destination_strides);
av_frame_unref(movie->frame);
return converted_rows == (int)movie->output_height;
}
SHARPEMU_EXPORT void sharpemu_bink2_close(void *movie) {
sharpemu_bink2_destroy((sharpemu_bink2_movie *)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())
+2 -7
View File
@@ -45,11 +45,6 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
// Avoid blocking full collections while guest and render threads are
// running, and establish the GC mode before the runtime reserves the
// fixed guest address-space window.
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
try
{
return Run(args);
@@ -612,7 +607,7 @@ internal static partial class Program
nint jobHandle = 0;
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
var created = CreateProcessW(
processPath,
null,
cmdLineBuilder,
0,
0,
@@ -1438,7 +1433,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,
+39 -1
View File
@@ -49,7 +49,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
@@ -88,4 +87,43 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
</Target>
<!-- Building the Bink2 bridge (native/bink2-bridge/sharpemu_bink2_bridge.c)
requires cmake, ninja, and clang-cl (Windows) or a C compiler
(Linux/macOS); the same tools the CI runners already ship with. No
prebuilt/download fallback: anyone publishing SharpEmu is expected to
have the same toolchain, matching every other native dependency in
this repo. -->
<PropertyGroup>
<Bink2BridgeBuildDir>$(BaseIntermediateOutputPath)bink2-bridge/$(RuntimeIdentifier)</Bink2BridgeBuildDir>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Windows'))">sharpemu_bink2_bridge.dll</Bink2BridgeFileName>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Linux'))">libsharpemu_bink2_bridge.so</Bink2BridgeFileName>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('OSX'))">libsharpemu_bink2_bridge.dylib</Bink2BridgeFileName>
<Bink2BridgeOutput>$(Bink2BridgeBuildDir)/out/$(Bink2BridgeFileName)</Bink2BridgeOutput>
</PropertyGroup>
<Target Name="BuildBink2Bridge"
BeforeTargets="ComputeResolvedFilesToPublishList"
Condition="'$(RuntimeIdentifier)' != '' And '$(Bink2BridgeFileName)' != ''">
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))"
Command="cmake -S &quot;$(RepoRoot)native/bink2-bridge&quot; -B &quot;$(Bink2BridgeBuildDir)&quot; -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-cl -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))"
Command="cmake -S &quot;$(RepoRoot)native/bink2-bridge&quot; -B &quot;$(Bink2BridgeBuildDir)&quot; -DCMAKE_BUILD_TYPE=Release -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
<Exec Command="cmake --build &quot;$(Bink2BridgeBuildDir)&quot; --config Release" />
</Target>
<!-- Embeds the bridge into the single-file bundle (self-extracted at first
load, see Bink2MovieBridge.NativeAdapter) instead of leaving it as a
loose file next to the executable, so a downloaded release needs no
setup: the DLL is already inside SharpEmu.exe. -->
<Target Name="EmbedBink2Bridge"
AfterTargets="ComputeResolvedFilesToPublishList"
DependsOnTargets="BuildBink2Bridge"
Condition="'$(RuntimeIdentifier)' != '' And Exists('$(Bink2BridgeOutput)')">
<ItemGroup>
<ResolvedFileToPublish Include="$(Bink2BridgeOutput)">
<RelativePath>$(Bink2BridgeFileName)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</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>
-596
View File
@@ -1,596 +0,0 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.FreeDesktop": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
"dependencies": {
"Avalonia": "11.3.18",
"Tmds.DBus.Protocol": "0.21.3"
}
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
},
"Avalonia.Skia": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
"dependencies": {
"Avalonia": "11.3.18",
"HarfBuzzSharp": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
"SkiaSharp": "2.88.9",
"SkiaSharp.NativeAssets.Linux": "2.88.9",
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
}
},
"Avalonia.Win32": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
}
},
"Avalonia.X11": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.FreeDesktop": "11.3.18",
"Avalonia.Skia": "11.3.18"
}
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
"dependencies": {
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.0",
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
},
"Microsoft.DotNet.PlatformAbstractions": {
"type": "Transitive",
"resolved": "3.1.6",
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "9.0.9",
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
},
"Silk.NET.Core": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
"dependencies": {
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
"Microsoft.Extensions.DependencyModel": "9.0.9"
}
},
"Silk.NET.GLFW": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Ultz.Native.GLFW": "3.4.0"
}
},
"Silk.NET.Input.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"Silk.NET.Input.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
},
"Silk.NET.Windowing.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Maths": "2.23.0"
}
},
"Silk.NET.Windowing.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
"dependencies": {
"Silk.NET.GLFW": "2.23.0",
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "2.88.9",
"SkiaSharp.NativeAssets.macOS": "2.88.9"
}
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
},
"sharpemu.core": {
"type": "Project",
"dependencies": {
"Iced": "[1.21.0, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.debugger": {
"type": "Project",
"dependencies": {
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.gui": {
"type": "Project",
"dependencies": {
"Avalonia": "[11.3.18, )",
"Avalonia.Desktop": "[11.3.18, )",
"Avalonia.Fonts.Inter": "[11.3.18, )",
"Avalonia.Themes.Fluent": "[11.3.18, )",
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )",
"Tmds.DBus.Protocol": "[0.21.3, )"
}
},
"sharpemu.hle": {
"type": "Project",
"dependencies": {
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.libs": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )",
"Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
"Silk.NET.Windowing": "[2.23.0, )"
}
},
"sharpemu.logging": {
"type": "Project"
},
"sharpemu.shadercompiler": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.vulkan": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
}
},
"Avalonia": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "11.3.18",
"MicroCom.Runtime": "0.11.0"
}
},
"Avalonia.Desktop": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Native": "11.3.18",
"Avalonia.Skia": "11.3.18",
"Avalonia.Win32": "11.3.18",
"Avalonia.X11": "11.3.18"
}
},
"Avalonia.Fonts.Inter": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Themes.Fluent": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Iced": {
"type": "CentralTransitive",
"requested": "[1.21.0, )",
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
"Silk.NET.Input": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Input.Glfw": "2.23.0"
}
},
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
"dependencies": {
"Silk.NET.Core": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.EXT": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.KHR": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Windowing": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Tmds.DBus.Protocol": {
"type": "CentralTransitive",
"requested": "[0.21.3, )",
"resolved": "0.21.3",
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
}
},
"net10.0/linux-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-arm64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/win-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
}
}
}
@@ -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;
}
}
}
@@ -133,6 +133,11 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverAmdCompatInstruction(contextRecord, rip))
{
return -1;
}
if (IsBenignHostDebugException(exceptionCode))
{
return -1;
@@ -478,7 +483,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
@@ -1410,6 +1418,8 @@ public sealed partial class DirectExecutionBackend
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
"Q2V+iqvjgC0" or // vsnprintf
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"q1cHNfGycLI" or // scePadRead
"xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent
@@ -1436,6 +1446,9 @@ 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 expectedNetAcceptWouldBlock =
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80410123);
@@ -1449,6 +1462,7 @@ public sealed partial class DirectExecutionBackend
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
!expectedMutexTrylockBusy &&
!expectedSemaphoreTrywaitAgain &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
@@ -1542,11 +1556,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
@@ -1571,6 +1587,8 @@ public sealed partial class DirectExecutionBackend
"WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"pNtJdE3x49E" or // wcscmp
"fV2xHER+bKE" or // wcscoll
"E8wCoUEbfzk" or // wcsncmp
@@ -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;
}
@@ -712,6 +712,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;
@@ -1118,7 +1123,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);
@@ -1291,6 +1298,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
{
if (IsHlePreferredNid(nid))
{
address = 0;
return false;
}
if (nid == "1jfXLRVzisc" &&
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
{
@@ -1402,6 +1415,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
0x75, 0xE7,
0xC3,
],
"AV6ipCNa4Rw" =>
[
0x0F, 0xB6, 0x07,
0x0F, 0xB6, 0x16,
0x8D, 0x48, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC0, 0x20,
0x8D, 0x4A, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC2, 0x20,
0x29, 0xD0,
0x75, 0x0C,
0x85, 0xD2,
0x74, 0x08,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xC6,
0xEB, 0xD4,
0xC3,
],
"viiwFMaNamA" =>
[
0x0F, 0xB6, 0x16,
0x84, 0xD2,
0x74, 0x2D,
0x0F, 0xB6, 0x07,
0x84, 0xC0,
0x74, 0x2A,
0x38, 0xD0,
0x75, 0x1D,
0x4C, 0x8D, 0x47, 0x01,
0x4C, 0x8D, 0x4E, 0x01,
0x41, 0x0F, 0xB6, 0x09,
0x84, 0xC9,
0x74, 0x12,
0x41, 0x38, 0x08,
0x75, 0x08,
0x49, 0xFF, 0xC0,
0x49, 0xFF, 0xC1,
0xEB, 0xEB,
0x48, 0xFF, 0xC7,
0xEB, 0xD3,
0x48, 0x89, 0xF8,
0xC3,
0x31, 0xC0,
0xC3,
],
"pNtJdE3x49E" or "fV2xHER+bKE" =>
[
0x0F, 0xB7, 0x07,
@@ -1466,8 +1527,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
"Q3VBxCXhUHs" =>
[
0x48, 0x89, 0xF8,
0x48, 0x89, 0xD1,
0xF3, 0xA4,
0x48, 0x85, 0xD2,
0x74, 0x11,
0x44, 0x8A, 0x06,
0x44, 0x88, 0x07,
0x48, 0xFF, 0xC6,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xCA,
0x75, 0xEF,
0xC3,
],
"8zTFvBIAIN8" =>
@@ -1601,7 +1668,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static bool IsHlePreferredNid(string nid)
{
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal);
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) ||
string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal);
}
private static bool IsLibcLibrary(string libraryName)
@@ -2541,28 +2609,22 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
{
// Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
// xmm2 to its low 40 bits, then copies the resulting second dword into
// xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
// extract source byte 4 and insert the zero-extended value into lane 1.
ReadOnlySpan<byte> pattern =
[
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
];
for (var i = 0; i < pattern.Length; i++)
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes.
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength);
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister))
{
if (source[i] != pattern[i])
{
return false;
}
return false;
}
Span<byte> replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength];
if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement))
{
return false;
}
ReadOnlySpan<byte> replacement =
[
0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04,
0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01,
];
uint oldProtect = 0;
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
{
@@ -3893,10 +3955,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;
}
@@ -3905,10 +3967,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(
@@ -3953,17 +4015,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(
@@ -4124,7 +4186,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;
}
@@ -4210,6 +4272,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)
{
@@ -4223,7 +4290,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
{
return;
}
@@ -4285,6 +4352,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,
@@ -4379,6 +4467,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_guestThreads.Clear();
_externalGuestThreads.Clear();
_pendingGuestExceptions.Clear();
Volatile.Write(ref _pendingGuestExceptionCount, 0);
_activeGuestExceptionDeliveries.Clear();
}
+1 -1
View File
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
var pattern = TlsAccessPattern;
var end = start + length - pattern.Length;
for (var ptr = start; ptr < end; ptr++)
for (var ptr = start; ptr <= end; ptr++)
{
if (MatchesPattern(ptr, pattern))
{
@@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.Core.Cpu.Native;
/// <summary>
/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an
/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but
/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode
/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM
/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the
/// source register is read from the ModRM r/m field rather than hard-coded.
///
/// The match/encode logic is deliberately free of native page-patching so it
/// can be unit-tested against handcrafted byte sequences.
/// </summary>
public static class Sse4aExtrqBlendPatch
{
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
public const int SequenceLength = 12;
/// <summary>
/// Matches the 12-byte idiom, extracting the destination register D and the
/// source (scratch) register N:
/// <code>
/// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits
/// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD
/// </code>
/// N lives in the ModRM r/m field of both instructions; D (the blend
/// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv.
/// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension).
/// The compiler allocates whichever registers it likes — Dead Cells builds use
/// D=xmm0 and D=xmm3, others differ — so both are read from the encoding.
/// </summary>
public static bool TryMatch(ReadOnlySpan<byte> source, out int destRegister, out int srcRegister)
{
destRegister = -1;
srcRegister = -1;
if (source.Length < SequenceLength)
{
return false;
}
// EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00.
if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 ||
(source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00)
{
return false;
}
var n = source[3] & 0x07;
// VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 <W=0 vvvv=~D L=0 pp=01> 02 ModRM 02.
// VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D.
if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 ||
source[9] != 0x02 || source[11] != 0x02)
{
return false;
}
var d = (~(source[8] >> 3)) & 0x0F;
if (d > 7)
{
return false;
}
// ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register).
if (source[10] != (0xC0 | (d << 3) | n))
{
return false;
}
destRegister = d;
srcRegister = n;
return true;
}
/// <summary>
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
/// <code>
/// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended)
/// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1
/// </code>
/// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4
/// zero-extended, so the two-instruction extract/insert reproduces the exact
/// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch
/// at every site the compiler emits this idiom.
/// </summary>
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> destination)
{
if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength)
{
return false;
}
// PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4.
destination[0] = 0x66;
destination[1] = 0x0F;
destination[2] = 0x3A;
destination[3] = 0x14;
destination[4] = (byte)(0xC0 | (srcRegister << 3));
destination[5] = 0x04;
// PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1.
destination[6] = 0x66;
destination[7] = 0x0F;
destination[8] = 0x3A;
destination[9] = 0x22;
destination[10] = (byte)(0xC0 | (destRegister << 3));
destination[11] = 0x01;
return true;
}
}
@@ -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)
@@ -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
@@ -349,6 +425,111 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return actualAddress;
}
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 +621,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
Interlocked.Increment(ref _mappingGeneration);
_hostMemory.Free(address);
}
@@ -611,6 +793,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
{
_allocationSearchHints.Clear();
}
Interlocked.Increment(ref _mappingGeneration);
}
finally
{
@@ -873,6 +1056,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{
// A managed write into a page the guest-image write tracker has
// protected surfaces as a fatal AccessViolation — the runtime turns
// SIGSEGV in managed code into an exception before the resumable
// signal bridge can restore access (native guest stores recover
// there). Pre-visit the span so tracked pages are unprotected and
// their owners dirtied before the copy; guest addresses are
// host-identical, matching the tracker's fault addresses.
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
var requiresExclusiveAccess = false;
_gate.EnterReadLock();
try
@@ -910,6 +1102,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
}
@@ -935,6 +1128,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);
@@ -1007,6 +1262,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1031,6 +1287,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1272,6 +1529,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;
@@ -1293,6 +1556,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;
}
@@ -1308,12 +1574,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
+45 -2
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "ملفات PS التنفيذية",
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
"Dialog.PlainTextFiles": "ملفات نصية عادية",
"Dialog.LogFiles": "ملفات السجل"
}
"Dialog.LogFiles": "ملفات السجل",
"Library.Context.GameSettings": "إعدادات اللعبة…",
"Options.Env.Tab": "البيئة",
"Options.Section.Environment": "متغيرات البيئة",
"Options.Env.Desc": "خيارات تُمرر إلى المحاكي كمتغيرات بيئة عند التشغيل.",
"Options.Env.Bthid.Desc": "الإبلاغ عن أن Bluetooth HID غير متاح للألعاب التي تنتظر برمجيات عجلة القيادة/FFB فيها إلى ما لا نهاية.\nاتركه معطلاً عادةً. بعض الألعاب تتجمد عند فشل التهيئة.",
"Options.Env.LoopGuard.Desc": "عدم إجبار الألعاب التي تكرر النداء نفسه لفترة طويلة على الإغلاق.\nجرّب هذا عندما تُغلق لعبة نفسها أثناء التحميل.",
"Options.Env.WritableApp0.Desc": "السماح للألعاب بإنشاء الملفات والكتابة داخل مجلد التثبيت الخاص بها.\nمطلوب للنسخ غير المحزومة التي تكتب بيانات الحفظ أو الإعدادات تحت ‎/app0.",
"Options.Env.VkValidation.Desc": "تفعيل طبقات التحقق في Vulkan لتصحيح أخطاء وحدة معالجة الرسوميات.\nبطيء. يتطلب تثبيت Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "تفريغ شيدرات AGC وترجماتها إلى SPIR-V في مجلد shader-dumps.\nاستخدمه عند الإبلاغ عن أخطاء الشيدرات أو العرض.",
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
"Common.Save": "حفظ",
"Common.Cancel": "إلغاء",
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح ‎SHARPEMU_*‎ لهذه اللعبة.",
"Options.About": "حول",
"About.Github.Label": "GitHub",
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
"About.Github.LatestCommitLabel": "أحدث Commit",
"About.Github.LatestCommitDescription": "أحدث commit على الفرع main",
"About.Discord.Label": "دسكورد",
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
"About.GithubButton": "ساهم على GitHub!",
"About.DiscordButton": "انضم إلى دسكوردنا!",
"Updater.Auto.Label": "التحقق من التحديثات عند بدء التشغيل",
"Updater.Auto.Desc": "يتحقق من GitHub دون تأخير بدء التشغيل.",
"Updater.Label": "التحديثات",
"Updater.Check": "التحقق من التحديثات",
"Updater.DownloadRestart": "تنزيل وإعادة التشغيل",
"Updater.Status.Ready": "الإصدار الحالي: {0}",
"Updater.Status.Checking": "جارٍ التحقق من التحديثات…",
"Updater.Status.Current": "أنت على أحدث إصدار ({0}).",
"Updater.Status.Available": "يتوفر إصدار جديد: {0}",
"Updater.Status.Downloading": "جارٍ تنزيل التحديث… {0}%",
"Updater.Status.Installing": "جارٍ تثبيت التحديث…",
"Updater.Status.Timeout": "انتهت مهلة التحقق من التحديثات بعد 10 ثوانٍ.",
"Updater.Status.Failed": "تعذر التحقق من التحديثات.",
"Updater.Status.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS."
}
+28 -1
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Entre no nosso Discord!"
"About.DiscordButton": "Entre no nosso Discord!",
"Library.Context.GameSettings": "Configurações do jogo…",
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
"Common.Save": "Salvar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Configurações por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit na branch main",
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar a inicialização.",
"Updater.Label": "Atualizações",
"Updater.Check": "Verificar atualizações",
"Updater.DownloadRestart": "Baixar e reiniciar",
"Updater.Status.Ready": "Build atual: {0}",
"Updater.Status.Checking": "Verificando atualizações…",
"Updater.Status.Current": "Você está atualizado ({0}).",
"Updater.Status.Available": "Um novo build está disponível: {0}",
"Updater.Status.Downloading": "Baixando atualização… {0}%",
"Updater.Status.Installing": "Instalando atualização…",
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
}
+44 -1
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PS-Ausführbare Dateien",
"Dialog.SaveLogFile": "Protokolldatei speichern unter",
"Dialog.PlainTextFiles": "Textdateien",
"Dialog.LogFiles": "Protokolldateien"
"Dialog.LogFiles": "Protokolldateien",
"Library.Context.GameSettings": "Spieleinstellungen…",
"Options.Env.Tab": "Umgebung",
"Options.Section.Environment": "UMGEBUNGSVARIABLEN",
"Options.Env.Desc": "Schalter, die dem Emulator beim Start als Umgebungsvariablen übergeben werden.",
"Options.Env.Bthid.Desc": "Bluetooth-HID als nicht verfügbar melden, wenn die Lenkrad-/FFB-Middleware eines Titels endlos wartet.\nNormalerweise ausgeschaltet lassen. Manche Titel frieren ein, wenn die Initialisierung fehlschlägt.",
"Options.Env.LoopGuard.Desc": "Titel nicht zwangsweise beenden, wenn sie denselben Aufruf zu lange wiederholen.\nAusprobieren, wenn ein Spiel sich beim Laden von selbst beendet.",
"Options.Env.WritableApp0.Desc": "Titeln erlauben, Dateien in ihrem Installationsordner anzulegen und zu schreiben.\nNötig für entpackte Dumps, die ihre Spielstände oder Konfiguration unter /app0 speichern.",
"Options.Env.VkValidation.Desc": "Vulkan-Validierungsschichten für GPU-Debugging aktivieren.\nLangsam. Erfordert ein installiertes Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "AGC-Shader und ihre SPIR-V-Übersetzungen im Ordner shader-dumps ablegen.\nBeim Melden von Shader- oder Grafikfehlern verwenden.",
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
"Common.Save": "Speichern",
"Common.Cancel": "Abbrechen",
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
"PerGame.EnvToggles.Label": "Umgebungsschalter",
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
"Options.About": "Über",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
"About.Github.LatestCommitLabel": "Neuester Commit",
"About.Github.LatestCommitDescription": "Neuester Commit auf dem main-Branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
"About.GithubButton": "Auf GitHub mitwirken!",
"About.DiscordButton": "Tritt unserem Discord bei!",
"Updater.Auto.Label": "Beim Start nach Updates suchen",
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
"Updater.Label": "Updates",
"Updater.Check": "Nach Updates suchen",
"Updater.DownloadRestart": "Herunterladen und neu starten",
"Updater.Status.Ready": "Aktueller Build: {0}",
"Updater.Status.Checking": "Suche nach Updates…",
"Updater.Status.Current": "Du bist auf dem neuesten Stand ({0}).",
"Updater.Status.Available": "Ein neuer Build ist verfügbar: {0}",
"Updater.Status.Downloading": "Update wird heruntergeladen… {0}%",
"Updater.Status.Installing": "Update wird installiert…",
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS."
}
+44 -1
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PS-programmer",
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
"Dialog.PlainTextFiles": "Almindelige tekstfiler",
"Dialog.LogFiles": "Logfiler"
"Dialog.LogFiles": "Logfiler",
"Library.Context.GameSettings": "Spilindstillinger…",
"Options.Env.Tab": "Miljø",
"Options.Section.Environment": "MILJØVARIABLER",
"Options.Env.Desc": "Kontakter, der gives videre til emulatoren som miljøvariabler ved start.",
"Options.Env.Bthid.Desc": "Rapportér Bluetooth HID som utilgængelig for titler, hvis rat-/FFB-middleware venter i det uendelige.\nLad den normalt være slået fra. Nogle titler fryser, når initialiseringen fejler.",
"Options.Env.LoopGuard.Desc": "Tving ikke titler til at lukke, når de gentager det samme kald for længe.\nPrøv dette, når et spil lukker af sig selv under indlæsning.",
"Options.Env.WritableApp0.Desc": "Tillad titler at oprette og skrive filer i deres installationsmappe.\nKræves af upakkede dumps, der skriver deres gemte data eller konfiguration under /app0.",
"Options.Env.VkValidation.Desc": "Aktivér Vulkan-valideringslag til GPU-fejlfinding.\nLangsomt. Kræver at Vulkan SDK er installeret.",
"Options.Env.DumpSpirv.Desc": "Gem AGC-shadere og deres SPIR-V-oversættelser i mappen shader-dumps.\nBrug dette, når du rapporterer shader- eller grafikfejl.",
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
"Common.Save": "Gem",
"Common.Cancel": "Annuller",
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
"PerGame.EnvToggles.Label": "Miljøkontakter",
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
"Options.About": "Om",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
"About.Github.LatestCommitLabel": "Seneste commit",
"About.Github.LatestCommitDescription": "Seneste commit på main-branchen",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
"About.GithubButton": "Bidrag på GitHub!",
"About.DiscordButton": "Bliv medlem af vores Discord!",
"Updater.Auto.Label": "Søg efter opdateringer ved start",
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
"Updater.Label": "Opdateringer",
"Updater.Check": "Søg efter opdateringer",
"Updater.DownloadRestart": "Download og genstart",
"Updater.Status.Ready": "Nuværende build: {0}",
"Updater.Status.Checking": "Søger efter opdateringer…",
"Updater.Status.Current": "Du er opdateret ({0}).",
"Updater.Status.Available": "Et nyt build er tilgængeligt: {0}",
"Updater.Status.Downloading": "Downloader opdatering… {0}%",
"Updater.Status.Installing": "Installerer opdatering…",
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS."
}
+35 -1
View File
@@ -135,5 +135,39 @@
"About.Github.LatestCommitDescription": "Último commit en la rama main",
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
"About.GithubButton": "Contribuye en GitHub!",
"About.DiscordButton": "Únete a nuestro Discord!"
"About.DiscordButton": "Únete a nuestro Discord!",
"Library.Context.GameSettings": "Ajustes del juego…",
"Options.Env.Tab": "Entorno",
"Options.Section.Environment": "VARIABLES DE ENTORNO",
"Options.Env.Desc": "Opciones que se pasan al emulador como variables de entorno al iniciar.",
"Options.Env.Bthid.Desc": "Indicar que Bluetooth HID no está disponible para títulos cuyo middleware de volante/FFB espera indefinidamente.\nDéjalo desactivado normalmente. Algunos títulos se congelan cuando la inicialización falla.",
"Options.Env.LoopGuard.Desc": "No forzar el cierre de títulos que repiten la misma llamada durante demasiado tiempo.\nPruébalo cuando un juego se cierre solo durante la carga.",
"Options.Env.WritableApp0.Desc": "Permitir que los títulos creen y escriban archivos dentro de su carpeta de instalación.\nNecesario para dumps sin empaquetar que guardan sus datos o configuración en /app0.",
"Options.Env.VkValidation.Desc": "Activar las capas de validación de Vulkan para depurar la GPU.\nLento. Requiere tener instalado el SDK de Vulkan.",
"Options.Env.DumpSpirv.Desc": "Volcar los shaders AGC y sus traducciones SPIR-V a la carpeta shader-dumps.\nÚsalo al informar de errores de shaders o de renderizado.",
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Ajustes por juego — {0} ({1})",
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
"PerGame.EnvToggles.Label": "Variables de entorno",
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
"Updater.Label": "Actualizaciones",
"Updater.Check": "Buscar actualizaciones",
"Updater.DownloadRestart": "Descargar y reiniciar",
"Updater.Status.Ready": "Build actual: {0}",
"Updater.Status.Checking": "Buscando actualizaciones…",
"Updater.Status.Current": "Estás al día ({0}).",
"Updater.Status.Available": "Hay un nuevo build disponible: {0}",
"Updater.Status.Downloading": "Descargando actualización… {0}%",
"Updater.Status.Installing": "Instalando actualización…",
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS."
}
+28 -1
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Rejoignez la communauté, obtenez de laide et suivez le développement.",
"About.GithubButton": "Contribuer sur GitHub !",
"About.DiscordButton": "Rejoindre notre Discord !"
"About.DiscordButton": "Rejoindre notre Discord !",
"Library.Context.GameSettings": "Paramètres du jeu…",
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier dinstallation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
"Options.Env.LogIo.Desc": "Journaliser louverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
"Common.Save": "Enregistrer",
"Common.Cancel": "Annuler",
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
"PerGame.EnvToggles.Label": "Variables denvironnement",
"PerGame.EnvToggles.Desc": "Remplacer lensemble global des options SHARPEMU_* pour ce jeu.",
"About.Github.LatestCommitLabel": "Dernier commit",
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
"Updater.Auto.Desc": "Interroge GitHub sans retarder le démarrage.",
"Updater.Label": "Mises à jour",
"Updater.Check": "Vérifier les mises à jour",
"Updater.DownloadRestart": "Télécharger et redémarrer",
"Updater.Status.Ready": "Build actuel : {0}",
"Updater.Status.Checking": "Recherche de mises à jour…",
"Updater.Status.Current": "Vous êtes à jour ({0}).",
"Updater.Status.Available": "Un nouveau build est disponible : {0}",
"Updater.Status.Downloading": "Téléchargement de la mise à jour… {0}%",
"Updater.Status.Installing": "Installation de la mise à jour…",
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS."
}
+29 -2
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
"About.GithubButton": "Járulj hozzá GitHubon!",
"About.DiscordButton": "Csatlakozz a Discordunhoz!"
}
"About.DiscordButton": "Csatlakozz a Discordunhoz!",
"Library.Context.GameSettings": "Játékbeállítások…",
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
"Common.Save": "Mentés",
"Common.Cancel": "Mégse",
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
"About.Github.LatestCommitLabel": "Legutóbbi commit",
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
"Updater.Auto.Label": "Frissítések keresése indításkor",
"Updater.Auto.Desc": "A GitHubot az indítás késleltetése nélkül ellenőrzi.",
"Updater.Label": "Frissítések",
"Updater.Check": "Frissítések keresése",
"Updater.DownloadRestart": "Letöltés és újraindítás",
"Updater.Status.Ready": "Jelenlegi build: {0}",
"Updater.Status.Checking": "Frissítések keresése…",
"Updater.Status.Current": "Naprakész vagy ({0}).",
"Updater.Status.Available": "Új build érhető el: {0}",
"Updater.Status.Downloading": "Frissítés letöltése… {0}%",
"Updater.Status.Installing": "Frissítés telepítése…",
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges."
}
+44 -1
View File
@@ -130,5 +130,48 @@
"Dialog.PsExecutables": "Eseguibili PS",
"Dialog.SaveLogFile": "Scegli dove salvare il file di log",
"Dialog.PlainTextFiles": "File di testo semplice",
"Dialog.LogFiles": "File di log"
"Dialog.LogFiles": "File di log",
"Library.Context.GameSettings": "Impostazioni del gioco…",
"Options.Env.Tab": "Ambiente",
"Options.Section.Environment": "VARIABILI D'AMBIENTE",
"Options.Env.Desc": "Opzioni passate all'emulatore come variabili d'ambiente all'avvio.",
"Options.Env.Bthid.Desc": "Segnala il Bluetooth HID come non disponibile per i titoli il cui middleware volante/FFB attende all'infinito.\nNormalmente lascialo disattivato. Alcuni titoli si bloccano quando l'inizializzazione fallisce.",
"Options.Env.LoopGuard.Desc": "Non forzare la chiusura dei titoli che ripetono la stessa chiamata troppo a lungo.\nProvalo quando un gioco si chiude da solo durante il caricamento.",
"Options.Env.WritableApp0.Desc": "Consenti ai titoli di creare e scrivere file nella propria cartella di installazione.\nNecessario per i dump non pacchettizzati che scrivono salvataggi o configurazioni in /app0.",
"Options.Env.VkValidation.Desc": "Abilita i validation layer di Vulkan per il debug della GPU.\nLento. Richiede l'SDK di Vulkan installato.",
"Options.Env.DumpSpirv.Desc": "Esporta gli shader AGC e le loro traduzioni SPIR-V nella cartella shader-dumps.\nUsalo quando segnali bug di shader o di rendering.",
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
"Common.Save": "Salva",
"Common.Cancel": "Annulla",
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
"Options.About": "Informazioni",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
"About.Github.LatestCommitLabel": "Ultimo commit",
"About.Github.LatestCommitDescription": "Ultimo commit sul branch main",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
"About.GithubButton": "Contribuisci su GitHub!",
"About.DiscordButton": "Unisciti al nostro Discord!",
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
"Updater.Label": "Aggiornamenti",
"Updater.Check": "Controlla aggiornamenti",
"Updater.DownloadRestart": "Scarica e riavvia",
"Updater.Status.Ready": "Build attuale: {0}",
"Updater.Status.Checking": "Ricerca aggiornamenti…",
"Updater.Status.Current": "Sei aggiornato ({0}).",
"Updater.Status.Available": "È disponibile un nuovo build: {0}",
"Updater.Status.Downloading": "Download dell'aggiornamento… {0}%",
"Updater.Status.Installing": "Installazione dell'aggiornamento…",
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS."
}
+45 -2
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PlayStation 実行ファイル",
"Dialog.SaveLogFile": "ログファイルの保存先を選択",
"Dialog.PlainTextFiles": "プレーンテキストファイル",
"Dialog.LogFiles": "ログファイル"
}
"Dialog.LogFiles": "ログファイル",
"Library.Context.GameSettings": "ゲーム設定…",
"Options.Env.Tab": "環境",
"Options.Section.Environment": "環境変数",
"Options.Env.Desc": "起動時に環境変数としてエミュレータへ渡されるスイッチです。",
"Options.Env.Bthid.Desc": "ハンドル/FFBミドルウェアが永久に待機するタイトル向けに、Bluetooth HIDを利用不可として報告します。\n通常はオフのままにしてください。初期化に失敗するとフリーズするタイトルもあります。",
"Options.Env.LoopGuard.Desc": "同じ呼び出しを長時間繰り返すタイトルを強制終了しません。\nロード中にゲームが勝手に終了する場合に試してください。",
"Options.Env.WritableApp0.Desc": "タイトルがインストールフォルダー内にファイルを作成・書き込みできるようにします。\nセーブや設定データを/app0以下に書き込む未パッケージのダンプに必要です。",
"Options.Env.VkValidation.Desc": "GPUデバッグ用のVulkan検証レイヤーを有効にします。\n低速です。Vulkan SDKのインストールが必要です。",
"Options.Env.DumpSpirv.Desc": "AGCシェーダーとそのSPIR-V変換をshader-dumpsフォルダーに出力します。\nシェーダーや描画のバグを報告する際に使用してください。",
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
"Options.Env.LogNp.Desc": "NPPlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
"Common.Save": "保存",
"Common.Cancel": "キャンセル",
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
"PerGame.EnvToggles.Label": "環境スイッチ",
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
"Options.About": "情報",
"About.Github.Label": "GitHub",
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
"About.Github.LatestCommitLabel": "最新コミット",
"About.Github.LatestCommitDescription": "mainブランチの最新コミット",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "コミュニティに参加して、サポートを受けたり開発を追いかけたりしましょう。",
"About.GithubButton": "GitHubで貢献しよう!",
"About.DiscordButton": "Discordに参加しよう!",
"Updater.Auto.Label": "起動時にアップデートを確認",
"Updater.Auto.Desc": "起動を遅らせずにGitHubへ確認します。",
"Updater.Label": "アップデート",
"Updater.Check": "アップデートを確認",
"Updater.DownloadRestart": "ダウンロードして再起動",
"Updater.Status.Ready": "現在のビルド: {0}",
"Updater.Status.Checking": "アップデートを確認しています…",
"Updater.Status.Current": "最新の状態です({0})。",
"Updater.Status.Available": "新しいビルドがあります: {0}",
"Updater.Status.Downloading": "アップデートをダウンロード中… {0}%",
"Updater.Status.Installing": "アップデートをインストール中…",
"Updater.Status.Timeout": "アップデートの確認が10秒でタイムアウトしました。",
"Updater.Status.Failed": "アップデートを確認できませんでした。",
"Updater.Status.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。"
}
+45 -2
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PlayStation 실행 파일",
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
"Dialog.PlainTextFiles": "일반 텍스트 파일",
"Dialog.LogFiles": "로그 파일"
}
"Dialog.LogFiles": "로그 파일",
"Library.Context.GameSettings": "게임 설정…",
"Options.Env.Tab": "환경",
"Options.Section.Environment": "환경 변수",
"Options.Env.Desc": "실행 시 환경 변수로 에뮬레이터에 전달되는 스위치입니다.",
"Options.Env.Bthid.Desc": "휠/FFB 미들웨어가 무한 대기하는 타이틀을 위해 블루투스 HID를 사용 불가로 보고합니다.\n평소에는 꺼 두세요. 초기화에 실패하면 멈추는 타이틀도 있습니다.",
"Options.Env.LoopGuard.Desc": "같은 호출을 너무 오래 반복하는 타이틀을 강제 종료하지 않습니다.\n게임이 로딩 중 저절로 종료될 때 시도해 보세요.",
"Options.Env.WritableApp0.Desc": "타이틀이 설치 폴더 안에 파일을 만들고 쓸 수 있도록 허용합니다.\n세이브나 설정 데이터를 /app0 아래에 쓰는 비패키지 덤프에 필요합니다.",
"Options.Env.VkValidation.Desc": "GPU 디버깅을 위한 Vulkan 검증 레이어를 활성화합니다.\n느립니다. Vulkan SDK가 설치되어 있어야 합니다.",
"Options.Env.DumpSpirv.Desc": "AGC 셰이더와 SPIR-V 변환 결과를 shader-dumps 폴더에 저장합니다.\n셰이더나 렌더링 버그를 보고할 때 사용하세요.",
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
"Common.Save": "저장",
"Common.Cancel": "취소",
"PerGame.Title": "게임별 설정 — {0} ({1})",
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
"PerGame.EnvToggles.Label": "환경 스위치",
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
"Options.About": "정보",
"About.Github.Label": "GitHub",
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
"About.Github.LatestCommitLabel": "최신 커밋",
"About.Github.LatestCommitDescription": "main 브랜치의 최신 커밋",
"About.Discord.Label": "디스코드",
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
"About.GithubButton": "GitHub에서 기여하기!",
"About.DiscordButton": "디스코드 참여하기!",
"Updater.Auto.Label": "시작 시 업데이트 확인",
"Updater.Auto.Desc": "시작을 지연시키지 않고 GitHub를 확인합니다.",
"Updater.Label": "업데이트",
"Updater.Check": "업데이트 확인",
"Updater.DownloadRestart": "다운로드 후 재시작",
"Updater.Status.Ready": "현재 빌드: {0}",
"Updater.Status.Checking": "업데이트 확인 중…",
"Updater.Status.Current": "최신 상태입니다 ({0}).",
"Updater.Status.Available": "새 빌드가 있습니다: {0}",
"Updater.Status.Downloading": "업데이트 다운로드 중… {0}%",
"Updater.Status.Installing": "업데이트 설치 중…",
"Updater.Status.Timeout": "업데이트 확인이 10초 후 시간 초과되었습니다.",
"Updater.Status.Failed": "업데이트를 확인할 수 없습니다.",
"Updater.Status.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다."
}
+44 -1
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PS-uitvoerbare bestanden",
"Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen",
"Dialog.PlainTextFiles": "Platte tekstbestanden",
"Dialog.LogFiles": "Logbestanden"
"Dialog.LogFiles": "Logbestanden",
"Library.Context.GameSettings": "Game-instellingen…",
"Options.Env.Tab": "Omgeving",
"Options.Section.Environment": "OMGEVINGSVARIABELEN",
"Options.Env.Desc": "Schakelaars die bij het starten als omgevingsvariabelen aan de emulator worden doorgegeven.",
"Options.Env.Bthid.Desc": "Meld Bluetooth HID als niet beschikbaar voor titels waarvan de stuur-/FFB-middleware eindeloos blijft wachten.\nLaat dit normaal uit. Sommige titels bevriezen wanneer de initialisatie mislukt.",
"Options.Env.LoopGuard.Desc": "Titels die dezelfde aanroep te lang herhalen niet geforceerd afsluiten.\nProbeer dit wanneer een game zichzelf tijdens het laden afsluit.",
"Options.Env.WritableApp0.Desc": "Sta titels toe bestanden aan te maken en te schrijven in hun installatiemap.\nNodig voor uitgepakte dumps die hun save- of configuratiegegevens onder /app0 wegschrijven.",
"Options.Env.VkValidation.Desc": "Schakel Vulkan-validatielagen in voor GPU-debugging.\nTraag. Vereist een geïnstalleerde Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "Sla AGC-shaders en hun SPIR-V-vertalingen op in de map shader-dumps.\nGebruik dit bij het melden van shader- of renderfouten.",
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
"Common.Save": "Opslaan",
"Common.Cancel": "Annuleren",
"PerGame.Title": "Instellingen per game — {0} ({1})",
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
"Options.About": "Over",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
"About.Github.LatestCommitLabel": "Nieuwste commit",
"About.Github.LatestCommitDescription": "Nieuwste commit op de main-branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
"About.GithubButton": "Draag bij op GitHub!",
"About.DiscordButton": "Word lid van onze Discord!",
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
"Updater.Label": "Updates",
"Updater.Check": "Controleren op updates",
"Updater.DownloadRestart": "Downloaden en opnieuw starten",
"Updater.Status.Ready": "Huidige build: {0}",
"Updater.Status.Checking": "Controleren op updates…",
"Updater.Status.Current": "Je bent up-to-date ({0}).",
"Updater.Status.Available": "Er is een nieuwe build beschikbaar: {0}",
"Updater.Status.Downloading": "Update downloaden… {0}%",
"Updater.Status.Installing": "Update installeren…",
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
"Updater.Status.Failed": "Kon niet controleren op updates.",
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS."
}
+28 -1
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Junte-se ao nosso Discord!"
"About.DiscordButton": "Junte-se ao nosso Discord!",
"Library.Context.GameSettings": "Definições do jogo…",
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Definições por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit no ramo main",
"Updater.Auto.Label": "Procurar atualizações no arranque",
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar o arranque.",
"Updater.Label": "Atualizações",
"Updater.Check": "Procurar atualizações",
"Updater.DownloadRestart": "Transferir e reiniciar",
"Updater.Status.Ready": "Build atual: {0}",
"Updater.Status.Checking": "A procurar atualizações…",
"Updater.Status.Current": "Está atualizado ({0}).",
"Updater.Status.Available": "Está disponível um novo build: {0}",
"Updater.Status.Downloading": "A transferir a atualização… {0}%",
"Updater.Status.Installing": "A instalar a atualização…",
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
}
+3 -1
View File
@@ -169,5 +169,7 @@
"Updater.Status.Installing": "Установка обновления…",
"Updater.Status.Timeout": "Проверка обновлений превысила лимит времени в 10 секунд.",
"Updater.Status.Failed": "Не удалось проверить наличие обновлений.",
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64."
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64.",
"Updater.Status.ChecksumFailed": "Скачанное обновление не прошло проверку SHA-256."
}
+29 -1
View File
@@ -141,5 +141,33 @@
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
"Updater.Status.ChecksumFailed": "İndirilen güncelleme SHA-256 doğrulamasını geçemedi.",
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir.",
"Library.Context.GameSettings": "Oyun ayarları…",
"Options.Env.Tab": "Ortam",
"Options.Section.Environment": "ORTAM DEĞİŞKENLERİ",
"Options.Env.Desc": "Başlatma sırasında emülatöre ortam değişkeni olarak geçirilen anahtarlar.",
"Options.Env.Bthid.Desc": "Direksiyon/FFB katmanı sonsuza kadar bekleyen oyunlar için Bluetooth HID'i kullanılamıyor olarak bildir.\nNormalde kapalı bırakın. Bazı oyunlar başlatma başarısız olduğunda donar.",
"Options.Env.LoopGuard.Desc": "Aynı çağrıyı uzun süre tekrarlayan oyunları zorla kapatma.\nBir oyun yükleme sırasında kendiliğinden kapanıyorsa bunu deneyin.",
"Options.Env.WritableApp0.Desc": "Oyunların kurulum klasörlerinde dosya oluşturup yazmasına izin ver.\nKayıt veya yapılandırma verisini /app0 altına yazan paketlenmemiş dump'lar için gereklidir.",
"Options.Env.VkValidation.Desc": "GPU hata ayıklaması için Vulkan doğrulama katmanlarını etkinleştir.\nYavaştır. Vulkan SDK'nın kurulu olması gerekir.",
"Options.Env.DumpSpirv.Desc": "AGC shader'larını ve SPIR-V çevirilerini shader-dumps klasörüne kaydet.\nShader veya görüntü hatalarını bildirirken kullanın.",
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
"Common.Save": "Kaydet",
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
"PerGame.EnvToggles.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
"About.Github.LatestCommitLabel": "Son Commit",
"About.Github.LatestCommitDescription": "main dalındaki son commit",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
"About.GithubButton": "GitHub'da katkıda bulun!",
"About.DiscordButton": "Discord'umuza katıl!"
}
+28 -2
View File
@@ -91,7 +91,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</Grid>
<Panel Grid.Row="1">
<Panel Grid.Row="1" x:Name="PagesHost">
<!-- Library page. The tile row gets extra top margin so it sits
closer to eye level (PS5 home-screen style) instead of hugging
@@ -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">
@@ -604,9 +627,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. -->
<!-- Anchored to MainContent, not GameView: the surface host is parked in
a 1x1 corner while loading/closing, which would pull a GameView-
anchored popup into the corner with it. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
PlacementTarget="{Binding #MainContent}"
Placement="Center"
Topmost="True"
ShouldUseOverlayLayer="False"
+146 -23
View File
@@ -88,6 +88,15 @@ public partial class MainWindow : Window
private int _detailLoadGeneration;
private int _backdropGeneration;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop;
// Whether the native loading/closing popup should be showing; it is a
// desktop-topmost popup, so it closes while the launcher is in the
// background or minimized and reopens from this flag on activation.
private bool _sessionLoadingActive;
// Controller navigation state.
private readonly DispatcherTimer _gamepadTimer;
private HostGamepadButtons _previousPadButtons;
@@ -111,6 +120,18 @@ public partial class MainWindow : Window
{
InitializeComponent();
try
{
_defaultBackdrop = new Bitmap(
AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/pic0.png")));
BackdropImage.Source = _defaultBackdrop;
BackdropImage.Opacity = 1.0;
}
catch (Exception)
{
_defaultBackdrop = null; // color background remains the fallback
}
GameList.ItemsSource = _visibleGames;
ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
@@ -134,8 +155,18 @@ public partial class MainWindow : Window
};
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
Activated += (_, _) => UpdateSessionBarVisibility();
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
// Native popups float above every window on the desktop; they must
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
TitleBar.PointerPressed += OnTitleBarPointerPressed;
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
@@ -161,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 += (_, _) =>
@@ -398,6 +441,15 @@ public partial class MainWindow : Window
return;
}
if (_isRunning || _isStopping)
{
// The game renders inside the launcher window, so the launcher
// stays active while playing. The controller belongs to the game
// then: no navigation, and Circle/B must never stop the session.
_previousPadButtons = pad.Buttons;
return;
}
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
if ((shoulderPressed & HostGamepadButtons.L1) != 0)
{
@@ -447,11 +499,6 @@ public partial class MainWindow : Window
LaunchSelected();
}
if ((pressed & HostGamepadButtons.Circle) != 0)
{
StopEmulator();
}
_previousPadButtons = pad.Buttons;
}
@@ -834,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;
@@ -1610,13 +1664,23 @@ public partial class MainWindow : Window
base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty)
{
// The XAML WindowState="Maximized" assignment raises this change
// during InitializeComponent, before named controls are wired up.
if (WindowState == WindowState.Minimized)
{
_sndPreview.Pause();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = false;
}
}
else
{
_sndPreview.Resume();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = _sessionLoadingActive;
}
}
}
}
@@ -1631,8 +1695,20 @@ public partial class MainWindow : Window
var generation = ++_backdropGeneration;
BackdropImage.Opacity = 0;
// The bundled key art is the primary backdrop whenever the selection
// has no art of its own; the window color stays as the last fallback.
void ShowDefaultBackdrop()
{
if (generation == _backdropGeneration && _defaultBackdrop is not null)
{
BackdropImage.Source = _defaultBackdrop;
BackdropImage.Opacity = 1.0;
}
}
if (game?.BackgroundPath is null)
{
ShowDefaultBackdrop();
return;
}
@@ -1649,7 +1725,8 @@ public partial class MainWindow : Window
}
catch (Exception)
{
return; // undecodable key art: keep the plain background
ShowDefaultBackdrop(); // undecodable key art
return;
}
}
@@ -1730,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;
@@ -1969,18 +2052,30 @@ public partial class MainWindow : Window
_awaitingFirstFrame = false;
ClearLibraryBlur();
MainContent.Margin = new Thickness(0);
RestoreGameViewToFull();
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
SessionLoadingPopup.IsOpen = 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);
});
}
});
}
@@ -2030,11 +2125,31 @@ public partial class MainWindow : Window
}
}
/// <summary>
/// The native host attachment is a real child window: it sits above every
/// Avalonia control it covers and swallows their mouse input regardless of
/// hit-test settings. While the library must stay interactive (loading,
/// closing), the surface is parked offscreen AT FULL SIZE via a negative
/// margin. It must not be shrunk instead: the emulator child polls the
/// HWND client size and its presenter defers swapchain creation while the
/// surface is 1px, which would deadlock the loading handshake.
/// </summary>
private void ParkGameViewOffscreen()
{
GameView.Margin = new Thickness(-20000, 0, 20000, 0);
}
private void RestoreGameViewToFull()
{
GameView.Margin = new Thickness(0);
}
private void ShowGameView()
{
_isStopping = false;
_awaitingFirstFrame = true;
var host = EnsureGameSurfaceHost();
ParkGameViewOffscreen();
GameView.IsVisible = true;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
@@ -2059,7 +2174,7 @@ public partial class MainWindow : Window
GameView.IsVisible = false;
GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
HideSessionLoading();
AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
@@ -2068,16 +2183,15 @@ public partial class MainWindow : Window
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
{
BackdropImage.Opacity = 1;
}
// Game art when the source still holds it, otherwise the bundled
// default; a bare color only when neither is available.
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
{
_libraryBlur ??= new BlurEffect();
MainContent.Effect = _libraryBlur;
PagesHost.Effect = _libraryBlur;
_libraryBlurStartRadius = _libraryBlur.Radius;
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
@@ -2126,7 +2240,7 @@ public partial class MainWindow : Window
if (_clearLibraryBlurWhenComplete)
{
MainContent.Effect = null;
PagesHost.Effect = null;
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
}
@@ -2137,14 +2251,21 @@ public partial class MainWindow : Window
_libraryBlurTimer.Stop();
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
MainContent.Effect = null;
PagesHost.Effect = null;
}
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
SessionLoadingDetail.Text = detail;
SessionLoadingPopup.IsOpen = true;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void HideSessionLoading()
{
_sessionLoadingActive = false;
SessionLoadingPopup.IsOpen = false;
}
private void ReturnToLibraryWhileStopping()
@@ -2156,10 +2277,12 @@ public partial class MainWindow : Window
// Keep the native child alive until the session exits, but hide it
// immediately. Destroying it while Vulkan still owns the surface can
// crash the GUI; leaving it transparent lets the library recover
// while the native closing popup reports teardown progress.
// crash the GUI; parking it in the 1x1 corner lets the library
// recover — and stay clickable — while the native closing popup
// reports teardown progress.
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
ParkGameViewOffscreen();
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
SessionBarPopup.IsOpen = false;
@@ -2171,7 +2294,7 @@ public partial class MainWindow : Window
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
UpdateRunButtons();
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
}
+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))
+5
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" />
@@ -38,6 +42,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
<AvaloniaResource Include="..\..\assets\images\update-icon.png" Link="Assets/update-icon.png" />
<AvaloniaResource Include="..\..\assets\images\commit-icon.png" Link="Assets/commit-icon.png" />
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
</ItemGroup>
<ItemGroup>
+83 -7
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;
@@ -51,9 +52,33 @@ public static unsafe class GuestImageWriteTracker
private static readonly object _gate = new();
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
// Snapshot array read lock-free from the signal handler; rebuilt on every
// mutation under the gate. Signal handlers must not take managed locks.
private static TrackedRange[] _rangeSnapshot = [];
/// <summary>Immutable snapshot read lock-free from the signal handler and
/// the managed-write pre-visit; rebuilt on every mutation under the gate
/// (signal handlers must not take managed locks). Carrying the overall
/// bounds inside the same object keeps the hot-path intersection test
/// consistent with the array it guards.</summary>
private sealed class RangeSnapshot
{
public static readonly RangeSnapshot Empty = new([]);
public readonly TrackedRange[] Ranges;
public readonly ulong Start;
public readonly ulong End;
public RangeSnapshot(TrackedRange[] ranges)
{
Ranges = ranges;
Start = ulong.MaxValue;
End = 0;
foreach (var range in ranges)
{
Start = Math.Min(Start, range.Start);
End = Math.Max(End, range.End);
}
}
}
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
@@ -131,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)
@@ -248,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
@@ -266,6 +327,17 @@ public static unsafe class GuestImageWriteTracker
var end = address > ulong.MaxValue - byteCount
? ulong.MaxValue
: address + byteCount;
// Fast rejection for the hot path: this runs on every managed guest
// write, and almost none of them touch tracked texture pages. The
// bounds live inside the snapshot so they are always consistent with
// the ranges the per-page visit below would consult.
var snapshot = Volatile.Read(ref _rangeSnapshot);
if (snapshot.Ranges.Length == 0 || end <= snapshot.Start || address >= snapshot.End)
{
return;
}
var candidate = address;
while (candidate < end)
{
@@ -311,7 +383,7 @@ public static unsafe class GuestImageWriteTracker
return false;
}
var ranges = Volatile.Read(ref _rangeSnapshot);
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges;
var writableStart = ulong.MaxValue;
var writableEnd = 0UL;
for (var index = 0; index < ranges.Length; index++)
@@ -390,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)
@@ -458,7 +534,7 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked()
{
_rangeSnapshot = _rangesByAddress.Values.ToArray();
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
}
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
+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
@@ -3,6 +3,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
@@ -26,8 +27,8 @@ internal static class AgcShaderCompilerHooks
internal static void Install()
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
KernelMemoryCompatExports.TryReadShaderGuestMemory;
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
VulkanVideoPresenter.GuestDataPool;
GuestDataPool.Shared;
}
}
+328 -43
View File
@@ -1,6 +1,9 @@
// 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>
@@ -16,8 +19,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 +124,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 +208,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 +418,83 @@ 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++)
{
for (var bx = 0; bx < blockWidth; bx++)
{
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
}
}
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));
for (var y = 0; y < elementsHigh; y++)
// 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)
{
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 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) =>
{
var blockX = x / blockWidth;
var inBlockX = x % blockWidth;
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)
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++)
{
continue;
}
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;
}
tiled.Slice((int)sourceByte, bytesPerElement)
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
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);
}
}
}
@@ -302,6 +507,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 +614,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;
}
+220 -2
View File
@@ -37,10 +37,26 @@ internal static class GpuWaitRegistry
public long RegisteredTicks;
public bool StaleReported;
public object? State;
// Latched by LatchSatisfiedByValue when a producer wrote a value that
// satisfies this waiter. The label is frequently reused (reset to 0 for
// the next frame) immediately after the producing write, so re-reading
// guest memory at wake time can miss the transient satisfied window.
// Latching records satisfaction at the moment of the write instead.
public bool Latched;
// Non-zero for indirect-dispatch dimension retries: a bounded deadline
// (Stopwatch ticks) after which the waiter is resumed even if unsatisfied,
// so a legitimately empty indirect dispatch can never stall forever.
public long RetryDeadlineTicks;
}
private static readonly object _gate = new();
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = new();
// The last value each label producer wrote. Used only by the deadlock
// breaker: our serial submission parser cannot model two GPU queues running
// concurrently, so a label written -> reset -> re-waited across queues can
// cycle forever even though a real producer did signal it. Keyed by (memory,
// address) so distinct guest processes never alias.
private static readonly Dictionary<(object, ulong), ulong> _lastProduced = new();
public static int Count
{
@@ -114,8 +130,14 @@ internal static class GpuWaitRegistry
continue;
}
var value = readValue(address, list[i].Is64Bit);
if (value is null || !Compare(list[i], value.Value))
var satisfied = list[i].Latched;
if (!satisfied)
{
var value = readValue(address, list[i].Is64Bit);
satisfied = value is not null && Compare(list[i], value.Value);
}
if (!satisfied)
{
continue;
}
@@ -236,6 +258,201 @@ internal static class GpuWaitRegistry
return matches;
}
/// <summary>
/// Records satisfaction for every waiter at <paramref name="address"/> whose
/// condition is met by <paramref name="value"/> — the value a producer just
/// wrote to that label. Called from the ordered producer side effect so a
/// same-frame label reset cannot lose the wakeup. The waiters stay registered
/// (latched) and are drained by the next CollectSatisfied. Returns true when
/// at least one waiter latched, so the caller can trigger a wake pass.
/// </summary>
public static bool LatchSatisfiedByValue(object memory, ulong address, ulong value)
{
var latchedAny = false;
lock (_gate)
{
if (!_waiters.TryGetValue(address, out var list))
{
return false;
}
for (var i = 0; i < list.Count; i++)
{
var waiter = list[i];
if (waiter.Latched ||
!ReferenceEquals(waiter.Memory, memory) ||
!Compare(waiter, value))
{
continue;
}
waiter.Latched = true;
list[i] = waiter;
latchedAny = true;
}
}
return latchedAny;
}
/// <summary>
/// Removes and returns waiters carrying a <see cref="WaitingDcb.RetryDeadlineTicks"/>
/// that has elapsed. Used for indirect-dispatch dimension retries: the caller
/// resumes them so a genuinely empty dispatch (dims that never become non-zero)
/// is dropped after a bounded wait instead of stalling the queue forever.
/// </summary>
public static List<WaitingDcb>? CollectExpiredRetries(object memory, long nowTicks)
{
List<WaitingDcb>? expired = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var i = list.Count - 1; i >= 0; i--)
{
var waiter = list[i];
if (waiter.RetryDeadlineTicks == 0 ||
!ReferenceEquals(waiter.Memory, memory) ||
nowTicks < waiter.RetryDeadlineTicks)
{
continue;
}
expired ??= new List<WaitingDcb>();
expired.Add(waiter);
list.RemoveAt(i);
}
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 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)
{
lock (_gate)
{
if (_lastProduced.Count >= 8192)
{
_lastProduced.Clear();
}
_lastProduced[(memory, address)] = value;
}
return LatchSatisfiedByValue(memory, address, value);
}
/// <summary>
/// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns
/// (and removes) waiters that have been stuck longer than
/// <paramref name="minAgeTicks"/> and whose condition is satisfied by the
/// last value a real producer wrote to their label — even though guest
/// memory has since been reset. Never fabricates a value: a waiter is only
/// released when an actual producer signalled it at least once.
/// </summary>
public static List<WaitingDcb>? CollectDeadlockBroken(
object memory,
long nowTicks,
long minAgeTicks)
{
List<WaitingDcb>? broken = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var i = list.Count - 1; i >= 0; i--)
{
var waiter = list[i];
if (!ReferenceEquals(waiter.Memory, memory) ||
nowTicks - waiter.RegisteredTicks < minAgeTicks ||
!_lastProduced.TryGetValue((memory, address), out var produced) ||
!Compare(waiter, produced))
{
continue;
}
broken ??= new List<WaitingDcb>();
broken.Add(waiter);
list.RemoveAt(i);
}
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 broken;
}
public static bool Compare(in WaitingDcb waiter, ulong value)
{
var masked = value & waiter.Mask;
@@ -260,6 +477,7 @@ internal static class GpuWaitRegistry
lock (_gate)
{
_waiters.Clear();
_lastProduced.Clear();
}
}
}
+48 -17
View File
@@ -6,6 +6,7 @@ using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Ampr;
@@ -43,17 +44,17 @@ public static class AmprExports
{
public CachedHostFile(string path)
{
Stream = new FileStream(
Handle = File.OpenHandle(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 1024 * 1024,
FileOptions.RandomAccess);
Length = RandomAccess.GetLength(Handle);
}
public object Gate { get; } = new();
public FileStream Stream { get; }
public SafeFileHandle Handle { get; }
public long Length { get; }
}
[SysAbiExport(
@@ -339,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",
@@ -508,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)
@@ -735,13 +774,7 @@ public static class AmprExports
return openResult;
}
long fileLength;
lock (cachedFile.Gate)
{
fileLength = cachedFile.Stream.Length;
}
if (fileOffset >= (ulong)fileLength)
if (fileOffset >= (ulong)cachedFile.Length)
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -760,12 +793,10 @@ public static class AmprExports
}
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
int read;
lock (cachedFile.Gate)
{
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
read = cachedFile.Stream.Read(buffer, 0, request);
}
var read = RandomAccess.Read(
cachedFile.Handle,
buffer.AsSpan(0, request),
unchecked((long)absoluteOffset));
if (read <= 0)
{
@@ -121,6 +121,33 @@ public static class AppContentExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Download data is not emulated as a real quota; report a comfortable
// fixed amount of free space so titles never take the "storage full" path.
[SysAbiExport(
Nid = "Gl6w5i0JokY",
ExportName = "sceAppContentDownloadDataGetAvailableSpaceKb",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAppContent")]
public static int AppContentDownloadDataGetAvailableSpaceKb(CpuContext ctx)
{
const ulong availableSpaceKb = 1024UL * 1024UL; // 1 GiB
var availableSpaceAddress = ctx[CpuRegister.Rsi];
if (availableSpaceAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> spaceBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(spaceBytes, availableSpaceKb);
if (!ctx.Memory.TryWrite(availableSpaceAddress, spaceBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryReadUserDefinedParam(uint paramId, out int value)
{
value = 0;
+1 -1
View File
@@ -17,7 +17,7 @@ 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;
+88 -1
View File
@@ -14,6 +14,12 @@ public static class AudioOutExports
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
private static int _nextPortHandle;
// Diagnostic: confirm sceAudioOutOutput is actually called and whether the
// guest submits real samples or silence. Gated so it costs nothing when off.
private static readonly bool _traceOutput = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_OUT"), "1", StringComparison.Ordinal);
private static long _outputCount;
private sealed class PortState : IDisposable
{
private readonly object _paceGate = new();
@@ -155,6 +161,37 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "GrQ9s4IrNaQ",
ExportName = "sceAudioOutGetPortState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAudioOut")]
public static int AudioOutGetPortState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var stateAddress = ctx[CpuRegister.Rsi];
if (stateAddress == 0 || !Ports.TryGetValue(handle, out var port))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// SceAudioOutPortState: report a connected primary output at full volume
// so pacing/mixing code sees a live port. We do no host rerouting, so
// rerouteCounter and flag stay zero.
Span<byte> state = stackalloc byte[16];
state.Clear();
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(state, 1);
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(
state[2..], (ushort)port.Channels);
state[7] = 127;
if (!ctx.Memory.TryWrite(stateAddress, state))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput",
@@ -166,7 +203,12 @@ public static class AudioOutExports
var sourceAddress = ctx[CpuRegister.Rsi];
if (!Ports.TryGetValue(handle, out var port))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
// Host shutdown disposes the ports while guest audio threads are
// still draining their last buffers; report success so the guest
// winds down without a per-buffer error (and its WARN log flood).
return ctx.SetReturn(_shutdown
? 0
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (sourceAddress == 0)
@@ -183,6 +225,17 @@ 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")}");
}
}
if (port.Backend is null)
{
port.PaceSilence();
@@ -266,8 +319,40 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
// Peak normalized amplitude [0,1] of an interleaved PCM buffer, used only by
// the SHARPEMU_LOG_AUDIO_OUT diagnostic to distinguish real audio from silence.
private static float PeakAmplitude(ReadOnlySpan<byte> source, bool isFloat, int bytesPerSample)
{
var peak = 0f;
if (isFloat && bytesPerSample == 4)
{
for (var i = 0; i + 4 <= source.Length; i += 4)
{
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(source.Slice(i, 4)));
if (v > peak)
{
peak = v;
}
}
}
else if (bytesPerSample == 2)
{
for (var i = 0; i + 2 <= source.Length; i += 2)
{
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(source.Slice(i, 2)) / 32768f);
if (v > peak)
{
peak = v;
}
}
}
return peak;
}
public static void ShutdownAllPorts()
{
Volatile.Write(ref _shutdown, true);
foreach (var handle in Ports.Keys)
{
if (Ports.TryRemove(handle, out var port))
@@ -277,6 +362,8 @@ public static class AudioOutExports
}
}
private static bool _shutdown;
private static bool TryGetFormat(
int rawFormat,
out int channels,
@@ -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);
}
}
@@ -0,0 +1,154 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Audio;
// PS5 acoustic-propagation (3D-audio ray/portal/room) module. We do not model
// acoustic propagation; the geometry-driven reverb/occlusion it produces is a
// quality feature, not a correctness gate. Games (e.g. Astro Bot) call it
// during audio init and hard-assert if any entry point is missing:
// ASSERT ... sceAudioPropagationSystemQueryMemory failed : 0x80020002
// The API is placement-style: QueryMemory reports a buffer size, the game
// allocates it, and the "system"/objects live inside that caller-owned buffer,
// so success-returning stubs let init proceed without us owning any state.
public static class AudioPropagationExports
{
private const int Ok = 0;
// QueryMemory reports the working-set size the caller must allocate before
// SystemCreate. rsi points at the out size/alignment; write a modest,
// aligned block so the caller's allocation succeeds.
[SysAbiExport(
Nid = "7xyAxrusLko",
ExportName = "sceAudioPropagationSystemQueryMemory",
Target = Generation.Gen5,
LibraryName = "libSceAudioPropagation")]
public static int SystemQueryMemory(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
if (outAddress != 0)
{
// {size, alignment} — 1 MiB / 256 B covers the caller's allocation.
ctx.TryWriteUInt64(outAddress, 0x10_0000);
ctx.TryWriteUInt64(outAddress + sizeof(ulong), 0x100);
}
return ctx.SetReturn(Ok);
}
[SysAbiExport(Nid = "GrA9ke1QT+E", ExportName = "sceAudioPropagationSystemQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "aNEqtSHdUSo", ExportName = "sceAudioPropagationSystemCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "x5VPqg5iyAk", ExportName = "sceAudioPropagationSystemDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ile38Gl-p5M", ExportName = "sceAudioPropagationSystem", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int System(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "cMl3u+7QBBM", ExportName = "sceAudioPropagationSystemMemoryInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemMemoryInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "3B9IabLByyM", ExportName = "sceAudioPropagationSystemOptionInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemOptionInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "B2KI2AachWE", ExportName = "sceAudioPropagationSystemLock", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemLock(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "kIdb+iQUzCs", ExportName = "sceAudioPropagationSystemSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "VlBT16890mA", ExportName = "sceAudioPropagationSystemSetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemSetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ht-QXT3zGxo", ExportName = "sceAudioPropagationSystemGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "CPLV6G-eXmk", ExportName = "sceAudioPropagationSystemRegisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemRegisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "XKCN4gpeYsM", ExportName = "sceAudioPropagationSystemUnregisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemUnregisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "8bI5h8req30", ExportName = "sceAudioPropagationRoomCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RoomCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "S0JwP2AFTTE", ExportName = "sceAudioPropagationRoomDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RoomDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "b-dYXrjSNZU", ExportName = "sceAudioPropagationPortalCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ZQXE-xS6MTE", ExportName = "sceAudioPropagationPortalDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "WXMhENV2NcA", ExportName = "sceAudioPropagationPortalSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "i687TNRF+hw", ExportName = "sceAudioPropagationPortalSettingsInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalSettingsInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "d84otraxt2s", ExportName = "sceAudioPropagationSourceCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "wkseM3LWPuc", ExportName = "sceAudioPropagationSourceDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "-wsUTr31yeg", ExportName = "sceAudioPropagationSourceSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "PBcrVpEqUVY", ExportName = "sceAudioPropagationSourceCalculateAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceCalculateAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "eEeKqFeNI3o", ExportName = "sceAudioPropagationSourceGetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "G+QLTfyLMYk", ExportName = "sceAudioPropagationSourceGetAudioPathCount", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetAudioPathCount(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "aKJZx7wCma8", ExportName = "sceAudioPropagationSourceGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "3aEY9tPXGKc", ExportName = "sceAudioPropagationSourceQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "hhz9pITnC8k", ExportName = "sceAudioPropagationSourceRender", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceRender(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "SoKPzY1-3SU", ExportName = "sceAudioPropagationSourceRenderInfoInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceRenderInfoInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "tKSmk2JsMAA", ExportName = "sceAudioPropagationSourceSetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "5vzOS2pHMFc", ExportName = "sceAudioPropagationSourceSetAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "MNmGapXrYRs", ExportName = "sceAudioPropagationSourceSetAudioPathsParamInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPathsParamInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "i-0aUex3zCE", ExportName = "sceAudioPropagationAudioPathInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int AudioPathInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "JZIkSbmt2BE", ExportName = "sceAudioPropagationAudioPathPointInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int AudioPathPointInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "tL2AEPejVQE", ExportName = "sceAudioPropagationPathGetNumPoints", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PathGetNumPoints(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "2BSFmuKtRss", ExportName = "sceAudioPropagationMaterialInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int MaterialInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "0r2+9UTg1BA", ExportName = "sceAudioPropagationRayInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RayInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "BbOT4vBwAjs", ExportName = "sceAudioPropagationResetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int ResetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "gCmQm6dvMxw", ExportName = "sceAudioPropagationReportApi", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int ReportApi(CpuContext ctx) => ctx.SetReturn(Ok);
}
+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)
+548 -93
View File
@@ -18,123 +18,156 @@ 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 _frameBufferPresented;
private static BinkFramePlayback? _playback;
private static long _frameSerial;
private static bool _loadAttempted;
private static bool _availabilityReported;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
/// <summary>
/// Returns true when the guest should receive a normal "file not found"
/// result for a Bink movie. This is the safe default without a decoder:
/// games that treat movies as optional fall through to their next state
/// rather than submitting an empty Bink GPU texture forever.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
internal static void ObserveGuestMovie(string hostPath)
internal static bool IsHostPlaybackActive
{
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
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
/// a host adapter the guest must be allowed to run the Bink implementation
/// statically linked into its executable.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
/// <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 false;
}
lock (Gate)
{
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
return;
return _playback is not null || _frameBuffer is not null;
}
if (ResolveMode() == MovieMode.Dummy)
var mode = ResolveMode();
if (mode is MovieMode.Guest or MovieMode.Skip)
{
AttachDummyMovieLocked(hostPath);
return;
return false;
}
var adapter = GetAdapterLocked();
if (adapter is null)
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);
}
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;
}
}
@@ -147,6 +180,63 @@ 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)
{
var adapter = GetAdapterLocked();
if (adapter is null)
{
return;
}
CloseActiveLocked();
if (!adapter.TryOpen(
hostPath,
_presentationWidth,
_presentationHeight,
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;
}
AttachPlaybackLocked(
hostPath,
info,
new NativeFrameDecoder(adapter, movie, info));
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");
@@ -165,16 +255,23 @@ internal static class Bink2MovieBridge
return MovieMode.Skip;
}
// With no SDK adapter present, returning "not found" makes optional
// cinematics advance. Supplying either an explicit path or the normal
// side-by-side adapter enables native playback automatically.
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.Skip;
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Ffmpeg;
}
// Native is the default: the bridge ships embedded in the published
// single-file executable (see SharpEmu.CLI.csproj), so it isn't a
// loose file next to the exe to probe for with File.Exists here.
// GetAdapterLocked() degrades gracefully (falls back to the guest's
// own decode, logging one informational line) if it's genuinely
// unavailable, so defaulting to Native unconditionally is safe.
return MovieMode.Native;
}
private static void AttachDummyMovieLocked(string hostPath)
@@ -191,22 +288,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;
}
@@ -215,10 +351,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;
}
@@ -248,6 +385,26 @@ internal static class Bink2MovieBridge
}
_loadAttempted = true;
// Assembly-relative resolution participates in the single-file
// bundle's native-library extraction, so it finds the bridge whether
// it was embedded in the publish or sits as a loose file next to the
// executable, without us needing to know which. Skipped when the env
// override is set so that override still takes priority below.
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) &&
NativeLibrary.TryLoad(
"sharpemu_bink2_bridge", typeof(Bink2MovieBridge).Assembly, null, out var bundledLibrary))
{
if (NativeAdapter.TryCreate(bundledLibrary, out var bundledAdapter))
{
_adapter = bundledAdapter;
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded (bundled).");
return bundledAdapter;
}
NativeLibrary.Free(bundledLibrary);
}
foreach (var candidate in EnumerateAdapterCandidates())
{
if (!NativeLibrary.TryLoad(candidate, out var library))
@@ -300,20 +457,20 @@ internal static class Bink2MovieBridge
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;
@@ -335,16 +492,72 @@ internal static class Bink2MovieBridge
private enum MovieMode
{
Guest,
Skip,
Dummy,
Native,
Ffmpeg,
}
private sealed class NativeFrameDecoder : IBinkFrameDecoder
{
private readonly NativeAdapter _adapter;
private readonly IntPtr _movie;
private int _disposed;
internal NativeFrameDecoder(NativeAdapter adapter, IntPtr movie, Bink2MovieInfo info)
{
_adapter = adapter;
_movie = movie;
Width = info.Width;
Height = info.Height;
FramesPerSecondNumerator = info.FramesPerSecondNumerator;
FramesPerSecondDenominator = info.FramesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
public unsafe bool TryDecodeNextFrame(Span<byte> destination)
{
fixed (byte* pointer = destination)
{
return _adapter.DecodeNextBgra(
_movie,
(IntPtr)pointer,
Width * 4,
checked((uint)destination.Length));
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
_adapter.Close(_movie);
}
}
}
private sealed class NativeAdapter
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenScaledUtf8Delegate(
IntPtr pathUtf8,
uint maximumWidth,
uint maximumHeight,
out IntPtr movie,
out Bink2MovieInfo info);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
@@ -352,15 +565,18 @@ internal static class Bink2MovieBridge
private delegate void CloseDelegate(IntPtr movie);
private readonly OpenUtf8Delegate _openUtf8;
private readonly OpenScaledUtf8Delegate? _openScaledUtf8;
private readonly DecodeNextBgraDelegate _decodeNextBgra;
private readonly CloseDelegate _close;
private NativeAdapter(
OpenUtf8Delegate openUtf8,
OpenScaledUtf8Delegate? openScaledUtf8,
DecodeNextBgraDelegate decodeNextBgra,
CloseDelegate close)
{
_openUtf8 = openUtf8;
_openScaledUtf8 = openScaledUtf8;
_decodeNextBgra = decodeNextBgra;
_close = close;
}
@@ -375,19 +591,42 @@ internal static class Bink2MovieBridge
return false;
}
OpenScaledUtf8Delegate? openScaled = null;
if (NativeLibrary.TryGetExport(
library,
"sharpemu_bink2_open_scaled_utf8",
out var scaledOpen))
{
openScaled = Marshal.GetDelegateForFunctionPointer<OpenScaledUtf8Delegate>(scaledOpen);
}
adapter = new NativeAdapter(
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
openScaled,
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
return true;
}
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
internal bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out IntPtr movie,
out Bink2MovieInfo info)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
try
{
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
var result = _openScaledUtf8 is not null
? _openScaledUtf8(
utf8,
maximumWidth,
maximumHeight,
out movie,
out info)
: _openUtf8(utf8, out movie, out info);
return result != 0 && movie != IntPtr.Zero;
}
finally
{
@@ -400,4 +639,220 @@ internal static class Bink2MovieBridge
internal void Close(IntPtr movie) => _close(movie);
}
private static readonly Queue<string> PendingMoviePaths = new();
private static readonly HashSet<string> PendingMoviePathSet =
new(StringComparer.OrdinalIgnoreCase);
private static void AttachNextQueuedMovieLocked()
{
while (PendingMoviePaths.Count > 0)
{
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;
}
}
}
// 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)
{
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;
}
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;
}
catch (Exception exception) when (
exception is IOException or EndOfStreamException or OverflowException)
{
return false;
}
}
internal readonly struct BinkGuestCompletionShim
{
private readonly uint _fileSizeMinusHeader;
private readonly uint _largestFrameSize;
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
{
_fileSizeMinusHeader = fileSizeMinusHeader;
_largestFrameSize = largestFrameSize;
}
/// <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;
}
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();
}
}
}
+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",
+140
View File
@@ -0,0 +1,140 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.Gpu;
/// <summary>
/// The pool backing AGC-to-presenter ownership transfers, shared by every backend
/// (the AGC layer rents, the presenter returns, so both sides must use one pool).
/// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size classes
/// thousands of times per second; the process-wide shared pool trims and
/// repartitions those large arrays aggressively under GC load, causing hundreds of
/// MiB/s of replacement byte[] allocations, so this pool is bounded and non-shared.
/// </summary>
internal static class GuestDataPool
{
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
maxArrayLength: 16 * 1024 * 1024,
maxCachedBytes: 256UL * 1024 * 1024,
maxArraysPerBucket: 8);
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
private sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _leases =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private ulong _cachedBytes;
public BoundedByteArrayPool(
int maxArrayLength,
ulong maxCachedBytes,
int maxArraysPerBucket)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
_maxArrayLength = maxArrayLength;
_maxCachedBytes = maxCachedBytes;
_maxArraysPerBucket = maxArraysPerBucket;
}
public override byte[] Rent(int minimumLength)
{
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
var length = GetAllocationLength(minimumLength);
byte[]? array = null;
lock (_gate)
{
if (length <= _maxArrayLength &&
_cachedByBucket.TryGetValue(length, out var bucket) &&
bucket.TryPop(out array))
{
_cachedBytes -= (ulong)array.LongLength;
}
array ??= new byte[length];
_leases.Add(array);
}
return array;
}
public override void Return(byte[] array, bool clearArray = false)
{
ArgumentNullException.ThrowIfNull(array);
lock (_gate)
{
if (!_leases.Remove(array))
{
return;
}
}
if (clearArray)
{
Array.Clear(array);
}
lock (_gate)
{
if (array.Length > _maxArrayLength ||
!IsBucketLength(array.Length) ||
(ulong)array.LongLength > _maxCachedBytes -
Math.Min(_cachedBytes, _maxCachedBytes))
{
return;
}
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
{
bucket = new Stack<byte[]>();
_cachedByBucket.Add(array.Length, bucket);
}
if (bucket.Count >= _maxArraysPerBucket)
{
return;
}
bucket.Push(array);
_cachedBytes += (ulong)array.LongLength;
}
}
public void Trim()
{
lock (_gate)
{
_cachedByBucket.Clear();
_cachedBytes = 0;
}
}
private int GetAllocationLength(int minimumLength)
{
if (minimumLength <= 16)
{
return 16;
}
if (minimumLength > _maxArrayLength)
{
return minimumLength;
}
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
}
private static bool IsBucketLength(int length) =>
length >= 16 && (length & (length - 1)) == 0;
}
}
+31 -2
View File
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Metal;
using SharpEmu.Libs.Gpu.Vulkan;
namespace SharpEmu.Libs.Gpu;
@@ -8,11 +9,39 @@ namespace SharpEmu.Libs.Gpu;
/// <summary>
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the
/// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>.
/// Vulkan is the only backend today; Metal/DX12 slot in here.
/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
/// once the presenter reaches parity.
/// </summary>
internal static class GuestGpu
{
private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create);
public static IGuestGpuBackend Current => Instance.Value;
private static IGuestGpuBackend Create()
{
var requested = Environment.GetEnvironmentVariable("SHARPEMU_GPU_BACKEND");
if (string.IsNullOrEmpty(requested) || requested.Equals("vulkan", StringComparison.OrdinalIgnoreCase))
{
return new VulkanGuestGpuBackend();
}
if (requested.Equals("metal", StringComparison.OrdinalIgnoreCase))
{
if (!OperatingSystem.IsMacOS())
{
Console.Error.WriteLine(
"[LOADER][WARN] SHARPEMU_GPU_BACKEND=metal is only available on macOS; using Vulkan.");
return new VulkanGuestGpuBackend();
}
Console.Error.WriteLine("[LOADER][INFO] GPU backend: Metal (SHARPEMU_GPU_BACKEND).");
return new MetalGuestGpuBackend();
}
Console.Error.WriteLine(
$"[LOADER][WARN] Unknown SHARPEMU_GPU_BACKEND value '{requested}'; using Vulkan.");
return new VulkanGuestGpuBackend();
}
}
+33 -2
View File
@@ -27,7 +27,12 @@ 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);
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
internal readonly record struct GuestSampler(
@@ -36,6 +41,22 @@ internal readonly record struct GuestSampler(
uint Word2,
uint Word3);
/// <summary>Identity of a texture's content in a backend texture cache, keyed
/// entirely on raw guest descriptor values; the AGC layer uses it to skip texel
/// copies for content the backend already holds.</summary>
internal readonly record struct TextureContentIdentity(
ulong Address,
uint Width,
uint Height,
uint Format,
uint NumberType,
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler,
bool Arrayed = false,
uint ArrayLayers = 1);
internal sealed record GuestMemoryBuffer(
ulong BaseAddress,
byte[] Data,
@@ -122,12 +143,22 @@ internal readonly record struct GuestBlendState(
WriteMask: 0xFu);
}
/// <summary>CB_BLEND_RED..ALPHA: the constant color referenced by the
/// CONSTANT_COLOR / CONSTANT_ALPHA blend factors. One constant serves every
/// render target of a draw; the hardware reset value is transparent black.</summary>
internal readonly record struct GuestBlendConstant(
float Red,
float Green,
float Blue,
float Alpha);
internal sealed record GuestRenderState(
IReadOnlyList<GuestBlendState> Blends,
GuestRect? Scissor,
GuestViewport? Viewport,
GuestRasterState Raster,
GuestDepthState Depth)
GuestDepthState Depth,
GuestBlendConstant BlendConstant = default)
{
public static GuestRenderState Default { get; } = new(
[GuestBlendState.Default],
+71
View File
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu;
@@ -17,6 +18,10 @@ namespace SharpEmu.Libs.Gpu;
/// </summary>
internal interface IGuestGpuBackend
{
/// <summary>Human-readable name of this backend ("Metal", "Vulkan"), shown in
/// the window title on macOS where either backend can run.</summary>
string BackendName { get; }
/// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
void EnsureStarted(uint width, uint height);
@@ -188,4 +193,70 @@ internal interface IGuestGpuBackend
/// the guest codes cross the seam and each backend maps them internally.
/// </summary>
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind);
// Guest work ordering. AGC submissions execute on a single backend consumer in
// logical guest-queue order; sequences returned here are backend work tickets.
// A backend without a running presenter returns 0 from the Submit* methods and
// callers fall back to executing inline.
/// <summary>Scopes subsequent submissions on this thread to a named guest queue.</summary>
IDisposable EnterGuestQueue(string queueName, ulong submissionId);
/// <summary>Enqueues an action at its exact position in the current guest queue;
/// returns its work sequence, or 0 when nothing could be enqueued.</summary>
long SubmitOrderedGuestAction(Action action, string debugName);
/// <summary>Preserves sceAgcDcbWaitUntilSafeForRendering in queue order.</summary>
long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex);
/// <summary>Blocks until the given work sequence completes; false on timeout,
/// close, or a non-positive sequence.</summary>
bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite);
/// <summary>Sequence currently executing on the guest-work consumer; diagnostics only.</summary>
long CurrentGuestWorkSequenceForDiagnostics { get; }
// Guest image lifecycle beyond presentation: CPU-visible seeding, writes, and
// extent queries the AGC layer uses to keep guest memory and backend images
// coherent. Addresses and formats are always raw guest values.
/// <summary>Whether the image exists on the backend or an already-queued upload
/// owns its initialization (a pending image may skip a duplicate upload but is
/// not yet a valid flip source).</summary>
bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType);
/// <summary>True when the first draw into this address must seed the backend
/// image from guest memory (PS5 render targets alias guest memory, so
/// CPU-prefilled pixels are visible before the first draw).</summary>
bool GuestImageWantsInitialData(ulong address);
void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels);
void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels);
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
/// <summary>Whether the backend's texture cache already holds this content; lets
/// the AGC layer skip copying texels out of guest memory on every draw.</summary>
bool IsTextureContentCached(in TextureContentIdentity identity);
/// <summary>Guest memory handle for backend self-healing (cache misses re-read
/// texels directly instead of showing a fallback pattern).</summary>
void AttachGuestMemory(ICpuMemory memory);
/// <summary>Alignment the AGC layer must apply to storage-buffer offsets before
/// they cross the seam.</summary>
ulong GuestStorageBufferOffsetAlignment { get; }
/// <summary>Counts a guest shader translation for the perf overlay.</summary>
void CountShaderCompilation();
(long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters();
/// <summary>Asks a running presenter to close its window.</summary>
void RequestClose();
}
@@ -0,0 +1,28 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// The Metal backend's compiled shader: MSL source plus the reflection data
/// (<see cref="Gen5MslShader"/>) the presenter needs to create and bind pipeline
/// states. The diagnostics payload is the source text — Metal has no portable
/// binary form until an MTLBinaryArchive is introduced.
/// </summary>
internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader
{
private byte[]? _payload;
public Gen5MslShader Shader { get; } = shader;
/// <summary>MTLLibrary handle cached by the presenter after the first
/// runtime compile; the render loop is its only reader and writer.</summary>
internal nint CachedLibrary;
public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source);
public string PayloadFileExtension => "msl";
}
@@ -0,0 +1,270 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// MTLPixelFormat raw values — only the formats the backend maps. Declared here
/// rather than pulled from a binding package: the Metal backend talks to the OS
/// exclusively through objc_msgSend, so ABI constants are owned locally.
/// </summary>
internal enum MtlPixelFormat : uint
{
Invalid = 0,
R8Unorm = 10,
R8Snorm = 12,
R8Uint = 13,
R8Sint = 14,
R16Unorm = 20,
R16Snorm = 22,
R16Uint = 23,
R16Sint = 24,
R16Float = 25,
Rg8Unorm = 30,
Rg8Snorm = 32,
Rg8Uint = 33,
Rg8Sint = 34,
B5G6R5Unorm = 40,
R32Uint = 53,
R32Sint = 54,
R32Float = 55,
Rg16Unorm = 60,
Rg16Uint = 63,
Rg16Sint = 64,
Rg16Float = 65,
Rgba8Unorm = 70,
Rgba8UnormSrgb = 71,
Rgba8Uint = 73,
Rgba8Sint = 74,
Bgra8Unorm = 80,
Bgra8UnormSrgb = 81,
Rgb10A2Unorm = 90,
Rg11B10Float = 92,
Rgb9E5Float = 93,
Bgr10A2Unorm = 94,
Rg32Uint = 103,
Rg32Sint = 104,
Rg32Float = 105,
Rgba16Unorm = 110,
Rgba16Uint = 113,
Rgba16Sint = 114,
Rgba16Float = 115,
Rgba32Uint = 123,
Rgba32Sint = 124,
Rgba32Float = 125,
Bc1Rgba = 130,
Bc1RgbaSrgb = 131,
Bc2Rgba = 132,
Bc2RgbaSrgb = 133,
Bc3Rgba = 134,
Bc3RgbaSrgb = 135,
Bc4RUnorm = 140,
Bc4RSnorm = 141,
Bc5RgUnorm = 142,
Bc5RgSnorm = 143,
Bc6HRgbFloat = 150,
Bc6HRgbUfloat = 151,
Bc7RgbaUnorm = 152,
Bc7RgbaUnormSrgb = 153,
Depth32Float = 252,
}
/// <summary>A sampled-texture format: the Metal pixel format plus the byte
/// layout the upload path needs. <see cref="BlockBytes"/> is nonzero for
/// block-compressed formats (bytes per 4x4 block); otherwise
/// <see cref="BytesPerPixel"/> applies.</summary>
internal readonly record struct MetalTextureFormat(
MtlPixelFormat Format,
uint BytesPerPixel,
uint BlockBytes)
{
public bool IsBlockCompressed => BlockBytes != 0;
}
internal readonly record struct MetalRenderTargetFormat(
MtlPixelFormat Format,
Gen5PixelOutputKind OutputKind)
{
public static uint GetBytesPerPixel(MtlPixelFormat format) =>
format switch
{
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Uint => 1,
MtlPixelFormat.Rg8Unorm => 2,
MtlPixelFormat.Rg32Float => 8,
MtlPixelFormat.Rgba16Unorm or MtlPixelFormat.Rgba16Uint or
MtlPixelFormat.Rgba16Sint or MtlPixelFormat.Rgba16Float => 8,
MtlPixelFormat.Rgba32Float => 16,
_ => 4,
};
}
/// <summary>
/// Guest texture-descriptor codes to Metal formats, mirroring the Vulkan
/// backend's table case for case so both backends accept the same guest
/// formats. Guest format 9 (2:10:10:10) maps to BGR10A2 — the bit layout that
/// matches Vulkan's A2R10G10B10 pack.
/// </summary>
internal static class MetalGuestFormats
{
/// <summary>Guest sampled-texture format to Metal, mirroring the Vulkan
/// backend's GetTextureFormat case for case (including its RGBA8 fallback
/// for unmapped codes, so unknown formats render something rather than
/// nothing). BC formats upload raw blocks — Mac-family GPUs decode them
/// natively.</summary>
public static MetalTextureFormat DecodeTextureFormat(uint dataFormat, uint numberType)
{
var format = (dataFormat, numberType) switch
{
(1, 0) => MtlPixelFormat.R8Unorm,
(1, 1) => MtlPixelFormat.R8Snorm,
(1, 4) => MtlPixelFormat.R8Uint,
(1, 5) => MtlPixelFormat.R8Sint,
(2, 0) => MtlPixelFormat.R16Unorm,
(2, 1) => MtlPixelFormat.R16Snorm,
(2, 4) => MtlPixelFormat.R16Uint,
(2, 5) => MtlPixelFormat.R16Sint,
(2, 7) => MtlPixelFormat.R16Float,
(3, 0) => MtlPixelFormat.Rg8Unorm,
(3, 1) => MtlPixelFormat.Rg8Snorm,
(3, 4) => MtlPixelFormat.Rg8Uint,
(3, 5) => MtlPixelFormat.Rg8Sint,
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
(5, 0) => MtlPixelFormat.Rg16Unorm,
(5, 4) => MtlPixelFormat.Rg16Uint,
(5, 5) => MtlPixelFormat.Rg16Sint,
(5, 7) => MtlPixelFormat.Rg16Float,
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
(8, _) or (9, _) => MtlPixelFormat.Bgr10A2Unorm,
(10, 4) => MtlPixelFormat.Rgba8Uint,
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(11, 4) => MtlPixelFormat.Rg32Uint,
(11, 5) => MtlPixelFormat.Rg32Sint,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 0) => MtlPixelFormat.Rgba16Unorm,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
(12, 7) => MtlPixelFormat.Rgba16Float,
(13, 4) or (14, 4) => MtlPixelFormat.Rgba32Uint,
(13, 5) or (14, 5) => MtlPixelFormat.Rgba32Sint,
(13, _) or (14, _) => MtlPixelFormat.Rgba32Float,
(16, 0) => MtlPixelFormat.B5G6R5Unorm,
(34, 7) => MtlPixelFormat.Rgb9E5Float,
(169, _) => MtlPixelFormat.Bc1Rgba,
(170, _) => MtlPixelFormat.Bc1RgbaSrgb,
(171, _) => MtlPixelFormat.Bc2Rgba,
(172, _) => MtlPixelFormat.Bc2RgbaSrgb,
(173, _) => MtlPixelFormat.Bc3Rgba,
(174, _) => MtlPixelFormat.Bc3RgbaSrgb,
(175, 1) or (176, _) => MtlPixelFormat.Bc4RSnorm,
(175, _) => MtlPixelFormat.Bc4RUnorm,
(177, 1) or (178, _) => MtlPixelFormat.Bc5RgSnorm,
(177, _) => MtlPixelFormat.Bc5RgUnorm,
(179, _) => MtlPixelFormat.Bc6HRgbUfloat,
(180, _) => MtlPixelFormat.Bc6HRgbFloat,
(181, _) => MtlPixelFormat.Bc7RgbaUnorm,
(182, _) => MtlPixelFormat.Bc7RgbaUnormSrgb,
_ => MtlPixelFormat.Rgba8Unorm,
};
var blockBytes = format switch
{
MtlPixelFormat.Bc1Rgba or MtlPixelFormat.Bc1RgbaSrgb or
MtlPixelFormat.Bc4RUnorm or MtlPixelFormat.Bc4RSnorm => 8u,
MtlPixelFormat.Bc2Rgba or MtlPixelFormat.Bc2RgbaSrgb or
MtlPixelFormat.Bc3Rgba or MtlPixelFormat.Bc3RgbaSrgb or
MtlPixelFormat.Bc5RgUnorm or MtlPixelFormat.Bc5RgSnorm or
MtlPixelFormat.Bc6HRgbFloat or MtlPixelFormat.Bc6HRgbUfloat or
MtlPixelFormat.Bc7RgbaUnorm or MtlPixelFormat.Bc7RgbaUnormSrgb => 16u,
_ => 0u,
};
var bytesPerPixel = format switch
{
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Snorm or
MtlPixelFormat.R8Uint or MtlPixelFormat.R8Sint => 1u,
MtlPixelFormat.R16Unorm or MtlPixelFormat.R16Snorm or
MtlPixelFormat.R16Uint or MtlPixelFormat.R16Sint or
MtlPixelFormat.R16Float or MtlPixelFormat.Rg8Unorm or
MtlPixelFormat.Rg8Snorm or MtlPixelFormat.Rg8Uint or
MtlPixelFormat.Rg8Sint or MtlPixelFormat.B5G6R5Unorm => 2u,
MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rg32Sint or
MtlPixelFormat.Rg32Float or MtlPixelFormat.Rgba16Unorm or
MtlPixelFormat.Rgba16Uint or MtlPixelFormat.Rgba16Sint or
MtlPixelFormat.Rgba16Float => 8u,
MtlPixelFormat.Rgba32Uint or MtlPixelFormat.Rgba32Sint or
MtlPixelFormat.Rgba32Float => 16u,
_ => 4u,
};
return new MetalTextureFormat(format, bytesPerPixel, blockBytes);
}
/// <summary>Source byte footprint of a sampled texture, block-aware —
/// the same math the AGC layer uses to size the texel copy it ships.</summary>
public static ulong GetTextureByteCount(in MetalTextureFormat format, uint width, uint height) =>
format.IsBlockCompressed
? checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * format.BlockBytes)
: checked((ulong)width * height * format.BytesPerPixel);
public static bool TryDecodeRenderTargetFormat(
uint dataFormat,
uint numberType,
out MetalRenderTargetFormat result)
{
var format = (dataFormat, numberType) switch
{
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
(5, 4) => MtlPixelFormat.Rg16Uint,
(5, 5) => MtlPixelFormat.Rg16Sint,
(5, 7) => MtlPixelFormat.Rg16Float,
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
(9, _) => MtlPixelFormat.Bgr10A2Unorm,
(10, 4) => MtlPixelFormat.Rgba8Uint,
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(10, _) => MtlPixelFormat.Rgba8Unorm,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
(12, 7) => MtlPixelFormat.Rgba16Float,
(13, 7) or (14, 7) => MtlPixelFormat.Rgba32Float,
(20, 0) => MtlPixelFormat.R32Uint,
(29, 0) or (4, 0) => MtlPixelFormat.R32Float,
(1, 0) or (36, 0) => MtlPixelFormat.R8Unorm,
(49, 0) => MtlPixelFormat.R8Uint,
(3, 0) => MtlPixelFormat.Rg8Unorm,
(5, 0) => MtlPixelFormat.Rg16Unorm,
(7, 0) => MtlPixelFormat.Rg11B10Float,
(12, 0) => MtlPixelFormat.Rgba16Unorm,
(13, 0) or (14, 0) => MtlPixelFormat.Rgba32Float,
(22, 0) or (71, 0) => MtlPixelFormat.Rgba16Float,
(56, 0) or (62, 0) or (64, 0) => MtlPixelFormat.Rgba8Unorm,
(75, 0) => MtlPixelFormat.Rg32Float,
_ => MtlPixelFormat.Invalid,
};
if (format == MtlPixelFormat.Invalid)
{
result = default;
return false;
}
var outputKind = format switch
{
MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new MetalRenderTargetFormat(format, outputKind);
return true;
}
}
@@ -0,0 +1,426 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Metal backend for the guest-GPU seam: MSL codegen via
/// SharpEmu.ShaderCompiler.Metal, rendering via the Metal presenter — the full
/// surface (presentation, guest images, ordered flips, translated draws, and
/// compute) with no Vulkan, MoltenVK, or windowing-library dependency.
/// </summary>
internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
{
public string BackendName => "Metal";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new MetalCompiledGuestShader(new Gen5MslShader(
MslFixedShaders.CreateDepthOnlyFragment(),
"depth_only_fs",
Gen5MslStage.Pixel,
[],
[],
AttributeCount: 0,
[]));
public bool TryCompileVertexShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
out IGuestCompiledShader? shader,
out string error,
int globalBufferBase = 0,
int totalGlobalBufferCount = -1,
int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1,
int requiredVertexOutputCount = 0,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
if (!Gen5MslTranslator.TryCompileVertexShader(
state,
evaluation,
out var compiled,
out error,
globalBufferBase,
totalGlobalBufferCount,
imageBindingBase,
scalarRegisterBufferIndex,
requiredVertexOutputCount,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public bool TryCompilePixelShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
IReadOnlyList<Gen5PixelOutputBinding> outputs,
out IGuestCompiledShader? shader,
out string error,
int globalBufferBase = 0,
int totalGlobalBufferCount = -1,
int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
if (!Gen5MslTranslator.TryCompilePixelShader(
state,
evaluation,
outputs,
out var compiled,
out error,
globalBufferBase,
totalGlobalBufferCount,
imageBindingBase,
scalarRegisterBufferIndex,
pixelInputEnable,
pixelInputAddress,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public bool TryCompileComputeShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
out IGuestCompiledShader? shader,
out string error,
int totalGlobalBufferCount = -1,
int initialScalarBufferIndex = -1,
uint waveLaneCount = 32,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
// Wave64 compute is emulated by the translator: cross-lane ops bridge
// the two 32-wide Apple simdgroups of a guest wave through threadgroup
// scratch, and wave-agnostic kernels run per-thread unchanged.
if (!Gen5MslTranslator.TryCompileComputeShader(
state,
evaluation,
localSizeX,
localSizeY,
localSizeZ,
out var compiled,
out error,
totalGlobalBufferCount,
initialScalarBufferIndex,
waveLaneCount,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public IGuestCompiledShader GetDepthOnlyFragmentShader() =>
DepthOnlyFragmentShader;
public bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind)
{
if (MetalGuestFormats.TryDecodeRenderTargetFormat(dataFormat, numberType, out var format))
{
outputKind = format.OutputKind;
return true;
}
outputKind = default;
return false;
}
public void EnsureStarted(uint width, uint height) =>
MetalVideoPresenter.EnsureStarted(width, height);
public void HideSplashScreen() =>
MetalVideoPresenter.HideSplashScreen();
public void Submit(byte[] bgraFrame, uint width, uint height) =>
MetalVideoPresenter.Submit(bgraFrame, width, height);
public bool TrySubmitGuestImage(
ulong address,
uint width,
uint height,
uint pitchInPixel) =>
MetalVideoPresenter.TrySubmitGuestImage(address, width, height, pitchInPixel);
public bool TrySubmitOrderedGuestImageFlip(
int videoOutHandle,
int displayBufferIndex,
ulong address,
uint width,
uint height,
uint pitchInPixel) =>
MetalVideoPresenter.TrySubmitOrderedGuestImageFlip(
videoOutHandle,
displayBufferIndex,
address,
width,
height,
pitchInPixel);
public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) =>
MetalVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat);
public bool IsGpuGuestImageAvailable(ulong address, uint format, uint numberType) =>
MetalVideoPresenter.IsGuestImageAvailable(address, format, numberType);
public bool TrySubmitGuestImageBlit(
ulong sourceAddress,
uint sourceWidth,
uint sourceHeight,
uint sourceFormat,
uint sourceNumberType,
ulong destinationAddress,
uint destinationWidth,
uint destinationHeight,
uint destinationFormat,
uint destinationNumberType) =>
MetalVideoPresenter.TrySubmitGuestImageBlit(
sourceAddress,
sourceWidth,
sourceHeight,
sourceFormat,
sourceNumberType,
destinationAddress,
destinationWidth,
destinationHeight,
destinationFormat,
destinationNumberType);
public void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) =>
MetalVideoPresenter.SubmitGuestDraw(drawKind, width, height);
public void SubmitTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint width,
uint height,
uint attributeCount,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null) =>
MetalVideoPresenter.SubmitTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
width,
height,
attributeCount,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState);
public void SubmitDepthOnlyTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
GuestDepthTarget depthTarget,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
depthTarget,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
shaderAddress);
public void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
IReadOnlyList<GuestRenderTarget> targets,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
targets,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
depthTarget,
shaderAddress);
public void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
uint width,
uint height,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitStorageTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
width,
height,
shaderAddress);
private static MetalCompiledGuestShader Msl(IGuestCompiledShader shader) =>
shader as MetalCompiledGuestShader ??
throw new InvalidOperationException(
$"shader handle of type {shader.GetType().Name} was not compiled by the Metal backend");
public long SubmitComputeDispatch(
ulong shaderAddress,
IGuestCompiledShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
bool isIndirect,
bool writesGlobalMemory,
uint threadCountX = uint.MaxValue,
uint threadCountY = uint.MaxValue,
uint threadCountZ = uint.MaxValue)
{
// The translated kernel bakes its threadgroup size; localSize and
// isIndirect are already folded in by the AGC layer before submission.
_ = localSizeX;
_ = localSizeY;
_ = localSizeZ;
_ = isIndirect;
return MetalVideoPresenter.SubmitComputeDispatch(
shaderAddress,
Msl(computeShader),
textures,
globalMemoryBuffers,
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
writesGlobalMemory,
threadCountX,
threadCountY,
threadCountZ);
}
private long _perfShaderCompilations;
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
MetalVideoPresenter.EnterGuestQueue(queueName, submissionId);
public long SubmitOrderedGuestAction(Action action, string debugName) =>
MetalVideoPresenter.SubmitOrderedGuestAction(action, debugName);
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
MetalVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
MetalVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
public long CurrentGuestWorkSequenceForDiagnostics =>
MetalVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
MetalVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
public bool GuestImageWantsInitialData(ulong address) =>
MetalVideoPresenter.GuestImageWantsInitialData(address);
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
MetalVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
MetalVideoPresenter.GetGuestImageExtents();
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
MetalVideoPresenter.IsTextureContentCached(identity);
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
MetalVideoPresenter.AttachGuestMemory(memory);
// Over-alignment is always valid, and 256 covers every Metal buffer-offset
// requirement (Intel Macs need 256 for constant buffers; Apple GPUs less).
public ulong GuestStorageBufferOffsetAlignment => 256;
public void CountShaderCompilation() =>
Interlocked.Increment(ref _perfShaderCompilations);
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters()
{
var (draws, drawMs, pipelines) = MetalVideoPresenter.ReadAndResetDrawPerfCounters();
return (draws, drawMs, pipelines, Interlocked.Exchange(ref _perfShaderCompilations, 0));
}
public void RequestClose() =>
MetalVideoPresenter.RequestClose();
}
@@ -0,0 +1,182 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
/// host input seam so pad emulation works like the Vulkan presenter's
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
/// Window gamepads are not surfaced by AppKit — controller support would go
/// through GameController.framework and is out of scope here.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
public static void Attach()
{
_connected = true;
PosixHostInput.SetSource(new MetalWindowInputSource());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
// macOS key code at each elapsed-seconds mark for a few frames, letting
// headless test runs navigate menus without a human at the keyboard.
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
System.Diagnostics.Stopwatch.StartNew();
private static List<(double, ushort, bool[])> ParseAutoKeys()
{
var keys = new List<(double, ushort, bool[])>();
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
if (string.IsNullOrWhiteSpace(spec))
{
return keys;
}
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(':');
if (parts.Length == 2 &&
double.TryParse(parts[0], out var at) &&
TryParseKeyCode(parts[1], out var key))
{
keys.Add((at, key, new bool[2]));
}
}
return keys;
}
private static bool TryParseKeyCode(string text, out ushort key)
{
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
: ushort.TryParse(text, out key);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
public static void PumpAutoKeys()
{
if (_autoKeys.Count == 0)
{
return;
}
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
foreach (var (at, key, state) in _autoKeys)
{
if (!state[0] && elapsed >= at)
{
state[0] = true;
KeyDown(key, isRepeat: false);
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
}
else if (state[0] && !state[1] && elapsed >= at + 0.2)
{
state[1] = true;
KeyUp(key);
}
}
}
public static void KeyDown(ushort keyCode, bool isRepeat)
{
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
if (keyCode == 0x7A && !isRepeat)
{
VideoOut.PerfOverlay.Toggle();
}
lock (Gate)
{
Pressed.Add(keyCode);
}
}
public static void KeyUp(ushort keyCode)
{
lock (Gate)
{
Pressed.Remove(keyCode);
}
}
private static bool IsKeyCodeDown(ushort keyCode)
{
lock (Gate)
{
return Pressed.Contains(keyCode);
}
}
private sealed class MetalWindowInputSource : IPosixWindowInputSource
{
public bool HasKeyboardFocus => _connected;
public bool IsKeyDown(int virtualKey) =>
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
{
keyCode = vk switch
{
0x08 => 0x33, // Backspace -> kVK_Delete
0x09 => 0x30, // Tab
0x0D => 0x24, // Enter -> kVK_Return
0x1B => 0x35, // Escape
0x20 => 0x31, // Space
0x25 => 0x7B, // Left
0x26 => 0x7E, // Up
0x27 => 0x7C, // Right
0x28 => 0x7D, // Down
// Letters: macOS ANSI key codes are layout-position based and
// non-contiguous, so map each polled letter explicitly.
0x41 => 0x00, // A
0x42 => 0x0B, // B
0x43 => 0x08, // C
0x44 => 0x02, // D
0x45 => 0x0E, // E
0x46 => 0x03, // F
0x47 => 0x05, // G
0x48 => 0x04, // H
0x49 => 0x22, // I
0x4A => 0x26, // J
0x4B => 0x28, // K
0x4C => 0x25, // L
0x4D => 0x2E, // M
0x4E => 0x2D, // N
0x4F => 0x1F, // O
0x50 => 0x23, // P
0x51 => 0x0C, // Q
0x52 => 0x0F, // R
0x53 => 0x01, // S
0x54 => 0x11, // T
0x55 => 0x20, // U
0x56 => 0x09, // V
0x57 => 0x0D, // W
0x58 => 0x07, // X
0x59 => 0x10, // Y
0x5A => 0x06, // Z
_ => ushort.MaxValue,
};
return keyCode != ushort.MaxValue;
}
}
+430
View File
@@ -0,0 +1,430 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
// them entirely keeps one calling convention everywhere.
[StructLayout(LayoutKind.Sequential)]
internal struct CGRect
{
public double X;
public double Y;
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlClearColor
{
public double Red;
public double Green;
public double Blue;
public double Alpha;
}
/// <summary>MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output
/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5).</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MtlTextureSwizzleChannels
{
public byte Red;
public byte Green;
public byte Blue;
public byte Alpha;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlRegion
{
public nuint X;
public nuint Y;
public nuint Z;
public nuint Width;
public nuint Height;
public nuint Depth;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlSize
{
public nuint Width;
public nuint Height;
public nuint Depth;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlOrigin
{
public nuint X;
public nuint Y;
public nuint Z;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlScissorRect
{
public nuint X;
public nuint Y;
public nuint Width;
public nuint Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlViewport
{
public double OriginX;
public double OriginY;
public double Width;
public double Height;
public double ZNear;
public double ZFar;
}
/// <summary>
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
/// </summary>
internal static partial class MetalNative
{
private const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
[LibraryImport(CoreFoundation)]
public static partial nint CFRunLoopGetMain();
[LibraryImport(CoreFoundation)]
public static partial void CFRunLoopStop(nint runLoop);
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
/// <summary>
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary>
public static void EnsureFrameworksLoaded()
{
if (_frameworksLoaded)
{
return;
}
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
[LibraryImport(MetalFramework)]
public static partial nint MTLCreateSystemDefaultDevice();
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint objc_getClass(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
[LibraryImport(ObjCLibrary)]
public static partial void objc_registerClassPair(nint cls);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
[LibraryImport(ObjCLibrary)]
public static partial void objc_autoreleasePoolPop(nint pool);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector);
/// <summary>objc_msgSend for -gpuResourceID. MTLResourceID is a one-field
/// 8-byte struct, returned in a register on the x86-64 ABI, so it maps to a
/// ulong return — the value written into a Tier 2 argument buffer slot.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial ulong SendGpuResourceId(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
/// pointer to caller storage passed ahead of self/_cmd — so this must not
/// be folded into the plain objc_msgSend overloads.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendAtIndex(nint receiver, nint selector, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool SendBool(nint receiver, nint selector);
/// <summary>One-argument BOOL sends, e.g. respondsToSelector:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool SendBool(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial double SendDouble(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument);
/// <summary>Two-object-argument void sends, e.g. setObject:forKey:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSwizzle(
nint receiver,
nint selector,
MtlTextureSwizzleChannels channels);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidDouble(nint receiver, nint selector, double argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidBlendColor(
nint receiver,
nint selector,
float red,
float green,
float blue,
float alpha);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidViewport(nint receiver, nint selector, MtlViewport viewport);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendSetAtIndex(nint receiver, nint selector, nint value, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidCopyTexture(nint receiver, nint selector, nint source, nint destination);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNewBuffer(nint receiver, nint selector, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendCopyTextureToBuffer(
nint receiver,
nint selector,
nint sourceTexture,
nuint sourceSlice,
nuint sourceLevel,
MtlOrigin sourceOrigin,
MtlSize sourceSize,
nint destinationBuffer,
nuint destinationOffset,
nuint destinationBytesPerRow,
nuint destinationBytesPerImage);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendCopyBufferToTexture(
nint receiver,
nint selector,
nint sourceBuffer,
nuint sourceOffset,
nuint sourceBytesPerRow,
nuint sourceBytesPerImage,
MtlSize sourceSize,
nint destinationTexture,
nuint destinationSlice,
nuint destinationLevel,
MtlOrigin destinationOrigin);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDispatch(
nint receiver,
nint selector,
MtlSize threadgroups,
MtlSize threadsPerThreadgroup);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidScissor(nint receiver, nint selector, MtlScissorRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawPrimitivesInstanced(
nint receiver,
nint selector,
nuint primitiveType,
nuint vertexStart,
nuint vertexCount,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawIndexedPrimitives(
nint receiver,
nint selector,
nuint primitiveType,
nuint indexCount,
nuint indexType,
nint indexBuffer,
nuint indexBufferOffset,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTimer(
nint receiver,
nint selector,
double interval,
nint target,
nint timerSelector,
nint userInfo,
[MarshalAs(UnmanagedType.I1)] bool repeats);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitWindow(
nint receiver,
nint selector,
CGRect contentRect,
nuint styleMask,
nuint backing,
[MarshalAs(UnmanagedType.I1)] bool defer);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNextEvent(
nint receiver,
nint selector,
ulong eventMask,
nint untilDate,
nint inMode,
[MarshalAs(UnmanagedType.I1)] bool dequeue);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
nint selector,
nuint pixelFormat,
nuint width,
nuint height,
[MarshalAs(UnmanagedType.I1)] bool mipmapped);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendReplaceRegion(
nint receiver,
nint selector,
MtlRegion region,
nuint mipmapLevel,
nint bytes,
nuint bytesPerRow);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawPrimitives(
nint receiver,
nint selector,
nuint primitiveType,
nuint vertexStart,
nuint vertexCount);
public static nint Class(string name) => objc_getClass(name);
public static nint Selector(string name) => sel_registerName(name);
/// <summary>Autoreleased NSString — only valid inside an autorelease pool
/// unless the caller retains it.</summary>
public static nint NsString(string value)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
try
{
return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
/// <summary>Reads an NSString's UTF-8 contents, or null if the handle is nil.</summary>
public static string? ReadNsString(nint nsString)
{
if (nsString == 0)
{
return null;
}
var utf8 = Send(nsString, Selector("UTF8String"));
return utf8 == 0 ? null : Marshal.PtrToStringUTF8(utf8);
}
public static string DescribeError(nint error)
{
if (error == 0)
{
return "unknown error";
}
var description = Send(error, Selector("localizedDescription"));
var utf8 = Send(description, Selector("UTF8String"));
return Marshal.PtrToStringUTF8(utf8) ?? "unknown error";
}
}
@@ -0,0 +1,50 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Guest draws and compute dispatches batch into one command buffer per drain
// instead of one per work item, mirroring the Vulkan presenter's batched guest
// commands: commit overhead dominated CPU time for scenes with dozens of draws
// per frame. Ordering inside the batch is by encoder sequence (snapshot blits
// for a draw's feedback reads are encoded before its render pass opens), and
// everything that must observe batched work on the serial queue — flips, image
// writes/blits, CPU-visible write-backs, the present pass — flushes first.
internal static partial class MetalVideoPresenter
{
private static nint _batchCommandBuffer;
private static bool _batchOpen;
/// <summary>Returns the open batch command buffer, opening one on first
/// use. Render thread only, like the drain it serves.</summary>
private static nint BeginBatchedGuestCommands(nint queue)
{
if (_batchOpen)
{
return _batchCommandBuffer;
}
_batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
_batchOpen = _batchCommandBuffer != 0;
return _batchCommandBuffer;
}
/// <summary>Commits the open batch (if any), tagging the upload pages and
/// snapshot resources it consumed. Returns the committed command buffer so
/// write-back sites can wait on it, or 0 when nothing was open.</summary>
private static nint FlushBatchedGuestCommands()
{
if (!_batchOpen)
{
return 0;
}
_batchOpen = false;
var commandBuffer = _batchCommandBuffer;
_batchCommandBuffer = 0;
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
TagUploadPages(commandBuffer);
TagSnapshotResources(commandBuffer);
return commandBuffer;
}
}
@@ -0,0 +1,424 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Guest compute dispatches: ordered guest work like draws, with two contracts to
// honor. Storage images are shared live through the guest-image registry so a
// dispatch's writes are visible to later draws, blits, and flips of the same
// address; and CPU-visible buffer writes land back in guest memory before the
// work item completes, which is the ordering point WaitForGuestWork promises.
internal static partial class MetalVideoPresenter
{
private static readonly bool _skipAllCompute =
Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1";
private static bool _tracedDispatchBase;
private sealed record ComputeGuestDispatch(
ulong ShaderAddress,
MetalCompiledGuestShader Shader,
GuestDrawTexture[] Textures,
GuestMemoryBuffer[] GlobalMemoryBuffers,
uint GroupCountX,
uint GroupCountY,
uint GroupCountZ,
uint BaseGroupX,
uint BaseGroupY,
uint BaseGroupZ,
uint ThreadCountX,
uint ThreadCountY,
uint ThreadCountZ);
private static readonly Dictionary<MetalCompiledGuestShader, nint> _computePipelineCache = new();
public static long SubmitComputeDispatch(
ulong shaderAddress,
MetalCompiledGuestShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
bool writesGlobalMemory,
uint threadCountX,
uint threadCountY,
uint threadCountZ)
{
var hasStorage = false;
foreach (var texture in textures)
{
hasStorage |= texture.IsStorage;
}
if (groupCountX == 0 ||
groupCountY == 0 ||
groupCountZ == 0 ||
(!hasStorage && !writesGlobalMemory))
{
return 0;
}
lock (_gate)
{
if (_closed || _thread is null)
{
return 0;
}
// Storage images a dispatch writes become flip sources and sampled
// inputs for later work, exactly like published render targets.
foreach (var texture in textures)
{
if (!texture.IsStorage || texture.Address == 0)
{
continue;
}
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
if (guestFormat != 0)
{
_availableGuestImages[texture.Address] = guestFormat;
}
}
var sequence = EnqueueGuestWorkLocked(
new ComputeGuestDispatch(
shaderAddress,
computeShader,
ToArray(textures),
ToArray(globalMemoryBuffers),
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
threadCountX,
threadCountY,
threadCountZ));
foreach (var texture in textures)
{
if (texture.IsStorage && texture.Address != 0)
{
_guestImageWorkSequences[texture.Address] = sequence;
}
}
return sequence;
}
}
private static void ExecuteComputeDispatch(nint device, nint queue, ComputeGuestDispatch dispatch)
{
if (_skipAllCompute)
{
ReturnPooledComputeData(dispatch);
return;
}
VideoOut.PerfOverlay.RecordDraw();
if ((dispatch.BaseGroupX | dispatch.BaseGroupY | dispatch.BaseGroupZ) != 0 &&
!_tracedDispatchBase)
{
// Metal has no dispatch-base; the translated kernel derives its ids
// from the raw grid position, so a nonzero base computes offset-zero
// work until base support lands in the emitted kernel.
_tracedDispatchBase = true;
Console.Error.WriteLine(
"[LOADER][WARN] Metal compute dispatch with nonzero base group " +
$"({dispatch.BaseGroupX},{dispatch.BaseGroupY},{dispatch.BaseGroupZ}); " +
"executing without the base offset.");
}
if (!TryGetComputePipeline(device, dispatch.Shader, out var pipeline))
{
ReturnPooledComputeData(dispatch);
return;
}
var commandBuffer = BeginBatchedGuestCommands(queue);
// Pre-resolve textures before the compute encoder opens: snapshot
// blits for feedback reads encode into the batch and encoder order
// must place them ahead of this dispatch.
Span<nint> textureHandles = stackalloc nint[dispatch.Textures.Length];
Span<bool> textureOwned = stackalloc bool[dispatch.Textures.Length];
for (var index = 0; index < dispatch.Textures.Length; index++)
{
var descriptor = dispatch.Textures[index];
if (descriptor.IsStorage && descriptor.Address != 0)
{
textureHandles[index] = EnsureStorageImage(device, descriptor)?.Texture ?? 0;
textureOwned[index] = false;
}
else
{
textureHandles[index] = CreateDrawTexture(
device, commandBuffer, descriptor, out var ownedTexture);
textureOwned[index] = ownedTexture;
}
}
var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder"));
MetalNative.SendVoid(encoder, MetalNative.Selector("setComputePipelineState:"), pipeline);
var writeBackBuffers = new List<(nint Pointer, GuestMemoryBuffer Guest)>();
var selSetBuffer = MetalNative.Selector("setBuffer:offset:atIndex:");
var bufferCount = dispatch.GlobalMemoryBuffers.Length;
Span<uint> boundBytes = stackalloc uint[Math.Max(bufferCount, 1)];
for (var index = 0; index < bufferCount; index++)
{
var guest = dispatch.GlobalMemoryBuffers[index];
var pointer = UploadGlobalBuffer(
device, guest, out var buffer, out var offset, out boundBytes[index]);
MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)index);
if (guest.Writable && guest.WriteBackToGuest)
{
writeBackBuffers.Add((pointer, guest));
}
}
// SharpEmuUniforms: the dispatch limit clamps the overshoot threads of the
// last threadgroup row, then each bound buffer's byte length follows
// (including the alignment-bias prefix the shader indexes past).
var shader = dispatch.Shader.Shader;
var uniforms = AllocateUpload(
device,
16 + (Math.Max(bufferCount, 1) * sizeof(uint)),
out var uniformsBuffer,
out var uniformsOffset);
WriteDispatchLimit(uniforms, 0, dispatch.ThreadCountX, dispatch.GroupCountX, shader.ThreadgroupSizeX);
WriteDispatchLimit(uniforms, 4, dispatch.ThreadCountY, dispatch.GroupCountY, shader.ThreadgroupSizeY);
WriteDispatchLimit(uniforms, 8, dispatch.ThreadCountZ, dispatch.GroupCountZ, shader.ThreadgroupSizeZ);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0);
for (var index = 0; index < bufferCount; index++)
{
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
uniforms[(16 + (index * sizeof(uint)))..],
boundBytes[index]);
}
// Bind at the stage's declared SharpEmuUniforms slot (see the draw path:
// stages compute their own index from globalBufferBase + total count).
var uniformsIndex = shader.UniformsBufferIndex;
MetalNative.SendSetBuffer(
encoder,
selSetBuffer,
uniformsBuffer,
(nuint)uniformsOffset,
(nuint)(uniformsIndex >= 0 ? uniformsIndex : bufferCount));
var selSetTexture = MetalNative.Selector("setTexture:atIndex:");
for (var index = 0; index < dispatch.Textures.Length; index++)
{
var texture = textureHandles[index];
if (texture != 0)
{
MetalNative.SendSetAtIndex(encoder, selSetTexture, texture, (nuint)index);
if (textureOwned[index])
{
MetalNative.SendVoid(texture, MetalNative.Selector("release"));
}
}
}
// Samplers travel in an argument buffer bound at setBuffer (see the draw
// path), sidestepping Metal's 16-sampler-per-stage cap.
BindSamplerArgumentBuffer(device, encoder, selSetBuffer, dispatch.Shader, dispatch.Textures);
MetalNative.SendDispatch(
encoder,
MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"),
new MtlSize
{
Width = dispatch.GroupCountX,
Height = dispatch.GroupCountY,
Depth = dispatch.GroupCountZ,
},
new MtlSize
{
Width = Math.Max(shader.ThreadgroupSizeX, 1),
Height = Math.Max(shader.ThreadgroupSizeY, 1),
Depth = Math.Max(shader.ThreadgroupSizeZ, 1),
});
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
// CPU-visible writes are ordering points (see the draw path): flush
// the batch and wait so the write-back lands before this work item
// completes. Pure-GPU dispatches stay in the open batch.
if (writeBackBuffers.Count > 0)
{
var committed = FlushBatchedGuestCommands();
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
WriteBuffersBackToGuest(writeBackBuffers);
}
foreach (var descriptor in dispatch.Textures)
{
if (!descriptor.IsStorage || descriptor.Address == 0)
{
continue;
}
GuestImage? image;
lock (_gate)
{
_guestImages.TryGetValue(descriptor.Address, out image);
}
if (image is not null)
{
image.MarkContentChanged();
}
}
ReturnPooledComputeData(dispatch);
}
/// <summary>The live, shared storage image for a guest address: dispatches,
/// draws, blits, and flips of the same address all see one texture.</summary>
private static GuestImage? EnsureStorageImage(nint device, GuestDrawTexture descriptor)
{
lock (_gate)
{
if (_guestImages.TryGetValue(descriptor.Address, out var existing))
{
return existing;
}
}
if (descriptor.Width == 0 || descriptor.Height == 0 ||
descriptor.Width > 16384 || descriptor.Height > 16384)
{
return null;
}
var format = MetalGuestFormats.TryDecodeRenderTargetFormat(
descriptor.Format, descriptor.NumberType, out var decoded)
? decoded.Format
: MtlPixelFormat.Rgba8Unorm;
var textureDescriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
(nuint)format,
descriptor.Width,
descriptor.Height,
mipmapped: false);
MetalNative.Send(
textureDescriptor,
MetalNative.Selector("setUsage:"),
(nint)(UsageShaderRead | UsageShaderWrite | UsageRenderTarget));
var image = new GuestImage
{
Texture = MetalNative.Send(
device, MetalNative.Selector("newTextureWithDescriptor:"), textureDescriptor),
Width = descriptor.Width,
Height = descriptor.Height,
Format = format,
};
if (image.Texture == 0)
{
return null;
}
var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format);
// Snapshot copies arrive in the image's native texel layout; only
// 4-byte texels can be RGBA8 verbatim, wider ones carry native bytes.
if ((ulong)descriptor.RgbaPixels.Length >= (ulong)descriptor.Width * bytesPerPixel)
{
var pitch = descriptor.Pitch != 0
? Math.Max(descriptor.Pitch, descriptor.Width)
: descriptor.Width;
ReplaceTextureContents(
image.Texture, descriptor.Width, descriptor.Height, descriptor.RgbaPixels, pitch, bytesPerPixel);
image.MarkContentChanged();
}
lock (_gate)
{
if (_guestImages.TryGetValue(descriptor.Address, out var raced))
{
MetalNative.SendVoid(image.Texture, MetalNative.Selector("release"));
return raced;
}
_guestImages[descriptor.Address] = image;
_guestImageExtents[descriptor.Address] =
(descriptor.Width, descriptor.Height, (ulong)descriptor.Width * descriptor.Height * bytesPerPixel);
}
return image;
}
private static bool TryGetComputePipeline(nint device, MetalCompiledGuestShader shader, out nint pipeline)
{
lock (_computePipelineCache)
{
if (_computePipelineCache.TryGetValue(shader, out pipeline))
{
return pipeline != 0;
}
}
var function = GetShaderFunction(device, shader);
if (function != 0)
{
nint error = 0;
pipeline = MetalNative.Send(
device,
MetalNative.Selector("newComputePipelineStateWithFunction:error:"),
function,
ref error);
if (pipeline == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Metal compute pipeline creation failed: {MetalNative.DescribeError(error)}");
}
else
{
Interlocked.Increment(ref _perfPipelineCreations);
}
}
else
{
pipeline = 0;
}
lock (_computePipelineCache)
{
_computePipelineCache[shader] = pipeline;
}
return pipeline != 0;
}
private static void WriteDispatchLimit(
Span<byte> uniforms,
int offset,
uint threadCount,
uint groupCount,
uint threadgroupSize)
{
var limit = threadCount != uint.MaxValue
? threadCount
: groupCount * Math.Max(threadgroupSize, 1);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
uniforms[offset..],
limit);
}
private static void ReturnPooledComputeData(ComputeGuestDispatch dispatch)
{
foreach (var buffer in dispatch.GlobalMemoryBuffers)
{
if (buffer.Pooled)
{
GuestDataPool.Shared.Return(buffer.Data);
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Feedback reads (draws sampling a live guest render target or depth image)
// need a fresh ordered snapshot per draw. Creating and destroying an MTLTexture
// — and for depth reads a private staging MTLBuffer — per draw is measurable
// CPU and allocator churn at hundreds of feedback draws per second, so both
// recycle through a pool with the same lifecycle as the upload arena pages:
// acquired snapshots are tagged with the command buffer that samples them at
// commit, and return to the free list once that command buffer completes (the
// command queue is serial, so the earlier snapshot-blit command buffer is
// necessarily complete by then too). Everything here runs on the render thread.
internal static partial class MetalVideoPresenter
{
private const int MaxFreeSnapshotResources = 16;
private sealed class PooledSnapshotResource
{
public nint Handle;
public bool IsBuffer;
/// <summary>Texture identity (unused for buffers).</summary>
public uint Format;
public uint Width;
public uint Height;
public nint Usage;
/// <summary>Buffer capacity in bytes (unused for textures).</summary>
public nuint Capacity;
/// <summary>Retained handle of the command buffer that samples this
/// snapshot; the resource is reusable once it completes.</summary>
public nint LastCommandBuffer;
}
private static readonly List<PooledSnapshotResource> _retiredSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _pendingSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _freeSnapshotResources = [];
/// <summary>Returns completed snapshot resources to the free list; called
/// once per render-loop drain, next to the upload-page recycler.</summary>
private static void RecycleCompletedSnapshotResources()
{
for (var index = _retiredSnapshotResources.Count - 1; index >= 0; index--)
{
var resource = _retiredSnapshotResources[index];
if (resource.LastCommandBuffer != 0)
{
// MTLCommandBufferStatus: Completed = 4, Error = 5.
var status = MetalNative.Send(
resource.LastCommandBuffer, MetalNative.Selector("status"));
if (status < 4)
{
continue;
}
MetalNative.SendVoid(resource.LastCommandBuffer, MetalNative.Selector("release"));
resource.LastCommandBuffer = 0;
}
_retiredSnapshotResources.RemoveAt(index);
if (_freeSnapshotResources.Count < MaxFreeSnapshotResources)
{
_freeSnapshotResources.Add(resource);
}
else
{
MetalNative.SendVoid(resource.Handle, MetalNative.Selector("release"));
}
}
}
/// <summary>Pops a pooled snapshot texture matching the exact identity, or
/// creates one. The returned handle is owned by the pool — callers must not
/// release it, and it must be tagged at the next commit.</summary>
private static nint AcquireSnapshotTexture(
nint device,
MtlPixelFormat format,
uint width,
uint height,
nint usage)
{
for (var index = 0; index < _freeSnapshotResources.Count; index++)
{
var candidate = _freeSnapshotResources[index];
if (!candidate.IsBuffer &&
candidate.Format == (uint)format &&
candidate.Width == width &&
candidate.Height == height &&
candidate.Usage == usage)
{
_freeSnapshotResources.RemoveAt(index);
_pendingSnapshotResources.Add(candidate);
return candidate.Handle;
}
}
var descriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
(nuint)format,
width,
height,
mipmapped: false);
MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), usage);
var handle = MetalNative.Send(
device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
if (handle == 0)
{
return 0;
}
_pendingSnapshotResources.Add(new PooledSnapshotResource
{
Handle = handle,
Format = (uint)format,
Width = width,
Height = height,
Usage = usage,
});
return handle;
}
/// <summary>Pops a pooled private-storage staging buffer of at least
/// <paramref name="minimumBytes"/>, or creates one. Pool-owned like
/// <see cref="AcquireSnapshotTexture"/>.</summary>
private static nint AcquireSnapshotBuffer(nint device, nuint minimumBytes)
{
for (var index = 0; index < _freeSnapshotResources.Count; index++)
{
var candidate = _freeSnapshotResources[index];
if (candidate.IsBuffer && candidate.Capacity >= minimumBytes)
{
_freeSnapshotResources.RemoveAt(index);
_pendingSnapshotResources.Add(candidate);
return candidate.Handle;
}
}
// MTLResourceStorageModePrivate = 32: staging never touches the CPU.
var handle = MetalNative.SendNewBuffer(
device, MetalNative.Selector("newBufferWithLength:options:"), minimumBytes, 32);
if (handle == 0)
{
return 0;
}
_pendingSnapshotResources.Add(new PooledSnapshotResource
{
Handle = handle,
IsBuffer = true,
Capacity = minimumBytes,
});
return handle;
}
/// <summary>Marks every snapshot resource acquired since the previous tag
/// as owing its lifetime to <paramref name="commandBuffer"/>. Called at the
/// same commit sites as <see cref="TagUploadPages"/>; a resource acquired
/// for a draw that never committed is tagged by the next commit, which is
/// conservative but safe.</summary>
private static void TagSnapshotResources(nint commandBuffer)
{
if (_pendingSnapshotResources.Count == 0)
{
return;
}
foreach (var resource in _pendingSnapshotResources)
{
resource.LastCommandBuffer = MetalNative.Send(
commandBuffer, MetalNative.Selector("retain"));
_retiredSnapshotResources.Add(resource);
}
_pendingSnapshotResources.Clear();
}
}
@@ -0,0 +1,179 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Gpu.Metal;
// Draw textures decoded from guest memory are cached across draws keyed by
// their full descriptor identity, mirroring the Vulkan presenter's texture
// cache: once an identity is marked cached, the AGC submit thread skips the
// guest-memory read/detile/copy entirely (shipping empty texels) and the
// render thread serves the cached MTLTexture — for scenes that sample large
// textures every draw, that per-draw copy dominated both allocation churn
// and CPU time. GuestImageWriteTracker write-protects the source pages, so
// a guest CPU write dirties the address and the entry is evicted at the next
// drain; the following draw ships fresh texels and re-populates the cache.
internal static partial class MetalVideoPresenter
{
private const int MaxCachedDrawTextures = 2048;
/// <summary>Render-thread-only cache of decoded draw textures; each value
/// holds one retain. Committed command buffers retain the textures they
/// reference, so eviction releases immediately without a GPU drain.</summary>
private static readonly Dictionary<TextureContentIdentity, nint> _drawTextureCache = new();
/// <summary>Identities the AGC submit thread may skip texel copies for.
/// Read from the submit thread, written by the render thread.</summary>
private static readonly ConcurrentDictionary<TextureContentIdentity, byte> _cachedDrawTextureIdentities = new();
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
_cachedDrawTextureIdentities.ContainsKey(identity);
/// <summary>Builds the same identity the AGC layer checks before skipping
/// a texel copy; the two must agree field-for-field or skips and cache
/// entries would never line up.</summary>
private static TextureContentIdentity GetDrawTextureIdentity(GuestDrawTexture texture) => new(
texture.Address,
texture.Width,
texture.Height,
texture.Format,
texture.NumberType,
texture.DstSelect,
texture.TileMode,
texture.Pitch,
texture.Sampler);
/// <summary>Caching requires the write tracker: without page protection a
/// guest CPU write would never evict the entry and draws would sample
/// stale texels forever. Storage textures are shader-writable on the GPU,
/// so their content identity is not stable either.</summary>
private static bool IsCacheableDrawTexture(GuestDrawTexture texture) =>
GuestImageWriteTracker.Enabled &&
texture.Address != 0 &&
!texture.IsStorage &&
!texture.IsFallback;
private static bool TryGetCachedDrawTexture(GuestDrawTexture texture, out nint handle) =>
_drawTextureCache.TryGetValue(GetDrawTextureIdentity(texture), out handle);
private static void CacheDrawTexture(GuestDrawTexture texture, nint handle)
{
var key = GetDrawTextureIdentity(texture);
if (_drawTextureCache.Remove(key, out var previous))
{
MetalNative.SendVoid(previous, MetalNative.Selector("release"));
}
_ = MetalNative.Send(handle, MetalNative.Selector("retain"));
_drawTextureCache[key] = handle;
_cachedDrawTextureIdentities[key] = 0;
GuestImageWriteTracker.Track(
texture.Address,
(ulong)texture.RgbaPixels.Length,
Volatile.Read(ref _executingGuestWorkSequence),
"metal.texture-cache");
}
/// <summary>Runs once per drain, before any queued draw executes: a draw
/// whose texels the submit thread skipped must never resolve to an entry
/// the guest has since rewritten.</summary>
private static void EvictDirtyCachedDrawTextures()
{
if (_drawTextureCache.Count == 0)
{
return;
}
// Evict by address rather than by identity: several identities can
// share one source address (same texels, different samplers), and
// ConsumeDirty clears the flag on first read — evicting only the
// first identity would leave the others sampling stale texels.
HashSet<ulong>? dirtyAddresses = null;
foreach (var entry in _drawTextureCache)
{
if (dirtyAddresses is not null && dirtyAddresses.Contains(entry.Key.Address))
{
continue;
}
if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
{
(dirtyAddresses ??= []).Add(entry.Key.Address);
}
}
if (dirtyAddresses is null && _drawTextureCache.Count <= MaxCachedDrawTextures)
{
return;
}
if (_drawTextureCache.Count > MaxCachedDrawTextures)
{
foreach (var entry in _drawTextureCache)
{
MetalNative.SendVoid(entry.Value, MetalNative.Selector("release"));
}
_drawTextureCache.Clear();
_cachedDrawTextureIdentities.Clear();
return;
}
List<TextureContentIdentity>? evicted = null;
foreach (var entry in _drawTextureCache)
{
if (dirtyAddresses!.Contains(entry.Key.Address))
{
(evicted ??= []).Add(entry.Key);
}
}
if (evicted is not null)
{
foreach (var key in evicted)
{
if (_drawTextureCache.Remove(key, out var handle))
{
_cachedDrawTextureIdentities.TryRemove(key, out _);
MetalNative.SendVoid(handle, MetalNative.Selector("release"));
}
}
}
foreach (var address in dirtyAddresses!)
{
GuestImageWriteTracker.Rearm(address);
}
}
/// <summary>Self-heal for the skip/eviction race: the submit thread saw a
/// cached identity and skipped the copy, but the entry was evicted before
/// this draw executed. Read the texels directly rather than rendering a
/// fallback texture for the frame, sized with the same block-aware math
/// the draw path expects.</summary>
private static byte[]? TryReadGuestDrawTexturePixels(GuestDrawTexture texture)
{
var memory = _guestMemory;
if (memory is null || texture.Address == 0)
{
return null;
}
var width = Math.Max(texture.Width, 1u);
var height = Math.Max(texture.Height, 1u);
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, width)
: width;
var format = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType);
var byteCount = MetalGuestFormats.GetTextureByteCount(format, rowLength, height);
if (byteCount == 0 || byteCount > int.MaxValue)
{
return null;
}
var pixels = new byte[(int)byteCount];
return memory.TryRead(texture.Address, pixels) ? pixels : null;
}
}
@@ -0,0 +1,162 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Per-draw upload data (guest global buffers, uniforms, vertex and index
// bytes) bump-allocates from shared-storage arena pages bound by offset,
// instead of creating one MTLBuffer and one managed copy per binding per
// draw — which dominated allocation churn (hundreds of MB/s) and held the
// guest flip rate well under the display rate. Pages recycle once the last
// command buffer that referenced them reports completion; everything here
// runs on the render thread, so no state is locked.
internal static partial class MetalVideoPresenter
{
private const int UploadPageBytes = 8 * 1024 * 1024;
// Superset of every Metal bind-offset alignment rule (constant address
// space on Intel Macs is the strictest at 256), and conveniently the
// guest storage-buffer alignment the shader bias contract assumes.
private const int UploadAlignment = 256;
private sealed class UploadPage
{
public nint Buffer;
public nint Contents;
public int Capacity;
public int Offset;
/// <summary>Retained handle of the last command buffer that consumed
/// data from this page; the page is reusable once it completes.</summary>
public nint LastCommandBuffer;
/// <summary>Stamp of the last TagUploadPages call that saw this page,
/// so a commit only re-tags pages it actually touched.</summary>
public int TouchStamp;
}
private static readonly List<UploadPage> _retiredUploadPages = [];
private static readonly Stack<UploadPage> _freeUploadPages = new();
private static readonly List<UploadPage> _touchedUploadPages = [];
private static UploadPage? _currentUploadPage;
private static int _uploadTouchStamp;
/// <summary>Returns completed pages to the free stack. Called once per
/// render-loop drain; completion is polled (command buffer status) rather
/// than block-based so the ObjC interop stays block-free.</summary>
private static void RecycleCompletedUploadPages()
{
for (var index = _retiredUploadPages.Count - 1; index >= 0; index--)
{
var page = _retiredUploadPages[index];
if (page.LastCommandBuffer != 0)
{
// MTLCommandBufferStatus: Completed = 4, Error = 5.
var status = MetalNative.Send(
page.LastCommandBuffer, MetalNative.Selector("status"));
if (status < 4)
{
continue;
}
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
page.LastCommandBuffer = 0;
}
_retiredUploadPages.RemoveAt(index);
if (page.Capacity == UploadPageBytes)
{
page.Offset = 0;
_freeUploadPages.Push(page);
}
else
{
// Oversized one-off allocation; not worth pooling.
MetalNative.SendVoid(page.Buffer, MetalNative.Selector("release"));
}
}
}
/// <summary>Bump-allocates an aligned slice for CPU-written upload data.
/// The returned span is the slice's shared-storage memory; bind the
/// buffer at the returned offset.</summary>
private static unsafe Span<byte> AllocateUpload(
nint device,
int length,
out nint buffer,
out int offset)
{
var page = _currentUploadPage;
var aligned = page is null
? 0
: (page.Offset + UploadAlignment - 1) & ~(UploadAlignment - 1);
if (page is null || aligned + length > page.Capacity)
{
if (page is not null)
{
_retiredUploadPages.Add(page);
}
page = AcquireUploadPage(device, length);
_currentUploadPage = page;
aligned = 0;
}
if (page.TouchStamp != _uploadTouchStamp)
{
page.TouchStamp = _uploadTouchStamp;
_touchedUploadPages.Add(page);
}
buffer = page.Buffer;
offset = aligned;
page.Offset = aligned + length;
return new Span<byte>((void*)(page.Contents + aligned), length);
}
private static UploadPage AcquireUploadPage(nint device, int minimumBytes)
{
if (minimumBytes <= UploadPageBytes && _freeUploadPages.Count > 0)
{
return _freeUploadPages.Pop();
}
var capacity = Math.Max(minimumBytes, UploadPageBytes);
// Options 0 = MTLResourceStorageModeShared: CPU writes are coherent
// and write-backs read the GPU's stores after waitUntilCompleted.
var handle = MetalNative.SendNewBuffer(
device, MetalNative.Selector("newBufferWithLength:options:"), (nuint)capacity, 0);
return new UploadPage
{
Buffer = handle,
Contents = MetalNative.Send(handle, MetalNative.Selector("contents")),
Capacity = capacity,
};
}
/// <summary>Marks every page touched since the previous tag as owing its
/// lifetime to <paramref name="commandBuffer"/>. Called after each commit
/// that consumed arena data.</summary>
private static void TagUploadPages(nint commandBuffer)
{
if (_touchedUploadPages.Count == 0)
{
_uploadTouchStamp++;
return;
}
foreach (var page in _touchedUploadPages)
{
if (page.LastCommandBuffer != 0)
{
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
}
page.LastCommandBuffer = MetalNative.Send(
commandBuffer, MetalNative.Selector("retain"));
}
_touchedUploadPages.Clear();
_uploadTouchStamp++;
}
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
/// </summary>
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
{
public string BackendName => "Vulkan";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
@@ -343,6 +345,60 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
return false;
}
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
VulkanVideoPresenter.EnterGuestQueue(queueName, submissionId);
public long SubmitOrderedGuestAction(Action action, string debugName) =>
VulkanVideoPresenter.SubmitOrderedGuestAction(action, debugName);
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
VulkanVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
VulkanVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
public long CurrentGuestWorkSequenceForDiagnostics =>
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
VulkanVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
public bool GuestImageWantsInitialData(ulong address) =>
VulkanVideoPresenter.GuestImageWantsInitialData(address);
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
VulkanVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
VulkanVideoPresenter.GetGuestImageExtents();
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
VulkanVideoPresenter.IsTextureContentCached(identity);
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
VulkanVideoPresenter.AttachGuestMemory(memory);
public ulong GuestStorageBufferOffsetAlignment =>
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment;
public void CountShaderCompilation() =>
VulkanVideoPresenter.CountSpirvCompilation();
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() =>
VulkanVideoPresenter.ReadAndResetPerfCounters();
public void RequestClose() =>
VulkanVideoPresenter.RequestClose();
private static byte[] Spirv(IGuestCompiledShader shader) =>
shader is VulkanCompiledGuestShader vulkanShader
? vulkanShader.Spirv
+11
View File
@@ -119,6 +119,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;
}
@@ -94,7 +163,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 +211,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 +650,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 +724,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 +756,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 +823,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 +855,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;
@@ -696,10 +874,17 @@ public static class KernelPthreadCompatExports
}
}
if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
// pthread_mutex_trylock succeeds whenever the mutex is not currently
// held; unlike the blocking lock it does not queue behind waiters
// (POSIX gives it no fairness obligation). Gating trylock on an empty
// wait queue is wrong and, worse, lets a single stale/undrainable
// 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) &&
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;
}
@@ -711,6 +896,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 &&
@@ -744,8 +937,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)
{
@@ -763,16 +977,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);
@@ -1232,8 +1459,22 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState)
lock (mutexState.SyncRoot)
{
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
{
// The guest holds the mutex through a path our host-side tracking
// never observed — most commonly libkernel's uncontended userspace
// fast-path, which locks the mutex word directly without an HLE
// call. Real pthread_cond_wait requires the caller to own the
// mutex and does not verify it for normal mutexes, so returning
// EPERM here is wrong: it spins the guest and, worse, leaves the
// 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.TryAcquireOwner(currentThreadId);
}
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
return mutexState.OwnerThreadId == currentThreadId
@@ -1385,6 +1626,32 @@ public static class KernelPthreadCompatExports
bool cooperative,
string? wakeKey = null)
{
// A guest thread can have at most one pending acquisition on a mutex —
// it is either running or blocked on exactly one wait. If a waiter for
// this thread is still queued when it comes back for a fresh
// acquisition, that entry is a stale leftover the thread abandoned
// (most often a cond_timedwait timeout whose re-acquire hand-off was
// lost). Stale entries clog the FIFO head with waiters no thread is
// blocked on, so the unlock hand-off wakes a dead wake-key and the
// mutex wedges permanently (observed deadlocking Hades: several
// re-acquire waiters from one thread piled ahead of a live locker).
// Prune any prior entry for this thread before enqueueing the new one.
if (threadId != 0)
{
for (var node = state.Waiters.First; node is not null;)
{
var next = node.Next;
if (node.Value.ThreadId == threadId)
{
state.Waiters.Remove(node);
state.WaiterRemovedLocked();
node.Value.Node = null;
}
node = next;
}
}
var waiter = new PthreadMutexWaiter
{
ThreadId = threadId,
@@ -1392,8 +1659,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;
}
@@ -1403,7 +1672,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);
@@ -1447,26 +1716,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(
@@ -1477,7 +1792,7 @@ public static class KernelPthreadCompatExports
PthreadMutexWaiter waiter)
{
var granted = false;
lock (state)
lock (state.SyncRoot)
{
granted = TryGrantMutexWaiterLocked(state, waiter);
}
@@ -1511,6 +1826,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,
@@ -1526,7 +1845,7 @@ public static class KernelPthreadCompatExports
waiter.TimeoutTimer?.Dispose();
waiter.TimeoutTimer = null;
lock (waiter.MutexState)
lock (waiter.MutexState.SyncRoot)
{
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
waiter.MutexState,
@@ -1574,7 +1893,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);
}
@@ -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;
}
}

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