Compare commits

..

69 Commits

Author SHA1 Message Date
ParantezTech b7856322a8 [Tests] Cover FP16 and uint buffer fills and raw fallback 2026-10-13 04:54:38 +03:00
ParantezTech 160521fd78 [GPU] Clear image buffer owners with format-correct fill values 2026-10-13 04:54:37 +03:00
ParantezTech 8164636d99 [GPU] Decode repeated FP16 and uint buffer fill words 2026-10-13 04:54:36 +03:00
ParantezTech f19c4ca4d5 [Tests] Cover the written mip statistics report 2026-10-13 03:46:56 +03:00
ParantezTech c61c1602aa [Presenter] Report bound mip statistics by default and measure on the GPU on request 2026-10-13 03:46:56 +03:00
ParantezTech d0ffcb24d2 [Presenter] Write the bound mip statistics in stream order on the render thread 2026-10-13 03:46:56 +03:00
ParantezTech 880270b344 [GPU] Write the interpreter's LOD statistics through the shared report writer 2026-10-13 03:46:56 +03:00
ParantezTech d5332f01ca [GPU] Write the mip statistics report with its ready marker 2026-10-13 03:46:56 +03:00
ParantezTech fab999316e [Tests] Cover concurrent resolve answers and release device slabs 2026-10-13 03:46:56 +03:00
ParantezTech 6631a665f8 [Presenter] Merge overlapping concurrent resolve ownership answers 2026-10-13 03:46:56 +03:00
ParantezTech 002007c323 [GPU] Fill metadata-shaped bounded fills on the host 2026-10-13 03:46:56 +03:00
ParantezTech b26c59460d [Diagnostics] Report the GPU writers of synchronized DCC metadata 2026-10-13 03:46:56 +03:00
ParantezTech d4276d89f8 [Profile] Count DCC metadata synchronizations and their outcome 2026-10-13 03:46:56 +03:00
ParantezTech e5c2755789 [Profile] Attribute render thread GPU waits to their call sites 2026-10-13 03:46:56 +03:00
ParantezTech 22ec74e085 [Profile] Classify what changed in stale resource cache entries 2026-10-13 03:46:56 +03:00
ParantezTech 7b730a51fc [Profile] Count resource cache refusal reasons 2026-10-13 03:46:56 +03:00
ParantezTech 8616383a58 [Tests] Cover wave64 subgroups, readbacks, barriers, LOD statistics and packet writes 2026-10-13 03:46:56 +03:00
ParantezTech 416464db1e [AGC] Emit register packets with one write 2026-10-13 03:46:56 +03:00
ParantezTech cd51a9ea2e [AGC] Emit event and flip packets with one write 2026-10-13 03:46:56 +03:00
ParantezTech 8f8737e2d1 [AGC] Emit draw packets with one write 2026-10-13 03:46:56 +03:00
ParantezTech 3fba66473b [AGC] Emit indirect buffer packets with one write 2026-10-13 03:46:56 +03:00
ParantezTech e2ad2084f8 [AGC] Emit dispatch, memory and marker packets with one write 2026-10-13 03:46:56 +03:00
ParantezTech 9b552ffb72 [AGC] Emit packets with one write and read the buffer state at once 2026-10-13 03:46:56 +03:00
ParantezTech 8c24a40bfa [HLE] Skip the region lock for writes to CPU-dirty pages 2026-10-13 03:46:56 +03:00
ParantezTech 643a565a22 [HLE] Answer GPU span coverage from a snapshot 2026-10-13 03:46:56 +03:00
ParantezTech 8f7fa3933f [HLE] Write single-view guest memory without the region lock 2026-10-13 03:46:56 +03:00
ParantezTech 48258b1e50 [Core] Map guest thread priorities one band lower 2026-10-13 03:46:56 +03:00
ParantezTech ca076237c6 [GPU] Memoize texture lookups of DCC images 2026-10-13 03:46:56 +03:00
ParantezTech dc73931dd6 [GPU] Split the post-lookup work out of image lookups 2026-10-13 03:46:56 +03:00
ParantezTech 1fbecd3233 [GPU] Skip native binds that repeat the bound state 2026-10-13 03:46:55 +03:00
ParantezTech ae97ac9525 [GPU] Advance a binding epoch when helper passes bind their own state 2026-10-13 03:46:55 +03:00
ParantezTech b179883b5c [ShaderCompiler] Start pixel append programs without helper lanes 2026-10-13 03:46:55 +03:00
ParantezTech 32c9a37b0e [GPU] Report LOD statistics measured on the GPU 2026-10-13 03:46:55 +03:00
ParantezTech 5d6b59eeb8 [GPU] Pack LOD statistics reports from counter words 2026-10-13 03:46:55 +03:00
ParantezTech 8d0af3d8f7 [ShaderCompiler] Count the finest mip each pixel sample reaches 2026-10-13 03:46:55 +03:00
ParantezTech a1b44452fa [GPU] Skip the upload walk when no CPU-dirty page is in range 2026-10-13 03:46:55 +03:00
ParantezTech d2090f47fb [GPU] Stop reading ahead windows whose copies keep going stale 2026-10-13 03:46:55 +03:00
ParantezTech b5c15a7426 [GPU] Publish read-back pieces that no later write covers 2026-10-13 03:46:55 +03:00
ParantezTech fd2afcd8c8 [GPU] Track GPU write ticks per buffer range 2026-10-13 03:46:55 +03:00
ParantezTech 6d69163297 [GPU] Request guest memory barriers through the recording buffer 2026-10-13 03:46:55 +03:00
ParantezTech c622b64ace [GPU] Merge consecutive memory barrier requests in a recording buffer 2026-10-13 03:46:55 +03:00
ParantezTech 0de84e34df [Diagnostics] Count GPU barriers and rendering scopes 2026-10-13 03:46:55 +03:00
ParantezTech 84c830f7ce [ShaderCompiler] Skip EXEC selects for vector writes no lane observes 2026-10-13 03:46:55 +03:00
ParantezTech d24f6c6a85 [GPU] Compile wave64 compute for 32-lane subgroups when the host lacks wave64 2026-10-13 03:46:55 +03:00
ParantezTech 08891b1fef [ShaderCompiler] Run lane-local wave64 programs as independent half waves 2026-10-13 03:46:55 +03:00
ParantezTech 7a6e3d0308 [ShaderCompiler] Run wave64 compute as paired lanes on 32-lane subgroups 2026-10-13 03:46:55 +03:00
ParantezTech 4087ab84f8 [ShaderCompiler] Let compile requests ask for 32-lane compute subgroups 2026-10-13 03:46:55 +03:00
ParantezTech 5febf063f4 [ShaderCompiler] Declare the SubgroupId built-in 2026-10-13 03:46:55 +03:00
ParantezTech c02f2163f2 [ShaderCompiler] Find vector writes that no later instruction observes 2026-10-13 03:46:55 +03:00
ParantezTech 8a0d713665 [ShaderCompiler] Prove wave64 programs whose lanes never read each other 2026-10-13 03:46:55 +03:00
Astellou 3f01c0e91c [GPU] Record a frame's draws before its boundary returns to the guest
With the command stream on its own thread, a frame boundary only posted its
flush, so the guest resumed while up to 64 items of the frame were still
queued for the render thread. The guest then rewrote per-draw memory
(descriptor tables, rings) that those draws read when recorded, which showed
as black frames and flicker. The boundary now waits for the render thread to
run everything queued before it.
2026-10-13 03:46:55 +03:00
Astellou 593cec957d [GPU] Run the command stream on its own thread and speed up draw execution
ASTRO BOT crowd scene: ~25 FPS -> ~55-58 FPS.

- The PM4 command stream is interpreted on its own thread. Draws, dispatches,
  barriers, end-of-pipe writes and the interpreter's stores are handed to the
  render thread in stream order through a short queue (64 items); callbacks
  that return a result wait for the work before them.
- Graphics program resolution runs on a worker thread ahead of the draws, from
  copy-on-write register bank snapshots, and is validated against the GPU
  ownership state before use.
- The resource materialization cache rebases buffer base addresses instead of
  missing on every moved descriptor.
- DCC metadata fills that cannot be absorbed run on the host, so binding the
  surface no longer waits for a GPU readback.
- Cheaper per-draw work: cached pipeline lookups no longer wait on program
  preparation, redundant dynamic state is skipped, image bindings and target
  requests are memoized, fewer allocations.
- Guest thread stack/TLS slots are chosen and mapped under one lock, so two
  threads can no longer share a stack.
- Wave lowering split into native wave32, native wave64 and emulated wave64.
2026-10-13 03:46:55 +03:00
ParantezTech 3f84f59065 Revert "Ghost of Yōtei and Tsushima in game: rendering, GPU/CPU memory coherence, tessellation"
This reverts commit c7bbb09311.
2026-10-13 03:38:40 +03:00
Berk 15768908a1
Revert "Fix/des render thread regression (#1086)" (#1091)
This reverts commit 28c922c7a5.
2026-10-13 03:33:15 +03:00
Foued Attar 682ad298ef
fix(vk): create the bindless image heap at device setup (#1088)
The heap was created lazily by pipeline creation and by the shader prewarm,
but a draw binds its bindless slot table before it creates its pipeline. A
title whose first draw samples images, with no pending prewarm work, hit a
null heap in BindFlattenedResourceTable.

The heap depends only on a device capability, so create it with the stores
and drop the lazy calls.
2026-10-12 20:18:36 +02:00
Ainesh M. 7ebf642aff
fix(agc): implement sceAgcGetGsOversubscription (#1083)
* Implement AGC GS oversubscription descriptor output

* fix(agc): drop the Yotei 0x191/0x192 register workaround

Ghost of Yotei 2.002 submitted two register descriptors whose output
sceAgcGetGsOversubscription left uninitialized, so it wrote user-config 0x191 and
shader 0x192, registers gfx10 does not define. The export now fills the descriptors
(user-config 0x260 and shader 0x81), so those writes no longer happen.

Remove the workaround that accepted and ignored both registers, together with its
logging and the two tests that pinned it. An unknown register in either table is a
fatal error again, which the export's own tests rely on.

Ghost of Yotei 2.0 starts and presents its first frame with this change and the
export from #1083.

---------

Co-authored-by: foufouadi <attar.foued@gmail.com>
2026-10-12 19:40:51 +02:00
Mathias b307b2f8fd
fix(json): iterate sce::Json::Array values (#1087) 2026-10-12 19:32:19 +03:00
Foued Attar 28c922c7a5
Fix/des render thread regression (#1086)
* perf(images): download dropped images through the tiler ring

PublishBeforeDrop moves an image's GPU contents into guest memory before an
overlap drops it. Demon's Souls drops a few full-screen targets every frame,
and each tiled download allocated a pooled scratch the size of the image; past
the pool budget the completion callback freed it again. Creating and freeing
those buffers took about 11 % of the render thread.

The download now takes its linear copy from the same ring the uploads use,
with the copy regions moved to the ring offset. A download that needs the
BGRA16 swap keeps its own scratch, so the swap output from the ring cannot
wrap onto it. Ring downloads hold no pooled scratch, so a collection under
pressure no longer has to end the tick to bound them; the pressure test now
checks that instead.

* revert(gpu): remove unified guest buffers and mapped readbacks

#1058 placed guest buffers in device-local, host-visible memory and read
them back through the mapping; its author reverted it the same day in
#1065, and #926 brought it back. The memory-type check also matches a
discrete GPU with resizable BAR, where the CPU reads that memory uncached
across the bus: on an RTX 5070 Demon's Souls' render thread went from
0.036 ms to 0.33 ms per readback and from 11.6 ms to 19.4 ms of work per
frame, and a Ryzen 7 5800X3D dropped to 25-30 FPS.

Guest buffers are device-local again and every readback copies, as before
#1058. SHARPEMU_UNIFIED_BUFFERS and SHARPEMU_MAPPED_READBACK_KB are gone, with
the two mapped-readback tests and the two shared-page tests that only ran on
mapped buffers. Shared page shadows still adopt pages from downloads; pulling
shader bytes from a GPU copy needs a mapping and no longer happens.
2026-10-12 19:28:37 +03:00
Mathias ae406fc049
Run and pack ZArchive (.zar) game archives (#1085)
* feat: run and pack ZArchive (.zar) game archives

* feat(gui): optionally delete the game folder after packing a .zar

* feat(gui): name a packed .zar after the game version

* feat(gui): explain packing in the .zar dialog

* feat(gui): name a packed .zar by title id and version
2026-10-12 19:26:51 +03:00
Heazer13 61697fa461
[GUI] French: add missing Library.View strings and polish translation (#1056)
* [GUI] French: add missing Library.View strings and polish translation

- Add Library.View.Grid / Library.View.Carousel (were missing in fr.json)
- Translate Bink playback options Host/Guest and make the description match the displayed option names
- Use infinitive form for RenderDoc and crash dump descriptions, like the other options
- Use typographic apostrophes consistently

* [GUI] French: reword the Bink Ignorer description

Ignorer permet de passer les vidéos Bink reads more naturally than Ignorer saute les vidéos Bink.

---------

Co-authored-by: foufouadi <attar.foued@gmail.com>
2026-10-12 15:39:31 +02:00
Foued Attar c7bbb09311
Ghost of Yōtei and Tsushima in game: rendering, GPU/CPU memory coherence, tessellation
* [ShaderCompiler] Pass guest LDS size to SPIR-V compilation

* [SpirvTranslator] Synchronize wave64 half masks through LDS exchange

* [Fiber] Avoid formatting unused names and disabled traces

* [Shader] Build materialization cache ranges without temporary copies

* [ShaderCompiler] Intern scalar graphs without string keys or hit allocations

* [Media] Vectorize movie YUV conversion without changing pixels

* [Shader] Reuse scratch storage for resource read recording

* [Tests] Initialize presenter state for GPU binding validation

* [Vulkan] Keep temporary descriptor writes off the managed heap

* [GPU] Reuse bounded descriptor preparation scratch arrays

* [ShaderCompiler] Persist versioned scalar analysis across launches

* [GPU] Remove duplicated image layouts from texture bindings

* [ShaderCompiler] Bind cached graph payloads to their input keys

* [Vulkan] Load content-addressed driver cache shards on demand

* [Tests] Keep presenter allocation fixture independent of diagnostics

* WIP: Yotei runtime descriptors, compile-cost fixes and Codex memory work before PR #1000

Snapshot before merging upstream PR #1000: runtime image descriptors (global table),
format-store and fault-flush compile fixes, runtime buffer strides, stencil plane
fixes, Codex's image memory, D16 and residency work. Tests: all pass except the
two known failures (DescriptorReloadedInsideLoop_SharesOneSampler,
RuntimeFormattedLoad_ReadsItsElementThroughThePageTable).

* [Cpu] Claim guest thread stack and TLS regions atomically

TryMapGuestThreadRegion looked for a free slot and mapped it in two steps, and Map
reuses a region that already exists. Two host threads claiming a nested-callback stack
at the same time therefore got the same stack, and Map zeroed the first one's frames.

In Ghost of Yotei two render threads ran the AGC command-buffer-full callback on that
shared stack at once: one returned through the other's entry stub onto the other
thread's host stack, abandoning managed frames. The runtime then failed fast with
"attempted to call a UnmanagedCallersOnly method from managed code" or an access
violation, one to four minutes after the intro.

The slot search and the mapping now run under one lock shared by the stack and TLS
claims.

* [Gpu] Preserve downloaded buffers and batch resource descriptor reads

* [Vulkan] Forget driver cache identities of destroyed shader modules

The remaining change of the updated #1006 head (e6659681) that the integration branch did not carry.

* [Vk] Measure GPU busy time from per-submission timestamps

Per-draw timestamps are written only with SHARPEMU_PROFILE_GPU_DRAWS=1; by
default each submission gets two timestamps and the overlapping intervals
are merged into a [PERF][GPU_BUSY] line with the busy share and the longest
submission.

* [HLE] Reserve a host core for the render thread

On Windows hosts with 8 to 64 logical processors the render thread gets the
last physical core of the fastest efficiency class at the highest priority,
and guest threads are kept off that core so spinning job workers cannot take
the renderer's SMT sibling. SHARPEMU_RENDER_CORE=0 turns it off.

* [Vk] Spin briefly before blocking on a GPU wait

A GPU wait polls the timeline for SHARPEMU_GPU_WAIT_SPIN_US microseconds
(150 by default) before it sleeps, which saves the wake-up latency when the
work completes soon.

* [GpuMemory] Answer CPU-dirty range queries without the tracker lock

MayHaveCpuDirtyPages reads the per-block summary and the region masks without
taking the region lock; a true answer still goes through the locked path.
Regions also bump a CPU-dirty epoch whenever a page becomes CPU-dirty.

* [Gpu] Skip the device-address sweep while no page became CPU-dirty

PrepareBda remembers the CPU-dirty epoch and the mapping it swept last and
skips the full sweep of mapped memory until either changes.

* [GpuMemory] Scan possibly CPU-dirty blocks 64 at a time

ForEachPossiblyCpuDirtyRange combined a region lookup and a summary read for
every 4 MiB block of mapped memory on each device-address sweep. A bitset of
blocks that have a region and the dirty summary words are now scanned a word
at a time, with the same runs as before.

* [Gpu] Skip syncing a written buffer range that is already GPU-owned

SynchronizeBuffer returns early when a written range lies entirely in the
GPU-modified ranges, uses the lock-free CPU-dirty query for reads, and the
render profile splits buffer sync time into written, texel and upload parts.

* [Gpu] Version the GPU-modified buffer ranges

A written range bumps GpuModifiedVersion only when it adds bytes to the
GPU-modified set, so readers can cache a clean verdict for a page until the
GPU writes something new.

* [Gpu] Track the extent of DCC metadata and answer overlap queries

A color target records the size of its DCC metadata, and the image cache can
tell whether a guest range overlaps registered DCC metadata.

* [Diagnostics] Trace the GPU writers of DCC metadata and of a chosen address

SHARPEMU_TRACE_DCC_WRITERS=1 writes the shader program and a call stack for
GPU buffer writes that overlap DCC metadata; SHARPEMU_TRACE_WRITER_ADDRESS
does the same for writes that cover one guest address.

* [Gpu] Fill DCC metadata on the GPU and in guest memory at once

A fill that targets DCC metadata which no GPU write owns is recorded as a GPU
fill and also written to the guest backing directly, so the range never
becomes GPU-dirty and later CPU reads need no readback. Ranges the GPU still
owns or that overlap an image keep the plain buffer fill.

* [Gpu] Turn AGC fill compute shaders into buffer fills

BoundedFillDetector recognizes the AGC range-fill and pattern-fill compute
programs, including thread-dimension dispatches, and a dispatch of one that
targets DCC metadata becomes a DCC fill instead of a compute dispatch.
Refused conversions are reported by the DCC writer trace.

* [HLE] Resolve backing aliases for readers that hold an access window

SharedBackingViews publishes its view snapshot as an alias identity, and a
reader can enter an alias access window once and resolve guest pages to their
backing alias without the views lock until the snapshot changes.

* [Diagnostics] Trace shader resource reads that wait for GPU-written memory

SHARPEMU_TRACE_GPU_READS=1 writes the shader, stage, address and table phase
of a resource read that has to synchronize GPU-written memory, once per shader
and page.

* [ShaderCompiler] Keep recent materialization variants per draw key

A cache key now holds up to four materializations in most-recently-used order,
so alternating descriptor contents reuse their earlier result instead of
re-materializing. The cache report counts stale and unreadable entries and
table refreshes, and ReadingTable tells a reader when the flattened table is
being evaluated.

* [VideoOut] Read resource words through cached clean pages and the backing alias

TryReadGuestWord and TryReadResidentGuestBytes remember the last 64 pages
found free of GPU-dirty bytes, keyed by the GPU-modified version, and read
them through the backing alias inside the alias access window that each
command stream slice now holds. GPU-dirty checks are byte-precise, and a
resident read refuses unreadable ranges before it asks the page tracker.

* [Gpu] Read GPU-written guest memory back asynchronously and ahead of time

VulkanAsyncReadback keeps eight slots: slot zero serves synchronous reads and
the others carry asynchronous downloads. A guest thread that reads GPU-written
memory lets the render thread start the copy and waits for it on its own
thread, then the render thread applies it if the range is unchanged; a
foreign read keeps the source buffer alive until it completes.

Windows the render thread had to read synchronously become hot. When a batch
writes into a hot window, the window is downloaded right after the batch is
submitted, and the next read uses that copy if it is still current. A pending
write to a hot window ends the open batch early. SHARPEMU_ASYNC_GUEST_READBACK=0
keeps the synchronous path; [PERF][ASYNC_READBACK] reports the counts.

* [ShaderCompiler] Prefetch contiguous resource words in one resident read

Resource materialization read every descriptor and flattened-table word
through its own guarded TryReadGuestWord call, about 2.6 million per five
seconds in Demon's Souls. The raw reads of each plan are grouped once; at run
time their addresses are resolved, sorted and read as contiguous runs through
the resident guest reader, and the values seed the evaluator cache. A run the
GPU may still own, or one out of bounds or through a null base, falls back to
the word reads. The cache recorder records the prefetched words, and
SHARPEMU_RESOURCE_PREFETCH=0 turns the prefetch off.

* [Gpu] Reuse image lookups that found the same backing

FindImage remembers, in a 256-slot table, each request that resolved to an
image with the same backing, keyed by the request bytes and a generation
that every index registration or removal advances. A repeated request
touches the image and returns the remembered result without walking the page
owner index. SHARPEMU_IMAGE_LOOKUP_MEMO=0 turns it off; [PERF][IMAGE_LOOKUP]
reports hits and misses.

* [ShaderCompiler] Look up fused programs without the registry lock

Every shader header lookup and decode asked the fused-program registry under
its lock. The registry now publishes an immutable dictionary that readers
take with a volatile read, and a registration copies it under the lock.

* [ShaderCompiler] Decode fused programs whose halves are far apart

The fused decoder joined the continuation at its address distance from the
entry and refused halves more than 4 GiB apart or in the wrong order, which
Poppy Playtime now hits for a vertex shader (invalid-fused-layout). Such a
continuation is placed right after the entry instead, and each of its
instructions keeps its real offset from the program address in AddressOffset;
S_GETPC_B64 in the scalar graph, the SPIR-V and the MSL translators uses that
offset, and the persisted scalar analysis key includes it.

* [Gpu] Collect an image only once it is old enough in frames

Since images age by frame, clamping the age to the current frame made every
image of the first frame collectable during the first 16 frames, and a
submission that ended no frame could delete a texture the frame still used.
Without memory pressure nothing is collected until the full age has passed;
under pressure the clamp still lets collection relieve memory early.

* [Tests] Cover the deferred global barrier of an attachment-only scope

A global barrier still ends a scope whose draws store to memory. In a scope
whose draws only write attachments it waits for the scope to end, and a later
memory-writing draw ends the scope and records it.

* [Agc] Make the number of suspend points in flight configurable

CommandStreamQueue takes the number of frame boundaries that may wait behind
the graphics commands. The default stays one, so the guest CPU can run one
frame ahead, and SHARPEMU_SUSPEND_POINTS_IN_FLIGHT=0 makes a suspend point
wait until its own boundary is processed, for diagnosis. Tests cover both
modes, including a label that the CPU rewrites after the suspend point.

* [Gpu] Throttle the device-address fault and 3D slice range logs

Little Nightmares wrote 195 thousand "Accessed non-GPU cached memory" lines in
one session and Silent Hill 6800 slice range lines. Both now print the first
occurrences and then only at power-of-two counts, with the count.

* [Docs] Document the new environment variables

Adds SHARPEMU_ASYNC_GUEST_READBACK, SHARPEMU_GPU_WAIT_SPIN_US,
SHARPEMU_IMAGE_LOOKUP_MEMO, SHARPEMU_RESOURCE_PREFETCH,
SHARPEMU_SUSPEND_POINTS_IN_FLIGHT, SHARPEMU_RENDER_CORE,
SHARPEMU_TRACE_DCC_WRITERS, SHARPEMU_TRACE_GPU_READS,
SHARPEMU_TRACE_WRITER_ADDRESS and SHARPEMU_PROFILE_GPU_DRAWS, and corrects the
group counts to the rows the tables hold.

* [GpuCommands] Write the 64-bit label of a compute-done release with an interrupt

RELEASE_MEM with event 0x2F and a 64-bit data selection was accepted only
without an interrupt. It is now handled like the other end-of-pipe events:
the label is written and the interrupt is raised after it.

* [GpuCommands] Let a wait see its own queue's preceding label store

A memory wait that directly follows a 4- or 8-byte store of its queue to the
same address, with only waits or inert NOPs and markers between them, compares
against the stored value when guest memory no longer holds it. Dead Cells
writes a label and waits for it while the CPU already rewrites it for the next
frame. The forwarded value lives only within one slice, and any draw, dispatch
or transfer in between drops it.

* [ShaderCompiler] Key fused programs by the underlying guest memory

Each native guest thread wraps the guest memory in its own tracking wrapper,
so a fused program registered while one thread created the shader was not
found by the render context. The registry now unwraps ICpuMemoryWrapper
before it looks up the per-memory table.

* [Agc] Implement the legacy sceAgcFuseShaderHalves export

The older NID (nApJjpKNBl4) shares the fusing path but keeps the front half's
user data pointer, takes the larger value of the resource allocation fields
of both halves, takes the user SGPR count and the ES component flag from the
front half, and copies the checksum register of the matching stage.

* [ShaderCompiler] Decode S_CMOV_B64 as a conditional select

S_CMOV_B64 is decoded as S_CSELECT_B64 with the old destination pair as the
false source, so it reuses the SCC, register-pair and EXEC handling of the
translators and of the scalar value graph.

* [Gpu] Merge the stencil replacement references of a face

Vulkan has one stencil reference per face for the comparison and every
replace operation. Replacements with the test value and with the operation
value are now merged into that reference over the written bits, and refused
only when they conflict on bits the comparison also reads. A replacement
with an operation value of zero over the written bits becomes a zero
operation, which ASTRO's PLAYROOM uses to clear one stencil bit.

* [Json] Add the sce::Json::Value copy constructor and keep shadow values on copies

The copy constructor (fSb2oQTNrgA) shares the assignment path. Assignment
and reads now carry the value a constructor or setter recorded in the object
heap, so a copy keeps it after the source is destroyed.

* [Font] Implement sceFontTextSourceInit

Fills the 0x60-byte text source with the text range, the cursor, the parser
callback and its object, and the text size, and leaves the memory after it
untouched.

* [AudioIn] Answer sceAudioInGetSilentState without an input backend

No AudioIn port can be opened until an input backend exists, so the export
returns an AudioIn error code (0x80260101) instead of being an unresolved
import.

* [PhtreadSpecificSemanticsTests] Fixes for computed shaders

* [Gpu] Return idle buffer memory and bound retired memory under pressure

- Small-buffer slab chunks track their live blocks; completely idle chunks go back to
  Vulkan under memory pressure instead of staying allocated until device teardown.
- Buffers removed from lookup still hold GPU memory until their retirement completes.
  The cache now counts them, collects on registered plus retired bytes, and waits for
  completion once retired memory passes 256 MiB.
- The tiler's pooled scratch buffers are released under pressure, and image collection
  waits for completion when outstanding scratch is over budget.

* fix(shader): preserve wave64 ballot masks on Vulkan

* test(shader): cover subgroup lane bits in wave mask predicates

* [Pad] Give each new touchpad contact its own touch id

The pad assigns a new id (1 to 127, then back to 1) to every finger contact and keeps it
while the finger stays down. SharpEmu reported the finger slot instead, so lifting the
finger and touching elsewhere read as one contact jumping across the pad. Games that tell
strokes apart by the id (Ghost of Yotei's name drawing) never saw a new stroke.

* [Gpu] Stop device-address faults from churning the buffer cache

Shaders reach guest memory through pointers, and a page they touch outside the cache
faults and is registered afterwards. In Ghost of Yotei that path re-created tens of
thousands of buffers every few minutes:

- Faulted ranges outside the GPU mappings were never touched again, so they aged out
  while still in use and faulted again. They are now kept and touched each tick, and
  forgotten once their guest mapping is gone.
- A fault inside a guest mapping now brings in its aligned 2 MiB window, clipped to the
  mapping, instead of one 16 KiB page.
- Faults on memory the guest never mapped (bad pointers) no longer create buffers: the
  shader reads zeros there, as on the console. They were 98% of the faults and about
  2.6 GiB of junk buffers; the cache now holds ~0.5 GiB in the same scene.
- The per-fault log line is gone; it wrote hundreds of thousands of lines per session.

* [Vulkan] Save and load driver pipeline caches past 2 GiB

Ghost of Yotei's driver cache grows past 2 GiB. Saving went through a .NET array, so
every save failed and new pipelines were lost at the next launch; loading had the same
limit. Both now stream the unchanged file format from and to native memory in chunks,
so existing caches still load.

* [Np] Treat NO_ENTITLEMENT for unowned add-on content as an expected result

sceNpEntitlementAccessGetAddcontEntitlementInfo returns NO_ENTITLEMENT when the user does
not own the add-on, as on the console. The import log reported it as a warning.

* [Vulkan] Log only slow pipeline creations

Every pipeline creation was logged, cache hits included, which wrote thousands of
0.0 ms lines per session. Only creations of 500 ms or more are logged now.

* [Shader] Treat a phi that merges one value as that value when planning table reads

* [Test] Read the formatted-load V# from GPU memory so the load goes through the page table

* [Repo] Add license headers to new files and build pipebench with central package versions

* [Test] Add the missing copyright line to the large dispatcher tests

* [Repo] Remove game-specific RenderDoc capture scripts

* [Repo] Remove the pipebench tool

* [Kernel] Implement sceKernelAioPollRequest

* [ShaderCompiler] Return the entry header address of a registered fused program

An overload of TryGetFusedProgramParts also gives the entry header, so a
caller can register the same fused program on another memory.

* [Vulkan] Keep saving a pipeline cache that outgrows its limit

A driver cache over 256 MiB was refused on every save, so it was never
written again and every launch compiled the same pipelines. Such a cache
is now written as an empty file once, and the next launch starts a fresh
one. A save is skipped when the driver reports the size that was loaded
or last saved, and a failed or deferred save waits for the next interval
instead of running again on every new pipeline.

* [Vulkan] Write only the cache shards that gained pipelines

A shard remembers the driver size it was loaded or saved with and is not
written again while that size holds. Shard saves are logged as one
summary line, and a shard load only when it is slow.

* [Vulkan] Remove the single-file cache once the shard mode takes over

The shard mode never reads the single cache file of the default path, so
it is deleted instead of staying on disk.

* [Gpu] Record compiled compute programs in a shader prewarm list

When the pipeline host gives a list, each compute permutation the game
compiles is appended at once to shader-prewarm.bin: the guest code the
decoder read (once per program), the fused parts, the compute input, the
system registers, the resource specialization and the push data start.
Records carry a checksum, so a cut tail is dropped at the next open.
TryCompilePrewarm rebuilds the compile request from a record through the
same layout and request code as the runtime, so it produces the same
SPIR-V.

* [Vulkan] Prewarm the recorded compute pipelines after a translator or driver change

A first run after a translator change compiled every compute pipeline
one at a time while the render thread waited (Demon's Souls: 282
pipelines, 144 s). When the translator assemblies or the driver differ
from shader-prewarm.stamp, background threads at below-normal priority
translate the listed programs again and create their pipelines, so the
driver has them before the game asks. With the same build and driver the
launch does no prewarm work. The log reports the prewarm result and how
many runtime compute modules it produced.

* [Vulkan] Keep the pipeline cache in memory on Windows

The GPU driver keeps its own shader cache on disk, and the shader prewarm
list rebuilds it after an update, so Windows no longer writes the driver
cache file by default. SHARPEMU_VK_PIPELINE_CACHE=file keeps one; Linux
keeps the file and macOS keeps its shards.

* [Docs] Document the pipeline cache modes and the shader prewarm list

Adds SHARPEMU_SHADER_PREWARM, SHARPEMU_VK_PIPELINE_CACHE_SHARDS and
SHARPEMU_VK_PIPELINE_CACHE_LEGACY_SHARDS, and describes the new cache
file default.

* [DeS] Fixes for page faults and stack bounds issues in the emulator and audio fixes

* [ShaderPrewarm] Hold the game until the prewarm finishes and resume interrupted runs

The prewarm compiled thousands of compute pipelines on background threads
while the now faster boot ran beside it, so logos, Bink movies and the first
scenes raced the compile and dropped frames.

- Hold the guest at its first splash hide, GPU submission or CPU flip until
  the prewarm is decided and done; the render thread keeps presenting the
  splash. SHARPEMU_SHADER_PREWARM_WAIT=0 opts out.
- Stamp only the shader translators, the compiler type and the driver, so a
  Libs-only build no longer triggers a full prewarm.
- Record finished pipelines in shader-prewarm.progress so an interrupted
  prewarm resumes with the remaining ones.

* [AudioOut2] Block a blocking push only until a queue slot frees

A blocking sceAudioOut2ContextPush waited only when the host stream hit its
128 KiB cap, so a title rendering faster than real time kept about 680 ms
queued and every sound played that far behind (GTA V: queue=2 context,
queued_ms avg=678). The push now waits until the device holds the cushion
plus QueueDepth-1 grains, the level GetQueueLevel already reports as full;
the host cap stays as the limit for non-blocking bursts. GTA V now holds
about 41 ms with no underruns.

* [ShaderCompiler] Share the device-address formatted load decoder per component count

Every BUFFER_LOAD_FORMAT through a device address inlined the whole page-table
walk, format conversion and bounds check. Shaders with many such loads grew to
several megabytes of SPIR-V; 128 D16 loads added about 4600 words each.

Emit one DontInline function per component count, taking the address, size,
offset, descriptor and caller PC. Fault recording, formats, swizzles, EXEC
and out-of-range values are unchanged.

Offline A/B on three extracted shaders (RTX 5070, driver cache disabled):
18.6 s -> 2.9 s, 12.9 s -> 2.8 s, 15.9 s -> 3.2 s.

* [Gpu] Never copy a linear image readback past its guest range

The readback storage is sized from the guest range, but the copy follows the
linear layout computed for the format, whose padded rows can need more
bytes. A 513x513 R8 image declared as 0x40401 bytes was copied with
768-byte rows (0x60201 bytes): the copy wrote past its slot in the
download ring, and at the end of the ring into freed memory, which lost
the device.

Plan the readback only when the copy fits the guest range (and the
partial-level readback only when it fits its covered bytes), with one
warning naming the image. A linear download that would still exceed its
destination now fails explicitly instead of corrupting GPU memory.

* [Gpu] Draw merged LS/HS tessellation through a native tessellator

The guest hull runs unchanged in a cooperative wave64 compute workgroup and
computes its own control points and tessellation factors. A generated
control stage feeds those factors to Vulkan's tessellator, and the guest
domain shader is translated to a tessellation evaluation stage that reads
the guest control-point ring.

- VGT_TF_PARAM gives the domain, spacing and winding; VGT_LS_HS_CONFIG
  gives the patches per group and the control-point counts.
- Factors are clamped by the guest's minimum and maximum levels; zero or
  NaN outer factors still cull the patch. Isoline factors are swapped
  into Vulkan's order.
- The SPI_SHADER_USER_DATA_ADDR_HS pair is carried separately from the LS
  scalars and moved to s0:s1 before the guest code runs.
- Patches are drawn one hull group at a time, because the guest rings
  are reused by relative patch id.
- A configuration the bridge cannot express (power-of-two partitioning,
  an undefined domain, an empty hull group) skips the draw with one
  warning instead of stopping the emulator.

* [VideoOut] Verify a guest page once for clean resource reads

Resource materialization reads a few hundred guest words per draw through
the clean readers, and each read re-checked the whole GPU ownership of its
bytes: GPU-dirty pages and bytes, then the image cache, which built a new
list (twice per word) under its lock. In Ghost of Yotei that was about 550
million reads and 90 GB of allocations per minute in game.

- The image cache answers whether an unsynchronized GPU image covers the
  bytes without building the list; the list is built only when one does.
- The clean-page memo, already used by plain reads, now serves clean reads
  too. A page is verified clean once (no GPU-dirty bytes or pages, no
  GPU-modified image) and trusted until a buffer or an image is written by
  the GPU, which advances the buffer cache's or the images' write version.
  GPU-dirty pages are still checked on every read.

Measured in game: one materialization went from 0.33 ms to 0.17 ms.

* [Gpu] Keep the guest's face culling on tessellated patches

A patch list stands for legacy rectangle lists, which are never culled, so
every patch-list draw dropped its culling. Tessellated draws use the patch
topology too, and their output rasterizes as ordinary triangles: back
faces were drawn. Only a patch list without tessellation stages is a
rectangle list now.

* [ShaderCompiler] Give the domain shader its patch ID in v8

The ES ABI passes u, v, the patch within its hull group and the patch ID in
v5..v8. v8 was never written. Each native draw covers one hull group, so
the patch ID is the group's first patch plus the native primitive ID.

* [Gpu] Run tessellation hull groups together in their own off-chip slots

The hardware gives each hull threadgroup in flight its own off-chip buffer
and its own run of the factor ring, so groups run together and one draw
consumes them. Every group used slot zero instead, which serialized the
draw into one dispatch, one draw and full barriers per group.

- sceAgcDriverSetHsOffchipParam (VGT_HS_OFFCHIP_PARAM) is kept: buffer
  count and granularity. Ghost of Yotei sets 256 buffers of 8K dwords.
- Hull workgroup k runs group k: its first patch, its patch count, s2 at
  slot k and s4 at group k's run of the factor ring.
- The domain shader finds its group from the native primitive ID: the
  off-chip slot in s4, the patch within the group in v7, the patch ID in
  v8.
- One dispatch and one draw cover as many groups as the off-chip buffers
  and the factor ring hold. Without the guest's configuration, one group
  runs at a time as before.

* [Tests] Restore two upstream tests the branch dropped without a behaviour change

DepthState_PlayroomReplacementClearsOnlyTheWrittenBit and
FormattedUntypedLoad_SpecializesOnTheDescriptorFormat were removed by
earlier branch work although the code still behaves as they expect; both
pass unchanged.

* [Tests] Check the AudioOut2 port volume and reserved bytes again

The layout change dropped the checks of the -1 volume at +4 and the zeroed
bytes at +8, which the export still writes.

* [Repo] Remove the branch's debugging switches

The capture-after-seconds RenderDoc request, the crash-capture directory and
first-chance fault traces, the unsupported shader dump, the AGC allocation
failure traces and the control-flow trace served earlier investigations
only. The configuration switches (direct memory size, image cache budget,
forcing the shader dispatcher) stay.

* [PlayGo] Do not reject chunk IDs the playgo.pgm numbering only assumes

playgo.pgm is read for its chunk count only; the IDs 0..count-1 are
derived, yet they were marked authoritative and any other ID returned
BAD_CHUNK_ID. The list is still reported, but like other metadata that
is not understood, it no longer rejects an ID outside it.

* [Vulkan] Bound the bindless sampler binding by the device's sampler limits

The persistent heap always declared 4096 update-after-bind samplers and
left them out of the pool-wide descriptor budget it split between sampled
and storage images. On a device with fewer samplers per stage, or a tight
update-after-bind total (MoltenVK, some Intel drivers), creating the heap
could fail.

The sampler binding now takes the smallest of the device's per-stage and
per-set sampler limits (at most 4096), and its share comes out of the
total before the images split the rest.

* [Gpu] Copy linear render targets with the guest's own pitch

A linear render target's builder lays it out with the guest's pitch, which
need not be aligned like a texture's: a 513x513 R8 target is 513 bytes per
row. Transfers recomputed the texture's aligned linear layout (768-byte
rows), so its uploads read shifted rows and its readbacks did not fit the
guest range and were skipped.

A single-level, non-volume linear image whose description holds a
consistent slice layout now transfers with that layout. Textures, which
carry no layout of their own, keep the format's linear layout, and the
readback guard still refuses a copy that does not fit.

* [Gpu] Free images and retry once when an image allocation runs out of memory

The image budget comes from the device's reported memory, so an allocation
can still fail when the driver or another process holds more than
expected. That was fatal.

Running out of device or host memory while allocating an image's backing
is now recoverable: the cache retires every image collection allows, waits
for the retired ones to be destroyed, returns unused pool memory and tries
once more. A second failure stays fatal. Tests make an allocation fail
through an internal hook on GpuDeviceInfo.

* [Vulkan] Size descriptor pools for the sets that need them

The branch had raised every descriptor pool to 65536 storage buffers,
262144 sampled images and 8192 storage images so the largest shader's set
would fit, and every pool paid for that on every device.

Pools are back to upstream's ordinary size (8192/8192/1024/1024). When a
set does not fit one, the next pool is sized for four sets of its kind,
and a retired pool is reused only if it can hold the set. MaxImages stays
the planner's bound on distinct image descriptors per shader.

* [Vulkan] Blend with a second source from the MRT1 export

Blend factors 15-18 (SRC1 color and alpha) were mapped to Vulkan's Src1
factors without enabling dualSrcBlend or giving the fragment shader a
second output, so the blend read an undefined value and validation
reported every such pipeline.

When the device supports dualSrcBlend, it is enabled, and a draw whose
target 0 blends with an SRC1 factor (and binds no other target, as on the
hardware) gets a second fragment output fed by the MRT1 export, declared at
location 0 with index 1. The translator accepts that one extra output and
keys the compiled program by it.

* [Tests] Skip device tests without a Vulkan device, as upstream does

The branch had turned the device gates into assertions, so every device test
failed on runners without a GPU. SHARPEMU_TEST_REQUIRE_DEVICE=1 still makes a
missing device a failure.

* [Gpu] Wait for pending image readbacks before uploading over them

A tiled image retired under pressure publishes its contents only when its
readback tick completes. Found again before then, it was rebuilt from the
guest bytes it supersedes. Uploads now wait for the readbacks into their range.

* [Shader] Unpack compressed 16-bit pixel exports by their export format

Compressed exports were always unpacked as half floats. UNORM16, SNORM16,
UINT16 and SINT16 exports (G-buffer normals among them) now follow the
SPI_SHADER_COL_FORMAT of their target, which also keys the compiled program.

* [Agc] Emit direct draws for every draw modifier

sceAgcDcbDrawIndex and sceAgcDcbDrawIndexAuto returned without writing the
draw for any modifier other than 0x40000000, so whole passes vanished
without a trace.

* [Agc] Emit addressed occlusion counter events

sceAgcDcbEventWrite refused every event with an address, so the 0x38/0x39
counter events never reached the command stream. Their results are now
published through the ordered buffer fill path instead of CPU memory only.

* [Gpu] Execute target-less draws whose pixel shader writes memory

A draw without color or depth targets was dropped before compilation even
when its pixel shader stores to buffers or images. Yotei fills its visibility
buffer this way; the dropped pass left most of the scene black.

* [Shader] Keep EXEC guards after V_CMPX with a legacy scalar destination

GFX10 V_CMPX in VOP3 or SDWA form changes EXEC and ignores its scalar
destination. The EXEC-full analysis took it as a write of that destination,
kept EXEC full and elided the guards of the following vector writes.

Ported from voidborn-astra/sharperemu 8499b905.

* [Vulkan] Use unsigned operations for scalar pair bit instructions

Ported from voidborn-astra/sharperemu c0cff157.

* [Gpu] Invalidate image copies for raw buffer writes

Ported from voidborn-astra/sharperemu caa4149d.

* [Gpu] Retain GPU image contents during collection

A GPU-written image whose readback a dirty buffer overlap blocks was cleared
and deleted, losing its contents. It is kept instead; same-page CPU writes are
resolved by hash before deciding.

Ported from voidborn-astra/sharperemu 3b5eb2f3.

* [Shader] Read the selected lane for V_READLANE in graphics stages

Graphics stages took the reading lane's own value instead of the selected
lane. A graphics wave is one group of up to 32 host lanes; the selected lane
of that group is now read with a subgroup shuffle.

* [Tests] Cover Yotei's wave64 march and waterfall loop shapes

A step counter compared into EXEC with lanes leaving through VCC, and a
waterfall over an SDWA compare into an SGPR pair. Both already end every lane
in both halves of the wave.

* [Vulkan] Make depth bounds dynamic pipeline state

The depth bounds were part of the graphics pipeline key. Ghost of Yotei moves
them per draw, so every value created a new pipeline: about 80 per frame and
thousands kept for the session. They are now set with vkCmdSetDepthBounds per
draw; the key bytes they used stay reserved.

* [Shader] Read absent graphics lanes as the reading lane

A host subgroup of a graphics stage can be partly empty, where a PS5 wave
still has dead lanes. DPP, V_READLANE and V_PERMLANE reads of an absent lane
returned undefined values: Ghost of Yotei's wave minimum then never reached its
exit and its pixel shaders ran until the step guard. An absent lane now reads
the calling lane's value, which keeps min/max/and/or reductions exact.

* [Memory] Skip the ownership transition for pages already CPU-dirty

A CPU write to pages that are all CPU-dirty re-ran ChangeState, which scans the
whole 4 MiB region and republishes the same summary, for every managed guest
store. CPU and GPU dirty states are exclusive, so nothing can change there.

* [GPU] Keep the previous frame's images through mid-frame collection

Collection runs before every draw while image memory is under pressure. It
evicted every image not used yet in the current frame, which the rest of the
frame samples again, so Ghost of Yotei recreated and uploaded about 7000
images per frame. Below the critical level the mid-frame pass now leaves
them alone; frame-end collection and allocation-time reclaim are unchanged.

* [GPU] Copy the newer GPU contents between same-layout image aliases

One surface read through two formats of the same layout is two cache
images over the same guest bytes. Before one is used as a texture, storage
image or target, it copies the newest GPU contents of its same-layout
aliases on the GPU, ordered by write sequence, as unified memory shows
them. Depth keeps its reinterpretation path.

* fix(kernel): provide read-only entropy device descriptors

(cherry picked from commit 2ad0eca819ba8456b42177d4e682915d99fb7639)

* fix(net): track epoll descriptor creation and destruction

(cherry picked from commit e1c5658ed5f9579756111c89943b136fb995182b)

* fix(pthread): implement relative timed mutex acquisition

(cherry picked from commit c75f28f489a7fa3f78d6baae533e49c33dc10cb7)

* fix(net): implement level-triggered socket epoll waits

(cherry picked from commit 2f27f0078888007861f71afc5615e5c9a7246532)

* fix(kernel): release timed-out event flag waiters

(cherry picked from commit 4daa8a34e4c0534d3a0ebcc79f7a15597aaed330)

* fix(system-service): report empty event polls

(cherry picked from commit 65fb9aae7a7ee994ce8ab5b70f8fb00df2f7c742)

* fix(videoout): preserve decoded frame presentation order

(cherry picked from commit 66526f0eedc4da4253a6650b5563c257ff5c278d)

* fix(ajm): decode ATRAC9 jobs from split buffers

(cherry picked from commit e8c9925d86eb886d8734913a0ba7c10c125cc209)

* fix(videodec): finish queued frames before decoder deletion

(cherry picked from commit eae19e7c170998af3f4cf865759536fb33886e28)

* fix(net): confirm socket errors before reporting epoll error readiness

(cherry picked from commit 95128f67d446b4f659692584d241bdc85a5a9af7)

* fix(net): format guest Ethernet addresses

(cherry picked from commit 08cca779b5c000b46322ce16eb75b9739f312175)

* fix(net): keep one sceNetEtherNtostr export

Both branches added sceNetEtherNtostr. Keep the version that reports an
unreadable source address as a fault, the errno a guest read fault gives.

* fix(net): distinguish pending accepts from socket hangups

(cherry picked from commit 28ad0c0a9d989ad8f368d471354ede295ce9acf7)

* fix(net): avoid querying payload bytes on listening sockets

(cherry picked from commit 2aac142b2fb58b4b398bb1f2dfdf96cd94a388f1)

* fix(audio): avoid pacing empty nonblocking context pushes

A nonblocking push returns at once. The grain it claims still counts in the
queue level when no host backend is attached; only a blocking push waits
for its slot.

(cherry picked from commit 9b2db6d814f89b44cf46da11c3f8ced474ace063,
adapted to keep the queued grain accounted)

* chore(net): remove an investigation trace call

(network part of 8d0bd2fc; its PlayGo trace only exists in the skipped d342dd1c)

* fix(videodec2): preserve the picture-info size prefix

Without a picture only the ready flag at offset 8 is cleared; the caller's
size prefix and the other fields are preserved. A decoded picture still
fills the whole output info, pitch included.

(cherry picked from commit 050e9c824ad4a1ac2d9a40a6d6dd9ba1f63fd4fb,
adapted to keep the complete output info for decoded pictures)

* fix(videodec): preserve adjacent picture-info fields

Width and height are 32-bit fields with the pitch between them.

(cherry picked from commit 88a79715; the complete output info this branch
writes for a decoded picture already used 32-bit fields, so only the
dimensions helper and its test are added)

* fix(shader): report descriptor accesses that fall back to the null descriptor

A control-dependent image or sampler descriptor with no plan-time source
reads the null descriptor, so its texels read as zero. The plan records
each such access and the host warns once per shader with its PC, so the
loss is visible instead of silent.

* fix(shader): read control-dependent descriptors at run time before the null fallback

A sampled float access whose image or sampler descriptor has no plan-time
source read the null descriptor even when the runtime descriptor table
could serve it, because the null fallback ran before the runtime path was
tried. Such an access now reads its descriptors at run time, as the
hardware reads them from memory; the null fallback stays for accesses the
table cannot serve yet.

* fix(gpu): keep invalid runtime image descriptors on the null texture

A shader reading its descriptors at run time reports every image it does not
find. One with a null base or a format without a host equivalent stopped
the emulator when the host tried to create it. It now stays out of the
table, so the shader keeps reading the null texture, as the hardware does
for such a descriptor.

* fix(gpu): translate equivalent stencil replacement states

(cherry picked from commit a6e7922dbede9548fe5ef8795d29387de98b1bf8)

* fix(gpu): accept GFX10 image resource-level bit

(cherry picked from commit 1cbafc0a52e44592e34c5ae708d98573f6d2e9d6)

* fix(gpu): preserve addressed texture view alignment

(cherry picked from commit 936d873f70922337b7e9557cf4e579f4ed73d471)

* test: keep only the tests that 1cbafc0a and 936d873f add

Resolving their test conflicts kept test blocks from later #1057 commits
that depend on code not merged yet; only each commit's own test remains.

* fix(gpu): read disjoint descriptor bytes through backing aliases

GPU ownership is tracked in bytes but page protection covers whole pages:
CPU-owned bytes on a page that also holds GPU-owned bytes are read through
the backing alias, without bringing the neighbouring GPU bytes back.

(cherry picked from commit 7c8f0b97; this branch's clean word reader, which
first synchronises GPU-owned words, is kept, and the test keeps the
resident-range assertions)

* fix(gpu): read indirect tessellation draw arguments on the CPU

Non-indexed indirect draws now leave their arguments to the GPU (#1036), but the
merged tessellation path counts patches from the vertex count on the CPU: an indirect
tessellation draw arrived with the placeholder count of one and drew no patch.

* fix(gpu): write a replaced stencil plane back before dropping its proxy

A depth buffer whose stencil plane has another size at the same address drops the
other buffer's stencil proxy. When the GPU had written that plane, deleting the
proxy stopped the emulator (Ghost of Tsushima). The plane now reaches guest memory
first, as when a depth buffer changes planes, and stencil write-backs register as
publishing downloads so an upload of the range waits for them.

* feat(shader): read image-load descriptors at run time

An ImageLoad whose descriptor has no plan-time source looks its words up in the
runtime descriptor table instead of reading the null descriptor. The key carries the
numeric class the descriptor format selects, so the shader fetches through the uint,
sint or float heap array the host view matches.

- Only active lanes record misses; an inactive lane may hold anything.
- A missed descriptor over unbacked memory stays out of the table, and an entry whose
  memory the title freed leaves it when its image is retired.
- An entry keeps the texture it resolved to until the cache retires its image, so a
  draw no longer looks every entry up again; each draw still acquires its views.
- A plan that reads descriptors at run time on a host without the bindless heap is a
  rejected plan, not a fatal layout error.

* fix(shader): resolve descriptors from empty scalar buffers

Descriptor words read from an empty scalar buffer are zero whatever the offset, so
the access reads the null descriptor; materialization rechecks that the buffer is
still empty. Ghost of Tsushima's pixel shaders read image descriptors this way.

(cherry picked from commit 5d03a71aacdead6adfbc9f46b5eeb777168e759a; on this branch
an access the runtime descriptor table can read keeps that path, which also serves a
buffer that is not empty, so the proof applies to the other accesses only. Its tests
use a depth-compare sample accordingly, the indirect-image case stays a runtime read,
and the planning-dump fixture adds one to its descriptor as f73663e8 does upstream)

* fix(shader): preserve scalar sources in immediate comparisons

(cherry picked from commit 9f4e325df34d9c075d9625da9994ecccfa9d1ac3)

* fix(shader): count unsigned leading bits in scalar instructions

(cherry picked from commit 09e9e1de7a8de12d92de39d51eed744ff4416fa6)

* fix(shader): bind signed integer storage images

(cherry picked from commit 1c134311d66ea6690a4549dff933017e1e478c5a)

* fix(shader): view only 32-bit signed storage writes as unsigned

A write-only signed storage image went through an unsigned view for every signed
format while no signed storage binding existed. With signed bindings (1c134311),
only the 32-bit format keeps that view, where the bits are the same; a 16-bit signed
image stores through its own signed view again.

* feat(shader): read adjusted-sample descriptors at run time

The IMAGE_SAMPLE_*_A forms carry their adjustment in the sampler's reserved dword-3
bits. Their descriptors without a plan-time source now go through the runtime table
like a plain sample, with those bits masked out of the sampler key so each
adjustment does not register a sampler of its own. A Ghost of Tsushima pixel shader
reads the image of such a sample from a material record that the plan cannot prove.

* fix(shader): qualify single-mode sample interpolants

(cherry picked from commit 3026bb288124f001f828f550755a2e4117c67ec0)

* fix(gpu): preserve sample masks and decode signed locations

(cherry picked from commit 3ab11b99d98482694037365c4e3865839544d891)

* fix(gpu): distinguish attachment sample counts in pipeline keys

(cherry picked from commit 51dd915d42f2ec877df970b66e0f7d2fc57b1300)

* fix(gpu): preserve EQAA depth and overrasterization controls

(cherry picked from commit 1087b9fba3914d3afda21041897c223dda3f324d)

* fix(gpu): retain scan mode control for sample iteration

(cherry picked from commit c8614eae54bc66d18ee60ac594280b9659b1fdd4)

* test(gpu): verify captured mixed-sample register decoding

(cherry picked from commit 943fc08e17f11dbe0995849813900a666781d42c)

* feat(shader): support explicit pixel interpolation samples

(cherry picked from commit fa66d55425a6108760f5dc1f7d6d5e9f62c2b8d9)

* fix(shader): honor eligible early depth testing in Vulkan

(cherry picked from commit 9b34fa404eaca555170e9c5fb18622a907b70179)

* fix(vulkan): preserve pixel sample-mask exports

(cherry picked from commit 5fbe6a3b38c1724c8c66fcbb6eb37aacdde4a981)

* fix(vulkan): preserve pixel depth exports

(cherry picked from commit bad8e123e566cf0a1a835fd5fa8736625ffa9581)

* fix(gpu): separate pixel iteration rate from interpolation inputs

(cherry picked from commit 9e0bdd4cea8105243347772328ec69a0a77e7b11)

* fix(vulkan): interpolate fixed custom samples with explicit offsets

(cherry picked from commit 8e7796636bed4fad5d5c65ce04a35b970fc369be)

* fix(vulkan): gate fragment execution with post-depth sample coverage

(cherry picked from commit 4c981e8edfae91b0a9ecdc336a2813ec841941f6)

* fix(gpu): apply sample exclusion to early fragment execution

(cherry picked from commit 0f5b5a71c54983db73d5d461088f3285cf13641a)

* fix(vulkan): preserve per-sample custom interpolation coordinates

(cherry picked from commit bfb5d7618b7ec994f2197c8f50d8b717f3c6bca6)

* fix(gpu): preserve late depth updates for shader coverage changes

(cherry picked from commit 4f8bcb6d142a7352a58d02328d798b390f244ee5)

* fix(gpu): key shader programs by custom sample positions

(cherry picked from commit dd36fcea9b83fd90ac1aea5af770cfdebbe5f987)

* fix(vulkan): preserve two-sample depth with single-sample color

(cherry picked from commit 042e5c4d19c5b104f0c20b0a38d87cb349d9241d)

* fix(gpu): reject unrepresented two-sample coverage masks

(cherry picked from commit b8fe958ac343b049295392a61c99419115020e63)

* fix(gpu): preserve forced shader depth ordering

(cherry picked from commit 58a4b282c89b2f35d04fe46a3f702c990f5164f0)

* fix(gpu): use late depth for unforced shader depth exports

(cherry picked from commit 29fe184bf1d241af6e8bfa521020d38d2eaba589)

* fix(shader): reject unsupported multisample sampling modules

(cherry picked from commit e77451e273d12e9c11355004654d592235e6aa63)

* fix(gpu): keep runtime descriptors that describe no view out of the table

A T# read at run time holds whatever the lane computed; the hardware reads one that
describes no valid view as the null texture. Ghost of Tsushima samples through such a
descriptor (image type 0, base level past the last level) before choosing another
one. The runtime path now asks for the texture without stopping on it: the T# checks
of the texture builder go through one rejection, fatal on the planned path as before
and a failure for the runtime one, which keeps that descriptor out of the table.

* perf(loader): sample repeated unresolved import warnings

(cherry picked from commit 842c3eea5228f017f26941e2f5ad9f69e678c002)

* perf(shader): read buffer-table descriptors at run time when the access allows it

A descriptor read from a table in a buffer, indexed by a lane value, was planned as
a dense table: materialization takes every record the buffer holds and each draw
finds, refreshes and binds all of them. A Ghost of Tsushima material table made that
about 2000 images per draw, nearly all of the render thread's time. An access the
runtime descriptor table can read now takes that path and binds only the records the
shader reads; a storage, compare or cube access keeps the dense table.

* fix(shader): read sample 0 for samples of a multisample image

Vulkan cannot sample a multisample image, and e77451e2 stopped such a module from
compiling, which stopped the emulator at a Ghost of Tsushima gameplay pixel shader.
A sample instruction on a 2D multisample image now fetches sample 0 of the texel its
normalized coordinate falls in, as shadPS4 lowers it, and the image is bound without
a sampler. A gather or an arrayed multisample sample is still refused.

* fix(gpu): free images and retry once when a buffer allocation runs out of memory

Images already retried after freeing what can go; a buffer allocation that found
every memory type full stopped the emulator (Ghost of Tsushima gameplay). The buffer
allocation now reports the failure, the buffer cache asks the image cache to free its
images and wait for retired ones, and tries once more before stopping.

* fix(vulkan): initialize format buffer scratch for runtime resources

* fix(shader): invalidate stale prewarm resource plans

* chore(sync): carry upstream Unity compatibility fixes

Apply upstream/main's post-revert Unity compatibility changes while preserving the Yotei-specific AudioOut2 state layout. The upstream rendering reversion is intentionally not imported because this branch still depends on those rendering changes.

* fix(shader): bind incompatible-only indirect image tables as typed nulls

An indirect image table whose candidates all have a dimension the MIMG
instruction cannot address (Demon's Souls 0x9D1D8F5B7F200DE7: every
candidate 2D, instruction MSAA) failed with "no typed candidate". Keep
the instruction's declared image type and bind every candidate as a
typed null instead.

* debug(gpu): dump the whole command buffer on an unknown packet

Temporary diagnostic for a tester's Yōtei crash ("packet opcode is
unknown", header 0x00000000). The old dump showed only 24 dwords around
the bad packet, too few to re-parse the stream and find which earlier
packet is misaligned. Dump from the start of the buffer instead.

* perf(gpu): copy CPU-write-hot pages once per memory visibility point

Every draw with a read-only device-address range re-copied the range's
CPU-write-hot pages: they stay writable, so they read as dirty forever.
In Ghost of Yotei that was ~47% of the render thread (thousands of
draws per frame re-copying the same pages, each copy ending the render
pass behind a full barrier).

The guest only guarantees CPU writes are visible at memory visibility
points (a submission slice, a satisfied wait), the rule the PrepareBda
sweep already follows. Each buffer now remembers the range whose hot
pages it copied in the current visibility generation and skips them
there; pages that fault dirty are still copied on every draw. A
command-processor write invalidates only the buffers holding the
written bytes instead of starting a new generation.

SHARPEMU_BDA_RESYNC_HOT_EVERY_DRAW=1 restores the previous behaviour.
[PERF][BDA_SYNC] reports visibility points, processor writes and how
many syncs skipped hot pages.

* perf(vulkan): stop retaining per-draw texture bindings until GPU completion

Ghost of Yotei binds ~300 images per draw, and each one allocated a
TextureResource of ~1.3 KB that stayed referenced until its submission
retired. At under one frame per second every binding outlived gen0, so
nearly every GC was a gen1 collection (299 gen1 vs 35 gen0 in 15 s).

- Drop TextureResource.Resolution: it was written in one place and never
  read, and duplicated the ImageRequest (about half the object).
- Keep only the host movie planes, which own a staging buffer recycled
  at completion; the other bindings die with the draw.

Measured in game: gen1 collections fell to 9 per 15 s.

* perf(images): widen the image lookup memo to 4096 slots

The direct-mapped memo had 256 slots, while a Yotei draw binds ~300
images and a frame thousands, so slots evicted each other and only 42%
of lookups hit. With 4096 slots (about 2.5 MB) 90% hit.

* fix(agc): allocate command buffer space atomically

Guest threads run HLE exports concurrently. Two exports appending to one
command buffer both read its cursor before either stored it back, so the
second packet overwrote the first. A tester's Yotei DCB showed a
PopMarker NOP written over the first two dwords of a SET_SH_REG 0x228
packet; the stream then hit its trailing zero dword ("packet opcode is
unknown, header 0x00000000") about 20 s into the game.

The cursor update is now atomic per buffer through striped locks; the
lock is released around the guest's buffer-full callback. A one-time
"[AGC][WARN] Two threads append to one command buffer" line reports the
race when the same buffer is contended.

* fix(gpu): stop executing queued GPU work once guest memory is torn down

Closing the window raced the runtime teardown: the runtime detached the
guest GPU memory registry while the presenter's shutdown drain was still
executing queued draws, so the first device-address draw died with
"A device-address program needs the guest GPU memory registry" and the
end-of-run reports were lost. The drain now drops the remaining work
once the registry is gone.

Also report the audio backend a port actually opened instead of a
hardcoded "coreaudio" in the AudioOut output trace.

* fix(runtime): release guest GPU memory only after the presenter's caches

Runtime teardown disposed the guest GPU memory while the presenter was
still shutting down on its own thread: its image and buffer caches still
held page watches, so PageGuard.Dispose failed fast with "Cannot release
the page guard while page state is active". Wait (bounded) until the
presenter detaches its stores, and keep the memory mapped if it does not.

* perf(buffers): stop read-aheads of windows the GPU keeps rewriting

Eager readbacks copied a 512 KiB window after every batch that wrote a
hot window. In Yōtei's loading phase and cinematic the GPU rewrites those
windows before the CPU reads them: of ~2,900 read-aheads per 10 s, 3 were
used and ~2,800 were discarded as stale. Count consecutive wasted
read-aheads per window, skip a window after two, and give every window a
new chance every 256 batches.

* perf(buffers): read only the requested pages from mapped device memory

A guest read of GPU-written memory served from a unified (ReBAR) buffer
copied the whole 512 KiB readback window out of device-local memory,
which the CPU reads uncached across the bus. In Yōtei's loading phase
that memmove was 30% of the render thread. Mapped reads now copy a
16 KiB window around the request; copy-command readbacks keep the large
window. Loading phase, same 10 s window: QueueRelay 2447 -> 947 ms,
frames 205 -> 252.

* diag(gpu): report how tessellated draws split into batches

Adds [PERF][TESSELLATION] to the render profile: draws, instances, patches, batches per draw and the off-chip buffer count. Each batch prepares and binds every stage again, so batches per draw is the multiplier on tessellation cost.

* perf(gpu): run all instances of a tessellated draw in shared batches

DrawTessellation looped over instances on the CPU: every instance took
its own hull dispatch, a full binding preparation of the hull, domain
and pixel stages, and its own draw. Yōtei's cinematic issues up to
~1,900 instances per tessellated draw, ~15,000 iterations per 10 s, and
tessellation was half of the render thread.

Number patches across all instances instead and batch that numbering by
the factor-ring and off-chip capacity, as the hardware distributes the
patches of an instanced draw. A new tessellation data word carries the
patches per instance; each hull thread derives its instance ID (v5), its
patch ID (v0) and its index-buffer vertex, and the domain stage its patch
ID (v8), from the shared number. Zero patches per instance keeps the old
values exactly.

Yōtei: one batch per tessellated draw, cinematic 0.6-0.7 -> 1.1-1.3 FPS.

* perf(memory): let CPU-write-hot pages cool down once per frame

A page the CPU rewrites right after three uploads turns hot: it stays
writable and dirty and every sync copies it whole. Nothing ever cooled a
page down, so a ring the game fills a little each frame ended up hot in
full. In Yōtei's cinematic two 28-32 MiB buffers were copied whole at
every device-address sync, ~60 GB per 10 s.

Once per presented frame, hot pages now lose their heat: the next sync
copies them once more and protects them again, and only pages the CPU
keeps rewriting within a frame turn hot again. SHARPEMU_KEEP_CPU_WRITE_HEAT=1
restores the old behaviour.

Yōtei cinematic: uploads ~60 GB -> ~0.6 GB per 10 s, 1.1 -> 1.5-1.6 FPS.

* diag(images): trace lost GPU image contents and name guest passes

SHARPEMU_TRACE_IMAGE_DROPS=1 reports every time an image loses GPU-written
contents that never reached guest memory: the code site, the image, the
range of a buffer write that caused it and how much of the image it
covers, the image's dirty flags, and the guest render pass decoded from
its push markers (one stack per queue). Evictions report whether they
published the contents first.

A renderdoc.capture file next to the executable now requests a RenderDoc
capture at the next guest flip, for unattended runs.

* fix(images): keep an image being resolved out of collection

ResolveOverlap can allocate a replacement (GrowImage, a depth overlap
copy), and an allocation under memory pressure collects images that
were not used this tick. The image being resolved is the copy source of
that replacement: when it was collected, the copy read a freed slot and
the process failed fast with "The slot is not allocated" (seen twice in
Yōtei with a 4 GB image cache budget). Touch it before resolving.

* fix(images): publish GPU-only image contents before an overlap drops them

Memory keeps its bytes when another image or a buffer write reuses part
of it. When an overlapping image request or a partial buffer write
dropped an image whose GPU contents had never reached guest memory, the
contents were simply lost; a later image over those bytes started from
stale guest memory. Yōtei's cloud tables alias a 2048x2048 RG16F and a
512x512 RGBA16F at one address, and several post-process targets are
partly overwritten by compute buffer writes (7-82 % coverage).

Before such a drop, copy the image into the buffer over its range on the
GPU (the existing image-to-buffer path) and let the buffer own the bytes,
as after any GPU write. Full overwrites still drop directly.
SHARPEMU_IMAGE_DROP_WITHOUT_PUBLISH=1 restores the old behaviour.

* perf(gpu): poll the timeline at most every 0.5 ms in per-draw housekeeping

The completed-submission collection before every draw read the timeline
semaphore twice per draw (vkGetSemaphoreCounterValue), about 12 % of the
command thread in Ghost of Yotei. The per-draw path now polls at most once
per interval and uses the last value otherwise; real waits still read the
timeline at once. SHARPEMU_TIMELINE_POLL_US overrides the interval.

* perf(gpu): look up known samplers without taking the store lock

Every sampler of every draw took the store lock, and the async compute
queue contended for it (3.7 % of the command thread in Monitor slow paths).
Hits now read a concurrent dictionary; only a miss locks to create.

* perf(buffers): read one tracker page per mapped readback

Mapped readbacks copy uncached device-local memory at about 300 MB/s, and
most want a descriptor word or a packet dword. The window drops from 16 KiB
to one 4 KiB tracker page. SHARPEMU_MAPPED_READBACK_KB overrides it.

* perf(images): reuse per-thread level scratch when resolving color targets

Every draw resolved its color targets with two fresh 16-entry arrays that are
only copied into the image description; they were about 10 % of the managed
allocation volume in Ghost of Yotei. One cleared pair per thread replaces them.

* fix(agc): leave the command buffer alone in Push/PopMarker

Ghost of Yotei 1.512 calls the marker exports from a second guest thread on a
DCB that the main thread fills with inline-written packets. Appending a marker
NOP read a stale cursor and overwrote the game's packets: a tester's dump shows
a PopMarker NOP over the first two dwords of a SET_SH_REG, whose stray value
dword then failed as an unknown packet (header 0x00000000). Markers are
tool-only NOPs on PS5, so by default they now return the current cursor
without writing. SHARPEMU_AGC_MARKERS=1 restores the packets for the pass
names that traces decode.

* diag(shader): say why resource materialization failed

Two testers of Ghost of Yotei 1.512 stop on 'could not be materialized ...
reason=Other' for one pixel shader, and Other named no step. Each failure
site now records what failed and the values it saw (the source and dword that
could not be evaluated, the guest address that could not be read, the bad
count, stride or descriptor words); the fatal message appends it as detail=.
Return values and the fatal/reject policy are unchanged.

* fix(agc): preserve marker reservations for the DCB writer

Restore packet reservation and capacity callbacks for markers. Suppress markers from another known guest writer under the allocation gate so they cannot overwrite unpublished inline packets. Scope writer identities to canonical memory across native thread wrappers.

* fix(gpu): preserve partial images before capturing buffer bindings

* fix(audio): report the channel count in AudioOut2 port state

* Revert "debug(gpu): dump the whole command buffer on an unknown packet"

The full dump identified the tester's crash: a PopMarker from a second guest
thread overwrote a SET_SH_REG (fixed by the marker reservation change). Back to
the short dump around the bad packet.

* fix(shader): probe record-aligned material offsets past the indirect image cap

Ghost of Yotei 1.512 stops on 'indirect image 0x494: 72559 unbounded selector
probes exceed 65536' for a 2502-record material table with a 116-byte stride.
Without a proven selector set every multiple of gcd(stride, 2^32) is probed,
because selector * stride + offset wraps modulo 2^32; reaching an offset that
is not a record start needs a selector of at least 2^32 / stride. Tables under
the cap keep the exhaustive enumeration; past it, the record-aligned offsets
are probed and a warning names the image once.

* fix(gpu): track guest pages written through device addresses

Shaders that store to memory through a runtime V# or a flat/global
pointer reach guest memory through the device-address page table, not
through a bound buffer. The buffer cache only learned about GPU writes
from bound writable bindings, so these stores were invisible to it: the
page stayed "clean", the host never downloaded the GPU copy, and the
next CPU upload or eviction silently overwrote the GPU result with stale
guest bytes (or the CPU kept reading stale bytes). Ghost of Yotei's
GPU-driven passes (producer computes filling per-object data and V#
tables) write almost exclusively this way.

The translator now reports the page of every device-address store and
atomic in a second bitmap that follows the fault bitmap in the fault
buffer. Non-pixel invocations merge bits of one bitmap word and publish
it when the word changes or the invocation ends; pixel invocations,
which a discard can end early, publish at once. The fault processor
compacts that bitmap with the same pass as the fault bitmap and, once
the scan completes, GuestBufferCache.NoteDeviceAddressWrites marks the
pages GPU-written exactly as a written binding would: GPU-dirty in the
page tracker, recorded in the GPU-modified ranges, the owning buffer's
write tick bumped, and overlapping image memory copies invalidated.

A page the CPU dirtied before the write is reported cannot be owned by
both sides; it stays CPU-owned and the conflict is logged once per power
of two. On Yotei these conflicts remain (dozens per run, mostly the
producer input pages at 0x50CB...), so mixed CPU/GPU pages can still
lose bytes; resolving them needs a byte-level merge and is left for a
follow-up.

Diagnostics added alongside:
- SHARPEMU_TRACE_COHERENCE=1 checks, every fourth presented frame with
  the GPU idle, that pages the tracker calls clean hold the same bytes
  in guest memory and in the mapped buffer, and reports each divergence
  (SHARPEMU_TRACE_COHERENCE_MB sets the slice checked per pass). With
  SHARPEMU_TRACE_GPU_MEMORY_ADDRESS=coherence the first diverging page
  is traced. On Yotei the detector went from hundreds of divergences to
  zero with this fix.
- Guest buffers are named "guest 0x<address>+0x<size>" when Vulkan debug
  labels are enabled, so RenderDoc captures show which guest range a
  binding is.

* fix(gpu): merge shader and CPU writes that share a guest page

On the console the CPU and the GPU write one memory, so a 4 KiB page
can hold bytes from both. The page tracker gives each page a single
owner. When a shader stored through a device address into a page the
CPU had dirtied before the store was reported, the page stayed
CPU-owned and the next upload copied the whole guest page, stale
where the shader wrote, over the GPU copy. The guest never saw those
shader bytes either. Ghost of Yotei hits this dozens of times per run
(producer input pages at 0x50CB..., per-draw rings at 0x2002...).

SharedPageShadows keeps, per such page, the bytes both copies last
agreed on and does a three-way merge:
- Uploads of a shadowed page copy only the byte runs the CPU changed
  since the shadow; the shader's bytes in the buffer stay.
- When a device-address write is reported on a CPU-dirty shadowed
  page (once every upload staged for it has executed), the bytes the
  GPU changed since the shadow are written into guest memory where
  the CPU left them alone. A byte both changed keeps the CPU value,
  which the next upload carries to the GPU.
- Downloads refresh the shadow, since both copies agree afterwards.

A shader-written page gets its shadow when the CPU first touches it:
that access downloads the page, so guest memory matches the GPU copy
and no extra read of uncached mapped memory is needed. Pages only the
GPU uses cost one dictionary entry. A page the CPU dirtied before its
first shader write was reported is shadowed from the mapped GPU copy;
shader bytes an upload already overwrote there are lost once, and the
log says so. At most 16384 pages (64 MiB) are shadowed.

On Yotei: 0 unresolved conflicts (8 one-time losses), 0 coherence
divergences, no throughput loss in an A/B run (about 5.6k vs 6.2k
draws/s over the same cinematic window). The in-game darkening is
unchanged, so these lost writes were not its cause.

* fix(gpu): expose the partial last word of byte-addressed buffers

Storage buffers are declared to shaders as uint[], and Vulkan's runtime
array length counts only whole words. A buffer whose size is not a
multiple of four therefore hid its last one to three bytes: byte,
short and formatted loads there read zero, and stores were dropped.

Ghost of Yotei's occluder producer reads a one-byte-per-instance state
buffer of 95 bytes. The last three instances were the ones to produce,
so the shader saw them as inactive, wrote no matrices, and the depth
prepass drew the object with the previous frame's stale matrices:
exploding vertices that cover the scene with black bands and holes.

Byte-addressed and formatted bindings now get a view rounded up to the
next word. The number of padding bytes travels in the two spare bits of
the runtime stride entry (strides use 14 bits), and the shader keeps
the guest byte bound for those bindings: byte assembly may read the
padded word, but an element is loaded or stored only when all its bytes
are inside the guest range, and whole-word accesses keep the old bound.
The padded view must stay inside the cache buffer, which spans whole
pages, so this never reaches past its VkBuffer.

The device test reads and stores the tail of 41- to 102-byte buffers,
aligned and unaligned, formatted and byte loads, with and without the
zero-out-of-bounds read path, and checks that bytes past the guest
range stay hidden and untouched; 13 of its 17 cases fail without the
padding.

* fix(gpu): only wait for shared page uploads that copy bytes

A shadowed page's merge waits until every upload staged for it has
executed, since until then the GPU copy lacks the staged CPU bytes. The
staged tick was advanced for every upload that visited the page, also
when filtering removed all of its bytes because the CPU changed none.
Such an upload copies nothing, yet merges waited for its tick, and a
page re-staged every frame could keep shader writes out of guest memory
indefinitely.

The tick now advances only for runs that survive filtering and for a
whole-page copy into a new owner. The tests cover an eliminated upload
followed by a merge and a real pending upload that still blocks it.

* fix(gpu): keep shared page shadows when a buffer grows

When a request outgrows a cached buffer, the cache creates a larger one
and copies the old buffer's GPU contents into it. Shadows of shared
pages still named the old buffer as owner, so the next upload into the
replacement treated the page as unknown and copied the whole guest
page: stale where shaders had written and not yet been reported, it
overwrote those bytes in the GPU copy.

The shadows (and the pages waiting for their first CPU access) now
follow the copy to the replacement, and a merge waits for the copy's
tick, since the replacement holds the old bytes only once it executes.
The device test grows a buffer between an unreported shader store and
the next upload and checks that both shader writes and the CPU write
survive.

* diag(gpu): watch guest ranges per frame and force full barriers

Two opt-in diagnostics used to trace Ghost of Yotei's stale occluder
matrices back to the producer that never wrote them:

- SHARPEMU_WATCH_BLOCKS=addr:len[,addr:len...] logs, after every
  presented frame, each watched range whose bytes changed in guest
  memory or in the GPU copy (both contents, dirty state, submitted and
  completed ticks), and every cache event touching a watched range:
  device-address write reports, uploads, writable bindings, CPU write
  faults, host DMA writes, fills and copies.
- SHARPEMU_DIAG_BARRIER_EVERY_CALL=1 records a full memory barrier
  before every draw and dispatch, to rule missing guest barriers in or
  out. It is very slow.

Both are off by default and cost one branch when off.

* fix(gpu): read and write whole words that start inside a buffer range

The previous fix padded only byte-addressed and formatted bindings and
kept the floor word count as the bound for whole-word accesses. The
hardware range-checks a buffer access by its first byte (stride 0 or
swizzle off: offset >= num_records is out of range), so a dword load or
store that starts inside a range ending mid-word is performed whole.
A dword-addressed buffer of 95 bytes therefore returned zero for its
last word, bytes 92..94 included, where the console returns the data;
whether the read got zero depended on the driver's robust access
granularity, not on the guest descriptor.

Every runtime-stride view is now padded to whole words, and a word is
in range when it is inside the padded view, which is exactly the
hardware bound. Byte and formatted elements keep the exact byte limit
carried in the runtime stride entry.

The new device test loads dwords from 93- to 96-byte buffers with both
read paths; three of its five cases fail without the change.

* fix(shader): track both words of binary64 vector results

(cherry picked from commit 2e339ac4143061e76bfefb4e065b04eba4647f77)

* fix(shader): lower half-word logical shifts on Metal

(cherry picked from commit fa9776ec2b71a30c6a833562d8e342f5718a612f)

* feat(gpu): pack narrow color channels without changing encoded bytes

(cherry picked from commit e98fe0b4e68cd5e070124c97d583911561f5fd00)

* feat(gpu): transfer narrow sRGB images through expanded backing

(cherry picked from commit 9a5f4e08aaf101ebd68ca18d43efac17ec94112c)

* fix(gpu): retain explicit pitch for linear color target transfers

(cherry picked from commit 88695316e2119b18cb095ae7ad00e1c17ea1f6fb)

* fix(shader): decode explicit-LOD comparison gathers

(cherry picked from commit a629cc3949cba63b52cfbdc0b41199a3fc1cba79)

* fix(gpu): decode narrow sRGB color target outputs

(cherry picked from commit 66aa64a01fc4ad984d53fa248b742481d3b54e61)

* fix(agc): accept the shader register Ghost of Yotei 2.002 writes at 0x192

Ghost of Yotei Complete Edition 2.002 applies a shader register table at
startup that writes 1 to SH offset 0x192 (0x2D92), just past the LS block.
gfx10 documents no register there and no shader state depends on it, but
the table writer had no entry for it and stopped the emulator right after
the splash screen. Ignore it like the other pace and control registers.

* diag(agc): log the values written to the undocumented shader register 0x192

The register is still not applied, but the first value and every change
are logged, so a rendering difference in Ghost of Yotei 2.002 can be
traced back to what the title wrote there.

* perf(shader): look runtime descriptors up through one shared function

Reading buffer-table descriptors at run time (90e35ba8) inlined the hash and
the eight probes of the runtime descriptor table at every image and sampler
access. A Ghost of Yotei pixel shader with 56 such accesses grew from 419 KB
to 2,051 KB of SPIR-V and took about 6.5 s to compile instead of 0.5 s.
The image and sampler lookups are now one DontInline function each, called
with the key and the lane's EXEC state, as the device buffer format decoder
already is; the same shader is 507 KB and still binds only the records it
reads.

* fix(shader): read format bytes in a buffer's last partial dword on the device

A format load through a device-side V# assembled each component byte from
its aligned dword, and that dword read zero unless all four of its bytes were
below num_records. The hardware range-checks the element, so the last one to
three elements of a byte buffer whose size is not a multiple of four read 0
instead of memory.

Ghost of Yotei's culling updater reads each object's 8_SINT visibility state,
skips the store when the new state equals the one it read, and counts the
element. Its 19-byte state buffers read 0 for elements 16..18, so the store
was skipped while the element was counted: counts[] exceeded the instance
slots the producer wrote, and the extra instances drew as black blocks.

The dword is now read when any of its bytes is inside the buffer; the existing
per-component check still zeroes a component past num_records.

* fix(loader): prove jump table bounds and tables over the control-flow graph

The red-zone patcher refused every short faultable site of a function whose
jump tables it could not resolve. The resolver only accepted a lea-only window
between a 32-bit compare and the table load, so Ghost of Tsushima's render
thread kept unprotected accesses in a leaf function that holds a pointer in the
red zone, and a host exception dispatch clobbered it.

Resolve each table by walking the function's control-flow graph backwards from
the load: every path must pass the fallthrough of ja after an unsigned compare
of the selector, reaching the load through register moves and zero-extensions
only, and must define the table register with lea of the same RIP-relative
table. The graph includes the resolved table edges, and resolution repeats until
a round reproduces its own targets. Tsushima's indirect-branch refusals drop
from 55 to 8.

* fix(agc): accept the user-config register Ghost of Yotei 2.002 writes at 0x191

The 2.002 build writes 0 to user-config offset 0x191 from a register table,
right after shader register 0x192. No gfx10-12 register lives there. Accept
it and log value changes, like 0x192.

* fix(images): keep a mapped mip chain whole when a texture starts past mip 0

A sampled texture whose descriptor starts past mip 0 was always made an image
of only the levels from there, for streamed textures whose larger mips are not
mapped. A pass that samples a lower mip of an image the GPU rendered (Demon's
Souls' fog and bloom chains) then got a separate image of the same memory,
filled from stale guest bytes and traded with the render target every frame:
white blocks across the fog and 0.2 fps.

The image cache now keeps the resident-levels form only when the chain is not
fully mapped; otherwise the texture is the whole chain viewed from its base
level, the same image as every other view of that memory.

* fix(np): report the package's real trophies from sceNpTrophy2GetTrophyInfoArray

b5b3d691 treated rdx as the first trophy id and always returned the requested
limit as the count. The call is (context, handle, offset, limit, details[],
data[], *count): offset indexes into the package and the count is how many
trophies exist from there, capped by limit. Hades asks for 50 and then walked
past its own on-stack arrays, crashing in a job thread before the first frame.

Read sce_sys/trophy2/trophy00.ucp (unencrypted container with tropconf.json and
tropmeta_*.json) and fill SceNpTrophy2Details (1312 bytes) and SceNpTrophy2Data
(32 bytes) for the trophies that really exist, all locked. Layouts and the
package format follow KytyPS5's NpTrophy2 implementation.

Co-authored-by: Felipe <54341095+Pipe530@users.noreply.github.com>
Co-authored-by: nmzik <Nmzik@mail.ru>

* chore(audio): mark the YoteiPC-derived ATRAC9 code as GPL-2.0-only

The multichannel block ordering in Atrac9DecodeState follows YoteiPC's at9_api.c,
which its author publishes under GPL-2.0 (no "or later" grant). The file now says
GPL-2.0-only with the source and author named, and the license text is added for REUSE.
LibAtrac9 itself stays MIT.

---------

Co-authored-by: ParantezTech <12572227+par274@users.noreply.github.com>
Co-authored-by: kuba <xnetcat.dev@gmail.com>
Co-authored-by: MathiasYG <06mathias6@gmail.com>
Co-authored-by: voidborn_astra <41128975+voidborn-astra@users.noreply.github.com>
Co-authored-by: Felipe <54341095+Pipe530@users.noreply.github.com>
Co-authored-by: nmzik <Nmzik@mail.ru>
2026-10-12 15:26:47 +02:00
ParantezTech a298369293 [Demo] remove bg music from bundled demo 2026-10-10 16:40:10 +03:00
T 4d5697892e
[HLE] Savedata and np fixes (#1080)
* [SaveData] Read the dialog mode and user data at their offsets in the open parameters

* [SaveData] Report each slot's own block count instead of the fixed maximum

* [Np] Report the offline profile as signed out from sceNpCheckNpReachability
2026-10-10 15:44:13 +03:00
Usif 46a7d3a446
[Kernel] Keep oversized reservations at the guest's hint and sign-extend user event ids (#1073)
* [Kernel] Keep an oversized reservation at the guest's hint instead of failing

A 768 GB sceKernelReserveVirtualRange with a hint at 0x1000000000 cannot use the
sparse aperture (it starts at 256 GB, and 256 GB plus 768 GB passes the 1008 GB
user limit), so it needed one contiguous host placeholder. When a host
allocation made before the startup reservation sits inside that window, the
call fails with 0x8002000C and the guest stops at boot.

Reserve such a range at the guest's own hint as a guest-only sparse range, as
smaller large reservations already are.

* [Kernel] Sign-extend the int id of user events into the event ident

sceKernelAddUserEvent, sceKernelAddUserEventEdge, sceKernelDeleteUserEvent and
sceKernelTriggerUserEvent take an int id, which the kernel stores in the
uintptr_t ident of the event. A negative id such as -1 therefore reads back from
sceKernelGetEventId as 0xFFFFFFFFFFFFFFFF. The exports used the zero-extended
register (0x00000000FFFFFFFF), so a guest that treats a negative id as a
wake-up event took its normal event path instead and never went back to wait.

Use the sign-extended id in all four exports.
2026-10-10 03:39:51 +03:00
Berk f32266834c
[SharpEmu] Add bundled demo game (#1072) 2026-10-09 17:41:17 +03:00
ParantezTech 0af8dce081 [GUI] improve library tile collection and acceleration fixes 2026-10-09 14:55:12 +03:00
qwe1qwe 289f090b50
[GPU] Align linear color target pitch like linear textures (#1063) 2026-10-09 14:07:12 +03:00
Berk 47d4c07a8d
chore: bump version to 0.0.5-nexus-release.2 (#1070) 2026-10-09 01:53:47 +03:00
Berk 1811cef944
Ports another set of compatibility and GPU fixes from PR #1016 (#1069)
* [Cpu] Prefer LLE for libc FILE-object exports

* [Libc] Add file-lock compatibility exports

* [Kernel] Expose the process argument count and vector

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [AppContent] Report missing add-on content entitlements

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Videodec2] Decode guest access units into pitched NV12 frames

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Add the RUDP context lifecycle

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [SharePlay] Report inactive share-play state

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Np] Complete offline authentication requests as signed out

* [Kernel] Warn when APR cannot resolve a resource path

* [GPU] Skip piece hashing for stencil association proxies

* [VideoOut] Add deduplicated volume texture binding traces

* [SpirvTranslator] Match guest F32 denormal and trigonometric semantics

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Add socket epoll registration and readiness waits

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Close POSIX socket descriptors through sceNetSocketClose

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Add Ethernet address string conversion

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Json] Track the global special-float handler

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Json] Add the special-float format setter

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Pad] Add the n3kSX62fgNo compatibility export

* [Pad] Trace input state changes when requested

* [PlayGo] Keep unknown chunk lists empty

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [PlayGo] Keep chunk metadata parsing in the metadata loader

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [PlayGo] Expose runtime state reset

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [PlayGo] Add install and optional chunk queries

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Loader] Preserve switch targets while patching guest red-zone accesses

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Loader] Report patching progress for large executables

* [SpirvTranslator] Preserve F32 signed zero, infinity and NaN when supported

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Use Gen5 render-target tile equations on the CPU and GPU

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Decode DCC fixed and register clear values

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Track DCC metadata coverage for color targets and volume textures

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Materialize DCC slice clears from guest metadata

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Cover Gen5 tiling, DCC clears and offline authentication

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Match red-zone coverage to forward-only patching

* [Pad] Track independent standard, special and remote port handles

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [UserService] Add user number and termination exports

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Expose the game trials flag

* [Kernel] Allow renaming and unlinking open guest files

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Cover controller ports, user exports, trials and open-file renames

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Clear the outputs and report no space when no direct memory span is free

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Return POSIX results from _open and keep sceKernelOpen raw

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Open /dev/random and /dev/urandom as host RNG descriptors

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Read random device descriptors from the host RNG

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Close random device descriptors

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Report random device descriptors in fstat

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Cpu] Place the guest exception mcontext at the start of the context

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Cpu] Report the flags and Sony stack alias in guest exception contexts

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Cpu] Expose the guest exception context writer to tests

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Keep Universal Data System handles across repeated initialization

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Create Universal Data System contexts before explicit initialization

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Reset Universal Data System state with the runtime

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Pad] Write the device class extended information at its guest size

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Pad] Add scePadDeviceClassParseData

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Cover random devices, POSIX open, UDS initialization, pad class data and exception contexts

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [SaveData] Write the mount info structure at its 48-byte guest size

* [Stubs] Add sceTextToSpeech2Cancel and sceTextToSpeech2GetSpeechStatus

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Add sceKernelSync

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Loader] Patch red-zone sites from the previous instruction when every indirect branch is resolved

* [Tests] Cover backward red-zone spans, direct memory no-space results and the mount info size

---------

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>
2026-10-09 01:44:45 +03:00
185 changed files with 17134 additions and 1449 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
[Aa]rtifacts/
logs/
.tmp/
src/SharpEmu.Demo/
x86/
x64/

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.5-nexus</SharpEmuVersion>
<SharpEmuVersion>0.0.5-nexus-release.2</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>

View File

@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageVersion Include="Iced" Version="1.21.0" />
<PackageVersion Include="ZstdSharp.Port" Version="0.8.5" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />

View File

@ -5,6 +5,7 @@ using SharpEmu.Core.Runtime;
using SharpEmu.Core.Cpu;
using SharpEmu.GUI;
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Runtime.InteropServices;
@ -732,7 +733,9 @@ internal static partial class Program
try
{
var directory = Path.GetDirectoryName(Path.GetFullPath(ebootPath));
var directory = ZArchive.IsArchivePath(ebootPath)
? ZArchiveCache.ExtractMetadata(ebootPath)
: Path.GetDirectoryName(Path.GetFullPath(ebootPath));
if (string.IsNullOrEmpty(directory))
{
return null;
@ -1010,7 +1013,7 @@ internal static partial class Program
private static void PrintUsage()
{
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin|path-to-game.zar>");
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
}

View File

@ -180,4 +180,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
SkipUnchangedFiles="true" />
</Target>
<PropertyGroup>
<SharpEmuDemoTag>49e6b3b</SharpEmuDemoTag>
<SharpEmuDemoFolderName>demo</SharpEmuDemoFolderName>
<SharpEmuDemoCacheDir>$(BaseIntermediateOutputPath)sharpemu-demo/$(SharpEmuDemoTag)</SharpEmuDemoCacheDir>
<SharpEmuDemoArchive>$(SharpEmuDemoCacheDir)/sharpemu-demo.zip</SharpEmuDemoArchive>
<SharpEmuDemoDir>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '..', 'SharpEmu.Demo'))</SharpEmuDemoDir>
<SharpEmuDemoStamp>$(SharpEmuDemoDir).tag</SharpEmuDemoStamp>
</PropertyGroup>
<Target Name="FetchSharpEmuDemo" BeforeTargets="Publish;Build">
<ReadLinesFromFile File="$(SharpEmuDemoStamp)" Condition="Exists('$(SharpEmuDemoStamp)')">
<Output TaskParameter="Lines" PropertyName="_SharpEmuDemoExtractedTag" />
</ReadLinesFromFile>
<PropertyGroup>
<_SharpEmuDemoCurrent Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoExtractedTag)' == '$(SharpEmuDemoTag)'">true</_SharpEmuDemoCurrent>
</PropertyGroup>
<DownloadFile
SourceUrl="https://github.com/sharpemu/sharpemu-demo/releases/download/$(SharpEmuDemoTag)/sharpemu-demo.zip"
DestinationFolder="$(SharpEmuDemoCacheDir)"
Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoCurrent)' != 'true' And !Exists('$(SharpEmuDemoArchive)')"
ContinueOnError="WarnAndContinue" />
<RemoveDir Directories="$(SharpEmuDemoDir)" Condition="'$(_SharpEmuDemoCurrent)' != 'true'" />
<Unzip
SourceFiles="$(SharpEmuDemoArchive)"
DestinationFolder="$(SharpEmuDemoDir)"
Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoCurrent)' != 'true' And Exists('$(SharpEmuDemoArchive)')"
ContinueOnError="WarnAndContinue" />
<!-- Older demo releases include preview music; keep the bundled demo silent. -->
<Delete Files="$(SharpEmuDemoDir)sce_sys/snd0.at9" />
<PropertyGroup>
<SharpEmuDemoComplete Condition="Exists('$(SharpEmuDemoDir)eboot.bin') And Exists('$(SharpEmuDemoDir)sce_sys/param.json') And Exists('$(SharpEmuDemoDir)sce_sys/icon0.png') And Exists('$(SharpEmuDemoDir)sce_sys/pic0.png')">true</SharpEmuDemoComplete>
</PropertyGroup>
<WriteLinesToFile
File="$(SharpEmuDemoStamp)"
Lines="$(SharpEmuDemoTag)"
Overwrite="true"
Condition="'$(SharpEmuDemoComplete)' == 'true' And '$(_SharpEmuDemoCurrent)' != 'true'" />
<RemoveDir Directories="$(SharpEmuDemoDir)" Condition="'$(SharpEmuDemoComplete)' != 'true'" />
<Delete Files="$(SharpEmuDemoArchive)" Condition="'$(SharpEmuDemoComplete)' != 'true' And Exists('$(SharpEmuDemoArchive)')" />
</Target>
<Target Name="BuildSharpEmuDemo" AfterTargets="Build" DependsOnTargets="FetchSharpEmuDemo">
<ItemGroup>
<_SharpEmuDemoFiles Include="$(SharpEmuDemoDir)**/*" Exclude="$(SharpEmuDemoStamp)" Condition="'$(SharpEmuDemoComplete)' == 'true'" />
</ItemGroup>
<RemoveDir
Directories="$(OutDir)$(SharpEmuDemoFolderName)"
Condition="'$(SharpEmuDemoComplete)' != 'true'"
ContinueOnError="WarnAndContinue" />
<Copy
SourceFiles="@(_SharpEmuDemoFiles)"
DestinationFiles="@(_SharpEmuDemoFiles->'$(OutDir)$(SharpEmuDemoFolderName)/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Delete Files="$(OutDir)$(SharpEmuDemoFolderName)/sce_sys/snd0.at9" />
</Target>
<Target Name="PublishSharpEmuDemo" AfterTargets="Publish" DependsOnTargets="FetchSharpEmuDemo">
<ItemGroup>
<_SharpEmuDemoFiles Include="$(SharpEmuDemoDir)**/*" Exclude="$(SharpEmuDemoStamp)" Condition="'$(SharpEmuDemoComplete)' == 'true'" />
</ItemGroup>
<RemoveDir
Directories="$(PublishDir)$(SharpEmuDemoFolderName)"
Condition="'$(SharpEmuDemoComplete)' != 'true'"
ContinueOnError="WarnAndContinue" />
<Copy
SourceFiles="@(_SharpEmuDemoFiles)"
DestinationFiles="@(_SharpEmuDemoFiles->'$(PublishDir)$(SharpEmuDemoFolderName)/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Delete Files="$(PublishDir)$(SharpEmuDemoFolderName)/sce_sys/snd0.at9" />
</Target>
</Project>

View File

@ -5604,6 +5604,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return false;
}
// Map does not reject a range that is already mapped: it reuses and zero-fills it. Two threads
// creating guest threads at once could both find the same slot free and share one stack, so
// choosing and mapping a slot happen under one lock.
private static readonly object GuestThreadRegionGate = new();
private static bool TryMapGuestThreadRegion(
IVirtualMemory virtualMemory,
ulong baseAddress,
@ -5611,6 +5616,20 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
ProgramHeaderFlags protection,
out ulong mappedBase,
out string? error)
{
lock (GuestThreadRegionGate)
{
return TryMapGuestThreadRegionLocked(virtualMemory, baseAddress, size, protection, out mappedBase, out error);
}
}
private static bool TryMapGuestThreadRegionLocked(
IVirtualMemory virtualMemory,
ulong baseAddress,
ulong size,
ProgramHeaderFlags protection,
out ulong mappedBase,
out string? error)
{
for (int i = 0; i < GuestThreadRegionSlots; i++)
{
@ -5645,6 +5664,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
IVirtualMemory virtualMemory,
out ulong tlsBase,
out string? error)
{
lock (GuestThreadRegionGate)
{
return TryMapGuestThreadTlsRegionLocked(virtualMemory, out tlsBase, out error);
}
}
private static bool TryMapGuestThreadTlsRegionLocked(
IVirtualMemory virtualMemory,
out ulong tlsBase,
out string? error)
{
for (int i = 0; i < GuestThreadRegionSlots; i++)
{
@ -5727,18 +5757,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return true;
}
private static readonly bool LegacyGuestPriorities =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_GUEST_PRIORITY"), "legacy", StringComparison.OrdinalIgnoreCase);
private static ThreadPriority MapGuestThreadPriority(int priority)
{
if (priority <= 478)
{
return ThreadPriority.Highest;
return LegacyGuestPriorities ? ThreadPriority.Highest : ThreadPriority.Normal;
}
if (priority >= 733)
{
return ThreadPriority.Lowest;
}
return ThreadPriority.Normal;
return LegacyGuestPriorities ? ThreadPriority.Normal : ThreadPriority.BelowNormal;
}
private void ApplyGuestThreadAffinity(ulong guestAffinityMask)

View File

@ -2070,6 +2070,13 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
GuestGpuMemoryHook.MarkCpuWrite(virtualAddress, (ulong)source.Length);
if (!source.IsEmpty && _backedSpace is { } backed && !_disposed &&
backed.TryWriteSingleView(virtualAddress, source))
{
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
var requiresExclusiveAccess = false;
_gate.EnterReadLock();
try

View File

@ -7,6 +7,7 @@ using SharpEmu.Core.Cpu.Disasm;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using SharpEmu.HLE.Host;
using SharpEmu.HLE.GpuMemory;
using SharpEmu.Libs.VideoOut;
@ -148,6 +149,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
public OrbisGen2Result Run(string ebootPath)
{
using var archiveMount = MountArchive(ref ebootPath);
var normalizedEbootPath = Path.GetFullPath(ebootPath);
using var app0Binding = BindApp0Root(normalizedEbootPath);
Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
@ -400,6 +402,72 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return result;
}
// A .zar holds the whole application folder. Mount it at a cache folder that gets the files host
// code reads by path (the executable, its modules and sce_sys); everything else is read from the
// archive when the guest asks for it. The path becomes the cached executable.
private static ArchiveMountScope? MountArchive(ref string ebootPath)
{
if (!ZArchive.IsArchivePath(ebootPath))
{
return null;
}
var archivePath = Path.GetFullPath(ebootPath);
var archive = ZArchive.Open(archivePath) ??
throw new InvalidDataException($"'{archivePath}' is not a valid .zar archive.");
if (!archive.IsFile(archive.LookUp("eboot.bin")))
{
archive.Dispose();
throw new FileNotFoundException("The archive has no eboot.bin at its root.", archivePath);
}
var cacheRoot = ZArchiveCache.Prepare(archivePath);
App0Archive.Mount(archive, cacheRoot);
try
{
foreach (var entry in archive.GetEntries(archive.LookUp(string.Empty)))
{
if (entry.IsDirectory)
{
if (entry.Name.Equals("sce_sys", StringComparison.OrdinalIgnoreCase) ||
entry.Name.Equals("sce_module", StringComparison.OrdinalIgnoreCase) ||
entry.Name.Equals("sce_modules", StringComparison.OrdinalIgnoreCase))
{
App0Archive.ExtractDirectory(entry.Name);
}
}
else if (entry.Name.Equals("eboot.bin", StringComparison.OrdinalIgnoreCase) ||
Path.GetExtension(entry.Name).Equals(".prx", StringComparison.OrdinalIgnoreCase) ||
Path.GetExtension(entry.Name).Equals(".sprx", StringComparison.OrdinalIgnoreCase))
{
App0Archive.Extract(Path.Combine(cacheRoot, entry.Name));
}
}
foreach (var modules in new[] { Path.Combine("Media", "Modules"), Path.Combine("Media", "Plugins") })
{
if (App0Archive.IsDirectory(Path.Combine(cacheRoot, modules)))
{
App0Archive.ExtractDirectory(modules);
}
}
}
catch
{
App0Archive.Unmount();
throw;
}
Console.Error.WriteLine($"[RUNTIME] Mounted archive '{archivePath}' at '{cacheRoot}'");
ebootPath = Path.Combine(cacheRoot, "eboot.bin");
return new ArchiveMountScope();
}
private sealed class ArchiveMountScope : IDisposable
{
public void Dispose() => App0Archive.Unmount();
}
private static App0BindingScope? BindApp0Root(string normalizedEbootPath)
{
const string app0VariableName = "SHARPEMU_APP0_DIR";
@ -806,6 +874,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
try
{
App0Archive.Extract(modulePath);
var fileInfo = new FileInfo(modulePath);
if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > int.MaxValue)
{

View File

@ -0,0 +1,33 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO;
namespace SharpEmu.GUI;
internal static class BundledDemo
{
internal const string FolderName = "demo";
private static readonly string[] RequiredFiles =
[
"eboot.bin",
Path.Combine("sce_sys", "param.json"),
Path.Combine("sce_sys", "icon0.png"),
Path.Combine("sce_sys", "pic0.png"),
];
internal static string? FindEboot(string baseDirectory)
{
var root = Path.Combine(baseDirectory, FolderName);
foreach (var file in RequiredFiles)
{
if (!File.Exists(Path.Combine(root, file)))
{
return null;
}
}
return Path.GetFullPath(Path.Combine(root, "eboot.bin"));
}
}

View File

@ -34,6 +34,25 @@
"Library.Context.CopyTitleId": "Copy title ID",
"Library.Context.GameSettings": "Game settings…",
"Library.Context.Remove": "Remove from library",
"Library.Context.PackZar": "Pack as .zar…",
"Library.Pack.Title": "Pack as .zar",
"Library.Pack.Heading": "Packing {0}",
"Library.Pack.Explanation": "This stores the whole game folder in a single compressed .zar file. SharpEmu runs the game straight from it without unpacking, so it usually takes less disk space and plays the same. The game folder is kept unless you choose to delete it below.",
"Library.Pack.Preparing": "Reading the game folder…",
"Library.Pack.Progress": "{0} / {1} GB ({2}%)",
"Library.Pack.Cancelling": "Cancelling…",
"Library.Pack.Done": "Done. The archive is ready to launch.",
"Library.Pack.Failed": "Packing failed: {0}",
"Library.Pack.Close": "Close",
"Library.Pack.MuteMusic": "Mute the title music while packing",
"Library.Pack.DeleteSource": "Delete the game folder after packing",
"Library.Pack.DeleteSourceWarning": "The folder is permanently deleted once the archive is written and checked. Keep a backup until you know the archive works.",
"Library.Pack.Verifying": "Checking the archive…",
"Library.Pack.Deleting": "Deleting the game folder…",
"Library.Pack.DoneDeleted": "Done. The archive is ready and the game folder was deleted.",
"Library.Pack.DeleteSkipped": "Done. The game folder was kept because the archive could not be confirmed to hold all of it.",
"Library.Pack.DeleteFailed": "The archive is ready, but deleting the game folder failed: {0}",
"Library.Empty.Title": "Your library is empty",
"Library.Empty.Hint": "Add a folder containing your games to get started.",
@ -201,6 +220,7 @@
"Status.AddFolderPrompt": "Add a game folder to populate the library.",
"Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).",
"Status.CouldNotOpenFolder": "Could not open folder: {0}",
"Status.PackedZar": "Packed game archive: {0}",
"Status.CopiedToClipboard": "{0} copied to clipboard.",
"Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.",
"Status.Running": "Running {0}",
@ -219,6 +239,8 @@
"Dialog.SaveLogFile": "Select where to save the Log file",
"Dialog.PlainTextFiles": "Plain Text Files",
"Dialog.LogFiles": "Log Files",
"Dialog.SaveZar": "Save the game archive",
"Dialog.ZarFiles": "Game archives",
"Options.About" : "About",
"About.Github.Label": "GitHub",

View File

@ -25,6 +25,8 @@
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": "Ajouter un dossier",
"Library.OpenFile": "Ouvrir un fichier…",
"Library.View.Grid": "Vue empilée",
"Library.View.Carousel": "Vue en rangée",
"Library.Context.Launch": "Lancer",
"Library.Context.OpenFolder": "Ouvrir le dossier du jeu",
@ -57,8 +59,8 @@
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
"Options.Env.Group.Debug": "Débogage",
"Options.Env.Group.General": "Général",
"Options.Env.RenderDoc.Desc": "Charge l'API in-application de RenderDoc afin de capturer des images depuis l'émulateur.\nAppuyez sur F12 pendant le jeu pour capturer une image ; les captures sont écrites dans user/logs/capture_logs/<TITLE_ID>.\nNécessite l'installation de RenderDoc. Ralentit le GPU et bloque certains jeux : laissez cette option désactivée sauf en cas de débogage.",
"Options.Env.DisableVkImplicitLayers.Desc": "Lorsque RenderDoc est activé, d'autres overlays en conflit peuvent provoquer une perte du périphérique. Activer cette option peut corriger le problème.",
"Options.Env.RenderDoc.Desc": "Charger l’API intégrée de RenderDoc afin de capturer des images depuis l’émulateur.\nAppuyez sur F12 pendant le jeu pour capturer une image ; les captures sont écrites dans user/logs/capture_logs/<TITLE_ID>.\nNécessite l’installation de RenderDoc. Ralentit le GPU et bloque certains jeux : laissez cette option désactivée sauf en cas de débogage.",
"Options.Env.DisableVkImplicitLayers.Desc": "Lorsque RenderDoc est activé, d’autres overlays en conflit peuvent provoquer une perte du périphérique. Activer cette option peut corriger le problème.",
"Options.Section.Emulation": "ÉMULATION",
"Options.Section.Logging": "JOURNALISATION",
"Options.Section.Launcher": "LANCEUR",
@ -83,7 +85,7 @@
"Options.TraceImports.Desc": "Tracer les N premiers imports de chaque module (0 = désactivé).",
"Options.CrashDump.Label": "Fichiers de vidage après plantage",
"Options.CrashDump.Desc": "Enregistre les fichiers de vidage à côté du journal d’exécution (Windows x64 uniquement). Ces fichiers peuvent être volumineux et contenir des données privées.",
"Options.CrashDump.Desc": "Enregistrer les fichiers de vidage à côté du journal d’exécution (Windows x64 uniquement). Ces fichiers peuvent être volumineux et contenir des données privées.",
"Options.LogToFile.Label": "Enregistrer dans un fichier",
"Options.LogToFile.Desc": "Copier la sortie de l’émulateur dans un fichier journal.",
@ -121,15 +123,15 @@
"Launch.Idle": "Inactif",
"Launch.Console": "≡ Console",
"Launch.Launch": "▶ Lancer",
"Launch.CustomEnv": "Lancer avec des variables d'environnement personnalisées",
"Launch.CustomEnv.Title": "Lancer avec des variables d'environnement personnalisées",
"Launch.CustomEnv.Desc": "Saisissez une variable d'environnement par ligne au format NOM=VALEUR. Les valeurs personnalisées remplacent les réglages GUI et spécifiques au jeu portant le même nom.",
"Launch.CustomEnv.Documentation": "Voir les variables d'environnement non documentées ↗",
"Launch.CustomEnv": "Lancer avec des variables d’environnement personnalisées",
"Launch.CustomEnv.Title": "Lancer avec des variables d’environnement personnalisées",
"Launch.CustomEnv.Desc": "Saisissez une variable d’environnement par ligne au format NOM=VALEUR. Les valeurs personnalisées remplacent les réglages GUI et spécifiques au jeu portant le même nom.",
"Launch.CustomEnv.Documentation": "Voir les variables d’environnement non documentées ↗",
"Launch.CustomEnv.Placeholder": "SHARPEMU_EXEMPLE=1",
"Launch.CustomEnv.SaveGame": "Enregistrer pour le jeu",
"Launch.CustomEnv.SaveGlobal": "Enregistrer globalement",
"Launch.CustomEnv.Play": "Jouer",
"Launch.CustomEnv.Invalid": "Variable d'environnement invalide : {0}",
"Launch.CustomEnv.Invalid": "Variable d’environnement invalide : {0}",
"Launch.CustomEnv.SavedGame": "Enregistré pour ce jeu.",
"Launch.CustomEnv.SavedGlobal": "Enregistré globalement.",
"Launch.Stop": "■ Arrêter",
@ -237,8 +239,8 @@
"Options.Hdr.Desc": "Utiliser le HDR lorsque l’écran sélectionné et le backend graphique le prennent en charge. Le mode automatique revient au SDR.",
"Options.Hdr.Auto": "Automatique",
"Options.Env.BinkPlayback.Label": "Lecture vidéo Bink",
"Options.Env.BinkPlayback.Desc": "Host lit les vidéos Bink via le pont FFmpeg, mais certains jeux peuvent rencontrer des problèmes.\nGuest utilise le décodeur propre au moteur du jeu.\nSkip ignore les vidéos Bink, ce qui peut faciliter le développement.",
"Options.Env.BinkPlayback.Host": "Host (FFmpeg)",
"Options.Env.BinkPlayback.Guest": "Guest",
"Options.Env.BinkPlayback.Desc": "Hôte lit les vidéos Bink via le pont FFmpeg, mais certains jeux peuvent rencontrer des problèmes.\nInvité utilise le décodeur propre au moteur du jeu.\nIgnorer permet de passer les vidéos Bink, ce qui peut faciliter le développement.",
"Options.Env.BinkPlayback.Host": "Hôte (FFmpeg)",
"Options.Env.BinkPlayback.Guest": "Invité",
"Options.Env.BinkPlayback.Skip": "Ignorer"
}

View File

@ -15,6 +15,7 @@ namespace SharpEmu.GUI;
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
{
private readonly ObservableCollection<GameEntry> _games;
private bool _includeAddFolderTile = true;
public LibraryTileCollection(ObservableCollection<GameEntry> games)
{
@ -24,7 +25,22 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _games.Count + 1;
public bool IncludeAddFolderTile
{
get => _includeAddFolderTile;
set
{
if (_includeAddFolderTile == value)
{
return;
}
_includeAddFolderTile = value;
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
public int Count => _games.Count + (_includeAddFolderTile ? 1 : 0);
bool IList.IsFixedSize => false;
@ -43,7 +59,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
return _games[index];
}
if (index == _games.Count)
if (_includeAddFolderTile && index == _games.Count)
{
return AddFolderTile.Instance;
}
@ -65,7 +81,10 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
yield return game;
}
yield return AddFolderTile.Instance;
if (_includeAddFolderTile)
{
yield return AddFolderTile.Instance;
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
@ -79,7 +98,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
int IList.IndexOf(object? value) => value switch
{
GameEntry game => _games.IndexOf(game),
AddFolderTile => _games.Count,
AddFolderTile when _includeAddFolderTile => _games.Count,
_ => -1,
};

View File

@ -216,6 +216,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxPackZar"
Header="{Binding [Library.Context.PackZar], Source={x:Static local:Localization.Instance}}">
<MenuItem.Icon>
<TextBlock Text="▣" FontSize="12" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxGameSettings"
Header="{Binding [Library.Context.GameSettings], Source={x:Static local:Localization.Instance}}">
@ -299,6 +306,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<Button x:Name="CarouselAddFolderButton"
Classes="ghost"
HorizontalAlignment="Left"
VerticalAlignment="Top"
IsVisible="False"
AutomationProperties.Name="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="+"
FontSize="16"
FontWeight="Light"
VerticalAlignment="Center" />
<TextBlock Text="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Panel>
<!-- Selected-game identity and actions share one detail surface so

View File

@ -18,6 +18,7 @@ using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime;
using SharpEmu.HLE.FileSystem;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Pad;
using SharpEmu.Libs.VideoOut;
@ -178,6 +179,19 @@ public partial class MainWindow : Window
private GameEntry? _lastSelectedGame;
private double _libraryRailRowHeight = 188;
private double _libraryGridRowHeight = 216;
private const double CarouselLeadingPadding = 20;
private const double CarouselItemMargin = 12;
private bool _carouselAnimating;
private readonly TranslateTransform _carouselTransform = new();
private double _carouselOffset;
private double _carouselScrollFrom;
private double _carouselScrollTo;
private long _carouselScrollStartedAt;
private bool _carouselPointerDown;
private bool _carouselDragging;
private Point _carouselDragStart;
private double _carouselDragStartOffset;
private double _carouselWheelAccumulator;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
@ -246,6 +260,27 @@ public partial class MainWindow : Window
_libraryLayoutTimer.Stop();
UpdateLibraryLayoutMetrics();
};
CarouselAddFolderButton.Click += (_, _) => StartAddFolderFromTile();
GameList.AddHandler(PointerPressedEvent, OnCarouselPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(PointerMovedEvent, OnCarouselPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(PointerReleasedEvent, OnCarouselPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(
PointerCaptureLostEvent,
(_, e) =>
{
if (ReferenceEquals(e.Source, GameList))
{
EndCarouselDrag(select: false);
}
},
RoutingStrategies.Tunnel | RoutingStrategies.Bubble,
handledEventsToo: true);
LibraryPage.AddHandler(PointerWheelChangedEvent, OnCarouselPointerWheel, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.SizeChanged += (_, _) =>
{
UpdateCarouselPadding();
Dispatcher.UIThread.Post(() => AnchorCarouselSelection(animate: false), DispatcherPriority.Loaded);
};
TitleBar.PointerPressed += OnTitleBarPointerPressed;
TitleBar.DoubleTapped += OnTitleBarDoubleTapped;
@ -371,18 +406,13 @@ public partial class MainWindow : Window
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
CtxPackZar.Click += async (_, _) => await PackSelectedAsZarAsync();
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => BeginWindowClosing();
Closed += (_, _) => CompleteWindowClosing();
SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
};
EnvRenderDocToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle(
"SHARPEMU_RENDERDOC",
EnvRenderDocToggle.IsChecked == true);
@ -390,24 +420,6 @@ public partial class MainWindow : Window
SetEnvironmentToggle(
"SHARPEMU_VK_DISABLE_IMPLICITS",
EnvDisableVkImplicitLayersToggle.IsChecked == true);
DefaultProfileBox.TextChanged += (_, _) =>
_settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => BeginWindowClosing();
Closed += (_, _) => CompleteWindowClosing();
SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
@ -492,12 +504,25 @@ public partial class MainWindow : Window
() => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false),
DispatcherPriority.Loaded);
}
else
{
FocusLibrarySelection();
}
}
private void SetLibraryLayout(bool grid)
{
_isLibraryGridLayout = grid;
var selectedTile = GameList.SelectedItem;
_libraryTiles.IncludeAddFolderTile = grid;
if (selectedTile is GameEntry && !ReferenceEquals(GameList.SelectedItem, selectedTile))
{
GameList.SelectedItem = selectedTile;
}
CarouselAddFolderButton.IsVisible = !grid;
SetClass(GameList, "gridLayout", grid);
UpdateCarouselPadding();
SetClass(LibrarySelectedDetails, "gridLayout", grid);
LibraryPage.Margin = new Thickness(0, 6, 0, 0);
UpdateLibraryLayoutMetrics();
@ -506,7 +531,11 @@ public partial class MainWindow : Window
if (GameList.SelectedItem is { } selected)
{
Dispatcher.UIThread.Post(
() => GameList.ScrollIntoView(selected),
() =>
{
GameList.ScrollIntoView(selected);
AnchorCarouselSelection(animate: false);
},
DispatcherPriority.Loaded);
}
}
@ -518,8 +547,17 @@ public partial class MainWindow : Window
Resources["LibraryTileWidth"] = metrics.ItemWidth;
Resources["LibraryRailItemHeight"] = metrics.RailItemHeight;
Resources["LibraryGridItemHeight"] = metrics.GridItemHeight;
_libraryRailRowHeight = metrics.RailItemHeight + 16;
_libraryRailRowHeight = metrics.RailItemHeight + 16 + 48;
CarouselAddFolderButton.Margin = new Thickness(0, metrics.RailItemHeight + 24, 0, 0);
_libraryGridRowHeight = metrics.GridItemHeight + 16;
UpdateCarouselPadding();
Dispatcher.UIThread.Post(
() =>
{
UpdateCarouselPadding();
AnchorCarouselSelection(animate: false);
},
DispatcherPriority.Loaded);
GameList.MaxHeight = double.PositiveInfinity;
UpdateLibraryMinimumHeight();
@ -850,11 +888,28 @@ public partial class MainWindow : Window
if (ShouldNavigate(left, ref _navLeftNextAt, now))
{
MoveSelection(-1);
ReturnFocusFromCarouselAddFolder();
}
if (ShouldNavigate(right, ref _navRightNextAt, now))
{
MoveSelection(1);
ReturnFocusFromCarouselAddFolder();
}
if (!_isLibraryGridLayout)
{
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
CarouselAddFolderButton.Focus(NavigationMethod.Directional);
}
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
ReturnFocusFromCarouselAddFolder();
}
}
if (_isLibraryGridLayout)
@ -877,7 +932,14 @@ public partial class MainWindow : Window
var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0)
{
LaunchSelected();
if (!_isLibraryGridLayout && CarouselAddFolderButton.IsFocused)
{
StartAddFolderFromTile();
}
else
{
LaunchSelected();
}
}
_previousPadButtons = pad.Buttons;
@ -940,6 +1002,10 @@ public partial class MainWindow : Window
: Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1);
GameList.SelectedIndex = index;
GameList.ScrollIntoView(index);
if (GameList.IsKeyboardFocusWithin && GameList.ContainerFromIndex(index) is { } container)
{
container.Focus(NavigationMethod.Unspecified);
}
}
private async Task OnOpenedAsync()
@ -953,6 +1019,7 @@ public partial class MainWindow : Window
: $" · UNOFFICIAL {BuildInfo.CommitSha}";
VersionText.Text = display;
Title = $"SharpEmu {display}";
ReportGuiRenderer();
ToolTip.SetTip(VersionText, BuildInfo.Banner);
_settings = GuiSettings.Load();
@ -970,7 +1037,66 @@ public partial class MainWindow : Window
}
SeedLibraryFromCache();
FocusLibrarySelection();
await RescanLibraryAsync();
FocusLibrarySelection();
}
private void ReturnFocusFromCarouselAddFolder()
{
if (CarouselAddFolderButton.IsFocused)
{
FocusLibrarySelection();
}
}
private void ReportGuiRenderer()
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
var angle = Process.GetCurrentProcess().Modules
.Cast<ProcessModule>()
.Any(static module => string.Equals(module.ModuleName, "av_libglesv2.dll", StringComparison.OrdinalIgnoreCase));
AppendConsoleLine(
angle ? "[GUI] Renderer: GPU (ANGLE/Direct3D)" : "[GUI] Renderer: software (ANGLE not loaded)",
angle ? DimLineBrush : WarningLineBrush);
}
catch (Exception)
{
}
}
private void FocusLibrarySelection()
{
Dispatcher.UIThread.Post(
() =>
{
if (_activePageIndex != 0 || _isGameSettingsOpen)
{
return;
}
if (GameList.SelectedIndex < 0 && _visibleGames.Count > 0)
{
GameList.SelectedIndex = 0;
}
AnchorCarouselSelection(animate: false);
if (GameList.SelectedIndex >= 0 && GameList.ContainerFromIndex(GameList.SelectedIndex) is { } container)
{
container.Focus(NavigationMethod.Unspecified);
}
else
{
GameList.Focus(NavigationMethod.Unspecified);
}
},
DispatcherPriority.Loaded);
}
private void PopulateLanguageBox()
@ -1828,6 +1954,19 @@ public partial class MainWindow : Window
/// </summary>
private static long ComputeInstallSize(string ebootPath)
{
// An archive is the whole game; the folder it sits in may hold other games.
if (ZArchive.IsArchivePath(ebootPath))
{
try
{
return new FileInfo(ebootPath).Length;
}
catch (IOException)
{
return 0;
}
}
var directory = Path.GetDirectoryName(ebootPath);
if (directory is null)
{
@ -1876,7 +2015,9 @@ public partial class MainWindow : Window
try
{
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
// A game is a folder with eboot.bin or a .zar archive of one.
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration)
.Concat(Directory.EnumerateFiles(folder, "*.zar", enumeration)))
{
var fullPath = Path.GetFullPath(file);
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
@ -1884,21 +2025,7 @@ public partial class MainWindow : Window
continue;
}
long size = 0;
try
{
size = new FileInfo(fullPath).Length;
}
catch (IOException exception)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
}
var (title, titleId, version) = TryReadParamJson(fullPath);
games.Add(new GameEntry(
title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
games.Add(CreateGameEntry(fullPath));
}
}
catch (Exception exception)
@ -1909,9 +2036,59 @@ public partial class MainWindow : Window
}
games.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
AddBundledDemo(games, excludedPaths);
return games;
}
private static void AddBundledDemo(List<GameEntry> games, IReadOnlySet<string> excludedPaths)
{
var demo = BundledDemo.FindEboot(AppContext.BaseDirectory);
if (demo is null || excludedPaths.Contains(demo))
{
return;
}
games.RemoveAll(game => string.Equals(game.Path, demo, GameLibraryPath.Comparison));
games.Insert(0, CreateGameEntry(demo));
}
private static GameEntry CreateGameEntry(string fullPath)
{
long size = 0;
try
{
size = new FileInfo(fullPath).Length;
}
catch (IOException exception)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
}
// An archive's metadata is read from its cache folder, which stands in for the game folder.
var metadataPath = fullPath;
if (ZArchive.IsArchivePath(fullPath))
{
try
{
if (ZArchiveCache.ExtractMetadata(fullPath) is { } cacheRoot)
{
metadataPath = Path.Combine(cacheRoot, "eboot.bin");
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not read archive '{fullPath}': {exception.Message}");
}
}
var (title, titleId, version) = TryReadParamJson(metadataPath);
return new GameEntry(
title ?? GameNameFor(metadataPath), titleId, version, fullPath, size,
FindCoverFor(metadataPath), FindBackgroundFor(metadataPath));
}
/// <summary>
/// Reads the game title, title id and content version from
/// sce_sys/param.json next to the executable, when present.
@ -2083,6 +2260,195 @@ public partial class MainWindow : Window
_lastSelectedGame = GameList.SelectedItem as GameEntry;
UpdateSelectedGame();
Dispatcher.UIThread.Post(() => AnchorCarouselSelection(animate: true), DispatcherPriority.Loaded);
}
private double CarouselItemStride() =>
(Resources.TryGetValue("LibraryTileWidth", out var width) && width is double tileWidth ? tileWidth : 0) +
CarouselItemMargin;
private void UpdateCarouselPadding()
{
if (GameList.ItemsPanelRoot is not { } panel)
{
return;
}
if (_isLibraryGridLayout)
{
panel.ClearValue(MarginProperty);
if (ReferenceEquals(panel.RenderTransform, _carouselTransform))
{
panel.RenderTransform = null;
}
return;
}
panel.Margin = new Thickness(CarouselLeadingPadding, 0, 0, 0);
if (!ReferenceEquals(panel.RenderTransform, _carouselTransform))
{
panel.RenderTransform = _carouselTransform;
}
SetCarouselOffset(_carouselOffset);
}
private double MaximumCarouselOffset() =>
Math.Max(0, (_visibleGames.Count - 1) * CarouselItemStride());
private void SetCarouselOffset(double offset)
{
_carouselOffset = Math.Clamp(offset, 0, MaximumCarouselOffset());
_carouselTransform.X = -_carouselOffset;
}
private void AnchorCarouselSelection(bool animate)
{
if (_isLibraryGridLayout || GameList.SelectedIndex < 0 || _carouselPointerDown)
{
return;
}
var target = Math.Clamp(GameList.SelectedIndex * CarouselItemStride(), 0, MaximumCarouselOffset());
if (!animate)
{
_carouselAnimating = false;
SetCarouselOffset(target);
return;
}
_carouselScrollFrom = _carouselOffset;
_carouselScrollTo = target;
_carouselScrollStartedAt = Stopwatch.GetTimestamp();
if (!_carouselAnimating)
{
_carouselAnimating = true;
RequestAnimationFrame(OnCarouselAnimationFrame);
}
}
private void OnCarouselPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (_isLibraryGridLayout || !e.GetCurrentPoint(GameList).Properties.IsLeftButtonPressed)
{
return;
}
_carouselPointerDown = true;
_carouselDragging = false;
_carouselDragStart = e.GetPosition(GameList);
_carouselDragStartOffset = _carouselOffset;
}
private void OnCarouselPointerMoved(object? sender, PointerEventArgs e)
{
if (!_carouselPointerDown)
{
return;
}
var deltaX = e.GetPosition(GameList).X - _carouselDragStart.X;
if (!_carouselDragging)
{
if (Math.Abs(deltaX) < 8)
{
return;
}
_carouselDragging = true;
_carouselAnimating = false;
_carouselDragStartOffset = _carouselOffset;
_carouselDragStart = e.GetPosition(GameList);
deltaX = 0;
e.Pointer.Capture(GameList);
}
SetCarouselOffset(_carouselDragStartOffset - deltaX);
e.Handled = true;
}
private void OnCarouselPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_carouselPointerDown)
{
return;
}
var dragged = _carouselDragging;
EndCarouselDrag(select: true);
if (dragged)
{
e.Pointer.Capture(null);
e.Handled = true;
}
}
private void EndCarouselDrag(bool select)
{
var dragged = _carouselDragging;
_carouselPointerDown = false;
_carouselDragging = false;
if (!dragged || !select || _visibleGames.Count == 0)
{
if (!dragged)
{
AnchorCarouselSelection(animate: true);
}
return;
}
var stride = CarouselItemStride();
var index = stride <= 0 ? 0 : (int)Math.Round(_carouselOffset / stride);
index = Math.Clamp(index, 0, _visibleGames.Count - 1);
if (GameList.SelectedIndex == index)
{
AnchorCarouselSelection(animate: true);
}
else
{
GameList.SelectedIndex = index;
}
}
private void OnCarouselPointerWheel(object? sender, PointerWheelEventArgs e)
{
if (_isLibraryGridLayout || _isGameSettingsOpen || _activePageIndex != 0 || _visibleGames.Count == 0)
{
return;
}
var delta = Math.Abs(e.Delta.X) > Math.Abs(e.Delta.Y) ? -e.Delta.X : e.Delta.Y;
_carouselWheelAccumulator += delta;
while (Math.Abs(_carouselWheelAccumulator) >= 1)
{
var step = _carouselWheelAccumulator > 0 ? -1 : 1;
_carouselWheelAccumulator += step;
MoveSelection(step);
}
e.Handled = true;
}
private void OnCarouselAnimationFrame(TimeSpan frameTime)
{
if (!_carouselAnimating || _carouselDragging)
{
_carouselAnimating = false;
return;
}
var progress = Math.Min(1.0, Stopwatch.GetElapsedTime(_carouselScrollStartedAt).TotalMilliseconds / 260.0);
var eased = 1 - Math.Pow(1 - progress, 3);
SetCarouselOffset(_carouselScrollFrom + (_carouselScrollTo - _carouselScrollFrom) * eased);
if (progress >= 1)
{
_carouselAnimating = false;
return;
}
RequestAnimationFrame(OnCarouselAnimationFrame);
}
private void OnGameListDoubleTapped(object? sender, TappedEventArgs e)
@ -2137,6 +2503,100 @@ public partial class MainWindow : Window
CtxLaunch.IsEnabled = !_isRunning;
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
CtxPackZar.IsEnabled = !ZArchive.IsArchivePath(game.Path);
}
// Packs the selected game's folder into a .zar chosen by the user, beside the folder by default.
private async Task PackSelectedAsZarAsync()
{
if (GameList.SelectedItem is not GameEntry game || ZArchive.IsArchivePath(game.Path) ||
Path.GetDirectoryName(game.Path) is not { } gameFolder)
{
return;
}
var loc = Localization.Instance;
var options = new FilePickerSaveOptions
{
Title = loc.Get("Dialog.SaveZar"),
SuggestedFileName = SuggestedArchiveName(Path.GetFileName(gameFolder), game.TitleId, game.Version),
DefaultExtension = "zar",
FileTypeChoices = [new FilePickerFileType(loc.Get("Dialog.ZarFiles")) { Patterns = ["*.zar"] }],
};
if (Path.GetDirectoryName(gameFolder) is { } parent &&
await StorageProvider.TryGetFolderFromPathAsync(parent) is { } startFolder)
{
options.SuggestedStartLocation = startFolder;
}
var file = await StorageProvider.SaveFilePickerAsync(options);
var destination = file?.TryGetLocalPath();
if (string.IsNullOrEmpty(destination))
{
return;
}
// A running game may hold files in the folder open, so it is never deleted then.
var dialog = new ZarPackDialog(game.Name, gameFolder, destination, canDeleteSource: !_isRunning);
dialog.MuteMusicChanged += SetTitleMusicMuted;
try
{
if (await dialog.ShowDialog<bool>(this))
{
AppendConsoleLine(loc.Format("Status.PackedZar", destination), SuccessLineBrush);
}
}
finally
{
SetTitleMusicMuted(false);
}
}
// TITLEID-version (PPSA01234-1.004): the title id differs between regions of the same game and
// the version between updates, so each archive gets its own name and cache folder. Without a
// title id the game folder's name stands in.
internal static string SuggestedArchiveName(string folderName, string? titleId, string? version)
{
var name = string.IsNullOrWhiteSpace(titleId) ? folderName : titleId.Trim();
var shortVersion = ShortVersion(version);
if (shortVersion.Length > 0)
{
name = $"{name}-{shortVersion}";
}
foreach (var invalid in Path.GetInvalidFileNameChars())
{
name = name.Replace(invalid, '_');
}
return name;
}
// "01.004.000" reads as "1.004": the leading zeros of the major number go, and the last part
// stays only when it is not zero. A version in another form is kept as it is.
private static string ShortVersion(string? version)
{
var text = version?.Trim() ?? string.Empty;
var parts = text.Split('.');
if (parts.Length < 2 || !parts.All(part => part.Length > 0 && part.All(char.IsAsciiDigit)))
{
return text;
}
var major = parts[0].TrimStart('0');
var shortened = $"{(major.Length == 0 ? "0" : major)}.{parts[1]}";
return parts.Length > 2 && parts[2].Any(digit => digit != '0')
? $"{shortened}.{string.Join('.', parts[2..])}"
: shortened;
}
private void SetTitleMusicMuted(bool muted)
{
_sndPreview.Muted = muted;
if (!muted && WindowState != WindowState.Minimized)
{
_sndPreview.Resume();
}
}
private void OpenSelectedGameFolder()
@ -2312,7 +2772,9 @@ public partial class MainWindow : Window
return;
}
var directory = Path.GetDirectoryName(game.Path);
var directory = ZArchive.IsArchivePath(game.Path)
? ZArchiveCache.GetCacheRoot(game.Path)
: Path.GetDirectoryName(game.Path);
var sndPath = directory is null ? null : Path.Combine(directory, "sce_sys", "snd0.at9");
if (sndPath is not null && File.Exists(sndPath))
{
@ -2420,7 +2882,7 @@ public partial class MainWindow : Window
FileTypeFilter = new[]
{
new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables"))
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf", "*.zar" } },
FilePickerFileTypes.All,
},
});

View File

@ -26,9 +26,44 @@ internal sealed class SndPreviewPlayer
private GCHandle _pinnedWav;
private bool _playing;
private bool _paused;
private bool _muted;
private string? _cachedPath;
private byte[]? _cachedWav;
/// <summary>
/// Keeps the music silent while set, even across <see cref="Play"/> and
/// <see cref="Resume"/>; the track stays ready so clearing it and
/// resuming restarts the music.
/// </summary>
public bool Muted
{
get
{
lock (_sync)
{
return _muted;
}
}
set
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (_sync)
{
_muted = value;
if (value && _playing)
{
_ = PlaySound(0, 0, 0);
_playing = false;
_paused = true;
}
}
}
}
/// <summary>Starts looping the given snd0.at9 after a short debounce.</summary>
public void Play(string at9Path)
{
@ -87,6 +122,12 @@ internal sealed class SndPreviewPlayer
// The WAV image must stay pinned while winmm plays from it.
_pinnedWav = GCHandle.Alloc(wav, GCHandleType.Pinned);
if (_muted)
{
_paused = true;
return;
}
_playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT);
if (!_playing)
{
@ -144,7 +185,7 @@ internal sealed class SndPreviewPlayer
lock (_sync)
{
if (!_paused || !_pinnedWav.IsAllocated)
if (!_paused || _muted || !_pinnedWav.IsAllocated)
{
return;
}

View File

@ -9,17 +9,21 @@ Cover-art library item states and motion.
<Style Selector="ListBox.tileGrid">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,0,8,0" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="-32,0,-32,0" />
<Setter Property="Padding" Value="0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<WrapPanel />
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid.gridLayout">
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="4,0,8,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible" />
@ -30,6 +34,7 @@ Cover-art library item states and motion.
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="Width" Value="{DynamicResource LibraryTileWidth}" />
<Setter Property="Height" Value="{DynamicResource LibraryRailItemHeight}" />
<Setter Property="Padding" Value="4" />

View File

@ -0,0 +1,61 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
x:Class="SharpEmu.GUI.ZarPackDialog"
Width="520"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource BgBrush}"
Title="{Binding [Library.Pack.Title], Source={x:Static local:Localization.Instance}}">
<StackPanel Margin="24" Spacing="12">
<TextBlock x:Name="HeadingText"
FontSize="20"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock TextWrapping="Wrap"
Text="{Binding [Library.Pack.Explanation], Source={x:Static local:Localization.Instance}}" />
<TextBlock x:Name="DestinationText"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
<ProgressBar x:Name="PackProgress"
Minimum="0"
Maximum="1"
Height="8"
Margin="0,8,0,0" />
<TextBlock x:Name="StatusText"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
<StackPanel Spacing="4">
<CheckBox x:Name="DeleteSourceCheck"
Content="{Binding [Library.Pack.DeleteSource], Source={x:Static local:Localization.Instance}}" />
<TextBlock Margin="28,0,0,0"
Foreground="{StaticResource DangerBrush}"
TextWrapping="Wrap"
Text="{Binding [Library.Pack.DeleteSourceWarning], Source={x:Static local:Localization.Instance}}" />
</StackPanel>
<Grid Margin="0,8,0,0" ColumnDefinitions="Auto,*,Auto">
<ToggleButton x:Name="MuteMusicButton"
Grid.Column="0"
Classes="ghost"
Padding="10,4"
ToolTip.Tip="{Binding [Library.Pack.MuteMusic], Source={x:Static local:Localization.Instance}}"
Content="🔊" />
<Button x:Name="CloseButton"
Grid.Column="2"
Classes="ghost"
Content="{Binding [Common.Cancel], Source={x:Static local:Localization.Instance}}" />
</Grid>
</StackPanel>
</Window>

View File

@ -0,0 +1,152 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia.Controls;
using SharpEmu.HLE.FileSystem;
namespace SharpEmu.GUI;
// Packs a game folder into a .zar on a worker thread and shows its progress. The button cancels
// while packing and closes once done; the dialog returns true when the archive was written. When
// the user opts in, the game folder is deleted afterwards, but only once the finished archive opens
// and holds every file of the folder at the same size.
public partial class ZarPackDialog : Window
{
private readonly CancellationTokenSource _cancellation = new();
private readonly string _sourceFolder = string.Empty;
private readonly string _destination = string.Empty;
private bool _finished;
private bool _succeeded;
public ZarPackDialog()
{
InitializeComponent();
}
public ZarPackDialog(string gameName, string sourceFolder, string destination, bool canDeleteSource = true)
: this()
{
_sourceFolder = sourceFolder;
_destination = destination;
DeleteSourceCheck.IsEnabled = canDeleteSource;
HeadingText.Text = Localization.Instance.Format("Library.Pack.Heading", gameName);
DestinationText.Text = destination;
StatusText.Text = Localization.Instance.Get("Library.Pack.Preparing");
CloseButton.Click += (_, _) => OnCloseClicked();
MuteMusicButton.IsCheckedChanged += (_, _) =>
{
var muted = MuteMusicButton.IsChecked == true;
MuteMusicButton.Content = muted ? "🔇" : "🔊";
MuteMusicChanged?.Invoke(muted);
};
Opened += async (_, _) => await PackAsync();
Closing += (_, _) =>
{
if (!_finished)
{
_cancellation.Cancel();
}
};
}
// Raised when the mute button changes; the owner silences its title music while it is set.
public event Action<bool>? MuteMusicChanged;
private void OnCloseClicked()
{
if (_finished)
{
Close(_succeeded);
return;
}
CloseButton.IsEnabled = false;
StatusText.Text = Localization.Instance.Get("Library.Pack.Cancelling");
_cancellation.Cancel();
}
private async Task PackAsync()
{
// Progress<T> captures the UI context, so reports arrive on the UI thread.
var progress = new Progress<ZArchiveWriter.Progress>(report =>
{
var fraction = report.BytesTotal == 0 ? 1 : (double)report.BytesDone / report.BytesTotal;
PackProgress.Value = fraction;
StatusText.Text = Localization.Instance.Format(
"Library.Pack.Progress",
FormatGigabytes(report.BytesDone),
FormatGigabytes(report.BytesTotal),
(int)(fraction * 100));
});
string? error = null;
var cancelled = false;
try
{
await Task.Run(() => ZArchiveWriter.Pack(_sourceFolder, _destination, progress, _cancellation.Token));
_succeeded = true;
}
catch (OperationCanceledException)
{
cancelled = true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
error = exception.Message;
}
_finished = true;
if (cancelled && !IsVisible)
{
return;
}
if (cancelled)
{
Close(false);
return;
}
PackProgress.Value = _succeeded ? 1 : PackProgress.Value;
StatusText.Text = _succeeded
? Localization.Instance.Get("Library.Pack.Done")
: Localization.Instance.Format("Library.Pack.Failed", error ?? string.Empty);
DeleteSourceCheck.IsEnabled = false;
if (_succeeded && DeleteSourceCheck.IsChecked == true)
{
StatusText.Text = await DeleteSourceAsync();
}
CloseButton.Content = Localization.Instance.Get("Library.Pack.Close");
CloseButton.IsEnabled = true;
}
// Deletes the game folder once the archive is confirmed to hold all of it. Returns the status
// to show.
private async Task<string> DeleteSourceAsync()
{
var loc = Localization.Instance;
CloseButton.IsEnabled = false;
StatusText.Text = loc.Get("Library.Pack.Verifying");
try
{
var source = _sourceFolder;
var destination = _destination;
if (!File.Exists(Path.Combine(source, "eboot.bin")) ||
!await Task.Run(() => ZArchiveWriter.MatchesFolder(destination, source)))
{
return loc.Get("Library.Pack.DeleteSkipped");
}
StatusText.Text = loc.Get("Library.Pack.Deleting");
await Task.Run(() => Directory.Delete(source, recursive: true));
return loc.Get("Library.Pack.DoneDeleted");
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return loc.Format("Library.Pack.DeleteFailed", exception.Message);
}
}
private static string FormatGigabytes(long bytes) => (bytes / (1024.0 * 1024 * 1024)).ToString("0.00");
}

View File

@ -0,0 +1,303 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO.Enumeration;
namespace SharpEmu.HLE.FileSystem;
// A game run from a .zar mounts /app0 at a small host directory that holds the files host code reads
// by path (the executable, its modules, sce_sys); every other /app0 path is served from the archive.
// A host path under that directory that is not on disk resolves here: stat, listing and read-only
// streams come from the archive, and a host consumer that needs a real file can extract just it.
public static class App0Archive
{
private static readonly object Gate = new();
private static ZArchive? _archive;
private static string? _root;
public static bool IsMounted => Volatile.Read(ref _archive) is not null;
public static string? Root => Volatile.Read(ref _root);
public static void Mount(ZArchive archive, string root)
{
lock (Gate)
{
_archive?.Dispose();
_root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
Volatile.Write(ref _archive, archive);
}
}
public static void Unmount()
{
lock (Gate)
{
_archive?.Dispose();
Volatile.Write(ref _archive, null);
_root = null;
}
}
// The archive and the node a host path names, when it lies under the mount and the archive has it.
public static bool TryResolve(string hostPath, out ZArchive archive, out int node)
{
archive = Volatile.Read(ref _archive)!;
node = ZArchive.InvalidNode;
var root = Volatile.Read(ref _root);
if (archive is null || root is null || string.IsNullOrEmpty(hostPath))
{
return false;
}
string full;
try
{
full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(hostPath));
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
{
return false;
}
string relative;
if (string.Equals(full, root, StringComparison.OrdinalIgnoreCase))
{
relative = string.Empty;
}
else if (full.Length > root.Length && full.StartsWith(root, StringComparison.OrdinalIgnoreCase) &&
full[root.Length] is '\\' or '/')
{
relative = full[(root.Length + 1)..];
}
else
{
return false;
}
node = archive.LookUp(relative);
return node != ZArchive.InvalidNode;
}
public static bool TryGetFile(string hostPath, out ulong size)
{
size = 0;
if (!TryResolve(hostPath, out var archive, out var node) || !archive.IsFile(node))
{
return false;
}
size = archive.GetFileSize(node);
return true;
}
public static bool IsDirectory(string hostPath) =>
TryResolve(hostPath, out var archive, out var node) && archive.IsDirectory(node);
public static bool Exists(string hostPath) => TryResolve(hostPath, out _, out _);
// The names in the archive directory a host path names; empty when there is none.
public static IReadOnlyList<ZArchive.Entry> List(string hostPath) =>
TryResolve(hostPath, out var archive, out var node) ? archive.GetEntries(node) : [];
// A read-only, seekable stream over the archive file a host path names.
public static ZArchiveFileStream? OpenRead(string hostPath) =>
TryResolve(hostPath, out var archive, out var node) && archive.IsFile(node)
? new ZArchiveFileStream(archive, node, hostPath)
: null;
// Opens a host path for reading: from the archive when it is under the mount and not on disk,
// otherwise as a host file.
public static Stream OpenReadOrFile(string hostPath, FileShare share = FileShare.Read) =>
!File.Exists(hostPath) && OpenRead(hostPath) is { } archived
? archived
: new FileStream(hostPath, FileMode.Open, FileAccess.Read, share);
// Writes the archive file a host path names to that path, for host code that opens files itself.
// True when the file is on disk afterwards.
public static bool Extract(string hostPath)
{
if (File.Exists(hostPath))
{
return true;
}
if (OpenRead(hostPath) is not { } source)
{
return false;
}
using (source)
{
lock (Gate)
{
WriteFile(source, hostPath);
}
}
return true;
}
// Copies an archive file to a host path through a temporary name, so a partial copy is never seen.
internal static void WriteFile(Stream source, string hostPath)
{
if (File.Exists(hostPath))
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(hostPath))!);
var temporary = hostPath + ".partial";
using (var target = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
{
source.CopyTo(target, ZArchive.BlockSize);
}
File.Move(temporary, hostPath, overwrite: true);
}
// Every file in the mounted archive, as paths relative to the mount with '/' separators.
public static List<string> ListAllFiles()
{
var files = new List<string>();
if (Volatile.Read(ref _archive) is { } archive)
{
AddFiles(archive, archive.LookUp(string.Empty), string.Empty, files);
}
return files;
}
private static void AddFiles(ZArchive archive, int directory, string prefix, List<string> files)
{
foreach (var entry in archive.GetEntries(directory))
{
var path = prefix + entry.Name;
if (entry.IsDirectory)
{
AddFiles(archive, archive.LookUp(path), path + "/", files);
}
else
{
files.Add(path);
}
}
}
// Files under a host directory and all its subdirectories whose names match a pattern: those on
// disk and, when the directory is under the mount, those in the archive.
public static IEnumerable<string> EnumerateFilesRecursive(string hostDirectory, string pattern)
{
var found = Directory.Exists(hostDirectory)
? Directory.EnumerateFiles(hostDirectory, pattern, SearchOption.AllDirectories).ToList()
: [];
var root = Root;
if (root is null || !TryResolve(hostDirectory, out var archive, out var node) || !archive.IsDirectory(node))
{
return found;
}
var seen = new HashSet<string>(found, StringComparer.OrdinalIgnoreCase);
var start = Path.GetRelativePath(root, Path.GetFullPath(hostDirectory));
var prefix = start == "." ? string.Empty : start.Replace('\\', '/') + "/";
var archived = new List<string>();
AddFiles(archive, node, prefix, archived);
foreach (var relative in archived)
{
var hostPath = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar));
if (FileSystemName.MatchesSimpleExpression(pattern, Path.GetFileName(hostPath)) && seen.Add(hostPath))
{
found.Add(hostPath);
}
}
return found;
}
// Extracts every file under the archive directory into the mount, for the files host code reads by path.
public static void ExtractDirectory(string relative)
{
var root = Root ?? throw new InvalidOperationException("No archive is mounted.");
var directory = Path.Combine(root, relative);
foreach (var entry in List(directory))
{
var path = Path.Combine(relative, entry.Name);
if (entry.IsDirectory)
{
ExtractDirectory(path);
}
else
{
Extract(Path.Combine(root, path));
}
}
}
}
// One archive file as a read-only, seekable stream; reads at any position go through the archive's
// block cache. Name is the host path it was opened as.
public sealed class ZArchiveFileStream : Stream
{
private readonly ZArchive _archive;
private readonly int _node;
private readonly long _length;
private long _position;
public ZArchiveFileStream(ZArchive archive, int node, string name)
{
_archive = archive;
_node = node;
_length = (long)archive.GetFileSize(node);
Name = name;
}
public string Name { get; }
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => _length;
public override long Position
{
get => _position;
set => _position = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
}
// Reads at an offset without moving the position, like a positional read on a file handle.
public int ReadAt(Span<byte> buffer, long offset) =>
offset < 0 ? throw new ArgumentOutOfRangeException(nameof(offset)) : _archive.Read(_node, (ulong)offset, buffer);
public override int Read(Span<byte> buffer)
{
var read = ReadAt(buffer, _position);
_position += read;
return read;
}
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
public override long Seek(long offset, SeekOrigin origin)
{
var target = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => _position + offset,
SeekOrigin.End => _length + offset,
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
};
Position = target;
return target;
}
public override void Flush()
{
}
public override void SetLength(long value) => throw new NotSupportedException("An archive file is read-only.");
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException("An archive file is read-only.");
}

View File

@ -0,0 +1,390 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Text;
using ZstdSharp;
namespace SharpEmu.HLE.FileSystem;
// A read-only ZArchive (.zar): a game folder packed into zstd-compressed 64 KiB blocks with an index,
// so any byte range reads without unpacking the archive. Format by Exzap
// (https://github.com/Exzap/ZArchive, MIT No Attribution); every multi-byte field is big-endian.
//
// Layout, read from the footer at the end of the file:
// compressed data blocks of 64 KiB once decompressed; a block stored at exactly 64 KiB is raw
// offset records per 16 blocks: the first block's offset (u64) and each block's size - 1 (u16)
// names per node: a 1-byte length (or 2 bytes when the top bit is set) and the bytes
// file tree 16-byte nodes; node 0 is the unnamed root directory
public sealed class ZArchive : IDisposable
{
public const int BlockSize = 64 * 1024;
private const int BlocksPerOffsetRecord = 16;
private const int OffsetRecordBytes = 8 + 2 * BlocksPerOffsetRecord;
private const int NodeBytes = 16;
private const int FooterBytes = 16 * 6 + 32 + 8 + 4 + 4;
private const uint FooterMagic = 0x169F52D6;
private const uint FooterVersion1 = 0x61BF3A01;
private const uint FileFlag = 0x80000000;
private const uint NameOffsetMask = 0x7FFFFFFF;
private const int CacheBlocks = 64;
public const int InvalidNode = -1;
private readonly FileStream _stream;
private readonly OffsetRecord[] _offsetRecords;
private readonly byte[] _names;
private readonly Node[] _nodes;
private readonly ulong _dataOffset;
private readonly ulong _dataSize;
private readonly object _gate = new();
private readonly Decompressor _decompressor = new();
private readonly byte[] _compressed = new byte[BlockSize];
// Decompressed blocks, least recently used first.
private readonly LinkedList<(ulong Block, byte[] Data)> _cache = new();
private readonly Dictionary<ulong, LinkedListNode<(ulong Block, byte[] Data)>> _cached = new();
private readonly record struct OffsetRecord(ulong BaseOffset, ushort[] Sizes);
private readonly record struct Node(uint NameAndType, uint Word0, uint Word1, uint Word2)
{
public bool IsFile => (NameAndType & FileFlag) != 0;
public uint NameOffset => NameAndType & NameOffsetMask;
public ulong FileOffset => Word0 | ((ulong)(Word2 & 0xFFFF) << 32);
public ulong FileSize => Word1 | ((ulong)(Word2 & 0xFFFF0000) << 16);
public uint FirstChild => Word0;
public uint ChildCount => Word1;
}
public readonly record struct Entry(string Name, bool IsDirectory, ulong Size);
public string Path { get; }
private ZArchive(string path, FileStream stream, OffsetRecord[] offsetRecords, byte[] names, Node[] nodes, ulong dataOffset, ulong dataSize)
{
Path = path;
_stream = stream;
_offsetRecords = offsetRecords;
_names = names;
_nodes = nodes;
_dataOffset = dataOffset;
_dataSize = dataSize;
}
public static bool IsArchivePath(string? path) =>
path is not null && path.EndsWith(".zar", StringComparison.OrdinalIgnoreCase) && File.Exists(path);
// Null when the file is not a well-formed version-1 archive.
public static ZArchive? Open(string path)
{
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.RandomAccess);
try
{
if (TryOpen(path, stream) is { } archive)
{
return archive;
}
}
catch (Exception exception) when (exception is IOException or ArgumentException or OverflowException)
{
}
stream.Dispose();
return null;
}
private static ZArchive? TryOpen(string path, FileStream stream)
{
var length = (ulong)stream.Length;
if (length <= FooterBytes)
{
return null;
}
Span<byte> footer = stackalloc byte[FooterBytes];
ReadExactly(stream, length - FooterBytes, footer);
var magic = BinaryPrimitives.ReadUInt32BigEndian(footer[(FooterBytes - 4)..]);
var version = BinaryPrimitives.ReadUInt32BigEndian(footer[(FooterBytes - 8)..]);
var totalSize = BinaryPrimitives.ReadUInt64BigEndian(footer[(FooterBytes - 16)..]);
if (magic != FooterMagic || version != FooterVersion1 || totalSize != length)
{
return null;
}
// Six (offset, size) pairs: compressed data, offset records, names, file tree, metadata
// directory and metadata; the last two are unused.
var sections = new (ulong Offset, ulong Size)[6];
for (var index = 0; index < sections.Length; index++)
{
var offset = BinaryPrimitives.ReadUInt64BigEndian(footer[(index * 16)..]);
var size = BinaryPrimitives.ReadUInt64BigEndian(footer[(index * 16 + 8)..]);
if (offset > length || size > length - offset)
{
return null;
}
sections[index] = (offset, size);
}
var (dataOffset, dataSize) = sections[0];
var (recordsOffset, recordsSize) = sections[1];
var (namesOffset, namesSize) = sections[2];
var (treeOffset, treeSize) = sections[3];
if (recordsSize == 0 || recordsSize % OffsetRecordBytes != 0 || recordsSize > uint.MaxValue ||
namesSize > int.MaxValue || treeSize == 0 || treeSize % NodeBytes != 0 || treeSize > int.MaxValue)
{
return null;
}
var recordBytes = new byte[recordsSize];
ReadExactly(stream, recordsOffset, recordBytes);
var records = new OffsetRecord[recordsSize / OffsetRecordBytes];
for (var index = 0; index < records.Length; index++)
{
var record = recordBytes.AsSpan(index * OffsetRecordBytes, OffsetRecordBytes);
var sizes = new ushort[BlocksPerOffsetRecord];
for (var block = 0; block < sizes.Length; block++)
{
sizes[block] = BinaryPrimitives.ReadUInt16BigEndian(record[(8 + block * 2)..]);
}
records[index] = new OffsetRecord(BinaryPrimitives.ReadUInt64BigEndian(record), sizes);
}
var names = new byte[namesSize];
ReadExactly(stream, namesOffset, names);
var treeBytes = new byte[treeSize];
ReadExactly(stream, treeOffset, treeBytes);
var nodes = new Node[treeSize / NodeBytes];
for (var index = 0; index < nodes.Length; index++)
{
var node = treeBytes.AsSpan(index * NodeBytes, NodeBytes);
nodes[index] = new Node(
BinaryPrimitives.ReadUInt32BigEndian(node),
BinaryPrimitives.ReadUInt32BigEndian(node[4..]),
BinaryPrimitives.ReadUInt32BigEndian(node[8..]),
BinaryPrimitives.ReadUInt32BigEndian(node[12..]));
}
if (nodes[0].IsFile || NameOf(names, nodes[0].NameOffset) is not { Length: 0 })
{
return null;
}
foreach (var node in nodes)
{
if (!node.IsFile && ((ulong)node.FirstChild + node.ChildCount > (ulong)nodes.Length))
{
return null;
}
}
return new ZArchive(path, stream, records, names, nodes, dataOffset, dataSize);
}
// The node at the path, with '/' or '\' separators and names compared without case;
// the root for an empty path; InvalidNode when it is missing.
public int LookUp(string path)
{
var current = 0;
foreach (var part in path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries))
{
var directory = _nodes[current];
if (directory.IsFile)
{
return InvalidNode;
}
var match = InvalidNode;
for (var child = directory.FirstChild; child < directory.FirstChild + directory.ChildCount; child++)
{
if (string.Equals(NameOf(_names, _nodes[child].NameOffset), part, StringComparison.OrdinalIgnoreCase))
{
match = (int)child;
break;
}
}
if (match == InvalidNode)
{
return InvalidNode;
}
current = match;
}
return current;
}
public bool IsFile(int node) => (uint)node < (uint)_nodes.Length && _nodes[node].IsFile;
public bool IsDirectory(int node) => (uint)node < (uint)_nodes.Length && !_nodes[node].IsFile;
public ulong GetFileSize(int node) => IsFile(node) ? _nodes[node].FileSize : 0;
public IReadOnlyList<Entry> GetEntries(int directory)
{
if (!IsDirectory(directory))
{
return [];
}
var node = _nodes[directory];
var entries = new List<Entry>((int)node.ChildCount);
for (var child = node.FirstChild; child < node.FirstChild + node.ChildCount; child++)
{
var entry = _nodes[child];
if (NameOf(_names, entry.NameOffset) is { Length: > 0 } name)
{
entries.Add(new Entry(name, !entry.IsFile, entry.IsFile ? entry.FileSize : 0));
}
}
return entries;
}
// Copies file bytes from offset into destination; returns the count read, short at the end of
// the file. Throws InvalidDataException for a damaged block.
public int Read(int file, ulong offset, Span<byte> destination)
{
if (!IsFile(file))
{
return 0;
}
var node = _nodes[file];
if (offset >= node.FileSize)
{
return 0;
}
var count = (int)Math.Min((ulong)destination.Length, node.FileSize - offset);
var position = node.FileOffset + offset;
var written = 0;
lock (_gate)
{
while (written < count)
{
var block = position / BlockSize;
var within = (int)(position % BlockSize);
var step = Math.Min(count - written, BlockSize - within);
LoadBlock(block).AsSpan(within, step).CopyTo(destination[written..]);
written += step;
position += (ulong)step;
}
}
return written;
}
private byte[] LoadBlock(ulong block)
{
if (_cached.TryGetValue(block, out var cached))
{
_cache.Remove(cached);
_cache.AddLast(cached);
return cached.Value.Data;
}
var recordIndex = block / BlocksPerOffsetRecord;
if (recordIndex >= (ulong)_offsetRecords.Length)
{
throw new InvalidDataException($"The archive block {block} is past its offset records.");
}
var record = _offsetRecords[recordIndex];
var sub = (int)(block % BlocksPerOffsetRecord);
var offset = record.BaseOffset;
for (var index = 0; index < sub; index++)
{
offset += (ulong)record.Sizes[index] + 1;
}
var size = record.Sizes[sub] + 1;
if (offset + (ulong)size > _dataSize)
{
throw new InvalidDataException($"The archive block {block} lies outside the compressed data.");
}
byte[] data;
LinkedListNode<(ulong Block, byte[] Data)> slot;
if (_cache.Count >= CacheBlocks)
{
slot = _cache.First!;
_cache.RemoveFirst();
_cached.Remove(slot.Value.Block);
data = slot.Value.Data;
}
else
{
data = new byte[BlockSize];
slot = new LinkedListNode<(ulong Block, byte[] Data)>((block, data));
}
if (size == BlockSize)
{
ReadExactly(_stream, _dataOffset + offset, data);
}
else
{
ReadExactly(_stream, _dataOffset + offset, _compressed.AsSpan(0, size));
if (!_decompressor.TryUnwrap(_compressed.AsSpan(0, size), data, out var produced) || produced != BlockSize)
{
throw new InvalidDataException($"The archive block {block} does not decompress to {BlockSize} bytes.");
}
}
slot.Value = (block, data);
_cache.AddLast(slot);
_cached[block] = slot;
return data;
}
// The name at the offset; empty for a missing or malformed one. A length of 128 or more takes a
// second byte: (first & 0x7F) | (second << 7), as the archive writer stores it.
private static string NameOf(byte[] names, uint offset)
{
if (offset == NameOffsetMask || offset >= (uint)names.Length)
{
return string.Empty;
}
var position = (int)offset;
var length = names[position] & 0x7F;
if ((names[position] & 0x80) != 0)
{
if (position + 1 >= names.Length)
{
return string.Empty;
}
length |= names[position + 1] << 7;
position += 2;
}
else
{
position++;
}
return position + length > names.Length ? string.Empty : Encoding.UTF8.GetString(names, position, length);
}
private static void ReadExactly(FileStream stream, ulong offset, Span<byte> destination)
{
if (RandomAccess.Read(stream.SafeFileHandle, destination, (long)offset) != destination.Length)
{
throw new IOException("The archive ended before a section it lists.");
}
}
public void Dispose()
{
_stream.Dispose();
_decompressor.Dispose();
}
}

View File

@ -0,0 +1,79 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.FileSystem;
// Each .zar gets one host folder under user/zar-cache, named after the archive. /app0 is mounted
// there while the game runs and the library reads the game's metadata from it. A stamp of the
// archive's path, size and write time tells whether the files in it came from this archive; when
// it differs, the folder starts over.
public static class ZArchiveCache
{
private const string StampName = ".sharpemu-archive";
// The files the library shows for a game: its parameters, icon and key art.
private static readonly string[] MetadataFiles =
[
"sce_sys/param.json",
"sce_sys/icon0.png",
"sce_sys/pic0.png",
"sce_sys/pic1.png",
"sce_sys/snd0.at9",
];
private static string DefaultCacheParent => Path.Combine(AppContext.BaseDirectory, "user", "zar-cache");
// The cache folder an archive uses, without creating or checking it.
public static string GetCacheRoot(string archivePath, string? cacheParent = null) =>
Path.Combine(cacheParent ?? DefaultCacheParent, Path.GetFileNameWithoutExtension(archivePath));
public static string Prepare(string archivePath) => Prepare(archivePath, DefaultCacheParent);
public static string Prepare(string archivePath, string cacheParent)
{
archivePath = Path.GetFullPath(archivePath);
var info = new FileInfo(archivePath);
var cacheRoot = GetCacheRoot(archivePath, cacheParent);
var stampPath = Path.Combine(cacheRoot, StampName);
var stamp = $"{archivePath}|{info.Length}|{info.LastWriteTimeUtc.Ticks}";
if (!File.Exists(stampPath) || File.ReadAllText(stampPath) != stamp)
{
if (Directory.Exists(cacheRoot))
{
Directory.Delete(cacheRoot, recursive: true);
}
Directory.CreateDirectory(cacheRoot);
File.WriteAllText(stampPath, stamp);
}
return cacheRoot;
}
// Extracts an archive's sce_sys metadata into its cache folder and returns the folder, or null
// when the file is not a readable archive. The folder stands in for the game folder.
public static string? ExtractMetadata(string archivePath, string? cacheParent = null)
{
using var archive = ZArchive.Open(archivePath);
if (archive is null)
{
return null;
}
var cacheRoot = cacheParent is null ? Prepare(archivePath) : Prepare(archivePath, cacheParent);
foreach (var relative in MetadataFiles)
{
var node = archive.LookUp(relative);
if (!archive.IsFile(node))
{
continue;
}
var hostPath = Path.Combine(cacheRoot, relative.Replace('/', Path.DirectorySeparatorChar));
using var source = new ZArchiveFileStream(archive, node, hostPath);
App0Archive.WriteFile(source, hostPath);
}
return cacheRoot;
}
}

View File

@ -0,0 +1,312 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Text;
using ZstdSharp;
namespace SharpEmu.HLE.FileSystem;
// Packs a folder into a .zar the way the ZArchive writer lays one out: file data appended in order
// and cut into 64 KiB zstd blocks (the last padded with zeros, a block that does not shrink stored
// raw), the data section padded to 8 bytes, then offset records, names, a breadth-first file tree
// with each directory's children sorted without case, empty metadata and the footer. Data streams
// through one block at a time, so a large game needs little memory.
public static class ZArchiveWriter
{
private const int CompressionLevel = 6;
private const uint Version = 0x61BF3A01;
private const uint Magic = 0x169F52D6;
public readonly record struct Progress(long BytesDone, long BytesTotal, int FilesDone, int FilesTotal);
private sealed class PathNode(string name, bool isFile)
{
public string Name { get; } = name;
public bool IsFile { get; } = isFile;
public List<PathNode> Children { get; } = [];
public ulong FileOffset { get; set; }
public ulong FileSize { get; set; }
public uint NameOffset { get; set; }
public uint FirstChild { get; set; }
}
// Writes the archive through a temporary name and moves it into place only when complete, so a
// cancelled or failed pack leaves no partial .zar behind.
public static void Pack(
string sourceDirectory,
string destinationPath,
IProgress<Progress>? progress = null,
CancellationToken cancellationToken = default)
{
var source = Path.GetFullPath(sourceDirectory);
var destination = Path.GetFullPath(destinationPath);
if (destination.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
throw new IOException("The archive cannot be written inside the folder being packed.");
}
var files = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)
.Select(path => new FileInfo(path))
.OrderBy(info => info.FullName, StringComparer.OrdinalIgnoreCase)
.ToList();
var bytesTotal = files.Sum(info => info.Length);
var temporary = destination + ".partial";
try
{
using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20))
{
Write(source, files, bytesTotal, output, progress, cancellationToken);
}
File.Move(temporary, destination, overwrite: true);
}
catch
{
try
{
File.Delete(temporary);
}
catch (IOException)
{
}
throw;
}
}
// Whether the archive opens and holds every file of the folder at the same size: the check
// that must pass before the folder may be deleted.
public static bool MatchesFolder(string archivePath, string sourceDirectory)
{
using var archive = ZArchive.Open(archivePath);
if (archive is null)
{
return false;
}
var source = Path.GetFullPath(sourceDirectory);
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
var node = archive.LookUp(Path.GetRelativePath(source, file));
if (!archive.IsFile(node) || archive.GetFileSize(node) != (ulong)new FileInfo(file).Length)
{
return false;
}
}
return true;
}
private static void Write(
string source,
List<FileInfo> files,
long bytesTotal,
FileStream output,
IProgress<Progress>? progress,
CancellationToken cancellationToken)
{
var root = new PathNode(string.Empty, isFile: false);
var block = new byte[ZArchive.BlockSize];
var filled = 0;
var blockIndex = 0;
var records = new List<(ulong BaseOffset, ushort[] Sizes)>();
using var compressor = new Compressor(CompressionLevel);
ulong dataOffset = 0;
var lastReport = 0L;
void FlushBlock()
{
var compressed = compressor.Wrap(block);
var offset = (ulong)output.Position;
int size;
if (compressed.Length >= ZArchive.BlockSize)
{
output.Write(block);
size = ZArchive.BlockSize;
}
else
{
output.Write(compressed);
size = compressed.Length;
}
if (blockIndex % 16 == 0)
{
records.Add((offset, new ushort[16]));
}
records[^1].Sizes[blockIndex % 16] = (ushort)(size - 1);
blockIndex++;
filled = 0;
}
for (var index = 0; index < files.Count; index++)
{
var parts = Path.GetRelativePath(source, files[index].FullName).Split(Path.DirectorySeparatorChar);
var directory = root;
foreach (var part in parts[..^1])
{
var next = directory.Children.Find(child => string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase));
if (next is null)
{
next = new PathNode(part, isFile: false);
directory.Children.Add(next);
}
directory = next;
}
var file = new PathNode(parts[^1], isFile: true) { FileOffset = dataOffset };
using (var input = new FileStream(files[index].FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20))
{
int read;
while ((read = input.Read(block, filled, block.Length - filled)) > 0)
{
filled += read;
dataOffset += (ulong)read;
file.FileSize += (ulong)read;
if (filled == block.Length)
{
cancellationToken.ThrowIfCancellationRequested();
FlushBlock();
if ((long)dataOffset - lastReport >= 16 << 20)
{
lastReport = (long)dataOffset;
progress?.Report(new Progress(lastReport, bytesTotal, index, files.Count));
}
}
}
}
directory.Children.Add(file);
}
if (filled > 0)
{
Array.Clear(block, filled, block.Length - filled);
FlushBlock();
}
var dataSize = (ulong)output.Position;
while (output.Position % 8 != 0)
{
output.WriteByte(0);
}
var sections = new (ulong Offset, ulong Size)[6];
sections[0] = (0, dataSize);
var start = (ulong)output.Position;
foreach (var (baseOffset, sizes) in records)
{
WriteU64(output, baseOffset);
foreach (var size in sizes)
{
WriteU16(output, size);
}
}
sections[1] = (start, (ulong)output.Position - start);
// Names in breadth-first order, as the reference writer interns them.
var ordered = new List<PathNode>();
var queue = new Queue<PathNode>();
queue.Enqueue(root);
var nextIndex = 1u;
while (queue.TryDequeue(out var node))
{
ordered.Add(node);
if (node.IsFile)
{
continue;
}
node.Children.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
node.FirstChild = nextIndex;
nextIndex += (uint)node.Children.Count;
foreach (var child in node.Children)
{
queue.Enqueue(child);
}
}
start = (ulong)output.Position;
foreach (var node in ordered.Skip(1))
{
node.NameOffset = (uint)((ulong)output.Position - start);
var name = Encoding.UTF8.GetBytes(node.Name);
if (name.Length >= 0x8000)
{
throw new IOException($"The name '{node.Name}' is too long for an archive.");
}
if (name.Length >= 0x80)
{
output.WriteByte((byte)((name.Length & 0x7F) | 0x80));
output.WriteByte((byte)(name.Length >> 7));
}
else
{
output.WriteByte((byte)name.Length);
}
output.Write(name);
}
sections[2] = (start, (ulong)output.Position - start);
start = (ulong)output.Position;
foreach (var node in ordered)
{
var nameOffset = ReferenceEquals(node, root) ? 0x7FFFFFFFu : node.NameOffset;
WriteU32(output, nameOffset | (node.IsFile ? 0x80000000u : 0));
if (node.IsFile)
{
WriteU32(output, (uint)node.FileOffset);
WriteU32(output, (uint)node.FileSize);
WriteU32(output, (uint)((node.FileOffset >> 32) & 0xFFFF) | (uint)((node.FileSize >> 16) & 0xFFFF0000));
}
else
{
WriteU32(output, node.FirstChild);
WriteU32(output, (uint)node.Children.Count);
WriteU32(output, 0);
}
}
sections[3] = (start, (ulong)output.Position - start);
sections[4] = ((ulong)output.Position, 0);
sections[5] = ((ulong)output.Position, 0);
foreach (var (offset, size) in sections)
{
WriteU64(output, offset);
WriteU64(output, size);
}
output.Write(new byte[32]);
WriteU64(output, (ulong)output.Position + 8 + 4 + 4);
WriteU32(output, Version);
WriteU32(output, Magic);
progress?.Report(new Progress(bytesTotal, bytesTotal, files.Count, files.Count));
}
private static void WriteU16(Stream stream, ushort value)
{
Span<byte> bytes = stackalloc byte[2];
BinaryPrimitives.WriteUInt16BigEndian(bytes, value);
stream.Write(bytes);
}
private static void WriteU32(Stream stream, uint value)
{
Span<byte> bytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
stream.Write(bytes);
}
private static void WriteU64(Stream stream, ulong value)
{
Span<byte> bytes = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(bytes, value);
stream.Write(bytes);
}
}

View File

@ -30,6 +30,7 @@ public sealed class GuestGpuMemory : IDisposable
private readonly PageGuard _pages;
private readonly ReaderWriterLockSlim _spansLock = new();
private readonly SpanSet _spans = new();
private SpanSnapshot _spanSnapshot = SpanSnapshot.Empty;
private long _spanVersion;
public long SpanVersion => Interlocked.Read(ref _spanVersion);
@ -153,15 +154,7 @@ public sealed class GuestGpuMemory : IDisposable
return false;
}
_spansLock.EnterReadLock();
try
{
return _spans.Contains(address, size);
}
finally
{
_spansLock.ExitReadLock();
}
return Volatile.Read(ref _spanSnapshot).Contains(address, size);
}
public void ForEachSpan(Action<ulong, ulong> visit)
@ -207,6 +200,7 @@ public sealed class GuestGpuMemory : IDisposable
try
{
_spans.Add(address, size);
Volatile.Write(ref _spanSnapshot, _spans.Snapshot());
Interlocked.Increment(ref _spanVersion);
}
finally
@ -280,6 +274,7 @@ public sealed class GuestGpuMemory : IDisposable
try
{
_spans.Remove(address, size);
Volatile.Write(ref _spanSnapshot, _spans.Snapshot());
Interlocked.Increment(ref _spanVersion);
}
finally

View File

@ -198,6 +198,14 @@ public sealed class GuestPageTracker
if (Volatile.Read(ref _regions[index]) is { } region)
{
tracked = true;
if (region.IsCpuDirtyThroughout(offset, bytes))
{
remaining -= bytes;
offset = 0;
index++;
continue;
}
using (region.Lock.Hold())
{
if (region.IsModified(WriteOrigin.Gpu, offset, bytes))

View File

@ -3,6 +3,22 @@
namespace SharpEmu.HLE.GpuMemory;
public sealed class SpanSnapshot(ulong[] starts, ulong[] ends)
{
public static readonly SpanSnapshot Empty = new([], []);
public bool Contains(ulong address, ulong size)
{
var index = Array.BinarySearch(starts, address);
if (index < 0)
{
index = ~index - 1;
}
return index >= 0 && ends[index] >= address + size;
}
}
public sealed class SpanSet
{
private readonly SortedList<ulong, ulong> _spans = new();
@ -79,6 +95,19 @@ public sealed class SpanSet
return index > 0 && _spans.GetValueAtIndex(index - 1) > address;
}
public SpanSnapshot Snapshot()
{
var starts = new ulong[_spans.Count];
var ends = new ulong[_spans.Count];
for (var index = 0; index < starts.Length; index++)
{
starts[index] = _spans.GetKeyAtIndex(index);
ends[index] = _spans.GetValueAtIndex(index);
}
return new SpanSnapshot(starts, ends);
}
public void ForEach(Action<ulong, ulong> visit)
{
foreach (var (start, end) in _spans)

View File

@ -46,6 +46,20 @@ public sealed class TrackedRegion
return GetDirtyMask(side).AnyInRange(start, end);
}
public bool IsCpuDirtyThroughout(ulong offset, ulong size)
{
var (start, end) = GetPageRange(BaseAddress + offset, size);
for (var page = start; page < end; page++)
{
if (!_cpuDirty.Get(page))
{
return false;
}
}
return true;
}
public bool IsCpuWriteHot(ulong offset, ulong size)
{
var (start, end) = GetPageRange(BaseAddress + offset, size);

View File

@ -67,6 +67,8 @@ public sealed class GuestSpaceOwner : IDisposable
// Reads through a view that covers the whole range without any lock; false otherwise.
public bool TryReadSingleView(ulong address, Span<byte> data) => _views.TryReadSingleView(address, data);
public bool TryWriteSingleView(ulong address, ReadOnlySpan<byte> data) => _views.TryWriteSingleView(address, data);
public object AliasSnapshot => _views.AliasSnapshot;
public bool TryEnterAliasAccess() => _views.TryEnterAliasAccess();

View File

@ -118,6 +118,24 @@ public sealed unsafe class SharedBackingViews : IDisposable
}
}
public bool TryWriteSingleView(ulong address, ReadOnlySpan<byte> data)
{
if (!TryAccessSingleView(address, (ulong)data.Length, out var target))
{
return false;
}
try
{
data.CopyTo(new Span<byte>((void*)target, data.Length));
return true;
}
finally
{
Interlocked.Decrement(ref _activeAccesses);
}
}
public bool TryReadBacking(ulong address, Span<byte> data)
{
if (TryAccessSingleView(address, (ulong)data.Length, out var source))

View File

@ -23,6 +23,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup>
<PackageReference Include="ppy.SDL3-CS" />
<PackageReference Include="ZstdSharp.Port" />
</ItemGroup>
<ItemGroup>

View File

@ -136,12 +136,7 @@ public static partial class AgcExports
var groupCountZ = (uint)ctx[CpuRegister.Rcx];
var modifier = (uint)ctx[CpuRegister.R8];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(5, ItDispatchDirect, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, groupCountX) ||
!TryWriteUInt32(ctx, commandAddress + 8, groupCountY) ||
!TryWriteUInt32(ctx, commandAddress + 12, groupCountZ) ||
!TryWriteUInt32(ctx, commandAddress + 16, DirectDispatchInitiator(modifier)))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(5, ItDispatchDirect, 0), groupCountX, groupCountY, groupCountZ, DirectDispatchInitiator(modifier)))
{
return ReturnPointer(ctx, 0);
}
@ -166,9 +161,7 @@ public static partial class AgcExports
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RAcbReset)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItNop, RAcbReset), 0))
{
return ReturnPointer(ctx, 0);
}
@ -198,15 +191,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 8, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(8, ItNop, RAcquireMem)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0x8000_0000u) ||
!TryWriteUInt32(ctx, commandAddress + 8, noSize ? 0 : (uint)(sizeBytes >> 8)) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)(baseAddress >> 8)) ||
!TryWriteUInt32(ctx, commandAddress + 20, 0) ||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40) ||
!TryWriteUInt32(ctx, commandAddress + 28, gcrControl))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(8, ItNop, RAcquireMem), 0x8000_0000u, noSize ? 0 : (uint)(sizeBytes >> 8), 0, (uint)(baseAddress >> 8), 0, pollCycles / 40, gcrControl))
{
return ReturnPointer(ctx, 0);
}
@ -301,11 +286,7 @@ public static partial class AgcExports
var argumentsAddress = ctx[CpuRegister.Rsi];
var modifier = (uint)ctx[CpuRegister.Rdx];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(4, ItDispatchIndirect, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)argumentsAddress) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(argumentsAddress >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (modifier & 0xA038u) | 0x41u))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(4, ItDispatchIndirect, 0), (uint)argumentsAddress, (uint)(argumentsAddress >> 32), (modifier & 0xA038u) | 0x41u))
{
return ReturnPointer(ctx, 0);
}
@ -360,18 +341,7 @@ public static partial class AgcExports
var packetAddressValue = interrupt == 4 ? 0UL : destinationAddress;
var packetData = interrupt == 4 ? 0UL : data;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 8, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(8, ItNop, RReleaseMem)) ||
!TryWriteUInt32(ctx, commandAddress + 4, action | (cachePolicy << 8)) ||
!TryWriteUInt32(
ctx,
commandAddress + 8,
gcrControl | (dataSelection << 16) | (interrupt << 24)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)packetAddressValue) ||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)(packetAddressValue >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)packetData) ||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(packetData >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 28, interruptContextId & 0x07FF_FFFFu))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(8, ItNop, RReleaseMem), action | (cachePolicy << 8), gcrControl | (dataSelection << 16) | (interrupt << 24), (uint)packetAddressValue, (uint)(packetAddressValue >> 32), (uint)packetData, (uint)(packetData >> 32), interruptContextId & 0x07FF_FFFFu))
{
return ReturnPointer(ctx, 0);
}
@ -397,9 +367,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RDrawReset)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItNop, RDrawReset), 0))
{
return ReturnPointer(ctx, 0);
}
@ -448,12 +416,7 @@ public static partial class AgcExports
(resetCounters << 18) |
(counterMask << 10) |
(counterSelect << 2);
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(5, ItGetLodStats, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, control) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)destinationAddress & ~0x3Fu) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(destinationAddress >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 16, packetControl))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(5, ItGetLodStats, 0), control, (uint)destinationAddress & ~0x3Fu, (uint)(destinationAddress >> 32), packetControl))
{
return ReturnPointer(ctx, 0);
}
@ -493,15 +456,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 8, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(8, ItNop, RAcquireMem)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (engine << 31) | cbDbOp) ||
!TryWriteUInt32(ctx, commandAddress + 8, noSize ? 0 : (uint)(sizeBytes >> 8)) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)(baseAddress >> 8)) ||
!TryWriteUInt32(ctx, commandAddress + 20, 0) ||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40) ||
!TryWriteUInt32(ctx, commandAddress + 28, gcrControl))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(8, ItNop, RAcquireMem), (engine << 31) | cbDbOp, noSize ? 0 : (uint)(sizeBytes >> 8), 0, (uint)(baseAddress >> 8), 0, pollCycles / 40, gcrControl))
{
return ReturnPointer(ctx, 0);
}
@ -631,16 +586,7 @@ public static partial class AgcExports
var control = 0x10u |
compareFunction |
(writeSpace == 1 ? 0x100u : 0u);
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 9, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(9, ItCondWrite, RZero)) ||
!TryWriteUInt32(ctx, commandAddress + 4, control) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)readAddress & ~0x3u) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(readAddress >> 32) & 0xFFFFu) ||
!TryWriteUInt32(ctx, commandAddress + 16, reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, mask) ||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)writeAddress & ~0x3u) ||
!TryWriteUInt32(ctx, commandAddress + 28, (uint)(writeAddress >> 32) & 0xFFFFu) ||
!TryWriteUInt32(ctx, commandAddress + 32, writeValue))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(9, ItCondWrite, RZero), control, (uint)readAddress & ~0x3u, (uint)(readAddress >> 32) & 0xFFFFu, reference, mask, (uint)writeAddress & ~0x3u, (uint)(writeAddress >> 32) & 0xFFFFu, writeValue))
{
return ReturnPointer(ctx, 0);
}
@ -752,9 +698,7 @@ public static partial class AgcExports
var address = ctx[CpuRegister.Rdx];
var reference = ctx[CpuRegister.Rcx];
if (commandBufferAddress == 0 || size > 1 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RZero)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItNop, RZero), 0))
{
return ReturnPointer(ctx, 0);
}
@ -790,11 +734,7 @@ public static partial class AgcExports
var baseIndex = (uint)ctx[CpuRegister.Rsi];
var address = ctx[CpuRegister.Rdx];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(4, ItSetBase, 0) | (baseIndex << 1)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 1) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)address & ~7u) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(address >> 32)))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(4, ItSetBase, 0) | (baseIndex << 1), 1, (uint)address & ~7u, (uint)(address >> 32)))
{
return ReturnPointer(ctx, 0);
}
@ -813,10 +753,7 @@ public static partial class AgcExports
var dataOffset = (uint)ctx[CpuRegister.Rsi];
var modifier = (uint)ctx[CpuRegister.Rdx];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 3, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(3, ItDispatchIndirect, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, dataOffset) ||
!TryWriteUInt32(ctx, commandAddress + 8, (modifier & 0xA038u) | 0x41u))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(3, ItDispatchIndirect, 0), dataOffset, (modifier & 0xA038u) | 0x41u))
{
return ReturnPointer(ctx, 0);
}
@ -884,9 +821,7 @@ public static partial class AgcExports
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RPopMarker)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItNop, RPopMarker), 0))
{
return ReturnPointer(ctx, 0);
}

View File

@ -23,6 +23,21 @@ public static partial class AgcExports
internal sealed record RetainedTargetlessDraw(RegisterBanks Banks, TargetlessDrawArguments Arguments);
// A draw or dispatch the translation deferred, with the banks it was issued under.
internal abstract record DeferredWork;
// Takes the deferred work of a command stream interpreted on its own thread.
internal interface IDeferredWorkSink
{
// True on the thread that interprets the stream; elsewhere the translation runs work itself.
bool IsProducerThread { get; }
// The non-draw items handed over so far; a draw's resolution waits until they ran.
long Gate { get; }
void EnqueueDeferred(DeferredWork work, bool isDraw);
}
// The snapshots the submit-time prepass captured for one submission.
private sealed record SubmittedGeometrySnapshots(
Dictionary<ulong, SubmittedIndexSnapshot>? IndexSnapshots,
@ -160,9 +175,18 @@ public static partial class AgcExports
{
_pipelines = new ShaderPipelineCache(_context, pipelineHost, GuestGpu.Current, CreateShaderHeaderRegistry(_context));
_executor = new RenderExecutor(renderHost, _pipelines);
if (host is IConcurrentResolveHost concurrentHost)
{
_parallel = new ParallelProgramPrefetch(_executor, renderHost, concurrentHost);
_executor.Prefetch = _parallel;
}
}
}
// Resolves the programs of deferred draws ahead of them, for a host that answers reads from
// another thread; draws are deferred whenever it exists.
private readonly ParallelProgramPrefetch? _parallel;
// The context bank of the queue whose slice runs now; the Metal host rebuilds its records from it.
internal ContextRegisters? CurrentContextRegisters => _current?.TypedRegisters?.Context;
@ -196,17 +220,194 @@ public static partial class AgcExports
}
}
private sealed record PendingDraw(
ulong SubmitId,
SubmittedDcbState State,
RegisterBanks Banks,
bool Indexed,
DrawIndexedArguments IndexedArguments,
DrawAutoArguments AutoArguments,
SubmittedVertexSnapshot? VertexSnapshot,
SubmittedIndexSnapshot? IndexSnapshot,
ParallelProgramPrefetch.Job? Job) : DeferredWork;
private sealed record PendingDispatch(
ulong SubmitId,
SubmittedDcbState State,
RegisterBanks Banks,
uint GroupsX,
uint GroupsY,
uint GroupsZ,
uint DispatchInitiator,
ulong IndirectArgumentsAddress) : DeferredWork;
// With the command stream on its own thread, its draws and dispatches reach the render
// thread through the sink, which runs them with RunDeferred in stream order.
internal IDeferredWorkSink? Sink { get; set; }
private bool Producing => Sink is { IsProducerThread: true };
// The queue state of the work the render thread runs now; _current belongs to the thread
// interpreting the stream.
private SubmittedDcbState? _executing;
public void RunDeferred(DeferredWork work)
{
switch (work)
{
case PendingDraw draw:
{
var executing = _executing;
_executing = draw.State;
RecordKnownColorTargets(draw.State, draw.Banks);
if (_parallel is not null)
{
_parallel.Current = draw.Job;
}
try
{
RunExecutorDraw(draw.SubmitId, draw.State, draw.Banks, draw.Indexed, draw.IndexedArguments, draw.AutoArguments,
clearSnapshots: false);
}
finally
{
_executing = executing;
if (_parallel is not null)
{
_parallel.Current = null;
}
}
break;
}
case PendingDispatch dispatch:
{
var executing = _executing;
_executing = dispatch.State;
try
{
ExecuteDispatch(_executor!, dispatch.SubmitId, dispatch.Banks, dispatch.GroupsX, dispatch.GroupsY, dispatch.GroupsZ,
dispatch.DispatchInitiator, dispatch.IndirectArgumentsAddress);
}
finally
{
_executing = executing;
}
break;
}
default:
throw _host.Fatal($"The deferred work is unknown: {work.GetType().Name}.");
}
}
// The resolution worker, so the render thread can order it with the stream's other work.
internal ParallelProgramPrefetch? Prefetch => _parallel;
private readonly Queue<PendingDraw> _pendingDraws = new();
public bool HasPendingDraws => _pendingDraws.Count != 0;
private bool _draining;
// Runs every deferred draw in issue order with the banks and snapshots it was issued with.
// A draw calls back into the host, whose entry points drain too: those calls run inside
// the draw and must not start the next one.
public void DrainPendingDraws()
{
if (_draining)
{
return;
}
_draining = true;
try
{
DrainPendingDrawsCore();
}
finally
{
_draining = false;
}
}
private void DrainPendingDrawsCore()
{
while (_pendingDraws.TryDequeue(out var pending))
{
var current = _current;
var state = pending.State;
_current = state;
state.CurrentVertexSnapshot = pending.VertexSnapshot;
state.CurrentIndexSnapshot = pending.IndexSnapshot;
if (_parallel is not null)
{
_parallel.Current = pending.Job;
}
try
{
RunExecutorDraw(pending.SubmitId, state, pending.Banks, pending.Indexed, pending.IndexedArguments, pending.AutoArguments);
}
finally
{
_current = current;
if (_parallel is not null)
{
_parallel.Current = null;
}
}
}
// What follows a drain may change guest memory and cache state the worker reads.
_parallel?.WaitIdle();
}
private bool TryRunExecutorDraw(ulong submitId, SubmittedDcbState state, bool indexed, in DrawIndexedArguments indexedArguments, in DrawAutoArguments autoArguments)
{
if (_executor is not { } executor)
if (_executor is null)
{
return false;
}
var banks = RequireTypedRegisters(state);
RecordKnownColorTargets(state, banks);
var producing = Producing;
if (!producing)
{
// A draw run by the render thread records its targets there.
RecordKnownColorTargets(state, banks);
}
state.FrameDrawCount++;
state.SawIndexedDraw |= indexed;
if (_parallel is not null || producing)
{
var snapshot = banks.Snapshot();
var pending = new PendingDraw(submitId, state, snapshot, indexed, indexedArguments, autoArguments,
state.CurrentVertexSnapshot, state.CurrentIndexSnapshot, _parallel?.Submit(snapshot, producing ? Sink!.Gate : 0));
if (producing)
{
Sink!.EnqueueDeferred(pending, isDraw: true);
}
else
{
_pendingDraws.Enqueue(pending);
}
state.CurrentIndexSnapshot = null;
state.CurrentVertexSnapshot = null;
return true;
}
RunExecutorDraw(submitId, state, banks, indexed, indexedArguments, autoArguments);
return true;
}
private void RunExecutorDraw(ulong submitId, SubmittedDcbState state, RegisterBanks banks, bool indexed, in DrawIndexedArguments indexedArguments, in DrawAutoArguments autoArguments,
bool clearSnapshots = true)
{
var executor = _executor!;
var drawStarted = DcbParseProfile.Begin();
try
{
@ -222,17 +423,19 @@ public static partial class AgcExports
finally
{
DcbParseProfile.RecordDraw(drawStarted);
state.CurrentIndexSnapshot = null;
state.CurrentVertexSnapshot = null;
// The snapshot fields belong to the interpreting thread when it is another one.
if (clearSnapshots)
{
state.CurrentIndexSnapshot = null;
state.CurrentVertexSnapshot = null;
}
}
return true;
}
// Keeps the banks the draw was issued under; the flip replays it into the display buffer.
internal void RetainTargetlessDraw(RegisterBanks banks, in TargetlessDrawArguments arguments)
{
var state = RequireCurrent();
var state = _executing ?? RequireCurrent();
if (RenderTrace.Enabled)
{
RenderTrace.Write($"TargetlessRetention submit={arguments.SubmitId} replaced={state.RetainedTargetlessDraw is not null} previousSubmit={state.RetainedTargetlessDraw?.Arguments.SubmitId} export=0x{banks.Shader.Vertex.ExportAddress:X16} pixel=0x{banks.Shader.Pixel.Address:X16}");
@ -363,22 +566,35 @@ public static partial class AgcExports
{
var banks = RequireTypedRegisters(state);
state.FrameDispatchCount++;
var executorStarted = DcbParseProfile.Begin();
try
if (Producing)
{
executor.Dispatch(submitId, banks, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress);
}
finally
{
DcbParseProfile.RecordDispatch(executorStarted);
var snapshot = banks.Snapshot();
Sink!.EnqueueDeferred(new PendingDispatch(submitId, state, snapshot, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress),
isDraw: false);
return;
}
ExecuteDispatch(executor, submitId, banks, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress);
return;
}
throw _host.Fatal("The command stream has no render executor.");
}
private static void ExecuteDispatch(RenderExecutor executor, ulong submitId, RegisterBanks banks, uint endX, uint endY, uint endZ,
uint dispatchInitiator, ulong indirectArgumentsAddress)
{
var executorStarted = DcbParseProfile.Begin();
try
{
executor.Dispatch(submitId, banks, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress);
}
finally
{
DcbParseProfile.RecordDispatch(executorStarted);
}
}
// The retained draw runs into the display buffer with the target words it was last bound with.
private void ReplayRetainedTargetlessDraw(RenderExecutor executor, SubmittedDcbState state, int handle, int displayBufferIndex)
{

View File

@ -249,11 +249,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(4, ItIndirectBuffer, RZero)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)(target & 0xFFFF_FFFFUL)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)((target >> 32) & 0xFFFFUL)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)targetDwords & 0xFFFFFu))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(4, ItIndirectBuffer, RZero), (uint)(target & 0xFFFF_FFFFUL), (uint)((target >> 32) & 0xFFFFUL), (uint)targetDwords & 0xFFFFFu))
{
return ReturnPointer(ctx, 0);
}

View File

@ -46,16 +46,7 @@ public static partial class AgcExports
uint perInstanceObjectIdSupport)
{
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 3, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(3, ItSetUconfigRegIndex, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0x2000_0000u | VgtIndexType) ||
!TryWriteUInt32(
ctx,
commandAddress + 8,
0x400u |
(indexSize & 0x3u) |
((cachePolicy & 0x3u) << 6) |
((perInstanceObjectIdSupport & 0x1u) << 14)))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(3, ItSetUconfigRegIndex, 0), 0x2000_0000u | VgtIndexType, 0x400u | (indexSize & 0x3u) | ((cachePolicy & 0x3u) << 6) | ((perInstanceObjectIdSupport & 0x1u) << 14)))
{
return ReturnPointer(ctx, 0);
}
@ -77,9 +68,7 @@ public static partial class AgcExports
var commandBufferAddress = ctx[CpuRegister.Rdi];
var indexCount = (uint)ctx[CpuRegister.Rsi];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RIndexCount)) ||
!TryWriteUInt32(ctx, commandAddress + 4, indexCount))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItNop, RIndexCount), indexCount))
{
return ReturnPointer(ctx, 0);
}
@ -112,9 +101,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNumInstances, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, instanceCount))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItNumInstances, 0), instanceCount))
{
return ReturnPointer(ctx, 0);
}
@ -140,12 +127,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var baseCommand) ||
!TryWriteUInt32(ctx, baseCommand, Pm4(3, ItIndexBase, 0)) ||
!TryWriteUInt32(ctx, baseCommand + 4, (uint)indexAddress) ||
!TryWriteUInt32(ctx, baseCommand + 8, (uint)(indexAddress >> 32)) ||
!TryWriteUInt32(ctx, baseCommand + 12, Pm4(2, ItIndexBufferSize, 0)) ||
!TryWriteUInt32(ctx, baseCommand + 16, indexCount))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var baseCommand, Pm4(3, ItIndexBase, 0), (uint)indexAddress, (uint)(indexAddress >> 32), Pm4(2, ItIndexBufferSize, 0), indexCount))
{
return ReturnPointer(ctx, 0);
}
@ -155,13 +137,7 @@ public static partial class AgcExports
// former five-dword packet omitted both the real base and the count
// field, so every call made by Unity looked like a zero-count draw to
// the submitted-command parser and the complete scene was discarded.
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 6, out var drawCommand) ||
!TryWriteUInt32(ctx, drawCommand, Pm4(6, ItDrawIndex2, 0)) ||
!TryWriteUInt32(ctx, drawCommand + 4, indexCount) ||
!TryWriteUInt32(ctx, drawCommand + 8, (uint)indexAddress) ||
!TryWriteUInt32(ctx, drawCommand + 12, (uint)(indexAddress >> 32)) ||
!TryWriteUInt32(ctx, drawCommand + 16, indexCount) ||
!TryWriteUInt32(ctx, drawCommand + 20, 0))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var drawCommand, Pm4(6, ItDrawIndex2, 0), indexCount, (uint)indexAddress, (uint)(indexAddress >> 32), indexCount, 0))
{
return ReturnPointer(ctx, 0);
}
@ -204,12 +180,7 @@ public static partial class AgcExports
? 2u
: ((modifierBits >> 3) & 0x20u) | 2u;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var drawCommand) ||
!TryWriteUInt32(ctx, drawCommand, Pm4(5, ItDrawIndirect, 0)) ||
!TryWriteUInt32(ctx, drawCommand + 4, dataOffset) ||
!TryWriteUInt32(ctx, drawCommand + 8, firstVertexRegister) ||
!TryWriteUInt32(ctx, drawCommand + 12, firstInstanceRegister) ||
!TryWriteUInt32(ctx, drawCommand + 16, drawInitiator))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var drawCommand, Pm4(5, ItDrawIndirect, 0), dataOffset, firstVertexRegister, firstInstanceRegister, drawInitiator))
{
var rejects = Interlocked.Increment(ref _indirectDrawEmitRejectCount);
if (rejects <= 8 || rejects % 250 == 0)
@ -244,14 +215,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 7, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(7, ItNop, RDrawIndexAuto)) ||
!TryWriteUInt32(ctx, commandAddress + 4, indexCount) ||
!TryWriteUInt32(ctx, commandAddress + 8, 0) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
!TryWriteUInt32(ctx, commandAddress + 16, 0) ||
!TryWriteUInt32(ctx, commandAddress + 20, 0) ||
!TryWriteUInt32(ctx, commandAddress + 24, 0))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(7, ItNop, RDrawIndexAuto), indexCount, 0, 0, 0, 0, 0))
{
return ReturnPointer(ctx, 0);
}
@ -390,12 +354,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(3, ItIndexBase, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)(indexBufferAddress & 0xFFFF_FFFFUL)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(indexBufferAddress >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 12, Pm4(2, ItIndexBufferSize, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 16, indexCount))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(3, ItIndexBase, 0), (uint)(indexBufferAddress & 0xFFFF_FFFFUL), (uint)(indexBufferAddress >> 32), Pm4(2, ItIndexBufferSize, 0), indexCount))
{
return ReturnPointer(ctx, 0);
}
@ -424,12 +383,7 @@ public static partial class AgcExports
var drawInitiator = (drawModifier & (1UL << 32)) != 0
? 0u
: ((uint)drawModifier >> 3) & 0x20u;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(5, ItDrawIndexOffset2, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, Math.Max(indexCount, 1u)) ||
!TryWriteUInt32(ctx, commandAddress + 8, indexOffset) ||
!TryWriteUInt32(ctx, commandAddress + 12, indexCount) ||
!TryWriteUInt32(ctx, commandAddress + 16, drawInitiator))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(5, ItDrawIndexOffset2, 0), Math.Max(indexCount, 1u), indexOffset, indexCount, drawInitiator))
{
return ReturnPointer(ctx, 0);
}

View File

@ -69,9 +69,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItEventWrite, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, eventType))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(2, ItEventWrite, 0), eventType))
{
return ReturnPointer(ctx, 0);
}
@ -95,14 +93,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 7, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(7, ItNop, RWaitFlipDone)) ||
!TryWriteUInt32(ctx, commandAddress + 4, videoOutHandle) ||
!TryWriteUInt32(ctx, commandAddress + 8, displayBufferIndex) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
!TryWriteUInt32(ctx, commandAddress + 16, 0) ||
!TryWriteUInt32(ctx, commandAddress + 20, 0) ||
!TryWriteUInt32(ctx, commandAddress + 24, 0))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(7, ItNop, RWaitFlipDone), videoOutHandle, displayBufferIndex, 0, 0, 0, 0))
{
return ReturnPointer(ctx, 0);
}
@ -128,13 +119,7 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 6, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(6, ItNop, RFlip)) ||
!TryWriteUInt32(ctx, commandAddress + 4, videoOutHandle) ||
!TryWriteUInt32(ctx, commandAddress + 8, unchecked((uint)displayBufferIndex)) ||
!TryWriteUInt32(ctx, commandAddress + 12, flipMode) ||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)(flipArg & 0xFFFF_FFFFUL)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)(flipArg >> 32)))
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(6, ItNop, RFlip), videoOutHandle, unchecked((uint)displayBufferIndex), flipMode, (uint)(flipArg & 0xFFFF_FFFFUL), (uint)(flipArg >> 32)))
{
return ReturnPointer(ctx, 0);
}

View File

@ -0,0 +1,140 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
namespace SharpEmu.Libs.Agc;
public static partial class AgcExports
{
[SysAbiExport(
Nid = "NKIzURsgV7I",
ExportName = "sceAgcGetGsOversubscription",
Target = Generation.Gen5,
LibraryName = "libSceAgcDriver")]
public static int GetGsOversubscription(CpuContext ctx)
{
var output = ctx[CpuRegister.Rdi];
var shader = ctx[CpuRegister.Rsi];
var budget = (uint)ctx[CpuRegister.Rdx];
if (output == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var pcAllocation = 0u;
var lateAllocation = 0u;
// These endpoints initialize both descriptors without dereferencing the shader.
if (budget == uint.MaxValue)
{
pcAllocation = 0x7FF;
lateAllocation = 0x7F_0000;
}
else if (budget != 0)
{
ctx.GetXmmRegister(0, out var factorBits, out _);
var factor = BitConverter.UInt32BitsToSingle((uint)factorBits);
if (!float.IsFinite(factor) ||
!TryGetGsOccupancy(ctx, shader, out var stages, out var baseline, out var expanded))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var minimum = Math.Min(baseline.Vertex, baseline.Export);
var waveShift = (stages & 0x0040_0000) != 0 ? 5 : 6;
var maximum = Math.Min(Math.Min(expanded.Vertex, expanded.Export), Math.Min(budget >> waveShift, 1024));
var headroom = maximum > minimum ? maximum - minimum : 0;
var scaled = MathF.FusedMultiplyAdd(factor, headroom, minimum);
if (!(scaled >= 0 && scaled < 4294967296f))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var target = (uint)scaled;
if (target > minimum && target < baseline.Export)
{
var range = Math.Max(expanded.Vertex - baseline.Vertex, 1);
var lines = Math.Max(Math.Min(((ulong)(target - baseline.Vertex) << 10) / range, 1024), 1);
pcAllocation = ((uint)lines * 2 - 1) & 0x7FF;
lateAllocation = 0x7F_0000;
}
else if (target > minimum)
{
var range = Math.Max(expanded.Export - baseline.Export, 1);
var waves = Math.Min((ulong)(target - baseline.Export) * 127 / range, 127);
pcAllocation = 0x7FF;
lateAllocation = (uint)waves << 16;
}
}
// The result is two bank-specific register descriptors, not two scalar values.
Span<byte> registers = stackalloc byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(registers, UserConfigRegisterOffset.ParameterOversubscription);
BinaryPrimitives.WriteUInt32LittleEndian(registers[4..], pcAllocation);
BinaryPrimitives.WriteUInt32LittleEndian(registers[8..], ShaderRegisterOffset.SpiShaderPgmRsrc4Gs);
BinaryPrimitives.WriteUInt32LittleEndian(registers[12..], lateAllocation);
if (!ctx.Memory.TryWrite(output, registers))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAgc($"agc.gs_oversubscription output=0x{output:X16} shader=0x{shader:X16} budget={budget} pc_alloc=0x{pcAllocation:X8} late_alloc=0x{lateAllocation:X8}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
private readonly record struct GsOccupancy(uint Vertex, uint Export);
private static bool TryGetGsOccupancy(CpuContext ctx, ulong shader, out uint stages, out GsOccupancy baseline, out GsOccupancy expanded)
{
stages = 0;
baseline = expanded = default;
if (shader == 0 ||
!TryReadUInt64(ctx, shader + ShaderCxRegistersOffset, out var table) || table == 0 ||
!TryReadByte(ctx, shader + 0x5B, out var count) ||
!TryReadUInt64(ctx, shader + ShaderSpecialsOffset, out var specials) || specials == 0 ||
!TryReadUInt32(ctx, specials + 12, out stages))
{
return false;
}
uint onchip = 0, subgroup = 0, vertexOutput = 0, clipOutput = 0, maxOutput = 0;
var found = 0;
for (var index = 0; index < count; index++)
{
if (!TryReadUInt32(ctx, table + (ulong)index * 8, out var offset) ||
!TryReadUInt32(ctx, table + (ulong)index * 8 + 4, out var value))
{
return false;
}
switch (offset)
{
case ContextRegisterOffset.VgtGsOnchipCntl: onchip = value; found |= 1; break;
case ContextRegisterOffset.GeNggSubgroupCntl: subgroup = value; found |= 2; break;
case ContextRegisterOffset.SpiVsOutConfig: vertexOutput = value; found |= 4; break;
case ContextRegisterOffset.PaClVsOutCntl: clipOutput = value; found |= 8; break;
case ContextRegisterOffset.GeMaxOutputPerSubgroup: maxOutput = value; found |= 16; break;
}
}
var outputWords = ((maxOutput & 0x3FF) + 31) >> 5;
if (found != 31 || outputWords == 0)
{
return false;
}
var work = ((((onchip >> 11) & 0x7FF) * (subgroup & 0x1FF)) + 31) >> 5;
var waves = Math.Max(work, outputWords);
if ((stages & 0x0040_0000) == 0) waves >>= 1;
waves = Math.Max(waves, 1);
var exports = 1 + ((clipOutput >> 21) & 1) + ((clipOutput >> 22) & 1) + ((clipOutput >> 23) & 1);
var parameters = ((vertexOutput >> 2) & 0xF) + 1;
var vertexCapacity = (vertexOutput & 0x80) != 0 ? 2048 : 1024 / parameters;
var expandedVertexCapacity = (vertexOutput & 0x80) != 0 ? 2048 : 2048 / parameters;
baseline = new GsOccupancy(waves * (vertexCapacity / outputWords), waves * ((128 / exports * 4) / outputWords));
expanded = new GsOccupancy(waves * (expandedVertexCapacity / outputWords), waves * ((382 / exports * 4) / outputWords));
return true;
}
}

View File

@ -109,12 +109,9 @@ public static partial class AgcExports
ulong commandAddress;
using (AgcRegisterPacketProfile.Measure(AgcRegisterPacketProfile.Phase.PacketSetup))
{
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var markerAddress) ||
!TryWriteUInt32(ctx, markerAddress, Pm4(2, ItNop, RZero)) ||
!TryWriteUInt32(ctx, markerAddress + 4, CbSetShRegisterRangeMarker) ||
if (!TryEmitCommandDwords(ctx, commandBufferAddress, out _, Pm4(2, ItNop, RZero), CbSetShRegisterRangeMarker) ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItSetShReg, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, offset))
!TryWriteCommandDwords(ctx, commandAddress, Pm4(packetDwords, ItSetShReg, 0), offset))
{
AgcRegisterPacketProfile.RecordResult(false);
return ReturnPointer(ctx, 0);
@ -288,12 +285,7 @@ public static partial class AgcExports
_ => 0u,
};
if (op == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(5, op, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)(registersAddress & 0xFFFF_FFFCUL)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(registersAddress >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0x8000_0000u) ||
!TryWriteUInt32(ctx, commandAddress + 16, registerCount & 0x3FFFu))
!TryEmitCommandDwords(ctx, commandBufferAddress, out var commandAddress, Pm4(5, op, 0), (uint)(registersAddress & 0xFFFF_FFFCUL), (uint)(registersAddress >> 32), 0x8000_0000u, registerCount & 0x3FFFu))
{
return ReturnPointer(ctx, 0);
}

View File

@ -491,15 +491,40 @@ public static partial class AgcExports
private static bool TryAllocateCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, out ulong commandAddress)
=> TryPrepareCommandDwords(ctx, commandBufferAddress, sizeDwords, true, out commandAddress);
private static bool TryEmitCommandDwords(CpuContext ctx, ulong commandBufferAddress, out ulong commandAddress, params ReadOnlySpan<uint> dwords)
{
return TryAllocateCommandDwords(ctx, commandBufferAddress, (uint)dwords.Length, out commandAddress) &&
TryWriteCommandDwords(ctx, commandAddress, dwords);
}
private static bool TryWriteCommandDwords(CpuContext ctx, ulong address, params ReadOnlySpan<uint> dwords) =>
ctx.Memory.TryWrite(address, System.Runtime.InteropServices.MemoryMarshal.AsBytes(dwords));
private const int CommandBufferStateBytes = (int)(CommandBufferReservedDwOffset + sizeof(uint) - CommandBufferCursorUpOffset);
private static bool TryReadCommandBufferState(CpuContext ctx, ulong commandBufferAddress, out ulong cursorUp, out ulong cursorDown, out ulong callback, out ulong userData, out uint reservedDwords)
{
Span<byte> state = stackalloc byte[CommandBufferStateBytes];
if (!ctx.Memory.TryRead(commandBufferAddress + CommandBufferCursorUpOffset, state))
{
cursorUp = cursorDown = callback = userData = 0;
reservedDwords = 0;
return false;
}
cursorUp = BinaryPrimitives.ReadUInt64LittleEndian(state);
cursorDown = BinaryPrimitives.ReadUInt64LittleEndian(state[(int)(CommandBufferCursorDownOffset - CommandBufferCursorUpOffset)..]);
callback = BinaryPrimitives.ReadUInt64LittleEndian(state[(int)(CommandBufferCallbackOffset - CommandBufferCursorUpOffset)..]);
userData = BinaryPrimitives.ReadUInt64LittleEndian(state[(int)(CommandBufferUserDataOffset - CommandBufferCursorUpOffset)..]);
reservedDwords = BinaryPrimitives.ReadUInt32LittleEndian(state[(int)(CommandBufferReservedDwOffset - CommandBufferCursorUpOffset)..]);
return true;
}
private static bool TryPrepareCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor, out ulong commandAddress)
{
commandAddress = 0;
if (sizeDwords == 0 ||
!TryReadUInt64(ctx, commandBufferAddress + CommandBufferCursorUpOffset, out var cursorUp) ||
!TryReadUInt64(ctx, commandBufferAddress + CommandBufferCursorDownOffset, out var cursorDown) ||
!TryReadUInt64(ctx, commandBufferAddress + CommandBufferCallbackOffset, out var callback) ||
!TryReadUInt64(ctx, commandBufferAddress + CommandBufferUserDataOffset, out var userData) ||
!TryReadUInt32(ctx, commandBufferAddress + CommandBufferReservedDwOffset, out var reservedDwords))
!TryReadCommandBufferState(ctx, commandBufferAddress, out var cursorUp, out var cursorDown, out var callback, out var userData, out var reservedDwords))
{
return false;
}

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Kernel;
using System.Buffers;
@ -100,23 +101,40 @@ public static class AmprExports
public uint Flush;
}
// A host file, or a file of an archive-mounted app0 that is not on disk.
private sealed class CachedHostFile : IDisposable
{
private readonly SafeFileHandle? _handle;
private readonly ZArchiveFileStream? _archived;
public CachedHostFile(string path)
{
Handle = File.OpenHandle(
if (!File.Exists(path) && App0Archive.OpenRead(path) is { } archived)
{
_archived = archived;
Length = archived.Length;
return;
}
_handle = File.OpenHandle(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
FileOptions.RandomAccess);
Length = RandomAccess.GetLength(Handle);
Length = RandomAccess.GetLength(_handle);
}
public SafeFileHandle Handle { get; }
public long Length { get; }
public void Dispose() => Handle.Dispose();
public int Read(Span<byte> buffer, long offset) =>
_archived?.ReadAt(buffer, offset) ?? RandomAccess.Read(_handle!, buffer, offset);
public void Dispose()
{
_handle?.Dispose();
_archived?.Dispose();
}
}
private sealed class CachedHostFileEntry
@ -1252,8 +1270,7 @@ public static class AmprExports
}
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
var read = RandomAccess.Read(
cachedFile.Handle,
var read = cachedFile.Read(
buffer.AsSpan(0, request),
unchecked((long)absoluteOffset));

View File

@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.FileSystem;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
@ -269,20 +270,29 @@ internal static class AmprFileRegistry
var relatives = new List<string>(256 * 1024);
try
{
foreach (var hostPath in Directory.EnumerateFiles(
normalizedRoot,
"*",
SearchOption.AllDirectories))
// An app0 mounted from an archive holds only a few files on disk; the archive lists them all.
if (App0Archive.Root is { } archiveRoot &&
string.Equals(archiveRoot, Path.TrimEndingDirectorySeparator(normalizedRoot), HostFsPath.Comparison))
{
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
.Replace('\\', '/');
if (string.IsNullOrEmpty(relative) ||
relative.StartsWith("..", StringComparison.Ordinal))
relatives.AddRange(App0Archive.ListAllFiles());
}
else
{
foreach (var hostPath in Directory.EnumerateFiles(
normalizedRoot,
"*",
SearchOption.AllDirectories))
{
continue;
}
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
.Replace('\\', '/');
if (string.IsNullOrEmpty(relative) ||
relative.StartsWith("..", StringComparison.Ordinal))
{
continue;
}
relatives.Add(relative);
relatives.Add(relative);
}
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.VideoOut;
@ -2592,6 +2593,8 @@ public static class AvPlayerExports
out string resolved)
{
resolved = string.Empty;
// A media file only in an archive-mounted app0 is extracted so the host decoder can open it.
App0Archive.Extract(Path.Combine(root, relativePath));
var current = root;
var segments = relativePath.Split(
Path.DirectorySeparatorChar,

View File

@ -28,6 +28,42 @@ internal static class DccWriterTrace
private static readonly HashSet<(ulong Address, ulong Size, ulong Program)> _reported = new();
private static readonly HashSet<(ulong Hash, string Reason)> _refusals = new();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (ulong Size, ulong Program, string Kind)> _writes = new();
private static readonly HashSet<ulong> _syncReported = new();
[ThreadStatic]
public static string? CurrentKind;
public static void RecordWrite(ulong address, ulong size)
{
if (DccEnabled)
{
_writes[address] = (size, CurrentProgram, CurrentKind ?? "?");
}
}
public static void ReportSync(ulong metadataAddress, ulong size)
{
if (!DccEnabled)
{
return;
}
lock (_syncReported)
{
if (_syncReported.Count >= 64 || !_syncReported.Add(metadataAddress))
{
return;
}
}
var writers = _writes
.Where(pair => pair.Key < metadataAddress + size && metadataAddress < pair.Key + pair.Value.Size)
.Take(8)
.Select(pair => $"0x{pair.Key:X}+0x{pair.Value.Size:X}:{pair.Value.Kind}:0x{pair.Value.Program:X16}");
Console.Error.WriteLine(
$"[GPU][DCC_SYNC_WRITER] metadata=0x{metadataAddress:X16} size=0x{size:X} writers={string.Join(" ", writers)}");
}
private static ulong ParseAddress(string? value)
{

View File

@ -216,6 +216,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
vk.CmdBindPipeline(command, PipelineBindPoint.Compute, _pipeline);
var set = _sets[_currentArea];
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &set, 0, null);
NativeBindingEpoch.Advance();
var threads = _pageCount / 32;
vk.CmdDispatch(command, (uint)((threads + 63) / 64), 1, 1);
VulkanSynchronization.PipelineBarrier(vk,

View File

@ -172,16 +172,39 @@ public unsafe class GpuBuffer : IDisposable
// A readback of it only has to wait for that tick, not for all queued work.
public ulong LastGpuWriteTick { get; private set; }
private readonly WriteTickMap _writeTicks = new();
private int _writeTickPruneCount = 64;
// Called by every path that records a GPU write into this buffer.
public void NoteGpuWrite()
public void NoteGpuWrite() => NoteGpuWrite(0, Size);
public void NoteGpuWrite(ulong offset, ulong size)
{
var tick = _scheduler.CurrentTick;
if (tick > LastGpuWriteTick)
{
LastGpuWriteTick = tick;
}
if (size == 0 || offset >= Size)
{
return;
}
_writeTicks.Assign(offset, Math.Min(size, Size - offset), tick);
if (_writeTicks.Count >= _writeTickPruneCount)
{
if (Volatile.Read(ref _foreignReads) == 0)
{
_writeTicks.Prune(_scheduler.Timeline.CompletedTick + 1);
}
_writeTickPruneCount = Math.Max(64, _writeTicks.Count * 2);
}
}
public ulong LastGpuWriteTickIn(ulong offset, ulong size) => _writeTicks.MaxTick(offset, size);
public void Write(ulong offset, ReadOnlySpan<byte> source)
{
if (_mapped == null || offset > Size || (ulong)source.Length > Size - offset)
@ -243,7 +266,7 @@ public unsafe class GpuBuffer : IDisposable
}
command.EndRendering();
NoteGpuWrite();
NoteGpuWrite(destinationOffset, size);
var vk = _device.Vk;
var native = new CommandBuffer(command.Handle);
var before = stackalloc BufferMemoryBarrier2[2];
@ -271,7 +294,7 @@ public unsafe class GpuBuffer : IDisposable
var command = _scheduler.Current;
command.EndRendering();
NoteGpuWrite();
NoteGpuWrite(offset, size);
var vk = _device.Vk;
var native = new CommandBuffer(command.Handle);
var before = CreateBarrier(offset, size, MemoryAccess, AccessFlags.TransferWriteBit);

View File

@ -240,10 +240,11 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
_ = SynchronizeBuffer(buffer, guestAddress, size, isWritten, isTexelBuffer, preserveCpuWriteHotPages: false);
if (isWritten)
{
buffer.NoteGpuWrite();
buffer.NoteGpuWrite(buffer.Offset(guestAddress), size);
if (Diagnostics.DccWriterTrace.Enabled)
{
Diagnostics.DccWriterTrace.Record(RequireImageCache(), guestAddress, size);
Diagnostics.DccWriterTrace.RecordWrite(guestAddress, size);
}
if (!_gpuModifiedRanges.Contains(guestAddress, size))
@ -333,6 +334,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
throw SubmissionScheduler.Fatal("The host DMA write range is invalid.");
}
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(guestAddress, data))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{guestAddress:X16} size=0x{data.Length:X16}");
@ -359,7 +361,10 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
}
public void FillBuffer(ulong guestAddress, ulong size, uint value, bool isGds)
public void FillBuffer(ulong guestAddress, ulong size, uint value, bool isGds) =>
FillBuffer(guestAddress, size, value, isGds, publishFill: false);
private void FillBuffer(ulong guestAddress, ulong size, uint value, bool isGds, bool publishFill)
{
if ((guestAddress & 3) != 0 || size == 0 || (size & 3) != 0 || size > ulong.MaxValue - guestAddress)
{
@ -408,56 +413,116 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
images.InvalidateMemoryFromGpu(guestAddress, size);
var bufferIdentifier = FindBuffer(guestAddress, size);
if (Diagnostics.DccWriterTrace.Enabled)
{
Diagnostics.DccWriterTrace.CurrentKind = "fill";
}
var (destination, destinationOffset) = ObtainBuffer(guestAddress, size, true, true, bufferIdentifier);
destination.Fill(destinationOffset, size, value);
if (images.OverlapsDccMetadata(guestAddress, size) && TryWriteFillToBacking(guestAddress, size, value))
if ((publishFill || images.OverlapsDccMetadata(guestAddress, size)) && TryWriteFillToBacking(guestAddress, size, value))
{
_gpuModifiedRanges.Remove(guestAddress, size);
var firstPage = guestAddress & ~(TrackerLayout.PageBytes - 1);
for (var page = firstPage; page < guestAddress + size; page += TrackerLayout.PageBytes)
{
if (!_gpuModifiedRanges.Overlaps(page, TrackerLayout.PageBytes))
{
_tracker.ClearGpuDirtyPages(page, TrackerLayout.PageBytes);
}
}
Interlocked.Increment(ref _gpuModifiedVersion);
ClearGpuDirtyPagesOfRemovedRange(guestAddress, size);
}
}
// After a range left the GPU-modified set: the pages inside it are clean in one tracker walk,
// and only a page the range shares at either end can still hold another GPU-modified range.
private void ClearGpuDirtyPagesOfRemovedRange(ulong guestAddress, ulong size)
{
const ulong pageBytes = TrackerLayout.PageBytes;
var end = guestAddress + size;
var firstPage = guestAddress & ~(pageBytes - 1);
var lastPage = (end - 1) & ~(pageBytes - 1);
var startPartial = guestAddress != firstPage;
var endPartial = end != lastPage + pageBytes;
var innerBegin = startPartial ? firstPage + pageBytes : firstPage;
var innerEnd = endPartial ? lastPage : end;
if (innerBegin < innerEnd)
{
_tracker.ClearGpuDirtyPages(innerBegin, innerEnd - innerBegin);
}
if (startPartial)
{
ClearGpuDirtyPageIfUnmodified(firstPage);
}
if (endPartial && (lastPage != firstPage || !startPartial))
{
ClearGpuDirtyPageIfUnmodified(lastPage);
}
}
private void ClearGpuDirtyPageIfUnmodified(ulong page)
{
if (!_gpuModifiedRanges.Overlaps(page, TrackerLayout.PageBytes))
{
_tracker.ClearGpuDirtyPages(page, TrackerLayout.PageBytes);
}
}
public void FillDccMetadata(ulong guestAddress, ulong size, uint value)
{
if (guestAddress == 0 || (guestAddress & 3) != 0 || size == 0 || (size & 3) != 0 || size > ulong.MaxValue - guestAddress ||
HasGpuDirtyBytes(guestAddress, size) || RequireImageCache().QueryRegion(guestAddress, size).ImageBytes)
HasGpuDirtyBytes(guestAddress, size))
{
FillBuffer(guestAddress, size, value, false);
FillBuffer(guestAddress, size, value, false, publishFill: true);
return;
}
// Images over the range read it again; only GPU-owned image bytes need the general fill.
// The buffer copy is filled on the GPU rather than uploaded from the written bytes.
var images = RequireImageCache();
var region = images.QueryRegion(guestAddress, size);
if (region.GpuImageBytes)
{
FillBuffer(guestAddress, size, value, false, publishFill: true);
return;
}
if (region.ImageBytes)
{
images.InvalidateMemory(guestAddress, size);
}
var (destination, destinationOffset) = ObtainBuffer(guestAddress, size, false, false, FindBuffer(guestAddress, size));
destination.Fill(destinationOffset, size, value);
if (!TryWriteFillToBacking(guestAddress, size, value))
{
FillBuffer(guestAddress, size, value, false);
FillBuffer(guestAddress, size, value, false, publishFill: true);
}
}
private bool TryWriteFillToBacking(ulong guestAddress, ulong size, uint value)
{
var values = new uint[(int)Math.Min(size / sizeof(uint), 4096)];
Array.Fill(values, value);
var bytes = MemoryMarshal.AsBytes<uint>(values);
for (ulong offset = 0; offset < size;)
// Metadata fills repeat every frame: the pattern buffer is pooled.
var count = (int)Math.Min(size / sizeof(uint), 4096);
var values = System.Buffers.ArrayPool<uint>.Shared.Rent(count);
try
{
var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length);
if (!_backing.TryWriteBacking(guestAddress + offset, bytes[..chunk]))
values.AsSpan(0, count).Fill(value);
var bytes = MemoryMarshal.AsBytes(values.AsSpan(0, count));
for (ulong offset = 0; offset < size;)
{
return false;
var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length);
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(guestAddress + offset, bytes[..chunk]))
{
return false;
}
offset += (ulong)chunk;
}
offset += (ulong)chunk;
return true;
}
finally
{
System.Buffers.ArrayPool<uint>.Shared.Return(values);
}
return true;
}
public const ulong MaxHostCopyWords = 64 * 1024;
@ -583,6 +648,11 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var srcId = srcMemory ? FindBuffer(srcVaddr, size) : default;
var dstId = dstMemory ? FindBuffer(dstVaddr, size) : default;
var (src, srcOffset) = srcMemory ? ObtainBuffer(srcVaddr, size, false, true, srcId) : (_gds, srcVaddr);
if (Diagnostics.DccWriterTrace.Enabled)
{
Diagnostics.DccWriterTrace.CurrentKind = $"copy_from_0x{srcVaddr:X}";
}
var (dst, dstOffset) = dstMemory ? ObtainBuffer(dstVaddr, size, true, true, dstId) : (_gds, dstVaddr);
if (ReferenceEquals(src, dst) && srcOffset < dstOffset + size && dstOffset < srcOffset + size)
{
@ -898,22 +968,34 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
if (!_relay.IsGpuQueueThread && AsyncReadback is { } readback && GuestReadsAwaitOffQueue)
{
PendingDownload? pending = null;
if (_relay.TryRunOnGpuQueue(() => pending = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending: true)))
for (var attempt = 0; ; attempt++)
{
var allowPending = attempt < MaxAsyncReadbackAttempts;
var markHot = attempt == 0 && !isWrite;
PendingDownload? pending = null;
if (!_relay.TryRunOnGpuQueue(() =>
pending = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending, markHot)))
{
return AwaitShutdown();
}
if (pending is null)
{
return true;
}
readback.Wait(pending.Ticket);
if (_relay.TryRunOnGpuQueue(() => CompleteReadMemoryOnGpu(pending, retrySynchronously: true)))
var complete = false;
if (!_relay.TryRunOnGpuQueue(() => complete = CompleteReadMemoryOnGpu(pending, consumed: true)))
{
return AwaitShutdown();
}
if (complete)
{
return true;
}
}
return AwaitShutdown();
}
var onQueue = _relay.IsGpuQueueThread;
@ -934,6 +1016,9 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private const ulong ReadbackWindowBytes = 512 * 1024;
private const long HotWindowLifetime = 512;
private const int MaxEagerReadbacks = 4;
private const int MaxAsyncReadbackAttempts = 4;
private const int EagerStrikeLimit = 3;
private const long EagerMuteBatches = 4096;
private readonly Dictionary<ulong, HotWindow> _hotWindows = new();
private readonly HashSet<ulong> _eagerCandidates = new();
private readonly List<PendingDownload> _eagerDownloads = new();
@ -941,8 +1026,9 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private static long _reportedEagerStarted;
private static long _reportedEagerUsed;
private static long _reportedEagerStale;
private static long _reportedEagerMuted;
private readonly record struct HotWindow(ulong Address, ulong Size, long LastHit);
private readonly record struct HotWindow(ulong Address, ulong Size, long LastHit, int Strikes = 0, long MutedUntil = 0);
internal long EagerReadbacksStarted { get; private set; }
@ -950,7 +1036,44 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private void NoteHotReadback(ulong guestAddress, ulong size)
{
_hotWindows[guestAddress & ~(ReadbackWindowBytes - 1)] = new HotWindow(guestAddress, size, _batchSerial);
var key = guestAddress & ~(ReadbackWindowBytes - 1);
_hotWindows[key] = _hotWindows.TryGetValue(key, out var hot)
? hot with { Address = guestAddress, Size = size, LastHit = _batchSerial }
: new HotWindow(guestAddress, size, _batchSerial);
}
internal long EagerReadbacksWasted { get; private set; }
private void NoteEagerOutcome(ulong key, bool wasted)
{
if (wasted)
{
EagerReadbacksWasted++;
}
if (!_hotWindows.TryGetValue(key, out var hot))
{
return;
}
if (!wasted)
{
if (hot.Strikes != 0)
{
_hotWindows[key] = hot with { Strikes = 0 };
}
return;
}
var strikes = hot.Strikes + 1;
_hotWindows[key] = strikes >= EagerStrikeLimit
? hot with { Strikes = 0, MutedUntil = _batchSerial + EagerMuteBatches }
: hot with { Strikes = strikes };
if (strikes >= EagerStrikeLimit)
{
Interlocked.Increment(ref _reportedEagerMuted);
}
}
private void NoteHotWindowWrite(ulong guestAddress, ulong size)
@ -963,10 +1086,11 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var last = (guestAddress + size - 1) & ~(ReadbackWindowBytes - 1);
for (var key = guestAddress & ~(ReadbackWindowBytes - 1); key <= last; key += ReadbackWindowBytes)
{
if (_hotWindows.TryGetValue(key, out var hot) && _batchSerial - hot.LastHit <= HotWindowLifetime)
if (_hotWindows.TryGetValue(key, out var hot) && _batchSerial - hot.LastHit <= HotWindowLifetime &&
_batchSerial >= hot.MutedUntil)
{
_eagerCandidates.Add(key);
HotWritePending = true;
HotWritePending |= hot.Strikes == 0;
}
}
}
@ -988,16 +1112,14 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
if (readback.IsComplete(pending.Ticket))
{
_eagerDownloads.RemoveAt(index);
if (!CompleteReadMemoryOnGpu(pending, retrySynchronously: false))
{
Interlocked.Increment(ref _reportedEagerStale);
}
_ = CompleteReadMemoryOnGpu(pending, consumed: false);
}
}
if ((_batchSerial & 1023) == 0)
{
foreach (var key in _hotWindows.Where(pair => _batchSerial - pair.Value.LastHit > HotWindowLifetime).Select(pair => pair.Key).ToList())
foreach (var key in _hotWindows.Where(pair => _batchSerial - pair.Value.LastHit > HotWindowLifetime && _batchSerial >= pair.Value.MutedUntil)
.Select(pair => pair.Key).ToList())
{
_hotWindows.Remove(key);
}
@ -1008,6 +1130,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return;
}
List<ulong>? settled = null;
foreach (var key in _eagerCandidates)
{
if (_eagerDownloads.Count >= MaxEagerReadbacks)
@ -1015,26 +1138,52 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
break;
}
if (_hotWindows.TryGetValue(key, out var hot))
if (!_hotWindows.TryGetValue(key, out var hot) || _batchSerial - hot.LastHit > HotWindowLifetime ||
_batchSerial < hot.MutedUntil || StartEagerReadback(hot) != EagerStart.Deferred)
{
StartEagerReadback(hot);
(settled ??= []).Add(key);
}
}
_eagerCandidates.Clear();
}
private void StartEagerReadback(HotWindow hot)
{
if (FindOwner(hot.Address, hot.Size) is not { } buffer)
if (settled is null)
{
return;
}
var copies = CollectReadbackWindow(buffer, hot.Address, hot.Size, out var windowBegin, out var windowEnd);
if (copies.Count == 0 || !TryBeginDownloadAsync(copies, out var ticket, out var writeTicks))
foreach (var key in settled)
{
return;
_eagerCandidates.Remove(key);
}
}
private enum EagerStart
{
Started,
Deferred,
Nothing,
}
private EagerStart StartEagerReadback(HotWindow hot)
{
if (FindOwner(hot.Address, hot.Size) is not { } buffer)
{
return EagerStart.Nothing;
}
if (OverlapsEagerReadback(hot.Address, hot.Size))
{
return EagerStart.Deferred;
}
var copies = CollectReadbackWindow(buffer, hot.Address, hot.Size, out var windowBegin, out var windowEnd);
if (copies.Count == 0)
{
return EagerStart.Nothing;
}
if (!TryBeginDownloadAsync(copies, out var ticket, out var writeTicks))
{
return EagerStart.Deferred;
}
foreach (var copy in copies)
@ -1044,6 +1193,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
_eagerDownloads.Add(new PendingDownload
{
HotKey = hot.Address & ~(ReadbackWindowBytes - 1),
Ticket = ticket,
Copies = copies,
WriteTicks = writeTicks,
@ -1052,11 +1202,26 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
GuestAddress = hot.Address,
Size = hot.Size,
IsWrite = false,
Eager = true,
Source = GuestMemoryProfile.ReadbackSource.ShaderResourceRead,
Started = System.Diagnostics.Stopwatch.GetTimestamp(),
});
EagerReadbacksStarted++;
Interlocked.Increment(ref _reportedEagerStarted);
return EagerStart.Started;
}
private bool OverlapsEagerReadback(ulong guestAddress, ulong size)
{
foreach (var pending in _eagerDownloads)
{
if (guestAddress < pending.WindowEnd && pending.WindowBegin < guestAddress + size)
{
return true;
}
}
return false;
}
private PendingDownload? TakeEagerReadback(ulong guestAddress, ulong size)
@ -1068,6 +1233,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
guestAddress - pending.WindowBegin <= pending.WindowEnd - pending.WindowBegin - size)
{
_eagerDownloads.RemoveAt(index);
NoteHotReadback(guestAddress, size);
return pending;
}
}
@ -1083,15 +1249,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
readback.Wait(pending.Ticket);
if (!CompleteReadMemoryOnGpu(pending, retrySynchronously: false))
{
Interlocked.Increment(ref _reportedEagerStale);
return false;
}
EagerReadbacksUsed++;
Interlocked.Increment(ref _reportedEagerUsed);
return !HasGpuDirtyBytes(guestAddress, size);
return CompleteReadMemoryOnGpu(pending with { GuestAddress = guestAddress, Size = size }, consumed: true);
}
private void AbandonEagerReadbacks()
@ -1131,9 +1289,6 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
}
private void ReadMemoryOnGpu(ulong guestAddress, ulong size, bool isWrite, GuestMemoryProfile.ReadbackSource source) =>
_ = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending: false);
internal long PendingReadbacksApplied { get; private set; }
internal long PendingReadbacksRetried { get; private set; }
@ -1141,8 +1296,13 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private static long _reportedApplied;
private static long _reportedRetried;
private static long _reportedStalePieces;
private static long _reportedPublishedPieces;
internal long StalePiecesKept { get; private set; }
public static string TakeAsyncReadbackReport() => FormattableString.Invariant(
$"[PERF][ASYNC_READBACK] applied={Interlocked.Exchange(ref _reportedApplied, 0)} retried={Interlocked.Exchange(ref _reportedRetried, 0)} eager_started={Interlocked.Exchange(ref _reportedEagerStarted, 0)} eager_used={Interlocked.Exchange(ref _reportedEagerUsed, 0)} eager_stale={Interlocked.Exchange(ref _reportedEagerStale, 0)}");
$"[PERF][ASYNC_READBACK] applied={Interlocked.Exchange(ref _reportedApplied, 0)} retried={Interlocked.Exchange(ref _reportedRetried, 0)} published_pieces={Interlocked.Exchange(ref _reportedPublishedPieces, 0)} stale_pieces={Interlocked.Exchange(ref _reportedStalePieces, 0)} eager_started={Interlocked.Exchange(ref _reportedEagerStarted, 0)} eager_used={Interlocked.Exchange(ref _reportedEagerUsed, 0)} eager_stale={Interlocked.Exchange(ref _reportedEagerStale, 0)} eager_muted={Interlocked.Exchange(ref _reportedEagerMuted, 0)}");
private sealed record PendingDownload
{
@ -1154,6 +1314,8 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
public required ulong GuestAddress { get; init; }
public required ulong Size { get; init; }
public required bool IsWrite { get; init; }
public bool Eager { get; init; }
public ulong HotKey { get; init; }
public required GuestMemoryProfile.ReadbackSource Source { get; init; }
public required long Started { get; init; }
}
@ -1169,7 +1331,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return null;
}
if (allowPending && TakeEagerReadback(guestAddress, size) is { } eager)
if (!isWrite && allowPending && TakeEagerReadback(guestAddress, size) is { } eager)
{
return eager with { GuestAddress = guestAddress, Size = size, IsWrite = isWrite, Source = source };
}
@ -1216,20 +1378,87 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return null;
}
private bool CompleteReadMemoryOnGpu(PendingDownload pending, bool retrySynchronously)
private bool CompleteReadMemoryOnGpu(PendingDownload pending, bool consumed)
{
using var readbackScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.BufferReadback);
var readback = AsyncReadback ?? throw SubmissionScheduler.Fatal("The pending readback lost its queue.");
try
{
if (!IsPendingDownloadCurrent(pending))
var copies = pending.Copies;
var current = new bool[copies.Count];
var published = 0;
for (var index = 0; index < copies.Count; index++)
{
readback.Complete(pending.Ticket, null);
if (retrySynchronously)
current[index] = IsPieceCurrent(pending, index);
if (current[index])
{
PendingReadbacksRetried++;
Interlocked.Increment(ref _reportedRetried);
ReadMemoryOnGpu(pending.GuestAddress, pending.Size, pending.IsWrite, pending.Source);
published++;
}
}
readback.Complete(pending.Ticket, published == 0 ? null : (index, bytes) =>
{
if (!current[index])
{
return;
}
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(copies[index].Address, bytes))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copies[index].Address:X16} size=0x{(ulong)bytes.Length:X16}");
}
});
for (var index = 0; index < copies.Count; index++)
{
if (current[index])
{
_gpuModifiedRanges.Remove(copies[index].Address, copies[index].Size);
Interlocked.Increment(ref _gpuModifiedVersion);
}
}
for (var index = 0; index < copies.Count; index++)
{
if (current[index])
{
ClearGpuDirtyPagesOfRemovedRange(copies[index].Address, copies[index].Size);
}
}
var stale = copies.Count - published;
if (pending.Eager)
{
NoteEagerOutcome(pending.HotKey, stale != 0);
}
if (stale != 0)
{
StalePiecesKept += stale;
Interlocked.Add(ref _reportedStalePieces, stale);
}
Interlocked.Add(ref _reportedPublishedPieces, published);
var clean = pending.IsWrite
? !_tracker.HasGpuDirtyPages(pending.GuestAddress, pending.Size)
: !HasGpuDirtyBytes(pending.GuestAddress, pending.Size);
if (!consumed)
{
if (pending.Eager && stale != 0)
{
Interlocked.Increment(ref _reportedEagerStale);
}
return clean;
}
if (!clean)
{
PendingReadbacksRetried++;
Interlocked.Increment(ref _reportedRetried);
if (pending.Eager)
{
Interlocked.Increment(ref _reportedEagerStale);
}
return false;
@ -1237,21 +1466,12 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
PendingReadbacksApplied++;
Interlocked.Increment(ref _reportedApplied);
var copies = pending.Copies;
readback.Complete(pending.Ticket, (index, bytes) =>
if (pending.Eager)
{
if (!_backing.TryWriteBacking(copies[index].Address, bytes))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copies[index].Address:X16} size=0x{(ulong)bytes.Length:X16}");
}
});
foreach (var copy in copies)
{
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
EagerReadbacksUsed++;
Interlocked.Increment(ref _reportedEagerUsed);
}
_tracker.ClearGpuDirtyPages(pending.WindowBegin, pending.WindowEnd - pending.WindowBegin);
if (pending.IsWrite)
{
_tracker.MarkCpuDirtyPages(pending.GuestAddress, pending.Size);
@ -1269,29 +1489,12 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
}
private bool IsPendingDownloadCurrent(PendingDownload pending)
private bool IsPieceCurrent(PendingDownload pending, int index)
{
var total = 0UL;
for (var index = 0; index < pending.Copies.Count; index++)
{
var copy = pending.Copies[index];
if (copy.Buffer.LastGpuWriteTick != pending.WriteTicks[index] ||
!_gpuModifiedRanges.Contains(copy.Address, copy.Size) ||
!ReferenceEquals(FindOwner(copy.Address, copy.Size), copy.Buffer))
{
return false;
}
total += copy.Size;
}
var current = 0UL;
foreach (var range in _gpuModifiedRanges.GetOverlappingRanges(pending.WindowBegin, pending.WindowEnd - pending.WindowBegin))
{
current += range.Size;
}
return current == total;
var copy = pending.Copies[index];
return copy.Buffer.LastGpuWriteTickIn(copy.SourceOffset, copy.Size) == pending.WriteTicks[index] &&
_gpuModifiedRanges.Contains(copy.Address, copy.Size) &&
ReferenceEquals(FindOwner(copy.Address, copy.Size), copy.Buffer);
}
private static void RecordReadback(ulong windowBegin, ulong windowEnd, bool isWrite, List<DownloadPiece> copies, long started,
@ -1456,6 +1659,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
foreach (var copy in copies)
{
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
Interlocked.Increment(ref _gpuModifiedVersion);
}
return;
@ -1492,6 +1696,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
foreach (var copy in copies)
{
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
Interlocked.Increment(ref _gpuModifiedVersion);
}
}
@ -1509,7 +1714,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var ticks = new ulong[copies.Count];
for (var index = 0; index < copies.Count; index++)
{
var written = copies[index].Buffer.LastGpuWriteTick;
var written = copies[index].Buffer.LastGpuWriteTickIn(copies[index].SourceOffset, copies[index].Size);
if (written == 0)
{
return false;
@ -1559,7 +1764,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var total = 0UL;
foreach (var copy in copies)
{
var written = copy.Buffer.LastGpuWriteTick;
var written = copy.Buffer.LastGpuWriteTickIn(copy.SourceOffset, copy.Size);
// A GPU-modified range with no recorded writer: keep the conservative path.
if (written == 0)
{
@ -1590,6 +1795,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
readback.Read(pieces, waitTick, (index, bytes) =>
{
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(copies[index].Address, bytes))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copies[index].Address:X16} size=0x{(ulong)bytes.Length:X16}");
@ -1622,6 +1828,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var placement = copy.Placement;
var offset = baseOffset + placement.DataOffset;
_download.Invalidate(offset, placement.DataSize);
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(copy.Address, _download.Mapped.Slice((int)offset, (int)placement.DataSize)))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copy.Address:X16} size=0x{placement.DataSize:X16}");
@ -1702,34 +1909,99 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferDirtySynchronization);
var startedAt = BufferUploadProfile.Enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0;
if ((!preserveCpuWriteHotPages && !isWritten && !isTexelBuffer && !_tracker.MayHaveCpuDirtyPages(guestAddress, size)) ||
(isWritten && !isTexelBuffer && _gpuModifiedRanges.Contains(guestAddress, size)))
var nothingToUpload = isWritten
? _gpuModifiedRanges.Contains(guestAddress, size)
: !_tracker.MayHaveCpuDirtyPages(guestAddress, size);
if (nothingToUpload)
{
if (BufferUploadProfile.Enabled)
BufferUploadProfile.Record(guestAddress, size, 0, 0, 0, System.Diagnostics.Stopwatch.GetTimestamp() - startedAt);
return false;
return isTexelBuffer && SynchronizeTexelFromImage(buffer, guestAddress, size);
}
profileScope.SwitchPhase(isWritten
? RenderPhaseProfile.Phase.BufferDirtySyncWritten
: isTexelBuffer ? RenderPhaseProfile.Phase.BufferDirtySyncTexel : RenderPhaseProfile.Phase.BufferDirtySyncUpload);
var copies = new List<BufferCopy>();
var totalSize = 0UL;
GpuBuffer? source = null;
_tracker.ForEachUploadRange(
guestAddress,
size,
isWritten,
(address, bytes) =>
var collector = _uploadCollector;
if (collector is null || collector.InUse)
{
// The first call on a thread keeps its collector; a nested one gets its own.
collector = new UploadCollector();
_uploadCollector ??= collector;
}
collector.Begin(this, buffer, guestAddress, size);
try
{
return SynchronizeBufferCore(collector, buffer, guestAddress, size, isWritten, isTexelBuffer, preserveCpuWriteHotPages, startedAt);
}
finally
{
collector.End();
}
}
// The ranges of one synchronization. The delegates are bound once, so a call that has dirty
// pages to upload allocates no closure and no list.
private sealed class UploadCollector
{
private GuestBufferCache _cache = null!;
private GpuBuffer _buffer = null!;
private ulong _guestAddress;
private ulong _size;
public readonly List<BufferCopy> Copies = new();
public ulong TotalSize;
public GpuBuffer? Source;
public bool InUse;
public readonly Action<ulong, ulong> Collect;
public readonly Action Upload;
public UploadCollector()
{
Collect = (address, bytes) =>
{
copies.Add(new BufferCopy(totalSize, buffer.Offset(address), bytes));
totalSize += bytes;
},
() => source = _uploader.PrepareSource(buffer.CpuAddress, CollectionsMarshal.AsSpan(copies), totalSize, guestAddress, size),
preserveCpuWriteHotPages);
Copies.Add(new BufferCopy(TotalSize, _buffer.Offset(address), bytes));
TotalSize += bytes;
};
Upload = () => Source = _cache._uploader.PrepareSource(_buffer.CpuAddress, CollectionsMarshal.AsSpan(Copies), TotalSize, _guestAddress, _size);
}
public void Begin(GuestBufferCache cache, GpuBuffer buffer, ulong guestAddress, ulong size)
{
InUse = true;
_cache = cache;
_buffer = buffer;
_guestAddress = guestAddress;
_size = size;
}
public void End()
{
InUse = false;
_cache = null!;
_buffer = null!;
Copies.Clear();
TotalSize = 0;
Source = null;
}
}
[ThreadStatic] private static UploadCollector? _uploadCollector;
private bool SynchronizeBufferCore(UploadCollector collector, GpuBuffer buffer, ulong guestAddress, ulong size, bool isWritten,
bool isTexelBuffer, bool preserveCpuWriteHotPages, long startedAt)
{
var copies = collector.Copies;
_tracker.ForEachUploadRange(guestAddress, size, isWritten, collector.Collect, collector.Upload, preserveCpuWriteHotPages);
var source = collector.Source;
var totalSize = collector.TotalSize;
if (source != null)
{
buffer.NoteGpuWrite();
foreach (var copy in copies)
{
buffer.NoteGpuWrite(copy.DstOffset, copy.Size);
}
var command = _scheduler.Current;
command.EndRendering();
var native = new CommandBuffer(command.Handle);
@ -1771,18 +2043,18 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
BufferUploadProfile.Record(guestAddress, size, copies.Count, totalSize, hotBytes, elapsedTicks);
}
if (isTexelBuffer)
{
var copiedFromImage = RequireImageCache().TrySynchronizeBufferFromImage(buffer, guestAddress, size);
if (copiedFromImage)
{
buffer.NoteGpuWrite();
}
return isTexelBuffer && SynchronizeTexelFromImage(buffer, guestAddress, size);
}
return copiedFromImage;
private bool SynchronizeTexelFromImage(GpuBuffer buffer, ulong guestAddress, ulong size)
{
var copiedFromImage = RequireImageCache().TrySynchronizeBufferFromImage(buffer, guestAddress, size);
if (copiedFromImage)
{
buffer.NoteGpuWrite(buffer.Offset(guestAddress), size);
}
return false;
return copiedFromImage;
}
private GpuBuffer? FindOwner(ulong guestAddress, ulong size)

View File

@ -0,0 +1,143 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Buffers;
public sealed class WriteTickMap
{
private readonly List<Entry> _entries = [];
private ulong _floor;
private readonly record struct Entry(ulong Begin, ulong End, ulong Tick);
public int Count => _entries.Count;
public ulong Floor => _floor;
public void Assign(ulong begin, ulong size, ulong tick)
{
if (size == 0)
{
return;
}
var end = begin + size;
var index = FirstEndingAfter(begin);
var removed = 0;
while (index + removed < _entries.Count && _entries[index + removed].Begin < end)
{
removed++;
}
Entry? left = null;
Entry? right = null;
if (removed != 0)
{
var first = _entries[index];
if (first.Begin < begin)
{
left = first with { End = begin };
}
var last = _entries[index + removed - 1];
if (last.End > end)
{
right = last with { Begin = end };
}
_entries.RemoveRange(index, removed);
}
var insert = index;
if (left is { } leftPiece)
{
_entries.Insert(insert++, leftPiece);
}
_entries.Insert(insert, new Entry(begin, end, tick));
var middle = insert++;
if (right is { } rightPiece)
{
_entries.Insert(insert, rightPiece);
}
if (middle + 1 < _entries.Count)
{
_ = TryMerge(middle, middle + 1);
}
if (middle > 0)
{
_ = TryMerge(middle - 1, middle);
}
}
public ulong MaxTick(ulong begin, ulong size)
{
var tick = _floor;
if (size == 0)
{
return tick;
}
var end = begin + size;
for (var index = FirstEndingAfter(begin); index < _entries.Count && _entries[index].Begin < end; index++)
{
tick = Math.Max(tick, _entries[index].Tick);
}
return tick;
}
public void Prune(ulong below)
{
var kept = 0;
for (var index = 0; index < _entries.Count; index++)
{
var entry = _entries[index];
if (entry.Tick < below)
{
_floor = Math.Max(_floor, entry.Tick);
continue;
}
_entries[kept++] = entry;
}
_entries.RemoveRange(kept, _entries.Count - kept);
}
private bool TryMerge(int left, int right)
{
var a = _entries[left];
var b = _entries[right];
if (a.End != b.Begin || a.Tick != b.Tick)
{
return false;
}
_entries[left] = a with { End = b.End };
_entries.RemoveAt(right);
return true;
}
private int FirstEndingAfter(ulong address)
{
var low = 0;
var high = _entries.Count;
while (low < high)
{
var middle = low + ((high - low) >> 1);
if (_entries[middle].End <= address)
{
low = middle + 1;
}
else
{
high = middle;
}
}
return low;
}
}

View File

@ -529,7 +529,7 @@ public sealed class CommandStreamQueue
if (submission.Kind == CommandSubmissionKind.FrameBoundary)
{
SlicesRun++;
_host.Flush();
_host.FlushFrame();
return true;
}

View File

@ -26,7 +26,7 @@ public sealed partial class GpuCommandInterpreter
}
// A compare-swap loop applies once per try and suspends while the compare fails.
if (!atomic.TryApply(_host.TryReadGuest, _host.Memory, out var priorValue, out _, out var comparePassed))
if (!atomic.TryApply(_host.TryReadGuestOperand, _host.Memory, out var priorValue, out _, out var comparePassed))
{
throw _host.Fatal($"The atomic address cannot be accessed: address=0x{atomic.Address:X16} size={atomic.ByteCount}.");
}
@ -68,7 +68,7 @@ public sealed partial class GpuCommandInterpreter
if (semaphore.IsSignal)
{
if (!Packets.MemorySemaphorePacket.TrySignal(_host.TryReadGuest, _host.Memory, semaphore.Address, semaphore.WriteSignal, out _))
if (!Packets.MemorySemaphorePacket.TrySignal(_host.TryReadGuestOperand, _host.Memory, semaphore.Address, semaphore.WriteSignal, out _))
{
throw _host.Fatal($"The semaphore cannot be signaled: address=0x{semaphore.Address:X16}.");
}
@ -77,7 +77,7 @@ public sealed partial class GpuCommandInterpreter
}
// A wait takes one token, or suspends until a signal adds one.
if (!Packets.MemorySemaphorePacket.TryConsume(_host.TryReadGuest, _host.Memory, semaphore.Address, out var priorValue))
if (!Packets.MemorySemaphorePacket.TryConsume(_host.TryReadGuestOperand, _host.Memory, semaphore.Address, out var priorValue))
{
throw _host.Fatal($"The semaphore cannot be read: address=0x{semaphore.Address:X16}.");
}

View File

@ -11,7 +11,6 @@ namespace SharpEmu.Libs.Gpu.GpuCommands;
public sealed partial class GpuCommandInterpreter
{
private const uint DmaSkippedDestination = 0x3022C;
private const uint LodStatsWithoutCounters = 1;
// Destination selectors 1, 2, 4 and 5 are memory; registers and GDS are not supported.
internal void WriteData(ulong destination, ReadOnlySpan<uint> source, uint writeControl)
@ -367,15 +366,16 @@ public sealed partial class GpuCommandInterpreter
var bufferSize = payload[0];
var destination = (payload[1] & 0xFFFF_FFC0u) | ((ulong)payload[2] << 32);
if (destination != 0 && bufferSize != 0 && _host.TryRecordLodStatistics(destination, bufferSize))
{
return 4;
}
if (destination != 0 && bufferSize != 0)
{
var report = new byte[bufferSize];
var hasCounters = MipStatistics.Shared.TryWriteReport(report);
MipStatistics.Shared.WriteReport(report);
WriteBytes(destination, report);
if (bufferSize >= sizeof(uint))
{
WriteDword(destination, hasCounters ? MipStatistics.FirstCounterOffset : LodStatsWithoutCounters);
}
}
return 4;

View File

@ -82,8 +82,8 @@ public sealed partial class GpuCommandInterpreter
var next = packet.PacketAddress + (ulong)packet.Length * sizeof(uint);
var expectedHeader = PacketHeader.Make(centroidPacketDwords, PacketOpcode.SetContextRegister);
if (packet.Remaining < packet.Length + centroidPacketDwords ||
ReadDword(next) != expectedHeader ||
RegisterBankLayout.Normalize(ReadDword(next + sizeof(uint))) != ContextRegisterOffset.PaScCentroidPriority0)
ReadDword(next, RenderPhaseProfile.CommandReadKind.Payload) != expectedHeader ||
RegisterBankLayout.Normalize(ReadDword(next + sizeof(uint), RenderPhaseProfile.CommandReadKind.Payload)) != ContextRegisterOffset.PaScCentroidPriority0)
{
throw _host.Fatal($"The sample location packet is not followed by the centroid priority packet: header=0x{packet.Header:X8} address=0x{packet.PacketAddress:X16} next=0x{next:X16}.");
}
@ -102,16 +102,16 @@ public sealed partial class GpuCommandInterpreter
var address = packet.PacketAddress;
var registerHeader = PacketHeader.Make(3, PacketOpcode.SetContextRegister);
if (ReadDword(address + 40) != registerHeader || ReadDword(address + 44) != RegisterBankLayout.DepthInfo ||
ReadDword(address + 52) != registerHeader || ReadDword(address + 56) != RegisterBankLayout.DepthView ||
ReadDword(address + 64) != registerHeader || ReadDword(address + 68) != RegisterBankLayout.HtileDataBase ||
ReadDword(address + 76) != registerHeader || ReadDword(address + 80) != RegisterBankLayout.HtileSurface ||
ReadDword(address + 88) != PacketHeader.Make(2, PacketOpcode.Nop))
if (ReadDword(address + 40, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 44, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.DepthInfo ||
ReadDword(address + 52, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 56, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.DepthView ||
ReadDword(address + 64, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 68, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.HtileDataBase ||
ReadDword(address + 76, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 80, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.HtileSurface ||
ReadDword(address + 88, RenderPhaseProfile.CommandReadKind.Payload) != PacketHeader.Make(2, PacketOpcode.Nop))
{
return;
}
var sizeXy = ReadDword(address + 92);
var sizeXy = ReadDword(address + 92, RenderPhaseProfile.CommandReadKind.Payload);
if (sizeXy != 0)
{
TypedRegisters.CompositeDepthSizeXy = sizeXy;

View File

@ -28,7 +28,7 @@ public sealed partial class GpuCommandInterpreter
return;
}
var value = is64Bit ? ReadQword(address) : ReadDword(address);
var value = ReadWaitOperand(address, is64Bit);
if (!WaitOperation.TryCompare(value, reference, mask, compareFunction, out var satisfied))
{
throw _host.Fatal($"The wait compare function is unknown: function={compareFunction} address=0x{address:X16}.");

View File

@ -394,7 +394,12 @@ public sealed partial class GpuCommandInterpreter
{
RenderPhaseProfile.RecordCommandRead(readKind, sizeof(uint));
Span<byte> bytes = stackalloc byte[sizeof(uint)];
if (!_host.TryReadGuest(address, bytes))
// The packets themselves are command data; anything else is an operand earlier work may write.
var read = readKind is RenderPhaseProfile.CommandReadKind.Header or RenderPhaseProfile.CommandReadKind.Payload or
RenderPhaseProfile.CommandReadKind.RegisterTable
? _host.TryReadGuest(address, bytes)
: _host.TryReadGuestOperand(address, bytes);
if (!read)
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size=4.");
}
@ -406,7 +411,7 @@ public sealed partial class GpuCommandInterpreter
{
RenderPhaseProfile.RecordCommandRead(RenderPhaseProfile.CommandReadKind.Operand64, sizeof(ulong));
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
if (!_host.TryReadGuest(address, bytes))
if (!_host.TryReadGuestOperand(address, bytes))
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size=8.");
}
@ -414,10 +419,24 @@ public sealed partial class GpuCommandInterpreter
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
}
private ulong ReadWaitOperand(ulong address, bool is64Bit)
{
RenderPhaseProfile.RecordCommandRead(is64Bit ? RenderPhaseProfile.CommandReadKind.Operand64 : RenderPhaseProfile.CommandReadKind.Operand32,
is64Bit ? sizeof(ulong) : sizeof(uint));
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
var size = is64Bit ? sizeof(ulong) : sizeof(uint);
if (!_host.TryReadGuestWaitOperand(address, bytes[..size]))
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size={size}.");
}
return is64Bit ? BinaryPrimitives.ReadUInt64LittleEndian(bytes) : BinaryPrimitives.ReadUInt32LittleEndian(bytes);
}
internal void ReadBytes(ulong address, Span<byte> destination)
{
RenderPhaseProfile.RecordCommandRead(RenderPhaseProfile.CommandReadKind.Other, destination.Length);
if (!_host.TryReadGuest(address, destination))
if (!_host.TryReadGuestOperand(address, destination))
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size={destination.Length}.");
}

View File

@ -81,9 +81,19 @@ public interface ICommandStreamHost
{
ICpuMemory Memory { get; }
bool TryRecordLodStatistics(ulong destination, uint size) => false;
// Reads guest memory the GPU may have written; the host synchronizes GPU-owned pages first.
bool TryReadGuest(ulong address, Span<byte> destination);
// Reads a packet operand (a label, a predicate, indirect arguments): memory earlier work may
// write, unlike the packets themselves. A host that defers work completes it first.
bool TryReadGuestOperand(ulong address, Span<byte> destination) => TryReadGuest(address, destination);
// Reads the label a wait polls. A stale value only suspends the wait until a retry, so a host
// that runs work behind the stream need not complete it first.
bool TryReadGuestWaitOperand(ulong address, Span<byte> destination) => TryReadGuestOperand(address, destination);
// Runs commands other threads posted to this worker. Called before every packet.
void RunPendingCommands();
@ -92,6 +102,10 @@ public interface ICommandStreamHost
void Flush();
// A frame boundary: submits what the frame recorded. The submitter resumes once it returns, so a
// host that runs the stream ahead of its recording returns only after the frame is recorded.
void FlushFrame() => Flush();
void FlushAndWait();
void SynchronizeGpu();

View File

@ -338,8 +338,15 @@ public sealed class ScreenViewportRegisters
public ScreenViewportRegisters Copy()
{
var copy = (ScreenViewportRegisters)MemberwiseClone();
var copy = CopySharingViewports();
copy.Viewports = (ViewportRegisters[])Viewports.Clone();
return copy;
}
// A copy that shares the viewport array, the largest part, until RegisterBanks copies it.
public ScreenViewportRegisters CopySharingViewports()
{
var copy = (ScreenViewportRegisters)MemberwiseClone();
copy.ClipRectangleLeft = (int[])ClipRectangleLeft.Clone();
copy.ClipRectangleTop = (int[])ClipRectangleTop.Clone();
copy.ClipRectangleRight = (int[])ClipRectangleRight.Clone();
@ -388,6 +395,9 @@ public sealed class ContextRegisters
public EnhancedQualityAntialiasingRegisters EnhancedQualityAntialiasing;
public ShaderInterfaceRegisters ShaderInterface = new();
// A copy that shares every nested part; RegisterBanks copies a part before writing it.
public ContextRegisters ShallowCopy() => (ContextRegisters)MemberwiseClone();
public uint RenderTargetMaskForSlot(uint slot) => (RenderTargetMask >> (int)(slot * 4)) & 0xFu;
public ContextRegisters Copy()

View File

@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Images;
namespace SharpEmu.Libs.Gpu.GpuCommands.Registers;
// The three typed register banks of one queue, with the state the register writers carry between packets.
@ -9,15 +11,31 @@ public sealed class RegisterBanks
private ContextRegisters? _savedContext;
private uint? _savedCompositeDepthSizeXy;
public RegisterBanks(Func<string, Exception> fatal) => Fatal = fatal;
public RegisterBanks(Func<string, Exception> fatal)
: this(fatal, new ContextRegisters(), new ShaderProgramRegisters(), new UserConfigRegisters())
{
}
// Banks over given parts, so a copy or a snapshot does not first build empty ones.
private RegisterBanks(
Func<string, Exception> fatal,
ContextRegisters context,
ShaderProgramRegisters shader,
UserConfigRegisters userConfig)
{
Fatal = fatal;
Context = context;
Shader = shader;
UserConfig = userConfig;
}
public Func<string, Exception> Fatal { get; }
public ContextRegisters Context { get; private set; } = new();
public ContextRegisters Context { get; private set; }
public ShaderProgramRegisters Shader { get; private set; } = new();
public ShaderProgramRegisters Shader { get; private set; }
public UserConfigRegisters UserConfig { get; private set; } = new();
public UserConfigRegisters UserConfig { get; private set; }
public bool ContextPushed => _savedContext is not null;
@ -31,11 +49,8 @@ public sealed class RegisterBanks
// A copy of the live banks; the saved context and the draw-time markers are not part of it.
public RegisterBanks Clone()
{
var clone = new RegisterBanks(Fatal)
var clone = new RegisterBanks(Fatal, Context.Copy(), Shader.Copy(), UserConfig.Copy())
{
Context = Context.Copy(),
Shader = Shader.Copy(),
UserConfig = UserConfig.Copy(),
CompositeDepthSizeXy = CompositeDepthSizeXy,
UserDataMarker = UserDataMarker,
IndexTypeAndSize = IndexTypeAndSize,
@ -43,9 +58,281 @@ public sealed class RegisterBanks
return clone;
}
// The parts a snapshot shares with these banks; each is copied before its next write here.
private bool _contextShared;
private bool _vertexShared;
private bool _pixelShared;
private bool _computeShared;
private bool _userConfigShared;
// A read-only copy that shares every part with the live banks until they next write it: a
// deferred draw takes one per draw, and between draws only a few parts change.
public RegisterBanks Snapshot()
{
var snapshot = new RegisterBanks(Fatal, Context, Shader.ShareStages(), UserConfig)
{
CompositeDepthSizeXy = CompositeDepthSizeXy,
UserDataMarker = UserDataMarker,
IndexTypeAndSize = IndexTypeAndSize,
};
_contextShared = _vertexShared = _pixelShared = _computeShared = _userConfigShared = true;
_pixelScalarsShared = _hullScalarsShared = _geometryScalarsShared = _legacyVertexScalarsShared = _exportScalarsShared = _computeScalarsShared = true;
return snapshot;
}
// Called by the register writers before they store into a part.
public void PrepareContextWrite()
{
if (_contextShared)
{
Context = Context.ShallowCopy();
_contextShared = false;
SetContextPartsShared(true);
}
}
// The context is copied without its nested parts, and a part only when a writer asks for it
// through these accessors: a draw usually rewrites a few plain fields.
private bool _screenViewportShared;
private bool _viewportsShared;
private bool _sampleLocationsShared;
private bool _shaderInterfaceShared;
private bool _blendControlsShared;
private bool _colorTargetsShared;
private bool _colorClearWord1Shared;
private bool _unmodeledShared;
private void SetContextPartsShared(bool shared) =>
_screenViewportShared = _viewportsShared = _sampleLocationsShared = _shaderInterfaceShared =
_blendControlsShared = _colorTargetsShared = _colorClearWord1Shared = _unmodeledShared = shared;
private void OwnContext(ContextRegisters context)
{
Context = context;
_contextShared = false;
SetContextPartsShared(false);
}
public ScreenViewportRegisters ScreenViewportForWrite
{
get
{
PrepareContextWrite();
if (_screenViewportShared)
{
Context.ScreenViewport = Context.ScreenViewport.CopySharingViewports();
_screenViewportShared = false;
}
return Context.ScreenViewport;
}
}
public ViewportRegisters[] ViewportsForWrite
{
get
{
var screenViewport = ScreenViewportForWrite;
if (_viewportsShared)
{
screenViewport.Viewports = (ViewportRegisters[])screenViewport.Viewports.Clone();
_viewportsShared = false;
}
return screenViewport.Viewports;
}
}
public SampleLocationRegisters SampleLocationsForWrite
{
get
{
PrepareContextWrite();
if (_sampleLocationsShared)
{
Context.SampleLocations = Context.SampleLocations.Copy();
_sampleLocationsShared = false;
}
return Context.SampleLocations;
}
}
public ShaderInterfaceRegisters ShaderInterfaceForWrite
{
get
{
PrepareContextWrite();
if (_shaderInterfaceShared)
{
Context.ShaderInterface = Context.ShaderInterface.Copy();
_shaderInterfaceShared = false;
}
return Context.ShaderInterface;
}
}
public BlendRegisters[] BlendControlsForWrite
{
get
{
PrepareContextWrite();
if (_blendControlsShared)
{
Context.BlendControls = (BlendRegisters[])Context.BlendControls.Clone();
_blendControlsShared = false;
}
return Context.BlendControls;
}
}
public ColorTargetWords[] ColorTargetsForWrite
{
get
{
PrepareContextWrite();
if (_colorTargetsShared)
{
Context.ColorTargets = (ColorTargetWords[])Context.ColorTargets.Clone();
_colorTargetsShared = false;
}
return Context.ColorTargets;
}
}
public uint[] ColorClearWord1ForWrite
{
get
{
PrepareContextWrite();
if (_colorClearWord1Shared)
{
Context.ColorClearWord1 = (uint[])Context.ColorClearWord1.Clone();
_colorClearWord1Shared = false;
}
return Context.ColorClearWord1;
}
}
public Dictionary<uint, uint> UnmodeledTableRegistersForWrite
{
get
{
PrepareContextWrite();
if (_unmodeledShared)
{
Context.UnmodeledTableRegisters = new Dictionary<uint, uint>(Context.UnmodeledTableRegisters);
_unmodeledShared = false;
}
return Context.UnmodeledTableRegisters;
}
}
public void PrepareUserConfigWrite()
{
if (_userConfigShared)
{
UserConfig = UserConfig.Copy();
_userConfigShared = false;
}
}
// COMPUTE_DISPATCH_INITIATOR, the first register of the compute range.
private const uint ComputeRangeStart = 0x200;
// A stage is copied without its user scalar sets, and a set only when its window is written:
// a draw usually rewrites one set of one or two stages.
private bool _pixelScalarsShared;
private bool _hullScalarsShared;
private bool _geometryScalarsShared;
private bool _legacyVertexScalarsShared;
private bool _exportScalarsShared;
private bool _computeScalarsShared;
private const uint UserScalarWindow = UserScalarRegisters.Capacity;
private static bool Overlaps(uint first, uint last, uint windowStart) =>
first < windowStart + UserScalarWindow && last >= windowStart;
// The shader bank's offsets: pixel below SPI_SHADER_PGM_LO_VS, the geometry stages up to the
// compute range, compute from there.
public void PrepareShaderWrite(uint offset, uint count)
{
var last = offset + Math.Max(count, 1) - 1;
if (offset < ShaderRegisterOffset.SpiShaderPgmLoVs)
{
if (_pixelShared)
{
Shader.Pixel = Shader.Pixel.ShallowCopy();
_pixelShared = false;
}
if (_pixelScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataPs0))
{
Shader.Pixel.UserScalars = Shader.Pixel.UserScalars.Copy();
_pixelScalarsShared = false;
}
}
if (offset < ComputeRangeStart && last >= ShaderRegisterOffset.SpiShaderPgmLoVs)
{
if (_vertexShared)
{
Shader.Vertex = Shader.Vertex.ShallowCopy();
_vertexShared = false;
}
var vertex = Shader.Vertex;
if (_hullScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataHs0))
{
vertex.HullUserScalars = vertex.HullUserScalars.Copy();
_hullScalarsShared = false;
}
if (_geometryScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataGs0))
{
vertex.GeometryUserScalars = vertex.GeometryUserScalars.Copy();
_geometryScalarsShared = false;
}
if (_legacyVertexScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataVs0))
{
vertex.LegacyVertexUserScalars = vertex.LegacyVertexUserScalars.Copy();
_legacyVertexScalarsShared = false;
}
if (_exportScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataEs0))
{
vertex.ExportUserScalars = vertex.ExportUserScalars.Copy();
_exportScalarsShared = false;
}
}
if (last >= ComputeRangeStart)
{
if (_computeShared)
{
Shader.Compute = Shader.Compute.ShallowCopy();
_computeShared = false;
}
if (_computeScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.ComputeUserData0))
{
Shader.Compute.UserScalars = Shader.Compute.UserScalars.Copy();
_computeScalarsShared = false;
}
}
}
public void Reset()
{
Context = new ContextRegisters();
_contextShared = _vertexShared = _pixelShared = _computeShared = _userConfigShared = false;
_pixelScalarsShared = _hullScalarsShared = _geometryScalarsShared = _legacyVertexScalarsShared = _exportScalarsShared = _computeScalarsShared = false;
OwnContext(new ContextRegisters());
Shader = new ShaderProgramRegisters();
UserConfig = new UserConfigRegisters();
_savedContext = null;
@ -61,7 +348,7 @@ public sealed class RegisterBanks
switch (operation)
{
case ContextStateOperation.Clear:
Context = new ContextRegisters();
OwnContext(new ContextRegisters());
CompositeDepthSizeXy = null;
break;
case ContextStateOperation.Push:
@ -73,14 +360,14 @@ public sealed class RegisterBanks
throw Fatal("The context state is not pushed.");
}
Context = _savedContext;
OwnContext(_savedContext);
CompositeDepthSizeXy = _savedCompositeDepthSizeXy;
_savedContext = null;
_savedCompositeDepthSizeXy = null;
break;
case ContextStateOperation.PushClear:
Push();
Context = new ContextRegisters();
OwnContext(new ContextRegisters());
CompositeDepthSizeXy = null;
break;
default:

View File

@ -28,6 +28,7 @@ public static class RegisterWriteTable
public static uint WriteContextPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
banks.PrepareContextWrite();
var consumed = WritePacket(banks, in packet, offset, values, Context, ContextIndirect, "context", emptyIsHandled: true);
if (Rendering.RenderTrace.Enabled)
{
@ -43,6 +44,7 @@ public static class RegisterWriteTable
// A shader packet without values has no writer; the other banks accept it.
public static uint WriteShaderPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
banks.PrepareShaderWrite(offset, (uint)values.Length);
var consumed = WritePacket(banks, in packet, offset, values, Shader, ShaderIndirect, "shader", emptyIsHandled: false);
if (Rendering.RenderTrace.Enabled)
{
@ -52,15 +54,19 @@ public static class RegisterWriteTable
return consumed;
}
public static uint WriteUserConfigPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>
WritePacket(banks, in packet, offset, values, UserConfig, UserConfigIndirect, "user-config", emptyIsHandled: true);
public static uint WriteUserConfigPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
banks.PrepareUserConfigWrite();
return WritePacket(banks, in packet, offset, values, UserConfig, UserConfigIndirect, "user-config", emptyIsHandled: true);
}
public static void WriteContextEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress)
{
banks.PrepareContextWrite();
if (offset < ContextIndirect.Length && ContextIndirect[offset] is null)
{
// Keep unknown in-bank table values so a later renderer implementation can decode them.
banks.Context.UnmodeledTableRegisters[offset] = value;
banks.UnmodeledTableRegistersForWrite[offset] = value;
}
else
{
@ -88,6 +94,7 @@ public static class RegisterWriteTable
public static void WriteShaderEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress)
{
banks.PrepareShaderWrite(offset, 1);
WriteEntry(banks, offset, value, ShaderIndirect, "shader", tableAddress);
if (Rendering.RenderTrace.Enabled)
TracePixelRegisterWrite(banks, offset, value, tableAddress, "table");
@ -104,8 +111,11 @@ public static class RegisterWriteTable
$"value=0x{value:X8} shader=0x{banks.Shader.Pixel.Address:X16}");
}
public static void WriteUserConfigEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress) =>
public static void WriteUserConfigEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress)
{
banks.PrepareUserConfigWrite();
WriteEntry(banks, offset, value, UserConfigIndirect, "user-config", tableAddress);
}
private static uint WritePacket(
RegisterBanks banks,

View File

@ -98,8 +98,8 @@ internal static partial class RegisterWriters
{
indirect[offset] = static (banks, register, value) =>
{
banks.Context.ShaderInterface.PixelInterpolatorSettings[register - SpiPsInputCntl0] = value;
banks.Context.ShaderInterface.PixelInterpolatorWritten |= 1u << (int)(register - SpiPsInputCntl0);
banks.ShaderInterfaceForWrite.PixelInterpolatorSettings[register - SpiPsInputCntl0] = value;
banks.ShaderInterfaceForWrite.PixelInterpolatorWritten |= 1u << (int)(register - SpiPsInputCntl0);
};
}
@ -160,7 +160,7 @@ internal static partial class RegisterWriters
for (var offset = PaScAaSampleLocations0; offset < PaScAaSampleLocations0 + SampleLocationRegisters.LocationCount; offset++)
{
indirect[offset] = static (banks, register, value) => banks.Context.SampleLocations.Locations[register - PaScAaSampleLocations0] = value;
indirect[offset] = static (banks, register, value) => banks.SampleLocationsForWrite.Locations[register - PaScAaSampleLocations0] = value;
}
foreach (var offset in new[]
@ -175,36 +175,36 @@ internal static partial class RegisterWriters
indirect[offset] = IgnoreEntry;
}
indirect[SpiVsOutConfig] = static (banks, _, value) => banks.Context.ShaderInterface.VertexOutputConfiguration = value;
indirect[SpiVsOutConfig] = static (banks, _, value) => banks.ShaderInterfaceForWrite.VertexOutputConfiguration = value;
indirect[DbRenderOverride] = static (banks, _, value) => banks.Context.DepthRenderOverride = DepthRenderOverrideRegisters.Decode(value);
indirect[SpiShaderPosFormat] = static (banks, _, value) => banks.Context.ShaderInterface.PositionExportFormat = value;
indirect[SpiShaderIdxFormat] = static (banks, _, value) => banks.Context.ShaderInterface.IndexExportFormat = value;
indirect[PaClVsOutCntl] = static (banks, _, value) => banks.Context.ShaderInterface.VertexOutputControl = value;
indirect[GeNggSubgroupCntl] = static (banks, _, value) => banks.Context.ShaderInterface.PrimitiveShaderSubgroupControl = value;
indirect[VgtGsInstanceCnt] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryInstanceCount = value;
indirect[VgtGsOnchipCntl] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryOnChipControl = value;
indirect[VgtHosMaxTessLevel] = static (banks, _, value) => banks.Context.ShaderInterface.MaxTessellationLevel = value;
indirect[VgtHosMinTessLevel] = static (banks, _, value) => banks.Context.ShaderInterface.MinTessellationLevel = value;
indirect[GeMaxOutputPerSubgroup] = static (banks, _, value) => banks.Context.ShaderInterface.MaxOutputPerSubgroup = value;
indirect[VgtEsgsRingItemSize] = static (banks, _, value) => banks.Context.ShaderInterface.ExportRingItemSize = value;
indirect[VgtGsMaxVertOut] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryMaxVerticesOut = value;
indirect[VgtPrimitiveIdEn] = static (banks, _, value) => banks.Context.ShaderInterface.PrimitiveIdEnable = value;
indirect[VgtReuseOff] = static (banks, _, value) => banks.Context.ShaderInterface.VertexReuseOff = value;
indirect[SpiShaderPosFormat] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PositionExportFormat = value;
indirect[SpiShaderIdxFormat] = static (banks, _, value) => banks.ShaderInterfaceForWrite.IndexExportFormat = value;
indirect[PaClVsOutCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.VertexOutputControl = value;
indirect[GeNggSubgroupCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PrimitiveShaderSubgroupControl = value;
indirect[VgtGsInstanceCnt] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryInstanceCount = value;
indirect[VgtGsOnchipCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryOnChipControl = value;
indirect[VgtHosMaxTessLevel] = static (banks, _, value) => banks.ShaderInterfaceForWrite.MaxTessellationLevel = value;
indirect[VgtHosMinTessLevel] = static (banks, _, value) => banks.ShaderInterfaceForWrite.MinTessellationLevel = value;
indirect[GeMaxOutputPerSubgroup] = static (banks, _, value) => banks.ShaderInterfaceForWrite.MaxOutputPerSubgroup = value;
indirect[VgtEsgsRingItemSize] = static (banks, _, value) => banks.ShaderInterfaceForWrite.ExportRingItemSize = value;
indirect[VgtGsMaxVertOut] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryMaxVerticesOut = value;
indirect[VgtPrimitiveIdEn] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PrimitiveIdEnable = value;
indirect[VgtReuseOff] = static (banks, _, value) => banks.ShaderInterfaceForWrite.VertexReuseOff = value;
indirect[VgtMultiPrimIbResetIndex] = static (banks, _, value) => banks.Context.PrimitiveResetIndex = value;
indirect[VgtTessDistribution] = static (banks, _, value) => banks.Context.ShaderInterface.TessellationDistribution = value;
indirect[VgtTessDistribution] = static (banks, _, value) => banks.ShaderInterfaceForWrite.TessellationDistribution = value;
indirect[VgtShaderStagesEn] = static (banks, _, value) => banks.Context.ShaderStages = value;
indirect[VgtLsHsConfig] = static (banks, _, value) => banks.Context.ShaderInterface.LocalHullConfiguration = value;
indirect[VgtGsOutPrimType] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryOutputPrimitiveType = value;
indirect[VgtTfParam] = static (banks, _, value) => banks.Context.ShaderInterface.TessellationFactorParameter = value;
indirect[SpiShaderZFormat] = static (banks, _, value) => banks.Context.ShaderInterface.DepthExportFormat = value;
indirect[VgtLsHsConfig] = static (banks, _, value) => banks.ShaderInterfaceForWrite.LocalHullConfiguration = value;
indirect[VgtGsOutPrimType] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryOutputPrimitiveType = value;
indirect[VgtTfParam] = static (banks, _, value) => banks.ShaderInterfaceForWrite.TessellationFactorParameter = value;
indirect[SpiShaderZFormat] = static (banks, _, value) => banks.ShaderInterfaceForWrite.DepthExportFormat = value;
indirect[SpiShaderColFormat] = ShaderColorFormatEntry;
indirect[SpiPsInputEna] = static (banks, _, value) => banks.Context.ShaderInterface.PixelInputEnable = value;
indirect[SpiPsInputAddr] = static (banks, _, value) => banks.Context.ShaderInterface.PixelInputAddress = value;
indirect[SpiPsInControl] = static (banks, _, value) => banks.Context.ShaderInterface.PixelInputControl = value;
indirect[SpiBarycCntl] = static (banks, _, value) => banks.Context.ShaderInterface.BarycentricControl = value;
indirect[DbShaderControl] = static (banks, _, value) => banks.Context.ShaderInterface.DepthShaderControl = DepthShaderControlRegisters.Decode(value);
indirect[CbShaderMask] = static (banks, _, value) => banks.Context.ShaderInterface.ColorShaderMask = value;
indirect[PaScShaderControl] = static (banks, _, value) => banks.Context.ShaderInterface.ScanShaderControl = value;
indirect[SpiPsInputEna] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PixelInputEnable = value;
indirect[SpiPsInputAddr] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PixelInputAddress = value;
indirect[SpiPsInControl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PixelInputControl = value;
indirect[SpiBarycCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.BarycentricControl = value;
indirect[DbShaderControl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.DepthShaderControl = DepthShaderControlRegisters.Decode(value);
indirect[CbShaderMask] = static (banks, _, value) => banks.ShaderInterfaceForWrite.ColorShaderMask = value;
indirect[PaScShaderControl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.ScanShaderControl = value;
indirect[CbTargetMask] = static (banks, _, value) => banks.Context.RenderTargetMask = value;
indirect[PaScScreenScissorTl] = ScreenScissorTopLeftEntry;
indirect[PaScScreenScissorBr] = ScreenScissorBottomRightEntry;
@ -248,7 +248,7 @@ internal static partial class RegisterWriters
indirect[DbStencilRefMaskBack] = StencilMaskBackEntry;
indirect[PaClClipCntl] = static (banks, _, value) => banks.Context.Clip = ClipControlRegisters.Decode(value);
indirect[PaSuLineCntl] = LineControlEntry;
indirect[PaClVteCntl] = static (banks, _, value) => banks.Context.ScreenViewport.TransformControl = value;
indirect[PaClVteCntl] = static (banks, _, value) => banks.ScreenViewportForWrite.TransformControl = value;
indirect[PaScModeCntl0] = static (banks, _, value) => banks.Context.ScanMode = ScanModeRegisters.Decode(value);
indirect[PaScAaConfig] = static (banks, _, value) => banks.Context.AntialiasingConfig = AntialiasingConfigRegisters.Decode(value);
indirect[PaScCentroidPriority0] = CentroidPriorityEntry;
@ -369,8 +369,8 @@ internal static partial class RegisterWriters
throw PacketError(banks, "The pixel interpolator packet has an invalid count", in packet, offset, values.Length);
}
values.CopyTo(banks.Context.ShaderInterface.PixelInterpolatorSettings);
banks.Context.ShaderInterface.PixelInterpolatorWritten |= (uint)((1ul << values.Length) - 1);
values.CopyTo(banks.ShaderInterfaceForWrite.PixelInterpolatorSettings);
banks.ShaderInterfaceForWrite.PixelInterpolatorWritten |= (uint)((1ul << values.Length) - 1);
return (uint)values.Length;
}
@ -410,7 +410,7 @@ internal static partial class RegisterWriters
// One location at any slot, or all sixteen from the first slot.
private static uint SampleLocationsPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
var locations = banks.Context.SampleLocations.Locations;
var locations = banks.SampleLocationsForWrite.Locations;
if (values.Length == 1)
{
locations[offset - PaScAaSampleLocations0] = values[0];
@ -506,19 +506,19 @@ internal static partial class RegisterWriters
private static void HardwareScreenOffsetEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.HardwareOffsetX = RegisterField.Get(value, 0, 0x1FF);
banks.Context.ScreenViewport.HardwareOffsetY = RegisterField.Get(value, 16, 0x1FF);
banks.ScreenViewportForWrite.HardwareOffsetX = RegisterField.Get(value, 0, 0x1FF);
banks.ScreenViewportForWrite.HardwareOffsetY = RegisterField.Get(value, 16, 0x1FF);
}
private static void WindowOffsetEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.WindowOffsetX = RegisterField.Coordinate16(value);
banks.Context.ScreenViewport.WindowOffsetY = RegisterField.Coordinate16(value >> 16);
banks.ScreenViewportForWrite.WindowOffsetX = RegisterField.Coordinate16(value);
banks.ScreenViewportForWrite.WindowOffsetY = RegisterField.Coordinate16(value >> 16);
}
private static void ClipRectangleEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
if (offset == PaScClipRectRule)
{
viewport.ClipRectangleRule = (ushort)(value & 0xFFFFu);
@ -598,7 +598,7 @@ internal static partial class RegisterWriters
private static void CentroidPriorityEntry(RegisterBanks banks, uint offset, uint value)
{
var locations = banks.Context.SampleLocations;
var locations = banks.SampleLocationsForWrite;
switch (offset)
{
case PaScCentroidPriority0:
@ -612,7 +612,7 @@ internal static partial class RegisterWriters
private static void ShaderColorFormatEntry(RegisterBanks banks, uint offset, uint value)
{
var modes = banks.Context.ShaderInterface.TargetOutputModes;
var modes = banks.ShaderInterfaceForWrite.TargetOutputModes;
for (var index = 0; index < modes.Length; index++)
{
modes[index] = (byte)((value >> (index * 4)) & 0xFu);
@ -621,19 +621,19 @@ internal static partial class RegisterWriters
private static void ScreenScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.ScreenScissorLeft = RegisterField.Coordinate16(value);
banks.Context.ScreenViewport.ScreenScissorTop = RegisterField.Coordinate16(value >> 16);
banks.ScreenViewportForWrite.ScreenScissorLeft = RegisterField.Coordinate16(value);
banks.ScreenViewportForWrite.ScreenScissorTop = RegisterField.Coordinate16(value >> 16);
}
private static void ScreenScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.ScreenScissorRight = RegisterField.Coordinate16(value);
banks.Context.ScreenViewport.ScreenScissorBottom = RegisterField.Coordinate16(value >> 16);
banks.ScreenViewportForWrite.ScreenScissorRight = RegisterField.Coordinate16(value);
banks.ScreenViewportForWrite.ScreenScissorBottom = RegisterField.Coordinate16(value >> 16);
}
private static void GenericScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
viewport.GenericScissorLeft = RegisterField.Coordinate15(value);
viewport.GenericScissorTop = RegisterField.Coordinate15(value >> 16);
viewport.GenericScissorWindowOffsetEnable = !RegisterField.Bit(value, 31);
@ -641,13 +641,13 @@ internal static partial class RegisterWriters
private static void GenericScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.GenericScissorRight = RegisterField.Coordinate15(value);
banks.Context.ScreenViewport.GenericScissorBottom = RegisterField.Coordinate15(value >> 16);
banks.ScreenViewportForWrite.GenericScissorRight = RegisterField.Coordinate15(value);
banks.ScreenViewportForWrite.GenericScissorBottom = RegisterField.Coordinate15(value >> 16);
}
private static void WindowScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
viewport.WindowScissorLeft = RegisterField.Coordinate15(value);
viewport.WindowScissorTop = RegisterField.Coordinate15(value >> 16);
viewport.WindowScissorWindowOffsetEnable = !RegisterField.Bit(value, 31);
@ -655,13 +655,13 @@ internal static partial class RegisterWriters
private static void WindowScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.WindowScissorRight = RegisterField.Coordinate15(value);
banks.Context.ScreenViewport.WindowScissorBottom = RegisterField.Coordinate15(value >> 16);
banks.ScreenViewportForWrite.WindowScissorRight = RegisterField.Coordinate15(value);
banks.ScreenViewportForWrite.WindowScissorBottom = RegisterField.Coordinate15(value >> 16);
}
private static void ViewportScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
ref var viewport = ref banks.Context.ScreenViewport.Viewports[(offset - PaScViewportScissor0Tl) / 2];
ref var viewport = ref banks.ViewportsForWrite[(offset - PaScViewportScissor0Tl) / 2];
viewport.ScissorLeft = RegisterField.Coordinate15(value);
viewport.ScissorTop = RegisterField.Coordinate15(value >> 16);
viewport.ScissorWindowOffsetEnable = !RegisterField.Bit(value, 31);
@ -669,21 +669,21 @@ internal static partial class RegisterWriters
private static void ViewportScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
ref var viewport = ref banks.Context.ScreenViewport.Viewports[(offset - PaScViewportScissor0Br) / 2];
ref var viewport = ref banks.ViewportsForWrite[(offset - PaScViewportScissor0Br) / 2];
viewport.ScissorRight = RegisterField.Coordinate15(value);
viewport.ScissorBottom = RegisterField.Coordinate15(value >> 16);
}
private static void ViewportZMinEntry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.ScreenViewport.Viewports[(offset - PaScViewportZMin0) / 2].MinDepth = RegisterField.AsFloat(value);
banks.ViewportsForWrite[(offset - PaScViewportZMin0) / 2].MinDepth = RegisterField.AsFloat(value);
private static void ViewportZMaxEntry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.ScreenViewport.Viewports[(offset - PaScViewportZMax0) / 2].MaxDepth = RegisterField.AsFloat(value);
banks.ViewportsForWrite[(offset - PaScViewportZMax0) / 2].MaxDepth = RegisterField.AsFloat(value);
private static void ViewportScaleOffsetEntry(RegisterBanks banks, uint offset, uint value)
{
var relative = offset - PaClViewportXScale;
ref var viewport = ref banks.Context.ScreenViewport.Viewports[relative / ViewportFieldCount];
ref var viewport = ref banks.ViewportsForWrite[relative / ViewportFieldCount];
var number = RegisterField.AsFloat(value);
switch (relative % ViewportFieldCount)
{
@ -718,7 +718,7 @@ internal static partial class RegisterWriters
private static void GuardBandEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
var number = RegisterField.AsFloat(value);
switch (offset)
{
@ -760,7 +760,7 @@ internal static partial class RegisterWriters
}
private static void BlendControlEntry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.BlendControls[offset - CbBlend0Control] = BlendRegisters.Decode(value);
banks.BlendControlsForWrite[offset - CbBlend0Control] = BlendRegisters.Decode(value);
private static void DepthZInfoEntry(RegisterBanks banks, uint offset, uint value)
{
@ -804,7 +804,7 @@ internal static partial class RegisterWriters
}
private static ref ColorTargetWords ColorTarget(RegisterBanks banks, uint offset, uint first, uint stride) =>
ref banks.Context.ColorTargets[(offset - first) / stride];
ref banks.ColorTargetsForWrite[(offset - first) / stride];
private static void ColorBaseLowEntry(RegisterBanks banks, uint offset, uint value)
{
@ -878,7 +878,7 @@ internal static partial class RegisterWriters
}
private static void ColorClearWord1Entry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.ColorClearWord1[(offset - CbColor0ClearWord1) / ColorSlotStride] = value;
banks.ColorClearWord1ForWrite[(offset - CbColor0ClearWord1) / ColorSlotStride] = value;
private static void ColorDccBaseLowEntry(RegisterBanks banks, uint offset, uint value)
{

View File

@ -199,6 +199,9 @@ public sealed class VertexStageRegisters
public UserScalarRegisters LegacyVertexUserScalars = new();
public UserScalarRegisters ExportUserScalars = new();
// The same registers sharing the user scalar sets, which a writer then copies one by one.
public VertexStageRegisters ShallowCopy() => (VertexStageRegisters)MemberwiseClone();
public VertexStageRegisters Copy()
{
var copy = (VertexStageRegisters)MemberwiseClone();
@ -217,6 +220,8 @@ public sealed class PixelStageRegisters
public PixelResource2 Resource2;
public UserScalarRegisters UserScalars = new();
public PixelStageRegisters ShallowCopy() => (PixelStageRegisters)MemberwiseClone();
public PixelStageRegisters Copy()
{
var copy = (PixelStageRegisters)MemberwiseClone();
@ -253,6 +258,8 @@ public sealed class ComputeStageRegisters
public byte SharedVectorRegisters;
public UserScalarRegisters UserScalars = new();
public ComputeStageRegisters ShallowCopy() => (ComputeStageRegisters)MemberwiseClone();
public ComputeStageRegisters Copy()
{
var copy = (ComputeStageRegisters)MemberwiseClone();
@ -264,6 +271,9 @@ public sealed class ComputeStageRegisters
// The shader bank: the program registers of every stage and their user scalars.
public sealed class ShaderProgramRegisters
{
// A bank holding the same stage objects, for a snapshot that shares them.
public ShaderProgramRegisters ShareStages() => (ShaderProgramRegisters)MemberwiseClone();
public VertexStageRegisters Vertex = new();
public PixelStageRegisters Pixel = new();
public ComputeStageRegisters Compute = new();

View File

@ -31,10 +31,13 @@ public sealed unsafe partial class CachedImage
SubresourceRange = new ImageSubresourceRange(ViewFormatRules.FullAspects(Backing.Format), baseLevel, levelCount, baseLayer, layerCount),
};
// What a state that needs no barrier returns, so the common case allocates nothing. Callers only read it.
private static readonly List<ImageMemoryBarrier2> NoBarriers = [];
// Barriers for the requested state; a repeated write always gets a barrier.
public (List<ImageMemoryBarrier2> Barriers, PipelineStageFlags SourceStages) GetBarriers(ImageLayout layout, AccessFlags access, PipelineStageFlags stage, SubresourceRange? range)
{
var barriers = new List<ImageMemoryBarrier2>();
List<ImageMemoryBarrier2>? barriers = null;
PipelineStageFlags sourceStages = 0;
if ((access & (WriteAccess | AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit)) != 0)
{
@ -83,7 +86,7 @@ public sealed unsafe partial class CachedImage
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout != layout || state.Access != access || repeatedWrite)
{
barriers.Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
(barriers ??= []).Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
sourceStages |= state.Stage;
states[index] = new ImageAccessState(stage, access, layout);
}
@ -101,15 +104,15 @@ public sealed unsafe partial class CachedImage
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout == layout && state.Access == access && !repeatedWrite)
{
return (barriers, sourceStages);
return (NoBarriers, sourceStages);
}
barriers.Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
(barriers ??= []).Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
sourceStages |= state.Stage;
}
Backing.State = new ImageAccessState(stage, access, layout);
return (barriers, sourceStages);
return (barriers ?? NoBarriers, sourceStages);
}
public void Transition(ImageLayout layout, AccessFlags access, SubresourceRange? range, CommandBuffer command)

View File

@ -303,6 +303,8 @@ public sealed unsafe class ColorToMultisampleDepthBlit : IDisposable
vk.CmdBeginRenderPass(command, &begin, SubpassContents.Inline);
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Graphics, _pipelineLayout, 0, 1, &set, 0, null);
vk.CmdBindPipeline(command, PipelineBindPoint.Graphics, cached.Pipeline);
Vulkan.GraphicsDynamicStateEpoch.Advance();
Vulkan.NativeBindingEpoch.Advance();
var viewport = new Viewport(0.0f, 0.0f, extent.Width, extent.Height, 0.0f, 1.0f);
var scissor = new Rect2D(new Offset2D(0, 0), extent);
vk.CmdSetViewport(command, 0, 1, &viewport);

View File

@ -540,8 +540,11 @@ public sealed unsafe class GpuTiler : IDisposable
return set;
}
private void BindDescriptorSet(CommandBuffer command, DescriptorSet set) =>
private void BindDescriptorSet(CommandBuffer command, DescriptorSet set)
{
_device.Vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &set, 0, null);
Vulkan.NativeBindingEpoch.Advance();
}
private void Record(bool tile, VkBuffer source, ulong sourceOffset, ulong sourceCapacity, VkBuffer target, ulong targetOffset, ulong targetCapacity, List<TransferDispatch> dispatches, bool clearTarget)
{
@ -587,6 +590,7 @@ public sealed unsafe class GpuTiler : IDisposable
infos[2] = new DescriptorBufferInfo(dispatch.ArgumentsBuffer, dispatch.ArgumentsOffset, TileTransferArguments.Size);
BindDescriptorSet(command, WriteDescriptorSet(infos, 2));
vk.CmdBindPipeline(command, PipelineBindPoint.Compute, GetPipeline(dispatch.PipelineSlot));
Vulkan.NativeBindingEpoch.Advance();
vk.CmdDispatch(command, (dispatch.Arguments.Width + 7) / 8, (dispatch.Arguments.Height + 7) / 8, dispatch.Arguments.Depth);
}
@ -697,6 +701,7 @@ public sealed unsafe class GpuTiler : IDisposable
fixed (TileTransferArguments* pointer = &arguments)
{
_device.Vk.CmdPushConstants(command, _pipelineLayout, ShaderStageFlags.ComputeBit, 0, TileTransferArguments.Size, pointer);
Vulkan.NativeBindingEpoch.Advance();
}
}
@ -743,6 +748,7 @@ public sealed unsafe class GpuTiler : IDisposable
AccessFlags.ShaderReadBit | AccessFlags.ShaderWriteBit);
VulkanSynchronization.PipelineBarrier(vk,command, PipelineStageFlags.AllCommandsBit | PipelineStageFlags.HostBit, PipelineStageFlags.ComputeShaderBit, 0, 0, null, 2, barriers, 0, null);
vk.CmdBindPipeline(command, PipelineBindPoint.Compute, pipeline);
Vulkan.NativeBindingEpoch.Advance();
var descriptorAlignment = StorageAlignment;
var groupLimit = _device.MaxComputeWorkGroupCount;
@ -830,6 +836,7 @@ public sealed unsafe class GpuTiler : IDisposable
var command = new CommandBuffer(_scheduler.Current.Handle);
VulkanSynchronization.PipelineBarrier(vk,command, PipelineStageFlags.AllCommandsBit | PipelineStageFlags.HostBit, PipelineStageFlags.ComputeShaderBit, 0, 0, null, 2, barriers, 0, null);
vk.CmdBindPipeline(command, PipelineBindPoint.Compute, _swapBgra16);
Vulkan.NativeBindingEpoch.Advance();
BindDescriptorSet(command, WriteDescriptorSet(infos, -1));
PushArguments(command, new TileTransferArguments { SourceBase = inputBinding.Base, DestinationBase = outputBinding.Base, Width = pixels });
vk.CmdDispatch(command, (pixels + 63) / 64, 1, 1);

View File

@ -35,6 +35,25 @@ public sealed partial class GuestImageCache
private void InvalidateLookups() => _lookupGeneration++;
// Changes whenever an image a lookup could find is created, removed or replaced, so a caller
// that remembers a lookup's result can tell when it may have changed.
public ulong LookupGeneration => _lookupGeneration;
// What a remembered lookup still does on reuse: the image counts as used now. False when the
// image is gone.
public bool TryTouchRemembered(ResourceSlotIdentifier imageIdentifier)
{
using var held = _lock.Hold();
if (_slots.TryGet(imageIdentifier) is not { Registered: true } image)
{
return false;
}
image.LastAccessTick = _scheduler.CurrentTick;
TouchImage(image);
return true;
}
private static int LookupSlot(in ImageRequest request, bool exactFormat)
{
var hash = HashCode.Combine(request.Description.Data.Address, request.Description.Data.Size, request.Description.PixelFormat,

View File

@ -11,6 +11,14 @@ namespace SharpEmu.Libs.Gpu.Images;
// Surface metadata (HTile, DCC, CMask, FMask) keyed by its guest address.
public sealed partial class GuestImageCache
{
private static long _dccSyncs;
private static long _dccSlicesUndecodable;
private static long _dccSlicesMixed;
private static long _dccSlicesCleared;
public static string TakeDccReport() => FormattableString.Invariant(
$"[PERF][DCC_SYNC] gpu_syncs={Interlocked.Exchange(ref _dccSyncs, 0)} cleared={Interlocked.Exchange(ref _dccSlicesCleared, 0)} mixed={Interlocked.Exchange(ref _dccSlicesMixed, 0)} undecodable={Interlocked.Exchange(ref _dccSlicesUndecodable, 0)}");
private unsafe void MaterializeDccClear(
ResourceSlotIdentifier imageIdentifier,
in ImageRequest request,
@ -96,6 +104,7 @@ public sealed partial class GuestImageCache
allowClearToRegister: request.Role == ImageRole.ColorTarget,
out var clear))
{
Interlocked.Increment(ref _dccSlicesUndecodable);
continue;
}
@ -126,9 +135,12 @@ public sealed partial class GuestImageCache
if (!uniform)
{
Interlocked.Increment(ref _dccSlicesMixed);
continue;
}
Interlocked.Increment(ref _dccSlicesCleared);
using (var held = _lock.Hold())
{
if (_slots.TryGet(imageIdentifier) == null)
@ -287,13 +299,20 @@ public sealed partial class GuestImageCache
public bool IsMetadataCleared(ulong address, uint slice) => IsMetadataCleared(address, slice, out _);
// DCC metadata of a bound target, or the range of a DCC fill seen before its target: both are
// filled on the host, which keeps them CPU-readable instead of GPU-written.
public bool OverlapsDccMetadata(ulong address, ulong size)
{
using var held = _lock.Hold();
foreach (var (start, metadata) in _surfaceMetadata)
{
if (metadata.Kind == SurfaceMetadataKind.Dcc && metadata.Size != 0 &&
address < start + metadata.Size && start < address + size)
var extent = metadata.Kind switch
{
SurfaceMetadataKind.Dcc => metadata.Size,
SurfaceMetadataKind.PendingDcc => metadata.FillSize,
_ => 0UL,
};
if (extent != 0 && address < start + extent && start < address + size)
{
return true;
}
@ -416,6 +435,12 @@ public sealed partial class GuestImageCache
var size = (ulong)layerCount * sliceSize;
if (IsValidRange(address, size) && _bufferCache.HasGpuDirtyBytes(address, size))
{
Interlocked.Increment(ref _dccSyncs);
if (Diagnostics.DccWriterTrace.Enabled)
{
Diagnostics.DccWriterTrace.ReportSync(address, size);
}
_ = _bufferCache.TrySynchronizeCpuRead(address, size);
}
}

View File

@ -761,6 +761,7 @@ public sealed unsafe partial class GuestImageCache
_scheduler.QueuePriorityCompletionAction(() =>
{
download.Invalidate(offset, range.Size);
GpuWritebackEpoch.Advance();
if (!backing.TryWriteBacking(range.Address, download.Mapped.Slice((int)offset, (int)range.Size)))
{
throw SubmissionScheduler.Fatal($"The image readback could not be written to guest memory: address=0x{range.Address:X16} size=0x{range.Size:X}.");
@ -833,7 +834,7 @@ public sealed unsafe partial class GuestImageCache
byte stencilClear = 0;
if (aspect == ImageAspectFlags.ColorBit)
{
if (!PackedClearValue.TryDecodeColor(image.Description.PixelFormat, packedClear, out colorClear))
if (!PackedClearValue.TryDecodeBufferColor(image.Description.PixelFormat, packedClear, out colorClear))
{
return false;
}

View File

@ -125,7 +125,12 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
var metadataBaseLayer = request.View.BaseLayer;
var result = LookUpImage(ref request, exactFormat);
CompleteLookup(result, in request, metadataBaseLayer);
return result;
}
public void CompleteLookup(ResourceSlotIdentifier result, in ImageRequest request, uint metadataBaseLayer)
{
if (request.Role is ImageRole.Texture or ImageRole.StorageImage)
{
ulong metadataAddress;
@ -163,8 +168,6 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
$"A compressed display surface can only be read from clean native GPU contents: address=0x{image.Description.Data.Address:X16} bufferModified={image.IsBufferModified} cpuDirty={image.IsCpuDirty} gpuModified={image.IsGpuModified}.");
}
}
return result;
}
private ResourceSlotIdentifier LookUpImage(ref ImageRequest request, bool exactFormat)

View File

@ -597,6 +597,30 @@ public static class PackedClearValue
_ => 0,
};
// A buffer fill repeats the word in every texel, unlike a DCC clear register.
public static bool TryDecodeBufferColor(Format format, uint packed, out ClearColorValue clear)
{
clear = default;
switch (format)
{
case Format.R16G16Sfloat:
case Format.R16G16B16A16Sfloat:
var low = BitConverter.UInt16BitsToHalf((ushort)packed);
var high = BitConverter.UInt16BitsToHalf((ushort)(packed >> 16));
// A float conversion may change a NaN payload; keep the raw fill in that case.
if (Half.IsNaN(low) || Half.IsNaN(high)) return false;
clear.Float32_0 = clear.Float32_2 = (float)low;
clear.Float32_1 = clear.Float32_3 = (float)high;
return true;
case Format.R32G32Uint:
case Format.R32G32B32A32Uint:
clear.Uint32_0 = clear.Uint32_1 = clear.Uint32_2 = clear.Uint32_3 = packed;
return true;
default:
return TryDecodeColor(format, packed, out clear);
}
}
public static bool TryDecodeColor(Format format, uint packed, out ClearColorValue clear)
{
static float Unorm8(uint value) => (value & 0xff) / 255.0f;

View File

@ -76,8 +76,56 @@ public static partial class ImageRequestBuilders
public static bool SupportsDccFixedClear(Format format) => PackedClearValue.SupportsDccFixedColor(format);
// Every draw rebuilds its targets' requests (a whole mip layout) from the same register words,
// twice: once for the attachments and once for the pixel outputs. The build reads nothing but
// its arguments, so a result is reused while they are equal.
private struct ColorTargetMemo
{
public bool Valid;
public ColorTargetWords Words;
public uint TargetMask;
public uint DrawLayerOffset;
public bool IgnoreTargetMask;
public ColorTargetResolution? Result;
}
private const int TargetMemoCount = 8;
[ThreadStatic]
private static ColorTargetMemo[]? _colorTargetMemos;
private static int TargetMemoSlot(ulong address) => (int)((address >> 12) ^ (address >> 20)) & (TargetMemoCount - 1);
// Builds the request for a bound color target. Null when the slot carries no target.
public static ColorTargetResolution? ColorTarget(in ColorTargetWords words, uint targetMask, uint drawLayerOffset, bool ignoreTargetMask)
{
if (Rendering.RenderTrace.Enabled)
{
return ColorTargetCore(in words, targetMask, drawLayerOffset, ignoreTargetMask);
}
var memos = _colorTargetMemos ??= new ColorTargetMemo[TargetMemoCount];
ref var memo = ref memos[TargetMemoSlot(words.BaseAddress)];
if (memo.Valid && memo.TargetMask == targetMask && memo.DrawLayerOffset == drawLayerOffset &&
memo.IgnoreTargetMask == ignoreTargetMask && memo.Words.Equals(words))
{
return memo.Result;
}
var result = ColorTargetCore(in words, targetMask, drawLayerOffset, ignoreTargetMask);
memo = new ColorTargetMemo
{
Valid = true,
Words = words,
TargetMask = targetMask,
DrawLayerOffset = drawLayerOffset,
IgnoreTargetMask = ignoreTargetMask,
Result = result,
};
return result;
}
private static ColorTargetResolution? ColorTargetCore(in ColorTargetWords words, uint targetMask, uint drawLayerOffset, bool ignoreTargetMask)
{
var mask = targetMask & 0xF;
if (ignoreTargetMask && words.BaseAddress != 0 && mask == 0)
@ -230,11 +278,12 @@ public static partial class ImageRequestBuilders
}
else
{
pitch = width;
// A linear color buffer uses the same 256-byte row alignment as a linear texture of the same memory;
// an unaligned pitch made the target and a later texture view of it two different cached images.
pitch = TileGeometry.TexturePitch(transferFormat, width, GuestTileMode.Linear);
}
var mipSpans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var mipPadded = new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
var (mipSpans, mipPadded) = MipLayoutScratch.Rent();
TiledSurfaceLayout? volumeLayout = null;
ulong size;
ulong backingSize = 0;

View File

@ -59,8 +59,28 @@ public static partial class ImageRequestBuilders
}
// Builds the request for the bound depth target. Null when no depth or stencil state is active.
public static DepthTargetResolution? DepthTarget(in DepthTargetWords depthWords, IImageFormatSupport device) =>
DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
// Reused while the words and the device are the same, as ColorTarget's requests are.
public static DepthTargetResolution? DepthTarget(in DepthTargetWords depthWords, IImageFormatSupport device)
{
if (Rendering.RenderTrace.Enabled)
{
return DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
}
if (_depthTargetMemo is { } memo && ReferenceEquals(memo.Device, device) && memo.Words.Equals(depthWords))
{
return memo.Result;
}
var result = DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
_depthTargetMemo = new DepthTargetMemo(depthWords, device, result);
return result;
}
private sealed record DepthTargetMemo(DepthTargetWords Words, IImageFormatSupport Device, DepthTargetResolution? Result);
[ThreadStatic]
private static DepthTargetMemo? _depthTargetMemo;
// Builds the source or destination image used by DB_RENDER_OVERRIDE depth/stencil copies.
public static DepthTargetResolution? DepthTargetCopy(in DepthTargetWords depthWords, IImageFormatSupport device, bool writeBuffer) =>

View File

@ -89,8 +89,7 @@ public static partial class ImageRequestBuilders
return;
}
var spans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var padded = new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
var (spans, padded) = MipLayoutScratch.Rent();
TileGeometry.TryGetTextureSize(
description.GuestFormat, description.Extent.Width, description.Extent.Height, description.Resources.Levels, description.TileMode,
out _, spans, padded);

View File

@ -11,6 +11,10 @@ public sealed class MipStatistics
public const uint FirstCounterOffset = 64;
public const int ReportBytes = (int)FirstCounterOffset + SlotCount * CounterBytes;
public const uint NoMipSampled = 15;
public const uint CounterWords = 257;
public const ulong CounterBufferBytes = 2 * CounterWords * sizeof(uint);
public const uint NoCounterField = 0x8000;
public const uint ReportWithoutCounters = 1;
private const int CounterBytes = 2 * sizeof(uint);
private const uint FinestMip = 0;
@ -29,6 +33,53 @@ public sealed class MipStatistics
}
}
public static uint ImageField(ReadOnlySpan<uint> texture)
{
if (((texture[5] >> 25) & 1) == 0)
{
return NoCounterField;
}
var counter = texture[6] & 0xFF;
var baseLevel = (texture[3] >> 12) & 0xF;
var minLod = (texture[1] >> 8) & 0xFFF;
return counter | (baseLevel << 8) | (minLod << 16);
}
public static void PackReport(ReadOnlySpan<uint> words, Span<byte> report)
{
report.Clear();
if (report.Length >= sizeof(uint))
{
BinaryPrimitives.WriteUInt32LittleEndian(report, FirstCounterOffset);
}
for (var slot = 0; slot < SlotCount; slot++)
{
var start = (int)FirstCounterOffset + slot * CounterBytes;
if (start + CounterBytes > report.Length)
{
break;
}
var finest = words[slot];
var count = Math.Min(words[(int)CounterWords + slot], MaxSamples);
var level = finest == uint.MaxValue ? NoMipSampled : Math.Min(finest, 14u);
var counter = report.Slice(start, CounterBytes);
BinaryPrimitives.WriteUInt32LittleEndian(counter, count | ((uint)slot << 24));
BinaryPrimitives.WriteUInt32LittleEndian(counter[sizeof(uint)..], level << 24);
}
}
public void WriteReport(Span<byte> report)
{
var hasCounters = TryWriteReport(report);
if (report.Length >= sizeof(uint))
{
BinaryPrimitives.WriteUInt32LittleEndian(report, hasCounters ? FirstCounterOffset : ReportWithoutCounters);
}
}
public bool TryWriteReport(Span<byte> report)
{
if (report.Length < ReportBytes)

View File

@ -48,7 +48,7 @@ public enum SamplerBorderColor : uint
public sealed unsafe class SamplerStore : IDisposable
{
private readonly GpuDeviceInfo _device;
private readonly Dictionary<(uint, uint, uint, uint, bool), Sampler> _samplers = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<(uint, uint, uint, uint, bool), Sampler> _samplers = new();
private readonly object _gate = new();
public SamplerStore(GpuDeviceInfo device) => _device = device;
@ -57,16 +57,22 @@ public sealed unsafe class SamplerStore : IDisposable
public Sampler GetSampler(in SamplerDescriptorWords words, bool integerView)
{
var key = (words[0], words[1], words[2], words[3], integerView);
// Every sampler binding of every draw asks; only the first of each kind creates.
if (_samplers.TryGetValue(key, out var found))
{
return found;
}
lock (_gate)
{
var key = (words[0], words[1], words[2], words[3], integerView);
if (_samplers.TryGetValue(key, out var existing))
{
return existing;
}
var sampler = CreateSampler(words, integerView);
_samplers.Add(key, sampler);
_samplers[key] = sampler;
return sampler;
}
}

View File

@ -233,8 +233,7 @@ public sealed class TextureTransferLayout
layout.Pitch = TileGeometry.TexturePitch(format, width, tile);
if (tile == GuestTileMode.Linear)
{
var levelSpans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var paddedSizes = new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
var (levelSpans, paddedSizes) = MipLayoutScratch.Rent();
TileGeometry.TryGetTextureSize(format, width, height, levels, tile, out _, levelSpans, paddedSizes);
for (uint level = 0; level < levels; level++)
{

View File

@ -21,6 +21,24 @@ public struct TileLevelSpan
public readonly record struct TilePaddedSize(uint Width, uint Height);
// The per-level output arrays of the layout queries, kept per thread: every draw that resolves a
// target or a texture needs them for a moment and reads them before the next query.
internal static class MipLayoutScratch
{
[ThreadStatic] private static TileLevelSpan[]? _spans;
[ThreadStatic] private static TilePaddedSize[]? _padded;
// Zeroed, as a new array would be, so a level the query leaves out reads as empty.
public static (TileLevelSpan[] Spans, TilePaddedSize[] Padded) Rent()
{
var spans = _spans ??= new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var padded = _padded ??= new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
Array.Clear(spans);
Array.Clear(padded);
return (spans, padded);
}
}
public enum TileBlockKind : uint
{
Standard256B,

View File

@ -28,7 +28,8 @@ public static class DescriptorWriter
{
DescriptorBindingKind.Samplers => Silk.NET.Vulkan.DescriptorType.Sampler,
DescriptorBindingKind.Buffers or DescriptorBindingKind.GlobalDataShare or DescriptorBindingKind.DeviceAddressPageTable or
DescriptorBindingKind.FaultBuffer or DescriptorBindingKind.FlattenedResourceTable or DescriptorBindingKind.ShaderData =>
DescriptorBindingKind.FaultBuffer or DescriptorBindingKind.FlattenedResourceTable or DescriptorBindingKind.ShaderData or
DescriptorBindingKind.MipStatistics =>
Silk.NET.Vulkan.DescriptorType.StorageBuffer,
_ => throw SubmissionScheduler.Fatal($"The descriptor binding kind is invalid: kind={kind}."),
};

View File

@ -36,6 +36,13 @@ internal interface IShaderPipelineHost
bool ComputeWave64Supported { get; }
// True when a 64-invocation wave64 compute workgroup runs as one 64-lane host subgroup.
bool ComputeWave64SubgroupNative => false;
bool ComputeRunsOn32LaneSubgroups(ComputeInputInfo input) => false;
bool MipStatisticsSubgroupReduction => false;
bool GraphicsSubgroupOperationsEnabled { get; }
// The device supports shaderSharedInt64Atomics, so LDS 64-bit atomics can be

View File

@ -45,6 +45,13 @@ public sealed class PipelineStaticParameters : IEquatable<PipelineStaticParamete
public PipelineStaticParameters()
{
Reset();
}
// The state of a new instance, for a lookup object reused across draws.
public void Reset()
{
Array.Clear(_bytes);
DepthClipEnable = true;
Samples = 1;
ColorCount = 1;
@ -146,6 +153,15 @@ public sealed class PipelineRenderingState : IEquatable<PipelineRenderingState>
public Format StencilFormat { get; set; } = Format.Undefined;
public uint ColorCount { get; set; }
// The state of a new instance, for a lookup object reused across draws.
public void Reset()
{
Array.Clear(ColorFormats);
DepthFormat = Format.Undefined;
StencilFormat = Format.Undefined;
ColorCount = 0;
}
public bool Equals(PipelineRenderingState? other) =>
other is not null && ColorCount == other.ColorCount && DepthFormat == other.DepthFormat && StencilFormat == other.StencilFormat &&
ColorFormats.AsSpan().SequenceEqual(other.ColorFormats);
@ -179,6 +195,15 @@ public sealed class PipelineVertexInputState : IEquatable<PipelineVertexInputSta
public byte BindingCount { get; set; }
public byte AttributeCount { get; set; }
// The state of a new instance, for a lookup object reused across draws.
public void Reset()
{
Array.Clear(Bindings);
Array.Clear(Attributes);
BindingCount = 0;
AttributeCount = 0;
}
public bool Equals(PipelineVertexInputState? other) =>
other is not null && BindingCount == other.BindingCount && AttributeCount == other.AttributeCount &&
Bindings.AsSpan().SequenceEqual(other.Bindings) && Attributes.AsSpan().SequenceEqual(other.Attributes);
@ -207,8 +232,9 @@ public sealed class PipelineVertexInputState : IEquatable<PipelineVertexInputSta
public sealed class GraphicsPipelineKey : IEquatable<GraphicsPipelineKey>
{
public required PipelineRenderingState Rendering { get; init; }
public ulong VertexProgramId { get; init; }
public ulong PixelProgramId { get; init; }
// Settable for the lookup key reused across draws; a key in the cache is never changed.
public ulong VertexProgramId { get; set; }
public ulong PixelProgramId { get; set; }
public required PipelineVertexInputState VertexInput { get; init; }
public required PipelineStaticParameters StaticParameters { get; init; }

View File

@ -27,6 +27,9 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
private readonly Dictionary<GraphicsPipelineKey, PipelineHandle> _graphicsPipelines = new();
private readonly Dictionary<ComputePipelineKey, PipelineHandle> _computePipelines = new();
private readonly object _gate = new();
// The pipeline maps: a lookup takes this lock or _gate, an addition both; a cached draw so does
// not wait for the program preparation a resolving thread holds _gate for.
private readonly object _pipelineGate = new();
private readonly bool _strictShaders = Environment.GetEnvironmentVariable("SHARPEMU_STRICT_COMPUTE") != "0";
private readonly HashSet<(ShaderStage Stage, ulong Hash, uint CodeSize)> _reportedShaderSkips = [];
@ -70,6 +73,11 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
return new ShaderSource(registered, hash, userData, userDataBase, stage);
}
// Program resolution runs on the render thread and on a thread resolving draws ahead of it;
// the program, decode and materialization caches it updates are used under this lock only.
// A pipeline lookup takes _pipelineGate instead, so it does not wait for a resolution.
private readonly object _resolveGate = new();
public GraphicsPrograms GetGraphicsPrograms(
VertexStageRegisters vertex,
PixelStageRegisters pixel,
@ -78,6 +86,21 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
ReadOnlySpan<ColorComponentMap> targetExportMapping,
bool pixelActive,
bool depthBound)
{
lock (_resolveGate)
{
return GetGraphicsProgramsCore(vertex, pixel, shaderInterface, context, targetExportMapping, pixelActive, depthBound);
}
}
private GraphicsPrograms GetGraphicsProgramsCore(
VertexStageRegisters vertex,
PixelStageRegisters pixel,
ShaderInterfaceRegisters shaderInterface,
ContextRegisters context,
ReadOnlySpan<ColorComponentMap> targetExportMapping,
bool pixelActive,
bool depthBound)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramPreparation);
var vertexSource = PrepareSource(
@ -209,44 +232,53 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
shaderInterface.VertexOutputControl, clipSpace);
}
// One past the highest parameter location the pixel program reads, resolved as its translator does.
private static uint ReadVertexOutputCount(Gen5ShaderProgram pixelProgram, PixelInputInfo info)
{
var attributes = pixelProgram.Instructions
// The interpolated attributes a pixel program reads, ascending. A program never changes, and this
// was rebuilt with LINQ on every draw.
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<Gen5ShaderProgram, uint[]> InterpolatedAttributeCache = new();
private static uint[] InterpolatedAttributesOf(Gen5ShaderProgram program) =>
InterpolatedAttributeCache.GetValue(program, static program => program.Instructions
.Select(static instruction => instruction.Control)
.OfType<Gen5InterpolationControl>()
.Select(static control => control.Attribute)
.Distinct()
.Order()
.ToArray();
.ToArray());
// One past the highest parameter location the pixel program reads, resolved as its translator does.
private static uint ReadVertexOutputCount(Gen5ShaderProgram pixelProgram, PixelInputInfo info)
{
var attributes = InterpolatedAttributesOf(pixelProgram);
if (attributes.Length == 0)
{
return 0;
}
var controls = new uint[32];
Span<uint> controls = stackalloc uint[32];
for (var index = 0u; index < (uint)controls.Length; index++)
{
controls[index] = index < info.InputCount && index < (uint)info.InterpolatorSettings.Length
controls[(int)index] = index < info.InputCount && index < (uint)info.InterpolatorSettings.Length
? info.InterpolatorSettings[index]
: index;
}
return Gen5PixelInputMapping.ResolveLocations(controls, attributes).Max() + 1;
}
private static uint InterpolatedAttributeCount(Gen5ShaderProgram program)
{
var maxAttribute = -1;
foreach (var instruction in program.Instructions)
// Attributes are distinct and below 32, so a program never has more than 32 of them.
Span<uint> locations = attributes.Length <= 32 ? stackalloc uint[attributes.Length] : new uint[attributes.Length];
Gen5PixelInputMapping.ResolveLocations(controls, attributes, locations);
var highest = 0u;
foreach (var location in locations)
{
if (instruction.Control is Gen5InterpolationControl interpolation)
{
maxAttribute = Math.Max(maxAttribute, (int)interpolation.Attribute);
}
highest = Math.Max(highest, location);
}
return (uint)(maxAttribute + 1);
return highest + 1;
}
// The attribute list is ascending, so its last entry is the highest.
private static uint InterpolatedAttributeCount(Gen5ShaderProgram program)
{
var attributes = InterpolatedAttributesOf(program);
return attributes.Length == 0 ? 0 : attributes[^1] + 1;
}
// The bound colour slots in order; each output mode names the kind the pixel program exports.
@ -321,6 +353,20 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
uint dimensionX,
uint dimensionY,
uint dimensionZ)
{
lock (_resolveGate)
{
return GetComputeProgramCore(compute, shaderInterface, dispatchInitiator, dimensionX, dimensionY, dimensionZ);
}
}
private ComputeProgram GetComputeProgramCore(
ComputeStageRegisters compute,
ShaderInterfaceRegisters shaderInterface,
uint dispatchInitiator,
uint dimensionX,
uint dimensionY,
uint dimensionZ)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramPreparation);
var source = PrepareSource(compute.Address, ShaderStage.Compute, "compute", compute.UserScalars, compute.UserScalarCount, probeWrittenRegisters: false, userDataBase: 0);
@ -417,6 +463,28 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
ShaderProgram pixelProgram)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.PipelineCreation);
// The lookup fills objects this thread reuses for every draw; only a new pipeline gets its own.
var lookup = _graphicsLookup ??= new GraphicsPipelineKey
{
Rendering = new PipelineRenderingState(),
VertexInput = new PipelineVertexInputState(),
StaticParameters = new PipelineStaticParameters(),
};
lookup.Rendering.Reset();
lookup.VertexInput.Reset();
lookup.StaticParameters.Reset();
FillGraphicsState(colors, in depth, vertexInput, pixelInput, context, in rendering, topology, primitiveRestartEnabled, disableBlending,
vertexProgram, pixelProgram, _host.NoAttachmentSampleCounts, lookup.Rendering, lookup.StaticParameters, lookup.VertexInput);
lookup.VertexProgramId = vertexProgram.Id;
lookup.PixelProgramId = pixelInput is not null ? pixelProgram.Id : 0;
lock (_pipelineGate)
{
if (_graphicsPipelines.TryGetValue(lookup, out var cached))
{
return cached;
}
}
var description = BuildGraphicsDescription(
colors, in depth, vertexInput, pixelInput, context, in rendering, topology, primitiveRestartEnabled, disableBlending,
vertexProgram, pixelProgram, _host.NoAttachmentSampleCounts);
@ -437,12 +505,19 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
}
var created = _host.CreateGraphicsPipeline(description);
_graphicsPipelines.Add(key, created);
lock (_pipelineGate)
{
_graphicsPipelines.Add(key, created);
}
ShaderCacheCounters.CountGraphicsPipeline();
return created;
}
}
[ThreadStatic]
private static GraphicsPipelineKey? _graphicsLookup;
// The full static state of one graphics pipeline from the draw's targets, registers and programs.
internal static GraphicsPipelineDescription BuildGraphicsDescription(
ReadOnlySpan<ColorTargetState> colors,
@ -457,6 +532,43 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
ShaderProgram vertexProgram,
ShaderProgram pixelProgram,
SampleCountFlags noAttachmentSampleCounts)
{
var renderingState = new PipelineRenderingState();
var parameters = new PipelineStaticParameters();
var vertexState = new PipelineVertexInputState();
var (vertexStage, pixelStage) = FillGraphicsState(colors, in depth, vertexInput, pixelInput, context, in rendering, topology,
primitiveRestartEnabled, disableBlending, vertexProgram, pixelProgram, noAttachmentSampleCounts, renderingState, parameters, vertexState);
return new GraphicsPipelineDescription
{
Rendering = renderingState,
VertexInput = vertexState,
VertexInfo = vertexInput,
VertexProgram = vertexProgram,
VertexStage = vertexStage,
PixelInfo = pixelInput,
PixelProgram = pixelProgram,
PixelStage = pixelStage,
StaticParameters = parameters,
};
}
// Writes the pipeline state into objects in their initial state; returns the stage programs.
private static (ShaderProgramInfo VertexStage, ShaderProgramInfo? PixelStage) FillGraphicsState(
ReadOnlySpan<ColorTargetState> colors,
in DepthAttachmentState depth,
VertexInputInfo vertexInput,
PixelInputInfo? pixelInput,
ContextRegisters context,
in RenderingState rendering,
PrimitiveTopology topology,
bool primitiveRestartEnabled,
bool disableBlending,
ShaderProgram vertexProgram,
ShaderProgram pixelProgram,
SampleCountFlags noAttachmentSampleCounts,
PipelineRenderingState renderingState,
PipelineStaticParameters parameters,
PipelineVertexInputState vertexState)
{
if (colors.Length > PipelineStaticParameters.ColorAttachmentCount)
{
@ -477,8 +589,8 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
var vertexStage = vertexInput.Stage.Program ?? throw SubmissionScheduler.Fatal("The vertex stage has no program.");
var pixelStage = pixelActive ? pixelInput!.Stage.Program ?? throw SubmissionScheduler.Fatal("The pixel stage has no program.") : null;
var colorCount = (uint)colors.Length;
var parameters = new PipelineStaticParameters { ColorCount = colorCount };
var renderingState = new PipelineRenderingState { ColorCount = colorCount };
parameters.ColorCount = colorCount;
renderingState.ColorCount = colorCount;
for (var index = 0; index < colors.Length; index++)
{
ref readonly var color = ref colors[index];
@ -564,18 +676,8 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
parameters.SetBlendBypass(index, ((context.ColorTargets[slot].Info >> 16) & 0x1) != 0);
}
return new GraphicsPipelineDescription
{
Rendering = renderingState,
VertexInput = BuildVertexInputState(vertexInput),
VertexInfo = vertexInput,
VertexProgram = vertexProgram,
VertexStage = vertexStage,
PixelInfo = pixelInput,
PixelProgram = pixelProgram,
PixelStage = pixelStage,
StaticParameters = parameters,
};
FillVertexInputState(vertexInput, vertexState);
return (vertexStage, pixelStage);
}
internal static GraphicsPipelineKey KeyOf(GraphicsPipelineDescription description) => new()
@ -587,18 +689,15 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
StaticParameters = description.StaticParameters,
};
private static PipelineVertexInputState BuildVertexInputState(VertexInputInfo info)
private static void FillVertexInputState(VertexInputInfo info, PipelineVertexInputState state)
{
if (info.Buffers.Length > VertexInputInfo.MaxBuffers || info.Attributes.Length > VertexInputInfo.MaxBuffers)
{
throw SubmissionScheduler.Fatal($"The vertex input is too large: buffers={info.Buffers.Length} attributes={info.Attributes.Length}.");
}
var state = new PipelineVertexInputState
{
BindingCount = (byte)info.Buffers.Length,
AttributeCount = (byte)info.Attributes.Length,
};
state.BindingCount = (byte)info.Buffers.Length;
state.AttributeCount = (byte)info.Attributes.Length;
for (var binding = 0; binding < info.Buffers.Length; binding++)
{
var buffer = info.Buffers[binding];
@ -615,8 +714,6 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
state.Attributes[index] = new PipelineVertexAttribute(attribute.OffsetBytes, (byte)attribute.BufferIndex);
}
return state;
}
public PipelineHandle CreateComputePipeline(ComputeInputInfo input, ShaderProgram program)
@ -629,6 +726,14 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
var stage = input.Stage.Program ?? throw SubmissionScheduler.Fatal("The compute stage has no program.");
var key = new ComputePipelineKey(program.Id);
lock (_pipelineGate)
{
if (_computePipelines.TryGetValue(key, out var cached))
{
return cached;
}
}
lock (_gate)
{
if (_computePipelines.TryGetValue(key, out var cached))
@ -642,7 +747,11 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
}
var created = _host.CreateComputePipeline(new ComputePipelineDescription { Input = input, Program = program, Stage = stage });
_computePipelines.Add(key, created);
lock (_pipelineGate)
{
_computePipelines.Add(key, created);
}
ShaderCacheCounters.CountComputePipeline();
return created;
}
@ -659,6 +768,15 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
var stage = input.Stage.Program ?? throw SubmissionScheduler.Fatal("The compute stage has no program.");
var key = new ComputePipelineKey(program.Id);
lock (_pipelineGate)
{
if (_computePipelines.TryGetValue(key, out var cached))
{
handle = cached;
return true;
}
}
lock (_gate)
{
if (_computePipelines.TryGetValue(key, out var cached))
@ -674,7 +792,11 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
return false;
}
_computePipelines.Add(key, created);
lock (_pipelineGate)
{
_computePipelines.Add(key, created);
}
ShaderCacheCounters.CountComputePipeline();
handle = created;
return true;

View File

@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Gpu.Scheduling;
@ -68,6 +69,37 @@ public sealed class ProgramKey : IEquatable<ProgramKey>
public override int GetHashCode() => HashCode.Combine(Stage, Hash, UserDataCount, CodeSize, StaticState.Length);
}
// A program key as a lookup sees it: the static state words stay in the caller's list, so a draw
// that finds its program does not build a key object or copy the words.
internal readonly ref struct ProgramKeyProbe(ShaderStage stage, ulong hash, uint userDataCount, uint codeSize, ReadOnlySpan<uint> staticState)
{
public readonly ShaderStage Stage = stage;
public readonly ulong Hash = hash;
public readonly uint UserDataCount = userDataCount;
public readonly uint CodeSize = codeSize;
public readonly ReadOnlySpan<uint> StaticState = staticState;
}
internal sealed class ProgramKeyComparer : IEqualityComparer<ProgramKey>, IAlternateEqualityComparer<ProgramKeyProbe, ProgramKey>
{
public static readonly ProgramKeyComparer Instance = new();
public bool Equals(ProgramKey? x, ProgramKey? y) => x is null ? y is null : x.Equals(y);
public int GetHashCode(ProgramKey key) => key.GetHashCode();
public bool Equals(ProgramKeyProbe alternate, ProgramKey key) =>
alternate.Stage == key.Stage && alternate.Hash == key.Hash && alternate.UserDataCount == key.UserDataCount &&
alternate.CodeSize == key.CodeSize && alternate.StaticState.SequenceEqual(key.StaticState);
// The same value as ProgramKey.GetHashCode.
public int GetHashCode(ProgramKeyProbe alternate) =>
HashCode.Combine(alternate.Stage, alternate.Hash, alternate.UserDataCount, alternate.CodeSize, alternate.StaticState.Length);
public ProgramKey Create(ProgramKeyProbe alternate) =>
new(alternate.Stage, alternate.Hash, alternate.UserDataCount, alternate.CodeSize, alternate.StaticState.ToArray());
}
// One compiled module of a program entry for one specialization and push-data start.
internal sealed class ProgramPermutation
{
@ -102,7 +134,7 @@ internal sealed class ShaderProgramCache
private readonly CpuContext _context;
private readonly IGuestGpuBackend _compiler;
private readonly IShaderPipelineHost _host;
private readonly Dictionary<ProgramKey, ProgramSourceEntry> _programs = new();
private readonly Dictionary<ProgramKey, ProgramSourceEntry> _programs = new(ProgramKeyComparer.Instance);
private readonly Dictionary<(ulong Hash, uint CodeSize), Gen5ShaderProgram> _decoded = new();
private readonly Dictionary<(ulong Hash, uint CodeSize), ShaderCodeCapture> _codeCaptures = new();
private readonly List<uint> _staticState = new(StageStaticKey.MaxWords);
@ -216,13 +248,14 @@ internal sealed class ShaderProgramCache
private ShaderProgram GetOrCompileCore(ShaderSource source, StageCompileOptions options, ref uint pushDataCursor, out ShaderStageResources stage)
{
ProgramKey key;
ProgramKey? key = null;
ProgramSourceEntry? entry;
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramCacheLookup))
{
BuildStaticState(source.Stage, options);
key = new ProgramKey(source.Stage, source.Hash, (uint)source.UserData.Length, source.CodeSize, _staticState.ToArray());
_programs.TryGetValue(key, out entry);
var probe = new ProgramKeyProbe(source.Stage, source.Hash, (uint)source.UserData.Length, source.CodeSize,
CollectionsMarshal.AsSpan(_staticState));
_programs.GetAlternateLookup<ProgramKeyProbe>().TryGetValue(probe, out entry);
}
var sourceWasCached = entry is not null;
if (RenderTrace.Enabled && RenderTrace.Pipeline())
@ -246,12 +279,14 @@ internal sealed class ShaderProgramCache
if (entry is null)
{
entry = CreateEntry(source, options);
key = BuildProgramKey(source);
_programs.Add(key, entry);
ShaderCacheCounters.CountProgram();
}
var snapshot = new ResourceSnapshot();
var specialization = new ResourceSpecialization();
// Both are assigned by the materialization that follows; a failed one throws.
ResourceSnapshot snapshot = null!;
ResourceSpecialization specialization = null!;
var captureIndirectImageFailure = _spirvDumpEnabled ? ShaderPermutationDump.CreateFailureCapture(source) : null;
if (Diagnostics.GpuReadTrace.Enabled)
{
@ -295,7 +330,7 @@ internal sealed class ShaderProgramCache
}
}
var permutation = CompilePermutation(source, options, entry, specialization, pushDataCursor, key, sourceWasCached);
var permutation = CompilePermutation(source, options, entry, specialization, pushDataCursor, key ?? BuildProgramKey(source), sourceWasCached);
entry.Permutations.Add(permutation);
ShaderCacheCounters.CountPermutation();
stage = CreateStageResources(permutation.Program, snapshot, source, options);
@ -308,6 +343,10 @@ internal sealed class ShaderProgramCache
return permutation.Handle;
}
// The key of the entry the static state was just built for.
private ProgramKey BuildProgramKey(ShaderSource source) =>
new(source.Stage, source.Hash, (uint)source.UserData.Length, source.CodeSize, _staticState.ToArray());
private static ShaderStageResources CreateStageResources(
ShaderProgramInfo program, ResourceSnapshot snapshot, ShaderSource source, StageCompileOptions options)
{
@ -641,12 +680,14 @@ internal sealed class ShaderProgramCache
SupportsPerVertexPixelInputs = _host.PerVertexPixelInputsSupported,
PixelInputAddress = options.PixelInputAddress,
PixelInputCntl = interpolators,
MipStatisticsSubgroupReduction = _host.MipStatisticsSubgroupReduction,
};
}
default:
return BuildComputeRequest(entry.Plan, resources, layout, options.ComputeInfo!, options.ComputeSystemRegisters,
sharedInt64Atomics, _host.ExecGuardElisionEnabled, signedZeroInfNanPreserve);
sharedInt64Atomics, _host.ExecGuardElisionEnabled, signedZeroInfNanPreserve, _host.ComputeWave64SubgroupNative,
_host.ComputeRunsOn32LaneSubgroups(options.ComputeInfo!));
}
}
@ -663,10 +704,12 @@ internal sealed class ShaderProgramCache
private static ShaderCompileRequest BuildComputeRequest(ShaderResourcePlan plan, SpecializedResourceInfo resources, BindingLayout layout,
ComputeInputInfo info, Gen5ComputeSystemRegisters? systemRegisters, bool sharedInt64Atomics, bool execGuardElision,
bool signedZeroInfNanPreserve) =>
bool signedZeroInfNanPreserve, bool hostWave64Supported = false, bool hostComputeSubgroup32 = false) =>
new(plan, resources, layout)
{
WaveSize = info.WaveSize,
HostWave64Supported = info.WaveSize == 64 && hostWave64Supported,
HostComputeSubgroup32 = info.WaveSize == 64 && !hostWave64Supported && hostComputeSubgroup32,
EnableExecGuardElision = info.WaveSize != 64 || execGuardElision,
TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled,
ScratchDwords = info.ScratchDwords,
@ -681,7 +724,7 @@ internal sealed class ShaderProgramCache
internal static bool TryCompilePrewarm(ComputePrewarmRecord record, ShaderCodeCapture code, IGuestGpuBackend compiler,
bool sharedInt64Atomics, bool execGuardElision, bool signedZeroInfNanPreserve, out IGuestCompiledShader? compiled,
out BindingLayout? layout, out string error)
out BindingLayout? layout, out string error, bool hostWave64Supported = false, bool hostComputeSubgroup32 = false)
{
compiled = null;
layout = null;
@ -698,7 +741,7 @@ internal sealed class ShaderProgramCache
layout = AllocateLayout(program, plan, resources, record.UserDataBase, record.UserDataCount, record.PushDataCursor,
record.Info.DispatchThreadDimensions);
var request = BuildComputeRequest(plan, resources, layout, record.Info, record.SystemRegisters,
sharedInt64Atomics, execGuardElision, signedZeroInfNanPreserve);
sharedInt64Atomics, execGuardElision, signedZeroInfNanPreserve, hostWave64Supported, hostComputeSubgroup32);
return compiler.TryCompileProgram(request, out compiled, out error) && compiled is not null;
}
catch (Exception exception)

View File

@ -0,0 +1,282 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using SharpEmu.Libs.Gpu.Scheduling;
namespace SharpEmu.Libs.Gpu.Rendering;
// Resolves the programs of deferred draws on a worker thread, in issue order, while the render
// thread interprets and records. The render thread takes a result for the draw it runs only when
// the resolution completed, used the inputs the draw derives and still matches the host's
// GPU-ownership state; otherwise it resolves the draw itself.
public sealed class ParallelProgramPrefetch : IProgramPrefetch, IDisposable
{
public sealed class Job(RegisterBanks banks, long gate)
{
internal readonly RegisterBanks Banks = banks;
// The count of stream items other than draws that must have run before the job resolves.
internal readonly long Gate = gate;
internal GraphicsProgramInputs Inputs;
internal GraphicsPrograms? Programs;
internal object? Assumptions;
// 0 queued, 1 resolved, 2 abandoned.
internal volatile int State;
}
private readonly RenderExecutor _executor;
private readonly IRenderHost _host;
private readonly IConcurrentResolveHost _concurrent;
private readonly object _lock = new();
private readonly Queue<Job> _queue = new();
private readonly Thread _thread;
private int _outstanding;
private long _finished;
private long _executedGate;
// The gate the worker sleeps on; long.MaxValue while it does not.
private long _sleepingOnGate = long.MaxValue;
private bool _paused;
private bool _running;
private bool _stopping;
public ParallelProgramPrefetch(RenderExecutor executor, IRenderHost host, IConcurrentResolveHost concurrent)
{
_executor = executor;
_host = host;
_concurrent = concurrent;
_thread = new Thread(Run) { IsBackground = true, Name = "SharpEmu program resolve" };
_thread.Start();
}
// The job of the draw the render thread runs now; set around each deferred draw.
public Job? Current { get; set; }
public long Submitted { get; private set; }
// A gate above zero holds the job until AdvanceGate reaches it: with the command stream on its own
// thread, draws are submitted ahead of the work before them, which may change what they read.
public Job Submit(RegisterBanks banks, long gate = 0)
{
var job = new Job(banks, gate);
lock (_lock)
{
_queue.Enqueue(job);
Submitted++;
_outstanding++;
Monitor.PulseAll(_lock);
}
return job;
}
// Returns once the worker has finished every submitted job: it reads guest memory and cache
// state, which the render thread changes only with the worker idle.
public void WaitIdle()
{
if (Volatile.Read(ref _outstanding) == 0)
{
return;
}
lock (_lock)
{
while (_outstanding != 0)
{
Monitor.Wait(_lock);
}
}
}
// The render thread ran this many non-draw items: jobs gated on them may resolve.
public void AdvanceGate(long executed)
{
Interlocked.Exchange(ref _executedGate, executed);
// Only a worker asleep on a gate this reaches needs a wake: one per item costs the render
// thread a kernel call each.
if (Volatile.Read(ref _sleepingOnGate) <= executed)
{
lock (_lock)
{
Monitor.PulseAll(_lock);
}
}
}
// Returns once the worker finished the first count jobs it was given, so a non-draw item runs
// with no resolution of the draws before it still reading.
public void WaitFinished(long count)
{
if (Volatile.Read(ref _finished) >= count)
{
return;
}
lock (_lock)
{
while (_finished < count)
{
Monitor.Wait(_lock);
}
}
}
// Holds the worker between jobs, for host work that changes what a resolution reads.
public void Pause()
{
lock (_lock)
{
_paused = true;
while (_running)
{
Monitor.Wait(_lock);
}
}
}
public void Resume()
{
lock (_lock)
{
_paused = false;
Monitor.PulseAll(_lock);
}
}
public bool TryTakeGraphics(RegisterBanks banks, in GraphicsProgramInputs inputs, out GraphicsPrograms programs)
{
programs = null!;
var job = Current;
if (job is null || !ReferenceEquals(job.Banks, banks))
{
return false;
}
Current = null;
WaitFor(job);
if (job.State != 1 || !job.Inputs.Equals(inputs) || !_concurrent.IsStillCurrent(job.Assumptions!))
{
return false;
}
programs = job.Programs!;
return true;
}
private void WaitFor(Job job)
{
var spinner = new SpinWait();
for (var spin = 0; spin < 64 && job.State == 0; spin++)
{
spinner.SpinOnce(sleep1Threshold: -1);
}
if (job.State != 0)
{
return;
}
lock (_lock)
{
while (job.State == 0)
{
Monitor.Wait(_lock);
}
}
}
private void Run()
{
SubmissionScheduler.ThrowOnFatal = true;
while (true)
{
Job job;
lock (_lock)
{
while (_queue.Count == 0 && !_stopping)
{
Monitor.Wait(_lock);
}
if (_stopping)
{
return;
}
job = _queue.Peek();
if (job.Gate > Volatile.Read(ref _executedGate))
{
// The gate usually opens within a few items: spin before sleeping.
Monitor.Exit(_lock);
try
{
var spinner = new SpinWait();
for (var spin = 0; spin < 50 && job.Gate > Volatile.Read(ref _executedGate); spin++)
{
spinner.SpinOnce(sleep1Threshold: -1);
}
}
finally
{
Monitor.Enter(_lock);
}
}
while ((_paused || job.Gate > Volatile.Read(ref _executedGate)) && !_stopping)
{
Interlocked.Exchange(ref _sleepingOnGate, _paused ? long.MaxValue : job.Gate);
if (!_paused && job.Gate <= Volatile.Read(ref _executedGate))
{
break;
}
Monitor.Wait(_lock, 50);
}
Volatile.Write(ref _sleepingOnGate, long.MaxValue);
if (_stopping)
{
return;
}
_queue.Dequeue();
_running = true;
}
Resolve(job);
lock (_lock)
{
_outstanding--;
_finished++;
_running = false;
Monitor.PulseAll(_lock);
}
}
}
private void Resolve(Job job)
{
_concurrent.BeginConcurrentResolve();
try
{
job.Inputs = RenderExecutor.ProgramInputsOf(job.Banks, _host.FormatSupport, _host.Fatal);
job.Programs = _executor.ResolveGraphicsPrograms(job.Banks, in job.Inputs);
job.Assumptions = _concurrent.EndConcurrentResolve();
job.State = 1;
}
catch (Exception)
{
// The render thread resolves the draw itself, and fails there if the error is real.
job.Assumptions = _concurrent.EndConcurrentResolve();
job.State = 2;
}
}
public void Dispose()
{
lock (_lock)
{
_stopping = true;
Monitor.PulseAll(_lock);
}
}
}

View File

@ -72,6 +72,7 @@ public sealed partial class RenderExecutor
if (Diagnostics.DccWriterTrace.Enabled)
{
Diagnostics.DccWriterTrace.CurrentProgram = program.Hash;
Diagnostics.DccWriterTrace.CurrentKind = "compute";
}
if (RenderTrace.Enabled)
@ -714,15 +715,19 @@ public sealed partial class RenderExecutor
var hash = input.Stage.Program!.Hash;
if (!_host.TryClearImageFromBuffer(address, clear.Size, clear.PackedClear))
{
// A metadata fill may run before its target is bound; the store keeps it pending and the dispatch runs.
// A metadata fill may run before its target is bound; the store keeps it pending. The fill
// itself then runs on the host when the range is known DCC metadata: run as a dispatch it
// leaves the metadata GPU-written, and every later bind of the surface waits for the GPU to
// read it back (Astro Bot fills several surfaces this way each frame).
var registered = _host.TryAbsorbDccFill(address, clear.Size, clear.PackedClear);
var filledOnHost = !registered && _host.TryFillDccMetadata(address, clear.Size, clear.PackedClear);
if (RenderTrace.Enabled && RenderTrace.MetadataClear())
{
RenderTrace.Write(
$"{(registered ? "Tracked" : "Deferred")} a metadata clear: shader=0x{hash:X16} address=0x{address:X16} size=0x{clear.Size:X16} value=0x{clear.PackedClear:X8}");
$"{(registered ? "Tracked" : filledOnHost ? "Filled on the host" : "Deferred")} a metadata clear: shader=0x{hash:X16} address=0x{address:X16} size=0x{clear.Size:X16} value=0x{clear.PackedClear:X8}");
}
return registered;
return registered || filledOnHost;
}
if (RenderTrace.Enabled && RenderTrace.ImageClear())

View File

@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using SharpEmu.Libs.Gpu.Images;
namespace SharpEmu.Libs.Gpu.Rendering;
// What a draw's program resolution reads besides the stage and context registers: the export
// mapping of every bound color slot, whether the pixel stage runs and whether a depth target is
// bound. All three follow from the register banks alone.
public struct GraphicsProgramInputs : IEquatable<GraphicsProgramInputs>
{
[System.Runtime.CompilerServices.InlineArray(RenderingState.ColorAttachmentCapacity)]
public struct ExportMappings
{
private ColorComponentMap _element0;
}
public ExportMappings Mapping;
public bool PixelActive;
public bool DepthBound;
public static GraphicsProgramInputs Create()
{
var inputs = new GraphicsProgramInputs();
((Span<ColorComponentMap>)inputs.Mapping).Fill(ColorComponentMap.Identity);
return inputs;
}
public readonly bool Equals(GraphicsProgramInputs other) =>
PixelActive == other.PixelActive && DepthBound == other.DepthBound &&
((ReadOnlySpan<ColorComponentMap>)Mapping).SequenceEqual(other.Mapping);
public override readonly bool Equals(object? obj) => obj is GraphicsProgramInputs other && Equals(other);
public override readonly int GetHashCode()
{
var hash = new HashCode();
foreach (var map in (ReadOnlySpan<ColorComponentMap>)Mapping)
{
hash.Add(map.Packed);
}
hash.Add(PixelActive);
hash.Add(DepthBound);
return hash.ToHashCode();
}
}
// The host side of resolving programs on a thread other than the render thread. The resolving
// thread records what it assumed of the host's GPU-ownership state; the render thread checks,
// when it takes the result, that the assumptions still hold.
public interface IConcurrentResolveHost
{
// On the resolving thread, around one resolution.
void BeginConcurrentResolve();
object EndConcurrentResolve();
// On the render thread.
bool IsStillCurrent(object assumptions);
}
// Program resolutions computed ahead of their draws. A draw takes one only when it was resolved
// from the same register banks with the same inputs; otherwise it resolves its programs itself.
public interface IProgramPrefetch
{
bool TryTakeGraphics(RegisterBanks banks, in GraphicsProgramInputs inputs, out GraphicsPrograms programs);
}
public sealed partial class RenderExecutor
{
public IProgramPrefetch? Prefetch { get; set; }
// The inputs ResolveShaderPrograms derives from the targets TryResolveDrawTargets binds, from the
// registers alone: the target requests and the pixel and depth activity, without the images.
public static GraphicsProgramInputs ProgramInputsOf(RegisterBanks banks, IImageFormatSupport formats, Func<string, Exception> fatal)
{
var inputs = GraphicsProgramInputs.Create();
var context = banks.Context;
Span<ColorComponentMap> mapping = inputs.Mapping;
for (var slot = 0u; slot < ContextRegisters.ColorTargetCount; slot++)
{
if (slot != 0 && (context.RenderTargetMaskForSlot(slot) == 0 || context.ColorTargets[slot].BaseAddress == 0))
{
continue;
}
if (ColorTargetResolver.Resolve(context, slot, DrawLayerOffset, ignoreTargetMask: false, out var resolvedSlot) is { } resolution)
{
mapping[(int)resolvedSlot] = resolution.ExportMapping;
}
}
inputs.DepthBound = DepthTargetResolver.Resolve(context, formats, fatal) is not null;
inputs.PixelActive = HasActivePixelShader(banks);
return inputs;
}
// The programs of a draw with these banks and inputs, as ResolveShaderPrograms resolves them.
public GraphicsPrograms ResolveGraphicsPrograms(RegisterBanks banks, in GraphicsProgramInputs inputs)
{
var context = banks.Context;
return _pipelines.GetGraphicsPrograms(
banks.Shader.Vertex,
banks.Shader.Pixel,
context.ShaderInterface,
context,
inputs.Mapping,
inputs.PixelActive,
inputs.DepthBound);
}
}

View File

@ -35,7 +35,8 @@ public sealed partial class RenderExecutor
throw _host.Fatal($"The vertex input has too many buffers: count={buffers.Length} max={VertexInputInfo.MaxBuffers}.");
}
Span<VertexBufferRange> ranges = stackalloc VertexBufferRange[VertexInputInfo.MaxBuffers];
// Sized to this draw's buffers: a MaxBuffers span is over a kilobyte, zeroed on every draw.
Span<VertexBufferRange> ranges = stackalloc VertexBufferRange[buffers.Length];
var rangeCount = 0;
foreach (ref readonly var vertex in buffers.AsSpan())
{
@ -54,7 +55,7 @@ public sealed partial class RenderExecutor
}
ranges[..rangeCount].Sort(static (left, right) => left.BaseAddress.CompareTo(right.BaseAddress));
Span<VertexBufferRange> merged = stackalloc VertexBufferRange[VertexInputInfo.MaxBuffers];
Span<VertexBufferRange> merged = stackalloc VertexBufferRange[buffers.Length];
var mergedCount = 0;
for (var i = 0; i < rangeCount; i++)
{

View File

@ -17,6 +17,12 @@ public sealed partial class RenderExecutor
private bool TryResolveDrawTargets(RegisterBanks banks, in DrawCall draw, ref DrawState state)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.DrawTargetResolution);
if (Diagnostics.DccWriterTrace.Enabled)
{
Diagnostics.DccWriterTrace.CurrentKind = "draw";
Diagnostics.DccWriterTrace.CurrentProgram = 0;
}
var context = banks.Context;
if (TryResolveMultisampleColor(context))
{

View File

@ -667,29 +667,22 @@ public sealed partial class RenderExecutor
private void ResolveShaderPrograms(RegisterBanks banks, ref DrawState state)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.DrawProgramResolution);
var context = banks.Context;
Span<ColorComponentMapArray> mappingStorage = stackalloc ColorComponentMapArray[1];
Span<ColorComponentMap> targetExportMapping = mappingStorage[0];
targetExportMapping.Fill(ColorComponentMap.Identity);
var inputs = GraphicsProgramInputs.Create();
Span<ColorComponentMap> targetExportMapping = inputs.Mapping;
foreach (ref readonly var color in BoundColors(ref state))
{
targetExportMapping[(int)color.Slot] = color.Resolution.ExportMapping;
}
state.Programs = _pipelines.GetGraphicsPrograms(
banks.Shader.Vertex,
banks.Shader.Pixel,
context.ShaderInterface,
context,
targetExportMapping,
state.PixelActive,
state.Depth.HasTarget);
}
inputs.PixelActive = state.PixelActive;
inputs.DepthBound = state.Depth.HasTarget;
if (Prefetch is { } prefetch && prefetch.TryTakeGraphics(banks, in inputs, out var prefetched))
{
state.Programs = prefetched;
return;
}
[System.Runtime.CompilerServices.InlineArray(RenderingState.ColorAttachmentCapacity)]
private struct ColorComponentMapArray
{
private ColorComponentMap _element0;
state.Programs = ResolveGraphicsPrograms(banks, in inputs);
}
private static void TraceDrawState(ulong submitId, RegisterBanks banks, in DrawCall draw, in DrawState state)

View File

@ -19,7 +19,29 @@ public readonly record struct RenderingAttachment(
bool HasDepth,
bool DepthClear,
bool HasStencil,
bool StencilClear);
bool StencilClear)
{
// Compared by handle value: the generated comparison goes through the object comparer of ImageView
// and boxes both views, which a draw pays for every attachment it re-checks.
public bool Equals(RenderingAttachment other) =>
View.Handle == other.View.Handle &&
Layout == other.Layout &&
Format == other.Format &&
ClearWord0 == other.ClearWord0 &&
ClearWord1 == other.ClearWord1 &&
ClearWord2 == other.ClearWord2 &&
ClearWord3 == other.ClearWord3 &&
IsClear == other.IsClear &&
HasDepth == other.HasDepth &&
DepthClear == other.DepthClear &&
HasStencil == other.HasStencil &&
StencilClear == other.StencilClear;
public override int GetHashCode() => HashCode.Combine(
View.Handle,
HashCode.Combine(Layout, Format, ClearWord0, ClearWord1, ClearWord2, ClearWord3),
HashCode.Combine(IsClear, HasDepth, DepthClear, HasStencil, StencilClear));
}
[InlineArray(RenderingState.ColorAttachmentCapacity)]
public struct RenderingColorAttachments

View File

@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Scheduling;
// Counts the stores of GPU data into guest memory: buffer and image downloads, and buffer transfers
// the caches complete there. Work done ahead of the render thread from guest memory is still
// current while the count has not moved.
internal static class GpuWritebackEpoch
{
private static long _value;
public static long Value => Volatile.Read(ref _value);
public static void Advance() => Interlocked.Increment(ref _value);
}

View File

@ -1,8 +1,23 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Scheduling;
public readonly record struct MemoryBarrierRequest(
PipelineStageFlags SourceStages,
PipelineStageFlags DestinationStages,
AccessFlags SourceAccess,
AccessFlags DestinationAccess)
{
public MemoryBarrierRequest Union(in MemoryBarrierRequest other) => new(
SourceStages | other.SourceStages,
DestinationStages | other.DestinationStages,
SourceAccess | other.SourceAccess,
DestinationAccess | other.DestinationAccess);
}
public enum RecordedOperation : uint
{
DispatchDirect,
@ -23,6 +38,8 @@ public sealed class RecordingBuffer
private readonly IRenderingState _rendering;
private SubmissionContext? _context;
private bool _handleWasRequested;
private MemoryBarrierRequest _pendingBarrier;
private bool _hasPendingBarrier;
internal nint Buffer;
@ -51,7 +68,11 @@ public sealed class RecordingBuffer
public bool IsInvalid => Buffer == 0;
internal bool HasPendingCommands => _handleWasRequested || _rendering.IsRendering;
internal bool HasPendingCommands => _handleWasRequested || _hasPendingBarrier || _rendering.IsRendering;
public long MergedBarriers { get; private set; }
public long RecordedBarriers { get; private set; }
// The native handle stays valid only until the next submission.
public nint Handle
@ -64,11 +85,68 @@ public sealed class RecordingBuffer
}
// A caller can record native commands after it obtains the handle.
FlushPendingBarrier();
_handleWasRequested = true;
return Buffer;
}
}
public nint StateHandle
{
get
{
if (IsInvalid)
{
throw SubmissionScheduler.Fatal("The command buffer is not recording.");
}
_handleWasRequested = true;
return Buffer;
}
}
public void RequestMemoryBarrier(in MemoryBarrierRequest request)
{
if (IsInvalid || _rendering.IsRendering)
{
throw SubmissionScheduler.Fatal("A memory barrier needs a recording command buffer outside rendering.");
}
if (_hasPendingBarrier)
{
_pendingBarrier = _pendingBarrier.Union(request);
MergedBarriers++;
if (Vulkan.GpuSyncProfile.Enabled)
{
Vulkan.GpuSyncProfile.CountMergedRequest();
}
return;
}
_pendingBarrier = request;
_hasPendingBarrier = true;
}
private void FlushPendingBarrier()
{
if (!_hasPendingBarrier)
{
return;
}
_hasPendingBarrier = false;
if (_rendering.IsRendering)
{
throw SubmissionScheduler.Fatal("A pending memory barrier reached a rendering scope.");
}
var recorder = Owner.MemoryBarrierRecorder ??
throw SubmissionScheduler.Fatal("A memory barrier was requested without a recorder.");
recorder(Buffer, _pendingBarrier);
RecordedBarriers++;
}
public SubmissionContext Context => _context ?? throw SubmissionScheduler.Fatal("The command buffer has no submission context.");
public void SetDebugInfo(uint op, ulong submitId, uint arg0 = 0, uint arg1 = 0, uint arg2 = 0, uint arg3 = 0, ulong arg4 = 0)
@ -101,6 +179,7 @@ public sealed class RecordingBuffer
_device.BeginBuffer(Buffer);
_handleWasRequested = false;
_hasPendingBarrier = false;
SetDebugInfo((uint)RecordedOperation.Unknown, 0);
}

View File

@ -84,13 +84,27 @@ public sealed class SubmissionScheduler : IGpuTickScheduler, IDisposable
public ulong CurrentTick => _timeline.CurrentTick;
public Action<nint, MemoryBarrierRequest>? MemoryBarrierRecorder { get; set; }
public TickTimeline Timeline => _timeline;
internal bool PriorityWorkerAlive => _priorityThread.IsAlive;
// Set on a thread that does work ahead of the render thread: there a fatal condition only
// abandons that work, which the render thread then does itself and fails on if it is real.
[ThreadStatic]
internal static bool ThrowOnFatal;
public sealed class AbandonedWorkException(string message) : Exception(message);
// Report the fatal error, then return an exception to stop the caller.
internal static Exception Fatal(string message)
{
if (ThrowOnFatal)
{
throw new AbandonedWorkException(message);
}
Console.Error.WriteLine($"[GPU][FATAL] {message}");
OnFatal(message);
return new InvalidOperationException(message);

View File

@ -0,0 +1,79 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Vulkan;
internal enum NativeStateKind
{
Pipeline,
PushConstants,
Descriptors,
VertexBuffers,
IndexBuffer,
Count,
}
internal static class GpuSyncProfile
{
private static readonly long[] _nativeStateRecorded = new long[(int)NativeStateKind.Count];
private static readonly long[] _nativeStateReused = new long[(int)NativeStateKind.Count];
private static long _barriers;
private static long _drainingBarriers;
private static long _memoryBarriers;
private static long _bufferBarriers;
private static long _imageBarriers;
private static long _mergedRequests;
private static long _renderingScopes;
private static long _batchFlushes;
public static bool Enabled => VideoOut.RenderPhaseProfile.Enabled;
public static void CountBarrier(PipelineStageFlags source, PipelineStageFlags destination, uint memory, uint buffer, uint image)
{
Interlocked.Increment(ref _barriers);
if ((source & PipelineStageFlags.AllCommandsBit) != 0 && (destination & (PipelineStageFlags.AllCommandsBit | PipelineStageFlags.TopOfPipeBit | PipelineStageFlags.DrawIndirectBit)) != 0)
{
Interlocked.Increment(ref _drainingBarriers);
}
Interlocked.Add(ref _memoryBarriers, memory);
Interlocked.Add(ref _bufferBarriers, buffer);
Interlocked.Add(ref _imageBarriers, image);
}
public static void CountMergedRequest() => Interlocked.Increment(ref _mergedRequests);
public static void CountRenderingScope() => Interlocked.Increment(ref _renderingScopes);
public static void CountBatchFlush() => Interlocked.Increment(ref _batchFlushes);
public static void CountNativeState(NativeStateKind kind, bool reused)
{
if (reused)
{
Interlocked.Increment(ref _nativeStateReused[(int)kind]);
}
else
{
Interlocked.Increment(ref _nativeStateRecorded[(int)kind]);
}
}
private static string TakeNativeStateReport()
{
var parts = new string[(int)NativeStateKind.Count];
for (var index = 0; index < parts.Length; index++)
{
var reused = Interlocked.Exchange(ref _nativeStateReused[index], 0);
var recorded = Interlocked.Exchange(ref _nativeStateRecorded[index], 0);
parts[index] = FormattableString.Invariant($"{(NativeStateKind)index}={reused}/{reused + recorded}");
}
return string.Join(" ", parts);
}
public static string TakeReport() => FormattableString.Invariant(
$"[PERF][GPU_SYNC] barriers={Interlocked.Exchange(ref _barriers, 0)} draining={Interlocked.Exchange(ref _drainingBarriers, 0)} memory={Interlocked.Exchange(ref _memoryBarriers, 0)} buffer={Interlocked.Exchange(ref _bufferBarriers, 0)} image={Interlocked.Exchange(ref _imageBarriers, 0)} merged_requests={Interlocked.Exchange(ref _mergedRequests, 0)} rendering_scopes={Interlocked.Exchange(ref _renderingScopes, 0)} draw_count_flushes={Interlocked.Exchange(ref _batchFlushes, 0)} reused {TakeNativeStateReport()}");
}

View File

@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Vulkan;
// Advanced by every graphics pipeline bind or dynamic state change outside the guest draw path:
// such a pipeline may fix the state the guest pipelines leave dynamic, so the dynamic state a
// guest draw last set no longer holds in the command buffer.
internal static class GraphicsDynamicStateEpoch
{
private static long _value;
public static long Value => Volatile.Read(ref _value);
public static void Advance() => Interlocked.Increment(ref _value);
}

View File

@ -0,0 +1,13 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Vulkan;
internal static class NativeBindingEpoch
{
private static long _value;
public static long Value => Volatile.Read(ref _value);
public static void Advance() => Interlocked.Increment(ref _value);
}

View File

@ -172,10 +172,13 @@ internal sealed unsafe class VulkanAsyncReadback : IDisposable
PSemaphores = &semaphore,
PValues = &signalValue,
};
var waitStart = VideoOut.GpuWaitSiteProfile.Begin();
using (VideoOut.RenderPhaseProfile.MeasureDetail(VideoOut.RenderPhaseProfile.Phase.GpuCompletionWait))
{
Require(vk.WaitSemaphores(_device.Device, &waitInfo, ulong.MaxValue), "vkWaitSemaphores(readback)");
}
VideoOut.GpuWaitSiteProfile.End(waitStart);
}
finally
{

View File

@ -22,6 +22,11 @@ internal static unsafe class VulkanSynchronization
uint imageMemoryBarrierCount,
ImageMemoryBarrier2* imageMemoryBarriers)
{
if (GpuSyncProfile.Enabled)
{
GpuSyncProfile.CountBarrier(sourceStages, destinationStages, memoryBarrierCount, bufferMemoryBarrierCount, imageMemoryBarrierCount);
}
var sourceStages2 = (PipelineStageFlags2)(ulong)sourceStages;
var destinationStages2 = (PipelineStageFlags2)(ulong)destinationStages;
for (var index = 0u; index < memoryBarrierCount; index++)

View File

@ -220,10 +220,13 @@ internal sealed unsafe class VulkanTickDevice : IGpuTickDevice
PValues = &tick,
};
Result result;
var waitStart = GpuWaitSiteProfile.Begin();
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.GpuCompletionWait))
{
result = _vk.WaitSemaphores(_device, &waitInfo, ulong.MaxValue);
}
GpuWaitSiteProfile.End(waitStart);
failure = result.ToString();
return result == Result.Success && WaitRetired(tick);
}

View File

@ -29,6 +29,18 @@ public static class JsonExports
private static readonly ConcurrentDictionary<ulong, JsonStringState> _strings = new();
private static readonly JsonElement _nullElement = CreateNullElement();
// sce::Json::Array is reached through Value::getArray and walked with an 8-byte iterator. The
// elements are laid out once as consecutive Value objects in guest memory; the Array object
// holds their base and count, and an iterator holds the address of its element, so stepping
// adds ValueObjectSize and dereferencing returns the element itself.
private sealed record JsonArrayState(ulong Elements, int Count);
private const int ArrayObjectSize = 0x10;
private static readonly ConcurrentDictionary<ulong, JsonArrayState> _arrays = new();
// The Array built for a parsed value, reused while that value is unchanged.
private static readonly ConcurrentDictionary<ulong, (JsonValueState Source, ulong Array)> _valueArrays = new();
[SysAbiExport(
Nid = "-hJRce8wn1U",
ExportName = "_ZN3sce4Json12MemAllocatorC2Ev",
@ -598,6 +610,289 @@ public static class JsonExports
return 0;
}
[SysAbiExport(
Nid = "ONT8As5R1ug",
ExportName = "_ZNK3sce4Json5Value8getArrayEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ValueGetArray(CpuContext ctx)
{
var valueAddress = ctx[CpuRegister.Rdi];
JsonValueState? source = null;
var hasParsed = !JsonObjectHeap.Values.ContainsKey(valueAddress) &&
_values.TryGetValue(valueAddress, out source);
if (hasParsed &&
_valueArrays.TryGetValue(valueAddress, out var cached) &&
ReferenceEquals(cached.Source, source))
{
ctx[CpuRegister.Rax] = cached.Array;
return 0;
}
// A value that is not an array reads as an empty one.
var element = GetValue(valueAddress);
var count = element.ValueKind == System.Text.Json.JsonValueKind.Array ? element.GetArrayLength() : 0;
if (!TryAllocateGuestObject(ctx, ArrayObjectSize + (count * ValueObjectSize), out var arrayAddress))
{
ctx[CpuRegister.Rax] = 0;
return 0;
}
var elements = arrayAddress + ArrayObjectSize;
for (var index = 0; index < count; index++)
{
StoreValue(ctx, elements + (ulong)(index * ValueObjectSize), element[index]);
}
_arrays[arrayAddress] = new JsonArrayState(elements, count);
ctx.TryWriteUInt64(arrayAddress, elements);
ctx.TryWriteUInt64(arrayAddress + 8, (ulong)count);
if (hasParsed)
{
_valueArrays[valueAddress] = (source!, arrayAddress);
}
ctx[CpuRegister.Rax] = arrayAddress;
TraceJson("Value.getArray", valueAddress, (ulong)count);
return 0;
}
[SysAbiExport(
Nid = "JP-PtKMiI1E",
ExportName = "_ZN3sce4Json5ArrayC1Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayConstructor(CpuContext ctx) => ConstructArray(ctx);
[SysAbiExport(
Nid = "t6AHhkWu4mY",
ExportName = "_ZN3sce4Json5ArrayC2Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayBaseConstructor(CpuContext ctx) => ConstructArray(ctx);
[SysAbiExport(
Nid = "HJ8GpRT1aiw",
ExportName = "_ZN3sce4Json5ArrayD1Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayDestructor(CpuContext ctx) => DestroyArray(ctx);
[SysAbiExport(
Nid = "MIyMPw7YTGM",
ExportName = "_ZN3sce4Json5ArrayD2Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayBaseDestructor(CpuContext ctx) => DestroyArray(ctx);
[SysAbiExport(
Nid = "rQGJeNjOuUk",
ExportName = "_ZNK3sce4Json5Array4sizeEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArraySize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = (ulong)GetArray(ctx[CpuRegister.Rdi]).Count;
return 0;
}
[SysAbiExport(
Nid = "9uP25i6ipno",
ExportName = "_ZNK3sce4Json5Array5emptyEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayEmpty(CpuContext ctx)
{
ctx[CpuRegister.Rax] = GetArray(ctx[CpuRegister.Rdi]).Count == 0 ? 1UL : 0UL;
return 0;
}
// begin() and end() return the iterator through the hidden result pointer in RDI; the Array
// is in RSI.
[SysAbiExport(
Nid = "bcH5EnFE2xY",
ExportName = "_ZNK3sce4Json5Array5beginEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayBegin(CpuContext ctx) =>
ReturnIterator(ctx, GetArray(ctx[CpuRegister.Rsi]).Elements);
[SysAbiExport(
Nid = "WXF2ihRF+B8",
ExportName = "_ZNK3sce4Json5Array3endEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ArrayEnd(CpuContext ctx)
{
var array = GetArray(ctx[CpuRegister.Rsi]);
return ReturnIterator(ctx, array.Elements + (ulong)(array.Count * ValueObjectSize));
}
[SysAbiExport(
Nid = "TAPTWYaYf8Q",
ExportName = "_ZN3sce4Json5Array8iteratorC1Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorConstructor(CpuContext ctx) => SetIterator(ctx, ctx[CpuRegister.Rdi], 0);
[SysAbiExport(
Nid = "JSg1MSo7NKE",
ExportName = "_ZN3sce4Json5Array8iteratorC2Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorBaseConstructor(CpuContext ctx) => SetIterator(ctx, ctx[CpuRegister.Rdi], 0);
[SysAbiExport(
Nid = "IaIvX3kgyb0",
ExportName = "_ZN3sce4Json5Array8iteratorC1ERKS2_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorCopyConstructor(CpuContext ctx) => CopyIterator(ctx);
[SysAbiExport(
Nid = "Hdc3DSIsG2I",
ExportName = "_ZN3sce4Json5Array8iteratorC2ERKS2_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorBaseCopyConstructor(CpuContext ctx) => CopyIterator(ctx);
[SysAbiExport(
Nid = "T3WReElX4BY",
ExportName = "_ZN3sce4Json5Array8iteratoraSERKS2_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorAssignment(CpuContext ctx) => CopyIterator(ctx);
[SysAbiExport(
Nid = "9yLjn46Ypfs",
ExportName = "_ZN3sce4Json5Array8iteratorD1Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorDestructor(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return 0;
}
[SysAbiExport(
Nid = "9niNrXsF3Bc",
ExportName = "_ZN3sce4Json5Array8iteratorD2Ev",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorBaseDestructor(CpuContext ctx) => IteratorDestructor(ctx);
[SysAbiExport(
Nid = "5AZPp99ogrc",
ExportName = "_ZNK3sce4Json5Array8iteratorneERKS2_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorNotEqual(CpuContext ctx)
{
ctx[CpuRegister.Rax] =
ReadIterator(ctx, ctx[CpuRegister.Rdi]) != ReadIterator(ctx, ctx[CpuRegister.Rsi]) ? 1UL : 0UL;
return 0;
}
[SysAbiExport(
Nid = "wcgr5mte7T8",
ExportName = "_ZNK3sce4Json5Array8iteratordeEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorDereference(CpuContext ctx)
{
ctx[CpuRegister.Rax] = ReadIterator(ctx, ctx[CpuRegister.Rdi]);
return 0;
}
[SysAbiExport(
Nid = "iAIYn4oAWvI",
ExportName = "_ZNK3sce4Json5Array8iteratorptEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorArrow(CpuContext ctx) => IteratorDereference(ctx);
[SysAbiExport(
Nid = "w5+VCznos5E",
ExportName = "_ZN3sce4Json5Array8iteratorppEv",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorPreIncrement(CpuContext ctx)
{
var iterator = ctx[CpuRegister.Rdi];
return SetIterator(ctx, iterator, ReadIterator(ctx, iterator) + ValueObjectSize);
}
// Postfix ++ returns the old position through the hidden result pointer in RDI; the iterator
// is in RSI.
[SysAbiExport(
Nid = "GEylwSsiGG8",
ExportName = "_ZN3sce4Json5Array8iteratorppEi",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorPostIncrement(CpuContext ctx)
{
var iterator = ctx[CpuRegister.Rsi];
var position = ReadIterator(ctx, iterator);
ctx.TryWriteUInt64(iterator, position + ValueObjectSize);
return ReturnIterator(ctx, position);
}
[SysAbiExport(
Nid = "43MESLndGvQ",
ExportName = "_ZN3sce4Json5Array8iterator7advanceEm",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int IteratorAdvance(CpuContext ctx)
{
var iterator = ctx[CpuRegister.Rdi];
var step = ctx[CpuRegister.Rsi] * ValueObjectSize;
return SetIterator(ctx, iterator, ReadIterator(ctx, iterator) + step);
}
private static int ConstructArray(CpuContext ctx)
{
var thisAddress = ctx[CpuRegister.Rdi];
if (thisAddress != 0)
{
_arrays[thisAddress] = new JsonArrayState(0, 0);
ctx.TryWriteUInt64(thisAddress, 0);
ctx.TryWriteUInt64(thisAddress + 8, 0);
}
ctx[CpuRegister.Rax] = thisAddress;
return 0;
}
private static int DestroyArray(CpuContext ctx)
{
_arrays.TryRemove(ctx[CpuRegister.Rdi], out _);
ctx[CpuRegister.Rax] = 0;
return 0;
}
private static JsonArrayState GetArray(ulong address) =>
_arrays.TryGetValue(address, out var array) ? array : new JsonArrayState(0, 0);
private static ulong ReadIterator(CpuContext ctx, ulong iterator) =>
iterator != 0 && ctx.TryReadUInt64(iterator, out var position) ? position : 0;
private static int SetIterator(CpuContext ctx, ulong iterator, ulong position)
{
if (iterator != 0)
{
ctx.TryWriteUInt64(iterator, position);
}
ctx[CpuRegister.Rax] = iterator;
return 0;
}
private static int CopyIterator(CpuContext ctx) =>
SetIterator(ctx, ctx[CpuRegister.Rdi], ReadIterator(ctx, ctx[CpuRegister.Rsi]));
private static int ReturnIterator(CpuContext ctx, ulong position) =>
SetIterator(ctx, ctx[CpuRegister.Rdi], position);
private static int ConstructValue(CpuContext ctx)
{
var thisAddress = ctx[CpuRegister.Rdi];
@ -642,6 +937,7 @@ public static class JsonExports
{
var thisAddress = ctx[CpuRegister.Rdi];
_values.TryRemove(thisAddress, out _);
_valueArrays.TryRemove(thisAddress, out _);
JsonObjectHeap.RemoveValue(thisAddress);
if (thisAddress != 0)
{

View File

@ -558,6 +558,9 @@ public static class KernelEventQueueCompatExports
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
// The user event id is an int that the kernel stores in a uintptr_t ident, so -1 becomes 0xFFFFFFFFFFFFFFFF.
private static ulong UserEventIdent(CpuContext ctx) => unchecked((ulong)(int)ctx[CpuRegister.Rsi]);
[SysAbiExport(
Nid = "WDszmSbWuDk",
ExportName = "sceKernelAddUserEventEdge",
@ -568,7 +571,7 @@ public static class KernelEventQueueCompatExports
var handle = ctx[CpuRegister.Rdi];
var registered = RegisterEvent(
handle,
ctx[CpuRegister.Rsi],
UserEventIdent(ctx),
KernelEventFilterUser,
0,
KernelEventFlagClear);
@ -588,7 +591,7 @@ public static class KernelEventQueueCompatExports
var handle = ctx[CpuRegister.Rdi];
var registered = RegisterEvent(
handle,
ctx[CpuRegister.Rsi],
UserEventIdent(ctx),
KernelEventFilterUser,
0,
flags: 0);
@ -608,7 +611,7 @@ public static class KernelEventQueueCompatExports
var handle = ctx[CpuRegister.Rdi];
var deleted = DeleteRegisteredEvent(
handle,
ctx[CpuRegister.Rsi],
UserEventIdent(ctx),
KernelEventFilterUser);
TraceEventQueue(ctx, "delete_user", handle);
return deleted
@ -626,7 +629,7 @@ public static class KernelEventQueueCompatExports
var handle = ctx[CpuRegister.Rdi];
var triggered = TriggerRegisteredEvent(
handle,
ctx[CpuRegister.Rsi],
UserEventIdent(ctx),
KernelEventFilterUser,
userData: ctx[CpuRegister.Rdx]);
TraceEventQueue(ctx, "trigger_user", handle);

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Threading;
@ -34,7 +35,7 @@ public static partial class KernelMemoryCompatExports
private static long _nextAioSubmitId = 1;
private static readonly ConcurrentDictionary<uint, int> _aioResults = new();
private static FileStream? GetOpenFile(int fd)
private static Stream? GetOpenFile(int fd)
{
lock (_fdGate)
{
@ -42,6 +43,32 @@ public static partial class KernelMemoryCompatExports
}
}
// A descriptor is a host file or a read-only file served from a mounted archive.
private static int ReadOpenFileAt(Stream stream, Span<byte> buffer, long offset) => stream switch
{
FileStream file => RandomAccess.Read(file.SafeFileHandle, buffer, offset),
ZArchiveFileStream archived => archived.ReadAt(buffer, offset),
_ => throw new IOException("The descriptor does not support positional reads."),
};
private static void WriteOpenFileAt(Stream stream, ReadOnlySpan<byte> payload, long offset)
{
if (stream is not FileStream file)
{
throw new IOException("The descriptor is read-only.");
}
RandomAccess.Write(file.SafeFileHandle, payload, offset);
}
private static void FlushOpenFileToDisk(Stream stream)
{
if (stream is FileStream file)
{
file.Flush(flushToDisk: true);
}
}
// ---- Positional read/write (do not move the file offset) ----
[SysAbiExport(Nid = "ezv-RSBNKqI", ExportName = "pread",
@ -79,7 +106,7 @@ public static partial class KernelMemoryCompatExports
int read;
try
{
read = RandomAccess.Read(stream.SafeFileHandle, buffer, offset);
read = ReadOpenFileAt(stream, buffer, offset);
}
catch (IOException)
{
@ -134,7 +161,7 @@ public static partial class KernelMemoryCompatExports
try
{
RandomAccess.Write(stream.SafeFileHandle, payload, offset);
WriteOpenFileAt(stream, payload, offset);
}
catch (IOException)
{
@ -180,7 +207,7 @@ public static partial class KernelMemoryCompatExports
try
{
stream.Flush(flushToDisk: true);
FlushOpenFileToDisk(stream);
}
catch (IOException)
{
@ -198,7 +225,7 @@ public static partial class KernelMemoryCompatExports
{
foreach (var stream in _openFiles.Values)
{
try { stream.Flush(flushToDisk: true); } catch (IOException) { }
try { FlushOpenFileToDisk(stream); } catch (IOException) { }
}
}
@ -597,11 +624,11 @@ public static partial class KernelMemoryCompatExports
return -1;
}
RandomAccess.Write(stream.SafeFileHandle, scratch, offset);
WriteOpenFileAt(stream, scratch, offset);
return nbyte;
}
var read = RandomAccess.Read(stream.SafeFileHandle, scratch, offset);
var read = ReadOpenFileAt(stream, scratch, offset);
if (read > 0 && !ctx.Memory.TryWrite(buf, scratch.AsSpan(0, read)))
{
return -1;

View File

@ -494,6 +494,9 @@ public static partial class KernelMemoryCompatExports
if (candidate > UserAddressLimit - length)
{
candidate = AlignUp(SparseReservationBase, alignment);
// A range too large for the aperture can only live at the guest's own hint.
if (candidate > UserAddressLimit - length)
candidate = AlignUp(requested, alignment);
if (candidate == 0 || candidate > UserAddressLimit - length)
return false;
}

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using SharpEmu.HLE.GpuMemory;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Ampr;
@ -97,7 +98,7 @@ public static partial class KernelMemoryCompatExports
private const int KernelStatStBirthtimOffset = 104;
private static readonly object _fdGate = new();
private static readonly Dictionary<int, FileStream> _openFiles = new();
private static readonly Dictionary<int, Stream> _openFiles = new();
private static readonly HashSet<int> _randomDeviceDescriptors = new();
private static readonly Dictionary<int, HostMovieBridge.BinkGuestCompletionShim>
_binkGuestCompletionShims = new();
@ -1480,6 +1481,11 @@ public static partial class KernelMemoryCompatExports
}
try
{
if (access == FileAccess.Read && HostMovieBridge.IsSelfDecodedMovie(hostPath))
{
App0Archive.Extract(hostPath);
}
if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
{
LogOpenTrace(
@ -1506,9 +1512,9 @@ public static partial class KernelMemoryCompatExports
}
var wantsDirectory = (flags & O_DIRECTORY) != 0;
if (wantsDirectory || Directory.Exists(hostPath))
if (wantsDirectory || HostDirectoryExists(hostPath))
{
if (!Directory.Exists(hostPath))
if (!HostDirectoryExists(hostPath))
{
LogOpenTrace($"_open miss path='{guestPath}' host='{hostPath}' flags=0x{flags:X8} directory=1");
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
@ -1536,8 +1542,18 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
EnsureOpenParentDirectoryExists(guestPath, hostPath, flags);
var stream = new FileStream(hostPath, mode, access, GuestFileShare);
Stream stream;
if (access == FileAccess.Read && !IsMutatingOpen(flags) && !File.Exists(hostPath) &&
App0Archive.OpenRead(hostPath) is { } archived)
{
stream = archived;
}
else
{
EnsureOpenParentDirectoryExists(guestPath, hostPath, flags);
stream = new FileStream(hostPath, mode, access, GuestFileShare);
}
if ((flags & O_APPEND) != 0)
{
stream.Seek(0, SeekOrigin.End);
@ -2268,7 +2284,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
FileStream? stream;
Stream? stream;
var notifyBinkClose = false;
string? observedBinkPath = null;
lock (_fdGate)
@ -2367,7 +2383,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
FileStream? stream;
Stream? stream;
HostMovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate)
@ -2385,7 +2401,7 @@ public static partial class KernelMemoryCompatExports
// 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))
if (HostMovieBridge.ShouldForceGuestMovieEof(OpenFileName(stream)))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@ -2411,7 +2427,7 @@ public static partial class KernelMemoryCompatExports
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
HostMovieBridge.WaitForHostPlaybackToFinish(OpenFileName(stream));
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
@ -2431,7 +2447,7 @@ public static partial class KernelMemoryCompatExports
LogIoTrace(
"read",
stream.Name,
OpenFileName(stream),
$"fd={fd} req={requested} read={read} pos={positionBefore}->{positionAfter} preview='{PreviewIoBytes(buffer, read, 64)}' hex={PreviewIoHex(buffer, read, 32)} guest_tail={PreviewGuestHex(ctx, bufferAddress + (ulong)Math.Max(read, 0), 32)}");
ctx[CpuRegister.Rax] = unchecked((ulong)read);
@ -2539,7 +2555,7 @@ public static partial class KernelMemoryCompatExports
{
position = -1;
FileStream? stream;
Stream? stream;
lock (_fdGate)
{
_openFiles.TryGetValue(fd, out stream);
@ -2564,7 +2580,7 @@ public static partial class KernelMemoryCompatExports
origin = SeekOrigin.End;
break;
default:
LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} result=invalid_whence");
LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} result=invalid_whence");
return OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
@ -2574,16 +2590,16 @@ public static partial class KernelMemoryCompatExports
}
catch (IOException ex)
{
LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} result=io_error ex={ex.Message}");
LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} result=io_error ex={ex.Message}");
return OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
catch (ArgumentException ex)
{
LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} result=invalid ex={ex.Message}");
LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} result=invalid ex={ex.Message}");
return OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} pos={position}");
LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} pos={position}");
return OrbisGen2Result.ORBIS_GEN2_OK;
}
@ -2643,7 +2659,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
FileStream? stream;
Stream? stream;
lock (_fdGate)
{
_openFiles.TryGetValue(fd, out stream);
@ -6966,7 +6982,7 @@ public static partial class KernelMemoryCompatExports
}
else if (_openFiles.TryGetValue(fd, out var stream))
{
hostPath = stream.Name;
hostPath = OpenFileName(stream);
}
}
@ -6985,7 +7001,9 @@ public static partial class KernelMemoryCompatExports
{
try
{
size = new FileInfo(hostPath!).Length;
size = !File.Exists(hostPath) && App0Archive.TryGetFile(hostPath!, out var archivedSize)
? (long)archivedSize
: new FileInfo(hostPath!).Length;
}
catch (IOException)
{
@ -7005,8 +7023,8 @@ public static partial class KernelMemoryCompatExports
private static bool TryWriteHostPathStat(CpuContext ctx, ulong statAddress, string hostPath)
{
var isDirectory = Directory.Exists(hostPath);
if (!isDirectory && !File.Exists(hostPath))
var isDirectory = HostDirectoryExists(hostPath);
if (!isDirectory && !HostFileExists(hostPath))
{
return false;
}
@ -7062,7 +7080,13 @@ public static partial class KernelMemoryCompatExports
return true;
}
if (!new DirectoryInfo(cachePath).Exists)
if (App0Archive.TryGetFile(cachePath, out size))
{
_aprFileSizeCache.TryAdd(cachePath, size);
return true;
}
if (!new DirectoryInfo(cachePath).Exists && !App0Archive.IsDirectory(cachePath))
{
if (IsUnderApp0(cachePath))
{
@ -7084,16 +7108,9 @@ public static partial class KernelMemoryCompatExports
private static bool TryWriteHostPathStat(CpuContext ctx, ulong statAddress, string hostPath, bool isDirectory)
{
if (isDirectory)
if (isDirectory ? !Directory.Exists(hostPath) : !File.Exists(hostPath))
{
if (!Directory.Exists(hostPath))
{
return false;
}
}
else if (!File.Exists(hostPath))
{
return false;
return TryWriteArchivePathStat(ctx, statAddress, hostPath, isDirectory);
}
try
@ -7110,6 +7127,33 @@ public static partial class KernelMemoryCompatExports
}
}
// A path under an app0 mounted from an archive that is not on disk takes its times from the archive.
private static bool TryWriteArchivePathStat(CpuContext ctx, ulong statAddress, string hostPath, bool isDirectory)
{
if (!App0Archive.TryResolve(hostPath, out var archive, out var node) ||
(isDirectory ? !archive.IsDirectory(node) : !archive.IsFile(node)))
{
return false;
}
var size = isDirectory ? 65536L : (long)archive.GetFileSize(node);
var archiveTime = File.GetLastWriteTimeUtc(archive.Path);
return TryWriteKernelStat(ctx, statAddress, isDirectory, size, archiveTime, archiveTime, archiveTime, hostPath);
}
private static bool HostFileExists(string hostPath) =>
File.Exists(hostPath) || App0Archive.TryGetFile(hostPath, out _);
private static bool HostDirectoryExists(string hostPath) =>
Directory.Exists(hostPath) || App0Archive.IsDirectory(hostPath);
private static string OpenFileName(Stream stream) => stream switch
{
FileStream file => file.Name,
ZArchiveFileStream archived => archived.Name,
_ => string.Empty,
};
private static bool TryWriteKernelStat(
CpuContext ctx,
ulong statAddress,
@ -7217,7 +7261,7 @@ public static partial class KernelMemoryCompatExports
var record = payload.AsSpan(written, recordLength);
BinaryPrimitives.WriteUInt32LittleEndian(record, ComputeDirectoryEntryHash(entryBytes.AsSpan(0, nameLength)));
BinaryPrimitives.WriteUInt16LittleEndian(record[4..], unchecked((ushort)recordLength));
record[6] = Directory.Exists(Path.Combine(directory.Path, entryName)) ? (byte)4 : (byte)8;
record[6] = HostDirectoryExists(Path.Combine(directory.Path, entryName)) ? (byte)4 : (byte)8;
record[7] = unchecked((byte)nameLength);
entryBytes.AsSpan(0, nameLength).CopyTo(record[8..]);
written += recordLength;
@ -7240,9 +7284,14 @@ public static partial class KernelMemoryCompatExports
// host dir previously returned EOF on the first call (rax=0), which
// sent GTA's fiWriteAsyncDataWorker down a path that treated the fd as
// a pointer (AV at 0xB1 on /download0/cloudcache/).
var children = Directory.EnumerateFileSystemEntries(hostPath)
.Select(Path.GetFileName)
// Under an app0 mounted from an archive the listing is the union of the cache folder and the archive.
var onDisk = Directory.Exists(hostPath)
? Directory.EnumerateFileSystemEntries(hostPath).Select(Path.GetFileName)
: [];
var children = onDisk
.Concat(App0Archive.List(hostPath).Select(static entry => entry.Name))
.Where(static name => !string.IsNullOrEmpty(name))
.Distinct(HostFsPath.Comparer)
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase);
return new[] { ".", ".." }.Concat(children).ToArray()!;
@ -7897,7 +7946,7 @@ public static partial class KernelMemoryCompatExports
}
var hostPath = ResolveGuestPath(guestPath);
if (File.Exists(hostPath) || Directory.Exists(hostPath))
if (HostFileExists(hostPath) || HostDirectoryExists(hostPath))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;

View File

@ -6,6 +6,7 @@ using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Kernel;
namespace SharpEmu.Libs.LibcStdio;
@ -30,10 +31,10 @@ public static class LibcStdioExports
private const int Einval = 22;
private const int Erofs = 30;
private sealed class StdioFile(FileStream stream)
private sealed class StdioFile(Stream stream)
{
public object Gate { get; } = new();
public FileStream Stream { get; set; } = stream;
public Stream Stream { get; set; } = stream;
public bool IsClosed { get; set; }
}
@ -147,7 +148,7 @@ public static class LibcStdioExports
}
}
var stream = new FileStream(hostPath, fileMode, fileAccess, KernelMemoryCompatExports.GuestFileShare);
var stream = OpenStdioStream(hostPath, fileMode, fileAccess);
if (mode.StartsWith('a') && fileAccess == FileAccess.ReadWrite)
{
stream.Seek(0, SeekOrigin.End);
@ -1063,7 +1064,7 @@ public static class LibcStdioExports
}
}
var replacement = new FileStream(hostPath, fileMode, fileAccess, KernelMemoryCompatExports.GuestFileShare);
var replacement = OpenStdioStream(hostPath, fileMode, fileAccess);
lock (file.Gate)
{
if (file.IsClosed)
@ -1227,9 +1228,15 @@ public static class LibcStdioExports
return (int)diagnosticResult;
}
// A read-only open of a file that exists only in an archive-mounted app0 reads from the archive.
private static Stream OpenStdioStream(string hostPath, FileMode fileMode, FileAccess fileAccess) =>
fileAccess == FileAccess.Read && fileMode == FileMode.Open
? App0Archive.OpenReadOrFile(hostPath, KernelMemoryCompatExports.GuestFileShare)
: new FileStream(hostPath, fileMode, fileAccess, KernelMemoryCompatExports.GuestFileShare);
private static void FlushForBufferingMode(
ulong handle,
FileStream stream,
Stream stream,
ReadOnlySpan<byte> payload)
{
if (!_fileBufferingModes.TryGetValue(handle, out var mode))

View File

@ -24,7 +24,7 @@ internal static class HostMovieBridge
private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
private static bool IsSelfDecodedMovie(string hostPath)
internal static bool IsSelfDecodedMovie(string hostPath)
{
foreach (var extension in SelfDecodedMovieExtensions)
{

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