From e17fb6fed1d188323099be1d63d999b540d4cc49 Mon Sep 17 00:00:00 2001 From: Foued Attar Date: Sun, 27 Sep 2026 19:43:07 +0200 Subject: [PATCH] Fix for DS,Little Nightmares, Little Nightmares III and Astro Bot shader (#913) * shader(decode): fill MIMG float-atomic and SOP1 opcode gaps Gen5ShaderTranslator's opcode name tables were missing several real RDNA2 opcodes that Ghost of Yotei's shaders use: MIMG 0x1D-0x1F (ImageAtomicFcmpswap/Fmin/Fmax) and SOP1 0x15/0x1B/0x1C/0x1E/0x34/0x3B (SFlbitI32B32, SBitset0/1 B32/B64, SAbsI32, SBitreplicateB64B32). Without names these decoded as unknown instructions and aborted translation before the pipeline ever reached GPU submission. * shader(resources): accept float-numeric-class atomic image descriptors ResourceMaterializer rejected any atomic image descriptor whose guest format wasn't exactly Format32Uint, but real atomic-float images (ImageAtomicFmin/Fmax) arrive with a float-numeric-class format. Relax the check to also accept formats whose SampledNumericClass is Float, and default the null-descriptor's NumericClass to the image's own class for atomics instead of always Float. Also surface the actual materialization failure reason in ShaderProgramCache's error message instead of a generic string, so each subsequent blocking bug could be identified from the log without extra instrumentation. * shader(bindings): add a dedicated atomic-float image binding range BindingLayout only had a single Atomic-Uint binding range (32-36), so any atomic image with a float numeric class had nowhere to bind and ForImage() returned null for it. Add AtomicFloatBinding (37-40), bump ImageBindingCount 36->41, and split Describe()'s atomic range into its Uint and Float sub-ranges to match. Also add an Atomic flag to ShaderImageShape (TargetRegisterWords.cs) so downstream view-format resolution can distinguish atomic images from ordinary sampled/storage ones. * spirv: emit ImageAtomicFmin/Fmax and DsSwizzleB32, fill SOP1 ALU gaps Vulkan has no native float image-atomic-min/max instruction, so emit them the way KytyPS5 does: OpImageTexelPointer (typed uint) -> OpAtomicLoad -> compare-and-swap loop (OpPhi/OpFOrdLessThan/OpSelect on the bitcast float value) -> OpAtomicCompareExchange, returning the pre-exchange value from EmitAtomicWordUpdate (now non-void, valid SSA at the loop's merge block). Add DsSwizzleB32 (bit-mode/quad-mode lane shuffle via GroupNonUniformShuffle) to the LDS-opcode switch and to UsesSubgroupShuffle(), and add the SFlbitI32B32/SAbsI32/SBitset0B32/ SBitset1B32 SOP1 cases so the newly-decoded opcodes from Gen5ShaderTranslator actually translate to SPIR-V instead of hitting the generic "unsupported instruction" fallback. * spirv: declare atomic image OpTypeImage with a Uint sampled type DeclareImageClass previously derived the SPIR-V OpTypeImage's sampled type purely from the binding's logical NumericClass, so an atomic-float image (logical class Float, backed by our new compare-and-swap emission on a uint texel pointer) still declared its OpTypeImage as float-sampled. That violates VUID-StandaloneSpirv-Image-04965: the R32ui image format and a float sampled type disagree on numeric class. Force the sampled type (and format, already correct as R32ui) to Uint whenever the image is atomic, regardless of its logical numeric class. * gpu: back atomic-float images with a real Uint VkImageView The SPIR-V side now declares atomic images as Uint (OpTypeImage), but the actual VkImageView bound at dispatch time was still created with the descriptor's own float-numeric-class format, tripping VUID-vkCmdDispatch-format-07753 (the shader's declared sampled type must match the bound view's format numeric class). Fix both resolution paths: - VulkanVideoPresenter.Descriptors.cs: NumericClassOf() now forces Uint for any atomic image (this covers the null-descriptor fallback path), and ShapeOf() forwards the Atomic flag. - ImageRequestBuilders.Texture.cs: the real, non-null descriptor path resolves its view format independently from the descriptor's raw GuestPixelFormat and was untouched by the above. Extend the existing Sint-storage override (which already forces such images to Format.R32Uint) to also cover shape.Atomic. Confirmed via a fresh Vulkan-validation-layer run against Ghost of Yotei that VUID-vkCmdDispatch-format-07753 no longer appears. * fix(shader): reconcile atomic SPIR-V emitters with upstream * spirv: fix ImageGetResinfo querying a non-existent sampled-image combo ImageGetResinfo never goes through UsesSampler() (it only matches ImageSample*/ImageGather* opcodes), so TryResolveLayoutImage always resolves imageObject as the raw OpTypeImage value for this instruction, never a sampled-image combo. The size-query code still called OpImage on it whenever resource.IsStorage was false, expecting to extract a combo that was never built, which spirv-val rejects: "Expected Sample Image to be of type OpTypeSampleImage". Just use imageObject directly in both branches. This surfaced only after the earlier atomic-image fixes let Ghost of Yotei's shader compilation get this far; it is an unrelated, pre-existing bug in the base PR. * gpu: remap compute workgroup axes past the Z-dimension device limit Ghost of Yotei dispatches a compute shader with NUM_THREAD (1,1,256). Vulkan only guarantees maxComputeWorkGroupSize >= (1024, 1024, 64): the Z axis has a much tighter guaranteed limit than X/Y, and this ran into it for real: "LocalSize Z (256) exceeds device limit maxComputeWorkGroupSize[2] (64)" at vkCreateComputePipelines, even though the NVIDIA RTX 5070 here allows up to 1024 total invocations. Fix by remapping logical GNM axes onto physical SPIR-V/dispatch axes at shader-compile time: the largest NUM_THREAD axis always lands on physical X (most headroom), the smallest on physical Z (tightest limit). Gen5SpirvTranslator.ComputeWorkgroupAxisOrder computes this permutation purely from the three thread-group sizes, so: - The translator uses it to declare the physical LocalSize execution mode and to read gl_LocalInvocationId/gl_WorkGroupId's components back into the correct logical VGPR/system-register slots, so guest shader code (which only ever sees logical TID/WorkGroupId values) is unaffected by the remap. - RenderExecutor.Compute.cs recomputes the identical permutation from the same thread-group sizes (input.ThreadsX/Y/Z) to reorder vkCmdDispatch's group counts the same way, with no new metadata plumbing between the shader compiler and the dispatch path. * shader(resources): drop redundant ImageHandle ScalarBufferWord guard; add opt-in resource-tracker diagnostics GetHandleSource had a blanket rejection of any ImageHandle whose dword was a ScalarBufferWord, predating ValidateSource/RuntimeValueValidator being able to generically resolve such dwords at bind time (the same mechanism MaterializationSources already uses for buffer descriptors). Drop the redundant, narrower guard and let ValidateSource be the single source of truth, so legitimately resolvable image descriptors loaded from a scalar buffer (not just the two hardcoded heap/direct shapes TryMakeIndirectImage/TryMakeDirectImage recognize) aren't rejected outright. This did not resolve Ghost of Yotei's current blocker: its compute shader at hash=0xCECFBCC210CB2372 pc=0x59C resolves an image descriptor's byte offset through a loop-carried Phi (a hash-table/ linear-probe style bindless material lookup) that is not loop- invariant, so it genuinely cannot be pre-evaluated as a single per-dispatch runtime value the way MakeRuntimeBufferSource/ RuntimeValueEvaluator expect. That needs real GPU-side dynamic descriptor indexing support, which doesn't exist yet in this resource-tracking pass. Also add SHARPEMU_RESOURCE_TRACKER_DIAG=1, gating verbose stderr tracing in TryMakeIndirectImage's rejection paths and RuntimeValueValidator's node-validation failures. This is what localized the loop-carried Phi as the actual root cause; keeping it opt-in and off by default costs nothing at runtime. * shader(resources): degrade control-dependent bindless image/sampler descriptors to null instead of failing compilation Ghost of Yotei's material lookup resolves an image descriptor's offset through a loop-carried phi (a hash-table/linear-probe bindless scheme) that RuntimeValueValidator correctly can't treat as a single compile-time source - this resource tracker has no GPU-side dynamic descriptor indexing to fall back on. Track *why* validation failed (RuntimeValueValidator.ControlDependent, set when the failure traces to a non-invariant phi) and, for image/sampler handles specifically, degrade to a null descriptor instead of hard-failing shader recompilation. Buffer handles and any other failure reason still fail as before. This ports KytyPS5's fix for the identical case (feat/shader-control-dependent-descriptor, "shader: fall back to a null image when its descriptor is control-dependent") rather than attempting full dynamic descriptor indexing support, which doesn't exist in either codebase. Also add SHARPEMU_RESOURCE_TRACKER_DIAG tracing in RuntimeValueEvaluator's evaluation failure paths (per-source-dword, flattened table reads, and raw memory reads), used to localize the next blocker down this same path. * gpu: keep image shader types compatible with Vulkan views * shader: decode and emit VOP1 0x39 (VFfbhU32, find-first-bit-high) Another decode gap in the same family as the earlier MIMG/SOP1 fixes: VOP1 0x39 was missing from the opcode name table, aborting translation of Ghost of Yotei's next compute shader before it reached GPU submission. Ported the fix straight from our own tree's Gen5ShaderTranslator.cs/Gen5SpirvTranslator.Alu.cs, which already decodes and emits it: reverse the bits then use the GLSL FindILsb extended instruction, since RDNA reports the leading-zero count with the architecturally required 0xFFFFFFFF sentinel for a zero source. * shader(resources): treat a null-based SRT raw read as zero instead of faulting A bindless pointer the guest leaves unbound for a dispatch resolves its address-handle dwords to zero; EvaluateRawRead's raw-address branch (as opposed to the buffer-descriptor branch) then dereferenced that near-null address directly, and the guest memory reader correctly refused it as unmapped, failing the whole materialization with a generic "reason=Other". Ghost of Yotei's next compute shader hit exactly this: a flattened SRT table read through a null-resolved 48-bit pointer plus a small immediate offset landed at address 0x14. Treat any raw-address read below a small guest null-page threshold (0x10000) as reading zero, so the value (and whatever descriptor it feeds) collapses to null instead of faulting materialization. Ported directly from KytyPS5's identical fix for the identical symptom ("shader: treat a null-based SRT constant read as zero"). * shader: decode/emit VOPC 64-bit compares and the 64-bit LDS atomics Ghost of Yotei's cs 0x80003AF600 emits V_CMPX_NE_U64 (VOPC 0xF5) and DS_ADD_U64/DS_OR_B64 (0x40/0x4A), which the decoder refused. The VOPC 0xE0-0xE7 / 0xF0-0xF7 forms are the same V_CMP/V_CMPX U64 operations the VOP3 table already names and the emitter already lowers, so mirror them into the two-operand VOPC table. The 64-bit LDS atomics sit just ahead of DS_WRITE_B64. LDS is modelled as an array of dwords, so each is lowered to the two 32-bit atomics that make it up. Neither opcode returns the old value, which makes the split exact: only the final memory state is observable. OR is bitwise-independent, so two 32-bit ORs are the 64-bit OR; ADD folds exactly one carry per low-dword wrap into the high dword, so the high word converges on the true wrap count regardless of lane interleaving. The RTN variants cannot be split without losing the returned pair's atomicity, so they stay undecoded and keep failing loudly. * shader: decode and emit VOP3-encoded 64-bit VOPC compares (0xE0-E7, 0xF0-F7) Ghost of Yotei's material-lookup shader issues V_CMP_*_U64/V_CMPX_*_U64 through the VOP3 encoding (opcode < 0x100, the same numbering VOPC uses), which DecodeVop3's own opcode table never listed - it only handled VOP3-native opcodes >= 0x101. Ported the fix straight from our own tree, which already special-cases this exact sub-range (see the "VOP3-encoded 64-bit VOPC" comment there): decode 0xE0-E7/0xF0-F7 to the VCmp*U64/VCmpx*U64 names, and emit them as a 64-bit integer compare (or a constant true/false for the F/T pseudo-compares), reusing the existing generic VCmpx exec-mask handling. * shader: implement GFX10 double-precision conversions and arithmetic Decode VOP1 0x04/0x0F/0x16/0x2F/0x30/0x34 and their VOP3-encoded forms (V_CVT_F64_I32, V_CVT_F64_U32, V_CVT_F32_F64, V_RCP_F64, V_RSQ_F64, V_SQRT_F64) and emit them as real SPIR-V doubles. A 64-bit result is split across the destination register pair: the low word goes through the common destination store and the high word is stored alongside it, both under the EXEC guard. V_MUL_F64 (VOP3 0x165) and V_FMA_F64 (VOP3 0x14C) are added as well. Source absolute/negate modifiers and the VOP3 output modifier and clamp are honoured. FP64 inline constants and literals are rejected explicitly rather than read as the wrong bits, following the gfx10 expansion rules. The shaderFloat64 device feature is enabled only when the physical device offers it, and the Float64 capability is declared only for modules that use a double. Ghost of Yotei compute shader 0x8000393600 (hash 0xB90E2024732C6111) now translates and compiles instead of stopping at decode. * shader: decode and emit VOP2 V_PK_FMAC_F16 V_PK_FMAC_F16 (VOP2 0x3C) reads the destination register as the addend for both packed f16 lanes and writes D = S0 * S1 + D back as a packed pair. Reuse the existing single-rounding fused-f16 path per lane and merge the two halves, so the result matches the hardware's f16 fused behaviour. Ghost of Yotei compute shader 0x8000393600 reaches this opcode after the decoder is past the f64 gap. * shader: decode and emit VOP3 V_MAD_I32_I24 V_MAD_I32_I24 (VOP3 0x142) differs from the already-handled V_MAD_U32_U24 only in that both multiplicands are signed 24-bit values. Sign-extend each operand from bit 23, multiply with 32-bit wrap and add the third source. Ghost of Yotei compute shader 0x8000393600 reaches this opcode after the decoder is past the f64 gap. * shader(resources): address runtime-fetched buffer descriptors via the device-address page table A V# whose four dwords are all read from a runtime scalar-memory address (a descriptor-array entry indexed by a loop counter, as in Ghost of Yotei's cs 0x80003F2A00 at pc=0x1B4) cannot be bound ahead of time, so resource tracking used to fail the whole shader. Its dword reads are not control-dependent in any other way: they are real guest memory loads emitted at runtime through the device-address page table. Detect that case in ResourceTracker and mark the access RuntimeBufferDescriptor instead of building a dense buffer resource. The SPIR-V backend then builds the guest address from the descriptor's runtime base (SGPR pair), the MUBUF offset/soffset/vector offset and, when indexed, the descriptor's stride, and stores/loads through the existing physical-storage-buffer path. num_records/stride bound the access so a null or empty descriptor binds nothing, matching the static path's range check. Formatted, typed and atomic accesses keep failing loudly because their descriptor format cannot be reconstructed from the raw words. This completes the former d8959888aafd2552 dispatch. * shader(resources): recognize runtime V# descriptors built from SRT reads and loop phis The first runtime-descriptor detection only accepted four direct raw ScalarAddressWord dwords. Ghost of Yotei's cs 0x8000289300 at pc=0x78C builds its V# from a loop-carried phi that merges two SBufferLoadDwordx4 reads of the SRT buffer at a dynamic offset, so the access was still rejected even though the descriptor is a real runtime value. Make the detection generic: a BufferHandle is a runtime descriptor when each dword is a plan-time-known value (constant, user data, shader base, flattened table word) or a guest-memory read, recursing through phis, selects and operations, and at least one leaf is a raw read. A cycle through a phi is accepted the same way the read planner treats cyclic phis. Anything else keeps hard-failing. The raw BufferLoadUshort at pc=0x78C now lowers through the device-address page table. The formatted BufferLoadFormatX at pc=0x18F4 correctly escalates to BoundedCandidateTable, which is still unimplemented and is now the next required feature. * shader(resources): plan bounded runtime V# candidate tables * shader(resources): materialize bounded runtime V# candidate tables * shader(resources): address a runtime scalar buffer V# through the device page table * shader(resources): model the guest V# separately from its Vulkan lowering; real 64-bit LDS atomics Replace MemoryAccessInfo.RuntimeBufferDescriptor with a GuestBufferDescriptor that carries the V#'s provenance (Static or Runtime) and, for static descriptors, the dense-buffer index. The backend now chooses an explicit BufferLoweringStrategy from the descriptor and the access shape via GuestBufferDescriptor.ChooseStrategy, instead of hard-coding a single path: NativeBinding for compile-time descriptors, PhysicalStorageBuffer for a runtime descriptor used by a raw dword/subword access, and BoundedCandidateTable for a runtime descriptor whose format/type cannot be reconstructed from the raw words. The last one is not implemented and fails loudly with a precise reason, which is now a single, discoverable place rather than a boolean scattered through the translator. DS_ADD_U64/DS_OR_B64 are now emitted as true 64-bit workgroup atomics when the device exposes shaderSharedInt64Atomics (queried through VkPhysicalDeviceShaderAtomicInt64Features, declared with the Int64Atomics capability, addressed through a 64-bit LDS view). The previous two-32-bit split is kept only as a documented fallback for devices without the feature; it is not atomic as a pair. Device setup reports the capability once. * shader/image: accept 11_11_10 UNORM sampling and 8-bit sRGB render targets The unified image format 30 is 11_11_10_UNORM, a packed 11/11/10 normalized float format the rendering side already knows as GuestPixelFormat.Bits11_11_10UNorm (mapped to VK_FORMAT_B10G11R11_UFLOAT_PACK32). It was missing from the shader-side sampled-format table, so cs 0x8000265D00 failed specialization with 'sampled image descriptor 7 uses unsupported format 30'. Bits8Srgb and Bits8_8Srgb also had RenderTargetBytesPerElement = 0 while their element sizes are 1 and 2 bytes, which made TryGetTextureSize reject a RenderTarget-tiled 8-bit sRGB surface (tile mode 27) that the guest creates. Their render-target footprint is the same as the matching UNORM formats, and the host view already substitutes R8/R8G8 UNORM, so give them the real byte counts. * shader: allow inline constants and literals as VOP3P packed-f16 operands The VOP3P packed-f16 path only accepted register operands and refused constants, so cs 0x80003AF600 stopped at V_PK_FMA_F16 with src[128]. An inline constant or literal is a single f32 value rather than a packed register pair, so there is no half to select: both lanes take the exactly-converted f16 of that constant, with the lane's negate modifier still applied. Keeps the register path unchanged. * shader: support Yotei f16 shifts bitsets and safe LDS atomics * shader(vulkan): dispatch bounded runtime V# candidate tables * shader: support additional LDS operations * shader: support RDNA LDS store encoding * shader: handle LDS operands and RDNA buffer cache op * shader: extend strict GFX9 buffer and ALU decoding * [VideoOut] added output mode events and profiling fixes * [Gpu] fixed image, render, and dispatch handling * shader: track SDWA and runtime buffer values * [Shader] expanded Gen5 resource and interpolation support * gpu: reconcile PR893 metadata and indirect dispatch * gpu(commands): consume unknown NOP marker ids instead of failing the submission Demon's Souls emits markers 0x6EC and 0x80C, which the interpreter treated as fatal. The packets carry no work for the ids without a known side effect (flips, user-data marker context), so they are consumed and each id is reported once (command_stream.marker_ignored). voidborn, which gets much further on this title, likewise ignores NOP marker payloads it has no handler for. * kernel: implement sceKernelGetOperationMode (PS5 base console, no submode) The import was unresolved, so callers polling it (Demon's Souls does it at very high frequency) got an unresolved-import result instead of the mode. Behaviour matches KytyPS5 KernelGetOperationMode: *mode = 2, *submode = 0. * loader: protect red-zone functions with enclosing patch spans The red-zone patcher only built a trampoline span forward from the faulting instruction, so it gave up whenever the next instruction was a branch target, touched RSP, or changed control flow. Those refusals never reached FailedSites, and the summary line reported sites=11472/11472 failed=0 while 777 faultable instructions inside red-zone functions stayed unprotected. Demon's Souls died on one of them. A GPU write-tracking fault at 0x800AD91F8 dispatched on the guest stack with RSP unshifted, which let Windows overwrite the 128 bytes below RSP; the function reloaded its pointer from [rsp-0x10], got 0, and faulted writing through it. The fault census confirms the same RSP in the faulting frame as in the crash report. Add TryBuildEnclosingPatchSpan: when the forward search refuses, take the span's bytes from before the faulting instruction so the span ends at that instruction and the one after it is never rewritten, which keeps every existing branch into it valid. Two invariants make this safe and both are exercised by the new tests: - the RSP shift brackets only the faultable core, so a relocated RSP-relative access still reads the slot it meant to; - a span may not start before the end of the previous site, because a backward span can otherwise reach instructions an earlier site already replaced with its own jump. Patch() also refuses overlapping sites outright rather than corrupting guest code, and the scan now reports unrelocatable sites by category. Unprotected sites drop from 777 to 270. The title runs past the main menu into its opening cinematic and stops on an unrelated shader resource error. The fiber tests move into a shared collection: they mutate the same static FiberExports state as FiberExportsTests, and xUnit runs separate classes in parallel, which made them fail intermittently. * cpu: count guest faults and make access-violation reports readable Guest code is commonly mapped PAGE_EXECUTE, which no host read can satisfy, so IsReadableHostRange refused it and the whole call-site section of the access violation report was skipped: every crash in such a title printed "Could not read code at RIP" and nothing else. Read those windows through the guest memory view instead, and print the faulting region so a future refusal explains itself. Add a census of the recoverable faults taken on guest threads, by guest RIP and by the handler that resolved them. Windows has no alternate exception-dispatch stack, so each of those faults overwrites the 128 bytes below RSP; knowing which guest instructions fault is what turns a red-zone corruption from a hypothesis into a measurement. Counters are allocated once per RIP and are inert unless SHARPEMU_FAULT_CENSUS is set, since the handler is hot. Also fix the guest context transfer frame: it published the stack bounds in the wrong order, storing the low address in NT_TIB.StackBase and the high one in StackLimit on every fiber switch. Note: these two files also carried uncommitted work from earlier in this worktree (guest stack sizing, the 22-qword transfer frame, import and exception paths); that work is included here rather than separated, because the hunks interleave. * gpu: stop handing image ownership to a buffer that was only bound Binding a formatted writable buffer over an image's memory invalidated every image covering that range. The shader only declares that it may write there, so this fired on every bind of every draw, and when the range belonged to a render target the image was refreshed from guest memory the GPU never wrote - its tiled backing read as linear, over the frame that had just been rendered. A Demon's Souls capture showed 412 buffer-dirty refreshes and 207 GB of image traffic. Suppressing the two bind-time call sites removed the banding entirely and took the frame time from 7471 ms to 4272 ms, with GPU occupancy going from 29% to 45%; buffer-dirty refreshes dropped to zero. Writes that actually happened still invalidate, from GuestBufferCache's fill and DMA-copy paths, which is what the image ownership tests cover. The declared-write case needs a reconciliation after the submission, once the GPU-modified ranges are known; that does not exist yet, so SHARPEMU_SPECULATIVE_IMAGE_INVALIDATION=1 restores the old behaviour meanwhile. Note: these two files also carried uncommitted work from earlier in this worktree. * shader: name the instruction behind an undefined descriptor dword A descriptor dword that resolves to an undefined value fails the resource plan with nothing to act on. Undefined nodes are interned per type, so one shared node stands for every instruction the builder could not model, and the builder has about twenty places that produce one: the message names a category where it should name an opcode. Record the instruction the builder is translating, and under SHARPEMU_RESOURCE_TRACKER_DIAG intern undefined nodes per instruction as well. Revisiting a block reaches the same instruction, so values stay as stable across visits as before, and the rejection can walk the failing dword back to the undefined leaves it rests on and name them. Without the diagnostic the interning and the message are unchanged. A first attempt recorded every undefined created while an instruction was in flight, which listed 1410 instructions for one shader because many are created legitimately, for SCC among others. Only the leaves the failing value actually depends on are reported. Test counts are unchanged at 614 passed and 95 pre-existing failures. * shader: carry over the Gen5 translation work from the port Pre-existing work in this worktree, recorded here rather than authored in this session: VOP3B carry/borrow paths for VSubCoCiU32 and VSubrevCoCiU32, the high opcode bit for MIMG and the ray-tracing names it selects, DS/GDS flags, the wave32 EXEC mask, and the scalar value graph and runtime evaluator changes that go with them, across the Vulkan and Metal back ends. * gpu: carry over the AGC and VideoOut work from the port Pre-existing work in this worktree, recorded here rather than authored in this session: tracked image caching in place of unused snapshots, depth/stencil handling, snapshot reuse for feedback loops, read-only depth, the Vulkan feedback target resolver, the GuestDepth and draw recording paths, GPU barriers and image cache tracking, the storage resolver, BDA and GPU memory tracking in VideoOut, and the submission and indirect dispatch corrections. * hle: carry over the library export work from the port Pre-existing work in this worktree, recorded here rather than authored in this session: an HTTP connect timeout, an offline MAC, NP signalling context, the asynchronous NP stubs and entitlement key, NP WebAPI context deletion, a CFW stub, and the added aliases for sceImeKeyboardGetInfo, unlink, sched_yield, scePadResetOrientation, scePadIsRemoteController and the solo scheduling attributes, along with the Ampr file registry and kernel compatibility exports. * cpu: carry over the guest execution and memory work from the port Pre-existing work in this worktree, recorded here rather than authored in this session: the guest stack size the pthread attribute asks for and its cap, the PthreadYield fallback, the multi-thread stall watchdog, import and handoff instrumentation, the host view and guest space owner changes, and the physical virtual memory and runtime adjustments that go with them. * base: verified pre-rebase gameplay baseline * fix: materialize indirect shader image resources * gpu,shader: Yotei bring-up changes (GPT session, unreviewed) Work from a separate GPT session aimed at Ghost of Yotei, committed as-is so it can be reviewed block by block. It clearly improves Demon's Souls rendering (the scene becomes visible), but review found problems to fix before a PR: - S_BITREPLICATE_B64_B32 copies the dword into both halves; the ISA doubles each bit (bit i -> bits 2i, 2i+1). Wrong in the translator and the graph. - FindMatchingMipLevel no longer matches 2D array views of 3D volumes; the test expectation was flipped from 0 to -1 instead of keeping the case. - VectorCompareResourceTests lost five passing tests, replaced by compile-only smoke tests. - 11_11_10 UNorm is mapped to a packed-float Vulkan format. - Unknown BufferLoadFormat descriptors now read zeros / null images silently. - Undefined scalar values are interned per instruction for every title. Also adds 16-bit integer VOPC compares, V_RSQ_F16, IMAGE_GATHER4_C_LZ, IMAGE_SAMPLE_A decode, Yotei base entitlements, richer image-creation failure messages and flip-source diagnostics. * diagnostics(renderdoc): capture automatically after N guest flips SHARPEMU_RENDERDOC_CAPTURE_AFTER_FLIPS=N requests one capture once N guest flip boundaries have been seen, so an unattended run can capture without the F12 key. The count is kept here because the flip image version is per display buffer, not a global frame index. * fix(import-guard): recognize polling progress boundaries (#895) * port shader GDS byte reads and float atomics * port kernel debug exception exports * Revert "port kernel debug exception exports" This reverts commit 71d605e82c82a4a921862bfc865a104305cf746d. * Revert "port shader GDS byte reads and float atomics" This reverts commit 8f4e7664d7fc81a28ba5c47c86cbc03dba61d47b. * [usbd] implement event timeout * [usbd] add initialization stubs * [share] add feature and overlay exports * [kernel] implement pthread cond signalto * [font] add renderer and kerning exports * [agc] implement ACB rewind exports * [share] add running status export * [stubs] add game service exports * [kernel] implement high resolution timer events * [gpu] support polygon primitive rendering * chore: bump version to 0.0.4 * [kernel] implement debug exception exports * [vulkan] adopt synchronization2 submission and barriers * docs: update upstream README * Revert "[vulkan] adopt synchronization2 submission and barriers" This reverts commit 7e44f90d624c05ccac3ba8d14f94bfa63c225b1d. * Reapply "[vulkan] adopt synchronization2 submission and barriers" This reverts commit 66b456dd2a4562b081970404034ab3cc3135f66a. * Revert "[kernel] implement high resolution timer events" This reverts commit faf105d9a0f93120f9954ff30890701683d79a67. * Reapply "[kernel] implement high resolution timer events" This reverts commit 9858506307cfdc43e875180f21727bea936ac888. * Revert "[kernel] implement pthread cond signalto" This reverts commit 83ad3b0ac313b01c7d1493e685423a5a419b8766. * Reapply "[kernel] implement pthread cond signalto" This reverts commit 4bc31dabf7885687dc3777aefede6b4122f936a4. * [ampr] fall back to executable-local index cache * [ampr] probe cache directory writability * [media] keep host movies progressing when Vulkan bindings lag * [media] release stalled host movies independently of rendering * [vulkan] reuse pipeline caches across emulator builds * [media] propagate stalled movie EOF to the guest * [ampr] return guest path ids from resolve exports * Revert "[vulkan] reuse pipeline caches across emulator builds" This reverts commit f0f8e21382b402b510645acb66cf122ad765cd4a. * [vulkan] reuse pipeline caches across emulator builds * Revert "[vulkan] reuse pipeline caches across emulator builds" This reverts commit fd667b6320b74be1e5d71b37a3c29fc11af713bb. * fix(media): honor full Bink movie duration * fix(vulkan): schedule wave64 barriers uniformly * fix(vulkan): emit wave64 loop merge before branch * fix(vulkan): predicate wave64 image stores * Revert "fix(vulkan): predicate wave64 image stores" This reverts commit 5842298743822d1b128e8b4dcb3f9d0a8fd03c35. * Revert "fix(vulkan): emit wave64 loop merge before branch" This reverts commit d2b98e0a8439e41c8faf04335320f202740fe7f9. * Revert "fix(vulkan): schedule wave64 barriers uniformly" This reverts commit 976d5a5957080de6568417c9d12235570b1503ef. * baseline: preserve good Demon's Souls rendering * Revert "[vulkan] adopt synchronization2 submission and barriers" This reverts commit 7e44f90d624c05ccac3ba8d14f94bfa63c225b1d. * fix(ampr): keep APR resolve ids unique on path-hash collisions The APR resolve exports now return the 31-bit FNV hash of the guest path (upstream 5c347d2c). Demon's Souls ships 13 same-hash file pairs under /app0/ alone, and it resolves 32 colliding paths before the main menu. Colliding files shared one id and read each other's bytes, and the first level load after the menu never finished. Hand out the hash only while it names one host file, keep it in an authoritative table that later alias publishes cannot poison, and fall back to a collision-safe Register() handle otherwise. Log both the fallback and reads of unknown ids, which previously failed silently. * fix(media): size the Bink watchdog to the movie, restore Gen4 export c77746bd reverted the duration-based Bink watchdog to a fixed 90 s, and MediaFramePlayback also capped every movie (AvPlayer too) at 120 s. Demon's Souls ships a 171 s intro, 117-138 s attract movies and 756 s credits, which both bounds cut short. Arm the watchdog for the movie length plus 60 s (minimum 90 s, no upper bound) and drop the fixed playback cap, as in the pre-rebase baseline. Also keep cfwBSQyr5Ys exported for Gen4 as the baseline did. * chore: trim added comments, keep upstream's cfwBSQyr5Ys target Restore upstream's APR ABI comment verbatim, keep each added comment to one line, and leave sceKernelDebugWriteCppExceptionInfo Gen5-only as upstream declares it. * fix(vulkan): query image levels only when the result is read ImageGetResinfo emitted OpImageQueryLevels for every sampled image even when the dmask never reads component 3. On a 3D image, the unused query crashes NVIDIA's pipeline compiler inside vkCreateComputePipelines: Demon's Souls compute shader 0xC553F37E46481A6B (dmask 3) takes the process down right after the first frame. * fix(shader): reconcile branch resource planning with upstream contracts - Keep upstream's 42-binding image ABI. A float min/max image atomic binds as the uint atomic image its compare-exchange loop already uses. - A null-page read turns into zero only after a null base pointer fails to read, not for every address below 0x10000. - An empty V# reads as zero; overrunning a bound buffer stays a failure. - A wave-indexed image table reads its mask through the clean reader, like every other slot its descriptor depends on. - A buffer load through a lane-varying address stays undefined, and a constant exec_lo write keeps s_cbranch_execz decidable. - An image descriptor from a scalar buffer must be one contiguous record, and a vector access through a draw-evaluable V# keeps its explicit binding instead of the runtime-descriptor path. * fix(shader): drop the null-page read rule, encode GDS at bit 17 in tests KytyPS5 does not turn a null SRT pointer read into zero: an unmapped read fails materialization, which FailedResourceReadsRemainFatal pins. Remove the rule. GFX10 DS encodes GDS at bit 17 with OP at 25:18; bit 16 is GFX9's position (LLVM DS_Real_gfx10). The decoder already reads bit 17, so the DataShare tests set GDS there, and the LDS wave-counter tests clear it. * fix(vulkan): keep bind-time image invalidation coherent with GPU contents Binding a formatted writable buffer over an image marks the image buffer-modified, so its next use refreshes it from the buffer. The buffer was only synchronized from the image for read-only views, so over a render target a writable view refreshed the image from stale guest bytes. That is the Demon's Souls banding 48cd723a worked around by skipping the invalidation altogether. Synchronize writable formatted views from the image as well, and at bind time invalidate every covered image except one whose only current copy is still on the GPU and could not be copied into the buffer. Drop the SHARPEMU_SPECULATIVE_IMAGE_INVALIDATION switch. * test(shader): expect the header immediate in the indirect selector offset The materializer reads the selector word at selector * stride + SelectorOffset, so the offset must include the material read's immediate. With the header immediate 4 and the +4 in the address arithmetic it is 8; the test expected 4 since it was written. * chore: remove session diagnostics Drop the ad-hoc instrumentation added while bringing Demon's Souls and Ghost of Yotei up: - SHARPEMU_RESOURCE_TRACKER_DIAG traces in the resource tracker, dense image tables, runtime evaluator and validator - unconditional materialize_snapshot_probe warnings and the SHARPEMU_TRACE_INDIRECT_IMAGES wave-table trace - the guest fault census (SHARPEMU_FAULT_CENSUS / SHARPEMU_FAULT_WATCH) - red-zone patcher address trace, unprotected-site file and short-jump estimate (SHARPEMU_REDZONE_TRACE / SHARPEMU_REDZONE_SITES_FILE) - RenderDoc capture after N flips and the flip-source refresh traces The red-zone coverage counters and the readable access-violation report stay. * fix(kernel): sparse virtual reservations and HLE compatibility fixes - sceKernelReserveVirtualRange: keep very large non-fixed reservations in the guest mapping table without one contiguous host placeholder. - Event flags: read names and write handles through the kernel compatibility helpers so read-only image pages work. - sceAjmInitialize: only validate the 32-bit reserved argument. - Pthread: only free opaque objects the compatibility layer allocated. - Route libc memset to the HLE implementation (int 45h path in LLE). * fix(gpu): tiling, color targets, VideoOut pipelines and primitive stage masks - Tile geometry and tiler shader fixes for detiling. - Color target request fixes for 3D slice ranges. - VideoOut descriptor, draw recording and pipeline fixes. - Accept the compact primitive-shader stage masks (0x2000, 0x2030) and report the rejected state when draws are skipped. * fix(shader): Little Nightmares III decoding and translation - Decode S_SCRATCH_STORE_DWORDX2 (SMRD op 0x16) as a scalar scratch store instead of a load. - Raise the decoder instruction limit to the maximum shader size. - Stop linear decoding at S_SETPC_B64 and treat the tail as unreachable; find fused GS pairs across the full 1 MiB object and resolve continuation image descriptors from its entry SGPRs. - Translate S_SETPC_B64 in linearised fused programs as a marker. - Implement S_MUL_HI_I32 and V_DOT2C_F32_F16. * fix(shader): Astro Bot debugger stubs and wave32 primitive pipelines - S_CBRANCH_CDBG* only fire under an attached GPU debugger; do not let them extend the decoded program past S_ENDPGM into debugger stubs that load descriptors from devkit-only addresses. - Stop decoding after an unconditional backward S_BRANCH with no pending forward target instead of reading the "sl00" metadata trailer as instructions. - Ignore the GS_W32_EN/VS_W32_EN wave-size bits when matching primitive-shader stage masks; graphics stages always compile as wave32, so these draws were skipped for no reason. * fix(kernel): keep sparse reservations out of the macOS graphics window macOS reserves 0x10_0000_0000..0x70_0000_0000 for graphics memory, so a sparse reservation starting at 0x40_0000_0000 could not be mapped there (LargeVirtualReservationFallsBackToSparseGuestRange failed on osx-x64). Start the sparse aperture at 0x70_0000_0000 on macOS. * fix(shader): declare guest 1D textures as 1D images Since #903 the image request builder binds guest 1D textures through 1D Vulkan views, but the SPIR-V translator still declared them as 2D. Sampling a 1D view through a 2D image type is invalid, and on NVIDIA Astro Bot's final composite pass wrote black to the flip buffer. Declare Dim1D/Dim1DArray as SPIR-V 1D images with Sampled1D/Image1D, matching upstream. * revert(gpu): fail on written device-address ranges that cannot be planned The Little Nightmares III work skipped unplannable written ranges on the host and allowed those stores unconditionally in the shader. Stores whose pages were not already in the BDA page table were then dropped silently. Restore upstream's fatal and remove UnplannableWrittenMemoryIndices, so each such shader is reported and fixed on its own. * fix(shader): route FLAT accesses through the LDS and scratch apertures GFX10 sends a FLAT access to LDS or scratch when bits 63:48 of its address match the shared or private aperture (SH_MEM_BASES), and to global memory otherwise. src_shared_base/src_private_base decoded to 0, so an LDS pointer became a device address the host could not plan (Little Nightmares traversal stack, shader 0x9669394374AAE5BC). - Give the apertures distinct high dwords no guest address can reach, consistent for 32-bit (high dword) and 64-bit (address) reads. - Keep aperture identity in the scalar value graph (MemoryAperture). - The device-address planner classifies a FLAT access whose high dword derives from an aperture and gives it no device range; plain device pointers keep the range and the fatal. - The SPIR-V backend routes each lane by bits 63:48 to LDS, scratch or the page-table global path. * fix(shader): use the PS5 apertures and prove clamped FLAT addresses local Little Nightmares builds its BVH traversal-stack addresses without src_shared_base: v_med3_u32(0x80000000, 0x70000000, sp) clamps the high dword, and the pop path reads the 0x7xxxxxxx entries with ds_read and the 0x8xxxxxxx ones with scratch_load. These are the PS5 shared and private apertures, so: - SharedApertureHigh = 0x70000000, PrivateApertureHigh = 0x80000000; the backend routes a FLAT lane by the top nibble of its high dword. - Model v_med3_u32/v_med3_i32 in the scalar value graph. - The planner bounds the high dword (Phi/Select/UMin/UMax, cycles ignored since each yields one of its inputs) and classifies it local when the bounds stay inside the apertures. - Record the EXEC mask of each global/flat access: a select on that same mask is a lane-masked register write whose inactive arm never reaches a lane that accesses memory. Little Nightmares shaders 0x9669..., 0xE9F1... and 0x392B... now plan without the removed unplanned-write skip. * feat(shader): emulate the GFX10 BVH node test for image_bvh_intersect_ray Follows AMD GPURT's RT IP 1.1 fallback: triangle nodes 0/1 with barycentric swizzle, FP16/FP32 box slab test with grow, NaN rule and sorting network. Nodes are read through the device-address page table. * fix(gpu): move a depth surface sampled as color into a color image A texture whose format reads neither the depth nor the stencil plane reinterprets the memory; Little Nightmares samples a D32S8 surface as BGRA8. * fix(shader): make s_barrier order buffer and image memory within the workgroup Guest code publishes stores to its workgroup with s_waitcnt vmcnt(0) + s_barrier; the host barrier only ordered shared memory. * fix(gpu): match sampler border color and filtering to the sampled view Border colors were always integer, which Vulkan leaves undefined on float views (an opaque-white border can read back as black). The numeric class now comes from the bound view formats; integer views also drop filtering. * fix(shader): decode the VOP1 and VOP3 half-precision transcendentals Silent Hill's pixel shaders use v_log_f16; add rcp, sqrt, log, exp, floor, ceil, trunc, rndne, fract, sin and cos for f16 in both encodings. * fix(shader): decode v_min3_f16, v_max3_f16 and v_med3_f16 Silent Hill's pixel shaders use v_min3_f16; the draw was skipped. * fix(agc): give a fused shader the larger user SGPR count of its halves sceAgcFuseShaderHalves kept the back half's SPI_SHADER_PGM_RSRC2_GS, so a front half reading its vertex tables from user SGPRs 26-29 saw only 12 (Silent Hill: front 30, back 12) and the draw faulted on null tables. A vertex program without input semantics no longer needs the tables, and the null-table and PRT-mapping messages name the registers involved. * fix(shader): read buffers past the binding budget through their device descriptors A Silent Hill compute shader binds more than 32 distinct buffers. Past the budget an untyped access reads its V# from its SGPRs at run time, as the hardware does, instead of failing the whole shader. * fix(gpu): treat a compute constant fill of DCC metadata as a fast clear Unreal fast-clears DCC render targets with a compute shader that stores one dword from a constant buffer into every metadata record (0x40404040 clears to 0,0,0,1). The fill only reached plain memory, so Silent Hill's translucency layer stayed 0 and its composite multiplied the scene by 0. The exact instruction sequence is recognised and routed to the existing DCC clear tracking. * fix(gpu): invalidate every image under a bound writable formatted buffer The bound-write path skipped images whose only current copy was on the GPU. Such an image kept stale contents after the guest rewrote it through the buffer: Dead Cells lost its background and Demon's Souls showed wrong surfaces. Bisected to ed3de27b; upstream invalidates unconditionally, and 52fbe275 already synchronizes writable views from the image, which fixed the banding the exception was meant to hide. --------- Co-authored-by: ParantezTech <12572227+par274@users.noreply.github.com> Co-authored-by: Mathias <06mathias6@gmail.com> --- .../DirectExecutionBackend.Exceptions.cs | 57 +- .../Cpu/Native/DirectExecutionBackend.cs | 92 +- .../Loader/GuestRedZonePatcher.cs | 370 +++++++- src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs | 31 + .../GpuMemory/GuestPageTracker.cs | 25 +- src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs | 7 +- src/SharpEmu.HLE/GuestThreadExecution.cs | 3 +- .../Agc/AgcExports.ShaderState.cs | 166 ++-- src/SharpEmu.Libs/Agc/AgcExports.cs | 1 + src/SharpEmu.Libs/Ampr/AmprExports.cs | 9 + src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs | 65 +- .../Gpu/Buffers/GuestBufferCache.cs | 15 +- .../GpuCommandInterpreter.Events.cs | 17 +- src/SharpEmu.Libs/Gpu/Images/CachedImage.cs | 37 +- .../Gpu/Images/GuestImageCache.Overlap.cs | 91 +- .../Gpu/Images/GuestImageCache.Transfers.cs | 1 + .../Gpu/Images/GuestPixelFormats.cs | 10 +- .../Gpu/Images/ImageDescription.cs | 112 +-- .../ImageRequestBuilders.ColorTarget.cs | 11 + .../Images/ImageRequestBuilders.Texture.cs | 3 +- src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs | 52 +- .../Gpu/Images/TargetRegisterWords.cs | 4 +- .../Gpu/Images/TileGeometry.Blocks.cs | 92 +- .../Gpu/Images/ViewFormatRules.cs | 13 + .../Metal/MetalCommandStreamHost.Rendering.cs | 6 + .../Gpu/Pipelines/ConstantFillDetector.cs | 68 ++ .../Gpu/Pipelines/IShaderPipelineHost.cs | 8 + .../Gpu/Pipelines/PipelineCacheSignature.cs | 35 +- .../Gpu/Pipelines/ShaderPermutationDump.cs | 6 +- .../Gpu/Pipelines/ShaderPipelineCache.cs | 11 +- .../Gpu/Pipelines/ShaderPlanningDump.cs | 2 +- .../Gpu/Pipelines/ShaderProgramCache.cs | 50 +- .../Gpu/Pipelines/VertexInputResolver.cs | 8 +- .../Gpu/Rendering/RenderExecutor.Compute.cs | 66 +- .../Gpu/Rendering/RenderExecutor.cs | 38 +- .../Gpu/Rendering/ShaderStageResources.cs | 1 + src/SharpEmu.Libs/Ime/ImeExports.cs | 18 + .../Kernel/KernelEventFlagCompatExports.cs | 15 +- src/SharpEmu.Libs/Kernel/KernelExports.cs | 64 +- ...ernelMemoryCompatExports.BackedMappings.cs | 113 ++- .../Kernel/KernelMemoryCompatExports.cs | 17 + .../Kernel/KernelPthreadCompatExports.cs | 24 +- .../KernelPthreadExtendedCompatExports.cs | 65 +- .../Kernel/KernelSemaphoreCompatExports.cs | 3 +- src/SharpEmu.Libs/Media/HostMovieBridge.cs | 75 ++ src/SharpEmu.Libs/Network/HttpExports.cs | 31 +- src/SharpEmu.Libs/Network/NetExports.cs | 19 + .../Np/NpEntitlementAccessExports.cs | 43 +- src/SharpEmu.Libs/Np/NpManagerExports.cs | 43 + .../Np/NpSessionSignalingExports.cs | 14 + src/SharpEmu.Libs/Np/NpWebApi2Exports.cs | 7 + src/SharpEmu.Libs/Pad/PadExports.cs | 26 + .../VideoOut/RenderPhaseProfile.cs | 1 + .../VulkanStorageFeedbackTargetResolver.cs | 53 ++ .../VulkanVideoPresenter.CachedImages.cs | 225 ++++- .../VulkanVideoPresenter.Descriptors.cs | 49 +- .../VulkanVideoPresenter.Device.Setup.cs | 42 +- .../VulkanVideoPresenter.Device.Teardown.cs | 1 + .../VulkanVideoPresenter.Draws.Recording.cs | 13 + .../VulkanVideoPresenter.HostMovie.cs | 8 +- .../VulkanVideoPresenter.Pipelines.cs | 29 + .../VulkanVideoPresenter.Rendering.cs | 54 +- .../VideoOut/VulkanVideoPresenter.cs | 13 + .../Gen5MslTranslator.Alu.cs | 63 +- .../Gen5SpirvTranslator.Alu.cs | 842 +++++++++++++++++- .../Gen5SpirvTranslator.RayIntersect.cs | 324 +++++++ .../Gen5SpirvTranslator.Resources.cs | 332 ++++++- .../Gen5SpirvTranslator.cs | 571 ++++++++++-- .../SpirvModuleBuilder.cs | 3 + .../TilerShaders.cs | 57 +- .../Gen5InlineConstants.cs | 44 + src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs | 10 +- .../Gen5ShaderTranslator.cs | 262 +++++- .../Resources/BufferCandidateTablePlan.cs | 124 +++ .../Resources/BufferCandidateTablePlanner.cs | 370 ++++++++ .../Resources/DeviceAddressRangePlanner.cs | 137 +++ .../Resources/GuestBufferDescriptor.cs | 56 ++ .../Resources/GuestImageFormat.cs | 4 +- .../Resources/MemoryAccessInfo.cs | 31 +- .../Resources/ResourceMaterializationCache.cs | 256 ++++++ .../Resources/ResourceMaterializer.cs | 319 ++++++- .../Resources/ResourceSpecialization.cs | 30 +- .../Resources/ResourceTracker.DenseImages.cs | 255 +++++- .../Resources/ResourceTracker.cs | 540 ++++++++++- .../Resources/RuntimeValueEvaluator.cs | 12 +- .../Resources/RuntimeValueValidator.cs | 39 +- .../Resources/ScalarValue.cs | 8 +- .../Resources/ScalarValueGraph.Builder.cs | 194 +++- .../Resources/ScalarValueGraph.cs | 108 ++- .../Resources/ShaderResourceInfo.cs | 22 + .../Resources/ShaderResourcePlan.cs | 33 +- .../ShaderCompileRequest.cs | 34 +- .../Agc/Gen5ShaderDecoderBoundaryTests.cs | 41 +- .../Ampr/AmprFileRegistryTests.cs | 43 + .../Fiber/FiberExportsTests.cs | 1 + .../Fiber/FiberStateCollection.cs | 18 + .../Fiber/FiberSwitchLoopTests.cs | 313 +++++++ .../GpuCommandInterpreterLabelTests.cs | 5 +- .../Gpu/Images/GuestPixelFormatsTests.cs | 4 + .../Gpu/Images/ImageDescriptionTests.cs | 17 +- .../Gpu/Images/ImageRequestBuildersTests.cs | 19 + .../Gpu/Images/SamplerStoreTests.cs | 20 +- .../Gpu/Pipelines/PipelineTestSupport.cs | 2 + .../Gpu/Rendering/RenderExecutorDrawTests.cs | 25 +- .../Kernel/KernelBackedMemoryTests.cs | 16 + .../Kernel/KernelOperationModeTests.cs | 58 ++ .../Loader/GuestRedZoneEnclosingSpanTests.cs | 130 +++ .../Media/HostMovieBridgeTests.cs | 13 + .../Memory/GpuMemory/GuestPageTrackerTests.cs | 35 + .../Memory/GuestMemory/FailingHostViews.cs | 4 + .../Np/NpEntitlementAccessExportsTests.cs | 58 ++ .../Gen5ShaderTranslatorTests.cs | 100 +++ ...ulkanStorageFeedbackTargetResolverTests.cs | 81 ++ .../Gen5Float16ArithmeticTests.cs | 49 + .../Gen5ImageTests.cs | 9 + .../Gen5Int16AluTests.cs | 97 ++ .../Gen5VopcF16Tests.cs | 48 + .../BufferCandidateTablePlannerTests.cs | 221 +++++ .../BufferCandidateTableSpirvTests.cs | 91 ++ .../Resources/DemonsSoulsProbeTests.cs | 64 ++ .../DeviceAddressRangePlannerTests.cs | 81 ++ .../Resources/DirectImageTableTests.cs | 65 ++ .../ResourceMaterializationCacheTests.cs | 175 ++++ .../Resources/ResourceMaterializerTests.cs | 20 + .../Resources/ResourceTrackerTests.cs | 46 +- .../Resources/ScalarValueGraphTests.cs | 31 + .../Resources/VectorCompareResourceTests.cs | 61 +- 127 files changed, 9145 insertions(+), 644 deletions(-) create mode 100644 src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs create mode 100644 src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs create mode 100644 src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs create mode 100644 src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs create mode 100644 src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs create mode 100644 src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs create mode 100644 src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs create mode 100644 src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs create mode 100644 tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs create mode 100644 tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs index 17ebcfd..c590886 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs @@ -365,7 +365,13 @@ public sealed partial class DirectExecutionBackend Console.Error.WriteLine("[LOADER][ERROR] - Guest code accessed unmapped memory"); Console.Error.WriteLine("[LOADER][ERROR] - Need to implement HLE for this NID"); byte[] code = new byte[16]; - if (TryReadHostBytes(rip, code)) + DumpHostRegion("RIP", rip); + // Guest code can be mapped execute-only, which makes every + // host-side read of it fail. The guest memory view still + // resolves those pages, so prefer it here: otherwise the + // whole call-site report below is silently skipped on + // exactly the faults that need it. + if (TryReadGuestOrHostBytes(rip, code)) { Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(code).Replace("-", " ")); if (code[0] == 100) @@ -383,12 +389,12 @@ public sealed partial class DirectExecutionBackend Console.Error.WriteLine($"[LOADER][INFO] RSP: 0x{rsp:X16} (mod 16 = {rsp % 16})"); } byte[] before = new byte[16]; - if (rip > 16 && TryReadHostBytes(rip - 16, before)) + if (rip > 16 && TryReadGuestOrHostBytes(rip - 16, before)) { Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + BitConverter.ToString(before).Replace("-", " ")); } byte[] window = new byte[64]; - if (rip > 32 && TryReadHostBytes(rip - 32, window)) + if (rip > 32 && TryReadGuestOrHostBytes(rip - 32, window)) { Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + BitConverter.ToString(window).Replace("-", " ")); } @@ -405,7 +411,7 @@ public sealed partial class DirectExecutionBackend continue; } byte[] callSiteWindow = new byte[48]; - if (TryReadHostBytes(candidate - 24, callSiteWindow)) + if (TryReadGuestOrHostBytes(candidate - 24, callSiteWindow)) { Console.Error.WriteLine( $"[LOADER][INFO] Stack guest-code candidate [rsp+0x{stackIndex * 8:X2}]=0x{candidate:X16}, bytes [-0x18..]: " + @@ -417,6 +423,11 @@ public sealed partial class DirectExecutionBackend { Console.Error.WriteLine("[LOADER][ERROR] Could not read code at RIP"); } + // The faulting instruction is the first thing an access + // violation report has to answer, so decode it through guest + // memory as well: that path still works when the host code + // page refuses a plain read. + DumpGuestInstructionStream("fault", rip, 4); DumpRecentImportTrace(); DumpGuestDisasmDiagnostics(rip, rbp, rsp); DumpGuestRegisterWindowDiagnostics( @@ -1463,6 +1474,44 @@ public sealed partial class DirectExecutionBackend return true; } + /// + /// Reads guest bytes for a crash report, preferring the guest memory view + /// over a raw host read. Guest code pages are commonly committed + /// PAGE_EXECUTE, which no host read can satisfy. + /// + private bool TryReadGuestOrHostBytes(ulong address, byte[] buffer) + { + if (_cpuContext is { } context) + { + try + { + if (context.Memory.TryRead(address, buffer)) + { + return true; + } + } + catch + { + // Fall through to the host reader. + } + } + + return TryReadHostBytes(address, buffer); + } + + private unsafe static void DumpHostRegion(string name, ulong address) + { + if (VirtualQuery((void*)address, out var mbi, (nuint)sizeof(MEMORY_BASIC_INFORMATION64)) == 0) + { + Console.Error.WriteLine($"[LOADER][INFO] {name} region: address=0x{address:X16}"); + return; + } + + Console.Error.WriteLine( + $"[LOADER][INFO] {name} region: base=0x{mbi.BaseAddress:X16} size=0x{mbi.RegionSize:X16} " + + $"state=0x{mbi.State:X08} protect=0x{mbi.Protect:X08} type=0x{mbi.Type:X08}"); + } + private unsafe static bool IsReadableHostRange(ulong address, int byteCount, bool allowExecuteOnly = false) { if (byteCount < 0 || !IsCanonicalUserAddress(address)) diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index b707afc..3af1142 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -151,6 +151,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private static readonly ulong GuestThreadTlsBaseAddress = OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL; private const ulong GuestThreadStackSize = 0x0020_0000UL; + private const ulong GuestThreadStackHeadroom = 0x0010_0000UL; + private const ulong GuestThreadStackSizeLimit = 0x0400_0000UL; private const ulong GuestThreadTlsSize = 0x0001_0000UL; @@ -285,7 +287,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private readonly List _importHandlerTrampolines = new List(); - private const int GuestContextTransferFrameQwords = 20; + private const int GuestContextTransferFrameQwords = 22; private readonly object _guestContextTransferStubGate = new(); @@ -1833,7 +1835,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private static bool IsHlePreferredNid(string nid) { - return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) || + // The shipped libc memset body contains a PS5-specific int 45h path + // which is not a Windows host instruction. Use the bounds-aware HLE + // implementation instead of redirecting this import to LLE. + return string.Equals(nid, "8zTFvBIAIN8", StringComparison.Ordinal) || + string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) || string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal); } @@ -2140,6 +2146,13 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I error = $"guest context transfer slot is not writable at 0x{target.Rsp - sizeof(ulong):X16}"; return false; } + ulong stackBottom = 0; + ulong stackTop = 0; + if (OperatingSystem.IsWindows() && !TryGetGuestStackBounds(target.Rsp, out stackBottom, out stackTop)) + { + error = $"guest context transfer target rsp=0x{target.Rsp:X16} has no committed host stack mapping"; + return false; + } transferStub = GetOrCreateGuestContextTransferStub(); if (transferStub == 0) @@ -2176,6 +2189,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I frame[17] = target.Mxcsr == 0 ? 0x1F80u : target.Mxcsr; frame[18] = target.FpuControlWord == 0 ? 0x037Fu : target.FpuControlWord; frame[19] = target.RestoreFullFpuState ? 1u : 0u; + // The TEB stores NT_TIB.StackBase (the high address) at +0x08 and + // NT_TIB.StackLimit (the low address) at +0x10. Keep the frame in that + // same order so the transfer stub can store the slots straight through. + frame[20] = stackTop; + frame[21] = stackBottom; return true; } @@ -2243,6 +2261,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I } Emit(0x49); Emit(0x89); Emit(0xC3); // mov r11, rax + // Fiber switches replace the guest stack without passing through a + // guest-entry stub, so keep the Windows TEB bounds in sync here. + EmitLoadFromR11Disp32(10, 160); // r10 = target stack top + EmitStackBound(code, ref offset, 10, 8, store: true); + EmitLoadFromR11Disp32(10, 168); // r10 = target stack bottom + EmitStackBound(code, ref offset, 10, 16, store: true); // A new >=3.50 fiber receives the SDK-defined MXCSR verbatim. A // resumed fiber follows _sceFiberLongJmp: preserve status bits 0-5 // while restoring the saved control bits. @@ -5342,7 +5366,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I error = "creator context memory is not backed by IVirtualMemory"; return false; } - if (!TryMapGuestThreadRegion(virtualMemory, GuestThreadStackBaseAddress, GuestThreadStackSize, ProgramHeaderFlags.Read | ProgramHeaderFlags.Write, out var stackBase, out error)) + var requestedStackSize = request.StackSize; + if (requestedStackSize > GuestThreadStackSizeLimit) + { + requestedStackSize = GuestThreadStackSizeLimit; + } + + var stackSize = requestedStackSize == 0 + ? GuestThreadStackSize + : AlignUp(requestedStackSize + GuestThreadStackHeadroom, 0x1000UL); + if (stackSize < GuestThreadStackSize) + { + stackSize = GuestThreadStackSize; + } + + if (!TryMapGuestThreadRegion(virtualMemory, GuestThreadStackBaseAddress, stackSize, ProgramHeaderFlags.Read | ProgramHeaderFlags.Write, out var stackBase, out error)) { return false; } @@ -5359,7 +5397,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I FsBase = tlsBase, GsBase = tlsBase, }; - context[CpuRegister.Rsp] = stackBase + GuestThreadStackSize - sizeof(ulong); + context[CpuRegister.Rsp] = stackBase + stackSize - sizeof(ulong); context[CpuRegister.Rdi] = request.Argument; context[CpuRegister.Rsi] = 0; context[CpuRegister.Rdx] = 0; @@ -5382,7 +5420,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I AffinityMask = request.AffinityMask, Context = context, StackBase = stackBase, - StackSize = GuestThreadStackSize, + StackSize = stackSize, State = GuestThreadRunState.Ready, }; error = null; @@ -6774,6 +6812,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I MarkExecutionProgress(); continue; } + if (AreAllLiveGuestThreadsExpectedlyBlocked()) + { + Console.Error.WriteLine( + $"[LOADER][WARN] No import progress for {stallWatchdogSeconds}s, but every live guest thread is parked in an expected blocking primitive; continuing."); + LogStallWatchdogSnapshot(); + Console.Error.Flush(); + MarkExecutionProgress(); + continue; + } if (Interlocked.Exchange(ref _stallWatchdogTriggered, 1) != 0) { continue; @@ -6841,6 +6888,41 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I return false; } + private bool AreAllLiveGuestThreadsExpectedlyBlocked() + { + using (LockGate("AreAllLiveGuestThreadsExpectedlyBlocked")) + { + var sawBlockedThread = false; + foreach (var thread in _guestThreads.Values) + { + if (thread.State is GuestThreadRunState.Exited or GuestThreadRunState.Faulted) + { + continue; + } + + if (thread.State != GuestThreadRunState.Blocked) + { + return false; + } + + if (Volatile.Read(ref thread.LastImportNid) is not ( + "Op8TBGY5KHg" or // pthread_cond_wait + "27bAgiJmOh0" or // pthread_cond_timedwait + "WKAXJ4XBPQ4" or // scePthreadCondWait + "BmMjYxmew1w" or // scePthreadCondTimedwait + "fzyMKs9kim0" or // sceKernelWaitEqueue + "Zxa0VhQVTsk")) // sceKernelWaitSema + { + return false; + } + + sawBlockedThread = true; + } + + return sawBlockedThread; + } + } + private void StopStallWatchdog() { _stallWatchdogStop = true; diff --git a/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs b/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs index e48c32e..d541b3c 100644 --- a/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs +++ b/src/SharpEmu.Core/Loader/GuestRedZonePatcher.cs @@ -20,6 +20,15 @@ internal static class GuestRedZonePatcher private const ulong AllocationAlignment = 0x10000; private const ulong MaximumRelativeJumpDistance = 0x7FFF_FFFF; + internal enum SpanRefusal + { + None, + ControlFlow, + BranchTargetAfter, + StackAfter, + TooShort, + } + public static PatchResult Patch( IVirtualMemory memory, PhysicalVirtualMemory physicalMemory, @@ -58,7 +67,8 @@ internal static class GuestRedZonePatcher if (sites.Count == 0) { Console.Error.WriteLine( - $"[LOADER] {hostName} red-zone scan: functions={scan.Functions} red_zone={scan.RedZoneFunctions} sites=0."); + $"[LOADER] {hostName} red-zone scan: functions={scan.Functions} red_zone={scan.RedZoneFunctions} " + + $"sites=0 unrelocatable={scan.UnrelocatableSites}."); return scan; } @@ -83,14 +93,28 @@ internal static class GuestRedZonePatcher var trampolineEnd = trampolineBase + requiredBytes; var patched = 0; var failed = 0; + var previousEnd = 0UL; foreach (var site in sites) { + // Sites are produced in increasing address order. Patching two that + // overlap would write one jump over another and send the trampoline + // into whatever follows, so refuse rather than corrupt guest code. + if (site.Address < previousEnd) + { + Console.Error.WriteLine( + $"[LOADER][WARN] {hostName} red-zone site 0x{site.Address:X16} overlaps the previous one " + + $"ending at 0x{previousEnd:X16}; left unpatched."); + failed++; + continue; + } + if (!TryPatchSite(memory, site, ref trampolineCursor, trampolineEnd, splitVectorStores, rewriteSha)) { failed++; continue; } + previousEnd = site.Address + (ulong)site.ByteLength; patched++; } @@ -99,11 +123,19 @@ internal static class GuestRedZonePatcher CandidateSites = sites.Count, PatchedSites = patched, FailedSites = failed, + UnrelocatableSites = scan.UnrelocatableSites, + ControlFlowRefusals = scan.ControlFlowRefusals, + BranchTargetRefusals = scan.BranchTargetRefusals, + StackRefusals = scan.StackRefusals, + TooShortRefusals = scan.TooShortRefusals, TrampolineBytes = trampolineCursor - trampolineBase, }; Console.Error.WriteLine( $"[LOADER] {hostName} red-zone patch: functions={result.Functions} red_zone={result.RedZoneFunctions} " + $"sites={result.PatchedSites}/{result.CandidateSites} failed={result.FailedSites} " + + $"unrelocatable={result.UnrelocatableSites} " + + $"(control_flow={result.ControlFlowRefusals} branch_target_after={result.BranchTargetRefusals} " + + $"stack_after={result.StackRefusals} too_short={result.TooShortRefusals}) " + $"rosetta_vector_stores={result.VectorStoreCount} sha_rewrites={result.ShaInstructionCount} " + $"trampolines=0x{trampolineBase:X16}+0x{result.TrampolineBytes:X}."); return result; @@ -124,6 +156,8 @@ internal static class GuestRedZonePatcher var redZoneFunctionCount = 0; var instructionCount = 0; var vectorStoreCount = 0; + var unrelocatableSites = 0; + var refusalCounts = new int[5]; var shaInstructionCount = 0; foreach (var header in programHeaders) @@ -174,6 +208,7 @@ internal static class GuestRedZonePatcher functionCount++; instructionCount += decoded.Count; var usesRedZone = protectRedZone && decoded.Any(static entry => UsesRedZone(entry.Instruction)); + if (!usesRedZone && !splitVectorStores && !rewriteSha) { continue; @@ -184,19 +219,43 @@ internal static class GuestRedZonePatcher redZoneFunctionCount++; } var branchTargets = CollectBranchTargets(decoded); + + var lastSiteEnd = 0UL; + for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++) { var instruction = decoded[instructionIndex].Instruction; var isShaSite = rewriteSha && ShaInstructionRewrite.CanRewrite(instruction); - if ((!isShaSite && - ((!usesRedZone && !(splitVectorStores && RosettaVectorStorePatch.RequiresStoreSplit(instruction))) || - !IsFaultableGuestMemoryInstruction(instruction))) || - !TryBuildPatchSpan(decoded, instructionIndex, branchTargets, out var span)) + if (!isShaSite && + ((!usesRedZone && !(splitVectorStores && RosettaVectorStorePatch.RequiresStoreSplit(instruction))) || + !IsFaultableGuestMemoryInstruction(instruction))) { continue; } + // A backward span reaches instructions that an earlier site + // may already have replaced with its own jump. Two jumps + // written over each other corrupt control flow, so a span is + // only usable when it starts at or after the end of the last + // one. Forward spans cannot overlap by construction; the + // check costs nothing and documents the invariant. + var backward = false; + if (!TryBuildPatchSpan(decoded, instructionIndex, branchTargets, out var span, out var refusal)) + { + backward = TryBuildEnclosingPatchSpan(decoded, instructionIndex, branchTargets, out span); + } + + if ((!backward && refusal != SpanRefusal.None) || span.Address < lastSiteEnd) + { + // Refused spans never enter the site list, so they are + // invisible in FailedSites. Count them separately. + unrelocatableSites++; + refusalCounts[(int)refusal]++; + continue; + } + sites.Add(span); + lastSiteEnd = span.Address + (ulong)span.ByteLength; if (splitVectorStores) { vectorStoreCount += span.Instructions.Count(static instruction => RosettaVectorStorePatch.RequiresStoreSplit(instruction)); @@ -205,7 +264,13 @@ internal static class GuestRedZonePatcher { shaInstructionCount += span.Instructions.Count(static instruction => ShaInstructionRewrite.CanRewrite(instruction)); } - instructionIndex += span.Instructions.Count - 1; + + // A backward span already ends at the current instruction, so + // only a forward one consumes the instructions that follow. + if (!backward) + { + instructionIndex += span.Instructions.Count - 1; + } } } } @@ -217,6 +282,11 @@ internal static class GuestRedZonePatcher Instructions = instructionCount, CandidateSites = sites.Count, VectorStoreCount = vectorStoreCount, + UnrelocatableSites = unrelocatableSites, + ControlFlowRefusals = refusalCounts[(int)SpanRefusal.ControlFlow], + BranchTargetRefusals = refusalCounts[(int)SpanRefusal.BranchTargetAfter], + StackRefusals = refusalCounts[(int)SpanRefusal.StackAfter], + TooShortRefusals = refusalCounts[(int)SpanRefusal.TooShort], ShaInstructionCount = shaInstructionCount, }; return sites; @@ -274,20 +344,199 @@ internal static class GuestRedZonePatcher return targets; } + /// + /// Runs the enclosing-span search over a raw instruction range, so the + /// behaviour can be pinned on the byte sequences that occur in real guest + /// code rather than on a mocked decode. + /// + internal static bool TryBuildEnclosingSpan( + byte[] code, + ulong baseAddress, + ulong faultAddress, + out ulong spanAddress, + out int spanLength, + out int coreStart, + out int coreCount) + { + spanAddress = 0; + spanLength = 0; + coreStart = 0; + coreCount = 0; + + var decoded = DecodeFunction(code, baseAddress, baseAddress, baseAddress + (ulong)code.Length); + var faultIndex = -1; + for (var index = 0; index < decoded.Count; index++) + { + if (decoded[index].Instruction.IP == faultAddress) + { + faultIndex = index; + break; + } + } + + if (faultIndex < 0) + { + return false; + } + + if (!TryBuildEnclosingPatchSpan(decoded, faultIndex, CollectBranchTargets(decoded), out var site)) + { + return false; + } + + spanAddress = site.Address; + spanLength = site.ByteLength; + coreStart = site.CoreStart; + coreCount = site.CoreCount; + return true; + } + + /// + /// Finds the faultable instructions of a span. The RSP shift has to bracket + /// all of them and nothing that reads or writes through RSP, because the + /// shift would move such an access by 128 bytes. + /// + private static bool TryComputeShiftedCore(IList instructions, out int coreStart, out int coreCount) + { + coreStart = -1; + var coreEnd = -1; + for (var index = 0; index < instructions.Count; index++) + { + if (!IsFaultableGuestMemoryInstruction(instructions[index])) + { + continue; + } + + if (coreStart < 0) + { + coreStart = index; + } + + coreEnd = index; + } + + if (coreStart < 0) + { + coreCount = 0; + return false; + } + + for (var index = coreStart; index <= coreEnd; index++) + { + if (TouchesStackPointer(instructions[index])) + { + coreCount = 0; + return false; + } + } + + coreCount = coreEnd - coreStart + 1; + return true; + } + + /// + /// Builds a span that ends at the faulting instruction, taking the + /// bytes it needs from the instructions before it. The instruction after the + /// site is never overwritten, so branches into it keep working - which is + /// what the forward search cannot offer when that instruction is a branch + /// target or touches RSP. + /// + private static bool TryBuildEnclosingPatchSpan( + IReadOnlyList decoded, + int faultIndex, + IReadOnlySet branchTargets, + out PatchSite site) + { + site = default; + var faulting = decoded[faultIndex].Instruction; + if (faulting.FlowControl != FlowControl.Next || TouchesStackPointer(faulting)) + { + return false; + } + + // Starting before the faulting instruction only works when nothing + // branches directly to it: such a branch would land inside the new jump. + if (branchTargets.Contains(faulting.IP)) + { + return false; + } + + var byteLength = faulting.Length; + for (var startIndex = faultIndex - 1; startIndex >= 0; startIndex--) + { + var candidate = decoded[startIndex].Instruction; + if (candidate.FlowControl != FlowControl.Next) + { + return false; + } + + byteLength += candidate.Length; + + // A branch target on the first instruction is fine: it lands on the + // jump that replaces the span. One in the middle is not, so stop as + // soon as the span is long enough rather than growing past it. + if (byteLength < MinimumJumpBytes) + { + if (branchTargets.Contains(candidate.IP)) + { + return false; + } + + continue; + } + + var instructions = new List(faultIndex - startIndex + 1); + for (var index = startIndex; index <= faultIndex; index++) + { + instructions.Add(decoded[index].Instruction); + } + + if (!TryComputeShiftedCore(instructions, out var coreStart, out var coreCount)) + { + return false; + } + + site = new PatchSite(candidate.IP, byteLength, instructions, coreStart, coreCount); + return true; + } + + return false; + } + private static bool TryBuildPatchSpan( IReadOnlyList decoded, int startIndex, IReadOnlySet branchTargets, - out PatchSite site) + out PatchSite site) => + TryBuildPatchSpan(decoded, startIndex, branchTargets, out site, out _); + + private static bool TryBuildPatchSpan( + IReadOnlyList decoded, + int startIndex, + IReadOnlySet branchTargets, + out PatchSite site, + out SpanRefusal refusal) { + refusal = SpanRefusal.None; var instructions = new List(3); var byteLength = 0; for (var index = startIndex; index < decoded.Count && byteLength < MinimumJumpBytes; index++) { var instruction = decoded[index].Instruction; - if (instruction.FlowControl != FlowControl.Next || - (index != startIndex && branchTargets.Contains(instruction.IP)) || - TouchesStackPointer(instruction)) + if (instruction.FlowControl != FlowControl.Next) + { + refusal = SpanRefusal.ControlFlow; + } + else if (index != startIndex && branchTargets.Contains(instruction.IP)) + { + refusal = SpanRefusal.BranchTargetAfter; + } + else if (TouchesStackPointer(instruction)) + { + refusal = SpanRefusal.StackAfter; + } + + if (refusal != SpanRefusal.None) { site = default; return false; @@ -299,11 +548,19 @@ internal static class GuestRedZonePatcher if (byteLength < MinimumJumpBytes) { + refusal = SpanRefusal.TooShort; site = default; return false; } - site = new PatchSite(decoded[startIndex].Instruction.IP, byteLength, instructions); + if (!TryComputeShiftedCore(instructions, out var coreStart, out var coreCount)) + { + refusal = SpanRefusal.TooShort; + site = default; + return false; + } + + site = new PatchSite(decoded[startIndex].Instruction.IP, byteLength, instructions, coreStart, coreCount); return true; } @@ -385,6 +642,30 @@ internal static class GuestRedZonePatcher return false; } + private static bool TryEncodeSegment( + IList instructions, + int start, + int count, + ulong address, + out byte[]? encoded) + { + encoded = null; + if (count <= 0) + { + return true; + } + + var writer = new ListCodeWriter(); + var segment = instructions.Skip(start).Take(count).ToList(); + if (!BlockEncoder.TryEncode(64, new InstructionBlock(writer, segment, address), out _, out _, BlockEncoderOptions.None)) + { + return false; + } + + encoded = writer.ToArray(); + return true; + } + private static bool TryPatchSite( IVirtualMemory memory, PatchSite site, @@ -393,9 +674,25 @@ internal static class GuestRedZonePatcher bool splitVectorStores, bool rewriteSha) { + // A span that starts before the faulting instruction may relocate an + // RSP-relative access, which must run before the shift or it would read + // 128 bytes away. Emit such a span as prefix / shifted core / suffix. + // A forward span starts at the faulting instruction and never steals a + // stack-dependent one, so it keeps the original single-block shape. + byte[]? prefix = null; + if (site.CoreStart > 0 && + !TryEncodeSegment(site.Instructions, 0, site.CoreStart, trampolineCursor, out prefix)) + { + return false; + } + + var prefixLength = prefix?.Length ?? 0; var writer = new ListCodeWriter(); - var relocatedAddress = trampolineCursor + 5; - var instructions = splitVectorStores ? RosettaVectorStorePatch.SplitVectorStores(site.Instructions) : site.Instructions; + var relocatedAddress = trampolineCursor + (ulong)prefixLength + 5; + var core = prefix is null + ? site.Instructions + : site.Instructions.Skip(site.CoreStart).Take(site.CoreCount).ToList(); + var instructions = splitVectorStores ? RosettaVectorStorePatch.SplitVectorStores(core) : core; if (rewriteSha) { instructions = ShaInstructionRewrite.Expand(instructions); @@ -407,21 +704,22 @@ internal static class GuestRedZonePatcher } var relocated = writer.ToArray(); - var trampolineLength = checked(5 + relocated.Length + 8 + 5); + var trampolineLength = checked(prefixLength + 5 + relocated.Length + 8 + 5); if (trampolineCursor > trampolineEnd || (ulong)trampolineLength > trampolineEnd - trampolineCursor) { return false; } var trampoline = new byte[trampolineLength]; + prefix?.CopyTo(trampoline, 0); // lea rsp,[rsp-128] -- LEA preserves guest flags. - trampoline[0] = 0x48; - trampoline[1] = 0x8D; - trampoline[2] = 0x64; - trampoline[3] = 0x24; - trampoline[4] = 0x80; - relocated.CopyTo(trampoline, 5); - var restoreOffset = 5 + relocated.Length; + trampoline[prefixLength] = 0x48; + trampoline[prefixLength + 1] = 0x8D; + trampoline[prefixLength + 2] = 0x64; + trampoline[prefixLength + 3] = 0x24; + trampoline[prefixLength + 4] = 0x80; + relocated.CopyTo(trampoline, prefixLength + 5); + var restoreOffset = prefixLength + 5 + relocated.Length; // lea rsp,[rsp+128] trampoline[restoreOffset] = 0x48; trampoline[restoreOffset + 1] = 0x8D; @@ -560,7 +858,19 @@ internal static class GuestRedZonePatcher private readonly record struct DecodedInstruction(Instruction Instruction); - private readonly record struct PatchSite(ulong Address, int ByteLength, IList Instructions); + /// + /// A run of guest instructions relocated into one trampoline. + /// and delimit the + /// faultable instructions the RSP shift must bracket. Instructions outside + /// that core run unshifted, which is what lets a span start before the + /// faulting instruction and still relocate an RSP-relative one correctly. + /// + private readonly record struct PatchSite( + ulong Address, + int ByteLength, + IList Instructions, + int CoreStart, + int CoreCount); internal readonly record struct PatchResult { @@ -580,6 +890,22 @@ internal static class GuestRedZonePatcher public int FailedSites { get; init; } + /// + /// Faultable instructions inside a red-zone function that no trampoline + /// span could cover. These stay unprotected and are not counted by + /// , which only tracks sites that were selected + /// and then failed to patch. + /// + public int UnrelocatableSites { get; init; } + + public int ControlFlowRefusals { get; init; } + + public int BranchTargetRefusals { get; init; } + + public int StackRefusals { get; init; } + + public int TooShortRefusals { get; init; } + public ulong TrampolineBytes { get; init; } } diff --git a/src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs b/src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs new file mode 100644 index 0000000..0703365 --- /dev/null +++ b/src/SharpEmu.HLE/GpuMemory/CpuDirtySummary.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.GpuMemory; + +// One bit per tracked block: set while the block has at least one CPU-dirty page. +// Regions update their bit under their own lock whenever their CPU-dirty mask +// changes, so a clear bit lets a reader skip the block without taking any lock. +// A reader that must observe a CPU write is ordered after it by the guest's own +// submission, which happens after the write fault published the bit. +public sealed class CpuDirtySummary +{ + private const int WordBits = 64; + private readonly long[] _words = new long[(TrackerLayout.BlockCount + WordBits - 1) / WordBits]; + + public void Set(ulong block, bool dirty) + { + var bit = 1L << (int)(block % WordBits); + ref var word = ref _words[block / WordBits]; + if (dirty) + { + Interlocked.Or(ref word, bit); + } + else + { + Interlocked.And(ref word, ~bit); + } + } + + public bool IsDirty(ulong block) => (Volatile.Read(ref _words[block / WordBits]) & (1L << (int)(block % WordBits))) != 0; +} diff --git a/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs b/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs index be7b5b8..e234511 100644 --- a/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs +++ b/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs @@ -16,12 +16,18 @@ public sealed class GuestPageTracker private readonly PageGuard _pages; private readonly TrackedRegion?[] _regions = new TrackedRegion?[TrackerLayout.BlockCount]; private readonly object _regionGate = new(); + private readonly CpuDirtySummary _cpuDirtySummary = new(); public GuestPageTracker(PageGuard pages) => _pages = pages; public bool HasCpuDirtyPages(ulong vaddr, ulong size) { RejectUploadCallbackReentry(); + if (IsKnownCpuClean(vaddr, size)) + { + return false; + } + return VisitRegions(vaddr, size, create: true, (region, offset, bytes) => { using var _ = region.Lock.Hold(); @@ -341,6 +347,23 @@ public sealed class GuestPageTracker } } + // True when every block of the range is tracked and has no CPU-dirty page. A missing + // region is not known clean: the precise path creates it, fully dirty. + private bool IsKnownCpuClean(ulong vaddr, ulong size) + { + ValidateRange(vaddr, size); + var last = (vaddr + size - 1) / BlockBytes; + for (var index = vaddr / BlockBytes; index <= last; index++) + { + if (Volatile.Read(ref _regions[index]) == null || _cpuDirtySummary.IsDirty(index)) + { + return false; + } + } + + return true; + } + // Visits (region, offset, bytes) per 4 MiB chunk; a true result stops the walk early. private bool VisitRegions(ulong vaddr, ulong size, bool create, Func visit) { @@ -379,7 +402,7 @@ public sealed class GuestPageTracker return existing; } - var created = new TrackedRegion(_pages, index * BlockBytes); + var created = new TrackedRegion(_pages, index * BlockBytes, _cpuDirtySummary); Volatile.Write(ref _regions[index], created); return created; } diff --git a/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs b/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs index ae2f6b9..8e4d87a 100644 --- a/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs +++ b/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs @@ -17,8 +17,9 @@ public sealed class TrackedRegion private PageMask _recentCpuUploads; private PageMask _repeatedCpuWrites; private PageMask _hotCpuWrites; + private readonly CpuDirtySummary? _summary; - public TrackedRegion(PageGuard pages, ulong baseAddress) + public TrackedRegion(PageGuard pages, ulong baseAddress, CpuDirtySummary? summary = null) { if (baseAddress % BlockBytes != 0) { @@ -26,10 +27,12 @@ public sealed class TrackedRegion } _pages = pages; + _summary = summary; BaseAddress = baseAddress; _cpuDirty.Fill(); _writable.Fill(); _readable.Fill(); + _summary?.Set(BaseAddress / BlockBytes, dirty: true); } public RegionLock Lock { get; } = new(); @@ -205,8 +208,10 @@ public sealed class TrackedRegion private ref PageMask GetDirtyMask(WriteOrigin side) => ref side == WriteOrigin.Cpu ? ref _cpuDirty : ref _gpuDirty; + // Every change to the CPU-dirty mask ends here, so it also publishes the block summary. private void UpdateCpuProtection(bool track) { + _summary?.Set(BaseAddress / BlockBytes, _cpuDirty.Any); var mask = _cpuDirty ^ _writable; _writable = _cpuDirty; if (mask.None) diff --git a/src/SharpEmu.HLE/GuestThreadExecution.cs b/src/SharpEmu.HLE/GuestThreadExecution.cs index 595de45..b7b2a81 100644 --- a/src/SharpEmu.HLE/GuestThreadExecution.cs +++ b/src/SharpEmu.HLE/GuestThreadExecution.cs @@ -12,7 +12,8 @@ public readonly record struct GuestThreadStartRequest( ulong AttributeAddress, string Name, int Priority, - ulong AffinityMask); + ulong AffinityMask, + ulong StackSize = 0); public readonly record struct GuestThreadSnapshot( ulong ThreadHandle, diff --git a/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs b/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs index 8bb0251..b629263 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.ShaderState.cs @@ -31,7 +31,10 @@ public static partial class AgcExports private const ulong ShaderNumShRegistersOffset = 0x5C; private const int ShaderStructBytes = 0x60; private const uint MaximumDeclaredShaderSizeBytes = 1024 * 1024; - private const int MaximumEmbeddedFusedScanBytes = 64 * 1024; + // Embedded GS/HS front/back descriptors can sit beyond the first 64 KiB of + // the upload. Scan the full shader object size accepted by AGC so the + // continuation is registered before resource planning sees the front half. + private const int MaximumEmbeddedFusedScanBytes = 1024 * 1024; private const ulong FusedShaderImageAlignment = 4; private const byte ComputeShaderType = 0; private const byte PsShaderType = 1; @@ -166,27 +169,6 @@ public static partial class AgcExports return false; } - var upload = new byte[MaximumEmbeddedFusedScanBytes]; - var bytesRead = 0; - const int readChunkBytes = 4 * 1024; - while (bytesRead < upload.Length) - { - var chunkLength = Math.Min(readChunkBytes, upload.Length - bytesRead); - if (!ctx.Memory.TryRead( - entryCodeAddress + (ulong)bytesRead, - upload.AsSpan(bytesRead, chunkLength))) - { - break; - } - - bytesRead += chunkLength; - } - - if (bytesRead < ShaderStructBytes) - { - return false; - } - var requiredContinuationType = entryType == GsFrontShaderType ? GsBackShaderType : HsBackShaderType; @@ -201,57 +183,95 @@ public static partial class AgcExports ulong bestCodeAddress = 0; ulong bestHeaderAddress = 0; var bestDistance = ulong.MaxValue; - for (var offset = 0; - offset <= bytesRead - ShaderStructBytes; - offset += sizeof(uint)) + + bool ScanForContinuation(ulong scanAddress) { - var descriptor = upload.AsSpan(offset, ShaderStructBytes); - if (BinaryPrimitives.ReadUInt32LittleEndian(descriptor) != ShaderFileHeader || - BinaryPrimitives.ReadUInt32LittleEndian(descriptor[sizeof(uint)..]) != ShaderVersion || - descriptor[(int)ShaderTypeOffset] != requiredContinuationType) + var upload = new byte[MaximumEmbeddedFusedScanBytes]; + var bytesRead = 0; + const int readChunkBytes = 4 * 1024; + while (bytesRead < upload.Length) { - continue; + var chunkLength = Math.Min(readChunkBytes, upload.Length - bytesRead); + if (!ctx.Memory.TryRead( + scanAddress + (ulong)bytesRead, + upload.AsSpan(bytesRead, chunkLength))) + { + break; + } + + bytesRead += chunkLength; } - var continuationCodeAddress = BinaryPrimitives.ReadUInt64LittleEndian( - descriptor[(int)ShaderCodeOffset..]); - var continuationSize = BinaryPrimitives.ReadUInt32LittleEndian( - descriptor[(int)ShaderSizeOffset..]); - if (continuationCodeAddress <= entryCodeAddress || - continuationCodeAddress - entryCodeAddress > uint.MaxValue || - !IsValidDeclaredShaderSize(continuationSize) || - !CanReadShaderRange(ctx, continuationCodeAddress, continuationSize)) + if (bytesRead < ShaderStructBytes) { - continue; + return false; } - var continuationSpecialsAddress = BinaryPrimitives.ReadUInt64LittleEndian( - descriptor[(int)ShaderSpecialsOffset..]); - if (entrySpecialsAddress != 0 && continuationSpecialsAddress != 0) + var found = false; + for (var offset = 0; + offset <= bytesRead - ShaderStructBytes; + offset += sizeof(uint)) { - if (!TryReadUInt32( - ctx, - entrySpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), - out var entryStages) || - !TryReadUInt32( - ctx, - continuationSpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), - out var continuationStages) || - ((entryStages ^ continuationStages) & waveSizeBit) != 0) + var descriptor = upload.AsSpan(offset, ShaderStructBytes); + if (BinaryPrimitives.ReadUInt32LittleEndian(descriptor) != ShaderFileHeader || + BinaryPrimitives.ReadUInt32LittleEndian(descriptor[sizeof(uint)..]) != ShaderVersion || + descriptor[(int)ShaderTypeOffset] != requiredContinuationType) { continue; } + + var continuationCodeAddress = BinaryPrimitives.ReadUInt64LittleEndian( + descriptor[(int)ShaderCodeOffset..]); + var continuationSize = BinaryPrimitives.ReadUInt32LittleEndian( + descriptor[(int)ShaderSizeOffset..]); + if (continuationCodeAddress <= entryCodeAddress || + continuationCodeAddress - entryCodeAddress > uint.MaxValue || + !IsValidDeclaredShaderSize(continuationSize) || + !CanReadShaderRange(ctx, continuationCodeAddress, continuationSize)) + { + continue; + } + + var continuationSpecialsAddress = BinaryPrimitives.ReadUInt64LittleEndian( + descriptor[(int)ShaderSpecialsOffset..]); + if (entrySpecialsAddress != 0 && continuationSpecialsAddress != 0) + { + if (!TryReadUInt32( + ctx, + entrySpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), + out var entryStages) || + !TryReadUInt32( + ctx, + continuationSpecialsAddress + ShaderSpecialVgtShaderStagesEnOffset + sizeof(uint), + out var continuationStages) || + ((entryStages ^ continuationStages) & waveSizeBit) != 0) + { + continue; + } + } + + var distance = continuationCodeAddress - entryCodeAddress; + if (distance >= bestDistance) + { + continue; + } + + bestDistance = distance; + bestCodeAddress = continuationCodeAddress; + bestHeaderAddress = scanAddress + (ulong)offset; + found = true; } - var distance = continuationCodeAddress - entryCodeAddress; - if (distance >= bestDistance) - { - continue; - } + return found; + } - bestDistance = distance; - bestCodeAddress = continuationCodeAddress; - bestHeaderAddress = entryCodeAddress + (ulong)offset; + // Most uploads place the second descriptor beside the code. Some + // titles keep both descriptors in the header allocation instead, so + // inspect that region as a fallback. + ScanForContinuation(entryCodeAddress); + if (bestHeaderAddress == 0) + { + ScanForContinuation(entryHeaderAddress); } if (bestHeaderAddress == 0) @@ -422,6 +442,13 @@ public static partial class AgcExports return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + // Both halves share one user-data block, so the fused USER_SGPR count is + // the larger of the two. + if (!MergeFusedUserScalarCount(ctx, frontRegistersAddress, frontRegisterCount, fusedRegistersAddress, registerCount)) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + for (var occurrence = 0; occurrence < 2; occurrence++) { if (!TryFindShaderRegister(ctx, fusedRegistersAddress, registerCount, SpiShaderPgmChksumGs, occurrence, out var fusedEntry) || @@ -1216,6 +1243,27 @@ public static partial class AgcExports (frontType == GsFrontShaderType && backType == GsBackShaderType) || (frontType == HsFrontShaderType && backType == HsBackShaderType); + private static uint UserScalarCount(uint rsrc2) => ((rsrc2 >> 1) & 0x1Fu) | (((rsrc2 >> 27) & 1u) << 5); + + private static bool MergeFusedUserScalarCount(CpuContext ctx, ulong frontRegisters, int frontCount, ulong fusedRegisters, int fusedCount) + { + if (!TryFindShaderRegister(ctx, frontRegisters, frontCount, SpiShaderPgmRsrc2Gs, 0, out var frontEntry) || + !TryFindShaderRegister(ctx, fusedRegisters, fusedCount, SpiShaderPgmRsrc2Gs, 0, out var fusedEntry)) + { + return true; + } + + if (!TryReadUInt32(ctx, frontEntry + sizeof(uint), out var front) || + !TryReadUInt32(ctx, fusedEntry + sizeof(uint), out var fused)) + { + return false; + } + + var count = Math.Max(UserScalarCount(front), UserScalarCount(fused)); + var merged = (fused & ~((0x1Fu << 1) | (1u << 27))) | ((count & 0x1Fu) << 1) | ((count >> 5) << 27); + return merged == fused || TryWriteUInt32(ctx, fusedEntry + sizeof(uint), merged); + } + private static bool TryFindShaderRegister( CpuContext ctx, ulong registersAddress, diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index e7af284..60f29fe 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -99,6 +99,7 @@ public static partial class AgcExports private const uint SpiShaderPgmLoGs = 0x88; private const uint SpiShaderPgmHiGs = 0x89; private const uint SpiShaderPgmRsrc1Gs = 0x8A; + private const uint SpiShaderPgmRsrc2Gs = 0x8B; private const uint SpiShaderPgmChksumGs = 0x80; private const uint SpiShaderPgmChksumPs = 0x06; private const uint SpiPsInputEna = 0x1B3; diff --git a/src/SharpEmu.Libs/Ampr/AmprExports.cs b/src/SharpEmu.Libs/Ampr/AmprExports.cs index e940a1d..355762f 100644 --- a/src/SharpEmu.Libs/Ampr/AmprExports.cs +++ b/src/SharpEmu.Libs/Ampr/AmprExports.cs @@ -965,6 +965,8 @@ public static class AmprExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + private static int _unknownReadFileIdWarnings; + private static int CompleteReadFileRecord(CpuContext ctx, ulong commandBuffer, ReadFileCommand command) { var fileId = command.FileId; @@ -982,6 +984,13 @@ public static class AmprExports if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath)) { + // An unknown id stalls the guest's stream silently, so warn even without tracing. + if (Interlocked.Increment(ref _unknownReadFileIdWarnings) <= 16) + { + Console.Error.WriteLine( + $"[LOADER][WARN] ampr.read_unknown_file_id id=0x{fileId:X8} size=0x{size:X} offset=0x{fileOffset:X}"); + } + TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; } diff --git a/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs b/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs index f24fe97..3e77db0 100644 --- a/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs +++ b/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs @@ -38,6 +38,9 @@ internal static class AmprFileRegistry private static readonly object _resolvedFileGate = new(); private static readonly Dictionary _resolvedIdsByPath = new(HostFsPath.Comparer); private static readonly ConcurrentDictionary _resolvedPathsById = new(); + // Ids already handed to the guest; alias publishes must not re-poison them. + private static readonly ConcurrentDictionary _aprResolvedPathsById = new(); + private static readonly ConcurrentDictionary _loggedAprCollisionIds = new(); // Resolved handles use a separate range from the 31-bit compatibility hashes. // Never reuse a handle while queued reads can still refer to it. private static uint _nextResolvedId = 0x80000000; @@ -67,24 +70,52 @@ internal static class AmprFileRegistry public static uint RegisterAprResolvedPath(string guestPath, string hostPath) { - if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0) - { - RegisterApp0Relative(relative, hostPath); - } - // APR file ids are part of the guest ABI: ResolveFilepathsToIds returns // the 31-bit FNV-1a hash of the guest path. Keep the collision-safe // process-local handles used by Register() separate from this path so a // title can compare resolved ids with ids baked into its asset tables. + // A hash shared by two files (13 /app0/ pairs in Demon's Souls) falls back to a handle. var fileId = ComputeFileId(guestPath); - PublishCompatibilityPath(fileId, hostPath, AliasRank.Resolved); - return fileId; + lock (_resolvedFileGate) + { + if (_aprResolvedPathsById.TryGetValue(fileId, out var claimedPath)) + { + if (IsSameHostFile(claimedPath, hostPath)) + return fileId; + } + else if (!_hostPathsById.TryGetValue(fileId, out var indexed) || + (!indexed.Ambiguous && IsSameHostFile(indexed.HostPath, hostPath))) + { + if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0) + { + RegisterApp0Relative(relative, hostPath); + } + + PublishCompatibilityPath(fileId, hostPath, AliasRank.Resolved); + _aprResolvedPathsById[fileId] = hostPath; + return fileId; + } + } + + var handle = Register(guestPath, hostPath); + if (_loggedAprCollisionIds.TryAdd(fileId, 0)) + { + Console.Error.WriteLine( + $"[LOADER][WARN] ampr.apr_id_collision id=0x{fileId:X8} path='{guestPath}' handle=0x{handle:X8}"); + } + + return handle; } + private static bool IsSameHostFile(string left, string right) => + HostFsPath.Comparer.Equals(Path.GetFullPath(left), Path.GetFullPath(right)); + public static bool TryGetHostPath(uint id, out string hostPath) { if ((id & 0x80000000) != 0) return _resolvedPathsById.TryGetValue(id, out hostPath!); + if (_aprResolvedPathsById.TryGetValue(id, out hostPath!)) + return true; if (_hostPathsById.TryGetValue(id, out var entry) && !entry.Ambiguous) { hostPath = entry.HostPath; @@ -101,8 +132,10 @@ internal static class AmprFileRegistry lock (_indexGate) { _hostPathsById.Clear(); + _loggedAprCollisionIds.Clear(); lock (_resolvedFileGate) { + _aprResolvedPathsById.Clear(); _resolvedIdsByPath.Clear(); _resolvedPathsById.Clear(); _nextResolvedId = 0x80000000; @@ -417,7 +450,23 @@ internal static class AmprFileRegistry Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SharpEmu", "ampr-index"); - Directory.CreateDirectory(cacheDir); + try + { + Directory.CreateDirectory(cacheDir); + var probePath = Path.Combine(cacheDir, $".write-test-{Environment.ProcessId}"); + using (File.Create(probePath)) { } + File.Delete(probePath); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + // Sandboxed launches and mitigated child processes can lose access + // to the profile AppData directory. Keep the index beside the + // executable so the next launch can still reuse it. + cacheDir = Path.Combine(AppContext.BaseDirectory, "user", "ampr-index"); + Directory.CreateDirectory(cacheDir); + Console.Error.WriteLine( + $"[LOADER][WARN] ampr.app0_index_cache_fallback path={cacheDir}: {exception.Message}"); + } // Distinct roots must not share a cache file. Folding case is only // correct where the host filesystem folds it too. diff --git a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs index 4e1e683..7ac5d65 100644 --- a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs +++ b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs @@ -497,18 +497,23 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable var index = _registry.FindFirstOverlappingIndex(guestAddress); for (; index < _registry.RegisteredCount && _registry.GetRegisteredAddress(index) < end; index++) { - var buffer = _registry.GetBuffer(_registry.GetRegisteredIdentifier(index)); + var identifier = _registry.GetRegisteredIdentifier(index); + var buffer = _registry.GetBuffer(identifier); var start = Math.Max(buffer.CpuAddress, guestAddress); var finish = Math.Min(buffer.CpuAddress + buffer.Size, end); if (start < finish) { if (GuestGpuMemoryHook.Traces(start, finish - start)) GuestGpuMemoryHook.Trace(start, finish - start, - $"device-address-touch buffer={_registry.GetRegisteredIdentifier(index)} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); + $"device-address-touch buffer={identifier} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); // Clean buffers remain in use through their device addresses. - TouchBuffer(buffer); + TouchBuffer(identifier); // Device-address reads reuse persistent buffers; track writes after each upload. - _ = SynchronizeBuffer(buffer, start, finish - start, false, false, preserveCpuWriteHotPages: false); + // A range without CPU-dirty pages has nothing to upload (the same early exit + // SynchronizeBuffer takes for this read-only call), and the block summary + // answers that without a lock for the common all-clean case. + if (_tracker.HasCpuDirtyPages(start, finish - start)) + _ = SynchronizeBuffer(buffer, start, finish - start, false, false, preserveCpuWriteHotPages: false); } } } @@ -1052,7 +1057,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable BufferUploadProfile.Record(guestAddress, size, copies.Count, totalSize, hotBytes, elapsedTicks); } - if (isTexelBuffer && !isWritten) + if (isTexelBuffer) { return RequireImageCache().TrySynchronizeBufferFromImage(buffer, guestAddress, size); } diff --git a/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs b/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs index 4516ef0..47af475 100644 --- a/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs +++ b/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Events.cs @@ -190,12 +190,27 @@ public sealed partial class GpuCommandInterpreter SubmitFlipWithInterrupt(payload[4], payload[5], Address(payload[1], payload[2]), payload[3]); break; default: - throw _host.Fatal($"The marker is unknown: offset=0x{packet.Offset:X5} marker=0x{markerId:X} address=0x{packet.PacketAddress:X16}."); + // A marker id this interpreter has no side effect for (profiling/debug annotations such as + // 0x6EC and 0x80C in Demon's Souls). The packet carries no work, so it is consumed; the id is + // reported once so a marker that later turns out to matter is still visible. + NoteUnknownMarker(markerId, packet); + break; } return packet.Length - 1; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary _reportedUnknownMarkers = new(); + + private static void NoteUnknownMarker(uint markerId, in PacketContext packet) + { + if (_reportedUnknownMarkers.TryAdd(markerId, true)) + { + Console.Error.WriteLine( + $"[LOADER][WARN] command_stream.marker_ignored marker=0x{markerId:X} offset=0x{packet.Offset:X5} address=0x{packet.PacketAddress:X16}"); + } + } + internal uint PushMarkerPacket(in PacketContext packet, ReadOnlySpan payload) => packet.Length - 1; internal uint PopMarkerPacket(in PacketContext packet, ReadOnlySpan payload) => packet.Length - 1; diff --git a/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs b/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs index 398d061..7bf81b4 100644 --- a/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs +++ b/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs @@ -68,6 +68,7 @@ public sealed unsafe partial class CachedImage : IDisposable private bool _maybeHashValid; private bool _gpuModified; private bool _bufferModified; + private bool _bufferHoldsGpuContents; public ImageDescription Description; public readonly ImageBacking Backing = new(); @@ -130,9 +131,10 @@ public sealed unsafe partial class CachedImage : IDisposable Backing.Usage = create.Usage; var vk = device.Vk; - if (vk.CreateImage(device.Device, &create, null, out Backing.Handle) != Result.Success) + var createResult = vk.CreateImage(device.Device, &create, null, out Backing.Handle); + if (createResult != Result.Success) { - throw CreateFailure(create); + throw CreateFailure(create, "vkCreateImage", createResult, 0); } vk.GetImageMemoryRequirements(device.Device, Backing.Handle, out var requirements); @@ -158,21 +160,33 @@ public sealed unsafe partial class CachedImage : IDisposable } } - if (allocated != Result.Success || vk.BindImageMemory(device.Device, Backing.Handle, Backing.Memory, 0) != Result.Success) + if (allocated != Result.Success) { vk.DestroyImage(device.Device, Backing.Handle, null); device.FreeMemory(Backing.Memory); Backing.Handle = default; Backing.Memory = default; - throw CreateFailure(create); + throw CreateFailure(create, "vkAllocateMemory", allocated, requirements.Size); + } + + var bindResult = vk.BindImageMemory(device.Device, Backing.Handle, Backing.Memory, 0); + if (bindResult != Result.Success) + { + vk.DestroyImage(device.Device, Backing.Handle, null); + device.FreeMemory(Backing.Memory); + Backing.Handle = default; + Backing.Memory = default; + throw CreateFailure(create, "vkBindImageMemory", bindResult, requirements.Size); } Backing.AllocationSize = requirements.Size; } - private static Exception CreateFailure(in ImageCreateInfo create) => + private static Exception CreateFailure(in ImageCreateInfo create, string operation, Result result, ulong requiredBytes) => SubmissionScheduler.Fatal( - $"The image could not be created: extent={create.Extent.Width}x{create.Extent.Height}x{create.Extent.Depth} format={(int)create.Format} layers={create.ArrayLayers} levels={create.MipLevels}."); + $"The image could not be created: operation={operation} result={result} required_bytes={requiredBytes} " + + $"extent={create.Extent.Width}x{create.Extent.Height}x{create.Extent.Depth} format={create.Format}({(int)create.Format}) " + + $"layers={create.ArrayLayers} levels={create.MipLevels} usage=0x{(uint)create.Usage:X} flags=0x{(uint)create.Flags:X}."); internal static bool TrySelectSupportedImageConfiguration(IImageFormatSupport device, ref ImageCreateInfo configuration, bool allowCompressedImageFallback) { @@ -351,12 +365,21 @@ public sealed unsafe partial class CachedImage : IDisposable public bool IsGpuModified => _gpuModified; - public void MarkGpuModified() => _gpuModified = true; + public void MarkGpuModified() + { + _gpuModified = true; + _bufferHoldsGpuContents = false; + } public void ClearGpuModified() => _gpuModified = false; public bool IsBufferModified => _bufferModified; + // The guest buffer cache holds this image's current GPU contents. + public bool BufferHoldsGpuContents => _bufferHoldsGpuContents; + + public void MarkBufferHoldsGpuContents() => _bufferHoldsGpuContents = true; + public void MarkBufferModified() => _bufferModified = true; public void ClearBufferModified() => _bufferModified = false; diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs index 0b31a83..1e6bbf5 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs @@ -349,6 +349,11 @@ public sealed partial class GuestImageCache case ImageRole.Texture: recreate |= requested.IsDepth && !cachedInfo.IsDepth; recreate |= rawD16Texture; + // A color texture that reads neither the depth nor the stencil plane + // reinterprets the memory as color, so its bytes move to a color image. + recreate |= cachedInfo.IsDepth && !requested.IsDepth && + !ViewFormatRules.IsDepthCompatible(requested.PixelFormat) && + !ViewFormatRules.IsStencilViewFormat(requested.PixelFormat); break; case ImageRole.StorageImage: case ImageRole.ColorTarget: @@ -440,23 +445,15 @@ public sealed partial class GuestImageCache ref var cachedInfo = ref cached.Description; var currentTick = _scheduler.CurrentTick; var safeToDelete = currentTick - Math.Min(currentTick, cached.LastAccessTick) > TicksBeforeRemoval; + var requestedBlock = requested.BytesPerBlock * requested.Samples; + var cachedBlock = cachedInfo.BytesPerBlock * cachedInfo.Samples; + var requestedBlockExtent = requested.BlockExtent; + var cachedBlockExtent = cachedInfo.BlockExtent; - if (requested.Data.Address == cachedInfo.Data.Address) + if (requested.Data.Address == cachedInfo.Data.Address && + requestedBlockExtent.Width == cachedBlockExtent.Width && requestedBlockExtent.Height == cachedBlockExtent.Height && + requestedBlock == cachedBlock) { - var requestedBlock = requested.BytesPerBlock * requested.Samples; - var cachedBlock = cachedInfo.BytesPerBlock * cachedInfo.Samples; - var requestedBlockExtent = requested.BlockExtent; - var cachedBlockExtent = cachedInfo.BlockExtent; - if (requestedBlockExtent.Width != cachedBlockExtent.Width || requestedBlockExtent.Height != cachedBlockExtent.Height || requestedBlock != cachedBlock) - { - if (safeToDelete) - { - ReleaseImage(cachedImageIdentifier); - } - - return new OverlapResolution(mergedImageIdentifier); - } - var depthImageIdentifier = ResolveDepthOverlap(requested, role, cachedImageIdentifier); if (depthImageIdentifier.IsValid) { @@ -509,50 +506,32 @@ public sealed partial class GuestImageCache $"type={(uint)requested.Type}/{(uint)cachedInfo.Type} tile={(uint)requested.TileMode}/{(uint)cachedInfo.TileMode}."); } - if (requested.Data.Address > cachedInfo.Data.Address) + var requestedMip = requested.FindMatchingMipLevel(cachedInfo); + if (requestedMip >= 0) { - var mip = requested.FindMatchingMipLevel(cachedInfo); - if (mip >= 0) - { - var layer = requested.FindMatchingArraySlice(cachedInfo, mip); - if (layer >= 0) - { - return new OverlapResolution(cachedImageIdentifier, mip, layer); - } - } - - if (safeToDelete) - { - ReleaseImage(cachedImageIdentifier); - } - - return new OverlapResolution(ResourceSlotIdentifier.Invalid); + var layer = requested.FindMatchingArraySlice(cachedInfo, requestedMip); + return new OverlapResolution(cachedImageIdentifier, requestedMip, layer); } var containedMip = cachedInfo.FindMatchingMipLevel(requested); if (containedMip >= 0) { var containedLayer = cachedInfo.FindMatchingArraySlice(requested, containedMip); - if (containedLayer >= 0) + if (!mergedImageIdentifier.IsValid) { - if (cached.Binding.IsTarget) - { - cached.Binding.NeedsRebind = true; - if (mergedImageIdentifier.IsValid) - { - _slots[mergedImageIdentifier].Binding.IsTarget = true; - } - - ReleaseImage(cachedImageIdentifier); - return new OverlapResolution(mergedImageIdentifier); - } - - if (mergedImageIdentifier.IsValid) - { - CopyIntoMip(mergedImageIdentifier, cachedImageIdentifier, (uint)containedMip, (uint)containedLayer); - ReleaseImage(cachedImageIdentifier); - } + return new OverlapResolution(GrowImage(requested, cachedImageIdentifier)); } + + cached.Binding.NeedsRebind |= cached.Binding.IsBound || cached.Binding.IsTarget; + _slots[mergedImageIdentifier].Binding.IsTarget |= cached.Binding.IsTarget; + CopyIntoMip(mergedImageIdentifier, cachedImageIdentifier, (uint)containedMip, (uint)containedLayer); + ReleaseImage(cachedImageIdentifier); + return new OverlapResolution(mergedImageIdentifier); + } + + if (requested.Data.Address >= cachedInfo.Data.Address && safeToDelete) + { + ReleaseImage(cachedImageIdentifier); } return new OverlapResolution(mergedImageIdentifier); @@ -572,7 +551,17 @@ public sealed partial class GuestImageCache } PopulateFromGuest(expandedImageIdentifier, ImageRequest.Refresh(description, UploadRole(source)), "expand"); - CopyWholeImage(expandedImageIdentifier, sourceImageIdentifier); + var mip = source.Description.FindMatchingMipLevel(description); + var layer = source.Description.FindMatchingArraySlice(description, mip); + if (layer >= 0) + { + CopyIntoMip(expandedImageIdentifier, sourceImageIdentifier, (uint)mip, (uint)layer); + } + else + { + CopyWholeImage(expandedImageIdentifier, sourceImageIdentifier); + } + ReleaseImage(sourceImageIdentifier); return expandedImageIdentifier; } diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs index 0255c37..08a4fd9 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Transfers.cs @@ -653,6 +653,7 @@ public sealed unsafe partial class GuestImageCache } DownloadToBuffer(image, buffer, bufferOffset, copySize, plan); + image.MarkBufferHoldsGpuContents(); return true; } diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs b/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs index ea973fb..50a0f04 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestPixelFormats.cs @@ -315,6 +315,7 @@ public static class GuestPixelFormats new(GuestPixelFormat.Bits16Float, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8UNorm, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8SNorm, 2, 0, 2, true, false), + new(GuestPixelFormat.Bits8_8UScaled, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8UInt, 2, 0, 2, true, true), new(GuestPixelFormat.Bits8_8SInt, 2, 0, 2, true, false, true), new(GuestPixelFormat.Bits32UInt, 4, 0, 4, true, true), @@ -325,6 +326,9 @@ public static class GuestPixelFormats new(GuestPixelFormat.Bits16_16UInt, 4, 0, 4, true, true), new(GuestPixelFormat.Bits16_16SInt, 4, 0, 4, true, false, true), new(GuestPixelFormat.Bits16_16Float, 4, 0, 4, true, false), + // Vulkan has no normalized 11:11:10 format. Keep the guest packing sampled + // through the native 32-bit packed-float view, matching the host backend. + new(GuestPixelFormat.Bits11_11_10UNorm, 4, 0, 4, true, false), new(GuestPixelFormat.Bits11_11_10UInt, 4, 0, 4, true, true), new(GuestPixelFormat.Bits11_11_10Float, 4, 0, 4, true, false), new(GuestPixelFormat.Bits10_10_10_2UNorm, 4, 0, 4, true, false), @@ -347,8 +351,8 @@ public static class GuestPixelFormats new(GuestPixelFormat.Bits32_32_32_32UInt, 16, 0, 16, true, true), new(GuestPixelFormat.Bits32_32_32_32SInt, 16, 0, 16, true, false, true), new(GuestPixelFormat.Bits32_32_32_32Float, 16, 0, 16, true, false), - new(GuestPixelFormat.Bits8Srgb, 1, 0, 0, true, false), - new(GuestPixelFormat.Bits8_8Srgb, 2, 0, 0, true, false), + new(GuestPixelFormat.Bits8Srgb, 1, 0, 1, true, false), + new(GuestPixelFormat.Bits8_8Srgb, 2, 0, 2, true, false), new(GuestPixelFormat.Bits8_8_8_8Srgb, 4, 0, 4, true, false), new(GuestPixelFormat.Bits9_9_9_5Float, 4, 0, 0, true, false), new(GuestPixelFormat.Bits5_6_5UNorm, 2, 0, 2, true, false), @@ -432,6 +436,7 @@ public static class GuestPixelFormats (GuestPixelFormat.Bits16Float, Format.R16Sfloat), (GuestPixelFormat.Bits8_8UNorm, Format.R8G8Unorm), (GuestPixelFormat.Bits8_8SNorm, Format.R8G8SNorm), + (GuestPixelFormat.Bits8_8UScaled, Format.R8G8Uscaled), (GuestPixelFormat.Bits8_8UInt, Format.R8G8Uint), (GuestPixelFormat.Bits8_8SInt, Format.R8G8Sint), (GuestPixelFormat.Bits32UInt, Format.R32Uint), @@ -442,6 +447,7 @@ public static class GuestPixelFormats (GuestPixelFormat.Bits16_16UInt, Format.R16G16Uint), (GuestPixelFormat.Bits16_16SInt, Format.R16G16Sint), (GuestPixelFormat.Bits16_16Float, Format.R16G16Sfloat), + (GuestPixelFormat.Bits11_11_10UNorm, Format.B10G11R11UfloatPack32), (GuestPixelFormat.Bits11_11_10Float, Format.B10G11R11UfloatPack32), (GuestPixelFormat.Bits10_10_10_2UNorm, Format.A2B10G10R10UnormPack32), (GuestPixelFormat.Bits10_10_10_2UInt, Format.A2B10G10R10UintPack32), diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs b/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs index 3c45d77..ce58daa 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageDescription.cs @@ -131,8 +131,29 @@ public struct ImageDescription // Return the matching mip level in the containing image, or -1 if none matches. public readonly int FindMatchingMipLevel(in ImageDescription container) { - if (!IsCompatible(container) || TileMode != container.TileMode || Resources.Levels != 1 || - container.Resources.Layers == 0 || container.Resources.Levels > MaxLevels) + if (container.Resources.Levels > MaxLevels) + { + return -1; + } + + for (uint level = 0; level < container.Resources.Levels; level++) + { + if (FindMatchingArraySlice(container, (int)level) >= 0) + { + return (int)level; + } + } + + return -1; + } + + // Return the matching slice in the containing mip level, or -1 if none matches. + public readonly int FindMatchingArraySlice(in ImageDescription container, int mip) + { + if (!IsCompatible(container) || TileMode != container.TileMode || Type != container.Type || IsVolume || + Resources.Levels != 1 || Resources.Layers == 0 || container.Resources.Layers == 0 || mip < 0 || + (uint)mip >= container.Resources.Levels || container.Resources.Levels > MaxLevels || + !IsValidRange(Data) || !IsValidRange(container.Data) || Data.Address < container.Data.Address || Data.End > container.Data.End) { return -1; } @@ -143,95 +164,38 @@ public struct ImageDescription return -1; } - var mip = -1; - for (uint level = 0; level < container.Resources.Levels; level++) - { - var layout = container.MipLayout[(int)level]; - if (layout.Size == 0 || layout.Size % container.Resources.Layers != 0 || container.Data.Address > ulong.MaxValue - layout.Offset) - { - continue; - } - - var mipBase = container.Data.Address + layout.Offset; - var sliceSize = layout.Size / container.Resources.Layers; - if (sliceSize == 0 || mipBase > ulong.MaxValue - layout.Size) - { - continue; - } - - var mipEnd = mipBase + layout.Size; - if (Data.Address >= mipBase && Data.Address < mipEnd && (Data.Address - mipBase) % sliceSize == 0) - { - mip = (int)level; - break; - } - } - - if (mip < 0) - { - return -1; - } - - if (Extent.Width != Math.Max(container.Extent.Width >> mip, 1) || Extent.Height != Math.Max(container.Extent.Height >> mip, 1)) - { - return -1; - } - - var mipDepth = Math.Max(container.Extent.Depth >> mip, 1); - if (container.Type == GuestImageType.Color3D && Type == GuestImageType.Color2D) - { - if (Resources.Layers != mipDepth) - { - return -1; - } - } - else if (Type != container.Type) - { - return -1; - } - - return mip; - } - - // Return the matching slice in the containing mip level, or -1 if none matches. - public readonly int FindMatchingArraySlice(in ImageDescription container, int mip) - { - if (!IsCompatible(container) || Type != container.Type || mip < 0 || (uint)mip >= container.Resources.Levels || - container.Resources.Levels > MaxLevels || container.Resources.Layers == 0 || Data.Size == 0) - { - return -1; - } - - if (Extent.Width != Math.Max(container.Extent.Width >> mip, 1) || Extent.Height != Math.Max(container.Extent.Height >> mip, 1)) - { - return -1; - } - var layout = container.MipLayout[mip]; - if (layout.Size == 0 || layout.Size % container.Resources.Layers != 0 || container.Data.Address > ulong.MaxValue - layout.Offset) + if (Extent.Width != Math.Max(container.Extent.Width >> mip, 1) || Extent.Height != Math.Max(container.Extent.Height >> mip, 1) || + Extent.Depth != container.Extent.Depth || MipLayout[0].Pitch != layout.Pitch || MipLayout[0].Height != layout.Height || + layout.Size == 0 || layout.Size % container.Resources.Layers != 0 || + container.Data.Size % container.Resources.Layers != 0 || Data.Size % Resources.Layers != 0) { return -1; } - var sliceSize = layout.Size / container.Resources.Layers; - if (sliceSize == 0 || Data.Size % sliceSize != 0) + // PS5 array slices contain the whole reversed mip chain, rather than + // storing all slices of one mip contiguously. + var sliceStride = container.Data.Size / container.Resources.Layers; + var childStride = Data.Size / Resources.Layers; + if (childStride != layout.Size / container.Resources.Layers || layout.Offset >= sliceStride || + childStride > sliceStride - layout.Offset || (Resources.Layers > 1 && childStride != sliceStride)) { return -1; } - var mipBase = container.Data.Address + layout.Offset; - if (Data.Address < mipBase) + var addressDelta = Data.Address - container.Data.Address; + if (addressDelta < layout.Offset || (addressDelta - layout.Offset) % sliceStride != 0) { return -1; } - var delta = Data.Address - mipBase; - if (delta % Data.Size != 0 || delta / Data.Size > int.MaxValue) + var layer = (addressDelta - layout.Offset) / sliceStride; + if (layer > int.MaxValue || layer >= container.Resources.Layers || Resources.Layers > container.Resources.Layers - layer) { return -1; } - return (int)(delta / Data.Size); + return (int)layer; } public readonly bool IsSupportedDepthTarget diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs index 816d7fc..1be295f 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.ColorTarget.cs @@ -122,6 +122,17 @@ public static partial class ImageRequestBuilders } var depth = volume ? words.Depth + 1 : 1; + // Some Prospero titles encode a full 3D attachment with SliceMax as + // the exclusive depth (one past the last slice). The hardware accepts + // that form, while Vulkan needs the actual slice count for a 2D-array + // view. Normalize only this exact one-past range; other invalid views + // remain rejected below. + if (volume && view.BaseLayer == 0 && view.LayerCount == depth + 1) + { + view = new TargetViewRange(0, depth, depth); + Console.Error.WriteLine( + $"[LOADER][INFO] normalized an exclusive 3D color-target slice range to depth={depth}"); + } var tileMode = words.TileMode; var standard4 = tileMode == GuestTileMode.Standard4KB; var standard64 = tileMode == GuestTileMode.Standard64KB; diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs index 9212e10..82e9a4f 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.Texture.cs @@ -240,7 +240,8 @@ public static partial class ImageRequestBuilders { pixelFormat = depthFormat.DepthAttachmentFormat; } - var storageViewFormat = storage && format == GuestPixelFormat.Bits32SInt ? Format.R32Uint : ViewFormatRules.SrgbStorageFormat(pixelFormat); + // Atomic storage images are declared as UINT in SPIR-V, including float atomics. + var storageViewFormat = storage && (shape.Atomic || format == GuestPixelFormat.Bits32SInt) ? Format.R32Uint : ViewFormatRules.SrgbStorageFormat(pixelFormat); var viewFormat = storage && storageViewFormat != Format.Undefined ? storageViewFormat : pixelFormat; var blockBytes = GuestPixelFormats.BlockCompressedBytes(format); var description = ImageDescription.Create(); diff --git a/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs b/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs index c2f8e20..466023e 100644 --- a/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs +++ b/src/SharpEmu.Libs/Gpu/Images/SamplerStore.cs @@ -42,28 +42,30 @@ public enum SamplerBorderColor : uint FromTable = 3, } -// One Vulkan sampler per distinct guest sampler descriptor, created on first use. +// One Vulkan sampler per distinct guest sampler descriptor and view numeric class, +// created on first use. The guest border color has no numeric class; Vulkan's has to +// match the sampled view, and integer views cannot be filtered. public sealed unsafe class SamplerStore : IDisposable { private readonly GpuDeviceInfo _device; - private readonly Dictionary<(uint, uint, uint, uint), Sampler> _samplers = new(); + private readonly Dictionary<(uint, uint, uint, uint, bool), Sampler> _samplers = new(); private readonly object _gate = new(); public SamplerStore(GpuDeviceInfo device) => _device = device; public int Count => _samplers.Count; - public Sampler GetSampler(in SamplerDescriptorWords words) + public Sampler GetSampler(in SamplerDescriptorWords words, bool integerView) { lock (_gate) { - var key = (words[0], words[1], words[2], words[3]); + var key = (words[0], words[1], words[2], words[3], integerView); if (_samplers.TryGetValue(key, out var existing)) { return existing; } - var sampler = CreateSampler(words); + var sampler = CreateSampler(words, integerView); _samplers.Add(key, sampler); return sampler; } @@ -96,10 +98,10 @@ public sealed unsafe class SamplerStore : IDisposable _ => throw SubmissionScheduler.Fatal($"The sampler clamp mode is unknown: clamp={clamp}."), }; - private Sampler CreateSampler(in SamplerDescriptorWords words) + private Sampler CreateSampler(in SamplerDescriptorWords words, bool integerView) { - var magnify = words.MagnifyFilter; - var minify = words.MinifyFilter; + var magnify = integerView ? (uint)SamplerFilter.Point : words.MagnifyFilter; + var minify = integerView ? (uint)SamplerFilter.Point : words.MinifyFilter; var anisotropic = IsAnisotropic(magnify) || IsAnisotropic(minify); var anisotropyRatio = 1.0f; if (anisotropic) @@ -117,6 +119,11 @@ public sealed unsafe class SamplerStore : IDisposable } var mipFilter = (SamplerMipFilter)words.MipFilter; + if (integerView && mipFilter == SamplerMipFilter.Linear) + { + mipFilter = SamplerMipFilter.Point; + } + var minLod = 0.0f; var maxLod = 0.0f; if (mipFilter != SamplerMipFilter.None) @@ -125,26 +132,21 @@ public sealed unsafe class SamplerStore : IDisposable maxLod = words.MaxLod / 256.0f; } - BorderColor border; - switch ((SamplerBorderColor)words.BorderColorType) + var borderType = (SamplerBorderColor)words.BorderColorType; + if (borderType == SamplerBorderColor.FromTable) { - case SamplerBorderColor.TransparentBlack: - border = BorderColor.IntTransparentBlack; - break; - case SamplerBorderColor.OpaqueBlack: - border = BorderColor.IntOpaqueBlack; - break; - case SamplerBorderColor.OpaqueWhite: - border = BorderColor.IntOpaqueWhite; - break; - case SamplerBorderColor.FromTable: - Console.Error.WriteLine($"[LOADER][WARN] A table border color is approximated as transparent black: index={words.BorderColorIndex}."); - border = BorderColor.IntTransparentBlack; - break; - default: - throw SubmissionScheduler.Fatal($"The border color type is unknown: type={words.BorderColorType}."); + Console.Error.WriteLine($"[LOADER][WARN] A table border color is approximated as transparent black: index={words.BorderColorIndex}."); + borderType = SamplerBorderColor.TransparentBlack; } + var border = borderType switch + { + SamplerBorderColor.TransparentBlack => integerView ? BorderColor.IntTransparentBlack : BorderColor.FloatTransparentBlack, + SamplerBorderColor.OpaqueBlack => integerView ? BorderColor.IntOpaqueBlack : BorderColor.FloatOpaqueBlack, + SamplerBorderColor.OpaqueWhite => integerView ? BorderColor.IntOpaqueWhite : BorderColor.FloatOpaqueWhite, + _ => throw SubmissionScheduler.Fatal($"The border color type is unknown: type={words.BorderColorType}."), + }; + var info = new SamplerCreateInfo { SType = StructureType.SamplerCreateInfo, diff --git a/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs b/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs index 592d979..1b3fbb9 100644 --- a/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs +++ b/src/SharpEmu.Libs/Gpu/Images/TargetRegisterWords.cs @@ -106,10 +106,10 @@ public readonly record struct DisplaySurfaceWords( bool Compressed); // The shape the shader module was compiled with; the view type must match it. -public readonly record struct ShaderImageShape(bool Volume, bool Arrayed, bool Cube, bool Storage, bool DynamicMip, TextureNumericClass NumericClass, bool OneDimensional = false, bool R128 = false, bool Multisampled = false, bool DepthCompare = false) +public readonly record struct ShaderImageShape(bool Volume, bool Arrayed, bool Cube, bool Storage, bool DynamicMip, TextureNumericClass NumericClass, bool OneDimensional = false, bool R128 = false, bool Multisampled = false, bool DepthCompare = false, bool Atomic = false) { public ShaderImageShape(bool volume, bool arrayed, bool storage, bool dynamicMip, TextureNumericClass numericClass) - : this(volume, arrayed, false, storage, dynamicMip, numericClass, false, false, false) + : this(volume, arrayed, false, storage, dynamicMip, numericClass, false, false, false, false, false) { } } diff --git a/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs b/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs index 3d1e646..4485662 100644 --- a/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs +++ b/src/SharpEmu.Libs/Gpu/Images/TileGeometry.Blocks.cs @@ -173,63 +173,57 @@ public static partial class TileGeometry switch (bytesPerElement) { case 1: - offset ^= (y << 2) & 0x0008; - offset ^= (y << 4) & 0x0010; - offset ^= (y << 3) & 0x00a0; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 6) & 0x1000; - offset ^= (y << 7) & 0x4000; - offset ^= x & 0x0007; - offset ^= (x << 3) & 0x0040; - offset ^= (x << 5) & 0x0300; - offset ^= (x << 4) & 0x0400; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 7) & 0x2000; - offset ^= (x << 8) & 0x8000; + offset ^= Bit(x, 0, 0) ^ Bit(x, 1, 1) ^ Bit(x, 2, 2) ^ Bit(x, 3, 3); + offset ^= Bit(y, 0, 4) ^ Bit(y, 1, 5) ^ Bit(y, 2, 6) ^ Bit(y, 3, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(x, 6, 12) ^ Bit(y, 6, 13); + offset ^= Bit(x, 7, 14) ^ Bit(y, 8, 14); + offset ^= Bit(x, 8, 15) ^ Bit(y, 7, 15); return offset; case 2: - offset ^= (y << 4) & 0x0070; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 8) & 0x5000; - offset ^= (x << 1) & 0x000e; - offset ^= (x << 4) & 0x0480; - offset ^= (x << 5) & 0x0300; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 7) & 0x2000; - offset ^= (x << 8) & 0x8000; + offset ^= Bit(x, 0, 1) ^ Bit(x, 1, 2) ^ Bit(x, 2, 3); + offset ^= Bit(y, 0, 4) ^ Bit(y, 1, 5) ^ Bit(y, 2, 6) ^ Bit(x, 3, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(y, 3, 12) ^ Bit(x, 6, 13); + offset ^= Bit(x, 7, 14) ^ Bit(y, 7, 14); + offset ^= Bit(x, 8, 15) ^ Bit(y, 6, 15); return offset; case 4: - offset ^= (y << 4) & 0x0070; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 9) & 0x1000; - offset ^= (y << 8) & 0x4000; - offset ^= (x << 2) & 0x000c; - offset ^= (x << 5) & 0x0380; - offset ^= (x << 4) & 0x0400; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 9) & 0xa000; + offset ^= Bit(x, 0, 2) ^ Bit(x, 1, 3); + offset ^= Bit(y, 0, 4) ^ Bit(y, 1, 5) ^ Bit(x, 2, 6) ^ Bit(y, 2, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(x, 3, 12) ^ Bit(y, 3, 13); + offset ^= Bit(x, 6, 14) ^ Bit(y, 7, 14); + offset ^= Bit(x, 7, 15) ^ Bit(y, 6, 15); return offset; case 8: - offset ^= (y << 4) & 0x0010; - offset ^= (y << 6) & 0x0080; - offset ^= (y << 5) & 0x0f00; - offset ^= (y << 10) & 0x5000; - offset ^= (x << 3) & 0x0008; - offset ^= (x << 4) & 0x0460; - offset ^= (x << 5) & 0x0300; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 10) & 0x2000; - offset ^= (x << 9) & 0x8000; + offset ^= Bit(x, 0, 3) ^ Bit(y, 0, 4) ^ Bit(x, 1, 5) ^ Bit(x, 2, 6) ^ Bit(y, 1, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(y, 2, 12) ^ Bit(x, 3, 13); + offset ^= Bit(x, 7, 14) ^ Bit(y, 3, 14); + offset ^= Bit(x, 6, 15) ^ Bit(y, 6, 15); return offset; case 16: - offset ^= (x << 4) & 0x0410; - offset ^= (x << 5) & 0x0340; - offset ^= (x << 6) & 0x0800; - offset ^= (x << 11) & 0xa000; - offset ^= (y << 5) & 0x0f20; - offset ^= (y << 6) & 0x0080; - offset ^= (y << 10) & 0x1000; - offset ^= (y << 11) & 0x4000; + offset ^= Bit(x, 0, 4) ^ Bit(y, 0, 5) ^ Bit(x, 1, 6) ^ Bit(y, 1, 7); + offset ^= Bit(x, 7, 8) ^ Bit(y, 4, 8) ^ Bit(y, 7, 8); + offset ^= Bit(x, 4, 9) ^ Bit(y, 4, 9); + offset ^= Bit(x, 6, 10) ^ Bit(y, 5, 10); + offset ^= Bit(x, 5, 11) ^ Bit(y, 6, 11); + offset ^= Bit(x, 2, 12) ^ Bit(y, 2, 13); + offset ^= Bit(x, 6, 14) ^ Bit(y, 3, 14); + offset ^= Bit(x, 3, 15) ^ Bit(y, 6, 15); return offset; default: throw SubmissionScheduler.Fatal($"The render-target tile block does not support this element size: bytes={bytesPerElement}."); diff --git a/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs b/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs index f2a81c3..bd51d5c 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ViewFormatRules.cs @@ -130,6 +130,19 @@ public static class ViewFormatRules _ => None, }; + // Integer views sample raw integers: no filtering, integer border colors. + public static bool IsIntegerFormat(Format format) => format is + Format.R8Uint or Format.R8Sint or Format.R8G8Uint or Format.R8G8Sint or + Format.R8G8B8A8Uint or Format.R8G8B8A8Sint or Format.B8G8R8A8Uint or Format.B8G8R8A8Sint or + Format.A8B8G8R8UintPack32 or Format.A8B8G8R8SintPack32 or + Format.A2B10G10R10UintPack32 or Format.A2B10G10R10SintPack32 or + Format.A2R10G10B10UintPack32 or Format.A2R10G10B10SintPack32 or + Format.R16Uint or Format.R16Sint or Format.R16G16Uint or Format.R16G16Sint or + Format.R16G16B16A16Uint or Format.R16G16B16A16Sint or + Format.R32Uint or Format.R32Sint or Format.R32G32Uint or Format.R32G32Sint or + Format.R32G32B32Uint or Format.R32G32B32Sint or Format.R32G32B32A32Uint or Format.R32G32B32A32Sint or + Format.S8Uint; + // The bytes of one texel block of a host format; 0 for a format outside the compatibility classes. public static uint BlockBytes(Format format) { diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs index c84cc52..d456471 100644 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs @@ -164,6 +164,9 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel bool IShaderPipelineHost.GraphicsSubgroupOperationsEnabled => true; + // Metal has no 64-bit workgroup atomics here; keep the non-atomic 32-bit pair. + bool IShaderPipelineHost.SharedInt64AtomicsEnabled => false; + RenderHostLimits IShaderPipelineHost.Limits => new(MaxDimension, MaxDimension, MaxDimension, MaxDimension); SampleCountFlags IShaderPipelineHost.NoAttachmentSampleCounts => @@ -174,6 +177,9 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel bool IShaderPipelineHost.TryReadCleanGuestWord(ulong address, out uint word) => TryReadWord(address, out word); + bool IShaderPipelineHost.TryReadResidentGuestBytes(ulong address, Span destination, bool clean) => + Memory.TryRead(address, destination); + private bool TryReadWord(ulong address, out uint word) { Span bytes = stackalloc byte[sizeof(uint)]; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs new file mode 100644 index 0000000..bc1f494 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ConstantFillDetector.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.Libs.Gpu.Pipelines; + +// A compute program that stores one dword, read from a constant buffer, into every +// record of a buffer: the element index is group * 64 + thread. +// v_lshl_add_u32 v0, s, 6, v0 +// s_buffer_load_dword s, s[:+3], 0 +// s_waitcnt +// v_mov_b32 v1, s +// buffer_store_format_x v1, v0, s[:+3], 0 idxen +// s_endpgm +// Unreal clears DCC metadata this way, so the dispatch must reach the image cache as a +// clear; the metadata is not emulated and the stores alone would leave the image stale. +public sealed record ConstantFill(uint GroupScalarRegister, uint DestinationScalarResource, uint SourceScalarResource); + +public static class ConstantFillDetector +{ + private const uint Shift64 = 134; // inline constant 6 + private const uint ZeroConstant = 128; + private const uint NullScalar = 125; + + public static ConstantFill? Detect(Gen5ShaderProgram program) + { + var instructions = program.Instructions.Where(instruction => instruction.Opcode != "SWaitcnt").ToArray(); + if (instructions.Length != 5) + { + return null; + } + + var index = instructions[0]; + if (index.Opcode != "VLshlAddU32" || index.Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } indexRegister] || + index.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } group, { Kind: Gen5OperandKind.EncodedConstant, Value: Shift64 }, + { Kind: Gen5OperandKind.VectorRegister } thread] || thread.Value != indexRegister.Value) + { + return null; + } + + var load = instructions[1]; + if (load.Opcode != "SBufferLoadDword" || load.Control is not Gen5ScalarMemoryControl { ImmediateOffsetBytes: 0, DynamicOffsetRegister: null } || + load.Destinations is not [{ Kind: Gen5OperandKind.ScalarRegister } value] || + load.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } source, { Kind: Gen5OperandKind.EncodedConstant, Value: NullScalar or ZeroConstant }]) + { + return null; + } + + var move = instructions[2]; + if (move.Opcode != "VMovB32" || move.Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } data] || + move.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } moved] || moved.Value != value.Value) + { + return null; + } + + var store = instructions[3]; + if (store.Opcode != "BufferStoreFormatX" || + store.Control is not Gen5BufferMemoryControl { DwordCount: 1, OffsetBytes: 0, IndexEnabled: true, OffsetEnabled: false, Typed: false } control || + control.VectorAddress != indexRegister.Value || control.VectorData != data.Value || + store.Sources is not [_, _, { Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }]) + { + return null; + } + + return instructions[4].Opcode == "SEndpgm" ? new ConstantFill(group.Value, control.ScalarResource, source.Value) : null; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs b/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs index aac1816..e5f0a20 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs @@ -38,6 +38,9 @@ internal interface IShaderPipelineHost bool GraphicsSubgroupOperationsEnabled { get; } + // The device supports shaderSharedInt64Atomics, so LDS 64-bit atomics can be + // emitted as real 64-bit atomics instead of a non-atomic 32-bit pair. + bool SharedInt64AtomicsEnabled { get; } bool PerVertexPixelInputsSupported => true; RenderHostLimits Limits { get; } @@ -53,6 +56,11 @@ internal interface IShaderPipelineHost // Reads one guest dword only when no GPU work may still own the range. bool TryReadCleanGuestWord(ulong address, out uint word); + // Copies guest bytes the CPU already holds, without synchronizing. False when the GPU + // may own the range (or, for a clean read, when a clean word read would be refused); + // the resource cache then re-materializes instead of trusting a stale copy. + bool TryReadResidentGuestBytes(ulong address, Span destination, bool clean) => false; + // Creates the host module of one compiled permutation and returns its handle. ulong CreateShaderModule(IGuestCompiledShader shader, ShaderStage stage, ulong hash, ulong programId); diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs b/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs index 27d8aed..1421bc5 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/PipelineCacheSignature.cs @@ -43,19 +43,44 @@ public static class PipelineCacheSignature public static bool TryUnwrap(string signature, ReadOnlySpan file, out byte[] payload) { payload = []; - var prefix = Encoding.ASCII.GetBytes(signature); - if (file.Length < prefix.Length + sizeof(ulong) || file.Length > int.MaxValue) + if (file.Length > int.MaxValue) { return false; } - if (!file[..prefix.Length].SequenceEqual(prefix)) + var newline = file.IndexOf((byte)'\n'); + if (newline < 0) { return false; } - var expectedHash = BinaryPrimitives.ReadUInt64LittleEndian(file[prefix.Length..]); - var data = file[(prefix.Length + sizeof(ulong))..]; + var actualSignature = Encoding.ASCII.GetString(file[..(newline + 1)]); + if (!string.Equals(actualSignature, signature, StringComparison.Ordinal)) + { + // Pipeline binaries are keyed by the Vulkan device and driver; + // the SharpEmu build version only controls which shader records + // may be present. A newer build can safely reuse an older driver + // cache because Vulkan validates each pipeline key and ignores + // stale records. Keep the cache when the hardware suffix matches. + var expectedParts = signature.TrimEnd('\n').Split(':'); + var actualParts = actualSignature.TrimEnd('\n').Split(':'); + if (expectedParts.Length != actualParts.Length || + expectedParts.Length < 6 || + !string.Equals(expectedParts[0], actualParts[0], StringComparison.Ordinal) || + !expectedParts[2..].SequenceEqual(actualParts[2..], StringComparer.Ordinal)) + { + return false; + } + } + + var hashOffset = newline + 1; + if (file.Length < hashOffset + sizeof(ulong)) + { + return false; + } + + var expectedHash = BinaryPrimitives.ReadUInt64LittleEndian(file[hashOffset..]); + var data = file[(hashOffset + sizeof(ulong))..]; if (XxHash3.HashToUInt64(data) != expectedHash) { return false; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs index d2143c5..a74faff 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPermutationDump.cs @@ -15,7 +15,7 @@ internal static class ShaderPermutationDump private static long _sequence; public static Action? CreateFailureCapture(ShaderSource source) => - CompiledShaderDump.ShouldWrite(source.Address) ? CreateEnabledFailureCapture(source) : null; + CompiledShaderDump.ShouldWrite(source.Address, source.Hash) ? CreateEnabledFailureCapture(source) : null; // Keep captured shader state out of the disabled per-draw path. private static Action CreateEnabledFailureCapture(ShaderSource source) => @@ -32,7 +32,7 @@ internal static class ShaderPermutationDump uint pushDataCursor, ulong programId) { - if (!CompiledShaderDump.ShouldWrite(source.Address)) return null; + if (!CompiledShaderDump.ShouldWrite(source.Address, source.Hash)) return null; try { var sequence = Interlocked.Increment(ref _sequence); @@ -90,7 +90,7 @@ internal static class ShaderPermutationDump public static void WriteIndirectImageFailure(ShaderSource source, IndirectImageFailure failure) { - if (!CompiledShaderDump.ShouldWrite(source.Address)) return; + if (!CompiledShaderDump.ShouldWrite(source.Address, source.Hash)) return; try { var sequence = Interlocked.Increment(ref _sequence); diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs index 8660f20..43a8a09 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs @@ -384,6 +384,7 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider RenderTrace.Write( $"PipelineCache create graphics vertex=0x{vertexProgram.Id:X16} pixel=0x{key.PixelProgramId:X16} colors={key.Rendering.ColorCount} " + $"depth={description.StaticParameters.WithDepth} samples={description.StaticParameters.Samples}"); + } var created = _host.CreateGraphicsPipeline(description); @@ -443,7 +444,15 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider // host output would otherwise write an undefined value (e.g. depth-only passes that // leave a color target bound and export only to the null target). var exported = pixelStage is null || ((pixelStage.PixelColorExportMasks >> (int)(color.Slot * 4)) & 0xFu) != 0; - parameters.SetColorMask(index, exported ? color.Resolution.ExportMapping.ApplyMask(context.RenderTargetMaskForSlot(color.Slot)) : 0); + var colorMask = exported ? color.Resolution.ExportMapping.ApplyMask(context.RenderTargetMaskForSlot(color.Slot)) : 0; + parameters.SetColorMask(index, colorMask); + if (RenderTrace.Enabled && RenderTrace.Pipeline()) + { + RenderTrace.Write( + $"PipelineCache output slot={color.Slot} guestMask=0x{context.RenderTargetMaskForSlot(color.Slot):X} " + + $"exported={(exported ? 1 : 0)} shaderMask=0x{pixelStage?.PixelColorExportMasks ?? 0:X8} " + + $"mapping=0x{color.Resolution.ExportMapping.Packed:X2} hostMask=0x{colorMask:X} format={(int)format}"); + } } var withDepth = depth.HasTarget; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs index 2debf3e..7f73a85 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPlanningDump.cs @@ -67,7 +67,7 @@ internal static class ShaderPlanningDump private static void Write(ShaderSource source, string suffix, Action write) { - if (!CompiledShaderDump.ShouldWrite(source.Address)) return; + if (!CompiledShaderDump.ShouldWrite(source.Address, source.Hash)) return; try { var path = $"{CompiledShaderDump.GetBasePath(source.Label, source.Address, source.Hash)}.{suffix}"; diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs index ef29e5f..0f8c87b 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs @@ -85,6 +85,7 @@ internal sealed class ProgramSourceEntry public required ShaderResourcePlan Plan { get; init; } public required Gen5ShaderProgram Program { get; init; } public required bool HasBitwiseExclusiveOr { get; init; } + public ConstantFill? ConstantFill { get; init; } public EmbeddedVertexFetchPlan? EmbeddedFetch { get; init; } public ShaderVertexInput[] VertexInputs { get; init; } = []; public List Permutations { get; } = new(8); @@ -102,8 +103,14 @@ internal sealed class ShaderProgramCache private readonly Dictionary _programs = new(); private readonly Dictionary<(ulong Hash, uint CodeSize), Gen5ShaderProgram> _decoded = new(); private readonly List _staticState = new(StageStaticKey.MaxWords); + // Draws that re-bind unchanged resources reuse the last materialization. + // SHARPEMU_RESOURCE_CACHE=0 materializes every draw, for A/B comparisons. + private readonly ResourceMaterializationCache? _materializations = + Environment.GetEnvironmentVariable("SHARPEMU_RESOURCE_CACHE") == "0" ? null : new(); private ulong _nextProgramId; + public ResourceMaterializationCache? Materializations => _materializations; + public ShaderProgramCache(CpuContext context, IGuestGpuBackend compiler, IShaderPipelineHost host) { _context = context; @@ -211,9 +218,15 @@ internal sealed class ShaderProgramCache var captureIndirectImageFailure = ShaderPermutationDump.CreateFailureCapture(source); using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ResourceMaterialization)) { - if (!ResourceMaterializer.Materialize(entry.Plan, inputs, ref snapshot, ref specialization, out var materializationFailure, captureIndirectImageFailure)) + // Failure capture needs the full walk, so a dump run bypasses the cache. + var materialized = _materializations is not null && captureIndirectImageFailure is null + ? _materializations.Materialize(entry.Plan, inputs, _host.TryReadResidentGuestBytes, ref snapshot, ref specialization, + out var materializationFailure) + : ResourceMaterializer.Materialize(entry.Plan, inputs, ref snapshot, ref specialization, out materializationFailure, + captureIndirectImageFailure); + if (!materialized) { - var message = $"The shader resources could not be materialized: stage={source.Label} hash=0x{source.Hash:X16} shader=0x{source.Address:X16}."; + var message = $"The shader resources could not be materialized: stage={source.Label} hash=0x{source.Hash:X16} shader=0x{source.Address:X16} reason={materializationFailure}."; if (materializationFailure is ResourceMaterializationFailure.IncompatibleImageCandidates or ResourceMaterializationFailure.ImageCapacityExceeded) throw new ShaderProgramRejectedException(message); throw SubmissionScheduler.Fatal(message); @@ -283,7 +296,7 @@ internal sealed class ShaderProgramCache private ProgramSourceEntry CreateEntry(ShaderSource source, StageCompileOptions options) { var program = Decode(source); - var dumpPlanning = CompiledShaderDump.ShouldWrite(source.Address); + var dumpPlanning = CompiledShaderDump.ShouldWrite(source.Address, source.Hash); if (dumpPlanning) ShaderPlanningDump.WriteInput(source, program); EmbeddedVertexFetchPlan? fetch = null; ShaderVertexInput[] vertexInputs = []; @@ -326,6 +339,7 @@ internal sealed class ShaderProgramCache Plan = plan, Program = program, HasBitwiseExclusiveOr = exclusiveOr, + ConstantFill = source.Stage == ShaderStage.Compute ? ConstantFillDetector.Detect(program) : null, EmbeddedFetch = fetch, VertexInputs = vertexInputs, }; @@ -521,6 +535,7 @@ internal sealed class ShaderProgramCache BindingLayout layout) { var enableGraphicsSubgroups = _host.GraphicsSubgroupOperationsEnabled; + var sharedInt64Atomics = _host.SharedInt64AtomicsEnabled; switch (source.Stage) { case ShaderStage.Vertex: @@ -532,6 +547,7 @@ internal sealed class ShaderProgramCache TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, ScratchDwords = info.ScratchDwords, EnableGraphicsSubgroupOperations = enableGraphicsSubgroups, + SupportsSharedInt64Atomics = sharedInt64Atomics, RequiredVertexOutputCount = options.RequiredVertexOutputCount, VertexInputs = entry.VertexInputs, PositionExportControl = info.PositionExportControl, @@ -557,6 +573,7 @@ internal sealed class ShaderProgramCache TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, ScratchDwords = info.ScratchDwords, EnableGraphicsSubgroupOperations = enableGraphicsSubgroups, + SupportsSharedInt64Atomics = sharedInt64Atomics, PixelOutputs = options.PixelOutputs, PixelInputEnable = options.PixelInputEnable, PixelCustomInterpolationMask = info.CustomInterpolationMask, @@ -574,6 +591,7 @@ internal sealed class ShaderProgramCache WaveSize = info.WaveSize, TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, ScratchDwords = info.ScratchDwords, + SupportsSharedInt64Atomics = sharedInt64Atomics, ComputeSystemRegisters = options.ComputeSystemRegisters, LocalSizeX = Math.Max(info.ThreadsX, 1), LocalSizeY = Math.Max(info.ThreadsY, 1), @@ -637,6 +655,7 @@ internal sealed class ShaderProgramCache InstanceOffsetScalarRegister = entry.EmbeddedFetch?.InstanceOffsetScalarRegister ?? ShaderProgramInfo.NoScalarRegister, UsesDeviceAddresses = info.UsesDeviceAddresses, HasBitwiseExclusiveOr = entry.HasBitwiseExclusiveOr, + ConstantFill = entry.ConstantFill, Buffers = buffers, Images = images, SamplerCount = info.Samplers.Count, @@ -652,7 +671,7 @@ internal sealed class ShaderProgramCache // Writes each compiled module and its decoded listing when the dump switch is on. internal static class CompiledShaderDump { - internal static bool ShouldWrite(ulong shaderAddress) + internal static bool ShouldWrite(ulong shaderAddress, ulong shaderHash) { if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DUMP_SPIRV"), "1", StringComparison.Ordinal)) { @@ -675,6 +694,27 @@ internal static class CompiledShaderDump } } + var hashFilter = Environment.GetEnvironmentVariable("SHARPEMU_DUMP_SPIRV_HASH"); + if (!string.IsNullOrWhiteSpace(hashFilter)) + { + foreach (var filter in hashFilter.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var span = filter.AsSpan(); + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + span = span[2..]; + } + + if (ulong.TryParse(span, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var filteredHash) && + shaderHash == filteredHash) + { + return true; + } + } + + return false; + } + return true; } @@ -692,7 +732,7 @@ internal static class CompiledShaderDump public static void Write(string stage, ulong shaderAddress, ulong hash, IGuestCompiledShader shader, Gen5ShaderProgram program) { - if (shader.Payload.Length == 0 || !ShouldWrite(shaderAddress)) return; + if (shader.Payload.Length == 0 || !ShouldWrite(shaderAddress, hash)) return; var basePath = GetBasePath(stage, shaderAddress, hash); File.WriteAllBytes($"{basePath}.{shader.PayloadFileExtension}", shader.Payload); var lines = new List(program.Instructions.Count + 2) diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs b/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs index 24e532c..4265201 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs @@ -41,13 +41,17 @@ public static class VertexInputResolver var attributes = new List(); VertexInputBuffer[] buffers = []; - if (metadata.VertexAttributeRegister >= 0) + // A shader without input semantics never reads its vertex tables, which may be null. + if (metadata.VertexAttributeRegister >= 0 && metadata.InputSemantics.Length != 0) { var attributeTable = ReadTablePointer(userData, metadata.VertexAttributeRegister); var bufferTable = ReadTablePointer(userData, metadata.VertexBufferRegister); if (attributeTable == 0 || bufferTable == 0) { - throw SubmissionScheduler.Fatal($"The vertex table pointer is null: shader=0x{shader.CodeAddress:X16} attributes=0x{attributeTable:X16} buffers=0x{bufferTable:X16}."); + throw SubmissionScheduler.Fatal( + $"The vertex table pointer is null: shader=0x{shader.CodeAddress:X16} attributes=0x{attributeTable:X16} buffers=0x{bufferTable:X16} " + + $"attributeRegister={metadata.VertexAttributeRegister} bufferRegister={metadata.VertexBufferRegister} " + + $"user_data=[{string.Join(",", userData.ToArray().Select(word => $"{word:X8}"))}]."); } attributes = ApplySemantics(context, metadata.InputSemantics, attributeTable, bufferTable, shader.CodeAddress); diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs index c32885e..c94f419 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Compute.cs @@ -3,6 +3,7 @@ using SharpEmu.Libs.Gpu.GpuCommands.Registers; using SharpEmu.Libs.Gpu.Scheduling; +using SharpEmu.ShaderCompiler.Vulkan; using Silk.NET.Vulkan; using ResourceSnapshot = SharpEmu.ShaderCompiler.Resources.ResourceSnapshot; @@ -18,6 +19,7 @@ public sealed partial class RenderExecutor private const uint DispatchInitiatorKnownMask = DispatchInitiatorBaseBits | DispatchInitiatorModifierBits; private const uint ImageClearDispatchInitiator = 0x61; private const uint ImageClearWaveSize = 64; + private const uint Format32UInt = 20; private const uint ImageClearStride = 16; private const uint ImageClearUserDataCount = 8; @@ -82,6 +84,12 @@ public sealed partial class RenderExecutor return; } + if (indirectArgumentsAddress == 0 && TryConsumeConstantFill(input, groupsX, groupsY, groupsZ)) + { + _host.ResetBindings(); + return; + } + if (indirectArgumentsAddress == 0 && TryConsumeImageClear(input, groupsX, groupsY, groupsZ, dispatchInitiator)) { _host.ResetBindings(); @@ -143,9 +151,25 @@ public sealed partial class RenderExecutor } _host.BindPipeline(PipelineBindPoint.Compute, in pipeline); + // The shader's local workgroup axes may have been remapped at compile time + // (see Gen5SpirvTranslator.ComputeWorkgroupAxisOrder) so the largest NUM_THREAD + // axis lands on a physical axis Vulkan actually allows it on. The dispatch group + // counts must be permuted the same way, or vkCmdDispatch would hand group counts + // for the wrong physical axis to the pipeline it built with the remapped sizes. + var axisOrder = Gen5SpirvTranslator.ComputeWorkgroupAxisOrder( + input.ThreadsX, + input.ThreadsY, + input.ThreadsZ); + var logicalGroups = new[] { groupsX, groupsY, groupsZ }; + var physicalGroups = new uint[3]; + for (var logical = 0; logical < 3; logical++) + { + physicalGroups[axisOrder[logical]] = logicalGroups[logical]; + } + if (indirectArgumentsAddress == 0 || !_host.TryDispatchIndirect(indirectArgumentsAddress)) { - _host.Dispatch(groupsX, groupsY, groupsZ); + _host.Dispatch(physicalGroups[0], physicalGroups[1], physicalGroups[2]); } _host.ShaderAccessBarrier(); } @@ -276,6 +300,46 @@ public sealed partial class RenderExecutor return new ComputeImageClear(descriptor, clear, size); } + // A constant fill that covers a whole image or DCC metadata becomes a clear. Anything + // else, including a fill of plain buffer memory, runs as the guest wrote it. + private bool TryConsumeConstantFill(ComputeInputInfo input, uint groupsX, uint groupsY, uint groupsZ) + { + var program = input.Stage.Program!; + if (program.ConstantFill is not { } fill || program.UserDataBase != 0) + { + return false; + } + + var userData = input.Stage.Resources.UserData; + if (fill.GroupScalarRegister != (uint)input.WorkgroupRegister || !input.GroupIdX || input.GroupIdY || input.GroupIdZ || + input.ThreadsX != ImageClearWaveSize || input.ThreadsY != 1 || input.ThreadsZ != 1 || groupsY != 1 || groupsZ != 1 || + fill.DestinationScalarResource + 4 > userData.Length || fill.SourceScalarResource + 4 > userData.Length) + { + return false; + } + + var destination = BufferDescriptorWords.From(userData.AsSpan((int)fill.DestinationScalarResource, 4).ToArray()); + var source = BufferDescriptorWords.From(userData.AsSpan((int)fill.SourceScalarResource, 4).ToArray()); + Span valueBytes = stackalloc byte[sizeof(uint)]; + if (destination.Format != Format32UInt || destination.Stride != sizeof(uint) || destination.SwizzleEnabled || destination.AddThreadId || + (ulong)groupsX * input.ThreadsX != destination.RecordCount || source.RecordCount == 0 || + !_host.TryReadGuest(source.Address, valueBytes)) + { + return false; + } + + var value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(valueBytes); + var size = (ulong)destination.RecordCount * sizeof(uint); + var consumed = _host.TryClearImageFromBuffer(destination.Address, size, value) || + _host.TryAbsorbDccFill(destination.Address, size, value); + if (RenderTrace.Enabled && RenderTrace.MetadataClear()) + { + RenderTrace.Write($"Constant fill: shader=0x{program.Hash:X16} address=0x{destination.Address:X16} size=0x{size:X} value=0x{value:X8} consumed={consumed}"); + } + + return consumed; + } + private bool TryConsumeImageClear(ComputeInputInfo input, uint groupsX, uint groupsY, uint groupsZ, uint dispatchInitiator) { if (TryDecodeImageClear(input, groupsX, groupsY, groupsZ, dispatchInitiator) is not { } clear) diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs index ddfcaaa..57b7eb7 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs @@ -37,7 +37,16 @@ public enum GuestIndexType : uint // Resolves draw and dispatch state from the register banks and records it through the host. public sealed partial class RenderExecutor { - private const uint PrimitiveShaderStageMask = 0x02002000; + // Prospero uses both the full NGG mask and compact primitive-shader masks + // when the task/mesh front end has already folded the upper enable bits + // away. Little Nightmares also emits 0x2030 for a primitive path with no + // geometry shader; the remaining bits describe the fixed NGG setup. + // GS_W32_EN (bit 22) and VS_W32_EN (bit 23) only select the wave size; + // graphics stages always compile as wave32, so they do not change the path. + private const uint VgtShaderStagesWaveSizeBits = (1u << 22) | (1u << 23); + + private static bool IsPrimitiveShaderStageMask(uint stages) => + (stages & ~VgtShaderStagesWaveSizeBits) is 0x02002000 or 0x00002000 or 0x00002030; private const uint MaxOutputPerSubgroupLimit = 0x40; private static int _geometryWarningShown; @@ -400,15 +409,16 @@ public sealed partial class RenderExecutor var vertex = banks.Shader.Vertex; var stages = context.ShaderStages; var primitiveShaderVertexPath = - stages == PrimitiveShaderStageMask && vertex.ExportAddress != 0 && - shaderInterface.GeometryMaxVerticesOut == 0 && IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType); - var unsupportedStageMask = stages != 0 && stages != PrimitiveShaderStageMask; + IsPrimitiveShaderStageMask(stages) && vertex.ExportAddress != 0 && + (vertex.GeometryAddress == 0 || + (shaderInterface.GeometryMaxVerticesOut == 0 && IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType))); + var unsupportedStageMask = stages != 0 && !IsPrimitiveShaderStageMask(stages); var unsupportedGeometryStage = vertex.ExportAddress != 0 && vertex.GeometryAddress != 0 && !primitiveShaderVertexPath; - var geometryRegisters = - (shaderInterface.PrimitiveShaderSubgroupControl != 0 && shaderInterface.PrimitiveShaderSubgroupControl != 1) || - shaderInterface.GeometryMaxVerticesOut != 0 || - !IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType) || - shaderInterface.MaxOutputPerSubgroup > MaxOutputPerSubgroupLimit; + var geometryRegisters = !primitiveShaderVertexPath && + ((shaderInterface.PrimitiveShaderSubgroupControl != 0 && shaderInterface.PrimitiveShaderSubgroupControl != 1) || + shaderInterface.GeometryMaxVerticesOut != 0 || + !IsKnownGeometryOutputPrimitiveType(shaderInterface.GeometryOutputPrimitiveType) || + shaderInterface.MaxOutputPerSubgroup > MaxOutputPerSubgroupLimit); if (!unsupportedStageMask && !unsupportedGeometryStage && !geometryRegisters) { return false; @@ -416,7 +426,15 @@ public sealed partial class RenderExecutor if (Interlocked.Exchange(ref _geometryWarningShown, 1) == 0) { - Console.Error.WriteLine("Warning: the title uses unsupported graphics pipelines; some draw calls were skipped."); + Console.Error.WriteLine( + "Warning: the title uses unsupported graphics pipelines; some draw calls were skipped. " + + $"stages=0x{stages:X8} subgroup=0x{shaderInterface.PrimitiveShaderSubgroupControl:X8} " + + $"maxOutput=0x{shaderInterface.MaxOutputPerSubgroup:X8} " + + $"maxVerticesOut=0x{shaderInterface.GeometryMaxVerticesOut:X8} " + + $"outputPrimitive=0x{shaderInterface.GeometryOutputPrimitiveType:X8} " + + $"export=0x{vertex.ExportAddress:X16} geometry=0x{vertex.GeometryAddress:X16} " + + $"unsupportedMask={unsupportedStageMask} unsupportedGeometry={unsupportedGeometryStage} " + + $"unsupportedRegisters={geometryRegisters}."); } if (RenderTrace.Enabled && RenderTrace.GeometryStageSkip()) diff --git a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs index caddd5a..4da0543 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs @@ -52,6 +52,7 @@ public class ShaderProgramInfo public int InstanceOffsetScalarRegister { get; init; } = NoScalarRegister; public bool UsesDeviceAddresses { get; init; } public bool HasBitwiseExclusiveOr { get; init; } + public Pipelines.ConstantFill? ConstantFill { get; init; } public BufferResourceInfo[] Buffers { get; init; } = []; public ImageResourceInfo[] Images { get; init; } = []; public int SamplerCount { get; init; } diff --git a/src/SharpEmu.Libs/Ime/ImeExports.cs b/src/SharpEmu.Libs/Ime/ImeExports.cs index 7235731..84d92cd 100644 --- a/src/SharpEmu.Libs/Ime/ImeExports.cs +++ b/src/SharpEmu.Libs/Ime/ImeExports.cs @@ -33,6 +33,24 @@ public static class ImeExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport( + Nid = "VkqLPArfFdc", + ExportName = "sceImeKeyboardGetInfo", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceIme")] + public static int ImeKeyboardGetInfo(CpuContext ctx) + { + var infoAddress = ctx[CpuRegister.Rsi]; + if (infoAddress != 0) + { + Span info = stackalloc byte[0x88]; + _ = ctx.Memory.TryWrite(infoAddress, info); + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + [SysAbiExport( Nid = "dKadqZFgKKQ", ExportName = "sceImeKeyboardGetResourceId", diff --git a/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs index d993c96..cb1edc7 100644 --- a/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelEventFlagCompatExports.cs @@ -60,8 +60,16 @@ public static class KernelEventFlagCompatExports return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } - if (!TryReadNullTerminatedUtf8(ctx, nameAddress, MaxEventFlagNameLength + 1, out var name)) + // Event flag names are commonly stored in executable/read-only image + // pages. Use the kernel compatibility reader so those pages can use + // its guarded host-read fallback when the guest mapping's protection + // metadata temporarily rejects a normal TryRead call. + if (!KernelMemoryCompatExports.TryReadNullTerminatedUtf8( + ctx, nameAddress, MaxEventFlagNameLength + 1, out var name)) { + Console.Error.WriteLine( + $"[LOADER][WARN] event_flag name read failed address=0x{nameAddress:X16} " + + $"range={ctx.Memory.DescribeReadRange(nameAddress, (ulong)(MaxEventFlagNameLength + 1))}"); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } @@ -78,8 +86,11 @@ public static class KernelEventFlagCompatExports Bits = initialPattern, }; - if (!ctx.TryWriteUInt64(outAddress, handle)) + if (!KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, outAddress, handle)) { + Console.Error.WriteLine( + $"[LOADER][WARN] event_flag handle write failed address=0x{outAddress:X16} " + + $"range={ctx.Memory.DescribeReadRange(outAddress, sizeof(ulong))}"); _eventFlags.TryRemove(handle, out _); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } diff --git a/src/SharpEmu.Libs/Kernel/KernelExports.cs b/src/SharpEmu.Libs/Kernel/KernelExports.cs index a935ef5..e32c545 100644 --- a/src/SharpEmu.Libs/Kernel/KernelExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelExports.cs @@ -11,6 +11,8 @@ public static class KernelExports private static readonly object _cxaGate = new(); private static readonly List _cxaDestructors = new(); private static readonly object _coredumpGate = new(); + private const string RepeatedGuestAssertion = "Assertion failed: 0"; + private static int _repeatedGuestAssertionCount; private static ulong _coredumpHandler; private static ulong _coredumpHandlerContext; @@ -30,7 +32,8 @@ public static class KernelExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } - + // KytyPS5 libKernel.cpp KernelGetOperationMode: *mode = 2 (PS5 base console, not Pro), + // *submode = 0 (none). Games poll this at high frequency. [SysAbiExport( Nid = "NH6xARDOVv8", ExportName = "sceKernelGetOperationMode", @@ -38,8 +41,19 @@ public static class KernelExports LibraryName = "libKernel")] public static int KernelGetOperationMode(CpuContext ctx) { - // SCE_KERNEL_MODE_RELEASE = 0. Reporting retail/release mode keeps guest - // code from taking devkit/tool-only branches we do not emulate. + var modeAddress = ctx[CpuRegister.Rdi]; + var submodeAddress = ctx[CpuRegister.Rsi]; + + if (modeAddress != 0 && !ctx.TryWriteUInt32(modeAddress, 2)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + if (submodeAddress != 0 && !ctx.TryWriteUInt32(submodeAddress, 0)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } @@ -223,7 +237,8 @@ public static class KernelExports ctx, attrAddress, out var priority, - out var affinityMask); + out var affinityMask, + out var requestedStackSize); KernelPthreadExtendedCompatExports.RegisterThreadStart( threadHandle, name, @@ -252,7 +267,8 @@ public static class KernelExports attrAddress, name, priority, - affinityMask); + affinityMask, + requestedStackSize); if (!scheduler.TryStartThread(ctx, request, out var error)) { Console.Error.WriteLine( @@ -402,19 +418,42 @@ public static class KernelExports ulong fmtPtr = ctx[CpuRegister.Rdi]; string fmt = ReadCString(ctx, fmtPtr, 4096); string outStr = KernelMemoryCompatExports.FormatStringFromVarArgs(ctx, fmt, firstGpArgIndex: 1); - if (outStr.EndsWith('\n') || outStr.EndsWith('\r')) + var byteCount = System.Text.Encoding.UTF8.GetByteCount(outStr); + if (ShouldWriteGuestPrintf(outStr)) { - Console.Write($"[DEBUG][PRINF] {outStr}"); - } - else - { - Console.WriteLine($"[DEBUG][PRINF] {outStr}"); + if (outStr.EndsWith('\n') || outStr.EndsWith('\r')) + { + Console.Write($"[DEBUG][PRINF] {outStr}"); + } + else + { + Console.WriteLine($"[DEBUG][PRINF] {outStr}"); + } } - ctx[CpuRegister.Rax] = (ulong)System.Text.Encoding.UTF8.GetByteCount(outStr); + ctx[CpuRegister.Rax] = (ulong)byteCount; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // Demon\'s Souls can print this same guest-side diagnostic thousands of times during + // bootstrap. Printing it does not convey new state and serializes the emulation threads, + // but printf must still report its original byte count to the guest. + private static bool ShouldWriteGuestPrintf(string text) + { + if (!string.Equals(text.TrimEnd('\r', '\n'), RepeatedGuestAssertion, StringComparison.Ordinal)) + { + return true; + } + + var count = Interlocked.Increment(ref _repeatedGuestAssertionCount); + if (count == 2) + { + Console.WriteLine("[DEBUG][PRINF] Further repeated 'Assertion failed: 0' messages are suppressed."); + } + + return count == 1; + } + [SysAbiExport( Nid = "EMutwaQ34Jo", ExportName = "perror", @@ -521,4 +560,5 @@ public static class KernelExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + } diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs index 37fd893..e563f75 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.BackedMappings.cs @@ -13,6 +13,16 @@ public static partial class KernelMemoryCompatExports private const int MemoryAccessDenied = unchecked((int)0x8002000D); private const int MemoryFault = unchecked((int)0x8002000E); private const int MemoryInvalidArgument = unchecked((int)0x80020016); + // Titles use sceKernelReserveVirtualRange as an address-space reservation + // primitive. Some of them ask for hundreds of GiB even though only small + // portions are ever mapped. Windows cannot always create one contiguous + // placeholder that large after the executable and runtime are loaded. + private const ulong SparseReservationThreshold = 0x10_0000_0000UL; + // macOS reserves 0x10_0000_0000..0x70_0000_0000 for graphics memory (see + // DefaultMapSearchBase), so sparse reservations start above that window there. + private static ulong SparseReservationBase => + OperatingSystem.IsMacOS() ? 0x70_0000_0000UL : 0x40_0000_0000UL; + private const ulong UserAddressLimit = 0xFC_0000_0000UL; private static FlexibleBackingPool _flexibleBacking = new(GuestMemoryLayout.FlexibleOffset, GuestMemoryLayout.FlexibleBytes); private static IGuestBackedSpace? _backingOwner; @@ -249,7 +259,9 @@ public static partial class KernelMemoryCompatExports { if (requested >= 0x10_0000_0000UL && requested < 0xFC_0000_0000UL && Interlocked.Exchange(ref _prtMapWarning, 1) == 0) - Console.Error.WriteLine("[LOADER][WARN] Shared mapping cannot replace the reserved aperture."); + Console.Error.WriteLine( + $"[LOADER][WARN] Shared mapping cannot replace the reserved aperture: address=0x{requested:X16} size=0x{length:X} flags=0x{flags:X} " + + $"regions=[{string.Join(",", GetMappingSlices(requested, length).Select(region => $"0x{region.Address:X}+0x{region.Length:X}:{(region.IsReserved ? "reserved" : region.IsDirect ? "direct" : region.IsFlexible ? "flexible" : "other")}:backed={space.IsBackedView(region.Address)}"))}]."); return false; } address = requested; @@ -335,7 +347,10 @@ public static partial class KernelMemoryCompatExports if (pointer == 0 || !IsValidMapRange(length, alignment)) return MemoryInvalidArgument; if (!ctx.TryReadUInt64(pointer, out var requested)) + { + Console.Error.WriteLine($"[LOADER][WARN] reserve_virtual pointer read failed pointer=0x{pointer:X16} size=0x{length:X}"); return MemoryFault; + } alignment = alignment == 0 ? OrbisPageSize : alignment; if ((flags & OrbisKernelMapFixed) != 0 && (requested == 0 || !IsAligned(requested, OrbisPageSize) || requested > ulong.MaxValue - length)) @@ -343,15 +358,107 @@ public static partial class KernelMemoryCompatExports lock (_memoryGate) { var space = ResolveBackingSpace(ctx); - if (space is null || !TrySelectBackingAddress(space, requested, length, alignment, flags, out var address, reuseReservation: false)) + var address = 0UL; + var sparseSelected = (flags & OrbisKernelMapFixed) == 0 && + TrySelectSparseReservationAddress(requested, length, alignment, flags, out address); + var selected = sparseSelected; + if (!selected && space is not null) + { + selected = TrySelectBackingAddress(space, requested, length, alignment, flags, + out address, reuseReservation: false); + } + + // A sparse reservation is only a guest mapping-table entry and + // intentionally does not need a host backing space yet. The host + // range is acquired later when a subrange is committed or mapped. + if (!selected || (!sparseSelected && space is null)) + { + Console.Error.WriteLine($"[LOADER][WARN] reserve_virtual address selection failed requested=0x{requested:X16} size=0x{length:X} sparse={sparseSelected} backing={space is not null}"); return MemoryNoSpace; + } + if (ShouldTraceDirectMemory()) + Console.Error.WriteLine($"[LOADER][TRACE] reserve_virtual selected address=0x{address:X16} requested=0x{requested:X16} size=0x{length:X} sparse={sparseSelected}"); ReplaceMappedRegionRangeLocked(new MappedRegion(address, length, 0, false, false, 0, IsReserved: true)); if (ShouldTraceDirectMemory()) Console.Error.WriteLine($"[LOADER][TRACE] reserve_virtual address=0x{address:X} requested=0x{requested:X} size=0x{length:X} flags=0x{flags:X}"); - return ctx.TryWriteUInt64(pointer, address) ? 0 : MemoryFault; + if (!ctx.TryWriteUInt64(pointer, address)) + { + Console.Error.WriteLine($"[LOADER][WARN] reserve_virtual pointer write failed pointer=0x{pointer:X16} address=0x{address:X16}"); + return MemoryFault; + } + return 0; } } + // Keep very large virtual reservations in the guest mapping table without + // requiring one equally large host placeholder. Actual backed mappings still + // reserve their individual host ranges when they are committed below this + // reservation. Fixed reservations never take this path: they must preserve + // the exact host address semantics of the original request. + private static bool TrySelectSparseReservationAddress( + ulong requested, + ulong length, + ulong alignment, + ulong flags, + out ulong address) + { + address = 0; + if ((flags & OrbisKernelMapFixed) != 0 || length < SparseReservationThreshold || + length > UserAddressLimit || alignment == 0) + { + return false; + } + + var candidate = requested == 0 + ? SparseReservationBase + : Math.Max(requested, SparseReservationBase); + candidate = AlignUp(candidate, alignment); + if (candidate == 0) + { + return false; + } + + // The requested address is a hint for a non-fixed reservation. If the + // hint is too high to contain the complete range, restart from the + // sparse aperture instead of treating the hint as a lower bound. + if (candidate > UserAddressLimit - length) + { + candidate = AlignUp(SparseReservationBase, alignment); + if (candidate == 0 || candidate > UserAddressLimit - length) + return false; + } + + foreach (var region in _mappedRegions.Values) + { + if (region.Address >= candidate + length) + { + break; + } + + var regionEnd = region.Address + region.Length; + if (regionEnd <= candidate) + { + continue; + } + + candidate = AlignUp(regionEnd, alignment); + if (candidate == 0 || candidate > UserAddressLimit - length) + { + return false; + } + } + + if (candidate == 0 || candidate > UserAddressLimit - length || + _mappedRegions.Values.Any(region => + region.Address < candidate + length && candidate < region.Address + region.Length)) + { + return false; + } + + address = candidate; + return true; + } + private static bool HasPhysicalSpan(ulong start, ulong length) => _directAllocations.ContainsAllocatedRange(start, length); diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 5b32193..fd59b42 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -2064,6 +2064,13 @@ public static partial class KernelMemoryCompatExports } } + [SysAbiExport( + Nid = "VAzswvTOCzI", + ExportName = "unlink", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int PosixUnlink(CpuContext ctx) => KernelUnlink(ctx); + [SysAbiExport( Nid = "1-LFLmRFxxM", ExportName = "sceKernelMkdir", @@ -2281,6 +2288,16 @@ public static partial class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; } + // A host movie watchdog may have closed the decoder while the guest + // still owns its Bink file descriptor. Return EOF so the guest closes + // that descriptor and proceeds to the next movie instead of waiting + // forever on a stale native decoder. + if (HostMovieBridge.ShouldForceGuestMovieEof(stream.Name)) + { + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + long positionBefore; try { diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs index c731b1c..3cea481 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs @@ -39,6 +39,11 @@ public static class KernelPthreadCompatExports private static readonly Dictionary _condStates = new(); private static readonly Dictionary _onceGates = new(); private static readonly Dictionary _condAttrStates = new(); + // Only these exact allocation bases are owned by the pthread compatibility + // layer. Guest pthread objects are commonly stored inside reusable blocks; + // never pass one of those storage addresses to TryFreeGuestMemory when a + // stale handle is destroyed. + private static readonly ConcurrentDictionary _opaqueAllocations = new(); private static readonly bool _tracePthreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREADS"), "1", StringComparison.Ordinal); private static readonly bool _tracePthreadConds = @@ -283,7 +288,11 @@ public static class KernelPthreadCompatExports public static int PthreadYield(CpuContext ctx) { _ = ctx; - Thread.Yield(); + if (!Thread.Yield()) + { + Thread.Sleep(0); + } + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } @@ -294,6 +303,13 @@ public static class KernelPthreadCompatExports LibraryName = "libKernel")] public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx); + [SysAbiExport( + Nid = "6XG4B33N09g", + ExportName = "sched_yield", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int SchedYield(CpuContext ctx) => PthreadYield(ctx); + [SysAbiExport( Nid = "GBUY7ywdULE", ExportName = "scePthreadRename", @@ -1696,6 +1712,8 @@ public static class KernelPthreadCompatExports return false; } + _opaqueAllocations[address] = 0; + Span initialData = stackalloc byte[size]; initialData.Clear(); if (ctx.Memory.TryWrite(address, initialData)) @@ -1710,7 +1728,9 @@ public static class KernelPthreadCompatExports private static void FreeOpaqueObject(CpuContext ctx, ulong address) { - if (address != 0 && ctx.Memory is IGuestMemoryAllocator allocator) + if (address != 0 && + _opaqueAllocations.TryRemove(address, out _) && + ctx.Memory is IGuestMemoryAllocator allocator) { _ = allocator.TryFreeGuestMemory(address); } diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs index 5d0bc4f..1a09063 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs @@ -20,6 +20,7 @@ public static class KernelPthreadExtendedCompatExports private const ulong NativeGuestStackSize = 0x20_0000UL; private const ulong NativeGuestStackStride = 0x100_0000UL; private const int DefaultInheritSched = 4; + private const int DefaultSoloSched = 0; private const int DefaultSchedPolicy = 1; private const int DefaultSchedPriority = DefaultThreadPriority; private const ulong SyntheticRwlockHandleBase = 0x00006003_0000_0000; @@ -44,12 +45,14 @@ public static class KernelPthreadExtendedCompatExports CpuContext ctx, ulong attrAddress, out int priority, - out ulong affinityMask) + out ulong affinityMask, + out ulong stackSize) { if (attrAddress == 0) { priority = DefaultThreadPriority; affinityMask = DefaultThreadAffinityMask; + stackSize = DefaultStackSize; return; } @@ -59,6 +62,7 @@ public static class KernelPthreadExtendedCompatExports var attributes = GetOrCreateAttrStateLocked(resolvedAddress); priority = attributes.SchedPriority; affinityMask = attributes.AffinityMask; + stackSize = attributes.StackSize; } } @@ -189,7 +193,8 @@ public static class KernelPthreadExtendedCompatExports ulong GuardSize, int InheritSched, int SchedPolicy, - int SchedPriority) + int SchedPriority, + int SoloSched) { public static PthreadAttrState Default => new( @@ -200,7 +205,8 @@ public static class KernelPthreadExtendedCompatExports DefaultGuardSize, DefaultInheritSched, DefaultSchedPolicy, - DefaultSchedPriority); + DefaultSchedPriority, + DefaultSoloSched); } [SysAbiExport( @@ -860,6 +866,59 @@ public static class KernelPthreadExtendedCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport( + Nid = "Dk6FC-TI+7Q", + ExportName = "scePthreadAttrSetsolosched", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int PthreadAttrSetsolosched(CpuContext ctx) + { + var attrAddress = ctx[CpuRegister.Rdi]; + var soloSched = unchecked((int)ctx[CpuRegister.Rsi]); + if (attrAddress == 0) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + lock (_stateGate) + { + var state = GetOrCreateAttrStateLocked(attrAddress); + _attrStates[attrAddress] = state with { SoloSched = soloSched }; + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + + [SysAbiExport( + Nid = "9RnL-m0+diQ", + ExportName = "scePthreadAttrGetsolosched", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libKernel")] + public static int PthreadAttrGetsolosched(CpuContext ctx) + { + var attrAddress = ctx[CpuRegister.Rdi]; + var outSoloSchedAddress = ctx[CpuRegister.Rsi]; + if (attrAddress == 0 || outSoloSchedAddress == 0) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; + } + + PthreadAttrState state; + lock (_stateGate) + { + state = GetOrCreateAttrStateLocked(attrAddress); + } + + if (!TryWriteInt32(ctx, outSoloSchedAddress, state.SoloSched)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + ctx[CpuRegister.Rax] = 0; + return (int)OrbisGen2Result.ORBIS_GEN2_OK; + } + /// /// The POSIX-named alias of . libKernel /// exports the same routine under two NIDs; middleware compiled against the diff --git a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs index 5eacd84..5dfe103 100644 --- a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs @@ -52,7 +52,8 @@ public static class KernelSemaphoreCompatExports return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } - if (!TryReadNullTerminatedUtf8(ctx, nameAddress, MaxSemaphoreNameLength, out var name)) + if (!KernelMemoryCompatExports.TryReadNullTerminatedUtf8( + ctx, nameAddress, MaxSemaphoreNameLength, out var name)) { return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } diff --git a/src/SharpEmu.Libs/Media/HostMovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs index bc8ac84..e91c310 100644 --- a/src/SharpEmu.Libs/Media/HostMovieBridge.cs +++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs @@ -42,6 +42,7 @@ internal static class HostMovieBridge private static byte[]? _frameBuffer; private static bool _frameBufferPresented; private static MediaFramePlayback? _playback; + private static Timer? _playbackWatchdog; private static long _frameSerial; private static uint _presentationWidth = MaxHostVideoWidth; private static uint _presentationHeight = MaxHostVideoHeight; @@ -287,6 +288,7 @@ internal static class HostMovieBridge _frameBuffer = GC.AllocateUninitializedArray(GetFrameBufferLength(info)); _frameBufferPresented = false; FillDummyFrame(_frameBuffer, info.Width, info.Height); + ArmPlaybackWatchdogLocked(hostPath); Console.Error.WriteLine( "[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + "."); @@ -301,6 +303,53 @@ internal static class HostMovieBridge _activePath = hostPath; _activeInfo = info; _playback = new MediaFramePlayback(decoder); + ArmPlaybackWatchdogLocked(hostPath); + } + + private static void ArmPlaybackWatchdogLocked(string hostPath) + { + _playbackWatchdog?.Dispose(); + var timeout = TryReadBinkInfo(hostPath, out var info) + ? GetPlaybackWatchdogTimeout(info) + : TimeSpan.FromSeconds(150); + _playbackWatchdog = new Timer( + static state => + { + var path = (string)state!; + lock (Gate) + { + if (!string.Equals(_activePath, path, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + Console.Error.WriteLine( + "[LOADER][WARN] Bink2 host watchdog expired for " + + Path.GetFileName(path) + "; advancing to the next movie."); + TimedOutMoviePaths.Add(path); + CloseActiveLocked(); + AttachNextQueuedMovieLocked(); + } + }, + hostPath, + timeout, + Timeout.InfiniteTimeSpan); + } + + // Must outlast the whole movie: Demon's Souls ships a 171 s intro and 756 s credits. + internal static TimeSpan GetPlaybackWatchdogTimeout(Bink2MovieInfo info) + { + if (info.FrameCount == 0 || + info.FramesPerSecondNumerator == 0 || + info.FramesPerSecondDenominator == 0) + { + return TimeSpan.FromSeconds(150); + } + + var duration = (double)info.FrameCount * + info.FramesPerSecondDenominator / + info.FramesPerSecondNumerator; + return TimeSpan.FromSeconds(Math.Max(90, duration + 60)); } internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info) @@ -319,6 +368,7 @@ internal static class HostMovieBridge info = new Bink2MovieInfo( BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(8, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4))); return info.FramesPerSecondNumerator != 0 && @@ -349,6 +399,8 @@ internal static class HostMovieBridge private static void CloseActiveLocked() { + _playbackWatchdog?.Dispose(); + _playbackWatchdog = null; _playback?.Dispose(); _playback = null; _activePath = null; @@ -364,6 +416,7 @@ internal static class HostMovieBridge [StructLayout(LayoutKind.Sequential)] internal readonly struct Bink2MovieInfo { + public readonly uint FrameCount; public readonly uint Width; public readonly uint Height; public readonly uint FramesPerSecondNumerator; @@ -372,14 +425,25 @@ internal static class HostMovieBridge internal Bink2MovieInfo( uint width, uint height, + uint frameCount, uint framesPerSecondNumerator, uint framesPerSecondDenominator) { Width = width; Height = height; + FrameCount = frameCount; FramesPerSecondNumerator = framesPerSecondNumerator; FramesPerSecondDenominator = framesPerSecondDenominator; } + + internal Bink2MovieInfo( + uint width, + uint height, + uint framesPerSecondNumerator, + uint framesPerSecondDenominator) + : this(width, height, 0, framesPerSecondNumerator, framesPerSecondDenominator) + { + } } private enum MovieMode @@ -393,6 +457,16 @@ internal static class HostMovieBridge private static readonly Queue PendingMoviePaths = new(); private static readonly HashSet PendingMoviePathSet = new(StringComparer.OrdinalIgnoreCase); + private static readonly HashSet TimedOutMoviePaths = + new(StringComparer.OrdinalIgnoreCase); + + internal static bool ShouldForceGuestMovieEof(string hostPath) + { + lock (Gate) + { + return TimedOutMoviePaths.Contains(hostPath); + } + } private static void AttachNextQueuedMovieLocked() { while (PendingMoviePaths.Count > 0) @@ -475,6 +549,7 @@ internal static class HostMovieBridge { lock (Gate) { + TimedOutMoviePaths.Remove(hostPath); if (PendingMoviePathSet.Remove(hostPath)) { var retained = PendingMoviePaths diff --git a/src/SharpEmu.Libs/Network/HttpExports.cs b/src/SharpEmu.Libs/Network/HttpExports.cs index 9c01e0e..06f8a15 100644 --- a/src/SharpEmu.Libs/Network/HttpExports.cs +++ b/src/SharpEmu.Libs/Network/HttpExports.cs @@ -18,7 +18,12 @@ public static partial class HttpExports private sealed record HttpContext(int NetMemoryId, int SslContextId, ulong PoolSize); - private sealed record HttpTemplate(int ContextId, ulong UserAgentAddress, int HttpVersion, bool AutoProxyConfig); + private sealed record HttpTemplate( + int ContextId, + ulong UserAgentAddress, + int HttpVersion, + bool AutoProxyConfig, + uint ConnectTimeoutMicroseconds = 30_000_000); [SysAbiExport( Nid = "A9cVMUtEp4Y", @@ -78,6 +83,30 @@ public static partial class HttpExports : ctx.SetReturn(HttpErrorInvalidId); } + [SysAbiExport( + Nid = "0S9tTH0uqTU", + ExportName = "sceHttpSetConnectTimeOut", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceHttp")] + public static int HttpSetConnectTimeOut(CpuContext ctx) + { + var id = unchecked((int)ctx[CpuRegister.Rdi]); + var timeoutMicroseconds = unchecked((uint)ctx[CpuRegister.Rsi]); + if (timeoutMicroseconds == 0) + { + return ctx.SetReturn(HttpErrorInvalidValue); + } + + if (!Templates.TryGetValue(id, out var template)) + { + return ctx.SetReturn(HttpErrorInvalidId); + } + + Templates[id] = template with { ConnectTimeoutMicroseconds = timeoutMicroseconds }; + TraceHttp("set_connect_timeout", id, timeoutMicroseconds, 0, 0, 0); + return ctx.SetReturn(0); + } + [SysAbiExport( Nid = "Ik-KpLTlf7Q", ExportName = "sceHttpTerm", diff --git a/src/SharpEmu.Libs/Network/NetExports.cs b/src/SharpEmu.Libs/Network/NetExports.cs index 1ecb14a..2f01884 100644 --- a/src/SharpEmu.Libs/Network/NetExports.cs +++ b/src/SharpEmu.Libs/Network/NetExports.cs @@ -25,6 +25,7 @@ public static class NetExports private const int NetErrnoAddressInUse = 48; private const int NetErrnoNotInitialized = 200; private const int MaxNameLength = 256; + private static ReadOnlySpan OfflineMacAddress => [0x02, 0x53, 0x48, 0x41, 0x52, 0x50]; private static readonly ConcurrentDictionary _pools = new(); private static readonly ConcurrentDictionary _resolvers = new(); @@ -117,6 +118,24 @@ public static class NetExports return ctx.SetReturn(0); } + [SysAbiExport( + Nid = "6Oc0bLsIYe0", + ExportName = "sceNetGetMacAddress", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNet")] + public static int NetGetMacAddress(CpuContext ctx) + { + var destinationAddress = ctx[CpuRegister.Rdi]; + var flags = unchecked((int)ctx[CpuRegister.Rsi]); + if (destinationAddress == 0 || flags != 0 || !ctx.Memory.TryWrite(destinationAddress, OfflineMacAddress)) + { + return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument); + } + + TraceNet("get_mac_address", 0, destinationAddress, unchecked((ulong)flags), 0); + return ctx.SetReturn(0); + } + [SysAbiExport( Nid = "Q4qBuN-c0ZM", ExportName = "sceNetSocket", diff --git a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs index 21f42db..5c60943 100644 --- a/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs +++ b/src/SharpEmu.Libs/Np/NpEntitlementAccessExports.cs @@ -23,15 +23,22 @@ public static class NpEntitlementAccessExports private const int NpEntitlementAccessErrorParameter = unchecked((int)0x817D0002); private const int NpEntitlementAccessErrorNoEntitlement = unchecked((int)0x817D0007); + private const int EntitlementKeySize = 16; - // Offline add-on entitlements titles query through NpEntitlementAccess. + // Offline entitlements queried by titles through NpEntitlementAccess. // GTA V Enhanced (PPSA04264) gates Story Mode on these three labels; without // them the frontend offers "Buy GTAV Story Mode" despite a full dump. + // Ghost of Yotei (PPSA26344) separately checks its application and base + // game entitlements before it creates the frontend. These represent the + // locally installed main package only; optional editions and add-ons remain + // unowned. private static readonly AddcontEntitlement[] OwnedAddcontEntitlements = [ new("85y-je", PackageTypePsal, DownloadStatusInstalled), new("5d5c48", PackageTypePsal, DownloadStatusInstalled), new("_mtqu6", PackageTypePsal, DownloadStatusInstalled), + new("GHOST2APP0000000", PackageTypePsal, DownloadStatusInstalled), + new("GHOST2BASE000000", PackageTypePsal, DownloadStatusInstalled), ]; private readonly record struct AddcontEntitlement( @@ -193,6 +200,40 @@ public static class NpEntitlementAccessExports return ctx.SetReturn(NpEntitlementAccessErrorNoEntitlement); } + [SysAbiExport( + Nid = "5LiMEPuW0DQ", + ExportName = "sceNpEntitlementAccessGetEntitlementKey", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNpEntitlementAccess")] + public static int NpEntitlementAccessGetEntitlementKey(CpuContext ctx) + { + var labelAddress = ctx[CpuRegister.Rsi]; + var keyAddress = ctx[CpuRegister.Rdx]; + if (labelAddress == 0 || keyAddress == 0) + { + return ctx.SetReturn(NpEntitlementAccessErrorParameter); + } + + Span labelBytes = stackalloc byte[EntitlementLabelSize]; + if (!ctx.Memory.TryRead(labelAddress, labelBytes)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + var label = ReadEntitlementLabel(labelBytes); + Span key = stackalloc byte[EntitlementKeySize]; + key.Clear(); + if (!ctx.Memory.TryWrite(keyAddress, key)) + { + return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + return ctx.SetReturn(OwnedAddcontEntitlements.Any(entitlement => + string.Equals(label, entitlement.Label, StringComparison.Ordinal)) + ? (int)OrbisGen2Result.ORBIS_GEN2_OK + : NpEntitlementAccessErrorNoEntitlement); + } + private static bool TryWriteAddcontEntitlementInfo( CpuContext ctx, ulong address, diff --git a/src/SharpEmu.Libs/Np/NpManagerExports.cs b/src/SharpEmu.Libs/Np/NpManagerExports.cs index ab43b2c..4f69258 100644 --- a/src/SharpEmu.Libs/Np/NpManagerExports.cs +++ b/src/SharpEmu.Libs/Np/NpManagerExports.cs @@ -11,6 +11,7 @@ public static class NpManagerExports private const int NpTitleIdSize = 16; private const int NpTitleSecretSize = 128; private const int NpErrorInvalidArgument = unchecked((int)0x80550003); + private static int _nextNpRequestId; [SysAbiExport( Nid = "3Zl8BePTh9Y", @@ -23,6 +24,48 @@ public static class NpManagerExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport(Nid = "eiqMCt9UshI", ExportName = "sceNpCreateAsyncRequest", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpCreateAsyncRequest(CpuContext ctx) => SetReturn(ctx, NextRequestId()); + + [SysAbiExport(Nid = "KfGZg2y73oM", ExportName = "sceNpCheckNpReachability", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpCheckNpReachability(CpuContext ctx) => + SetReturn(ctx, unchecked((int)ctx[CpuRegister.Rdi]) > 0 ? (int)OrbisGen2Result.ORBIS_GEN2_OK : NpErrorInvalidArgument); + + [SysAbiExport(Nid = "GpLQDNKICac", ExportName = "sceNpCreateRequest", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpCreateRequest(CpuContext ctx) => SetReturn(ctx, NextRequestId()); + + [SysAbiExport(Nid = "OzKvTvg3ZYU", ExportName = "sceNpAbortRequest", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpAbortRequest(CpuContext ctx) => SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + + [SysAbiExport(Nid = "uqcPJLWL08M", ExportName = "sceNpPollAsync", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceNpManager")] + public static int NpPollAsync(CpuContext ctx) + { + var requestId = unchecked((int)ctx[CpuRegister.Rdi]); + var resultAddress = ctx[CpuRegister.Rsi]; + if (requestId <= 0) + { + return SetReturn(ctx, NpErrorInvalidArgument); + } + + if (resultAddress != 0) + { + Span result = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(result, 0); + if (!ctx.Memory.TryWrite(resultAddress, result)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_OK); + } + + private static int NextRequestId() + { + var id = Interlocked.Increment(ref _nextNpRequestId) & 0x7FFF_FFFF; + return id == 0 ? 1 : id; + } + [SysAbiExport( Nid = "S7QTn72PrDw", ExportName = "sceNpDeleteRequest", diff --git a/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs b/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs index 015d259..49eb507 100644 --- a/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs +++ b/src/SharpEmu.Libs/Np/NpSessionSignalingExports.cs @@ -7,6 +7,8 @@ namespace SharpEmu.Libs.Np; public static class NpSessionSignalingExports { + private static int _nextContextId; + [SysAbiExport( Nid = "ysmw6J-P8Ak", ExportName = "sceNpSessionSignalingInitialize", @@ -17,4 +19,16 @@ public static class NpSessionSignalingExports ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + + [SysAbiExport( + Nid = "aBuX0PX-T7I", + ExportName = "sceNpSessionSignalingCreateContext2", + Target = Generation.Gen5, + LibraryName = "libSceNpSessionSignaling")] + public static int NpSessionSignalingCreateContext2(CpuContext ctx) + { + var contextId = Interlocked.Increment(ref _nextContextId); + ctx[CpuRegister.Rax] = unchecked((ulong)contextId); + return contextId; + } } diff --git a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs index a497fe4..ba07186 100644 --- a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs +++ b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs @@ -134,6 +134,13 @@ public static class NpWebApi2Exports return Interlocked.Increment(ref _nextPushEventHandle); } + [SysAbiExport( + Nid = "QafxeZM3WK4", + ExportName = "sceNpWebApi2PushEventDeletePushContext", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceNpWebApi2")] + public static int NpWebApi2PushEventDeletePushContext(CpuContext ctx) => ctx.SetReturn(0); + private static bool IsValidLibraryContextId(int libraryContextId) { if (libraryContextId <= 0 || libraryContextId >= 0x8000) diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 6668fed..b6225a5 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -652,6 +652,32 @@ public static class PadExports return ctx.SetReturn(0); } + [SysAbiExport( + Nid = "rIZnR6eSpvk", + ExportName = "scePadResetOrientation", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadResetOrientation(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + return IsPrimaryPadHandle(handle) + ? ctx.SetReturn(0) + : ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + + [SysAbiExport( + Nid = "fCWdlnmB1Ks", + ExportName = "scePadIsRemoteController", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libScePad")] + public static int PadIsRemoteController(CpuContext ctx) + { + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + return IsPrimaryPadHandle(handle) + ? ctx.SetReturn(0) + : ctx.SetReturn(OrbisPadErrorInvalidHandle); + } + private static bool WriteNeutralPadData(CpuContext ctx, ulong dataAddress) { Span data = stackalloc byte[PadDataSize]; diff --git a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs index 1b92cfa..5a27739 100644 --- a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs +++ b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs @@ -464,6 +464,7 @@ internal static class RenderPhaseProfile ReportImageUploads(); BufferUploadProfile.Report(); + Console.Error.WriteLine(SharpEmu.ShaderCompiler.Resources.ResourceMaterializationCache.TakeReport()); SharpEmu.ShaderCompiler.Resources.ResourceMaterializationProfile.WriteReport(); SharpEmu.Libs.Diagnostics.AgcRegisterPacketProfile.WriteReport(); SharpEmu.HLE.GpuMemory.GpuMemoryAccessProfile.WriteReport(); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs b/src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs new file mode 100644 index 0000000..bc0fdc6 --- /dev/null +++ b/src/SharpEmu.Libs/VideoOut/VulkanStorageFeedbackTargetResolver.cs @@ -0,0 +1,53 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu; + +namespace SharpEmu.Libs.VideoOut; + +// Keeps a storage image out of the Vulkan color-attachment list when the guest +// disabled color writes. A storage image with active color writes still needs a +// pass split; silently binding both views would make the result undefined. +internal static class VulkanStorageFeedbackTargetResolver +{ + public static bool TryResolve( + IReadOnlyList targets, + IReadOnlyList textures, + IReadOnlyList blends, + out GuestRenderTarget[] resolvedTargets, + out bool usedCompatibilityAttachment, + out ulong unsupportedAddress) + { + if (targets.Count != blends.Count) + { + throw new ArgumentException( + "color attachment and blend-state counts must match", + nameof(blends)); + } + + resolvedTargets = targets.ToArray(); + usedCompatibilityAttachment = false; + unsupportedAddress = 0; + + for (var targetIndex = 0; targetIndex < targets.Count; targetIndex++) + { + var target = targets[targetIndex]; + if (target.Address == 0 || !textures.Any(texture => + texture.IsStorage && texture.Address == target.Address)) + { + continue; + } + + if (targets.Count != 1 || blends[targetIndex].WriteMask != 0) + { + unsupportedAddress = target.Address; + return false; + } + + resolvedTargets[targetIndex] = target with { Address = 0 }; + usedCompatibilityAttachment = true; + } + + return true; + } +} diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs index 11208bf..ae527a9 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs @@ -14,6 +14,18 @@ using VkBuffer = Silk.NET.Vulkan.Buffer; internal static unsafe partial class VulkanVideoPresenter { + private readonly record struct FeedbackSnapshotKey( + Format Format, + uint Width, + uint Height, + uint MipLevels, + uint Layers, + uint Samples, + ImageAspectFlags Aspect); + + private const int FeedbackSnapshotPoolMaxEntriesPerKey = 2; + private const ulong FeedbackSnapshotPoolMaxBytes = 256UL * 1024 * 1024; + // Display buffers the guest registered; the only image addresses a flip may capture. private static readonly HashSet _knownDisplayBuffers = new(); @@ -115,12 +127,17 @@ internal static unsafe partial class VulkanVideoPresenter public uint DestinationSelect; // One view per mip level for a storage image the program indexes by mip; empty otherwise. public ImageView[] MipViews = []; + // A draw-local copy used when the guest samples a depth aspect while the + // same image is attached for writes. It retires with the submission. + public bool IsFeedbackSnapshot; } private sealed partial class Presenter { private readonly List _trackedImageBindings = new(); private static int _guestImageDumpSequence; + private readonly Dictionary> _feedbackSnapshotPool = []; + private ulong _feedbackSnapshotPoolBytes; // Shader images for work without color targets: resolve, acquire, transition and release the bindings. private TextureResource[] ResolveDrawTextures(IReadOnlyList textures) @@ -514,10 +531,10 @@ internal static unsafe partial class VulkanVideoPresenter }; } - private Sampler ResolveSampler(in GuestSampler sampler) + private Sampler ResolveSampler(in GuestSampler sampler, Format viewFormat) { Span words = stackalloc uint[4] { sampler.Word0, sampler.Word1, sampler.Word2, sampler.Word3 }; - return _samplerStore.GetSampler(new SamplerDescriptorWords(words)); + return _samplerStore.GetSampler(new SamplerDescriptorWords(words), ViewFormatRules.IsIntegerFormat(viewFormat)); } // Views for every cached binding after the targets are bound; a stale image is found again first. @@ -571,12 +588,12 @@ internal static unsafe partial class VulkanVideoPresenter binding.Image = image.Backing.Handle; if (!binding.IsStorage) { - binding.Sampler = ResolveSampler(binding.SamplerState); + binding.Sampler = ResolveSampler(binding.SamplerState, binding.Request.View.Format); } } } - private CachedImage AcquireStencilStorage(TextureResource binding, CachedImage attachment) + private CachedImage AcquireStencilStorage(TextureResource binding, CachedImage attachment, bool writeBack = true) { var description = binding.Request.Description; if (description.Data.Address != attachment.Description.Stencil.Address || description.Data.Size > attachment.Description.Stencil.Size || @@ -586,7 +603,7 @@ internal static unsafe partial class VulkanVideoPresenter throw SubmissionScheduler.Fatal("The stencil storage request does not cover its attachment's stencil layout."); } - if (_hasBoundDepth && _boundDepth.Image == binding.ImageIdentifier && + if (writeBack && _hasBoundDepth && _boundDepth.Image == binding.ImageIdentifier && (_boundDepthLoadState.StencilTestEnabled || _boundDepthLoadState.StencilClearEnabled)) { throw SubmissionScheduler.Fatal("A stencil storage write cannot share an active stencil attachment."); @@ -595,7 +612,16 @@ internal static unsafe partial class VulkanVideoPresenter var preparation = RequirePreparation(); var stencilImages = preparation.StencilStorageImages ??= new(); if (stencilImages.TryGetValue(attachment, out var storage)) + { + if (writeBack) + { + preparation.StencilStorageWriteBackImages ??= new(); + preparation.StencilStorageWriteBackImages.Add(attachment); + storage.Binding.ShaderWrite = true; + } + return storage; + } var sampledRequest = binding.Request with { @@ -603,10 +629,19 @@ internal static unsafe partial class VulkanVideoPresenter View = binding.Request.View with { Usage = ImageUsageFlags.SampledBit }, }; _imageCache.AcquireTextureView(binding.ImageIdentifier, sampledRequest); - _imageCache.MarkGpuWritten(binding.ImageIdentifier); + if (writeBack) + { + _imageCache.MarkGpuWritten(binding.ImageIdentifier); + } + storage = attachment.CreateStencilStorageImage(); stencilImages.Add(attachment, storage); - storage.Binding.ShaderWrite = true; + storage.Binding.ShaderWrite = writeBack; + if (writeBack) + { + preparation.StencilStorageWriteBackImages ??= new(); + preparation.StencilStorageWriteBackImages.Add(attachment); + } attachment.CopyStencilStorage(storage, _bufferCache.GetUtilityBuffer(GpuBufferUsage.DeviceLocal), writeBack: false); if (_hasBoundDepth && _boundDepth.Image == binding.ImageIdentifier) { @@ -619,6 +654,182 @@ internal static unsafe partial class VulkanVideoPresenter return storage; } + private CachedImage CreateDepthFeedbackSnapshot(CachedImage source, ImageAspectFlags aspect) + { + if (source.Backing.ImageType != ImageType.Type2D || source.Backing.MipLevels == 0 || + source.Backing.Samples == 0) + { + throw SubmissionScheduler.Fatal( + $"A depth feedback snapshot needs a 2D image: type={source.Backing.ImageType} " + + $"mips={source.Backing.MipLevels} samples={source.Backing.Samples}."); + } + + var key = new FeedbackSnapshotKey( + source.Backing.Format, + source.Backing.Extent.Width, + source.Backing.Extent.Height, + source.Backing.MipLevels, + source.Backing.Layers, + source.Backing.Samples, + ImageAspectFlags.None); + var snapshot = RentFeedbackSnapshot(key); + if (snapshot is null) + { + var description = ImageDescription.Create(); + description.PixelFormat = source.Backing.Format; + description.GuestFormat = source.Description.GuestFormat; + description.Type = GuestImageType.Color2D; + description.Extent = new Extent3D(source.Backing.Extent.Width, source.Backing.Extent.Height, 1); + description.Resources = new SubresourceCount(source.Backing.MipLevels, source.Backing.Layers); + description.BytesPerBlock = source.Description.BytesPerBlock == 0 ? 1 : source.Description.BytesPerBlock; + description.Samples = source.Backing.Samples; + // The snapshot is host-owned. Keeping guest placement empty prevents + // the image cache from treating it as guest memory that needs upload. + description.Pitch = 0; + description.TileMode = GuestTileMode.Linear; + snapshot = new CachedImage(_deviceInfo, _scheduler, _guestBacking, description); + } + var range = new SubresourceRange(0, source.Backing.MipLevels, 0, source.Backing.Layers); + if ((aspect & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit)) != 0) + { + snapshot.CopyDepthStencilFrom( + source, + range, + source.Backing.Extent, + aspect & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit)); + var depthView = _boundDepth.Target.Target.Request.View; + var writes = _boundDepthLoadState.AttachmentWriteAspects(_boundDepth.Target.Target.Format); + source.Transition( + _boundDepthLayout, + AccessFlags.DepthStencilAttachmentReadBit | + (writes != 0 ? AccessFlags.DepthStencilAttachmentWriteBit : 0), + new SubresourceRange(depthView.BaseLevel, depthView.LevelCount, depthView.BaseLayer, depthView.LayerCount), + BeginBatchedGuestCommands()); + snapshot.Transition( + ImageLayout.DepthStencilReadOnlyOptimal, + AccessFlags.ShaderReadBit, + range, + BeginBatchedGuestCommands()); + } + else + { + snapshot.CopyFrom(source); + snapshot.Transition( + ImageLayout.ShaderReadOnlyOptimal, + AccessFlags.ShaderReadBit, + range, + BeginBatchedGuestCommands()); + } + + var preparation = RequirePreparation(); + preparation.FeedbackSnapshots ??= new(); + preparation.FeedbackSnapshots.Add(snapshot); + return snapshot; + } + + private CachedImage? RentFeedbackSnapshot(FeedbackSnapshotKey key) + { + if (!_feedbackSnapshotPool.TryGetValue(key, out var entries) || !entries.TryPop(out var snapshot)) + { + return null; + } + + _feedbackSnapshotPoolBytes -= snapshot.Backing.AllocationSize; + if (entries.Count == 0) + { + _feedbackSnapshotPool.Remove(key); + } + + return snapshot; + } + + private void RetireFeedbackSnapshot(CachedImage snapshot) + { + var key = new FeedbackSnapshotKey( + snapshot.Backing.Format, + snapshot.Backing.Extent.Width, + snapshot.Backing.Extent.Height, + snapshot.Backing.MipLevels, + snapshot.Backing.Layers, + snapshot.Backing.Samples, + ImageAspectFlags.None); + var allocation = snapshot.Backing.AllocationSize; + _feedbackSnapshotPool.TryGetValue(key, out var entries); + if (entries is null && _feedbackSnapshotPoolBytes + allocation <= FeedbackSnapshotPoolMaxBytes) + { + entries = new Stack(); + _feedbackSnapshotPool[key] = entries; + } + + if (entries is not null && entries.Count < FeedbackSnapshotPoolMaxEntriesPerKey && + _feedbackSnapshotPoolBytes + allocation <= FeedbackSnapshotPoolMaxBytes) + { + entries.Push(snapshot); + _feedbackSnapshotPoolBytes += allocation; + return; + } + + snapshot.Dispose(); + } + + private void DestroyFeedbackSnapshotPool() + { + foreach (var entries in _feedbackSnapshotPool.Values) + { + foreach (var snapshot in entries) + { + snapshot.Dispose(); + } + } + + _feedbackSnapshotPool.Clear(); + _feedbackSnapshotPoolBytes = 0; + } + + // Vulkan core does not permit a sampled depth aspect to alias a depth + // attachment that the same draw may write. Preserve the guest's + // read-before-write semantics with a private copy for this draw. + private void PrepareDepthFeedback(TextureResource[] bindings) + { + if (!_hasBoundDepth) + { + return; + } + + var depthImage = _imageCache.GetImage(_boundDepth.Image); + var attachmentView = _boundDepth.Target.Target.Request.View; + var writes = _boundDepthLoadState.AttachmentWriteAspects(_boundDepth.Target.Target.Format); + var writableDepth = writes & ImageAspectFlags.DepthBit; + if (writableDepth == 0) + { + return; + } + + foreach (var binding in bindings) + { + if (binding.IsHostMovie || binding.IsStorage || binding.CachedImage is not { } image || + !ReferenceEquals(image, depthImage) || !ViewsOverlap(binding.Request.View, attachmentView)) + { + continue; + } + + var sampledAspect = ViewFormatRules.IsStencilViewFormat(binding.Request.View.Format) + ? ImageAspectFlags.StencilBit + : ImageAspectFlags.DepthBit; + if ((sampledAspect & writableDepth) == 0) + { + continue; + } + + var snapshot = CreateDepthFeedbackSnapshot(image, sampledAspect); + binding.CachedImage = snapshot; + binding.Image = snapshot.Backing.Handle; + binding.View = snapshot.GetOrCreateView(binding.Request.View); + binding.MipViews = []; + binding.IsFeedbackSnapshot = true; + } + } + // The layout each binding reads through; a target read by its own draw uses the general layout. private void RecordTextureTransitions(TextureResource[] bindings) => RecordDrawTextureTransitions(bindings, null, GuestDepthState.Default); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs index c3a273a..143755c 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs @@ -95,7 +95,8 @@ internal static unsafe partial class VulkanVideoPresenter OneDimensional: image.Dimension is ImageDimension.Dim1D or ImageDimension.Dim1DArray, R128: image.R128, Multisampled: image.Dimension is ImageDimension.Dim2DMsaa or ImageDimension.Dim2DMsaaArray, - DepthCompare: image.DepthCompare); + DepthCompare: image.DepthCompare, + Atomic: image.Atomic); // Render-state discovery for one shader image; the view is acquired later with the draw. private TextureResource ResolveImageBinding(ImageResource image, uint[] words, ShaderProgramInfo program, int index) @@ -114,6 +115,20 @@ internal static unsafe partial class VulkanVideoPresenter imageIdentifier = ImageRequestBuilders.ValidateTextureOwner(_imageCache, imageIdentifier, resolution); BindImage(imageIdentifier, storage); var descriptor = new TextureDescriptorWords(words); + if (ShouldTraceTextureBindings()) + { + var cached = _imageCache.GetImage(imageIdentifier); + var description = cached.Description; + Console.Error.WriteLine( + $"TextureBinding stage={program.Stage} hash=0x{program.Hash:X16} index={index} " + + $"address=0x{new TextureDescriptorWords(words).BaseAddress:X16} " + + $"descriptor={descriptor.BaseAddress:X16} size={descriptor.Width + 1}x{descriptor.Height + 1} " + + $"format={(uint)descriptor.Format} tile={(uint)descriptor.TileMode} " + + $"image=0x{description.Data.Address:X16} size=0x{description.Data.Size:X} " + + $"extent={description.Extent.Width}x{description.Extent.Height} pitch={description.Pitch} " + + $"guestFormat={(uint)description.GuestFormat} imageTile={(uint)description.TileMode} " + + $"backing={cached.Backing.Extent.Width}x{cached.Backing.Extent.Height} format={cached.Backing.Format}"); + } return new TextureResource { Address = descriptor.BaseAddress, @@ -128,7 +143,31 @@ internal static unsafe partial class VulkanVideoPresenter } // Compare bits stay only on depth-compare samplers; a forced point sampler drops its filters. - private Sampler ResolveSampler(SamplerResource sampler, uint[] words, ShaderProgramInfo program, int index, ShaderStageResources stage) + // A sampler takes the numeric class of the views it samples; integer only when every + // paired view is integer, since a float view needs a float border and filtering. + private static bool SamplesIntegerViews(ShaderResourceInfo info, TextureResource[] images, int sampler) + { + var paired = false; + foreach (var pair in info.SampledPairs) + { + if (pair.Sampler != sampler || pair.Image >= images.Length || images[pair.Image].IsHostMovie) + { + continue; + } + + if (!ViewFormatRules.IsIntegerFormat(images[pair.Image].Request.View.Format)) + { + return false; + } + + paired = true; + } + + return paired; + } + + private Sampler ResolveSampler(SamplerResource sampler, uint[] words, ShaderProgramInfo program, int index, ShaderStageResources stage, + bool integerView) { if (words.Length < 4) { @@ -164,7 +203,7 @@ internal static unsafe partial class VulkanVideoPresenter $"user_data=[{string.Join(",", stage.Resources.UserData.Select(word => $"{word:X8}"))}]"); } - return _samplerStore.GetSampler(descriptor); + return _samplerStore.GetSampler(descriptor, integerView); } // The guest textures the movie path matches; built only while a decoded frame is active. @@ -216,7 +255,8 @@ internal static unsafe partial class VulkanVideoPresenter descriptors.Samplers = new Sampler[info.Samplers.Count]; for (var index = 0; index < info.Samplers.Count; index++) { - descriptors.Samplers[index] = ResolveSampler(info.Samplers[index], snapshot.Samplers[index], program, index, stage); + descriptors.Samplers[index] = ResolveSampler(info.Samplers[index], snapshot.Samplers[index], program, index, stage, + SamplesIntegerViews(info, descriptors.Images, index)); } var shaderData = new uint[layout.ShaderDataDwordCount]; @@ -852,6 +892,7 @@ internal static unsafe partial class VulkanVideoPresenter { DebugName = bindPoint == PipelineBindPoint.Compute ? "SharpEmu dispatch" : "SharpEmu draw", Textures = textures, + FeedbackSnapshots = preparation.FeedbackSnapshots?.ToArray() ?? [], OverflowBuffers = preparation.OverflowBuffers.Count == 0 ? null : preparation.OverflowBuffers.ToArray(), }); preparation.OverflowBuffers.Clear(); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs index e84876f..f638a4f 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs @@ -50,6 +50,9 @@ internal static unsafe partial class VulkanVideoPresenter private string? _pipelineCachePath; private bool _pipelineCacheDirty; private long _lastPipelineCacheSaveTick; + // Reading a mature driver cache can itself take several seconds. Keep the + // render thread focused on pipeline warm-up; teardown still saves eagerly. + private const long PipelineCacheCheckpointIntervalMs = 300_000; private Queue _queue; private uint _queueFamilyIndex; @@ -669,6 +672,7 @@ internal static unsafe partial class VulkanVideoPresenter VertexPipelineStoresAndAtomics = supportedFeatures.VertexPipelineStoresAndAtomics, FragmentStoresAndAtomics = supportedFeatures.FragmentStoresAndAtomics, ShaderInt64 = supportedFeatures.ShaderInt64, + ShaderFloat64 = supportedFeatures.ShaderFloat64, ShaderImageGatherExtended = supportedFeatures.ShaderImageGatherExtended, ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats, ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat, @@ -699,6 +703,15 @@ internal static unsafe partial class VulkanVideoPresenter "The device lacks the shaderInt64 feature, which the device-address programs need."); } + if (!supportedFeatures.ShaderFloat64) + { + Console.Error.WriteLine( + "[LOADER][WARN] GPU does not support shaderFloat64 " + + "translated shaders using double precision will fail."); + } + + + if (!supportedFeatures.VertexPipelineStoresAndAtomics || !supportedFeatures.FragmentStoresAndAtomics) { Console.Error.WriteLine( @@ -786,19 +799,33 @@ internal static unsafe partial class VulkanVideoPresenter SType = StructureType.PhysicalDeviceBufferDeviceAddressFeatures, PNext = &timelineSemaphoreFeatures, }; + var atomicInt64Features = new PhysicalDeviceShaderAtomicInt64Features + { + SType = StructureType.PhysicalDeviceShaderAtomicInt64Features, + PNext = &addressFeatures, + }; var featuresQuery = new PhysicalDeviceFeatures2 { SType = StructureType.PhysicalDeviceFeatures2, - PNext = &addressFeatures, + PNext = &atomicInt64Features, }; _vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery); var supportsTimelineSemaphore = timelineSemaphoreFeatures.TimelineSemaphore; var supportsBufferDeviceAddress = addressFeatures.BufferDeviceAddress; + var supportsSharedInt64Atomics = atomicInt64Features.ShaderSharedInt64Atomics; var supportsMaintenance8 = maintenance8Features.Maintenance8; var supportsRobustBufferAccess2 = robustness2Features.RobustBufferAccess2; var supportsRobustImageAccess2 = robustness2Features.RobustImageAccess2; var supportsNullDescriptor = robustness2Features.NullDescriptor; var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor; + SetSharedInt64AtomicsCapability(supportsSharedInt64Atomics); + if (!supportsSharedInt64Atomics) + { + Console.Error.WriteLine( + "[LOADER][WARN] GPU does not support shaderSharedInt64Atomics " + + "64-bit LDS atomics fall back to a non-atomic pair of 32-bit atomics."); + } + _vk.GetPhysicalDeviceProperties(_physicalDevice, out var deviceProperties); var deviceName = SilkMarshal.PtrToString((nint)deviceProperties.DeviceName) ?? "unknown"; RequireRenderingFeature(vulkan13Features.DynamicRendering, "Vulkan 1.3 dynamicRendering", deviceName); @@ -911,6 +938,17 @@ internal static unsafe partial class VulkanVideoPresenter PNext = &timelineSemaphoreFeatures, }; void* renderingChain = &addressFeatures; + if (supportsSharedInt64Atomics) + { + atomicInt64Features = new PhysicalDeviceShaderAtomicInt64Features + { + SType = StructureType.PhysicalDeviceShaderAtomicInt64Features, + ShaderSharedInt64Atomics = true, + PNext = renderingChain, + }; + renderingChain = &atomicInt64Features; + } + if (_supportsFragmentShaderBarycentric) { barycentricFeatures.PNext = renderingChain; @@ -1143,7 +1181,7 @@ internal static unsafe partial class VulkanVideoPresenter // frame, so saving after every slow creation compounds a warm-up // hitch into a multi-minute stall. Coalesce all creations into one // periodic snapshot; shutdown still forces a final save. - if (Environment.TickCount64 - _lastPipelineCacheSaveTick >= 30_000) + if (Environment.TickCount64 - _lastPipelineCacheSaveTick >= PipelineCacheCheckpointIntervalMs) { SavePipelineCache(force: false); } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs index bca9f04..cd3fa85 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs @@ -29,6 +29,7 @@ internal static unsafe partial class VulkanVideoPresenter SavePipelineCache(force: true); DrainFrameSlots(); CollectCompletedGuestSubmissions(waitForOldest: false); + DestroyFeedbackSnapshotPool(); DestroyRenderPipelines(); _descriptorHeap.Dispose(); _imageCache.Dispose(); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs index ed3a3de..24b12e0 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Draws.Recording.cs @@ -90,6 +90,11 @@ internal static unsafe partial class VulkanVideoPresenter Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC_SHADER"), "1", StringComparison.Ordinal); + private static readonly bool _traceTextureBindingsEnabled = + string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TEXTURE_BINDINGS"), + "1", + StringComparison.Ordinal); private static readonly uint _traceGuestImageWidth = uint.TryParse( Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_WIDTH"), @@ -259,6 +264,9 @@ internal static unsafe partial class VulkanVideoPresenter private static bool ShouldTraceVulkanResources() => _traceVulkanResourcesEnabled; + private static bool ShouldTraceTextureBindings() => + _traceTextureBindingsEnabled; + private void RecycleSubmissionUploads(SubmissionUploadResources resources) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ResourceDestroy); @@ -270,6 +278,11 @@ internal static unsafe partial class VulkanVideoPresenter } } + foreach (var snapshot in resources.FeedbackSnapshots) + { + RetireFeedbackSnapshot(snapshot); + } + foreach (var (buffer, memory) in resources.OverflowBuffers ?? []) { RecycleHostBuffer(buffer, memory); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs index ad4a40e..b5aa427 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.HostMovie.cs @@ -137,8 +137,12 @@ internal static unsafe partial class VulkanVideoPresenter { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.MovieFramePolling); if (!HostMovieBridge.TryDecodeNextFrame( - advanceClock: _hostMovieLumaTextureAddress != 0 && - _hostMovieChromaTextureAddress != 0, + // Once the host bridge owns a movie, advance its clock on + // every presenter tick. Texture discovery can lag behind + // the first decoded frame; tying playback to those two + // addresses leaves the guest stuck forever on attract + // movies when Vulkan selects a different binding layout. + advanceClock: true, out var pixels, out var width, out var height, diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs index b286fc9..d8f66d1 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs @@ -66,6 +66,7 @@ internal static unsafe partial class VulkanVideoPresenter bool IShaderPipelineHost.GraphicsSubgroupOperationsEnabled => GraphicsSubgroupOperationsEnabled; + bool IShaderPipelineHost.SharedInt64AtomicsEnabled => SharedInt64AtomicsEnabled; bool IShaderPipelineHost.PerVertexPixelInputsSupported => _supportsPerVertexPixelInputs; RenderHostLimits IShaderPipelineHost.Limits => _renderHostLimits; @@ -77,6 +78,16 @@ internal static unsafe partial class VulkanVideoPresenter { using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.GuestRead); word = 0; + // Resource planning can inspect a dynamic descriptor before the draw has + // supplied a valid guest address. Do not pass an invalid range to the + // page tracker: it treats that as an emulator invariant violation and + // terminates the process. A failed read lets the materializer reject or + // specialize the source normally. + if (!_guestMemory.CanRead(address, sizeof(uint))) + { + return false; + } + if (!_bufferCache.TrySynchronizeCpuRead(address, sizeof(uint), SharpEmu.HLE.GuestMemory.GuestMemoryProfile.ReadbackSource.ShaderResourceRead)) { @@ -98,6 +109,11 @@ internal static unsafe partial class VulkanVideoPresenter { using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.CleanGuestRead); word = 0; + if (!_guestMemory.CanRead(address, sizeof(uint))) + { + return false; + } + if (_bufferCache.HasGpuDirtyPages(address, sizeof(uint)) || _bufferCache.HasGpuDirtyBytes(address, sizeof(uint)) || _imageCache.HasGpuModifiedImageBytes(address, sizeof(uint))) @@ -115,6 +131,19 @@ internal static unsafe partial class VulkanVideoPresenter return true; } + // The same ownership rules as the two word readers, checked once for a whole range. + public bool TryReadResidentGuestBytes(ulong address, Span destination, bool clean) + { + var size = (ulong)destination.Length; + if (_bufferCache.HasGpuDirtyPages(address, size) || + (clean && (_bufferCache.HasGpuDirtyBytes(address, size) || _imageCache.HasGpuModifiedImageBytes(address, size)))) + { + return false; + } + + return _guestMemory.TryRead(address, destination); + } + public ulong CreateShaderModule(IGuestCompiledShader shader, ShaderStage stage, ulong hash, ulong programId) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramCompile); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs index 2f3d3da..bce1b6a 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Rendering.cs @@ -50,6 +50,10 @@ internal static unsafe partial class VulkanVideoPresenter public Dictionary? StencilStorageImages { get; set; } + public HashSet? StencilStorageWriteBackImages { get; set; } + + public List? FeedbackSnapshots { get; set; } + public bool Committed { get; set; } public bool CommandsRecorded { get; set; } @@ -69,7 +73,8 @@ internal static unsafe partial class VulkanVideoPresenter { try { - if (CommandsRecorded) + if (CommandsRecorded && StencilStorageWriteBackImages is not null && + StencilStorageWriteBackImages.Contains(attachment)) attachment.CopyStencilStorage(storage, owner._bufferCache.GetUtilityBuffer(GpuBufferUsage.DeviceLocal), writeBack: true); } finally @@ -79,6 +84,14 @@ internal static unsafe partial class VulkanVideoPresenter } } + if (!Committed && FeedbackSnapshots is { } feedbackSnapshots) + { + foreach (var snapshot in feedbackSnapshots) + { + snapshot.Dispose(); + } + } + if (Committed) { return; @@ -367,6 +380,40 @@ internal static unsafe partial class VulkanVideoPresenter ? entry : throw SubmissionScheduler.Fatal($"The pipeline handle is unknown: pipeline={pipeline.Pipeline} layout={pipeline.Layout}."); + // Vulkan cannot sample a stencil aspect while the same aspect is attached for writes. + // Use a per-draw storage copy for read-only stencil feedback and keep the attachment authoritative. + private void PrepareStencilFeedback(TextureResource[] bindings) + { + if (!_hasBoundDepth) + { + return; + } + + var depthImage = _imageCache.GetImage(_boundDepth.Image); + var attachmentView = _boundDepth.Target.Target.Request.View; + var writes = _boundDepthLoadState.AttachmentWriteAspects(_boundDepth.Target.Target.Format); + if ((writes & ImageAspectFlags.StencilBit) == 0) + { + return; + } + + foreach (var binding in bindings) + { + if (binding.IsHostMovie || binding.CachedImage is not { } image || + !ReferenceEquals(image, depthImage) || !ViewFormatRules.IsStencilViewFormat(binding.Request.View.Format) || + !ViewsOverlap(binding.Request.View, attachmentView)) + { + continue; + } + + var storage = AcquireStencilStorage(binding, image, writeBack: false); + binding.CachedImage = storage; + binding.Image = storage.Backing.Handle; + binding.View = storage.GetOrCreateView(binding.Request.View with { Aspect = ImageAspectFlags.ColorBit }); + binding.MipViews = []; + } + } + // The layout each sampled image reads through; a target read by its own draw uses the general layout. private void RecordStageTextureTransitions(TextureResource[] bindings) @@ -404,6 +451,11 @@ internal static unsafe partial class VulkanVideoPresenter } } + // Create feedback copies after load clears have been materialized; + // otherwise a shader would sample the pre-clear contents. + PrepareDepthFeedback(bindings); + PrepareStencilFeedback(bindings); + var command = BeginBatchedGuestCommands(); foreach (var binding in bindings) { diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index c549231..8fd8978 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -9,6 +9,7 @@ using SharpEmu.Libs.AvPlayer; using SharpEmu.Libs.Media; using SharpEmu.Libs.Gpu; using SharpEmu.Libs.Gpu.GpuCommands; +using SharpEmu.Libs.Gpu.Images; using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler.Vulkan; using Silk.NET.Vulkan; @@ -79,6 +80,15 @@ internal static unsafe partial class VulkanVideoPresenter private static int _nativeSubgroupSize; private static int _nativeSubgroupShaderStages; + private static int _sharedInt64AtomicsSupported; + + // True when the device supports shaderSharedInt64Atomics, so LDS 64-bit + // atomics can be emitted as real 64-bit atomics. + internal static bool SharedInt64AtomicsEnabled => + Volatile.Read(ref _sharedInt64AtomicsSupported) != 0; + + private static void SetSharedInt64AtomicsCapability(bool supported) => + Volatile.Write(ref _sharedInt64AtomicsSupported, supported ? 1 : 0); internal static bool GraphicsSubgroupOperationsEnabled => VulkanGraphicsSubgroupPolicy.ShouldUseNativeGraphicsSubgroups( @@ -233,6 +243,9 @@ internal static unsafe partial class VulkanVideoPresenter { public string DebugName = "SharpEmu translated"; public TextureResource[] Textures = []; + // Draw-local feedback copies stay alive until the GPU has consumed + // the descriptors that reference them. + public CachedImage[] FeedbackSnapshots = []; // Host buffers that took uploads the stream ring could not hold; recycled with the draw. public (VkBuffer Buffer, DeviceMemory Memory)[]? OverflowBuffers; } diff --git a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs index 936be1d..fb3dd60 100644 --- a/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Metal/Gen5MslTranslator.Alu.cs @@ -203,7 +203,7 @@ public static partial class Gen5MslTranslator // 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)})"), - "VFmaF16" => Float16Result( + "VFmaF16" or "VFmaMkF16" or "VFmaAkF16" => Float16Result( instruction, destination, $"fma({F16(instruction, 0)}, {F16(instruction, 1)}, {F16(instruction, 2)})"), @@ -216,6 +216,17 @@ public static partial class Gen5MslTranslator "VFractF32" => FloatResult(instruction, $"fract({F(instruction, 0)})"), "VSqrtF32" => FloatResult(instruction, $"sqrt({F(instruction, 0)})"), "VRsqF32" => FloatResult(instruction, $"rsqrt({F(instruction, 0)})"), + "VRcpF16" => Float16Result(instruction, destination, $"(1.0f / {F16(instruction, 0)})"), + "VSqrtF16" => Float16Result(instruction, destination, $"sqrt({F16(instruction, 0)})"), + "VLogF16" => Float16Result(instruction, destination, $"log2({F16(instruction, 0)})"), + "VExpF16" => Float16Result(instruction, destination, $"exp2({F16(instruction, 0)})"), + "VFloorF16" => Float16Result(instruction, destination, $"floor({F16(instruction, 0)})"), + "VCeilF16" => Float16Result(instruction, destination, $"ceil({F16(instruction, 0)})"), + "VTruncF16" => Float16Result(instruction, destination, $"trunc({F16(instruction, 0)})"), + "VRndneF16" => Float16Result(instruction, destination, $"rint({F16(instruction, 0)})"), + "VFractF16" => Float16Result(instruction, destination, $"fract({F16(instruction, 0)})"), + "VSinF16" => Float16Result(instruction, destination, $"sin({F16(instruction, 0)} * {TauLiteral})"), + "VCosF16" => Float16Result(instruction, destination, $"cos({F16(instruction, 0)} * {TauLiteral})"), "VRsqF16" => Float16Result( instruction, destination, @@ -228,6 +239,12 @@ public static partial class Gen5MslTranslator "VCosF32" => FloatResult(instruction, $"cos({F(instruction, 0)} * {TauLiteral})"), "VLdexpF32" => FloatResult(instruction, $"ldexp({F(instruction, 0)}, as_type({RawSource(instruction, 1)}))"), + "VMin3F16" => Float16Result(instruction, destination, + $"fmin(fmin({F16(instruction, 0)}, {F16(instruction, 1)}), {F16(instruction, 2)})"), + "VMax3F16" => Float16Result(instruction, destination, + $"fmax(fmax({F16(instruction, 0)}, {F16(instruction, 1)}), {F16(instruction, 2)})"), + "VMed3F16" => Float16Result(instruction, destination, + $"fmax(fmin({F16(instruction, 0)}, {F16(instruction, 1)}), fmin(fmax({F16(instruction, 0)}, {F16(instruction, 1)}), {F16(instruction, 2)}))"), "VMin3F32" => FloatResult(instruction, $"fmin(fmin({F(instruction, 0)}, {F(instruction, 1)}), {F(instruction, 2)})"), "VMax3F32" => @@ -422,8 +439,10 @@ public static partial class Gen5MslTranslator } case "VSubbU32": case "VSubbrevU32": + case "VSubCoCiU32": + case "VSubrevCoCiU32": { - var reverse = instruction.Opcode == "VSubbrevU32"; + var reverse = instruction.Opcode is "VSubbrevU32" or "VSubrevCoCiU32"; var left = Temp("uint", RawSource(instruction, reverse ? 1 : 0)); var right = Temp("uint", RawSource(instruction, reverse ? 0 : 1)); var borrowIn = instruction.Sources.Count > 2 @@ -1107,7 +1126,18 @@ public static partial class Gen5MslTranslator return true; } + if (instruction.Opcode == "SFlbitI32B64") + { + var wide = Temp("ulong", RawSource64(instruction, 0)); + var result = Temp( + "uint", + $"{wide} == 0ul ? 0xFFFFFFFFu : (uint)clz({wide})"); + StoreScalar(destination, result); + return true; + } + if (instruction.Opcode.EndsWith("B64", StringComparison.Ordinal) || + instruction.Opcode == "SAshrI64" || instruction.Opcode is "SBfeU64" or "SBfeI64") { return TryEmitScalar64(instruction, destination, out error); @@ -1240,6 +1270,14 @@ public static partial class Gen5MslTranslator resultExpression = $"({left} - {right})"; sccStatement = $"(((({left} ^ {right})) & ({left} ^ RESULT)) >> 31) != 0u"; break; + case "SAddF32": + resultExpression = $"as_type(as_type({left}) + as_type({right}))"; + sccStatement = string.Empty; + break; + case "SSubF32": + resultExpression = $"as_type(as_type({left}) - as_type({right}))"; + sccStatement = string.Empty; + break; case "SAddcU32": { var partial = Temp("uint", $"{left} + {right}"); @@ -1479,6 +1517,16 @@ public static partial class Gen5MslTranslator out string error) { error = string.Empty; + + // S_SWAPPC_B64 is a dynamic call/return operation. The Gen5 + // translator has already linearized the reachable shader body, + // so there is no host program counter to exchange here. Its + // single pair operand must not be treated as a binary 64-bit op. + if (instruction.Opcode == "SSwappcB64") + { + return true; + } + var left = Temp("ulong", RawSource64(instruction, 0)); if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal)) { @@ -1536,12 +1584,15 @@ public static partial class Gen5MslTranslator value = $"({quadAny} * 0xFul)"; break; } - case "SLshlB64" or "SLshrB64": + case "SLshlB64" or "SLshrB64" or "SAshrI64": { var shift = Temp("uint", $"({RawSource(instruction, 1)}) & 63u"); - value = instruction.Opcode == "SLshlB64" - ? $"({left} << {shift})" - : $"({left} >> {shift})"; + value = instruction.Opcode switch + { + "SLshlB64" => $"({left} << {shift})", + "SLshrB64" => $"({left} >> {shift})", + _ => $"(ulong)(as_type({left}) >> {shift})", + }; break; } case "SBfmB64": diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index d9e5f95..3a41b8d 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -243,6 +243,169 @@ public static partial class Gen5SpirvTranslator case "VCvtOffF32I4": result = EmitCvtOffF32I4(instruction); break; + case "VCvtF64I32": + case "VCvtF64U32": + { + var source = GetRawSource(instruction, 0); + var converted = _module.AddInstruction( + instruction.Opcode == "VCvtF64I32" + ? SpirvOp.ConvertSToF + : SpirvOp.ConvertUToF, + DoubleType(), + instruction.Opcode == "VCvtF64I32" + ? Bitcast(_intType, source) + : source); + if (!TryStoreDoubleResult( + instruction, + destination, + converted, + out result, + out error)) + { + return false; + } + + break; + } + case "VCvtF32F64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + result = Bitcast( + _uintType, + _module.AddInstruction(SpirvOp.FConvert, _floatType, source)); + break; + } + case "VRcpF64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + var reciprocal = _module.AddInstruction( + SpirvOp.FDiv, + DoubleType(), + Double(1.0), + source); + if (!TryStoreDoubleResult( + instruction, + destination, + reciprocal, + out result, + out error)) + { + return false; + } + + break; + } + case "VRsqF64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + var root = Ext(31, DoubleType(), source); + var reciprocal = _module.AddInstruction( + SpirvOp.FDiv, + DoubleType(), + Double(1.0), + root); + if (!TryStoreDoubleResult( + instruction, + destination, + reciprocal, + out result, + out error)) + { + return false; + } + + break; + } + case "VSqrtF64": + { + if (!TryGetDoubleSource(instruction, 0, out var source, out error)) + { + return false; + } + + if (!TryStoreDoubleResult( + instruction, + destination, + Ext(31, DoubleType(), source), + out result, + out error)) + { + return false; + } + + break; + } + case "VMulF64": + { + if (!TryGetDoubleSource(instruction, 0, out var left, out error)) + { + return false; + } + + if (!TryGetDoubleSource(instruction, 1, out var right, out error)) + { + return false; + } + + var product = _module.AddInstruction( + SpirvOp.FMul, + DoubleType(), + left, + right); + if (!TryStoreDoubleResult( + instruction, + destination, + product, + out result, + out error)) + { + return false; + } + + break; + } + case "VFmaF64": + { + if (!TryGetDoubleSource(instruction, 0, out var first, out error)) + { + return false; + } + + if (!TryGetDoubleSource(instruction, 1, out var second, out error)) + { + return false; + } + + if (!TryGetDoubleSource(instruction, 2, out var third, out error)) + { + return false; + } + + var fused = Ext(50, DoubleType(), first, second, third); + if (!TryStoreDoubleResult( + instruction, + destination, + fused, + out result, + out error)) + { + return false; + } + + break; + } case "VCvtPkU8F32": { var converted = _module.AddInstruction( @@ -295,6 +458,39 @@ public static partial class Gen5SpirvTranslator instruction, Ext(32, _floatType, GetFloatSource(instruction, 0))); break; + case "VRcpF16": + result = EmitFloat16Result(instruction, destination, _module.AddInstruction(SpirvOp.FDiv, _floatType, Float(1), GetFloat16Source(instruction, 0))); + break; + case "VSqrtF16": + result = EmitFloat16Result(instruction, destination, Ext(31, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VLogF16": + result = EmitFloat16Result(instruction, destination, Ext(30, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VExpF16": + result = EmitFloat16Result(instruction, destination, Ext(29, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VFloorF16": + result = EmitFloat16Result(instruction, destination, Ext(8, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VCeilF16": + result = EmitFloat16Result(instruction, destination, Ext(9, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VTruncF16": + result = EmitFloat16Result(instruction, destination, Ext(3, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VRndneF16": + result = EmitFloat16Result(instruction, destination, Ext(2, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VFractF16": + result = EmitFloat16Result(instruction, destination, Ext(10, _floatType, GetFloat16Source(instruction, 0))); + break; + case "VSinF16": + result = EmitFloat16Result(instruction, destination, Ext(13, _floatType, _module.AddInstruction(SpirvOp.FMul, _floatType, GetFloat16Source(instruction, 0), Float(MathF.Tau)))); + break; + case "VCosF16": + result = EmitFloat16Result(instruction, destination, Ext(14, _floatType, _module.AddInstruction(SpirvOp.FMul, _floatType, GetFloat16Source(instruction, 0), Float(MathF.Tau)))); + break; case "VRsqF16": result = EmitFloat16Result( instruction, @@ -395,6 +591,20 @@ public static partial class Gen5SpirvTranslator case "VMaxF16": result = EmitFloat16ExtBinary(instruction, destination, 40); break; + case "VFmaF16": + case "VFmaMkF16": + case "VFmaAkF16": + { + var fusedBits = EmitPackedF16FusedMultiplyAdd( + GetFloat16Source(instruction, 0), + GetFloat16Source(instruction, 1), + GetFloat16Source(instruction, 2)); + result = EmitFloat16Result( + instruction, + destination, + Bitcast(_floatType, fusedBits)); + break; + } case "VMadF32": case "VFmaF32": case "VMadMkF32": @@ -410,17 +620,6 @@ public static partial class Gen5SpirvTranslator GetFloatSource(instruction, 1), GetFloatSource(instruction, 2))); break; - case "VFmaF16": - result = EmitFloat16Result( - instruction, - destination, - Ext( - 50, - _floatType, - GetFloat16Source(instruction, 0), - GetFloat16Source(instruction, 1), - GetFloat16Source(instruction, 2))); - break; case "VMacF32": case "VFmacF32": { @@ -435,6 +634,47 @@ public static partial class Gen5SpirvTranslator addend)); break; } + case "VDot2cF32F16": + { + // The two packed half pairs are widened before the + // products are accumulated in f32. The c form clamps + // the final dot product to the normalized [0, 1] range. + var left = GetRawSource(instruction, 0); + var right = GetRawSource(instruction, 1); + var lowProduct = _module.AddInstruction( + SpirvOp.FMul, + _floatType, + EmitPackedF16Half(left, highLane: false), + EmitPackedF16Half(right, highLane: false)); + var highProduct = _module.AddInstruction( + SpirvOp.FMul, + _floatType, + EmitPackedF16Half(left, highLane: true), + EmitPackedF16Half(right, highLane: true)); + var dot = _module.AddInstruction(SpirvOp.FAdd, _floatType, lowProduct, highProduct); + result = EmitFloatResult( + instruction, + Bitcast(_floatType, EmitClampToUnitInterval(Bitcast(_uintType, dot)))); + break; + } + case "VMin3F16": + case "VMax3F16": + case "VMed3F16": + { + // Every f16 value widens to f32 exactly, and min/max pick one + // input, so the f32 result narrows back without rounding. + var a = GetFloat16Source(instruction, 0); + var b = GetFloat16Source(instruction, 1); + var c = GetFloat16Source(instruction, 2); + var value = instruction.Opcode switch + { + "VMin3F16" => Ext(37, _floatType, Ext(37, _floatType, a, b), c), + "VMax3F16" => Ext(40, _floatType, Ext(40, _floatType, a, b), c), + _ => Ext(40, _floatType, Ext(37, _floatType, a, b), Ext(37, _floatType, Ext(40, _floatType, a, b), c)), + }; + result = EmitFloat16Result(instruction, destination, value); + break; + } case "VMin3F32": result = EmitFloatTernaryExt(instruction, 37); break; @@ -671,6 +911,19 @@ public static partial class Gen5SpirvTranslator BitwiseAnd(GetRawSource(instruction, 0), lowBitsMask))); break; } + case "VLshlrevB64": + { + // D.u64 = S1.u64 << (S0.u32 & 0x3F). + var shiftAmount = _module.AddInstruction( + SpirvOp.UConvert, + _ulongType, + BitwiseAnd(GetRawSource(instruction, 0), UInt(63))); + var shifted = ShiftLeftLogical64(GetRawSource64(instruction, 1), shiftAmount); + result = Narrow(shifted); + StoreV(destination + 1, Narrow(ShiftRightLogical64( + shifted, _module.Constant64(_ulongType, 32)))); + break; + } case "VBfmB32": { var width = BitwiseAnd(GetRawSource(instruction, 0), UInt(31)); @@ -682,6 +935,30 @@ public static partial class Gen5SpirvTranslator result = ShiftLeftLogical(lowMask, GetRawSource(instruction, 1)); break; } + case "VMadI32I24": + { + // D = S0 * S1 + S2 with S0 and S1 taken as signed 24-bit + // values; the product wraps in 32 bits before the add. + var signedLeft = _module.AddInstruction( + SpirvOp.BitFieldSExtract, + _intType, + Bitcast(_intType, GetRawSource(instruction, 0)), + UInt(0), + UInt(24)); + var signedRight = _module.AddInstruction( + SpirvOp.BitFieldSExtract, + _intType, + Bitcast(_intType, GetRawSource(instruction, 1)), + UInt(0), + UInt(24)); + var product = _module.AddInstruction( + SpirvOp.IMul, + _uintType, + Bitcast(_uintType, signedLeft), + Bitcast(_uintType, signedRight)); + result = IAdd(product, GetRawSource(instruction, 2)); + break; + } case "VMadU32U24": { var left = BitwiseAnd( @@ -1156,6 +1433,15 @@ public static partial class Gen5SpirvTranslator return false; } + break; + case "VPkAddI16": + result = EmitPackedI16Arithmetic(instruction, subtract: false); + break; + case "VPkSubI16": + result = EmitPackedI16Arithmetic(instruction, subtract: true); + break; + case "VPkFmacF16": + result = EmitPackedF16Fmac(instruction, destination); break; case "VFmaMixF32": case "VFmaMixloF16": @@ -1166,6 +1452,66 @@ public static partial class Gen5SpirvTranslator } break; + case "VAddNcI16": + case "VSubNcU16": + case "VSubNcI16": + case "VLshrrevB16": + case "VLshlrevB16": + case "VAshrrevI16": + case "VMaxU16": + case "VMinU16": + case "VMaxI16": + case "VMinI16": + { + if (instruction.Control is Gen5Vop3Control { Clamp: true }) + { + error = $"{instruction.Opcode}: clamped 16-bit integer results are not supported"; + return false; + } + + var signed = instruction.Opcode.EndsWith("I16", StringComparison.Ordinal); + var left = GetInt16Source(instruction, 0, signed); + var right = GetInt16Source(instruction, 1, signed); + uint value; + switch (instruction.Opcode) + { + case "VAddNcU16": + case "VAddNcI16": + value = _module.AddInstruction(SpirvOp.IAdd, _uintType, left, right); + break; + case "VSubNcU16": + case "VSubNcI16": + value = _module.AddInstruction(SpirvOp.ISub, _uintType, left, right); + break; + case "VLshrrevB16": + value = ShiftRightLogical(right, BitwiseAnd(left, UInt(15))); + break; + case "VLshlrevB16": + value = ShiftLeftLogical(right, BitwiseAnd(left, UInt(15))); + break; + case "VAshrrevI16": + value = ShiftRightArithmetic(right, BitwiseAnd(left, UInt(15))); + break; + case "VMaxU16": + value = Ext(41, _uintType, left, right); + break; + case "VMinU16": + value = Ext(38, _uintType, left, right); + break; + default: + value = Bitcast( + _uintType, + Ext( + instruction.Opcode == "VMaxI16" ? 42u : 39u, + _intType, + Bitcast(_intType, left), + Bitcast(_intType, right))); + break; + } + + result = EmitInt16Result(instruction, destination, value); + break; + } default: error = $"unsupported vector opcode {instruction.Opcode}"; return false; @@ -1297,6 +1643,37 @@ public static partial class Gen5SpirvTranslator return true; } + // V_PK_ADD/SUB_I16 operates on the selected 16-bit halves of src0 and + // src1. The packed integer result is modulo 16 bits in each lane; the + // signed and unsigned forms therefore share the same bit-level + // implementation. + private uint EmitPackedI16Arithmetic(Gen5ShaderInstruction instruction, bool subtract) + { + var control = (Gen5Vop3pControl)instruction.Control!; + var left = GetRawSource(instruction, 0); + var right = GetRawSource(instruction, 1); + uint Arithmetic(uint a, uint b) => subtract ? ISubU(a, b) : IAdd(a, b); + + uint SelectHalf(uint value, uint mask, int source) + { + return ((mask >> source) & 1) != 0 + ? ShiftRightLogical(value, UInt(16)) + : value; + } + + var low = BitwiseAnd( + Arithmetic( + BitwiseAnd(SelectHalf(left, control.OpSelMask, 0), UInt(0xFFFF)), + BitwiseAnd(SelectHalf(right, control.OpSelMask, 1), UInt(0xFFFF))), + UInt(0xFFFF)); + var high = BitwiseAnd( + Arithmetic( + BitwiseAnd(SelectHalf(left, control.OpSelHiMask, 0), UInt(0xFFFF)), + BitwiseAnd(SelectHalf(right, control.OpSelHiMask, 1), UInt(0xFFFF))), + UInt(0xFFFF)); + return BitwiseOr(low, ShiftLeftLogical(high, UInt(16))); + } + // V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 / // 0x22). Unlike the packed v_pk_* ops these compute a single f32 // fma(a, b, c): each of the three sources is *independently* read as @@ -1436,6 +1813,37 @@ public static partial class Gen5SpirvTranslator return EmitFloatToHalf(value); } + // V_PK_FMAC_F16 (VOP2 0x3C): each result half is a fused f16 multiply-add + // whose addend is the matching half of the old destination, so the packed + // register is read as D = S0 * S1 + D for both lanes at once. + private uint EmitPackedF16Fmac( + Gen5ShaderInstruction instruction, + uint destination) + { + var accumulator = LoadV(destination); + var low = EmitPackedF16FmacLane(instruction, accumulator, highLane: false); + var high = EmitPackedF16FmacLane(instruction, accumulator, highLane: true); + return BitwiseOr(low, ShiftLeftLogical(high, UInt(16))); + } + + private uint EmitPackedF16FmacLane( + Gen5ShaderInstruction instruction, + uint accumulator, + bool highLane) + { + var left = EmitPackedF16Half(GetRawSource(instruction, 0), highLane); + var right = EmitPackedF16Half(GetRawSource(instruction, 1), highLane); + var addend = EmitPackedF16Half(accumulator, highLane); + return EmitFloatToHalf( + EmitPackedF16FusedMultiplyAdd(left, right, addend)); + } + + private uint EmitPackedF16Half(uint raw, bool highLane) + { + var half = highLane ? ShiftRightLogical(raw, UInt(16)) : raw; + return Bitcast(_floatType, EmitHalfToFloat(half)); + } + // Saturates an f32 bit pattern to [0, 1] the way the VOP3P clamp modifier // does: below 0 (and NaN, since the ordered compare is false for it) becomes // 0, above 1 becomes 1. Ordered compares match the hardware's NaN-to-zero @@ -1527,6 +1935,22 @@ public static partial class Gen5SpirvTranslator int index, bool highLane) { + var operand = instruction.Sources[index]; + if (operand.Kind is Gen5OperandKind.EncodedConstant or Gen5OperandKind.LiteralConstant) + { + // An inline constant or literal is a single f32 value, not a + // packed register: both lanes take the exactly-converted f16 of + // the same constant, so there is no half to select. + var constant = GetFloatSource(instruction, index); + var constantNegate = highLane ? control.NegHiMask : control.NegLoMask; + if (((constantNegate >> index) & 1) != 0) + { + constant = _module.AddInstruction(SpirvOp.FNegate, _floatType, constant); + } + + return constant; + } + var raw = GetRawSource(instruction, index); var selectMask = highLane ? control.OpSelHiMask : control.OpSelMask; var half = ((selectMask >> index) & 1) != 0 @@ -1869,6 +2293,70 @@ public static partial class Gen5SpirvTranslator condition = _module.AddInstruction(operation, _boolType, left, right); } + else if (opcode.EndsWith("U64", StringComparison.Ordinal)) + { + if (opcode is "VCmpFU64" or "VCmpxFU64") + { + condition = _module.ConstantBool(false); + } + else if (opcode is "VCmpTU64" or "VCmpxTU64") + { + condition = _module.ConstantBool(true); + } + else + { + var wideLeft = GetRawSource64(instruction, 0); + var wideRight = GetRawSource64(instruction, 1); + var operation = opcode switch + { + "VCmpEqU64" or "VCmpxEqU64" => SpirvOp.IEqual, + "VCmpNeU64" or "VCmpxNeU64" => SpirvOp.INotEqual, + "VCmpLtU64" or "VCmpxLtU64" => SpirvOp.ULessThan, + "VCmpLeU64" or "VCmpxLeU64" => SpirvOp.ULessThanEqual, + "VCmpGtU64" or "VCmpxGtU64" => SpirvOp.UGreaterThan, + "VCmpGeU64" or "VCmpxGeU64" => SpirvOp.UGreaterThanEqual, + _ => SpirvOp.Nop, + }; + if (operation == SpirvOp.Nop) + { + error = $"unsupported u64 compare {opcode}"; + return false; + } + + condition = _module.AddInstruction(operation, _boolType, wideLeft, wideRight); + } + } + else if (opcode.EndsWith("U16", StringComparison.Ordinal) || opcode.EndsWith("I16", StringComparison.Ordinal)) + { + // VOPC 16-bit integer comparisons consume the low half of each VGPR. + // Signed comparisons sign-extend that half before the SPIR-V operation. + var signed = opcode.EndsWith("I16", StringComparison.Ordinal); + var left = GetInteger16Source(instruction, 0, signed); + var right = GetInteger16Source(instruction, 1, signed); + var operation = opcode switch + { + "VCmpEqI16" or "VCmpxEqI16" or + "VCmpEqU16" or "VCmpxEqU16" => SpirvOp.IEqual, + "VCmpNeI16" or "VCmpxNeI16" or + "VCmpNeU16" or "VCmpxNeU16" => SpirvOp.INotEqual, + "VCmpLtI16" or "VCmpxLtI16" => SpirvOp.SLessThan, + "VCmpLeI16" or "VCmpxLeI16" => SpirvOp.SLessThanEqual, + "VCmpGtI16" or "VCmpxGtI16" => SpirvOp.SGreaterThan, + "VCmpGeI16" or "VCmpxGeI16" => SpirvOp.SGreaterThanEqual, + "VCmpLtU16" or "VCmpxLtU16" => SpirvOp.ULessThan, + "VCmpLeU16" or "VCmpxLeU16" => SpirvOp.ULessThanEqual, + "VCmpGtU16" or "VCmpxGtU16" => SpirvOp.UGreaterThan, + "VCmpGeU16" or "VCmpxGeU16" => SpirvOp.UGreaterThanEqual, + _ => SpirvOp.Nop, + }; + if (operation == SpirvOp.Nop) + { + error = $"unsupported 16-bit integer compare {opcode}"; + return false; + } + + condition = _module.AddInstruction(operation, _boolType, left, right); + } else if (opcode is not ("VCmpClassF32" or "VCmpxClassF32")) { var compare64 = opcode.EndsWith("U64", StringComparison.Ordinal) || @@ -2116,13 +2604,62 @@ public static partial class Gen5SpirvTranslator return true; } + if (instruction.Opcode == "SFlbitI32B64") + { + var wide = GetRawSource64(instruction, 0); + var low = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + wide); + var high = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + ShiftRightLogical64( + wide, + _module.Constant64(_ulongType, 32))); + var highClz = _module.AddInstruction( + SpirvOp.ISub, + _uintType, + UInt(31), + Ext(74, _uintType, high)); + var lowClz = _module.AddInstruction( + SpirvOp.ISub, + _uintType, + UInt(31), + Ext(74, _uintType, low)); + var lowResult = IAdd(lowClz, UInt(32)); + var leadingZeroResult = _module.AddInstruction( + SpirvOp.Select, + _uintType, + IsNotZero(high), + highClz, + _module.AddInstruction( + SpirvOp.Select, + _uintType, + IsNotZero(low), + lowResult, + UInt(uint.MaxValue))); + StoreS(destination, leadingZeroResult); + return true; + } + if (instruction.Opcode.EndsWith("B64", StringComparison.Ordinal) || + instruction.Opcode == "SAshrI64" || instruction.Opcode is "SWqmB64" or "SBfeU64" or "SBfeI64") { return TryEmitScalar64(instruction, destination, out error); } var left = GetRawSource(instruction, 0); + if (instruction.Opcode == "SBitreplicateB64B32") + { + // S_BITREPLICATE_B64_B32 broadcasts the source dword into both + // halves of its 64-bit SGPR destination and does not update SCC. + StoreS(destination, left); + StoreS(destination + 1, left); + return true; + } + if (instruction.Opcode.EndsWith("SaveexecB32", StringComparison.Ordinal)) { var oldExec64 = BooleanToWaveMask(Load(_boolType, _exec)); @@ -2227,8 +2764,21 @@ public static partial class Gen5SpirvTranslator result = Ext(73, _uintType, left); StoreS(destination, result); return true; + case "SFlbitI32B32": + { + // Count leading zero bits, 0xFFFFFFFF when the source is zero. + var msb = Ext(74, _uintType, left); + var clz = _module.AddInstruction(SpirvOp.ISub, _uintType, UInt(31), msb); + result = _module.AddInstruction( + SpirvOp.Select, _uintType, IsNotZero(left), clz, UInt(0xFFFFFFFFu)); + StoreS(destination, result); + return true; + } case "SBitset0B32": case "SBitset1B32": + // S_BITSET*_B32 is a read-modify-write of the destination: + // it sets the single bit selected by SSRC0[4:0]. Neither + // form writes SCC. result = _module.AddInstruction( SpirvOp.BitFieldInsert, _uintType, @@ -2281,6 +2831,24 @@ public static partial class Gen5SpirvTranslator right); Store(_scc, SignedSubOverflow(left, right, result)); break; + case "SAddF32": + result = Bitcast( + _uintType, + _module.AddInstruction( + SpirvOp.FAdd, + _floatType, + Bitcast(_floatType, left), + Bitcast(_floatType, right))); + break; + case "SSubF32": + result = Bitcast( + _uintType, + _module.AddInstruction( + SpirvOp.FSub, + _floatType, + Bitcast(_floatType, left), + Bitcast(_floatType, right))); + break; case "SAddcU32": { var carryIn = _module.AddInstruction( @@ -2362,6 +2930,33 @@ public static partial class Gen5SpirvTranslator left, right); break; + case "SMulHiI32": + { + var wideLeft = _module.AddInstruction( + SpirvOp.SConvert, + _longType, + Bitcast(_intType, left)); + var wideRight = _module.AddInstruction( + SpirvOp.SConvert, + _longType, + Bitcast(_intType, right)); + var product = _module.AddInstruction( + SpirvOp.IMul, + _longType, + wideLeft, + wideRight); + result = Bitcast( + _uintType, + _module.AddInstruction( + SpirvOp.SConvert, + _intType, + _module.AddInstruction( + SpirvOp.ShiftRightArithmetic, + _longType, + product, + _module.Constant64(_longType, 32)))); + break; + } case "SMulHiU32": { var product = _module.AddInstruction( @@ -2710,6 +3305,43 @@ public static partial class Gen5SpirvTranslator out string error) { error = string.Empty; + + // S_SETPC_B64 and S_SWAPPC_B64 are dynamic call/return operations. The Gen5 + // translator has already linearized the reachable shader body, + // so there is no host program counter to exchange here. Keep + // the instruction as a control-flow marker and continue with the + // linearized body; unlike the binary scalar operations it has a + // single pair operand (s[dst:dst+1] <- s[src:src+1]). + if (instruction.Opcode is "SSetpcB64" or "SSwappcB64") + { + return true; + } + + // S_BITSET0/1_B64 use a 32-bit bit index and update the 64-bit + // value already held at the destination; they do not have a + // scalar 64-bit source operand. + if (instruction.Opcode is "SBitset0B64" or "SBitset1B64") + { + var bitIndex = Widen(BitwiseAnd(GetRawSource(instruction, 0), UInt(63))); + var selected = ShiftLeftLogical64( + _module.Constant64(_ulongType, 1), + bitIndex); + var current = LoadS64(destination); + var updated = instruction.Opcode == "SBitset1B64" + ? _module.AddInstruction( + SpirvOp.BitwiseOr, + _ulongType, + current, + selected) + : _module.AddInstruction( + SpirvOp.BitwiseAnd, + _ulongType, + current, + _module.AddInstruction(SpirvOp.Not, _ulongType, selected)); + StoreS64(destination, updated); + return true; + } + var left = GetRawSource64(instruction, 0); if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal)) { @@ -2782,13 +3414,20 @@ public static partial class Gen5SpirvTranslator return false; } + if (!_emulateWave64 && _waveLaneCount != 64) + { + newExec = BitwiseAnd( + newExec, + _module.Constant64(_ulongType, 0xFFFF_FFFFUL)); + } + StoreS64(destination, oldExec); StoreS64(126, newExec); Store(_scc, IsNotZero64(newExec)); return true; } - if (instruction.Opcode is "SLshlB64" or "SLshrB64") + if (instruction.Opcode is "SLshlB64" or "SLshrB64" or "SAshrI64") { if (instruction.Sources.Count < 2) { @@ -2800,9 +3439,12 @@ public static partial class Gen5SpirvTranslator SpirvOp.UConvert, _ulongType, GetRawSource(instruction, 1)); - var shiftedValue = instruction.Opcode == "SLshlB64" - ? ShiftLeftLogical64(left, shift) - : ShiftRightLogical64(left, shift); + var shiftedValue = instruction.Opcode switch + { + "SLshlB64" => ShiftLeftLogical64(left, shift), + "SLshrB64" => ShiftRightLogical64(left, shift), + _ => ShiftRightArithmetic64(left, shift), + }; StoreS64(destination, shiftedValue); Store(_scc, IsNotZero64(shiftedValue)); return true; @@ -3443,6 +4085,17 @@ public static partial class Gen5SpirvTranslator sourceAllowsWrite)); } + private uint GetInteger16Source( + Gen5ShaderInstruction instruction, + int sourceIndex, + bool signed) + { + var value = BitwiseAnd(GetRawSource(instruction, sourceIndex), UInt(0xFFFF)); + return signed + ? ShiftRightArithmetic(ShiftLeftLogical(value, UInt(16)), UInt(16)) + : value; + } + private uint GetFloat16Source( Gen5ShaderInstruction instruction, int sourceIndex) @@ -3594,6 +4247,12 @@ public static partial class Gen5SpirvTranslator high); } + if (operand.Kind == Gen5OperandKind.EncodedConstant && + Gen5InlineConstants.IsAperture(operand.Value)) + { + return _module.Constant64(_ulongType, Gen5InlineConstants.DecodeAperture64(operand.Value)); + } + // Scalar inline negative constants are signed immediates. B64 // consumers sign-extend them, so -1 denotes a full 64-bit mask. if (operand.Kind == Gen5OperandKind.EncodedConstant && @@ -3631,6 +4290,117 @@ public static partial class Gen5SpirvTranslator _module.AddInstruction(SpirvOp.UConvert, _uintType, high)); } + private uint DoubleType() + { + _module.AddCapability(SpirvCapability.Float64); + return _module.TypeFloat(64); + } + + private uint Double(double value) => + _module.Constant64( + DoubleType(), + unchecked((ulong)BitConverter.DoubleToInt64Bits(value))); + + // FP64 operands live in a register pair. The gfx10 inline-constant + // and literal encodings follow different expansion rules than the + // integer 64-bit forms, so refuse them instead of reading the wrong + // bits as a double. + private bool TryGetDoubleSource( + Gen5ShaderInstruction instruction, + int sourceIndex, + out uint value, + out string error) + { + error = string.Empty; + value = 0; + var operand = instruction.Sources[sourceIndex]; + if (operand.Kind != Gen5OperandKind.VectorRegister && + operand.Kind != Gen5OperandKind.ScalarRegister) + { + error = $"FP64 source {sourceIndex} of {instruction.Opcode} is not a register pair"; + return false; + } + + value = Bitcast(DoubleType(), GetRawSource64(instruction, sourceIndex)); + + var absoluteMask = 0u; + var negateMask = 0u; + switch (instruction.Control) + { + case Gen5Vop3Control vop3: + absoluteMask = vop3.AbsoluteMask; + negateMask = vop3.NegateMask; + break; + case Gen5SdwaControl sdwa: + absoluteMask = sdwa.AbsoluteMask; + negateMask = sdwa.NegateMask; + break; + } + + if ((absoluteMask & (1u << sourceIndex)) != 0) + { + value = Ext(4, DoubleType(), value); + } + + if ((negateMask & (1u << sourceIndex)) != 0) + { + value = _module.AddInstruction(SpirvOp.FNegate, DoubleType(), value); + } + + return true; + } + + // Applies the VOP3 output modifier and clamp to a double result, then + // splits it across the destination register pair. The low word is + // returned for the caller's common destination store; the high word is + // stored here so both words share the same EXEC guard. + private bool TryStoreDoubleResult( + Gen5ShaderInstruction instruction, + uint destination, + uint doubleValue, + out uint low, + out string error) + { + error = string.Empty; + low = 0; + var outputModifier = 0u; + var clamp = false; + switch (instruction.Control) + { + case Gen5Vop3Control control: + outputModifier = control.OutputModifier; + clamp = control.Clamp; + break; + case null: + break; + default: + error = $"FP64 output control {instruction.Control.GetType().Name} is not implemented in {instruction.Opcode}"; + return false; + } + + var type = DoubleType(); + doubleValue = outputModifier switch + { + 1 => _module.AddInstruction(SpirvOp.FMul, type, doubleValue, Double(2.0)), + 2 => _module.AddInstruction(SpirvOp.FMul, type, doubleValue, Double(4.0)), + 3 => _module.AddInstruction(SpirvOp.FMul, type, doubleValue, Double(0.5)), + _ => doubleValue, + }; + if (clamp) + { + doubleValue = Ext(43, type, doubleValue, Double(0.0), Double(1.0)); + } + + var bits = Bitcast(_ulongType, doubleValue); + var high = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + ShiftRightLogical64(bits, _module.Constant64(_ulongType, 32))); + StoreV(destination + 1, high); + low = _module.AddInstruction(SpirvOp.UConvert, _uintType, bits); + return true; + } + private uint EmitFloat16Binary( Gen5ShaderInstruction instruction, uint destination, @@ -4015,6 +4785,24 @@ public static partial class Gen5SpirvTranslator return result; } + // A lane selector reduced to the guest wave: bits [5:0] for wave64, [4:0] for wave32. + private uint GuestLaneSelect(uint selector) => + BitwiseAnd(selector, UInt(_waveLaneCount == 64 ? 63u : RdnaWaveLaneCount - 1)); + + // An emulated wave64 spans two host subgroups, so a subgroup broadcast cannot reach + // the other half. The selected guest lane publishes through workgroup scratch, like + // BroadcastFirstWave64Active; the trailing barrier keeps the next use from racing. + private uint BroadcastWave64Lane(uint value, uint lane) + { + EmitConditional( + _module.AddInstruction(SpirvOp.IEqual, _boolType, GuestWaveLane(), lane), + () => Store(WaveBroadcastScratchPointer(), value)); + EmitWave64Barrier(); + var result = Load(_uintType, WaveBroadcastScratchPointer()); + EmitWave64Barrier(); + return result; + } + private uint BroadcastFirstWave64Active(uint value) { var lane = GuestWaveLane(); @@ -4215,6 +5003,30 @@ public static partial class Gen5SpirvTranslator UInt(0)); } + private uint GetInt16Source(Gen5ShaderInstruction instruction, int sourceIndex, bool signed) + { + var raw = GetRawSource(instruction, sourceIndex, applySdwaIntegerModifiers: false); + if (instruction.Control is Gen5Vop3Control control && (control.OperandSelect & (1u << sourceIndex)) != 0) + { + raw = ShiftRightLogical(raw, UInt(16)); + } + + var half = BitwiseAnd(raw, UInt(0xFFFF)); + return signed + ? ShiftRightArithmetic(ShiftLeftLogical(half, UInt(16)), UInt(16)) + : half; + } + + private uint EmitInt16Result(Gen5ShaderInstruction instruction, uint destination, uint value) + { + var control = instruction.Control as Gen5Vop3Control; + var half = BitwiseAnd(value, UInt(0xFFFF)); + var current = LoadV(destination); + return ((control?.OperandSelect ?? 0) & 8) != 0 + ? BitwiseOr(BitwiseAnd(current, UInt(0x0000_FFFF)), ShiftLeftLogical(half, UInt(16))) + : BitwiseOr(BitwiseAnd(current, UInt(0xFFFF_0000)), half); + } + private uint EmitFloat16Result( Gen5ShaderInstruction instruction, uint destination, diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs new file mode 100644 index 0000000..0e2bedf --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RayIntersect.cs @@ -0,0 +1,324 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Vulkan; + +// IMAGE_BVH_INTERSECT_RAY tests one ray against one BVH node; the guest shader owns the +// traversal loop and stack. This is the GFX10 (RT IP 1.1) node test as AMD's GPURT +// software fallback defines it (IntersectCommon.hlsl, image_bvh64_intersect_ray_base), +// with node layouts from GPURT's gfx10 TriangleNode1_0 and BoxNode1_0. +public static partial class Gen5SpirvTranslator +{ + private sealed partial class CompilationContext + { + private const uint InvalidNode = 0xFFFF_FFFF; + + // Node pointer: the byte offset divided by eight above bits 2:0, which hold the type. + private const uint NodeTypeTriangle1 = 1; + private const uint NodeTypeBoxFloat16 = 4; + private const uint NodeTypeBoxFloat32 = 5; + + // Triangle node: four float3 vertices, then flags/indices, the triangle id at dword 15. + private const uint TriangleIdDword = 15; + private const int TriangleIdBitStride = 8; + private const int TriangleIdISourceShift = 0; + private const int TriangleIdJSourceShift = 2; + + // Box nodes: four child pointers, then per child a float3 min/max pair (six floats) + // or six packed halves (three dwords). + private const uint BoxBoundsDword = 4; + + private uint[]? _rayResult; + + private void EmitRayIntersect(Gen5RayIntersectControl ray, bool bvh64) + { + _rayResult ??= DeclareRayResult(); + EmitExecConditional(() => EmitRayIntersectActive(ray, bvh64)); + } + + private uint[] DeclareRayResult() + { + var result = new uint[Gen5RayIntersectControl.ResultDwords]; + for (var component = 0; component < result.Length; component++) + { + result[component] = _module.AddGlobalVariable(_privateUintPointer, SpirvStorageClass.Private, UInt(InvalidNode)); + _module.AddName(result[component], $"rayIntersectResult{component}"); + _interfaces.Add(result[component]); + } + + return result; + } + + private void EmitRayIntersectActive(Gen5RayIntersectControl ray, bool bvh64) + { + var result = _rayResult!; + var nodeLow = LoadV(ray.GetAddressRegister(0)); + var nodePointer = bvh64 ? Pair64(nodeLow, LoadV(ray.GetAddressRegister(1))) : Widen(nodeLow); + var nodeType = BitwiseAnd(nodeLow, UInt(7)); + var nodeIndex = ShiftRightLogical64(nodePointer, ULong(3)); + + // The BVH T#: base_address[39:0] in 256-byte units, box_grow_value [62:55], + // box_sort_en [63], size [105:64] (the last valid 64-byte node index), + // triangle_return_mode [120]. + var word0 = LoadS(ray.ScalarResource); + var word1 = LoadS(ray.ScalarResource + 1); + var word2 = LoadS(ray.ScalarResource + 2); + var word3 = LoadS(ray.ScalarResource + 3); + var baseUnits = Pair64(word0, BitwiseAnd(word1, UInt(0xFF))); + var bvhBase = ShiftLeftLogical64(baseUnits, ULong(8)); + var lastNode = Pair64(word2, BitwiseAnd(word3, UInt(0x3FF))); + var boxGrow = BitwiseAnd(ShiftRightLogical(word1, UInt(23)), UInt(0xFF)); + var boxSort = IsNotZero(BitwiseAnd(word1, UInt(0x8000_0000))); + var barycentrics = IsNotZero(BitwiseAnd(word3, UInt(1u << 24))); + var nodeAddress = IAdd64(bvhBase, ShiftLeftLogical64(And64(nodePointer, ULong(~7ul)), ULong(3))); + + var present = _module.AddInstruction(SpirvOp.INotEqual, _boolType, baseUnits, ULong(0)); + var inRange = _module.AddInstruction(SpirvOp.ULessThanEqual, _boolType, nodeIndex, lastNode); + // A float32 box node spans two 64-byte units. + var wideInRange = _module.AddInstruction(SpirvOp.ULessThan, _boolType, nodeIndex, lastNode); + var valid = LogicalAnd(present, inRange); + var isTriangle = LogicalAnd(valid, _module.AddInstruction(SpirvOp.ULessThanEqual, _boolType, nodeType, UInt(NodeTypeTriangle1))); + var isBox16 = LogicalAnd(valid, _module.AddInstruction(SpirvOp.IEqual, _boolType, nodeType, UInt(NodeTypeBoxFloat16))); + var isBox32 = LogicalAnd(LogicalAnd(present, wideInRange), + _module.AddInstruction(SpirvOp.IEqual, _boolType, nodeType, UInt(NodeTypeBoxFloat32))); + + var extent = Bitcast(_floatType, LoadV(ray.GetAddressRegister(bvh64 ? 2 : 1))); + var origin = new uint[3]; + for (var axis = 0; axis < 3; axis++) + origin[axis] = Bitcast(_floatType, LoadV(ray.GetAddressRegister((bvh64 ? 3 : 2) + axis))); + var direction = LoadRayVector(ray, bvh64, inverse: false); + var inverseDirection = LoadRayVector(ray, bvh64, inverse: true); + + // Other node types (user nodes 6 and 7, unused 2 and 3), a null BVH or an + // out-of-range node return four invalid dwords. + foreach (var variable in result) + Store(variable, UInt(InvalidNode)); + EmitConditional(isTriangle, () => + { + var values = EmitRayTriangle(nodeAddress, nodeType, barycentrics, origin, direction); + for (var component = 0; component < values.Length; component++) + Store(result[component], values[component]); + }); + // Each box format reads only its own node bytes. + void EmitBoxes(bool fp16) + { + var children = EmitRayBoxes(nodeAddress, fp16, boxGrow, boxSort, extent, origin, inverseDirection); + for (var component = 0; component < children.Length; component++) + Store(result[component], children[component]); + } + + EmitConditional(isBox16, () => EmitBoxes(fp16: true)); + EmitConditional(isBox32, () => EmitBoxes(fp16: false)); + + for (var component = 0u; component < Gen5RayIntersectControl.ResultDwords; component++) + StoreV(ray.VectorData + component, Load(_uintType, result[component])); + } + + // Direction and inverse direction follow the origin; A16 packs both as six halves + // into three dwords. + private uint[] LoadRayVector(Gen5RayIntersectControl ray, bool bvh64, bool inverse) + { + var first = bvh64 ? 6 : 5; + var values = new uint[3]; + if (!ray.A16) + { + for (var axis = 0; axis < 3; axis++) + values[axis] = Bitcast(_floatType, LoadV(ray.GetAddressRegister(first + (inverse ? 3 : 0) + axis))); + return values; + } + + for (var axis = 0; axis < 3; axis++) + { + var half = (inverse ? 3 : 0) + axis; + var word = LoadV(ray.GetAddressRegister(first + half / 2)); + var bits = half % 2 == 0 ? BitwiseAnd(word, UInt(0xFFFF)) : ShiftRightLogical(word, UInt(16)); + values[axis] = Bitcast(_floatType, EmitHalfToFloat(bits)); + } + + return values; + } + + private uint LoadNodeDword(uint nodeAddress, uint dword) => + LoadDeviceDword(IAdd64(nodeAddress, ULong(dword * 4ul))); + + private uint LoadNodeFloat(uint nodeAddress, uint dword) => + Bitcast(_floatType, LoadNodeDword(nodeAddress, dword)); + + // fast_intersect_triangle and SwizzleBarycentrics. Node type 0 tests (v0, v1, v2), + // type 1 tests (v1, v3, v2). A miss returns t_num = +inf over t_denom = 1. The + // barycentric return mode yields the i/j numerators the builder's rotation maps + // back; the other mode yields the triangle id and a hit flag. + private uint[] EmitRayTriangle(uint nodeAddress, uint nodeType, uint barycentrics, + IReadOnlyList origin, IReadOnlyList direction) + { + var second = _module.AddInstruction(SpirvOp.IEqual, _boolType, nodeType, UInt(NodeTypeTriangle1)); + var v1 = new uint[3]; + var v2 = new uint[3]; + var v3 = new uint[3]; + for (var axis = 0u; axis < 3; axis++) + { + var first = LoadNodeFloat(nodeAddress, axis); + var vertex1 = LoadNodeFloat(nodeAddress, 3 + axis); + var vertex2 = LoadNodeFloat(nodeAddress, 6 + axis); + var vertex3 = LoadNodeFloat(nodeAddress, 9 + axis); + v1[axis] = SelectF(second, vertex1, first); + v2[axis] = SelectF(second, vertex3, vertex1); + v3[axis] = vertex2; + } + + var e1 = Subtract(v2, v1); + var e2 = Subtract(v3, v1); + var e3 = Subtract(origin, v1); + var s1 = Cross(direction, e2); + var s2 = Cross(e3, e1); + var tNum = Dot(e2, s2); + var tDenom = Dot(s1, e1); + var iNum = Dot(e3, s1); + var jNum = Dot(direction, s2); + + var t = FDiv(tNum, tDenom); + var u = FDiv(iNum, tDenom); + var v = FDiv(jNum, tDenom); + var zero = Float(0f); + var one = Float(1f); + var missed = Any( + FCompare(SpirvOp.FOrdLessThan, u, zero), + FCompare(SpirvOp.FOrdGreaterThan, u, one), + FCompare(SpirvOp.FOrdLessThan, v, zero), + FCompare(SpirvOp.FOrdGreaterThan, FAdd(u, v), one), + FCompare(SpirvOp.FOrdLessThan, t, zero)); + tNum = SelectF(missed, Float(float.PositiveInfinity), tNum); + tDenom = SelectF(missed, one, tDenom); + + var triangleId = LoadNodeDword(nodeAddress, TriangleIdDword); + var shift = ShiftLeftLogical(nodeType, UInt(3)); + uint Barycentric(int sourceShift) + { + var source = BitwiseAnd(ShiftRightLogical(triangleId, IAdd(shift, UInt((uint)sourceShift))), UInt(3)); + return SelectF(_module.AddInstruction(SpirvOp.IEqual, _boolType, source, UInt(1)), iNum, + SelectF(_module.AddInstruction(SpirvOp.IEqual, _boolType, source, UInt(2)), jNum, + FSub(FSub(tDenom, iNum), jNum))); + } + + var hit = LogicalNot(missed); + return + [ + Bitcast(_uintType, tNum), + Bitcast(_uintType, tDenom), + SelectU(barycentrics, Bitcast(_uintType, Barycentric(TriangleIdISourceShift)), triangleId), + SelectU(barycentrics, Bitcast(_uintType, Barycentric(TriangleIdJSourceShift)), SelectU(hit, UInt(1), UInt(0))), + ]; + } + + // IntersectNodeBvh4 with fast_intersect_bbox: slab test clipped to [0, extent], a + // NaN interval misses, box_grow_value widens the exit time by that many 2^-24 + // steps, and the optional sort orders hit children by entry time. + private uint[] EmitRayBoxes(uint nodeAddress, bool fp16, uint boxGrow, uint boxSort, uint extent, + IReadOnlyList origin, IReadOnlyList inverseDirection) + { + var zero = Float(0f); + var growFactor = FAdd(Float(1f), FMul( + _module.AddInstruction(SpirvOp.ConvertUToF, _floatType, boxGrow), Float(5.960464478e-8f))); + var children = new uint[4]; + var keys = new uint[4]; + for (var child = 0u; child < 4; child++) + { + var pointer = LoadNodeDword(nodeAddress, child); + var enter = Float(float.NegativeInfinity); + var leave = Float(float.PositiveInfinity); + for (var axis = 0u; axis < 3; axis++) + { + var min = BoxBound(nodeAddress, fp16, child, axis); + var max = BoxBound(nodeAddress, fp16, child, axis + 3); + var planeMin = FMul(FSub(min, origin[(int)axis]), inverseDirection[(int)axis]); + var planeMax = FMul(FSub(max, origin[(int)axis]), inverseDirection[(int)axis]); + var positive = FCompare(SpirvOp.FOrdGreaterThanEqual, inverseDirection[(int)axis], zero); + var near = SelectF(positive, planeMin, planeMax); + var far = SelectF(positive, planeMax, planeMin); + // HLSL max3/min3: NaN operands propagate, so the NaN check below sees them. + enter = axis == 0 ? near : NanMax(enter, near); + leave = axis == 0 ? far : NanMin(leave, far); + } + + var nan = _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, enter), + _module.AddInstruction(SpirvOp.IsNan, _boolType, leave)); + var minT = SelectF(nan, Float(float.PositiveInfinity), Ext(40, _floatType, enter, zero)); + var maxT = SelectF(nan, Float(float.NegativeInfinity), Ext(37, _floatType, leave, extent)); + var hit = FCompare(SpirvOp.FOrdLessThanEqual, minT, FMul(maxT, growFactor)); + children[child] = SelectU(hit, pointer, UInt(InvalidNode)); + keys[child] = minT; + } + + // The hardware sorting network; an invalid child always sinks. + var sorted = (uint[])children.Clone(); + (int, int)[] network = [(0, 2), (1, 3), (0, 1), (2, 3), (1, 2)]; + foreach (var (a, b) in network) + { + var bValid = _module.AddInstruction(SpirvOp.INotEqual, _boolType, sorted[b], UInt(InvalidNode)); + var aInvalid = _module.AddInstruction(SpirvOp.IEqual, _boolType, sorted[a], UInt(InvalidNode)); + var swap = _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + LogicalAnd(bValid, FCompare(SpirvOp.FOrdLessThan, keys[b], keys[a])), aInvalid); + (sorted[a], sorted[b]) = (SelectU(swap, sorted[b], sorted[a]), SelectU(swap, sorted[a], sorted[b])); + (keys[a], keys[b]) = (SelectF(swap, keys[b], keys[a]), SelectF(swap, keys[a], keys[b])); + } + + for (var child = 0; child < 4; child++) + children[child] = SelectU(boxSort, sorted[child], children[child]); + return children; + } + + // Bound 0-2 is the minimum, 3-5 the maximum of one child box. + private uint BoxBound(uint nodeAddress, bool fp16, uint child, uint bound) + { + var index = child * 6 + bound; + if (!fp16) + return LoadNodeFloat(nodeAddress, BoxBoundsDword + index); + + var packed = LoadNodeDword(nodeAddress, BoxBoundsDword + index / 2); + var bits = index % 2 == 0 ? BitwiseAnd(packed, UInt(0xFFFF)) : ShiftRightLogical(packed, UInt(16)); + return Bitcast(_floatType, EmitHalfToFloat(bits)); + } + + private uint NanMax(uint left, uint right) => SelectF( + _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, left), + FCompare(SpirvOp.FOrdGreaterThan, left, right)), left, right); + + private uint NanMin(uint left, uint right) => SelectF( + _module.AddInstruction(SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IsNan, _boolType, left), + FCompare(SpirvOp.FOrdLessThan, left, right)), left, right); + + private uint[] Subtract(IReadOnlyList left, IReadOnlyList right) => + [FSub(left[0], right[0]), FSub(left[1], right[1]), FSub(left[2], right[2])]; + + private uint[] Cross(IReadOnlyList a, IReadOnlyList b) => + [ + FSub(FMul(a[1], b[2]), FMul(a[2], b[1])), + FSub(FMul(a[2], b[0]), FMul(a[0], b[2])), + FSub(FMul(a[0], b[1]), FMul(a[1], b[0])), + ]; + + private uint Dot(IReadOnlyList a, IReadOnlyList b) => + FAdd(FAdd(FMul(a[0], b[0]), FMul(a[1], b[1])), FMul(a[2], b[2])); + + private uint Any(params uint[] conditions) + { + var result = conditions[0]; + for (var index = 1; index < conditions.Length; index++) + result = _module.AddInstruction(SpirvOp.LogicalOr, _boolType, result, conditions[index]); + return result; + } + + private uint FCompare(SpirvOp op, uint left, uint right) => _module.AddInstruction(op, _boolType, left, right); + private uint FAdd(uint left, uint right) => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right); + private uint FSub(uint left, uint right) => _module.AddInstruction(SpirvOp.FSub, _floatType, left, right); + private uint FMul(uint left, uint right) => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right); + private uint FDiv(uint left, uint right) => _module.AddInstruction(SpirvOp.FDiv, _floatType, left, right); + private uint SelectF(uint condition, uint whenTrue, uint whenFalse) => + _module.AddInstruction(SpirvOp.Select, _floatType, condition, whenTrue, whenFalse); + } +} diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs index 2ccf92c..34df42b 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs @@ -115,6 +115,7 @@ public static partial class Gen5SpirvTranslator _localSizeX = Math.Max(request.LocalSizeX, 1); _localSizeY = Math.Max(request.LocalSizeY, 1); _localSizeZ = Math.Max(request.LocalSizeZ, 1); + _physicalAxisOfLogical = ComputeWorkgroupAxisOrder(_localSizeX, _localSizeY, _localSizeZ); _emulateWave64 = _stage == Gen5SpirvStage.Compute && _waveLaneCount == 64 && (ulong)_localSizeX * _localSizeY * _localSizeZ == 64; _requiredVertexOutputCount = request.RequiredVertexOutputCount; _pixelInputEnable = request.PixelInputEnable; @@ -322,31 +323,41 @@ public static partial class Gen5SpirvTranslator } var isStorage = resourceClass == ImageResourceClass.Storage; - var kind = numericClass switch - { - ImageNumericClass.Uint => ImageComponentKind.Uint, - ImageNumericClass.Sint => ImageComponentKind.Sint, - _ => ImageComponentKind.Float, - }; + // Every atomic image (integer or float) is declared with the R32ui + // format below, so its OpTypeImage Sampled Type must be uint too - + // VUID-StandaloneSpirv-Image-04965 requires the two to match. The + // float atomics (Fmin/Fmax) still operate on the real float bits; + // they just get there through a bitcast + integer compare-exchange + // (see Gen5SpirvTranslator.cs's ImageAtomicFmax/Fmin case) instead + // of through the declared image/sampled type. + var kind = atomic + ? ImageComponentKind.Uint + : numericClass switch + { + ImageNumericClass.Uint => ImageComponentKind.Uint, + ImageNumericClass.Sint => ImageComponentKind.Sint, + _ => ImageComponentKind.Float, + }; var componentType = kind switch { ImageComponentKind.Sint => _intType, ImageComponentKind.Uint => _uintType, _ => _floatType, }; + // The image request builder binds guest 1D textures through 1D views, + // so the shader declaration must be 1D as well. var spirvDimension = dimension switch { ImageDimension.Dim1D or ImageDimension.Dim1DArray => SpirvImageDim.Dim1D, ImageDimension.Dim3D => SpirvImageDim.Dim3D, _ => SpirvImageDim.Dim2D, }; - var arrayed = dimension is ImageDimension.Dim1DArray or ImageDimension.Dim2DArray or ImageDimension.Dim2DMsaaArray; - var multisampled = dimension is ImageDimension.Dim2DMsaa or ImageDimension.Dim2DMsaaArray; if (spirvDimension == SpirvImageDim.Dim1D) { _module.AddCapability(isStorage ? SpirvCapability.Image1D : SpirvCapability.Sampled1D); } - + var arrayed = dimension is ImageDimension.Dim1DArray or ImageDimension.Dim2DArray or ImageDimension.Dim2DMsaaArray; + var multisampled = dimension is ImageDimension.Dim2DMsaa or ImageDimension.Dim2DMsaaArray; var format = atomic ? SpirvImageFormat.R32ui : SpirvImageFormat.Unknown; if (isStorage && !atomic) { @@ -772,16 +783,44 @@ public static partial class Gen5SpirvTranslator } else if (entry.Kind == MemoryResourceKind.ScalarBuffer) { - if (entry.Resource == MemoryAccessInfo.NoResource) + if (entry.Resource != MemoryAccessInfo.NoResource) { - error = "scalar buffer load has no dense buffer"; - return false; + var bindingIndex = (int)entry.Resource; + var byteAddress = IAdd(dynamicOffset, UInt(unchecked((uint)control.ImmediateOffsetBytes + (uint)component * sizeof(uint)))); + byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress); + value = LoadBufferWord(bindingIndex, ShiftRightLogical(byteAddress, UInt(2))); } + else + { + // A runtime SRT V#: its SGPRs hold the descriptor, so build the + // guest address from the base and go through the device-address + // page table, bounded by the descriptor's stride and record count. + if (instruction.Sources.Count == 0 || instruction.Sources[0].Kind != Gen5OperandKind.ScalarRegister) + { + error = "runtime scalar buffer load has no scalar base"; + return false; + } - var bindingIndex = (int)entry.Resource; - var byteAddress = IAdd(dynamicOffset, UInt(unchecked((uint)control.ImmediateOffsetBytes + (uint)component * sizeof(uint)))); - byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress); - value = LoadBufferWord(bindingIndex, ShiftRightLogical(byteAddress, UInt(2))); + var baseRegister = instruction.Sources[0].Value; + var descriptorWord1 = LoadS(baseRegister + 1); + var stride = BitwiseAnd(ShiftRightLogical(descriptorWord1, UInt(16)), UInt(0x3FFF)); + var records = LoadS(baseRegister + 2); + var size = _module.AddInstruction( + SpirvOp.Select, + _uintType, + _module.AddInstruction(SpirvOp.IEqual, _boolType, stride, UInt(0)), + records, + _module.AddInstruction(SpirvOp.IMul, _uintType, stride, records)); + var baseAddress = Pair64(LoadS(baseRegister), BitwiseAnd(descriptorWord1, UInt(0xFFFF))); + var byteOffset = IAdd(dynamicOffset, UInt(unchecked((uint)control.ImmediateOffsetBytes + (uint)component * sizeof(uint)))); + var address = And64(IAdd64(baseAddress, Widen(byteOffset)), ULong(DeviceAddressMask & ~3ul)); + var inRange = _module.AddInstruction( + SpirvOp.ULessThan, + _boolType, + ShiftRightLogical(byteOffset, UInt(2)), + ShiftRightLogical(size, UInt(2))); + value = LoadBoundedDeviceDword(address, inRange); + } } else { @@ -846,6 +885,11 @@ public static partial class Gen5SpirvTranslator address = IAdd64(address, SignedOffset64(control.OffsetBytes)); var entry = request.Memory[memoryIndex]; + if (control.UsesFlatAddress && entry.AddressSpace != FlatAddressSpace.Global) + { + return TryEmitFlatLocalMemory(instruction, control, memoryOpcode, entry.AddressSpace, address, out error); + } + var writes = entry.Access is MemoryAccess.Write or MemoryAccess.Atomic; var accessBytes = Math.Max((entry.DataBits + 7) / 8, 1u) * Math.Max(entry.DataDwords, 1u); var allowed = writes ? IsWrittenAccessAllowed(memoryIndex, address, accessBytes) : _module.ConstantBool(true); @@ -927,6 +971,100 @@ public static partial class Gen5SpirvTranslator return true; } + // Routes each lane of an aperture-derived FLAT access like the hardware: LDS or + // scratch at the low dword when the high dword is in an aperture, otherwise a + // global access through the page table. + private bool TryEmitFlatLocalMemory( + Gen5ShaderInstruction instruction, + Gen5GlobalMemoryControl control, + string memoryOpcode, + FlatAddressSpace addressSpace, + uint address, + out string error) + { + error = string.Empty; + var store = memoryOpcode.StartsWith("GlobalStoreDword", StringComparison.Ordinal); + var load = memoryOpcode.StartsWith("GlobalLoadDword", StringComparison.Ordinal); + if (!store && !load) + { + error = $"{instruction.Opcode} through the {addressSpace} flat aperture is not implemented"; + return false; + } + + var canShare = addressSpace is FlatAddressSpace.Shared or FlatAddressSpace.SharedOrPrivate; + var canPrivate = addressSpace is FlatAddressSpace.Private or FlatAddressSpace.SharedOrPrivate; + if (canShare && _lds == 0) + { + error = "a shared flat aperture access has no LDS storage"; + return false; + } + + if (canPrivate && (_scratch == 0 || _scratchDwordCount == 0)) + { + error = "a private flat aperture access has no scratch storage"; + return false; + } + + var offset = _module.AddInstruction(SpirvOp.UConvert, _uintType, address); + var aperture = _module.AddInstruction( + SpirvOp.UConvert, + _uintType, + ShiftRightLogical64(address, _module.Constant64(_ulongType, 32 + Gen5InlineConstants.ApertureShift))); + var isShared = _module.AddInstruction(SpirvOp.IEqual, _boolType, aperture, + UInt(Gen5InlineConstants.SharedApertureHigh >> Gen5InlineConstants.ApertureShift)); + var isPrivate = _module.AddInstruction(SpirvOp.IEqual, _boolType, aperture, + UInt(Gen5InlineConstants.PrivateApertureHigh >> Gen5InlineConstants.ApertureShift)); + + void Shared() + { + for (uint index = 0; index < control.DwordCount; index++) + { + var pointer = LdsPointer(offset, index * sizeof(uint)); + if (store) + Store(pointer, LoadV(control.SourceVectorRegister + index)); + else + StoreV(control.DestinationVectorRegister + index, Load(_uintType, pointer)); + } + } + + void Private() + { + for (uint index = 0; index < control.DwordCount; index++) + { + var pointer = ScratchPointer(offset, index * sizeof(uint)); + if (store) + Store(pointer, LoadV(control.SourceVectorRegister + index)); + else + StoreV(control.DestinationVectorRegister + index, Load(_uintType, pointer)); + } + } + + void Global() + { + var aligned = And64(address, ULong(~3ul)); + for (uint index = 0; index < control.DwordCount; index++) + { + var dwordAddress = index == 0 ? aligned : IAdd64(aligned, ULong((ulong)index * sizeof(uint))); + if (store) + StoreDeviceDword(dwordAddress, LoadV(control.SourceVectorRegister + index), _module.ConstantBool(true)); + else + StoreV(control.DestinationVectorRegister + index, LoadDeviceDword(dwordAddress)); + } + } + + // Inactive lanes must not touch any of the three storages. + EmitExecConditional(() => + { + if (canShare && canPrivate) + EmitConditional(isShared, Shared, () => EmitConditional(isPrivate, Private, Global)); + else if (canShare) + EmitConditional(isShared, Shared, Global); + else + EmitConditional(isPrivate, Private, Global); + }); + return true; + } + private bool TryEmitScratchMemory( Gen5ShaderInstruction instruction, Gen5GlobalMemoryControl control, @@ -942,6 +1080,10 @@ public static partial class Gen5SpirvTranslator var address = control.ScalarAddress < 125 ? LoadS(control.ScalarAddress) : LoadV(control.VectorAddress); + if (control.DynamicOffsetRegister is { } dynamicOffsetRegister) + { + address = IAdd(address, LoadS(dynamicOffsetRegister)); + } if (control.OffsetBytes != 0) { address = IAdd(address, UInt(unchecked((uint)control.OffsetBytes))); @@ -961,7 +1103,9 @@ public static partial class Gen5SpirvTranslator { Store( ScratchPointer(address, index * sizeof(uint)), - LoadV(control.SourceVectorRegister + index)); + control.SourceIsScalar + ? LoadS(control.SourceVectorRegister + index) + : LoadV(control.SourceVectorRegister + index)); } }); return true; @@ -1085,6 +1229,160 @@ public static partial class Gen5SpirvTranslator return true; } + // BufferLoweringStrategy.PhysicalStorageBuffer: a raw buffer access whose + // V# was read from a runtime scalar address. The descriptor dwords live in + // the SGPRs named by the instruction, so build the guest address from the + // runtime base and go through the device-address page table. + // num_records/stride bound the access, so a null or empty descriptor binds + // nothing, matching the static path's range check. Formatted/typed/atomic + // accesses never reach here; the chooser escalates them to + // BoundedCandidateTable. + private bool TryEmitPhysicalStorageBufferMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + out string error) + { + error = string.Empty; + if (control.Typed || instruction.Opcode.Contains("Format", StringComparison.Ordinal)) + { + error = $"runtime buffer descriptor does not support the formatted access {instruction.Opcode}"; + return false; + } + + if (instruction.Opcode.StartsWith("BufferAtomic", StringComparison.Ordinal)) + { + error = $"runtime buffer descriptor does not support the atomic access {instruction.Opcode}"; + return false; + } + + if (instruction.Sources.Count < 2 || + instruction.Sources[1].Kind != Gen5OperandKind.ScalarRegister) + { + error = "runtime buffer descriptor has no scalar resource base"; + return false; + } + + var srsrc = instruction.Sources[1].Value; + var scalarOffset = instruction.Sources.Count > 2 + ? GetRawSource(instruction, 2) + : UInt(0); + var vectorIndex = control.IndexEnabled ? LoadV(control.VectorAddress) : UInt(0); + var vectorOffset = control.OffsetEnabled + ? LoadV(control.VectorAddress + (control.IndexEnabled ? 1u : 0u)) + : UInt(0); + + var descriptorWord1 = LoadS(srsrc + 1); + var stride = BitwiseAnd(ShiftRightLogical(descriptorWord1, UInt(16)), UInt(0x3FFF)); + var byteOffset = IAdd(UInt(unchecked((uint)control.OffsetBytes)), scalarOffset); + byteOffset = IAdd(byteOffset, vectorOffset); + byteOffset = IAdd( + byteOffset, + _module.AddInstruction(SpirvOp.IMul, _uintType, vectorIndex, stride)); + + // size = stride == 0 ? num_records : stride * num_records, in bytes. + var records = LoadS(srsrc + 2); + var size = _module.AddInstruction( + SpirvOp.Select, + _uintType, + _module.AddInstruction(SpirvOp.IEqual, _boolType, stride, UInt(0)), + records, + _module.AddInstruction(SpirvOp.IMul, _uintType, stride, records)); + + var dwordOffset = ShiftRightLogical(byteOffset, UInt(2)); + var dwordSize = ShiftRightLogical(size, UInt(2)); + var baseAddress = Pair64( + LoadS(srsrc), + BitwiseAnd(descriptorWord1, UInt(0xFFFF))); + var address = IAdd64(baseAddress, Widen(byteOffset)); + + uint InRange(uint index) + { + var dword = index == 0 ? dwordOffset : IAdd(dwordOffset, UInt(index)); + return _module.AddInstruction(SpirvOp.ULessThan, _boolType, dword, dwordSize); + } + + if (instruction.Opcode.StartsWith("BufferStoreDword", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("BufferStoreByte", StringComparison.Ordinal) || + instruction.Opcode.StartsWith("BufferStoreShort", StringComparison.Ordinal)) + { + EmitExecConditional(() => + { + if (TryGetSubdwordStoreInfo(instruction.Opcode, out var byteCount, out var sourceShift)) + { + StoreDeviceBytes( + address, + LoadV(control.VectorData), + byteCount, + sourceShift, + InRange(0)); + return; + } + + for (uint index = 0; index < control.DwordCount; index++) + { + StoreDeviceDword( + index == 0 ? address : IAdd64(address, ULong((ulong)index * sizeof(uint))), + LoadV(control.VectorData + index), + InRange(index)); + } + }); + return true; + } + + if (TryGetSubdwordLoadInfo( + instruction.Opcode, + out var loadByteCount, + out var signExtend, + out var d16, + out var d16High)) + { + StoreV( + control.VectorData, + LoadSubdwordDeviceValue( + address, + LoadV(control.VectorData), + loadByteCount, + signExtend, + d16, + d16High)); + return true; + } + + if (!instruction.Opcode.StartsWith("BufferLoad", StringComparison.Ordinal) && + !instruction.Opcode.StartsWith("TBufferLoad", StringComparison.Ordinal)) + { + error = $"unsupported runtime buffer opcode {instruction.Opcode}"; + return false; + } + + for (uint index = 0; index < control.DwordCount; index++) + { + StoreV( + control.VectorData + index, + LoadBoundedDeviceDword( + index == 0 ? address : IAdd64(address, ULong((ulong)index * sizeof(uint))), + InRange(index))); + } + + return true; + } + + private uint LoadBoundedDeviceDword(uint address64, uint inRange) + { + var (pointer, valid) = ResolveDeviceAddress(address64); + Store(_deviceWordScratch, UInt(0)); + EmitConditional(LogicalAnd(inRange, valid), () => + Store( + _deviceWordScratch, + _module.AddInstruction( + SpirvOp.Load, + _uintType, + DeviceWordPointer(pointer), + 2u, + 4u))); + return Load(_uintType, _deviceWordScratch); + } + // ---- images ---- // An access over several descriptors takes one constant-element case per descriptor: diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index 81cf6fe..4c4a7b9 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -23,6 +23,38 @@ public static partial class Gen5SpirvTranslator uint numberType) => CompilationContext.DecodeStorageImageFormat(dataFormat, numberType); + // Vulkan only guarantees maxComputeWorkGroupSize >= (1024, 1024, 64) and + // maxComputeWorkGroupInvocations >= 1024, but a GNM NUM_THREAD layout can put its + // largest axis on Z (real PS5 hardware has no such asymmetry between axes). Remap + // logical GNM axes onto physical SPIR-V/dispatch axes so the largest logical size + // always lands on the physical axis with the most headroom (X), and the smallest + // lands on Z, which is the only axis with a tight guaranteed limit. This is a pure + // function of the three thread-group sizes so the host dispatch path (which permutes + // vkCmdDispatch's group counts the same way) can recompute it independently without + // any shader-compiler metadata plumbing. + // + // Returns physicalAxisOfLogical: physicalAxisOfLogical[logical 0=X/1=Y/2=Z] is the + // physical axis (0=X/1=Y/2=Z) that logical axis is remapped onto. + public static int[] ComputeWorkgroupAxisOrder(uint sizeX, uint sizeY, uint sizeZ) + { + if (sizeZ <= 64) + { + return [0, 1, 2]; + } + + var sizes = new[] { sizeX, sizeY, sizeZ }; + var byDescendingSize = new[] { 0, 1, 2 }; + Array.Sort(byDescendingSize, (a, b) => sizes[b].CompareTo(sizes[a])); + + var physicalAxisOfLogical = new int[3]; + for (var physical = 0; physical < 3; physical++) + { + physicalAxisOfLogical[byDescendingSize[physical]] = physical; + } + + return physicalAxisOfLogical; + } + private sealed partial class CompilationContext { private const int ScalarRegisterCount = 128; @@ -80,6 +112,13 @@ public static partial class Gen5SpirvTranslator private readonly uint _localSizeX; private readonly uint _localSizeY; private readonly uint _localSizeZ; + + // Vulkan only guarantees maxComputeWorkGroupSize >= (1024, 1024, 64), but a GNM + // NUM_THREAD layout can put its largest axis on Z (real hardware has no such asymmetry). + // physicalAxisOfLogical[logical 0=X/1=Y/2=Z] gives which physical SPIR-V axis (and which + // vkCmdDispatch group-count slot) that logical axis is remapped onto, so the largest size + // always lands on the physical axis with the most headroom. + private readonly int[] _physicalAxisOfLogical; private readonly uint _pixelInputEnable; private readonly uint _pixelInputAddress; private readonly uint[] _pixelInputCntl; @@ -122,6 +161,7 @@ public static partial class Gen5SpirvTranslator private uint _storageUintPointer; private uint _lds; private uint _ldsElementPointer; + private uint _lds64ElementPointer; private uint _ldsDwordMask; private uint _scratch; private uint _scratchElementPointer; @@ -172,11 +212,11 @@ public static partial class Gen5SpirvTranslator ImageComponentKind ComponentKind, bool IsStorage, bool Arrayed, - bool Cube, - bool Multisampled, - SpirvImageDim Dimension, - uint ConversionFormat, - uint ShaderSwizzle, + bool Cube = false, + bool Multisampled = false, + SpirvImageDim Dimension = SpirvImageDim.Dim2D, + uint ConversionFormat = 0, + uint ShaderSwizzle = 0, int EmulatedCompareFunction = -1); private readonly record struct SpirvVertexInput( @@ -428,12 +468,19 @@ public static partial class Gen5SpirvTranslator } else if (_stage == Gen5SpirvStage.Compute) { + var logicalSizes = new[] { _localSizeX, _localSizeY, _localSizeZ }; + var physicalSizes = new uint[3]; + for (var logical = 0; logical < 3; logical++) + { + physicalSizes[_physicalAxisOfLogical[logical]] = logicalSizes[logical]; + } + _module.AddExecutionMode( main, SpirvExecutionMode.LocalSize, - _localSizeX, - _localSizeY, - _localSizeZ); + physicalSizes[0], + physicalSizes[1], + physicalSizes[2]); } var attributeCount = _stage == Gen5SpirvStage.Vertex @@ -576,7 +623,9 @@ public static partial class Gen5SpirvTranslator private void DeclareScratch() { if (!_request.Program.Instructions.Any(static instruction => - instruction.Opcode.StartsWith("Scratch", StringComparison.Ordinal))) + instruction.Opcode.StartsWith("Scratch", StringComparison.Ordinal)) && + !_request.Memory.Entries.Any(static memory => + memory.AddressSpace is FlatAddressSpace.Private or FlatAddressSpace.SharedOrPrivate)) { return; } @@ -661,6 +710,7 @@ public static partial class Gen5SpirvTranslator var ldsArrayType = _module.TypeArray(_uintType, dwordCount); var ldsPointer = _module.TypePointer(storageClass, ldsArrayType); _ldsElementPointer = _module.TypePointer(storageClass, _uintType); + _lds64ElementPointer = _module.TypePointer(storageClass, _ulongType); _lds = storageClass == SpirvStorageClass.Workgroup ? _module.AddGlobalVariable(ldsPointer, storageClass) : _module.AddGlobalVariable( @@ -855,6 +905,8 @@ public static partial class Gen5SpirvTranslator for (var index = 0; index < attributes.Length; index++) { var attribute = attributes[index]; + // V_INTERP_MOV reads one vertex of the primitive, so its attribute is a + // per-vertex array rather than an interpolated value. var variable = _module.AddGlobalVariable( _perVertexAttributes.Contains(attribute) ? _module.TypePointer(SpirvStorageClass.Input, _module.TypeArray(_vec4Type, 3)) @@ -1074,18 +1126,23 @@ public static partial class Gen5SpirvTranslator var invocationInBounds = _module.ConstantBool(true); for (uint component = 0; component < 3; component++) { + // gl_LocalInvocationId/gl_WorkGroupId are indexed by the physical SPIR-V + // axis, which can differ from the logical GNM axis when the workgroup was + // remapped to respect the device's tighter Z-axis limit (see + // ComputeWorkgroupAxisOrder). + var physicalComponent = (uint)_physicalAxisOfLogical[component]; var localComponent = _module.AddInstruction( SpirvOp.CompositeExtract, _uintType, localId, - component); + physicalComponent); StoreV(component, localComponent, guardWithExec: false); var groupComponent = _module.AddInstruction( SpirvOp.CompositeExtract, _uintType, workGroupId, - component); + physicalComponent); var localSize = component switch { 0 => _localSizeX, @@ -1120,15 +1177,15 @@ public static partial class Gen5SpirvTranslator StoreComputeSystemRegister( registers.WorkGroupXRegister, workGroupId, - 0); + (uint)_physicalAxisOfLogical[0]); StoreComputeSystemRegister( registers.WorkGroupYRegister, workGroupId, - 1); + (uint)_physicalAxisOfLogical[1]); StoreComputeSystemRegister( registers.WorkGroupZRegister, workGroupId, - 2); + (uint)_physicalAxisOfLogical[2]); if (registers.ThreadGroupSizeRegister is { } sizeRegister) { StoreS( @@ -1172,6 +1229,8 @@ public static partial class Gen5SpirvTranslator { if ((_pixelInputAddress & (1u << bit)) != 0) { + // Shaders that interpolate by hand (P1/P2 on per-vertex attributes) read + // the I/J barycentrics from these registers. if (_barycentricInputs.TryGetValue(bit, out var barycentricInput)) { var coordinates = LoadBarycentricCoordinates(bit, barycentricInput); @@ -1182,6 +1241,7 @@ public static partial class Gen5SpirvTranslator StoreV(vgpr + component, Bitcast(_uintType, coordinate), guardWithExec: false); } } + vgpr += dwordCount; } } @@ -1433,8 +1493,11 @@ public static partial class Gen5SpirvTranslator { if (_stage == Gen5SpirvStage.Compute) { + // s_waitcnt vmcnt(0) + s_barrier also publishes buffer and image + // stores to the workgroup: AcquireRelease over uniform, workgroup + // and image memory. var workgroup = UInt(2); - var semantics = UInt(0x108); + var semantics = UInt(0x8 | 0x40 | 0x100 | 0x800); _module.AddStatement( SpirvOp.ControlBarrier, workgroup, @@ -1461,7 +1524,8 @@ public static partial class Gen5SpirvTranslator if (instruction.Control is Gen5RayIntersectControl rayIntersect) { - EmitRayIntersectMiss(rayIntersect); + _deviceAddressInstructionPc = instruction.Pc; + EmitRayIntersect(rayIntersect, instruction.Opcode == "ImageBvh64IntersectRay"); return true; } @@ -1540,6 +1604,8 @@ public static partial class Gen5SpirvTranslator case "DsAppend": case "DsConsume": return TryEmitDataShareWaveCounter(instruction, control, out error); + case "DsMskorB32": + return TryEmitDataShareAtomic(instruction, control, out error); case "DsWriteB32": case "DsWriteAddtidB32": { @@ -1575,6 +1641,92 @@ public static partial class Gen5SpirvTranslator GetRawSource(instruction, 2)); return true; } + case "DsAddU64": + case "DsOrB64": + { + if (instruction.Sources.Count < 3) + { + error = "missing LDS 64-bit atomic source"; + return false; + } + + var atomicAddress = GetRawSource(instruction, 0); + var isOr = instruction.Opcode == "DsOrB64"; + // The LDS allocation is a uint array. Reinterpreting its + // pointer as ulong with OpBitcast is invalid SPIR-V, so use + // the dword fallback even when native 64-bit atomics exist. + // The pair is not atomic against concurrent 64-bit use. + var lowPointer = LdsPointer( + atomicAddress, + control.SingleOffsetBytes); + var highPointer = LdsPointer( + atomicAddress, + control.SingleOffsetBytes + sizeof(uint)); + EmitExecConditional(() => + { + var lowValue = GetRawSource(instruction, 1); + var highValue = GetRawSource(instruction, 2); + if (isOr) + { + // OR is bitwise-independent: no dword influences the + // other, so two 32-bit ORs are the 64-bit OR. + EmitAtomic( + SpirvOp.AtomicOr, + _uintType, + lowPointer, + scope: 2, + semantics: 0x108, + value: () => lowValue, + comparator: () => lowValue); + EmitAtomic( + SpirvOp.AtomicOr, + _uintType, + highPointer, + scope: 2, + semantics: 0x108, + value: () => highValue, + comparator: () => highValue); + return; + } + + // ADD needs the carry out of the low dword folded into + // the high one. Each lane detects its own wrap from the + // pre-add value its atomic returned and adds exactly one + // carry, so the high dword ends up with the true count of + // wraps however the lanes interleave. + var originalLow = EmitAtomic( + SpirvOp.AtomicIAdd, + _uintType, + lowPointer, + scope: 2, + semantics: 0x108, + value: () => lowValue, + comparator: () => lowValue); + var sumLow = IAdd(originalLow, lowValue); + var wrapped = _module.AddInstruction( + SpirvOp.ULessThan, + _boolType, + sumLow, + originalLow); + var carry = _module.AddInstruction( + SpirvOp.Select, + _uintType, + wrapped, + UInt(1), + UInt(0)); + var highWithCarry = IAdd(highValue, carry); + EmitAtomic( + SpirvOp.AtomicIAdd, + _uintType, + highPointer, + scope: 2, + semantics: 0x108, + value: () => highWithCarry, + comparator: () => highWithCarry); + }); + + return true; + } case "DsWriteB96": case "DsWriteB128": { @@ -1898,6 +2050,40 @@ public static partial class Gen5SpirvTranslator out string error) { error = string.Empty; + if (instruction.Opcode == "DsMskorB32") + { + if (instruction.Sources.Count < 3) + { + error = "missing LDS masked-OR source"; + return false; + } + + var maskedPointer = LdsPointer( + GetRawSource(instruction, 0), + control.SingleOffsetBytes); + EmitExecConditional(() => + { + var original = Load(_uintType, maskedPointer); + var updated = BitwiseOr( + BitwiseAnd( + original, + _module.AddInstruction( + SpirvOp.Not, + _uintType, + GetRawSource(instruction, 1))), + GetRawSource(instruction, 2)); + EmitAtomic( + SpirvOp.AtomicCompareExchange, + _uintType, + maskedPointer, + scope: 2, + semantics: 0x108, + value: () => updated, + comparator: () => original); + }); + return true; + } + if (instruction.Opcode is "DsMinF32" or "DsMaxF32") { if (instruction.Sources.Count < 3) @@ -2208,6 +2394,13 @@ public static partial class Gen5SpirvTranslator out string error) { error = string.Empty; + if (instruction.Opcode is "BufferWbinvl1" or "BufferWbinvl1Vol") + { + // The host storage path is already coherent; the guest instruction + // invalidates a cache level and does not access the buffer itself. + return true; + } + if (control.Typed && instruction.Opcode.Contains("D16", StringComparison.Ordinal)) { error = $"unsupported buffer opcode {instruction.Opcode}"; @@ -2225,23 +2418,65 @@ public static partial class Gen5SpirvTranslator return TryEmitDeviceDescriptorBufferMemory(instruction, control, out error); } - int bindingIndex; - uint stride; - uint descriptorWord3; - uint descriptorFormat; + if (_request.Memory.TryGetIndex(instruction.Pc, 0, out var accessMemoryIndex)) { - // The dense buffer, its stride and its format come from the specialization. - if (!TryResolveLayoutBuffer(instruction.Pc, out bindingIndex, out var specialized)) + var accessMemory = _request.Memory[accessMemoryIndex]; + if (accessMemory.PlanningOnly) { - error = "missing buffer-memory binding"; - return false; + // The access belongs to the linearized tail of a fused + // shader. Its descriptor is supplied by the continuation + // object and is intentionally absent from this plan; keep + // the dead linear instruction side-effect free. + return true; } - stride = UInt(specialized.PackedStride & 0x3FFF); - descriptorFormat = specialized.DescriptorFormat; - descriptorWord3 = UInt((specialized.DescriptorFormat << 12) | (specialized.DescriptorSwizzle & 0xFFF)); + var strategy = accessMemory.BufferDescriptor?.ChooseStrategy( + control.Typed, + accessMemory.Formatted, + accessMemory.Access == MemoryAccess.Atomic) + ?? BufferLoweringStrategy.NativeBinding; + if (strategy == BufferLoweringStrategy.PhysicalStorageBuffer) + { + return TryEmitPhysicalStorageBufferMemory(instruction, control, out error); + } + + if (strategy == BufferLoweringStrategy.BoundedCandidateTable) + { + return TryEmitBoundedCandidateTableMemory(instruction, control, accessMemoryIndex, out error); + } } + if (!TryResolveLayoutBuffer(instruction.Pc, out var bindingIndex, out _)) + { + error = "missing buffer-memory binding"; + return false; + } + + return EmitResolvedBufferMemory(instruction, control, bindingIndex, out error); + } + + // One buffer operation against a resolved candidate binding: the dense binding path + // and every arm of a bounded candidate table share it, so the operation itself + // stays independent of how its descriptor was selected. + private bool EmitResolvedBufferMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + int bindingIndex, + out string error) + { + error = string.Empty; + var info = _request.Resources.Info; + if (bindingIndex < 0 || bindingIndex >= info.Buffers.Count) + { + error = $"buffer binding {bindingIndex} is out of range"; + return false; + } + + var specialized = info.Buffers[bindingIndex]; + var stride = UInt(specialized.PackedStride & 0x3FFF); + var descriptorFormat = specialized.DescriptorFormat; + var descriptorWord3 = UInt((specialized.DescriptorFormat << 12) | (specialized.DescriptorSwizzle & 0xFFF)); + var scalarOffset = instruction.Sources.Count > 2 ? GetRawSource(instruction, 2) : UInt(0); @@ -2261,6 +2496,44 @@ public static partial class Gen5SpirvTranslator byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress); var dwordAddress = ShiftRightLogical(byteAddress, UInt(2)); + if (instruction.Opcode is "BufferAtomicSwapX2" or "BufferAtomicOrX2") + { + EmitExecConditional(() => + { + var firstInRange = IsBufferWordInRange(bindingIndex, dwordAddress); + var secondAddress = IAdd(dwordAddress, UInt(1)); + var secondInRange = IsBufferWordInRange(bindingIndex, secondAddress); + EmitConditional(LogicalAnd(firstInRange, secondInRange), () => + { + var atomicOp = instruction.Opcode == "BufferAtomicSwapX2" + ? SpirvOp.AtomicExchange + : SpirvOp.AtomicOr; + var first = EmitAtomic( + atomicOp, + _uintType, + BufferWordPointer(bindingIndex, dwordAddress), + scope: 1, + semantics: 0x48, + value: () => LoadV(control.VectorData), + comparator: () => UInt(0)); + var second = EmitAtomic( + atomicOp, + _uintType, + BufferWordPointer(bindingIndex, secondAddress), + scope: 1, + semantics: 0x48, + value: () => LoadV(control.VectorData + 1), + comparator: () => UInt(0)); + if (control.Glc) + { + StoreV(control.VectorData, first); + StoreV(control.VectorData + 1, second); + } + }); + }); + return true; + } + if (instruction.Opcode.StartsWith("BufferAtomic", StringComparison.Ordinal)) { var atomicSuffix = instruction.Opcode["BufferAtomic".Length..]; @@ -2481,6 +2754,114 @@ public static partial class Gen5SpirvTranslator return true; } + // BufferLoweringStrategy.BoundedCandidateTable: a runtime V# whose descriptors cannot + // be reconstructed from its raw words. The runtime V# sits in the scalar resource + // registers; its base-address dword is the probe key into the flattened candidate + // mapping, and each arm binds a native candidate and runs the ordinary buffer op. + private bool TryEmitBoundedCandidateTableMemory( + Gen5ShaderInstruction instruction, + Gen5BufferMemoryControl control, + int memoryIndex, + out string error) + { + error = string.Empty; + if (!_request.BufferCandidateTableByMemoryIndex.TryGetValue(memoryIndex, out var table)) + { + // A formatted load needs a statically known Vulkan view format. Most + // runtime V#s prove a bounded SRT candidate set above, but a few Yotei + // material-lookup paths merge descriptors through control flow and have + // no finite, safe candidate table. Do not reinterpret an arbitrary + // device address with a guessed format: a null read is the defined + // fallback, matching the bindless-image fallback in ResourceTracker. + if (instruction.Opcode.StartsWith("BufferLoadFormat", StringComparison.Ordinal)) + { + for (uint index = 0; index < control.DwordCount; index++) + { + StoreV(control.VectorData + index, UInt(0)); + } + + return true; + } + + error = $"runtime buffer descriptor has no candidate table for {instruction.Opcode}"; + return false; + } + + if (table.CandidateCount == 0) + { + error = "runtime buffer descriptor candidate table is empty"; + return false; + } + + if (table.CandidateCount == 1) + { + return EmitResolvedBufferMemory(instruction, control, (int)table.FirstCandidate, out error); + } + + if (!HasFlattenedTable) + { + error = "runtime buffer descriptor candidate table without a flattened table binding"; + return false; + } + + if (instruction.Sources.Count < 2 || instruction.Sources[1].Kind != Gen5OperandKind.ScalarRegister) + { + error = "runtime buffer descriptor has no scalar resource base"; + return false; + } + + var probeKey = LoadS(instruction.Sources[1].Value); + var selector = SelectBufferCandidate(table, probeKey); + var emitted = true; + var caseError = string.Empty; + for (uint index = 0; index < table.CandidateCount && emitted; index++) + { + var candidate = table.FirstCandidate + index; + EmitConditional(_module.AddInstruction(SpirvOp.IEqual, _boolType, selector, UInt(index)), () => + { + if (!EmitResolvedBufferMemory(instruction, control, (int)candidate, out caseError)) + { + emitted = false; + } + }); + } + + error = caseError; + return emitted; + } + + // Searches the sorted base-address mapping of a candidate table for the runtime V#'s + // probe key; the result is the candidate-local index, candidate 0 when absent. + private uint SelectBufferCandidate(BufferCandidateTableUse table, uint key) + { + var mapping = UInt(table.MappingOffset); + var count = LoadFlattenedWord(mapping); + var low = UInt(0); + var high = count; + for (uint iteration = 0; iteration < table.SearchIterations; iteration++) + { + var span = _module.AddInstruction(SpirvOp.ISub, _uintType, high, low); + var middle = IAdd(low, ShiftRightLogical(span, UInt(1))); + var probeSlot = IAdd(IAdd(mapping, UInt(1)), ShiftLeftLogical(middle, UInt(1))); + var probeKey = LoadFlattenedWord(probeSlot); + var moveUp = LogicalAnd( + _module.AddInstruction(SpirvOp.ULessThan, _boolType, probeKey, key), + _module.AddInstruction(SpirvOp.ULessThan, _boolType, low, high)); + low = _module.AddInstruction(SpirvOp.Select, _uintType, moveUp, IAdd(middle, UInt(1)), low); + high = _module.AddInstruction(SpirvOp.Select, _uintType, moveUp, high, middle); + } + + var foundSlot = IAdd(IAdd(mapping, UInt(1)), ShiftLeftLogical(low, UInt(1))); + var found = LogicalAnd( + _module.AddInstruction(SpirvOp.ULessThan, _boolType, low, count), + _module.AddInstruction(SpirvOp.IEqual, _boolType, LoadFlattenedWord(foundSlot), key)); + var mapped = LoadFlattenedWord(IAdd(foundSlot, UInt(1))); + var inRange = LogicalAnd( + found, + _module.AddInstruction(SpirvOp.ULessThan, _boolType, mapped, UInt(table.CandidateCount))); + return _module.AddInstruction(SpirvOp.Select, _uintType, inRange, mapped, UInt(0)); + } + private bool TryEmitDeviceDescriptorBufferMemory( Gen5ShaderInstruction instruction, Gen5BufferMemoryControl control, @@ -3695,8 +4076,10 @@ public static partial class Gen5SpirvTranslator } // Compare-exchange loop: the merge runs on the observed word until the - // exchange succeeds. - private void EmitAtomicWordUpdate(uint pointer, Func merge) + // exchange succeeds. Returns the observed (pre-exchange) word at the + // point the loop exits, which is a valid use here because it is + // defined by the OpPhi in `header`, and `header` dominates `mergeLabel`. + private uint EmitAtomicWordUpdate(uint pointer, Func merge) { var preheader = _module.AllocateId(); var header = _module.AllocateId(); @@ -3733,6 +4116,7 @@ public static partial class Gen5SpirvTranslator _module.AddLabel(continueLabel); _module.AddStatement(SpirvOp.Branch, header); _module.AddLabel(mergeLabel); + return observed; } private static bool TryGetSubdwordLoadInfo( @@ -3833,48 +4217,12 @@ public static partial class Gen5SpirvTranslator return true; } - // BVH nodes are not traversed yet: every ray misses the node it is tested against. - // The node type sits in the low three bits of the node pointer. A box node (4-7) - // returns four invalid child pointers, a triangle node (0-3) returns t = +inf over - // a denominator of 1, so the guest's traversal loop ends with no hit. - private void EmitRayIntersectMiss(Gen5RayIntersectControl ray) - { - var nodeType = BitwiseAnd(LoadV(ray.GetAddressRegister(0)), UInt(7)); - var isTriangle = _module.AddInstruction(SpirvOp.ULessThan, _boolType, nodeType, UInt(4)); - uint[] triangleMiss = [0x7F800000u, 0x3F800000u, 0u, 0u]; - for (var component = 0u; component < Gen5RayIntersectControl.ResultDwords; component++) - { - var value = _module.AddInstruction( - SpirvOp.Select, - _uintType, - isTriangle, - UInt(triangleMiss[component]), - UInt(0xFFFFFFFFu)); - StoreV(ray.VectorData + component, value); - } - } - private bool TryEmitImage( Gen5ShaderInstruction instruction, Gen5ImageControl image, out string error) { error = string.Empty; - if (instruction.Opcode is "ImageBvhIntersectRay" or "ImageBvh64IntersectRay") - { - // The host path does not expose Vulkan ray-query or an - // acceleration-structure descriptor for GFX10's raw BVH - // texture. Return a deterministic miss instead of rejecting - // the complete compute shader. The instruction always writes - // four DWORD result registers. - for (uint component = 0; component < 4; component++) - { - StoreV(image.VectorData + component, UInt(0)); - } - - return true; - } - SpirvImageResource resource; uint imageObject; uint dstSelect; @@ -3954,7 +4302,8 @@ public static partial class Gen5SpirvTranslator resource.IsStorage || resource.Multisampled ? [queryImage] : [queryImage, LoadImageIntegerAddress(image, 0)]); - var levels = !resource.IsStorage && !resource.Multisampled + // NVIDIA's compiler crashes on an unused level query of a 3D image. + var levels = (image.Dmask & 0x8u) != 0 && !resource.IsStorage && !resource.Multisampled ? _module.AddInstruction( SpirvOp.ImageQueryLevels, _uintType, @@ -4097,6 +4446,50 @@ public static partial class Gen5SpirvTranslator return false; } + // IMAGE_ATOMIC_FMIN/FMAX target float-format storage images and + // have no native SPIR-V storage-image atomic without pulling in + // SPV_EXT_shader_atomic_float_min_max. Lower them as a + // compare-and-swap loop on the underlying bit pattern instead + // (same technique used by other float-atomic emulations that + // avoid that extension dependency). + if (instruction.Opcode is "ImageAtomicFmax" or "ImageAtomicFmin") + { + var isMax = instruction.Opcode == "ImageAtomicFmax"; + var floatCoordinateCount = ImageCoordinateComponentCount(resource); + var floatCoordinates = BuildIntegerCoordinates( + image, + 0, + floatCoordinateCount); + EmitExecConditional(() => + { + var pointer = _module.AddInstruction( + SpirvOp.ImageTexelPointer, + _module.TypePointer(SpirvStorageClass.Image, _uintType), + resource.Variable, + floatCoordinates, + UInt(0)); + var srcBits = Bitcast(_uintType, LoadV(image.VectorData)); + var old = EmitAtomicWordUpdate(pointer, observed => + { + var oldFloat = Bitcast(_floatType, observed); + var srcFloat = Bitcast(_floatType, srcBits); + var pickSrc = _module.AddInstruction( + isMax ? SpirvOp.FOrdLessThan : SpirvOp.FOrdGreaterThan, + _boolType, + oldFloat, + srcFloat); + var chosen = _module.AddInstruction( + SpirvOp.Select, _floatType, pickSrc, srcFloat, oldFloat); + return Bitcast(_uintType, chosen); + }); + if (image.Glc) + { + StoreV(image.VectorData, old); + } + }); + return true; + } + if (resource.ComponentKind == ImageComponentKind.Float || !TryGetAtomicOp(instruction.Opcode["ImageAtomic".Length..], out var atomicOp)) { @@ -4903,6 +5296,12 @@ public static partial class Gen5SpirvTranslator ? _intType : _module.TypeVector(_intType, componentCount); + private uint IntegerTypeForComponents(uint componentCount) => + componentCount == 1 ? _intType : _module.TypeVector(_intType, componentCount); + + private uint FloatTypeForComponents(uint componentCount) => + componentCount == 1 ? _floatType : _module.TypeVector(_floatType, componentCount); + private uint BuildFloatCoordinates( Gen5ImageControl image, int start, @@ -6143,9 +6542,8 @@ public static partial class Gen5SpirvTranslator value = 0; var packedSource = exportInstruction.Sources[component >> 1]; var tracePackedExport = - Environment.GetEnvironmentVariable( - "SHARPEMU_TRACE_PACKED_EXPORT") == "1" && - _request.Program.Address == 0x0000000500781200ul; + Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PACKED_EXPORT") == "1" && + TraceShaderAddressMatches("SHARPEMU_TRACE_PACKED_EXPORT_ADDRESS"); if (tracePackedExport) { Console.Error.WriteLine( @@ -6261,6 +6659,28 @@ public static partial class Gen5SpirvTranslator return false; } + private bool TraceShaderAddressMatches(string environmentVariable) + { + var filter = Environment.GetEnvironmentVariable(environmentVariable); + if (string.IsNullOrWhiteSpace(filter)) + { + return true; + } + + var span = filter.AsSpan(); + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + span = span[2..]; + } + + return ulong.TryParse( + span, + System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, + out var address) && + _request.Program.Address == address; + } + private uint GetPixelOutputType(Gen5PixelOutputKind kind) => kind switch { @@ -6536,6 +6956,15 @@ public static partial class Gen5SpirvTranslator left, BitwiseAnd64(right, _module.Constant64(_ulongType, 63))); + private uint ShiftRightArithmetic64(uint left, uint right) => + Bitcast( + _ulongType, + _module.AddInstruction( + SpirvOp.ShiftRightArithmetic, + _longType, + Bitcast(_longType, left), + BitwiseAnd64(right, _module.Constant64(_ulongType, 63)))); + private uint BitwiseAnd(uint left, uint right) => _module.AddInstruction(SpirvOp.BitwiseAnd, _uintType, left, right); @@ -6845,7 +7274,9 @@ public static partial class Gen5SpirvTranslator private bool UsesLds() => _request.Program.Instructions.Any(instruction => - instruction.Control is Gen5DataShareControl); + instruction.Control is Gen5DataShareControl) || + _request.Memory.Entries.Any(static memory => + memory.AddressSpace is FlatAddressSpace.Shared or FlatAddressSpace.SharedOrPrivate); private bool UsesSubgroupShuffle() => _request.Program.Instructions.Any(instruction => diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs index 4fcd834..485a002 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvModuleBuilder.cs @@ -197,6 +197,7 @@ public enum SpirvCapability : uint Float16 = 9, Float64 = 10, Int64 = 11, + Int64Atomics = 12, Int16 = 22, ImageGatherExtended = 25, StorageImageExtendedFormats = 49, @@ -207,6 +208,8 @@ public enum SpirvCapability : uint GroupNonUniformVote = 62, GroupNonUniformBallot = 64, GroupNonUniformShuffle = 65, + ShaderLayer = 5253, + ShaderViewportIndex = 5254, ShaderViewportIndexLayerExt = 5254, RuntimeDescriptorArray = 5302, PhysicalStorageBufferAddresses = 5347, diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs b/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs index 269e038..0662b24 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/TilerShaders.cs @@ -201,14 +201,55 @@ public static class TilerShaders _ => [X(6, 0x0c0), X(7, 0x200), X(8, 0x800), X(9, 0x2000), X(10, 0x8000), Y(4, 0x030), Y(6, 0x100), Y(7, 0x400), Y(8, 0x1000), Y(9, 0x4000)], }; - private static Term[] RenderTargetTerms(uint bytes) => bytes switch + private readonly record struct AddressBit(Axis Source, byte Bit); + + private static AddressBit BX(byte bit) => new(Axis.X, bit); + + private static AddressBit BY(byte bit) => new(Axis.Y, bit); + + private static AddressBit[] BXY(byte x, byte y) => [BX(x), BY(y)]; + + // PS5 Oberon RB+ R_X addressing. The destination bit index is the array + // index. Keep this in lockstep with GnmTiling.RbPlus64KRenderX and + // TileGeometry.RenderTargetOffset. + private static readonly AddressBit[][][] RenderTargetAddressBits = + [ + [ + [BX(0)], [BX(1)], [BX(2)], [BX(3)], [BY(0)], [BY(1)], [BY(2)], [BY(3)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BX(6)], [BY(6)], BXY(7, 8), BXY(8, 7), + ], + [ + [], [BX(0)], [BX(1)], [BX(2)], [BY(0)], [BY(1)], [BY(2)], [BX(3)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BY(3)], [BX(6)], BXY(7, 7), BXY(8, 6), + ], + [ + [], [], [BX(0)], [BX(1)], [BY(0)], [BY(1)], [BX(2)], [BY(2)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BX(3)], [BY(3)], BXY(6, 7), BXY(7, 6), + ], + [ + [], [], [], [BX(0)], [BY(0)], [BX(1)], [BX(2)], [BY(1)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BY(2)], [BX(3)], BXY(7, 3), BXY(6, 6), + ], + [ + [], [], [], [], [BX(0)], [BY(0)], [BX(1)], [BY(1)], + BXY(7, 4).Concat([BY(7)]).ToArray(), BXY(4, 4), BXY(6, 5), BXY(5, 6), [BX(2)], [BY(2)], BXY(6, 3), BXY(3, 6), + ], + ]; + + private static uint RenderTargetOffset(ShaderModuleContext shaderModule, uint x, uint y, uint bytes) { - 1 => [Y(2, 0x008), Y(4, 0x010), Y(3, 0x0a0), Y(5, 0xf00), Y(6, 0x1000), Y(7, 0x4000), X(0, 7), X(3, 0x040), X(5, 0x300), X(4, 0x400), X(6, 0x800), X(7, 0x2000), X(8, 0x8000)], - 2 => [Y(4, 0x070), Y(5, 0xf00), Y(8, 0x5000), X(1, 0x00e), X(4, 0x480), X(5, 0x300), X(6, 0x800), X(7, 0x2000), X(8, 0x8000)], - 4 => [Y(4, 0x070), Y(5, 0xf00), Y(9, 0x1000), Y(8, 0x4000), X(2, 0x00c), X(5, 0x380), X(4, 0x400), X(6, 0x800), X(9, 0xa000)], - 8 => [Y(4, 0x010), Y(6, 0x080), Y(5, 0xf00), Y(10, 0x5000), X(3, 0x008), X(4, 0x460), X(5, 0x300), X(6, 0x800), X(10, 0x2000), X(9, 0x8000)], - _ => [X(4, 0x410), X(5, 0x340), X(6, 0x800), X(11, 0xa000), Y(5, 0xf20), Y(6, 0x080), Y(10, 0x1000), Y(11, 0x4000)], - }; + uint offset = shaderModule.GetUnsignedConstant(0); + var table = RenderTargetAddressBits[Log2(bytes)]; + for (uint destination = 0; destination < table.Length; destination++) + { + foreach (var bit in table[destination]) + { + offset = shaderModule.Xor(offset, Bit(shaderModule, bit.Source == Axis.X ? x : y, bit.Bit, destination)); + } + } + + return offset; + } private static Term[] DepthTerms(uint bytes) => bytes switch { @@ -306,7 +347,7 @@ public static class TilerShaders return shaderModule.Xor(Standard64KB3DOffset(shaderModule, x, y, z, bytes), delta); } case TilerBlockShape.RenderTarget64KB: - return shaderModule.Xor(XorTerms(shaderModule, x, y, z, RenderTargetTerms(bytes)), ZSpread(shaderModule, z)); + return shaderModule.Xor(RenderTargetOffset(shaderModule, x, y, bytes), ZSpread(shaderModule, z)); case TilerBlockShape.Depth64KB: return shaderModule.Xor(ZSpread(shaderModule, z), XorTerms(shaderModule, x, y, z, DepthTerms(bytes))); default: diff --git a/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs b/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs index 672d80b..ece91a8 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5InlineConstants.cs @@ -9,8 +9,52 @@ namespace SharpEmu.ShaderCompiler; /// public static class Gen5InlineConstants { + public const uint SharedBase = 235; + public const uint SharedLimit = 236; + public const uint PrivateBase = 237; + public const uint PrivateLimit = 238; + + // The PS5 FLAT apertures, as title shaders hard-code them: a high dword of + // 0x7xxxxxxx selects LDS and 0x8xxxxxxx scratch. Guest device addresses fit in + // 40 bits and never reach them. + public const uint SharedApertureHigh = 0x7000_0000; + public const uint PrivateApertureHigh = 0x8000_0000; + + // Each aperture is the window of high dwords sharing its top nibble. + public const int ApertureShift = 28; + + public static bool IsSharedApertureHigh(uint high) => high >> ApertureShift == SharedApertureHigh >> ApertureShift; + + public static bool IsPrivateApertureHigh(uint high) => high >> ApertureShift == PrivateApertureHigh >> ApertureShift; + + public static bool IsAperture(uint encoded) => encoded is >= SharedBase and <= PrivateLimit; + + public static bool IsSharedAperture(uint encoded) => encoded is SharedBase or SharedLimit; + + // The 64-bit aperture value: a base has a zero low dword, a limit spans the + // whole 32-bit offset range. A 32-bit read returns the high dword. + public static ulong DecodeAperture64(uint encoded) + { + var high = IsSharedAperture(encoded) ? SharedApertureHigh : PrivateApertureHigh; + var low = encoded is SharedLimit or PrivateLimit ? uint.MaxValue : 0u; + return ((ulong)high << 32) | low; + } + public static bool TryDecode(uint encoded, out uint value) { + if (IsAperture(encoded)) + { + value = (uint)(DecodeAperture64(encoded) >> 32); + return true; + } + + // POPS_EXITING_WAVE_ID: no primitive-ordered pixel shading is modeled. + if (encoded == 239) + { + value = 0; + return true; + } + if (encoded == 125) { value = 0; diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs index 52d5116..fb48f84 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs @@ -160,7 +160,11 @@ public readonly record struct Gen5Operand(Gen5OperandKind Kind, uint Value) return new(Gen5OperandKind.LiteralConstant, literal.Value); } - if (encoded <= 105 || encoded is 106 or 107 or 124 or 126 or 127) + // 108..123 are the temporary SGPRs (TTMP0..TTMP15). They use the + // same scalar register file as ordinary SGPRs; treating them as + // encoded constants loses values in shader continuations that spill + // through a temporary register. + if (encoded <= 105 || encoded is 106 or 107 or (>= 108 and <= 123) or 124 or 126 or 127) { return Scalar(encoded); } @@ -223,7 +227,9 @@ public sealed record Gen5GlobalMemoryControl( int OffsetBytes, bool Glc, bool Slc, - bool UsesFlatAddress = false) : Gen5InstructionControl; + bool UsesFlatAddress = false, + uint? DynamicOffsetRegister = null, + bool SourceIsScalar = false) : Gen5InstructionControl; // A typed access carries the unified format from the instruction; a formatted // untyped access reads the descriptor format when it executes. diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs index d54f618..e4c1f3f 100644 --- a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs +++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs @@ -14,7 +14,10 @@ public static partial class Gen5ShaderTranslator { private static int _dppVectorsValidated; - private const int MaxInstructions = 16384; + // Some shipped Gen5 shaders exceed 64 KiB of encoded instructions before + // reaching S_ENDPGM. Keep the malformed-shader guard, but size it to the + // largest declared shader object accepted by the decoder (1 MiB). + private const int MaxInstructions = (int)(MaximumDeclaredShaderSizeBytes / sizeof(uint)); private const ulong ShaderSizeOffset = 0x44; private const uint MaximumDeclaredShaderSizeBytes = 1024 * 1024; private static readonly ConditionalWeakTable _fusedProgramsByMemory = new(); @@ -120,7 +123,13 @@ public static partial class Gen5ShaderTranslator ctx, address, maximumBytes: null, - stopAtSetProgramCounter: false, + // An unregistered S_SETPC transfers control to a separately + // allocated code object. The bytes that follow it in this + // object are padding or unrelated data, so decoding through them + // produces bogus ALU instructions and can reject an otherwise + // valid shader. Registered fused programs take the dedicated + // path above and splice their continuation explicitly. + stopAtSetProgramCounter: true, out program, out _, out error); @@ -266,6 +275,8 @@ public static partial class Gen5ShaderTranslator var instructions = new List(); var instructionCount = 0; + var lastPc = 0u; + var lastWord = 0u; uint furthestForwardBranchTarget = 0; for (uint pc = 0; instructionCount < MaxInstructions && @@ -283,6 +294,9 @@ public static partial class Gen5ShaderTranslator return false; } + lastPc = pc; + lastWord = word; + if (!TryDecodeInstruction( ctx, address, @@ -329,7 +343,12 @@ public static partial class Gen5ShaderTranslator if (Gen5IrBranchResolver.Instance.TryGetBranchTarget( instruction, out var branchTargetPc) && - branchTargetPc > instruction.Pc) + branchTargetPc > instruction.Pc && + // S_CBRANCH_CDBG* only fire under an attached GPU debugger, and + // the translators emit them as never taken. Their targets are + // debugger stubs placed after S_ENDPGM that read devkit-only + // addresses, so they must not extend the decoded program. + !instruction.Opcode.StartsWith("SCbranchCdbg", StringComparison.Ordinal)) { furthestForwardBranchTarget = Math.Max( furthestForwardBranchTarget, @@ -337,7 +356,12 @@ public static partial class Gen5ShaderTranslator } pc += sizeDwords * sizeof(uint); - if (string.Equals(name, "SEndpgm", StringComparison.Ordinal) && + // An unconditional backward S_BRANCH with no forward target + // pending is the last reachable instruction too: compilers place + // out-of-line loop blocks after S_ENDPGM, and the code object's + // metadata trailer follows them. + if ((string.Equals(name, "SEndpgm", StringComparison.Ordinal) || + string.Equals(name, "SBranch", StringComparison.Ordinal)) && pc > furthestForwardBranchTarget) { program = new Gen5ShaderProgram(address, instructions); @@ -355,8 +379,8 @@ public static partial class Gen5ShaderTranslator } error = maximumBytes.HasValue - ? $"unterminated limit=0x{maximumBytes.Value:X}" - : "unterminated"; + ? $"unterminated limit=0x{maximumBytes.Value:X} pc=0x{lastPc:X} word=0x{lastWord:X8} count={instructionCount}" + : $"unterminated pc=0x{lastPc:X} word=0x{lastWord:X8} count={instructionCount}"; return false; } @@ -589,8 +613,12 @@ public static partial class Gen5ShaderTranslator 0x10 => "SBcnt1I32B64", 0x13 => "SFF1I32B32", 0x14 => "SFF1I32B64", + 0x15 => "SFlbitI32B32", + 0x16 => "SFlbitI32B64", 0x1B => "SBitset0B32", + 0x1C => "SBitset0B64", 0x1D => "SBitset1B32", + 0x1E => "SBitset1B64", 0x1F => "SGetpcB64", 0x20 => "SSetpcB64", 0x21 => "SSwappcB64", @@ -605,6 +633,7 @@ public static partial class Gen5ShaderTranslator 0x34 => "SAbsI32", 0x37 => "SAndn1SaveexecB64", 0x38 => "SOrn1SaveexecB64", + 0x3B => "SBitreplicateB64B32", 0x3C => "SAndSaveexecB32", 0x3D => "SOrSaveexecB32", 0x3E => "SXorSaveexecB32", @@ -681,6 +710,11 @@ public static partial class Gen5ShaderTranslator 0x34 => "SPackHhB32B16", 0x35 => "SMulHiU32", 0x36 => "SMulHiI32", + // RDNA scalar floating-point forms use the extended SOP2 opcode + // range. They keep the raw SGPR bit representation and do not + // alter SCC. + 0x40 => "SAddF32", + 0x41 => "SSubF32", _ => string.Empty, }; @@ -798,6 +832,7 @@ public static partial class Gen5ShaderTranslator 0x00 => "VNop", 0x01 => "VMovB32", 0x02 => "VReadfirstlaneB32", + 0x04 => "VCvtF64I32", 0x05 => "VCvtF32I32", 0x06 => "VCvtF32U32", 0x07 => "VCvtU32F32", @@ -807,10 +842,12 @@ public static partial class Gen5ShaderTranslator 0x0C => "VCvtRpiI32F32", 0x0D => "VCvtFlrI32F32", 0x0E => "VCvtOffF32I4", + 0x0F => "VCvtF32F64", 0x11 => "VCvtF32Ubyte0", 0x12 => "VCvtF32Ubyte1", 0x13 => "VCvtF32Ubyte2", 0x14 => "VCvtF32Ubyte3", + 0x16 => "VCvtF64U32", 0x20 => "VFractF32", 0x21 => "VTruncF32", 0x22 => "VCeilF32", @@ -821,7 +858,10 @@ public static partial class Gen5ShaderTranslator 0x2A => "VRcpF32", 0x2B => "VRcpIflagF32", 0x2E => "VRsqF32", + 0x2F => "VRcpF64", + 0x30 => "VRsqF64", 0x33 => "VSqrtF32", + 0x34 => "VSqrtF64", 0x35 => "VSinF32", 0x36 => "VCosF32", 0x37 => "VNotB32", @@ -833,7 +873,18 @@ public static partial class Gen5ShaderTranslator 0x44 => "VMovrelsdB32", 0x48 => "VMovrelsd2B32", 0x52 => "VCvtU16F16", + 0x54 => "VRcpF16", + 0x55 => "VSqrtF16", 0x56 => "VRsqF16", + 0x57 => "VLogF16", + 0x58 => "VExpF16", + 0x5B => "VFloorF16", + 0x5C => "VCeilF16", + 0x5D => "VTruncF16", + 0x5E => "VRndneF16", + 0x5F => "VFractF16", + 0x60 => "VSinF16", + 0x61 => "VCosF16", _ => string.Empty, }; @@ -854,11 +905,15 @@ public static partial class Gen5ShaderTranslator } var src0 = word & 0x1FF; - sizeDwords = opcode is 0x20 or 0x21 or 0x2C or 0x2D || + sizeDwords = opcode is 0x20 or 0x21 or 0x2C or 0x2D or 0x37 or 0x38 || src0 is 0xE9 or 0xEA or 0xF9 or 0xFA or 0xFF ? 2u : 1u; error = string.Empty; name = opcode switch { + // Some Gen5 shader streams use the VOP2 opcode-zero form as a + // padding/no-op instruction. Keep it as VNop so the translator + // can consume the instruction without manufacturing a result. + 0x00 => "VNop", 0x01 => "VCndmaskB32", 0x02 => "VDot2cF32F16", 0x03 => "VAddF32", @@ -906,8 +961,13 @@ public static partial class Gen5ShaderTranslator 0x33 => "VSubF16", 0x34 => "VSubrevF16", 0x35 => "VMulF16", + // RDNA encodes FMAMK/FMAAK as VOP2 instructions with a literal + // dword immediately following the opcode word. + 0x37 => "VFmaMkF16", + 0x38 => "VFmaAkF16", 0x39 => "VMaxF16", 0x3A => "VMinF16", + 0x3C => "VPkFmacF16", _ => string.Empty, }; @@ -979,6 +1039,24 @@ public static partial class Gen5ShaderTranslator 0x8D => "VCmpNeI16", 0x8E => "VCmpGeI16", 0x98 => "VCmpxClassF32", + 0xA9 => "VCmpLtU16", + 0xAA => "VCmpEqU16", + 0xAB => "VCmpLeU16", + 0xAC => "VCmpGtU16", + 0xAD => "VCmpNeU16", + 0xAE => "VCmpGeU16", + 0x99 => "VCmpxLtI16", + 0x9A => "VCmpxEqI16", + 0x9B => "VCmpxLeI16", + 0x9C => "VCmpxGtI16", + 0x9D => "VCmpxNeI16", + 0x9E => "VCmpxGeI16", + 0xB9 => "VCmpxLtU16", + 0xBA => "VCmpxEqU16", + 0xBB => "VCmpxLeU16", + 0xBC => "VCmpxGtU16", + 0xBD => "VCmpxNeU16", + 0xBE => "VCmpxGeU16", 0x90 => "VCmpxFI32", 0x91 => "VCmpxLtI32", 0x92 => "VCmpxEqI32", @@ -987,12 +1065,6 @@ public static partial class Gen5ShaderTranslator 0x95 => "VCmpxNeI32", 0x96 => "VCmpxGeI32", 0x97 => "VCmpxTI32", - 0x99 => "VCmpxLtI16", - 0x9A => "VCmpxEqI16", - 0x9B => "VCmpxLeI16", - 0x9C => "VCmpxGtI16", - 0x9D => "VCmpxNeI16", - 0x9E => "VCmpxGeI16", 0xA0 => "VCmpFI64", 0xA1 => "VCmpLtI64", 0xA2 => "VCmpEqI64", @@ -1122,6 +1194,7 @@ public static partial class Gen5ShaderTranslator 0x12B => "VFmacF32", 0x12F => "VCvtPkrtzF16F32", 0x141 => "VMadF32", + 0x142 => "VMadI32I24", 0x143 => "VMadU32U24", 0x144 => "VCubeidF32", 0x145 => "VCubescF32", @@ -1131,6 +1204,7 @@ public static partial class Gen5ShaderTranslator 0x14E => "VAlignbitB32", 0x14F => "VAlignbyteB32", 0x14B => "VFmaF32", + 0x14C => "VFmaF64", 0x151 => "VMin3F32", 0x152 => "VMin3I32", 0x153 => "VMin3U32", @@ -1147,12 +1221,37 @@ public static partial class Gen5ShaderTranslator 0x15E => "VCvtPkU8F32", 0x148 => "VBfeU32", 0x149 => "VBfeI32", + 0x165 => "VMulF64", 0x169 => "VMulLoU32", 0x16A => "VMulHiU32", 0x16B => "VMulLoI32", 0x16C => "VMulHiI32", + // VOP3-encoded VOP1 (opcode + 0x180) for the double-precision + // conversions and transcendentals. + 0x184 => "VCvtF64I32", + 0x18F => "VCvtF32F64", + 0x196 => "VCvtF64U32", + 0x1AF => "VRcpF64", + 0x1B0 => "VRsqF64", + 0x1B4 => "VSqrtF64", + // VOP3-encoded half-precision transcendentals and rounding. + 0x1D4 => "VRcpF16", + 0x1D5 => "VSqrtF16", + 0x1D7 => "VLogF16", + 0x1D8 => "VExpF16", + 0x1DB => "VFloorF16", + 0x1DC => "VCeilF16", + 0x1DD => "VTruncF16", + 0x1DE => "VRndneF16", + 0x1DF => "VFractF16", + 0x1E0 => "VSinF16", + 0x1E1 => "VCosF16", + 0x1D6 => "VRsqF16", 0x303 => "VAddNcU16", 0x34B => "VFmaF16", + 0x351 => "VMin3F16", + 0x354 => "VMax3F16", + 0x357 => "VMed3F16", 0x360 => "VReadlaneB32", 0x361 => "VWritelaneB32", 0x362 => "VLdexpF32", @@ -1164,6 +1263,9 @@ public static partial class Gen5ShaderTranslator 0x369 => "VCvtPknormU16F32", 0x36A => "VCvtPkU16U32", 0x36B => "VCvtPkI16I32", + // GFX10 reverse 64-bit shifts. + 0x2FF => "VLshlrevB64", + 0x300 => "VLshrrevB64", 0x373 => "VMadU32U16", 0x346 => "VLshlAddU32", 0x345 => "VXadU32", @@ -1173,11 +1275,38 @@ public static partial class Gen5ShaderTranslator 0x178 => "VXor3B32", // VOP1 opcode 0x52 is available through VOP3 as opcode 0x1D2. 0x1D2 => "VCvtU16F16", - 0x300 => "VLshrrevB64", 0x371 => "VAndOrB32", 0x372 => "VOr3U32", 0x377 => "VPermlane16B32", 0x378 => "VPermlanex16B32", + // GFX10 16-bit integer ALU, VOP3-only. + 0x304 => "VSubNcU16", + 0x307 => "VLshrrevB16", + 0x308 => "VAshrrevI16", + 0x309 => "VMaxU16", + 0x30A => "VMaxI16", + 0x30B => "VMinU16", + 0x30C => "VMinI16", + 0x30D => "VAddNcI16", + 0x30E => "VSubNcI16", + 0x314 => "VLshlrevB16", + // VOP3-encoded 64-bit VOPC (opcode < 0x100): V_CMP_*_U64 / V_CMPX_*_U64. + 0x0E0 => "VCmpFU64", + 0x0E1 => "VCmpLtU64", + 0x0E2 => "VCmpEqU64", + 0x0E3 => "VCmpLeU64", + 0x0E4 => "VCmpGtU64", + 0x0E5 => "VCmpNeU64", + 0x0E6 => "VCmpGeU64", + 0x0E7 => "VCmpTU64", + 0x0F0 => "VCmpxFU64", + 0x0F1 => "VCmpxLtU64", + 0x0F2 => "VCmpxEqU64", + 0x0F3 => "VCmpxLeU64", + 0x0F4 => "VCmpxGtU64", + 0x0F5 => "VCmpxNeU64", + 0x0F6 => "VCmpxGeU64", + 0x0F7 => "VCmpxTU64", _ => $"Vop3Raw{opcode:X3}", }; @@ -1222,6 +1351,8 @@ public static partial class Gen5ShaderTranslator // loudly at emission rather than being silently mis-emitted. name = opcode switch { + 0x02 => "VPkAddI16", + 0x03 => "VPkSubI16", 0x0E => "VPkFmaF16", 0x0F => "VPkAddF16", 0x10 => "VPkMulF16", @@ -1284,6 +1415,13 @@ public static partial class Gen5ShaderTranslator 0x39 => "DsReadI8", 0x3D => "DsConsume", 0x3E => "DsAppend", + // gfx10 groups the 64-bit LDS atomics at 0x40..0x4C, directly ahead + // of DS_WRITE_B64 (0x4D). Only the no-return forms are named here: + // the RTN variants cannot be split into two 32-bit atomics without + // losing the atomicity of the returned pair, so they stay unknown + // and keep failing loudly. + 0x40 => "DsAddU64", + 0x4A => "DsOrB64", 0x4D => "DsWriteB64", 0x4E => "DsWrite2B64", 0x4F => "DsWrite2St64B64", @@ -1409,8 +1547,21 @@ public static partial class Gen5ShaderTranslator 0x3B => "BufferAtomicXor", 0x3C => "BufferAtomicInc", 0x3D => "BufferAtomicDec", + 0x3E => "BufferWbinvl1", 0x3F => "BufferAtomicFmin", 0x40 => "BufferAtomicFmax", + // GFX9 buffer atomic encodings used by the guest shaders. + 0x41 => "BufferAtomicCmpswap", + 0x42 => "BufferAtomicAdd", + 0x43 => "BufferAtomicSub", + 0x45 => "BufferAtomicSmin", + 0x46 => "BufferAtomicUmin", + 0x47 => "BufferAtomicSmax", + 0x48 => "BufferAtomicUmax", + 0x49 => "BufferAtomicAnd", + 0x4A => "BufferAtomicOr", + 0x4B => "BufferAtomicXor", + 0x4C => "BufferAtomicInc", 0x50 => "BufferAtomicSwapX2", 0x5A => "BufferAtomicOrX2", _ => $"MubufRaw{opcode:X2}", @@ -1494,10 +1645,13 @@ public static partial class Gen5ShaderTranslator 0x0A => "SBufferLoadDwordx4", 0x0B => "SBufferLoadDwordx8", 0x0C => "SBufferLoadDwordx16", + 0x15 => "ScratchStoreDword", + 0x16 => "ScratchStoreDwordx2", + 0x17 => "ScratchStoreDwordx4", _ => string.Empty, }; - return FinishDecode(name, $"unknown-smrd op=0x{opcode:X2}", out error); + return FinishDecode(name, $"unknown-smrd op=0x{opcode:X2} word=0x{word:X8}", out error); } private static bool DecodeSmem(uint word, out string name, out uint sizeDwords, out string error) @@ -1558,6 +1712,13 @@ public static partial class Gen5ShaderTranslator 0x1A => "ImageAtomicXor", 0x1B => "ImageAtomicInc", 0x1C => "ImageAtomicDec", + // The float image atomics close the gfx10 atomic block. No + // backend emits them yet, so they still fail - but naming them + // lets the decoder finish the program, which is what makes the + // shader's bindings and global-memory usage visible in the trace. + 0x1D => "ImageAtomicFcmpswap", + 0x1E => "ImageAtomicFmin", + 0x1F => "ImageAtomicFmax", 0x20 => "ImageSample", 0x21 => "ImageSampleCl", 0x22 => "ImageSampleD", @@ -1593,6 +1754,7 @@ public static partial class Gen5ShaderTranslator 0x40 => "ImageGather4", 0x47 => "ImageGather4Lz", 0x48 => "ImageGather4C", + 0x4E => "ImageGather4CBCl", 0x4F => "ImageGather4CLz", 0x57 => "ImageGather4LzO", 0x58 => "ImageGather4CO", @@ -1775,7 +1937,8 @@ public static partial class Gen5ShaderTranslator "DsAddRtnU32" or "DsSubRtnU32" or "DsIncRtnU32" or "DsDecRtnU32" or "DsMinRtnI32" or "DsMaxRtnI32" or "DsMinRtnU32" or "DsMaxRtnU32" or "DsAndRtnB32" or "DsOrRtnB32" or "DsXorRtnB32" or - "DsWrxchgRtnB32" or "DsCmpstRtnB32" => true, + "DsWrxchgRtnB32" or "DsCmpstRtnB32" or + "DsAddU64" or "DsOrB64" => true, _ => false, }; @@ -1932,6 +2095,50 @@ public static partial class Gen5ShaderTranslator var scalarDestination = (word >> 15) & 0x7F; var immediate = ((word >> 8) & 1) != 0; var offset = word & 0xFF; + if (opcode.StartsWith("ScratchStore", StringComparison.Ordinal)) + { + var storeCount = ScalarMemoryDwordCount(opcode); + uint? storeDynamicOffsetRegister = null; + var storeImmediateOffsetBytes = 0; + if (immediate) + { + storeImmediateOffsetBytes = checked((int)(offset * sizeof(uint))); + } + else if (offset == 0xFF && literal.HasValue) + { + storeImmediateOffsetBytes = unchecked((int)literal.Value); + } + else + { + storeDynamicOffsetRegister = offset; + } + + sources = [ + Gen5Operand.Scalar(scalarBase), + .. Enumerable.Range(0, checked((int)storeCount)).Select(index => + Gen5Operand.Scalar(scalarDestination + (uint)index)), + ]; + if (storeDynamicOffsetRegister.HasValue) + { + sources = [.. sources, Gen5Operand.Scalar(storeDynamicOffsetRegister.Value)]; + } + + destinations = []; + control = new Gen5GlobalMemoryControl( + storeCount, + 0, + scalarDestination, + 0, + scalarBase, + storeImmediateOffsetBytes, + false, + false, + false, + storeDynamicOffsetRegister, + true); + break; + } + var count = ScalarLoadDwordCount(opcode); uint? dynamicOffsetRegister = null; var immediateOffsetBytes = 0; @@ -2075,7 +2282,7 @@ public static partial class Gen5ShaderTranslator Gen5Operand.Source(word & 0x1FF, literal), Gen5Operand.Vector((word >> 9) & 0xFF), ]; - if ((opcode is "VMadMkF32" or "VFmaMkF32") && literal.HasValue) + if ((opcode is "VMadMkF32" or "VFmaMkF32" or "VFmaMkF16") && literal.HasValue) { sources = [ @@ -2084,7 +2291,7 @@ public static partial class Gen5ShaderTranslator sources[1], ]; } - else if ((opcode is "VMadAkF32" or "VFmaAkF32") && literal.HasValue) + else if ((opcode is "VMadAkF32" or "VFmaAkF32" or "VFmaAkF16") && literal.HasValue) { sources = [ @@ -2277,6 +2484,17 @@ public static partial class Gen5ShaderTranslator Gen5Operand.Vector(vectorData0), Gen5Operand.Vector(vectorData1), ], + "DsMskorB32" => [ + Gen5Operand.Vector(vectorAddress), + Gen5Operand.Vector(vectorData0), + Gen5Operand.Vector(vectorData1), + ], + // 64-bit LDS atomics take ADDR plus a DATA0 register pair. + "DsAddU64" or "DsOrB64" => [ + Gen5Operand.Vector(vectorAddress), + Gen5Operand.Vector(vectorData0), + Gen5Operand.Vector(vectorData0 + 1), + ], // GFX10 DS_MIN/MAX_F32 use DATA0 as the replacement value and // DATA1 as the floating-point compare operand. "DsMinF32" or "DsMaxF32" => [ @@ -2687,6 +2905,14 @@ public static partial class Gen5ShaderTranslator _ => 0, }; + private static uint ScalarMemoryDwordCount(string opcode) => opcode switch + { + "ScratchStoreDword" => 1, + "ScratchStoreDwordx2" => 2, + "ScratchStoreDwordx4" => 4, + _ => 0, + }; + private static int SignExtend(uint value, int bits) { var shift = 32 - bits; diff --git a/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs new file mode 100644 index 0000000..ea8f6f6 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlan.cs @@ -0,0 +1,124 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; + +namespace SharpEmu.ShaderCompiler.Resources; + +// A runtime V# whose four dwords are read from a static scalar buffer (the SRT) at an +// offset driven by a canonical unsigned induction loop. The loop's guard bounds the set +// of descriptors the access can ever select, so the backend can enumerate that set into +// a flat table of native candidates and dispatch with a run-time selector. +// +// The plan is deliberately a pure analysis result: it names the guest SRT source and the +// offset/index expressions, and records the proven byte range. It does not invent a +// descriptor, and it never treats the safety extent of the SRT as a semantic proof of +// which entries are valid descriptors; that proof is the loop guard, and each candidate +// is decoded and validated again at materialisation. +public sealed class BufferCandidateTablePlan +{ + // A hard, generic ceiling on the number of native candidates one table may bind. + // Exceeding it is an explicit unsupported case, never a silent truncation. + public const int DefaultCap = 64; + + public const uint DescriptorByteSize = 16; + + // The descriptor source of the static guest SRT buffer the V# dwords are read from. + public uint SourceSrtResource { get; set; } = DescriptorConstants.NoIndex; + + // The guest SRT handle itself, kept for diagnostics and equivalence. + public ScalarValue SrtHandle { get; init; } = null!; + + // The full dynamic byte offset fed to the scalar-buffer read: base + index * stride. + public ScalarValue OffsetExpression { get; init; } = null!; + + // The induction variable whose loop guard bounds the candidate set. + public ScalarValue IndexExpression { get; init; } = null!; + + // The induction's first value and per-iteration increment. + public ScalarValue Initial { get; init; } = null!; + public uint Step { get; init; } + + // The guard's exclusive/inclusive limit; null when the bound is a compile-time constant. + public ScalarValue? Limit { get; init; } + public bool LimitInclusive { get; init; } + + // The byte distance between two consecutive candidates and the constant base offset. + public uint Stride { get; init; } + public uint BaseOffset { get; init; } + + // The proven candidate byte span: MinOffset is the first candidate, MaxOffset is one + // past the final candidate's descriptor, so MaxOffset - MinOffset is the accessed span. + public uint MinOffset { get; init; } + public uint MaxOffset { get; init; } + + // The statically proven candidate count, or -1 when the guard limit is run-time. + public int Count { get; init; } = -1; + + // The SRT's declared byte size when it is a compile-time constant, else uint.MaxValue. + // This is a memory-safety bound, not the set of valid descriptors. + public uint SrtByteExtent { get; init; } = uint.MaxValue; + + // The memory access records this table serves. + public IReadOnlyList MemoryIndices { get; init; } = []; + + public int Cap { get; init; } = DefaultCap; + + public bool IsStaticallyBounded => Count >= 0; + + // The byte distance between two consecutive candidates: the induction step times the + // descriptor stride. + public uint CandidateSpacing => unchecked(Step * Stride); + + // The byte offset of candidate index, valid only for a statically bounded table. + public uint StaticCandidateOffset(int index) => unchecked(MinOffset + (uint)index * CandidateSpacing); + + // The candidate count for a run-time limit, clamped to the cap. Returns -1 when the + // limit is below the initial value (an empty loop) or overflows. + public bool TryResolveCount(uint limit, out int count) + { + count = 0; + if (!Initial.IsConstant) + { + return false; + } + + var first = Initial.ConstantU32; + var bound = LimitInclusive ? (ulong)limit + 1ul : limit; + if (bound <= first) + { + count = 0; + return true; + } + + var span = bound - first; + var iterations = (span + Step - 1ul) / Step; + if (iterations > int.MaxValue) + { + return false; + } + + count = (int)iterations; + return true; + } + + public BufferCandidateTablePlan WithMemoryIndex(int memoryIndex) => new() + { + SourceSrtResource = SourceSrtResource, + SrtHandle = SrtHandle, + OffsetExpression = OffsetExpression, + IndexExpression = IndexExpression, + Initial = Initial, + Step = Step, + Limit = Limit, + LimitInclusive = LimitInclusive, + Stride = Stride, + BaseOffset = BaseOffset, + MinOffset = MinOffset, + MaxOffset = MaxOffset, + Count = Count, + SrtByteExtent = SrtByteExtent, + MemoryIndices = [.. MemoryIndices, memoryIndex], + Cap = Cap, + }; +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs new file mode 100644 index 0000000..993360a --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/BufferCandidateTablePlanner.cs @@ -0,0 +1,370 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; + +namespace SharpEmu.ShaderCompiler.Resources; + +// Recognises a runtime V# whose four dwords come from one static scalar buffer (the SRT) +// at a dynamic offset driven by a canonical unsigned induction loop, and proves that the +// loop's guard bounds the descriptors the access can select. The result is a pure +// description of the candidate set; no descriptor is invented and no memory bound is +// promoted into a semantic proof. +internal static class BufferCandidateTablePlanner +{ + public static bool TryPlan(ShaderResourcePlan plan, ScalarValue handle, int memoryIndex, out BufferCandidateTablePlan table) + { + table = null!; + if (handle.Kind != ScalarValueKind.BufferHandle || handle.Operands.Length != 4) + { + return false; + } + + + // Every dword must be the same-width scalar-buffer read at a shared dynamic offset, + // with the component immediates 0,4,8,12 that one dwordx4 read produces. + ScalarValue? srt = null; + ScalarValue? dynamicOffset = null; + for (var word = 0; word < 4; word++) + { + var read = handle.Operands[word]; + if (read.Kind != ScalarValueKind.ScalarBufferWord || read.MemoryIndex < 0 || read.MemoryIndex >= plan.Memory.Count) + { + return false; + } + + var memory = plan.Memory[read.MemoryIndex]; + if (memory.Kind != MemoryResourceKind.ScalarBuffer || memory.DataBits != 32 || memory.DataDwords != 1 || + memory.Offset != (uint)word * sizeof(uint)) + { + return false; + } + + if (srt is null) + { + srt = read.Operands[0]; + } + else if (!plan.Graph.Equivalent(srt, read.Operands[0])) + { + return false; + } + + if (dynamicOffset is null) + { + dynamicOffset = read.Operands[1]; + } + else if (!plan.Graph.Equivalent(dynamicOffset, read.Operands[1])) + { + return false; + } + } + + // offset = base + index * stride + var offset = dynamicOffset!; + var baseOffset = 0u; + if (offset.Kind == ScalarValueKind.Operation && offset.Operation == ScalarOperation.IAdd32) + { + if (offset.Operands[0].IsConstant) + { + baseOffset = offset.Operands[0].ConstantU32; + offset = offset.Operands[1]; + } + else if (offset.Operands[1].IsConstant) + { + baseOffset = offset.Operands[1].ConstantU32; + offset = offset.Operands[0]; + } + } + + if (offset.Kind != ScalarValueKind.Operation || offset.Operation != ScalarOperation.IMul32) + { + return false; + } + + ScalarValue index; + uint stride; + if (offset.Operands[0].IsConstant) + { + stride = offset.Operands[0].ConstantU32; + index = offset.Operands[1]; + } + else if (offset.Operands[1].IsConstant) + { + stride = offset.Operands[1].ConstantU32; + index = offset.Operands[0]; + } + else + { + return false; + } + + if (stride < BufferCandidateTablePlan.DescriptorByteSize) + { + return false; + } + + if (!TryProveInductionBound(plan, index, out var initial, out var step, out var limit, out var inclusive) || limit is null) + { + return false; + } + + if (!initial.IsConstant) + { + return false; + } + + var srtExtent = SrtByteExtent(plan, srt!); + var minOffset = unchecked(baseOffset + initial.ConstantU32 * stride); + var candidate = new BufferCandidateTablePlan + { + SrtHandle = srt!, + OffsetExpression = dynamicOffset!, + IndexExpression = index, + Initial = initial, + Step = step, + Limit = limit.IsConstant ? null : limit, + LimitInclusive = inclusive, + Stride = stride, + BaseOffset = baseOffset, + MinOffset = minOffset, + SrtByteExtent = srtExtent, + MemoryIndices = [memoryIndex], + }; + + int count; + uint maxOffset; + if (limit.IsConstant) + { + if (!candidate.TryResolveCount(limit.ConstantU32, out count)) + { + return false; + } + + if (count <= 0 || count > candidate.Cap) + { + return false; + } + + maxOffset = unchecked(candidate.StaticCandidateOffset(count - 1) + BufferCandidateTablePlan.DescriptorByteSize); + } + else + { + if (!plan.ValidateRuntimeValue(limit)) + { + return false; + } + + // A run-time limit is capped and memory-checked at materialisation; the static + // start of the range still has to stay inside the declared SRT extent. + count = -1; + maxOffset = uint.MaxValue; + } + + if (maxOffset < minOffset || (maxOffset != uint.MaxValue && maxOffset > srtExtent)) + { + return false; + } + + table = new BufferCandidateTablePlan + { + SrtHandle = srt!, + OffsetExpression = dynamicOffset!, + IndexExpression = index, + Initial = initial, + Step = step, + Limit = limit.IsConstant ? null : limit, + LimitInclusive = inclusive, + Stride = stride, + BaseOffset = baseOffset, + MinOffset = minOffset, + MaxOffset = maxOffset, + Count = count, + SrtByteExtent = srtExtent, + MemoryIndices = [memoryIndex], + Cap = candidate.Cap, + }; + return true; + } + + // A canonical unsigned induction: the value is a phi with a constant initial and an + // add-recurrence, and a branch guarded by an unsigned comparison against the phi + // lives at the loop header or one of its predecessors. + private static bool TryProveInductionBound( + ShaderResourcePlan plan, + ScalarValue index, + out ScalarValue initial, + out uint step, + out ScalarValue? limit, + out bool inclusive) + { + initial = null!; + step = 0; + limit = null; + inclusive = false; + if (index.Kind != ScalarValueKind.Phi) + { + return false; + } + + ScalarValue? found = null; + var initialCount = 0; + foreach (var operand in index.Operands) + { + if (operand.Kind == ScalarValueKind.Operation && operand.Operation == ScalarOperation.IAdd32) + { + if (ReferenceEquals(operand.Operands[0], index) && operand.Operands[1].IsConstant) + { + step = operand.Operands[1].ConstantU32; + } + else if (ReferenceEquals(operand.Operands[1], index) && operand.Operands[0].IsConstant) + { + step = operand.Operands[0].ConstantU32; + } + else + { + continue; + } + } + else if (!ReferenceEquals(operand, index)) + { + found = operand; + initialCount++; + } + } + + if (step == 0 || initialCount != 1 || found is null) + { + return false; + } + + initial = found; + + var flow = plan.Graph.ControlFlow; + var guardBlock = (int?)null; + foreach (var pair in plan.Graph.BranchConditions) + { + var condition = pair.Value; + if (condition.Kind != ScalarValueKind.Operation) + { + continue; + } + + ScalarValue? bound; + bool boundInclusive; + switch (condition.Operation) + { + case ScalarOperation.ULessThan32 when MatchesIndex(plan, condition.Operands[0], index, step): + bound = condition.Operands[1]; + boundInclusive = false; + break; + case ScalarOperation.ULessThanEqual32 when MatchesIndex(plan, condition.Operands[0], index, step): + bound = condition.Operands[1]; + boundInclusive = true; + break; + case ScalarOperation.UGreaterThan32 when MatchesIndex(plan, condition.Operands[1], index, step): + bound = condition.Operands[0]; + boundInclusive = false; + break; + case ScalarOperation.UGreaterThanEqual32 when MatchesIndex(plan, condition.Operands[1], index, step): + bound = condition.Operands[0]; + boundInclusive = true; + break; + default: + continue; + } + + if (!bound.IsConstant && !plan.ValidateRuntimeValue(bound)) + { + continue; + } + + var block = BlockOf(plan, pair.Key); + if (block < 0) + { + continue; + } + + // The guard must live at the loop header that owns the phi or one of the + // blocks feeding it, so it really controls the recurrence. + var header = index.PhiBlock; + if (block != header && !flow.Predecessors[header].Contains(block)) + { + continue; + } + + if (limit is not null && !plan.Graph.Equivalent(limit, bound)) + { + return false; + } + + limit = bound; + inclusive = boundInclusive; + guardBlock = block; + } + + return limit is not null && guardBlock is not null; + } + + // The guard compares either the induction value itself or its next value; both bound + // the same executed set, so a one-step add of the phi is accepted. + private static bool MatchesIndex(ShaderResourcePlan plan, ScalarValue operand, ScalarValue index, uint step) + { + if (plan.Graph.Equivalent(operand, index)) + { + return true; + } + + if (operand.Kind != ScalarValueKind.Operation || operand.Operation != ScalarOperation.IAdd32) + { + return false; + } + + if (plan.Graph.Equivalent(operand.Operands[0], index) && operand.Operands[1].IsConstant) + { + return operand.Operands[1].ConstantU32 == step; + } + + if (plan.Graph.Equivalent(operand.Operands[1], index) && operand.Operands[0].IsConstant) + { + return operand.Operands[0].ConstantU32 == step; + } + + return false; + } + + private static int BlockOf(ShaderResourcePlan plan, uint pc) + { + var blocks = plan.Graph.ControlFlow.Blocks; + for (var index = 0; index < blocks.Count; index++) + { + if (pc >= blocks[index].StartPc && pc < blocks[index].EndPc) + { + return index; + } + } + + return -1; + } + + // The SRT's declared byte size when both its stride and record count are constants. + // This is a memory-safety bound, never evidence that any entry is a valid descriptor. + private static uint SrtByteExtent(ShaderResourcePlan plan, ScalarValue srt) + { + if (srt.Operands.Length != 4) + { + return uint.MaxValue; + } + + var strideWord = plan.Graph.ResolveInvariantPhi(srt.Operands[1]) ?? srt.Operands[1]; + var recordsWord = plan.Graph.ResolveInvariantPhi(srt.Operands[2]) ?? srt.Operands[2]; + if (!strideWord.IsConstant || !recordsWord.IsConstant) + { + return uint.MaxValue; + } + + var stride = (strideWord.ConstantU32 >> 16) & 0x3FFFu; + var records = recordsWord.ConstantU32; + var size = stride == 0 ? records : (ulong)stride * records; + return size > uint.MaxValue ? uint.MaxValue : (uint)size; + } +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs b/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs index 19ff040..9b7cfbf 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/DeviceAddressRangePlanner.cs @@ -46,6 +46,17 @@ public static class DeviceAddressRangePlanner continue; } + // A FLAT address built from an aperture base is an LDS or scratch + // pointer; the backend routes it per lane, so it owns no device range. + if (memory.Kind == MemoryResourceKind.Flat) + { + memory.AddressSpace = ClassifyFlatAddress(plan, handle.Operands[1], plan.Accesses[index]!.Active); + if (memory.AddressSpace != FlatAddressSpace.Global) + { + continue; + } + } + var slot = handles.FindIndex(existing => plan.Graph.Equivalent(existing, handle)); if (slot < 0) { @@ -79,6 +90,132 @@ public static class DeviceAddressRangePlanner return ranges; } + // A FLAT address is local when its high dword provably lies in an aperture. + // Two shapes occur: the high dword is clamped between constants inside the + // apertures (v_med3_u32 with the hard-coded PS5 bounds), or it derives from an + // aperture operand, which shaders often merge into the upper half of a + // computed dword with SDWA WORD1. + private static FlatAddressSpace ClassifyFlatAddress(ShaderResourcePlan plan, ScalarValue high, ScalarValue? active) + { + var range = UnsignedRange(plan, high, active, []); + var (low, highBound) = IsEmpty(range) ? (0u, uint.MaxValue) : range; + var lowShared = Gen5InlineConstants.IsSharedApertureHigh(low); + var lowPrivate = Gen5InlineConstants.IsPrivateApertureHigh(low); + var highShared = Gen5InlineConstants.IsSharedApertureHigh(highBound); + var highPrivate = Gen5InlineConstants.IsPrivateApertureHigh(highBound); + // The two apertures are adjacent windows, so bounds inside them keep every + // value in between inside them too. + if ((lowShared || lowPrivate) && (highShared || highPrivate)) + { + return (lowShared || highShared, lowPrivate || highPrivate) switch + { + (true, true) => FlatAddressSpace.SharedOrPrivate, + (true, false) => FlatAddressSpace.Shared, + _ => FlatAddressSpace.Private, + }; + } + + return ClassifyByApertureOperand(high); + } + + // Conservative unsigned bounds of a 32-bit value as the access sees it. Phi, Select, + // UMin and UMax yield one of their inputs, so a cycle adds nothing (Empty); a select + // on the access's own EXEC mask contributes only its active arm. + // The mask is the same node when EXEC was not written in between, even when its + // value is unknown (an undefined node is never structurally equivalent). + private static readonly (uint Low, uint High) Empty = (uint.MaxValue, 0u); + + private static bool IsEmpty((uint Low, uint High) range) => range.Low > range.High; + + private static (uint Low, uint High) Union((uint Low, uint High) left, (uint Low, uint High) right) => + IsEmpty(left) ? right : IsEmpty(right) ? left : (Math.Min(left.Low, right.Low), Math.Max(left.High, right.High)); + + private static (uint Low, uint High) UnsignedRange(ShaderResourcePlan plan, ScalarValue value, ScalarValue? active, + HashSet visiting) + { + if (value.Kind == ScalarValueKind.Constant && value.Type == ScalarValueType.U32) + return (value.ConstantU32, value.ConstantU32); + if (value.Kind == ScalarValueKind.MemoryAperture) + { + var aperture = (uint)(Gen5InlineConstants.DecodeAperture64((uint)value.Payload) >> 32); + return (aperture, aperture); + } + + if (!visiting.Add(value)) + return Empty; + try + { + switch (value.Kind) + { + case ScalarValueKind.Select when value.Operands.Length == 3 && + active is not null && (ReferenceEquals(value.Operands[0], active) || plan.Graph.Equivalent(value.Operands[0], active)): + return UnsignedRange(plan, value.Operands[1], active, visiting); + case ScalarValueKind.Select when value.Operands.Length == 3: + return Union(UnsignedRange(plan, value.Operands[1], active, visiting), + UnsignedRange(plan, value.Operands[2], active, visiting)); + case ScalarValueKind.Phi: + { + var range = Empty; + foreach (var operand in value.Operands) + range = Union(range, UnsignedRange(plan, operand, active, visiting)); + return range; + } + case ScalarValueKind.Operation when value.Operation is ScalarOperation.UMin32 or ScalarOperation.UMax32: + { + var left = UnsignedRange(plan, value.Operands[0], active, visiting); + var right = UnsignedRange(plan, value.Operands[1], active, visiting); + if (IsEmpty(left) || IsEmpty(right)) + return IsEmpty(left) ? right : left; + return value.Operation == ScalarOperation.UMin32 + ? (Math.Min(left.Low, right.Low), Math.Min(left.High, right.High)) + : (Math.Max(left.Low, right.Low), Math.Max(left.High, right.High)); + } + default: + return (0, uint.MaxValue); + } + } + finally + { + visiting.Remove(value); + } + } + + private static FlatAddressSpace ClassifyByApertureOperand(ScalarValue high) + { + var shared = false; + var @private = false; + var pending = new Stack(); + var visited = new HashSet(); + pending.Push(high); + while (pending.TryPop(out var value)) + { + if (!visited.Add(value)) + { + continue; + } + + if (value.Kind == ScalarValueKind.MemoryAperture) + { + if (Gen5InlineConstants.IsSharedAperture((uint)value.Payload)) + shared = true; + else + @private = true; + continue; + } + + foreach (var operand in value.Operands) + pending.Push(operand); + } + + return (shared, @private) switch + { + (true, true) => FlatAddressSpace.SharedOrPrivate, + (true, false) => FlatAddressSpace.Shared, + (false, true) => FlatAddressSpace.Private, + _ => FlatAddressSpace.Global, + }; + } + // An access is bounded when its only run-time term is the record's immediate offset: // a scalar read, or a global access whose vector offset is a constant zero. private static bool IsBoundedOffset(ShaderResourcePlan plan, int memoryIndex) diff --git a/src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs b/src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs new file mode 100644 index 0000000..6f05cfc --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/GuestBufferDescriptor.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.ShaderCompiler.Resources; + +// Where a guest V#'s dwords come from. A static descriptor is evaluated ahead of +// dispatch into the dense buffer table; a runtime descriptor is fetched by the +// shader itself from guest memory (a descriptor array indexed at run time). +public enum BufferDescriptorProvenance : byte +{ + Static, + Runtime, +} + +// How the Vulkan backend lowers a guest buffer descriptor. This is backend +// policy, deliberately kept out of the guest descriptor representation: the +// same guest V# could be lowered differently depending on the access, the +// device capabilities or cost, without changing what the guest descriptor is. +public enum BufferLoweringStrategy : byte +{ + // A compile-time V# bound as a native storage-buffer descriptor. + NativeBinding, + + // A runtime V# addressed through the physical-storage-buffer page table. + // Base, stride and byte bounds are read from the descriptor's raw words at + // run time, so an untyped/raw access needs no compile-time value at all. + PhysicalStorageBuffer, + + // A runtime V# enumerated into a bounded set of native candidate bindings + // and selected with an OpSwitch. Required when the access's format/type + // cannot be reconstructed from the raw words alone. + BoundedCandidateTable, +} + +// The guest buffer view (V#) as the hardware sees it, independent of how the +// host binds it. Static descriptors reference a dense buffer already +// materialised by the host; runtime descriptors are loaded by the shader. +public sealed class GuestBufferDescriptor +{ + public BufferDescriptorProvenance Provenance { get; init; } + + // A static descriptor's index in ShaderResourceInfo.Buffers. + public uint StaticResource { get; init; } = DescriptorConstants.NoIndex; + + // Chooses the backend lowering for one access through this descriptor. + // A static descriptor always binds natively. A runtime descriptor is + // addressed through the page table when the access is a raw dword/subword + // access, and otherwise needs candidate enumeration because Vulkan fixes + // the resource's format/type at pipeline creation. + public BufferLoweringStrategy ChooseStrategy(bool typed, bool formatted, bool atomic) => + Provenance == BufferDescriptorProvenance.Static + ? BufferLoweringStrategy.NativeBinding + : typed || formatted || atomic + ? BufferLoweringStrategy.BoundedCandidateTable + : BufferLoweringStrategy.PhysicalStorageBuffer; +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs b/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs index 4954f58..4589323 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/GuestImageFormat.cs @@ -34,9 +34,9 @@ public static class GuestImageFormat [ (1, ImageNumericClass.Float), (5, ImageNumericClass.Uint), (7, ImageNumericClass.Float), (8, ImageNumericClass.Float), (11, ImageNumericClass.Uint), (12, ImageNumericClass.Sint), (13, ImageNumericClass.Float), (14, ImageNumericClass.Float), - (15, ImageNumericClass.Float), (18, ImageNumericClass.Uint), (19, ImageNumericClass.Sint), (20, ImageNumericClass.Uint), + (15, ImageNumericClass.Float), (16, ImageNumericClass.Float), (18, ImageNumericClass.Uint), (19, ImageNumericClass.Sint), (20, ImageNumericClass.Uint), (21, ImageNumericClass.Sint), (22, ImageNumericClass.Float), (23, ImageNumericClass.Float), (24, ImageNumericClass.Float), - (27, ImageNumericClass.Uint), (28, ImageNumericClass.Sint), (29, ImageNumericClass.Float), (34, ImageNumericClass.Uint), + (27, ImageNumericClass.Uint), (28, ImageNumericClass.Sint), (29, ImageNumericClass.Float), (30, ImageNumericClass.Float), (34, ImageNumericClass.Uint), (36, ImageNumericClass.Float), (50, ImageNumericClass.Float), (54, ImageNumericClass.Uint), (56, ImageNumericClass.Float), (57, ImageNumericClass.Float), (60, ImageNumericClass.Uint), (61, ImageNumericClass.Sint), (62, ImageNumericClass.Uint), (63, ImageNumericClass.Sint), (64, ImageNumericClass.Float), (65, ImageNumericClass.Float), (66, ImageNumericClass.Float), diff --git a/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs b/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs index 7c252a0..67ff6fe 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/MemoryAccessInfo.cs @@ -5,6 +5,20 @@ using System.Collections.Generic; namespace SharpEmu.ShaderCompiler.Resources; +// Where a FLAT access can land: LDS or scratch when its high dword lies in the shared +// or private aperture, otherwise global memory. +public enum FlatAddressSpace : byte +{ + // No aperture reaches the address: an ordinary device address. + Global, + // The address is built from SHARED_BASE/SHARED_LIMIT. + Shared, + // The address is built from PRIVATE_BASE/PRIVATE_LIMIT. + Private, + // Both apertures reach the address; the aperture is chosen per lane. + SharedOrPrivate, +} + public enum MemoryResourceKind : byte { None, @@ -90,10 +104,20 @@ public sealed class MemoryAccessInfo public bool IndexEnabled { get; init; } public bool OffsetEnabled { get; init; } public bool PlanningOnly { get; set; } + // A FLAT access whose address derives from an LDS/scratch aperture. Set by the + // device-address planner; such an access is routed per lane like the hardware + // and has no device-address range of its own. + public FlatAddressSpace AddressSpace { get; set; } // A scalar buffer load whose descriptor only exists on the device: the shader reads // through the descriptor in its registers instead of a host-bound buffer. public bool DeviceDescriptor { get; set; } + // The guest V# behind a buffer access, filled in by the resource tracker. + // Null until then. Its provenance tells the backend which lowering + // strategy to pick; the access shape (Typed/Formatted/Access) completes + // the choice via GuestBufferDescriptor.ChooseStrategy. + public GuestBufferDescriptor? BufferDescriptor { get; set; } + public bool IsAddressKind => Kind is MemoryResourceKind.ScalarAddress or MemoryResourceKind.Flat or MemoryResourceKind.Global or MemoryResourceKind.Scratch; @@ -110,7 +134,7 @@ public sealed class MemoryAccessInfo ImageHasMip == other.ImageHasMip && ImageR128 == other.ImageR128 && Glc == other.Glc && Slc == other.Slc && IndexEnabled == other.IndexEnabled && OffsetEnabled == other.OffsetEnabled && Resource == other.Resource && Sampler == other.Sampler && PlanningOnly == other.PlanningOnly && - DeviceDescriptor == other.DeviceDescriptor; + DeviceDescriptor == other.DeviceDescriptor && AddressSpace == other.AddressSpace; } // Every memory access of a program, indexed by program counter and component. @@ -271,9 +295,8 @@ public sealed class MemoryAccessTable var opcode = instruction.Opcode; if (opcode is "ImageBvhIntersectRay" or "ImageBvh64IntersectRay") { - // The current backends return a deterministic miss for raw GFX10 - // BVH traversal. Do not materialize its descriptor as a texture; - // BVH descriptors do not use the regular image descriptor layout. + // A BVH T# is not an image descriptor: the backend reads the nodes + // through device addresses, so nothing is materialized here. return new MemoryAccessInfo { Pc = instruction.Pc, diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs new file mode 100644 index 0000000..5d795af --- /dev/null +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializationCache.cs @@ -0,0 +1,256 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.CompilerServices; + +namespace SharpEmu.ShaderCompiler.Resources; + +// Copies guest bytes only when they are already resident on the CPU side. It never +// synchronizes: a range the GPU may still own, or that a clean read would refuse, +// returns false so the caller falls back to a full materialization. +public delegate bool ResidentGuestBytesReader(ulong address, Span destination, bool clean); + +// Materialization is a pure function of the plan, the draw's user data, the shader +// base, the compute state and the guest words it reads. This cache records exactly +// those words and reuses the result while every one of them is unchanged, so a draw +// that re-binds the same resources skips the descriptor walk. Any failed read, any +// GPU-owned range and any byte difference make the entry miss; nothing is guessed. +public sealed class ResourceMaterializationCache +{ + // Two generations approximate LRU: a full young generation becomes the old one, + // and an old entry that is used again is promoted back. + private readonly int _generationCapacity; + private Dictionary _young = new(); + private Dictionary _old = new(); + private byte[] _scratch = new byte[256]; + + public ResourceMaterializationCache(int generationCapacity = 16384) + { + _generationCapacity = Math.Max(1, generationCapacity); + } + + private static long _totalHits; + private static long _totalMisses; + private static long _totalUncacheable; + + public long Hits { get; private set; } + public long Misses { get; private set; } + public long Uncacheable { get; private set; } + + // Process-wide counters since the previous call, for the periodic render report. + public static string TakeReport() + { + var hits = Interlocked.Exchange(ref _totalHits, 0); + var misses = Interlocked.Exchange(ref _totalMisses, 0); + var uncacheable = Interlocked.Exchange(ref _totalUncacheable, 0); + var total = hits + misses; + return FormattableString.Invariant( + $"[PERF][RESOURCE_CACHE] hits={hits} misses={misses} uncacheable={uncacheable} hit_rate={(total == 0 ? 0 : hits * 100.0 / total):F1}%"); + } + + public bool Materialize( + ShaderResourcePlan plan, + ResourceRuntimeInputs inputs, + ResidentGuestBytesReader residentReader, + ref ResourceSnapshot snapshot, + ref ResourceSpecialization specialization, + out ResourceMaterializationFailure failure) + { + var key = KeyOf(plan, inputs); + if (TryFind(key, plan, inputs, out var cached) && Validate(cached, residentReader)) + { + Hits++; + Interlocked.Increment(ref _totalHits); + snapshot = cached.Snapshot; + specialization = cached.Specialization; + failure = default; + return true; + } + + Misses++; + Interlocked.Increment(ref _totalMisses); + var recorder = new ReadRecorder(); + var recording = new ResourceRuntimeInputs + { + UserData = inputs.UserData, + ShaderBase = inputs.ShaderBase, + ReadMemory = recorder.Wrap(inputs.ReadMemory, clean: false), + ReadCleanMemory = recorder.Wrap(inputs.ReadCleanMemory, clean: true), + ComputeState = inputs.ComputeState, + }; + if (!ResourceMaterializer.Materialize(plan, recording, ref snapshot, ref specialization, out failure)) + return false; + + if (recorder.Failed) + { + Uncacheable++; + Interlocked.Increment(ref _totalUncacheable); + return true; + } + + Store(key, recorder.Build(plan, inputs, snapshot, specialization)); + return true; + } + + private static ulong KeyOf(ShaderResourcePlan plan, ResourceRuntimeInputs inputs) + { + var hash = new HashCode(); + hash.Add(RuntimeHelpers.GetHashCode(plan)); + hash.Add(inputs.ShaderBase); + hash.Add(inputs.ComputeState); + var userData = inputs.UserData; + hash.Add(userData.Count); + for (var index = 0; index < userData.Count; index++) + hash.Add(userData[index]); + var low = (uint)hash.ToHashCode(); + // A second, independent mix keeps accidental collisions out of the 64-bit key. + var high = 0x9E3779B9u; + for (var index = 0; index < userData.Count; index++) + high = (high ^ userData[index]) * 0x01000193u; + return ((ulong)high << 32) | low; + } + + private bool TryFind(ulong key, ShaderResourcePlan plan, ResourceRuntimeInputs inputs, out Entry entry) + { + if (_young.TryGetValue(key, out entry!)) + return entry.Matches(plan, inputs); + if (!_old.TryGetValue(key, out entry!) || !entry.Matches(plan, inputs)) + return false; + _old.Remove(key); + Store(key, entry); + return true; + } + + private void Store(ulong key, Entry entry) + { + if (_young.Count >= _generationCapacity && !_young.ContainsKey(key)) + { + _old = _young; + _young = new Dictionary(_generationCapacity); + } + + _young[key] = entry; + } + + private bool Validate(Entry entry, ResidentGuestBytesReader residentReader) + { + for (var index = 0; index < entry.RangeAddresses.Length; index++) + { + var length = entry.RangeLengths[index]; + if (_scratch.Length < length) + _scratch = new byte[Math.Max(length, _scratch.Length * 2)]; + var current = _scratch.AsSpan(0, length); + if (!residentReader(entry.RangeAddresses[index], current, entry.RangeClean[index]) || + !current.SequenceEqual(entry.Bytes.AsSpan(entry.RangeOffsets[index], length))) + return false; + } + + return true; + } + + private sealed class Entry + { + public required ShaderResourcePlan Plan { get; init; } + public required uint[] UserData { get; init; } + public required ulong ShaderBase { get; init; } + public required ComputeSelectorState? ComputeState { get; init; } + public required ulong[] RangeAddresses { get; init; } + public required int[] RangeOffsets { get; init; } + public required int[] RangeLengths { get; init; } + public required bool[] RangeClean { get; init; } + public required byte[] Bytes { get; init; } + public required ResourceSnapshot Snapshot { get; init; } + public required ResourceSpecialization Specialization { get; init; } + + public bool Matches(ShaderResourcePlan plan, ResourceRuntimeInputs inputs) + { + if (!ReferenceEquals(Plan, plan) || ShaderBase != inputs.ShaderBase || + !Nullable.Equals(ComputeState, inputs.ComputeState) || UserData.Length != inputs.UserData.Count) + return false; + for (var index = 0; index < UserData.Length; index++) + if (UserData[index] != inputs.UserData[index]) + return false; + return true; + } + } + + private sealed class ReadRecorder + { + private readonly List<(ulong Address, uint Word, bool Clean)> _reads = new(); + + public bool Failed { get; private set; } + + public GuestWordReader? Wrap(GuestWordReader? inner, bool clean) + { + if (inner is null) + return null; + return (ulong address, out uint word) => + { + if (!inner(address, out word)) + { + Failed = true; + return false; + } + + _reads.Add((address, word, clean)); + return true; + }; + } + + public Entry Build(ShaderResourcePlan plan, ResourceRuntimeInputs inputs, ResourceSnapshot snapshot, + ResourceSpecialization specialization) + { + _reads.Sort((left, right) => left.Address.CompareTo(right.Address)); + var addresses = new List(); + var offsets = new List(); + var lengths = new List(); + var clean = new List(); + var bytes = new List(_reads.Count * sizeof(uint)); + ulong end = 0; + var previous = ulong.MaxValue; + foreach (var (address, word, wordClean) in _reads) + { + if (address == previous) + { + clean[^1] |= wordClean; + continue; + } + + previous = address; + if (addresses.Count == 0 || address != end) + { + addresses.Add(address); + offsets.Add(bytes.Count); + lengths.Add(0); + clean.Add(false); + } + + lengths[^1] += sizeof(uint); + clean[^1] |= wordClean; + bytes.Add((byte)word); + bytes.Add((byte)(word >> 8)); + bytes.Add((byte)(word >> 16)); + bytes.Add((byte)(word >> 24)); + end = address + sizeof(uint); + } + + var userData = new uint[inputs.UserData.Count]; + for (var index = 0; index < userData.Length; index++) + userData[index] = inputs.UserData[index]; + return new Entry + { + Plan = plan, + UserData = userData, + ShaderBase = inputs.ShaderBase, + ComputeState = inputs.ComputeState, + RangeAddresses = [.. addresses], + RangeOffsets = [.. offsets], + RangeLengths = [.. lengths], + RangeClean = [.. clean], + Bytes = [.. bytes], + Snapshot = snapshot, + Specialization = specialization, + }; + } + } +} diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs index 2b2303c..ff4da93 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceMaterializer.cs @@ -39,6 +39,17 @@ public static class ResourceMaterializer public uint[] FlattenedTable = []; public uint[] UserData = []; public List IndirectImages = []; + public List BufferCandidateTables = []; + } + + // One bounded runtime V# table resolved for this draw: the distinct candidate descriptors + // and the run-time offset -> candidate mapping the emitter searches. + private sealed class BufferCandidateTable + { + public int MemoryIndex; + public List Keys = []; + public List Candidates = []; + public List Descriptors = []; } public static bool Materialize( @@ -142,7 +153,10 @@ public static class ResourceMaterializer if (indirect.DirectCandidates is { } directCandidates) { if (!RuntimeValueEvaluator.EvaluateSources(plan, directCandidates.Select(candidate => candidate.Source).ToArray(), - cleanInputs, [], evaluateTable: false, out var descriptors, out _)) return false; + cleanInputs, [], evaluateTable: false, out var descriptors, out _)) + { + return false; + } var directTable = new IndirectImageTable { Resource = (uint)imageIndex }; for (var candidateIndex = 0; candidateIndex < descriptors.Count; candidateIndex++) { @@ -163,6 +177,21 @@ public static class ResourceMaterializer if (directTable.Descriptors.Count > 1) snapshot.IndirectImages.Add(directTable); continue; } + if (indirect.WaveIndexed is { } waveIndexed) + { + if (!RuntimeValueEvaluator.EvaluateSources(plan, [indirect.HeapSource], cleanInputs, [], evaluateTable: false, + out var waveSources, out _)) + return false; + if (!MaterializeWaveIndexedImage(indirect, waveIndexed, waveSources[0], image, image.R128, inputs, out var waveTable, out failure)) + return false; + snapshot.Images[imageIndex] = waveTable.Descriptors[(int)waveTable.Candidates[0]].Dwords; + if (waveTable.Descriptors.Count > 1) + { + waveTable.Resource = (uint)imageIndex; + snapshot.IndirectImages.Add(waveTable); + } + continue; + } if (indirect.Dense) { if (!RuntimeValueEvaluator.EvaluateSources(plan, [indirect.HeapSource], cleanInputs, [], evaluateTable: false, @@ -211,10 +240,114 @@ public static class ResourceMaterializer snapshot.Samplers = new uint[plan.Info.Samplers.Count][]; for (var index = 0; index < snapshot.Samplers.Length; index++) snapshot.Samplers[index] = values[cursor++].Dwords; + + // Bounded runtime V# tables: the whole table is read and validated before it is + // published, so a single unreadable candidate leaves the previous snapshot intact. + foreach (var candidatePlan in plan.BufferCandidateTables) + { + if (candidatePlan.SourceSrtResource == DescriptorConstants.NoIndex || + !RuntimeValueEvaluator.EvaluateSources(plan, [candidatePlan.SourceSrtResource], inputs, [], evaluateTable: false, + out var srtSources, out _) || srtSources.Count == 0) + { + return false; + } + + if (!MaterializeBufferCandidateTable(plan, candidatePlan, srtSources[0], inputs, out var candidateTable)) + { + return false; + } + + snapshot.BufferCandidateTables.Add(candidateTable); + } + snapshot.UserData = inputs.UserData.ToArray(); return true; } + // Reads every candidate the loop guard can select from the guest SRT, validates it and + // records the run-time probe key (its base-address low dword) -> candidate mapping. + // Distinct keys are required so the mapping is unambiguous; identical descriptors share + // one candidate. + private static bool MaterializeBufferCandidateTable( + ShaderResourcePlan plan, + BufferCandidateTablePlan table, + DescriptorWords srt, + ResourceRuntimeInputs inputs, + out BufferCandidateTable result) + { + result = new BufferCandidateTable { MemoryIndex = table.MemoryIndices.Count != 0 ? table.MemoryIndices[0] : -1 }; + if (srt.DwordCount != 4) + { + return false; + } + + int count; + if (table.IsStaticallyBounded) + { + count = table.Count; + } + else + { + if (table.Limit is null) + { + return false; + } + + using var scratch = RuntimeEvaluationScratch.Rent(); + var evaluator = new RuntimeValueEvaluator(scratch, plan, inputs); + if (!evaluator.Evaluate(table.Limit, out var limit) || !table.TryResolveCount(limit, out count)) + { + return false; + } + } + + if (count <= 0 || count > table.Cap) + { + return false; + } + + var keyToCandidate = new Dictionary(); + for (var index = 0; index < count; index++) + { + var offset = unchecked(table.MinOffset + (uint)index * table.CandidateSpacing); + var words = new uint[4]; + for (uint dword = 0; dword < 4; dword++) + { + if (!ReadScalarBufferWord(srt.Dwords, offset, dword * sizeof(uint), inputs, out words[dword])) + { + return false; + } + } + + // A descriptor whose reserved bits are set is unbound and reads as zero. + if ((words[3] >> 30) != 0) + { + Array.Clear(words); + } + + var descriptor = new DescriptorWords(words); + if (keyToCandidate.TryGetValue(words[0], out var existing)) + { + // Two different descriptors sharing one probe key cannot be told apart at + // run time; reject precisely instead of guessing. + if (!result.Descriptors[(int)existing].SameAs(descriptor)) + { + return false; + } + + continue; + } + + var candidate = (uint)result.Descriptors.Count; + result.Descriptors.Add(descriptor); + keyToCandidate[words[0]] = candidate; + } + + result.Keys = [.. keyToCandidate.Keys]; + result.Candidates = result.Keys.Select(key => keyToCandidate[key]).ToList(); + return true; + } + private static string DiagnoseSnapshotEvaluationFailure( ShaderResourcePlan plan, ResourceRuntimeInputs inputs, @@ -569,6 +702,72 @@ public static class ResourceMaterializer out failure); } + private static bool MaterializeWaveIndexedImage( + IndirectImageSelector indirect, + WaveIndexedImageSelector wave, + DescriptorWords heap, + ImageResource image, + bool r128, + ResourceRuntimeInputs inputs, + out IndirectImageTable result, + out ResourceMaterializationFailure failure) + { + failure = ResourceMaterializationFailure.Other; + result = new IndirectImageTable(); + if (heap.DwordCount != 2 || inputs.ReadCleanMemory is null || wave.IndexStride == 0) + return false; + + var baseAddress = (((ulong)heap.Dwords[1] << 32) | heap.Dwords[0]) & AddressMask; + if (!TryReadCleanWord(baseAddress, wave.MaskOffset, inputs, out var activeMask)) + return false; + + var keys = new SortedSet(); + for (uint bit = 0; bit < 32; bit++) + { + if ((activeMask & (1u << (int)bit)) == 0) + continue; + var indexOffset = (ulong)wave.IndexTableOffset + (ulong)bit * wave.IndexStride; + if (!TryReadCleanWord(baseAddress, indexOffset, inputs, out var key)) + return false; + // VCmpxLeI32 0, key suppresses signed-negative indices before ReadFirstLane. + if ((key & 0x8000_0000u) == 0) + keys.Add(key); + } + + var probed = new List(Math.Max(1, keys.Count)); + var offsets = new List(Math.Max(1, keys.Count)); + foreach (var key in keys) + { + var candidate = new uint[8]; + var descriptorOffset = (ulong)indirect.TableOffset + ((ulong)key << 5); + for (uint dword = 0; dword < candidate.Length; dword++) + { + if (!TryReadCleanWord(baseAddress, descriptorOffset + dword * sizeof(uint), inputs, out candidate[dword])) + return false; + } + + if (NullImageDescriptor(candidate) || !ValidImageDescriptor(candidate, r128) || !ReservedImageBitsClear(candidate)) + Array.Clear(candidate); + probed.Add(candidate); + offsets.Add(unchecked(indirect.DynamicOffsetBase + (key << 5))); + } + + if (probed.Count == 0) + { + probed.Add(new uint[8]); + offsets.Add(0); + } + + return FinishIndirectImage(probed, offsets, out result, out failure); + } + + private static bool TryReadCleanWord(ulong baseAddress, ulong offset, ResourceRuntimeInputs inputs, out uint word) + { + word = 0; + return offset <= AddressMask && baseAddress <= AddressMask - offset && + inputs.ReadCleanMemory is not null && inputs.ReadCleanMemory(baseAddress + offset, out word); + } + private static bool FinishIndirectImage( IReadOnlyList probed, IEnumerable keys, @@ -687,6 +886,11 @@ public static class ResourceMaterializer mappingWordCount = checked(mappingWordCount + 1 + table.Keys.Count * 2); } + foreach (var table in snapshot.BufferCandidateTables) + { + mappingWordCount = checked(mappingWordCount + 1 + table.Keys.Count * 2); + } + // Each draw owns these arrays. Only indirect candidates require a larger table. Array.Resize(ref snapshot.Images, imageCount); var mappingCursor = snapshot.FlattenedTable.Length; @@ -794,7 +998,10 @@ public static class ResourceMaterializer { images[index] = image with { - NumericClass = baseImage.Atomic ? ImageNumericClass.Uint : ImageNumericClass.Float, + // A null-bound atomic keeps its declared numeric class + // (Uint or, since float image atomics were added, Float) + // instead of assuming every atomic image is Uint. + NumericClass = baseImage.Atomic ? baseImage.NumericClass : ImageNumericClass.Float, Dimension = ImageDimension.Dim2D, Cube = false, }; @@ -810,7 +1017,14 @@ public static class ResourceMaterializer } var format = GuestImageFormat.FormatOf(descriptor); - if (baseImage.Atomic && format != GuestImageFormat.Format32Uint) + // Integer image atomics (Add/Smax/And/...) only ever operate on a + // 32-bit uint UAV. The float image atomics (Fmin/Fmax/Fcmpswap, + // MIMG op 0x1D-0x1F) are lowered as a compare-and-swap loop on the + // raw bit pattern (Gen5SpirvTranslator) and legitimately target a + // 32-bit float-format UAV instead - reject only formats that are + // neither. + if (baseImage.Atomic && format != GuestImageFormat.Format32Uint && + GuestImageFormat.SampledNumericClass(format) != ImageNumericClass.Float) { return Fail($"atomic image descriptor {index} uses unsupported format {format}"); } @@ -827,7 +1041,8 @@ public static class ResourceMaterializer return Fail($"storage image descriptor {index} uses unsupported format {format}"); } - if (rawSintStorage) + // Atomics use the uint view; float min/max compare-exchange its bits. + if (rawSintStorage || (baseImage.Atomic && numericClass == ImageNumericClass.Float)) { numericClass = ImageNumericClass.Uint; } @@ -961,7 +1176,71 @@ public static class ResourceMaterializer } } - specialization = new ResourceSpecialization { Buffers = buffers, Images = images }; + // Bounded runtime V# candidates are appended after the plan's buffers; each draw + // owns their words and the run-time key mapping sits in the flattened table. + var baseBufferCount = buffers.Count; + var bufferCursor = snapshot.Buffers.Length; + var candidateTables = new List(snapshot.BufferCandidateTables.Count); + foreach (var table in snapshot.BufferCandidateTables) + { + if (table.Descriptors.Count == 0 || table.Keys.Count == 0) + { + return Fail("buffer candidate table has no candidates"); + } + + Array.Resize(ref snapshot.Buffers, bufferCursor + table.Descriptors.Count); + for (var candidate = 0; candidate < table.Descriptors.Count; candidate++) + { + var words = table.Descriptors[candidate].Dwords; + if ((words[3] >> 30) != 0) + { + Array.Clear(words); + } + + snapshot.Buffers[bufferCursor + candidate] = words; + var stride = (words[1] >> 16) & 0x3FFF; + var swizzleEnabled = (words[1] >> 31) != 0; + var indexStride = (words[3] >> 21) & 0x3; + var addThreadId = (words[3] >> 23) & 0x1; + var packedStride = stride | ((swizzleEnabled ? 1u : 0u) << 14) | (indexStride << 16) | (addThreadId << 20); + var swizzle = stride != 0 && ((packedStride >> 14) & 1) != 0; + if (stride == 0) + { + packedStride &= ~((1u << 14) | (3u << 16)); + } + else if (!swizzle) + { + packedStride &= ~(3u << 16); + } + + buffers.Add(new BufferSpecialization(packedStride, (words[3] >> 12) & 0x7F, words[3] & 0xFFF)); + } + + var mappingOffset = (uint)mappingCursor; + var order = Enumerable.Range(0, table.Keys.Count).OrderBy(index => table.Keys[index]).ToArray(); + snapshot.FlattenedTable[(int)mappingOffset] = (uint)table.Keys.Count; + for (var entry = 0; entry < order.Length; entry++) + { + var source = order[entry]; + var offset = (int)mappingOffset + 1 + entry * 2; + snapshot.FlattenedTable[offset] = table.Keys[source]; + snapshot.FlattenedTable[offset + 1] = table.Candidates[source]; + } + + mappingCursor += 1 + table.Keys.Count * 2; + candidateTables.Add(new BufferCandidateTableSpecialization( + (uint)bufferCursor, (uint)table.Descriptors.Count, mappingOffset, + (uint)BitOperations.Log2((uint)table.Keys.Count) + 1)); + bufferCursor += table.Descriptors.Count; + } + + specialization = new ResourceSpecialization + { + BaseBufferCount = baseBufferCount, + Buffers = buffers, + Images = images, + BufferCandidateTables = candidateTables, + }; specializedSnapshot = snapshot; return true; } @@ -1026,11 +1305,11 @@ public static class ResourceMaterializer public static SpecializedResourceInfo ApplyTo(ShaderResourcePlan plan, ResourceSpecialization specialization) { var source = plan.Info; - if (source.Buffers.Count != specialization.Buffers.Count || source.Images.Count > specialization.Images.Count) + if (source.Buffers.Count != specialization.BaseBufferCount || source.Images.Count > specialization.Images.Count) { throw new ResourcePlanException( $"shader resource specialization does not match the plan: hash=0x{plan.Hash:X16} stage={plan.Stage} " + - $"buffers={source.Buffers.Count}/{specialization.Buffers.Count} images={source.Images.Count}/{specialization.Images.Count}"); + $"buffers={source.Buffers.Count}/{specialization.BaseBufferCount} images={source.Images.Count}/{specialization.Images.Count}"); } var info = source.Clone(); @@ -1041,6 +1320,32 @@ public static class ResourceMaterializer info.Buffers[index].DescriptorSwizzle = specialization.Buffers[index].DescriptorSwizzle; } + // Candidate buffers follow the plan's buffers, one native binding each. + for (var index = source.Buffers.Count; index < specialization.Buffers.Count; index++) + { + var specialized = specialization.Buffers[index]; + info.Buffers.Add(new BufferResource + { + Source = DescriptorConstants.NoIndex, + Read = true, + PackedStride = specialized.PackedStride, + DescriptorFormat = specialized.DescriptorFormat, + DescriptorSwizzle = specialized.DescriptorSwizzle, + }); + } + + for (var index = 0; index < specialization.BufferCandidateTables.Count; index++) + { + var table = specialization.BufferCandidateTables[index]; + info.BufferCandidateTables.Add(new BufferCandidateTableInfo + { + FirstCandidate = table.FirstCandidate, + CandidateCount = table.CandidateCount, + MappingOffset = table.MappingOffset, + SearchIterations = table.SearchIterations, + }); + } + for (var index = 0; index < specialization.Images.Count; index++) { var specialized = specialization.Images[index]; diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs index 37c7c06..f1fbcd9 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceSpecialization.cs @@ -8,6 +8,15 @@ namespace SharpEmu.ShaderCompiler.Resources; public readonly record struct BufferSpecialization(uint PackedStride, uint DescriptorFormat, uint DescriptorSwizzle); +// The module-affecting layout of one bounded runtime V# table. Candidate descriptors +// themselves are appended to Buffers; this names their contiguous run and the flattened +// key mapping, so the switch body does not have to embed per-draw addresses. +public readonly record struct BufferCandidateTableSpecialization( + uint FirstCandidate, + uint CandidateCount, + uint MappingOffset, + uint SearchIterations); + public readonly record struct ImageSpecialization( ImageNumericClass NumericClass, ImageDimension Dimension, @@ -24,17 +33,22 @@ public readonly record struct ImageSpecialization( // stay in the snapshot, so they never create a permutation. public sealed class ResourceSpecialization : IEquatable { + // The plan's base buffer count; Buffers beyond it are materialized candidates. + public int BaseBufferCount { get; init; } public List Buffers { get; init; } = []; public List Images { get; init; } = []; + public List BufferCandidateTables { get; init; } = []; public bool Equals(ResourceSpecialization? other) => - other is not null && Buffers.SequenceEqual(other.Buffers) && Images.SequenceEqual(other.Images); + other is not null && BaseBufferCount == other.BaseBufferCount && Buffers.SequenceEqual(other.Buffers) && + Images.SequenceEqual(other.Images) && BufferCandidateTables.SequenceEqual(other.BufferCandidateTables); public override bool Equals(object? obj) => Equals(obj as ResourceSpecialization); public override int GetHashCode() { var hash = new HashCode(); + hash.Add(BaseBufferCount); foreach (var buffer in Buffers) { hash.Add(buffer); @@ -45,14 +59,26 @@ public sealed class ResourceSpecialization : IEquatable hash.Add(image); } + foreach (var table in BufferCandidateTables) + { + hash.Add(table); + } + return hash.ToHashCode(); } - public ResourceSpecialization Clone() => new() { Buffers = [.. Buffers], Images = [.. Images] }; + public ResourceSpecialization Clone() => new() + { + BaseBufferCount = BaseBufferCount, + Buffers = [.. Buffers], + Images = [.. Images], + BufferCandidateTables = [.. BufferCandidateTables], + }; // The specialization of a plan before any draw: raw buffers and the tracked image classes. public static ResourceSpecialization Default(ShaderResourceInfo info) => new() { + BaseBufferCount = info.Buffers.Count, Buffers = info.Buffers.Select(_ => new BufferSpecialization(0, DescriptorConstants.InvalidFormat, DescriptorConstants.IdentityDestinationSelect)).ToList(), Images = info.Images.Select(image => new ImageSpecialization( image.NumericClass == ImageNumericClass.Unsupported ? (image.Atomic ? ImageNumericClass.Uint : ImageNumericClass.Float) : image.NumericClass, diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs index 64f0dd9..fab01fe 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.DenseImages.cs @@ -113,8 +113,11 @@ public sealed partial class ResourceTracker var key = scaled.Operands[0]; var bound = DenseKeyBound(key); - if (bound == 0) + var waveIndexed = TryCreateWaveIndexedImageSelector(key, reads); + if (bound == 0 && waveIndexed is null) + { return false; + } foreach (var read in reads) if (!UsesOnly(read, [handle])) @@ -135,6 +138,7 @@ public sealed partial class ResourceTracker TableOffset = tableOffset, DynamicOffsetBase = unchecked(tableOffset - tableImmediate), KeyBound = bound, + WaveIndexed = waveIndexed, }, }; @@ -174,4 +178,253 @@ public sealed partial class ResourceTracker return 0; } + + // Post-process kernels can select a descriptor per active bit in a scalar mask: + // mask -> s_ff1 -> v_mov -> (bit * stride + table) -> global_load -> + // v_readfirstlane -> key << 5 -> s_load_dwordx8. The global load itself is + // lane-varying, so it deliberately stays undefined in the scalar graph. The host + // can nevertheless resolve the same finite key set from the mask and index table. + private WaveIndexedImageSelector? TryCreateWaveIndexedImageSelector( + ScalarValue key, + IReadOnlyList reads) + { + if (key.Kind != ScalarValueKind.FirstLane || key.Operands.Length != 2 || reads.Count == 0) + { + return null; + } + + var instructions = _graph.Program.Instructions; + var firstLaneIndex = FindInstructionIndex(instructions, (uint)key.Payload); + if (firstLaneIndex < 0 || instructions[firstLaneIndex] is not { Opcode: "VReadfirstlaneB32", Sources.Count: 1, Destinations.Count: 1 } firstLane || + firstLane.Sources[0] is not { Kind: Gen5OperandKind.VectorRegister } vectorSource || + firstLane.Destinations[0] is not { Kind: Gen5OperandKind.ScalarRegister } scalarDestination) + { + return null; + } + + var globalIndex = FindLastDefinition(instructions, firstLaneIndex, vectorSource); + if (globalIndex < 0 || instructions[globalIndex].Control is not Gen5GlobalMemoryControl + { + DwordCount: 1, + UsesFlatAddress: false, + OffsetBytes: 0, + } global || global.DestinationVectorRegister != vectorSource.Value) + { + return null; + } + + var descriptorMemoryIndex = reads[0].MemoryIndex; + if (descriptorMemoryIndex < 0 || descriptorMemoryIndex >= _plan.Memory.Count) + { + return null; + } + + var descriptorLoadIndex = FindInstructionIndex(instructions, _plan.Memory[descriptorMemoryIndex].Pc); + if (descriptorLoadIndex < 0 || instructions[descriptorLoadIndex] is not + { + Control: Gen5ScalarMemoryControl + { + DestinationCount: >= 8, + DynamicOffsetRegister: { } dynamicOffsetRegister, + }, + } descriptorLoad || dynamicOffsetRegister != scalarDestination.Value || + descriptorLoad.Sources.Count == 0 || descriptorLoad.Sources[0] != Gen5Operand.Scalar(global.ScalarAddress)) + { + return null; + } + + var address = Gen5Operand.Vector(global.VectorAddress); + var addressDefinitionIndex = FindLastDefinition(instructions, globalIndex, address); + if (addressDefinitionIndex < 0 || !TryGetAddedConstant(instructions[addressDefinitionIndex], out var indexDataOffset)) + { + return null; + } + + var heapAddress = Gen5Operand.Scalar(global.ScalarAddress); + if (TryGetSelfAddedConstant(instructions[addressDefinitionIndex], address, out _) && + TryGetWaveIndexedStride(instructions, addressDefinitionIndex, address, heapAddress, + out var maskRegister, out var bitRegister, out var maskOffset, out var indexStride) && + indexDataOffset != 0 && indexStride != 0 && instructions.Any(instruction => instruction.Opcode == "SBitset0B32" && + instruction.Destinations.Contains(maskRegister) && instruction.Sources.Contains(bitRegister))) + { + return new(maskOffset, indexDataOffset, indexStride); + } + + // Some game kernels reuse the vector address register while preparing the + // lane mask. The address arithmetic no longer has a single SSA-looking + // chain, but the scalar data dependency is still exact: one clean mask word, + // its FF1 result, the matching bit clear, and a scalar multiply that gives + // the global index stride. This is the same finite runtime-table model; it + // merely avoids making register allocation part of the proof. + return TryGetWaveIndexedMaskStride(instructions, addressDefinitionIndex, heapAddress, indexDataOffset); + } + + private static WaveIndexedImageSelector? TryGetWaveIndexedMaskStride( + IReadOnlyList instructions, + int before, + Gen5Operand heapAddress, + uint indexTableOffset) + { + if (indexTableOffset == 0) + return null; + + for (var loadIndex = before - 1; loadIndex >= 0; loadIndex--) + { + if (instructions[loadIndex] is not + { + Control: Gen5ScalarMemoryControl { DestinationCount: 1, ImmediateOffsetBytes: >= 0, DynamicOffsetRegister: null } maskControl, + Sources.Count: >= 1, + Destinations.Count: 1, + } maskLoad || maskLoad.Sources[0] != heapAddress || + maskLoad.Destinations[0] is not { Kind: Gen5OperandKind.ScalarRegister } maskRegister) + continue; + + for (var scanIndex = loadIndex + 1; scanIndex < before; scanIndex++) + { + if (instructions[scanIndex] is not { Opcode: "SFF1I32B32", Sources.Count: 1, Destinations.Count: 1 } scan || + scan.Sources[0] != maskRegister || scan.Destinations[0] is not { Kind: Gen5OperandKind.ScalarRegister } bitRegister || + !instructions.Skip(scanIndex + 1).Take(before - scanIndex - 1) + .Any(instruction => instruction.Opcode == "SBitset0B32" && instruction.Destinations.Contains(maskRegister) && + instruction.Sources.Contains(bitRegister))) + continue; + + for (var multiplyIndex = scanIndex + 1; multiplyIndex < before; multiplyIndex++) + { + var multiply = instructions[multiplyIndex]; + if (multiply.Opcode != "SMulI32" || multiply.Sources.Count != 2 || !multiply.Sources.Contains(bitRegister)) + continue; + var strideOperand = multiply.Sources[0] == bitRegister ? multiply.Sources[1] : multiply.Sources[0]; + if (!TryGetConstant(strideOperand, out var stride) || stride == 0) + continue; + return new((uint)maskControl.ImmediateOffsetBytes, indexTableOffset, stride); + } + } + } + + return null; + } + + private static bool TryGetWaveIndexedStride( + IReadOnlyList instructions, + int addressAddIndex, + Gen5Operand address, + Gen5Operand heapAddress, + out Gen5Operand maskRegister, + out Gen5Operand bitRegister, + out uint maskOffset, + out uint stride) + { + maskRegister = default; + bitRegister = default; + maskOffset = 0; + stride = 0; + var strideAddIndex = FindLastDefinition(instructions, addressAddIndex, address); + if (strideAddIndex < 0 || instructions[strideAddIndex] is not { Opcode: "VLshlAddU32", Sources.Count: 3 } strideAdd || + strideAdd.Sources[0] != address || strideAdd.Sources[2] != address || !TryGetConstant(strideAdd.Sources[1], out var outerShift) || outerShift >= 31) + return false; + + var innerShiftIndex = FindLastDefinition(instructions, strideAddIndex, address); + if (innerShiftIndex < 0 || instructions[innerShiftIndex] is not { Opcode: "VLshlrevB32", Sources.Count: 2 } innerShift || + !TryGetConstant(innerShift.Sources[0], out var innerAmount) || innerAmount >= 31 || + innerShift.Sources[1] is not { Kind: Gen5OperandKind.VectorRegister } bitVector) + return false; + + var moveIndex = FindLastDefinition(instructions, innerShiftIndex, bitVector); + if (moveIndex < 0 || instructions[moveIndex] is not { Opcode: "VMovB32", Sources.Count: 1 } move || + move.Sources[0] is not { Kind: Gen5OperandKind.ScalarRegister } bitScalar) + return false; + + var bitScanIndex = FindLastDefinition(instructions, moveIndex, bitScalar); + if (bitScanIndex < 0 || instructions[bitScanIndex] is not { Opcode: "SFF1I32B32", Sources.Count: 1 } bitScan || + bitScan.Sources[0] is not { Kind: Gen5OperandKind.ScalarRegister } mask) + return false; + + var maskLoadIndex = FindLastDefinition(instructions, bitScanIndex, mask); + if (maskLoadIndex < 0 || instructions[maskLoadIndex] is not + { + Control: Gen5ScalarMemoryControl { DestinationCount: 1, ImmediateOffsetBytes: >= 0, DynamicOffsetRegister: null } maskControl, + Sources.Count: >= 1, + } maskLoad || maskLoad.Sources[0] != heapAddress) + return false; + + var combinedStride = (ulong)(1u << (int)innerAmount) * ((1u << (int)outerShift) + 1u); + if (combinedStride == 0 || combinedStride > uint.MaxValue) + return false; + + maskRegister = mask; + bitRegister = bitScalar; + maskOffset = (uint)maskControl.ImmediateOffsetBytes; + stride = (uint)combinedStride; + return true; + } + + private static int FindLastDefinition(IReadOnlyList instructions, int before, Gen5Operand destination) + { + for (var index = before - 1; index >= 0; index--) + { + if (instructions[index].Destinations.Contains(destination)) + { + return index; + } + } + + return -1; + } + + 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 TryGetSelfAddedConstant(Gen5ShaderInstruction instruction, Gen5Operand destination, out uint constant) + { + constant = 0; + if (instruction.Opcode is not ("VAddI32" or "VAddU32") || instruction.Sources.Count != 2 || + !instruction.Destinations.Contains(destination)) + { + return false; + } + + var left = instruction.Sources[0]; + var right = instruction.Sources[1]; + if (left == destination) + { + return TryGetConstant(right, out constant); + } + + return right == destination && TryGetConstant(left, out constant); + } + + private static bool TryGetAddedConstant(Gen5ShaderInstruction instruction, out uint constant) + { + constant = 0; + if (instruction.Opcode is not ("VAddI32" or "VAddU32") || instruction.Sources.Count != 2) + return false; + return TryGetConstant(instruction.Sources[0], out constant) || TryGetConstant(instruction.Sources[1], out constant); + } + + private static bool TryGetConstant(Gen5Operand operand, out uint constant) + { + if (operand.Kind == Gen5OperandKind.LiteralConstant) + { + constant = operand.Value; + return true; + } + + if (operand.Kind == Gen5OperandKind.EncodedConstant && Gen5InlineConstants.TryDecode(operand.Value, out constant)) + { + return true; + } + + constant = 0; + return false; + } } diff --git a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs index 698e1f6..57ea04b 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ResourceTracker.cs @@ -19,6 +19,7 @@ public sealed partial class ResourceTracker private readonly List _sources = []; private readonly List<(int Index, uint Resource, uint Sampler, bool HasSampler)> _memoryPatches = []; private readonly List _indirectImages = []; + private readonly List _bufferCandidateTables = []; private readonly Dictionary> _uses; private readonly Dictionary> _readsByMemory = []; @@ -38,7 +39,8 @@ public sealed partial class ResourceTracker IReadOnlyList Sources, ShaderResourceInfo Info, IReadOnlyList IndirectImages, - IReadOnlySet IndirectReads); + IReadOnlySet IndirectReads, + IReadOnlyList BufferCandidateTables); private ResourceTracker(ShaderResourcePlan plan) { @@ -134,12 +136,80 @@ public sealed partial class ResourceTracker } } - return new Result(_sources, _info, indirectAccesses, indirectReads); + return new Result(_sources, _info, indirectAccesses, indirectReads, _bufferCandidateTables); } private ResourcePlanException Failure(uint pc, string reason) => new($"shader resource tracking: hash=0x{_plan.Hash:X16} stage={_plan.Stage} pc=0x{pc:X8} {reason}"); + /// + /// Names the instructions that produced the undefined leaves + /// rests on, so a rejected descriptor dword points at an opcode. + /// + private string DescribeUndefinedLeaves(ScalarValue value) + { + var origins = new List(); + var seen = new HashSet(); + var pending = new Stack(); + pending.Push(value); + while (pending.Count != 0 && origins.Count < 8) + { + var current = pending.Pop(); + if (!seen.Add(current)) + { + continue; + } + + if (current.Kind == ScalarValueKind.Undefined && + _graph.TryGetUndefinedOrigin(current, out var origin)) + { + var described = $"{origin.Opcode}@0x{origin.Pc:X}"; + if (!origins.Contains(described)) + { + origins.Add(described); + } + + continue; + } + + foreach (var operand in current.Operands) + { + pending.Push(operand); + } + } + + return origins.Count == 0 ? string.Empty : $" (undefined from: {string.Join(", ", origins)})"; + } + + private bool HasUndefinedOrigin(ScalarValue value, string opcodePrefix) + { + var seen = new HashSet(); + var pending = new Stack(); + pending.Push(value); + while (pending.Count != 0) + { + var current = pending.Pop(); + if (!seen.Add(current)) + { + continue; + } + + if (current.Kind == ScalarValueKind.Undefined && + _graph.TryGetUndefinedOrigin(current, out var origin) && + origin.Opcode.StartsWith(opcodePrefix, StringComparison.Ordinal)) + { + return true; + } + + foreach (var operand in current.Operands) + { + pending.Push(operand); + } + } + + return false; + } + // ---- descriptor sources ---- // Copies a handle's dwords into a source. A sampler that no clamp axis sets to @@ -221,12 +291,20 @@ public sealed partial class ResourceTracker } } - private bool ValidateSource(DescriptorSource source, out uint badDword) + private bool ValidateSource(DescriptorSource source, out uint badDword) => ValidateSource(source, out badDword, out _); + + private bool ValidateSource(DescriptorSource source, out uint badDword, out bool controlDependent) { + controlDependent = false; for (badDword = 0; badDword < source.DwordCount; badDword++) { var dword = source.Dwords[badDword]; - if (dword.Type != ScalarValueType.U32 || !_plan.ValidateRuntimeValue(dword)) + if (dword.Type != ScalarValueType.U32) + { + return false; + } + + if (!_plan.ValidateRuntimeValue(dword, out controlDependent)) { return false; } @@ -261,6 +339,42 @@ public sealed partial class ResourceTracker return (uint)(_sources.Count - 1); } + // Records a proven bounded SRT candidate table, reusing an equivalent table so several + // accesses share one native candidate layout. Returns false when the SRT root itself is + // not a valid runtime source, in which case the access stays on its old lowering. + private bool InternBufferCandidateTable(BufferCandidateTablePlan table, uint pc, int memoryIndex) + { + table.SourceSrtResource = InternRuntimeSource(table.SrtHandle, pc); + if (table.SourceSrtResource == DescriptorConstants.NoIndex) + { + return false; + } + + for (var existing = 0; existing < _bufferCandidateTables.Count; existing++) + { + var current = _bufferCandidateTables[existing]; + if (!_graph.Equivalent(current.SrtHandle, table.SrtHandle) || + !_graph.Equivalent(current.OffsetExpression, table.OffsetExpression)) + { + continue; + } + + _bufferCandidateTables[existing] = current.WithMemoryIndex(memoryIndex); + return true; + } + + _bufferCandidateTables.Add(table); + return true; + } + + // A fully-resolvable descriptor source for the static SRT root a candidate table reads + // through. Its dwords stay runtime values; this only records them for the materialiser. + private uint InternRuntimeSource(ScalarValue handle, uint pc) + { + var source = MakeSource(handle, 4, false, false, pc); + return ValidateSource(source, out _) ? InternSource(source) : DescriptorConstants.NoIndex; + } + private bool IsHostBufferHandle(ScalarValue? handle) => handle is { Kind: ScalarValueKind.BufferHandle, Operands.Length: 4 } && handle.Operands.All(dword => dword.Type == ScalarValueType.U32 && _plan.ValidateRuntimeValue(dword)); @@ -298,7 +412,15 @@ public sealed partial class ResourceTracker return false; } - private uint GetHandleSource(ScalarValue? handle, ScalarValueKind expected, uint width, uint pc, bool sampler = false, bool sampleAdjust = false) + private uint GetHandleSource( + ScalarValue? handle, + ScalarValueKind expected, + uint width, + uint pc, + bool sampler = false, + bool sampleAdjust = false, + string? memoryOpcode = null, + MemoryAccess memoryAccess = MemoryAccess.Read) { if (handle is null || handle.Kind != expected) { @@ -306,23 +428,248 @@ public sealed partial class ResourceTracker } var source = MakeSource(handle, width, sampler, sampleAdjust, pc); - if (expected == ScalarValueKind.ImageHandle) + var nonContiguousImage = expected == ScalarValueKind.ImageHandle && !IsContiguousScalarBufferRecord(source); + + // Image descriptors loaded straight from a scalar buffer at a dynamically-uniform + // offset (e.g. a bindless material heap entry read via S_BUFFER_LOAD, without going + // through the explicit TryMakeIndirectImage/TryMakeDirectImage heap-record shapes) + // used to be rejected outright here. But RuntimeValueValidator/RuntimeValueEvaluator + // already handle ScalarBufferWord dwords generically (the same mechanism buffer + // descriptors rely on via MaterializationSources), so let ValidateSource below be the + // single source of truth instead of a narrower, ImageHandle-specific blanket ban. + var badDword = 0u; + var controlDependent = false; + if (nonContiguousImage || !ValidateSource(source, out badDword, out controlDependent)) { - for (uint dword = 0; dword < source.DwordCount; dword++) + // A bindless image/sampler descriptor whose dwords resolve through a + // control-dependent phi (e.g. a hash-table/linear-probe material lookup, as seen + // in Ghost of Yotei) has no single compile-time source: real support needs + // GPU-side dynamic descriptor indexing, which this resource tracker doesn't + // implement. Rather than fail shader recompilation outright, degrade to a null + // descriptor for that one access and let it read as a null/black texture, + // mirroring KytyPS5's fallback for the same case (feat/shader-control-dependent- + // descriptor). Buffer/sampler-adjacent handles or any other validation failure + // still hard-fail, since those aren't safe to silently zero. + var dynamicImageFallback = expected is (ScalarValueKind.ImageHandle or ScalarValueKind.SamplerHandle) && + (controlDependent || HasUndefinedOrigin(source.Dwords[badDword], "BufferLoadFormat") || + (nonContiguousImage && source.Dwords.Any(dword => HasUndefinedOrigin(dword, "SAndB32")))); + if (dynamicImageFallback) { - if (source.Dwords[dword].Kind == ScalarValueKind.ScalarBufferWord) + source = new DescriptorSource { - throw Failure(pc, $"{expected} dword {dword} is not a valid runtime value"); - } + Dwords = Enumerable.Repeat(_graph.Constant(0u), (int)source.DwordCount).ToArray(), + }; + } + else + { + throw Failure( + pc, + $"{memoryOpcode ?? "memory"} ({memoryAccess}) {expected} dword {badDword} is not a valid runtime value" + + DescribeUndefinedLeaves(source.Dwords[badDword]) + + $" (value: {DescribeValueShape(source.Dwords[badDword])})"); } } - if (!ValidateSource(source, out var badDword)) + return InternSource(source); + } + + private string DescribeValueShape(ScalarValue value, int depth = 0) + { + if (depth >= 4) { - throw Failure(pc, $"{expected} dword {badDword} is not a valid runtime value"); + return $"{value.Kind}#{value.Id}"; } - return InternSource(source); + if (value.IsConstant) + { + return value.Type == ScalarValueType.U64 + ? $"0x{value.ConstantU64:X}" + : value.Type == ScalarValueType.Bool ? (value.ConstantBool ? "true" : "false") : $"0x{value.ConstantU32:X}"; + } + + if (value.Kind == ScalarValueKind.Operation) + { + return $"{value.Operation}#{value.Id}({string.Join(',', value.Operands.Select(operand => DescribeValueShape(operand, depth + 1)))})"; + } + + if (value.Kind == ScalarValueKind.Undefined && _graph.TryGetUndefinedOrigin(value, out var origin)) + { + return $"Undefined#{value.Id}({origin.Opcode}@0x{origin.Pc:X})"; + } + + return $"{value.Kind}#{value.Id}"; + } + + // An image descriptor read from a scalar buffer must be one contiguous record. + private bool IsContiguousScalarBufferRecord(DescriptorSource source) + { + if (!source.Dwords.Any(dword => dword.Kind == ScalarValueKind.ScalarBufferWord)) + { + return true; + } + + var first = ScalarReadMemory(source.Dwords[0], out _); + for (var dword = 0; dword < source.Dwords.Length; dword++) + { + var read = source.Dwords[dword]; + var memory = ScalarReadMemory(read, out _); + if (first is null || memory is null || + memory.Offset != first.Offset + (uint)dword * sizeof(uint) || + !_graph.Equivalent(read.Operands[0], source.Dwords[0].Operands[0]) || + !_graph.Equivalent(read.Operands[1], source.Dwords[0].Operands[1])) + { + return false; + } + } + + return true; + } + + // A runtime V# is one whose four dwords cannot be resolved at plan time, but + // whose non-constant leaves are all reads from guest memory. This covers a + // descriptor read straight from a device address and one read from the SRT + // buffer at a dynamic offset, including the phi that merges an SRT read + // across a loop. A handle that is fully resolvable (constants and flattened + // table words only) is not a runtime descriptor and keeps the native binding. + private bool IsRuntimeDescriptorHandle(ScalarValue? handle, bool allowComputedWords = false) + { + if (handle is null || handle.Kind != ScalarValueKind.BufferHandle || + handle.Operands.Length != 4) + { + return false; + } + + // Descriptor words loaded by the shader can point at a memory entry that + // is intentionally omitted from the host-side table (for example a + // vertex-fetch record created after fixed-function lowering). The + // device-address lowering does not need to evaluate that word on the + // host, so keep the descriptor runtime when the word is still a real + // scalar memory read. Other values retain the stricter derivability + // check, which keeps divergent first-lane handles rejected. + if (!handle.Operands.All(IsRuntimeDescriptorDword)) + { + // Some vertex-fetch shaders combine one scalar address load with + // descriptor words produced in the vector path. Those words are + // not host-evaluable, but the shader still owns the complete + // descriptor in its registers. Once a scalar memory word is part + // of the handle, lower the whole descriptor through device + // addresses instead of trying to bind a partial host source. + return allowComputedWords && handle.Operands.Any(operand => HasRuntimeReadKind(operand, ScalarValueKind.ScalarAddressWord)); + } + + return handle.Operands.Any(operand => HasRuntimeRead(operand)); + } + + private bool IsRuntimeDescriptorDword(ScalarValue value) => IsRuntimeDescriptorDword(value, []); + + private bool IsRuntimeDescriptorDword(ScalarValue value, HashSet visiting) + { + if (!visiting.Add(value)) + { + return value.Kind == ScalarValueKind.Phi; + } + + try + { + if (value.Kind is ScalarValueKind.ScalarAddressWord or ScalarValueKind.ScalarBufferWord) + { + // The backend consumes this as a device-address word. It may + // not have a corresponding host materialization record. + return value.MemoryIndex >= 0; + } + + return IsRuntimeDerivable(value, visiting); + } + finally + { + visiting.Remove(value); + } + } + + // True when a value is known at plan time (constant, user data, shader base, + // a flattened resource-table word) or is ultimately a guest-memory read. + // A phi, select or operation qualifies when every operand does, so a + // loop-carried descriptor merge stays a runtime descriptor. A cycle through + // a phi is accepted: its non-cyclic inputs are checked on the path that + // reached it, the same way the read planner treats cyclic phis. + private bool IsRuntimeDerivable(ScalarValue value) => IsRuntimeDerivable(value, []); + + private bool IsRuntimeDerivable(ScalarValue value, HashSet visiting) + { + if (!visiting.Add(value)) + { + return value.Kind == ScalarValueKind.Phi; + } + + try + { + switch (value.Kind) + { + case ScalarValueKind.Constant: + case ScalarValueKind.ResourceTableWord: + case ScalarValueKind.UserData: + case ScalarValueKind.ShaderBase: + case ScalarValueKind.MemoryAperture: + return true; + case ScalarValueKind.ScalarAddressWord: + case ScalarValueKind.ScalarBufferWord: + return value.MemoryIndex < _graph.Memory.Count; + case ScalarValueKind.Phi: + case ScalarValueKind.Select: + case ScalarValueKind.Operation: + case ScalarValueKind.FirstLane: + return value.Operands.All(operand => IsRuntimeDerivable(operand, visiting)); + default: + return false; + } + } + finally + { + visiting.Remove(value); + } + } + + private static bool HasRuntimeRead(ScalarValue value) => HasRuntimeRead(value, []); + + private static bool HasRuntimeReadKind(ScalarValue value, ScalarValueKind kind) => HasRuntimeReadKind(value, kind, []); + + private static bool HasRuntimeReadKind(ScalarValue value, ScalarValueKind kind, HashSet visiting) + { + if (!visiting.Add(value)) + { + return false; + } + + try + { + return value.Kind == kind || value.Operands.Any(operand => HasRuntimeReadKind(operand, kind, visiting)); + } + finally + { + visiting.Remove(value); + } + } + + private static bool HasRuntimeRead(ScalarValue value, HashSet visiting) + { + if (!visiting.Add(value)) + { + return false; + } + + try + { + if (value.Kind is ScalarValueKind.ScalarAddressWord or ScalarValueKind.ScalarBufferWord) + { + return true; + } + + return value.Operands.Any(operand => HasRuntimeRead(operand, visiting)); + } + finally + { + visiting.Remove(value); + } } // ---- dense tables ---- @@ -490,6 +837,14 @@ public sealed partial class ResourceTracker var isBuffer = memory.Kind is MemoryResourceKind.Buffer or MemoryResourceKind.ScalarBuffer; var isAddress = memory.Kind is MemoryResourceKind.ScalarAddress or MemoryResourceKind.Flat or MemoryResourceKind.Global or MemoryResourceKind.Scratch; var isImage = memory.Kind == MemoryResourceKind.Image; + if (memory.Opcode is "ImageBvhIntersectRay" or "ImageBvh64IntersectRay") + { + // BVH nodes are read through the device-address page table: traversal + // reaches bottom-level trees whose bases only exist in guest memory. + _info.UsesDeviceAddresses = true; + return; + } + if (!isBuffer && !isAddress && !isImage) { return; @@ -497,6 +852,12 @@ public sealed partial class ResourceTracker if (access is null) { + if (HasIndirectPcTransferBefore(memory.Pc)) + { + memory.PlanningOnly = true; + return; + } + throw Failure(memory.Pc, "memory operation has no resource handle"); } @@ -507,6 +868,85 @@ public sealed partial class ResourceTracker if (isBuffer) { + // A front GS/HS shader can carry a second code object after + // S_SETPC_B64. Until AGC supplies its continuation descriptor, + // linear decoding sees that unreachable tail with undefined SGPRs. + // Do not turn that dead instruction into a descriptor failure; the + // live path has already transferred control through the PC pair. + if (access.Handle is { Kind: ScalarValueKind.BufferHandle } unreachableHandle && + unreachableHandle.Operands.Any(value => value.IsUndefined) && + HasIndirectPcTransferBefore(memory.Pc)) + { + memory.PlanningOnly = true; + return; + } + + // A buffer access can carry a descriptor assembled in vector lanes. Its + // record count/stride is not a host-evaluable scalar (the A6FE Little + // Nightmares path is one example), but the Vulkan physical-storage-buffer + // lowering can read the four descriptor words directly from shader + // registers. Keep this narrow to vector-derived accesses; ordinary + // malformed handles must still fail validation below. + if (memory.Access is MemoryAccess.Read or MemoryAccess.Write && + access.Handle is { Kind: ScalarValueKind.BufferHandle } writeHandle) + { + var writeSource = MakeSource(writeHandle, 4, false, false, memory.Pc); + if (writeSource.Dwords.Any(dword => + HasUndefinedOrigin(dword, "VAdd3U32") || + HasUndefinedOrigin(dword, "VCvtU32F32") || + HasUndefinedOrigin(dword, "VRcpIflagF32"))) + { + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Runtime, + }; + _info.UsesDeviceAddresses = true; + return; + } + } + + // A V# whose dwords are all read from a runtime scalar-memory address + // (e.g. a descriptor-array entry indexed by a loop counter) cannot be + // bound ahead of time. Record it as a runtime guest descriptor so the + // backend can choose a lowering strategy for each access. + // A vector access through a V# the draw can evaluate keeps the explicit binding. + var allowComputedFormatDescriptor = memory.Formatted && + memory.Opcode.StartsWith("BufferLoadFormat", StringComparison.Ordinal); + if (IsRuntimeDescriptorHandle(access.Handle, allowComputedFormatDescriptor) && + (memory.Kind != MemoryResourceKind.Buffer || + !ValidateSource(MakeSource(access.Handle!, 4, false, false, memory.Pc), out _))) + { + // A formatted vector access can enumerate its bounded candidates; a scalar + // buffer read only needs the descriptor's base, which its SGPRs already + // hold, so it is addressed through the device-address page table. + if (memory.Kind == MemoryResourceKind.Buffer && + BufferCandidateTablePlanner.TryPlan(_plan, access.Handle!, index, out var table) && + InternBufferCandidateTable(table, memory.Pc, index)) + { + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Runtime, + }; + return; + } + + // Without a bounded candidate table, a V# loaded from scalar-buffer + // data is read through its descriptor registers on the device. + if (memory.Kind == MemoryResourceKind.Buffer && IsDeviceLoadedBufferHandle(access.Handle)) + { + memory.DeviceDescriptor = true; + _info.UsesDeviceAddresses = true; + return; + } + + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Runtime, + }; + _info.UsesDeviceAddresses = true; + return; + } + // Scalar loads can address buffers that have no host descriptor binding, while // vector buffer descriptors loaded from scalar-buffer data must stay device-side. if ((memory.Kind == MemoryResourceKind.ScalarBuffer && !IsHostBufferHandle(access.Handle)) || @@ -517,13 +957,35 @@ public sealed partial class ResourceTracker return; } - var source = GetHandleSource(access.Handle, ScalarValueKind.BufferHandle, 4, memory.Pc); + var source = GetHandleSource( + access.Handle, + ScalarValueKind.BufferHandle, + 4, + memory.Pc, + memoryOpcode: memory.Opcode, + memoryAccess: memory.Access); var resource = AddBuffer(source, memory, memory.Pc); if (resource == DescriptorConstants.NoIndex) { - throw Failure(memory.Pc, "buffer resource limit exceeded"); + // Past the binding budget the access reads its V# from its SGPRs when it + // runs, as the hardware does, and goes through the device-address table. + // That path has no typed (tbuffer) or formatted store lowering. + if (memory.Opcode.StartsWith("TBuffer", StringComparison.Ordinal) || + (memory.Formatted && memory.Access != MemoryAccess.Read)) + { + throw Failure(memory.Pc, "buffer resource limit exceeded"); + } + + memory.DeviceDescriptor = true; + _info.UsesDeviceAddresses = true; + return; } + memory.BufferDescriptor = new GuestBufferDescriptor + { + Provenance = BufferDescriptorProvenance.Static, + StaticResource = resource, + }; AddMemoryPatch(index, resource, 0, false, memory.Pc); return; } @@ -551,6 +1013,22 @@ public sealed partial class ResourceTracker return; } + // A fused shader resumes through S_SETPC_B64. The continuation can + // legitimately consume the image descriptor carried in the incoming + // SGPR state, while the standalone resource graph only sees the first + // code object and therefore has no provenance for those dwords. Do + // not reject the whole program for that cross-object bookkeeping gap; + // leave the access unplanned so the continuation path can supply its + // descriptor at runtime. + if (isImage && + access.Handle is { Kind: ScalarValueKind.ImageHandle } continuationImage && + continuationImage.Operands.Any(value => value.IsUndefined) && + HasIndirectPcTransferBefore(memory.Pc)) + { + memory.PlanningOnly = true; + return; + } + if (memory.ImageClass == ImageResourceClass.None) { throw Failure(memory.Pc, "image operation has invalid resource kind"); @@ -633,6 +1111,35 @@ public sealed partial class ResourceTracker private bool IsIndirectPlanningMemory(int index) => _indirectImages.Any(plan => plan.SuppressMemoryReads && plan.Memory.Contains(index)); + private bool HasIndirectPcTransferBefore(uint pc) + { + var previousEnd = 0u; + foreach (var instruction in _graph.Program.Instructions) + { + if (instruction.Pc >= pc) + { + break; + } + + if (instruction.Opcode is "SSetpcB64" or "SRfeB64") + { + return true; + } + + // Fused shaders replace the S_SETPC marker with a NOP and append + // the continuation at its real address. The PC discontinuity is + // the remaining reliable boundary when the marker is gone. + if (previousEnd != 0 && instruction.Pc > previousEnd) + { + return true; + } + + previousEnd = instruction.Pc + (uint)(instruction.Words.Count * sizeof(uint)); + } + + return false; + } + private void PlanIndirectImages() { for (var index = 0; index < _plan.Memory.Count; index++) @@ -662,7 +1169,8 @@ public sealed partial class ResourceTracker } var memory = _plan.Memory[index]; - return memory.Kind == MemoryResourceKind.ScalarBuffer && memory.DataBits == 32 && memory.DataDwords == 1 ? memory : null; + return memory.Kind is MemoryResourceKind.ScalarBuffer or MemoryResourceKind.Buffer && + memory.DataBits == 32 && memory.DataDwords == 1 ? memory : null; } private bool MemoryIndexBelongsTo(int index, ScalarValue owner) => diff --git a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs index 2b238be..668af94 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueEvaluator.cs @@ -116,6 +116,9 @@ public sealed class RuntimeValueEvaluator { case ScalarValueKind.Undefined: return false; + case ScalarValueKind.MemoryAperture: + result = Gen5InlineConstants.DecodeAperture64((uint)value.Payload) >> 32; + return true; case ScalarValueKind.UserData: { var register = value.UserDataRegister; @@ -235,7 +238,9 @@ public sealed class RuntimeValueEvaluator var size = stride == 0 ? (ulong)(uint)records : (ulong)stride * (uint)records; if (aligned > size || size - aligned < sizeof(uint)) { - return false; + // An unbound (empty) V# reads as zero; overrunning a bound buffer stays a failure. + result = 0; + return (uint)records == 0; } address = ((baseAddress & ~3ul) + byteOffset) & ~3ul; @@ -354,7 +359,10 @@ public sealed class RuntimeValueEvaluator { for (var index = 0; index < words.Length; index++) { - if (!evaluator.Evaluate(source.Dwords[index], out words[index])) return false; + if (!evaluator.Evaluate(source.Dwords[index], out words[index])) + { + return false; + } } } diff --git a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs index 274dd6c..7df4bb3 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/RuntimeValueValidator.cs @@ -16,6 +16,14 @@ public sealed class RuntimeValueValidator private readonly ScalarValue? _activeMask; private readonly HashSet _visiting = []; + // Set when a failure traces back to a phi that isn't loop-invariant (e.g. a + // hash-table/linear-probe bindless lookup): the value genuinely has no single + // compile-time source, as opposed to using an operation or memory shape we just + // don't recognize. Callers use this to decide whether degrading to a null + // descriptor is reasonable instead of failing outright (KytyPS5 does the same + // for image/sampler handles specifically). + public bool ControlDependent { get; private set; } + public RuntimeValueValidator(ScalarValueGraph graph, uint userDataBase, uint userDataCount, int tableReadCount, ScalarValue? activeMask = null) { _graph = graph; @@ -58,7 +66,8 @@ public sealed class RuntimeValueValidator try { - return ValidateNode(value); + var result = ValidateNode(value); + return result; } finally { @@ -81,15 +90,35 @@ public sealed class RuntimeValueValidator case ScalarValueKind.UserData: return value.UserDataRegister >= _userDataBase && value.UserDataRegister - _userDataBase < _userDataCount; case ScalarValueKind.ShaderBase: + case ScalarValueKind.MemoryAperture: return true; case ScalarValueKind.Phi: { var invariant = _graph.ResolveInvariantPhi(value); - return invariant is not null && Validate(invariant); + if (invariant is null) + { + ControlDependent = true; + return false; + } + + return Validate(invariant); } case ScalarValueKind.FirstLane: - return value.Operands.Length == 2 && - new RuntimeValueValidator(_graph, _userDataBase, _userDataCount, _tableReadCount, value.Operands[1]).Validate(value.Operands[0]); + { + if (value.Operands.Length != 2) + { + return false; + } + + var nested = new RuntimeValueValidator(_graph, _userDataBase, _userDataCount, _tableReadCount, value.Operands[1]); + var ok = nested.Validate(value.Operands[0]); + if (nested.ControlDependent) + { + ControlDependent = true; + } + + return ok; + } case ScalarValueKind.ResourceTableWord: return value.Payload < (ulong)_tableReadCount; case ScalarValueKind.ScalarAddressWord: @@ -168,6 +197,6 @@ public sealed class RuntimeValueValidator var kind = _graph.Memory[value.MemoryIndex].Kind; return (value.Kind == ScalarValueKind.ScalarAddressWord && kind == MemoryResourceKind.ScalarAddress) || - (value.Kind == ScalarValueKind.ScalarBufferWord && kind == MemoryResourceKind.ScalarBuffer); + (value.Kind == ScalarValueKind.ScalarBufferWord && kind is MemoryResourceKind.ScalarBuffer or MemoryResourceKind.Buffer); } } diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs index 4fe7977..f398e7f 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarValue.cs @@ -32,6 +32,8 @@ public enum ScalarValueKind : byte ScalarAddressWord, ScalarBufferWord, ResourceTableWord, + // The high dword of a GFX10 LDS/scratch aperture (payload: the inline operand). + MemoryAperture, } // Operations a value node can apply to its operands. The validator accepts only the @@ -154,6 +156,9 @@ public sealed class ScalarValue public static ScalarValue ConstantOf(bool value) => new(ScalarValueKind.Constant, ScalarValueType.Bool, ScalarOperation.None, []) { Payload = value ? 1u : 0u }; + public static ScalarValue MemoryAperture(uint operand) => + new(ScalarValueKind.MemoryAperture, ScalarValueType.U32, ScalarOperation.None, []) { Payload = operand }; + public static ScalarValue Undefined(ScalarValueType type) => new(ScalarValueKind.Undefined, type, ScalarOperation.None, []); @@ -197,6 +202,7 @@ public sealed class ScalarValue { ScalarValueKind.Constant => Type == ScalarValueType.U64 ? $"0x{Payload:X}ul" : Type == ScalarValueType.Bool ? (Payload != 0 ? "true" : "false") : $"0x{(uint)Payload:X}", ScalarValueKind.UserData => $"UserData(s{Payload})", + ScalarValueKind.MemoryAperture => $"Aperture({Payload})", ScalarValueKind.Operation => $"{Operation}({string.Join(", ", Operands.Select(operand => operand.ToString()))})", ScalarValueKind.Phi => $"Phi#{Id}(block {PhiBlock})", _ => $"{Kind}#{Id}", @@ -232,7 +238,7 @@ public static class ScalarValueEquivalence return false; } - if (left.Kind is ScalarValueKind.Constant or ScalarValueKind.UserData or ScalarValueKind.ResourceTableWord) + if (left.Kind is ScalarValueKind.Constant or ScalarValueKind.UserData or ScalarValueKind.ResourceTableWord or ScalarValueKind.MemoryAperture) { return left.Payload == right.Payload; } diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs index 4b051e2..4db3ae1 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.Builder.cs @@ -297,6 +297,8 @@ public sealed partial class ScalarValueGraph private void Apply(Gen5ShaderInstruction instruction, RegisterState state) { + // Lets ScalarValueGraph.Undefined record which instruction gave up. + _graph.BuilderInstruction = (instruction.Pc, instruction.Opcode); switch (instruction.Encoding) { case Gen5ShaderEncoding.Sop1: @@ -357,6 +359,13 @@ public sealed partial class ScalarValueGraph case "SMovB32": state.WriteScalar(destinationRegister, Read(instruction.Sources[0], state)); return; + case "SBitreplicateB64B32": + { + var replicated = Read(instruction.Sources[0], state); + state.WriteScalar(destinationRegister, replicated); + state.WriteScalar(destinationRegister + 1, replicated); + return; + } case "SMovkI32": state.WriteScalar(destinationRegister, _graph.Constant(unchecked((uint)(short)instruction.Sources[0].Value))); return; @@ -1029,9 +1038,12 @@ public sealed partial class ScalarValueGraph return; } - var hasModifiers = instruction.Control is Gen5Vop3Control { AbsoluteMask: not 0 } or Gen5Vop3Control { NegateMask: not 0 } or - Gen5Vop3Control { Clamp: true } or Gen5Vop3Control { OutputModifier: not 0 } or Gen5Vop3Control { OperandSelect: not 0 } or - Gen5SdwaControl or Gen5DppControl or Gen5Dpp8Control or Gen5Vop3pControl; + var hasModifiers = (instruction.Control is Gen5Vop3Control { AbsoluteMask: not 0 } or Gen5Vop3Control { NegateMask: not 0 } or + Gen5Vop3Control { Clamp: true } or Gen5Vop3Control { OutputModifier: not 0 } or Gen5Vop3Control { OperandSelect: not 0 }) || + (instruction.Control is Gen5SdwaControl sdwa && + (sdwa.AbsoluteMask != 0 || sdwa.NegateMask != 0 || sdwa.OutputModifier != 0 || sdwa.Clamp || + sdwa.DestinationSelect == 7 || sdwa.Source0Select == 7 || sdwa.Source1Select == 7 || sdwa.DestinationUnused == 3)) || + instruction.Control is Gen5DppControl or Gen5Dpp8Control or Gen5Vop3pControl; var value = hasModifiers ? _graph.Undefined(ScalarValueType.U32) : VectorResult(instruction, state); if (instruction.Control is Gen5Vop3Control { ScalarDestination: { } carryDestination } && !hasModifiers && opcode is "VAddCoU32" or "VSubCoU32" or "VSubrevCoU32" or "VAddCoCiU32" or "VSubCoCiU32" or "VSubrevCoCiU32" or "VMadU64U32") @@ -1049,7 +1061,10 @@ public sealed partial class ScalarValueGraph { if (destination.Kind == Gen5OperandKind.VectorRegister) { - state.WriteVector(destination.Value, value); + var result = instruction.Control is Gen5SdwaControl destinationSdwa + ? ApplySdwaDestination(destinationSdwa, value, state.ReadVector(destination.Value)) + : value; + state.WriteVector(destination.Value, result); // Multi-dword results are not modelled; the second dword is undefined. value = _graph.Undefined(ScalarValueType.U32); } @@ -1064,7 +1079,9 @@ public sealed partial class ScalarValueGraph { var opcode = instruction.Opcode; var sources = instruction.Sources; - ScalarValue Source(int index) => index < sources.Count ? ReadVectorOperand(sources[index], state) : _graph.Undefined(ScalarValueType.U32); + ScalarValue Source(int index) => index < sources.Count + ? ApplySdwaSource(instruction.Control as Gen5SdwaControl, index, ReadVectorOperand(sources[index], state)) + : _graph.Undefined(ScalarValueType.U32); ScalarValue Shift(ScalarValue value, ScalarValue count, ScalarOperation operation) => Binary(operation, value, Binary(ScalarOperation.And32, count, _graph.Constant(31u))); ScalarValue Low24(ScalarValue value) => @@ -1105,6 +1122,26 @@ public sealed partial class ScalarValueGraph state.CarryOut = Bool(ScalarOperation.LogicalAnd, state.Exec, NotZero(Binary(ScalarOperation.Or32, Extract(first, 1), Extract(second, 1)))); return Extract(second, 0); } + case "VSubCoCiU32": + case "VSubrevCoCiU32": + { + var left = opcode == "VSubCoCiU32" ? Source(0) : Source(1); + var right = opcode == "VSubCoCiU32" ? Source(1) : Source(0); + var borrowIn = _graph.Select( + instruction.Sources.Count > 2 ? MaskOf(sources[2], state) : state.Vcc, + _graph.Constant(1u), + _graph.Constant(0u)); + var partial = Binary(ScalarOperation.ISub32, left, right); + var result = Binary(ScalarOperation.ISub32, partial, borrowIn); + state.CarryOut = Bool( + ScalarOperation.LogicalAnd, + state.Exec, + Bool( + ScalarOperation.LogicalOr, + Bool(ScalarOperation.UGreaterThan32, right, left), + Bool(ScalarOperation.UGreaterThan32, borrowIn, partial))); + return result; + } case "VMulLoU32": case "VMulLoI32": return Binary(ScalarOperation.IMul32, Source(0), Source(1)); @@ -1165,6 +1202,15 @@ public sealed partial class ScalarValueGraph return Binary(ScalarOperation.UMax32, Source(0), Source(1)); case "VMinI32": return Binary(ScalarOperation.SMin32, Source(0), Source(1)); + case "VMed3U32": + // median(a, b, c) = max(min(a, b), min(max(a, b), c)) + return Binary(ScalarOperation.UMax32, + Binary(ScalarOperation.UMin32, Source(0), Source(1)), + Binary(ScalarOperation.UMin32, Binary(ScalarOperation.UMax32, Source(0), Source(1)), Source(2))); + case "VMed3I32": + return Binary(ScalarOperation.SMax32, + Binary(ScalarOperation.SMin32, Source(0), Source(1)), + Binary(ScalarOperation.SMin32, Binary(ScalarOperation.SMax32, Source(0), Source(1)), Source(2))); case "VMaxI32": return Binary(ScalarOperation.SMax32, Source(0), Source(1)); case "VBfeU32": @@ -1238,6 +1284,68 @@ public sealed partial class ScalarValueGraph } } + private ScalarValue ApplySdwaSource(Gen5SdwaControl? control, int sourceIndex, ScalarValue value) + { + if (control is null || sourceIndex > 1) + { + return value; + } + + var selector = sourceIndex == 0 ? control.Source0Select : control.Source1Select; + if (selector == 6) + { + return value; + } + + if (selector > 5) + { + return _graph.Undefined(ScalarValueType.U32); + } + + var width = selector <= 3 ? 8u : 16u; + var shift = selector <= 3 ? selector * 8u : (selector - 4u) * 16u; + var extracted = Binary(ScalarOperation.And32, + Binary(ScalarOperation.ShiftRightLogical32, value, _graph.Constant(shift)), + _graph.Constant(width == 8 ? 0xFFu : 0xFFFFu)); + + return (sourceIndex == 0 ? control.Source0SignExtend : control.Source1SignExtend) + ? _graph.Operation(ScalarOperation.BitFieldSExtract, ScalarValueType.U32, extracted, + _graph.Constant(0u), _graph.Constant(width)) + : extracted; + } + + private ScalarValue ApplySdwaDestination(Gen5SdwaControl control, ScalarValue value, ScalarValue previous) + { + if (control.DestinationSelect == 6) + { + return value; + } + + if (control.DestinationSelect > 5 || control.DestinationUnused == 3) + { + return _graph.Undefined(ScalarValueType.U32); + } + + var width = control.DestinationSelect <= 3 ? 8u : 16u; + var shift = control.DestinationSelect <= 3 ? control.DestinationSelect * 8u : (control.DestinationSelect - 4u) * 16u; + var lowMask = width == 8 ? 0xFFu : 0xFFFFu; + var fieldMask = lowMask << (int)shift; + var positioned = Binary(ScalarOperation.ShiftLeft32, + Binary(ScalarOperation.And32, value, _graph.Constant(lowMask)), _graph.Constant(shift)); + + return control.DestinationUnused switch + { + 0 => positioned, + 1 => Binary(ScalarOperation.Or32, positioned, + _graph.Select(NotZero(Binary(ScalarOperation.And32, positioned, + _graph.Constant(1u << (int)(shift + width - 1)))), + _graph.Constant(uint.MaxValue << (int)(shift + width)), _graph.Constant(0u))), + 2 => Binary(ScalarOperation.Or32, + Binary(ScalarOperation.And32, previous, _graph.Constant(~fieldMask)), positioned), + _ => _graph.Undefined(ScalarValueType.U32), + }; + } + private void ApplyVectorCompare(Gen5ShaderInstruction instruction, RegisterState state) { var opcode = instruction.Opcode; @@ -1245,6 +1353,27 @@ public sealed partial class ScalarValueGraph var right = instruction.Sources.Count > 1 ? ReadVectorOperand(instruction.Sources[1], state) : _graph.Undefined(ScalarValueType.U32); var updatesExecutionMask = opcode.StartsWith("VCmpx", StringComparison.Ordinal); var suffix = opcode[(updatesExecutionMask ? "VCmpx".Length : "VCmp".Length)..]; + + // Integer 16-bit compares consume the low word of their operands. Signed forms + // sign-extend it before comparison; floating-point F16 compares use their own path. + if (suffix.EndsWith("16", StringComparison.Ordinal) && !suffix.EndsWith("F16", StringComparison.Ordinal)) + { + var signed16 = suffix.EndsWith("I16", StringComparison.Ordinal); + ScalarValue Narrow16(ScalarValue value) + { + var low = Binary(ScalarOperation.And32, value, _graph.Constant(0xffffu)); + return signed16 + ? Binary( + ScalarOperation.ShiftRightArithmetic32, + Binary(ScalarOperation.ShiftLeft32, low, _graph.Constant(16u)), + _graph.Constant(16u)) + : low; + } + + left = Narrow16(left); + right = Narrow16(right); + } + var result = instruction.Control is Gen5SdwaControl or Gen5Vop3Control { AbsoluteMask: not 0 } or Gen5Vop3Control { NegateMask: not 0 } ? _graph.Undefined(ScalarValueType.Bool) : suffix switch @@ -1259,6 +1388,16 @@ public sealed partial class ScalarValueGraph "GtI32" => Bool(ScalarOperation.SGreaterThan32, left, right), "LeI32" => Bool(ScalarOperation.SLessThanEqual32, left, right), "GeI32" => Bool(ScalarOperation.SGreaterThanEqual32, left, right), + "EqU16" or "EqI16" => Bool(ScalarOperation.IEqual32, left, right), + "NeU16" or "NeI16" => Bool(ScalarOperation.INotEqual32, left, right), + "LtU16" => Bool(ScalarOperation.ULessThan32, left, right), + "LeU16" => Bool(ScalarOperation.ULessThanEqual32, left, right), + "GtU16" => Bool(ScalarOperation.UGreaterThan32, left, right), + "GeU16" => Bool(ScalarOperation.UGreaterThanEqual32, left, right), + "LtI16" => Bool(ScalarOperation.SLessThan32, left, right), + "LeI16" => Bool(ScalarOperation.SLessThanEqual32, left, right), + "GtI16" => Bool(ScalarOperation.SGreaterThan32, left, right), + "GeI16" => Bool(ScalarOperation.SGreaterThanEqual32, left, right), "LeF32" => Bool(ScalarOperation.FLessThanEqual, left, right), "GeF32" => Bool(ScalarOperation.FGreaterThanEqual, left, right), "LtF32" => Bool(ScalarOperation.FLessThan, left, right), @@ -1341,9 +1480,10 @@ public sealed partial class ScalarValueGraph private void ApplyMemory(Gen5ShaderInstruction instruction, RegisterState state) { + ScalarValue? vectorRead = null; if (_recording && _graph.Memory.TryGetIndex(instruction.Pc, 0, out var memoryIndex)) { - _graph.Accesses[memoryIndex] = instruction.Control switch + var binding = instruction.Control switch { Gen5BufferMemoryControl buffer => new MemoryAccessBinding( _graph.Handle(ScalarValueKind.BufferHandle, state.Read(buffer.ScalarResource), state.Read(buffer.ScalarResource + 1), @@ -1360,9 +1500,26 @@ public sealed partial class ScalarValueGraph AddressHandleOf(global, state), null, null, - global.UsesFlatAddress ? null : state.ReadVector(global.VectorAddress)), + global.UsesFlatAddress ? null : state.ReadVector(global.VectorAddress), + state.Exec), _ => null, }; + + if (binding is not null && instruction.Control is Gen5BufferMemoryControl bufferControl && + !instruction.Opcode.Contains("Store", StringComparison.Ordinal) && + instruction.Destinations.FirstOrDefault(destination => destination.Kind == Gen5OperandKind.VectorRegister) is { } vectorDestination) + { + var handle = binding.Handle!; + var offset = state.ReadVector(bufferControl.VectorAddress); + // A lane-varying address loads a lane-varying value. + if (!offset.IsUndefined) + { + vectorRead = _graph.MemoryRead(ScalarValueKind.ScalarBufferWord, handle, offset, memoryIndex); + binding = binding with { Read = vectorRead }; + } + } + + _graph.Accesses[memoryIndex] = binding; } foreach (var destination in instruction.Destinations) @@ -1370,7 +1527,7 @@ public sealed partial class ScalarValueGraph if (destination.Kind == Gen5OperandKind.VectorRegister) { state.ClearLanes(destination.Value); - state.WriteVector(destination.Value, _graph.Undefined(ScalarValueType.U32)); + state.WriteVector(destination.Value, vectorRead ?? _graph.Undefined(ScalarValueType.U32)); } else if (destination.Kind == Gen5OperandKind.ScalarRegister) { @@ -1411,6 +1568,13 @@ public sealed partial class ScalarValueGraph return _graph.Select(state.Scc, _graph.Constant(1u), _graph.Constant(0u)); } + // An aperture keeps its identity: a FLAT address built from it + // targets LDS or scratch, not the device-address page table. + if (Gen5InlineConstants.IsAperture(operand.Value)) + { + return _graph.MemoryAperture(operand.Value); + } + return Gen5InlineConstants.TryDecode(operand.Value, out var value) ? _graph.Constant(value) : _graph.Undefined(ScalarValueType.U32); @@ -1429,6 +1593,14 @@ public sealed partial class ScalarValueGraph return (state.Read(operand.Value), state.Read(operand.Value + 1)); } + if (operand.Kind == Gen5OperandKind.EncodedConstant && Gen5InlineConstants.IsAperture(operand.Value)) + { + // A 64-bit aperture read: the offset dword is a constant, the high + // dword carries the aperture. + return (_graph.Constant((uint)Gen5InlineConstants.DecodeAperture64(operand.Value)), + _graph.MemoryAperture(operand.Value)); + } + var low = Read(operand, state); var negative = operand.Kind == Gen5OperandKind.EncodedConstant && operand.Value is >= 193 and <= 208; return (low, _graph.Constant(negative ? uint.MaxValue : 0u)); @@ -1549,7 +1721,11 @@ public sealed partial class ScalarValueGraph ThreadBits.Remove(register - 1); if (register == ExecLow) { - Exec = _graph.Undefined(ScalarValueType.Bool); + // An all-or-nothing constant keeps the mask known, so s_cbranch_execz stays decidable. + Exec = value.IsConstant && Scalars[ExecHigh] is { IsConstant: true, ConstantU32: 0 } && + value.ConstantU32 is 0 or FullWaveMask + ? _graph.Constant(value.ConstantU32 != 0) + : _graph.Undefined(ScalarValueType.Bool); } else if (register == VccLow) { diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs index 0500c84..f934e5b 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarValueGraph.cs @@ -8,7 +8,10 @@ namespace SharpEmu.ShaderCompiler.Resources; // The graph values one memory access reads its descriptor from: the handles, the read // node of a scalar load, and the uniform vector offset of a global access. -public sealed record MemoryAccessBinding(ScalarValue? Handle, ScalarValue? SamplerHandle, ScalarValue? Read, ScalarValue? Offset = null); +// Active is the EXEC mask the access runs under: a lane outside it performs no access, +// so a lane-masked register write under the same mask contributes only its new value. +public sealed record MemoryAccessBinding(ScalarValue? Handle, ScalarValue? SamplerHandle, ScalarValue? Read, ScalarValue? Offset = null, + ScalarValue? Active = null); // The uniform value graph of one program: every value a descriptor can be assembled // from, symbolic in user data and in the shader base. @@ -61,12 +64,48 @@ public sealed partial class ScalarValueGraph // constants fold, so revisiting a block reproduces its values exactly. internal ScalarValue Constant(uint value) => Intern($"c32:{value}", () => ScalarValue.ConstantOf(value)); + internal ScalarValue MemoryAperture(uint operand) => Intern($"aperture:{operand}", () => ScalarValue.MemoryAperture(operand)); + internal ScalarValue Constant(ulong value) => Intern($"c64:{value}", () => ScalarValue.ConstantOf(value)); internal ScalarValue Constant(bool value) => Intern($"c1:{value}", () => ScalarValue.ConstantOf(value)); - // One undefined node per type keeps every value built over it stable across visits. - internal ScalarValue Undefined(ScalarValueType type) => Intern($"undef:{type}", () => ScalarValue.Undefined(type)); + /// + /// The instruction the builder is translating, for diagnostics only. + /// + /// + /// Undefined nodes are interned per type, so a single node stands for every + /// instruction the builder could not model and carries no origin of its own. + /// A resource plan that fails on an undefined dword therefore names no + /// culprit, and the builder has about twenty places that produce one. This + /// records which instructions actually gave up, so the failure points at an + /// opcode instead of a list of candidates. + /// + internal (uint Pc, string Opcode) BuilderInstruction { get; set; } + + private readonly Dictionary _undefinedOrigins = []; + + /// + /// The instruction an undefined value came from, when origins are tracked. + /// + internal bool TryGetUndefinedOrigin(ScalarValue value, out (uint Pc, string Opcode) origin) => + _undefinedOrigins.TryGetValue(value, out origin); + + // Undefined nodes are interned per instruction. Revisiting a block reaches + // the same instruction, so graph values stay stable while resource planning + // can distinguish a known safe fallback from an unrelated malformed input. + internal ScalarValue Undefined(ScalarValueType type) + { + if (BuilderInstruction.Opcode is null) + { + return Intern($"undef:{type}", () => ScalarValue.Undefined(type)); + } + + var instruction = BuilderInstruction; + var value = Intern($"undef:{type}:{instruction.Pc:X}", () => ScalarValue.Undefined(type)); + _undefinedOrigins.TryAdd(value, instruction); + return value; + } internal ScalarValue UserData(uint register) => Intern($"ud:{register}", () => ScalarValue.UserData(register)); @@ -115,11 +154,25 @@ public sealed partial class ScalarValueGraph internal ScalarValue Operation(ScalarOperation operation, ScalarValueType type, params ScalarValue[] operands) { + // Some ALU identities produce a deterministic value even when the other + // operand has no tracked provenance. This matters for descriptor setup code + // that masks an unused or hardware-defined register before using it. Keep + // these identities ahead of the general undefined propagation below. + if (TryFoldWithUndefined(operation, type, operands, out var undefinedFolded)) + { + return undefinedFolded; + } + foreach (var operand in operands) { if (operand.IsUndefined) { - return Undefined(type); + // Keep the operation shape when one input lacks provenance. The + // runtime validator will still reject the undefined leaf, but + // preserving the node lets later symbolic identities eliminate + // only the bits that are provably independent of that input and + // keeps diagnostics attached to the real instruction graph. + return Intern($"op:{operation}:{type}:{Ids(operands)}", () => ScalarValue.MakeOperation(operation, type, operands)); } } @@ -146,6 +199,53 @@ public sealed partial class ScalarValueGraph return Intern($"op:{operation}:{type}:{Ids(operands)}", () => ScalarValue.MakeOperation(operation, type, operands)); } + private bool TryFoldWithUndefined(ScalarOperation operation, ScalarValueType type, ScalarValue[] operands, out ScalarValue folded) + { + folded = null!; + + if (operands.Length != 2) + { + return false; + } + + if (operation == ScalarOperation.And32 && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.U32 && operand.ConstantU32 == 0)) + { + folded = Constant(0u); + return true; + } + + if (operation == ScalarOperation.And64 && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.U64 && operand.ConstantU64 == 0)) + { + folded = Constant(0ul); + return true; + } + + if (operation == ScalarOperation.Or32 && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.U32 && operand.ConstantU32 == uint.MaxValue)) + { + folded = Constant(uint.MaxValue); + return true; + } + + if (operation == ScalarOperation.LogicalAnd && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.Bool && !operand.ConstantBool)) + { + folded = Constant(false); + return true; + } + + if (operation == ScalarOperation.LogicalOr && + operands.Any(operand => operand.IsConstant && operand.Type == ScalarValueType.Bool && operand.ConstantBool)) + { + folded = Constant(true); + return true; + } + + return false; + } + private bool TryFold(ScalarOperation operation, ScalarValueType type, ScalarValue[] operands, out ScalarValue folded) { folded = null!; diff --git a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs index f2413bd..9154e70 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourceInfo.cs @@ -141,6 +141,18 @@ public sealed record StageInput(StageInputKind Kind, uint Location, uint Compone public sealed record StageOutput(StageOutputKind Kind, uint Index, uint Location, string DebugName); +// One bounded runtime V# table lowered to a contiguous run of native buffer candidates, +// selected through a key mapping in the flattened table. +public sealed class BufferCandidateTableInfo +{ + public uint FirstCandidate { get; set; } + public uint CandidateCount { get; set; } + public uint MappingOffset { get; set; } + public uint SearchIterations { get; set; } + + public BufferCandidateTableInfo Clone() => (BufferCandidateTableInfo)MemberwiseClone(); +} + // The dense resource tables of a program plus the facts the pipeline layout needs. public sealed class ShaderResourceInfo { @@ -154,6 +166,7 @@ public sealed class ShaderResourceInfo public List Images { get; set; } = []; public List Samplers { get; set; } = []; public List SampledPairs { get; set; } = []; + public List BufferCandidateTables { get; set; } = []; public List Inputs { get; set; } = []; public List Outputs { get; set; } = []; public byte[] VertexFetchComponents { get; set; } = new byte[32]; @@ -168,6 +181,7 @@ public sealed class ShaderResourceInfo Images = Images.Select(image => image.Clone()).ToList(), Samplers = Samplers.Select(sampler => sampler.Clone()).ToList(), SampledPairs = SampledPairs.Select(pair => pair.Clone()).ToList(), + BufferCandidateTables = BufferCandidateTables.Select(table => table.Clone()).ToList(), Inputs = [.. Inputs], Outputs = [.. Outputs], VertexFetchComponents = (byte[])VertexFetchComponents.Clone(), @@ -192,12 +206,20 @@ public sealed record IndirectImageSelector( public uint TableOffset { get; init; } public uint DynamicOffsetBase { get; init; } public uint KeyBound { get; init; } + public WaveIndexedImageSelector? WaveIndexed { get; init; } + // The key read's immediate offset. The hardware adds it after the 32-bit selector offset, without wrapping. public uint MaterialImmediate { get; init; } } public sealed record DirectImageCandidate(uint Offset, uint Source); +// A wave-uniform descriptor selector. The guest derives each descriptor key from +// a set bit in one scalar mask, through a compact global index table. Keeping this +// shape explicit lets the host materialize only those keys, rather than treating a +// lane value as an unknowable descriptor address. +public sealed record WaveIndexedImageSelector(uint MaskOffset, uint IndexTableOffset, uint IndexStride); + // The graph values one descriptor is assembled from, up to eight dwords. public sealed class DescriptorSource { diff --git a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs index 8a349a1..8089357 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ShaderResourcePlan.cs @@ -38,6 +38,7 @@ public sealed class ShaderResourcePlan public IReadOnlyList DynamicReads { get; private set; } = []; public IReadOnlyList CleanFlatSlots { get; private set; } = []; public IReadOnlyList IndirectImages { get; private set; } = []; + public IReadOnlyList BufferCandidateTables { get; private set; } = []; public bool RequiresSpecializationMemory { get; private set; } public ShaderResourceInfo Info { get; private set; } = new(); @@ -77,7 +78,8 @@ public sealed class ShaderResourcePlan access.Handle is null ? null : Rewrite(access.Handle), access.SamplerHandle is null ? null : Rewrite(access.SamplerHandle), access.Read is null ? null : (reads.Replacements.ContainsKey(access.Read) ? RewriteRead(access.Read) : Rewrite(access.Read)), - access.Offset is null ? null : Rewrite(access.Offset)); + access.Offset is null ? null : Rewrite(access.Offset), + access.Active is null ? null : Rewrite(access.Active)); } // Diagnostics observe rewritten values before descriptor validation. @@ -86,6 +88,7 @@ public sealed class ShaderResourcePlan plan.DescriptorSources = tracked.Sources; plan.Info = tracked.Info; plan.IndirectImages = tracked.IndirectImages; + plan.BufferCandidateTables = tracked.BufferCandidateTables; plan.DynamicReads = plan.DynamicReads.Where(read => !tracked.IndirectReads.Contains(read)).ToList(); var materialization = new List(); @@ -129,6 +132,8 @@ public sealed class ShaderResourcePlan else if (indirect.Dense) { plan.MarkCleanFlatSlots(plan.DescriptorSources[(int)indirect.HeapSource], cleanSlots); + if (indirect.WaveIndexed is not null) + plan.MarkHeapReadSlots(plan.DescriptorSources[(int)indirect.HeapSource], cleanSlots); } else { @@ -168,6 +173,21 @@ public sealed class ShaderResourcePlan // The flattened table the host fills per draw: table reads, then the written ranges. public int FlattenedTableReservedCount => TableReads.Count + WrittenRangeCount * WrittenRangeDwordCount; + // A wave-indexed table picks its keys from a mask word read off the same heap. + private void MarkHeapReadSlots(DescriptorSource heap, byte[] slots) + { + for (var slot = 0; slot < TableReads.Count && slot < slots.Length; slot++) + { + var value = TableReads[slot].Value; + if (value.Kind == ScalarValueKind.ScalarAddressWord && value.Operands.Length != 0 && + value.Operands[0].Kind == ScalarValueKind.AddressHandle && + value.Operands[0].Operands.SequenceEqual(heap.Dwords)) + { + slots[slot] = 1; + } + } + } + // Every flattened slot an indirect table's descriptor depends on must be read // through the clean reader, including the slots those reads depend on. private void MarkCleanFlatSlots(DescriptorSource source, byte[] slots) @@ -201,6 +221,13 @@ public sealed class ShaderResourcePlan } } - public bool ValidateRuntimeValue(ScalarValue value) => - new RuntimeValueValidator(Graph, UserDataBase, UserDataCount, TableReads.Count).Validate(value); + public bool ValidateRuntimeValue(ScalarValue value) => ValidateRuntimeValue(value, out _); + + public bool ValidateRuntimeValue(ScalarValue value, out bool controlDependent) + { + var validator = new RuntimeValueValidator(Graph, UserDataBase, UserDataCount, TableReads.Count); + var ok = validator.Validate(value); + controlDependent = validator.ControlDependent; + return ok; + } } diff --git a/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs b/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs index bb80cc6..b4ccb8c 100644 --- a/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs +++ b/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs @@ -54,6 +54,14 @@ public readonly record struct ShaderClipSpaceTransform( float HalfExtentX, float HalfExtentY); +// One bounded runtime V# table as the emitter sees it: a contiguous run of native buffer +// candidates plus the flattened key mapping that selects among them. +public readonly record struct BufferCandidateTableUse( + uint FirstCandidate, + uint CandidateCount, + uint MappingOffset, + uint SearchIterations); + // Everything an emitter needs to compile one permutation of a program: the decoded // program, its resource plan applied to one specialization, and the binding layout. public sealed class ShaderCompileRequest @@ -97,11 +105,26 @@ public sealed class ShaderCompileRequest } WrittenRangeSlotByMemoryIndex = writtenSlots; + + var candidateTables = new Dictionary(); + for (var index = 0; index < plan.BufferCandidateTables.Count && index < resources.Info.BufferCandidateTables.Count; index++) + { + var info = resources.Info.BufferCandidateTables[index]; + var use = new BufferCandidateTableUse(info.FirstCandidate, info.CandidateCount, info.MappingOffset, info.SearchIterations); + foreach (var memoryIndex in plan.BufferCandidateTables[index].MemoryIndices) + { + candidateTables[memoryIndex] = use; + } + } + + BufferCandidateTableByMemoryIndex = candidateTables; } - // The flattened table is bound when host reads, written ranges or indirect mappings fill it. + // The flattened table is bound when host reads, written ranges, indirect mappings or + // bounded buffer candidate mappings fill it. public static bool RequiresFlattenedTable(ShaderResourcePlan plan, SpecializedResourceInfo resources) => plan.TableReads.Count != 0 || plan.WrittenRangeCount != 0 || + plan.BufferCandidateTables.Count != 0 || resources.Info.Images.Any(image => image.IndirectSearchIterations != 0); public Gen5ShaderProgram Program { get; } @@ -127,12 +150,21 @@ public sealed class ShaderCompileRequest // Indirect image accesses: memory index → the memory index of the key read. public IReadOnlyDictionary IndirectRootByMemoryIndex { get; } + // Bounded runtime V# accesses: memory index → the candidate run and its key mapping. + public IReadOnlyDictionary BufferCandidateTableByMemoryIndex { get; } + // Written device-address accesses: memory index → the flattened slot of their range. public IReadOnlyDictionary WrittenRangeSlotByMemoryIndex { get; } public uint WaveSize { get; init; } = 32; public uint ScratchDwords { get; init; } public bool EnableGraphicsSubgroupOperations { get; init; } = true; + + // The device supports 64-bit integer atomics on workgroup memory + // (VkPhysicalDeviceFeatures.shaderSharedInt64Atomics). When set, the LDS + // 64-bit atomics are emitted as real 64-bit atomics instead of a pair of + // 32-bit ones, which is not atomic as a pair. + public bool SupportsSharedInt64Atomics { get; init; } public Gen5ComputeSystemRegisters? ComputeSystemRegisters { get; init; } public IReadOnlyList PixelOutputs { get; init; } = []; diff --git a/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs b/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs index 1c316bc..0d80a55 100644 --- a/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Agc/Gen5ShaderDecoderBoundaryTests.cs @@ -14,7 +14,7 @@ public sealed class Gen5ShaderDecoderBoundaryTests private const uint Export = 0xF8000000; private const uint Nop = 0xBF800000; private const uint EndPgm = 0xBF810000; - private const int MaximumInstructionCount = 16384; + private const int MaximumInstructionCount = 1024 * 1024 / sizeof(uint); [Fact] public void MissingAddress_IsRejectedWithoutReadingGuestMemory() @@ -89,7 +89,7 @@ public sealed class Gen5ShaderDecoderBoundaryTests Assert.False(decoded); Assert.Empty(program.Instructions); - Assert.Equal("unterminated", error); + Assert.StartsWith("unterminated", error, StringComparison.Ordinal); Assert.Equal(MaximumInstructionCount, memory.Reads.Count); Assert.All(memory.Reads, read => Assert.True(read.Succeeded)); Assert.Equal( @@ -117,6 +117,43 @@ public sealed class Gen5ShaderDecoderBoundaryTests Assert.Equal(words.Length, memory.Reads.Count); } + [Fact] + public void TrailingBackwardBranch_StopsBeforeMetadataTrailer() + { + // Astro Bot: an out-of-line loop block after S_ENDPGM ends with an + // S_BRANCH back into the loop, followed by the "sl00" metadata block. + const uint CBranchScc0OverEndPgm = 0xBF840001u; + const uint BranchBackToStart = 0xBF82FFFCu; + const uint MetadataWord = 0x5D000040u; + var memory = RecordingCpuMemory.FromWords( + ShaderAddress, + [CBranchScc0OverEndPgm, EndPgm, Nop, BranchBackToStart, MetadataWord]); + + var decoded = Decode(memory, ShaderAddress, out var program, out var error); + + Assert.True(decoded, error); + Assert.Equal(4, program.Instructions.Count); + Assert.Equal("SBranch", program.Instructions[^1].Opcode); + } + + [Fact] + public void DebuggerBranchPastEndPgm_DoesNotDecodeDebuggerStub() + { + // Astro Bot: S_CBRANCH_CDBGSYS jumps to a debugger stub after + // S_ENDPGM that loads descriptors from a devkit-only address. + const uint CBranchCdbgsysOverEndPgm = 0xBF970001u; + const uint StubMove = 0xBE8003FFu; + var memory = RecordingCpuMemory.FromWords( + ShaderAddress, + [CBranchCdbgsysOverEndPgm, EndPgm, StubMove]); + + var decoded = Decode(memory, ShaderAddress, out var program, out var error); + + Assert.True(decoded, error); + Assert.Equal(2, program.Instructions.Count); + Assert.Equal("SEndpgm", program.Instructions[^1].Opcode); + } + private static bool Decode( RecordingCpuMemory memory, ulong address, diff --git a/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs b/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs index 8f3e656..ca5b3a7 100644 --- a/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Ampr/AmprFileRegistryTests.cs @@ -89,6 +89,49 @@ public class AmprFileRegistryTests Assert.Equal(firstHost, resolved); } + [Fact] + public void AprResolve_UniquePath_ReturnsGuestPathHash() + { + AmprFileRegistry.ClearForTests(); + const string guestPath = "/app0/levels/m08_tutorial/a.cmsh"; + var host = Path.Combine(Path.GetTempPath(), "apr-unique.bin"); + + var id = AmprFileRegistry.RegisterAprResolvedPath(guestPath, host); + + Assert.Equal(AmprFileRegistry.ComputeFileId(guestPath), id); + Assert.Equal(id, AmprFileRegistry.RegisterAprResolvedPath(guestPath, host)); + Assert.True(AmprFileRegistry.TryGetHostPath(id, out var result)); + Assert.Equal(host, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AprResolve_CollidingPaths_ReadTheirOwnFiles(bool indexFirst) + { + AmprFileRegistry.ClearForTests(); + const string firstPath = "$/assets/bce5a816.bin"; + const string secondPath = "$/assets/1b9e7058.bin"; + var firstHost = Path.Combine(Path.GetTempPath(), "apr-first.bin"); + var secondHost = Path.Combine(Path.GetTempPath(), "apr-second.bin"); + if (indexFirst) + { + AmprFileRegistry.RegisterApp0RelativeForTests("assets/bce5a816.bin", firstHost); + AmprFileRegistry.RegisterApp0RelativeForTests("assets/1b9e7058.bin", secondHost); + } + + var firstId = AmprFileRegistry.RegisterAprResolvedPath(firstPath, firstHost); + var secondId = AmprFileRegistry.RegisterAprResolvedPath(secondPath, secondHost); + // A later alias publish of the partner must not re-poison either id. + AmprFileRegistry.RegisterApp0RelativeForTests("assets/1b9e7058.bin", secondHost); + + Assert.NotEqual(firstId, secondId); + Assert.True(AmprFileRegistry.TryGetHostPath(firstId, out var firstResult)); + Assert.True(AmprFileRegistry.TryGetHostPath(secondId, out var secondResult)); + Assert.Equal(firstHost, firstResult); + Assert.Equal(secondHost, secondResult); + } + [Fact] public void ComputeFileId_matches_utf8_fnv1a() { diff --git a/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs b/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs index 9b73ef2..e750a5e 100644 --- a/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Fiber/FiberExportsTests.cs @@ -15,6 +15,7 @@ namespace SharpEmu.Libs.Tests.Fiber; /// validation and layout behaviour of ; they do not /// exercise a live guest thread scheduler. /// +[Collection(FiberStateCollection.Name)] public sealed class FiberExportsTests { private const ulong Base = 0x3_0000_0000UL; diff --git a/tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs b/tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs new file mode 100644 index 0000000..510eb2b --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Fiber/FiberStateCollection.cs @@ -0,0 +1,18 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Xunit; + +namespace SharpEmu.Libs.Tests.Fiber; + +/// +/// FiberExports keeps its continuations, stack ranges and thread states in +/// static maps, and the tests reset them between cases. xUnit runs separate +/// classes in parallel, so every fiber test class has to share one collection +/// or a reset in one class wipes the state another is midway through using. +/// +[CollectionDefinition(Name)] +public sealed class FiberStateCollection +{ + public const string Name = "fiber-runtime-state"; +} diff --git a/tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs b/tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs new file mode 100644 index 0000000..605357d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Fiber/FiberSwitchLoopTests.cs @@ -0,0 +1,313 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; + +using SharpEmu.HLE; +using SharpEmu.Libs.Fiber; + +using Xunit; + +namespace SharpEmu.Libs.Tests.Fiber; + +/// +/// Continuation contract for a job-system style fiber loop: two fibers that +/// hand control back and forth many times from the same resume point. Each +/// resume has to restore exactly the state its own suspension captured, so a +/// stale or cross-fiber restore shows up here instead of as a null dereference +/// inside guest code several switches later. +/// +[Collection(FiberStateCollection.Name)] +public sealed class FiberSwitchLoopTests : IDisposable +{ + private const ulong Base = 0x3_0000_0000UL; + private const int RegionSize = 0x8000; + + private const ulong FiberA = Base + 0x0000; + private const ulong FiberB = Base + 0x0100; + private const ulong NameA = Base + 0x0300; + private const ulong NameB = Base + 0x0340; + private const ulong ArgSlotRoot = Base + 0x0380; + private const ulong ArgSlotA = Base + 0x0388; + private const ulong ArgSlotB = Base + 0x0390; + private const ulong ContextA = Base + 0x1000; + private const ulong ContextB = Base + 0x3000; + private const ulong ContextSize = 0x1000; + + private const ulong EntryA = 0x4_0000_1000UL; + private const ulong EntryB = 0x4_0000_2000UL; + private const ulong ArgOnInitializeA = 0xA11AUL; + private const ulong ArgOnInitializeB = 0xB22BUL; + + // The guest side of a job worker calls sceFiberSwitch from one place, so + // every suspension of a given fiber reports the same return address and the + // same resume stack. Only the payload registers differ between rounds. + private const ulong ResumeRipA = 0x8_0084_1FD1UL; + private const ulong ResumeRipB = 0x8_0084_1FD1UL; + private const ulong ResumeRspA = 0x2_0858_FF10UL; + private const ulong ResumeRspB = 0x2_085A_F3A0UL; + private const ulong ReturnSlotA = ResumeRspA - 0x10UL; + private const ulong ReturnSlotB = ResumeRspB - 0x10UL; + + private const ulong RootReturnRip = 0x8_0084_0000UL; + private const ulong RootResumeRsp = 0x2_0850_0000UL; + private const ulong RootReturnSlot = RootResumeRsp - 0x10UL; + + private const ulong ThreadHandle = 0x2E45_16AD_090UL; + + private const uint StateRun = 1; + private const uint StateIdle = 2; + + private readonly IGuestThreadScheduler? _previousScheduler; + private readonly ulong _previousThread; + private readonly ulong _previousFiber; + + public FiberSwitchLoopTests() + { + FiberExports.ResetRuntimeState(); + _previousScheduler = GuestThreadExecution.Scheduler; + GuestThreadExecution.Scheduler = new ContextTransferScheduler(); + _previousThread = GuestThreadExecution.EnterGuestThread(ThreadHandle); + _previousFiber = GuestThreadExecution.EnterFiber(0); + } + + public void Dispose() + { + GuestThreadExecution.EnterFiber(_previousFiber); + GuestThreadExecution.RestoreGuestThread(_previousThread); + GuestThreadExecution.Scheduler = _previousScheduler; + FiberExports.ResetRuntimeState(); + } + + [Fact] + public void RepeatedSwitch_RestoresEachFiberOwnResumePoint() + { + var memory = new FakeCpuMemory(Base, RegionSize); + var context = new CpuContext(memory, Generation.Gen5); + + InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); + InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); + + // The thread enters the fiber world through sceFiberRun. + GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); + context[CpuRegister.Rdi] = FiberA; + context[CpuRegister.Rsi] = 0x1000UL; + context[CpuRegister.Rdx] = ArgSlotRoot; + Assert.Equal(0, FiberExports.FiberRun(context)); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toA)); + Assert.Equal(EntryA, toA.Rip); + Assert.Equal(ArgOnInitializeA, toA.Rdi); + Assert.Equal(0x1000UL, toA.Rsi); + Assert.Equal(0UL, toA.Rax); + Assert.Equal(FiberA, GuestThreadExecution.CurrentFiberAddress); + Assert.Equal(StateRun, ReadUInt32(memory, FiberA + 4)); + + // Round 0 suspends A for the first time and starts B at its entry point. + var sentinelsA = Sentinels(0xA0); + Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, + ArgSlotA, argOnRun: 0xB000UL, sentinelsA); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toB)); + Assert.Equal(EntryB, toB.Rip); + Assert.Equal(ArgOnInitializeB, toB.Rdi); + Assert.Equal(0xB000UL, toB.Rsi); + + var expectedA = new FiberExpectation(ResumeRipA, ResumeRspA, ReturnSlotA, sentinelsA); + FiberExpectation? expectedB = null; + + // Eight full round trips over the same pair of resume points. + for (var round = 1; round <= 8; round++) + { + var sentinelsB = Sentinels((ulong)round << 32 | 0xB0); + var argToA = 0xA000UL + (ulong)round; + Switch(memory, context, from: FiberB, to: FiberA, ResumeRipB, ResumeRspB, ReturnSlotB, + ArgSlotB, argToA, sentinelsB); + expectedB = new FiberExpectation(ResumeRipB, ResumeRspB, ReturnSlotB, sentinelsB); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var resumedA)); + AssertResume(resumedA, expectedA, $"round {round}: fiber A"); + Assert.Equal(argToA, ReadUInt64(memory, ArgSlotA)); + Assert.Equal(StateRun, ReadUInt32(memory, FiberA + 4)); + Assert.Equal(StateIdle, ReadUInt32(memory, FiberB + 4)); + + var nextSentinelsA = Sentinels((ulong)round << 32 | 0xA0); + var argToB = 0xB000UL + (ulong)round; + Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, + ArgSlotA, argToB, nextSentinelsA); + expectedA = new FiberExpectation(ResumeRipA, ResumeRspA, ReturnSlotA, nextSentinelsA); + + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var resumedB)); + AssertResume(resumedB, expectedB, $"round {round}: fiber B"); + Assert.Equal(argToB, ReadUInt64(memory, ArgSlotB)); + Assert.Equal(StateRun, ReadUInt32(memory, FiberB + 4)); + Assert.Equal(StateIdle, ReadUInt32(memory, FiberA + 4)); + } + } + + [Fact] + public void RepeatedSwitch_KeepsResumeStackInsideTheOwningFiberContext() + { + var memory = new FakeCpuMemory(Base, RegionSize); + var context = new CpuContext(memory, Generation.Gen5); + + InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); + InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); + + GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); + context[CpuRegister.Rdi] = FiberA; + context[CpuRegister.Rsi] = 0; + context[CpuRegister.Rdx] = ArgSlotRoot; + Assert.Equal(0, FiberExports.FiberRun(context)); + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toA)); + + // A fresh fiber must start on its own context block, not on the stack of + // the thread that ran it. + Assert.InRange(toA.Rsp, ContextA, ContextA + ContextSize); + Assert.InRange(toA.ReturnSlotAddress, ContextA, ContextA + ContextSize); + + Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, + ArgSlotA, argOnRun: 0, Sentinels(0xA0)); + Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toB)); + + Assert.InRange(toB.Rsp, ContextB, ContextB + ContextSize); + Assert.InRange(toB.ReturnSlotAddress, ContextB, ContextB + ContextSize); + Assert.NotEqual(toA.Rsp, toB.Rsp); + } + + private static void Switch( + FakeCpuMemory memory, + CpuContext context, + ulong from, + ulong to, + ulong resumeRip, + ulong resumeRsp, + ulong returnSlot, + ulong argOnReturnAddress, + ulong argOnRun, + IReadOnlyDictionary sentinels) + { + Assert.Equal(from, GuestThreadExecution.CurrentFiberAddress); + + // The guest reaches sceFiberSwitch through the import trampoline, which + // is what publishes the resume point for the calling fiber. + GuestThreadExecution.EnterImportCallFrame(resumeRip, resumeRsp, returnSlot); + foreach (var (register, value) in sentinels) + { + context[register] = value; + } + + context[CpuRegister.Rdi] = to; + context[CpuRegister.Rsi] = argOnRun; + context[CpuRegister.Rdx] = argOnReturnAddress; + + Assert.Equal(0, FiberExports.FiberSwitch(context)); + Assert.Equal(to, GuestThreadExecution.CurrentFiberAddress); + _ = memory; + } + + private static void AssertResume(GuestCpuContinuation actual, FiberExpectation? expected, string because) + { + Assert.NotNull(expected); + Assert.Equal(expected.Rip, actual.Rip); + Assert.Equal(expected.Rsp, actual.Rsp); + Assert.Equal(expected.ReturnSlotAddress, actual.ReturnSlotAddress); + // sceFiberSwitch returns SCE_OK to the fiber it resumes. + Assert.Equal(0UL, actual.Rax); + + foreach (var (register, value) in expected.Sentinels) + { + var restored = register switch + { + CpuRegister.Rbx => actual.Rbx, + CpuRegister.Rbp => actual.Rbp, + CpuRegister.R12 => actual.R12, + CpuRegister.R13 => actual.R13, + CpuRegister.R14 => actual.R14, + CpuRegister.R15 => actual.R15, + _ => throw new InvalidOperationException($"Unexpected sentinel register {register}."), + }; + + Assert.True(value == restored, $"{because}: {register} was 0x{restored:X16}, expected 0x{value:X16}."); + } + } + + private static IReadOnlyDictionary Sentinels(ulong seed) => + new Dictionary + { + [CpuRegister.Rbx] = 0x1111_0000_0000_0000UL | seed, + [CpuRegister.Rbp] = 0x2222_0000_0000_0000UL | seed, + [CpuRegister.R12] = 0x3333_0000_0000_0000UL | seed, + [CpuRegister.R13] = 0x4444_0000_0000_0000UL | seed, + [CpuRegister.R14] = 0x5555_0000_0000_0000UL | seed, + [CpuRegister.R15] = 0x6666_0000_0000_0000UL | seed, + }; + + private static void InitializeFiber( + FakeCpuMemory memory, + CpuContext context, + ulong fiber, + ulong nameAddress, + ulong entry, + ulong argOnInitialize, + ulong contextAddress) + { + memory.WriteCString(nameAddress, $"F{fiber:X}"); + context[CpuRegister.Rdi] = fiber; + context[CpuRegister.Rsi] = nameAddress; + context[CpuRegister.Rdx] = entry; + context[CpuRegister.Rcx] = argOnInitialize; + context[CpuRegister.R8] = contextAddress; + context[CpuRegister.R9] = ContextSize; + Assert.Equal(0, FiberExports.FiberInitialize(context)); + } + + private static uint ReadUInt32(FakeCpuMemory memory, ulong address) + { + Span buffer = stackalloc byte[sizeof(uint)]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); + } + + private static ulong ReadUInt64(FakeCpuMemory memory, ulong address) + { + Span buffer = stackalloc byte[sizeof(ulong)]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt64LittleEndian(buffer); + } + + private sealed record FiberExpectation( + ulong Rip, + ulong Rsp, + ulong ReturnSlotAddress, + IReadOnlyDictionary Sentinels); + + private sealed class ContextTransferScheduler : IGuestThreadScheduler + { + public bool SupportsGuestContextTransfer => true; + + public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context) { } + public bool TryStartThread(CpuContext creatorContext, GuestThreadStartRequest request, out string? error) => throw new NotSupportedException(); + public bool TryJoinThread(CpuContext callerContext, ulong threadHandle, out ulong returnValue, out string? error) => throw new NotSupportedException(); + public void Pump(CpuContext callerContext, string reason) { } + public int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue) => 0; + public bool HasPendingGuestExceptionForCurrentThread() => false; + public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) => throw new NotSupportedException(); + public bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask) => throw new NotSupportedException(); + public IReadOnlyList SnapshotThreads() => Array.Empty(); + public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, + ulong stackAddress, ulong stackSize, string reason, out string? error) => throw new NotSupportedException(); + public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, + ulong arg2, ulong stackAddress, ulong stackSize, string reason, out ulong returnValue, + out string? error) => throw new NotSupportedException(); + public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, + ulong arg2, ulong arg3, ulong stackAddress, ulong stackSize, string reason, out ulong returnValue, + out string? error) => throw new NotSupportedException(); + public bool TryCallGuestContinuation(CpuContext callerContext, GuestCpuContinuation continuation, string reason, + out string? error) => throw new NotSupportedException(); + public bool TryRaiseGuestException(CpuContext callerContext, ulong threadHandle, ulong handler, int exceptionType, + out string? error) => throw new NotSupportedException(); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs index fdfe0ec..fdc234a 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs @@ -322,7 +322,10 @@ public sealed class GpuCommandInterpreterLabelTests runner.Run(StreamRunner.CustomPacket(Nop, 0, 0x6875_0781, StreamRunner.Low(Label), StreamRunner.High(Label), 0x9A, 0x04, 0x38)); Assert.Equal(new[] { "prepare_flip 7 2 1 68", "eop FlipWithInterruptWriteBack32", "flush" }, runner.Host.Calls); Assert.Contains("flip event type is unknown", runner.RunExpectingFatal(StreamRunner.CustomPacket(Nop, 0, 0x6875_0781, 0, 0, 0, 0x28, 0x38)).Message); - Assert.Contains("marker is unknown", runner.RunExpectingFatal(StreamRunner.CustomPacket(Nop, 0, 0x6875_0001)).Message); + // Marker ids with no side effect (Demon's Souls emits 0x6EC and 0x80C) are consumed, not fatal. + runner.Host.Calls.Clear(); + runner.Run(StreamRunner.CustomPacket(Nop, 0, 0x6875_06EC), StreamRunner.CustomPacket(Nop, 0, 0x6875_080C)); + Assert.Empty(runner.Host.Calls); } [Fact] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs index dfa092f..4e540e3 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestPixelFormatsTests.cs @@ -14,6 +14,7 @@ public sealed class GuestPixelFormatsTests { Assert.Equal(4u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits8_8_8_8UNorm)); Assert.Equal(8u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits16_16_16_16Float)); + Assert.Equal(2u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits8_8UScaled)); Assert.Equal(0u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Bits8SNorm)); Assert.Equal(0u, GuestPixelFormats.BytesPerElement(GuestPixelFormat.Invalid)); Assert.Equal(0u, GuestPixelFormats.BytesPerElement((GuestPixelFormat)200)); @@ -35,10 +36,13 @@ public sealed class GuestPixelFormatsTests Assert.Equal(TextureNumericClass.Uint, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8_8_8UInt)); Assert.Equal(TextureNumericClass.Sint, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8_8_8SInt)); Assert.Equal(TextureNumericClass.Float, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8_8_8UNorm)); + Assert.Equal(TextureNumericClass.Float, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8_8UScaled)); Assert.Equal(TextureNumericClass.Unsupported, GuestPixelFormats.SampledNumericClass(GuestPixelFormat.Bits8SNorm)); Assert.Equal(GuestPixelFormat.Bits32UInt, GuestPixelFormats.RemapTextureFormat(GuestPixelFormat.Bits11_11_10UInt)); Assert.Equal(GuestPixelFormat.Bc7Srgb, GuestPixelFormats.RemapTextureFormat(GuestPixelFormat.Bc7Srgb)); Assert.Equal(Format.R8G8B8A8Unorm, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits8_8_8_8UNorm)); + Assert.Equal(Format.R8G8Uscaled, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits8_8UScaled)); + Assert.Equal(Format.B10G11R11UfloatPack32, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits11_11_10UNorm)); Assert.Equal(Format.R8G8B8A8Srgb, GuestPixelFormats.HostFormat(GuestPixelFormat.Bits8_8_8_8Srgb)); Assert.Equal(Format.BC1RgbaUnormBlock, GuestPixelFormats.HostFormat(GuestPixelFormat.Bc1UNorm)); Assert.Equal(Format.Undefined, GuestPixelFormats.HostFormat(GuestPixelFormat.Invalid)); diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs index 4678cf4..ac84d0c 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageDescriptionTests.cs @@ -24,6 +24,7 @@ public sealed class ImageDescriptionTests description.Resources = new SubresourceCount(levels, layers); description.Pitch = width; description.BytesPerBlock = 4; + description.MipLayout[0] = new MipLevelLayout { Size = size, Pitch = width, Height = height }; return description; } @@ -70,10 +71,24 @@ public sealed class ImageDescriptionTests volume.Type = GuestImageType.Color3D; volume.Extent = new Extent3D(32, 32, 8); volume.MipLayout[0] = new MipLevelLayout { Offset = 0, Size = 32768, Pitch = 32, Height = 32 }; - Assert.Equal(0, Color(Base, 32768, 32, 32, layers: 8).FindMatchingMipLevel(volume)); + Assert.Equal(-1, Color(Base, 32768, 32, 32, layers: 8).FindMatchingMipLevel(volume)); Assert.Equal(-1, Color(Base, 32768, 32, 32, layers: 4).FindMatchingMipLevel(volume)); } + [Fact] + public void FindMatchingArraySlice_UsesTheFullMipChainStrideForEachArrayLayer() + { + const ulong sliceStride = 21504; + var container = Color(Base, 2 * sliceStride, 64, 64, levels: 3, layers: 2); + container.MipLayout[0] = new MipLevelLayout { Offset = 0, Size = 2 * 16384, Pitch = 64, Height = 64 }; + container.MipLayout[1] = new MipLevelLayout { Offset = 16384, Size = 2 * 4096, Pitch = 32, Height = 32 }; + container.MipLayout[2] = new MipLevelLayout { Offset = 20480, Size = 2 * 1024, Pitch = 16, Height = 16 }; + + var secondLayerMip = Color(Base + sliceStride + 16384, 4096, 32, 32); + Assert.Equal(1, secondLayerMip.FindMatchingMipLevel(container)); + Assert.Equal(1, secondLayerMip.FindMatchingArraySlice(container, 1)); + } + [Fact] public void DepthRules_ResolveAttachmentAndTransferFormats() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs index f00fefc..31b1a2e 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs @@ -126,6 +126,25 @@ public sealed class ImageRequestBuildersTests : IClassFixture } [Fact] - public void GetSampler_CachesByTheFourWords() + public void GetSampler_CachesByTheFourWordsAndTheViewClass() { if (!GatePrerequisites.Ready(_vulkan, samplerAnisotropy: true)) return; using var fatal = new FatalScope(); using var store = new SamplerStore(_vulkan.DeviceInfo); var linear = Words(0, 0, (1u << 22) | (1u << 20)); - var first = store.GetSampler(linear); + var first = store.GetSampler(linear, false); Assert.NotEqual(0UL, first.Handle); - Assert.Equal(first, store.GetSampler(linear)); + Assert.Equal(first, store.GetSampler(linear, false)); Assert.Equal(1, store.Count); var anisotropic = Words(2u << 9, 0, (3u << 22) | (3u << 20) | (2u << 26)); - Assert.NotEqual(first, store.GetSampler(anisotropic)); + Assert.NotEqual(first, store.GetSampler(anisotropic, false)); var unnormalized = Words(1u << 15, 0, (1u << 22) | (1u << 20) | (2u << 26)); - Assert.NotEqual(first, store.GetSampler(unnormalized)); + Assert.NotEqual(first, store.GetSampler(unnormalized, false)); var tableBorder = Words(4, 0, 0, (3u << 30) | 5); - Assert.NotEqual(0UL, store.GetSampler(tableBorder).Handle); + Assert.NotEqual(0UL, store.GetSampler(tableBorder, false).Handle); Assert.Equal(4, store.Count); - Assert.Throws(() => store.GetSampler(Words(5u << 9, 0, (3u << 22) | (3u << 20)))); + // An integer view gets its own sampler: integer border, no filtering. + Assert.NotEqual(first, store.GetSampler(linear, true)); + Assert.Equal(5, store.Count); + + Assert.Throws(() => store.GetSampler(Words(5u << 9, 0, (3u << 22) | (3u << 20)), false)); Assert.Contains(fatal.Messages, message => message.Contains("anisotropy ratio is unknown")); - Assert.Equal(4, store.Count); + Assert.Equal(5, store.Count); _vulkan.AssertNoValidationMessages(); } } diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs index b409f47..0feefa2 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/PipelineTestSupport.cs @@ -44,6 +44,8 @@ internal sealed class FakePipelineHost(ICpuMemory memory) : IShaderPipelineHost public bool GraphicsSubgroupOperationsEnabled => true; + public bool SharedInt64AtomicsEnabled => false; + public RenderHostLimits Limits => new(16384, 16384, 16384, 16384); public bool TryResolveColorOutput(uint dataFormat, uint numberType, uint componentSwap, out Gen5PixelOutputKind outputKind, out Gen5ColorComponentMapping componentMapping) diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs index 08021c8..3b9262c 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorDrawTests.cs @@ -165,12 +165,14 @@ public sealed class RenderExecutorDrawTests : IDisposable } [Theory] - [InlineData(0u)] - [InlineData(1u)] - public void PrimitiveShaderVertexPath_WithDefaultGeometryStateDraws(uint subgroupControl) + [InlineData(0x02002000u, 0u)] + [InlineData(0x02002000u, 1u)] + [InlineData(0x00002000u, 0u)] + [InlineData(0x00002000u, 1u)] + public void PrimitiveShaderVertexPath_WithDefaultGeometryStateDraws(uint stageMask, uint subgroupControl) { var banks = Banks(); - banks.Context.ShaderStages = 0x02002000; + banks.Context.ShaderStages = stageMask; banks.Shader.Vertex.GeometryAddress = 0x3000; banks.Context.ShaderInterface.PrimitiveShaderSubgroupControl = subgroupControl; banks.Context.ShaderInterface.MaxOutputPerSubgroup = 0x40; @@ -179,6 +181,21 @@ public sealed class RenderExecutorDrawTests : IDisposable Assert.Contains(_host.Calls, c => c.StartsWith("draw_indexed", StringComparison.Ordinal)); } + [Fact] + public void PrimitiveShaderVertexPath_WithFixedNggStateAndNoGeometryShaderDraws() + { + var banks = Banks(); + banks.Context.ShaderStages = 0x00002030; + banks.Context.ShaderInterface.PrimitiveShaderSubgroupControl = 1; + banks.Context.ShaderInterface.MaxOutputPerSubgroup = 0xC0; + banks.Context.ShaderInterface.GeometryMaxVerticesOut = 3; + banks.Context.ShaderInterface.GeometryOutputPrimitiveType = 2; + + _executor.DrawIndexed(1, banks, Indexed(3)); + + Assert.Contains(_host.Calls, c => c.StartsWith("draw_indexed", StringComparison.Ordinal)); + } + [Theory] [InlineData(1u, PrimitiveTopology.PointList)] [InlineData(2u, PrimitiveTopology.LineList)] diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs index da3478a..b92d680 100644 --- a/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelBackedMemoryTests.cs @@ -297,6 +297,22 @@ public sealed class KernelBackedMemoryTests Assert.Equal((second, second + 0x10000), test.Query(second)); } + [Fact] + public void LargeVirtualReservationFallsBackToSparseGuestRange() + { + using var test = new BackedKernelMemory(); + const ulong size = 0x8000000000; + test.Host.FailReserveHoleWhen = (_, requestedSize) => requestedSize >= size; + + var address = test.Reserve(size); + Assert.NotEqual(0UL, address); + Assert.Equal((address, address + size), test.Query(address)); + + test.Allocate(0, 0x4000); + Assert.Equal(address, test.Map(0, 0x4000, address)); + Assert.Equal((address, address + 0x4000), test.Query(address)); + } + [Fact] public void AddressSearchUsesTheFullReservationExtent() { diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs new file mode 100644 index 0000000..9a18d84 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelOperationModeTests.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Kernel; +using Xunit; + +namespace SharpEmu.Libs.Tests.Kernel; + +public sealed class KernelOperationModeTests +{ + private const ulong BaseAddress = 0x1_0000; + private const ulong ModeAddress = BaseAddress + 0x10; + private const ulong SubmodeAddress = BaseAddress + 0x20; + + private static uint Read(FakeCpuMemory memory, ulong address) + { + Span bytes = stackalloc byte[sizeof(uint)]; + Assert.True(memory.TryRead(address, bytes)); + return BinaryPrimitives.ReadUInt32LittleEndian(bytes); + } + + [Fact] + public void ReportsThePs5BaseConsoleWithNoSubmode() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + ctx[CpuRegister.Rdi] = ModeAddress; + ctx[CpuRegister.Rsi] = SubmodeAddress; + + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelExports.KernelGetOperationMode(ctx)); + Assert.Equal(2u, Read(memory, ModeAddress)); + Assert.Equal(0u, Read(memory, SubmodeAddress)); + } + + [Fact] + public void NullOutputPointersAreSkipped() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + ctx[CpuRegister.Rdi] = 0; + ctx[CpuRegister.Rsi] = 0; + + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelExports.KernelGetOperationMode(ctx)); + } + + [Fact] + public void UnmappedOutputPointerFaults() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + ctx[CpuRegister.Rdi] = 0xDEAD_0000; + ctx[CpuRegister.Rsi] = SubmodeAddress; + + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, KernelExports.KernelGetOperationMode(ctx)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs b/tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs new file mode 100644 index 0000000..a281d2f --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Loader/GuestRedZoneEnclosingSpanTests.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Loader; + +using Xunit; + +namespace SharpEmu.Libs.Tests.Loader; + +/// +/// The forward span search anchors at the faulting instruction, so it gives up +/// whenever the next instruction is a branch target, touches RSP, or changes +/// control flow. These are the three shapes that actually occur around the +/// faulting sites of a red-zone function in a shipped title; the enclosing +/// search has to take its bytes from before the faulting instruction instead, +/// and must never let the RSP shift cover an RSP-relative access. +/// +public sealed class GuestRedZoneEnclosingSpanTests +{ + private const ulong Base = 0x8_0000_0000UL; + + [Fact] + public void TakesBytesFromBeforeWhenTheNextInstructionIsABranchTarget() + { + // jne +8 -> makes the instruction after the site a target + // shr r11, 12 -> 4 bytes, no memory operand + // and r10d,[rcx+8] -> 4 bytes, faultable: the site + // mov rax,[rcx] -> branch target, must stay untouched + byte[] code = + [ + 0x75, 0x08, + 0x49, 0xC1, 0xEB, 0x0C, + 0x44, 0x23, 0x51, 0x08, + 0x48, 0x8B, 0x01, + ]; + + Assert.True(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 6, out var address, out var length, out var coreStart, out var coreCount)); + + Assert.Equal(Base + 2, address); + Assert.Equal(8, length); + // The span ends exactly at the branch target, which is never rewritten. + Assert.Equal(Base + 10, address + (ulong)length); + Assert.Equal(1, coreStart); + Assert.Equal(1, coreCount); + } + + [Fact] + public void KeepsAnRspRelativeInstructionOutsideTheShiftedCore() + { + // jne +8 + // mov rax,[rsp-0x10] -> reads the red zone; must run before the shift + // mov [rax+0x18],ebp -> 3 bytes, faultable: the site + // pop rbx -> branch target and stack dependent + byte[] code = + [ + 0x75, 0x08, + 0x48, 0x8B, 0x44, 0x24, 0xF0, + 0x89, 0x68, 0x18, + 0x5B, + ]; + + Assert.True(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 7, out var address, out var length, out var coreStart, out var coreCount)); + + Assert.Equal(Base + 2, address); + Assert.Equal(8, length); + // coreStart == 1 is what keeps the shift off the [rsp-0x10] load: shifting + // it would move the access 128 bytes and silently read the wrong slot. + Assert.Equal(1, coreStart); + Assert.Equal(1, coreCount); + } + + [Fact] + public void BracketsEveryFaultableInstructionOfTheSpan() + { + // mov eax,[rcx+8] -> faultable, absorbed by the span + // test [rcx+8],eax -> faultable: the site + // je +2 -> control flow, cannot be absorbed + byte[] code = + [ + 0x8B, 0x41, 0x08, + 0x85, 0x41, 0x08, + 0x74, 0x02, + ]; + + Assert.True(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 3, out var address, out var length, out var coreStart, out var coreCount)); + + Assert.Equal(Base, address); + Assert.Equal(6, length); + // A stolen instruction that can fault too has to sit inside the shift, + // otherwise it recreates the very corruption this pass prevents. + Assert.Equal(0, coreStart); + Assert.Equal(2, coreCount); + } + + [Fact] + public void RefusesWhenTheFaultingInstructionIsItselfABranchTarget() + { + // jne +6 lands on the faulting instruction, so a jump placed before it + // would be entered in the middle. + byte[] code = + [ + 0x75, 0x04, + 0x49, 0xC1, 0xEB, 0x0C, + 0x44, 0x23, 0x51, 0x08, + 0x48, 0x8B, 0x01, + ]; + + Assert.False(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 6, out _, out _, out _, out _)); + } + + [Fact] + public void RefusesWhenControlFlowSitsBetweenTheStartAndTheSite() + { + // A conditional branch cannot be relocated as part of a span, and the + // bytes before the site are not otherwise sufficient. + byte[] code = + [ + 0x74, 0x02, + 0x44, 0x23, 0x51, 0x08, + 0x48, 0x8B, 0x01, + ]; + + Assert.False(GuestRedZonePatcher.TryBuildEnclosingSpan( + code, Base, Base + 2, out _, out _, out _, out _)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs index 7e921c1..1bc9ea7 100644 --- a/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs +++ b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs @@ -46,6 +46,19 @@ public sealed class HostMovieBridgeTests : IDisposable Assert.True(HostMovieBridge.TryReadBinkInfo(path, out _)); } + [Theory] + // Demon's Souls history_of_the_world.bk2 (171 s) and credits (756 s). + [InlineData(5_125u, 30_000u, 1_001u, 231)] + [InlineData(22_685u, 30u, 1u, 816)] + [InlineData(255u, 30u, 1u, 90)] + public void PlaybackWatchdogOutlastsTheWholeMovie(uint frames, uint numerator, uint denominator, int expectedSeconds) + { + var timeout = HostMovieBridge.GetPlaybackWatchdogTimeout( + new HostMovieBridge.Bink2MovieInfo(1920, 1080, frames, numerator, denominator)); + + Assert.Equal(expectedSeconds, (int)timeout.TotalSeconds); + } + [Fact] public void HeaderRejectsMissingFrameRateDenominator() { diff --git a/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs index 21b181c..294c8f0 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs @@ -153,6 +153,41 @@ public sealed class GuestPageTrackerTests : IDisposable Release(address, Page * 2); } + // The lock-free block summary must agree with the precise page masks through every + // CPU-dirty transition: upload, explicit mark, write-fault invalidation and GPU writes. + [NativePageProtectionFact] + public void CpuDirtySummaryFollowsEveryTransitionOfTheBlock() + { + var address = AllocateAligned(Region, Region); + Assert.True(_tracker.HasCpuDirtyPages(address, Region)); + + _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); + Assert.False(_tracker.HasCpuDirtyPages(address, Region)); + Assert.False(_tracker.HasCpuDirtyPages(address + Region / 2, Page)); + + _tracker.MarkCpuDirtyPages(address + Page * 3, 8); + Assert.True(_tracker.HasCpuDirtyPages(address, Region)); + Assert.True(_tracker.HasCpuDirtyPages(address + Page * 3, Page)); + // A clean page inside a block that has a dirty page takes the precise path. + Assert.False(_tracker.HasCpuDirtyPages(address + Page * 9, Page)); + + _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); + Assert.False(_tracker.HasCpuDirtyPages(address, Region)); + + Assert.True(_tracker.InvalidateRegion(address + Page * 5, 4, () => { })); + Assert.True(_tracker.HasCpuDirtyPages(address + Page * 5, Page)); + + _tracker.ForEachUploadRange(address, Region, false, NoRange, NoUpload, preserveCpuWriteHotPages: false); + _tracker.ForEachUploadRange(address + Page, Page, true, NoRange, NoUpload); + Assert.False(_tracker.HasCpuDirtyPages(address, Region)); + Assert.True(_tracker.HasGpuDirtyPages(address + Page, Page)); + _tracker.ForEachDownloadRange(address + Page, Page, clear: true, null, NoRange); + + _tracker.UntrackMemory(address, Region); + Assert.True(_tracker.HasCpuDirtyPages(address, Region)); + Release(address, Region); + } + [NativePageProtectionFact] public void RangeInvalidationBatchesOwnershipTransferAcrossRegions() { diff --git a/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs b/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs index 834a4f3..16923dc 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/GuestMemory/FailingHostViews.cs @@ -41,6 +41,8 @@ internal sealed class FailingHostViews : IHostViewMemory public Action? BeforeReserveHole { get; set; } + public Func? FailReserveHoleWhen { get; set; } + public Action? BeforeChangeAccess { get; set; } public ulong PageSize => _inner.PageSize; @@ -69,6 +71,8 @@ internal sealed class FailingHostViews : IHostViewMemory public ulong ReserveHole(ulong address, ulong size) { BeforeReserveHole?.Invoke(address, size); + if (FailReserveHoleWhen?.Invoke(address, size) == true) + return 0; return ShouldFail(Op.ReserveHole) ? 0 : _inner.ReserveHole(address, size); } diff --git a/tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs b/tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs new file mode 100644 index 0000000..7cf2556 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Np/NpEntitlementAccessExportsTests.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Np; +using Xunit; + +namespace SharpEmu.Libs.Tests.Np; + +public sealed class NpEntitlementAccessExportsTests +{ + private const ulong MemoryBase = 0x1_0000_0000; + + [Theory] + [InlineData("GHOST2APP0000000")] + [InlineData("GHOST2BASE000000")] + public void GhostOfYoteiMainEntitlement_IsReportedAsInstalled(string label) + { + var memory = new FakeCpuMemory(MemoryBase, 0x1_000); + var context = new CpuContext(memory, Generation.Gen5); + var labelAddress = MemoryBase + 0x100; + var infoAddress = MemoryBase + 0x200; + memory.WriteCString(labelAddress, label); + context[CpuRegister.Rsi] = labelAddress; + context[CpuRegister.Rdx] = infoAddress; + + Assert.Equal(0, NpEntitlementAccessExports.NpEntitlementAccessGetAddcontEntitlementInfo(context)); + + Span info = stackalloc byte[0x1C]; + Assert.True(memory.TryRead(infoAddress, info)); + Assert.Equal(label, ReadCString(info[..17])); + Assert.Equal(3U, BinaryPrimitives.ReadUInt32LittleEndian(info[20..])); + Assert.Equal(4U, BinaryPrimitives.ReadUInt32LittleEndian(info[24..])); + } + + [Fact] + public void GhostOfYoteiOptionalEntitlement_RemainsUnowned() + { + var memory = new FakeCpuMemory(MemoryBase, 0x1_000); + var context = new CpuContext(memory, Generation.Gen5); + var labelAddress = MemoryBase + 0x100; + var infoAddress = MemoryBase + 0x200; + memory.WriteCString(labelAddress, "GHOST2DDE0000000"); + context[CpuRegister.Rsi] = labelAddress; + context[CpuRegister.Rdx] = infoAddress; + + Assert.Equal( + unchecked((int)0x817D0007), + NpEntitlementAccessExports.NpEntitlementAccessGetAddcontEntitlementInfo(context)); + } + + private static string ReadCString(ReadOnlySpan value) + { + var length = value.IndexOf((byte)0); + return System.Text.Encoding.ASCII.GetString(value[..(length < 0 ? value.Length : length)]); + } +} diff --git a/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs b/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs index 5c1b218..0ab0652 100644 --- a/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs +++ b/tests/SharpEmu.Libs.Tests/ShaderCompiler/Gen5ShaderTranslatorTests.cs @@ -12,6 +12,106 @@ public sealed class Gen5ShaderTranslatorTests { private const ulong ProgramAddress = 0x1_0000_0000; + [Fact] + public void Vop2OpcodeZeroDecodesAsNoop() + { + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, 0x00000000u, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Vop2, instruction.Encoding); + Assert.Equal("VNop", instruction.Opcode); + Assert.Equal(0u, instruction.Pc); + } + + [Fact] + public void Vop2FmamkF16ConsumesLiteralDword() + { + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, 0x6F104E10u, 0x3C003C00u, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Vop2, instruction.Encoding); + Assert.Equal("VFmaMkF16", instruction.Opcode); + Assert.Equal(2, instruction.Words.Count); + Assert.Equal(3, instruction.Sources.Count); + Assert.Equal(Gen5OperandKind.LiteralConstant, instruction.Sources[1].Kind); + Assert.Equal(0x3C003C00u, instruction.Sources[1].Value); + } + + [Fact] + public void Sop2ScalarFloatSubtractDecodes() + { + const uint instructionWord = + (2u << 30) | (0x41u << 23) | (3u << 16) | (2u << 8) | 1u; + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, instructionWord, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Sop2, instruction.Encoding); + Assert.Equal("SSubF32", instruction.Opcode); + Assert.Equal(3u, instruction.Destinations[0].Value); + } + + [Fact] + public void ScalarScratchStoreDwordx2DecodesAsStore() + { + var memory = new FakeCpuMemory(ProgramAddress, 0x100); + WriteWords(memory, ProgramAddress, 0xC59B2040u, 0xBF810000u); + + var context = new CpuContext(memory, Generation.Gen5); + Assert.True( + Gen5ShaderTranslator.TryDecodeProgram( + context, + ProgramAddress, + out var program, + out var error), + error); + + var instruction = program.Instructions[0]; + Assert.Equal(Gen5ShaderEncoding.Smrd, instruction.Encoding); + Assert.Equal("ScratchStoreDwordx2", instruction.Opcode); + Assert.Empty(instruction.Destinations); + Assert.Equal(4, instruction.Sources.Count); + Assert.Equal(32u, instruction.Sources[0].Value); + Assert.Equal(54u, instruction.Sources[1].Value); + Assert.Equal(55u, instruction.Sources[2].Value); + Assert.Equal(64u, instruction.Sources[3].Value); + var control = Assert.IsType(instruction.Control); + Assert.Equal(2u, control.DwordCount); + Assert.Equal(32u, control.ScalarAddress); + Assert.Equal(54u, control.SourceVectorRegister); + Assert.Equal(64u, control.DynamicOffsetRegister); + Assert.True(control.SourceIsScalar); + } + [Theory] [InlineData(0xD7600005u, 5u)] [InlineData(0xD7600065u, 101u)] diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs new file mode 100644 index 0000000..aa88fbf --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanStorageFeedbackTargetResolverTests.cs @@ -0,0 +1,81 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu; +using SharpEmu.Libs.VideoOut; +using Xunit; + +namespace SharpEmu.Libs.Tests.VideoOut; + +public sealed class VulkanStorageFeedbackTargetResolverTests +{ + private const ulong TargetAddress = 0x1044_500000; + + [Fact] + public void ZeroWriteSingleTargetAliasUsesCompatibilityAttachment() + { + var target = Target(); + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [target], [Texture(TargetAddress, true)], [Blend(0)], + out var resolved, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.True(supported); + Assert.True(usedCompatibilityAttachment); + Assert.Equal(0UL, unsupportedAddress); + Assert.Equal(0UL, resolved[0].Address); + Assert.Equal(target.Width, resolved[0].Width); + Assert.Equal(target.Height, resolved[0].Height); + Assert.Equal(target.Format, resolved[0].Format); + Assert.Equal(target.NumberType, resolved[0].NumberType); + } + + [Fact] + public void WritableColorAliasRemainsUnsupported() + { + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [Target()], [Texture(TargetAddress, true)], [Blend(0xF)], + out var resolved, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.False(supported); + Assert.False(usedCompatibilityAttachment); + Assert.Equal(TargetAddress, unsupportedAddress); + Assert.Equal(TargetAddress, resolved[0].Address); + } + + [Fact] + public void NonStorageAliasDoesNotNeedCompatibilityAttachment() + { + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [Target()], [Texture(TargetAddress, false)], [Blend(0xF)], + out var resolved, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.True(supported); + Assert.False(usedCompatibilityAttachment); + Assert.Equal(0UL, unsupportedAddress); + Assert.Equal(TargetAddress, resolved[0].Address); + } + + [Fact] + public void AliasedMrtRemainsUnsupported() + { + const ulong otherAddress = 0x1042_F00000; + var supported = VulkanStorageFeedbackTargetResolver.TryResolve( + [Target(), Target(otherAddress)], + [Texture(TargetAddress, true)], [Blend(0), Blend(0)], + out _, out var usedCompatibilityAttachment, out var unsupportedAddress); + + Assert.False(supported); + Assert.False(usedCompatibilityAttachment); + Assert.Equal(TargetAddress, unsupportedAddress); + } + + private static GuestRenderTarget Target(ulong address = TargetAddress) => + new(address, 2048, 2048, Format: 10, NumberType: 6); + + private static GuestDrawTexture Texture(ulong address, bool isStorage) => + new(address, 2048, 2048, Format: 10, NumberType: 0, RgbaPixels: [], + IsFallback: false, IsStorage: isStorage); + + private static GuestBlendState Blend(uint writeMask) => + GuestBlendState.Default with { WriteMask = writeMask }; +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs index faf10c7..7d4fb4a 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs @@ -50,6 +50,55 @@ public sealed class Gen5Float16ArithmeticTests Assert.DoesNotContain((ushort)SpirvCapability.Float16, ReadCapabilities(shader.Spirv)); } + [Theory] + [InlineData(0x54u, "VRcpF16")] + [InlineData(0x55u, "VSqrtF16")] + [InlineData(0x57u, "VLogF16")] + [InlineData(0x58u, "VExpF16")] + [InlineData(0x5Bu, "VFloorF16")] + [InlineData(0x5Cu, "VCeilF16")] + [InlineData(0x5Du, "VTruncF16")] + [InlineData(0x5Eu, "VRndneF16")] + [InlineData(0x5Fu, "VFractF16")] + [InlineData(0x60u, "VSinF16")] + [InlineData(0x61u, "VCosF16")] + public void Float16UnaryDecodesAndCompilesInBothEncodings(uint opcode, string name) + { + // VOP1 v0, v1 (Silent Hill f: v_log_f16), then the VOP3 form v2, v3. + var program = Decode( + [ + 0x7E000000u | (opcode << 9) | 257u, + (0x35u << 26) | ((0x180u + opcode) << 16) | 2u, + 259u, + SEndpgm, + ]); + + Assert.Equal([name, name, "SEndpgm"], program.Instructions.Select(instruction => instruction.Opcode)); + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.DoesNotContain((ushort)SpirvCapability.Float16, ReadCapabilities(shader.Spirv)); + } + + [Theory] + [InlineData(0x351u, "VMin3F16")] + [InlineData(0x354u, "VMax3F16")] + [InlineData(0x357u, "VMed3F16")] + public void Vop3Float16ThreeOperandMinMaxDecodesAndCompiles(uint opcode, string name) + { + // Silent Hill's pixel shaders use v_min3_f16. + var program = Decode( + [ + (0x35u << 26) | (opcode << 16) | 122u, + 261u | (262u << 9) | (263u << 18), + SEndpgm, + ]); + + Assert.Equal(name, program.Instructions[0].Opcode); + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.DoesNotContain((ushort)SpirvCapability.Float16, ReadCapabilities(shader.Spirv)); + } + [Fact] public void Vop3Float16FmaDecodesAndCompiles() { diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs index b19ff7a..eee1963 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ImageTests.cs @@ -144,6 +144,15 @@ public sealed class Gen5ImageTests Assert.DoesNotContain(instructions, item => item.Opcode == SpirvOp.ImageSampleExplicitLod); } + [Fact] + public void ImageGatherCompareLzUsesNativeDepthGather() + { + var instructions = ReadSpirvInstructions( + CompileImageOperation("ImageGather4CLz", dimension: 1, unifiedFormat: 22u)); + + Assert.Contains(instructions, item => item.Opcode == SpirvOp.ImageDrefGather); + } + [Theory] [InlineData(0xFACu, 0xFu, 4, 5, 6, 7)] [InlineData(0x9F5u, 0xFu, 7, 4, 5, 6)] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs new file mode 100644 index 0000000..8d2fbbc --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Int16AluTests.cs @@ -0,0 +1,97 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler.Tests.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +public sealed class Gen5Int16AluTests +{ + private const ulong ShaderAddress = 0x1_0000_0000; + private const uint SEndpgm = 0xBF810000; + + public static TheoryData Opcodes = new() + { + { 0x303, "VAddNcU16" }, + { 0x304, "VSubNcU16" }, + { 0x307, "VLshrrevB16" }, + { 0x308, "VAshrrevI16" }, + { 0x309, "VMaxU16" }, + { 0x30A, "VMaxI16" }, + { 0x30B, "VMinU16" }, + { 0x30C, "VMinI16" }, + { 0x30D, "VAddNcI16" }, + { 0x30E, "VSubNcI16" }, + { 0x314, "VLshlrevB16" }, + }; + + [Theory] + [MemberData(nameof(Opcodes))] + public void SixteenBitIntegerOpcodeDecodesAndCompiles(uint opcode, string expectedName) + { + var program = Decode(Vop3(opcode)); + Assert.Equal([expectedName, "SEndpgm"], program.Instructions.Select(instruction => instruction.Opcode)); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(ResourceTestProgram.Request(program, userDataCount: 0), out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + } + + private static uint[] Vop3(uint opcode) => [(0x35u << 26) | (opcode << 16) | 3u, (0x102u << 9) | 0x101u, SEndpgm]; + + private static Gen5ShaderProgram Decode(IReadOnlyList words) + { + var memory = new TestCpuMemory(ShaderAddress, words.Count * sizeof(uint)); + var bytes = new byte[words.Count * sizeof(uint)]; + for (var index = 0; index < words.Count; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(ShaderAddress, bytes)); + var context = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, ShaderAddress, out var program, out var error), error); + return program; + } + + private sealed class TestCpuMemory(ulong baseAddress, int size) : ICpuMemory + { + private readonly byte[] _storage = new byte[size]; + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + private bool TryResolve(ulong address, int length, out int offset) + { + offset = 0; + if (address < baseAddress || address - baseAddress > int.MaxValue) + { + return false; + } + + offset = (int)(address - baseAddress); + return offset <= _storage.Length - length; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs index 3dce05a..02c51be 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5VopcF16Tests.cs @@ -51,6 +51,54 @@ public sealed class Gen5VopcF16Tests { 0xFF, "VCmpxTruF16" }, }; + [Theory] + [InlineData(0xA9u, "VCmpLtU16")] + [InlineData(0xAAu, "VCmpEqU16")] + [InlineData(0xABu, "VCmpLeU16")] + [InlineData(0xACu, "VCmpGtU16")] + [InlineData(0xADu, "VCmpNeU16")] + [InlineData(0xAEu, "VCmpGeU16")] + [InlineData(0xB9u, "VCmpxLtU16")] + [InlineData(0xBAu, "VCmpxEqU16")] + [InlineData(0xBBu, "VCmpxLeU16")] + [InlineData(0xBCu, "VCmpxGtU16")] + [InlineData(0xBDu, "VCmpxNeU16")] + [InlineData(0xBEu, "VCmpxGeU16")] + public void U16CompareOpcodeDecodesAndLowersToSpirv(uint opcode, string expectedName) + { + var memory = new TestCpuMemory(ShaderAddress, 0x100); + Span shader = stackalloc byte[2 * sizeof(uint)]; + var word = (0x3Eu << 25) | (opcode << 17) | (1u << 9); + BinaryPrimitives.WriteUInt32LittleEndian(shader, word); + BinaryPrimitives.WriteUInt32LittleEndian(shader[sizeof(uint)..], SEndpgm); + Assert.True(memory.TryWrite(ShaderAddress, shader)); + + var ctx = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(ctx, ShaderAddress, out var program, out var error), error); + var instruction = Assert.Single(program.Instructions, candidate => candidate.Encoding == Gen5ShaderEncoding.Vopc); + Assert.Equal(expectedName, instruction.Opcode); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(ResourceTestProgram.Request(program, userDataCount: 0), out var lowered, out error), error); + Assert.NotEmpty(lowered.Spirv); + } + + [Theory] + [InlineData(0x89u, "VCmpLtI16")] + [InlineData(0x8Au, "VCmpEqI16")] + [InlineData(0x8Bu, "VCmpLeI16")] + [InlineData(0x8Cu, "VCmpGtI16")] + [InlineData(0x8Du, "VCmpNeI16")] + [InlineData(0x8Eu, "VCmpGeI16")] + [InlineData(0x99u, "VCmpxLtI16")] + [InlineData(0x9Au, "VCmpxEqI16")] + [InlineData(0x9Bu, "VCmpxLeI16")] + [InlineData(0x9Cu, "VCmpxGtI16")] + [InlineData(0x9Du, "VCmpxNeI16")] + [InlineData(0x9Eu, "VCmpxGeI16")] + public void I16CompareOpcodeDecodesAndLowersToSpirv(uint opcode, string expectedName) + { + U16CompareOpcodeDecodesAndLowersToSpirv(opcode, expectedName); + } + [Theory] [MemberData(nameof(Opcodes))] public void F16CompareOpcodeDecodes(uint opcode, string expectedName) diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs new file mode 100644 index 0000000..800dec4 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTablePlannerTests.cs @@ -0,0 +1,221 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// Planning of bounded runtime V# candidate tables: a canonical unsigned induction loop +// that reads its descriptor from a static SRT must yield a table; every unproven or +// out-of-range shape must be rejected and stay on the old lowering. +public sealed class BufferCandidateTablePlannerTests +{ + // preheader: s0..s3 = SRT V#, s10 = 0; branch to header + // header: s12 = s10 * stride; dwordx4 read; formatted load; s10 += 1; guard loop + private static Gen5ShaderProgram CandidateProgram( + uint stride = 16, + uint records = 4, + string guard = "SCmpLtU32", + uint guardRegister = 10, + uint limit = 4, + bool runtimeLimit = false) + { + var instructions = new List(); + uint pc = 0; + void Add(Gen5ShaderInstruction instruction) + { + instructions.Add(instruction); + pc += (uint)instruction.Words.Count * sizeof(uint); + } + + Add(MoveScalar(pc, 0, 0x3000)); + Add(MoveScalar(pc, 1, (stride & 0x3FFF) << 16)); + Add(MoveScalar(pc, 2, records)); + Add(MoveScalar(pc, 3, 0)); + Add(MoveScalar(pc, 10, 0)); + Add(Branch(pc, "SBranch", 0)); + var headerPc = pc; + + Add(Sop2(pc, "SMulI32", 12, Gen5Operand.Scalar(10), Operand(stride))); + Add(ScalarBufferLoad(pc, 0, destination: 16, count: 4, dynamicOffsetRegister: 12)); + Add(BufferLoad(pc, 16, formatted: true)); + Add(Sop2(pc, "SAddU32", 10, Gen5Operand.Scalar(10), Operand(1))); + var bound = runtimeLimit ? Gen5Operand.Scalar(7) : Operand(limit); + Add(Sopc(pc, guard, Gen5Operand.Scalar(guardRegister), bound)); + var branchPc = pc; + var target = (int)headerPc; + Add(Branch(branchPc, "SCbranchScc1", (short)((target - (int)(branchPc + 4)) / 4))); + Add(EndProgram(pc)); + return Program([.. instructions]); + } + + private static MemoryAccessInfo BufferAccessOf(ShaderResourcePlan plan) + { + foreach (var access in plan.Memory.Entries) + { + if (access.Opcode.StartsWith("BufferLoadFormat", System.StringComparison.Ordinal)) + { + return access; + } + } + + throw new Xunit.Sdk.XunitException("the program has no formatted buffer load"); + } + + [Fact] + public void CanonicalUnsignedLoop_ProducesCandidateTable() + { + var plan = Extract(CandidateProgram()); + + var table = Assert.Single(plan.BufferCandidateTables); + Assert.True(table.IsStaticallyBounded); + Assert.Equal(4, table.Count); + Assert.Equal(16u, table.Stride); + Assert.Equal(0u, table.MinOffset); + Assert.Equal(64u, table.MaxOffset); + Assert.NotEqual(DescriptorConstants.NoIndex, table.SourceSrtResource); + Assert.Equal(64u, table.SrtByteExtent); + Assert.Equal(BufferDescriptorProvenance.Runtime, BufferAccessOf(plan).BufferDescriptor!.Provenance); + Assert.False(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void RuntimeLimit_ProducesCandidateTableWithoutStaticCount() + { + var plan = Extract(CandidateProgram(runtimeLimit: true)); + + var table = Assert.Single(plan.BufferCandidateTables); + Assert.False(table.IsStaticallyBounded); + Assert.Equal(-1, table.Count); + Assert.NotNull(table.Limit); + } + + [Fact] + public void PhiWithoutProvableGuard_IsRejected() + { + var plan = Extract(CandidateProgram(guardRegister: 7)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void SignedGuard_IsRejected() + { + var plan = Extract(CandidateProgram(guard: "SCmpLtI32")); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void StrideSmallerThanDescriptor_IsRejected() + { + var plan = Extract(CandidateProgram(stride: 8)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void RangeExceedingSrtExtent_IsRejected() + { + var plan = Extract(CandidateProgram(records: 2)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + [Fact] + public void CandidateCountBeyondTheCap_IsRejected() + { + var plan = Extract(CandidateProgram(records: 0x100, limit: 0x100)); + + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Info.UsesDeviceAddresses); + } + + private static TestWordMemory CandidateMemory() + { + var memory = new TestWordMemory { Base = 0x3000, Words = new uint[0x2000 / 4], RequireAlignment = true }; + for (var index = 0; index < 4; index++) + { + var address = 0x3000 + (ulong)index * 16; + memory.At(address + 0) = 0x4000u + (uint)index * 0x100; + memory.At(address + 4) = 16u << 16; + memory.At(address + 8) = 4; + memory.At(address + 12) = 0; + } + + return memory; + } + + [Fact] + public void CanonicalTable_MaterializesCandidatesTransactionally() + { + var plan = Extract(CandidateProgram()); + var memory = CandidateMemory(); + var inputs = Inputs([], readCleanMemory: memory.Read); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + + var table = Assert.Single(specialization.BufferCandidateTables); + Assert.Equal(4u, table.CandidateCount); + Assert.Equal(plan.Info.Buffers.Count + 4, specialization.Buffers.Count); + Assert.Equal(4u, snapshot.FlattenedResourceTable[(int)table.MappingOffset]); + + var applied = ResourceMaterializer.ApplyTo(plan, specialization); + Assert.Equal(plan.Info.Buffers.Count + 4, applied.Info.Buffers.Count); + var info = Assert.Single(applied.Info.BufferCandidateTables); + Assert.Equal(4u, info.CandidateCount); + Assert.Equal((uint)plan.Info.Buffers.Count, info.FirstCandidate); + + // One unreadable candidate must leave the previous snapshot and specialization intact. + var priorSnapshot = snapshot; + var priorSpecialization = specialization; + memory.FailAddress = 0x3004; + Assert.False(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + Assert.Same(priorSnapshot, snapshot); + Assert.Same(priorSpecialization, specialization); + } + + [Fact] + public void IdenticalCandidatesShareOneBindingAndProbeKey() + { + var plan = Extract(CandidateProgram()); + var memory = CandidateMemory(); + for (var index = 1; index < 4; index++) + { + var address = 0x3000 + (ulong)index * 16; + memory.At(address + 0) = memory.At(0x3000); + } + + var inputs = Inputs([], readCleanMemory: memory.Read); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + + var table = Assert.Single(specialization.BufferCandidateTables); + Assert.Equal(1u, table.CandidateCount); + Assert.Equal(1u, snapshot.FlattenedResourceTable[(int)table.MappingOffset]); + } + + [Fact] + public void DistinctDescriptorsSharingAProbeKey_AreRejected() + { + var plan = Extract(CandidateProgram()); + var memory = CandidateMemory(); + memory.At(0x3010) = memory.At(0x3000); + memory.At(0x3018) = 8; + + var inputs = Inputs([], readCleanMemory: memory.Read); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.False(ResourceMaterializer.Materialize(plan, inputs, ref snapshot, ref specialization)); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs new file mode 100644 index 0000000..887db2c --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferCandidateTableSpirvTests.cs @@ -0,0 +1,91 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// Lowering of a bounded runtime V# table: the emitted module must bind one native candidate +// per descriptor and dispatch the normal buffer operation through a flattened key mapping. +public sealed class BufferCandidateTableSpirvTests +{ + private static Gen5ShaderProgram CandidateProgram(uint guardRegister = 10) + { + var instructions = new List(); + uint pc = 0; + void Add(Gen5ShaderInstruction instruction) + { + instructions.Add(instruction); + pc += (uint)instruction.Words.Count * sizeof(uint); + } + + Add(MoveScalar(pc, 0, 0x3000)); + Add(MoveScalar(pc, 1, 16u << 16)); + Add(MoveScalar(pc, 2, 4)); + Add(MoveScalar(pc, 3, 0)); + Add(MoveScalar(pc, 10, 0)); + Add(Branch(pc, "SBranch", 0)); + var headerPc = pc; + + Add(Sop2(pc, "SMulI32", 12, Gen5Operand.Scalar(10), Operand(16))); + Add(ScalarBufferLoad(pc, 0, destination: 16, count: 4, dynamicOffsetRegister: 12)); + Add(BufferLoad(pc, 16, formatted: true)); + Add(Sop2(pc, "SAddU32", 10, Gen5Operand.Scalar(10), Operand(1))); + Add(Sopc(pc, "SCmpLtU32", Gen5Operand.Scalar(guardRegister), Operand(4))); + var branchPc = pc; + Add(Branch(branchPc, "SCbranchScc1", (short)(((int)headerPc - (int)(branchPc + 4)) / 4))); + Add(EndProgram(pc)); + return Program([.. instructions]); + } + + private static TestWordMemory CandidateMemory() + { + var memory = new TestWordMemory { Base = 0x3000, Words = new uint[0x2000 / 4], RequireAlignment = true }; + for (var index = 0; index < 4; index++) + { + var address = 0x3000 + (ulong)index * 16; + memory.At(address + 0) = 0x4000u + (uint)index * 0x100; + memory.At(address + 4) = 16u << 16; + memory.At(address + 8) = 4; + memory.At(address + 12) = 77u << 12; + } + + return memory; + } + + private static ShaderCompileRequest CompileRequest(ShaderResourcePlan plan, SpecializedResourceInfo resources) + { + var layout = BindingLayout.Allocate( + resources.Info, + BindingLayout.CollectUserDataRegisters(plan.Graph.Program, plan.UserDataBase, plan.UserDataCount), + BindingLayout.UsesGlobalDataShare(plan.Graph.Program), + ShaderCompileRequest.RequiresFlattenedTable(plan, resources), + BindingLayout.ReadsShaderBase(plan.Graph.Program)); + return new ShaderCompileRequest(plan, resources, layout); + } + + [Fact] + public void BoundedCandidateTable_CompilesToValidSpirv() + { + var plan = ShaderResourcePlan.Extract(CandidateProgram(), ShaderStage.Compute, Hash, 0, 64); + Assert.Single(plan.BufferCandidateTables); + + var memory = CandidateMemory(); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([], readCleanMemory: memory.Read), ref snapshot, ref specialization)); + + var resources = ResourceMaterializer.ApplyTo(plan, specialization); + var request = CompileRequest(plan, resources); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + + var module = new SpirvModuleInspector(shader.Spirv); + Assert.Contains((0u, BindingLayout.NativeBindingIndex(ShaderStage.Compute, DescriptorBindingKind.FlattenedResourceTable)), module.DescriptorBindings); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs new file mode 100644 index 0000000..daecb59 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DemonsSoulsProbeTests.cs @@ -0,0 +1,64 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler.Resources; +using Xunit; +using Xunit.Abstractions; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +public sealed class DemonsSoulsProbeTests(ITestOutputHelper output) +{ + private const ulong ShaderAddress = 0x1000; + + [Theory] + [InlineData("59775BB47AD2848A")] + [InlineData("E005B9EB0E74B0A3")] + public void Probe(string hash) + { + var path = Environment.GetEnvironmentVariable("SHARPEMU_PROBE_DIR"); + if (string.IsNullOrEmpty(path)) return; + var words = File.ReadAllText(Path.Combine(path, hash + ".words.txt")).Split(',') + .Select(word => Convert.ToUInt32(word, 16)).ToArray(); + var bytes = new byte[words.Length * 4]; + for (var index = 0; index < words.Length; index++) + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * 4), words[index]); + var memory = new ProbeMemory(bytes); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(new CpuContext(memory, Generation.Gen5), ShaderAddress, out var program, out var error), error); + var writer = new StringWriter(); + var previous = Console.Error; + Console.SetError(writer); + try + { + var plan = ShaderResourcePlan.Extract(program!, ShaderStage.Compute, Convert.ToUInt64(hash, 16), 0, 2); + output.WriteLine($"OK images={plan.Info.Images.Count}"); + } + catch (Exception exception) + { + output.WriteLine("FAIL " + exception.Message); + } + finally + { + Console.SetError(previous); + output.WriteLine(writer.ToString()); + } + } + + private sealed class ProbeMemory(byte[] bytes) : ICpuMemory + { + public bool TryRead(ulong address, Span destination) + { + if (address < ShaderAddress || address - ShaderAddress + (ulong)destination.Length > (ulong)bytes.Length) + return false; + bytes.AsSpan((int)(address - ShaderAddress), destination.Length).CopyTo(destination); + return true; + } + + public bool CanRead(ulong address, ulong size) => + address >= ShaderAddress && size <= (ulong)bytes.Length && address - ShaderAddress <= (ulong)bytes.Length - size; + + public bool TryWrite(ulong address, ReadOnlySpan source) => false; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs index 5202de6..6e0ec7a 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DeviceAddressRangePlannerTests.cs @@ -10,6 +10,87 @@ namespace SharpEmu.ShaderCompiler.Tests.Resources; public sealed class DeviceAddressRangePlannerTests { + // flat_store through (aperture_hi << 32) | offset: the high dword comes from an + // aperture operand, so the store targets LDS or scratch and owns no device range. + [Theory] + [InlineData(Gen5InlineConstants.SharedBase, FlatAddressSpace.Shared)] + [InlineData(Gen5InlineConstants.PrivateBase, FlatAddressSpace.Private)] + public void FlatAddressFromAnAperture_IsLocalAndHasNoDeviceRange(uint aperture, FlatAddressSpace expected) + { + var plan = Extract(Program( + Sop1(0, "SMovB32", 1, Gen5Operand.Source(aperture)), + Vop1(4, "VMovB32", 1, Gen5Operand.Scalar(1)), + Vop1(8, "VMovB32", 0, Operand(16)), + GlobalAccess(12, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(20))); + + Assert.Empty(plan.DeviceAddressRanges); + Assert.True(plan.Memory.TryGetIndex(12, 0, out var index)); + Assert.Equal(expected, plan.Memory[index].AddressSpace); + } + + // A high dword clamped between the hard-coded PS5 aperture bounds + // (v_med3_u32 0x80000000, 0x70000000, sp) always lands in LDS or scratch. + [Fact] + public void FlatAddressClampedIntoTheApertures_IsLocal() + { + var plan = Extract(Program( + Vop3(0, "VMed3U32", 1, Operand(0x8000_0000), Operand(0x7000_0000), Gen5Operand.Vector(7)), + Vop1(8, "VMovB32", 0, Operand(16)), + GlobalAccess(12, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(20))); + + Assert.Empty(plan.DeviceAddressRanges); + Assert.True(plan.Memory.TryGetIndex(12, 0, out var index)); + Assert.Equal(FlatAddressSpace.SharedOrPrivate, plan.Memory[index].AddressSpace); + } + + // After v_cmpx the clamp is a lane-masked write whose inactive lanes keep an + // unknown value; those lanes do not store, so the address is still local. + [Fact] + public void ClampedAddressWrittenUnderTheStoresExecMask_IsLocal() + { + var plan = Extract(Program( + Vopc(0, "VCmpxLtU32", Operand(3), 5), + Vop3(4, "VMed3U32", 1, Operand(0x8000_0000), Operand(0x7000_0000), Gen5Operand.Vector(7)), + Vop1(12, "VMovB32", 0, Operand(16)), + GlobalAccess(16, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(24))); + + Assert.Empty(plan.DeviceAddressRanges); + Assert.True(plan.Memory.TryGetIndex(16, 0, out var index)); + Assert.Equal(FlatAddressSpace.SharedOrPrivate, plan.Memory[index].AddressSpace); + } + + // Without an aperture the same flat store is a device access and keeps its range. + [Fact] + public void FlatAddressWithoutAperture_StaysGlobal() + { + var plan = Extract(Program( + Vop1(0, "VMovB32", 1, Operand(0)), + Vop1(4, "VMovB32", 0, Operand(0x1000)), + GlobalAccess(8, "FlatStoreDword", 0, vectorAddress: 0), + EndProgram(16))); + + var range = Assert.Single(plan.DeviceAddressRanges); + Assert.True(range.Written); + Assert.True(plan.Memory.TryGetIndex(8, 0, out var index)); + Assert.Equal(FlatAddressSpace.Global, plan.Memory[index].AddressSpace); + } + + // A 32-bit read returns the aperture's high dword; a 64-bit read the full address. + [Fact] + public void ApertureOperands_DecodeConsistentlyAsHighDwordAndAddress() + { + Assert.True(Gen5InlineConstants.TryDecode(Gen5InlineConstants.SharedBase, out var shared)); + Assert.Equal(Gen5InlineConstants.SharedApertureHigh, shared); + Assert.True(Gen5InlineConstants.TryDecode(Gen5InlineConstants.PrivateLimit, out var privateLimit)); + Assert.Equal(Gen5InlineConstants.PrivateApertureHigh, privateLimit); + Assert.Equal(0x7000_0000_0000_0000ul, Gen5InlineConstants.DecodeAperture64(Gen5InlineConstants.SharedBase)); + Assert.Equal(0x7000_0000_FFFF_FFFFul, Gen5InlineConstants.DecodeAperture64(Gen5InlineConstants.SharedLimit)); + Assert.Equal(0x8000_0000_0000_0000ul, Gen5InlineConstants.DecodeAperture64(Gen5InlineConstants.PrivateBase)); + } + [Fact] public void BoundedOffsets_GiveTheLargestExtent() { diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs index 37e4e17..c7ef505 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/DirectImageTableTests.cs @@ -11,6 +11,25 @@ namespace SharpEmu.ShaderCompiler.Tests.Resources; public sealed class DirectImageTableTests { + internal static Gen5ShaderProgram CreateWaveIndexedDescriptorProgram() + { + return Program( + ScalarLoad(0, 0, 16, immediateOffset: 0x80), + Sop1(8, "SFF1I32B32", 18, Gen5Operand.Scalar(16)), + Sop2(12, "SMulI32", 106, Gen5Operand.Scalar(18), new Gen5Operand(Gen5OperandKind.LiteralConstant, 0x90)), + MoveVectorFromScalar(16, 34, 18), + Vop2(20, "VLshlrevB32", 15, Operand(4), Gen5Operand.Vector(34)), + Vop3(24, "VLshlAddU32", 16, Gen5Operand.Vector(15), Operand(3), Gen5Operand.Vector(15)), + Sop1(32, "SBitset0B32", 16, Gen5Operand.Scalar(18)), + Vop2(36, "VAddI32", 15, new Gen5Operand(Gen5OperandKind.LiteralConstant, 0x40), Gen5Operand.Vector(16)), + GlobalMemory(40, "GlobalLoadDword", 0, 15, 22, 0), + ReadFirstLane(48, 106, 22), + Sop2(52, "SLshlB32", 106, Gen5Operand.Scalar(106), Operand(5)), + ScalarLoad(56, 0, 4, 8, immediateOffset: 0x100, dynamicOffsetRegister: 106), + Image(64, "ImageLoad", 4, dmask: 1, vectorAddress: 1), + EndProgram(72)); + } + public static Gen5ShaderProgram CreateProgram(uint mask = 1, bool split = true, bool bitScan = true) { var instructions = new List @@ -79,6 +98,52 @@ public sealed class DirectImageTableTests return (plan, snapshot, new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 }); } + [Fact] + public void WaveIndexedDescriptorTableMaterializesOnlyActiveMaskKeys() + { + var plan = ShaderResourcePlan.Extract(CreateWaveIndexedDescriptorProgram(), ShaderStage.Compute, Hash, 0, 2); + var selector = plan.DescriptorSources[(int)plan.Info.Images[0].Source].IndirectImage!; + Assert.True(selector.Dense); + Assert.Equal(0u, selector.KeyBound); + Assert.Equal(new WaveIndexedImageSelector(0x80, 0x40, 0x90), selector.WaveIndexed); + + bool Read(ulong address, out uint word) + { + word = 0; + if (address == 0x1000 + 0x80) + { + word = (1u << 1) | (1u << 4); + return true; + } + + if (address == 0x1000 + 0x40 + 0x90 || address == 0x1000 + 0x40 + 4 * 0x90) + { + word = address == 0x1000 + 0x40 + 0x90 ? 2u : 5u; + return true; + } + + if (address < 0x1000 + 0x100 || address >= 0x1000 + 0x100 + 6 * 32) + return false; + var relative = address - 0x1000 - 0x100; + var record = relative / 32; + if (record is not (2 or 5)) + return false; + word = (relative % 32 / 4) switch + { + 0 => (record & 1) == 0 ? 0x2000u : 0x1000u, + 1 => 20u << 20, + 3 => 0xFACu | (9u << 28), + _ => 0, + }; + return true; + } + + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([0x1000, 0], readCleanMemory: Read), ref snapshot, ref specialization)); + Assert.Equal(2, snapshot.Images.Length); + } + public static (ResourceSnapshot Snapshot, ShaderCompileRequest Request) PrepareMixedDimensions(uint mask, bool arrayFirst) { var program = CreateGuardedProgram(mask); diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs new file mode 100644 index 0000000..b9f126a --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializationCacheTests.cs @@ -0,0 +1,175 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler.Resources; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +public sealed class ResourceMaterializationCacheTests +{ + private const ulong HeapBase = 0x1000; + + // A mask word, a two-entry index table and six 32-byte image records. + private sealed class Heap + { + public readonly Dictionary Words = new(); + public int Reads; + public bool GpuOwned; + + public Heap() + { + Words[HeapBase + 0x80] = (1u << 1) | (1u << 4); + Words[HeapBase + 0x40 + 0x90] = 2; + Words[HeapBase + 0x40 + 4 * 0x90] = 5; + foreach (var record in new uint[] { 2, 5 }) + { + var entry = HeapBase + 0x100 + record * 32; + for (uint dword = 0; dword < 8; dword++) + Words[entry + dword * 4] = dword switch + { + 0 => (record & 1) == 0 ? 0x2000u : 0x1000u, + 1 => 20u << 20, + 3 => 0xFACu | (9u << 28), + _ => 0, + }; + } + } + + public bool Read(ulong address, out uint word) + { + Reads++; + return Words.TryGetValue(address, out word); + } + + public bool ReadResident(ulong address, Span destination, bool clean) + { + if (GpuOwned) + return false; + for (var offset = 0; offset < destination.Length; offset += 4) + { + if (!Words.TryGetValue(address + (ulong)offset, out var word)) + return false; + BitConverter.TryWriteBytes(destination[offset..], word); + } + + return true; + } + } + + private static ShaderResourcePlan Plan() => + ShaderResourcePlan.Extract(DirectImageTableTests.CreateWaveIndexedDescriptorProgram(), ShaderStage.Compute, Hash, 0, 2); + + private static bool Run(ResourceMaterializationCache cache, ShaderResourcePlan plan, Heap heap, uint[] userData, + out ResourceSnapshot snapshot, out ResourceSpecialization specialization, ulong shaderBase = 0) + { + snapshot = new ResourceSnapshot(); + specialization = new ResourceSpecialization(); + return cache.Materialize(plan, Inputs(userData, readCleanMemory: heap.Read, shaderBase: shaderBase), heap.ReadResident, + ref snapshot, ref specialization, out _); + } + + [Fact] + public void UnchangedMemoryReusesTheMaterialization() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var first, out var firstSpecialization)); + var readsAfterFirst = heap.Reads; + Assert.True(readsAfterFirst > 0); + + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var second, out var secondSpecialization)); + Assert.Equal(readsAfterFirst, heap.Reads); + Assert.Same(first, second); + Assert.Same(firstSpecialization, secondSpecialization); + Assert.Equal((1, 1), (cache.Hits, cache.Misses)); + } + + [Fact] + public void AChangedDescriptorWordMaterializesAgain() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var first, out _)); + + heap.Words[HeapBase + 0x100 + 5 * 32] = 0x3000; + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var second, out _)); + Assert.NotSame(first, second); + Assert.Equal((0, 2), (cache.Hits, cache.Misses)); + + // An uncached full walk gives the same result as the re-materialization. + var reference = new ResourceSnapshot(); + var referenceSpecialization = new ResourceSpecialization(); + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([0x1000, 0], readCleanMemory: heap.Read), + ref reference, ref referenceSpecialization)); + Assert.Equal(reference.Images.Select(image => image.ToArray()), second.Images.Select(image => image.ToArray())); + } + + [Fact] + public void AChangedMaskMaterializesAgain() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + heap.Words[HeapBase + 0x80] = 1u << 1; + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.Equal(0, cache.Hits); + } + + [Fact] + public void GpuOwnedMemoryIsNeverTrusted() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + heap.GpuOwned = true; + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.Equal(0, cache.Hits); + } + + [Fact] + public void ADifferentDrawKeyIsADifferentEntry() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var first, out _)); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var second, out _, shaderBase: 0x100)); + Assert.NotSame(first, second); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out var third, out _)); + Assert.Same(first, third); + Assert.Equal((1, 2), (cache.Hits, cache.Misses)); + } + + [Fact] + public void AFailedReadIsNotCached() + { + var plan = Plan(); + var heap = new Heap(); + heap.Words.Remove(HeapBase + 0x100 + 5 * 32 + 4); + var cache = new ResourceMaterializationCache(); + var ok = Run(cache, plan, heap, [0x1000, 0], out _, out _); + var reads = heap.Reads; + Assert.Equal(ok, Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.True(heap.Reads > reads); + Assert.Equal(0, cache.Hits); + } + + [Fact] + public void AFullGenerationKeepsRecentEntries() + { + var plan = Plan(); + var heap = new Heap(); + var cache = new ResourceMaterializationCache(generationCapacity: 1); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _, shaderBase: 0x100)); + // The first entry moved to the old generation and is still found. + Assert.True(Run(cache, plan, heap, [0x1000, 0], out _, out _)); + Assert.Equal(1, cache.Hits); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs index 693b286..2d368ef 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceMaterializerTests.cs @@ -13,6 +13,7 @@ public sealed class ResourceMaterializerTests private const uint Format32Float = 22; private const uint Format32Sint = 21; private const uint Format11x2x10Uint = 34; + private const uint Format8x2Uscaled = 16; private const uint ImageType2D = 9; [Theory] @@ -245,6 +246,25 @@ public sealed class ResourceMaterializerTests Assert.NotEqual(snapshot.Samplers[0], snapshot.Samplers[2]); } + [Fact] + public void ScaledImage_MaterializesAsAFloatSampledImage() + { + var instructions = new List(); + uint pc = 0; + instructions.AddRange(ImageWords(ref pc, 16, 0x1000, Format8x2Uscaled)); + instructions.AddRange(SamplerWords(ref pc, 24, 0)); + instructions.Add(Image(pc, "ImageSample", 16, 24)); + instructions.Add(EndProgram(pc + 8)); + var plan = Extract(Program([.. instructions])); + var snapshot = new ResourceSnapshot(); + var specialization = new ResourceSpecialization(); + + Assert.True(ResourceMaterializer.Materialize(plan, Inputs([]), ref snapshot, ref specialization)); + var applied = ResourceMaterializer.ApplyTo(plan, specialization); + var image = Assert.Single(applied.Info.Images); + Assert.Equal(ImageNumericClass.Float, image.NumericClass); + } + // Three images share one sampler; the packed and signed ones need point filtering, // so the sampler splits and their accesses sample through the duplicate. [Fact] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs index 43b8724..1f273db 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ResourceTrackerTests.cs @@ -3,6 +3,7 @@ using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; using Xunit; using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; @@ -501,6 +502,42 @@ public sealed class ResourceTrackerTests Assert.Equal(layout.MemoryOffsetDword + 1, layout.ShaderDataDwordCount); } + [Fact] + public void RuntimeScalarBufferLoad_UsesDeviceAddresses() + { + var program = Program( + ScalarLoad(0, 0, destination: 8, count: 4, dynamicOffsetRegister: 2), + ScalarBufferLoad(8, 8, destination: 12, count: 1), + EndProgram(16)); + var plan = Extract(program); + + Assert.True(plan.Info.UsesDeviceAddresses); + Assert.Empty(plan.BufferCandidateTables); + Assert.True(plan.Memory.TryGetIndex(8, 0, out var index)); + Assert.Equal(BufferDescriptorProvenance.Runtime, plan.Memory[index].BufferDescriptor!.Provenance); + Assert.Equal(MemoryAccessInfo.NoResource, plan.Memory[index].Resource); + + var request = Request(program); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + } + + [Fact] + public void UnboundedRuntimeFormattedBufferLoad_UsesNullReadFallback() + { + var program = Program( + ScalarLoad(0, 0, destination: 8, count: 4, dynamicOffsetRegister: 2), + ScalarBufferLoad(8, 8, destination: 12, count: 1), + BufferLoad(16, 8, formatted: true), + EndProgram(20)); + var plan = Extract(program); + + Assert.Empty(plan.BufferCandidateTables); + var request = Request(program); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); + } + [Fact] public void PhiValidation() { @@ -605,7 +642,7 @@ public sealed class ResourceTrackerTests } [Fact] - public void ResourceLimits_FailBeforeAnyTableIsWritten() + public void BuffersPastTheBindingBudget_ReadTheirDescriptorsOnTheDevice() { var instructions = new List(); uint pc = 0; @@ -622,8 +659,11 @@ public sealed class ResourceTrackerTests } instructions.Add(EndProgram(pc)); - var error = Assert.Throws(() => Extract(Program([.. instructions]))); - Assert.Contains("buffer resource limit exceeded", error.Message); + var plan = Extract(Program([.. instructions])); + + Assert.Equal(ShaderResourceInfo.MaxBuffers, plan.Info.Buffers.Count); + Assert.True(plan.Info.UsesDeviceAddresses); + Assert.Single(plan.Memory.Entries, memory => memory.DeviceDescriptor); } [Fact] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs index 094e602..6674193 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/ScalarValueGraphTests.cs @@ -323,6 +323,37 @@ public sealed class ScalarValueGraphTests Assert.Contains("not a valid runtime value", error.Message); } + [Fact] + public void BitwiseMaskCanResolveUndefinedDescriptorBits() + { + var plan = Extract(Program( + Sop2(0, "SAndB32", 0, Gen5Operand.Scalar(20), Operand(0)), + MoveScalar(4, 1, 0), + MoveScalar(8, 2, 0), + MoveScalar(12, 3, 0), + BufferLoad(16, 0), + EndProgram(24)), userDataCount: 0); + + Assert.True(RuntimeValueEvaluator.EvaluateDescriptorSource(plan, plan.Info.Buffers[0].Source, Inputs([]), out var descriptor)); + Assert.Equal([0u, 0u, 0u, 0u], descriptor.Dwords); + } + + [Fact] + public void DirectExecutionMaskWriteKeepsBranchConditionDefined() + { + var program = Program( + MoveScalar(0, 126, 0), + Branch(4, "SCbranchExecz", 0), + EndProgram(8)); + + var graph = ScalarValueGraph.Build(program, 0, 0); + + Assert.True(graph.BranchConditions.TryGetValue(4, out var condition)); + Assert.False(condition.IsUndefined); + Assert.True(condition.IsConstant); + Assert.Equal(1u, condition.ConstantU32); + } + // ---- Requirement A: lane provenance and uniform vector values ---- [Fact] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs index 921f38a..38ac9c6 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/VectorCompareResourceTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; using Xunit; using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; @@ -10,59 +11,29 @@ namespace SharpEmu.ShaderCompiler.Tests.Resources; public sealed class VectorCompareResourceTests { [Fact] - public void ExecutionComparePreservesResourcePointerWithUnknownVectorInputs() + public void BitreplicateB64B32Compiles() { var program = Program( - ScalarLoad(0, 0, 106, count: 2), - Vopc(8, "VCmpxLtU32", Gen5Operand.Vector(0), 1), - Branch(12, "SCbranchExecz", 4), - ScalarLoad(16, 106, 8, count: 4), - BufferLoad(24, 8), - EndProgram(32)); - var plan = Extract(program, userDataCount: 2); - var memory = new TestWordMemory - { - Base = 0x2_00001000, - Words = [0x1010, 2, 0, 0, 0x3000, 0, 256, 0], - }; - var snapshot = new ResourceSnapshot(); - var specialization = new ResourceSpecialization(); + Sop1(0, "SBitreplicateB64B32", 0, Operand(7)), + EndProgram(8)); - Assert.True(ResourceMaterializer.Materialize(plan, Inputs([0x1000, 2], memory.Read), - ref snapshot, ref specialization, out var failure)); - Assert.Equal(ResourceMaterializationFailure.None, failure); - Assert.Equal(new uint[] { 0x3000, 0, 256, 0 }, Assert.Single(snapshot.Buffers)); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(Request(program, userDataCount: 0), out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); } [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public void CompareUpdatesOnlyItsDestinationMask(bool updatesExecutionMask, bool comparisonPasses) + [InlineData("VCmpEqU16")] + [InlineData("VCmpLtU16")] + [InlineData("VCmpGeU16")] + [InlineData("VCmpEqI16")] + [InlineData("VCmpLtI16")] + public void Integer16BitCompareCompilesToSpirv(string opcode) { var program = Program( - MoveScalar(0, 106, 0x12345678), - MoveScalar(4, 107, 0x87654321), - MoveVector(8, 0, comparisonPasses ? 2u : 0u), - Vopc(12, updatesExecutionMask ? "VCmpxLtU32" : "VCmpLtU32", Operand(1), 0), - MoveScalarRegister(16, 8, 106), - MoveScalarRegister(20, 9, 107), - MoveScalarRegister(24, 10, 126), - MoveScalarRegister(28, 11, 127), - BufferLoad(32, 8), - EndProgram(40)); - var plan = Extract(program, userDataCount: 0); + Vopc(0, opcode, Gen5Operand.Vector(0), 1), + EndProgram(8)); - Assert.True(RuntimeValueEvaluator.EvaluateDescriptorSource(plan, - Assert.Single(plan.Info.Buffers).Source, Inputs([]), out var descriptor)); - var comparisonMask = comparisonPasses ? 1u : 0u; - Assert.Equal(new uint[] - { - updatesExecutionMask ? 0x12345678u : comparisonMask, - updatesExecutionMask ? 0x87654321u : 0u, - updatesExecutionMask ? comparisonMask : uint.MaxValue, - 0, - }, descriptor.Dwords); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(Request(program, userDataCount: 0), out var shader, out var error), error); + Assert.NotEmpty(shader.Spirv); } }