From 94153955b0547664745b17d978e056f1d2098164 Mon Sep 17 00:00:00 2001 From: Gutemberg Ribeiro Date: Sat, 18 Jul 2026 18:32:00 +0100 Subject: [PATCH] [Gpu] Metal backend: complete IGuestGpuBackend implementation on AppKit + Metal (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 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, 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 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/, events, full CRUD Rework the SaveData HLE from a partial stub into a working subsystem: - Storage moves to ~/SharpEmu/Saves/// (was next to the exe under user/savedata//), overridable via SHARPEMU_SAVEDATA_DIR. Metadata (title/subtitle/detail/userParam) and icon live in /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. --- REUSE.toml | 2 + SharpEmu.slnx | 2 + src/SharpEmu.CLI/packages.lock.json | 7 + .../Cpu/Native/DirectExecutionBackend.cs | 32 +- .../Cpu/Native/Sse4aExtrqBlendPatch.cs | 115 + .../Memory/PhysicalVirtualMemory.cs | 9 + src/SharpEmu.HLE/GuestImageWriteTracker.cs | 45 +- src/SharpEmu.Libs/Agc/AgcExports.cs | 372 ++- .../Agc/AgcShaderCompilerHooks.cs | 3 +- src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs | 183 +- src/SharpEmu.Libs/Audio/AudioOutExports.cs | 89 +- .../Audio/AudioPropagationExports.cs | 154 ++ src/SharpEmu.Libs/Gpu/GuestDataPool.cs | 21 + src/SharpEmu.Libs/Gpu/GuestGpu.cs | 33 +- src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs | 26 +- src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs | 71 + .../Gpu/Metal/MetalCompiledGuestShader.cs | 28 + .../Gpu/Metal/MetalGuestFormats.cs | 270 ++ .../Gpu/Metal/MetalGuestGpuBackend.cs | 426 ++++ src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs | 182 ++ src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs | 430 ++++ .../Gpu/Metal/MetalVideoPresenter.Batch.cs | 50 + .../Gpu/Metal/MetalVideoPresenter.Compute.cs | 424 ++++ .../Gpu/Metal/MetalVideoPresenter.Draws.cs | 2153 ++++++++++++++++ .../Metal/MetalVideoPresenter.GuestImages.cs | 1237 +++++++++ .../Metal/MetalVideoPresenter.SnapshotPool.cs | 180 ++ .../Metal/MetalVideoPresenter.TextureCache.cs | 179 ++ .../Gpu/Metal/MetalVideoPresenter.Uploads.cs | 162 ++ .../Gpu/Metal/MetalVideoPresenter.cs | 1154 +++++++++ .../Gpu/Vulkan/VulkanGuestGpuBackend.cs | 56 + .../Kernel/KernelMemoryCompatExports.cs | 15 + .../Kernel/KernelPthreadCompatExports.cs | 50 +- src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs | 553 +++- src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs | 150 ++ .../Np/NpEntitlementAccessExports.cs | 30 + .../Np/NpUniversalDataSystemExports.cs | 12 + src/SharpEmu.Libs/Pad/PadExports.cs | 61 + src/SharpEmu.Libs/SaveData/SaveDataExports.cs | 589 ++++- src/SharpEmu.Libs/SaveData/SaveDataStorage.cs | 130 + src/SharpEmu.Libs/SharpEmu.Libs.csproj | 1 + .../VideoOut/BoundedByteArrayPool.cs | 122 - src/SharpEmu.Libs/VideoOut/VideoOutExports.cs | 28 +- .../VideoOut/VulkanVideoPresenter.cs | 63 +- .../Gen5MslShader.cs | 58 + .../Gen5MslTranslator.Alu.cs | 1610 ++++++++++++ .../Gen5MslTranslator.Pixel.cs | 888 +++++++ .../Gen5MslTranslator.cs | 2238 +++++++++++++++++ .../MslFixedShaders.cs | 81 + .../MslTemplates.cs | 55 + .../SharpEmu.ShaderCompiler.Metal.csproj | 26 + .../Templates/attribute_fragment.msl | 13 + .../Templates/copy_fragment.msl | 16 + .../Templates/depth_only_fragment.msl | 7 + .../Templates/format_prelude.msl | 68 + .../Templates/fullscreen_vertex.msl | 19 + .../Templates/prelude.msl | 59 + .../Templates/present_fragment.msl | 16 + .../Templates/solid_fragment.msl | 8 + .../Gen5SpirvTranslator.cs | 4 + .../SaveData/SaveDataExportsTests.cs | 228 ++ .../SaveData/SaveDataMemoryExportsTests.cs | 3 +- .../SaveDataStorageTests.cs | 115 + .../Sse4aExtrqBlendPatchTests.cs | 130 + .../FakeGuestMemory.cs | 47 + .../Gen5ComputeFixtures.cs | 289 +++ .../Goldens/exec-store.msl | 169 ++ .../Goldens/fmac.msl | 127 + .../Goldens/lds.msl | 135 + .../Goldens/loop.msl | 149 ++ .../Goldens/muls.msl | 127 + .../Goldens/pixel.msl | 136 + .../Goldens/vertex.msl | 129 + .../MetalNative.cs | 240 ++ .../MetalRuntimeTests.cs | 265 ++ .../MslGoldenTests.cs | 88 + .../MslTranslationTests.cs | 163 ++ ...SharpEmu.ShaderCompiler.Metal.Tests.csproj | 32 + 77 files changed, 17245 insertions(+), 392 deletions(-) create mode 100644 src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs create mode 100644 src/SharpEmu.Libs/Audio/AudioPropagationExports.cs create mode 100644 src/SharpEmu.Libs/Gpu/GuestDataPool.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs create mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs create mode 100644 src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs create mode 100644 src/SharpEmu.Libs/SaveData/SaveDataStorage.cs delete mode 100644 src/SharpEmu.Libs/VideoOut/BoundedByteArrayPool.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs create mode 100644 src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl create mode 100644 src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl create mode 100644 tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj diff --git a/REUSE.toml b/REUSE.toml index df760ef..b7162dd 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -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", diff --git a/SharpEmu.slnx b/SharpEmu.slnx index a867d51..aa18593 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -14,11 +14,13 @@ SPDX-License-Identifier: GPL-2.0-or-later + + diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json index f23527a..f41e08b 100644 --- a/src/SharpEmu.CLI/packages.lock.json +++ b/src/SharpEmu.CLI/packages.lock.json @@ -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": { diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index 7fbad04..4b40ecb 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -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 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(source, Sse4aExtrqBlendPatch.SequenceLength); + if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister)) { - if (source[i] != pattern[i]) - { - return false; - } + return false; + } + + Span replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength]; + if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement)) + { + return false; } - ReadOnlySpan 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)) { diff --git a/src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs b/src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs new file mode 100644 index 0000000..d624826 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Native/Sse4aExtrqBlendPatch.cs @@ -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; + +/// +/// 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. +/// +public static class Sse4aExtrqBlendPatch +{ + /// Length in bytes of both the matched idiom and its replacement. + public const int SequenceLength = 12; + + /// + /// Matches the 12-byte idiom, extracting the destination register D and the + /// source (scratch) register N: + /// + /// 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 + /// + /// 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. + /// + public static bool TryMatch(ReadOnlySpan 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 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; + } + + /// + /// Writes the SSE4.1 equivalent into : + /// + /// 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 + /// + /// 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. + /// + public static bool TryEncode(int destRegister, int srcRegister, Span 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; + } +} diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs index 06535ea..2eb2f84 100644 --- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs +++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs @@ -873,6 +873,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA public bool TryWrite(ulong virtualAddress, ReadOnlySpan 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 diff --git a/src/SharpEmu.HLE/GuestImageWriteTracker.cs b/src/SharpEmu.HLE/GuestImageWriteTracker.cs index 079bd09..adf7e29 100644 --- a/src/SharpEmu.HLE/GuestImageWriteTracker.cs +++ b/src/SharpEmu.HLE/GuestImageWriteTracker.cs @@ -51,9 +51,33 @@ public static unsafe class GuestImageWriteTracker private static readonly object _gate = new(); private static readonly Dictionary _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 = []; + /// 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. + 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) diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 64f48c3..39419e4 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -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); } + /// CB_BLEND_RED..ALPHA carry the constant blend color as raw + /// float bits; unwritten registers read as the reset value (0.0). + private static GuestBlendConstant DecodeBlendConstant( + IReadOnlyDictionary 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 registers, uint slot) @@ -7320,7 +7515,7 @@ public static partial class AgcExports IReadOnlyList registers, IReadOnlyList 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(); 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); + } } diff --git a/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs b/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs index d6dedbc..bb6699d 100644 --- a/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs +++ b/src/SharpEmu.Libs/Agc/AgcShaderCompilerHooks.cs @@ -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; } } diff --git a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs index dd8b3fe..abbb3ef 100644 --- a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs +++ b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs @@ -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> _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; } + /// + /// Records satisfaction for every waiter at whose + /// condition is met by — 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. + /// + 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; + } + + /// + /// Removes and returns waiters carrying a + /// 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. + /// + public static List? CollectExpiredRetries(object memory, long nowTicks) + { + List? expired = null; + lock (_gate) + { + List? 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(); + expired.Add(waiter); + list.RemoveAt(i); + } + + if (list.Count == 0) + { + emptied ??= new List(); + emptied.Add(address); + } + } + + if (emptied is not null) + { + foreach (var address in emptied) + { + _waiters.Remove(address); + } + } + } + + return expired; + } + + /// Records the value a label producer wrote, for the deadlock + /// breaker. Also latches any already-waiting waiter it satisfies. + 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); + } + + /// + /// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns + /// (and removes) waiters that have been stuck longer than + /// 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. + /// + public static List? CollectDeadlockBroken( + object memory, + long nowTicks, + long minAgeTicks) + { + List? broken = null; + lock (_gate) + { + List? 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(); + broken.Add(waiter); + list.RemoveAt(i); + } + + if (list.Count == 0) + { + emptied ??= new List(); + 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(); } } } diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs index a736b8c..ceee3fb 100644 --- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs @@ -14,6 +14,12 @@ public static class AudioOutExports private static readonly ConcurrentDictionary 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 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 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, diff --git a/src/SharpEmu.Libs/Audio/AudioPropagationExports.cs b/src/SharpEmu.Libs/Audio/AudioPropagationExports.cs new file mode 100644 index 0000000..806198e --- /dev/null +++ b/src/SharpEmu.Libs/Audio/AudioPropagationExports.cs @@ -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); +} diff --git a/src/SharpEmu.Libs/Gpu/GuestDataPool.cs b/src/SharpEmu.Libs/Gpu/GuestDataPool.cs new file mode 100644 index 0000000..d885bdc --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/GuestDataPool.cs @@ -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; + +/// +/// 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. +/// +internal static class GuestDataPool +{ + public static ArrayPool Shared { get; } = ArrayPool.Create( + maxArrayLength: 16 * 1024 * 1024, + maxArraysPerBucket: 96); +} diff --git a/src/SharpEmu.Libs/Gpu/GuestGpu.cs b/src/SharpEmu.Libs/Gpu/GuestGpu.cs index 78c6b1f..2402c60 100644 --- a/src/SharpEmu.Libs/Gpu/GuestGpu.cs +++ b/src/SharpEmu.Libs/Gpu/GuestGpu.cs @@ -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; /// /// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the /// host seam: static HLE export classes resolve the renderer through . -/// 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. /// internal static class GuestGpu { - private static readonly Lazy Instance = new(static () => new VulkanGuestGpuBackend()); + private static readonly Lazy 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(); + } } diff --git a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs index 9c143a3..70b25ad 100644 --- a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs +++ b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs @@ -36,6 +36,20 @@ internal readonly record struct GuestSampler( uint Word2, uint Word3); +/// 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. +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); } +/// 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. +internal readonly record struct GuestBlendConstant( + float Red, + float Green, + float Blue, + float Alpha); + internal sealed record GuestRenderState( IReadOnlyList Blends, GuestRect? Scissor, GuestViewport? Viewport, GuestRasterState Raster, - GuestDepthState Depth) + GuestDepthState Depth, + GuestBlendConstant BlendConstant = default) { public static GuestRenderState Default { get; } = new( [GuestBlendState.Default], diff --git a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs index 371ad82..e1b1ef4 100644 --- a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs +++ b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs @@ -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; /// internal interface IGuestGpuBackend { + /// Human-readable name of this backend ("Metal", "Vulkan"), shown in + /// the window title on macOS where either backend can run. + string BackendName { get; } + /// Starts the presenter (window + device) once; safe to call repeatedly. 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. /// 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. + + /// Scopes subsequent submissions on this thread to a named guest queue. + IDisposable EnterGuestQueue(string queueName, ulong submissionId); + + /// Enqueues an action at its exact position in the current guest queue; + /// returns its work sequence, or 0 when nothing could be enqueued. + long SubmitOrderedGuestAction(Action action, string debugName); + + /// Preserves sceAgcDcbWaitUntilSafeForRendering in queue order. + long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex); + + /// Blocks until the given work sequence completes; false on timeout, + /// close, or a non-positive sequence. + bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite); + + /// Sequence currently executing on the guest-work consumer; diagnostics only. + 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. + + /// 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). + bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType); + + /// 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). + 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(); + + /// Whether the backend's texture cache already holds this content; lets + /// the AGC layer skip copying texels out of guest memory on every draw. + bool IsTextureContentCached(in TextureContentIdentity identity); + + /// Guest memory handle for backend self-healing (cache misses re-read + /// texels directly instead of showing a fallback pattern). + void AttachGuestMemory(ICpuMemory memory); + + /// Alignment the AGC layer must apply to storage-buffer offsets before + /// they cross the seam. + ulong GuestStorageBufferOffsetAlignment { get; } + + /// Counts a guest shader translation for the perf overlay. + void CountShaderCompilation(); + + (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters(); + + /// Asks a running presenter to close its window. + void RequestClose(); } diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs new file mode 100644 index 0000000..454e585 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCompiledGuestShader.cs @@ -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; + +/// +/// The Metal backend's compiled shader: MSL source plus the reflection data +/// () 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. +/// +internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader +{ + private byte[]? _payload; + + public Gen5MslShader Shader { get; } = shader; + + /// MTLLibrary handle cached by the presenter after the first + /// runtime compile; the render loop is its only reader and writer. + internal nint CachedLibrary; + + public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source); + + public string PayloadFileExtension => "msl"; +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs new file mode 100644 index 0000000..2715eac --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestFormats.cs @@ -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; + +/// +/// 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. +/// +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, +} + +/// A sampled-texture format: the Metal pixel format plus the byte +/// layout the upload path needs. is nonzero for +/// block-compressed formats (bytes per 4x4 block); otherwise +/// applies. +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, + }; +} + +/// +/// 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. +/// +internal static class MetalGuestFormats +{ + /// 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. + 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); + } + + /// Source byte footprint of a sampled texture, block-aware — + /// the same math the AGC layer uses to size the texel copy it ships. + 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; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs new file mode 100644 index 0000000..deae08e --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs @@ -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; + +/// +/// 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. +/// +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 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 textures, + IReadOnlyList globalMemoryBuffers, + uint width, + uint height, + uint attributeCount, + IGuestCompiledShader? vertexShader = null, + uint vertexCount = 3, + uint instanceCount = 1, + uint primitiveType = 4, + GuestIndexBuffer? indexBuffer = null, + IReadOnlyList? 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 textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + GuestDepthTarget depthTarget, + IGuestCompiledShader? vertexShader = null, + uint vertexCount = 3, + uint instanceCount = 1, + uint primitiveType = 4, + GuestIndexBuffer? indexBuffer = null, + IReadOnlyList? 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 textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + IReadOnlyList targets, + IGuestCompiledShader? vertexShader = null, + uint vertexCount = 3, + uint instanceCount = 1, + uint primitiveType = 4, + GuestIndexBuffer? indexBuffer = null, + IReadOnlyList? 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 textures, + IReadOnlyList 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 textures, + IReadOnlyList 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(); + +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs new file mode 100644 index 0000000..83dbfa2 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs @@ -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; + +/// +/// 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. +/// +internal static class MetalHostInput +{ + private static readonly object Gate = new(); + private static readonly HashSet Pressed = new(); + private static volatile bool _connected; + + /// Registers this window's keyboard as the host input source. + 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); + } + + /// Called once per render frame; fires and releases scripted keys. + 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 destination) => 0; + + public string? DescribeConnectedGamepad() => null; + } + + /// Windows virtual-key semantics (the seam's contract) to macOS + /// kVK virtual key codes, covering the keys pad emulation polls. + 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; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs new file mode 100644 index 0000000..2afb7f1 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs @@ -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; +} + +/// MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output +/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5). +[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; +} + +/// +/// 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. +/// +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; + + /// + /// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is + /// pulled in by its own LibraryImport. Call once before any Class() lookup. + /// + 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); + + /// 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. + [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); + + + /// 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. + [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); + + /// One-argument BOOL sends, e.g. respondsToSelector:. + [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); + + /// Two-object-argument void sends, e.g. setObject:forKey:. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1); + + /// performSelectorOnMainThread:withObject:waitUntilDone: — the SEL + /// to perform is itself an argument, followed by the object and the wait + /// flag. + [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] + public static partial void SendVoidPerformSelector( + nint receiver, + nint selector, + nint performedSelector, + nint argument, + [MarshalAs(UnmanagedType.I1)] bool waitUntilDone); + + /// setSwizzle: on MTLTextureDescriptor. Four one-byte + /// MTLTextureSwizzle values, passed packed like the framework expects. + [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); + + /// Autoreleased NSString — only valid inside an autorelease pool + /// unless the caller retains it. + public static nint NsString(string value) + { + var utf8 = Marshal.StringToCoTaskMemUTF8(value); + try + { + return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8); + } + finally + { + Marshal.FreeCoTaskMem(utf8); + } + } + + /// Reads an NSString's UTF-8 contents, or null if the handle is nil. + 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"; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs new file mode 100644 index 0000000..72698eb --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Batch.cs @@ -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; + + /// Returns the open batch command buffer, opening one on first + /// use. Render thread only, like the drain it serves. + private static nint BeginBatchedGuestCommands(nint queue) + { + if (_batchOpen) + { + return _batchCommandBuffer; + } + + _batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + _batchOpen = _batchCommandBuffer != 0; + return _batchCommandBuffer; + } + + /// 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. + 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; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs new file mode 100644 index 0000000..97f1b3d --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Compute.cs @@ -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 _computePipelineCache = new(); + + public static long SubmitComputeDispatch( + ulong shaderAddress, + MetalCompiledGuestShader computeShader, + IReadOnlyList textures, + IReadOnlyList 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 textureHandles = stackalloc nint[dispatch.Textures.Length]; + Span 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 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); + } + + /// The live, shared storage image for a guest address: dispatches, + /// draws, blits, and flips of the same address all see one texture. + 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 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); + } + } + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs new file mode 100644 index 0000000..86740ef --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs @@ -0,0 +1,2153 @@ +// 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; + +// Translated guest draws. Submission mirrors the Vulkan presenter (offscreen and +// depth-only draws are ordered guest work publishing into guest images; onscreen +// draws ride the presentation), while execution is idiomatic Metal: render passes +// express load/clear intent directly, the driver's hazard tracking replaces the +// explicit barrier choreography, and the binding layout follows the translation +// contract documented on Gen5MslTranslator (global buffers at their flat slot, +// SharpEmuUniforms after them, textures and samplers at the image slots, vertex +// streams at a high base that never collides with global buffers). +internal static partial class MetalVideoPresenter +{ + private const nuint VertexBufferSlotBase = 26; + + /// Metal's vertex stage exposes buffer indices 0..30; setting a + /// vertex-descriptor attribute to 31 is a framework assertion that aborts + /// the process. + private const nuint MaxVertexStageBufferIndex = 30; + + private static int _vertexSlotOverflowTraces; + + /// Assigns each vertex stream a Metal buffer slot, sharing one + /// slot between attributes that read the same guest buffer — interleaved + /// vertices arrive from AGC as one per + /// attribute, so without sharing a handful of attributes exhausts the + /// vertex-stage buffer range. Deterministic over the draw's buffer array; + /// the pipeline descriptor and the bind path both derive from it. Returns + /// false when the unique streams still overflow Metal's last slot. + private static bool TryAssignVertexBufferSlots( + GuestVertexBuffer[] vertexBuffers, + Span slots) + { + var uniqueCount = 0; + var overflowed = false; + for (var index = 0; index < vertexBuffers.Length; index++) + { + var buffer = vertexBuffers[index]; + var shared = false; + if (buffer.BaseAddress != 0) + { + for (var prior = 0; prior < index; prior++) + { + var candidate = vertexBuffers[prior]; + if (candidate.BaseAddress == buffer.BaseAddress && + candidate.Stride == buffer.Stride && + candidate.Length == buffer.Length) + { + slots[index] = slots[prior]; + shared = true; + break; + } + } + } + + if (shared) + { + continue; + } + + slots[index] = VertexBufferSlotBase + (nuint)uniqueCount; + uniqueCount++; + overflowed |= slots[index] > MaxVertexStageBufferIndex; + } + + return !overflowed; + } + private const nuint UsageShaderRead = 1; + private const nuint UsageShaderWrite = 2; + private const nuint UsageRenderTarget = 4; + private static bool _tracedTriangleFan; + + private sealed record TranslatedGuestDraw( + MetalCompiledGuestShader? VertexShader, + MetalCompiledGuestShader PixelShader, + GuestDrawTexture[] Textures, + GuestMemoryBuffer[] GlobalMemoryBuffers, + GuestVertexBuffer[] VertexBuffers, + uint AttributeCount, + uint VertexCount, + uint InstanceCount, + uint PrimitiveType, + GuestIndexBuffer? IndexBuffer, + GuestRenderState RenderState); + + private sealed record OffscreenGuestDraw( + TranslatedGuestDraw Draw, + GuestRenderTarget[] Targets, + GuestDepthTarget? DepthTarget, + bool PublishTarget, + ulong ShaderAddress); + + private sealed record PipelineKey( + MetalCompiledGuestShader? VertexShader, + MetalCompiledGuestShader PixelShader, + ulong StateHash); + + private static long _perfDrawCount; + private static long _perfDrawTicks; + private static long _perfPipelineCreations; + + public static (long Draws, double DrawMs, long Pipelines) ReadAndResetDrawPerfCounters() + { + var draws = Interlocked.Exchange(ref _perfDrawCount, 0); + var ticks = Interlocked.Exchange(ref _perfDrawTicks, 0); + var pipelines = Interlocked.Exchange(ref _perfPipelineCreations, 0); + return (draws, ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency, pipelines); + } + + private static readonly Dictionary _pipelineCache = new(); + private static readonly Dictionary _samplerCache = new(); + private static readonly Dictionary _guestDepthImages = new(); + + // A depth target sampled later through its read address must resolve to + // the same image the write address produced (the Vulkan presenter matches + // either address on its depth resources). + private static readonly Dictionary _guestDepthReadAliases = new(); + + // Retired same-address render targets: a game that recreates a target with + // a new extent at the same address may still sample the old content later, + // so replacement retires the image here instead of releasing it, and the + // draw-texture path scores candidates like the Vulkan presenter's + // guest-image variants. Bounded FIFO so stale variants cannot accumulate. + private const int MaxGuestImageVariants = 32; + private static readonly Dictionary<(ulong Address, uint Width, uint Height, MtlPixelFormat Format), GuestImage> + _guestImageVariants = new(); + private static readonly Queue<(ulong Address, uint Width, uint Height, MtlPixelFormat Format)> + _guestImageVariantOrder = new(); + private static readonly Dictionary<(MtlPixelFormat Format, uint Width, uint Height), nint> + _transientTargets = new(); + + public static void SubmitTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint width, + uint height, + uint attributeCount, + MetalCompiledGuestShader? vertexShader, + uint vertexCount, + uint instanceCount, + uint primitiveType, + GuestIndexBuffer? indexBuffer, + IReadOnlyList? vertexBuffers, + GuestRenderState? renderState) + { + if (width == 0 || height == 0) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + _latestPresentation = new Presentation( + null, + width, + height, + sequence, + IsSplash: false, + RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(), + TranslatedDraw: new TranslatedGuestDraw( + vertexShader, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + vertexBuffers is null ? [] : ToArray(vertexBuffers), + attributeCount, + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + renderState ?? GuestRenderState.Default)); + if (_thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + StartPresenterLocked(); + } + } + + public static void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) + { + if (drawKind == GuestDrawKind.None || width == 0 || height == 0) + { + return; + } + + lock (_gate) + { + if (_closed || + _latestPresentation is { Pixels: null } latest && + latest.DrawKind == drawKind && + latest.Width == width && + latest.Height == height) + { + return; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + _latestPresentation = new Presentation( + null, + width, + height, + sequence, + IsSplash: false, + RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(), + DrawKind: drawKind); + if (_thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + StartPresenterLocked(); + } + } + + public static void SubmitOffscreenTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + IReadOnlyList targets, + MetalCompiledGuestShader? vertexShader, + uint vertexCount, + uint instanceCount, + uint primitiveType, + GuestIndexBuffer? indexBuffer, + IReadOnlyList? vertexBuffers, + GuestRenderState? renderState, + GuestDepthTarget? depthTarget, + ulong shaderAddress) + { + if (targets.Count == 0) + { + return; + } + + var effectiveRenderState = renderState ?? GuestRenderState.Default; + if (effectiveRenderState.Blends.Count == 1 && targets.Count > 1) + { + var blends = new GuestBlendState[targets.Count]; + for (var index = 0; index < blends.Length; index++) + { + blends[index] = effectiveRenderState.Blends[0]; + } + + effectiveRenderState = effectiveRenderState with { Blends = blends }; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + foreach (var target in targets) + { + var guestTextureFormat = GetGuestTextureFormat(target.Format, target.NumberType); + if (target.Address != 0 && guestTextureFormat != 0) + { + _availableGuestImages[target.Address] = guestTextureFormat; + } + } + + var workSequence = EnqueueGuestWorkLocked( + new OffscreenGuestDraw( + new TranslatedGuestDraw( + vertexShader, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + vertexBuffers is null ? [] : ToArray(vertexBuffers), + attributeCount, + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + effectiveRenderState), + ToArray(targets), + depthTarget, + PublishTarget: true, + shaderAddress)); + foreach (var target in targets) + { + if (target.Address != 0) + { + _guestImageWorkSequences[target.Address] = workSequence; + } + } + } + } + + public static void SubmitDepthOnlyTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + GuestDepthTarget depthTarget, + MetalCompiledGuestShader? vertexShader, + uint vertexCount, + uint instanceCount, + uint primitiveType, + GuestIndexBuffer? indexBuffer, + IReadOnlyList? vertexBuffers, + GuestRenderState? renderState, + ulong shaderAddress) + { + if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + EnqueueGuestWorkLocked( + new OffscreenGuestDraw( + new TranslatedGuestDraw( + vertexShader, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + vertexBuffers is null ? [] : ToArray(vertexBuffers), + attributeCount, + vertexCount, + instanceCount, + primitiveType, + indexBuffer, + renderState ?? GuestRenderState.Default), + [new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)], + depthTarget, + PublishTarget: false, + shaderAddress)); + } + } + + public static void SubmitStorageTranslatedDraw( + MetalCompiledGuestShader pixelShader, + IReadOnlyList textures, + IReadOnlyList globalMemoryBuffers, + uint attributeCount, + uint width, + uint height, + ulong shaderAddress) + { + var hasStorage = false; + foreach (var texture in textures) + { + hasStorage |= texture.IsStorage; + } + + if (width == 0 || height == 0 || !hasStorage) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + EnqueueGuestWorkLocked( + new OffscreenGuestDraw( + new TranslatedGuestDraw( + null, + pixelShader, + ToArray(textures), + ToArray(globalMemoryBuffers), + [], + attributeCount, + 3, + 1, + 4, + null, + GuestRenderState.Default), + [new GuestRenderTarget(Address: 0, width, height, Format: 12, NumberType: 7)], + DepthTarget: null, + PublishTarget: false, + shaderAddress)); + } + } + + private static long CurrentSubmittingQueueTailLocked() + { + var queue = _submittingGuestQueue; + return queue is { } identity && + _lastEnqueuedGuestWorkByQueue.TryGetValue(identity.Name, out var tail) + ? tail + : 0; + } + + private static T[] ToArray(IReadOnlyList source) + { + var result = new T[source.Count]; + for (var index = 0; index < result.Length; index++) + { + result[index] = source[index]; + } + + return result; + } + + private static void ExecuteOffscreenDraw(nint device, nint queue, OffscreenGuestDraw work) + { + var perfStart = System.Diagnostics.Stopwatch.GetTimestamp(); + Interlocked.Increment(ref _perfDrawCount); + VideoOut.PerfOverlay.RecordDraw(); + try + { + ExecuteOffscreenDrawCore(device, queue, work); + } + finally + { + Interlocked.Add( + ref _perfDrawTicks, + System.Diagnostics.Stopwatch.GetTimestamp() - perfStart); + } + } + + private static void ExecuteOffscreenDrawCore(nint device, nint queue, OffscreenGuestDraw work) + { + var draw = work.Draw; + var targetFormats = new MetalRenderTargetFormat[work.Targets.Length]; + for (var index = 0; index < targetFormats.Length; index++) + { + var target = work.Targets[index]; + if (!MetalGuestFormats.TryDecodeRenderTargetFormat( + target.Format, + target.NumberType, + out targetFormats[index])) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal skipped draw with unsupported color target " + + $"format={target.Format} number_type={target.NumberType}."); + ReturnPooledGuestData(draw); + return; + } + } + + if (draw.RenderState.Blends.Count != targetFormats.Length) + { + ReturnPooledGuestData(draw); + return; + } + + // Resolve color targets: published guest images by address, transient + // pooled textures for address-0 targets (depth-only and storage draws). + var targetTextures = new nint[work.Targets.Length]; + var targetLoadActions = new nuint[work.Targets.Length]; + var publishedTargets = new GuestImage?[work.Targets.Length]; + var firstWidth = work.Targets[0].Width; + var firstHeight = work.Targets[0].Height; + for (var index = 0; index < work.Targets.Length; index++) + { + var target = work.Targets[index]; + if (target.Address == 0) + { + var extentWidth = target.Width == 0 ? firstWidth : target.Width; + var extentHeight = target.Height == 0 ? firstHeight : target.Height; + targetTextures[index] = GetTransientTarget( + device, targetFormats[index].Format, extentWidth, extentHeight); + targetLoadActions[index] = LoadActionClear; + continue; + } + + var image = EnsureGuestRenderTarget(device, target, targetFormats[index].Format); + if (image is null) + { + ReturnPooledGuestData(draw); + return; + } + + publishedTargets[index] = image; + targetTextures[index] = image.Texture; + targetLoadActions[index] = image.Initialized ? LoadActionLoad : LoadActionClear; + } + + if (targetTextures[0] == 0) + { + ReturnPooledGuestData(draw); + return; + } + + // Depth attachment, keyed by guest DB address; read-only depth drops write. + GuestImage? depth = null; + var depthState = draw.RenderState.Depth; + if (work.DepthTarget is { } depthTarget && (depthState.TestEnable || depthState.WriteEnable)) + { + if (depthTarget.ReadOnly && depthState.WriteEnable) + { + depthState = depthState with { WriteEnable = false }; + } + + var depthWidth = Math.Max(depthTarget.Width, firstWidth); + var depthHeight = Math.Max(depthTarget.Height, firstHeight); + depth = EnsureGuestDepthImage(device, depthTarget, depthWidth, depthHeight); + } + + if (!TryGetDrawPipeline(device, draw, targetFormats, depth is not null, out var pipeline)) + { + ReturnPooledGuestData(draw); + return; + } + + var commandBuffer = BeginBatchedGuestCommands(queue); + Span textureHandles = stackalloc nint[draw.Textures.Length]; + Span textureOwned = stackalloc bool[draw.Textures.Length]; + ResolveDrawTextures(device, commandBuffer, draw.Textures, textureHandles, textureOwned); + + var pass = MetalNative.Send( + MetalNative.Class("MTLRenderPassDescriptor"), + MetalNative.Selector("renderPassDescriptor")); + var colorAttachments = MetalNative.Send(pass, MetalNative.Selector("colorAttachments")); + for (var index = 0; index < targetTextures.Length; index++) + { + var attachment = MetalNative.SendAtIndex( + colorAttachments, MetalNative.Selector("objectAtIndexedSubscript:"), (nuint)index); + MetalNative.SendVoid(attachment, MetalNative.Selector("setTexture:"), targetTextures[index]); + MetalNative.Send(attachment, MetalNative.Selector("setLoadAction:"), (nint)targetLoadActions[index]); + MetalNative.Send(attachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore); + } + + if (depth is not null && work.DepthTarget is { } depthDescriptor) + { + var depthAttachment = MetalNative.Send(pass, MetalNative.Selector("depthAttachment")); + MetalNative.SendVoid(depthAttachment, MetalNative.Selector("setTexture:"), depth.Texture); + MetalNative.Send( + depthAttachment, + MetalNative.Selector("setLoadAction:"), + (nint)(depth.Initialized ? LoadActionLoad : LoadActionClear)); + MetalNative.Send(depthAttachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore); + MetalNative.SendVoidDouble( + depthAttachment, MetalNative.Selector("setClearDepth:"), depthDescriptor.ClearDepth); + } + + var encoder = MetalNative.Send( + commandBuffer, MetalNative.Selector("renderCommandEncoderWithDescriptor:"), pass); + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + + EncodeRenderState(device, encoder, draw.RenderState, depthState, depth is not null, firstWidth, firstHeight); + EncodeDrawBindings(device, encoder, work, textureHandles, textureOwned, out var writeBackBuffers); + EncodeDrawCall(encoder, draw); + + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + + // CPU-visible GPU writes are ordering points in the guest command + // stream: completing this work item is the signal WaitForGuestWork + // relies on, so the batch must land and the write-back must complete + // before this item does. Pure-GPU draws stay in the open batch. + if (writeBackBuffers.Count > 0) + { + var committed = FlushBatchedGuestCommands(); + MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted")); + WriteBuffersBackToGuest(writeBackBuffers); + } + + for (var index = 0; index < publishedTargets.Length; index++) + { + if (publishedTargets[index] is { } published) + { + published.MarkContentChanged(); + } + } + + depth?.MarkContentChanged(); + ReturnPooledGuestData(draw); + } + + /// Renders a presentation-carried draw (onscreen translated draw or a + /// recognized fixed-function draw) into the reusable onscreen target. + private static nint ExecutePresentationDraw(nint device, nint queue, Presentation presentation) + { + var target = GetTransientTarget( + device, MtlPixelFormat.Bgra8Unorm, presentation.Width, presentation.Height); + if (target == 0) + { + return 0; + } + + if (presentation.TranslatedDraw is { } translatedDraw) + { + ExecuteOffscreenDrawToTexture(device, queue, translatedDraw, target); + } + else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric) + { + if (!TryGetFixedDrawPipeline(device, out var pipeline)) + { + return 0; + } + + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + var encoder = MetalNative.Send( + commandBuffer, + MetalNative.Selector("renderCommandEncoderWithDescriptor:"), + CreateClearPass(target, new MtlClearColor { Alpha = 1 })); + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + MetalNative.SendDrawPrimitives( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"), + PrimitiveTypeTriangle, + 0, + 3); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + } + + return target; + } + + private static void ExecuteOffscreenDrawToTexture( + nint device, + nint queue, + TranslatedGuestDraw draw, + nint target) + { + var formats = new[] { new MetalRenderTargetFormat(MtlPixelFormat.Bgra8Unorm, Gen5PixelOutputKind.Float) }; + if (!TryGetDrawPipeline(device, draw, formats, hasDepth: false, out var pipeline)) + { + ReturnPooledGuestData(draw); + return; + } + + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + Span textureHandles = stackalloc nint[draw.Textures.Length]; + Span textureOwned = stackalloc bool[draw.Textures.Length]; + ResolveDrawTextures(device, commandBuffer, draw.Textures, textureHandles, textureOwned); + var encoder = MetalNative.Send( + commandBuffer, + MetalNative.Selector("renderCommandEncoderWithDescriptor:"), + CreateClearPass(target, new MtlClearColor { Alpha = 1 })); + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + var work = new OffscreenGuestDraw(draw, [], null, PublishTarget: false, ShaderAddress: 0); + EncodeDrawBindings(device, encoder, work, textureHandles, textureOwned, out var writeBackBuffers); + EncodeDrawCall(encoder, draw); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + TagUploadPages(commandBuffer); + TagSnapshotResources(commandBuffer); + if (writeBackBuffers.Count > 0) + { + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted")); + WriteBuffersBackToGuest(writeBackBuffers); + } + + ReturnPooledGuestData(draw); + } + + private static void EncodeRenderState( + nint device, + nint encoder, + GuestRenderState renderState, + GuestDepthState depthState, + bool hasDepth, + uint targetWidth, + uint targetHeight) + { + // CB_BLEND_RED..ALPHA feed the CONSTANT_COLOR / CONSTANT_ALPHA blend + // factors; encoder state, so set unconditionally like the Vulkan + // presenter's dynamic blend constants. + var blendConstant = renderState.BlendConstant; + MetalNative.SendVoidBlendColor( + encoder, + MetalNative.Selector("setBlendColorRed:green:blue:alpha:"), + blendConstant.Red, + blendConstant.Green, + blendConstant.Blue, + blendConstant.Alpha); + + if (renderState.Viewport is { } viewport) + { + // Guests program Vulkan-style negative-height viewports to get y-up + // rendering out of Vulkan's y-down NDC. Metal's NDC is already + // y-up and rejects negative heights (the draw rasterizes nothing), + // so the equivalent is the normalized rect with the same on-screen + // mapping. + double originY = viewport.Y; + double height = viewport.Height; + if (height < 0) + { + originY += height; + height = -height; + } + + MetalNative.SendVoidViewport( + encoder, + MetalNative.Selector("setViewport:"), + new MtlViewport + { + OriginX = viewport.X, + OriginY = originY, + Width = viewport.Width, + Height = height, + ZNear = viewport.MinDepth, + ZFar = viewport.MaxDepth, + }); + } + + if (renderState.Scissor is { } scissor) + { + var x = (nuint)Math.Clamp(scissor.X, 0, (int)targetWidth); + var y = (nuint)Math.Clamp(scissor.Y, 0, (int)targetHeight); + var width = Math.Min(scissor.Width, targetWidth - (uint)x); + var height = Math.Min(scissor.Height, targetHeight - (uint)y); + if (width > 0 && height > 0) + { + MetalNative.SendVoidScissor( + encoder, + MetalNative.Selector("setScissorRect:"), + new MtlScissorRect { X = x, Y = y, Width = width, Height = height }); + } + } + + var raster = renderState.Raster; + // MTLCullMode: None=0, Front=1, Back=2. + var cullMode = raster switch + { + { CullFront: true, CullBack: true } => 3, + { CullFront: true } => 1, + { CullBack: true } => 2, + _ => 0, + }; + if (cullMode == 3) + { + // Culling both faces draws nothing; Metal has no such mode, so an + // empty scissor is the cheapest equivalent. + MetalNative.SendVoidScissor( + encoder, + MetalNative.Selector("setScissorRect:"), + new MtlScissorRect { X = 0, Y = 0, Width = 1, Height = 1 }); + } + else if (cullMode != 0) + { + MetalNative.Send(encoder, MetalNative.Selector("setCullMode:"), (nint)cullMode); + } + + // MTLWinding: Clockwise=0, CounterClockwise=1. + MetalNative.Send( + encoder, + MetalNative.Selector("setFrontFacingWinding:"), + raster.FrontFaceClockwise ? 0 : 1); + if (raster.Wireframe) + { + // MTLTriangleFillMode.Lines = 1. + MetalNative.Send(encoder, MetalNative.Selector("setTriangleFillMode:"), 1); + } + + if (hasDepth) + { + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLDepthStencilDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + // The guest ZFUNC encoding matches MTLCompareFunction ordering. + MetalNative.Send( + descriptor, + MetalNative.Selector("setDepthCompareFunction:"), + (nint)(depthState.TestEnable ? depthState.CompareOp & 0x7 : 7)); + MetalNative.SendVoidBool( + descriptor, MetalNative.Selector("setDepthWriteEnabled:"), depthState.WriteEnable); + var depthStencilState = MetalNative.Send( + device, MetalNative.Selector("newDepthStencilStateWithDescriptor:"), descriptor); + MetalNative.SendVoid(encoder, MetalNative.Selector("setDepthStencilState:"), depthStencilState); + } + } + + /// Builds and binds a stage's sampler argument buffer: one 8-byte + /// Tier 2 resource ID per sampled image, written into an arena slice and + /// bound at the shader's SamplerArgBufferIndex. The stage's images are + /// draw.Textures[ImageBindingBase + j] for its j-th image, matching how the + /// translator numbered SamplerSlots. No-op for stages that sample nothing. + private static void BindSamplerArgumentBuffer( + nint device, + nint encoder, + nint selSetBuffer, + MetalCompiledGuestShader shader, + GuestDrawTexture[] textures) + { + var slots = shader.Shader.SamplerSlots; + var count = shader.Shader.SamplerCount; + var argIndex = shader.Shader.SamplerArgBufferIndex; + if (slots is null || count == 0 || argIndex < 0) + { + return; + } + + var imageBase = shader.Shader.ImageBindingBase; + var slice = AllocateUpload(device, count * sizeof(ulong), out var buffer, out var offset); + slice.Clear(); + for (var j = 0; j < slots.Count; j++) + { + var slot = slots[j]; + if (slot < 0) + { + continue; + } + + var sampler = GetOrCreateSampler(device, textures[imageBase + j].Sampler); + var resourceId = MetalNative.SendGpuResourceId(sampler, MetalNative.Selector("gpuResourceID")); + System.Buffers.Binary.BinaryPrimitives.WriteUInt64LittleEndian( + slice[(slot * sizeof(ulong))..], resourceId); + } + + MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)argIndex); + } + + /// Resolves every texture a draw samples, encoding any snapshot + /// blits into ; must run before the + /// consuming encoder opens on that command buffer. + private static void ResolveDrawTextures( + nint device, + nint blitCommandBuffer, + GuestDrawTexture[] textures, + Span handles, + Span owned) + { + for (var index = 0; index < textures.Length; index++) + { + handles[index] = CreateDrawTexture( + device, blitCommandBuffer, textures[index], out var ownedTexture); + owned[index] = ownedTexture; + } + } + + /// Binds everything the translation contract names: global buffers + /// and SharpEmuUniforms to both stages, the pre-resolved textures/samplers + /// to both stages, vertex streams at the high slots. Collects the writable + /// buffers for guest write-back. + private static void EncodeDrawBindings( + nint device, + nint encoder, + OffscreenGuestDraw work, + ReadOnlySpan textureHandles, + ReadOnlySpan textureOwned, + out List<(nint Pointer, GuestMemoryBuffer Guest)> writeBackBuffers) + { + var draw = work.Draw; + writeBackBuffers = []; + + var selSetVertexBuffer = MetalNative.Selector("setVertexBuffer:offset:atIndex:"); + var selSetFragmentBuffer = MetalNative.Selector("setFragmentBuffer:offset:atIndex:"); + var bufferCount = draw.GlobalMemoryBuffers.Length; + Span boundBytes = stackalloc uint[Math.Max(bufferCount, 1)]; + for (var index = 0; index < bufferCount; index++) + { + var guest = draw.GlobalMemoryBuffers[index]; + var pointer = UploadGlobalBuffer( + device, guest, out var buffer, out var offset, out boundBytes[index]); + MetalNative.SendSetBuffer(encoder, selSetVertexBuffer, buffer, (nuint)offset, (nuint)index); + MetalNative.SendSetBuffer(encoder, selSetFragmentBuffer, buffer, (nuint)offset, (nuint)index); + if (guest.Writable && guest.WriteBackToGuest) + { + writeBackBuffers.Add((pointer, guest)); + } + } + + // SharpEmuUniforms per the translation contract: dispatch limit (unused by + // graphics stages), reserved, then each bound buffer's byte length + // (including the alignment-bias prefix the shader indexes past). + var uniforms = AllocateUpload( + device, + 16 + (Math.Max(bufferCount, 1) * sizeof(uint)), + out var uniformsBuffer, + out var uniformsOffset); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms, 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[4..], 1); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[8..], 1); + 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]); + } + + // Each stage declares SharpEmuUniforms at its own translation-time index + // (globalBufferBase + totalGlobalBufferCount). A draw whose vertex-stage + // guest buffers sit after the pixel stage's gives the two stages + // different indices, so bind the buffer at each stage's declared slot — + // one shared index leaves the other stage's uniforms unbound, which + // zeroes its bounds-checked loads (caught by Metal API validation as + // "missing Buffer binding ... for sharpemu_uniforms"). + var vertexUniformsIndex = draw.VertexShader?.Shader.UniformsBufferIndex ?? -1; + MetalNative.SendSetBuffer( + encoder, + selSetVertexBuffer, + uniformsBuffer, + (nuint)uniformsOffset, + (nuint)(vertexUniformsIndex >= 0 ? vertexUniformsIndex : bufferCount)); + var fragmentUniformsIndex = draw.PixelShader.Shader.UniformsBufferIndex; + MetalNative.SendSetBuffer( + encoder, + selSetFragmentBuffer, + uniformsBuffer, + (nuint)uniformsOffset, + (nuint)(fragmentUniformsIndex >= 0 ? fragmentUniformsIndex : bufferCount)); + + var selSetVertexTexture = MetalNative.Selector("setVertexTexture:atIndex:"); + var selSetFragmentTexture = MetalNative.Selector("setFragmentTexture:atIndex:"); + // Texture slots are global across the draw's stages ([0, vertexImageBase) + // is the pixel stage's block), so textures bind to both stage tables at + // their global index. + for (var index = 0; index < draw.Textures.Length; index++) + { + var texture = textureHandles[index]; + if (texture != 0) + { + MetalNative.SendSetAtIndex(encoder, selSetVertexTexture, texture, (nuint)index); + MetalNative.SendSetAtIndex(encoder, selSetFragmentTexture, texture, (nuint)index); + if (textureOwned[index]) + { + MetalNative.SendVoid(texture, MetalNative.Selector("release")); + } + } + } + + // Samplers travel in a per-stage argument buffer (Metal caps direct + // sampler slots at 16 per stage, but shaders sample more), one entry + // per sampled image. + BindSamplerArgumentBuffer(device, encoder, selSetFragmentBuffer, draw.PixelShader, draw.Textures); + if (draw.VertexShader is { } vertexShader) + { + BindSamplerArgumentBuffer(device, encoder, selSetVertexBuffer, vertexShader, draw.Textures); + } + + Span vertexSlots = stackalloc nuint[draw.VertexBuffers.Length]; + _ = TryAssignVertexBufferSlots(draw.VertexBuffers, vertexSlots); + for (var index = 0; index < draw.VertexBuffers.Length; index++) + { + // Streams sharing a slot read the same guest bytes; the first + // occurrence uploads and binds them once. + var duplicate = false; + for (var prior = 0; prior < index; prior++) + { + if (vertexSlots[prior] == vertexSlots[index]) + { + duplicate = true; + break; + } + } + + if (duplicate) + { + continue; + } + + var vertexBuffer = draw.VertexBuffers[index]; + var length = Math.Max(vertexBuffer.Length, 1); + var slice = AllocateUpload(device, length, out var buffer, out var offset); + vertexBuffer.Data.AsSpan(0, Math.Min(vertexBuffer.Length, vertexBuffer.Data.Length)) + .CopyTo(slice); + // The attribute's byte offset (set in the vertex descriptor) + // selects the field inside the interleaved vertex; the bind + // offset is just the arena slice. + MetalNative.SendSetBuffer( + encoder, selSetVertexBuffer, buffer, (nuint)offset, vertexSlots[index]); + } + } + + private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw) + { + var primitive = GetPrimitiveType(draw.PrimitiveType); + var vertexCount = draw.PrimitiveType == 0x11 && draw.IndexBuffer is null + ? 4u + : draw.VertexCount; + if (draw.IndexBuffer is { } indexBuffer) + { + var device = MetalNative.Send(encoder, MetalNative.Selector("device")); + var slice = AllocateUpload( + device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset); + indexBuffer.Data.AsSpan(0, Math.Min(indexBuffer.Length, indexBuffer.Data.Length)) + .CopyTo(slice); + MetalNative.SendDrawIndexedPrimitives( + encoder, + MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"), + primitive, + vertexCount, + indexBuffer.Is32Bit ? 1u : 0u, + buffer, + (nuint)offset, + Math.Max(draw.InstanceCount, 1)); + if (indexBuffer.Pooled) + { + GuestDataPool.Shared.Return(indexBuffer.Data); + } + } + else + { + MetalNative.SendDrawPrimitivesInstanced( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"), + primitive, + 0, + vertexCount, + Math.Max(draw.InstanceCount, 1)); + } + } + + private static bool TryGetDrawPipeline( + nint device, + TranslatedGuestDraw draw, + MetalRenderTargetFormat[] targetFormats, + bool hasDepth, + out nint pipeline) + { + var stateHash = 14695981039346656037UL; + void Mix(ulong value) + { + stateHash = (stateHash ^ value) * 1099511628211UL; + } + + for (var index = 0; index < targetFormats.Length; index++) + { + Mix((ulong)targetFormats[index].Format); + var blend = draw.RenderState.Blends[index]; + Mix(blend.Enable ? 1UL : 0UL); + Mix(blend.ColorSrcFactor | ((ulong)blend.ColorDstFactor << 8) | ((ulong)blend.ColorFunc << 16)); + Mix(blend.AlphaSrcFactor | ((ulong)blend.AlphaDstFactor << 8) | ((ulong)blend.AlphaFunc << 16)); + Mix(blend.SeparateAlphaBlend ? 1UL : 0UL); + Mix(blend.WriteMask); + } + + Mix(hasDepth ? 2UL : 1UL); + Span vertexSlots = stackalloc nuint[draw.VertexBuffers.Length]; + if (!TryAssignVertexBufferSlots(draw.VertexBuffers, vertexSlots)) + { + if (_vertexSlotOverflowTraces < 16) + { + _vertexSlotOverflowTraces++; + Console.Error.WriteLine( + "[LOADER][WARN] Metal skipped draw: " + + $"{draw.VertexBuffers.Length} vertex streams need more than " + + $"{MaxVertexStageBufferIndex - VertexBufferSlotBase + 1} unique buffer slots."); + } + + pipeline = 0; + return false; + } + + for (var index = 0; index < draw.VertexBuffers.Length; index++) + { + var vertexBuffer = draw.VertexBuffers[index]; + Mix(vertexBuffer.Location | + ((ulong)vertexBuffer.ComponentCount << 8) | + ((ulong)vertexBuffer.DataFormat << 16) | + ((ulong)vertexBuffer.NumberFormat << 26) | + ((ulong)vertexBuffer.Stride << 34)); + // The attribute byte offset and the assigned slot are baked into + // the pipeline's vertex descriptor, so both must key the cache + // (the slot captures which streams alias one guest buffer). + Mix(vertexBuffer.OffsetBytes | ((ulong)vertexSlots[index] << 32)); + } + + var key = new PipelineKey(draw.VertexShader, draw.PixelShader, stateHash); + lock (_pipelineCache) + { + if (_pipelineCache.TryGetValue(key, out pipeline)) + { + return pipeline != 0; + } + } + + pipeline = CreateDrawPipeline(device, draw, targetFormats, hasDepth); + lock (_pipelineCache) + { + _pipelineCache[key] = pipeline; + } + + return pipeline != 0; + } + + private static nint CreateDrawPipeline( + nint device, + TranslatedGuestDraw draw, + MetalRenderTargetFormat[] targetFormats, + bool hasDepth) + { + var vertexFunction = draw.VertexShader is { } vertexShader + ? GetShaderFunction(device, vertexShader) + : GetFixedFullscreenVertexFunction(device, draw.AttributeCount); + var fragmentFunction = GetShaderFunction(device, draw.PixelShader); + if (vertexFunction == 0 || fragmentFunction == 0) + { + return 0; + } + + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction); + + var colorAttachments = MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")); + for (var index = 0; index < targetFormats.Length; index++) + { + var attachment = MetalNative.SendAtIndex( + colorAttachments, MetalNative.Selector("objectAtIndexedSubscript:"), (nuint)index); + MetalNative.Send( + attachment, MetalNative.Selector("setPixelFormat:"), (nint)targetFormats[index].Format); + var blend = draw.RenderState.Blends[index]; + MetalNative.Send( + attachment, + MetalNative.Selector("setWriteMask:"), + (nint)ToMetalWriteMask(blend.WriteMask)); + if (blend.Enable && !IsIntegerFormat(targetFormats[index].OutputKind)) + { + MetalNative.SendVoidBool(attachment, MetalNative.Selector("setBlendingEnabled:"), true); + MetalNative.Send( + attachment, + MetalNative.Selector("setSourceRGBBlendFactor:"), + (nint)ToMetalBlendFactor(blend.ColorSrcFactor)); + MetalNative.Send( + attachment, + MetalNative.Selector("setDestinationRGBBlendFactor:"), + (nint)ToMetalBlendFactor(blend.ColorDstFactor)); + MetalNative.Send( + attachment, + MetalNative.Selector("setRgbBlendOperation:"), + (nint)ToMetalBlendOperation(blend.ColorFunc)); + var alphaSrc = blend.SeparateAlphaBlend ? blend.AlphaSrcFactor : blend.ColorSrcFactor; + var alphaDst = blend.SeparateAlphaBlend ? blend.AlphaDstFactor : blend.ColorDstFactor; + var alphaFunc = blend.SeparateAlphaBlend ? blend.AlphaFunc : blend.ColorFunc; + MetalNative.Send( + attachment, + MetalNative.Selector("setSourceAlphaBlendFactor:"), + (nint)ToMetalBlendFactor(alphaSrc)); + MetalNative.Send( + attachment, + MetalNative.Selector("setDestinationAlphaBlendFactor:"), + (nint)ToMetalBlendFactor(alphaDst)); + MetalNative.Send( + attachment, + MetalNative.Selector("setAlphaBlendOperation:"), + (nint)ToMetalBlendOperation(alphaFunc)); + } + } + + if (hasDepth) + { + MetalNative.Send( + descriptor, + MetalNative.Selector("setDepthAttachmentPixelFormat:"), + (nint)MtlPixelFormat.Depth32Float); + } + + if (draw.VertexShader is not null && draw.VertexBuffers.Length > 0) + { + MetalNative.SendVoid( + descriptor, + MetalNative.Selector("setVertexDescriptor:"), + CreateVertexDescriptor(draw.VertexBuffers)); + } + + nint error = 0; + var pipeline = MetalNative.Send( + device, + MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"), + descriptor, + ref error); + if (pipeline == 0) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal draw pipeline creation failed: {MetalNative.DescribeError(error)}"); + } + + Interlocked.Increment(ref _perfPipelineCreations); + return pipeline; + } + + private static nint CreateVertexDescriptor(GuestVertexBuffer[] vertexBuffers) + { + var descriptor = MetalNative.Send( + MetalNative.Class("MTLVertexDescriptor"), MetalNative.Selector("vertexDescriptor")); + var attributes = MetalNative.Send(descriptor, MetalNative.Selector("attributes")); + var layouts = MetalNative.Send(descriptor, MetalNative.Selector("layouts")); + var selAt = MetalNative.Selector("objectAtIndexedSubscript:"); + Span slots = stackalloc nuint[vertexBuffers.Length]; + _ = TryAssignVertexBufferSlots(vertexBuffers, slots); + for (var index = 0; index < vertexBuffers.Length; index++) + { + var vertexBuffer = vertexBuffers[index]; + var slot = slots[index]; + var attribute = MetalNative.SendAtIndex(attributes, selAt, vertexBuffer.Location); + MetalNative.Send( + attribute, + MetalNative.Selector("setFormat:"), + (nint)ToMetalVertexFormat( + vertexBuffer.DataFormat, vertexBuffer.NumberFormat, vertexBuffer.ComponentCount)); + // The guest byte offset is the attribute's position inside the + // interleaved vertex; carry it on the attribute (buffer bound at 0) + // rather than the buffer bind offset. Metal fetches a fixed + // (bind-offset + attribute-offset + index*stride), so the two are + // arithmetically equal, but a non-zero per-buffer bind offset here + // fetched zero on this path — keeping the attribute offset is the + // layout Metal's vertex-descriptor path expects. + var attributeOffset = vertexBuffer.OffsetBytes < (uint)vertexBuffer.Length + ? vertexBuffer.OffsetBytes + : 0; + MetalNative.Send(attribute, MetalNative.Selector("setOffset:"), (nint)attributeOffset); + MetalNative.Send(attribute, MetalNative.Selector("setBufferIndex:"), (nint)slot); + + var layout = MetalNative.SendAtIndex(layouts, selAt, slot); + var stride = vertexBuffer.Stride != 0 + ? vertexBuffer.Stride + : Math.Max(vertexBuffer.ComponentCount, 1) * 4; + MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride); + // MTLVertexStepFunction.PerVertex = 1. + MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1); + } + + return descriptor; + } + + private static nint GetShaderFunction(nint device, MetalCompiledGuestShader shader) + { + if (shader.CachedLibrary == 0) + { + if (!TryCompileLibrary(device, shader.Shader.Source, out var library, out var error)) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal shader compile failed: {error}"); + return 0; + } + + shader.CachedLibrary = library; + } + + return MetalNative.Send( + shader.CachedLibrary, + MetalNative.Selector("newFunctionWithName:"), + MetalNative.NsString(shader.Shader.EntryPoint)); + } + + private static readonly Dictionary _fixedVertexLibraries = new(); + private static nint _fixedDrawPipeline; + + private static nint GetFixedFullscreenVertexFunction(nint device, uint attributeCount) + { + nint library; + lock (_fixedVertexLibraries) + { + _fixedVertexLibraries.TryGetValue(attributeCount, out library); + } + + if (library == 0) + { + if (!TryCompileLibrary( + device, MslFixedShaders.CreateFullscreenVertex(attributeCount), out library, out var error)) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal fullscreen vertex compile failed: {error}"); + return 0; + } + + lock (_fixedVertexLibraries) + { + _fixedVertexLibraries[attributeCount] = library; + } + } + + return MetalNative.Send( + library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("fullscreen_vs")); + } + + private static bool TryGetFixedDrawPipeline(nint device, out nint pipeline) + { + if (_fixedDrawPipeline != 0) + { + pipeline = _fixedDrawPipeline; + return true; + } + + pipeline = 0; + var vertexFunction = GetFixedFullscreenVertexFunction(device, 1); + if (vertexFunction == 0) + { + return false; + } + + if (!TryCompileLibrary(device, MslFixedShaders.CreateAttributeFragment(0), out var library, out var error)) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal fixed draw pipeline unavailable: {error}"); + return false; + } + + var fragmentFunction = MetalNative.Send( + library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("attribute_fs")); + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction); + var attachment = MetalNative.SendAtIndex( + MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")), + MetalNative.Selector("objectAtIndexedSubscript:"), + 0); + MetalNative.Send(attachment, MetalNative.Selector("setPixelFormat:"), (nint)MtlPixelFormat.Bgra8Unorm); + nint pipelineError = 0; + pipeline = MetalNative.Send( + device, + MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"), + descriptor, + ref pipelineError); + _fixedDrawPipeline = pipeline; + _ = error; + return pipeline != 0; + } + + private static GuestImage? EnsureGuestRenderTarget( + nint device, + GuestRenderTarget target, + MtlPixelFormat format) + { + lock (_gate) + { + if (_guestImages.TryGetValue(target.Address, out var existing) && + existing.Width == target.Width && + existing.Height == target.Height) + { + return existing; + } + } + + if (target.Width == 0 || target.Height == 0 || target.Width > 16384 || target.Height > 16384) + { + return null; + } + + var image = new GuestImage + { + Texture = CreateGuestTexture(device, format, target.Width, target.Height), + Width = target.Width, + Height = target.Height, + Format = format, + }; + if (image.Texture == 0) + { + return null; + } + + byte[]? initialData; + lock (_gate) + { + _pendingGuestImageInitialData.Remove(target.Address, out initialData); + if (_guestImages.TryGetValue(target.Address, out var replaced)) + { + RetireGuestImageVariantLocked(target.Address, replaced); + } + + _guestImages[target.Address] = image; + _guestImageExtents[target.Address] = (target.Width, target.Height, (ulong)target.Width * target.Height * 4); + } + + // Pending initial data is RGBA8; only 4-byte-texel targets take it verbatim. + if (initialData is not null && + MetalRenderTargetFormat.GetBytesPerPixel(format) == 4 && + (ulong)initialData.Length >= (ulong)target.Width * target.Height * 4) + { + ReplaceTextureContents( + image.Texture, target.Width, target.Height, initialData, target.Width, bytesPerPixel: 4); + // A guest-memory seed initializes content without marking it + // GPU-produced; the version still moves so snapshots refresh. + image.Initialized = true; + image.ContentVersion++; + } + + return image; + } + + private static void RetireGuestImageVariantLocked(ulong address, GuestImage retired) + { + var key = (address, retired.Width, retired.Height, retired.Format); + if (_guestImageVariants.Remove(key, out var previous)) + { + previous.ReleaseSnapshot(); + MetalNative.SendVoid(previous.Texture, MetalNative.Selector("release")); + } + else + { + while (_guestImageVariantOrder.Count >= MaxGuestImageVariants) + { + var evicted = _guestImageVariantOrder.Dequeue(); + if (_guestImageVariants.Remove(evicted, out var old)) + { + old.ReleaseSnapshot(); + MetalNative.SendVoid(old.Texture, MetalNative.Selector("release")); + } + } + + _guestImageVariantOrder.Enqueue(key); + } + + _guestImageVariants[key] = retired; + } + + private static GuestImage EnsureGuestDepthImage( + nint device, + GuestDepthTarget target, + uint width, + uint height) + { + var address = target.Address; + lock (_gate) + { + if (target.ReadAddress != 0 && target.ReadAddress != address) + { + _guestDepthReadAliases[target.ReadAddress] = address; + } + + if (_guestDepthImages.TryGetValue(address, out var existing) && + existing.Width == width && + existing.Height == height) + { + return existing; + } + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)MtlPixelFormat.Depth32Float, + width, + height, + mipmapped: false); + MetalNative.Send( + descriptor, MetalNative.Selector("setUsage:"), (nint)(UsageRenderTarget | UsageShaderRead)); + // MTLStorageMode.Private = 2: depth never round-trips to the CPU. + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), 2); + var image = new GuestImage + { + Texture = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor), + Width = width, + Height = height, + Format = MtlPixelFormat.Depth32Float, + }; + lock (_gate) + { + if (_guestDepthImages.Remove(address, out var replaced)) + { + replaced.ReleaseSnapshot(); + MetalNative.SendVoid(replaced.Texture, MetalNative.Selector("release")); + } + + _guestDepthImages[address] = image; + } + + return image; + } + + private static nint GetTransientTarget(nint device, MtlPixelFormat format, uint width, uint height) + { + var key = (format, width, height); + lock (_transientTargets) + { + if (_transientTargets.TryGetValue(key, out var existing)) + { + return existing; + } + } + + var texture = CreateGuestTexture(device, format, width, height); + lock (_transientTargets) + { + _transientTargets[key] = texture; + } + + return texture; + } + + private static int _missingTextureTraces; + + /// Resolves one draw texture. Snapshot copies for feedback reads + /// are encoded into , so this must be + /// called before the consuming render or compute encoder opens on that + /// same command buffer — encoder order is what keeps the snapshot after + /// earlier batched passes that render to the source image. + private static nint CreateDrawTexture( + nint device, + nint blitCommandBuffer, + GuestDrawTexture texture, + out bool ownedByCaller) + { + ownedByCaller = true; + if (texture.Width == 0 || texture.Height == 0) + { + return 0; + } + + var cacheable = IsCacheableDrawTexture(texture); + + // Feedback reads of a live guest render target sample an ordered + // snapshot, resolved like the Vulkan presenter: a guest depth image + // first (shadow-style depth sampling), then the current image or a + // retired variant at the same address, scored by descriptor match. + if (texture.RgbaPixels.Length == 0 && texture.Address != 0) + { + if (TryCreateDepthSampleTexture(device, blitCommandBuffer, texture, out var depthSample)) + { + ownedByCaller = false; + return depthSample; + } + + var live = ResolveGuestImageAlias(texture); + if (live is { Initialized: true } && blitCommandBuffer != 0) + { + // One snapshot per content version: draws sampling the same + // unchanged image share it, so the blit happens per content + // change instead of per draw — compositing games otherwise + // copy a full render target for every draw. The image holds + // the retain; consuming command buffers keep replaced + // snapshots alive until they complete. + if (live.SnapshotTexture != 0 && live.SnapshotVersion == live.ContentVersion) + { + ownedByCaller = false; + return live.SnapshotTexture; + } + + var snapshot = CreateGuestTexture(device, live.Format, live.Width, live.Height); + if (snapshot != 0) + { + EncodeCopyTexture(blitCommandBuffer, live.Texture, snapshot); + live.ReleaseSnapshot(); + live.SnapshotTexture = snapshot; + live.SnapshotVersion = live.ContentVersion; + ownedByCaller = false; + return snapshot; + } + } + + // Empty texels can also mean the submit thread skipped the + // guest-memory copy because this identity is cached here. + if (cacheable && TryGetCachedDrawTexture(texture, out var cachedSkip)) + { + ownedByCaller = false; + return cachedSkip; + } + + // A miss on skipped texels is an invalidation race (the entry + // was evicted after the submit thread checked). Self-heal by + // reading the texels directly rather than rendering a fallback. + var refreshed = TryReadGuestDrawTexturePixels(texture); + if (refreshed is null) + { + if (_missingTextureTraces < 16) + { + _missingTextureTraces++; + Console.Error.WriteLine( + $"[LOADER][WARN] Metal draw texture unresolved: live 0x{texture.Address:X} " + + $"{texture.Width}x{texture.Height} found={live is not null} " + + $"init={live?.Initialized ?? false}"); + } + + return 0; + } + + texture = texture with { RgbaPixels = refreshed }; + } + else if (cacheable && TryGetCachedDrawTexture(texture, out var cached)) + { + // Fresh texels for an identity already cached: the content is + // unchanged (a guest write would have evicted the entry at drain + // start), so skip the redundant texture creation and upload. + ownedByCaller = false; + return cached; + } + + // AGC ships the raw (detiled) source texels; create the texture in the + // guest's native format — Mac-family GPUs sample BC blocks directly — + // and size expectations with the same block-aware math AGC used. + var textureFormat = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType); + var pitch = texture.Pitch != 0 ? Math.Max(texture.Pitch, texture.Width) : texture.Width; + var expectedBytes = MetalGuestFormats.GetTextureByteCount(textureFormat, pitch, texture.Height); + if ((ulong)texture.RgbaPixels.Length < expectedBytes) + { + if (_missingTextureTraces < 16) + { + _missingTextureTraces++; + Console.Error.WriteLine( + $"[LOADER][WARN] Metal draw texture undersized: 0x{texture.Address:X} " + + $"{texture.Width}x{texture.Height} pitch={texture.Pitch} " + + $"fmt={texture.Format}/{texture.NumberType} " + + $"bytes={texture.RgbaPixels.Length} expected={expectedBytes}"); + } + + return 0; + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)textureFormat.Format, + texture.Width, + texture.Height, + mipmapped: false); + // MTLStorageModeShared (0): CPU-uploaded (replaceRegion) + GPU-sampled; + // the Managed default reads stale on unified memory (see CreateGuestTexture). + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0); + if (texture.IsStorage) + { + MetalNative.Send( + descriptor, + MetalNative.Selector("setUsage:"), + (nint)(UsageShaderRead | UsageShaderWrite)); + } + else if (texture.DstSelect != 0xFAC && texture.DstSelect != 0) + { + // Channel select from the guest descriptor, like the Vulkan view's + // component mapping. Shader-writable textures reject swizzles, so + // storage stays identity (matching Vulkan, which never swizzles + // storage views either). + MetalNative.SendVoidSwizzle( + descriptor, + MetalNative.Selector("setSwizzle:"), + ToMetalSwizzle(texture.DstSelect)); + } + + var handle = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + if (handle != 0) + { + ReplaceDrawTextureContents(handle, texture, pitch, textureFormat); + if (cacheable) + { + CacheDrawTexture(texture, handle); + } + } + + return handle; + } + + /// Uploads a draw texture's source texels: linear formats reuse the + /// row-clamping helper; block-compressed formats upload whole 4x4 block rows + /// with the block-row stride replaceRegion expects. + private static unsafe void ReplaceDrawTextureContents( + nint handle, + GuestDrawTexture texture, + uint pitch, + in MetalTextureFormat format) + { + if (!format.IsBlockCompressed) + { + ReplaceTextureContents( + handle, texture.Width, texture.Height, texture.RgbaPixels, pitch, format.BytesPerPixel); + return; + } + + var blocksWide = ((ulong)Math.Max(pitch, texture.Width) + 3) / 4; + var bytesPerBlockRow = blocksWide * format.BlockBytes; + if (bytesPerBlockRow == 0) + { + return; + } + + var blockRows = Math.Min( + ((ulong)texture.Height + 3) / 4, + (ulong)texture.RgbaPixels.Length / bytesPerBlockRow); + if (blockRows == 0) + { + return; + } + + var texelRows = Math.Min(texture.Height, (uint)(blockRows * 4)); + fixed (byte* source = texture.RgbaPixels) + { + MetalNative.SendReplaceRegion( + handle, + MetalNative.Selector("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), + new MtlRegion { X = 0, Y = 0, Z = 0, Width = texture.Width, Height = texelRows, Depth = 1 }, + 0, + (nint)source, + (nuint)bytesPerBlockRow); + } + } + + /// Guest DST_SEL (3 bits per channel: 0=zero, 1=one, 4..7=RGBA) to + /// Metal swizzle bytes, mirroring the Vulkan view's component mapping. + private static MtlTextureSwizzleChannels ToMetalSwizzle(uint dstSelect) => new() + { + Red = ToMetalSwizzleChannel(dstSelect & 0x7, identity: 2), + Green = ToMetalSwizzleChannel((dstSelect >> 3) & 0x7, identity: 3), + Blue = ToMetalSwizzleChannel((dstSelect >> 6) & 0x7, identity: 4), + Alpha = ToMetalSwizzleChannel((dstSelect >> 9) & 0x7, identity: 5), + }; + + private static byte ToMetalSwizzleChannel(uint selector, byte identity) => + selector switch + { + 0 => 0, + 1 => 1, + 4 => 2, + 5 => 3, + 6 => 4, + 7 => 5, + _ => identity, + }; + + /// Resolves a live guest texture to the current image or a retired + /// same-address variant, scored by descriptor match like the Vulkan + /// presenter's guest-image variants: exact extent outranks format, format + /// outranks initialization, and the active image breaks ties. + private static GuestImage? ResolveGuestImageAlias(GuestDrawTexture texture) + { + var hasViewFormat = MetalGuestFormats.TryDecodeRenderTargetFormat( + texture.Format, texture.NumberType, out var viewFormat); + GuestImage? best = null; + var bestScore = int.MinValue; + + void Consider(GuestImage candidate, bool isActive) + { + // Exact extent always qualifies; a larger image qualifies only for + // tiled descriptors, mirroring IsCompatibleGuestImageAlias. + var sizeMatch = candidate.Width == texture.Width && + candidate.Height == texture.Height; + if (!sizeMatch && + (texture.TileMode == 0 || + texture.Width == 0 || + texture.Height == 0 || + texture.Width > candidate.Width || + texture.Height > candidate.Height)) + { + return; + } + + var score = 0; + if (sizeMatch) + { + score += 32; + } + + if (hasViewFormat && candidate.Format == viewFormat.Format) + { + score += 16; + } + + if (candidate.Initialized) + { + score += 4; + } + + if (isActive) + { + score += 1; + } + + if (score > bestScore) + { + best = candidate; + bestScore = score; + } + } + + lock (_gate) + { + if (_guestImages.TryGetValue(texture.Address, out var active)) + { + Consider(active, isActive: true); + } + + foreach (var (key, candidate) in _guestImageVariants) + { + if (key.Address == texture.Address) + { + Consider(candidate, isActive: false); + } + } + } + + return best; + } + + /// Snapshots a guest depth image for sampling when the texture + /// descriptor names a depth target's write or read address. Depth32Float + /// cannot blit to a color format, so the copy round-trips through a + /// buffer into an R32Float texture the translated shader can sample. + private static bool TryCreateDepthSampleTexture( + nint device, + nint blitCommandBuffer, + GuestDrawTexture texture, + out nint sample) + { + sample = 0; + // Identity channel select only; swizzled depth reads keep the + // unresolved-texture warning until a title needs them. + if (texture.DstSelect != 0xFAC || blitCommandBuffer == 0) + { + return false; + } + + GuestImage? depth; + lock (_gate) + { + if (!_guestDepthImages.TryGetValue(texture.Address, out depth) && + _guestDepthReadAliases.TryGetValue(texture.Address, out var primary)) + { + _guestDepthImages.TryGetValue(primary, out depth); + } + } + + if (depth is null || + !depth.Initialized || + texture.Width > depth.Width || + texture.Height > depth.Height) + { + return false; + } + + var bytesPerRow = (nuint)depth.Width * 4; + var bytesPerImage = bytesPerRow * depth.Height; + var staging = AcquireSnapshotBuffer(device, bytesPerImage); + if (staging == 0) + { + return false; + } + + sample = AcquireSnapshotTexture( + device, + MtlPixelFormat.R32Float, + depth.Width, + depth.Height, + (nint)UsageShaderRead); + if (sample == 0) + { + return false; + } + + var blit = MetalNative.Send(blitCommandBuffer, MetalNative.Selector("blitCommandEncoder")); + var size = new MtlSize { Width = depth.Width, Height = depth.Height, Depth = 1 }; + MetalNative.SendCopyTextureToBuffer( + blit, + MetalNative.Selector( + "copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:" + + "toBuffer:destinationOffset:destinationBytesPerRow:destinationBytesPerImage:"), + depth.Texture, + 0, + 0, + default, + size, + staging, + 0, + bytesPerRow, + bytesPerImage); + MetalNative.SendCopyBufferToTexture( + blit, + MetalNative.Selector( + "copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:" + + "toTexture:destinationSlice:destinationLevel:destinationOrigin:"), + staging, + 0, + bytesPerRow, + bytesPerImage, + size, + sample, + 0, + 0, + default); + MetalNative.SendVoid(blit, MetalNative.Selector("endEncoding")); + return true; + } + + private static nint GetOrCreateSampler(nint device, GuestSampler sampler) + { + lock (_samplerCache) + { + if (_samplerCache.TryGetValue(sampler, out var cached)) + { + return cached; + } + } + + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLSamplerDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.Send( + descriptor, + MetalNative.Selector("setSAddressMode:"), + (nint)ToMetalAddressMode(sampler.Word0 & 0x7)); + MetalNative.Send( + descriptor, + MetalNative.Selector("setTAddressMode:"), + (nint)ToMetalAddressMode((sampler.Word0 >> 3) & 0x7)); + MetalNative.Send( + descriptor, + MetalNative.Selector("setRAddressMode:"), + (nint)ToMetalAddressMode((sampler.Word0 >> 6) & 0x7)); + var magFilter = (sampler.Word2 >> 20) & 0x3; + var minFilter = (sampler.Word2 >> 22) & 0x3; + MetalNative.Send( + descriptor, + MetalNative.Selector("setMagFilter:"), + magFilter is 1 or 3 ? 1 : 0); + MetalNative.Send( + descriptor, + MetalNative.Selector("setMinFilter:"), + minFilter is 1 or 3 ? 1 : 0); + + var handle = MetalNative.Send( + device, MetalNative.Selector("newSamplerStateWithDescriptor:"), descriptor); + lock (_samplerCache) + { + _samplerCache[sampler] = handle; + } + + return handle; + } + + // A guest global buffer is bound so the shader's alignment bias (the guest + // base address's low bits below the storage-buffer offset alignment) lands + // on the real data: the slice holds bias + length bytes with the data at + // offset bias, matching how the Vulkan backend binds into a larger + // allocation at an aligned-down descriptor offset. boundBytes is what + // SharpEmuUniforms must carry so the shader's bounds check passes. The + // returned pointer addresses the data (past the bias) for write-backs. + private const ulong StorageBufferOffsetAlignment = 256; + + private static unsafe nint UploadGlobalBuffer( + nint device, + GuestMemoryBuffer guest, + out nint buffer, + out int offset, + out uint boundBytes) + { + var bias = (int)((ulong)guest.BaseAddress & (StorageBufferOffsetAlignment - 1)); + var length = Math.Clamp(guest.Length, 0, guest.Data.Length); + boundBytes = (uint)(bias + length); + var slice = AllocateUpload(device, bias + Math.Max(length, 1), out buffer, out offset); + if (bias != 0) + { + // Deterministic zeros below the bias, like the padded copy had. + slice[..bias].Clear(); + } + + guest.Data.AsSpan(0, length).CopyTo(slice[bias..]); + fixed (byte* data = slice) + { + return (nint)(data + bias); + } + } + + private static void WriteBuffersBackToGuest( + List<(nint Pointer, GuestMemoryBuffer Guest)> writeBackBuffers) + { + var memory = _guestMemory; + if (memory is null) + { + return; + } + + foreach (var (pointer, guest) in writeBackBuffers) + { + unsafe + { + // The pointer addresses the slice's data (past the alignment + // bias) inside its shared-storage arena page, which stays + // alive until the command buffer completes — and the caller + // waited on that before reading. + _ = memory.TryWrite( + guest.BaseAddress, + new ReadOnlySpan((void*)pointer, guest.Length)); + } + } + } + + private static void ReturnPooledGuestData(TranslatedGuestDraw draw) + { + foreach (var buffer in draw.GlobalMemoryBuffers) + { + if (buffer.Pooled) + { + GuestDataPool.Shared.Return(buffer.Data); + } + } + + foreach (var vertexBuffer in draw.VertexBuffers) + { + if (vertexBuffer.Pooled) + { + GuestDataPool.Shared.Return(vertexBuffer.Data); + } + } + } + + // MTLPrimitiveType: Point=0, Line=1, LineStrip=2, Triangle=3, TriangleStrip=4. + private static nuint GetPrimitiveType(uint guestPrimitiveType) + { + switch (guestPrimitiveType) + { + case 1: + return 0; + case 2: + return 1; + case 3: + return 2; + case 5: + // Metal has no triangle fans; a list is the closest safe shape. + if (!_tracedTriangleFan) + { + _tracedTriangleFan = true; + Console.Error.WriteLine( + "[LOADER][WARN] Metal has no triangle-fan primitive; drawing as a list."); + } + + return 3; + case 6: + case 0x11: + return 4; + default: + return 3; + } + } + + private static bool IsIntegerFormat(Gen5PixelOutputKind kind) => + kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint; + + // Guest CB write-mask bits are R=1,G=2,B=4,A=8; MTLColorWriteMask reverses them. + private static nuint ToMetalWriteMask(uint guestMask) => + ((guestMask & 1) != 0 ? 8u : 0u) | + ((guestMask & 2) != 0 ? 4u : 0u) | + ((guestMask & 4) != 0 ? 2u : 0u) | + ((guestMask & 8) != 0 ? 1u : 0u); + + // Guest CB_BLEND factor codes to MTLBlendFactor, matching the Vulkan mapping. + private static nuint ToMetalBlendFactor(uint factor) => + factor switch + { + 0 => 0, // Zero + 1 => 1, // One + 2 => 2, // SourceColor + 3 => 3, // OneMinusSourceColor + 4 => 4, // SourceAlpha + 5 => 5, // OneMinusSourceAlpha + 6 => 8, // DestinationAlpha + 7 => 9, // OneMinusDestinationAlpha + 8 => 6, // DestinationColor + 9 => 7, // OneMinusDestinationColor + 10 => 10, // SourceAlphaSaturated + 13 => 11, // BlendColor + 14 => 12, // OneMinusBlendColor + 15 => 15, // Source1Color + 16 => 16, // OneMinusSource1Color + 17 => 17, // Source1Alpha + 18 => 18, // OneMinusSource1Alpha + 19 => 13, // BlendAlpha + 20 => 14, // OneMinusBlendAlpha + _ => 1, + }; + + // Guest COMB_FCN codes to MTLBlendOperation (Add=0, Sub=1, RevSub=2, Min=3, Max=4). + private static nuint ToMetalBlendOperation(uint function) => + function switch + { + 0 => 0, + 1 => 1, + 2 => 3, + 3 => 4, + 4 => 2, + _ => 0, + }; + + // Guest sampler clamp codes to MTLSamplerAddressMode, matching the Vulkan mapping. + private static nuint ToMetalAddressMode(uint mode) => + mode switch + { + 0 => 2, // Repeat + 1 => 3, // MirrorRepeat + 2 => 0, // ClampToEdge + 3 or 5 or 7 => 1, // MirrorClampToEdge + 4 or 6 => 5, // ClampToBorderColor + _ => 0, + }; + + // Guest vertex (dataFormat, numberFormat) codes to MTLVertexFormat raw values, + // mirroring the Vulkan attribute table; unmapped codes fall back to float{n}. + private static nuint ToMetalVertexFormat(uint dataFormat, uint numberFormat, uint componentCount) + { + var format = (dataFormat, numberFormat) switch + { + (1, 0) => 47u, // ucharNormalized + (1, 1) => 48u, // charNormalized + (1, 4) => 45u, // uchar + (1, 5) => 46u, // char + (2, 0) => 51u, // ushortNormalized + (2, 1) => 52u, // shortNormalized + (2, 4) => 49u, // ushort + (2, 5) => 50u, // short + (2, 7) => 53u, // half + (3, 0) => 7u, // uchar2Normalized + (3, 1) => 10u, // char2Normalized + (3, 4) => 1u, // uchar2 + (3, 5) => 4u, // char2 + (4, 4) => 36u, // uint + (4, 5) => 32u, // int + (4, 7) => 28u, // float + (5, 0) => 19u, // ushort2Normalized + (5, 1) => 22u, // short2Normalized + (5, 4) => 13u, // ushort2 + (5, 5) => 16u, // short2 + (5, 7) => 25u, // half2 + (6, 7) or (7, 7) => 54u, // floatRG11B10 + (8, 0) or (9, 0) => 41u, // uint1010102Normalized (R in bits 0..9) + (8, 1) or (9, 1) => 40u, // int1010102Normalized + (10, 0) => 9u, // uchar4Normalized + (10, 1) => 12u, // char4Normalized + (10, 4) => 3u, // uchar4 + (10, 5) => 6u, // char4 + (11, 4) => 37u, // uint2 + (11, 5) => 33u, // int2 + (11, 7) => 29u, // float2 + (12, 0) => 21u, // ushort4Normalized + (12, 1) or (12, 6) => 24u, // short4Normalized + (12, 4) => 15u, // ushort4 + (12, 5) => 18u, // short4 + (12, 7) => 27u, // half4 + (13, 4) => 38u, // uint3 + (13, 5) => 34u, // int3 + (13, 7) => 30u, // float3 + (14, 4) => 39u, // uint4 + (14, 5) => 35u, // int4 + (14, 7) => 31u, // float4 + (34, 7) => 55u, // floatRGB9E5 + _ => 0u, + }; + if (format != 0) + { + return format; + } + + return componentCount switch + { + 1 => 28, + 2 => 29, + 3 => 30, + _ => 31, + }; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs new file mode 100644 index 0000000..d21e6b9 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.GuestImages.cs @@ -0,0 +1,1237 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.Libs.Gpu.Metal; + +// Guest work ordering and guest images, mirroring the Vulkan presenter's model: +// AGC submissions become queued work items consumed in logical-guest-queue order +// by the render loop; guest images are Metal textures keyed by guest address, +// seeded once from guest memory (PS5 render targets alias guest memory) and kept +// coherent through explicit write/fill mirroring; 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. +internal static partial class MetalVideoPresenter +{ + private const int MaxPendingGuestWork = 64; + private const int MaxGuestWorkPerRender = 256; + private const int MaxPendingGuestFlipVersions = 4; + private const ulong MaxPendingGuestWorkBytes = 256UL * 1024 * 1024; + private static readonly long _renderWorkBudgetTicks = + 12L * System.Diagnostics.Stopwatch.Frequency / 1000L; + + private readonly record struct GuestQueueIdentity(string Name, ulong SubmissionId) + { + public static GuestQueueIdentity Default { get; } = new("host.default", 0); + } + + private readonly record struct PendingGuestWork( + object Work, + ulong PayloadBytes, + long Sequence, + GuestQueueIdentity Queue); + + private sealed record OrderedGuestAction(Action Action, string DebugName); + + private sealed record GuestImageWrite(ulong Address, byte[]? Pixels, uint FillValue); + + private sealed record OrderedGuestFlip( + long Version, + int VideoOutHandle, + int DisplayBufferIndex, + ulong Address, + uint Width, + uint Height, + uint PitchInPixel); + + private sealed record OrderedGuestFlipWait( + long Version, + int VideoOutHandle, + int DisplayBufferIndex); + + private sealed record GuestImageBlit(ulong SourceAddress, ulong DestinationAddress); + + /// A guest-addressed Metal texture (or an immutable captured version). + private sealed class GuestImage + { + public nint Texture; + public uint Width; + public uint Height; + public MtlPixelFormat Format; + public bool Initialized; + + /// True once GPU work (draw, blit, dispatch) or an explicit + /// guest write produced this content; false while it only holds a + /// speculative guest-memory seed. Flips prefer produced content. + public bool GpuWritten; + + /// Bumped whenever anything changes this image's content; + /// feedback-read snapshots are reused until it moves. Games that + /// composite by sampling their render target otherwise force a + /// full-texture blit on every draw. + public int ContentVersion; + + /// Cached feedback-read snapshot (one retain held here) and + /// the content version it captured. Command buffers that sampled it + /// retain it through completion, so replacing releases immediately. + public nint SnapshotTexture; + public int SnapshotVersion; + + public void MarkContentChanged() + { + Initialized = true; + GpuWritten = true; + ContentVersion++; + } + + public void ReleaseSnapshot() + { + if (SnapshotTexture != 0) + { + MetalNative.SendVoid(SnapshotTexture, MetalNative.Selector("release")); + SnapshotTexture = 0; + } + } + } + + // PS5 exposes independent graphics and asynchronous-compute queues; keep FIFO + // order within each logical guest queue and schedule ready queues round-robin + // so one slow queue cannot delay another (same policy as the Vulkan backend). + private static readonly Dictionary> + _pendingGuestWorkByQueue = new(StringComparer.Ordinal); + private static readonly List _pendingGuestQueueSchedule = []; + private static int _pendingGuestQueueCursor; + private static int _pendingGuestWorkCount; + private static ulong _pendingGuestWorkBytes; + private static long _enqueuedGuestWorkSequence; + private static long _completedGuestWorkSequence; + private static readonly HashSet _completedGuestWorkOutOfOrder = []; + private static readonly Dictionary _lastEnqueuedGuestWorkByQueue = + new(StringComparer.Ordinal); + private static long _executingGuestWorkSequence; + [ThreadStatic] + private static GuestQueueIdentity? _submittingGuestQueue; + [ThreadStatic] + private static bool _enqueueAsImmediateQueueFollowup; + [ThreadStatic] + private static LinkedListNode? _immediateFollowupTail; + + private static readonly Dictionary _availableGuestImages = new(); + private static readonly Dictionary + _guestImageExtents = new(); + private static readonly Dictionary _guestImages = new(); + private static readonly Dictionary _pendingGuestImageInitialData = new(); + private static readonly Dictionary _guestImageWorkSequences = new(); + private static readonly Queue _pendingGuestImagePresentations = new(); + private static readonly Dictionary _guestImageVersions = new(); + private static readonly Dictionary<(int Handle, int BufferIndex), long> + _lastOrderedGuestFlipVersions = new(); + private static long _orderedGuestFlipVersionSequence; + private static volatile ICpuMemory? _guestMemory; + + private sealed class GuestQueueScope : IDisposable + { + private readonly GuestQueueIdentity? _previous; + private bool _disposed; + + public GuestQueueScope(GuestQueueIdentity queue) + { + _previous = _submittingGuestQueue; + _submittingGuestQueue = queue; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _submittingGuestQueue = _previous; + } + } + + public static IDisposable EnterGuestQueue(string queueName, ulong submissionId) => + new GuestQueueScope(new GuestQueueIdentity( + string.IsNullOrWhiteSpace(queueName) ? "guest.unknown" : queueName, + submissionId)); + + public static void AttachGuestMemory(ICpuMemory memory) => + _guestMemory = memory; + + public static long SubmitOrderedGuestAction(Action action, string debugName) + { + ArgumentNullException.ThrowIfNull(action); + lock (_gate) + { + return _closed || _thread is null + ? 0 + : EnqueueGuestWorkLocked(new OrderedGuestAction(action, debugName)); + } + } + + public static long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) + { + lock (_gate) + { + var version = _lastOrderedGuestFlipVersions.TryGetValue( + (videoOutHandle, displayBufferIndex), + out var lastVersion) + ? lastVersion + : 0; + return _closed || _thread is null + ? 0 + : EnqueueGuestWorkLocked( + new OrderedGuestFlipWait(version, videoOutHandle, displayBufferIndex)); + } + } + + public static long CurrentGuestWorkSequenceForDiagnostics => + Volatile.Read(ref _executingGuestWorkSequence); + + public static bool WaitForGuestWork(long workSequence, int timeoutMilliseconds) + { + if (workSequence <= 0) + { + return false; + } + + var waitIndefinitely = timeoutMilliseconds == Timeout.Infinite; + var deadline = waitIndefinitely + ? long.MaxValue + : Environment.TickCount64 + Math.Max(timeoutMilliseconds, 1); + lock (_gate) + { + while (!_closed && !IsGuestWorkCompletedLocked(workSequence)) + { + var remaining = waitIndefinitely ? 1_000 : deadline - Environment.TickCount64; + if (remaining <= 0) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal guest work wait timed out sequence={workSequence} " + + $"contiguous_completed={_completedGuestWorkSequence}"); + return false; + } + + // Closing the presenter pulses this monitor, so an unbounded + // correctness wait remains interruptible. + Monitor.Wait(_gate, checked((int)Math.Min(remaining, 1_000))); + } + + return IsGuestWorkCompletedLocked(workSequence); + } + } + + public static void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) + { + if (address == 0 || guestFormat == 0) + { + return; + } + + lock (_gate) + { + _availableGuestImages[address] = guestFormat; + } + } + + public static bool IsGuestImageAvailable(ulong address, uint format, uint numberType) + { + var guestFormat = GetGuestTextureFormat(format, numberType); + if (address == 0 || guestFormat == 0) + { + return false; + } + + lock (_gate) + { + return _availableGuestImages.TryGetValue(address, out var availableFormat) && + availableFormat == guestFormat; + } + } + + public static bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) => + IsGuestImageAvailable(address, format, numberType); + + public static bool GuestImageWantsInitialData(ulong address) + { + if (address == 0) + { + return false; + } + + lock (_gate) + { + return !_availableGuestImages.ContainsKey(address) && + !_pendingGuestImageInitialData.ContainsKey(address); + } + } + + public static void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) + { + lock (_gate) + { + _pendingGuestImageInitialData[address] = rgbaPixels; + } + } + + public static bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) + { + lock (_gate) + { + if (_guestImageExtents.TryGetValue(address, out var extent)) + { + (width, height, byteCount) = extent; + return true; + } + } + + width = 0; + height = 0; + byteCount = 0; + return false; + } + + public static IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() + { + lock (_gate) + { + var extents = new (ulong, uint, uint, ulong)[_guestImageExtents.Count]; + var index = 0; + foreach (var entry in _guestImageExtents) + { + extents[index++] = (entry.Key, entry.Value.Width, entry.Value.Height, entry.Value.ByteCount); + } + + return extents; + } + } + + public static void SubmitGuestImageFill(ulong address, uint fillValue) + { + lock (_gate) + { + if (_closed || !_guestImageExtents.ContainsKey(address)) + { + return; + } + + _guestImageWorkSequences[address] = EnqueueGuestWorkLocked( + new GuestImageWrite(address, null, fillValue)); + } + } + + public static void SubmitGuestImageWrite(ulong address, byte[] pixels) + { + lock (_gate) + { + if (_closed || !_guestImageExtents.ContainsKey(address)) + { + return; + } + + _guestImageWorkSequences[address] = EnqueueGuestWorkLocked( + new GuestImageWrite(address, pixels, 0)); + } + } + + public static bool TrySubmitGuestImage(ulong address, uint width, uint height, uint pitchInPixel) + { + lock (_gate) + { + if (_closed || !_availableGuestImages.ContainsKey(address)) + { + return false; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + // Wait only for the work that last wrote this image, not the global + // queue tail — requiring the tail lets a fast guest permanently + // outrun the renderer. + var requiredWorkSequence = _guestImageWorkSequences.TryGetValue( + address, + out var imageWorkSequence) + ? imageWorkSequence + : _completedGuestWorkSequence; + var presentation = new Presentation( + null, + width, + height, + sequence, + IsSplash: false, + GuestImageAddress: address, + GuestImagePitch: pitchInPixel, + RequiredGuestWorkSequence: requiredWorkSequence); + _latestPresentation = presentation; + _pendingGuestImagePresentations.Enqueue(presentation); + while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork) + { + RetireSkippedPresentationLocked(_pendingGuestImagePresentations.Dequeue()); + } + } + + return true; + } + + public static bool TrySubmitOrderedGuestImageFlip( + int videoOutHandle, + int displayBufferIndex, + ulong address, + uint width, + uint height, + uint pitchInPixel) + { + lock (_gate) + { + if (_closed || _thread is null || !_availableGuestImages.ContainsKey(address)) + { + return false; + } + + var version = ++_orderedGuestFlipVersionSequence; + _lastOrderedGuestFlipVersions[(videoOutHandle, displayBufferIndex)] = version; + return EnqueueGuestWorkLocked( + new OrderedGuestFlip( + version, + videoOutHandle, + displayBufferIndex, + address, + width, + height, + pitchInPixel)) > 0; + } + } + + /// Same-extent, same-format image copies only; anything else returns + /// false and the caller keeps its CPU fallback. + public static bool TrySubmitGuestImageBlit( + ulong sourceAddress, + uint sourceWidth, + uint sourceHeight, + uint sourceFormat, + uint sourceNumberType, + ulong destinationAddress, + uint destinationWidth, + uint destinationHeight, + uint destinationFormat, + uint destinationNumberType) + { + if (sourceWidth != destinationWidth || + sourceHeight != destinationHeight || + GetGuestTextureFormat(sourceFormat, sourceNumberType) != + GetGuestTextureFormat(destinationFormat, destinationNumberType)) + { + return false; + } + + lock (_gate) + { + if (_closed || + _thread is null || + !_guestImages.TryGetValue(sourceAddress, out var source) || + !source.Initialized || + !_guestImages.TryGetValue(destinationAddress, out var destination) || + source.Width != destination.Width || + source.Height != destination.Height || + source.Format != destination.Format) + { + return false; + } + + _guestImageWorkSequences[destinationAddress] = EnqueueGuestWorkLocked( + new GuestImageBlit(sourceAddress, destinationAddress)); + return true; + } + } + + private static bool IsGuestWorkCompletedLocked(long sequence) => + sequence <= 0 || + sequence <= _completedGuestWorkSequence || + _completedGuestWorkOutOfOrder.Contains(sequence); + + private static long EnqueueGuestWorkLocked(object work) + { + var payloadBytes = GetGuestWorkPayloadBytes(work); + // Work executed by the render-loop consumer can enqueue an ordered + // same-queue follow-up; blocking the consumer on producer backpressure + // would deadlock a full queue, so follow-ups are always admitted. + while (!_enqueueAsImmediateQueueFollowup && + !_closed && + _thread is not null && + (_pendingGuestWorkCount >= MaxPendingGuestWork || + // Always admit one item when no payload is outstanding, even + // when that single item exceeds the configured budget: with + // nothing left to drain, waiting for room would never return. + // The budget bounds the normal multi-item backlog. + (_pendingGuestWorkBytes != 0 && + payloadBytes > MaxPendingGuestWorkBytes - + Math.Min(_pendingGuestWorkBytes, MaxPendingGuestWorkBytes)))) + { + // Full queue: ask the render loop to drain now rather than at its + // next timer tick, or this producer stalls a frame per admission. + ScheduleGuestWorkDrain(); + Monitor.Wait(_gate); + } + + if (_closed) + { + return 0; + } + + var queue = _submittingGuestQueue ?? GuestQueueIdentity.Default; + var sequence = ++_enqueuedGuestWorkSequence; + _lastEnqueuedGuestWorkByQueue[queue.Name] = sequence; + if (!_pendingGuestWorkByQueue.TryGetValue(queue.Name, out var pendingQueue)) + { + pendingQueue = new LinkedList(); + _pendingGuestWorkByQueue.Add(queue.Name, pendingQueue); + _pendingGuestQueueSchedule.Add(queue.Name); + } + + var pending = new PendingGuestWork(work, payloadBytes, sequence, queue); + if (_enqueueAsImmediateQueueFollowup && + _immediateFollowupTail is { List: not null } tail && + ReferenceEquals(tail.List, pendingQueue)) + { + _immediateFollowupTail = pendingQueue.AddAfter(tail, pending); + } + else if (_enqueueAsImmediateQueueFollowup) + { + _immediateFollowupTail = pendingQueue.AddFirst(pending); + } + else + { + pendingQueue.AddLast(pending); + } + + _pendingGuestWorkCount++; + var total = _pendingGuestWorkBytes + payloadBytes; + _pendingGuestWorkBytes = total < _pendingGuestWorkBytes ? ulong.MaxValue : total; + if (!_enqueueAsImmediateQueueFollowup) + { + // Drain promptly: guests that submit work and then wait on its + // side effects (release-mem labels, write-backs) round-trip + // through this queue several times per frame, and a timer-tick + // drain cadence turns each round-trip into a full frame interval. + ScheduleGuestWorkDrain(); + } + + return sequence; + } + + private static ulong GetGuestWorkPayloadBytes(object work) => + work is GuestImageWrite { Pixels: { } pixels } ? (ulong)pixels.Length : 0; + + private static bool TryTakeGuestWork(out PendingGuestWork work) + { + lock (_gate) + { + while (_pendingGuestQueueSchedule.Count > 0) + { + if (_pendingGuestQueueCursor >= _pendingGuestQueueSchedule.Count) + { + _pendingGuestQueueCursor = 0; + } + + var queueName = _pendingGuestQueueSchedule[_pendingGuestQueueCursor]; + if (!_pendingGuestWorkByQueue.TryGetValue(queueName, out var queue) || + queue.First is not { } first) + { + _pendingGuestWorkByQueue.Remove(queueName); + _pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor); + continue; + } + + work = first.Value; + queue.RemoveFirst(); + _pendingGuestWorkCount--; + if (queue.Count == 0) + { + _pendingGuestWorkByQueue.Remove(queueName); + _pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor); + } + else + { + _pendingGuestQueueCursor = + (_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count; + } + + return true; + } + + work = default; + return false; + } + } + + private static void CompleteGuestWork(in PendingGuestWork pending) + { + lock (_gate) + { + _pendingGuestWorkBytes = pending.PayloadBytes >= _pendingGuestWorkBytes + ? 0 + : _pendingGuestWorkBytes - pending.PayloadBytes; + if (pending.Sequence == _completedGuestWorkSequence + 1) + { + _completedGuestWorkSequence = pending.Sequence; + while (_completedGuestWorkOutOfOrder.Remove(_completedGuestWorkSequence + 1)) + { + _completedGuestWorkSequence++; + } + } + else if (pending.Sequence > _completedGuestWorkSequence) + { + _completedGuestWorkOutOfOrder.Add(pending.Sequence); + } + + Monitor.PulseAll(_gate); + } + } + + /// Drains queued guest work on the render loop, bounded by count and a + /// wall-clock budget so a backlog cannot starve the event pump or the present. + private static void DrainGuestWork(nint device, nint queue) + { + var deadline = System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks; + var completedWork = 0; + RecycleCompletedUploadPages(); + RecycleCompletedSnapshotResources(); + EvictDirtyCachedDrawTextures(); + try + { + while (completedWork < MaxGuestWorkPerRender) + { + if (!TryTakeGuestWork(out var pendingGuestWork)) + { + return; + } + + Volatile.Write(ref _executingGuestWorkSequence, pendingGuestWork.Sequence); + using var guestQueueScope = EnterGuestQueue( + pendingGuestWork.Queue.Name, + pendingGuestWork.Queue.SubmissionId); + _enqueueAsImmediateQueueFollowup = true; + _immediateFollowupTail = null; + try + { + // Draws and compute dispatches encode into the shared batch + // command buffer; everything else must observe their output + // on the serial queue, so it flushes the batch first. + switch (pendingGuestWork.Work) + { + case GuestImageWrite write: + FlushBatchedGuestCommands(); + ExecuteGuestImageWrite(device, queue, write); + break; + case OrderedGuestAction action: + FlushBatchedGuestCommands(); + ExecuteOrderedGuestAction(action); + break; + case OrderedGuestFlip flip: + FlushBatchedGuestCommands(); + ExecuteOrderedGuestFlip(device, queue, flip); + break; + case OrderedGuestFlipWait: + // Reaching this marker in queue order is the guarantee: + // the flip it follows has already captured its image. + break; + case GuestImageBlit blit: + FlushBatchedGuestCommands(); + ExecuteGuestImageBlit(queue, blit); + break; + case OffscreenGuestDraw offscreenDraw: + ExecuteOffscreenDraw(device, queue, offscreenDraw); + break; + case ComputeGuestDispatch computeDispatch: + ExecuteComputeDispatch(device, queue, computeDispatch); + break; + } + } + catch (Exception exception) + { + Console.Error.WriteLine( + $"[LOADER][ERROR] Metal guest work failed " + + $"({pendingGuestWork.Work.GetType().Name}): {exception.Message}"); + } + finally + { + CompleteGuestWork(pendingGuestWork); + _enqueueAsImmediateQueueFollowup = false; + _immediateFollowupTail = null; + Volatile.Write(ref _executingGuestWorkSequence, 0); + } + + completedWork++; + if (System.Diagnostics.Stopwatch.GetTimestamp() >= deadline) + { + return; + } + } + } + finally + { + // Whatever path exits the drain, batched work must reach the queue: + // the present pass and the next drain's recyclers both assume every + // encoded command buffer has been committed. + _ = FlushBatchedGuestCommands(); + } + } + + private static void ExecuteOrderedGuestAction(OrderedGuestAction ordered) + { + try + { + ordered.Action(); + } + catch (Exception exception) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Metal ordered guest action '{ordered.DebugName}' failed: {exception.Message}"); + } + } + + private static void ExecuteGuestImageWrite(nint device, nint queue, GuestImageWrite write) + { + GuestImage? image; + lock (_gate) + { + _guestImages.TryGetValue(write.Address, out image); + } + + if (image is null) + { + return; + } + + if (write.Pixels is { } pixels) + { + var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(image.Format); + if ((ulong)pixels.Length < (ulong)image.Width * image.Height * bytesPerPixel) + { + return; + } + + // Swap in a freshly written texture instead of mutating one an + // in-flight present may still sample (the command buffer keeps its + // own reference to the old texture until it completes). + var replacement = CreateGuestTexture(device, image.Format, image.Width, image.Height); + if (replacement == 0) + { + return; + } + + ReplaceTextureContents(replacement, image.Width, image.Height, pixels, image.Width, bytesPerPixel); + var previous = image.Texture; + image.Texture = replacement; + MetalNative.SendVoid(previous, MetalNative.Selector("release")); + } + else + { + ClearTexture(queue, image.Texture, write.FillValue); + } + + // The pixel path swapped the texture out entirely; either way the + // cached snapshot no longer reflects this image. + image.ReleaseSnapshot(); + image.MarkContentChanged(); + } + + private static void ExecuteOrderedGuestFlip(nint device, nint queue, OrderedGuestFlip flip) + { + // The flipped VideoOut address is the display buffer's start, but games + // render into the pixel surface, which sits past the buffer's surface + // metadata (64KB+ on PS5). Prefer a drawn image inside the buffer's + // extent over seeding a new (empty) image at the exact start address. + GuestImage? image; + lock (_gate) + { + image = FindGuestImageForFlipLocked(flip.Address, flip.Width, flip.Height); + } + + image ??= EnsureGuestImage(device, flip.Address, flip.Width, flip.Height, flip.PitchInPixel); + if (image is null || !image.Initialized) + { + return; + } + + // Capture the mutable image into an immutable generation at this exact + // queue position; later work cannot change the frame this flip selected. + var captured = new GuestImage + { + Texture = CreateGuestTexture(device, image.Format, image.Width, image.Height), + Width = image.Width, + Height = image.Height, + Format = image.Format, + Initialized = true, + }; + if (captured.Texture == 0) + { + return; + } + + CopyTexture(queue, image.Texture, captured.Texture); + + lock (_gate) + { + _guestImageVersions[flip.Version] = captured; + // Retain a short version history, always preserving the newest. + while (_guestImageVersions.Count > MaxPendingGuestFlipVersions) + { + var oldest = 0L; + foreach (var version in _guestImageVersions.Keys) + { + if (oldest == 0 || version < oldest) + { + oldest = version; + } + } + + if (oldest == flip.Version) + { + break; + } + + if (_guestImageVersions.Remove(oldest, out var retired)) + { + MetalNative.SendVoid(retired.Texture, MetalNative.Selector("release")); + } + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + var presentation = new Presentation( + null, + image.Width, + image.Height, + sequence, + IsSplash: false, + GuestImageAddress: flip.Address, + GuestImageVersion: flip.Version); + _latestPresentation = presentation; + _pendingGuestImagePresentations.Enqueue(presentation); + while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork) + { + RetireSkippedPresentationLocked(_pendingGuestImagePresentations.Dequeue()); + } + } + } + + private static void ExecuteGuestImageBlit(nint queue, GuestImageBlit blit) + { + GuestImage? source, destination; + lock (_gate) + { + _guestImages.TryGetValue(blit.SourceAddress, out source); + _guestImages.TryGetValue(blit.DestinationAddress, out destination); + } + + if (source is null || destination is null || !source.Initialized) + { + return; + } + + CopyTexture(queue, source.Texture, destination.Texture); + destination.MarkContentChanged(); + } + + private static bool _tracedFlipAlias; + + /// Resolves a flip to produced content: the exact-address image when + /// GPU work wrote it, else the nearest same-extent produced image within the + /// display buffer's plausible metadata window above the start address (games + /// render into the pixel surface past the buffer's metadata block), else the + /// exact-address image even if it only holds a speculative seed. + private static GuestImage? FindGuestImageForFlipLocked(ulong address, uint width, uint height) + { + _guestImages.TryGetValue(address, out var exact); + if (exact is { Initialized: true, GpuWritten: true }) + { + return exact; + } + + GuestImage? best = null; + var bestDelta = ulong.MaxValue; + ulong bestAddress = 0; + foreach (var entry in _guestImages) + { + if (entry.Key <= address) + { + continue; + } + + var delta = entry.Key - address; + if (delta <= 0x20_0000 && + delta < bestDelta && + entry.Value.Width == width && + entry.Value.Height == height && + entry.Value is { Initialized: true, GpuWritten: true }) + { + best = entry.Value; + bestDelta = delta; + bestAddress = entry.Key; + } + } + + if (best is not null) + { + if (!_tracedFlipAlias) + { + _tracedFlipAlias = true; + Console.Error.WriteLine( + $"[LOADER][INFO] Metal flip alias: display buffer 0x{address:X16} " + + $"presents drawn surface 0x{bestAddress:X16} (+0x{bestDelta:X})."); + } + + return best; + } + + return exact is { Initialized: true } ? exact : null; + } + + /// Returns the mutable image for a guest address, creating and seeding + /// it (pending initial data first, guest memory second) on first use. + private static GuestImage? EnsureGuestImage( + nint device, + ulong address, + uint width, + uint height, + uint pitchInPixel) + { + uint formatTag; + byte[]? initialData; + lock (_gate) + { + if (_guestImages.TryGetValue(address, out var existing)) + { + return existing; + } + + if (!_availableGuestImages.TryGetValue(address, out formatTag)) + { + return null; + } + + _pendingGuestImageInitialData.Remove(address, out initialData); + } + + if (width == 0 || height == 0 || width > 16384 || height > 16384) + { + return null; + } + + var format = DecodeGuestFormatTag(formatTag); + var image = new GuestImage + { + Texture = CreateGuestTexture(device, format, width, height), + Width = width, + Height = height, + Format = format, + }; + if (image.Texture == 0) + { + return null; + } + + var pitch = pitchInPixel == 0 ? width : Math.Max(pitchInPixel, width); + var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format); + if (initialData is not null && + bytesPerPixel == 4 && + (ulong)initialData.Length >= (ulong)width * height * 4) + { + // Pending initial data is RGBA8; only 4-byte-texel images can take + // it verbatim. Wider formats seed from guest memory below, whose + // layout is the image's native one. + ReplaceTextureContents(image.Texture, width, height, initialData, width, bytesPerPixel); + image.Initialized = true; + image.ContentVersion++; + } + else if (_guestMemory is { } memory) + { + // PS5 render targets alias guest memory: CPU-prefilled pixels are + // visible before the first draw, so the first use seeds from there. + var byteCount = checked((int)((ulong)pitch * height * bytesPerPixel)); + var guestPixels = GuestDataPool.Shared.Rent(byteCount); + try + { + if (memory.TryRead(address, guestPixels.AsSpan(0, byteCount))) + { + ReplaceTextureContents(image.Texture, width, height, guestPixels, pitch, bytesPerPixel); + image.Initialized = true; + image.ContentVersion++; + } + } + finally + { + GuestDataPool.Shared.Return(guestPixels); + } + } + + lock (_gate) + { + _guestImages[address] = image; + _guestImageExtents[address] = (width, height, (ulong)pitch * height * bytesPerPixel); + } + + return image; + } + + private static void RetireSkippedPresentationLocked(Presentation presentation) + { + if (presentation.GuestImageVersion != 0 && + _guestImageVersions.Remove(presentation.GuestImageVersion, out var version)) + { + MetalNative.SendVoid(version.Texture, MetalNative.Selector("release")); + } + } + + // Guest texture-format tags, byte-identical to the Vulkan backend's encoding so + // VideoOut's registered display-buffer tags mean the same thing on both. + private static uint GetGuestTextureFormat(uint format, uint numberType) => + IsKnownGuestTextureFormat(format) + ? 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu) + : 0; + + private static bool IsKnownGuestTextureFormat(uint format) => + format is >= 1 and <= 19 or 34 or >= 169 and <= 182; + + private static MtlPixelFormat DecodeGuestFormatTag(uint tag) + { + var format = (tag >> 8) & 0x1FFu; + var numberType = tag & 0xFFu; + return MetalGuestFormats.TryDecodeRenderTargetFormat(format, numberType, out var decoded) + ? decoded.Format + : MtlPixelFormat.Rgba8Unorm; + } + + /// Picks the newest ready queued guest presentation (retiring the ones + /// it supersedes), falling back to the latest CPU frame or splash. + private static bool TryTakePresentation(long presentedSequence, out Presentation presentation) + { + lock (_gate) + { + Presentation? selected = null; + while (_pendingGuestImagePresentations.Count > 0) + { + var head = _pendingGuestImagePresentations.Peek(); + if (!IsGuestWorkCompletedLocked(head.RequiredGuestWorkSequence)) + { + break; + } + + _pendingGuestImagePresentations.Dequeue(); + if (selected is not null) + { + RetireSkippedPresentationLocked(selected); + } + + selected = head; + } + + if (selected is not null) + { + presentation = selected; + return true; + } + + if (_latestPresentation is { } latest && + latest.Sequence > presentedSequence && + (latest.Pixels is not null || + (latest.TranslatedDraw is not null || latest.DrawKind != GuestDrawKind.None) && + IsGuestWorkCompletedLocked(latest.RequiredGuestWorkSequence))) + { + presentation = latest; + return true; + } + + presentation = default!; + return false; + } + } + + private static bool TryResolveGuestPresentation( + nint device, + Presentation presentation, + out nint texture, + out uint width, + out uint height, + out bool owned) + { + GuestImage? image = null; + owned = false; + lock (_gate) + { + if (presentation.GuestImageVersion != 0) + { + owned = _guestImageVersions.Remove(presentation.GuestImageVersion, out image); + } + + if (image is null && presentation.GuestImageAddress != 0) + { + _guestImages.TryGetValue(presentation.GuestImageAddress, out image); + } + } + + // An unordered flip can name a registered display buffer that no work has + // materialized yet; seed it from guest memory so CPU-rendered buffers show. + image ??= presentation.GuestImageAddress != 0 + ? EnsureGuestImage( + device, + presentation.GuestImageAddress, + presentation.Width, + presentation.Height, + presentation.GuestImagePitch) + : null; + + if (image is not { Initialized: true }) + { + if (owned && image is not null) + { + MetalNative.SendVoid(image.Texture, MetalNative.Selector("release")); + } + + texture = 0; + width = 0; + height = 0; + owned = false; + return false; + } + + texture = image.Texture; + width = image.Width; + height = image.Height; + return true; + } + + private static void SwitchPresentSource( + nint texture, + uint width, + uint height, + bool ownsTexture, + ref nint presentTexture, + ref uint presentWidth, + ref uint presentHeight, + ref nint ownedTexture) + { + if (ownedTexture != 0 && ownedTexture != texture) + { + // In-flight command buffers hold their own references; dropping ours + // is safe even if the previous frame is still presenting. + MetalNative.SendVoid(ownedTexture, MetalNative.Selector("release")); + ownedTexture = 0; + } + + if (ownsTexture) + { + ownedTexture = texture; + } + + presentTexture = texture; + presentWidth = width; + presentHeight = height; + } + + private static nint CreateGuestTexture(nint device, MtlPixelFormat format, uint width, uint height) + { + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + (nuint)format, + width, + height, + mipmapped: false); + // ShaderRead | RenderTarget: the present pass samples these and fills + // clear them through a render pass. + MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), (nint)5); + // MTLStorageModeShared (0): these textures are CPU-populated + // (replaceRegion) and GPU-sampled. The MTLTextureDescriptor default is + // Managed, which on unified memory needs an explicit host->device sync + // we never issue, so the GPU reads stale (uninitialized/white) texels — + // Xcode's frame capture flags exactly this. Shared is coherent on + // Apple Silicon with no sync. + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0); + return MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + } + + /// Uploads pixel rows sized by the texture's real texel width. The + /// row count is clamped to what actually holds, so + /// replaceRegion can never read past the managed buffer. + private static void ReplaceTextureContents( + nint texture, + uint width, + uint height, + byte[] pixels, + uint pitchInPixel, + uint bytesPerPixel) + { + var bytesPerRow = (ulong)Math.Max(pitchInPixel, width) * bytesPerPixel; + var lastRowBytes = (ulong)width * bytesPerPixel; + if (lastRowBytes == 0 || (ulong)pixels.Length < lastRowBytes) + { + return; + } + + var maxRows = (((ulong)pixels.Length - lastRowBytes) / bytesPerRow) + 1; + var rows = (uint)Math.Min(height, maxRows); + if (rows == 0) + { + return; + } + + unsafe + { + fixed (byte* source = pixels) + { + MetalNative.SendReplaceRegion( + texture, + MetalNative.Selector("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), + new MtlRegion { X = 0, Y = 0, Z = 0, Width = width, Height = rows, Depth = 1 }, + 0, + (nint)source, + (nuint)bytesPerRow); + } + } + } + + /// Clears via a render pass (GPU-side, hazard-tracked against in-flight + /// sampling) using the raw 32-bit guest fill pattern interpreted as RGBA8. + private static void ClearTexture(nint queue, nint texture, uint fillValue) + { + var color = new MtlClearColor + { + Red = (fillValue & 0xFF) / 255.0, + Green = ((fillValue >> 8) & 0xFF) / 255.0, + Blue = ((fillValue >> 16) & 0xFF) / 255.0, + Alpha = ((fillValue >> 24) & 0xFF) / 255.0, + }; + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + var encoder = MetalNative.Send( + commandBuffer, + MetalNative.Selector("renderCommandEncoderWithDescriptor:"), + CreateClearPass(texture, color)); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + } + + private static void CopyTexture(nint queue, nint source, nint destination) + { + var commandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer")); + EncodeCopyTexture(commandBuffer, source, destination); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + } + + /// Encodes a full-texture copy into an existing command buffer; + /// used by the batched draw path, where the copy must be ordered after the + /// batch's earlier passes rather than committed ahead of them. + private static void EncodeCopyTexture(nint commandBuffer, nint source, nint destination) + { + var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("blitCommandEncoder")); + MetalNative.SendVoidCopyTexture( + encoder, + MetalNative.Selector("copyFromTexture:toTexture:"), + source, + destination); + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs new file mode 100644 index 0000000..9bdcf05 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.SnapshotPool.cs @@ -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; + + /// Texture identity (unused for buffers). + public uint Format; + public uint Width; + public uint Height; + public nint Usage; + + /// Buffer capacity in bytes (unused for textures). + public nuint Capacity; + + /// Retained handle of the command buffer that samples this + /// snapshot; the resource is reusable once it completes. + public nint LastCommandBuffer; + } + + private static readonly List _retiredSnapshotResources = []; + private static readonly List _pendingSnapshotResources = []; + private static readonly List _freeSnapshotResources = []; + + /// Returns completed snapshot resources to the free list; called + /// once per render-loop drain, next to the upload-page recycler. + 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")); + } + } + } + + /// 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. + 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; + } + + /// Pops a pooled private-storage staging buffer of at least + /// , or creates one. Pool-owned like + /// . + 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; + } + + /// Marks every snapshot resource acquired since the previous tag + /// as owing its lifetime to . Called at the + /// same commit sites as ; a resource acquired + /// for a draw that never committed is tagged by the next commit, which is + /// conservative but safe. + 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(); + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs new file mode 100644 index 0000000..3e8d4a9 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.TextureCache.cs @@ -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; + + /// 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. + private static readonly Dictionary _drawTextureCache = new(); + + /// Identities the AGC submit thread may skip texel copies for. + /// Read from the submit thread, written by the render thread. + private static readonly ConcurrentDictionary _cachedDrawTextureIdentities = new(); + + internal static bool IsTextureContentCached(in TextureContentIdentity identity) => + _cachedDrawTextureIdentities.ContainsKey(identity); + + /// 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. + 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); + + /// 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. + 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"); + } + + /// 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. + 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? 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? 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); + } + } + + /// 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. + 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; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs new file mode 100644 index 0000000..700ce86 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Uploads.cs @@ -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; + + /// Retained handle of the last command buffer that consumed + /// data from this page; the page is reusable once it completes. + public nint LastCommandBuffer; + + /// Stamp of the last TagUploadPages call that saw this page, + /// so a commit only re-tags pages it actually touched. + public int TouchStamp; + } + + private static readonly List _retiredUploadPages = []; + private static readonly Stack _freeUploadPages = new(); + private static readonly List _touchedUploadPages = []; + private static UploadPage? _currentUploadPage; + private static int _uploadTouchStamp; + + /// 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. + 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")); + } + } + } + + /// 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. + private static unsafe Span 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((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, + }; + } + + /// Marks every page touched since the previous tag as owing its + /// lifetime to . Called after each commit + /// that consumed arena data. + 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++; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs new file mode 100644 index 0000000..ee9f809 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs @@ -0,0 +1,1154 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SharpEmu.HLE; +using SharpEmu.Libs.VideoOut; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Metal; + +namespace SharpEmu.Libs.Gpu.Metal; + +/// +/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink +/// requested from the content view drives in sync with the +/// display refresh, on the main run loop — the loop whose Core Animation observer +/// commits presented drawables to the window server, so it must be a real running +/// run loop (a hand-pumped event drain never fires that observer and the window +/// stays black). Everything AppKit runs on the process main thread via +/// (AppKit traps off-main), which the CLI parks for us. +/// +internal static partial class MetalVideoPresenter +{ + private const uint DefaultWindowWidth = 1280; + private const uint DefaultWindowHeight = 720; + + // NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable + // both lets the user drag the window edges and turns the green zoom button + // into the full-screen toggle (paired with the collection behavior below). + private const nuint WindowStyleMask = 1 | 2 | 4 | 8; + + // NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native + // full-screen, so the green button enters full-screen rather than zooming. + private const nuint FullScreenPrimaryBehavior = 1 << 7; + private const nuint BackingStoreBuffered = 2; + private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm; + private const nuint LoadActionLoad = 1; + private const nuint LoadActionClear = 2; + private const nuint StoreActionStore = 1; + private const nuint PrimitiveTypeTriangle = 3; + private const nuint SamplerMinMagFilterLinear = 1; + + private sealed record Presentation( + byte[]? Pixels, + uint Width, + uint Height, + long Sequence, + bool IsSplash, + ulong GuestImageAddress = 0, + long GuestImageVersion = 0, + uint GuestImagePitch = 0, + long RequiredGuestWorkSequence = 0, + TranslatedGuestDraw? TranslatedDraw = null, + GuestDrawKind DrawKind = GuestDrawKind.None); + + private static readonly object _gate = new(); + private static Thread? _thread; + private static bool _closed; + private static bool _splashHidden; + private static bool _closeRequested; + private static Presentation? _latestPresentation; + private static bool _loggedFirstPresentedFrame; + private static int _titleRefreshCounter; + private static string? _lastWindowTitle; + + // CPU-rasterized perf HUD (F1), blitted over the frame like the Vulkan + // presenter does; the panel texture lives for the window's lifetime. + private static nint _overlayTexture; + private static readonly byte[] _overlayPixels = + new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4]; + + // Presenter objects and per-frame present state, shared between window setup + // and the display-link RenderFrame callback (both on the main thread). + private static nint _device; + private static nint _commandQueue; + private static nint _metalLayer; + private static nint _presentPipeline; + private static nint _presentSampler; + private static nint _window; + private static nint _application; + private static nint _renderTimer; + private static nint _renderTimerTarget; + private static double _drawableWidth; + private static double _drawableHeight; + private static nint _frameTexture; + private static uint _frameTextureWidth; + private static uint _frameTextureHeight; + private static nint _presentTexture; + private static uint _presentTextureWidth; + private static uint _presentTextureHeight; + private static nint _ownedVersionTexture; + private static ulong _presentGuestAddress; + private static long _presentedSequence = -1; + private static bool _userClosed; + private static uint _windowWidth; + private static uint _windowHeight; + + public static void EnsureStarted(uint width, uint height) + { + if (width == 0 || height == 0) + { + return; + } + + lock (_gate) + { + if (_closed || _thread is not null) + { + return; + } + } + + var hasSplash = PngSplashLoader.TryLoad( + out var splashPixels, + out var splashWidth, + out var splashHeight); + lock (_gate) + { + if (_closed || _thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + _latestPresentation ??= _splashHidden + ? new Presentation(CreateBlackFrame(width, height), width, height, 1, IsSplash: false) + : hasSplash + ? new Presentation(splashPixels, splashWidth, splashHeight, 1, IsSplash: true) + : new Presentation(null, width, height, 0, IsSplash: false); + StartPresenterLocked(); + } + } + + public static void HideSplashScreen() + { + lock (_gate) + { + _splashHidden = true; + if (_closed || _latestPresentation is not { IsSplash: true } latest) + { + return; + } + + _latestPresentation = new Presentation( + CreateBlackFrame(latest.Width, latest.Height), + latest.Width, + latest.Height, + latest.Sequence + 1, + IsSplash: false); + Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut hid splash"); + } + } + + public static void Submit(byte[] bgraFrame, uint width, uint height) + { + if (bgraFrame.Length != checked((int)(width * height * 4))) + { + return; + } + + lock (_gate) + { + if (_closed) + { + return; + } + + var sequence = (_latestPresentation?.Sequence ?? 0) + 1; + _latestPresentation = new Presentation(bgraFrame, width, height, sequence, IsSplash: false); + if (_thread is not null) + { + return; + } + + _windowWidth = width; + _windowHeight = height; + StartPresenterLocked(); + } + } + + /// Asks a running presenter loop to close its window and return. + public static void RequestClose() + { + Volatile.Write(ref _closeRequested, true); + } + + private static void StartPresenterLocked() + { + if (HostMainThread.IsAvailable) + { + _thread = Thread.CurrentThread; + HostMainThread.SetShutdownRequestHandler(RequestClose); + HostMainThread.Post(Run); + return; + } + + _thread = new Thread(Run) + { + IsBackground = true, + Name = "SharpEmu Metal VideoOut", + }; + _thread.Start(); + } + + private static void Run() + { + try + { + RunWindowLoop(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal VideoOut presenter failed: {exception}"); + } + finally + { + lock (_gate) + { + _closed = true; + _thread = null; + // Wake guest-work waiters and backpressured producers so close + // never strands a blocked guest thread. + Monitor.PulseAll(_gate); + } + } + } + + private static void RunWindowLoop() + { + MetalNative.EnsureFrameworksLoaded(); + + _device = MetalNative.MTLCreateSystemDefaultDevice(); + if (_device == 0) + { + Console.Error.WriteLine("[LOADER][ERROR] No Metal device available."); + return; + } + + // Mirror the Vulkan presenter: fold the selected GPU's name into the + // window title. Without this the Metal title never gains the "· " + // suffix the Vulkan path shows. + var deviceName = MetalNative.ReadNsString( + MetalNative.Send(_device, MetalNative.Selector("name"))); + if (!string.IsNullOrEmpty(deviceName)) + { + VideoOutExports.SetSelectedGpuName(deviceName); + } + + // Fixed window like the Vulkan presenter: guest frames letterbox into + // it. Sizing the window from the guest's display mode (4K) exceeds the + // screen — macOS clamps the window while the layer keeps the requested + // geometry, leaving the visible region showing nothing but clear. + const uint width = DefaultWindowWidth; + const uint height = DefaultWindowHeight; + + var setupPool = MetalNative.objc_autoreleasePoolPush(); + try + { + _application = MetalNative.Send( + MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication")); + // NSApplicationActivationPolicyRegular: dock icon + key window like any app. + MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0); + MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching")); + + _window = CreateWindow(width, height); + + // Swap in the key-capturing view before the metal layer attaches so + // the layer lands on the input-aware content view. + var keyView = MetalNative.SendInitFrame( + MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")), + MetalNative.Selector("initWithFrame:"), + new CGRect { X = 0, Y = 0, Width = width, Height = height }); + MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView); + + _metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight); + _commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue")); + if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError)) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal present pipeline failed: {pipelineError}"); + return; + } + + _presentSampler = CreateLinearSampler(_device); + + MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0); + MetalNative.SendVoidBool( + _application, MetalNative.Selector("activateIgnoringOtherApps:"), true); + MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView); + MetalHostInput.Attach(); + + // A repeating NSTimer on this (main) run loop fires onFrame: at the + // display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:) + // is the natural choice but its callback never fires under the x86-64 + // Rosetta process this emulator runs as — proven in isolation against + // a bare AppKit harness, where a timer fires and composites and the + // display link does not. nextDrawable still throttles presentation to + // the display, so the timer only needs to keep up, not pace precisely. + _renderTimerTarget = CreateRenderTimerTarget(); + _renderTimer = MetalNative.Send( + MetalNative.SendTimer( + MetalNative.Class("NSTimer"), + MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"), + 1.0 / 60.0, + _renderTimerTarget, + MetalNative.Selector("onFrame:"), + 0, + repeats: true), + MetalNative.Selector("retain")); + } + finally + { + MetalNative.objc_autoreleasePoolPop(setupPool); + } + + Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started."); + + // [NSApp run] runs the main run loop (its Core Animation observer commits + // presented drawables to the window server) AND fully activates the app, + // which a bare CFRunLoopRun does not — the CADisplayLink is only serviced + // once the app is running, and NSApp dispatches window events itself. + // Returns once RenderFrame stops it. + MetalNative.SendVoid(_application, MetalNative.Selector("run")); + + var closePool = MetalNative.objc_autoreleasePoolPush(); + try + { + MetalNative.SendVoid(_window, MetalNative.Selector("close")); + } + finally + { + MetalNative.objc_autoreleasePoolPop(closePool); + } + + if (_userClosed) + { + Console.Error.WriteLine( + "[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown."); + VideoOutExports.NotifyPresentationWindowClosed(); + } + } + + /// + /// An NSView subclass that records key events for pad emulation. Overriding + /// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and + /// swallowing the events also silences the system alert beep AppKit plays + /// for unhandled keys. Registered once per process. + /// + private static unsafe nint CreateKeyViewClass() + { + var cls = MetalNative.objc_allocateClassPair( + MetalNative.Class("NSView"), "SharpEmuMetalView", 0); + if (cls == 0) + { + return MetalNative.Class("SharpEmuMetalView"); + } + + var keyDown = (nint)(delegate* unmanaged[Cdecl])&OnKeyDown; + MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@"); + var keyUp = (nint)(delegate* unmanaged[Cdecl])&OnKeyUp; + MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@"); + // Command-modified keys never reach keyDown: — AppKit routes them through + // performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here. + var keyEquivalent = (nint)(delegate* unmanaged[Cdecl])&OnPerformKeyEquivalent; + MetalNative.class_addMethod( + cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@"); + // First responder status is what routes key events to this view. + var accepts = (nint)(delegate* unmanaged[Cdecl])&AcceptsFirstResponder; + MetalNative.class_addMethod( + cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:"); + MetalNative.objc_registerClassPair(cls); + return cls; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnKeyDown(nint self, nint cmd, nint nsEvent) + { + try + { + var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF); + var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")); + + // Function keys can arrive here even with Command held (AppKit only + // reroutes some chords through the key-equivalent path), so catch + // Cmd+F1 in both places — and keep it away from MetalHostInput so it + // never toggles the plain-F1 perf overlay. + var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags")); + if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0) + { + if (!isRepeat) + { + ToggleMetalPerformanceHud(); + } + + return; + } + + MetalHostInput.KeyDown(keyCode, isRepeat); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnKeyUp(nint self, nint cmd, nint nsEvent) + { + try + { + var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF); + MetalHostInput.KeyUp(keyCode); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static byte AcceptsFirstResponder(nint self, nint cmd) => 1; + + private const ushort KeyCodeF1 = 0x7A; + private const ulong NsEventModifierFlagCommand = 1UL << 20; + private static bool _metalHudVisible; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent) + { + try + { + var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF); + var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags")); + if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0) + { + if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"))) + { + ToggleMetalPerformanceHud(); + } + + return 1; // handled: no system beep, no further routing + } + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}"); + } + + return 0; + } + + /// + /// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps + /// the built-in CPU-rasterized perf overlay). Configured per Apple's + /// "Customizing Metal Performance HUD": developerHUDProperties takes + /// mode=default|disabled and logging=default, plus any MTL_HUD_* environment + /// keys directly in the dictionary — all three HUD flags (enabled, per-frame + /// logging, shader-compile logging) ride in one property set. Runs on the + /// AppKit main thread (the key-equivalent path), same thread as the render loop. + /// + private static void ToggleMetalPerformanceHud() + { + var layer = _metalLayer; + if (layer == 0) + { + return; + } + + var setProperties = MetalNative.Selector("setDeveloperHUDProperties:"); + if (!MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setProperties)) + { + Console.Error.WriteLine("[LOADER][WARN] Metal Performance HUD unavailable on this macOS."); + return; + } + + _metalHudVisible = !_metalHudVisible; + var pool = MetalNative.objc_autoreleasePoolPush(); + try + { + var properties = MetalNative.Send( + MetalNative.Class("NSMutableDictionary"), MetalNative.Selector("dictionary")); + var setObjectForKey = MetalNative.Selector("setObject:forKey:"); + if (_metalHudVisible) + { + var defaultValue = MetalNative.NsString("default"); + MetalNative.SendVoid(properties, setObjectForKey, defaultValue, MetalNative.NsString("mode")); + MetalNative.SendVoid(properties, setObjectForKey, defaultValue, MetalNative.NsString("logging")); + MetalNative.SendVoid( + properties, + setObjectForKey, + MetalNative.NsString("1"), + MetalNative.NsString("MTL_HUD_LOG_SHADER_ENABLED")); + } + else + { + MetalNative.SendVoid( + properties, setObjectForKey, MetalNative.NsString("disabled"), MetalNative.NsString("mode")); + } + + MetalNative.SendVoid(layer, setProperties, properties); + } + finally + { + MetalNative.objc_autoreleasePoolPop(pool); + } + + Console.Error.WriteLine( + $"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1)."); + } + + private static unsafe nint CreateRenderTimerTarget() + { + // A minimal NSObject subclass whose onFrame: is our unmanaged callback — + // the dependency-free way to hand a target/selector to NSTimer without a + // binding library. Registered once per process. + var cls = MetalNative.objc_allocateClassPair( + MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0); + if (cls != 0) + { + var imp = (nint)(delegate* unmanaged[Cdecl])&OnRenderTimer; + // "v@:@": void return, self, _cmd, one object argument (the timer). + MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@"); + var wakeImp = (nint)(delegate* unmanaged[Cdecl])&OnGuestWorkWake; + MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@"); + MetalNative.objc_registerClassPair(cls); + } + else + { + cls = MetalNative.Class("SharpEmuRenderTimerTarget"); + } + + return MetalNative.Send( + MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init")); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnRenderTimer(nint self, nint cmd, nint timer) + { + try + { + RenderFrame(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal render frame failed: {exception}"); + } + } + + /// Set while an onGuestWork: wake is scheduled on the main run + /// loop; coalesces enqueue-side wake requests to one in-flight message. + private static int _guestWorkWakeScheduled; + + /// Wakes the main run loop to drain guest work now instead of at + /// the next render tick. Guest submit→wait round-trips (release-mem labels, + /// CPU-visible write-backs) otherwise cost a full frame interval each — + /// games that chain several per frame crawl at a fraction of the display + /// rate. Safe from any thread; no-op until the presenter starts. + internal static void ScheduleGuestWorkDrain() + { + if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0) + { + return; + } + + var target = _renderTimerTarget; + if (target == 0) + { + Volatile.Write(ref _guestWorkWakeScheduled, 0); + return; + } + + MetalNative.SendVoidPerformSelector( + target, + MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"), + MetalNative.Selector("onGuestWork:"), + 0, + waitUntilDone: false); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnGuestWorkWake(nint self, nint cmd, nint argument) + { + Volatile.Write(ref _guestWorkWakeScheduled, 0); + if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested)) + { + return; + } + + var pool = MetalNative.objc_autoreleasePoolPush(); + try + { + DrainGuestWork(_device, _commandQueue); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}"); + } + finally + { + MetalNative.objc_autoreleasePoolPop(pool); + } + } + + private static void RenderFrame() + { + MetalHostInput.PumpAutoKeys(); + var pool = MetalNative.objc_autoreleasePoolPush(); + try + { + // NSApp.run dispatches window events itself, so there is no manual + // event drain here. + var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible")); + if (Volatile.Read(ref _closeRequested) || !visible) + { + _userClosed = !visible && !Volatile.Read(ref _closeRequested); + MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate")); + // Stop both the AppKit loop and the underlying CFRunLoop so + // [NSApp run] returns. + MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0); + MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain()); + return; + } + + DrainGuestWork(_device, _commandQueue); + + if (TryTakePresentation(_presentedSequence, out var presentation)) + { + _presentedSequence = presentation.Sequence; + if (presentation.Pixels is not null) + { + UploadFrame( + _device, + presentation, + ref _frameTexture, + ref _frameTextureWidth, + ref _frameTextureHeight); + SwitchPresentSource( + _frameTexture, + _frameTextureWidth, + _frameTextureHeight, + ownsTexture: false, + ref _presentTexture, + ref _presentTextureWidth, + ref _presentTextureHeight, + ref _ownedVersionTexture); + _presentGuestAddress = 0; + } + else if (presentation.TranslatedDraw is not null || + presentation.DrawKind != GuestDrawKind.None) + { + var drawTarget = ExecutePresentationDraw(_device, _commandQueue, presentation); + if (drawTarget != 0) + { + // Transient targets are pooled by the presenter; the + // present source borrows them. + SwitchPresentSource( + drawTarget, + presentation.Width, + presentation.Height, + ownsTexture: false, + ref _presentTexture, + ref _presentTextureWidth, + ref _presentTextureHeight, + ref _ownedVersionTexture); + _presentGuestAddress = 0; + } + } + else if (TryResolveGuestPresentation( + _device, + presentation, + out var guestTexture, + out var guestWidth, + out var guestHeight, + out var ownsGuestTexture)) + { + // Captured versions are immutable and owned here; mutable + // address-keyed images are re-resolved at encode time so a + // write swapping the texture never leaves a stale handle. + SwitchPresentSource( + ownsGuestTexture ? guestTexture : 0, + guestWidth, + guestHeight, + ownsGuestTexture, + ref _presentTexture, + ref _presentTextureWidth, + ref _presentTextureHeight, + ref _ownedVersionTexture); + _presentGuestAddress = ownsGuestTexture ? 0 : presentation.GuestImageAddress; + } + } + + if (_presentGuestAddress != 0) + { + // Re-resolve every frame: a guest-image write swaps the texture + // behind the address. + _presentTexture = 0; + lock (_gate) + { + if (_guestImages.TryGetValue(_presentGuestAddress, out var borrowed) && + borrowed.Initialized) + { + _presentTexture = borrowed.Texture; + _presentTextureWidth = borrowed.Width; + _presentTextureHeight = borrowed.Height; + } + } + } + + // The window title reflects late guest state (the game registers its + // application name after boot) plus the GPU suffix; the Vulkan + // presenter re-reads it, so refresh periodically here for parity. + if ((++_titleRefreshCounter & 0x3F) == 0) + { + var title = VideoOutExports.GetWindowTitle(); + if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal)) + { + _lastWindowTitle = title; + MetalNative.SendVoid( + _window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title)); + } + } + + // The window is resizable, so the backing layer's bounds follow the + // window while its drawable size does not — match them before asking + // for a drawable, or nextDrawable keeps handing back the original + // resolution and Core Animation stretches it (blurry, mis-scaled + // overlay). No-op when the size is unchanged, i.e. almost every tick. + SyncDrawableSizeToLayer(); + + var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable")); + if (drawable == 0) + { + // No free drawable this tick; the next timer fire retries. + return; + } + + if (_presentTexture != 0 && !_loggedFirstPresentedFrame) + { + _loggedFirstPresentedFrame = true; + Console.Error.WriteLine( + $"[LOADER][INFO] Metal VideoOut presenting {_presentTextureWidth}x{_presentTextureHeight}."); + } + + var drawableTexture = MetalNative.Send(drawable, MetalNative.Selector("texture")); + var commandBuffer = MetalNative.Send(_commandQueue, MetalNative.Selector("commandBuffer")); + var pass = CreateClearPass( + drawableTexture, + new MtlClearColor { Red = 0, Green = 0, Blue = 0, Alpha = 1 }); + var encoder = MetalNative.Send( + commandBuffer, MetalNative.Selector("renderCommandEncoderWithDescriptor:"), pass); + if (_presentTexture != 0) + { + EncodePresent( + encoder, + _presentPipeline, + _presentSampler, + _presentTexture, + _presentTextureWidth, + _presentTextureHeight, + _drawableWidth, + _drawableHeight); + } + + if (PerfOverlay.Enabled) + { + EncodeOverlay(encoder); + } + + MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding")); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("presentDrawable:"), drawable); + MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit")); + PerfOverlay.RecordPresent(); + } + finally + { + MetalNative.objc_autoreleasePoolPop(pool); + } + } + + /// Draws the CPU-rasterized perf panel over the frame's top-left + /// corner, reusing the present pipeline with a panel-sized viewport. + private static void EncodeOverlay(nint encoder) + { + if (_overlayTexture == 0) + { + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + PixelFormatBgra8Unorm, + PerfOverlay.PanelWidth, + PerfOverlay.PanelHeight, + mipmapped: false); + _overlayTexture = MetalNative.Send( + _device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + if (_overlayTexture == 0) + { + return; + } + } + + int pendingWork; + lock (_gate) + { + pendingWork = _pendingGuestWorkCount; + } + + PerfOverlay.Fill(_overlayPixels, pendingWork, 0); + ReplaceTextureContents( + _overlayTexture, + PerfOverlay.PanelWidth, + PerfOverlay.PanelHeight, + _overlayPixels, + PerfOverlay.PanelWidth, + bytesPerPixel: 4); + + const double margin = 16; + var panelWidth = Math.Min(PerfOverlay.PanelWidth, _drawableWidth - margin); + var panelHeight = Math.Min(PerfOverlay.PanelHeight, _drawableHeight - margin); + if (panelWidth <= 0 || panelHeight <= 0) + { + return; + } + + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), _presentPipeline); + MetalNative.SendVoidViewport( + encoder, + MetalNative.Selector("setViewport:"), + new MtlViewport + { + OriginX = margin, + OriginY = margin, + Width = panelWidth, + Height = panelHeight, + ZNear = 0, + ZFar = 1, + }); + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentTexture:atIndex:"), _overlayTexture, 0); + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentSamplerState:atIndex:"), _presentSampler, 0); + MetalNative.SendDrawPrimitives( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"), + PrimitiveTypeTriangle, + 0, + 3); + } + + private static nint CreateWindow(uint width, uint height) + { + var window = MetalNative.SendInitWindow( + MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")), + MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"), + new CGRect { X = 0, Y = 0, Width = width, Height = height }, + WindowStyleMask, + BackingStoreBuffered, + defer: false); + // The presenter owns the handle; AppKit must not free it on user close. + MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false); + MetalNative.Send( + window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior); + MetalNative.SendVoid( + window, + MetalNative.Selector("setTitle:"), + MetalNative.NsString(VideoOutExports.GetWindowTitle())); + MetalNative.SendVoid(window, MetalNative.Selector("center")); + // makeKeyAndOrderFront happens after the metal layer is attached. + return window; + } + + /// Keeps the CAMetalLayer's drawable size (pixels) matched to its + /// current bounds (points) × scale as the window resizes or moves between + /// displays. CAMetalLayer never updates drawableSize on its own, even as a + /// view's backing layer, so the render loop drives it. + private static void SyncDrawableSizeToLayer() + { + MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds")); + var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale")); + if (scale <= 0) + { + scale = 1; + } + + var width = Math.Max(1, Math.Round(bounds.Width * scale)); + var height = Math.Max(1, Math.Round(bounds.Height * scale)); + if (width == _drawableWidth && height == _drawableHeight) + { + return; + } + + _drawableWidth = width; + _drawableHeight = height; + MetalNative.SendVoidSize( + _metalLayer, + MetalNative.Selector("setDrawableSize:"), + new CGSize { Width = width, Height = height }); + } + + private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight) + { + var contentView = MetalNative.Send(window, MetalNative.Selector("contentView")); + var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor")); + if (scale <= 0) + { + scale = 1; + } + + const uint width = DefaultWindowWidth; + const uint height = DefaultWindowHeight; + drawableWidth = width * scale; + drawableHeight = height * scale; + + var layer = MetalNative.Send( + MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device); + MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm); + // A Core Animation layer composites with its alpha channel by default, so + // a presented frame whose guest alpha is zero would show through as the + // window background (black). The presenter output is a finished opaque + // frame; mark the layer opaque so alpha never reaches the compositor. + MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true); + MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true); + MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale); + MetalNative.SendVoidSize( + layer, + MetalNative.Selector("setDrawableSize:"), + new CGSize { Width = drawableWidth, Height = drawableHeight }); + + // A manually created layer defaults to a zero-size frame, and a hosted + // layer's geometry is the caller's job: without this the presenter + // happily presents every drawable into a layer with no on-screen + // extent — a permanently black window. + MetalNative.SendVoidRect( + layer, + MetalNative.Selector("setFrame:"), + new CGRect { X = 0, Y = 0, Width = width, Height = height }); + + // wantsLayer FIRST, then the layer: that makes the metal layer the + // view's AppKit-managed BACKING layer (geometry and window-server + // commits handled by AppKit) — the SDL/GLFW pattern. The reverse order + // creates a layer-hosting view whose tree the app must commit itself, + // which never composites under a manually pumped run loop. + MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true); + MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer); + MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush")); + return layer; + } + + private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error) + { + pipeline = 0; + var dbg = Environment.GetEnvironmentVariable("SHARPEMU_METAL_DBG"); + var fragmentSource = dbg switch + { + "solid" => MslFixedShaders.CreateSolidFragment(0f, 1f, 0f, 1f), + "uv" => MslFixedShaders.CreateAttributeFragment(0), + _ => MslFixedShaders.CreatePresentFragment(), + }; + if (!TryCompileLibrary(device, MslFixedShaders.CreateFullscreenVertex(1), out var vertexLibrary, out error) || + !TryCompileLibrary(device, fragmentSource, out var fragmentLibrary, out error)) + { + return false; + } + + var selNewFunction = MetalNative.Selector("newFunctionWithName:"); + var fragmentEntry = dbg switch { "solid" => "solid_fs", "uv" => "attribute_fs", _ => "present_fs" }; + var vertexFunction = MetalNative.Send(vertexLibrary, selNewFunction, MetalNative.NsString("fullscreen_vs")); + var fragmentFunction = MetalNative.Send(fragmentLibrary, selNewFunction, MetalNative.NsString(fragmentEntry)); + if (vertexFunction == 0 || fragmentFunction == 0) + { + error = "present shader entry points missing from the compiled libraries"; + return false; + } + + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction); + MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction); + var colorAttachment = MetalNative.SendAtIndex( + MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")), + MetalNative.Selector("objectAtIndexedSubscript:"), + 0); + MetalNative.Send(colorAttachment, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm); + + nint nsError = 0; + pipeline = MetalNative.Send( + device, + MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"), + descriptor, + ref nsError); + if (pipeline == 0) + { + error = MetalNative.DescribeError(nsError); + return false; + } + + return true; + } + + private static bool TryCompileLibrary(nint device, string source, out nint library, out string error) + { + error = string.Empty; + + // Fast-math off everywhere for parity with translated guest shaders, + // whose GCN float semantics do not survive it. + var options = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLCompileOptions"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.SendVoidBool(options, MetalNative.Selector("setFastMathEnabled:"), false); + + nint nsError = 0; + library = MetalNative.Send( + device, + MetalNative.Selector("newLibraryWithSource:options:error:"), + MetalNative.NsString(source), + options, + ref nsError); + if (library == 0) + { + error = MetalNative.DescribeError(nsError); + return false; + } + + return true; + } + + private static nint CreateLinearSampler(nint device) + { + var descriptor = MetalNative.Send( + MetalNative.Send(MetalNative.Class("MTLSamplerDescriptor"), MetalNative.Selector("alloc")), + MetalNative.Selector("init")); + MetalNative.Send(descriptor, MetalNative.Selector("setMinFilter:"), (nint)SamplerMinMagFilterLinear); + MetalNative.Send(descriptor, MetalNative.Selector("setMagFilter:"), (nint)SamplerMinMagFilterLinear); + return MetalNative.Send(device, MetalNative.Selector("newSamplerStateWithDescriptor:"), descriptor); + } + + private static void UploadFrame( + nint device, + Presentation presentation, + ref nint frameTexture, + ref uint textureWidth, + ref uint textureHeight) + { + if (frameTexture == 0 || textureWidth != presentation.Width || textureHeight != presentation.Height) + { + if (frameTexture != 0) + { + MetalNative.SendVoid(frameTexture, MetalNative.Selector("release")); + } + + var descriptor = MetalNative.SendTextureDescriptor( + MetalNative.Class("MTLTextureDescriptor"), + MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"), + PixelFormatBgra8Unorm, + presentation.Width, + presentation.Height, + mipmapped: false); + // Shared (0): CPU-uploaded frame, GPU-sampled by the present pass; + // the Managed default reads stale on unified memory. + MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0); + frameTexture = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor); + textureWidth = presentation.Width; + textureHeight = presentation.Height; + } + + ReplaceTextureContents( + frameTexture, + presentation.Width, + presentation.Height, + presentation.Pixels!, + presentation.Width, + bytesPerPixel: 4); + } + + private static nint CreateClearPass(nint targetTexture, MtlClearColor clearColor) + { + var pass = MetalNative.Send( + MetalNative.Class("MTLRenderPassDescriptor"), + MetalNative.Selector("renderPassDescriptor")); + var colorAttachment = MetalNative.SendAtIndex( + MetalNative.Send(pass, MetalNative.Selector("colorAttachments")), + MetalNative.Selector("objectAtIndexedSubscript:"), + 0); + MetalNative.SendVoid(colorAttachment, MetalNative.Selector("setTexture:"), targetTexture); + MetalNative.Send(colorAttachment, MetalNative.Selector("setLoadAction:"), (nint)LoadActionClear); + MetalNative.Send(colorAttachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore); + MetalNative.SendVoidClearColor( + colorAttachment, + MetalNative.Selector("setClearColor:"), + clearColor); + return pass; + } + + private static void EncodePresent( + nint encoder, + nint pipeline, + nint sampler, + nint frameTexture, + uint frameWidth, + uint frameHeight, + double drawableWidth, + double drawableHeight) + { + MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); + + // Aspect-fit letterbox: scale the frame into the drawable via the viewport. + var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight); + var viewportWidth = frameWidth * scale; + var viewportHeight = frameHeight * scale; + MetalNative.SendVoidViewport( + encoder, + MetalNative.Selector("setViewport:"), + new MtlViewport + { + OriginX = (drawableWidth - viewportWidth) * 0.5, + OriginY = (drawableHeight - viewportHeight) * 0.5, + Width = viewportWidth, + Height = viewportHeight, + ZNear = 0, + ZFar = 1, + }); + + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentTexture:atIndex:"), frameTexture, 0); + MetalNative.SendSetAtIndex( + encoder, MetalNative.Selector("setFragmentSamplerState:atIndex:"), sampler, 0); + MetalNative.SendDrawPrimitives( + encoder, + MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"), + PrimitiveTypeTriangle, + 0, + 3); + } + + private static byte[] CreateBlackFrame(uint width, uint height) + { + if (width == 0 || height == 0 || width > 8192 || height > 8192) + { + width = 1; + height = 1; + } + + var pixels = GC.AllocateUninitializedArray(checked((int)(width * height * 4))); + pixels.AsSpan().Clear(); + for (var offset = 3; offset < pixels.Length; offset += 4) + { + pixels[offset] = 0xFF; + } + + return pixels; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs index c221fe4..f2fcef1 100644 --- a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs +++ b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs @@ -15,6 +15,8 @@ namespace SharpEmu.Libs.Gpu.Vulkan; /// 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 diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 7191f30..0a6c46e 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -224,6 +224,21 @@ public static partial class KernelMemoryCompatExports } } + /// Removes a guest mount registered by . + 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, diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs index 083f068..a3ed204 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs @@ -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, diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs index 3e2d32e..19ed2b4 100644 --- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs +++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs @@ -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 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.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.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 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 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.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.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.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.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; diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs b/src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs new file mode 100644 index 0000000..05623f6 --- /dev/null +++ b/src/SharpEmu.Libs/Ngs2/Ngs2VagDecoder.cs @@ -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 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 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 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); + } +} diff --git a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs index 4efe1f6..1043e30 100644 --- a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs +++ b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs @@ -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 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)) diff --git a/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs b/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs index 529d3c0..e2fb769 100644 --- a/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs +++ b/src/SharpEmu.Libs/Np/NpUniversalDataSystemExports.cs @@ -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)); + } } diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 91884ea..1299040 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -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 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", diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs index cf470dc..41ef98a 100644 --- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs +++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs @@ -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 _events = new(); + private static readonly object _mountGate = new(); + // mountPoint -> live mount, for umount/IsMounted/GetMountInfo. + private static readonly Dictionary _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 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 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 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.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.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 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 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 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//; 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) diff --git a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs new file mode 100644 index 0000000..e8e966e --- /dev/null +++ b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs @@ -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; + +/// +/// Host-side layout and metadata for PS5 save data. Saves live under +/// ~/SharpEmu/Saves/<titleId>/<dirName>/ (overridable via +/// SHARPEMU_SAVEDATA_DIR); the game's files are written directly inside a +/// slot through the mounted /savedata0 filesystem, and the PS5 UI +/// metadata (title/subtitle/detail/userParam) plus icon live under +/// <slot>/sce_sys/. This type is pure filesystem logic with no guest +/// interop so the path and metadata handling can be unit-tested. +/// +public static class SaveDataStorage +{ + /// Root of all saves: the env override, else ~/SharpEmu/Saves. + 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); + } + + /// Per-title directory: <root>/<titleId>. + public static string TitleRoot(string root, string titleId) => + Path.Combine(root, Sanitize(titleId)); + + /// A single save slot: <titleRoot>/<dirName>. + public static string SlotDir(string titleRoot, string dirName) => + Path.Combine(titleRoot, Sanitize(dirName)); + + /// The SaveDataMemory blob shared by a title. + 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"); + + /// + /// 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. + /// + public static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "default"; + } + + var invalid = Path.GetInvalidFileNameChars(); + Span 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; + } + + /// Reads a slot's metadata, or defaults if none has been written. + 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))); + } + + /// Writes a slot's metadata, creating sce_sys/ as needed. + 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)); + } +} + +/// PS5 save-slot metadata surfaced by sceSaveDataGetParam / the save UI. +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 +{ +} diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj index 9b0babf..7656651 100644 --- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj +++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj @@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + -{ - private readonly object _gate = new(); - private readonly int _maxArrayLength; - private readonly ulong _maxCachedBytes; - private readonly int _maxArraysPerBucket; - private readonly Dictionary> _cachedByBucket = []; - private readonly HashSet _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(); - _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; -} diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 71689fe..fd174b0 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -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. diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 9ceba3c..94af4c0 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -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) diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs new file mode 100644 index 0000000..547db07 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslShader.cs @@ -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, +} + +/// +/// 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 : global memory buffers +/// occupy [[buffer(globalBufferBase + i)]] in +/// 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. +/// +/// +/// 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. +/// Texture slots are global across a draw's stages ([[texture( +/// ImageBindingBase + i)]]). Samplers live in a per-stage argument buffer +/// bound at (Metal caps direct +/// [[sampler(N)]] slots at 16 per stage, but shaders sample more), holding one +/// sampler per sampled image. maps this stage's +/// image binding index to its [[id(N)]] entry in that argument buffer, -1 for +/// storage images that take none; is the entry +/// count. +/// +public sealed record Gen5MslShader( + string Source, + string EntryPoint, + Gen5MslStage Stage, + IReadOnlyList GlobalMemoryBindings, + IReadOnlyList ImageBindings, + uint AttributeCount, + IReadOnlyList VertexInputs, + uint ThreadgroupSizeX = 1, + uint ThreadgroupSizeY = 1, + uint ThreadgroupSizeZ = 1, + int UniformsBufferIndex = -1, + int ImageBindingBase = 0, + IReadOnlyList? SamplerSlots = null, + int SamplerCount = 0, + int SamplerArgBufferIndex = -1); diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs new file mode 100644 index 0000000..0907f20 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs @@ -0,0 +1,1610 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal; + +public static partial class Gen5MslTranslator +{ + private sealed partial class CompilationContext + { + private const string TauLiteral = "6.2831853071795862f"; + + // ---- vector ALU ---- + + private bool TryEmitVectorAlu( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + if (instruction.Opcode == "VNop") + { + return true; + } + + if (instruction.Control is Gen5SdwaControl sdwa && + (sdwa.Source0Select == 7 || + sdwa.Source1Select == 7 || + sdwa.DestinationSelect == 7 || + sdwa.DestinationUnused == 3)) + { + error = $"reserved SDWA selector/modifier in {instruction.Opcode}"; + return false; + } + + if (instruction.Control is Gen5DppControl dppControl && + !IsSupportedDppControl(dppControl.Control)) + { + error = $"unsupported DPP16 control 0x{dppControl.Control:X3}"; + return false; + } + + if (instruction.Opcode.StartsWith("VCmp", StringComparison.Ordinal)) + { + return TryEmitVectorCompare(instruction, out error); + } + + switch (instruction.Opcode) + { + case "VReadfirstlaneB32": + { + if (instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.ScalarRegister || + instruction.Sources.Count == 0) + { + error = "invalid read-first-lane operands"; + return false; + } + + // Under the single-lane graphics model the "first active + // lane" is always this lane; a real simd_shuffle would read + // another fragment's value. Compute broadcasts from the + // first guest-active lane (the ballot of EXEC), matching + // the SPIR-V translator — SPIR-V's own BroadcastFirst uses + // the first host-active invocation, which may be a lane the + // guest has masked off. + var value = RawSource(instruction, 0); + if (IsSingleLaneStage) + { + StoreScalar(instruction.Destinations[0].Value, Temp("uint", value)); + return true; + } + + if (IsWave64) + { + StoreScalar( + instruction.Destinations[0].Value, + EmitWave64ReadFirstLane(value)); + return true; + } + + var mask = Temp("uint", "sharpemu_ballot(exec)"); + var firstLane = Temp("uint", $"{mask} == 0u ? 0u : (uint)ctz({mask})"); + StoreScalar( + instruction.Destinations[0].Value, + Temp("uint", ShuffleLane(value, firstLane))); + return true; + } + case "VReadlaneB32": + { + if (instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.ScalarRegister) + { + error = "VReadlaneB32 expects scalar destination"; + return false; + } + + var value = RawSource(instruction, 0); + var lane = Temp("uint", $"({RawSource(instruction, 1)}) & 31u"); + StoreScalar( + instruction.Destinations[0].Value, + Temp("uint", ShuffleLane(value, lane))); + return true; + } + case "VWritelaneB32": + { + // vdst[lane(src1)] = src0; a writelane lands regardless of EXEC. + var destination = DestinationVector(instruction); + var source = RawSource(instruction, 0); + var lane = RawSource(instruction, 1); + StoreVector( + destination, + $"(sharpemu_lane == (({lane}) & 31u)) ? ({source}) : v[{destination}]", + guardWithExec: false); + return true; + } + case "VCndmaskB32": + { + // dst = mask-bit(lane) ? src1 : src0. Sources are raw (no + // float modifiers), matching the SPIR-V translator; the mask + // is VCC for VOP2 and an explicit SGPR operand for VOP3. + var mask = instruction.Sources.Count > 2 + ? MaskBitExpression(instruction.Sources[2]) + : "vcc"; + StoreVector( + DestinationVector(instruction), + $"({mask}) ? ({RawSource(instruction, 1)}) : ({RawSource(instruction, 0)})"); + return true; + } + } + + return TryEmitVectorValue(instruction, out error); + } + + private bool TryEmitVectorValue( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + var destination = DestinationVector(instruction); + string? expression = instruction.Opcode switch + { + "VMovB32" => RawSource(instruction, 0), + + // ---- float arithmetic ---- + "VAddF32" => FloatResult(instruction, $"{F(instruction, 0)} + {F(instruction, 1)}"), + "VSubF32" => FloatResult(instruction, $"{F(instruction, 0)} - {F(instruction, 1)}"), + "VSubrevF32" => FloatResult(instruction, $"{F(instruction, 1)} - {F(instruction, 0)}"), + "VMulF32" => FloatResult(instruction, $"{F(instruction, 0)} * {F(instruction, 1)}"), + "VMinF32" => FloatResult(instruction, $"fmin({F(instruction, 0)}, {F(instruction, 1)})"), + "VMaxF32" => FloatResult(instruction, $"fmax({F(instruction, 0)}, {F(instruction, 1)})"), + // The decoder normalizes mk/ak literal placement, so every MAD/FMA + // form is fma(src0, src1, src2) exactly like the SPIR-V translator. + "VFmaF32" or "VMadF32" or "VMadAkF32" or "VMadMkF32" or "VFmaAkF32" or "VFmaMkF32" => + FloatResult(instruction, $"fma({F(instruction, 0)}, {F(instruction, 1)}, {F(instruction, 2)})"), + "VFmacF32" or "VMacF32" => + FloatResult(instruction, $"fma({F(instruction, 0)}, {F(instruction, 1)}, as_type(v[{destination}]))"), + "VFloorF32" => FloatResult(instruction, $"floor({F(instruction, 0)})"), + "VCeilF32" => FloatResult(instruction, $"ceil({F(instruction, 0)})"), + "VTruncF32" => FloatResult(instruction, $"trunc({F(instruction, 0)})"), + "VRndneF32" => FloatResult(instruction, $"rint({F(instruction, 0)})"), + "VFractF32" => FloatResult(instruction, $"fract({F(instruction, 0)})"), + "VSqrtF32" => FloatResult(instruction, $"sqrt({F(instruction, 0)})"), + "VRsqF32" => FloatResult(instruction, $"rsqrt({F(instruction, 0)})"), + "VRcpF32" or "VRcpIflagF32" => FloatResult(instruction, $"(1.0f / {F(instruction, 0)})"), + "VLogF32" => FloatResult(instruction, $"log2({F(instruction, 0)})"), + "VExpF32" => FloatResult(instruction, $"exp2({F(instruction, 0)})"), + // GCN sin/cos take revolutions; mirror the SPIR-V Tau prescale. + "VSinF32" => FloatResult(instruction, $"sin({F(instruction, 0)} * {TauLiteral})"), + "VCosF32" => FloatResult(instruction, $"cos({F(instruction, 0)} * {TauLiteral})"), + "VLdexpF32" => + FloatResult(instruction, $"ldexp({F(instruction, 0)}, as_type({RawSource(instruction, 1)}))"), + "VMin3F32" => + FloatResult(instruction, $"fmin(fmin({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)})"), + "VMax3F32" => + FloatResult(instruction, $"fmax(fmax({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)})"), + "VMed3F32" => + FloatResult(instruction, $"fmax(fmin({F(instruction, 0)}, {F(instruction, 1)}), fmin(fmax({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)}))"), + + // ---- conversions ---- + "VCvtF32I32" => FloatResult(instruction, $"(float)as_type({RawSource(instruction, 0)})"), + "VCvtF32U32" => FloatResult(instruction, $"(float)({RawSource(instruction, 0)})"), + "VCvtU32F32" => $"(uint)({F(instruction, 0)})", + "VCvtI32F32" => AsUInt($"(int)({F(instruction, 0)})"), + // RPI rounds toward positive infinity; FLR toward negative. + "VCvtRpiI32F32" => AsUInt($"(int)ceil({F(instruction, 0)})"), + "VCvtFlrI32F32" => AsUInt($"(int)floor({F(instruction, 0)})"), + "VCvtF32Ubyte0" => FloatResult(instruction, $"(float)(({RawSource(instruction, 0)}) & 0xFFu)"), + "VCvtF32Ubyte1" => FloatResult(instruction, $"(float)((({RawSource(instruction, 0)}) >> 8) & 0xFFu)"), + "VCvtF32Ubyte2" => FloatResult(instruction, $"(float)((({RawSource(instruction, 0)}) >> 16) & 0xFFu)"), + "VCvtF32Ubyte3" => FloatResult(instruction, $"(float)((({RawSource(instruction, 0)}) >> 24) & 0xFFu)"), + "VCvtF16F32" => + $"((uint)as_type(half({F(instruction, 0)})))", + "VCvtF32F16" => + AsUInt($"(float)as_type((ushort)(({RawSource(instruction, 0)}) & 0xFFFFu))"), + "VCvtOffF32I4" => + AsUInt($"sharpemu_off_i4_table[({RawSource(instruction, 0)}) & 15u]"), + "VCvtPkU8F32" => + EmitCvtPkU8F32(instruction), + "VCvtPkrtzF16F32" => + EmitCvtPkrtzF16F32(instruction), + "VCvtPknormI16F32" => + $"pack_float_to_snorm2x16(float2({F(instruction, 0)}, {F(instruction, 1)}))", + "VCvtPknormU16F32" => + $"pack_float_to_unorm2x16(float2({F(instruction, 0)}, {F(instruction, 1)}))", + + // ---- integer arithmetic ---- + "VAddU32" or "VAddI32" => + $"(({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)}))", + "VSubU32" or "VSubI32" => + $"(({RawSource(instruction, 0)}) - ({RawSource(instruction, 1)}))", + "VSubrevU32" or "VSubrevI32" => + $"(({RawSource(instruction, 1)}) - ({RawSource(instruction, 0)}))", + // The SPIR-V translator treats the U24 multiply as a full 32-bit + // multiply (only the Hi/Mad forms mask); mirror it exactly. + "VMulLoU32" or "VMulLoI32" or "VMulU32U24" => + $"(({RawSource(instruction, 0)}) * ({RawSource(instruction, 1)}))", + "VMulHiU32" => + $"mulhi({RawSource(instruction, 0)}, {RawSource(instruction, 1)})", + "VMulHiU32U24" => + $"mulhi(({RawSource(instruction, 0)}) & 0xFFFFFFu, ({RawSource(instruction, 1)}) & 0xFFFFFFu)", + "VMulHiI32" => + AsUInt($"mulhi(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)}))"), + "VMadU32U24" => + $"(((({RawSource(instruction, 0)}) & 0xFFFFFFu) * (({RawSource(instruction, 1)}) & 0xFFFFFFu)) + ({RawSource(instruction, 2)}))", + "VMadU32U16" => + $"(((({RawSource(instruction, 0)}) & 0xFFFFu) * (({RawSource(instruction, 1)}) & 0xFFFFu)) + ({RawSource(instruction, 2)}))", + "VAdd3U32" => + $"(({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)}) + ({RawSource(instruction, 2)}))", + "VAddLshlU32" => + $"((({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)})) << (({RawSource(instruction, 2)}) & 31u))", + "VLshlAddU32" => + $"((({RawSource(instruction, 0)}) << (({RawSource(instruction, 1)}) & 31u)) + ({RawSource(instruction, 2)}))", + "VMinU32" => $"min({RawSource(instruction, 0)}, {RawSource(instruction, 1)})", + "VMaxU32" => $"max({RawSource(instruction, 0)}, {RawSource(instruction, 1)})", + "VMinI32" => + AsUInt($"min(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)}))"), + "VMaxI32" => + AsUInt($"max(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)}))"), + "VMin3U32" => + $"min(min({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), {RawSource(instruction, 2)})", + "VMax3U32" => + $"max(max({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), {RawSource(instruction, 2)})", + "VMin3I32" => + AsUInt($"min(min(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), as_type({RawSource(instruction, 2)}))"), + "VMax3I32" => + AsUInt($"max(max(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), as_type({RawSource(instruction, 2)}))"), + "VMed3U32" => + $"max(min({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), min(max({RawSource(instruction, 0)}, {RawSource(instruction, 1)}), {RawSource(instruction, 2)}))", + "VMed3I32" => + AsUInt($"max(min(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), min(max(as_type({RawSource(instruction, 0)}), as_type({RawSource(instruction, 1)})), as_type({RawSource(instruction, 2)})))"), + + // ---- bitwise ---- + "VAndB32" => $"(({RawSource(instruction, 0)}) & ({RawSource(instruction, 1)}))", + "VOrB32" => $"(({RawSource(instruction, 0)}) | ({RawSource(instruction, 1)}))", + "VXorB32" => $"(({RawSource(instruction, 0)}) ^ ({RawSource(instruction, 1)}))", + "VXnorB32" => $"~(({RawSource(instruction, 0)}) ^ ({RawSource(instruction, 1)}))", + "VNotB32" => $"~({RawSource(instruction, 0)})", + "VAndOrB32" => + $"((({RawSource(instruction, 0)}) & ({RawSource(instruction, 1)})) | ({RawSource(instruction, 2)}))", + "VOr3U32" => + $"(({RawSource(instruction, 0)}) | ({RawSource(instruction, 1)}) | ({RawSource(instruction, 2)}))", + "VLshlOrU32" => + $"((({RawSource(instruction, 0)}) << (({RawSource(instruction, 1)}) & 31u)) | ({RawSource(instruction, 2)}))", + "VLshlB32" => $"(({RawSource(instruction, 0)}) << (({RawSource(instruction, 1)}) & 31u))", + "VLshlrevB32" => $"(({RawSource(instruction, 1)}) << (({RawSource(instruction, 0)}) & 31u))", + "VLshrB32" => $"(({RawSource(instruction, 0)}) >> (({RawSource(instruction, 1)}) & 31u))", + "VLshrrevB32" => $"(({RawSource(instruction, 1)}) >> (({RawSource(instruction, 0)}) & 31u))", + "VAshrI32" => + AsUInt($"(as_type({RawSource(instruction, 0)}) >> (({RawSource(instruction, 1)}) & 31u))"), + "VAshrrevI32" => + AsUInt($"(as_type({RawSource(instruction, 1)}) >> (({RawSource(instruction, 0)}) & 31u))"), + "VBfeU32" => + $"extract_bits({RawSource(instruction, 0)}, ({RawSource(instruction, 1)}) & 31u, ({RawSource(instruction, 2)}) & 31u)", + "VBfiB32" => + $"((({RawSource(instruction, 0)}) & ({RawSource(instruction, 1)})) | (~({RawSource(instruction, 0)}) & ({RawSource(instruction, 2)})))", + "VBfmB32" => + $"(((1u << (({RawSource(instruction, 0)}) & 31u)) - 1u) << (({RawSource(instruction, 1)}) & 31u))", + "VBfrevB32" => $"reverse_bits({RawSource(instruction, 0)})", + "VBcntU32B32" => $"(popcount({RawSource(instruction, 0)}) + ({RawSource(instruction, 1)}))", + "VFfblB32" => + $"(({RawSource(instruction, 0)}) == 0u ? 0xFFFFFFFFu : (uint)ctz({RawSource(instruction, 0)}))", + + // ---- wave / lane ---- + // mbcnt reads the mask dword the guest passes (no cross-lane + // op), so only the per-lane thread-mask math differs by wave + // size. Wave64 lanes 32..63 count the whole low half in mbcnt_lo + // and their own partial in mbcnt_hi; a 1u << lane for lane>=32 + // would be undefined, so those are split out. + "VMbcntLoU32B32" => IsWave64 + ? $"((sharpemu_lane >= 32u ? popcount({RawSource(instruction, 0)}) : popcount(({RawSource(instruction, 0)}) & ((1u << sharpemu_lane) - 1u))) + ({RawSource(instruction, 1)}))" + : $"(popcount(({RawSource(instruction, 0)}) & ((1u << sharpemu_lane) - 1u)) + ({RawSource(instruction, 1)}))", + "VMbcntHiU32B32" => IsWave64 + ? $"((sharpemu_lane >= 32u ? popcount(({RawSource(instruction, 0)}) & ((1u << (sharpemu_lane - 32u)) - 1u)) : 0u) + ({RawSource(instruction, 1)}))" + // Wave32: the high mask half holds no lanes; pass the addend. + : RawSource(instruction, 1), + "VPermlane16B32" => EmitPermlane16(instruction, exchangeRows: false), + "VPermlanex16B32" => EmitPermlane16(instruction, exchangeRows: true), + + // ---- cube map helpers ---- + "VCubeidF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Id), + "VCubescF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Sc), + "VCubetcF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Tc), + "VCubemaF32" => EmitCubeCoordinate(instruction, CubeCoordinate.Ma), + + _ => null, + }; + + if (expression is null) + { + switch (instruction.Opcode) + { + case "VAddCoU32": + { + var left = Temp("uint", RawSource(instruction, 0)); + var right = Temp("uint", RawSource(instruction, 1)); + var sum = Temp("uint", $"{left} + {right}"); + StoreCarryOut(instruction, $"{sum} < {left}"); + expression = sum; + break; + } + case "VSubCoU32": + case "VSubrevCoU32": + { + var reverse = instruction.Opcode == "VSubrevCoU32"; + var left = Temp("uint", RawSource(instruction, reverse ? 1 : 0)); + var right = Temp("uint", RawSource(instruction, reverse ? 0 : 1)); + StoreCarryOut(instruction, $"{left} < {right}"); + expression = $"({left} - {right})"; + break; + } + case "VAddcU32": + case "VAddCoCiU32": + { + var left = Temp("uint", RawSource(instruction, 0)); + var right = Temp("uint", RawSource(instruction, 1)); + var carryIn = instruction.Sources.Count > 2 + ? MaskBitExpression(instruction.Sources[2]) + : "vcc"; + var partial = Temp("uint", $"{left} + {right}"); + var sum = Temp("uint", $"{partial} + (({carryIn}) ? 1u : 0u)"); + StoreCarryOut(instruction, $"({partial} < {left}) || ({sum} < {partial})"); + expression = sum; + break; + } + case "VSubbU32": + case "VSubbrevU32": + { + var reverse = instruction.Opcode == "VSubbrevU32"; + var left = Temp("uint", RawSource(instruction, reverse ? 1 : 0)); + var right = Temp("uint", RawSource(instruction, reverse ? 0 : 1)); + var borrowIn = instruction.Sources.Count > 2 + ? MaskBitExpression(instruction.Sources[2]) + : "vcc"; + var borrow = Temp("uint", $"({borrowIn}) ? 1u : 0u"); + var partial = Temp("uint", $"{left} - {right}"); + StoreCarryOut(instruction, $"({left} < {right}) || ({partial} < {borrow})"); + expression = $"({partial} - {borrow})"; + break; + } + case "VMadU64U32": + { + // 64-bit product+addend into a VGPR pair, carry to SDST. + var product = Temp( + "ulong", + $"(ulong)({RawSource(instruction, 0)}) * (ulong)({RawSource(instruction, 1)})"); + var addend = Temp("ulong", RawSource64(instruction, 2)); + var wide = Temp("ulong", $"{product} + {addend}"); + StoreCarryOut(instruction, $"{wide} < {addend}"); + StoreVector(destination + 1, $"(uint)({wide} >> 32)"); + expression = $"(uint){wide}"; + break; + } + default: + error = $"unsupported vector opcode {instruction.Opcode}"; + return false; + } + } + + var result = Temp("uint", expression); + if (instruction.Control is Gen5DppControl dpp) + { + var writeEnabled = EmitDppWriteEnabled(dpp); + result = Temp("uint", $"({writeEnabled}) ? {result} : v[{destination}]"); + } + + if (instruction.Control is Gen5SdwaControl { ScalarDestination: null } sdwaDestination) + { + result = ApplySdwaDestination(sdwaDestination, result, $"v[{destination}]"); + } + + StoreVector(destination, result); + return true; + } + + private string EmitCvtPkU8F32(Gen5ShaderInstruction instruction) + { + var converted = Temp("uint", $"(uint)({F(instruction, 0)})"); + var offset = Temp("uint", $"(({RawSource(instruction, 1)}) & 3u) << 3"); + var baseValue = Temp("uint", RawSource(instruction, 2)); + return $"(({baseValue} & ~(0xFFu << {offset})) | (({converted} & 0xFFu) << {offset}))"; + } + + private string EmitCvtPkrtzF16F32(Gen5ShaderInstruction instruction) + { + // Round-to-zero via mantissa truncation before the half conversion, + // mirroring the SPIR-V translator's TruncateFloat32ForPack. + var first = Temp( + "float", + $"as_type(as_type({F(instruction, 0)}) & 0xFFFFE000u)"); + var second = Temp( + "float", + $"as_type(as_type({F(instruction, 1)}) & 0xFFFFE000u)"); + return $"(((uint)as_type(half({first}))) | (((uint)as_type(half({second}))) << 16))"; + } + + // ---- DPP / SDWA machinery ---- + + private static bool IsSupportedDppControl(uint control) => + control <= 0xFF || + control is >= 0x101 and <= 0x10F or + >= 0x111 and <= 0x11F or + >= 0x121 and <= 0x12F or + 0x140 or 0x141 or + >= 0x150 and <= 0x15F or + >= 0x160 and <= 0x16F; + + /// Target lane + in-range flag for a DPP16 control. + private (string TargetLane, string InRange) EmitDppSourceLane(Gen5DppControl control) + { + var dpp = control.Control; + if (dpp <= 0xFF) + { + // Quad permute: two selector bits per lane-in-quad. + var selected = Temp( + "uint", + $"({dpp}u >> ((sharpemu_lane & 3u) * 2u)) & 3u"); + return (Temp("uint", $"(sharpemu_lane & 0xFFFFFFFCu) + {selected}"), "true"); + } + + if (dpp is >= 0x101 and <= 0x10F) + { + // row_shl + var shifted = Temp("uint", $"(sharpemu_lane & 15u) + {dpp & 15}u"); + var inRange = Temp("bool", $"{shifted} < 16u"); + return (Temp("uint", $"(sharpemu_lane & 0xFFFFFFF0u) + ({shifted} & 15u)"), inRange); + } + + if (dpp is >= 0x111 and <= 0x11F) + { + // row_shr + var inRange = Temp("bool", $"(sharpemu_lane & 15u) >= {dpp & 15}u"); + return ( + Temp("uint", $"(sharpemu_lane & 0xFFFFFFF0u) + (((sharpemu_lane & 15u) - {dpp & 15}u) & 15u)"), + inRange); + } + + if (dpp is >= 0x121 and <= 0x12F) + { + // row_ror + return ( + Temp("uint", $"(sharpemu_lane & 0xFFFFFFF0u) + (((sharpemu_lane & 15u) - {dpp & 15}u) & 15u)"), + "true"); + } + + var target = dpp switch + { + 0x140 => "(sharpemu_lane & 0xFFFFFFF0u) + (15u - (sharpemu_lane & 15u))", + 0x141 => "(sharpemu_lane & 0xFFFFFFF8u) + (7u - (sharpemu_lane & 7u))", + >= 0x150 and <= 0x15F => $"(sharpemu_lane & 0xFFFFFFF0u) + {dpp & 15}u", + >= 0x160 and <= 0x16F => $"(sharpemu_lane & 0xFFFFFFF0u) + ((sharpemu_lane & 15u) ^ {dpp & 15}u)", + _ => "sharpemu_lane", + }; + return (Temp("uint", target), "true"); + } + + // Under the single-lane graphics model every shuffle-select resolves + // to the lane's own value (the register conceptually holds this + // thread's value in every lane); compute lanes are real simdgroup + // threads and shuffle for real. Mirrors the SPIR-V translator's + // no-subgroup fallback for graphics stages. + private bool IsSingleLaneStage => _stage != Gen5MslStage.Compute; + + private string ShuffleLane(string value, string targetLane) => + IsSingleLaneStage ? value : $"simd_shuffle({value}, (ushort){targetLane})"; + + private string LaneActiveExpression(string targetLane) => + IsSingleLaneStage ? "exec" : $"simd_shuffle(exec ? 1u : 0u, (ushort){targetLane}) != 0u"; + + private string ApplyDppSource(Gen5DppControl control, string value) + { + var stored = Temp("uint", value); + var (targetLane, inRange) = EmitDppSourceLane(control); + var safeTarget = Temp("uint", $"(({inRange}) ? {targetLane} : sharpemu_lane) & 31u"); + var shuffled = Temp("uint", ShuffleLane(stored, safeTarget)); + if (control.FetchInactive) + { + return shuffled; + } + + var sourceActive = Temp("bool", LaneActiveExpression(safeTarget)); + return Temp("uint", $"(({inRange}) && {sourceActive}) ? {shuffled} : 0u"); + } + + private string ApplyDpp8Source(Gen5Dpp8Control control, string value) + { + var stored = Temp("uint", value); + var selector = Temp( + "uint", + $"({control.LaneSelectors}u >> ((sharpemu_lane & 7u) * 3u)) & 7u"); + var targetLane = Temp("uint", $"((sharpemu_lane & 0xFFFFFFF8u) + {selector}) & 31u"); + var shuffled = Temp("uint", ShuffleLane(stored, targetLane)); + if (control.FetchInactive) + { + return shuffled; + } + + var sourceActive = Temp("bool", LaneActiveExpression(targetLane)); + return Temp("uint", $"{sourceActive} ? {shuffled} : 0u"); + } + + private string EmitDppWriteEnabled(Gen5DppControl control) + { + var (_, inRange) = EmitDppSourceLane(control); + var rowEnabled = $"(({control.RowMask}u >> (sharpemu_lane >> 4)) & 1u) != 0u"; + var bankEnabled = $"(({control.BankMask}u >> (sharpemu_lane & 3u)) & 1u) != 0u"; + var sourceAllows = control.BoundControl ? "true" : inRange; + return Temp("bool", $"({rowEnabled}) && ({bankEnabled}) && ({sourceAllows})"); + } + + private string ApplySdwaDestination( + Gen5SdwaControl control, + string value, + string previous) + { + var (shift, width) = control.DestinationSelect switch + { + 0 => (0u, 8u), + 1 => (8u, 8u), + 2 => (16u, 8u), + 3 => (24u, 8u), + 4 => (0u, 16u), + 5 => (16u, 16u), + _ => (0u, 32u), + }; + if (width == 32) + { + return value; + } + + var lowMask = width == 8 ? 0xFFu : 0xFFFFu; + var fieldMask = lowMask << (int)shift; + var upperStart = shift + width; + var upperMask = upperStart == 32 ? 0u : uint.MaxValue << (int)upperStart; + var positioned = Temp("uint", $"(({value}) & 0x{lowMask:X}u) << {shift}"); + return control.DestinationUnused switch + { + // 0: unused bits zeroed. 1: sign-extend upward. 2: preserve. + 0 => positioned, + 1 => Temp( + "uint", + $"{positioned} | ((({positioned} & 0x{1u << (int)(shift + width - 1):X}u) != 0u) ? 0x{upperMask:X}u : 0u)"), + 2 => Temp("uint", $"(({previous}) & 0x{~fieldMask:X}u) | {positioned}"), + _ => throw new InvalidOperationException("reserved SDWA destination-unused mode"), + }; + } + + // ---- compares ---- + + private bool TryEmitVectorCompare( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + var opcode = instruction.Opcode; + string condition; + if (opcode is "VCmpClassF32" or "VCmpxClassF32") + { + condition = EmitCompareClass(instruction); + } + else if (opcode is "VCmpTruF32" or "VCmpxTruF32" or "VCmpTI32" or "VCmpTU32") + { + condition = "true"; + } + else if (opcode is "VCmpFF32" or "VCmpxFF32" or "VCmpFI32" or "VCmpFU32") + { + condition = "false"; + } + else if (opcode is "VCmpOF32" or "VCmpxOF32") + { + condition = $"(!isnan({F(instruction, 0)}) && !isnan({F(instruction, 1)}))"; + } + else if (opcode is "VCmpUF32" or "VCmpxUF32") + { + condition = $"(isnan({F(instruction, 0)}) || isnan({F(instruction, 1)}))"; + } + else if (opcode.EndsWith("F32", StringComparison.Ordinal)) + { + // Ordered compares are the plain C operators (false on NaN); + // the Nxx forms are their unordered negations (true on NaN). + var (op, unordered) = TrimCompare(opcode) switch + { + "Lt" => ("<", false), + "Eq" => ("==", false), + "Le" => ("<=", false), + "Gt" => (">", false), + "Lg" => ("!=", false), + "Ge" => (">=", false), + "Neq" => ("==", true), + "Nlt" => ("<", true), + "Nle" => ("<=", true), + "Ngt" => (">", true), + "Nge" => (">=", true), + "Nlg" => ("!=", true), + _ => (string.Empty, false), + }; + if (op.Length == 0) + { + error = $"unsupported float compare {opcode}"; + return false; + } + + var comparison = $"({F(instruction, 0)} {op} {F(instruction, 1)})"; + condition = unordered ? $"(!{comparison})" : comparison; + } + else + { + var signed = opcode.EndsWith("I32", StringComparison.Ordinal); + var op = TrimCompare(opcode) switch + { + "Eq" => "==", + "Ne" => "!=", + "Lt" => "<", + "Le" => "<=", + "Gt" => ">", + "Ge" => ">=", + _ => string.Empty, + }; + if (op.Length == 0) + { + error = $"unsupported integer compare {opcode}"; + return false; + } + + condition = signed + ? $"(as_type({RawSource(instruction, 0)}) {op} as_type({RawSource(instruction, 1)}))" + : $"(({RawSource(instruction, 0)}) {op} ({RawSource(instruction, 1)}))"; + } + + // Only EXEC-enabled lanes can pass; balloting the raw condition + // would leak results from disabled lanes into saveexec/branches. + var active = Temp("bool", $"exec && {condition}"); + if (instruction.Control is Gen5DppControl compareDpp) + { + var writeEnabled = EmitDppWriteEnabled(compareDpp); + active = Temp("bool", $"({writeEnabled}) ? {active} : vcc"); + } + + if (opcode.StartsWith("VCmpx", StringComparison.Ordinal)) + { + // GFX10 VCMPX writes EXEC only. + Line($"exec = {active};"); + EmitBallotStore(ExecLoRegister, "exec"); + } + else + { + var target = instruction.Control is Gen5SdwaControl + { ScalarDestination: { } scalarDestination } + ? scalarDestination + : VccLoRegister; + StoreMaskBit(target, active); + } + + return true; + } + + private string EmitCompareClass(Gen5ShaderInstruction instruction) + { + var source = Temp("float", F(instruction, 0)); + var raw = Temp("uint", RawSource(instruction, 0)); + var mask = Temp("uint", RawSource(instruction, 1)); + var negative = Temp("bool", $"({raw} & 0x80000000u) != 0u"); + var nan = Temp("bool", $"isnan({source})"); + var infinite = Temp("bool", $"isinf({source})"); + var zero = Temp("bool", $"{source} == 0.0f"); + var subnormal = Temp( + "bool", + $"fabs({source}) > 0.0f && fabs({source}) < as_type(0x00800000u)"); + var normal = Temp( + "bool", + $"!({nan} || {infinite} || {zero} || {subnormal})"); + // Class bits: 0 sNaN, 1 qNaN, 2 -inf, 3 -normal, 4 -subnormal, + // 5 -zero, 6 +zero, 7 +subnormal, 8 +normal, 9 +inf. + return Temp( + "bool", + $"((({mask} & 3u) != 0u) && {nan}) || " + + $"((({mask} >> 2) & 1u) != 0u && {infinite} && {negative}) || " + + $"((({mask} >> 3) & 1u) != 0u && {normal} && {negative}) || " + + $"((({mask} >> 4) & 1u) != 0u && {subnormal} && {negative}) || " + + $"((({mask} >> 5) & 1u) != 0u && {zero} && {negative}) || " + + $"((({mask} >> 6) & 1u) != 0u && {zero} && !{negative}) || " + + $"((({mask} >> 7) & 1u) != 0u && {subnormal} && !{negative}) || " + + $"((({mask} >> 8) & 1u) != 0u && {normal} && !{negative}) || " + + $"((({mask} >> 9) & 1u) != 0u && {infinite} && !{negative})"); + } + + private static string TrimCompare(string opcode) + { + var trimmed = opcode.StartsWith("VCmpx", StringComparison.Ordinal) + ? opcode["VCmpx".Length..] + : opcode["VCmp".Length..]; + return trimmed[..^3]; + } + + private void StoreCarryOut(Gen5ShaderInstruction instruction, string carryCondition) + { + var active = Temp("bool", $"exec && ({carryCondition})"); + var target = instruction.Control is Gen5Vop3Control { ScalarDestination: { } register } + ? register + : VccLoRegister; + StoreMaskBit(target, active); + } + + /// + /// Writes this lane's bit of a wave mask: VCC/EXEC update the per-lane + /// bool and mirror the ballot into their architectural SGPRs; a plain + /// SGPR receives the ballot of the per-lane condition. + /// + private void StoreMaskBit(uint register, string condition) + { + switch (register) + { + case VccLoRegister: + Line($"vcc = {condition};"); + EmitBallotStore(VccLoRegister, "vcc"); + return; + case ExecLoRegister: + Line($"exec = {condition};"); + EmitBallotStore(ExecLoRegister, "exec"); + return; + default: + if (register < ScalarRegisterFileCount) + { + EmitBallotStore(register, condition); + } + + return; + } + } + + /// Broadcasts from the first guest-active + /// lane (lowest set bit of the 64-lane EXEC mask) to all lanes, through the + /// threadgroup broadcast slot — mirroring the SPIR-V translator's + /// BroadcastFirstWave64Active. Returns the temp holding the result. + private string EmitWave64ReadFirstLane(string value) + { + Line("if (sharpemu_lane == 0u) { sharpemu_wave_scratch[2] = 0u; }"); + // 64-lane EXEC mask across both halves (slots 0/1), broadcast in 2. + Line("sharpemu_wave_scratch[(sharpemu_lane >> 5) & 1u] = sharpemu_ballot(exec);"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + var lo = Temp("uint", "sharpemu_wave_scratch[0]"); + var hi = Temp("uint", "sharpemu_wave_scratch[1]"); + var first = Temp( + "uint", + $"({lo} != 0u) ? (uint)ctz({lo}) : (({hi} != 0u) ? (32u + (uint)ctz({hi})) : 0u)"); + var anyActive = Temp("bool", $"(({lo}) | ({hi})) != 0u"); + Line($"if ({anyActive} && sharpemu_lane == {first}) {{ sharpemu_wave_scratch[2] = {value}; }}"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + var result = Temp("uint", "sharpemu_wave_scratch[2]"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + return result; + } + + /// Stores the wave ballot of into the + /// mask register pair (low, low+1). Wave32 fills the low dword and clears + /// the high; wave64 bridges both 32-wide halves through threadgroup + /// scratch so the pair holds the full 64-lane mask. The bridging barriers + /// are safe because the guest program's scalar PC keeps all 64 lanes in + /// lockstep through the dispatcher (one wave per threadgroup). + private void EmitBallotStore(uint loRegister, string condition) + { + var hiRegister = loRegister + 1; + if (!IsWave64) + { + Line($"s[{loRegister}] = sharpemu_ballot({condition});"); + if (hiRegister < ScalarRegisterFileCount) + { + Line($"s[{hiRegister}] = 0u;"); + } + + return; + } + + // simd_ballot is uniform across a simdgroup, so every lane of a half + // writes the same 32-bit value to that half's slot — no first-lane + // guard needed. Barrier, read both halves, barrier before the slot + // can be reused by the next ballot. + Line($"sharpemu_wave_scratch[(sharpemu_lane >> 5) & 1u] = sharpemu_ballot({condition});"); + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + Line($"s[{loRegister}] = sharpemu_wave_scratch[0];"); + if (hiRegister < ScalarRegisterFileCount) + { + Line($"s[{hiRegister}] = sharpemu_wave_scratch[1];"); + } + + Line("threadgroup_barrier(mem_flags::mem_threadgroup);"); + } + + // ---- permlane / cube ---- + + private string EmitPermlane16(Gen5ShaderInstruction instruction, bool exchangeRows) + { + if (instruction.Control is not Gen5Vop3Control control || + (control.OperandSelect & ~3u) != 0 || + control.AbsoluteMask != 0 || + control.NegateMask != 0 || + control.OutputModifier != 0 || + control.Clamp) + { + throw new NotSupportedException( + $"invalid permlane modifiers for {instruction.Opcode}"); + } + + var value = Temp("uint", RawSource(instruction, 0)); + var selectorLow = Temp("uint", RawSource(instruction, 1)); + var selectorHigh = Temp("uint", RawSource(instruction, 2)); + var localLane = Temp("uint", "sharpemu_lane & 15u"); + var selector = Temp( + "uint", + $"({localLane} < 8u ? ({selectorLow} >> ({localLane} << 2)) : ({selectorHigh} >> (({localLane} - 8u) << 2))) & 15u"); + var rowBase = exchangeRows + ? "((sharpemu_lane & 0xFFFFFFF0u) ^ 16u)" + : "(sharpemu_lane & 0xFFFFFFF0u)"; + var targetLane = Temp("uint", $"({rowBase} + {selector}) & 31u"); + var shuffled = Temp("uint", ShuffleLane(value, targetLane)); + var fetchInactive = (control.OperandSelect & 1) != 0; + if (fetchInactive) + { + return shuffled; + } + + var sourceActive = Temp("bool", LaneActiveExpression(targetLane)); + return Temp("uint", $"{sourceActive} ? {shuffled} : 0u"); + } + + private enum CubeCoordinate + { + Id, + Sc, + Tc, + Ma, + } + + private string EmitCubeCoordinate( + Gen5ShaderInstruction instruction, + CubeCoordinate coordinate) + { + var x = Temp("float", F(instruction, 0)); + var y = Temp("float", F(instruction, 1)); + var z = Temp("float", F(instruction, 2)); + var amaxXY = Temp("float", $"fmax(fabs({x}), fabs({y}))"); + var amax = Temp("float", $"fmax(fabs({z}), {amaxXY})"); + if (coordinate == CubeCoordinate.Ma) + { + return FloatResult(instruction, $"2.0f * {amax}"); + } + + var isZMax = Temp("bool", $"fabs({z}) >= {amaxXY}"); + var yGeX = Temp("bool", $"fabs({y}) >= fabs({x})"); + var isYMax = Temp("bool", $"!{isZMax} && {yGeX}"); + switch (coordinate) + { + case CubeCoordinate.Id: + { + var zCase = $"({z} < 0.0f ? 5.0f : 4.0f)"; + var yCase = $"({y} < 0.0f ? 3.0f : 2.0f)"; + var xCase = $"({x} < 0.0f ? 1.0f : 0.0f)"; + return FloatResult( + instruction, + $"({isZMax} ? {zCase} : ({yGeX} ? {yCase} : {xCase}))"); + } + case CubeCoordinate.Sc: + { + var zCase = $"({z} < 0.0f ? (-{x}) : {x})"; + var xCase = $"({x} < 0.0f ? {z} : (-{z}))"; + return FloatResult( + instruction, + $"({isZMax} ? {zCase} : ({isYMax} ? {x} : {xCase}))"); + } + default: + { + var yCase = $"({y} < 0.0f ? (-{z}) : {z})"; + return FloatResult( + instruction, + $"({isYMax} ? {yCase} : (-{y}))"); + } + } + } + + // ---- scalar ALU ---- + + private bool TryEmitScalarAlu( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + if (instruction.Encoding == Gen5ShaderEncoding.Sopc) + { + return TryEmitScalarCompare(instruction, out error); + } + + if (instruction.Destinations.Count == 0 || + instruction.Destinations[0].Kind != Gen5OperandKind.ScalarRegister) + { + error = "missing scalar destination"; + return false; + } + + var destination = instruction.Destinations[0].Value; + if (instruction.Encoding == Gen5ShaderEncoding.Sopk) + { + var immediate = unchecked((uint)(short)(instruction.Words[0] & 0xFFFF)); + if (instruction.Opcode.StartsWith("SCmpk", StringComparison.Ordinal)) + { + return TryEmitScalarCompareK(instruction, destination, immediate, out error); + } + + var value = instruction.Opcode switch + { + "SMovkI32" => FormatUInt(immediate), + "SAddkI32" => $"({ScalarExpression(destination)} + {FormatUInt(immediate)})", + "SMulkI32" => $"({ScalarExpression(destination)} * {FormatUInt(immediate)})", + _ => string.Empty, + }; + if (value.Length == 0) + { + error = $"unsupported scalar immediate {instruction.Opcode}"; + return false; + } + + StoreScalar(destination, Temp("uint", value)); + return true; + } + + if (instruction.Opcode == "SGetpcB64") + { + var pc = _state.Program.Address + + instruction.Pc + + (ulong)(instruction.Words.Count * sizeof(uint)); + StoreScalar(destination, FormatUInt((uint)pc)); + StoreScalar(destination + 1, FormatUInt((uint)(pc >> 32))); + return true; + } + + if (instruction.Opcode.EndsWith("B64", StringComparison.Ordinal) || + instruction.Opcode is "SBfeU64" or "SBfeI64") + { + return TryEmitScalar64(instruction, destination, out error); + } + + var left = Temp("uint", RawSource(instruction, 0)); + if (instruction.Opcode.EndsWith("SaveexecB32", StringComparison.Ordinal)) + { + var oldExec = Temp("uint", $"s[{ExecLoRegister}]"); + var operation = instruction.Opcode[1..instruction.Opcode.IndexOf( + "Saveexec", + StringComparison.Ordinal)]; + var combined = operation switch + { + "And" => $"({left} & {oldExec})", + "Or" => $"({left} | {oldExec})", + "Xor" => $"({left} ^ {oldExec})", + "Nand" => $"~({left} & {oldExec})", + "Nor" => $"~({left} | {oldExec})", + "Xnor" => $"~({left} ^ {oldExec})", + "Andn1" => $"(~{left} & {oldExec})", + "Andn2" => $"({left} & ~{oldExec})", + "Orn1" => $"(~{left} | {oldExec})", + "Orn2" => $"({left} | ~{oldExec})", + _ => string.Empty, + }; + if (combined.Length == 0) + { + error = $"unsupported scalar 32-bit saveexec opcode {instruction.Opcode}"; + return false; + } + + var mask = Temp("uint", combined); + StoreScalar(destination, oldExec); + Line($"s[{ExecLoRegister}] = {mask};"); + Line($"s[{ExecHiRegister}] = 0u;"); + Line($"exec = (({mask} >> sharpemu_lane) & 1u) != 0u;"); + Line($"scc = {mask} != 0u;"); + return true; + } + + switch (instruction.Opcode) + { + case "SMovB32": + StoreScalar(destination, left); + return true; + case "SNotB32": + { + var result = Temp("uint", $"~{left}"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SBrevB32": + { + var result = Temp("uint", $"reverse_bits({left})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SBcnt1I32B32": + { + var result = Temp("uint", $"popcount({left})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SFF1I32B32": + { + var result = Temp( + "uint", + $"{left} == 0u ? 0xFFFFFFFFu : (uint)ctz({left})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SBitset1B32": + StoreScalar( + destination, + $"{ScalarExpression(destination)} | (1u << ({left} & 31u))"); + return true; + } + + if (instruction.Sources.Count < 2) + { + error = $"missing scalar source for {instruction.Opcode}"; + return false; + } + + var right = Temp("uint", RawSource(instruction, 1)); + string resultExpression; + string sccStatement; + switch (instruction.Opcode) + { + case "SAddU32": + resultExpression = $"({left} + {right})"; + sccStatement = "RESULT < " + left; + break; + case "SSubU32": + resultExpression = $"({left} - {right})"; + sccStatement = $"{right} > {left}"; + break; + case "SAddI32": + resultExpression = $"({left} + {right})"; + sccStatement = $"((~({left} ^ {right}) & ({left} ^ RESULT)) >> 31) != 0u"; + break; + case "SSubI32": + resultExpression = $"({left} - {right})"; + sccStatement = $"(((({left} ^ {right})) & ({left} ^ RESULT)) >> 31) != 0u"; + break; + case "SAddcU32": + { + var partial = Temp("uint", $"{left} + {right}"); + var sum = Temp("uint", $"{partial} + (scc ? 1u : 0u)"); + Line($"scc = ({partial} < {left}) || ({sum} < {partial});"); + StoreScalar(destination, sum); + return true; + } + case "SSubbU32": + { + var borrow = Temp("uint", "scc ? 1u : 0u"); + var partial = Temp("uint", $"{left} - {right}"); + var difference = Temp("uint", $"{partial} - {borrow}"); + Line($"scc = ({right} > {left}) || (({borrow} == 1u) && ({right} == {left}));"); + StoreScalar(destination, difference); + return true; + } + case "SMulI32": + resultExpression = $"({left} * {right})"; + sccStatement = string.Empty; + break; + case "SMulHiU32": + resultExpression = $"mulhi({left}, {right})"; + sccStatement = string.Empty; + break; + case "SAndB32": + resultExpression = $"({left} & {right})"; + sccStatement = "NONZERO"; + break; + case "SOrB32": + resultExpression = $"({left} | {right})"; + sccStatement = "NONZERO"; + break; + case "SXorB32": + resultExpression = $"({left} ^ {right})"; + sccStatement = "NONZERO"; + break; + case "SNandB32": + resultExpression = $"~({left} & {right})"; + sccStatement = "NONZERO"; + break; + case "SNorB32": + resultExpression = $"~({left} | {right})"; + sccStatement = "NONZERO"; + break; + case "SXnorB32": + resultExpression = $"~({left} ^ {right})"; + sccStatement = "NONZERO"; + break; + case "SAndn2B32": + resultExpression = $"({left} & ~{right})"; + sccStatement = "NONZERO"; + break; + case "SOrn2B32": + resultExpression = $"({left} | ~{right})"; + sccStatement = "NONZERO"; + break; + case "SLshlB32": + resultExpression = $"({left} << ({right} & 31u))"; + sccStatement = "NONZERO"; + break; + case "SLshrB32": + resultExpression = $"({left} >> ({right} & 31u))"; + sccStatement = "NONZERO"; + break; + case "SAshrI32": + resultExpression = $"(uint)(as_type({left}) >> ({right} & 31u))"; + sccStatement = "NONZERO"; + break; + case "SBfmB32": + resultExpression = $"(((1u << ({left} & 31u)) - 1u) << ({right} & 31u))"; + sccStatement = string.Empty; + break; + case "SBfeU32": + case "SBfeI32": + { + // Width clamps to the bits remaining above the offset. + var offset = Temp("uint", $"{right} & 31u"); + var width = Temp( + "uint", + $"min(({right} >> 16) & 0x7Fu, 32u - {offset})"); + var result = instruction.Opcode == "SBfeI32" + ? Temp( + "uint", + $"{width} == 0u ? 0u : (uint)extract_bits(as_type({left}), {offset}, {width})") + : Temp( + "uint", + $"{width} == 0u ? 0u : extract_bits({left}, {offset}, {width})"); + StoreScalar(destination, result); + Line($"scc = {result} != 0u;"); + return true; + } + case "SCselectB32": + resultExpression = $"(scc ? {left} : {right})"; + sccStatement = string.Empty; + break; + case "SMinU32": + resultExpression = $"min({left}, {right})"; + sccStatement = $"{left} < {right}"; + break; + case "SMaxU32": + resultExpression = $"max({left}, {right})"; + sccStatement = $"{left} > {right}"; + break; + case "SMinI32": + resultExpression = $"(uint)min(as_type({left}), as_type({right}))"; + sccStatement = $"as_type({left}) < as_type({right})"; + break; + case "SMaxI32": + resultExpression = $"(uint)max(as_type({left}), as_type({right}))"; + sccStatement = $"as_type({left}) > as_type({right})"; + break; + case "SLshl1AddU32": + case "SLshl2AddU32": + case "SLshl3AddU32": + case "SLshl4AddU32": + { + var shift = (uint)(instruction.Opcode[5] - '0'); + resultExpression = $"(({left} << {shift}) + {right})"; + sccStatement = string.Empty; + break; + } + case "SPackLlB32B16": + resultExpression = $"(({left} & 0xFFFFu) | ({right} << 16))"; + sccStatement = string.Empty; + break; + case "SPackLhB32B16": + resultExpression = $"(({left} & 0xFFFFu) | ({right} & 0xFFFF0000u))"; + sccStatement = string.Empty; + break; + case "SPackHhB32B16": + resultExpression = $"(({left} >> 16) | ({right} & 0xFFFF0000u))"; + sccStatement = string.Empty; + break; + default: + error = $"unsupported scalar opcode {instruction.Opcode}"; + return false; + } + + var value2 = Temp("uint", resultExpression); + StoreScalar(destination, value2); + if (sccStatement == "NONZERO") + { + Line($"scc = {value2} != 0u;"); + } + else if (sccStatement.Length != 0) + { + Line($"scc = {sccStatement.Replace("RESULT", value2)};"); + } + + return true; + } + + private bool TryEmitScalarCompare( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + if (instruction.Sources.Count < 2) + { + error = "missing scalar compare source"; + return false; + } + + var left = Temp("uint", RawSource(instruction, 0)); + var right = Temp("uint", RawSource(instruction, 1)); + if (instruction.Opcode is "SBitcmp0B32" or "SBitcmp1B32") + { + var isSet = $"(({left} >> ({right} & 31u)) & 1u) != 0u"; + Line(instruction.Opcode == "SBitcmp1B32" + ? $"scc = {isSet};" + : $"scc = !({isSet});"); + return true; + } + + return TryEmitScalarCompareCore(instruction.Opcode, "SCmp", left, right, out error); + } + + private bool TryEmitScalarCompareK( + Gen5ShaderInstruction instruction, + uint destination, + uint immediate, + out string error) => + TryEmitScalarCompareCore( + instruction.Opcode, + "SCmpk", + ScalarExpression(destination), + FormatUInt(immediate), + out error); + + private bool TryEmitScalarCompareCore( + string opcode, + string prefix, + string left, + string right, + out string error) + { + error = string.Empty; + var suffix = opcode[prefix.Length..]; + var signed = suffix.EndsWith("I32", StringComparison.Ordinal); + var op = suffix[..^3] switch + { + "Eq" => "==", + "Lg" => "!=", + "Gt" => ">", + "Ge" => ">=", + "Lt" => "<", + "Le" => "<=", + _ => string.Empty, + }; + if (op.Length == 0) + { + error = $"unsupported scalar compare {opcode}"; + return false; + } + + Line(signed + ? $"scc = as_type({left}) {op} as_type({right});" + : $"scc = ({left}) {op} ({right});"); + return true; + } + + // ---- 64-bit scalar ops over register pairs ---- + + private bool TryEmitScalar64( + Gen5ShaderInstruction instruction, + uint destination, + out string error) + { + error = string.Empty; + var left = Temp("ulong", RawSource64(instruction, 0)); + if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal)) + { + var oldExec = Temp("ulong", Scalar64Expression(ExecLoRegister)); + var operation = instruction.Opcode[1..instruction.Opcode.IndexOf( + "Saveexec", + StringComparison.Ordinal)]; + var combined = operation switch + { + "And" => $"({left} & {oldExec})", + "Or" => $"({left} | {oldExec})", + "Xor" => $"({left} ^ {oldExec})", + "Nand" => $"~({left} & {oldExec})", + "Nor" => $"~({left} | {oldExec})", + "Xnor" => $"~({left} ^ {oldExec})", + "Andn1" => $"(~{left} & {oldExec})", + "Andn2" => $"({left} & ~{oldExec})", + "Orn1" => $"(~{left} | {oldExec})", + "Orn2" => $"({left} | ~{oldExec})", + _ => string.Empty, + }; + if (combined.Length == 0) + { + error = $"unsupported scalar 64-bit saveexec opcode {instruction.Opcode}"; + return false; + } + + var mask = Temp("ulong", combined); + StoreScalar64(destination, oldExec); + Line($"s[{ExecLoRegister}] = (uint){mask};"); + Line($"s[{ExecHiRegister}] = (uint)({mask} >> 32);"); + Line($"exec = ((((uint){mask}) >> sharpemu_lane) & 1u) != 0u;"); + Line($"scc = {mask} != 0ul;"); + return true; + } + + string value; + var setsScc = true; + switch (instruction.Opcode) + { + case "SMovB64": + value = left; + setsScc = false; + break; + case "SNotB64": + value = $"~{left}"; + break; + case "SWqmB64": + { + // Whole-quad mode: each 4-lane group becomes all-ones if any + // of its bits is set. + var quadAny = Temp( + "ulong", + $"({left} | ({left} >> 1) | ({left} >> 2) | ({left} >> 3)) & 0x1111111111111111ul"); + value = $"({quadAny} * 0xFul)"; + break; + } + case "SLshlB64" or "SLshrB64": + { + var shift = Temp("uint", $"({RawSource(instruction, 1)}) & 63u"); + value = instruction.Opcode == "SLshlB64" + ? $"({left} << {shift})" + : $"({left} >> {shift})"; + break; + } + case "SBfmB64": + { + var width = Temp("ulong", $"(ulong)(({RawSource(instruction, 0)}) & 63u)"); + var offset = Temp("ulong", $"(ulong)(({RawSource(instruction, 1)}) & 63u)"); + value = $"((((1ul << {width}) - 1ul)) << {offset})"; + break; + } + case "SBfeU64" or "SBfeI64": + { + var control = Temp("uint", RawSource(instruction, 1)); + var offset = Temp("uint", $"{control} & 63u"); + var width = Temp("uint", $"min(({control} >> 16) & 0x7Fu, 64u - {offset})"); + var mask = Temp( + "ulong", + $"{width} >= 64u ? 0xFFFFFFFFFFFFFFFFul : ((1ul << {width}) - 1ul)"); + var extracted = Temp("ulong", $"({left} >> {offset}) & {mask}"); + if (instruction.Opcode == "SBfeI64") + { + var signBit = Temp( + "ulong", + $"{width} == 0u ? 0ul : (1ul << ({width} - 1u))"); + extracted = Temp( + "ulong", + $"{width} == 0u ? 0ul : (({extracted} ^ {signBit}) - {signBit})"); + } + + value = extracted; + break; + } + default: + { + if (instruction.Sources.Count < 2) + { + error = "missing scalar 64-bit source"; + return false; + } + + var right = Temp("ulong", RawSource64(instruction, 1)); + value = instruction.Opcode switch + { + "SAndB64" => $"({left} & {right})", + "SOrB64" => $"({left} | {right})", + "SXorB64" => $"({left} ^ {right})", + "SNandB64" => $"~({left} & {right})", + "SNorB64" => $"~({left} | {right})", + "SXnorB64" => $"~({left} ^ {right})", + "SAndn1B64" => $"(~{left} & {right})", + "SAndn2B64" => $"({left} & ~{right})", + "SOrn1B64" => $"(~{left} | {right})", + "SOrn2B64" => $"({left} | ~{right})", + "SCselectB64" => $"(scc ? {left} : {right})", + _ => string.Empty, + }; + if (value.Length == 0) + { + error = $"unsupported scalar 64-bit opcode {instruction.Opcode}"; + return false; + } + + setsScc = instruction.Opcode != "SCselectB64"; + break; + } + } + + var stored = Temp("ulong", value); + StoreScalar64(destination, stored); + if (setsScc) + { + Line($"scc = {stored} != 0ul;"); + } + + return true; + } + + // ---- operand helpers ---- + + private uint DestinationVector(Gen5ShaderInstruction instruction) + { + var destination = instruction.Destinations[0]; + return destination.Kind == Gen5OperandKind.VectorRegister + ? destination.Value + : throw new NotSupportedException( + $"vector destination expected in {instruction.Opcode}"); + } + + /// + /// Raw 32-bit source with DPP/DPP8 lane remapping on src0 and SDWA + /// byte/word selection + integer modifiers, mirroring GetRawSource. + /// + private string RawSource( + Gen5ShaderInstruction instruction, + int sourceIndex, + bool applySdwaIntegerModifiers = true) + { + var value = SourceExpression(instruction.Sources[sourceIndex], instruction); + if (sourceIndex == 0 && instruction.Control is Gen5DppControl dpp) + { + value = ApplyDppSource(dpp, value); + } + else if (sourceIndex == 0 && instruction.Control is Gen5Dpp8Control dpp8) + { + value = ApplyDpp8Source(dpp8, value); + } + + if (instruction.Control is Gen5SdwaControl sdwa) + { + var selector = sourceIndex switch + { + 0 => sdwa.Source0Select, + 1 => sdwa.Source1Select, + _ => 6u, + }; + value = selector switch + { + 0 => $"(({value}) & 0xFFu)", + 1 => $"((({value}) >> 8) & 0xFFu)", + 2 => $"((({value}) >> 16) & 0xFFu)", + 3 => $"((({value}) >> 24) & 0xFFu)", + 4 => $"(({value}) & 0xFFFFu)", + 5 => $"((({value}) >> 16) & 0xFFFFu)", + _ => value, + }; + var signExtend = sourceIndex switch + { + 0 => sdwa.Source0SignExtend, + 1 => sdwa.Source1SignExtend, + _ => false, + }; + if (signExtend && selector != 6) + { + var width = selector <= 3 ? 8u : 16u; + value = $"(uint)extract_bits(as_type({value}), 0u, {width}u)"; + } + + if (applySdwaIntegerModifiers) + { + if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0) + { + value = $"(uint)abs(as_type({value}))"; + } + + if ((sdwa.NegateMask & (1u << sourceIndex)) != 0) + { + value = $"(0u - ({value}))"; + } + } + } + + return value; + } + + /// 64-bit source: SGPR/VGPR pair, sign-extended inline, or zero-extended 32-bit. + private string RawSource64(Gen5ShaderInstruction instruction, int sourceIndex) + { + var operand = instruction.Sources[sourceIndex]; + switch (operand.Kind) + { + case Gen5OperandKind.ScalarRegister: + return Scalar64Expression(operand.Value); + case Gen5OperandKind.VectorRegister: + return $"((ulong)v[{operand.Value}] | ((ulong)v[{operand.Value + 1}] << 32))"; + case Gen5OperandKind.EncodedConstant when operand.Value is >= 193 and <= 208: + { + // Inline negatives sign-extend: -1 denotes a full 64-bit mask. + var signed = -(long)(operand.Value - 192); + return $"0x{unchecked((ulong)signed):X}ul"; + } + default: + return $"(ulong)({RawSource(instruction, sourceIndex)})"; + } + } + + private string Scalar64Expression(uint register) => register switch + { + // VCC/EXEC read their architectural SGPR pairs like any other + // register — programs park plain data there (see StoreScalar). + _ when register + 1 < ScalarRegisterFileCount => + $"((ulong)s[{register}] | ((ulong)s[{register + 1}] << 32))", + _ => "0ul", + }; + + private void StoreScalar64(uint register, string ulongValue) + { + StoreScalar(register, $"(uint)({ulongValue})"); + StoreScalar(register + 1, $"(uint)(({ulongValue}) >> 32)"); + } + + /// Float view of a source with abs/neg modifiers from VOP3/SDWA/DPP. + private string F(Gen5ShaderInstruction instruction, int sourceIndex) + { + var expression = AsFloat( + RawSource(instruction, sourceIndex, applySdwaIntegerModifiers: false)); + var (absoluteMask, negateMask) = instruction.Control switch + { + Gen5Vop3Control control => (control.AbsoluteMask, control.NegateMask), + Gen5SdwaControl control => (control.AbsoluteMask, control.NegateMask), + Gen5DppControl control => (control.AbsoluteMask, control.NegateMask), + _ => (0u, 0u), + }; + if ((absoluteMask & (1u << sourceIndex)) != 0) + { + expression = $"fabs({expression})"; + } + + if ((negateMask & (1u << sourceIndex)) != 0) + { + expression = $"(-{expression})"; + } + + return expression; + } + + /// + /// Wraps a float expression with VOP3/SDWA output modifiers and clamp, + /// then bitcasts back to the register file's uint domain. + /// + private string FloatResult(Gen5ShaderInstruction instruction, string expression) + { + var (outputModifier, clamp) = instruction.Control switch + { + Gen5Vop3Control control => (control.OutputModifier, control.Clamp), + Gen5SdwaControl control => (control.OutputModifier, control.Clamp), + _ => (0u, false), + }; + expression = outputModifier switch + { + 1 => $"(({expression}) * 2.0f)", + 2 => $"(({expression}) * 4.0f)", + 3 => $"(({expression}) * 0.5f)", + _ => expression, + }; + if (clamp) + { + expression = $"clamp({expression}, 0.0f, 1.0f)"; + } + + return AsUInt($"({expression})"); + } + + /// The lane's bit of a mask operand (VCC/EXEC/SGPR mask). + private string MaskBitExpression(Gen5Operand operand) => operand switch + { + { Kind: Gen5OperandKind.ScalarRegister, Value: VccLoRegister } => "vcc", + { Kind: Gen5OperandKind.ScalarRegister, Value: ExecLoRegister } => "exec", + { Kind: Gen5OperandKind.ScalarRegister } scalar => + $"(((s[{scalar.Value}] >> sharpemu_lane) & 1u) != 0u)", + _ => throw new NotSupportedException("mask operand must be a scalar register"), + }; + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs new file mode 100644 index 0000000..7b95234 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Pixel.cs @@ -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 ---- + + /// + /// 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. + /// + 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++; + } + } + + /// Records that reads and/or writes the + /// storage image at , so EmitImageArguments + /// can pick the minimal Metal access qualifier. + 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; + } + } + + /// "float", "int", or "uint" from the descriptor's unified format. + private static string DecodeImageComponentKind(IReadOnlyList 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", + }; + } + + /// Per image binding: its sampler's [[id(N)]] inside the sampler + /// argument buffer, or -1 for storage images. Set by DeclareImageKinds. + private int[] _samplerSlots = []; + + /// Number of sampled images (= sampler argument-buffer entries). + private int _samplerCount; + + /// Buffer slot the sampler argument buffer binds to, past this + /// stage's global buffers, uniforms, and scalar-state buffer. + private int SamplerArgBufferIndex => + Math.Max(UniformsBufferIndex, _initialScalarBufferIndex) + 1; + + /// 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. + 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})]],"); + } + } + + /// Declares the sampler argument-buffer struct at file scope (one + /// sampler per sampled image). Empty when the stage samples nothing. + 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({ImageIntegerAddress(image, 0)})"); + var y = Temp("int", $"as_type({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({ImageIntegerAddress(image, 0)}), 0, (int){widthQuery} - 1)"); + var y = Temp( + "uint", + $"(uint)clamp(as_type({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(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(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(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(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(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; + + /// Float address component, unpacking A16 half pairs. + private string ImageFloatAddress(Gen5ImageControl image, int component) + { + var register = image.GetAddressRegister(ImageAddressRegister(image, component)); + return image.A16 + ? $"(float)as_type(v[{register}])[{component & 1}]" + : $"as_type(v[{register}])"; + } + + /// Integer address component, unpacking A16 16-bit pairs. + 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}]"; + } + + /// One store-source component, unpacking D16 halves. + 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({packed})[{component & 1}]"); + } + + var low = $"(({packed} >> {(component & 1) * 16}) & 0xFFFFu)"; + return kind == "int" + ? $"(uint)extract_bits(as_type({low}), 0u, 16u)" + : low; + } + + private static string ImageTexelComponent(string kind, string raw) => kind switch + { + "int" => $"as_type({raw})", + "uint" => raw, + _ => $"as_type({raw})", + }; + + private string PackImageD16(string kind, string low, string high) + { + if (kind == "float") + { + return $"(((uint)as_type(half(as_type({low})))) | (((uint)as_type(half(as_type({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({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({raw})", + _ => $"as_type({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({packed})[{component & 1}]"; + continue; + } + + values[component] = $"as_type(v[{instruction.Sources[component].Value}])"; + } + + Line($"{field} = exec ? float4({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};"); + return true; + } + + /// + /// 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). + /// + 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; + } + + /// + /// 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. + /// + 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(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"); + } + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs new file mode 100644 index 0000000..c036752 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.cs @@ -0,0 +1,2238 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Globalization; +using System.Text; +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Metal; + +/// +/// Gen5 (gfx10) -> Metal Shading Language codegen. Consumes the backend-neutral +/// (Gen5ShaderState, Gen5ShaderEvaluation) contract out of Gen5ShaderTranslator / +/// Gen5ShaderScalarEvaluator and emits MSL source text; the Metal renderer compiles +/// it with MTLLibrary at bind time. +/// +/// The execution model mirrors Gen5SpirvTranslator: one GPU invocation is one GCN +/// lane (wave32 — natively the Apple simdgroup width), the register file is typeless +/// 32-bit uints (float ALU bitcasts through as_type<float>), and control flow is a +/// PC-dispatcher loop — a bounded while over a switch of GCN basic blocks — rather +/// than reconstructed structured control flow. EXEC/VCC live in their architectural +/// SGPRs (s106/s107, s126/s127) as raw data, with per-lane bools as synced views. +/// Graphics stages model a single logical wave lane (lane 0, ballots degrade to bit +/// 0, shuffle-family selects resolve to the lane's own value) — the SPIR-V +/// translator's no-subgroup fallback — because Metal leaves simdgroup ops undefined +/// inside the divergent dispatcher loop. Compute threads map one-to-one onto real +/// simdgroup lanes and shuffle for real. +/// +/// Wave64: 64-bit masks are carried faithfully as data — every B64 mask op, +/// saveexec, and VCCZ/EXECZ test reads and writes the full register pair. A +/// wave64 guest wave is two 32-wide Apple simdgroups co-resident in one +/// threadgroup; cross-lane ops that span the full 64 lanes (ballots into +/// EXEC/VCC, read-first-lane) rendezvous the two halves through threadgroup +/// scratch with a barrier — the guest's scalar PC keeps all 64 lanes lockstep +/// through the dispatcher, so the barriers are reached uniformly. This mirrors +/// the SPIR-V translator's bridge and shares its scope: the scratch is indexed +/// by half, so it is correct for a one-wave (64-thread) workgroup; readlane +/// across halves stays a 32-wide shuffle (same as the SPIR-V path). Wave- +/// agnostic wave64 kernels translate per-thread unchanged. +/// +/// Buffer argument contract (documented for the Metal backend): +/// [[buffer(globalBufferBase + i)]] global memory binding i, in +/// Gen5ShaderEvaluation.GlobalMemoryBindings order +/// [[buffer(uniformsIndex)]] one SharpEmuUniforms constant buffer holding the +/// compute dispatch limit and per-buffer byte +/// lengths, where uniformsIndex is +/// globalBufferBase + totalGlobalBufferCount +/// +public static partial class Gen5MslTranslator +{ + private const uint ScalarRegisterFileCount = 128; + private const uint VectorRegisterFileCount = 256; + private const uint LdsDwordCount = 8192; + private const uint LdsDwordMask = LdsDwordCount - 1; + // Graphics stages model LDS as per-invocation scratch; a full 32 KB array + // per fragment/vertex invocation risks Metal compile limits, and + // per-invocation write-then-read correctness only needs deterministic + // address masking (mirrors the SPIR-V translator's Private-array choice). + private const uint PrivateLdsDwordCount = 2048; + private const uint VccLoRegister = 106; + private const uint VccHiRegister = 107; + private const uint ExecLoRegister = 126; + private const uint ExecHiRegister = 127; + + public static bool TryCompilePixelShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + Gen5PixelOutputKind outputKind, + out Gen5MslShader shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int initialScalarBufferIndex = -1, + int pixelRenderTargetSlot = 0, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + ulong storageBufferOffsetAlignment = 1) => + TryCompilePixelShader( + state, + evaluation, + [new Gen5PixelOutputBinding((uint)pixelRenderTargetSlot, 0, outputKind)], + out shader, + out error, + globalBufferBase, + totalGlobalBufferCount, + imageBindingBase, + initialScalarBufferIndex, + pixelInputEnable, + pixelInputAddress, + storageBufferOffsetAlignment); + + public static bool TryCompilePixelShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + IReadOnlyList outputs, + out Gen5MslShader shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int initialScalarBufferIndex = -1, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + ulong storageBufferOffsetAlignment = 1) + { + shader = default!; + error = string.Empty; + if (outputs.Count > 8) + { + error = "pixel outputs must contain at most eight guest slots in the 0..7 range"; + return false; + } + + for (var index = 0; index < outputs.Count; index++) + { + if (outputs[index].GuestSlot > 7) + { + error = "pixel outputs must contain at most eight guest slots in the 0..7 range"; + return false; + } + + for (var other = index + 1; other < outputs.Count; other++) + { + if (outputs[other].GuestSlot == outputs[index].GuestSlot || + outputs[other].HostLocation == outputs[index].HostLocation) + { + error = "pixel output guest slots and host locations must be unique"; + return false; + } + } + } + + // Host locations must be dense 0..N-1 so [[color(n)]] attachments match. + for (uint location = 0; location < outputs.Count; location++) + { + var found = false; + foreach (var output in outputs) + { + found |= output.HostLocation == location; + } + + if (!found) + { + error = "pixel output host locations must be dense in the 0..N-1 range"; + return false; + } + } + + var context = new CompilationContext( + Gen5MslStage.Pixel, + state, + evaluation, + 1, + 1, + 1, + globalBufferBase, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount: 32, + storageBufferOffsetAlignment, + pixelOutputBindings: outputs, + imageBindingBase: imageBindingBase, + pixelInputEnable: pixelInputEnable, + pixelInputAddress: pixelInputAddress); + return context.TryCompile(out shader, out error); + } + + public static bool TryCompileVertexShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + out Gen5MslShader shader, + out string error, + int globalBufferBase = 0, + int totalGlobalBufferCount = -1, + int imageBindingBase = 0, + int initialScalarBufferIndex = -1, + int requiredVertexOutputCount = 0, + ulong storageBufferOffsetAlignment = 1) + { + var context = new CompilationContext( + Gen5MslStage.Vertex, + state, + evaluation, + 1, + 1, + 1, + globalBufferBase, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount: 32, + storageBufferOffsetAlignment, + imageBindingBase: imageBindingBase, + requiredVertexOutputCount: requiredVertexOutputCount); + return context.TryCompile(out shader, out error); + } + + public static bool TryCompileComputeShader( + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + uint localSizeX, + uint localSizeY, + uint localSizeZ, + out Gen5MslShader shader, + out string error, + int totalGlobalBufferCount = -1, + int initialScalarBufferIndex = -1, + uint waveLaneCount = 32, + ulong storageBufferOffsetAlignment = 1) + { + var context = new CompilationContext( + Gen5MslStage.Compute, + state, + evaluation, + Math.Max(localSizeX, 1), + Math.Max(localSizeY, 1), + Math.Max(localSizeZ, 1), + globalBufferBase: 0, + totalGlobalBufferCount, + initialScalarBufferIndex, + waveLaneCount, + storageBufferOffsetAlignment); + return context.TryCompile(out shader, out error); + } + + private sealed partial class CompilationContext + { + // Safety valve for the PC-dispatcher loop, mirroring the SPIR-V + // translator: a mistranslated loop-exit condition must terminate the + // invocation instead of wedging the GPU queue. + private static readonly int _maxDispatcherSteps = + int.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_SHADER_MAX_STEPS"), + out var maxSteps) && maxSteps >= 0 + ? maxSteps + : 100_000; + + private const long InitialScalarDefinition = -1; + private const long ConflictingScalarDefinition = -2; + private const long UnreachableScalarDefinition = -3; + + private readonly Gen5MslStage _stage; + private readonly Gen5ShaderState _state; + private readonly Gen5ShaderEvaluation _evaluation; + private readonly uint _localSizeX; + private readonly uint _localSizeY; + private readonly uint _localSizeZ; + private readonly int _globalBufferBase; + private readonly int _totalGlobalBufferCount; + private readonly int _initialScalarBufferIndex; + private readonly uint _waveLaneCount; + private readonly ulong _storageBufferOffsetAlignment; + private readonly Dictionary _scalarDefinitionsBeforePc = []; + private readonly IReadOnlyList _pixelOutputBindings; + private readonly int _imageBindingBase; + private readonly uint _pixelInputEnable; + private readonly uint _pixelInputAddress; + private readonly Dictionary _imageBindingByPc = []; + private readonly Dictionary _bufferBindingByPc = []; + private readonly List<(bool IsStorage, string ComponentKind)> _imageKinds = []; + // Per storage-image binding: whether the body reads it, writes it, or + // both. Metal caps access::read_write textures at 8 per function, so each + // binding is declared with the minimal access it actually uses. + private bool[] _imageBindingReads = []; + private bool[] _imageBindingWrites = []; + private readonly SortedSet _pixelAttributes = []; + private readonly SortedSet _vertexOutputs = []; + private readonly Dictionary _vertexInputsByPc = []; + private readonly int _requiredVertexOutputCount; + private readonly StringBuilder _body = new(); + private int _indent; + private int _nextTemp; + private bool _usesLds; + private bool _usesFormatLoads; + private bool _usesWaveScratch; + + /// True when this stage emulates a 64-lane guest wave across two + /// 32-wide Apple simdgroups (compute only; graphics stages use the + /// single-lane model regardless of guest wave size). + private bool IsWave64 => _waveLaneCount == 64 && _stage == Gen5MslStage.Compute; + + public CompilationContext( + Gen5MslStage stage, + Gen5ShaderState state, + Gen5ShaderEvaluation evaluation, + uint localSizeX, + uint localSizeY, + uint localSizeZ, + int globalBufferBase, + int totalGlobalBufferCount, + int initialScalarBufferIndex, + uint waveLaneCount, + ulong storageBufferOffsetAlignment, + IReadOnlyList? pixelOutputBindings = null, + int imageBindingBase = 0, + uint pixelInputEnable = 0, + uint pixelInputAddress = 0, + int requiredVertexOutputCount = 0) + { + _pixelOutputBindings = pixelOutputBindings ?? []; + _imageBindingBase = imageBindingBase; + _pixelInputEnable = pixelInputEnable; + _pixelInputAddress = pixelInputAddress; + _requiredVertexOutputCount = requiredVertexOutputCount; + _stage = stage; + _state = state; + _evaluation = evaluation; + _localSizeX = localSizeX; + _localSizeY = localSizeY; + _localSizeZ = localSizeZ; + _globalBufferBase = globalBufferBase; + _totalGlobalBufferCount = totalGlobalBufferCount < 0 + ? evaluation.GlobalMemoryBindings.Count + : totalGlobalBufferCount; + _initialScalarBufferIndex = initialScalarBufferIndex; + _waveLaneCount = waveLaneCount == 64 ? 64u : 32u; + if (storageBufferOffsetAlignment == 0 || + (storageBufferOffsetAlignment & (storageBufferOffsetAlignment - 1)) != 0 || + storageBufferOffsetAlignment > uint.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(storageBufferOffsetAlignment), + storageBufferOffsetAlignment, + "storage-buffer offset alignment must be a uint-sized power of two"); + } + + _storageBufferOffsetAlignment = storageBufferOffsetAlignment; + } + + public bool TryCompile(out Gen5MslShader shader, out string error) + { + shader = default!; + error = string.Empty; + try + { + // A 64-lane guest wave that uses cross-lane ops needs the + // threadgroup-scratch bridge (ballots span both 32-wide halves, + // read-first-lane broadcasts across them). Programs without such + // ops are wave-size-agnostic and need no scratch. + _usesWaveScratch = IsWave64 && UsesWaveSensitiveOperations(); + + var blocks = BuildBasicBlocks(_state.Program.Instructions); + if (blocks.Count == 0) + { + error = "shader contains no executable blocks"; + return false; + } + + BuildScalarDefinitionInfo(blocks, _state.Program.Instructions); + DeclareImageKinds(); + foreach (var instruction in _state.Program.Instructions) + { + _usesLds |= instruction.Control is Gen5DataShareControl { Gds: false }; + _usesFormatLoads |= IsFormatBufferLoad(instruction.Opcode); + if (instruction.Control is Gen5InterpolationControl interpolationControl) + { + _pixelAttributes.Add(interpolationControl.Attribute); + } + + if (_stage == Gen5MslStage.Vertex && + instruction.Control is Gen5ExportControl { Target: >= 32 and < 64 } vertexExport) + { + _vertexOutputs.Add(vertexExport.Target - 32); + } + } + + if (_stage == Gen5MslStage.Vertex) + { + // Cover every location the paired fragment shader reads, + // even ones this vertex program never exports, so Metal's + // exact vertex-out/fragment-in interface match succeeds. + // Extras stay zero-filled. + for (uint location = 0; location < _requiredVertexOutputCount; location++) + { + _vertexOutputs.Add(location); + } + + foreach (var input in _evaluation.VertexInputs ?? []) + { + if (input.ComponentCount is >= 1 and <= 4) + { + _vertexInputsByPc.TryAdd(input.Pc, input); + } + } + } + + // Emit the dispatcher body first: block translation discovers + // nothing that changes the signature in the compute stage, but + // keeping the order body-then-wrap matches how the pixel/vertex + // stages will need it (their IO discovery happens during block + // translation). + _indent = 2; + for (var index = 0; index < blocks.Count; index++) + { + Line($"case {index}u:"); + Line("{"); + _indent++; + if (!TryEmitBlock(blocks, index, out error)) + { + error = $"block=0x{blocks[index].StartPc:X}: {error}"; + return false; + } + + _indent--; + Line("}"); + Line("break;"); + } + + var source = new StringBuilder(); + EmitModule(source, blocks.Count); + shader = new Gen5MslShader( + source.ToString(), + EntryPointName, + _stage, + _evaluation.GlobalMemoryBindings, + _evaluation.ImageBindings, + AttributeCount: _stage switch + { + Gen5MslStage.Pixel => (uint)_pixelAttributes.Count, + Gen5MslStage.Vertex => (uint)_vertexOutputs.Count, + _ => 0, + }, + VertexInputs: _stage == Gen5MslStage.Vertex + ? _evaluation.VertexInputs ?? [] + : [], + _localSizeX, + _localSizeY, + _localSizeZ, + UniformsBufferIndex: UniformsBufferIndex, + ImageBindingBase: _imageBindingBase, + SamplerSlots: _samplerSlots, + SamplerCount: _samplerCount, + SamplerArgBufferIndex: _samplerCount > 0 ? SamplerArgBufferIndex : -1); + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + /// Mirrors the SPIR-V translator's subgroup-usage predicates: + /// the ops whose results depend on the wave width or on other lanes. + /// A program without them is wave-size-agnostic. + private bool UsesWaveSensitiveOperations() + { + foreach (var instruction in _state.Program.Instructions) + { + if (instruction.Control is Gen5DppControl or Gen5Dpp8Control || + instruction.Opcode is "VPermlane16B32" or "VPermlanex16B32" + or "VReadlaneB32" or "VReadfirstlaneB32" + or "VMbcntLoU32B32" or "VMbcntHiU32B32" || + instruction.Opcode.Contains("Saveexec", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("SCbranchExec", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("SCbranchVcc", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("VCmpx", StringComparison.Ordinal)) + { + return true; + } + + foreach (var operand in instruction.Sources) + { + if (IsWaveMaskOperand(operand)) + { + return true; + } + } + + foreach (var operand in instruction.Destinations) + { + if (IsWaveMaskOperand(operand)) + { + return true; + } + } + } + + return false; + } + + private static bool IsWaveMaskOperand(Gen5Operand operand) => + operand.Kind == Gen5OperandKind.ScalarRegister && + operand.Value is VccLoRegister or VccHiRegister or ExecLoRegister or ExecHiRegister; + + /// Rewrites the trailing comma of the last emitted parameter + /// line into the closing parenthesis. Every stage emits each entry + /// parameter with a trailing comma so optional parameters never need + /// to know whether they are last. + private static void CloseParameterList(StringBuilder source) + { + var index = source.Length - 1; + while (index >= 0 && (source[index] == '\n' || source[index] == '\r')) + { + index--; + } + + if (index >= 0 && source[index] == ',') + { + source.Remove(index, source.Length - index); + source.AppendLine(")"); + } + } + + private string EntryPointName => _stage switch + { + Gen5MslStage.Vertex => "gen5_vs", + Gen5MslStage.Pixel => "gen5_ps", + _ => "gen5_cs", + }; + + private int UniformsBufferIndex => _globalBufferBase + _totalGlobalBufferCount; + + private void EmitModule(StringBuilder source, int blockCount) + { + source.AppendLine("// Generated by SharpEmu Gen5MslTranslator."); + source.AppendLine("#include "); + source.AppendLine(); + source.AppendLine("using namespace metal;"); + source.AppendLine(); + + // Uniforms: dispatch bounds plus per-buffer byte lengths. Metal has + // no OpArrayLength equivalent, so buffer extents travel with the + // dispatch instead of being queried in-shader. + if (_usesFormatLoads) + { + EmitFormatLoadPrelude(source); + } + + source.AppendLine("struct SharpEmuUniforms"); + source.AppendLine("{"); + source.AppendLine(" uint dispatch_limit_x;"); + source.AppendLine(" uint dispatch_limit_y;"); + source.AppendLine(" uint dispatch_limit_z;"); + source.AppendLine(" uint reserved;"); + source.AppendLine($" uint buffer_bytes[{Math.Max(_totalGlobalBufferCount, 1)}];"); + source.AppendLine("};"); + source.AppendLine(); + EmitSamplerArgumentBufferStruct(source); + EmitPrelude(source); + source.AppendLine(); + + if (_stage == Gen5MslStage.Vertex) + { + // Stage IO structs: fetched attributes from the evaluated vertex + // inputs (bound via MTLVertexDescriptor), position plus the + // param outputs the paired fragment shader reads. + if (_vertexInputsByPc.Count != 0) + { + source.AppendLine("struct Gen5VsIn"); + source.AppendLine("{"); + var declared = new HashSet(); + foreach (var input in _vertexInputsByPc.Values) + { + if (!declared.Add(input.Location)) + { + continue; + } + + var fieldType = input.ComponentCount == 1 + ? "float" + : $"float{input.ComponentCount}"; + source.AppendLine( + $" {fieldType} in{input.Location} [[attribute({input.Location})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + } + + source.AppendLine("struct Gen5VsOut"); + source.AppendLine("{"); + source.AppendLine(" float4 sharpemu_position [[position]];"); + foreach (var location in _vertexOutputs) + { + source.AppendLine($" float4 param{location} [[user(locn{location})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + source.AppendLine($"vertex Gen5VsOut {EntryPointName}("); + if (_vertexInputsByPc.Count != 0) + { + source.AppendLine(" Gen5VsIn sharpemu_vin [[stage_in]],"); + } + } + else if (_stage == Gen5MslStage.Pixel) + { + // Stage IO structs: interpolated attributes discovered from the + // program's V_INTERP controls, MRT outputs from the bindings. + source.AppendLine("struct Gen5PsIn"); + source.AppendLine("{"); + source.AppendLine(" float4 sharpemu_frag_coord [[position]];"); + foreach (var attribute in _pixelAttributes) + { + source.AppendLine($" float4 attr{attribute} [[user(locn{attribute})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + source.AppendLine("struct Gen5PsOut"); + source.AppendLine("{"); + foreach (var binding in _pixelOutputBindings) + { + var fieldType = binding.Kind switch + { + Gen5PixelOutputKind.Uint => "uint4", + Gen5PixelOutputKind.Sint => "int4", + _ => "float4", + }; + source.AppendLine( + $" {fieldType} mrt{binding.GuestSlot} [[color({binding.HostLocation})]];"); + } + + source.AppendLine("};"); + source.AppendLine(); + source.AppendLine($"fragment Gen5PsOut {EntryPointName}("); + source.AppendLine(" Gen5PsIn sharpemu_in [[stage_in]],"); + } + else + { + source.AppendLine($"kernel void {EntryPointName}("); + } + + for (var index = 0; index < _evaluation.GlobalMemoryBindings.Count; index++) + { + source.AppendLine( + $" device uint* b{index} [[buffer({_globalBufferBase + index})]],"); + } + + if (_initialScalarBufferIndex >= 0) + { + // The per-dispatch scalar-state buffer sits at its flat slot, + // past every stage's global bindings; the shader only reads it. + source.AppendLine( + $" const device uint* b{_initialScalarBufferIndex} " + + $"[[buffer({_initialScalarBufferIndex})]],"); + } + + source.AppendLine( + $" constant SharpEmuUniforms& sharpemu_uniforms [[buffer({UniformsBufferIndex})]],"); + EmitImageArguments(source); + if (_stage == Gen5MslStage.Compute) + { + source.AppendLine(" uint3 sharpemu_local_id [[thread_position_in_threadgroup]],"); + source.AppendLine(" uint3 sharpemu_group_id [[threadgroup_position_in_grid]],"); + } + + if (_stage == Gen5MslStage.Vertex) + { + source.AppendLine(" uint sharpemu_vertex_id [[vertex_id]],"); + source.AppendLine(" uint sharpemu_instance_id [[instance_id]],"); + } + else if (_stage == Gen5MslStage.Compute && IsWave64) + { + // A 64-lane guest wave is two 32-wide Apple simdgroups. Metal + // packs a threadgroup's threads into simdgroups in ascending + // thread_index order, so thread_index_in_threadgroup & 63 is the + // guest lane and its low bit-5 selects the half. Both halves sit + // in one threadgroup, so a threadgroup_barrier rendezvous bridges + // the wave for 64-wide ballots (see EmitWave64Ballot). + source.AppendLine(" uint sharpemu_tg_index [[thread_index_in_threadgroup]],"); + } + else if (_stage == Gen5MslStage.Compute) + { + // Compute threads map one-to-one onto guest lanes, so wave ops + // address the invocation's real simdgroup lane. + source.AppendLine(" uint sharpemu_lane [[thread_index_in_simdgroup]],"); + } + + CloseParameterList(source); + source.AppendLine("{"); + if (_stage == Gen5MslStage.Compute && IsWave64) + { + source.AppendLine(" uint sharpemu_lane = sharpemu_tg_index & 63u;"); + if (_usesWaveScratch && !_usesLds) + { + // Two dwords bridge each half's ballot; the third carries a + // broadcast value for read-first-lane. Indexed only by half, + // so correct for a one-wave (64-thread) workgroup — larger + // workgroups would need per-wave scratch (matches the SPIR-V + // translator's bridge scope). When the shader also uses LDS the + // bridge instead aliases the top of that allocation (below) so + // total threadgroup memory stays within Metal's 32 KB limit. + source.AppendLine(" threadgroup uint sharpemu_wave_scratch[3];"); + } + } + if (_stage != Gen5MslStage.Compute) + { + // Graphics stages model a single logical wave lane — the SPIR-V + // translator's no-subgroup fallback — because Metal leaves + // simdgroup ops undefined inside the divergent dispatcher loop. + // Ballots degrade to bit 0 in the prelude and shuffle-family + // selects resolve to the lane's own value. + source.AppendLine(" const uint sharpemu_lane = 0u;"); + } + if (_usesLds) + { + if (_stage == Gen5MslStage.Compute) + { + // 32 KB of guest LDS as workgroup-shared memory; the address + // is masked into bounds like the SPIR-V translator. + source.AppendLine($" threadgroup uint sharpemu_lds[{LdsDwordCount}];"); + } + else + { + // Graphics stages model LDS as per-invocation scratch (the + // SPIR-V translator's Private-array trick), sized smaller + // because only write-then-read correctness is needed. + source.AppendLine($" thread uint sharpemu_lds[{PrivateLdsDwordCount}] = {{}};"); + } + } + + if (_usesWaveScratch && _usesLds && _stage == Gen5MslStage.Compute) + { + // Reuse the final three dwords of the LDS allocation for the + // wave64 bridge. A separate threadgroup array would push total + // threadgroup memory past Metal's 32 KB limit for shaders that + // request the full LDS (mirrors the SPIR-V translator). Guest LDS + // accesses are bounds-masked into the same allocation, so this + // trades a rare top-of-LDS collision for a compilable pipeline. + source.AppendLine( + $" threadgroup uint* sharpemu_wave_scratch = &sharpemu_lds[{LdsDwordCount - 3}u];"); + } + + EmitRegisterFile(source); + if (_stage == Gen5MslStage.Pixel) + { + source.AppendLine(" Gen5PsOut sharpemu_out = {};"); + } + else if (_stage == Gen5MslStage.Vertex) + { + // Zero-initialized: param outputs the program never exports + // stay (0,0,0,0) to satisfy the fragment interface. + source.AppendLine(" Gen5VsOut sharpemu_out = {};"); + } + + EmitInitialState(source); + source.AppendLine(); + source.AppendLine(" while (active)"); + source.AppendLine(" {"); + source.AppendLine(" switch (pc)"); + source.AppendLine(" {"); + source.Append(_body); + source.AppendLine(" default:"); + source.AppendLine(" active = false;"); + source.AppendLine(" break;"); + source.AppendLine(" }"); + if (_maxDispatcherSteps > 0) + { + source.AppendLine($" if (++steps >= {_maxDispatcherSteps}u)"); + source.AppendLine(" {"); + source.AppendLine(" active = false;"); + source.AppendLine(" }"); + } + + source.AppendLine(" }"); + if (_stage == Gen5MslStage.Pixel) + { + // A lane still removed from EXEC when the guest shader exits is + // a killed fragment; it must not contribute color or blending. + source.AppendLine(" if (!exec)"); + source.AppendLine(" {"); + source.AppendLine(" discard_fragment();"); + source.AppendLine(" }"); + source.AppendLine(" return sharpemu_out;"); + } + else if (_stage == Gen5MslStage.Vertex) + { + source.AppendLine(" return sharpemu_out;"); + } + + source.AppendLine("}"); + } + + // Shared helpers: MSL allows free functions, so unaligned and subdword + // access is a byte-pointer cast instead of the manual word-combining + // the SPIR-V translator inlines at every site. All access is + // range-checked against the binding's byte length; loads outside the + // buffer produce zero and stores are dropped, matching the SPIR-V + // translator's robust-access behavior. The static text lives in + // Templates/prelude.msl; only the wave-ballot expression varies. + // + // Graphics stages model one logical wave lane (lane 0), so a ballot is + // just that lane's bit: "value ? 1 : 0". A real simd_ballot cannot be + // used there: the translated program runs inside the divergent + // while(active){switch(pc)} dispatcher, where Metal leaves cross-lane + // ops undefined, so lanes at different pc corrupt each other's EXEC/VCC + // reconstruction and kill whole quads (all fragments discarded). This + // is the SPIR-V translator's no-subgroup fallback model. Compute + // mirrors the SPIR-V translator's compute path instead: threads map + // one-to-one onto real simdgroup lanes and ballots are real, so masks + // parked in VCC/EXEC hold each lane's actual bit. + private void EmitPrelude(StringBuilder source) => + source.Append(MslTemplates.Render( + "prelude", + ("ballot_return", _stage == Gen5MslStage.Compute + ? "(uint)(uint64_t)simd_ballot(value)" + : "value ? 1u : 0u"))); + + // The GFX10 unified-format table is baked from the same authoritative + // decoder descriptor evaluation uses (dataFormat | numberFormat << 8); + // the descriptor is read at execution time, so decoding stays dynamic — + // compiled shaders may be reused with new SRDs. The static conversion + // functions live in Templates/format_prelude.msl. + private static void EmitFormatLoadPrelude(StringBuilder source) + { + var table = new StringBuilder(); + for (uint format = 0; format < 128; format++) + { + Gfx10UnifiedFormat.TryDecode(format, out var dataFormat, out var numberFormat); + if ((format & 15) == 0) + { + if (format != 0) + { + table.AppendLine(); + } + + table.Append(" "); + } + + table.Append($" 0x{dataFormat | (numberFormat << 8):X}u,"); + } + + var layoutCases = new StringBuilder(); + var first = true; + foreach (var (component, format, bytes, bitOffset, bitCount) in FormatComponentLayouts()) + { + if (!first) + { + layoutCases.AppendLine(); + } + + first = false; + layoutCases.Append( + $" case {component * 16 + format}u: byteOff = {bytes}u; bitOff = {bitOffset}u; bits = {bitCount}u; break;"); + } + + source.AppendLine(MslTemplates.Render( + "format_prelude", + ("format_table", table.ToString()), + ("layout_cases", layoutCases.ToString()))); + } + + /// + /// The legacy DATA_FORMAT component layouts the SPIR-V translator encodes + /// in LoadGfx10BufferFormatComponent, as (component, format, byteOffset, + /// bitOffset, bitCount) tuples. + /// + private static IEnumerable<(uint Component, uint Format, uint Bytes, uint BitOffset, uint BitCount)> FormatComponentLayouts() + { + // Component 0. + yield return (0, 1, 0, 0, 8); + yield return (0, 2, 0, 0, 16); + yield return (0, 3, 0, 0, 8); + yield return (0, 4, 0, 0, 32); + yield return (0, 5, 0, 0, 16); + yield return (0, 6, 0, 0, 10); + yield return (0, 7, 0, 0, 11); + yield return (0, 8, 0, 0, 10); + yield return (0, 9, 0, 0, 2); + yield return (0, 10, 0, 0, 8); + yield return (0, 11, 0, 0, 32); + yield return (0, 12, 0, 0, 16); + yield return (0, 13, 0, 0, 32); + yield return (0, 14, 0, 0, 32); + // Component 1. + yield return (1, 3, 1, 0, 8); + yield return (1, 5, 2, 0, 16); + yield return (1, 6, 0, 10, 11); + yield return (1, 7, 0, 11, 11); + yield return (1, 8, 0, 10, 10); + yield return (1, 9, 0, 2, 10); + yield return (1, 10, 1, 0, 8); + yield return (1, 11, 4, 0, 32); + yield return (1, 12, 2, 0, 16); + yield return (1, 13, 4, 0, 32); + yield return (1, 14, 4, 0, 32); + // Component 2. + yield return (2, 6, 0, 21, 11); + yield return (2, 7, 0, 22, 10); + yield return (2, 8, 0, 20, 10); + yield return (2, 9, 0, 12, 10); + yield return (2, 10, 2, 0, 8); + yield return (2, 12, 4, 0, 16); + yield return (2, 13, 8, 0, 32); + yield return (2, 14, 8, 0, 32); + // Component 3. + yield return (3, 8, 0, 30, 2); + yield return (3, 9, 0, 22, 10); + yield return (3, 10, 3, 0, 8); + yield return (3, 12, 6, 0, 16); + yield return (3, 14, 12, 0, 32); + } + + private void EmitRegisterFile(StringBuilder source) + { + source.AppendLine($" uint s[{ScalarRegisterFileCount}] = {{}};"); + source.AppendLine($" uint v[{VectorRegisterFileCount}] = {{}};"); + source.AppendLine(" bool exec = true;"); + source.AppendLine(" bool vcc = false;"); + source.AppendLine(" bool scc = false;"); + source.AppendLine(" uint pc = 0u;"); + source.AppendLine(" bool active = true;"); + source.AppendLine(" uint steps = 0u;"); + } + + private void EmitInitialState(StringBuilder source) + { + if (_initialScalarBufferIndex >= 0) + { + // Initial scalar registers arrive in a per-dispatch buffer so + // animated user data reuses one translation, mirroring the + // SPIR-V translator. Word 256+i of the same buffer carries the + // per-binding byte bias for suballocated guest buffers. + var consumed = Gen5ShaderTranslator.ComputeConsumedScalarMask(_state.Program); + for (uint index = 0; + index < _evaluation.InitialScalarRegisters.Count && + index < ScalarRegisterFileCount; + index++) + { + if (Gen5ShaderTranslator.IsScalarConsumed(consumed, index)) + { + source.AppendLine( + $" s[{index}] = b{_initialScalarBufferIndex}[{index}];"); + } + } + + var biasCount = _globalBufferBase + _evaluation.GlobalMemoryBindings.Count; + source.AppendLine($" uint bias[{Math.Max(biasCount, 1)}] = {{}};"); + for (var binding = 0; binding < biasCount; binding++) + { + source.AppendLine( + $" bias[{binding}] = b{_initialScalarBufferIndex}[{256 + binding}];"); + } + } + else + { + for (uint index = 0; + index < _evaluation.InitialScalarRegisters.Count && + index < ScalarRegisterFileCount; + index++) + { + var value = _evaluation.InitialScalarRegisters[(int)index]; + if (value != 0) + { + source.AppendLine($" s[{index}] = 0x{value:X}u;"); + } + } + + var biasCount = _globalBufferBase + _evaluation.GlobalMemoryBindings.Count; + source.AppendLine($" uint bias[{Math.Max(biasCount, 1)}] = {{}};"); + } + + if (_stage == Gen5MslStage.Compute) + { + source.AppendLine(" v[0] = sharpemu_local_id.x;"); + source.AppendLine(" v[1] = sharpemu_local_id.y;"); + source.AppendLine(" v[2] = sharpemu_local_id.z;"); + + // Partial-group guard: lanes whose global id falls outside the + // guest dispatch stay inactive, matching the SPIR-V bounds + // check driven by the same uniform. + source.AppendLine( + $" active = (sharpemu_group_id.x * {_localSizeX}u + sharpemu_local_id.x) < sharpemu_uniforms.dispatch_limit_x"); + source.AppendLine( + $" && (sharpemu_group_id.y * {_localSizeY}u + sharpemu_local_id.y) < sharpemu_uniforms.dispatch_limit_y"); + source.AppendLine( + $" && (sharpemu_group_id.z * {_localSizeZ}u + sharpemu_local_id.z) < sharpemu_uniforms.dispatch_limit_z;"); + + if (_state.ComputeSystemRegisters is { } registers) + { + EmitComputeSystemRegister(source, registers.WorkGroupXRegister, "sharpemu_group_id.x"); + EmitComputeSystemRegister(source, registers.WorkGroupYRegister, "sharpemu_group_id.y"); + EmitComputeSystemRegister(source, registers.WorkGroupZRegister, "sharpemu_group_id.z"); + if (registers.ThreadGroupSizeRegister is { } sizeRegister && + sizeRegister < ScalarRegisterFileCount) + { + source.AppendLine( + $" s[{sizeRegister}] = {checked(_localSizeX * _localSizeY * _localSizeZ)}u;"); + } + } + } + else if (_stage == Gen5MslStage.Pixel) + { + EmitPixelInputState(source); + } + else if (_stage == Gen5MslStage.Vertex) + { + // Hardware-selected VGPRs for the vertex and instance indices. + source.AppendLine(" v[5] = sharpemu_vertex_id;"); + source.AppendLine(" v[8] = sharpemu_instance_id;"); + } + + // VCC/EXEC live in their architectural SGPRs (see StoreScalar); + // establish the entry state over whatever the initial-scalar block + // carried so the register file and the bool views agree from the + // first instruction. + source.AppendLine($" s[{VccLoRegister}] = 0u;"); + source.AppendLine($" s[{VccHiRegister}] = 0u;"); + if (IsWave64) + { + // All 64 lanes are active at entry (before the dispatcher masks + // any off), and this runs at a uniform point, so the bridge + // barriers are safe here too. + EmitBallotStoreAtEntry(source, ExecLoRegister, "true"); + } + else + { + source.AppendLine($" s[{ExecLoRegister}] = sharpemu_ballot(true);"); + source.AppendLine($" s[{ExecHiRegister}] = 0u;"); + } + } + + /// Entry-time form of writing to + /// at the fixed indent of the module prologue. + private void EmitBallotStoreAtEntry(StringBuilder source, uint loRegister, string condition) + { + if (!_usesWaveScratch) + { + // No cross-lane ops: the high half stays zero and the low half + // is this simdgroup's ballot, matching the wave-agnostic path. + source.AppendLine($" s[{loRegister}] = sharpemu_ballot({condition});"); + source.AppendLine($" s[{loRegister + 1}] = 0u;"); + return; + } + + source.AppendLine( + $" sharpemu_wave_scratch[(sharpemu_lane >> 5) & 1u] = sharpemu_ballot({condition});"); + source.AppendLine(" threadgroup_barrier(mem_flags::mem_threadgroup);"); + source.AppendLine($" s[{loRegister}] = sharpemu_wave_scratch[0];"); + source.AppendLine($" s[{loRegister + 1}] = sharpemu_wave_scratch[1];"); + source.AppendLine(" threadgroup_barrier(mem_flags::mem_threadgroup);"); + } + + private static void EmitComputeSystemRegister( + StringBuilder source, + uint? scalarRegister, + string expression) + { + if (scalarRegister is { } register && register < ScalarRegisterFileCount) + { + source.AppendLine($" s[{register}] = {expression};"); + } + } + + // ---- dispatcher blocks ---- + + private bool TryEmitBlock( + IReadOnlyList blocks, + int blockIndex, + out string error) + { + error = string.Empty; + var block = blocks[blockIndex]; + var instructions = _state.Program.Instructions; + for (var index = block.StartIndex; index < block.EndIndex; index++) + { + var instruction = instructions[index]; + var isTerminator = index == block.EndIndex - 1; + if (instruction.Opcode == "SEndpgm") + { + Line("active = false;"); + return true; + } + + if (instruction.Opcode == "SBranch") + { + // A branch to (or past) the program's end is an exit — the + // pattern sprite alpha-kill shaders use to skip their tail. + if (IsExitBranchTarget(instructions, instruction)) + { + Line("active = false;"); + return true; + } + + if (!TryGetBranchTargetBlock(blocks, instruction, out var target)) + { + error = $"branch target outside program at pc=0x{instruction.Pc:X}"; + return false; + } + + Line($"pc = {target}u;"); + return true; + } + + if (instruction.Opcode.StartsWith("SCbranch", StringComparison.Ordinal)) + { + if (!TryGetBranchCondition(instruction.Opcode, out var condition)) + { + error = $"unsupported conditional branch {instruction.Opcode}"; + return false; + } + + var fallthrough = blockIndex + 1; + if (IsExitBranchTarget(instructions, instruction)) + { + // Taken → exit; not taken → fall through (or exit when + // this is the last block anyway). + if (fallthrough >= blocks.Count) + { + Line("active = false;"); + } + else + { + Line($"pc = {fallthrough}u;"); + Line($"active = !({condition});"); + } + + return true; + } + + if (!TryGetBranchTargetBlock(blocks, instruction, out var target)) + { + error = $"branch target outside program at pc=0x{instruction.Pc:X}"; + return false; + } + + if (fallthrough >= blocks.Count) + { + Line($"pc = ({condition}) ? {target}u : 0xFFFFFFFFu;"); + Line($"active = ({condition});"); + } + else + { + Line($"pc = ({condition}) ? {target}u : {fallthrough}u;"); + } + + return true; + } + + if (!TryEmitInstruction(instruction, out error)) + { + error = $"pc=0x{instruction.Pc:X4} {instruction.Opcode}: {error}"; + return false; + } + + if (isTerminator) + { + // Fall through to the next block (or exit at program end). + if (blockIndex + 1 < blocks.Count) + { + Line($"pc = {blockIndex + 1}u;"); + } + else + { + Line("active = false;"); + } + } + } + + return true; + } + + private bool TryGetBranchCondition(string opcode, out string condition) + { + condition = opcode switch + { + "SCbranchScc0" => "!scc", + "SCbranchScc1" => "scc", + // VCCZ/EXECZ test the full architectural register pair, which + // also covers programs that parked plain data in VCC. + "SCbranchVccz" => $"(s[{VccLoRegister}] | s[{VccHiRegister}]) == 0u", + "SCbranchVccnz" => $"(s[{VccLoRegister}] | s[{VccHiRegister}]) != 0u", + "SCbranchExecz" => $"(s[{ExecLoRegister}] | s[{ExecHiRegister}]) == 0u", + "SCbranchExecnz" => $"(s[{ExecLoRegister}] | s[{ExecHiRegister}]) != 0u", + _ => string.Empty, + }; + return condition.Length != 0; + } + + private static bool TryGetBranchTargetBlock( + IReadOnlyList blocks, + Gen5ShaderInstruction instruction, + out int block) + { + block = -1; + return TryGetBranchTargetPc(instruction, out var targetPc) && + TryFindBlock(blocks, targetPc, out block); + } + + /// True when the branch lands at or past the last instruction's + /// end — an exit, matching the SPIR-V translator's handling. + private static bool IsExitBranchTarget( + IReadOnlyList instructions, + Gen5ShaderInstruction instruction) + { + if (instructions.Count == 0 || + !TryGetBranchTargetPc(instruction, out var targetPc)) + { + return false; + } + + var last = instructions[^1]; + var lastEndPc = last.Pc + (uint)(last.Words.Count * sizeof(uint)); + return targetPc >= lastEndPc; + } + + // ---- instruction dispatch ---- + + private bool TryEmitInstruction( + Gen5ShaderInstruction instruction, + out string error) + { + error = string.Empty; + switch (instruction.Opcode) + { + case "SNop": + case "SWaitcnt": + case "SInstPrefetch": + case "STtraceData": + case "SClause": + case "VNop": + // 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. + case "SSendmsg": + return true; + case "SBarrier": + Line("threadgroup_barrier(mem_flags::mem_threadgroup | mem_flags::mem_device);"); + return true; + } + + if (instruction.Control is Gen5ImageControl imageControl) + { + return TryEmitImage(instruction, imageControl, out error); + } + + if (instruction.Control is Gen5ExportControl exportControl) + { + return TryEmitExport(instruction, exportControl, out error); + } + + if (instruction.Control is Gen5InterpolationControl interpolationControl) + { + return TryEmitInterpolation(instruction, interpolationControl, out error); + } + + if (instruction.Control is Gen5DataShareControl dataShare) + { + return TryEmitDataShare(instruction, dataShare, out error); + } + + if (instruction.Control is Gen5ScalarMemoryControl scalarMemory) + { + return TryEmitScalarMemory(instruction, scalarMemory, out error); + } + + if (instruction.Control is Gen5GlobalMemoryControl globalMemory) + { + return TryEmitGlobalMemory(instruction, globalMemory, out error); + } + + if (instruction.Control is Gen5BufferMemoryControl bufferMemory) + { + return TryEmitBufferMemory(instruction, bufferMemory, out error); + } + + if (instruction.Opcode.StartsWith("V", StringComparison.Ordinal)) + { + return TryEmitVectorAlu(instruction, out error); + } + + if (instruction.Opcode.StartsWith("S", StringComparison.Ordinal)) + { + return TryEmitScalarAlu(instruction, out error); + } + + error = "unsupported instruction"; + return false; + } + + // ---- memory ---- + + private bool TryEmitScalarMemory( + Gen5ShaderInstruction instruction, + Gen5ScalarMemoryControl control, + out string error) + { + error = string.Empty; + var scalarAddress = instruction.Sources.Count != 0 && + instruction.Sources[0].Kind == Gen5OperandKind.ScalarRegister + ? instruction.Sources[0].Value + : uint.MaxValue; + if (!TryResolveDominatingBufferBinding( + instruction.Pc, + scalarAddress, + registerCount: instruction.Opcode.StartsWith( + "SBufferLoad", + StringComparison.Ordinal) ? 4u : 2u, + out var bindingIndex)) + { + foreach (var destination in instruction.Destinations) + { + if (destination.Kind == Gen5OperandKind.ScalarRegister) + { + StoreScalar(destination.Value, "0u"); + } + } + + return true; + } + + var offset = control.DynamicOffsetRegister is { } register + ? $"(s[{register}] + 0x{unchecked((uint)control.ImmediateOffsetBytes):X}u)" + : $"0x{unchecked((uint)control.ImmediateOffsetBytes):X}u"; + var address = Temp("uint", ApplyByteBias(bindingIndex, offset)); + for (var index = 0; index < instruction.Destinations.Count; index++) + { + var destination = instruction.Destinations[index]; + if (destination.Kind != Gen5OperandKind.ScalarRegister) + { + error = "invalid scalar-memory destination"; + return false; + } + + StoreScalar( + destination.Value, + LoadWord(bindingIndex, $"({address} + {index * 4}u)")); + } + + return true; + } + + private bool TryEmitGlobalMemory( + Gen5ShaderInstruction instruction, + Gen5GlobalMemoryControl control, + out string error) + { + error = string.Empty; + if (!TryResolveDominatingBufferBinding( + instruction.Pc, + control.ScalarAddress, + registerCount: 2, + out var bindingIndex)) + { + error = "missing global-memory binding"; + return false; + } + + var address = Temp( + "uint", + ApplyByteBias( + bindingIndex, + $"(v[{control.VectorAddress}] + 0x{unchecked((uint)control.OffsetBytes):X}u)")); + return TryEmitResolvedMemoryAccess( + instruction.Opcode, + bindingIndex, + address, + control.VectorData, + control.DwordCount, + control.Glc, + out error); + } + + private bool TryEmitBufferMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + out string error) + { + error = string.Empty; + if (_stage == Gen5MslStage.Vertex && + _vertexInputsByPc.TryGetValue(instruction.Pc, out var vertexInput)) + { + return TryEmitVertexInputFetch(control, vertexInput, out error); + } + + if (!TryResolveDominatingBufferBinding( + instruction.Pc, + control.ScalarResource, + registerCount: 4, + out var bindingIndex)) + { + error = "missing buffer-memory binding"; + return false; + } + + var scalarOffset = instruction.Sources.Count > 2 + ? SourceExpression(instruction.Sources[2], instruction) + : "0u"; + var stride = $"((s[{control.ScalarResource + 1}] >> 16) & 0x3FFFu)"; + var vectorIndex = control.IndexEnabled + ? $"v[{control.VectorAddress}]" + : "0u"; + var vectorOffset = control.OffsetEnabled + ? $"v[{control.VectorAddress + (control.IndexEnabled ? 1u : 0u)}]" + : "0u"; + var address = Temp( + "uint", + ApplyByteBias( + bindingIndex, + $"(0x{unchecked((uint)control.OffsetBytes):X}u + {scalarOffset} + {vectorOffset} + ({vectorIndex} * {stride}))")); + // Typed MUBUF/MTBUF loads convert through the descriptor's unified + // format; raw dword loads and every store take the byte path below + // (format stores write raw dwords, matching the SPIR-V translator). + if (IsFormatBufferLoad(instruction.Opcode) && + !instruction.Opcode.StartsWith("BufferStore", StringComparison.Ordinal)) + { + EmitBufferFormatLoad( + bindingIndex, + address, + control.ScalarResource, + control.VectorData, + control.DwordCount); + return true; + } + + return TryEmitResolvedMemoryAccess( + instruction.Opcode, + bindingIndex, + address, + control.VectorData, + control.DwordCount, + control.Glc, + out error); + } + + private void EmitBufferFormatLoad( + int bindingIndex, + string byteAddress, + uint scalarResource, + uint vectorData, + uint componentCount) + { + // Format and destination swizzle come from descriptor word 3 at + // execution time; the prelude table decodes the unified format the + // same way descriptor evaluation does. + var word3 = Temp("uint", ScalarExpression(scalarResource + 3)); + var entry = Temp("uint", $"sharpemu_gfx10_formats[({word3} >> 12) & 0x7Fu]"); + var dataFormat = Temp("uint", $"{entry} & 0xFFu"); + var numberFormat = Temp("uint", $"({entry} >> 8) & 0xFFu"); + var canonical = new string[4]; + for (var component = 0; component < 4; component++) + { + var byteOff = Temp("uint", "0u"); + var bitOff = Temp("uint", "0u"); + var bits = Temp("uint", "0u"); + Line($"sharpemu_format_layout({dataFormat}, {component}u, {byteOff}, {bitOff}, {bits});"); + var packed = Temp( + "uint", + LoadWord(bindingIndex, $"({byteAddress} + {byteOff})")); + var raw = Temp( + "uint", + $"{bits} == 0u ? 0u : extract_bits({packed}, {bitOff}, {bits})"); + var missing = component == 3 + ? $"sharpemu_format_one({numberFormat})" + : "0u"; + canonical[component] = Temp( + "uint", + $"{bits} == 0u ? {missing} : sharpemu_format_convert({raw}, {bits}, {numberFormat}, {dataFormat})"); + } + + for (uint destination = 0; destination < componentCount; destination++) + { + var selector = Temp("uint", $"({word3} >> {destination * 3}u) & 7u"); + StoreVector( + vectorData + destination, + $"{selector} == 1u ? sharpemu_format_one({numberFormat}) : " + + $"{selector} == 4u ? {canonical[0]} : " + + $"{selector} == 5u ? {canonical[1]} : " + + $"{selector} == 6u ? {canonical[2]} : " + + $"{selector} == 7u ? {canonical[3]} : 0u"); + } + } + + private bool TryEmitDataShare( + Gen5ShaderInstruction instruction, + Gen5DataShareControl control, + out string error) + { + error = string.Empty; + if (control.Gds) + { + error = "GDS data share is not implemented"; + return false; + } + + var ldsMask = _stage == Gen5MslStage.Compute + ? LdsDwordMask + : PrivateLdsDwordCount - 1; + string LdsIndex(string address, uint offsetBytes) => + offsetBytes == 0 + ? $"((({address}) >> 2) & {ldsMask}u)" + : $"(((({address}) + {offsetBytes}u) >> 2) & {ldsMask}u)"; + + void StoreLds(string index, string value) + { + // Exec-guarded like every other lane-visible write. + Line($"if (exec) {{ sharpemu_lds[{index}] = {value}; }}"); + } + + switch (instruction.Opcode) + { + case "DsAddU32": + { + var address = Temp("uint", RawSource(instruction, 0)); + var value = Temp("uint", RawSource(instruction, 1)); + Line("if (exec)"); + Line("{"); + _indent++; + Line($"atomic_fetch_add_explicit((threadgroup atomic_uint*)&sharpemu_lds[{LdsIndex(address, control.Offset0)}], {value}, memory_order_relaxed);"); + _indent--; + Line("}"); + return true; + } + case "DsWriteB32": + { + var address = Temp("uint", RawSource(instruction, 0)); + StoreLds(LdsIndex(address, control.Offset0), RawSource(instruction, 1)); + return true; + } + case "DsWriteB64": + { + var address = Temp("uint", RawSource(instruction, 0)); + StoreLds(LdsIndex(address, control.Offset0), RawSource(instruction, 1)); + StoreLds(LdsIndex(address, control.Offset0 + sizeof(uint)), RawSource(instruction, 2)); + return true; + } + case "DsWriteB96": + case "DsWriteB128": + { + var dwordCount = instruction.Opcode == "DsWriteB128" ? 4 : 3; + var address = Temp("uint", RawSource(instruction, 0)); + for (var dword = 0; dword < dwordCount; dword++) + { + StoreLds( + LdsIndex(address, control.Offset0 + (uint)(dword * sizeof(uint))), + RawSource(instruction, 1 + dword)); + } + + return true; + } + case "DsWrite2B32": + case "DsWrite2St64B32": + { + var st64 = instruction.Opcode == "DsWrite2St64B32"; + var address = Temp("uint", RawSource(instruction, 0)); + StoreLds( + LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset0, st64)), + RawSource(instruction, 1)); + StoreLds( + LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset1, st64)), + RawSource(instruction, 2)); + return true; + } + case "DsReadB32": + { + var address = Temp("uint", RawSource(instruction, 0)); + StoreVector( + instruction.Destinations[0].Value, + $"sharpemu_lds[{LdsIndex(address, control.Offset0)}]"); + return true; + } + case "DsReadB96": + case "DsReadB128": + { + var dwordCount = instruction.Opcode == "DsReadB128" ? 4 : 3; + if (instruction.Destinations.Count < dwordCount) + { + error = "missing LDS read operand"; + return false; + } + + var address = Temp("uint", RawSource(instruction, 0)); + for (var dword = 0; dword < dwordCount; dword++) + { + StoreVector( + instruction.Destinations[dword].Value, + $"sharpemu_lds[{LdsIndex(address, control.Offset0 + (uint)(dword * sizeof(uint)))}]"); + } + + return true; + } + case "DsRead2B32": + case "DsRead2St64B32": + { + if (instruction.Destinations.Count < 2) + { + error = "missing LDS read2 operand"; + return false; + } + + var st64 = instruction.Opcode == "DsRead2St64B32"; + var address = Temp("uint", RawSource(instruction, 0)); + StoreVector( + instruction.Destinations[0].Value, + $"sharpemu_lds[{LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset0, st64))}]"); + StoreVector( + instruction.Destinations[1].Value, + $"sharpemu_lds[{LdsIndex(address, EffectiveDsPairOffsetBytes(control.Offset1, st64))}]"); + return true; + } + default: + error = $"unsupported LDS opcode {instruction.Opcode}"; + return false; + } + } + + private static uint EffectiveDsPairOffsetBytes(uint offset, bool st64) => + offset * (st64 ? 256u : sizeof(uint)); + + private bool TryEmitResolvedMemoryAccess( + string opcode, + int bindingIndex, + string byteAddress, + uint vectorData, + uint dwordCount, + bool glc, + out string error) + { + error = string.Empty; + if (opcode is "GlobalAtomicAdd" or "BufferAtomicAdd" or + "GlobalAtomicUMax" or "BufferAtomicUMax") + { + var function = opcode.EndsWith("Add", StringComparison.Ordinal) + ? "atomic_fetch_add_explicit" + : "atomic_fetch_max_explicit"; + Line("if (exec)"); + Line("{"); + _indent++; + Line($"if ({byteAddress} + 4u <= {BufferBytes(bindingIndex)} && ({byteAddress} & 3u) == 0u)"); + Line("{"); + _indent++; + var original = Temp( + "uint", + $"{function}((device atomic_uint*)(b{bindingIndex} + ({byteAddress} >> 2)), v[{vectorData}], memory_order_relaxed)"); + if (glc) + { + Line($"v[{vectorData}] = {original};"); + } + + _indent--; + Line("}"); + _indent--; + Line("}"); + return true; + } + + if (opcode.StartsWith("GlobalStore", StringComparison.Ordinal) || + opcode.StartsWith("BufferStore", StringComparison.Ordinal)) + { + Line("if (exec)"); + Line("{"); + _indent++; + if (TryGetSubdwordStoreInfo(opcode, out var storeBytes, out var sourceShift)) + { + var source = sourceShift == 0 + ? $"v[{vectorData}]" + : $"(v[{vectorData}] >> {sourceShift})"; + Line($"sharpemu_store_bytes(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress}, {source}, {storeBytes}u);"); + } + else + { + for (uint index = 0; index < dwordCount; index++) + { + Line($"sharpemu_store_bytes(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress} + {index * 4}u, v[{vectorData + index}], 4u);"); + } + } + + _indent--; + Line("}"); + return true; + } + + if (TryGetSubdwordLoadInfo(opcode, out var loadBytes, out var signExtend, out var d16, out var d16High)) + { + var loaded = Temp( + "uint", + $"sharpemu_load_bytes(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress}, {loadBytes}u, {(signExtend ? "true" : "false")})"); + if (!d16) + { + StoreVector(vectorData, loaded); + return true; + } + + // D16 loads merge into one half of the destination register. + StoreVector( + vectorData, + d16High + ? $"(v[{vectorData}] & 0x0000FFFFu) | (({loaded} & 0xFFFFu) << 16)" + : $"(v[{vectorData}] & 0xFFFF0000u) | ({loaded} & 0xFFFFu)"); + return true; + } + + if (opcode.StartsWith("GlobalLoad", StringComparison.Ordinal) || + opcode.StartsWith("BufferLoad", StringComparison.Ordinal)) + { + for (uint index = 0; index < dwordCount; index++) + { + StoreVector( + vectorData + index, + LoadWord(bindingIndex, $"({byteAddress} + {index * 4}u)")); + } + + return true; + } + + error = $"unsupported memory opcode {opcode}"; + return false; + } + + private static bool TryGetSubdwordLoadInfo( + string opcode, + out uint byteCount, + out bool signExtend, + out bool d16, + out bool d16High) + { + byteCount = opcode.Contains("byte", StringComparison.OrdinalIgnoreCase) ? 1u : 2u; + signExtend = opcode.Contains("Sbyte", StringComparison.Ordinal) || + opcode.Contains("Sshort", StringComparison.Ordinal); + d16 = opcode.Contains("D16", StringComparison.Ordinal); + d16High = opcode.EndsWith("D16Hi", StringComparison.Ordinal); + return opcode.Contains("LoadUbyte", StringComparison.Ordinal) || + opcode.Contains("LoadSbyte", StringComparison.Ordinal) || + opcode.Contains("LoadUshort", StringComparison.Ordinal) || + opcode.Contains("LoadSshort", StringComparison.Ordinal) || + opcode.Contains("LoadShortD16", StringComparison.Ordinal); + } + + private static bool TryGetSubdwordStoreInfo( + string opcode, + out uint byteCount, + out uint sourceShift) + { + byteCount = opcode.Contains("StoreByte", StringComparison.Ordinal) ? 1u : 2u; + sourceShift = opcode.EndsWith("D16Hi", StringComparison.Ordinal) ? 16u : 0u; + return opcode.Contains("StoreByte", StringComparison.Ordinal) || + opcode.Contains("StoreShort", StringComparison.Ordinal); + } + + private static bool IsFormatBufferLoad(string opcode) => + opcode.StartsWith("BufferLoadFormat", StringComparison.Ordinal) || + opcode.StartsWith("TBufferLoad", StringComparison.Ordinal); + + private string BufferBytes(int bindingIndex) => + $"sharpemu_uniforms.buffer_bytes[{_globalBufferBase + bindingIndex}]"; + + private string LoadWord(int bindingIndex, string byteAddress) => + $"sharpemu_load_word(b{bindingIndex}, {BufferBytes(bindingIndex)}, {byteAddress})"; + + private string ApplyByteBias(int bindingIndex, string byteAddress) => + $"({byteAddress} + bias[{_globalBufferBase + bindingIndex}])"; + + // ---- binding resolution (ports the SPIR-V dominating-definition scheme) ---- + + private bool TryResolveDominatingBufferBinding( + uint pc, + uint scalarAddress, + uint registerCount, + out int bindingIndex) + { + if (_bufferBindingByPc.TryGetValue(pc, out bindingIndex)) + { + return true; + } + + var candidates = _evaluation.GlobalMemoryBindings; + for (var index = 0; index < candidates.Count; index++) + { + var binding = candidates[index]; + foreach (var bindingPc in binding.InstructionPcs) + { + if (bindingPc == pc) + { + bindingIndex = index; + _bufferBindingByPc.Add(pc, index); + return true; + } + } + } + + // No direct PC match: accept a binding only when the descriptor + // registers hold the exact same definitions here as at one of the + // binding's own access points — the scalar-definition dataflow the + // SPIR-V translator uses for descriptors shared across sites. + for (var index = 0; index < candidates.Count; index++) + { + var binding = candidates[index]; + if (binding.ScalarAddress != scalarAddress) + { + continue; + } + + foreach (var candidatePc in binding.InstructionPcs) + { + if (!HasSameScalarDefinitions(candidatePc, pc, scalarAddress, registerCount)) + { + continue; + } + + bindingIndex = index; + _bufferBindingByPc.Add(pc, index); + return true; + } + } + + bindingIndex = -1; + return false; + } + + // ---- writer helpers ---- + + private void Line(string text) + { + for (var index = 0; index < _indent; index++) + { + _body.Append(" "); + } + + _body.AppendLine(text); + } + + private string Temp(string type, string expression) + { + var name = $"t{_nextTemp++}"; + Line($"{type} {name} = {expression};"); + return name; + } + + // VCC (s106:s107) and EXEC (s126:s127) are architectural SGPRs: programs + // freely use them as scratch data registers (s_buffer_load into s[106], + // then v_rcp_f32 of that value is real RDNA2 code). The register file + // holds their raw 32-bit values as the source of truth; the bools + // vcc/exec are cached per-lane views kept in sync at every write so + // control flow stays cheap. Reading them back as data returns the file. + private void StoreScalar(uint register, string expression) + { + switch (register) + { + case VccLoRegister: + { + var value = Temp("uint", expression); + Line($"s[{VccLoRegister}] = {value};"); + Line($"vcc = (({value}) >> sharpemu_lane & 1u) != 0u;"); + return; + } + + case ExecLoRegister: + { + var value = Temp("uint", expression); + Line($"s[{ExecLoRegister}] = {value};"); + Line($"exec = (({value}) >> sharpemu_lane & 1u) != 0u;"); + return; + } + + case VccHiRegister: + case ExecHiRegister: + // Wave32: the high halves carry no lanes, but keep the data. + Line($"s[{register}] = {expression};"); + return; + } + + if (register < ScalarRegisterFileCount) + { + Line($"s[{register}] = {expression};"); + } + } + + private void StoreVector(uint register, string expression, bool guardWithExec = true) + { + if (register >= VectorRegisterFileCount) + { + return; + } + + if (guardWithExec) + { + Line($"if (exec) {{ v[{register}] = {expression}; }}"); + } + else + { + Line($"v[{register}] = {expression};"); + } + } + + private string ScalarExpression(uint register) => + register < ScalarRegisterFileCount ? $"s[{register}]" : "0u"; + + private string SourceExpression( + Gen5Operand operand, + Gen5ShaderInstruction instruction) + { + switch (operand.Kind) + { + case Gen5OperandKind.ScalarRegister: + return ScalarExpression(operand.Value); + case Gen5OperandKind.VectorRegister: + return $"v[{operand.Value}]"; + case Gen5OperandKind.LiteralConstant: + return FormatUInt(operand.Value); + case Gen5OperandKind.EncodedConstant: + // 251/252/253 read the VCCZ/EXECZ/SCC status bits as data. + if (operand.Value == 251) + { + return $"((s[{VccLoRegister}] | s[{VccHiRegister}]) == 0u ? 1u : 0u)"; + } + + if (operand.Value == 252) + { + return $"((s[{ExecLoRegister}] | s[{ExecHiRegister}]) == 0u ? 1u : 0u)"; + } + + if (operand.Value == 253) + { + return "(scc ? 1u : 0u)"; + } + + if (Gen5InlineConstants.TryDecode(operand.Value, out var constant)) + { + return FormatUInt(constant); + } + + throw new NotSupportedException( + $"unsupported encoded constant {operand.Value} in {instruction.Opcode}"); + default: + throw new NotSupportedException($"unsupported operand kind {operand.Kind}"); + } + } + + private static string FormatUInt(uint value) => + value <= 9 ? $"{value}u" : $"0x{value.ToString("X", CultureInfo.InvariantCulture)}u"; + + private static string AsFloat(string expression) => $"as_type({expression})"; + + private static string AsUInt(string expression) => $"as_type({expression})"; + + // ---- basic blocks (ports BuildBasicBlocks from the SPIR-V translator) ---- + + private readonly record struct ShaderBlock( + uint StartPc, + int StartIndex, + int EndIndex); + + private static IReadOnlyList BuildBasicBlocks( + IReadOnlyList instructions) + { + if (instructions.Count == 0) + { + return []; + } + + var leaders = new SortedSet { instructions[0].Pc }; + for (var index = 0; index < instructions.Count; index++) + { + var instruction = instructions[index]; + if (IsBranch(instruction.Opcode) && + TryGetBranchTargetPc(instruction, out var targetPc)) + { + leaders.Add(targetPc); + } + + if ((IsBranch(instruction.Opcode) || instruction.Opcode == "SEndpgm") && + index + 1 < instructions.Count) + { + leaders.Add(instructions[index + 1].Pc); + } + } + + var starts = new List(leaders.Count); + foreach (var pc in leaders) + { + if (FindInstructionIndex(instructions, pc) >= 0) + { + starts.Add(pc); + } + } + + var blocks = new List(starts.Count); + for (var index = 0; index < starts.Count; index++) + { + var startIndex = FindInstructionIndex(instructions, starts[index]); + var endIndex = index + 1 < starts.Count + ? FindInstructionIndex(instructions, starts[index + 1]) + : instructions.Count; + if (startIndex >= 0 && endIndex > startIndex) + { + blocks.Add(new ShaderBlock(starts[index], startIndex, endIndex)); + } + } + + return blocks; + } + + private static bool IsBranch(string opcode) => + opcode == "SBranch" || + opcode.StartsWith("SCbranch", StringComparison.Ordinal); + + private static bool TryGetBranchTargetPc( + Gen5ShaderInstruction instruction, + out uint targetPc) + { + targetPc = 0; + if (instruction.Encoding != Gen5ShaderEncoding.Sopp || + instruction.Words.Count == 0) + { + return false; + } + + var offset = unchecked((short)(instruction.Words[0] & 0xFFFF)); + var nextPc = (long)instruction.Pc + + (instruction.Words.Count * sizeof(uint)); + var target = nextPc + (offset * sizeof(uint)); + if (target < 0 || target > uint.MaxValue) + { + return false; + } + + targetPc = (uint)target; + return true; + } + + private static int FindInstructionIndex( + IReadOnlyList instructions, + uint pc) + { + for (var index = 0; index < instructions.Count; index++) + { + if (instructions[index].Pc == pc) + { + return index; + } + } + + return -1; + } + + private static bool TryFindBlock( + IReadOnlyList blocks, + uint pc, + out int block) + { + for (var index = 0; index < blocks.Count; index++) + { + if (blocks[index].StartPc == pc) + { + block = index; + return true; + } + } + + block = -1; + return false; + } + + // ---- scalar-definition dataflow (ports BuildScalarDefinitionInfo) ---- + + private void BuildScalarDefinitionInfo( + IReadOnlyList blocks, + IReadOnlyList instructions) + { + var predecessors = new HashSet[blocks.Count]; + for (var index = 0; index < blocks.Count; index++) + { + predecessors[index] = []; + } + + void AddEdge(int source, int destination) + { + if (destination < 0 || destination >= blocks.Count) + { + return; + } + + predecessors[destination].Add(source); + } + + for (var blockIndex = 0; blockIndex < blocks.Count; blockIndex++) + { + var block = blocks[blockIndex]; + var terminator = instructions[block.EndIndex - 1]; + var hasFallthrough = blockIndex + 1 < blocks.Count; + if (terminator.Opcode == "SEndpgm") + { + continue; + } + + if (terminator.Opcode == "SBranch") + { + if (TryGetBranchTargetPc(terminator, out var targetPc) && + TryFindBlock(blocks, targetPc, out var targetBlock)) + { + AddEdge(blockIndex, targetBlock); + } + + continue; + } + + if (terminator.Opcode.StartsWith("SCbranch", StringComparison.Ordinal)) + { + if (TryGetBranchTargetPc(terminator, out var targetPc) && + TryFindBlock(blocks, targetPc, out var targetBlock)) + { + AddEdge(blockIndex, targetBlock); + } + + if (hasFallthrough) + { + AddEdge(blockIndex, blockIndex + 1); + } + + continue; + } + + if (hasFallthrough) + { + AddEdge(blockIndex, blockIndex + 1); + } + } + + var blockInputs = new long[blocks.Count][]; + var blockOutputs = new long[blocks.Count][]; + var hasOutput = new bool[blocks.Count]; + var initialDefinitions = new long[ScalarRegisterFileCount]; + Array.Fill(initialDefinitions, InitialScalarDefinition); + + static void MergeDefinitions( + long[] destination, + long[] source, + ref bool hasInput) + { + if (!hasInput) + { + Array.Copy(source, destination, (int)ScalarRegisterFileCount); + hasInput = true; + return; + } + + for (var register = 0; register < ScalarRegisterFileCount; register++) + { + if (destination[register] != source[register]) + { + destination[register] = ConflictingScalarDefinition; + } + } + } + + static void ApplyScalarDefinitions( + long[] definitions, + ShaderBlock block, + IReadOnlyList blockInstructions) + { + for (var instructionIndex = block.StartIndex; + instructionIndex < block.EndIndex; + instructionIndex++) + { + var instruction = blockInstructions[instructionIndex]; + foreach (var destination in instruction.Destinations) + { + if (destination.Kind == Gen5OperandKind.ScalarRegister && + destination.Value < ScalarRegisterFileCount) + { + definitions[destination.Value] = instruction.Pc + 1L; + } + } + } + } + + var changed = true; + while (changed) + { + changed = false; + for (var blockIndex = 0; blockIndex < blocks.Count; blockIndex++) + { + var input = new long[ScalarRegisterFileCount]; + Array.Fill(input, UnreachableScalarDefinition); + var hasInput = false; + if (blockIndex == 0) + { + MergeDefinitions(input, initialDefinitions, ref hasInput); + } + + foreach (var predecessor in predecessors[blockIndex]) + { + if (hasOutput[predecessor]) + { + MergeDefinitions( + input, + blockOutputs[predecessor], + ref hasInput); + } + } + + if (!hasInput) + { + continue; + } + + var output = (long[])input.Clone(); + ApplyScalarDefinitions(output, blocks[blockIndex], instructions); + if (!hasOutput[blockIndex] || + !blockInputs[blockIndex].AsSpan().SequenceEqual(input) || + !blockOutputs[blockIndex].AsSpan().SequenceEqual(output)) + { + blockInputs[blockIndex] = input; + blockOutputs[blockIndex] = output; + hasOutput[blockIndex] = true; + changed = true; + } + } + } + + _scalarDefinitionsBeforePc.Clear(); + for (var blockIndex = 0; blockIndex < blocks.Count; blockIndex++) + { + if (!hasOutput[blockIndex]) + { + continue; + } + + var definitions = (long[])blockInputs[blockIndex].Clone(); + var block = blocks[blockIndex]; + for (var instructionIndex = block.StartIndex; + instructionIndex < block.EndIndex; + instructionIndex++) + { + var instruction = instructions[instructionIndex]; + if (instruction.Control is Gen5ImageControl or + Gen5ScalarMemoryControl or + Gen5GlobalMemoryControl or + Gen5BufferMemoryControl) + { + _scalarDefinitionsBeforePc[instruction.Pc] = + (long[])definitions.Clone(); + } + + foreach (var destination in instruction.Destinations) + { + if (destination.Kind == Gen5OperandKind.ScalarRegister && + destination.Value < ScalarRegisterFileCount) + { + definitions[destination.Value] = instruction.Pc + 1L; + } + } + } + } + } + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs b/src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs new file mode 100644 index 0000000..ab30194 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/MslFixedShaders.cs @@ -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; + +/// +/// 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. +/// +public static class MslFixedShaders +{ + /// + /// 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. + /// + 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())); + } + + /// Samples texture 0 at the interpolated location-0 UV. + public static string CreateCopyFragment() => MslTemplates.Render("copy_fragment"); + + /// + /// 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. + /// + 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))); + + /// + /// Diagnostic fragment stage exposing one interpolated vertex output + /// directly as color, isolating fragment translation from interface data. + /// + public static string CreateAttributeFragment(uint location) => + MslTemplates.Render( + "attribute_fragment", + ("location", location.ToString(CultureInfo.InvariantCulture))); + + /// + /// 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. + /// + public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment"); + + private static string Format(float value) => + value.ToString("0.0######", CultureInfo.InvariantCulture) + "f"; +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs b/src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs new file mode 100644 index 0000000..3146ed7 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/MslTemplates.cs @@ -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; + +/// +/// 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. +/// +internal static class MslTemplates +{ + private static readonly ConcurrentDictionary _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(); + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj b/src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj new file mode 100644 index 0000000..907d384 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj @@ -0,0 +1,26 @@ + + + + + + + false + + + + + + + + + + + + diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl new file mode 100644 index 0000000..e414747 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/attribute_fragment.msl @@ -0,0 +1,13 @@ +#include + +using namespace metal; + +struct AttributeIn +{ + float4 attr{{location}} [[user(locn{{location}})]]; +}; + +fragment float4 attribute_fs(AttributeIn in [[stage_in]]) +{ + return in.attr{{location}}; +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl new file mode 100644 index 0000000..f60f104 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/copy_fragment.msl @@ -0,0 +1,16 @@ +#include + +using namespace metal; + +struct CopyIn +{ + float4 attr0 [[user(locn0)]]; +}; + +fragment float4 copy_fs( + CopyIn in [[stage_in]], + texture2d tex0 [[texture(0)]], + sampler smp0 [[sampler(0)]]) +{ + return tex0.sample(smp0, in.attr0.xy); +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl new file mode 100644 index 0000000..a074f30 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/depth_only_fragment.msl @@ -0,0 +1,7 @@ +#include + +using namespace metal; + +fragment void depth_only_fs() +{ +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl new file mode 100644 index 0000000..9c4e93f --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/format_prelude.msl @@ -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((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(raw), 0u, bits); + switch (nfmt) + { + case 0u: return as_type((float)raw / (float)lowMask); + case 1u: + { + float snorm = (float)signedRaw / (float)(lowMask >> 1); + return as_type(fmax(snorm, -1.0f)); + } + case 2u: return as_type((float)raw); + case 3u: return as_type((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((float)as_type((ushort)(raw & 0xFFFFu))); + } + return raw; + } + default: return raw; + } +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl new file mode 100644 index 0000000..d0fed40 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/fullscreen_vertex.msl @@ -0,0 +1,19 @@ +#include + +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; +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl new file mode 100644 index 0000000..30aa4f4 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/prelude.msl @@ -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, +}; diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl new file mode 100644 index 0000000..9e6cda2 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/present_fragment.msl @@ -0,0 +1,16 @@ +#include + +using namespace metal; + +struct PresentIn +{ + float4 attr0 [[user(locn0)]]; +}; + +fragment float4 present_fs( + PresentIn in [[stage_in]], + texture2d tex0 [[texture(0)]], + sampler smp0 [[sampler(0)]]) +{ + return tex0.sample(smp0, float2(in.attr0.x, 1.0f - in.attr0.y)); +} diff --git a/src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl b/src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl new file mode 100644 index 0000000..a98db4c --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Metal/Templates/solid_fragment.msl @@ -0,0 +1,8 @@ +#include + +using namespace metal; + +fragment float4 solid_fs() +{ + return float4({{red}}, {{green}}, {{blue}}, {{alpha}}); +} diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index eb49ad0..e90ecfc 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -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; diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs new file mode 100644 index 0000000..4c755f1 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs @@ -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 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 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 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 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 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 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 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))); + } +} diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs index 11924a7..fddee7f 100644 --- a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs @@ -49,8 +49,9 @@ public sealed class SaveDataMemoryExportsTests : IDisposable SaveDataExports.ConfigureApplicationInfo(TitleId); } + // Saves are keyed by title id only (single-user layout): //... private string MemoryPath => - Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat"); + Path.Combine(_root, TitleId, "sce_sdmemory", "memory.dat"); public void Dispose() { diff --git a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs new file mode 100644 index 0000000..2dc1e8a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs @@ -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; + +/// +/// Save data lives under ~/SharpEmu/Saves/<titleId>/<dirName>/ with UI +/// metadata in <slot>/sce_sys/param.json. These guard the pure path and +/// metadata logic that the SaveData HLE exports build on. +/// +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); + } + } + } +} diff --git a/tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs b/tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs new file mode 100644 index 0000000..6b823bc --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Sse4aExtrqBlendPatchTests.cs @@ -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; + +/// +/// 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. +/// +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)); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs new file mode 100644 index 0000000..c8775c8 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/FakeGuestMemory.cs @@ -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 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 source) => false; +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs new file mode 100644 index 0000000..6fb61a5 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Gen5ComputeFixtures.cs @@ -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); + +/// +/// 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. +/// +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(), + Array.Empty()); + 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(), + Array.Empty()); + if (!Gen5MslTranslator.TryCompilePixelShader( + state, + evaluation, + outputKind, + out var shader, + out var compileError)) + { + throw new InvalidOperationException($"[pixel] MSL emit failed: {compileError}"); + } + + return shader; + } + + /// Drives the real decoder and the MSL emitter for one fixture. + 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(); + 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(); + + var state = new Gen5ShaderState(program, new uint[16], Metadata: null); + var evaluation = new Gen5ShaderEvaluation( + new uint[128], + new uint[128], + Array.Empty(), + 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!; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl new file mode 100644 index 0000000..2ee949c --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/exec-store.msl @@ -0,0 +1,169 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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((fma(as_type(v[0]), as_type(v[1]), as_type(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(mulhi(as_type(v[3]), as_type(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; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl new file mode 100644 index 0000000..af106b1 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/fmac.msl @@ -0,0 +1,127 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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((fma(as_type(v[1]), as_type(v[2]), as_type(v[5])))); + if (exec) { v[5] = t0; } + uint t1 = as_type((fma(as_type(v[1]), as_type(0x42280000u), as_type(v[2])))); + if (exec) { v[5] = t1; } + uint t2 = as_type((fma(as_type(v[1]), as_type(v[2]), as_type(0x42280000u)))); + if (exec) { v[5] = t2; } + uint t3 = as_type((fma(as_type(v[1]), as_type(v[2]), as_type(v[5])))); + if (exec) { v[5] = t3; } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl new file mode 100644 index 0000000..1720035 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/lds.msl @@ -0,0 +1,135 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl new file mode 100644 index 0000000..38763e5 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/loop.msl @@ -0,0 +1,149 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl new file mode 100644 index 0000000..bfecd0d --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/muls.msl @@ -0,0 +1,127 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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(mulhi(as_type(v[1]), as_type(v[2]))); + if (exec) { v[5] = t3; } + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl new file mode 100644 index 0000000..48c4364 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/pixel.msl @@ -0,0 +1,136 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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(sharpemu_in.attr0[0]); } + if (exec) { v[1] = as_type(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(as_type(v[0]), as_type(v[1]), as_type(v[2]), as_type(v[3])) : sharpemu_out.mrt0; + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } + if (!exec) + { + discard_fragment(); + } + return sharpemu_out; +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl new file mode 100644 index 0000000..b8b95f3 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/vertex.msl @@ -0,0 +1,129 @@ +// Generated by SharpEmu Gen5MslTranslator. +#include + +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(v[0]), as_type(v[0]), as_type(v[0]), as_type(v[1])) : sharpemu_out.sharpemu_position; + sharpemu_out.param0 = exec ? float4(as_type(v[1]), as_type(v[1]), as_type(v[0]), as_type(v[1])) : sharpemu_out.param0; + active = false; + } + break; + default: + active = false; + break; + } + if (++steps >= 100000u) + { + active = false; + } + } + return sharpemu_out; +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs new file mode 100644 index 0000000..1fa7344 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalNative.cs @@ -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; + +/// +/// 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. +/// +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 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"; + } + + /// Compiles MSL source with the OS runtime compiler. + 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; + } + + /// + /// 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. + /// + 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); + + /// Runs the kernel as a single threadgroup of + /// threads, so wave64 fixtures can + /// exercise both 32-wide simdgroups of one guest wave under a threadgroup + /// barrier. + 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; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs new file mode 100644 index 0000000..4895cbc --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MetalRuntimeTests.cs @@ -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; + +/// +/// 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. +/// +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)); +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs new file mode 100644 index 0000000..5ec86ef --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslGoldenTests.cs @@ -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; + +/// +/// 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. +/// +public sealed class MslGoldenTests +{ + public static TheoryData FixtureNames() + { + var data = new TheoryData(); + 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"); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs new file mode 100644 index 0000000..e044409 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/MslTranslationTests.cs @@ -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; + +/// +/// 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. +/// +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(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( + () => Gen5ComputeFixtures.CompileOrThrow(fixture)); + Assert.Contains("pc=0x", exception.Message, StringComparison.Ordinal); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj b/tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj new file mode 100644 index 0000000..888a325 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj @@ -0,0 +1,32 @@ + + + + + false + false + + true + + + + + + + + + + + + + + + + + +