[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.
This commit is contained in:
Gutemberg Ribeiro
2026-07-18 18:32:00 +01:00
committed by GitHub
parent 6dda6589d0
commit 94153955b0
77 changed files with 17245 additions and 392 deletions
+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>
+7
View File
@@ -253,6 +253,7 @@
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler.Metal": "[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, )",
@@ -270,6 +271,12 @@
"SharpEmu.HLE": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.metal": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.vulkan": {
"type": "Project",
"dependencies": {
@@ -2541,28 +2541,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))
{
@@ -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;
}
}
@@ -873,6 +873,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
+40 -5
View File
@@ -51,9 +51,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";
@@ -266,6 +290,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 +346,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++)
@@ -458,7 +493,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)
+307 -65
View File
@@ -14,6 +14,12 @@ namespace SharpEmu.Libs.Agc;
public static partial class AgcExports
{
// The backend is a process-fixed singleton, so its offset-alignment
// requirement is snapshot once: several per-draw paths (shader-key
// hashing, buffer-offset alignment) read it in loops.
private static readonly ulong _storageBufferOffsetAlignment =
GuestGpu.Current.GuestStorageBufferOffsetAlignment;
#if DEBUG
static AgcExports()
{
@@ -2666,7 +2672,7 @@ public static partial class AgcExports
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
}
VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate)
{
@@ -2718,7 +2724,7 @@ public static partial class AgcExports
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
}
VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate)
{
@@ -2946,7 +2952,7 @@ public static partial class AgcExports
// guest-memory writes have finished. Put the notification on that same
// logical graphics queue instead of approximating completion with a
// timer, which can wake Unity while its upload data is still stale.
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
if (GuestGpu.Current.SubmitOrderedGuestAction(
TriggerCompletionEvents,
$"agc submit completion {submissionId}") == 0)
{
@@ -2970,11 +2976,11 @@ public static partial class AgcExports
return false;
}
using var guestQueueScope = VulkanVideoPresenter.EnterGuestQueue(
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
state.QueueName,
state.ActiveSubmissionId);
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
var rented = VulkanVideoPresenter.GuestDataPool.Rent(windowByteCount);
var rented = GuestDataPool.Shared.Rent(windowByteCount);
try
{
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
@@ -2996,7 +3002,7 @@ public static partial class AgcExports
{
_dcbWindowBuffer = null;
_dcbWindowByteLength = 0;
VulkanVideoPresenter.GuestDataPool.Return(rented);
GuestDataPool.Shared.Return(rented);
}
}
@@ -3298,17 +3304,33 @@ public static partial class AgcExports
indexed: false);
}
if ((op is ItDispatchDirect or ItDispatchIndirect) &&
TryReadComputeDispatch(
ctx,
state,
currentAddress,
length,
op,
out var dispatch))
if (op is ItDispatchDirect or ItDispatchIndirect)
{
state.FrameDispatchCount++;
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
if (TryReadComputeDispatch(
ctx,
state,
currentAddress,
length,
op,
out var dispatch,
out var indirectDimsRetryAddress))
{
state.FrameDispatchCount++;
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
}
else if (indirectDimsRetryAddress != 0 &&
HandleSubmittedIndirectDimsWait(
ctx,
state,
commandAddress,
currentAddress,
offset,
dwordCount,
indirectDimsRetryAddress,
tracePackets))
{
return true; // suspend until the producer computes the dims
}
}
if (op == ItNop &&
@@ -3317,7 +3339,7 @@ public static partial class AgcExports
TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) &&
TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex))
{
var waitSequence = VulkanVideoPresenter.SubmitOrderedGuestFlipWait(
var waitSequence = GuestGpu.Current.SubmitOrderedGuestFlipWait(
unchecked((int)waitVideoOutHandle),
unchecked((int)waitDisplayBufferIndex));
TraceAgcShader(
@@ -3677,7 +3699,7 @@ public static partial class AgcExports
// wake another queue before that mirror is visible. Queue a
// second same-queue ordered action after all immediate follow-up
// writes; it fences those writes before publishing the producer.
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
if (GuestGpu.Current.SubmitOrderedGuestAction(
CompleteAndWake,
$"{debugName} completion") == 0)
{
@@ -3685,7 +3707,7 @@ public static partial class AgcExports
}
}
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
if (GuestGpu.Current.SubmitOrderedGuestAction(
ApplyAndQueueCompletion,
debugName) == 0)
{
@@ -3895,11 +3917,11 @@ public static partial class AgcExports
TraceAgc(
$"agc.acquire_mem_applied queue={queueName} " +
$"submission={submissionId} packet=0x{packetAddress:X16} " +
$"work_sequence={VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics}");
$"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}");
}
}
var sequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
ApplyAcquire,
debugName);
if (sequence == 0)
@@ -4045,7 +4067,7 @@ public static partial class AgcExports
return;
}
foreach (var (address, width, height, byteCount) in VulkanVideoPresenter.GetGuestImageExtents())
foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents())
{
if (scopeByteCount != ulong.MaxValue &&
!RangesOverlap(address, byteCount, scopeAddress, scopeByteCount))
@@ -4066,7 +4088,7 @@ public static partial class AgcExports
var pixels = new byte[byteCount];
if (ctx.Memory.TryRead(address, pixels))
{
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
GuestGpu.Current.SubmitGuestImageWrite(address, pixels);
if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64)
{
Console.Error.WriteLine(
@@ -4109,7 +4131,7 @@ public static partial class AgcExports
ulong byteCount,
uint? fillValue)
{
var hasImage = VulkanVideoPresenter.TryGetGuestImageExtent(
var hasImage = GuestGpu.Current.TryGetGuestImageExtent(
destinationAddress,
out var width,
out var height,
@@ -4133,14 +4155,14 @@ public static partial class AgcExports
if (fillValue is { } fill)
{
VulkanVideoPresenter.SubmitGuestImageFill(destinationAddress, fill);
GuestGpu.Current.SubmitGuestImageFill(destinationAddress, fill);
return;
}
var pixels = new byte[imageBytes];
if (ctx.Memory.TryRead(destinationAddress, pixels))
{
VulkanVideoPresenter.SubmitGuestImageWrite(destinationAddress, pixels);
GuestGpu.Current.SubmitGuestImageWrite(destinationAddress, pixels);
}
}
@@ -4528,6 +4550,17 @@ public static partial class AgcExports
? fallbackMs
: 0L) * System.Diagnostics.Stopwatch.Frequency / 1000L;
// How long a suspended GPU wait may sit before the deadlock breaker may
// release it using the last value a real producer wrote to its label. Long
// enough that legitimate GPU work (which completes within a frame) never
// trips it; short enough that a wedged cross-queue cycle unblocks quickly.
private static readonly long _gpuDeadlockBreakTicks =
(long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_GPU_DEADLOCK_BREAK_MS"),
out var deadlockMs) && deadlockMs > 0
? deadlockMs
: 500L) * System.Diagnostics.Stopwatch.Frequency / 1000L;
// Reads the WAIT_REG_MEM watched address, reference, mask, and 3-bit compare
// function for both the AGC NOP-encapsulated (RWaitMem32/64) and the standard
// ItWaitRegMem packet layouts.
@@ -4592,6 +4625,85 @@ public static partial class AgcExports
// Returns true when the DCB should suspend parsing at this wait (its
// continuation was registered into GpuWaitRegistry); false to keep parsing
// (already satisfied, unreadable, or legacy force-satisfy mode).
// How long an indirect dispatch may wait for its producing dispatch to write
// non-zero dimensions before we give up and drop it (matching the pre-existing
// reject behavior). The producer runs on the render thread within a frame or
// two; this only bounds the pathological/legitimately-empty case.
private const long IndirectDimsRetryBudgetMs = 150;
private static readonly object _indirectDimsGate = new();
// Keys (memory, packetAddress) whose retry deadline elapsed. Added by
// DrainResumableDcbs when it resumes an expired retry, consumed by the very
// next re-parse of that packet so it drops instead of re-suspending. Never
// persists across frames — a fresh submit of the same packet retries anew.
private static readonly HashSet<(object, ulong)> _indirectDimsExpired = new();
// Suspends an indirect-dispatch DCB until the guest buffer holding its
// thread-group dimensions becomes non-zero (written by a prior GPU dispatch),
// then re-parses the dispatch. Returns false — so the caller drops the work —
// when the dims already expired once (genuinely empty dispatch).
private static bool HandleSubmittedIndirectDimsWait(
CpuContext ctx,
SubmittedDcbState state,
ulong commandAddress,
ulong packetAddress,
uint offset,
uint dwordCount,
ulong dimsAddress,
bool tracePacket)
{
if (!_gpuWaitSuspendEnabled ||
dimsAddress == 0 ||
dimsAddress % sizeof(uint) != 0)
{
return false;
}
var key = (ctx.Memory, packetAddress);
lock (_indirectDimsGate)
{
// This is the re-parse right after the deadline elapsed: drop the
// dispatch instead of suspending again.
if (_indirectDimsExpired.Remove(key))
{
return false;
}
}
var waiter = new GpuWaitRegistry.WaitingDcb
{
CommandBufferAddress = commandAddress,
ResumeAddress = packetAddress, // re-parse this dispatch packet
ResumeOffset = offset,
TotalDwords = dwordCount,
WaitAddress = dimsAddress,
ReferenceValue = 0,
Mask = 0xFFFFFFFF,
CompareFunction = 4, // NOT_EQUAL: dims became available
Is64Bit = false,
IsStandard = false,
Memory = ctx.Memory,
QueueName = state.QueueName,
SubmissionId = state.ActiveSubmissionId,
RegisteredTicks = System.Diagnostics.Stopwatch.GetTimestamp(),
RetryDeadlineTicks = System.Diagnostics.Stopwatch.GetTimestamp() +
(IndirectDimsRetryBudgetMs * System.Diagnostics.Stopwatch.Frequency / 1000L),
State = state,
};
GpuWaitRegistry.Register(dimsAddress, waiter);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
EnsureGpuWaitMonitor(ctx, gpuState);
if (tracePacket)
{
TraceAgc(
$"agc.dispatch_indirect_wait dims=0x{dimsAddress:X16} " +
$"packet=0x{packetAddress:X16} queue={state.QueueName}");
}
return true;
}
private static bool HandleSubmittedWaitRegMem(
CpuContext ctx,
SubmittedDcbState state,
@@ -4857,7 +4969,50 @@ public static partial class AgcExports
? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null
: TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null);
if (woken is null)
// Indirect-dispatch dimension retries whose deadline elapsed are
// resumed so they drop instead of stalling. Flag each so its immediate
// re-parse drops the dispatch rather than suspending again.
var expiredRetries = GpuWaitRegistry.CollectExpiredRetries(
ctx.Memory, System.Diagnostics.Stopwatch.GetTimestamp());
if (expiredRetries is not null)
{
lock (_indirectDimsGate)
{
foreach (var retry in expiredRetries)
{
_indirectDimsExpired.Add((ctx.Memory, retry.ResumeAddress));
}
}
foreach (var retry in expiredRetries)
{
ResumeSuspendedDcb(ctx, gpuState, retry, tracePackets);
}
}
// Break cross-queue deadlocks: a waiter stuck past the deadline whose
// label a real producer already signalled (but guest memory has since
// been reset for reuse) is released using that produced value. Only
// fires for genuinely wedged waits, so fast-resolving ones on working
// titles are untouched.
var deadlockBroken = GpuWaitRegistry.CollectDeadlockBroken(
ctx.Memory, System.Diagnostics.Stopwatch.GetTimestamp(), _gpuDeadlockBreakTicks);
if (deadlockBroken is not null)
{
foreach (var waiter in deadlockBroken)
{
if (tracePackets)
{
TraceAgc(
$"agc.deadlock_break label=0x{waiter.WaitAddress:X16} " +
$"queue={waiter.QueueName} submission={waiter.SubmissionId}");
}
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
}
}
if (woken is null && expiredRetries is null && deadlockBroken is null)
{
if (_gpuWaitStaleTicks > 0 &&
GpuWaitRegistry.CollectUnreportedStale(
@@ -4887,9 +5042,12 @@ public static partial class AgcExports
return;
}
foreach (var waiter in woken)
if (woken is not null)
{
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
foreach (var waiter in woken)
{
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
}
}
}
}
@@ -5033,6 +5191,15 @@ public static partial class AgcExports
_ => false,
});
// Record + latch the written value so a same-frame label reset
// cannot lose the wakeup, and so the deadlock breaker can release
// a cross-queue waiter later (see ApplySubmittedReleaseMem).
if (wroteData && dataSelection is 1 or 2)
{
GpuWaitRegistry.RecordProduced(
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
}
if (tracePacket)
{
TraceAgc(
@@ -5098,6 +5265,16 @@ public static partial class AgcExports
_ => false,
};
// Latch waiters against the value we just wrote: the guest reuses
// these labels and can reset them to 0 before the wake pass reads
// memory, which otherwise loses the wakeup and stalls at a black
// screen (Astro Bot: graphics queue waiting on a compute EOP label).
if (wroteData && dataSelection is 1 or 2)
{
GpuWaitRegistry.RecordProduced(
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
}
if (tracePacket)
{
TraceAgc(
@@ -5398,7 +5575,7 @@ public static partial class AgcExports
state.KnownRenderTargets[resolveSource.Address] = resolveSource;
state.KnownRenderTargets[resolveDestination.Address] = resolveDestination;
ProvideRenderTargetInitialData(ctx, resolveSource);
if (VulkanVideoPresenter.TrySubmitGuestImageBlit(
if (GuestGpu.Current.TrySubmitGuestImageBlit(
resolveSource.Address,
resolveSource.Width,
resolveSource.Height,
@@ -5709,7 +5886,7 @@ public static partial class AgcExports
var cacheKey = (
exportShaderAddress,
exportFingerprint,
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
_storageBufferOffsetAlignment);
_depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader);
if (vertexShader is null)
@@ -5734,7 +5911,7 @@ public static partial class AgcExports
: guestGlobalBufferCount + 1,
requiredVertexOutputCount: 0,
storageBufferOffsetAlignment:
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
_storageBufferOffsetAlignment))
{
ReturnPooledEvaluationArrays(exportEvaluation);
return false;
@@ -5746,7 +5923,7 @@ public static partial class AgcExports
exportFingerprint,
vertexShader!,
exportState.Program);
VulkanVideoPresenter.CountSpirvCompilation();
GuestGpu.Current.CountShaderCompilation();
_depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!);
}
@@ -6032,7 +6209,7 @@ public static partial class AgcExports
attributeCount,
psInputEna,
psInputAddr,
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
_storageBufferOffsetAlignment);
var guestGlobalBuffers =
pixelEvaluation.GlobalMemoryBindings.Count +
@@ -6068,7 +6245,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment:
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment) ||
_storageBufferOffsetAlignment) ||
!GuestGpu.Current.TryCompileVertexShader(
exportState,
exportEvaluation,
@@ -6080,7 +6257,7 @@ public static partial class AgcExports
scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1,
requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState),
storageBufferOffsetAlignment:
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
_storageBufferOffsetAlignment))
{
ReturnPooledEvaluationArrays(exportEvaluation);
ReturnPooledEvaluationArrays(pixelEvaluation);
@@ -6100,7 +6277,7 @@ public static partial class AgcExports
pixelStateFingerprint,
compiled.Pixel,
pixelState.Program);
VulkanVideoPresenter.CountSpirvCompilation();
GuestGpu.Current.CountShaderCompilation();
_graphicsShaderCache.TryAdd(shaderKey, compiled);
}
@@ -6378,7 +6555,7 @@ public static partial class AgcExports
var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort);
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var byteCount = checked((int)(indexCount * (uint)bytesPerIndex));
var data = VulkanVideoPresenter.GuestDataPool.Rent(byteCount);
var data = GuestDataPool.Shared.Rent(byteCount);
var span = data.AsSpan(0, byteCount);
var address = state.IndexBufferAddress + byteOffset;
if (ctx.Memory.TryRead(address, span) ||
@@ -6387,7 +6564,7 @@ public static partial class AgcExports
return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true);
}
VulkanVideoPresenter.GuestDataPool.Return(data);
GuestDataPool.Shared.Return(data);
return null;
}
@@ -6414,7 +6591,7 @@ public static partial class AgcExports
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var address = state.IndexBufferAddress + byteOffset;
const int chunkBytes = 64 * 1024;
var scratch = VulkanVideoPresenter.GuestDataPool.Rent(chunkBytes);
var scratch = GuestDataPool.Shared.Rent(chunkBytes);
var remaining = drawCount;
var maxIndex = 0u;
var sawIndex = false;
@@ -6456,7 +6633,7 @@ public static partial class AgcExports
}
finally
{
VulkanVideoPresenter.GuestDataPool.Return(scratch);
GuestDataPool.Shared.Return(scratch);
}
var indexedRecords = sawIndex && maxIndex != uint.MaxValue
@@ -6580,7 +6757,7 @@ public static partial class AgcExports
{
hash = (hash ^ (
binding.BaseAddress &
(VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))) * prime;
(_storageBufferOffsetAlignment - 1))) * prime;
}
if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters)
@@ -6670,7 +6847,8 @@ public static partial class AgcExports
scissor,
DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers),
DecodeDepthState(registers));
DecodeDepthState(registers),
DecodeBlendConstant(registers));
}
private static GuestRenderState CreateRenderState(
@@ -6703,7 +6881,8 @@ public static partial class AgcExports
scissor,
DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers),
DecodeDepthState(registers));
DecodeDepthState(registers),
DecodeBlendConstant(registers));
}
// DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE
@@ -6808,6 +6987,22 @@ public static partial class AgcExports
return new GuestRasterState(cullFront, cullBack, frontFaceClockwise, wireframe);
}
/// <summary>CB_BLEND_RED..ALPHA carry the constant blend color as raw
/// float bits; unwritten registers read as the reset value (0.0).</summary>
private static GuestBlendConstant DecodeBlendConstant(
IReadOnlyDictionary<uint, uint> registers)
{
registers.TryGetValue(CbBlendRed, out var red);
registers.TryGetValue(CbBlendGreen, out var green);
registers.TryGetValue(CbBlendBlue, out var blue);
registers.TryGetValue(CbBlendAlpha, out var alpha);
return new GuestBlendConstant(
BitConverter.Int32BitsToSingle(unchecked((int)red)),
BitConverter.Int32BitsToSingle(unchecked((int)green)),
BitConverter.Int32BitsToSingle(unchecked((int)blue)),
BitConverter.Int32BitsToSingle(unchecked((int)alpha)));
}
private static GuestBlendState DecodeBlendState(
IReadOnlyDictionary<uint, uint> registers,
uint slot)
@@ -7320,7 +7515,7 @@ public static partial class AgcExports
IReadOnlyList<uint> registers,
IReadOnlyList<Gen5GlobalMemoryBinding> bindings)
{
var bytes = VulkanVideoPresenter.GuestDataPool.Rent(
var bytes = GuestDataPool.Shared.Rent(
GetRuntimeScalarBufferLength(bindings.Count));
PackRuntimeScalarStateInto(bytes, registers, bindings);
return bytes;
@@ -7346,7 +7541,7 @@ public static partial class AgcExports
{
var byteBias = checked((uint)(
bindings[index].BaseAddress &
(VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1)));
(_storageBufferOffsetAlignment - 1)));
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)),
byteBias);
@@ -7389,7 +7584,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
GuestDataPool.Shared.Return(binding.Data);
}
}
@@ -7399,7 +7594,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
GuestDataPool.Shared.Return(binding.Data);
}
}
}
@@ -7425,7 +7620,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
GuestDataPool.Shared.Return(binding.Data);
}
}
}
@@ -7436,7 +7631,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
GuestDataPool.Shared.Return(binding.Data);
}
}
}
@@ -7444,7 +7639,7 @@ public static partial class AgcExports
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer &&
returned.Add(indexBuffer.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data);
GuestDataPool.Shared.Return(indexBuffer.Data);
}
}
@@ -7701,7 +7896,7 @@ public static partial class AgcExports
if (!isStorage &&
descriptor.Address != 0 &&
VulkanVideoPresenter.IsGuestImageAvailable(
GuestGpu.Current.IsGpuGuestImageAvailable(
descriptor.Address,
descriptor.Format,
descriptor.NumberType))
@@ -7730,7 +7925,7 @@ public static partial class AgcExports
{
var initialPixels = Array.Empty<byte>();
var uploadKnown = descriptor.Address != 0 &&
VulkanVideoPresenter.IsGuestImageUploadKnown(
GuestGpu.Current.IsGuestImageUploadKnown(
descriptor.Address,
descriptor.Format,
descriptor.NumberType);
@@ -7811,8 +8006,8 @@ public static partial class AgcExports
if (!_textureCopySkipDisabled &&
descriptor.Address != 0 &&
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
VulkanVideoPresenter.IsTextureContentCached(
new VulkanVideoPresenter.TextureContentIdentity(
GuestGpu.Current.IsTextureContentCached(
new TextureContentIdentity(
descriptor.Address,
descriptor.Width,
descriptor.Height,
@@ -7915,7 +8110,7 @@ public static partial class AgcExports
CpuContext ctx,
RenderTargetDescriptor target)
{
if (!VulkanVideoPresenter.GuestImageWantsInitialData(target.Address))
if (!GuestGpu.Current.GuestImageWantsInitialData(target.Address))
{
return;
}
@@ -7941,7 +8136,7 @@ public static partial class AgcExports
if (nonZero)
{
VulkanVideoPresenter.ProvideGuestImageInitialData(target.Address, initialData);
GuestGpu.Current.ProvideGuestImageInitialData(target.Address, initialData);
}
}
@@ -8238,9 +8433,14 @@ public static partial class AgcExports
ulong packetAddress,
uint packetLength,
uint opcode,
out ComputeDispatch dispatch)
out ComputeDispatch dispatch,
out ulong indirectDimsRetryAddress)
{
dispatch = default;
// Non-zero only when this is an INDIRECT dispatch whose dimensions read as
// zero — meaning the producing GPU dispatch that computes them has not run
// yet. The caller suspends on this address instead of dropping the work.
indirectDimsRetryAddress = 0;
ulong dimensionsAddress;
uint initiator;
string dispatchSource;
@@ -8289,6 +8489,17 @@ public static partial class AgcExports
if (dispatchEndX == 0 || dispatchEndY == 0 || dispatchEndZ == 0)
{
// Indirect dispatches read their dimensions from a guest buffer a
// prior GPU dispatch fills. Zero here means that producer has not run
// yet — signal the caller to suspend on the dims buffer and retry,
// rather than dropping the work (which black-screens GPU-driven games
// like Astro Bot). Direct dispatches carry dims inline, so a zero is
// genuinely malformed and still rejected.
if (opcode == ItDispatchIndirect)
{
indirectDimsRetryAddress = dimensionsAddress;
}
return RejectComputeDispatch(
dimensionsAddress,
initiator,
@@ -8623,7 +8834,7 @@ public static partial class AgcExports
// still queued, so the clear could erase newly constructed CPU
// objects. Waiting on the work sequence also retires preceding
// Vulkan writes before the next evaluator snapshot is captured.
if (!VulkanVideoPresenter.WaitForGuestWork(semanticCopySequence))
if (!GuestGpu.Current.WaitForGuestWork(semanticCopySequence))
{
computeError =
$"semantic-global-write-sync-timeout sequence={semanticCopySequence}";
@@ -8641,7 +8852,7 @@ public static partial class AgcExports
localSizeY,
localSizeZ,
dispatch.WaveLaneCount,
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
_storageBufferOffsetAlignment);
var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count;
var totalGlobalBufferCount = _bakeScalars
? guestGlobalBufferCount
@@ -8663,7 +8874,7 @@ public static partial class AgcExports
: guestGlobalBufferCount,
waveLaneCount: dispatch.WaveLaneCount,
storageBufferOffsetAlignment:
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
_storageBufferOffsetAlignment))
{
DumpCompiledShader(
"cs",
@@ -8704,7 +8915,7 @@ public static partial class AgcExports
dispatch.ThreadCountZ);
gpuDispatch = true;
if (writesGlobalMemory &&
!VulkanVideoPresenter.WaitForGuestWork(workSequence))
!GuestGpu.Current.WaitForGuestWork(workSequence))
{
computeError = $"global-write-sync-timeout sequence={workSequence}";
}
@@ -8898,7 +9109,7 @@ public static partial class AgcExports
}
var destinationAddress = destination.BaseAddress;
workSequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
workSequence = GuestGpu.Current.SubmitOrderedGuestAction(
() =>
{
if (!ctx.Memory.TryWrite(destinationAddress, output))
@@ -8912,7 +9123,7 @@ public static partial class AgcExports
GuestImageWriteTracker.Track(
destinationAddress,
(ulong)output.Length,
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics,
GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics,
"agc.masked-dword-copy");
},
$"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}");
@@ -9601,7 +9812,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment:
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
_storageBufferOffsetAlignment))
{
TraceAgcShader(
$"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " +
@@ -11225,4 +11436,35 @@ public static partial class AgcExports
TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
// Tessellation-factor ring and hull-shader off-chip buffers are guest-driver
// configuration for on-hardware tessellation memory. Our translator handles
// shader execution directly, so there is no guest-side ring to program: the
// guest driver only needs these to report success so init proceeds. Games
// (e.g. Unity titles) call them during GPU setup and stall if unresolved.
[SysAbiExport(
Nid = "XlNp7jzGiPo",
ExportName = "sceAgcDriverSetTFRing",
Target = Generation.Gen5,
LibraryName = "libSceAgcDriver")]
public static int DriverSetTFRing(CpuContext ctx)
{
TraceAgc(
$"agc.driver_set_tf_ring ring=0x{ctx[CpuRegister.Rdi]:X16} " +
$"size=0x{(uint)ctx[CpuRegister.Rsi]:X8}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "MM4IZSEYytQ",
ExportName = "sceAgcDriverSetHsOffchipParam",
Target = Generation.Gen5,
LibraryName = "libSceAgcDriver")]
public static int DriverSetHsOffchipParam(CpuContext ctx)
{
TraceAgc(
$"agc.driver_set_hs_offchip_param buffer=0x{ctx[CpuRegister.Rdi]:X16} " +
$"param=0x{(uint)ctx[CpuRegister.Rsi]:X8}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
}
@@ -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;
@@ -28,6 +29,6 @@ internal static class AgcShaderCompilerHooks
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
VulkanVideoPresenter.GuestDataPool;
GuestDataPool.Shared;
}
}
+181 -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,162 @@ 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;
}
/// <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 +438,7 @@ internal static class GpuWaitRegistry
lock (_gate)
{
_waiters.Clear();
_lastProduced.Clear();
}
}
}
+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,
@@ -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);
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
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; } = ArrayPool<byte>.Create(
maxArrayLength: 16 * 1024 * 1024,
maxArraysPerBucket: 96);
}
+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();
}
}
+25 -1
View File
@@ -36,6 +36,20 @@ 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);
internal sealed record GuestMemoryBuffer(
ulong BaseAddress,
byte[] Data,
@@ -122,12 +136,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
@@ -224,6 +224,21 @@ public static partial class KernelMemoryCompatExports
}
}
/// <summary>Removes a guest mount registered by <see cref="RegisterGuestPathMount"/>.</summary>
public static bool UnregisterGuestPathMount(string guestMountPoint)
{
var normalizedMountPoint = NormalizeGuestStatCachePath(guestMountPoint);
if (normalizedMountPoint is null)
{
return false;
}
lock (_guestMountGate)
{
return _guestMounts.Remove(normalizedMountPoint);
}
}
internal static bool TryAllocateHleData(
CpuContext ctx,
ulong length,
@@ -696,7 +696,14 @@ 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.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
@@ -1234,7 +1241,21 @@ public static class KernelPthreadCompatExports
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState)
{
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
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.OwnerThreadId = currentThreadId;
mutexState.RecursionCount = 1;
}
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
return mutexState.OwnerThreadId == currentThreadId
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
@@ -1385,6 +1406,31 @@ 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);
node.Value.Node = null;
}
node = next;
}
}
var waiter = new PthreadMutexWaiter
{
ThreadId = threadId,
+449 -104
View File
@@ -3,6 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Threading;
@@ -25,24 +26,44 @@ public static class Ngs2Exports
private static long _nextUid;
private static long _renderCount;
private sealed record SystemState(uint Uid);
private sealed record RackState(ulong SystemHandle, uint RackId);
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender.
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256;
private const double OutputSampleRate = 48000.0;
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx)
private sealed class SystemState
{
var bufferInfoAddress = ctx[CpuRegister.Rsi];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
public SystemState(uint uid) => Uid = uid;
public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples;
}
private sealed record RackState(ulong SystemHandle, uint RackId);
private sealed class VoiceState
{
public VoiceState(ulong rackHandle, uint voiceIndex)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
RackHandle = rackHandle;
VoiceIndex = voiceIndex;
}
return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
public ulong RackHandle { get; }
public uint VoiceIndex { get; }
// Software-mixer playback state. Pcm is the fully decoded mono waveform;
// Position is a fractional read cursor advanced at the source/output rate
// ratio each output frame.
public short[]? Pcm { get; set; }
public ulong SourceAddr { get; set; }
public int SourceRate { get; set; }
public double Position { get; set; }
public bool Playing { get; set; }
public int LoopStart { get; set; } = -1;
public int LoopEnd { get; set; }
public float Gain { get; set; } = 1f;
}
[SysAbiExport(
@@ -58,14 +79,34 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle))
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return CreateSystem(ctx, outHandleAddress, handle);
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
}
// Non-allocator create: identical to the WithAllocator form for our purposes.
// The only signature difference is the caller-supplied buffer info in rsi
// (vs an allocator callback); the system option (rdi) and out-handle (rdx)
// sit at the same argument positions, so we reuse the same implementation.
// Dead Cells uses these variants — leaving sceNgs2SystemCreate unresolved
// gave the game a garbage system handle, so every later rack/voice call
// failed and it polled sceNgs2VoiceGetState forever, freezing at FLIP 0.
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx) => Ngs2SystemCreateWithAllocator(ctx);
[SysAbiExport(
Nid = "u-WrYDaJA3k",
ExportName = "sceNgs2SystemDestroy",
@@ -94,27 +135,6 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx)
{
var bufferInfoAddress = ctx[CpuRegister.Rcx];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CreateRack(
ctx,
ctx[CpuRegister.Rdi],
unchecked((uint)ctx[CpuRegister.Rsi]),
ctx[CpuRegister.R8],
hostBuffer);
}
[SysAbiExport(
Nid = "U546k6orxQo",
ExportName = "sceNgs2RackCreateWithAllocator",
@@ -138,14 +158,29 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle))
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
}
// Non-allocator rack create: system handle (rdi), rack id (rsi) and the
// out-handle (r8) share the WithAllocator argument layout, so reuse it.
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx) => Ngs2RackCreateWithAllocator(ctx);
[SysAbiExport(
Nid = "lCqD7oycmIM",
ExportName = "sceNgs2RackDestroy",
@@ -220,14 +255,217 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")]
public static int Ngs2VoiceControl(CpuContext ctx)
{
var voiceHandle = ctx[CpuRegister.Rdi];
var paramList = ctx[CpuRegister.Rsi];
lock (StateGate)
{
return SetReturn(
ctx,
Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : OrbisNgs2ErrorInvalidVoiceHandle);
if (!Voices.ContainsKey(voiceHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle);
}
}
if (ShouldTrace())
{
TraceVoiceParamList(ctx, voiceHandle, paramList);
}
HandleVoiceParams(ctx, voiceHandle, paramList);
return SetReturn(ctx, 0);
}
// Parse the SceNgs2VoiceParamHead command list (header = u32 size, u32 id;
// params are laid out contiguously) and apply the ones the mixer needs:
// the waveform-blocks param arms a voice with decoded PCM, and the port
// matrix param carries its output gain.
private static void HandleVoiceParams(CpuContext ctx, ulong voiceHandle, ulong paramList)
{
if (paramList == 0)
{
return;
}
var offset = paramList;
for (var guard = 0; guard < 32; guard++)
{
if (!ctx.TryReadUInt32(offset, out var size) ||
!ctx.TryReadUInt32(offset + 4, out var id))
{
return;
}
switch (id)
{
case 0x10000001:
ApplyWaveformParam(ctx, voiceHandle, offset);
break;
case 0x20010001:
ApplyPortMatrixParam(ctx, voiceHandle, offset);
break;
}
// Advance to the next contiguous block; the game normally sends one
// param per call (size==whole block), so stop when size is degenerate.
if (size < 8 || size > 0x1000)
{
return;
}
offset += (size + 7) & ~7u;
}
}
// Waveform-blocks param: the guest pointer at +8 references a "VAGp"
// (PS-ADPCM) container. Decode it once and arm the voice for playback.
private static void ApplyWaveformParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
{
if (!ctx.TryReadUInt64(paramOffset + 8, out var dataAddr) || dataAddr <= 0x10000)
{
return;
}
lock (StateGate)
{
if (Voices.TryGetValue(voiceHandle, out var existing) &&
existing.SourceAddr == dataAddr && existing.Pcm is not null)
{
// Same waveform already armed — don't restart it every frame.
return;
}
}
Span<byte> header = stackalloc byte[Ngs2VagDecoder.VagHeaderSize];
if (!ctx.Memory.TryRead(dataAddr, header) || !Ngs2VagDecoder.IsVag(header))
{
return;
}
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(header[0x0C..]);
var totalBytes = Ngs2VagDecoder.VagHeaderSize + Math.Clamp(declaredSize, 0, 8 * 1024 * 1024);
var raw = System.Buffers.ArrayPool<byte>.Shared.Rent(totalBytes);
try
{
if (!ctx.Memory.TryRead(dataAddr, raw.AsSpan(0, totalBytes)) ||
!Ngs2VagDecoder.TryDecode(raw.AsSpan(0, totalBytes), out var waveform))
{
return;
}
lock (StateGate)
{
if (!Voices.TryGetValue(voiceHandle, out var voice))
{
return;
}
voice.Pcm = waveform.Samples;
voice.SourceAddr = dataAddr;
voice.SourceRate = waveform.SampleRate;
voice.LoopStart = waveform.LoopStart;
voice.LoopEnd = waveform.LoopEnd > 0 ? waveform.LoopEnd : waveform.Samples.Length;
voice.Position = 0;
voice.Playing = true;
}
if (ShouldTrace())
{
var peak = 0;
for (var i = 0; i < waveform.Samples.Length; i++)
{
peak = Math.Max(peak, Math.Abs((int)waveform.Samples[i]));
}
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.arm voice=0x{voiceHandle:X16} addr=0x{dataAddr:X} rate={waveform.SampleRate} samples={waveform.Samples.Length} loop={waveform.LoopStart} peak={peak}");
}
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(raw);
}
}
// Port matrix param: the first float level is a reasonable proxy for the
// voice's output gain until per-channel panning is implemented.
private static void ApplyPortMatrixParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
{
if (!ctx.TryReadUInt32(paramOffset + 12, out var levelBits))
{
return;
}
var level = BitConverter.UInt32BitsToSingle(levelBits);
if (!float.IsFinite(level) || level < 0f || level > 8f)
{
return;
}
lock (StateGate)
{
if (Voices.TryGetValue(voiceHandle, out var voice))
{
voice.Gain = level;
}
}
}
// Empirically dump the SceNgs2VoiceParamHead-chained command list so we can
// confirm the real struct layout (size/next/id) against public NGS2 sources
// before building the software mixer. Assumed header: u16 size, s16 next
// (byte offset to the next block, 0 = end), u32 id.
private static void TraceVoiceParamList(CpuContext ctx, ulong voiceHandle, ulong paramList)
{
if (paramList == 0)
{
return;
}
Span<byte> peek = stackalloc byte[32];
var offset = paramList;
for (int guard = 0; guard < 32; guard++)
{
if (!ctx.TryReadUInt16(offset, out var size) ||
!ctx.TryReadUInt16(offset + 2, out var next) ||
!ctx.TryReadUInt32(offset + 4, out var id))
{
Console.Error.WriteLine($"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} @0x{offset:X}: unreadable header");
return;
}
peek.Clear();
var readable = Math.Min((int)Math.Max((ushort)8, size), peek.Length);
ctx.Memory.TryRead(offset, peek[..readable]);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} id=0x{id:X} size={size} next={unchecked((short)next)} bytes={Convert.ToHexString(peek[..readable])}");
// For the waveform-blocks param, follow the embedded pointers and
// dump the pointed-to bytes so we can tell PCM16 from ATRAC9.
if (id == 0x10000001 && Interlocked.Increment(ref _waveformDumps) <= 8)
{
for (int po = 8; po + 8 <= readable; po += 8)
{
if (ctx.TryReadUInt64(offset + (ulong)po, out var ptr) && ptr > 0x10000 &&
ctx.Memory.TryRead(ptr, peek))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.waveform @+{po} ptr=0x{ptr:X} head={Convert.ToHexString(peek)}");
}
}
}
var advance = unchecked((short)next);
if (advance <= 0)
{
return;
}
offset += (ulong)advance;
}
}
private static long _waveformDumps;
private static long _renderInfoDumps;
[SysAbiExport(
Nid = "AbYvTOZ8Pts",
ExportName = "sceNgs2VoiceRunCommands",
@@ -273,11 +511,32 @@ public static class Ngs2Exports
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// SceNgs2RenderBufferInfo: {ptr@0, size@8, waveformType@16,
// channelsCount@20}. Mix the armed voices into the leading grain
// as interleaved float32 — this is what the game copies to
// sceAudioOutOutput, so it is where NGS2 audio must appear.
var channels = 2;
if (ctx.TryReadUInt32(entryAddress + 20, out var declaredChannels) &&
declaredChannels is > 0 and <= 8)
{
channels = (int)declaredChannels;
}
MixVoicesIntoGrain(ctx, systemHandle, bufferAddress, bufferSize, channels);
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize];
ctx.Memory.TryRead(entryAddress, rbi);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
}
}
}
var count = Interlocked.Increment(ref _renderCount);
if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
if (ShouldTrace() && (count <= 4 || count % 200 == 0))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
@@ -286,6 +545,135 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
// Sum every armed voice belonging to this system into the leading grain of
// the render buffer as interleaved float32. The buffer was just zeroed, so
// this is a plain additive mix; silence stays silence when nothing plays.
private static void MixVoicesIntoGrain(
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{
int grain;
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return;
}
grain = system.GrainSamples;
}
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
if (capacityFrames <= 0)
{
return;
}
var floatCount = capacityFrames * channels;
var accum = ArrayPool<float>.Shared.Rent(floatCount);
var mixedAnything = false;
try
{
Array.Clear(accum, 0, floatCount);
lock (StateGate)
{
foreach (var pair in Voices)
{
var voice = pair.Value;
if (!voice.Playing || voice.Pcm is null || voice.Pcm.Length == 0)
{
continue;
}
if (!Racks.TryGetValue(voice.RackHandle, out var rack) ||
rack.SystemHandle != systemHandle)
{
continue;
}
MixOneVoice(accum, capacityFrames, channels, voice);
mixedAnything = true;
}
}
if (mixedAnything)
{
WriteGrain(ctx, bufferAddress, accum, floatCount);
}
}
finally
{
ArrayPool<float>.Shared.Return(accum);
}
}
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add
// it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate.
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
{
var pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart;
var step = voice.SourceRate / OutputSampleRate;
var gain = voice.Gain / 32768f;
var pos = voice.Position;
for (var f = 0; f < frames; f++)
{
var idx = (int)pos;
if (idx >= loopEnd)
{
if (loopStart >= 0 && loopStart < loopEnd)
{
pos = loopStart;
idx = loopStart;
}
else
{
voice.Playing = false;
break;
}
}
if (idx < 0 || idx >= pcm.Length)
{
voice.Playing = false;
break;
}
var sample = pcm[idx] * gain;
var baseIndex = f * channels;
accum[baseIndex] += sample;
if (channels > 1)
{
accum[baseIndex + 1] += sample;
}
pos += step;
}
voice.Position = pos;
}
private static void WriteGrain(CpuContext ctx, ulong address, float[] accum, int count)
{
var bytes = ArrayPool<byte>.Shared.Rent(count * sizeof(float));
try
{
var span = bytes.AsSpan(0, count * sizeof(float));
for (var i = 0; i < count; i++)
{
var value = Math.Clamp(accum[i], -1f, 1f);
BinaryPrimitives.WriteSingleLittleEndian(span.Slice(i * sizeof(float), sizeof(float)), value);
}
ctx.Memory.TryWrite(address, span);
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
}
[SysAbiExport(
Nid = "pgFAiLR5qT4",
ExportName = "sceNgs2SystemQueryBufferSize",
@@ -323,7 +711,25 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetGrainSamples",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(ctx);
public static int Ngs2SystemSetGrainSamples(CpuContext ctx)
{
var systemHandle = ctx[CpuRegister.Rdi];
var grain = unchecked((int)ctx[CpuRegister.Rsi]);
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
if (grain > 0 && grain <= 8192)
{
system.GrainSamples = grain;
}
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "-tbc2SxQD60",
@@ -412,67 +818,6 @@ public static class Ngs2Exports
}
}
private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle)
{
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
}
private static int CreateRack(
CpuContext ctx,
ulong systemHandle,
uint rackId,
ulong outHandleAddress,
ulong handle)
{
lock (StateGate)
{
if (!Systems.ContainsKey(systemHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
}
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
}
private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer)
{
hostBuffer = 0;
return address != 0 &&
ctx.TryReadUInt64(address, out hostBuffer) &&
hostBuffer != 0;
}
private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
{
handle = 0;
+150
View File
@@ -0,0 +1,150 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
namespace SharpEmu.Libs.Ngs2;
// Clean-room PS-ADPCM ("VAG") decoder. NGS2 sampler voices point at waveforms
// wrapped in the classic Sony "VAGp" container: a 48-byte big-endian header
// followed by 16-byte ADPCM frames (2-byte predictor/shift + flags, then 14
// bytes = 28 nibbles = 28 samples). The predictor coefficient table and the
// nibble decode are the publicly documented PSX SPU ADPCM algorithm.
public static class Ngs2VagDecoder
{
// Standard PS-ADPCM predictor filters (scaled by 1/64).
private static readonly int[] Coeff0 = { 0, 60, 115, 98, 122 };
private static readonly int[] Coeff1 = { 0, 0, -52, -55, -60 };
public const int VagHeaderSize = 0x30;
private const uint VagMagic = 0x56414770; // "VAGp"
public readonly struct Waveform
{
public Waveform(short[] samples, int sampleRate, int loopStart, int loopEnd)
{
Samples = samples;
SampleRate = sampleRate;
LoopStart = loopStart;
LoopEnd = loopEnd;
}
public short[] Samples { get; }
public int SampleRate { get; }
public int LoopStart { get; } // -1 when the waveform does not loop
public int LoopEnd { get; }
}
// True when the buffer begins with a recognizable "VAGp" container header.
public static bool IsVag(ReadOnlySpan<byte> data) =>
data.Length >= VagHeaderSize &&
BinaryPrimitives.ReadUInt32BigEndian(data) == VagMagic;
// Decode a full "VAGp" container into mono PCM16. Returns false when the
// header is missing/short so callers can skip unsupported formats safely.
public static bool TryDecode(ReadOnlySpan<byte> data, out Waveform waveform)
{
waveform = default;
if (!IsVag(data))
{
return false;
}
// Header (big-endian): +0x0C dataSize, +0x10 sampleRate.
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x0C..]);
var sampleRate = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x10..]);
if (sampleRate <= 0)
{
sampleRate = 48000;
}
var body = data[VagHeaderSize..];
// Trust the declared payload size when it fits; otherwise decode what we
// actually have (some tools pad or under-report).
var available = body.Length - (body.Length % 16);
var frameBytes = declaredSize > 0 && declaredSize <= available ? declaredSize - (declaredSize % 16) : available;
if (frameBytes <= 0)
{
return false;
}
waveform = Decode(body[..frameBytes], sampleRate);
return waveform.Samples.Length > 0;
}
// Decode raw 16-byte-framed PS-ADPCM (no container header) into PCM16 and
// resolve loop points from the per-frame flag bytes.
public static Waveform Decode(ReadOnlySpan<byte> frames, int sampleRate)
{
var frameCount = frames.Length / 16;
var samples = new short[frameCount * 28];
var loopStart = -1;
var loopEnd = -1;
var hist1 = 0;
var hist2 = 0;
var outIndex = 0;
var ended = false;
for (var frame = 0; frame < frameCount && !ended; frame++)
{
var offset = frame * 16;
var header = frames[offset];
var shift = header & 0x0F;
var filter = (header >> 4) & 0x0F;
if (filter > 4)
{
filter = 0;
}
// Per-frame loop marker (exact PS-ADPCM values, not bit masks):
// 3 = loop start, 6 = loop end + jump back, 1/7 = one-shot end.
var flags = frames[offset + 1];
var blockStart = outIndex;
if (flags == 0x03)
{
loopStart = blockStart;
}
var f0 = Coeff0[filter];
var f1 = Coeff1[filter];
for (var i = 0; i < 14; i++)
{
var d = frames[offset + 2 + i];
for (var nibble = 0; nibble < 2; nibble++)
{
var raw = nibble == 0 ? d & 0x0F : d >> 4;
// Sign-extend the 4-bit sample into the top nibble, then scale.
var s = (short)(raw << 12) >> shift;
var predicted = (hist1 * f0 + hist2 * f1) >> 6;
var sample = Math.Clamp(s + predicted, short.MinValue, short.MaxValue);
samples[outIndex++] = (short)sample;
hist2 = hist1;
hist1 = sample;
}
}
if (flags == 0x06)
{
loopEnd = outIndex;
}
else if (flags == 0x01 || flags == 0x07)
{
ended = true;
}
}
// Trim to the samples we actually decoded (a one-shot end marker can stop
// us before the declared frame count).
if (outIndex != samples.Length)
{
Array.Resize(ref samples, outIndex);
}
if (loopStart >= 0 && loopEnd <= loopStart)
{
loopEnd = outIndex;
}
return new Waveform(samples, sampleRate, loopStart, loopEnd);
}
}
@@ -58,6 +58,36 @@ public static class NpEntitlementAccessExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private const int EmptyAddcontInfoSize = 0x30;
// Singular lookup of one add-on-content entitlement (rdx = info out). We own
// no DLC, so report an empty/zeroed info and success — matching the list
// variant's "no entitlements" answer. Dead Cells calls this while loading a
// level; leaving it unresolved left the info struct uninitialized.
[SysAbiExport(
Nid = "xddD23+8TfQ",
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpEntitlementAccess")]
public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdx];
if (infoAddress != 0)
{
Span<byte> info = stackalloc byte[EmptyAddcontInfoSize];
info.Clear();
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
TraceNpEntitlementAccess(
$"get_addcont_info service=0x{ctx[CpuRegister.Rdi]:X16} label=0x{ctx[CpuRegister.Rsi]:X16} " +
$"info=0x{infoAddress:X16} -> empty");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static void TraceNpEntitlementAccess(string message)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal))
@@ -197,4 +197,16 @@ public static class NpUniversalDataSystemExports
{
return ctx.SetReturn(0, typeof(long));
}
// Telemetry property setter (event property array, string value). We do not
// upload analytics, so accept and drop it — matching the other Set* stubs.
[SysAbiExport(
Nid = "4llLk7YJRTE",
ExportName = "sceNpUniversalDataSystemEventPropertyArraySetString",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpUniversalDataSystem")]
public static int NpUniversalDataSystemEventPropertyArraySetString(CpuContext ctx)
{
return ctx.SetReturn(0, typeof(long));
}
}
+61
View File
@@ -65,6 +65,35 @@ public static class PadExports
LibraryName = "libScePad")]
public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true);
// scePadGetHandle(userId, type, index): returns the handle of an already-open
// pad without opening a new one. Dead Cells calls it every frame to poll
// input; leaving it unresolved returned a garbage handle so the input path
// (and the game loop that drives it) misbehaved. Same validation as
// scePadOpen — the one primary pad — returning its handle or a not-connected
// error, never opening or logging.
[SysAbiExport(
Nid = "u1GRHp+oWoY",
ExportName = "scePadGetHandle",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadGetHandle(CpuContext ctx)
{
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var type = unchecked((int)ctx[CpuRegister.Rsi]);
var index = unchecked((int)ctx[CpuRegister.Rdx]);
if (!_initialized)
{
return ctx.SetReturn(OrbisPadErrorNotInitialized);
}
if (userId != PrimaryUserId || type is not (0 or 1 or 2) || index != 0)
{
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
}
return ctx.SetReturn(PrimaryPadHandle);
}
// scePadOpen rejects a non-null 4th arg and non-standard ports; scePadOpenExt accepts a
// ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected).
private static int PadOpenCore(CpuContext ctx, bool extended)
@@ -216,6 +245,38 @@ public static class PadExports
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "AcslpN1jHR8",
ExportName = "scePadDeviceClassGetExtendedInformation",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadDeviceClassGetExtendedInformation(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (informationAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// ScePadDeviceClassExtendedInformation: deviceClass 0 = standard pad
// (DualSense). We emulate no special peripheral (guitar/drums/wheel), so
// the class-data union stays zeroed — the guest treats it as a plain
// controller with no extended capabilities.
Span<byte> information = stackalloc byte[0x20];
information.Clear();
BinaryPrimitives.WriteInt32LittleEndian(information[0x00..], 0);
return ctx.Memory.TryWrite(informationAddress, information)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "YndgXqQVV7c",
ExportName = "scePadReadState",
+565 -24
View File
@@ -45,6 +45,530 @@ public static class SaveDataExports
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
_preparedTransactionResources.Clear();
}
lock (_eventGate)
{
_events.Clear();
}
lock (_mountGate)
{
_mounts.Clear();
}
}
// Additional error codes and the async-event model (see sceSaveDataGetEventResult).
private const int OrbisSaveDataErrorBusy = unchecked((int)0x809F0006);
private const int OrbisSaveDataErrorNoEvent = unchecked((int)0x809F0008); // NOT_FOUND: no pending event
private const int OrbisSaveDataErrorBadMounted = unchecked((int)0x809F0013);
// SceSaveDataEventType
private const uint EventTypeUmountBackupEnd = 1;
private const uint EventTypeBackupEnd = 2;
private const uint EventTypeSaveDataMemorySyncEnd = 3;
private const int SaveDataEventSize = 0x60;
private const int MountInfoSize = 0x40;
private const uint DefaultBlockSize = 32768;
private const ulong DefaultTotalBlocks = 0x8000; // 1 GiB of 32 KiB blocks
private static readonly object _eventGate = new();
private static readonly Queue<SaveDataEvent> _events = new();
private static readonly object _mountGate = new();
// mountPoint -> live mount, for umount/IsMounted/GetMountInfo.
private static readonly Dictionary<string, MountEntry> _mounts = new(StringComparer.Ordinal);
private readonly record struct SaveDataEvent(uint Type, int ErrorCode, int UserId, string DirName);
private sealed record MountEntry(string SlotDir, string DirName, int UserId);
private static void EnqueueEvent(uint type, int userId, string dirName, int errorCode = 0)
{
lock (_eventGate)
{
_events.Enqueue(new SaveDataEvent(type, errorCode, userId, dirName));
}
TraceSaveData($"event.enqueue type={type} user={userId} dir='{dirName}' err=0x{errorCode:X}");
}
[SysAbiExport(
Nid = "j8xKtiFj0SY",
ExportName = "sceSaveDataGetEventResult",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataGetEventResult(CpuContext ctx)
{
// rdi: SceSaveDataEventParam* (filter, ignored). rsi: SceSaveDataEvent* out.
var eventAddress = ctx[CpuRegister.Rsi];
if (eventAddress == 0)
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
SaveDataEvent pending;
lock (_eventGate)
{
if (_events.Count == 0)
{
// No queued completion. Games poll this from a worker; report the
// defined "no event" status so the loop keeps polling instead of
// acting on an uninitialized event struct.
return SetReturn(ctx, OrbisSaveDataErrorNoEvent);
}
pending = _events.Dequeue();
}
Span<byte> ev = stackalloc byte[SaveDataEventSize];
ev.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(ev[0x00..], pending.Type);
BinaryPrimitives.WriteInt32LittleEndian(ev[0x04..], pending.ErrorCode);
BinaryPrimitives.WriteInt32LittleEndian(ev[0x08..], pending.UserId);
WriteAscii(ev.Slice(0x10, SaveDataDirNameSize), pending.DirName);
if (!ctx.Memory.TryWrite(eventAddress, ev))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "hsKd5c21sQc",
ExportName = "sceSaveDataRegisterEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataRegisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(
Nid = "v-AK1AxQhS0",
ExportName = "sceSaveDataUnregisterEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataUnregisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0);
// ---- lifecycle ----
[SysAbiExport(Nid = "ZkZhskCPXFw", ExportName = "sceSaveDataInitialize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataInitialize(CpuContext ctx) => SaveDataInitializeCommon(ctx);
[SysAbiExport(Nid = "l1NmDeDpNGU", ExportName = "sceSaveDataInitialize2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataInitialize2(CpuContext ctx) => SaveDataInitializeCommon(ctx);
private static int SaveDataInitializeCommon(CpuContext ctx)
{
try
{
Directory.CreateDirectory(ResolveSaveDataRoot());
return SetReturn(ctx, 0);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
[SysAbiExport(Nid = "yKDy8S5yLA0", ExportName = "sceSaveDataTerminate", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataTerminate(CpuContext ctx) => SetReturn(ctx, 0);
// ---- mount variants (all share the SceSaveDataMount layout) ----
[SysAbiExport(Nid = "32HQAQdwM2o", ExportName = "sceSaveDataMount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataMount(CpuContext ctx) => SaveDataMount3(ctx);
[SysAbiExport(Nid = "0z45PIH+SNI", ExportName = "sceSaveDataMount2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataMount2(CpuContext ctx) => SaveDataMount3(ctx);
[SysAbiExport(Nid = "xz0YMi6BfNk", ExportName = "sceSaveDataMount5", Target = Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataMount5(CpuContext ctx) => SaveDataMount3(ctx);
[SysAbiExport(Nid = "BMR4F-Uek3E", ExportName = "sceSaveDataUmount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataUmount(CpuContext ctx) => SaveDataUmount2(ctx);
[SysAbiExport(Nid = "ieP6jP138Qo", ExportName = "sceSaveDataIsMounted", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataIsMounted(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
int mountCount;
lock (_mountGate)
{
mountCount = _mounts.Count;
}
if (outAddress != 0)
{
TryWriteUInt32(ctx, outAddress, mountCount > 0 ? 1u : 0u);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "65VH0Qaaz6s", ExportName = "sceSaveDataGetMountInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetMountInfo(CpuContext ctx)
{
var mountPointAddress = ctx[CpuRegister.Rdi];
var infoAddress = ctx[CpuRegister.Rsi];
if (mountPointAddress == 0 || infoAddress == 0 ||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
MountEntry? entry;
lock (_mountGate)
{
_mounts.TryGetValue(mountPoint, out entry);
}
if (entry is null)
{
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
}
var used = SafeDirectorySize(entry.SlotDir);
var usedBlocks = (ulong)((used + DefaultBlockSize - 1) / DefaultBlockSize);
Span<byte> info = stackalloc byte[MountInfoSize];
info.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(info[0x00..], DefaultTotalBlocks); // blocks
BinaryPrimitives.WriteUInt64LittleEndian(info[0x08..], usedBlocks); // freeBlocks slot reused as used
return ctx.Memory.TryWrite(infoAddress, info)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// ---- delete ----
[SysAbiExport(Nid = "S1GkePI17zQ", ExportName = "sceSaveDataDelete", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataDelete(CpuContext ctx) => SaveDataDeleteCommon(ctx);
[SysAbiExport(Nid = "SQWusLoK8Pw", ExportName = "sceSaveDataDelete5", Target = Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataDelete5(CpuContext ctx) => SaveDataDeleteCommon(ctx);
private static int SaveDataDeleteCommon(CpuContext ctx)
{
// SceSaveDataDelete: +0x00 userId, +0x08 dirName*, ... (dirName drives the slot).
var deleteAddress = ctx[CpuRegister.Rdi];
if (deleteAddress == 0 ||
!TryReadInt32(ctx, deleteAddress, out var userId) ||
!ctx.TryReadUInt64(deleteAddress + 0x08, out var dirNameAddress) ||
dirNameAddress == 0 ||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName) ||
string.IsNullOrWhiteSpace(dirName))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
try
{
var slotDir = SaveDataStorage.SlotDir(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), dirName);
if (!Directory.Exists(slotDir))
{
return SetReturn(ctx, OrbisSaveDataErrorNotFound);
}
Directory.Delete(slotDir, recursive: true);
TraceSaveData($"delete user={userId} dir='{dirName}' path='{slotDir}'");
return SetReturn(ctx, 0);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
// ---- params (metadata shown in the save UI) ----
[SysAbiExport(Nid = "XgvSuIdnMlw", ExportName = "sceSaveDataGetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetParam(CpuContext ctx) => TransferParam(ctx, write: false);
[SysAbiExport(Nid = "85zul--eGXs", ExportName = "sceSaveDataSetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetParam(CpuContext ctx) => TransferParam(ctx, write: true);
private static int TransferParam(CpuContext ctx, bool write)
{
// rdi: mount-point string (16 bytes). rsi: paramType. rdx: SceSaveDataParam*. rcx: size.
var mountPointAddress = ctx[CpuRegister.Rdi];
var paramAddress = ctx[CpuRegister.Rdx];
if (mountPointAddress == 0 || paramAddress == 0 ||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
MountEntry? entry;
lock (_mountGate)
{
_mounts.TryGetValue(mountPoint, out entry);
}
if (entry is null)
{
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
}
try
{
if (write)
{
Span<byte> raw = stackalloc byte[SaveDataParamSize];
if (!ctx.Memory.TryRead(paramAddress, raw))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var metadata = new SaveDataMetadata
{
Title = ReadAsciiField(raw.Slice(0x00, 128)),
SubTitle = ReadAsciiField(raw.Slice(0x80, 128)),
Detail = ReadAsciiField(raw.Slice(0x100, 1024)),
UserParam = BinaryPrimitives.ReadUInt32LittleEndian(raw[0x500..]),
};
SaveDataStorage.WriteMetadata(entry.SlotDir, metadata);
TraceSaveData($"set_param mount='{mountPoint}' title='{metadata.Title}'");
return SetReturn(ctx, 0);
}
var loaded = SaveDataStorage.ReadMetadata(entry.SlotDir);
var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), loaded.Title);
WriteAscii(param.AsSpan(0x80, 128), loaded.SubTitle);
WriteAscii(param.AsSpan(0x100, 1024), loaded.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), loaded.UserParam);
BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508),
new DateTimeOffset(SafeLastWriteUtc(entry.SlotDir)).ToUnixTimeSeconds());
return ctx.Memory.TryWrite(paramAddress, param)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
// ---- icons ----
[SysAbiExport(Nid = "c88Yy54Mx0w", ExportName = "sceSaveDataSaveIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSaveIcon(CpuContext ctx) => TransferIconForMount(ctx, write: true);
[SysAbiExport(Nid = "cGjO3wM3V28", ExportName = "sceSaveDataLoadIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataLoadIcon(CpuContext ctx) => TransferIconForMount(ctx, write: false);
private static int TransferIconForMount(CpuContext ctx, bool write)
{
// rdi: mount-point string. rsi: SceSaveDataIcon* {buf@+0x00, bufSize@+0x08, dataSize@+0x10}.
var mountPointAddress = ctx[CpuRegister.Rdi];
var iconAddress = ctx[CpuRegister.Rsi];
if (mountPointAddress == 0 || iconAddress == 0 ||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) ||
!ctx.TryReadUInt64(iconAddress + 0x00, out var bufferAddress) ||
!ctx.TryReadUInt64(iconAddress + 0x08, out var bufferSize))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
MountEntry? entry;
lock (_mountGate)
{
_mounts.TryGetValue(mountPoint, out entry);
}
if (entry is null)
{
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
}
var iconPath = SaveDataStorage.IconPath(entry.SlotDir);
try
{
if (write)
{
var length = checked((int)Math.Min(bufferSize, (ulong)16 * 1024 * 1024));
var bytes = ArrayPool<byte>.Shared.Rent(length);
try
{
if (!ctx.Memory.TryRead(bufferAddress, bytes.AsSpan(0, length)))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Directory.CreateDirectory(Path.GetDirectoryName(iconPath)!);
File.WriteAllBytes(iconPath, bytes.AsSpan(0, length).ToArray());
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
return SetReturn(ctx, 0);
}
if (!File.Exists(iconPath))
{
return SetReturn(ctx, OrbisSaveDataErrorNotFound);
}
var data = File.ReadAllBytes(iconPath);
var copy = (int)Math.Min((ulong)data.Length, bufferSize);
if (bufferAddress != 0 && copy > 0 && !ctx.Memory.TryWrite(bufferAddress, data.AsSpan(0, copy)))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TryWriteUInt32(ctx, iconAddress + 0x10, (uint)data.Length); // dataSize
return SetReturn(ctx, 0);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
// ---- size / progress / abort ----
[SysAbiExport(Nid = "A1ThglSGUwA", ExportName = "sceSaveDataGetAllSize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetAllSize(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
long total = 0;
try
{
var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId());
if (Directory.Exists(titleRoot))
{
total = SafeDirectorySize(titleRoot);
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Report zero on an unreadable tree rather than fail the query.
}
if (outAddress != 0)
{
var kib = (ulong)((total + 1023) / 1024);
ctx.TryWriteUInt64(outAddress, kib);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "ANmSWUiyyGQ", ExportName = "sceSaveDataGetProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetProgress(CpuContext ctx)
{
// Our operations complete synchronously, so any in-flight progress is 100%.
var outAddress = ctx[CpuRegister.Rdi];
if (outAddress != 0)
{
Span<byte> progress = stackalloc byte[8];
progress.Clear();
BinaryPrimitives.WriteSingleLittleEndian(progress, 1.0f);
ctx.Memory.TryWrite(outAddress, progress);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "Wz-4JZfeO9g", ExportName = "sceSaveDataClearProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataClearProgress(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "dQ2GohUHXzk", ExportName = "sceSaveDataAbort", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataAbort(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "eBSSNIG6hMk", ExportName = "sceSaveDataGetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetEventInfo(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "52pL2GKkdjA", ExportName = "sceSaveDataSetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetEventInfo(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "Z7z6HXWORJY", ExportName = "sceSaveDataSaveIconByPath", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSaveIconByPath(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "SN7rTPHS+Cg", ExportName = "sceSaveDataGetSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetSaveDataCount(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
var count = 0;
try
{
var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId());
if (Directory.Exists(titleRoot))
{
foreach (var dir in Directory.EnumerateDirectories(titleRoot))
{
if (!string.Equals(Path.GetFileName(dir), "sce_sdmemory", StringComparison.Ordinal))
{
count++;
}
}
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Report zero on an unreadable tree.
}
if (outAddress != 0)
{
TryWriteUInt32(ctx, outAddress, (uint)count);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "pc4guaUPVqA", ExportName = "sceSaveDataGetMountedSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetMountedSaveDataCount(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
int mounted;
lock (_mountGate)
{
mounted = _mounts.Count;
}
if (outAddress != 0)
{
TryWriteUInt32(ctx, outAddress, (uint)mounted);
}
return SetReturn(ctx, 0);
}
// ---- SaveDataMemory v1 aliases (identical arg layout to the v2 forms) ----
[SysAbiExport(Nid = "v7AAAMo0Lz4", ExportName = "sceSaveDataSetupSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetupSaveDataMemory(CpuContext ctx) => SaveDataSetupSaveDataMemory2(ctx);
[SysAbiExport(Nid = "7Bt5pBC-Aco", ExportName = "sceSaveDataGetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetSaveDataMemory(CpuContext ctx) => SaveDataGetSaveDataMemory2(ctx);
[SysAbiExport(Nid = "h3YURzXGSVQ", ExportName = "sceSaveDataSetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetSaveDataMemory(CpuContext ctx) => SaveDataSetSaveDataMemory2(ctx);
private static string ReadAsciiField(ReadOnlySpan<byte> field)
{
var length = field.IndexOf((byte)0);
if (length < 0)
{
length = field.Length;
}
return Encoding.ASCII.GetString(field[..length]);
}
private static long SafeDirectorySize(string root)
{
try
{
return Directory.Exists(root) ? GetDirectorySize(root) : 0;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return 0;
}
}
private static DateTime SafeLastWriteUtc(string path)
{
try
{
return Directory.Exists(path) ? Directory.GetLastWriteTimeUtc(path) : DateTime.UtcNow;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return DateTime.UtcNow;
}
}
[SysAbiExport(
@@ -221,6 +745,10 @@ public static class SaveDataExports
const string mountPoint = "/savedata0";
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath);
lock (_mountGate)
{
_mounts[mountPoint] = new MountEntry(savePath, dirName, userId);
}
Span<byte> result = stackalloc byte[MountResultSize];
result.Clear();
@@ -318,10 +846,20 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")]
public static int SaveDataUmount2(CpuContext ctx)
{
// Unmounting a save directory always succeeds in the stub filesystem;
// returning an error here makes the game's save flow stall before it
// hands control to the title/gameplay state.
TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}");
// rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2.
var mountPointAddress = ctx[CpuRegister.Rdi];
if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) &&
!string.IsNullOrEmpty(mountPoint))
{
lock (_mountGate)
{
_mounts.Remove(mountPoint);
}
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
TraceSaveData($"umount2 mount='{mountPoint}'");
}
return SetReturn(ctx, 0);
}
@@ -405,9 +943,12 @@ public static class SaveDataExports
private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry)
{
var metadata = SaveDataStorage.ReadMetadata(entry.Path);
var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), "Saved Data");
WriteAscii(param.AsSpan(0x100, 1024), entry.Name);
WriteAscii(param.AsSpan(0x00, 128), metadata.Title);
WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle);
WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam);
BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508, sizeof(long)),
new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds());
@@ -474,11 +1015,14 @@ public static class SaveDataExports
return false;
}
// Saves are keyed by title id only (single-user emulation) under
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
private static string ResolveSaveDataMemoryPath(int userId) =>
Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()));
private static bool TryReadMemoryData(
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
@@ -490,14 +1034,7 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset);
}
private static string ResolveSaveDataRoot()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
: configured;
return Path.GetFullPath(root);
}
private static string ResolveSaveDataRoot() => SaveDataStorage.Root();
private static string ResolveConfiguredTitleId()
{
@@ -525,12 +1062,7 @@ public static class SaveDataExports
return "default";
}
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
}
private static string SanitizePathSegment(string value) => SaveDataStorage.Sanitize(value);
private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value)
{
@@ -790,8 +1322,17 @@ public static class SaveDataExports
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
return ctx.SetReturn(
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
if (!File.Exists(ResolveSaveDataMemoryPath(userId)))
{
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
}
// The write already reached disk synchronously, but the guest treats
// sync as asynchronous and blocks a worker on sceSaveDataGetEventResult
// until the SAVE_DATA_MEMORY_SYNC_END event arrives. Post it so that
// poll completes (this is what wedged Dead Cells at FLIP 0 in-level).
EnqueueEvent(EventTypeSaveDataMemorySyncEnd, userId, string.Empty);
return ctx.SetReturn(0);
}
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
@@ -0,0 +1,130 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpEmu.Libs.SaveData;
/// <summary>
/// Host-side layout and metadata for PS5 save data. Saves live under
/// <c>~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/</c> (overridable via
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
/// <c>&lt;slot&gt;/sce_sys/</c>. This type is pure filesystem logic with no guest
/// interop so the path and metadata handling can be unit-tested.
/// </summary>
public static class SaveDataStorage
{
/// <summary>Root of all saves: the env override, else <c>~/SharpEmu/Saves</c>.</summary>
public static string Root(string? overrideDir = null)
{
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"SharpEmu",
"Saves")
: configured;
return Path.GetFullPath(root);
}
/// <summary>Per-title directory: <c>&lt;root&gt;/&lt;titleId&gt;</c>.</summary>
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
/// <summary>A single save slot: <c>&lt;titleRoot&gt;/&lt;dirName&gt;</c>.</summary>
public static string SlotDir(string titleRoot, string dirName) =>
Path.Combine(titleRoot, Sanitize(dirName));
/// <summary>The SaveDataMemory blob shared by a title.</summary>
public static string MemoryPath(string titleRoot) =>
Path.Combine(titleRoot, "sce_sdmemory", "memory.dat");
public static string ParamPath(string slotDir) =>
Path.Combine(slotDir, "sce_sys", "param.json");
public static string IconPath(string slotDir) =>
Path.Combine(slotDir, "sce_sys", "icon0.png");
/// <summary>
/// Replaces characters that are invalid in a host path segment. Empty or
/// all-invalid input collapses to "default" so a bad guest name can never
/// escape the save root or produce an empty segment.
/// </summary>
public static string Sanitize(string value)
{
if (string.IsNullOrEmpty(value))
{
return "default";
}
var invalid = Path.GetInvalidFileNameChars();
Span<char> buffer = value.Length <= 128 ? stackalloc char[value.Length] : new char[value.Length];
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
buffer[i] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch;
}
var sanitized = new string(buffer).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
}
/// <summary>Reads a slot's metadata, or defaults if none has been written.</summary>
public static SaveDataMetadata ReadMetadata(string slotDir)
{
var path = ParamPath(slotDir);
if (File.Exists(path))
{
try
{
var parsed = JsonSerializer.Deserialize(File.ReadAllText(path), SaveDataMetadataContext.Default.SaveDataMetadata);
if (parsed is not null)
{
return parsed;
}
}
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
{
// Fall through to defaults on a corrupt or unreadable metadata file.
}
}
return SaveDataMetadata.CreateDefault(Path.GetFileName(slotDir.TrimEnd(Path.DirectorySeparatorChar)));
}
/// <summary>Writes a slot's metadata, creating <c>sce_sys/</c> as needed.</summary>
public static void WriteMetadata(string slotDir, SaveDataMetadata metadata)
{
var path = ParamPath(slotDir);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(metadata, SaveDataMetadataContext.Default.SaveDataMetadata));
}
}
/// <summary>PS5 save-slot metadata surfaced by sceSaveDataGetParam / the save UI.</summary>
public sealed record SaveDataMetadata
{
[JsonPropertyName("title")]
public string Title { get; init; } = "Saved Data";
[JsonPropertyName("subTitle")]
public string SubTitle { get; init; } = string.Empty;
[JsonPropertyName("detail")]
public string Detail { get; init; } = string.Empty;
[JsonPropertyName("userParam")]
public uint UserParam { get; init; }
public static SaveDataMetadata CreateDefault(string dirName) =>
new() { Title = string.IsNullOrWhiteSpace(dirName) ? "Saved Data" : dirName };
}
[JsonSerializable(typeof(SaveDataMetadata))]
[JsonSourceGenerationOptions(WriteIndented = true)]
internal sealed partial class SaveDataMetadataContext : JsonSerializerContext
{
}
+1
View File
@@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup>
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" />
<!-- SysAbi export generator + analyzers (compile-time registry, NID validation). -->
<ProjectReference Include="..\SharpEmu.SourceGenerators\SharpEmu.SourceGenerators.csproj"
@@ -1,122 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.VideoOut;
internal 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;
}
+20 -8
View File
@@ -131,9 +131,14 @@ public static class VideoOutExports
return;
}
// macOS can run either backend (Vulkan through MoltenVK, or Metal), so
// name the active one in the title to make which is in use unambiguous.
var backendSuffix = OperatingSystem.IsMacOS()
? $" ({GuestGpu.Current.BackendName})"
: string.Empty;
lock (_stateGate)
{
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}";
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
}
}
@@ -166,11 +171,12 @@ public static class VideoOutExports
HostSessionControl.RequestShutdown(reason);
// A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the Vulkan resources alive until the GUI session
// reaches its guest-safe exit path and disposes the host surface.
// own shutdown. Keep the presenter's resources alive until the GUI
// session reaches its guest-safe exit path and disposes the host
// surface.
if (!embedded)
{
VulkanVideoPresenter.RequestClose();
GuestGpu.Current.RequestClose();
}
// The embedded GUI owns the process lifetime. A guest shutdown should
@@ -1070,6 +1076,12 @@ public static class VideoOutExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// SceVideoOutBufferCategory is a 32-bit enum passed on the stack; the
// upper 32 bits of the slot are stale (games leave GNM magic there), so
// mask before validating. UNCOMPRESSED (0) and COMPRESSED (1) are both
// valid — we present either identically, so accept both.
var category = (uint)categoryRaw;
if (!TryGetPort(handle, out var port))
{
return OrbisVideoOutErrorInvalidHandle;
@@ -1090,7 +1102,7 @@ public static class VideoOutExports
return OrbisVideoOutErrorInvalidValue;
}
if (categoryRaw != 0 || option != 0)
if (category > 1 || option != 0)
{
return OrbisVideoOutErrorInvalidValue;
}
@@ -1209,7 +1221,7 @@ public static class VideoOutExports
{
TriggerFlipEvents();
}
else if (VulkanVideoPresenter.SubmitOrderedGuestAction(
else if (GuestGpu.Current.SubmitOrderedGuestAction(
TriggerFlipEvents,
$"videoout flip complete handle={handle} index={bufferIndex}") == 0)
{
@@ -1263,7 +1275,7 @@ public static class VideoOutExports
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0);
var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0);
var (draws, drawMs, pipelines, spirvCompiles) = VulkanVideoPresenter.ReadAndResetPerfCounters();
var (draws, drawMs, pipelines, spirvCompiles) = GuestGpu.Current.ReadAndResetPerfCounters();
Console.Error.WriteLine(
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
$"presented_fps={presentedCount / elapsedSeconds:F1} " +
@@ -1702,7 +1714,7 @@ public static class VideoOutExports
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
// the backend keys its guest-image registry on (see the presenter's
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
// Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
// display something rather than silently failing the flip pipeline.
@@ -337,15 +337,6 @@ internal static unsafe class VulkanVideoPresenter
// thread's physical-device query) gives shader translation and descriptor
// creation one stable aliasing contract on every conformant device.
internal const ulong GuestStorageBufferOffsetAlignment = 256;
// 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. Keep a
// bounded, non-shared pool for AGC-to-presenter ownership transfers.
internal static BoundedByteArrayPool GuestDataPool { get; } = new(
maxArrayLength: 16 * 1024 * 1024,
maxCachedBytes: 256UL * 1024 * 1024,
maxArraysPerBucket: 8);
// The pending queue and per-render drain budget bound how much guest GPU
// work can be buffered ahead of the presenter. Draws are batched into
// shared command buffers, so draining a large batch per render tick is
@@ -1719,17 +1710,6 @@ internal static unsafe class VulkanVideoPresenter
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
TextureContentIdentity, byte> _cachedTextureIdentities = new();
internal readonly record struct TextureContentIdentity(
ulong Address,
uint Width,
uint Height,
uint Format,
uint NumberType,
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler);
// Guest memory handle for render-thread self-healing: when a draw whose
// texel copy was skipped misses the texture cache (eviction, cache
// clear, or any other race), the presenter re-reads the texels itself
@@ -2932,6 +2912,7 @@ internal static unsafe class VulkanVideoPresenter
public uint InstanceCount = 1;
public PrimitiveTopology Topology = PrimitiveTopology.TriangleList;
public GuestBlendState[] Blends = [GuestBlendState.Default];
public GuestBlendConstant BlendConstant;
// Vulkan format of this draw's color target. Needed to suppress
// blending on formats Metal cannot blend (integer / 32-bit float),
// which otherwise makes vkCreateGraphicsPipelines fail and can
@@ -5375,7 +5356,7 @@ internal static unsafe class VulkanVideoPresenter
{
if (buffer.Pooled && returned.Add(buffer.Data))
{
GuestDataPool.Return(buffer.Data);
GuestDataPool.Shared.Return(buffer.Data);
}
}
@@ -5383,14 +5364,14 @@ internal static unsafe class VulkanVideoPresenter
{
if (buffer.Pooled && returned.Add(buffer.Data))
{
GuestDataPool.Return(buffer.Data);
GuestDataPool.Shared.Return(buffer.Data);
}
}
if (draw.IndexBuffer is { Pooled: true } indexBuffer &&
returned.Add(indexBuffer.Data))
{
GuestDataPool.Return(indexBuffer.Data);
GuestDataPool.Shared.Return(indexBuffer.Data);
}
}
@@ -5870,6 +5851,7 @@ internal static unsafe class VulkanVideoPresenter
InstanceCount = Math.Max(draw.InstanceCount, 1),
Topology = GetPrimitiveTopology(draw.PrimitiveType),
Blends = draw.RenderState.Blends.ToArray(),
BlendConstant = draw.RenderState.BlendConstant,
Scissor = draw.RenderState.Scissor,
Viewport = draw.RenderState.Viewport,
Raster = draw.RenderState.Raster,
@@ -6005,7 +5987,7 @@ internal static unsafe class VulkanVideoPresenter
resources.Index32Bit = indexBuffer.Is32Bit;
if (indexBuffer.Pooled)
{
GuestDataPool.Return(indexBuffer.Data);
GuestDataPool.Shared.Return(indexBuffer.Data);
}
}
@@ -6034,7 +6016,7 @@ internal static unsafe class VulkanVideoPresenter
{
if (vertex.Pooled && returnedVertexData.Add(vertex.Data))
{
GuestDataPool.Return(vertex.Data);
GuestDataPool.Shared.Return(vertex.Data);
}
}
}
@@ -6530,13 +6512,16 @@ internal static unsafe class VulkanVideoPresenter
AttachmentCount = (uint)resources.Blends.Length,
PAttachments = colorBlendAttachments,
};
var dynamicStateValues = stackalloc DynamicState[2];
var dynamicStateValues = stackalloc DynamicState[3];
dynamicStateValues[0] = DynamicState.Viewport;
dynamicStateValues[1] = DynamicState.Scissor;
// CB_BLEND_RED..ALPHA vary per draw without a pipeline
// identity change, so the constant stays dynamic.
dynamicStateValues[2] = DynamicState.BlendConstants;
var dynamicState = new PipelineDynamicStateCreateInfo
{
SType = StructureType.PipelineDynamicStateCreateInfo,
DynamicStateCount = 2,
DynamicStateCount = 3,
PDynamicStates = dynamicStateValues,
};
var depth = resources.Depth;
@@ -8312,7 +8297,7 @@ internal static unsafe class VulkanVideoPresenter
}
if (guestBuffer.Pooled)
{
GuestDataPool.Return(guestBuffer.Data);
GuestDataPool.Shared.Return(guestBuffer.Data);
}
return new GlobalBufferResource
@@ -8360,7 +8345,7 @@ internal static unsafe class VulkanVideoPresenter
{
var descriptorSize = checked((guestSize + byteBias + 3) & ~3UL);
var descriptorLength = checked((int)descriptorSize);
var snapshot = GuestDataPool.Rent(descriptorLength);
var snapshot = GuestDataPool.Shared.Rent(descriptorLength);
try
{
var snapshotData = snapshot.AsSpan(0, descriptorLength);
@@ -8389,10 +8374,10 @@ internal static unsafe class VulkanVideoPresenter
}
finally
{
GuestDataPool.Return(snapshot);
GuestDataPool.Shared.Return(snapshot);
if (guestBuffer.Pooled)
{
GuestDataPool.Return(guestBuffer.Data);
GuestDataPool.Shared.Return(guestBuffer.Data);
}
}
}
@@ -8407,7 +8392,7 @@ internal static unsafe class VulkanVideoPresenter
out var mapped);
if (guestBuffer.Pooled)
{
GuestDataPool.Return(guestBuffer.Data);
GuestDataPool.Shared.Return(guestBuffer.Data);
}
return new GlobalBufferResource
@@ -9921,7 +9906,7 @@ internal static unsafe class VulkanVideoPresenter
// into millions of writes for alternating output patterns.
const int pageSize = 4096;
const int unreadableMergeGap = 16;
var livePageBuffer = GuestDataPool.Rent(pageSize);
var livePageBuffer = GuestDataPool.Shared.Rent(pageSize);
var pageRuns = new List<(int Start, int Length)>(64);
try
{
@@ -10091,7 +10076,7 @@ internal static unsafe class VulkanVideoPresenter
}
finally
{
GuestDataPool.Return(livePageBuffer);
GuestDataPool.Shared.Return(livePageBuffer);
}
var probe = mappedBytes[..Math.Min(mappedBytes.Length, 256)];
@@ -14148,6 +14133,15 @@ internal static unsafe class VulkanVideoPresenter
drawViewport.Y += ViewportDebugEpsilon;
}
_vk.CmdSetViewport(_commandBuffer, 0, 1, &drawViewport);
// CB_BLEND_RED..ALPHA feed the CONSTANT_COLOR/CONSTANT_ALPHA factors.
var blendConstants = stackalloc float[4]
{
resources.BlendConstant.Red,
resources.BlendConstant.Green,
resources.BlendConstant.Blue,
resources.BlendConstant.Alpha,
};
_vk.CmdSetBlendConstants(_commandBuffer, blendConstants);
if (resources.VertexBuffers.Length != 0)
{
var buffers = stackalloc VkBuffer[resources.VertexBuffers.Length];
@@ -15023,7 +15017,6 @@ internal static unsafe class VulkanVideoPresenter
_vk.DestroyInstance(_instance, null);
_instance = default;
}
GuestDataPool.Trim();
}
private void RecreateSwapchainResources(string operation, Result result)
@@ -0,0 +1,58 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal;
// MSL-specific shader artifact types. These stay beside the MSL emitter (not in the
// backend-neutral SharpEmu.ShaderCompiler project): each codegen owns its own
// compiled-shader shape, mirroring Gen5SpirvShader on the Vulkan side.
public enum Gen5MslStage
{
Vertex,
Pixel,
Compute,
}
/// <summary>
/// A translated Metal shader: MSL source text plus the reflection data the Metal
/// backend needs to bind it. Buffer argument indices follow the translation
/// contract documented on <see cref="Gen5MslTranslator"/>: global memory buffers
/// occupy [[buffer(globalBufferBase + i)]] in <see cref="GlobalMemoryBindings"/>
/// order, and compute shaders reserve one trailing slot for the dispatch-limit
/// uniform. Unlike SPIR-V, Metal fixes the threadgroup size at dispatch time, so
/// the size the shader was translated for is carried here.
/// </summary>
/// <remarks>
/// <para><see cref="UniformsBufferIndex"/> is the [[buffer(N)]] slot this stage's
/// SharpEmuUniforms argument was emitted at (globalBufferBase +
/// totalGlobalBufferCount, both translation-time inputs). Stages sharing a draw
/// can disagree — a vertex stage whose guest buffers sit after the pixel
/// stage's has a higher base — so the presenter must bind the uniforms buffer
/// per stage at this exact index rather than assuming one shared slot.</para>
/// <para>Texture slots are global across a draw's stages ([[texture(
/// ImageBindingBase + i)]]). Samplers live in a per-stage argument buffer
/// bound at <see cref="SamplerArgBufferIndex"/> (Metal caps direct
/// [[sampler(N)]] slots at 16 per stage, but shaders sample more), holding one
/// sampler per sampled image. <see cref="SamplerSlots"/> maps this stage's
/// image binding index to its [[id(N)]] entry in that argument buffer, -1 for
/// storage images that take none; <see cref="SamplerCount"/> is the entry
/// count.</para>
/// </remarks>
public sealed record Gen5MslShader(
string Source,
string EntryPoint,
Gen5MslStage Stage,
IReadOnlyList<Gen5GlobalMemoryBinding> GlobalMemoryBindings,
IReadOnlyList<Gen5ImageBinding> ImageBindings,
uint AttributeCount,
IReadOnlyList<Gen5VertexInputBinding> VertexInputs,
uint ThreadgroupSizeX = 1,
uint ThreadgroupSizeY = 1,
uint ThreadgroupSizeZ = 1,
int UniformsBufferIndex = -1,
int ImageBindingBase = 0,
IReadOnlyList<int>? SamplerSlots = null,
int SamplerCount = 0,
int SamplerArgBufferIndex = -1);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,888 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal;
public static partial class Gen5MslTranslator
{
private sealed partial class CompilationContext
{
private const uint ImageDescriptorDwords = 8;
private const uint SamplerDescriptorDwords = 4;
// ---- image resources ----
/// <summary>
/// Classifies every image binding (storage vs sampled, component kind
/// from the descriptor's unified format) and seeds the PC lookup,
/// mirroring DeclareImages on the SPIR-V side. MSL needs no format on
/// the texture type — only the component type and access.
/// </summary>
private void DeclareImageKinds()
{
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var binding = _evaluation.ImageBindings[index];
_imageBindingByPc.TryAdd(binding.Pc, index);
var isStorage = Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
_imageKinds.Add((isStorage, DecodeImageComponentKind(binding.ResourceDescriptor)));
}
// Seed each binding's access from the opcode that defined it; the body
// emission (TryEmitImage) then ORs in the access of every instruction
// that resolves to the same binding, so a load and a store sharing one
// binding correctly become read_write.
_imageBindingReads = new bool[_imageKinds.Count];
_imageBindingWrites = new bool[_imageKinds.Count];
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
MarkImageBindingAccess(index, _evaluation.ImageBindings[index].Opcode);
}
// Assign one sampler per sampled image (storage images take none).
// Computed before body emission because the sample calls reference
// the slots. Samplers live in an argument buffer (see
// EmitImageArguments), so there is no 16-slot cap to dedup against —
// each image keeps its own sampler, matching the SPIR-V/Vulkan path.
_samplerSlots = new int[_imageKinds.Count];
_samplerCount = 0;
for (var index = 0; index < _imageKinds.Count; index++)
{
_samplerSlots[index] = _imageKinds[index].IsStorage ? -1 : _samplerCount++;
}
}
/// <summary>Records that <paramref name="opcode"/> reads and/or writes the
/// storage image at <paramref name="bindingIndex"/>, so EmitImageArguments
/// can pick the minimal Metal access qualifier.</summary>
private void MarkImageBindingAccess(int bindingIndex, string opcode)
{
if ((uint)bindingIndex >= (uint)_imageBindingReads.Length)
{
return;
}
if (opcode.StartsWith("ImageStore", StringComparison.Ordinal))
{
_imageBindingWrites[bindingIndex] = true;
}
else if (opcode.StartsWith("ImageAtomic", StringComparison.Ordinal))
{
_imageBindingReads[bindingIndex] = true;
_imageBindingWrites[bindingIndex] = true;
}
else
{
// ImageLoad/ImageLoadMip and ImageGetResinfo read the texture;
// sampled ops are non-storage and ignore these flags.
_imageBindingReads[bindingIndex] = true;
}
}
/// <summary>"float", "int", or "uint" from the descriptor's unified format.</summary>
private static string DecodeImageComponentKind(IReadOnlyList<uint> descriptor)
{
if (descriptor.Count < 2)
{
return "float";
}
var unifiedFormat = (descriptor[1] >> 20) & 0x1FFu;
if (!Gfx10UnifiedFormat.TryDecode(unifiedFormat, out _, out var numberType))
{
return "float";
}
return numberType switch
{
4 => "uint",
5 => "int",
_ => "float",
};
}
/// <summary>Per image binding: its sampler's [[id(N)]] inside the sampler
/// argument buffer, or -1 for storage images. Set by DeclareImageKinds.</summary>
private int[] _samplerSlots = [];
/// <summary>Number of sampled images (= sampler argument-buffer entries).</summary>
private int _samplerCount;
/// <summary>Buffer slot the sampler argument buffer binds to, past this
/// stage's global buffers, uniforms, and scalar-state buffer.</summary>
private int SamplerArgBufferIndex =>
Math.Max(UniformsBufferIndex, _initialScalarBufferIndex) + 1;
/// <summary>Emits the texture arguments (direct [[texture(N)]] slots, which
/// run to 31 — enough) plus, when the stage samples anything, the sampler
/// argument buffer. Samplers go through an argument buffer rather than
/// [[sampler(N)]] slots because Metal caps those at 16 per stage while
/// real shaders sample more (void Terrarium's scene shader: 17); argument
/// buffers have no such limit on Apple Silicon.</summary>
private void EmitImageArguments(StringBuilder source)
{
for (var index = 0; index < _imageKinds.Count; index++)
{
var (isStorage, kind) = _imageKinds[index];
var textureSlot = _imageBindingBase + index;
if (isStorage)
{
// Minimal access keeps read_write textures under Metal's cap
// of 8 per function: only images that are both read and
// written (or resolve a load and a store to one binding) need
// read_write; the rest are read-only or write-only.
var access = _imageBindingWrites[index]
? (_imageBindingReads[index] ? "read_write" : "write")
: "read";
source.AppendLine(
$" texture2d<{kind}, access::{access}> tex{index} [[texture({textureSlot})]],");
}
else
{
source.AppendLine($" texture2d<{kind}> tex{index} [[texture({textureSlot})]],");
}
}
if (_samplerCount > 0)
{
source.AppendLine(
$" constant Gen5Samplers& sharpemu_samplers [[buffer({SamplerArgBufferIndex})]],");
}
}
/// <summary>Declares the sampler argument-buffer struct at file scope (one
/// sampler per sampled image). Empty when the stage samples nothing.</summary>
private void EmitSamplerArgumentBufferStruct(StringBuilder source)
{
if (_samplerCount == 0)
{
return;
}
source.AppendLine("struct Gen5Samplers");
source.AppendLine("{");
for (var slot = 0; slot < _samplerCount; slot++)
{
source.AppendLine($" sampler smp{slot} [[id({slot})]];");
}
source.AppendLine("};");
source.AppendLine();
}
private bool TryResolveDominatingImageBinding(
Gen5ShaderInstruction instruction,
Gen5ImageControl control,
out int bindingIndex)
{
if (_imageBindingByPc.TryGetValue(instruction.Pc, out bindingIndex) &&
bindingIndex < _imageKinds.Count)
{
return true;
}
var storage = Gen5ShaderTranslator.IsStorageImageOperation(instruction.Opcode);
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var candidate = _evaluation.ImageBindings[index];
if (candidate.Control.ScalarResource != control.ScalarResource ||
candidate.Control.ScalarSampler != control.ScalarSampler ||
Gen5ShaderTranslator.IsStorageImageOperation(candidate.Opcode) != storage ||
!HasSameScalarDefinitions(
candidate.Pc,
instruction.Pc,
control.ScalarResource,
ImageDescriptorDwords) ||
(UsesSampler(instruction.Opcode) &&
!HasSameScalarDefinitions(
candidate.Pc,
instruction.Pc,
control.ScalarSampler,
SamplerDescriptorDwords)))
{
continue;
}
bindingIndex = index;
_imageBindingByPc.Add(instruction.Pc, index);
return true;
}
bindingIndex = -1;
return false;
}
private bool HasSameScalarDefinitions(
uint candidatePc,
uint targetPc,
uint firstRegister,
uint registerCount)
{
if (firstRegister + registerCount > ScalarRegisterFileCount ||
!_scalarDefinitionsBeforePc.TryGetValue(candidatePc, out var candidate) ||
!_scalarDefinitionsBeforePc.TryGetValue(targetPc, out var target))
{
return false;
}
for (var register = firstRegister;
register < firstRegister + registerCount;
register++)
{
var definition = candidate[register];
if (definition is ConflictingScalarDefinition or UnreachableScalarDefinition ||
target[register] != definition)
{
return false;
}
}
return true;
}
private static bool UsesSampler(string opcode) =>
opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
opcode.StartsWith("ImageGather", StringComparison.Ordinal);
// ---- image instruction emission ----
private bool TryEmitImage(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
out string error)
{
error = string.Empty;
if (!TryResolveDominatingImageBinding(instruction, image, out var bindingIndex))
{
error = $"unresolved image binding t=s{image.ScalarResource} s=s{image.ScalarSampler}";
return false;
}
// The resolving instruction may differ from the one that defined the
// binding (a store can dominate a load's binding); fold its access in.
MarkImageBindingAccess(bindingIndex, instruction.Opcode);
var (isStorage, kind) = _imageKinds[bindingIndex];
var texture = $"tex{bindingIndex}";
if (instruction.Opcode == "ImageGetResinfo")
{
var width = Temp("uint", isStorage
? $"{texture}.get_width()"
: $"{texture}.get_width(0)");
var height = Temp("uint", isStorage
? $"{texture}.get_height()"
: $"{texture}.get_height(0)");
uint outputIndex = 0;
for (var component = 0; component < 4; component++)
{
if ((image.Dmask & (1u << component)) == 0)
{
continue;
}
StoreVector(
image.VectorData + outputIndex++,
component switch
{
0 => width,
1 => height,
_ => "1u",
});
}
return true;
}
if (instruction.Opcode is "ImageStore" or "ImageStoreMip")
{
if (!isStorage)
{
error = "image store is not bound as storage";
return false;
}
var x = Temp("int", $"as_type<int>({ImageIntegerAddress(image, 0)})");
var y = Temp("int", $"as_type<int>({ImageIntegerAddress(image, 1)})");
var components = new string[4];
uint sourceIndex = 0;
for (var component = 0; component < 4; component++)
{
components[component] = (image.Dmask & (1u << component)) != 0
? ImageTexelComponent(kind, ImageStoreComponent(image, kind, sourceIndex++))
: kind == "float" ? "0.0f" : "0";
}
// Bounds-checked, EXEC-guarded write.
Line($"if (exec && {x} >= 0 && {y} >= 0 && {x} < (int){texture}.get_width() && {y} < (int){texture}.get_height())");
Line("{");
_indent++;
Line($"{texture}.write({VectorLiteral(kind)}({components[0]}, {components[1]}, {components[2]}, {components[3]}), uint2((uint){x}, (uint){y}));");
_indent--;
Line("}");
return true;
}
if (isStorage && instruction.Opcode is not ("ImageLoad" or "ImageLoadMip"))
{
error = $"unsupported storage image opcode {instruction.Opcode}";
return false;
}
string sampled;
var writeAllComponents = false;
if (instruction.Opcode is "ImageLoad" or "ImageLoadMip")
{
var mip = _evaluation.ImageBindings[bindingIndex].MipLevel ?? 0;
var widthQuery = isStorage ? $"{texture}.get_width()" : $"{texture}.get_width({mip}u)";
var heightQuery = isStorage ? $"{texture}.get_height()" : $"{texture}.get_height({mip}u)";
var x = Temp(
"uint",
$"(uint)clamp(as_type<int>({ImageIntegerAddress(image, 0)}), 0, (int){widthQuery} - 1)");
var y = Temp(
"uint",
$"(uint)clamp(as_type<int>({ImageIntegerAddress(image, 1)}), 0, (int){heightQuery} - 1)");
sampled = Temp(
$"vec<{kind}, 4>",
isStorage
? $"{texture}.read(uint2({x}, {y}))"
: $"{texture}.read(uint2({x}, {y}), {mip}u)");
}
else if (instruction.Opcode.StartsWith("ImageSample", StringComparison.Ordinal))
{
if (!TryEmitImageSample(instruction, image, bindingIndex, kind, out sampled, out error))
{
return false;
}
}
else if (instruction.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal))
{
if (!TryEmitImageGather(instruction, image, bindingIndex, kind, out sampled, out error))
{
return false;
}
writeAllComponents = true;
}
else
{
error = $"unsupported image opcode {instruction.Opcode}";
return false;
}
var outputValues = new List<string>(4);
for (var component = 0; component < 4; component++)
{
if (!writeAllComponents && (image.Dmask & (1u << component)) == 0)
{
continue;
}
var value = $"{sampled}[{component}]";
outputValues.Add(kind == "uint" ? value : AsUInt(value));
}
if (image.D16)
{
for (var index = 0; index < outputValues.Count; index += 2)
{
var low = outputValues[index];
var high = index + 1 < outputValues.Count ? outputValues[index + 1] : "0u";
StoreVector(
image.VectorData + (uint)(index / 2),
PackImageD16(kind, low, high));
}
}
else
{
for (var index = 0; index < outputValues.Count; index++)
{
StoreVector(image.VectorData + (uint)index, outputValues[index]);
}
}
return true;
}
private bool TryEmitImageSample(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
int bindingIndex,
string kind,
out string sampled,
out string error)
{
sampled = string.Empty;
error = string.Empty;
var opcode = instruction.Opcode;
var texture = $"tex{bindingIndex}";
var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}";
var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare = opcode.Contains("SampleC", StringComparison.Ordinal);
var hasGradients = opcode.Contains("SampleD", StringComparison.Ordinal);
var hasZeroLod = opcode.Contains("Lz", StringComparison.Ordinal);
var hasLod = !hasZeroLod && opcode.Contains("SampleL", StringComparison.Ordinal);
var hasBias = opcode.Contains("SampleB", StringComparison.Ordinal);
// RDNA MIMG address operands are ordered
// {offset}{bias}{z-compare}{derivatives}{body}; SAMPLE_L carries LOD
// as the final body component instead.
var addressCursor = 0;
var offsetX = "0";
var offsetY = "0";
if (hasOffset)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
var packed = Temp(
"int",
$"as_type<int>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
offsetX = Temp("int", $"extract_bits({packed}, 0u, 6u)");
offsetY = Temp("int", $"extract_bits({packed}, 8u, 6u)");
addressCursor += ImageFullAddressSlots(image);
}
var bias = hasBias ? Temp("float", ImageFloatAddress(image, addressCursor++)) : "0.0f";
var reference = "0.0f";
if (hasCompare)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
reference = Temp(
"float",
$"as_type<float>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
addressCursor += ImageFullAddressSlots(image);
}
var gradientX = "float2(0.0f)";
var gradientY = "float2(0.0f)";
if (hasGradients)
{
gradientX = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
gradientY = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor + 2)}, {ImageFloatAddress(image, addressCursor + 3)})");
addressCursor += 4;
}
var coordinates = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
var lod = hasZeroLod
? "0.0f"
: hasLod
? Temp("float", ImageFloatAddress(image, addressCursor + 2))
: bias;
if (hasOffset)
{
// Per-lane texel offsets fold into normalized coordinates using
// the selected mip extent, mirroring the SPIR-V translator
// (Metal sample offsets must be compile-time constants).
var explicitLod = hasGradients || hasZeroLod || hasLod;
var offsetLod = explicitLod && !hasGradients ? lod : "0.0f";
var mipLevel = Temp("uint", $"(uint)max((int)({offsetLod}), 0)");
coordinates = Temp(
"float2",
$"{coordinates} + float2((float){offsetX} / (float){texture}.get_width({mipLevel}), " +
$"(float){offsetY} / (float){texture}.get_height({mipLevel}))");
}
var samplerArguments = hasGradients
? $", gradient2d({gradientX}, {gradientY})"
: hasZeroLod || hasLod
? $", level({lod})"
: hasBias
? $", bias({bias})"
: string.Empty;
sampled = Temp(
$"vec<{kind}, 4>",
$"{texture}.sample({samplerName}, {coordinates}{samplerArguments})");
if (hasCompare)
{
// Manual PCF: reference passes when <= texel, broadcast (r,r,r,1).
var passes = Temp("bool", $"{reference} <= (float){sampled}[0]");
var one = kind == "float" ? "1.0f" : "1";
var zero = kind == "float" ? "0.0f" : "0";
sampled = Temp(
$"vec<{kind}, 4>",
$"{VectorLiteral(kind)}({passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {one})");
}
return true;
}
private bool TryEmitImageGather(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
int bindingIndex,
string kind,
out string sampled,
out string error)
{
sampled = string.Empty;
error = string.Empty;
var opcode = instruction.Opcode;
var texture = $"tex{bindingIndex}";
var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}";
var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare = opcode.Contains("Gather4C", StringComparison.Ordinal);
var addressCursor = 0;
var offset = "int2(0)";
if (hasOffset)
{
var packed = Temp(
"int",
$"as_type<int>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
offset = Temp(
"int2",
$"int2(extract_bits({packed}, 0u, 6u), extract_bits({packed}, 8u, 6u))");
addressCursor += ImageFullAddressSlots(image);
}
var reference = "0.0f";
if (hasCompare)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
reference = Temp(
"float",
$"as_type<float>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
addressCursor += ImageFullAddressSlots(image);
}
var coordinates = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
// The gathered component is selected from the first dmask bit.
uint component = 0;
while (component < 3 && (image.Dmask & (1u << (int)component)) == 0)
{
component++;
}
var componentName = hasCompare ? "x" : component switch
{
0 => "x",
1 => "y",
2 => "z",
_ => "w",
};
sampled = Temp(
$"vec<{kind}, 4>",
$"{texture}.gather({samplerName}, {coordinates}, {offset}, component::{componentName})");
if (hasCompare)
{
var one = kind == "float" ? "1.0f" : "1";
var zero = kind == "float" ? "0.0f" : "0";
var compared = Temp(
$"vec<{kind}, 4>",
$"{VectorLiteral(kind)}(" +
$"{reference} <= (float){sampled}[0] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[1] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[2] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[3] ? {one} : {zero})");
sampled = compared;
}
return true;
}
private static string VectorLiteral(string kind) => $"vec<{kind}, 4>";
private static int ImageAddressRegister(Gen5ImageControl image, int component) =>
image.A16 ? component / 2 : component;
private static int ImageFullAddressSlots(Gen5ImageControl image) =>
image.A16 ? 2 : 1;
private static int AlignFullImageAddress(Gen5ImageControl image, int component) =>
image.A16 ? (component + 1) & ~1 : component;
/// <summary>Float address component, unpacking A16 half pairs.</summary>
private string ImageFloatAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"(float)as_type<half2>(v[{register}])[{component & 1}]"
: $"as_type<float>(v[{register}])";
}
/// <summary>Integer address component, unpacking A16 16-bit pairs.</summary>
private string ImageIntegerAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"((v[{register}] >> {(component & 1) * 16}) & 0xFFFFu)"
: $"v[{register}]";
}
/// <summary>One store-source component, unpacking D16 halves.</summary>
private string ImageStoreComponent(Gen5ImageControl image, string kind, uint component)
{
if (!image.D16)
{
return $"v[{image.VectorData + component}]";
}
var packed = $"v[{image.VectorData + (component / 2)}]";
if (kind == "float")
{
return AsUInt($"(float)as_type<half2>({packed})[{component & 1}]");
}
var low = $"(({packed} >> {(component & 1) * 16}) & 0xFFFFu)";
return kind == "int"
? $"(uint)extract_bits(as_type<int>({low}), 0u, 16u)"
: low;
}
private static string ImageTexelComponent(string kind, string raw) => kind switch
{
"int" => $"as_type<int>({raw})",
"uint" => raw,
_ => $"as_type<float>({raw})",
};
private string PackImageD16(string kind, string low, string high)
{
if (kind == "float")
{
return $"(((uint)as_type<ushort>(half(as_type<float>({low})))) | (((uint)as_type<ushort>(half(as_type<float>({high})))) << 16))";
}
return $"((({low}) & 0xFFFFu) | ((({high}) & 0xFFFFu) << 16))";
}
// ---- exports ----
private bool TryEmitExport(
Gen5ShaderInstruction instruction,
Gen5ExportControl export,
out string error)
{
error = string.Empty;
if (instruction.Sources.Count < 4)
{
error = "missing export sources";
return false;
}
if (_stage == Gen5MslStage.Vertex)
{
return TryEmitVertexExport(instruction, export);
}
if (_stage != Gen5MslStage.Pixel)
{
// Compute programs have no export interface.
return true;
}
Gen5PixelOutputBinding? binding = null;
foreach (var candidate in _pixelOutputBindings)
{
if (candidate.GuestSlot == export.Target)
{
binding = candidate;
break;
}
}
if (binding is null)
{
return true;
}
var field = $"sharpemu_out.mrt{binding.Value.GuestSlot}";
var componentType = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => "uint",
Gen5PixelOutputKind.Sint => "int",
_ => "float",
};
var values = new string[4];
for (var component = 0; component < 4; component++)
{
if ((export.EnableMask & (1u << component)) == 0)
{
values[component] = $"{field}[{component}]";
continue;
}
if (export.Compressed)
{
var packed = $"v[{instruction.Sources[component >> 1].Value}]";
var half = $"(float)as_type<half2>({packed})[{component & 1}]";
values[component] = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => $"(uint)({half})",
Gen5PixelOutputKind.Sint => $"(int)({half})",
_ => half,
};
continue;
}
var raw = $"v[{instruction.Sources[component].Value}]";
values[component] = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => raw,
Gen5PixelOutputKind.Sint => $"as_type<int>({raw})",
_ => $"as_type<float>({raw})",
};
}
// A lane removed from EXEC keeps the previous output value; killed
// fragments are discarded in the epilogue.
Line($"{field} = exec ? vec<{componentType}, 4>({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
private bool TryEmitVertexExport(
Gen5ShaderInstruction instruction,
Gen5ExportControl export)
{
// Target 12 is POS0; 32..63 are the param outputs. Everything else
// (other position slots, MRTZ) is ignored like the SPIR-V side.
string field;
if (export.Target == 12)
{
field = "sharpemu_out.sharpemu_position";
}
else if (export.Target is >= 32 and < 64 &&
_vertexOutputs.Contains(export.Target - 32))
{
field = $"sharpemu_out.param{export.Target - 32}";
}
else
{
return true;
}
var values = new string[4];
for (var component = 0; component < 4; component++)
{
if ((export.EnableMask & (1u << component)) == 0)
{
values[component] = component == 3 ? "1.0f" : "0.0f";
continue;
}
if (export.Compressed)
{
var packed = $"v[{instruction.Sources[component >> 1].Value}]";
values[component] = $"(float)as_type<half2>({packed})[{component & 1}]";
continue;
}
values[component] = $"as_type<float>(v[{instruction.Sources[component].Value}])";
}
Line($"{field} = exec ? float4({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
/// <summary>
/// Vertex attribute fetch: the evaluator captured this buffer load as a
/// fixed-function vertex input, so read the stage_in field instead of
/// guest memory (bound via MTLVertexDescriptor by the backend).
/// </summary>
private bool TryEmitVertexInputFetch(
Gen5BufferMemoryControl control,
Gen5VertexInputBinding input,
out string error)
{
error = string.Empty;
if (control.DwordCount == 0 || control.DwordCount > input.ComponentCount)
{
error =
$"invalid vertex input fetch components={control.DwordCount} " +
$"input={input.ComponentCount}";
return false;
}
for (uint component = 0; component < control.DwordCount; component++)
{
var value = input.ComponentCount == 1
? $"sharpemu_vin.in{input.Location}"
: $"sharpemu_vin.in{input.Location}[{component}]";
StoreVector(control.VectorData + component, AsUInt(value));
}
return true;
}
// ---- interpolation / pixel inputs ----
private bool TryEmitInterpolation(
Gen5ShaderInstruction instruction,
Gen5InterpolationControl interpolation,
out string error)
{
error = string.Empty;
if (_stage != Gen5MslStage.Pixel ||
!_pixelAttributes.Contains(interpolation.Attribute) ||
instruction.Destinations.Count == 0 ||
instruction.Destinations[0].Kind != Gen5OperandKind.VectorRegister)
{
error = "invalid interpolated attribute";
return false;
}
StoreVector(
instruction.Destinations[0].Value,
AsUInt($"sharpemu_in.attr{interpolation.Attribute}[{interpolation.Channel}]"));
return true;
}
/// <summary>
/// Seeds pixel input VGPRs in SPI_PS_INPUT_ADDR compact order: the
/// interpolation slots reserve registers even though V_INTERP reads MSL
/// varyings directly, and the position inputs land in the
/// hardware-selected VGPRs from the fragment coordinate.
/// </summary>
private void EmitPixelInputState(StringBuilder source)
{
uint vgpr = 0;
void Advance(int bit, uint dwordCount)
{
if ((_pixelInputAddress & (1u << bit)) != 0)
{
vgpr += dwordCount;
}
}
void Position(int bit, string component)
{
var mask = 1u << bit;
if ((_pixelInputAddress & mask) == 0)
{
return;
}
if ((_pixelInputEnable & mask) != 0)
{
source.AppendLine(
$" v[{vgpr}] = as_type<uint>(sharpemu_in.sharpemu_frag_coord.{component});");
}
vgpr++;
}
Advance(0, 2); // PERSP_SAMPLE
Advance(1, 2); // PERSP_CENTER
Advance(2, 2); // PERSP_CENTROID
Advance(3, 3); // PERSP_PULL_MODEL
Advance(4, 2); // LINEAR_SAMPLE
Advance(5, 2); // LINEAR_CENTER
Advance(6, 2); // LINEAR_CENTROID
Advance(7, 1); // LINE_STIPPLE
Position(8, "x");
Position(9, "y");
Position(10, "z");
Position(11, "w");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// The fixed presenter shaders, mirroring SpirvFixedShaders semantically. The
/// MSL lives in Templates/*.msl (authored as real Metal source); this class
/// only substitutes the per-call parameters. Entry point names are stable
/// (Metal forbids "main"); textures and samplers bind at index 0; attributes
/// use the same user(locn) convention as the translated stages.
/// </summary>
public static class MslFixedShaders
{
/// <summary>
/// Fullscreen triangle from the vertex index; every attribute location in
/// 0..attributeCount-1 carries (x, y, 0, 1) so paired fragment stages can
/// read a screen-space UV from any location.
/// </summary>
public static string CreateFullscreenVertex(uint attributeCount)
{
var fields = new StringBuilder();
var stores = new StringBuilder();
for (uint index = 0; index < attributeCount; index++)
{
if (index != 0)
{
fields.AppendLine();
stores.AppendLine();
}
fields.Append($" float4 attr{index} [[user(locn{index})]];");
stores.Append($" out.attr{index} = float4(x, y, 0.0f, 1.0f);");
}
return MslTemplates.Render(
"fullscreen_vertex",
("attribute_fields", fields.ToString()),
("attribute_stores", stores.ToString()));
}
/// <summary>Samples texture 0 at the interpolated location-0 UV.</summary>
public static string CreateCopyFragment() => MslTemplates.Render("copy_fragment");
/// <summary>
/// The presenter's blit stage: samples texture 0 with V flipped, because
/// pairing the shared fullscreen triangle with Metal's y-up NDC puts UV
/// (0,0) at the bottom of the screen while textures keep v=0 at the top.
/// </summary>
public static string CreatePresentFragment() => MslTemplates.Render("present_fragment");
public static string CreateSolidFragment(float red, float green, float blue, float alpha) =>
MslTemplates.Render(
"solid_fragment",
("red", Format(red)),
("green", Format(green)),
("blue", Format(blue)),
("alpha", Format(alpha)));
/// <summary>
/// Diagnostic fragment stage exposing one interpolated vertex output
/// directly as color, isolating fragment translation from interface data.
/// </summary>
public static string CreateAttributeFragment(uint location) =>
MslTemplates.Render(
"attribute_fragment",
("location", location.ToString(CultureInfo.InvariantCulture)));
/// <summary>
/// Output-free fragment stage for fixed-function depth-only passes: the
/// guest has no pixel shader, so no color may be written while depth
/// testing still runs for the translated vertex shader.
/// </summary>
public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment");
private static string Format(float value) =>
value.ToString("0.0######", CultureInfo.InvariantCulture) + "f";
}
@@ -0,0 +1,55 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// Loads the static MSL blocks from embedded Templates/*.msl resources and
/// substitutes {{placeholder}} tokens. The static prelude and fixed shaders
/// are authored as real Metal source files; only the per-instruction body
/// emission stays programmatic in the translator.
/// </summary>
internal static class MslTemplates
{
private static readonly ConcurrentDictionary<string, string> _cache = new(StringComparer.Ordinal);
public static string Render(string name, params (string Key, string Value)[] substitutions)
{
var template = _cache.GetOrAdd(name, Load);
if (substitutions.Length == 0)
{
return template;
}
var builder = new StringBuilder(template);
foreach (var (key, value) in substitutions)
{
builder.Replace("{{" + key + "}}", value);
}
var rendered = builder.ToString();
var marker = rendered.IndexOf("{{", StringComparison.Ordinal);
if (marker >= 0)
{
var end = rendered.IndexOf("}}", marker, StringComparison.Ordinal);
var token = end > marker ? rendered[marker..(end + 2)] : "{{...";
throw new InvalidOperationException(
$"template '{name}' has an unsubstituted placeholder {token}");
}
return rendered;
}
private static string Load(string name)
{
var assembly = typeof(MslTemplates).Assembly;
var resourceName = $"SharpEmu.ShaderCompiler.Metal.Templates.{name}.msl";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"missing embedded MSL template {resourceName}");
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
@@ -0,0 +1,26 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<!-- The Metal codegen backend: consumes the backend-neutral shader IR from
SharpEmu.ShaderCompiler and emits Metal Shading Language source text.
Deliberately has no dependency on Metal bindings — emitters produce text;
renderers own APIs (the Metal backend compiles the source via MTLLibrary). -->
<PropertyGroup>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Static MSL blocks (prelude helpers, fixed shaders) authored as real
Metal source; the emitter renders them with placeholder substitution. -->
<EmbeddedResource Include="Templates\**\*.msl" />
</ItemGroup>
</Project>
@@ -0,0 +1,13 @@
#include <metal_stdlib>
using namespace metal;
struct AttributeIn
{
float4 attr{{location}} [[user(locn{{location}})]];
};
fragment float4 attribute_fs(AttributeIn in [[stage_in]])
{
return in.attr{{location}};
}
@@ -0,0 +1,16 @@
#include <metal_stdlib>
using namespace metal;
struct CopyIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 copy_fs(
CopyIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, in.attr0.xy);
}
@@ -0,0 +1,7 @@
#include <metal_stdlib>
using namespace metal;
fragment void depth_only_fs()
{
}
@@ -0,0 +1,68 @@
static constant uint sharpemu_gfx10_formats[128] = {
{{format_table}}
};
static inline void sharpemu_format_layout(uint dfmt, uint component, thread uint& byteOff, thread uint& bitOff, thread uint& bits)
{
byteOff = 0u; bitOff = 0u; bits = 0u;
switch (component * 16u + dfmt)
{
{{layout_cases}}
default: break;
}
}
static inline uint sharpemu_minifloat(uint raw, uint bits)
{
uint mantissaBits = bits - 5u;
uint mantissa = raw & ((1u << mantissaBits) - 1u);
uint exponent = (raw >> mantissaBits) & 0x1Fu;
uint shift = 23u - mantissaBits;
if (exponent == 31u)
{
return 0x7F800000u | (mantissa << shift);
}
if (exponent == 0u)
{
float scale = mantissaBits == 6u ? (1.0f / 1048576.0f) : (1.0f / 524288.0f);
return as_type<uint>((float)mantissa * scale);
}
return ((exponent + 112u) << 23) | (mantissa << shift);
}
static inline uint sharpemu_format_one(uint nfmt)
{
return (nfmt == 4u || nfmt == 5u) ? 1u : 0x3F800000u;
}
static inline uint sharpemu_format_convert(uint raw, uint bits, uint nfmt, uint dfmt)
{
uint lowMask = bits >= 32u ? 0xFFFFFFFFu : ((1u << bits) - 1u);
int signedRaw = extract_bits(as_type<int>(raw), 0u, bits);
switch (nfmt)
{
case 0u: return as_type<uint>((float)raw / (float)lowMask);
case 1u:
{
float snorm = (float)signedRaw / (float)(lowMask >> 1);
return as_type<uint>(fmax(snorm, -1.0f));
}
case 2u: return as_type<uint>((float)raw);
case 3u: return as_type<uint>((float)signedRaw);
case 5u: return (uint)signedRaw;
case 7u:
{
// 10_11_11/11_11_10 packed floats are unsigned mini-floats.
if (dfmt == 6u || dfmt == 7u)
{
return sharpemu_minifloat(raw, bits);
}
if (bits == 16u)
{
return as_type<uint>((float)as_type<half>((ushort)(raw & 0xFFFFu)));
}
return raw;
}
default: return raw;
}
}
@@ -0,0 +1,19 @@
#include <metal_stdlib>
using namespace metal;
struct FullscreenOut
{
float4 position [[position]];
{{attribute_fields}}
};
vertex FullscreenOut fullscreen_vs(uint vertex_id [[vertex_id]])
{
float x = (float)((vertex_id << 1) & 2u);
float y = (float)(vertex_id & 2u);
FullscreenOut out = {};
out.position = float4(x * 2.0f - 1.0f, y * 2.0f - 1.0f, 0.0f, 1.0f);
{{attribute_stores}}
return out;
}
@@ -0,0 +1,59 @@
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return {{ballot_return}};
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
@@ -0,0 +1,16 @@
#include <metal_stdlib>
using namespace metal;
struct PresentIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 present_fs(
PresentIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, float2(in.attr0.x, 1.0f - in.attr0.y));
}
@@ -0,0 +1,8 @@
#include <metal_stdlib>
using namespace metal;
fragment float4 solid_fs()
{
return float4({{red}}, {{green}}, {{blue}}, {{alpha}});
}
@@ -1754,6 +1754,10 @@ public static partial class Gen5SpirvTranslator
"SWaitcnt" or
"SInstPrefetch" or
"STtraceData" or
// NGG shaders bracket their exports with s_sendmsg
// (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space;
// exports are translated directly, so the message is moot.
"SSendmsg" or
"VInterpMovF32")
{
return true;
@@ -0,0 +1,228 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Text;
using SharpEmu.HLE;
using SharpEmu.Libs.SaveData;
using Xunit;
namespace SharpEmu.Libs.Tests.SaveData;
// Exercises the mount / event / param / delete exports end to end against a
// temp save root. Shares the environment-pinning collection so it never runs
// alongside other tests that mutate SHARPEMU_SAVEDATA_DIR.
[Collection("SaveDataMemoryState")]
public sealed class SaveDataExportsTests : IDisposable
{
private const ulong Base = 0x2_0000_0000;
private const int UserId = 0x1001;
private const string TitleId = "SDEXPORTTEST";
private const string MountPoint = "/savedata0";
private const string DirName = "SAVE0000";
private const ulong MountParam = Base + 0x100;
private const ulong MountResult = Base + 0x200;
private const ulong DirNamePtr = Base + 0x300;
private const ulong MountPointStr = Base + 0x340;
private const ulong EventOut = Base + 0x400;
private const ulong ParamStruct = Base + 0x500;
private const ulong DeleteParam = Base + 0xC00;
private const ulong SyncParam = Base + 0xC80;
private const ulong SetupParam = Base + 0xD00;
private const ulong SetupResult = Base + 0xD80;
private const int NoEvent = unchecked((int)0x809F0008);
private const int ParameterError = unchecked((int)0x809F0000);
private const uint MountModeCreate = 1u << 2;
private readonly FakeCpuMemory _memory = new(Base, 0x10000);
private readonly CpuContext _ctx;
private readonly string _root;
private readonly string? _previousRoot;
public SaveDataExportsTests()
{
_ctx = new CpuContext(_memory, Generation.Gen5);
_root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdexport-{Guid.NewGuid():N}");
_previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root);
SaveDataExports.ConfigureApplicationInfo(TitleId);
}
public void Dispose()
{
SaveDataExports.ConfigureApplicationInfo(null);
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot);
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private string SlotDir => Path.Combine(_root, TitleId, DirName);
private void WriteAscii(ulong address, string value)
{
var bytes = new byte[value.Length + 1];
Encoding.ASCII.GetBytes(value).CopyTo(bytes, 0);
Assert.True(_memory.TryWrite(address, bytes));
}
private CpuContext Reg(ulong rdi = 0, ulong rsi = 0, ulong rdx = 0, ulong rcx = 0)
{
_ctx[CpuRegister.Rdi] = rdi;
_ctx[CpuRegister.Rsi] = rsi;
_ctx[CpuRegister.Rdx] = rdx;
_ctx[CpuRegister.Rcx] = rcx;
return _ctx;
}
private int Mount(uint mountMode = MountModeCreate)
{
WriteAscii(DirNamePtr, DirName);
Span<byte> param = stackalloc byte[0x30];
param.Clear();
BinaryPrimitives.WriteInt32LittleEndian(param, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], DirNamePtr);
BinaryPrimitives.WriteUInt32LittleEndian(param[0x20..], mountMode);
Assert.True(_memory.TryWrite(MountParam, param));
return SaveDataExports.SaveDataMount3(Reg(rdi: MountParam, rsi: MountResult));
}
[Fact]
public void GetEventResult_WhenNoEvents_ReportsNoEvent()
{
Assert.Equal(NoEvent, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut)));
}
[Fact]
public void GetEventResult_NullOut_ReturnsParameterError()
{
Assert.Equal(ParameterError, SaveDataExports.SaveDataGetEventResult(Reg(rsi: 0)));
}
[Fact]
public void SyncSaveDataMemory_PostsSyncEndEvent_DrainedOnce()
{
// Setup the memory blob so sync succeeds.
Span<byte> setup = stackalloc byte[0x10];
setup.Clear();
BinaryPrimitives.WriteInt32LittleEndian(setup[0x04..], UserId);
BinaryPrimitives.WriteUInt64LittleEndian(setup[0x08..], 0x1000);
Assert.True(_memory.TryWrite(SetupParam, setup));
Assert.Equal(0, SaveDataExports.SaveDataSetupSaveDataMemory2(Reg(rdi: SetupParam, rdx: SetupResult)));
Span<byte> sync = stackalloc byte[0x10];
sync.Clear();
BinaryPrimitives.WriteInt32LittleEndian(sync, UserId);
Assert.True(_memory.TryWrite(SyncParam, sync));
Assert.Equal(0, SaveDataExports.SaveDataSyncSaveDataMemory(Reg(rdi: SyncParam)));
// The queued SAVE_DATA_MEMORY_SYNC_END (type 3) event is delivered once.
Assert.Equal(0, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut)));
Assert.True(_ctx.TryReadUInt32(EventOut + 0x00, out var type));
Assert.Equal(3u, type);
Assert.True(_ctx.TryReadInt32(EventOut + 0x04, out var errorCode));
Assert.Equal(0, errorCode);
Assert.Equal(NoEvent, SaveDataExports.SaveDataGetEventResult(Reg(rsi: EventOut)));
}
[Fact]
public void Mount_CreatesSlotDirectory_AndReportsMounted()
{
Assert.Equal(0, Mount());
Assert.True(Directory.Exists(SlotDir));
Assert.Equal(0, SaveDataExports.SaveDataIsMounted(Reg(rsi: EventOut)));
Assert.True(_ctx.TryReadUInt32(EventOut, out var mounted));
Assert.Equal(1u, mounted);
}
[Fact]
public void Umount_RemovesMountTracking()
{
Assert.Equal(0, Mount());
WriteAscii(MountPointStr, MountPoint);
Assert.Equal(0, SaveDataExports.SaveDataUmount2(Reg(rdi: MountPointStr)));
Assert.Equal(0, SaveDataExports.SaveDataIsMounted(Reg(rsi: EventOut)));
Assert.True(_ctx.TryReadUInt32(EventOut, out var mounted));
Assert.Equal(0u, mounted);
}
[Fact]
public void SetParam_ThenGetParam_RoundTripsThroughMetadata()
{
Assert.Equal(0, Mount());
WriteAscii(MountPointStr, MountPoint);
Span<byte> param = stackalloc byte[0x530];
param.Clear();
Encoding.ASCII.GetBytes("My Save").CopyTo(param); // +0x00 title
Encoding.ASCII.GetBytes("Biome 2").CopyTo(param[0x80..]); // +0x80 subtitle
Encoding.ASCII.GetBytes("2h 30m").CopyTo(param[0x100..]); // +0x100 detail
BinaryPrimitives.WriteUInt32LittleEndian(param[0x500..], 7); // +0x500 userParam
Assert.True(_memory.TryWrite(ParamStruct, param));
Assert.Equal(0, SaveDataExports.SaveDataSetParam(Reg(rdi: MountPointStr, rdx: ParamStruct)));
Assert.True(File.Exists(SaveDataStorage.ParamPath(SlotDir)));
var meta = SaveDataStorage.ReadMetadata(SlotDir);
Assert.Equal("My Save", meta.Title);
Assert.Equal("Biome 2", meta.SubTitle);
Assert.Equal("2h 30m", meta.Detail);
Assert.Equal(7u, meta.UserParam);
// Read it back through the export into a fresh buffer.
Span<byte> zero = stackalloc byte[0x530];
zero.Clear();
Assert.True(_memory.TryWrite(ParamStruct, zero));
Assert.Equal(0, SaveDataExports.SaveDataGetParam(Reg(rdi: MountPointStr, rdx: ParamStruct)));
var title = new byte[7];
Assert.True(_memory.TryRead(ParamStruct, title));
Assert.Equal("My Save", Encoding.ASCII.GetString(title));
}
[Fact]
public void SetParam_WithoutMount_ReturnsBadMounted()
{
WriteAscii(MountPointStr, "/notmounted");
Assert.True(_memory.TryWrite(ParamStruct, new byte[0x530]));
Assert.Equal(
unchecked((int)0x809F0013),
SaveDataExports.SaveDataSetParam(Reg(rdi: MountPointStr, rdx: ParamStruct)));
}
[Fact]
public void Delete_RemovesTheSlotDirectory()
{
Assert.Equal(0, Mount());
Assert.True(Directory.Exists(SlotDir));
WriteAscii(DirNamePtr, DirName);
Span<byte> del = stackalloc byte[0x10];
del.Clear();
BinaryPrimitives.WriteInt32LittleEndian(del, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(del[0x08..], DirNamePtr);
Assert.True(_memory.TryWrite(DeleteParam, del));
Assert.Equal(0, SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
Assert.False(Directory.Exists(SlotDir));
}
[Fact]
public void Delete_MissingSlot_ReturnsNotFound()
{
WriteAscii(DirNamePtr, "NOSUCHSLOT");
Span<byte> del = stackalloc byte[0x10];
del.Clear();
BinaryPrimitives.WriteInt32LittleEndian(del, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(del[0x08..], DirNamePtr);
Assert.True(_memory.TryWrite(DeleteParam, del));
Assert.Equal(
unchecked((int)0x809F0008),
SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
}
}
@@ -49,8 +49,9 @@ public sealed class SaveDataMemoryExportsTests : IDisposable
SaveDataExports.ConfigureApplicationInfo(TitleId);
}
// Saves are keyed by title id only (single-user layout): <root>/<titleId>/...
private string MemoryPath =>
Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat");
Path.Combine(_root, TitleId, "sce_sdmemory", "memory.dat");
public void Dispose()
{
@@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO;
using SharpEmu.Libs.SaveData;
using Xunit;
namespace SharpEmu.Libs.Tests;
/// <summary>
/// Save data lives under ~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/ with UI
/// metadata in &lt;slot&gt;/sce_sys/param.json. These guard the pure path and
/// metadata logic that the SaveData HLE exports build on.
/// </summary>
public sealed class SaveDataStorageTests
{
[Fact]
public void RootHonorsOverrideAndFallsBackToUserProfile()
{
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root());
}
[Fact]
public void LayoutNestsTitleThenSlotThenSceSys()
{
var root = SaveDataStorage.Root("/saves");
var titleRoot = SaveDataStorage.TitleRoot(root, "PPSA15552");
var slot = SaveDataStorage.SlotDir(titleRoot, "SAVE0000");
Assert.Equal(Path.Combine(Path.GetFullPath("/saves"), "PPSA15552"), titleRoot);
Assert.Equal(Path.Combine(titleRoot, "SAVE0000"), slot);
Assert.Equal(Path.Combine(slot, "sce_sys", "param.json"), SaveDataStorage.ParamPath(slot));
Assert.Equal(Path.Combine(slot, "sce_sys", "icon0.png"), SaveDataStorage.IconPath(slot));
Assert.Equal(Path.Combine(titleRoot, "sce_sdmemory", "memory.dat"), SaveDataStorage.MemoryPath(titleRoot));
}
[Theory]
[InlineData("SAVE0000", "SAVE0000")]
[InlineData("../../etc/passwd", ".._.._etc_passwd")] // '/' separators -> '_', collapsing to one segment
[InlineData("a/b", "a_b")]
[InlineData("", "default")]
[InlineData(" ", "default")]
public void SanitizeNeutralizesPathSeparatorsAndEmpties(string input, string expected)
{
Assert.Equal(expected, SaveDataStorage.Sanitize(input));
}
[Fact]
public void SanitizedSlotStaysUnderTheTitleRoot()
{
// The dangerous part of a traversal is the separator; sanitizing it to a
// single segment keeps the slot a direct child of the title root.
var titleRoot = SaveDataStorage.TitleRoot(SaveDataStorage.Root("/saves"), "PPSA15552");
var slot = SaveDataStorage.SlotDir(titleRoot, "../escape");
Assert.Equal(titleRoot, Path.GetDirectoryName(slot));
Assert.DoesNotContain(Path.DirectorySeparatorChar, Path.GetFileName(slot));
}
[Fact]
public void MetadataRoundTripsThroughParamJson()
{
var slot = Path.Combine(Path.GetTempPath(), "sharpemu-savetest-" + Path.GetRandomFileName());
try
{
var written = new SaveDataMetadata
{
Title = "Dead Cells",
SubTitle = "The Prisoners' Quarters",
Detail = "Cell 1 - 3h 12m",
UserParam = 42,
};
SaveDataStorage.WriteMetadata(slot, written);
Assert.True(File.Exists(SaveDataStorage.ParamPath(slot)));
var read = SaveDataStorage.ReadMetadata(slot);
Assert.Equal(written.Title, read.Title);
Assert.Equal(written.SubTitle, read.SubTitle);
Assert.Equal(written.Detail, read.Detail);
Assert.Equal(written.UserParam, read.UserParam);
}
finally
{
if (Directory.Exists(slot))
{
Directory.Delete(slot, recursive: true);
}
}
}
[Fact]
public void ReadMetadataDefaultsWhenMissingOrCorrupt()
{
var slot = Path.Combine(Path.GetTempPath(), "sharpemu-savetest-" + Path.GetRandomFileName());
try
{
var missing = SaveDataStorage.ReadMetadata(slot);
Assert.Equal(Path.GetFileName(slot), missing.Title);
Directory.CreateDirectory(Path.GetDirectoryName(SaveDataStorage.ParamPath(slot))!);
File.WriteAllText(SaveDataStorage.ParamPath(slot), "{ not valid json");
var corrupt = SaveDataStorage.ReadMetadata(slot);
Assert.Equal(Path.GetFileName(slot), corrupt.Title);
}
finally
{
if (Directory.Exists(slot))
{
Directory.Delete(slot, recursive: true);
}
}
}
}
@@ -0,0 +1,130 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using Xunit;
namespace SharpEmu.Libs.Tests;
/// <summary>
/// The SSE4a EXTRQ+blend idiom raises #UD -> SIGILL under Rosetta 2, so the
/// loader rewrites it to SSE4.1 at boot. Sony's toolchain allocates both the
/// blend destination and the scratch source register freely (one Dead Cells
/// build uses dest=xmm0, another dest=xmm3), so the matcher must read both from
/// the encoding rather than assume fixed registers.
/// </summary>
public sealed class Sse4aExtrqBlendPatchTests
{
// EXTRQ xmmSrc, 0x28, 0x00 ; VPBLENDD xmmDest, xmmDest, xmmSrc, 2.
private static byte[] Idiom(int dest, int src) =>
[
0x66, 0x0F, 0x78, (byte)(0xC0 | src), 0x28, 0x00,
0xC4, 0xE3, (byte)(((~dest & 0xF) << 3) | 0x01), 0x02, (byte)(0xC0 | (dest << 3) | src), 0x02,
];
// PEXTRB eax, xmmSrc, 4 ; PINSRD xmmDest, eax, 1.
private static byte[] Replacement(int dest, int src) =>
[
0x66, 0x0F, 0x3A, 0x14, (byte)(0xC0 | (src << 3)), 0x04,
0x66, 0x0F, 0x3A, 0x22, (byte)(0xC0 | (dest << 3)), 0x01,
];
[Fact]
public void MatchesEveryDestinationAndSourceCombination()
{
for (var dest = 0; dest <= 7; dest++)
{
for (var src = 0; src <= 7; src++)
{
Assert.True(
Sse4aExtrqBlendPatch.TryMatch(Idiom(dest, src), out var matchedDest, out var matchedSrc),
$"dest={dest} src={src}");
Assert.Equal(dest, matchedDest);
Assert.Equal(src, matchedSrc);
}
}
}
[Fact]
public void MatchesTheDeadCellsXmm3Xmm4Idiom()
{
// The exact bytes that faulted: EXTRQ xmm4,0x28,0x00 ; VPBLENDD xmm3,xmm3,xmm4,2.
byte[] bytes = [0x66, 0x0F, 0x78, 0xC4, 0x28, 0x00, 0xC4, 0xE3, 0x61, 0x02, 0xDC, 0x02];
Assert.True(Sse4aExtrqBlendPatch.TryMatch(bytes, out var dest, out var src));
Assert.Equal(3, dest);
Assert.Equal(4, src);
}
[Fact]
public void RoundTripsEveryCombinationThroughMatchThenEncode()
{
for (var dest = 0; dest <= 7; dest++)
{
for (var src = 0; src <= 7; src++)
{
Assert.True(Sse4aExtrqBlendPatch.TryMatch(Idiom(dest, src), out var matchedDest, out var matchedSrc));
var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength];
Assert.True(Sse4aExtrqBlendPatch.TryEncode(matchedDest, matchedSrc, buffer));
Assert.Equal(Replacement(dest, src), buffer);
}
}
}
[Fact]
public void PreservesTheOriginalXmm0DestinationEncoding()
{
// Guards the behaviour the previous matcher (dest fixed to xmm0) produced.
var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength];
Assert.True(Sse4aExtrqBlendPatch.TryEncode(destRegister: 0, srcRegister: 2, buffer));
Assert.Equal(
new byte[] { 0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04, 0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01 },
buffer);
}
[Fact]
public void RejectsMismatchedSourceAcrossTheTwoInstructions()
{
// EXTRQ masks xmm1 but the blend reads xmm2 — not the paired idiom.
var mixed = Idiom(dest: 0, src: 1);
mixed[10] = 0xC0 | 2;
Assert.False(Sse4aExtrqBlendPatch.TryMatch(mixed, out _, out _));
}
[Theory]
[InlineData(0)] // wrong first byte
[InlineData(2)] // wrong opcode
[InlineData(4)] // wrong EXTRQ length immediate
[InlineData(9)] // wrong blend opcode
[InlineData(11)] // wrong blend immediate
public void RejectsSequencesThatDifferFromTheIdiom(int corruptIndex)
{
var bytes = Idiom(dest: 0, src: 2);
bytes[corruptIndex] ^= 0xFF;
Assert.False(Sse4aExtrqBlendPatch.TryMatch(bytes, out _, out _));
}
[Fact]
public void RejectsNonModRmRegisterEncodings()
{
// A memory-form ModRM (mod != 11) is not the register-to-register idiom.
var bytes = Idiom(dest: 0, src: 2);
bytes[3] = 0x02; // mod=00, rm=010 — memory operand, not xmm2 direct
Assert.False(Sse4aExtrqBlendPatch.TryMatch(bytes, out _, out _));
}
[Fact]
public void RejectsTooShortWindows()
{
Assert.False(Sse4aExtrqBlendPatch.TryMatch(Idiom(dest: 0, src: 2).AsSpan(0, 11), out _, out _));
}
[Theory]
[InlineData(-1, 0)]
[InlineData(8, 0)]
[InlineData(0, 8)]
public void EncodeRejectsRegistersOutsideXmm0Through7(int dest, int src)
{
var buffer = new byte[Sse4aExtrqBlendPatch.SequenceLength];
Assert.False(Sse4aExtrqBlendPatch.TryEncode(dest, src, buffer));
}
}
@@ -0,0 +1,47 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
// Read-only guest memory holding hand-assembled instruction words for the decoder.
// Copied (not shared) from the ShaderDump tool on purpose: shader-codegen test projects
// stay self-contained so each backend's suite can serve as a standalone model.
internal sealed class FakeGuestMemory : ICpuMemory
{
private readonly List<(ulong Base, byte[] Data)> _regions = [];
public void AddRegion(ulong baseAddress, uint[] words)
{
var bytes = new byte[words.Length * sizeof(uint)];
for (var index = 0; index < words.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(index * sizeof(uint)),
words[index]);
}
_regions.Add((baseAddress, bytes));
}
public bool TryRead(ulong virtualAddress, Span<byte> destination)
{
foreach (var (baseAddress, data) in _regions)
{
if (virtualAddress >= baseAddress &&
virtualAddress + (ulong)destination.Length <= baseAddress + (ulong)data.Length)
{
data.AsSpan(
(int)(virtualAddress - baseAddress),
destination.Length).CopyTo(destination);
return true;
}
}
return false;
}
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) => false;
}
@@ -0,0 +1,289 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
internal sealed record Gen5ComputeFixture(
string Name,
uint[] Words,
uint StoreScalarResourceBase,
int StoreBackingBytes);
/// <summary>
/// Hand-assembled Gen5 (gfx10) programs plus the decode -> (state, evaluation) -> MSL
/// pipeline the tests drive. Programs are synthetic by construction — shader binaries
/// captured from games are copyrighted content and must never land in fixtures.
/// </summary>
internal static class Gen5ComputeFixtures
{
public const ulong ProgramAddress = 0x100000;
// Straight-line ALU: VOP2 fmac + fmamk/fmaak literals + the VOP3 fmac form.
public static readonly Gen5ComputeFixture Fmac = new(
"fmac",
[
0x560A0501, // v_fmac_f32 v5, v1, v2
0x580A0501, 0x42280000, // v_fmamk_f32 v5, v1, 42.0, v2
0x5A0A0501, 0x42280000, // v_fmaak_f32 v5, v1, v2, 42.0
0xD52B0005, 0x00020501, // v_fmac_f32_e64 v5, v1, v2
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 0,
StoreBackingBytes: 0);
// VOP3 integer multiplies, low and high halves, signed and unsigned.
public static readonly Gen5ComputeFixture Muls = new(
"muls",
[
0xD5690005, 0x00020501, // v_mul_lo_u32 v5, v1, v2
0xD56A0005, 0x00020501, // v_mul_hi_u32 v5, v1, v2
0xD56B0005, 0x00020501, // v_mul_lo_i32 v5, v1, v2
0xD56C0005, 0x00020501, // v_mul_hi_i32 v5, v1, v2
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 0,
StoreBackingBytes: 0);
// End-to-end executable program: real ALU results stored to buffer 0 at dword
// offsets 0/4/8, then proof that a store with EXEC=0 does not land (offset 12
// keeps its sentinel) and that stores work again once EXEC is restored (16).
public static readonly Gen5ComputeFixture ExecStore = new(
"exec-store",
[
0xBFA10001, // s_clause 0x1 (scheduling hint)
0x7E0002FF, 0x3FC00000, // v_mov_b32 v0, 1.5f
0x7E0202FF, 0x40100000, // v_mov_b32 v1, 2.25f
0x7E0402FF, 0x41200000, // v_mov_b32 v2, 10.0f
0x56040300, // v_fmac_f32 v2, v0, v1 -> v2 = fma(1.5, 2.25, 10.0)
0x7E0602FF, 0x7FFFFFFF, // v_mov_b32 v3, 0x7FFFFFFF
0x7E0802FF, 0x00010003, // v_mov_b32 v4, 0x00010003
0xD56C0005, 0x00020903, // v_mul_hi_i32 v5, v3, v4
0xD56B0006, 0x00020903, // v_mul_lo_i32 v6, v3, v4
0xE0700000, 0x80020200, // buffer_store_dword v2, off, s[8:11], 0
0xE0700004, 0x80020500, // buffer_store_dword v5, off, s[8:11], 0 offset:4
0xE0700008, 0x80020600, // buffer_store_dword v6, off, s[8:11], 0 offset:8
0xBEFE0380, // s_mov_b32 exec_lo, 0 -> lane inactive
0xE070000C, 0x80020200, // buffer_store_dword v2 offset:12 (masked, must not land)
0xBEFE03C1, // s_mov_b32 exec_lo, -1 -> lane active again
0xE0700010, 0x80020000, // buffer_store_dword v0 offset:16
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 64);
// Control flow through the PC dispatcher: a scalar loop counting down from 5,
// accumulating 5+4+3+2+1 = 15 into v1, with the result stored to buffer 0.
// (s_sub_i32's SCC is signed overflow, so the loop condition needs an
// explicit s_cmp_lg_u32 — which also exercises the scalar-compare path.)
public static readonly Gen5ComputeFixture Loop = new(
"loop",
[
0xBE800385, // s_mov_b32 s0, 5
0x7E020280, // v_mov_b32 v1, 0
// loop: (pc=0x8)
0x4A020200, // v_add_nc_u32 v1, s0, v1
0x81808100, // s_sub_i32 s0, s0, 1 (inline +1 = 0x81)
0xBF078000, // s_cmp_lg_u32 s0, 0
0xBF85FFFC, // s_cbranch_scc1 loop
0xE0700000, 0x80020100, // buffer_store_dword v1, off, s[8:11], 0
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 16);
// LDS round trip: write a literal through workgroup-shared memory, barrier,
// read it back, and store it to buffer 0.
public static readonly Gen5ComputeFixture Lds = new(
"lds",
[
0x7E000280, // v_mov_b32 v0, 0 (LDS byte address)
0x7E0402FF, 0x00001234, // v_mov_b32 v2, 0x1234
0xD8340000, 0x00000200, // ds_write_b32 v0, v2
0xBF8A0000, // s_barrier
0xD8D80000, 0x03000000, // ds_read_b32 v3, v0
0xE0700000, 0x80020300, // buffer_store_dword v3, off, s[8:11], 0
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 16);
// Wave64 cross-lane: v_readfirstlane routes through the translator's
// two-half threadgroup-scratch broadcast bridge. All lanes hold the same
// value (42), so the broadcast result is 42 on every lane regardless of
// which lane is "first" — the fixture's job is to prove the emitted wave64
// MSL compiles and that a full 64-lane dispatch runs through the bridge
// barriers without deadlocking, returning the broadcast value.
public static readonly Gen5ComputeFixture Wave64Broadcast = new(
"wave64-broadcast",
[
0x7E0002FF, 0x0000002A, // v_mov_b32 v0, 42
0x7E000500, // v_readfirstlane_b32 s0, v0
0x7E020200, // v_mov_b32 v1, s0
0xE0700000, 0x80020100, // buffer_store_dword v1, off, s[8:11], 0
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 8,
StoreBackingBytes: 16);
public static readonly Gen5ComputeFixture[] All = [Fmac, Muls, ExecStore, Loop, Lds];
// Minimal pixel program: two interpolated attribute channels plus two
// inline constants exported to MRT0 (done+vm).
public static readonly uint[] PixelWords =
[
0xC8020002, // v_interp_mov_f32 v0, p0, attr0.x
0xC8060102, // v_interp_mov_f32 v1, p0, attr0.y
0x7E0402F2, // v_mov_b32 v2, 1.0
0x7E0602F0, // v_mov_b32 v3, 0.5
0xF800180F, 0x03020100, // exp mrt0 v0, v1, v2, v3 done vm
0xBF810000, // s_endpgm
];
// Minimal vertex program: constant position export plus one param output.
public static readonly uint[] VertexWords =
[
0x7E000280, // v_mov_b32 v0, 0
0x7E0202F2, // v_mov_b32 v1, 1.0
0xF80008CF, 0x01000000, // exp pos0 v0, v0, v0, v1 done
0xF800020F, 0x01000101, // exp param0 v1, v1, v0, v1
0xBF810000, // s_endpgm
];
public static Gen5MslShader CompileVertexOrThrow(int requiredVertexOutputCount = 0)
{
var memory = new FakeGuestMemory();
memory.AddRegion(ProgramAddress, VertexWords);
var ctx = new CpuContext(memory, Generation.Gen5);
if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError))
{
throw new InvalidOperationException($"[vertex] decode failed: {decodeError}");
}
var state = new Gen5ShaderState(program!, new uint[16], Metadata: null);
var evaluation = new Gen5ShaderEvaluation(
new uint[128],
new uint[128],
Array.Empty<Gen5ImageBinding>(),
Array.Empty<Gen5GlobalMemoryBinding>());
if (!Gen5MslTranslator.TryCompileVertexShader(
state,
evaluation,
out var shader,
out var compileError,
requiredVertexOutputCount: requiredVertexOutputCount))
{
throw new InvalidOperationException($"[vertex] MSL emit failed: {compileError}");
}
return shader;
}
public static Gen5MslShader CompilePixelOrThrow(
Gen5PixelOutputKind outputKind = Gen5PixelOutputKind.Float)
{
var memory = new FakeGuestMemory();
memory.AddRegion(ProgramAddress, PixelWords);
var ctx = new CpuContext(memory, Generation.Gen5);
if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError))
{
throw new InvalidOperationException($"[pixel] decode failed: {decodeError}");
}
var state = new Gen5ShaderState(program!, new uint[16], Metadata: null);
var evaluation = new Gen5ShaderEvaluation(
new uint[128],
new uint[128],
Array.Empty<Gen5ImageBinding>(),
Array.Empty<Gen5GlobalMemoryBinding>());
if (!Gen5MslTranslator.TryCompilePixelShader(
state,
evaluation,
outputKind,
out var shader,
out var compileError))
{
throw new InvalidOperationException($"[pixel] MSL emit failed: {compileError}");
}
return shader;
}
/// <summary>Drives the real decoder and the MSL emitter for one fixture.</summary>
public static Gen5MslShader CompileOrThrow(Gen5ComputeFixture fixture) =>
CompileOrThrow(fixture, waveLaneCount: 32, localSizeX: 32);
public static Gen5MslShader CompileOrThrow(
Gen5ComputeFixture fixture,
uint waveLaneCount,
uint localSizeX)
{
var program = DecodeOrThrow(fixture);
// Buffer stores need a global-memory binding; the emitter resolves them
// by instruction PC, so collect memory-access PCs from the decoded
// program itself.
var accessPcs = new List<uint>();
foreach (var instruction in program.Instructions)
{
if (instruction.Control is Gen5BufferMemoryControl or Gen5GlobalMemoryControl)
{
accessPcs.Add(instruction.Pc);
}
}
var globalBindings = accessPcs.Count != 0
? new[]
{
new Gen5GlobalMemoryBinding(
fixture.StoreScalarResourceBase,
0UL,
accessPcs,
new byte[Math.Max(fixture.StoreBackingBytes, 4)],
Math.Max(fixture.StoreBackingBytes, 4),
DataPooled: false)
{
Writable = true,
},
}
: Array.Empty<Gen5GlobalMemoryBinding>();
var state = new Gen5ShaderState(program, new uint[16], Metadata: null);
var evaluation = new Gen5ShaderEvaluation(
new uint[128],
new uint[128],
Array.Empty<Gen5ImageBinding>(),
globalBindings);
if (!Gen5MslTranslator.TryCompileComputeShader(
state,
evaluation,
localSizeX,
1,
1,
out var shader,
out var compileError,
waveLaneCount: waveLaneCount))
{
throw new InvalidOperationException($"[{fixture.Name}] MSL emit failed: {compileError}");
}
return shader;
}
public static Gen5ShaderProgram DecodeOrThrow(Gen5ComputeFixture fixture)
{
var memory = new FakeGuestMemory();
memory.AddRegion(ProgramAddress, fixture.Words);
var ctx = new CpuContext(memory, Generation.Gen5);
if (!Gen5ShaderTranslator.TryDecodeProgram(ctx, ProgramAddress, out var program, out var decodeError))
{
throw new InvalidOperationException($"[{fixture.Name}] decode failed: {decodeError}");
}
return program!;
}
}
@@ -0,0 +1,169 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
device uint* b0 [[buffer(0)]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 0x3FC00000u;
if (exec) { v[0] = t0; }
uint t1 = 0x40100000u;
if (exec) { v[1] = t1; }
uint t2 = 0x41200000u;
if (exec) { v[2] = t2; }
uint t3 = as_type<uint>((fma(as_type<float>(v[0]), as_type<float>(v[1]), as_type<float>(v[2]))));
if (exec) { v[2] = t3; }
uint t4 = 0x7FFFFFFFu;
if (exec) { v[3] = t4; }
uint t5 = 0x10003u;
if (exec) { v[4] = t5; }
uint t6 = as_type<uint>(mulhi(as_type<int>(v[3]), as_type<int>(v[4])));
if (exec) { v[5] = t6; }
uint t7 = ((v[3]) * (v[4]));
if (exec) { v[6] = t7; }
uint t8 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t8 + 0u, v[2], 4u);
}
uint t9 = ((0x4u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t9 + 0u, v[5], 4u);
}
uint t10 = ((0x8u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t10 + 0u, v[6], 4u);
}
uint t11 = 0u;
uint t12 = t11;
s[126] = t12;
exec = ((t12) >> sharpemu_lane & 1u) != 0u;
uint t13 = ((0xCu + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t13 + 0u, v[2], 4u);
}
uint t14 = 0xFFFFFFFFu;
uint t15 = t14;
s[126] = t15;
exec = ((t15) >> sharpemu_lane & 1u) != 0u;
uint t16 = ((0x10u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t16 + 0u, v[0], 4u);
}
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -0,0 +1,127 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(v[5]))));
if (exec) { v[5] = t0; }
uint t1 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(0x42280000u), as_type<float>(v[2]))));
if (exec) { v[5] = t1; }
uint t2 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(0x42280000u))));
if (exec) { v[5] = t2; }
uint t3 = as_type<uint>((fma(as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(v[5]))));
if (exec) { v[5] = t3; }
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -0,0 +1,135 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
device uint* b0 [[buffer(0)]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
threadgroup uint sharpemu_lds[8192];
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 0u;
if (exec) { v[0] = t0; }
uint t1 = 0x1234u;
if (exec) { v[2] = t1; }
uint t2 = v[0];
if (exec) { sharpemu_lds[(((t2) >> 2) & 8191u)] = v[2]; }
threadgroup_barrier(mem_flags::mem_threadgroup | mem_flags::mem_device);
uint t3 = v[0];
if (exec) { v[3] = sharpemu_lds[(((t3) >> 2) & 8191u)]; }
uint t4 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t4 + 0u, v[3], 4u);
}
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -0,0 +1,149 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
device uint* b0 [[buffer(0)]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(1)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 5u;
s[0] = t0;
uint t1 = 0u;
if (exec) { v[1] = t1; }
pc = 1u;
}
break;
case 1u:
{
uint t2 = ((s[0]) + (v[1]));
if (exec) { v[1] = t2; }
uint t3 = s[0];
uint t4 = 1u;
uint t5 = (t3 - t4);
s[0] = t5;
scc = ((((t3 ^ t4)) & (t3 ^ t5)) >> 31) != 0u;
uint t6 = s[0];
uint t7 = 0u;
scc = (t6) != (t7);
pc = (scc) ? 1u : 2u;
}
break;
case 2u:
{
uint t8 = ((0x0u + 0u + 0u + (0u * ((s[9] >> 16) & 0x3FFFu))) + bias[0]);
if (exec)
{
sharpemu_store_bytes(b0, sharpemu_uniforms.buffer_bytes[0], t8 + 0u, v[1], 4u);
}
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -0,0 +1,127 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return (uint)(uint64_t)simd_ballot(value);
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
kernel void gen5_cs(
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]],
uint3 sharpemu_local_id [[thread_position_in_threadgroup]],
uint3 sharpemu_group_id [[threadgroup_position_in_grid]],
uint sharpemu_lane [[thread_index_in_simdgroup]])
{
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
uint bias[1] = {};
v[0] = sharpemu_local_id.x;
v[1] = sharpemu_local_id.y;
v[2] = sharpemu_local_id.z;
active = (sharpemu_group_id.x * 32u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x
&& (sharpemu_group_id.y * 1u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y
&& (sharpemu_group_id.z * 1u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = ((v[1]) * (v[2]));
if (exec) { v[5] = t0; }
uint t1 = mulhi(v[1], v[2]);
if (exec) { v[5] = t1; }
uint t2 = ((v[1]) * (v[2]));
if (exec) { v[5] = t2; }
uint t3 = as_type<uint>(mulhi(as_type<int>(v[1]), as_type<int>(v[2])));
if (exec) { v[5] = t3; }
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
}
@@ -0,0 +1,136 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return value ? 1u : 0u;
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
struct Gen5PsIn
{
float4 sharpemu_frag_coord [[position]];
float4 attr0 [[user(locn0)]];
};
struct Gen5PsOut
{
float4 mrt0 [[color(0)]];
};
fragment Gen5PsOut gen5_ps(
Gen5PsIn sharpemu_in [[stage_in]],
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]])
{
const uint sharpemu_lane = 0u;
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
Gen5PsOut sharpemu_out = {};
uint bias[1] = {};
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
if (exec) { v[0] = as_type<uint>(sharpemu_in.attr0[0]); }
if (exec) { v[1] = as_type<uint>(sharpemu_in.attr0[1]); }
uint t0 = 0x3F800000u;
if (exec) { v[2] = t0; }
uint t1 = 0x3F000000u;
if (exec) { v[3] = t1; }
sharpemu_out.mrt0 = exec ? vec<float, 4>(as_type<float>(v[0]), as_type<float>(v[1]), as_type<float>(v[2]), as_type<float>(v[3])) : sharpemu_out.mrt0;
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
if (!exec)
{
discard_fragment();
}
return sharpemu_out;
}
@@ -0,0 +1,129 @@
// Generated by SharpEmu Gen5MslTranslator.
#include <metal_stdlib>
using namespace metal;
struct SharpEmuUniforms
{
uint dispatch_limit_x;
uint dispatch_limit_y;
uint dispatch_limit_z;
uint reserved;
uint buffer_bytes[1];
};
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return value ? 1u : 0u;
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
struct Gen5VsOut
{
float4 sharpemu_position [[position]];
float4 param0 [[user(locn0)]];
};
vertex Gen5VsOut gen5_vs(
constant SharpEmuUniforms& sharpemu_uniforms [[buffer(0)]],
uint sharpemu_vertex_id [[vertex_id]],
uint sharpemu_instance_id [[instance_id]])
{
const uint sharpemu_lane = 0u;
uint s[128] = {};
uint v[256] = {};
bool exec = true;
bool vcc = false;
bool scc = false;
uint pc = 0u;
bool active = true;
uint steps = 0u;
Gen5VsOut sharpemu_out = {};
uint bias[1] = {};
v[5] = sharpemu_vertex_id;
v[8] = sharpemu_instance_id;
s[106] = 0u;
s[107] = 0u;
s[126] = sharpemu_ballot(true);
s[127] = 0u;
while (active)
{
switch (pc)
{
case 0u:
{
uint t0 = 0u;
if (exec) { v[0] = t0; }
uint t1 = 0x3F800000u;
if (exec) { v[1] = t1; }
sharpemu_out.sharpemu_position = exec ? float4(as_type<float>(v[0]), as_type<float>(v[0]), as_type<float>(v[0]), as_type<float>(v[1])) : sharpemu_out.sharpemu_position;
sharpemu_out.param0 = exec ? float4(as_type<float>(v[1]), as_type<float>(v[1]), as_type<float>(v[0]), as_type<float>(v[1])) : sharpemu_out.param0;
active = false;
}
break;
default:
active = false;
break;
}
if (++steps >= 100000u)
{
active = false;
}
}
return sharpemu_out;
}
@@ -0,0 +1,240 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Minimal Metal.framework access via objc_msgSend — just enough to compile MSL source
/// with the OS runtime compiler and dispatch a single-thread compute kernel. Kept
/// dependency-free on purpose; object lifetimes lean on process teardown, which is fine
/// for a test host.
/// </summary>
internal static partial class MetalNative
{
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
[StructLayout(LayoutKind.Sequential)]
private struct MtlSize
{
public nuint Width;
public nuint Height;
public nuint Depth;
}
[LibraryImport(MetalFramework)]
private 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, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendVoid(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendVoid(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
private static partial void SendDispatch(nint receiver, nint selector, MtlSize threadgroups, MtlSize threadsPerThreadgroup);
private static readonly Lazy<nint> Device = new(() =>
OperatingSystem.IsMacOS() ? MTLCreateSystemDefaultDevice() : 0);
public static bool IsAvailable => Device.Value != 0;
private static nint Selector(string name) => sel_registerName(name);
private static nint NsString(string value)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
try
{
return Send(objc_getClass("NSString"), Selector("stringWithUTF8String:"), utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
private 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";
}
/// <summary>Compiles MSL source with the OS runtime compiler.</summary>
public static bool TryCompileLibrary(string source, out nint library, out string error)
{
library = 0;
error = string.Empty;
// Metal defaults to fast-math; GCN float semantics do not survive it, so the
// harness compiles the way a real Metal backend must: fast-math off.
var options = Send(Send(objc_getClass("MTLCompileOptions"), Selector("alloc")), Selector("init"));
SendVoidBool(options, Selector("setFastMathEnabled:"), false);
nint nsError = 0;
library = Send(
Device.Value,
Selector("newLibraryWithSource:options:error:"),
NsString(source),
options,
ref nsError);
if (library == 0)
{
error = DescribeError(nsError);
return false;
}
return true;
}
/// <summary>
/// Runs one thread of a compute kernel with the guest data buffer and the
/// SharpEmuUniforms constant buffer bound at the caller-supplied indices
/// (per the Gen5MslTranslator contract the uniforms index equals the
/// global-buffer count), then returns the data buffer contents.
/// </summary>
public static bool TryExecuteSingleThread(
nint library,
string entryPoint,
byte[] bufferContents,
byte[] uniformsContents,
nuint dataIndex,
nuint uniformsIndex,
out byte[] result,
out string error) =>
TryExecuteThreadgroup(
library, entryPoint, bufferContents, uniformsContents,
dataIndex, uniformsIndex, threadsPerThreadgroup: 1, out result, out error);
/// <summary>Runs the kernel as a single threadgroup of
/// <paramref name="threadsPerThreadgroup"/> threads, so wave64 fixtures can
/// exercise both 32-wide simdgroups of one guest wave under a threadgroup
/// barrier.</summary>
public static bool TryExecuteThreadgroup(
nint library,
string entryPoint,
byte[] bufferContents,
byte[] uniformsContents,
nuint dataIndex,
nuint uniformsIndex,
uint threadsPerThreadgroup,
out byte[] result,
out string error)
{
result = [];
error = string.Empty;
var function = Send(library, Selector("newFunctionWithName:"), NsString(entryPoint));
if (function == 0)
{
error = $"entry point '{entryPoint}' not found in the compiled library";
return false;
}
nint nsError = 0;
var pipeline = Send(
Device.Value,
Selector("newComputePipelineStateWithFunction:error:"),
function,
ref nsError);
if (pipeline == 0)
{
error = $"pipeline creation failed: {DescribeError(nsError)}";
return false;
}
var queue = Send(Device.Value, Selector("newCommandQueue"));
nint buffer;
unsafe
{
fixed (byte* contents = bufferContents)
{
// options 0 = MTLResourceStorageModeShared: CPU-visible for readback.
buffer = SendBuffer(
Device.Value,
Selector("newBufferWithBytes:length:options:"),
(nint)contents,
(nuint)bufferContents.Length,
0);
}
}
nint uniforms;
unsafe
{
fixed (byte* contents = uniformsContents)
{
uniforms = SendBuffer(
Device.Value,
Selector("newBufferWithBytes:length:options:"),
(nint)contents,
(nuint)uniformsContents.Length,
0);
}
}
if (queue == 0 || buffer == 0 || uniforms == 0)
{
error = "failed to create command queue or buffer";
return false;
}
var commandBuffer = Send(queue, Selector("commandBuffer"));
var encoder = Send(commandBuffer, Selector("computeCommandEncoder"));
SendVoid(encoder, Selector("setComputePipelineState:"), pipeline);
SendSetBuffer(encoder, Selector("setBuffer:offset:atIndex:"), buffer, 0, dataIndex);
SendSetBuffer(encoder, Selector("setBuffer:offset:atIndex:"), uniforms, 0, uniformsIndex);
var oneGroup = new MtlSize { Width = 1, Height = 1, Depth = 1 };
var threads = new MtlSize { Width = threadsPerThreadgroup, Height = 1, Depth = 1 };
SendDispatch(encoder, Selector("dispatchThreadgroups:threadsPerThreadgroup:"), oneGroup, threads);
SendVoid(encoder, Selector("endEncoding"));
SendVoid(commandBuffer, Selector("commit"));
SendVoid(commandBuffer, Selector("waitUntilCompleted"));
var contentsPointer = Send(buffer, Selector("contents"));
if (contentsPointer == 0)
{
error = "buffer contents unavailable after execution";
return false;
}
result = new byte[bufferContents.Length];
Marshal.Copy(contentsPointer, result, 0, result.Length);
return true;
}
}
@@ -0,0 +1,265 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using Xunit;
using Xunit.Abstractions;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Tier 2 and 3: the emitted MSL must compile with the OS runtime Metal compiler, and
/// the executable fixtures must produce bit-exact results on the GPU, including EXEC
/// masking and dispatcher control flow. These tests no-op (with a note) on hosts
/// without a Metal device so the suite stays green on Windows/Linux CI; the golden
/// and structural tiers still run everywhere.
/// </summary>
public sealed class MetalRuntimeTests(ITestOutputHelper output)
{
[Fact]
public void AllFixturesCompileWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
foreach (var fixture in Gen5ComputeFixtures.All)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"[{fixture.Name}] Metal rejected the emitted MSL: {error}\n{shader.Source}");
}
}
[Fact]
public void ExecStoreProgramExecutesWithExecMasking()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
// Sentinel-filled buffer: any dword the program does not store must survive.
const uint Sentinel = 0xDEADBEEFu;
var buffer = new byte[64];
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
{
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
}
var result = ExecuteOrThrow(Gen5ComputeFixtures.ExecStore, buffer);
// Reference results computed with the same semantics the program encodes.
var fmac = BitConverter.SingleToUInt32Bits(MathF.FusedMultiplyAdd(1.5f, 2.25f, 10.0f));
var mulHiSigned = (uint)(((long)0x7FFFFFFF * 0x00010003) >> 32);
var mulLoSigned = unchecked(0x7FFFFFFFu * 0x00010003u);
var movBits = BitConverter.SingleToUInt32Bits(1.5f);
Assert.Equal(fmac, ReadDword(result, 0));
Assert.Equal(mulHiSigned, ReadDword(result, 4));
Assert.Equal(mulLoSigned, ReadDword(result, 8));
Assert.Equal(Sentinel, ReadDword(result, 12)); // EXEC=0: the store must not land.
Assert.Equal(movBits, ReadDword(result, 16));
for (var offset = 20; offset < result.Length; offset += sizeof(uint))
{
Assert.Equal(Sentinel, ReadDword(result, offset));
}
}
[Fact]
public void LoopProgramIteratesThroughTheDispatcher()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
var result = ExecuteOrThrow(Gen5ComputeFixtures.Loop, new byte[16]);
// 5 + 4 + 3 + 2 + 1, accumulated across five dispatcher round trips.
Assert.Equal(15u, ReadDword(result, 0));
}
[Fact]
public void PixelShaderCompilesWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"[pixel] Metal rejected the emitted MSL: {error}\n{shader.Source}");
}
[Fact]
public void VertexShaderCompilesWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 2);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"[vertex] Metal rejected the emitted MSL: {error}\n{shader.Source}");
}
[Fact]
public void FixedShadersCompileWithTheRuntimeMetalCompiler()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
var sources = new (string Name, string Source)[]
{
("fullscreen", MslFixedShaders.CreateFullscreenVertex(3)),
("copy", MslFixedShaders.CreateCopyFragment()),
("present", MslFixedShaders.CreatePresentFragment()),
("solid", MslFixedShaders.CreateSolidFragment(0.25f, 0.5f, 0.75f, 1f)),
("attribute", MslFixedShaders.CreateAttributeFragment(1)),
("depth-only", MslFixedShaders.CreateDepthOnlyFragment()),
};
foreach (var (name, source) in sources)
{
Assert.True(
MetalNative.TryCompileLibrary(source, out _, out var error),
$"[{name}] Metal rejected the fixed shader: {error}\n{source}");
}
}
[Fact]
public void LdsRoundTripExecutesThroughThreadgroupMemory()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
var result = ExecuteOrThrow(Gen5ComputeFixtures.Lds, new byte[16]);
// Written to LDS, barriered, read back, stored to the buffer.
Assert.Equal(0x1234u, ReadDword(result, 0));
}
[Fact]
public void Wave64CrossLaneEmitsValidMsl()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
// A wave64 program with a cross-lane op emits the threadgroup-scratch
// bridge and its barriers; the runtime Metal compiler must accept it.
var shader = Gen5ComputeFixtures.CompileOrThrow(
Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64);
Assert.Contains("sharpemu_wave_scratch", shader.Source);
Assert.Contains("threadgroup_barrier", shader.Source);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
$"Metal rejected the wave64 MSL: {error}\n{shader.Source}");
}
[Fact]
public void Wave64BroadcastExecutesAcrossBothHalvesWithoutDeadlock()
{
if (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
// 64 threads = one guest wave (two 32-wide simdgroups). The read-first-
// lane bridge takes a threadgroup_barrier reached by all 64 lanes; if
// the two halves did not rendezvous this would deadlock (the command
// buffer would never complete). Every lane holds 42, so the broadcast
// result is 42 — proving the bridge runs to completion and returns the
// published value.
var shader = Gen5ComputeFixtures.CompileOrThrow(
Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError),
$"{compileError}\n{shader.Source}");
var buffer = new byte[16];
var uniforms = new byte[16 + sizeof(uint)];
// Dispatch limit 64: all 64 lanes active (see WriteDispatchLimit contract).
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 64);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length);
Assert.True(
MetalNative.TryExecuteThreadgroup(
library,
shader.EntryPoint,
buffer,
uniforms,
dataIndex: 0,
uniformsIndex: (nuint)shader.GlobalMemoryBindings.Count,
threadsPerThreadgroup: 64,
out var result,
out var runError),
runError);
Assert.Equal(42u, ReadDword(result, 0));
}
private static byte[] ExecuteOrThrow(Gen5ComputeFixture fixture, byte[] buffer)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
if (!MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError))
{
throw new InvalidOperationException($"[{fixture.Name}] {compileError}\n{shader.Source}");
}
// Per the translator contract, global buffers occupy indices
// 0..count-1 and SharpEmuUniforms sits at index count; the executable
// fixtures bind exactly one data buffer.
var bufferCount = shader.GlobalMemoryBindings.Count;
Assert.Equal(1, bufferCount);
// SharpEmuUniforms: dispatch limit (one thread), reserved, then the
// byte length of each bound buffer (the struct's array never has fewer
// than one entry).
var uniforms = new byte[16 + (Math.Max(bufferCount, 1) * sizeof(uint))];
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length);
if (!MetalNative.TryExecuteSingleThread(
library,
shader.EntryPoint,
buffer,
uniforms,
dataIndex: 0,
uniformsIndex: (nuint)bufferCount,
out var result,
out var runError))
{
throw new InvalidOperationException($"[{fixture.Name}] {runError}");
}
return result;
}
private static uint ReadDword(byte[] buffer, int offset) =>
BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(offset));
}
@@ -0,0 +1,88 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Xunit;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Pins the emitted MSL for the synthetic fixtures. Codegen changes show up as a
/// readable text diff instead of a runtime mystery. Regenerate with
/// SHARPEMU_UPDATE_GOLDENS=1 (writes into the source tree) after intentional
/// emitter changes, then review the diff like any other code change.
/// </summary>
public sealed class MslGoldenTests
{
public static TheoryData<string> FixtureNames()
{
var data = new TheoryData<string>();
foreach (var fixture in Gen5ComputeFixtures.All)
{
data.Add(fixture.Name);
}
return data;
}
[Fact]
public void PixelShaderMatchesGolden()
{
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
AssertMatchesGolden("pixel", shader.Source);
}
[Fact]
public void VertexShaderMatchesGolden()
{
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 1);
AssertMatchesGolden("vertex", shader.Source);
}
[Theory]
[MemberData(nameof(FixtureNames))]
public void EmittedMslMatchesGolden(string name)
{
Gen5ComputeFixture? fixture = null;
foreach (var candidate in Gen5ComputeFixtures.All)
{
if (candidate.Name == name)
{
fixture = candidate;
break;
}
}
Assert.NotNull(fixture);
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
AssertMatchesGolden(name, shader.Source);
}
private static void AssertMatchesGolden(string name, string source)
{
var goldenPath = Path.Combine(AppContext.BaseDirectory, "Goldens", $"{name}.msl");
if (Environment.GetEnvironmentVariable("SHARPEMU_UPDATE_GOLDENS") == "1")
{
var sourcePath = Path.Combine(
FindSourceDirectory(),
"Goldens",
$"{name}.msl");
Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!);
File.WriteAllText(sourcePath, source);
return;
}
Assert.True(File.Exists(goldenPath), $"missing golden {goldenPath}; run with SHARPEMU_UPDATE_GOLDENS=1 to create it");
var expected = File.ReadAllText(goldenPath).ReplaceLineEndings();
Assert.Equal(expected, source.ReplaceLineEndings());
}
private static string FindSourceDirectory([System.Runtime.CompilerServices.CallerFilePath] string sourcePath = "")
{
// Binaries land in artifacts/bin (outside the project directory), so
// walking up from the test binary never finds the csproj; the compiler
// records this file's source path instead.
return Path.GetDirectoryName(sourcePath)
?? throw new InvalidOperationException("test project directory not found");
}
}
@@ -0,0 +1,163 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Xunit;
namespace SharpEmu.ShaderCompiler.Metal.Tests;
/// <summary>
/// Structural checks over the emitted MSL — these run on every platform because
/// translation is pure text generation; only the runtime tests need a Metal device.
/// </summary>
public sealed class MslTranslationTests
{
[Fact]
public void EveryFixtureTranslates()
{
foreach (var fixture in Gen5ComputeFixtures.All)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
Assert.Equal(Gen5MslStage.Compute, shader.Stage);
Assert.Equal("gen5_cs", shader.EntryPoint);
Assert.Contains("kernel void gen5_cs(", shader.Source, StringComparison.Ordinal);
Assert.Contains("while (active)", shader.Source, StringComparison.Ordinal);
}
}
[Fact]
public void ExecMaskedStoresAreGuarded()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.ExecStore);
// Every buffer store must sit behind the per-lane EXEC guard.
Assert.Contains("if (exec)", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_store_bytes(b0,", shader.Source, StringComparison.Ordinal);
// s_mov_b32 exec_lo, 0 / -1 must drive the per-lane bool.
Assert.Contains("exec = ((", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void LoopFixtureProducesMultipleDispatcherBlocks()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.Loop);
// The backward branch splits the program into at least three blocks and
// the conditional branch selects between loop head and fallthrough.
Assert.Contains("case 0u:", shader.Source, StringComparison.Ordinal);
Assert.Contains("case 1u:", shader.Source, StringComparison.Ordinal);
Assert.Contains("case 2u:", shader.Source, StringComparison.Ordinal);
Assert.Contains("pc = (scc) ?", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void DispatcherIsBoundedByDefault()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.Fmac);
Assert.Contains("if (++steps >=", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void UniformsCarryDispatchLimitAndBufferLengths()
{
var shader = Gen5ComputeFixtures.CompileOrThrow(Gen5ComputeFixtures.ExecStore);
Assert.Contains("struct SharpEmuUniforms", shader.Source, StringComparison.Ordinal);
Assert.Contains("dispatch_limit_x", shader.Source, StringComparison.Ordinal);
Assert.Contains("buffer_bytes[", shader.Source, StringComparison.Ordinal);
// One global binding: b0 at [[buffer(0)]], uniforms at [[buffer(1)]].
Assert.Contains("device uint* b0 [[buffer(0)]]", shader.Source, StringComparison.Ordinal);
Assert.Contains("[[buffer(1)]]", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void PixelStageEmitsFragmentInterface()
{
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
Assert.Equal(Gen5MslStage.Pixel, shader.Stage);
Assert.Equal("gen5_ps", shader.EntryPoint);
Assert.Equal(1u, shader.AttributeCount);
Assert.Contains("fragment Gen5PsOut gen5_ps(", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 attr0 [[user(locn0)]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("[[color(0)]]", shader.Source, StringComparison.Ordinal);
Assert.Contains("[[position]]", shader.Source, StringComparison.Ordinal);
// Interpolation reads land in VGPRs; the export writes MRT0 under EXEC
// and inactive lanes discard at the end.
Assert.Contains("as_type<uint>(sharpemu_in.attr0[0])", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_out.mrt0 = exec ?", shader.Source, StringComparison.Ordinal);
Assert.Contains("discard_fragment();", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void PixelOutputKindsSelectTheAttachmentType()
{
var uintShader = Gen5ComputeFixtures.CompilePixelOrThrow(Gen5PixelOutputKind.Uint);
Assert.Contains("uint4 mrt0 [[color(0)]];", uintShader.Source, StringComparison.Ordinal);
var sintShader = Gen5ComputeFixtures.CompilePixelOrThrow(Gen5PixelOutputKind.Sint);
Assert.Contains("int4 mrt0 [[color(0)]];", sintShader.Source, StringComparison.Ordinal);
}
[Fact]
public void VertexStageEmitsVertexInterface()
{
var shader = Gen5ComputeFixtures.CompileVertexOrThrow();
Assert.Equal(Gen5MslStage.Vertex, shader.Stage);
Assert.Equal("gen5_vs", shader.EntryPoint);
Assert.Equal(1u, shader.AttributeCount);
Assert.Contains("vertex Gen5VsOut gen5_vs(", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 sharpemu_position [[position]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 param0 [[user(locn0)]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("uint sharpemu_vertex_id [[vertex_id]],", shader.Source, StringComparison.Ordinal);
Assert.Contains("v[5] = sharpemu_vertex_id;", shader.Source, StringComparison.Ordinal);
Assert.Contains("v[8] = sharpemu_instance_id;", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_out.sharpemu_position = exec ?", shader.Source, StringComparison.Ordinal);
Assert.Contains("sharpemu_out.param0 = exec ?", shader.Source, StringComparison.Ordinal);
Assert.Contains("return sharpemu_out;", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void RequiredVertexOutputsAreZeroFilledDeclarations()
{
// The paired fragment shader reads locations 0..2; the program only
// exports param0, so 1 and 2 must still be declared (zero-filled).
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 3);
Assert.Equal(3u, shader.AttributeCount);
Assert.Contains("float4 param1 [[user(locn1)]];", shader.Source, StringComparison.Ordinal);
Assert.Contains("float4 param2 [[user(locn2)]];", shader.Source, StringComparison.Ordinal);
}
[Fact]
public void FixedShadersCoverThePresenterSurface()
{
var fullscreen = MslFixedShaders.CreateFullscreenVertex(2);
Assert.Contains("vertex FullscreenOut fullscreen_vs(", fullscreen, StringComparison.Ordinal);
Assert.Contains("float4 attr1 [[user(locn1)]];", fullscreen, StringComparison.Ordinal);
Assert.Contains("tex0.sample(smp0, in.attr0.xy)", MslFixedShaders.CreateCopyFragment(), StringComparison.Ordinal);
Assert.Contains("float4(1.0f, 0.0f, 1.0f, 1.0f)", MslFixedShaders.CreateSolidFragment(1f, 0f, 1f, 1f), StringComparison.Ordinal);
Assert.Contains("return in.attr3;", MslFixedShaders.CreateAttributeFragment(3), StringComparison.Ordinal);
Assert.Contains("fragment void depth_only_fs()", MslFixedShaders.CreateDepthOnlyFragment(), StringComparison.Ordinal);
}
[Fact]
public void UnsupportedOpcodeFailsLoudlyWithPc()
{
// v_cubeid_f32 is real but outside the phase-1 ALU set: the translator
// must name the opcode and pc instead of emitting wrong code.
var fixture = new Gen5ComputeFixture(
"unsupported",
[
0xD5C40000, 0x04060501, // v_cubeid_f32 v0, v1, v2, v3
0xBF810000, // s_endpgm
],
StoreScalarResourceBase: 0,
StoreBackingBytes: 0);
var exception = Assert.Throws<InvalidOperationException>(
() => Gen5ComputeFixtures.CompileOrThrow(fixture));
Assert.Contains("pc=0x", exception.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,32 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- The Metal runtime harness pins buffers and uses source-generated interop. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- Deliberately self-contained: this project is the model for how per-backend shader
codegen gets tested, so it depends only on the codegen under test (which brings
the shared decoder and HLE contract transitively) — never on other test projects
or the CLI tools. Helpers that exist elsewhere are copied, not referenced. -->
<ItemGroup>
<ProjectReference Include="..\..\src\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<None Include="Goldens\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>