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>
This commit is contained in:
Foued Attar 2026-10-12 15:26:47 +02:00 committed by GitHub
parent a298369293
commit c7bbb09311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
384 changed files with 33234 additions and 3476 deletions

117
LICENSES/GPL-2.0-only.txt Normal file
View File

@ -0,0 +1,117 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker.
signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice

View File

@ -0,0 +1,112 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Rendering resolution
Options → Rendering exposes three independent sizes, globally and per game:
- **Resolution** sets the host window size or exclusive fullscreen mode.
- **Game display mode** reports a 1080p display to the game by default. Select
3840×2160 to request a 4K display. A registered presentation buffer cannot
change this setting.
- **Internal resolution scale** multiplies the host size of the game's
screen-sized render targets. 100% is native and changes nothing.
The CLI equivalents are `--resolution=1280x720`,
`--guest-resolution=1920x1080` (or `3840x2160`) and
`--render-scale=0.5`. `SHARPEMU_RENDER_SCALE=0.5` does the same for scripted
runs and overrides the stored setting. The accepted range is 0.25 to 2,
rounded to a hundredth.
The game chooses its internal render targets. Some games adapt those targets to
the reported display mode; others render at a fixed resolution and may still
register 4K buffers with a 1080p display. Silent Hill: The Short Message does so
in current testing. The size of that presentation buffer alone also does not
establish the sizes of its intermediate scene targets. Changing the window alone
does not reduce the work the game renders; the internal resolution scale does.
## How the scale is applied
Guest memory layouts never change. A scaled image keeps its guest geometry in
its description - extent, pitch, tile mode, mip layout - and only its host
backing is created at `ceil(guest × scale)`. Everything that would notice:
- **Rasterization.** The render area, the viewport and the scissor are scaled
with the attachments. Scissor edges round outward so no guest pixel loses its
host coverage. With clipping disabled the vertex program bakes the viewport
transform against a reference extent that is divided by an upscale, so the
same program maps guest pixels onto host positions at either resolution.
- **Shaders.** `gl_FragCoord` is divided by the attachments' factor as it is
loaded, so the pixel position inputs and everything derived from them - screen
UVs above all - stay in guest pixels. Integer image coordinates are mapped
onto host texels through the bound image's factor, and resource-info queries
report guest sizes. Dynamic sample offsets are converted from guest texels
to host texels before normalization. The factors and a mask of scaled image resources travel in
shader data, so no shader is specialized per scale.
- **Guest transfers.** An upload fills a guest-resolution twin of the image from
the copy regions the tiler built and blits into the backing; a download blits
the backing into the twin and copies out of it. A copy between two images that
disagree on host resolution runs at guest resolution the same way.
- **Presentation.** The flip path already works from the host extent of the
captured image, so a scaled flip buffer still fills the window.
## What scales and what does not
Only screen-class, single-level, single-sample, uncompressed 2D render and depth
targets scale: width ≥ 1280, height ≥ 720, not square, render or depth tiling,
guest-placed, in a format the device can blit. The decision is a pure function
of the guest image description, so every view, alias and copy of one guest range
agrees on it.
Everything else keeps its guest resolution:
- block-compressed images, multisample footprints, mip chains, cube and array
faces, 3D images;
- depth targets with a stencil plane, because Vulkan cannot blit stencil and the
guest-resolution twin could not be filled;
- HTile, which encodes guest depth blocks the renderer reads back as clear
state. DCC only describes guest bytes the host never stores, so it does scale;
- asset tilings (linear, standard), shadow maps, lookup tables, probe atlases
and eye-adaptation readbacks, which are below the size threshold or square;
- storage images the moment a compute program writes one. The guest dispatch
grid does not shrink with the host image, so a scaled image would be written
with holes above 100% and redundantly below. The guest range is remembered and
every later image over that memory stays native;
- every attachment of a pass whose attachments disagree, which happens when one
of them falls into a case above. The draw drops the scaled attachments back to
guest resolution and resolves its targets again.
Only the Vulkan backend scales; the Metal backend ignores the setting.
## Known limitations
- **Compute-written surfaces never scale.** In a deferred renderer that is a
large part of the frame, so the saving is smaller than the scale suggests.
- **Depth with stencil never scales**, and it takes its pass with it.
- **Tone and screen-space effects shift.** Measured on Silent Hill: The Short
Message, the title scene is correct in composition, coverage and UI placement
at 50% and 200%, but slightly brighter than native at both, and at 50% the
character's coat picks up colour speckle. Effects that reason about texels
rather than about the screen - screen-space shadows, subsurface scattering,
dithering - change their footprint with the host resolution.
- **Guest stencil uploads and downloads** of a scaled image would lose the
stencil plane; this cannot happen today only because stencil images never
scale.
## Measured effect
Silent Hill: The Short Message at the title menu, 1080p display mode, GPU time
per draw scope (averaged over a whole run):
| scope | 50% | 100% | 200% |
|---|---|---|---|
| `vs=CBE40391 ps=E0D7BA42` | 1.68 ms | 7.60 ms | 41.81 ms |
| `vs=9A9C9C47 ps=B2FD2C67` | 0.71 ms | 2.44 ms | 10.52 ms |
| `vs=9A9C9C47 ps=E5A1AB7F` | 0.78 ms | 2.35 ms | 8.27 ms |
Pixel work follows the area, as it should. Frame rate does not: this title is
bound by guest command translation and compute, not by pixels, and stays at
roughly 2.5 fps at every scale. Dreaming Sarah holds 60 fps at 50% with one
scaled target.

View File

@ -9,6 +9,7 @@ using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Globalization;
using System.Text;
using System.Text.Json;
@ -45,6 +46,7 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
RosettaGcBudget.ReexecIfNeeded(args);
ConfigureManagedPluginResolution();
if (args.Length > 0 && args[0] == SharpEmu.Core.Diagnostics.WindowsCrashCapture.HelperArgument)
@ -1010,7 +1012,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>] [--guest-resolution=<1920x1080|3840x2160>] [--render-scale=<0.5|0.75|1|1.5|2>] [--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(@"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.");
}
@ -1075,6 +1077,8 @@ internal static partial class Program
var systemLanguage = 1;
HostWindowMode? windowModeOverride = null;
HostScalingMode? scalingModeOverride = null;
GuestDisplayResolution? guestResolutionOverride = null;
float? renderScaleOverride = null;
int? windowWidthOverride = null;
int? windowHeightOverride = null;
int? displayIndexOverride = null;
@ -1134,6 +1138,31 @@ internal static partial class Program
windowHeightOverride = windowHeight;
continue;
}
if (TrySplitOption(argument, "--guest-resolution", out var guestResolutionText))
{
if (guestResolutionText is not ("1920x1080" or "3840x2160"))
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
guestResolutionOverride = guestResolutionText == "3840x2160"
? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd;
continue;
}
if (TrySplitOption(argument, "--render-scale", out var renderScaleText))
{
if (!float.TryParse(renderScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out var renderScale) ||
!float.IsFinite(renderScale) ||
renderScale < HostVideoOptions.MinimumRenderScale || renderScale > HostVideoOptions.MaximumRenderScale)
{
ebootPath = string.Empty;
runtimeOptions = default;
return false;
}
renderScaleOverride = renderScale;
continue;
}
if (TrySplitOption(argument, "--display", out var displayText))
{
if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
@ -1391,6 +1420,8 @@ internal static partial class Program
{
WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
GuestResolution = guestResolutionOverride ?? configuredVideoOptions.GuestResolution,
RenderScale = renderScaleOverride ?? configuredVideoOptions.RenderScale,
Width = windowWidthOverride ?? configuredVideoOptions.Width,
Height = windowHeightOverride ?? configuredVideoOptions.Height,
DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
@ -1431,6 +1462,9 @@ internal static partial class Program
{
WindowMode = windowMode,
ScalingMode = scalingMode,
GuestResolution = effective.GuestResolution == "3840x2160"
? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd,
RenderScale = effective.RenderScale,
Width = hasResolution ? configuredWidth : defaults.Width,
Height = hasResolution ? configuredHeight : defaults.Height,
DisplayIndex = effective.DisplayIndex,

View File

@ -0,0 +1,100 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections;
using System.Runtime.InteropServices;
namespace SharpEmu.CLI;
/// <summary>
/// Under Rosetta 2 every garbage collection suspends the runtime with
/// FlushProcessWriteBuffers, which reads each thread's registers through a Mach call that
/// Rosetta services by parking the thread in its runtime. A signal that reaches a parked
/// thread (guest memory tracking raises thousands per second) trips Rosetta's "expected saved
/// LR to be in translated code" assertion and wedges the process. With the default gen0 budget
/// the emulator collects about twice a second; a 64 MiB budget makes that rare.
/// </summary>
/// <remarks>
/// The GC reads its gen0 budget only from the environment when the runtime starts, so the
/// process re-executes itself once with the variable set. execve keeps the process id.
/// </remarks>
internal static class RosettaGcBudget
{
private const string BudgetVariable = "DOTNET_GCgen0size";
// 64 MiB: a larger budget turns every collection into a blocking gen1/gen2 pass of
// about a second (512 MiB measured 8 such pauses in 150 s of Demon's Souls).
private const string Budget = "0x4000000";
private const string OptOutVariable = "SHARPEMU_ROSETTA_GC_BUDGET";
public static void ReexecIfNeeded(string[] args)
{
if (!OperatingSystem.IsMacOS() ||
RuntimeInformation.ProcessArchitecture != Architecture.X64 ||
Environment.GetEnvironmentVariable(BudgetVariable) is not null ||
Environment.GetEnvironmentVariable(OptOutVariable) == "0" ||
!IsTranslated())
{
return;
}
var path = Environment.ProcessPath;
if (string.IsNullOrEmpty(path))
{
return;
}
var environment = new List<string>();
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
environment.Add($"{entry.Key}={entry.Value}");
}
environment.Add($"{BudgetVariable}={Budget}");
var argv = BuildArguments(path, Environment.GetCommandLineArgs()[0], args);
var nativeArguments = ToNullTerminated(argv);
var nativeEnvironment = ToNullTerminated(environment);
// Only returns on failure; the emulator then runs with the default budget.
_ = Execve(path, nativeArguments, nativeEnvironment);
var error = Marshal.GetLastPInvokeError();
foreach (var pointer in nativeArguments) Marshal.FreeCoTaskMem(pointer);
foreach (var pointer in nativeEnvironment) Marshal.FreeCoTaskMem(pointer);
Console.Error.WriteLine(
$"[LOADER][WARN] Could not restart with a larger GC budget under Rosetta: errno={error}");
}
internal static string[] BuildArguments(string executable, string entryAssembly, string[] args) =>
Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)
? [executable, entryAssembly, .. args]
: [executable, .. args];
private static bool IsTranslated()
{
var value = 0;
var size = (nint)sizeof(int);
return SysctlByName("sysctl.proc_translated", ref value, ref size, 0, 0) == 0 && value == 1;
}
private static nint[] ToNullTerminated(IReadOnlyList<string> values)
{
var pointers = new nint[values.Count + 1];
for (var i = 0; i < values.Count; i++)
{
pointers[i] = Marshal.StringToCoTaskMemUTF8(values[i]);
}
return pointers;
}
[DllImport("libc", EntryPoint = "execve", SetLastError = true)]
private static extern int Execve(
[MarshalAs(UnmanagedType.LPUTF8Str)] string path,
nint[] argv,
nint[] envp);
[DllImport("libc", EntryPoint = "sysctlbyname")]
private static extern int SysctlByName(
[MarshalAs(UnmanagedType.LPUTF8Str)] string name,
ref int value,
ref nint size,
nint newValue,
nint newSize);
}

View File

@ -261,23 +261,40 @@ public sealed unsafe partial class DirectExecutionBackend
return code;
}
// SHARPEMU_MEMCPY_MANAGED_MIN overrides the size from which guest memcpy takes the managed path.
internal static readonly int ManagedCopyMinimumBytes =
int.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_MEMCPY_MANAGED_MIN"), out var minimum) && minimum > 0 ? minimum : 4096;
/// <summary>
/// memcpy/memmove(dst, src, n) as a forward <c>rep movsb</c>, returning dst. The libc
/// export copies with memmove semantics, so a destination that starts inside the source
/// (where a forward copy would read bytes it already overwrote) takes the managed path.
/// So does a copy of at least <see cref="ManagedCopyMinimumBytes"/>: the native copy takes
/// one write-tracking fault per GPU-watched page it writes, a signal round trip of about
/// 0.2 ms under Rosetta, while the managed copy marks the whole destination written first.
/// Only RAX, RCX, RSI, RDI and the flags change - all caller-saved - and the direction
/// flag is clear on entry per the SysV ABI.
/// </summary>
internal static List<byte> EmitGuestMemoryCopyStub(nint fallbackTrampoline)
{
var code = new List<byte>(40);
var code = new List<byte>(48);
var slowPathFixups = new List<int>();
// mov rcx, rdi / sub rcx, rsi / cmp rcx, rdx / jb slow: dst - src < n (unsigned) is
// exactly a destination in (src, src + n) or equal to src with a non-empty copy.
code.AddRange([0x48, 0x89, 0xF9, 0x48, 0x29, 0xF1, 0x48, 0x39, 0xD1, 0x72, 0x00]);
var slowPathFixup = code.Count - 1;
slowPathFixups.Add(code.Count - 1);
// cmp rdx, imm32 / jae slow
code.AddRange([0x48, 0x81, 0xFA]);
code.AddRange(BitConverter.GetBytes(ManagedCopyMinimumBytes));
code.AddRange([0x73, 0x00]);
slowPathFixups.Add(code.Count - 1);
// mov rax, rdi / mov rcx, rdx / rep movsb / ret
code.AddRange([0x48, 0x89, 0xF8, 0x48, 0x89, 0xD1, 0xF3, 0xA4, 0xC3]);
code[slowPathFixup] = checked((byte)(code.Count - (slowPathFixup + 1)));
foreach (var fixup in slowPathFixups)
{
code[fixup] = checked((byte)(code.Count - (fixup + 1)));
}
// jmp qword [rip+0] with the absolute trampoline address after it.
code.AddRange([0xFF, 0x25, 0x00, 0x00, 0x00, 0x00]);
code.AddRange(BitConverter.GetBytes((long)fallbackTrampoline));

View File

@ -36,6 +36,7 @@ public sealed partial class DirectExecutionBackend
private readonly object _importResultLogSampleGate = new();
private readonly Dictionary<string, int> _importResultLogSamples = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _unresolvedImportLogSamples = new(StringComparer.Ordinal);
private int _il2CppExceptionDiagnosticCount;
private static ulong ImportDispatchGatewayManaged(nint backendHandle, int importIndex, nint argPackPtr)
@ -163,6 +164,14 @@ public sealed partial class DirectExecutionBackend
LastError = $"Import dispatch index out of range: {importIndex}";
return 18446744071562199042uL;
}
// Stop before the leaf fast paths too: services may already be tearing down.
// ActiveForcedGuestExit includes the global request on this backend, so testing
// its negation here would prevent every shutdown redirect.
if (_forcedGuestExit && TryEndGuestSliceForShutdown(argPackPtr))
{
cpuContext[CpuRegister.Rax] = 1uL;
return 1uL;
}
ImportStubEntry importStubEntry = _importEntries[importIndex];
using var registerPacketImport = SharpEmu.Libs.Diagnostics.AgcRegisterPacketProfile.MeasureImport(importStubEntry.Nid);
if (_perfHleHistogram)
@ -345,14 +354,6 @@ public sealed partial class DirectExecutionBackend
Console.Error.WriteLine(
$"[LOADER][TRACE] bootstrap_call#{num}: op=0x{value:X16} sym_ptr=0x{value2:X16} sym='{symbolText}' out_ptr=0x{num3:X16} ret=0x{num7:X16}");
}
// Once the host is shutting down, a guest thread still running reaches no more HLE: the
// services behind the imports are being torn down, and a title that sees them fail (a null
// command buffer from the GPU library, say) crashes the process on its way out.
if (_forcedGuestExit && !ActiveForcedGuestExit && TryEndGuestSliceForShutdown(argPackPtr))
{
cpuContext[CpuRegister.Rax] = 1uL;
return 1uL;
}
if (!isGuestWorker &&
!ActiveForcedGuestExit &&
ShouldForceGuestExitOnImportLoop(in importStubEntry, num7, num, value, value2))
@ -594,9 +595,12 @@ public sealed partial class DirectExecutionBackend
{
DumpIl2CppExceptionDiagnostic(cpuContext, value, num7);
}
Console.Error.WriteLine(
$"[LOADER][WARN] Import#{num} unresolved: nid={importStubEntry.Nid} ret=0x{num7:X16} " +
$"rdi=0x{value:X16} rsi=0x{value2:X16} rdx=0x{num3:X16} rcx=0x{num4:X16} r8=0x{num5:X16} r9=0x{num6:X16}");
if (ShouldLogUnresolvedImport(importStubEntry.Nid, out var unresolvedCount))
{
Console.Error.WriteLine(
$"[LOADER][WARN] Import#{num} unresolved occurrence={unresolvedCount}: nid={importStubEntry.Nid} ret=0x{num7:X16} " +
$"rdi=0x{value:X16} rsi=0x{value2:X16} rdx=0x{num3:X16} rcx=0x{num4:X16} r8=0x{num5:X16} r9=0x{num6:X16}");
}
if (importStubEntry.Nid == "L-Q3LEjIbgA")
{
string value18 = string.Join(" ", importStubEntry.Nid.Select(delegate (char c)
@ -1665,12 +1669,19 @@ public sealed partial class DirectExecutionBackend
var expectedUserServiceNoEvent =
string.Equals(nid, "yH17Q6NWtVg", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80960007);
var expectedSystemServiceNoEvent =
string.Equals(nid, "656LMQSrg6U", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80A10004);
var expectedPrivacyInvalidParameter =
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80960009);
var expectedPlayGoChunkEnumerationEnd =
string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80B2000C);
// NO_ENTITLEMENT: the title asks about add-on content the user does not own.
var expectedAddcontNotOwned =
string.Equals(nid, "xddD23+8TfQ", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x817D0007);
if (!expectedFileProbeMiss &&
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
@ -1682,8 +1693,10 @@ public sealed partial class DirectExecutionBackend
!expectedPollSemaBusy &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedSystemServiceNoEvent &&
!expectedPrivacyInvalidParameter &&
!expectedPlayGoChunkEnumerationEnd)
!expectedPlayGoChunkEnumerationEnd &&
!expectedAddcontNotOwned)
{
return true;
}
@ -1705,6 +1718,19 @@ public sealed partial class DirectExecutionBackend
return count <= 8 || count % 10000 == 0;
}
private bool ShouldLogUnresolvedImport(string nid, out long count)
{
// Keep the first call sites and periodic totals without formatting a warning
// on every invocation of an unsupported export. Dispatch still returns its error.
lock (_importResultLogSampleGate)
{
_unresolvedImportLogSamples.TryGetValue(nid, out count);
count++;
_unresolvedImportLogSamples[nid] = count;
}
return count <= 8 || count % 10000 == 0;
}
private static bool ShouldLogExpectedImportResults() =>
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_EXPECTED_IMPORT_RESULTS"),
@ -1869,7 +1895,7 @@ public sealed partial class DirectExecutionBackend
}
}
// Returns the import straight to the host entry stub, which ends this guest slice.
// Return directly to the host entry stub instead of executing another HLE call.
private unsafe bool TryEndGuestSliceForShutdown(nint argPackPtr)
{
ulong sentinel = ActiveEntryReturnSentinelRip;

View File

@ -3982,6 +3982,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
public bool SupportsGuestContextTransfer => true;
// The handle and context this host thread registered last; a repeat registration (every
// scePthreadSelf on an external guest thread, tens of thousands a second) then skips the
// scheduler gate.
[ThreadStatic] private static ulong _registeredExternalHandle;
[ThreadStatic] private static CpuContext? _registeredExternalContext;
public bool TryGetGuestThreadStackBounds(
ulong threadHandle,
out ulong stackBase,
@ -4016,6 +4021,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (_registeredExternalHandle == threadHandle && ReferenceEquals(_registeredExternalContext, context))
{
Volatile.Write(ref _currentExternalGuestThreadHandle, threadHandle);
return;
}
lock (_guestThreadGate)
{
_currentExternalGuestThreadHandle = threadHandle;
@ -4029,15 +4040,19 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
existing.Context = context;
Volatile.Write(ref existing.HostThreadId, hostThreadId);
return;
}
else
{
_externalGuestThreads[threadHandle] = new ExternalGuestThreadState
{
Context = context,
Name = $"External-{threadHandle:X}",
HostThreadId = hostThreadId,
};
}
_externalGuestThreads[threadHandle] = new ExternalGuestThreadState
{
Context = context,
Name = $"External-{threadHandle:X}",
HostThreadId = hostThreadId,
};
_registeredExternalHandle = threadHandle;
_registeredExternalContext = context;
}
}
@ -5604,7 +5619,25 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return false;
}
private static bool TryMapGuestThreadRegion(
// Finding a free slot and mapping it must be one step: Map reuses a region that
// already exists, so two threads claiming at once would both get the same stack.
private static readonly object GuestThreadRegionGate = new();
internal static bool TryMapGuestThreadRegion(
IVirtualMemory virtualMemory,
ulong baseAddress,
ulong size,
ProgramHeaderFlags protection,
out ulong mappedBase,
out string? error)
{
lock (GuestThreadRegionGate)
{
return TryMapFreeGuestThreadRegion(virtualMemory, baseAddress, size, protection, out mappedBase, out error);
}
}
private static bool TryMapFreeGuestThreadRegion(
IVirtualMemory virtualMemory,
ulong baseAddress,
ulong size,
@ -5645,6 +5678,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
IVirtualMemory virtualMemory,
out ulong tlsBase,
out string? error)
{
lock (GuestThreadRegionGate)
{
return TryMapFreeGuestThreadTlsRegion(virtualMemory, out tlsBase, out error);
}
}
private static bool TryMapFreeGuestThreadTlsRegion(
IVirtualMemory virtualMemory,
out ulong tlsBase,
out string? error)
{
for (int i = 0; i < GuestThreadRegionSlots; i++)
{
@ -6383,19 +6427,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return GuestNativeCallExitReason.Exception;
}
const uint stubSize = 512u;
void* ptr = VirtualAlloc(null, stubSize, 12288u, 4u);
if (ptr == null)
// The stub page and host-RSP slot are kept per host thread and nesting depth: allocating
// and releasing them on every resume cost an mmap/munmap pair and two acquisitions of the
// host-memory gate, which the GPU write tracker holds across its own protection changes.
var depth = _continuationDepth;
if (!TryAcquireContinuationStub(depth, stubSize, out var ptr, out var hostRspStorage, out reason))
{
reason = "failed to allocate executable memory for guest thread stub";
return GuestNativeCallExitReason.Exception;
}
void* hostRspStorage = NativeMemory.AllocZeroed((nuint)HostStackStateBytes);
if (hostRspStorage == null)
{
VirtualFree(ptr, 0u, 32768u);
reason = "failed to allocate writable host-RSP storage for guest continuation stub";
return GuestNativeCallExitReason.Exception;
}
_continuationDepth = depth + 1;
var previousActiveBackend = _activeExecutionBackend;
var previousActiveContext = _activeCpuContext;
var previousSentinel = _activeEntryReturnSentinelRip;
@ -6469,8 +6509,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
reason = $"failed to patch guest continuation return slot at 0x{returnSlotAddress:X16}";
return GuestNativeCallExitReason.Exception;
}
uint oldProtect = default(uint);
if (!VirtualProtect(ptr, stubSize, 32u, &oldProtect))
if (!ProtectContinuationStub(ptr, stubSize, executable: true))
{
reason = "failed to seal guest continuation stub execute-read";
return GuestNativeCallExitReason.Exception;
@ -6530,11 +6569,85 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
previousForcedExit,
previousYieldRequested,
previousYieldReason);
NativeMemory.Free(hostRspStorage);
VirtualFree(ptr, 0u, 32768u);
_continuationDepth = depth;
}
}
[ThreadStatic] private static nint[]? _continuationStubPages;
[ThreadStatic] private static nint[]? _continuationHostRspSlots;
[ThreadStatic] private static int _continuationDepth;
// Returns this thread's stub page for the given nesting depth, writable and with a zeroed
// host-RSP slot; the first use at a depth allocates them, and they live as long as the thread.
private unsafe bool TryAcquireContinuationStub(int depth, uint stubSize, out void* stub, out void* hostRspStorage, out string? reason)
{
reason = null;
stub = null;
hostRspStorage = null;
var pages = _continuationStubPages ??= new nint[4];
var slots = _continuationHostRspSlots ??= new nint[4];
if (depth >= pages.Length)
{
Array.Resize(ref pages, depth + 2);
Array.Resize(ref slots, depth + 2);
_continuationStubPages = pages;
_continuationHostRspSlots = slots;
}
if (pages[depth] == 0)
{
var page = VirtualAlloc(null, stubSize, 12288u, 4u);
if (page == null)
{
reason = "failed to allocate executable memory for guest thread stub";
return false;
}
var slot = NativeMemory.AllocZeroed((nuint)HostStackStateBytes);
if (slot == null)
{
VirtualFree(page, 0u, 32768u);
reason = "failed to allocate writable host-RSP storage for guest continuation stub";
return false;
}
pages[depth] = (nint)page;
slots[depth] = (nint)slot;
}
else
{
if (!ProtectContinuationStub((void*)pages[depth], stubSize, executable: false))
{
reason = "failed to reopen guest continuation stub for writing";
return false;
}
NativeMemory.Clear((void*)slots[depth], (nuint)HostStackStateBytes);
}
stub = (void*)pages[depth];
hostRspStorage = (void*)slots[depth];
return true;
}
// The stub page is emulator-private, so on POSIX its protection changes bypass the tracked
// host-memory layer and its gate.
private static unsafe bool ProtectContinuationStub(void* stub, uint stubSize, bool executable)
{
if (OperatingSystem.IsWindows())
{
uint oldProtect = 0;
return VirtualProtect(stub, stubSize, executable ? 32u : 4u, &oldProtect);
}
var pageSize = (nint)Environment.SystemPageSize;
var page = (nint)stub & ~(pageSize - 1);
return PosixMprotect(page, (nuint)pageSize, executable ? 1 | 4 : 1 | 2) == 0;
}
[DllImport("libc", EntryPoint = "mprotect")]
private static extern int PosixMprotect(nint address, nuint length, int protection);
// The continuation trampoline is rebuilt on every blocked-thread resume.
// Keep its tiny writer on the stack: capturing local emit functions create a
// managed display-class allocation on this extremely hot path.

View File

@ -322,20 +322,43 @@ internal static unsafe class PosixHostStubs
private static nint EmitSleep(byte* page, ref int offset)
{
// void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi).
var usleep = ResolveLibcExport("usleep");
// void Sleep(DWORD milliseconds in ecx) -> nanosleep(&req, &rem), resumed on EINTR. usleep
// would be shorter, but macOS rejects usleep(>= 1 s) with EINVAL, and a signal (the
// runtime's thread activations, for one) cuts either short: a sleep that returned at once
// on an ignored error turned UE's idle HTTP thread into a hot spin.
var nanosleep = ResolveLibcExport("nanosleep");
var errorLocation = ResolveLibcExport(OperatingSystem.IsMacOS() ? "__error" : "__errno_location");
var start = (nint)(page + offset);
Emit(page, ref offset, 0x56); // push rsi
Emit(page, ref offset, 0x57); // push rdi
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF
Emit(page, ref offset, 0x76, 0x05); // jbe +5
Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s)
Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000
EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64
Emit(page, ref offset, 0x53); // push rbx
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x20); // sub rsp, 0x20: req at [rsp], rem at [rsp+16]
Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx
Emit(page, ref offset, 0x31, 0xD2); // xor edx, edx
Emit(page, ref offset, 0xB9, 0xE8, 0x03, 0x00, 0x00); // mov ecx, 1000
Emit(page, ref offset, 0xF7, 0xF1); // div ecx: eax = seconds, edx = milliseconds
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax (tv_sec)
Emit(page, ref offset, 0x69, 0xD2, 0x40, 0x42, 0x0F, 0x00); // imul edx, edx, 1000000
Emit(page, ref offset, 0x48, 0x89, 0x54, 0x24, 0x08); // mov [rsp+8], rdx (tv_nsec)
// loop:
Emit(page, ref offset, 0x48, 0x89, 0xE7); // mov rdi, rsp
Emit(page, ref offset, 0x48, 0x8D, 0x74, 0x24, 0x10); // lea rsi, [rsp+16]
EmitMovRaxImm64(page, ref offset, nanosleep); // mov rax, imm64
Emit(page, ref offset, 0xFF, 0xD0); // call rax
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
Emit(page, ref offset, 0x85, 0xC0); // test eax, eax
Emit(page, ref offset, 0x74, 0x26); // jz done
EmitMovRaxImm64(page, ref offset, errorLocation); // mov rax, imm64
Emit(page, ref offset, 0xFF, 0xD0); // call rax
Emit(page, ref offset, 0x83, 0x38, 0x04); // cmp dword [rax], EINTR
Emit(page, ref offset, 0x75, 0x15); // jne done
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x10); // mov rax, [rsp+16]
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x18); // mov rax, [rsp+24]
Emit(page, ref offset, 0x48, 0x89, 0x44, 0x24, 0x08); // mov [rsp+8], rax
Emit(page, ref offset, 0xEB, 0xC2); // jmp loop
// done:
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x20); // add rsp, 0x20
Emit(page, ref offset, 0x5B); // pop rbx
Emit(page, ref offset, 0x5F); // pop rdi
Emit(page, ref offset, 0x5E); // pop rsi
Emit(page, ref offset, 0xC3); // ret

View File

@ -20,7 +20,8 @@ internal static class GuestRedZonePatcher
private const ulong AllocationAlignment = 0x10000;
private const ulong MaximumRelativeJumpDistance = 0x7FFF_FFFF;
private const int MaximumJumpTableEntries = 4096;
private const int MaximumJumpTableSetupInstructions = 6;
private const int MaximumJumpTableChainInstructions = 8;
private const int MaximumJumpTableRounds = 4;
private const ulong LargeExecutableBytes = 32UL << 20;
private static readonly TimeSpan ProgressInterval = TimeSpan.FromSeconds(2);
@ -425,202 +426,476 @@ internal static class GuestRedZonePatcher
ulong functionEnd,
HashSet<ulong> branchTargets)
{
List<ulong>? resolvedTargets = null;
HashSet<ulong>? instructionBoundaries = null;
for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++)
{
if (decoded[instructionIndex].Instruction.FlowControl != FlowControl.IndirectBranch)
{
continue;
}
instructionBoundaries ??= decoded
.Select(static entry => entry.Instruction.IP)
.ToHashSet();
resolvedTargets ??= new List<ulong>();
if (!TryResolveJumpTable(
memory,
programHeaders,
imageBase,
decoded,
instructionIndex,
functionStart,
functionEnd,
instructionBoundaries,
branchTargets,
resolvedTargets))
{
return false;
}
}
if (resolvedTargets is not null)
{
branchTargets.UnionWith(resolvedTargets);
}
return true;
return TryCollectJumpTableTargets(
(address, entries) =>
IsRangeInReadableLoad(programHeaders, imageBase, address, (ulong)entries.Length) &&
memory.TryRead(address, entries),
decoded,
functionStart,
functionEnd,
branchTargets);
}
private static bool TryResolveJumpTable(
IVirtualMemory memory,
IReadOnlyList<ProgramHeader> programHeaders,
ulong imageBase,
// Resolve every indirect jump of the function as a bounded relative jump table, or refuse.
// Bounds and table addresses are proven by walking the control-flow graph backwards
// from the table load. The graph includes the edges of the tables themselves, so the
// resolution is repeated until a round reproduces the targets it was built from.
private static bool TryCollectJumpTableTargets(
JumpTableReader readTable,
IReadOnlyList<DecodedInstruction> decoded,
ulong functionStart,
ulong functionEnd,
HashSet<ulong> branchTargets)
{
List<int>? jumps = null;
for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++)
{
if (decoded[instructionIndex].Instruction.FlowControl == FlowControl.IndirectBranch)
{
(jumps ??= []).Add(instructionIndex);
}
}
if (jumps is null)
{
return true;
}
var indexByAddress = new Dictionary<ulong, int>(decoded.Count);
for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++)
{
indexByAddress[decoded[instructionIndex].Instruction.IP] = instructionIndex;
}
var infoFactory = new InstructionInfoFactory();
var tableTargets = new Dictionary<int, ulong[]>();
for (var round = 0; round < MaximumJumpTableRounds; round++)
{
var predecessors = BuildPredecessors(decoded, indexByAddress, tableTargets);
var resolved = new Dictionary<int, ulong[]>();
foreach (var jumpIndex in jumps)
{
if (!TryResolveJumpTable(
readTable,
infoFactory,
decoded,
predecessors,
indexByAddress,
jumpIndex,
functionStart,
functionEnd,
out var targets))
{
return false;
}
resolved[jumpIndex] = targets;
}
if (resolved.Count == tableTargets.Count &&
resolved.All(entry => tableTargets.TryGetValue(entry.Key, out var previous) &&
previous.AsSpan().SequenceEqual(entry.Value)))
{
foreach (var targets in resolved.Values)
{
branchTargets.UnionWith(targets);
}
return true;
}
tableTargets = resolved;
}
return false;
}
private static List<(int Index, bool Fallthrough)>?[] BuildPredecessors(
IReadOnlyList<DecodedInstruction> decoded,
Dictionary<ulong, int> indexByAddress,
Dictionary<int, ulong[]> tableTargets)
{
var predecessors = new List<(int Index, bool Fallthrough)>?[decoded.Count];
for (var index = 0; index < decoded.Count; index++)
{
var instruction = decoded[index].Instruction;
switch (instruction.FlowControl)
{
case FlowControl.UnconditionalBranch:
case FlowControl.IndirectBranch:
case FlowControl.Return:
case FlowControl.Exception:
break;
default:
if (index + 1 < decoded.Count && decoded[index + 1].Instruction.IP == instruction.NextIP)
{
(predecessors[index + 1] ??= []).Add((index, true));
}
break;
}
if (instruction.FlowControl is FlowControl.UnconditionalBranch or FlowControl.ConditionalBranch &&
TryGetNearBranchTarget(instruction, out var target) &&
indexByAddress.TryGetValue(target, out var targetIndex))
{
(predecessors[targetIndex] ??= []).Add((index, false));
}
if (instruction.FlowControl == FlowControl.IndirectBranch &&
tableTargets.TryGetValue(index, out var targets))
{
foreach (var tableTarget in targets)
{
(predecessors[indexByAddress[tableTarget]] ??= []).Add((index, false));
}
}
}
return predecessors;
}
private static bool HasOnlyFallthroughPredecessor(
List<(int Index, bool Fallthrough)>?[] predecessors,
int index)
{
return predecessors[index] is [(var previous, true)] && previous == index - 1;
}
// jmp T, preceded by add T, B and movsxd T, dword [B + I*4] in one straight-line run.
private static bool TryResolveJumpTable(
JumpTableReader readTable,
InstructionInfoFactory infoFactory,
IReadOnlyList<DecodedInstruction> decoded,
List<(int Index, bool Fallthrough)>?[] predecessors,
Dictionary<ulong, int> indexByAddress,
int jumpIndex,
ulong functionStart,
ulong functionEnd,
IReadOnlySet<ulong> instructionBoundaries,
IReadOnlySet<ulong> knownBranchTargets,
List<ulong> targets)
out ulong[] targets)
{
if (jumpIndex < 4)
{
return false;
}
targets = [];
var jump = decoded[jumpIndex].Instruction;
var add = decoded[jumpIndex - 1].Instruction;
var load = decoded[jumpIndex - 2].Instruction;
if (jump.OpCount != 1 || jump.GetOpKind(0) != OpKind.Register ||
add.Mnemonic != Mnemonic.Add || add.OpCount != 2 ||
add.GetOpKind(0) != OpKind.Register || add.GetOpKind(1) != OpKind.Register ||
load.Mnemonic != Mnemonic.Movsxd || load.OpCount != 2 ||
load.GetOpKind(0) != OpKind.Register || load.GetOpKind(1) != OpKind.Memory ||
load.MemorySize.GetSize() != sizeof(int) || load.MemoryIndexScale != sizeof(int) ||
load.MemoryBase == Register.None || load.MemoryIndex == Register.None ||
load.MemoryDisplacement64 != 0)
jump.GetOpRegister(0).GetSize() != sizeof(ulong))
{
return false;
}
var targetRegister = jump.GetOpRegister(0).GetFullRegister();
var tableRegister = add.GetOpRegister(1).GetFullRegister();
var selectorRegister = load.MemoryIndex.GetFullRegister();
if (targetRegister != add.GetOpRegister(0).GetFullRegister() ||
targetRegister != load.GetOpRegister(0).GetFullRegister() ||
tableRegister != load.MemoryBase.GetFullRegister() ||
targetRegister.GetSize() != sizeof(ulong) ||
tableRegister.GetSize() != sizeof(ulong) ||
selectorRegister.GetSize() != sizeof(ulong) ||
tableRegister == selectorRegister)
{
return false;
}
var branchIndex = -1;
for (var index = jumpIndex - 3;
index >= 1 && index >= jumpIndex - MaximumJumpTableSetupInstructions;
var targetRegister = jump.GetOpRegister(0);
var tableRegister = Register.None;
var loadIndex = -1;
for (var index = jumpIndex - 1;
index >= 0 && index >= jumpIndex - MaximumJumpTableChainInstructions;
index--)
{
if (!HasOnlyFallthroughPredecessor(predecessors, index + 1))
{
return false;
}
var candidate = decoded[index].Instruction;
if (candidate.Mnemonic == Mnemonic.Ja)
if (tableRegister == Register.None)
{
branchIndex = index;
break;
if (!WritesRegister(infoFactory, candidate, targetRegister))
{
continue;
}
if (candidate.Mnemonic != Mnemonic.Add || candidate.OpCount != 2 ||
candidate.GetOpKind(0) != OpKind.Register || candidate.GetOpKind(1) != OpKind.Register ||
candidate.GetOpRegister(0) != targetRegister ||
candidate.GetOpRegister(1).GetSize() != sizeof(ulong) ||
candidate.GetOpRegister(1) == targetRegister)
{
return false;
}
tableRegister = candidate.GetOpRegister(1);
continue;
}
if (candidate.Mnemonic != Mnemonic.Lea)
{
return false;
}
}
if (branchIndex < 1)
{
return false;
}
for (var index = branchIndex + 1; index <= jumpIndex; index++)
{
if (knownBranchTargets.Contains(decoded[index].Instruction.IP))
{
return false;
}
}
var boundsBranch = decoded[branchIndex].Instruction;
var boundsCompare = decoded[branchIndex - 1].Instruction;
if (boundsBranch.FlowControl != FlowControl.ConditionalBranch ||
!TryGetNearBranchTarget(boundsBranch, out var defaultTarget) ||
defaultTarget < functionStart || defaultTarget >= functionEnd ||
boundsCompare.Mnemonic != Mnemonic.Cmp || boundsCompare.OpCount != 2 ||
boundsCompare.GetOpKind(0) != OpKind.Register ||
boundsCompare.GetOpRegister(0).GetFullRegister() != selectorRegister ||
boundsCompare.GetOpRegister(0).GetSize() != sizeof(int) ||
!TryGetNonNegativeImmediate(boundsCompare, 1, out var maximumIndex) ||
maximumIndex >= MaximumJumpTableEntries)
{
return false;
}
var tableAddress = 0UL;
var tableDefinitionFound = false;
for (var index = branchIndex + 1; index <= jumpIndex - 3; index++)
{
var candidate = decoded[index].Instruction;
if (candidate.OpCount < 1 || candidate.GetOpKind(0) != OpKind.Register)
if (WritesRegister(infoFactory, candidate, tableRegister))
{
return false;
}
var writtenRegister = candidate.GetOpRegister(0).GetFullRegister();
if (writtenRegister == selectorRegister)
{
return false;
}
if (writtenRegister != tableRegister)
if (!WritesRegister(infoFactory, candidate, targetRegister))
{
continue;
}
if (tableDefinitionFound || candidate.OpCount != 2 ||
candidate.GetOpKind(1) != OpKind.Memory || !candidate.IsIPRelativeMemoryOperand)
if (candidate.Mnemonic != Mnemonic.Movsxd || candidate.OpCount != 2 ||
candidate.GetOpKind(0) != OpKind.Register || candidate.GetOpKind(1) != OpKind.Memory ||
candidate.GetOpRegister(0) != targetRegister ||
candidate.MemorySize.GetSize() != sizeof(int) || candidate.MemoryIndexScale != sizeof(int) ||
candidate.MemoryBase != tableRegister || candidate.MemoryDisplacement64 != 0 ||
candidate.MemoryIndex.GetSize() != sizeof(ulong) ||
candidate.MemoryIndex == tableRegister || candidate.SegmentPrefix != Register.None)
{
return false;
}
tableAddress = candidate.IPRelativeMemoryAddress;
tableDefinitionFound = true;
loadIndex = index;
break;
}
if (!tableDefinitionFound)
if (loadIndex < 0)
{
return false;
}
var selectorRegister = decoded[loadIndex].Instruction.MemoryIndex;
if (!TryProveSelectorBound(infoFactory, decoded, predecessors, loadIndex, selectorRegister, out var maximumIndex) ||
!TryProveTableAddress(infoFactory, decoded, predecessors, loadIndex, tableRegister, out var tableAddress))
{
return false;
}
var entryCount = checked((int)maximumIndex + 1);
var tableBytes = checked(entryCount * sizeof(int));
if (!IsRangeInReadableLoad(programHeaders, imageBase, tableAddress, (ulong)tableBytes))
var entries = GC.AllocateUninitializedArray<byte>(checked(entryCount * sizeof(int)));
if (!readTable(tableAddress, entries))
{
return false;
}
var entries = GC.AllocateUninitializedArray<byte>(tableBytes);
if (!memory.TryRead(tableAddress, entries))
{
return false;
}
var firstTarget = targets.Count;
var resolved = new ulong[entryCount];
for (var index = 0; index < entryCount; index++)
{
var relative = BinaryPrimitives.ReadInt32LittleEndian(entries.AsSpan(index * sizeof(int)));
if (!TryAddSignedOffset(tableAddress, relative, out var target) ||
target < functionStart || target >= functionEnd ||
!instructionBoundaries.Contains(target))
!indexByAddress.ContainsKey(target))
{
targets.RemoveRange(firstTarget, targets.Count - firstTarget);
return false;
}
targets.Add(target);
resolved[index] = target;
}
targets = resolved;
return true;
}
// Every path into the load must leave the fallthrough of ja after an unsigned compare of
// the selector, then reach the load through register copies and zero-extensions only.
// A compare narrower than the selector bounds it only when a zero-extension on that path
// cleared the bits above the compared ones.
private static bool TryProveSelectorBound(
InstructionInfoFactory infoFactory,
IReadOnlyList<DecodedInstruction> decoded,
List<(int Index, bool Fallthrough)>?[] predecessors,
int loadIndex,
Register selectorRegister,
out ulong maximumIndex)
{
maximumIndex = 0;
var bounded = false;
var visited = new HashSet<(int Index, Register Register, int Bits)>();
var pending = new Stack<(int Index, Register Register, int Bits)>();
pending.Push((loadIndex, selectorRegister, 64));
while (pending.TryPop(out var state))
{
if (state.Index == 0)
{
return false;
}
if (predecessors[state.Index] is not { } incoming)
{
continue;
}
foreach (var (predecessorIndex, fallthrough) in incoming)
{
var predecessor = decoded[predecessorIndex].Instruction;
if (fallthrough && predecessor.Mnemonic == Mnemonic.Ja &&
TryGetNearBranchTarget(predecessor, out var branchTarget) &&
branchTarget != predecessor.NextIP &&
predecessorIndex >= 1 &&
HasOnlyFallthroughPredecessor(predecessors, predecessorIndex) &&
TryGetSelectorCompareBound(decoded[predecessorIndex - 1].Instruction, state.Register, state.Bits, out var bound))
{
maximumIndex = Math.Max(maximumIndex, bound);
bounded = true;
continue;
}
var register = state.Register;
var bits = state.Bits;
if (WritesRegister(infoFactory, predecessor, register) &&
!TryTraceSelectorDefinition(predecessor, ref register, ref bits))
{
return false;
}
if (visited.Add((predecessorIndex, register, bits)))
{
pending.Push((predecessorIndex, register, bits));
}
}
}
return bounded;
}
private static bool TryGetSelectorCompareBound(in Instruction compare, Register register, int bits, out ulong bound)
{
bound = 0;
if (compare.Mnemonic != Mnemonic.Cmp || compare.OpCount != 2 ||
compare.GetOpKind(0) != OpKind.Register ||
compare.GetOpRegister(0) is Register.AH or Register.CH or Register.DH or Register.BH ||
compare.GetOpRegister(0).GetFullRegister() != register.GetFullRegister() ||
!TryGetNonNegativeImmediate(compare, 1, out bound) ||
bound >= MaximumJumpTableEntries)
{
return false;
}
// A 32-bit compare of a 64-bit selector keeps the long-standing assumption that
// compilers emit it only for a zero-extended 32-bit value.
var compareBits = compare.GetOpRegister(0).GetSize() * 8;
return compareBits >= bits || compareBits == 32;
}
// Follow the selector through movzx and register moves; any other definition is unbounded.
private static bool TryTraceSelectorDefinition(in Instruction definition, ref Register register, ref int bits)
{
if (definition.OpCount != 2 ||
definition.GetOpKind(0) != OpKind.Register || definition.GetOpKind(1) != OpKind.Register ||
definition.GetOpRegister(0).GetFullRegister() != register.GetFullRegister())
{
return false;
}
var destinationSize = definition.GetOpRegister(0).GetSize();
var source = definition.GetOpRegister(1);
var sourceSize = source.GetSize();
if (!source.IsGPR() || destinationSize < sizeof(int) ||
source is Register.AH or Register.CH or Register.DH or Register.BH)
{
return false;
}
switch (definition.Mnemonic)
{
case Mnemonic.Movzx when sourceSize < sizeof(int):
case Mnemonic.Mov when sourceSize == destinationSize:
register = source;
bits = Math.Min(bits, sourceSize * 8);
return true;
default:
return false;
}
}
// Every path into the load must define the table register with lea of the same RIP-relative table.
private static bool TryProveTableAddress(
InstructionInfoFactory infoFactory,
IReadOnlyList<DecodedInstruction> decoded,
List<(int Index, bool Fallthrough)>?[] predecessors,
int loadIndex,
Register tableRegister,
out ulong tableAddress)
{
tableAddress = 0;
var defined = false;
var visited = new HashSet<int>();
var pending = new Stack<int>();
pending.Push(loadIndex);
while (pending.TryPop(out var index))
{
if (index == 0)
{
return false;
}
if (predecessors[index] is not { } incoming)
{
continue;
}
foreach (var (predecessorIndex, _) in incoming)
{
var predecessor = decoded[predecessorIndex].Instruction;
if (!WritesRegister(infoFactory, predecessor, tableRegister))
{
if (visited.Add(predecessorIndex))
{
pending.Push(predecessorIndex);
}
continue;
}
if (predecessor.Mnemonic != Mnemonic.Lea || predecessor.OpCount != 2 ||
predecessor.GetOpKind(0) != OpKind.Register ||
predecessor.GetOpRegister(0) != tableRegister ||
!predecessor.IsIPRelativeMemoryOperand ||
(defined && predecessor.IPRelativeMemoryAddress != tableAddress))
{
return false;
}
tableAddress = predecessor.IPRelativeMemoryAddress;
defined = true;
}
}
return defined;
}
private static bool WritesRegister(InstructionInfoFactory infoFactory, in Instruction instruction, Register register)
{
var fullRegister = register.GetFullRegister();
if (instruction.FlowControl is FlowControl.Call or FlowControl.IndirectCall &&
fullRegister is Register.RAX or Register.RCX or Register.RDX or Register.RSI or Register.RDI or
Register.R8 or Register.R9 or Register.R10 or Register.R11)
{
return true;
}
foreach (var used in infoFactory.GetInfo(instruction).GetUsedRegisters())
{
if (used.Access is OpAccess.Write or OpAccess.CondWrite or OpAccess.ReadWrite or OpAccess.ReadCondWrite &&
used.Register.GetFullRegister() == fullRegister)
{
return true;
}
}
return false;
}
// Test entry point: the code occupies the first codeLength bytes, the tables follow it.
internal static bool TryResolveJumpTableTargets(
byte[] image,
ulong baseAddress,
int codeLength,
out ulong[] targets)
{
var functionEnd = baseAddress + (ulong)codeLength;
var decoded = DecodeFunction(image, baseAddress, baseAddress, functionEnd);
var resolved = new HashSet<ulong>();
var success = TryCollectJumpTableTargets(
(address, entries) =>
{
if (address < baseAddress || address - baseAddress + (ulong)entries.Length > (ulong)image.Length)
{
return false;
}
image.AsSpan((int)(address - baseAddress), entries.Length).CopyTo(entries);
return true;
},
decoded,
baseAddress,
functionEnd,
resolved);
targets = [.. resolved.Order()];
return success;
}
private static bool IsRangeInReadableLoad(
IReadOnlyList<ProgramHeader> programHeaders,
ulong imageBase,
@ -1431,6 +1706,8 @@ internal static class GuestRedZonePatcher
private static ulong AlignDown(ulong value, ulong alignment) => value & ~(alignment - 1);
private delegate bool JumpTableReader(ulong address, byte[] entries);
private readonly record struct DecodedInstruction(Instruction Instruction);
/// <summary>

View File

@ -140,12 +140,13 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private GuestSpaceOwner? _backedSpace;
public PhysicalVirtualMemory(IHostMemory? hostMemory = null, IHostViewMemory? viewHost = null,
ulong backingBytes = GuestMemoryLayout.BackingBytes, bool preReserveGuestAddressSpace = false)
ulong backingBytes = 0, bool preReserveGuestAddressSpace = false)
{
_hostMemory = hostMemory ?? CrossPlatformHostMemory.Instance;
if (viewHost != null)
{
_backedSpace = new GuestSpaceOwner(viewHost, backingBytes, preReserveGuestAddressSpace);
// Zero means the whole guest backing (direct plus flexible memory).
_backedSpace = new GuestSpaceOwner(viewHost, backingBytes == 0 ? GuestMemoryLayout.BackingBytes : backingBytes, preReserveGuestAddressSpace);
RunBackingSelfTest();
}
}

View File

@ -1401,6 +1401,15 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
Console.Error.WriteLine("[LOADER][INFO] " + gpuSummary);
}
// The presenter shuts down on its own thread; its caches still hold page watches
// and may still run queued GPU work until it detaches its stores.
if (!_gpuMemory.WaitForStoresDetached(TimeSpan.FromSeconds(15)))
{
Console.Error.WriteLine(
"[RUNTIME] The GPU caches did not release guest memory in time; keeping it mapped.");
return;
}
GuestGpuMemoryHook.Attach(null);
_gpuMemory.Dispose();
}

View File

@ -70,6 +70,28 @@ public sealed class GuiSettings
public string Resolution { get; set; } = "1920x1080";
public string GuestResolution { get; set; } = "1920x1080";
internal static string NormalizeGuestResolution(string? value) =>
string.Equals(value, "3840x2160", StringComparison.OrdinalIgnoreCase) ? "3840x2160" : "1920x1080";
// Host resolution multiplier of the render targets the renderer is allowed to scale.
public float RenderScale { get; set; } = 1.0f;
internal static float NormalizeRenderScale(float value) =>
SharpEmu.Libs.VideoOut.HostVideoOptions.NormalizeRenderScale(value);
internal static readonly string[] RenderScaleChoices = ["50%", "75%", "100%", "125%", "150%", "200%"];
internal static string RenderScaleText(float value) =>
((int)MathF.Round(NormalizeRenderScale(value) * 100)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
internal static float ParseRenderScale(string? text) =>
int.TryParse(text?.TrimEnd('%'), System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var percent)
? NormalizeRenderScale(percent / 100.0f)
: 1.0f;
public int DisplayIndex { get; set; }
public int RefreshRate { get; set; }
@ -89,9 +111,6 @@ public sealed class GuiSettings
public string BinkPlaybackMode { get; set; } = "Guest";
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as
@ -184,6 +203,8 @@ public sealed class GuiSettings
"Ukrainian");
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
settings.Resolution = NormalizeResolution(settings.Resolution);
settings.GuestResolution = NormalizeGuestResolution(settings.GuestResolution);
settings.RenderScale = NormalizeRenderScale(settings.RenderScale);
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
settings.BinkPlaybackMode = NormalizeChoice(settings.BinkPlaybackMode, "Guest", "Host", "Skip");

View File

@ -219,6 +219,10 @@
"Options.WindowMode.Exclusive": "حصري",
"Options.Resolution.Label": "الدقة",
"Options.Resolution.Desc": "حجم النافذة الأولي أو دقة ملء الشاشة الحصرية.",
"Options.GuestResolution.Label": "وضع عرض اللعبة",
"Options.GuestResolution.Desc": "دقة العرض المُبلّغ عنها للعبة. الافتراضي 1080p. تختار الألعاب دقة التصيير الداخلية؛ وقد تتجاهل الألعاب ذات الدقة الثابتة هذا الإعداد.",
"Options.RenderScale.Label": "مقياس الدقة الداخلية",
"Options.RenderScale.Desc": "مضاعف دقة المضيف المطبّق على أهداف التصيير بحجم الشاشة في اللعبة. أقل من 100% يقايض جودة الصورة بالسرعة، وأكثر من 100% يفعل العكس. تبقى خرائط الظلال وجداول البحث والمواد المضغوطة والأسطح التي تكتبها مظللات الحوسبة بحجمها الأصلي دائمًا.",
"Options.Display.Label": "الشاشة",
"Options.Display.Desc": "الشاشة المستخدمة للتوسيط وملء الشاشة.",
"Options.RefreshRate.Label": "معدل التحديث",

View File

@ -74,6 +74,10 @@
"Options.WindowMode.Exclusive": "Eksklüziv",
"Options.Resolution.Label": "Təsvir ölçüsü",
"Options.Resolution.Desc": "Pəncərənin başlanğıc ölçüsü və ya eksklüziv tam ekranın təsvir ölçüsü.",
"Options.GuestResolution.Label": "Oyunun ekran rejimi",
"Options.GuestResolution.Desc": "Oyuna bildirilən ekran təsvir ölçüsü. Standart 1080p-dir. Daxili render ölçüsünü oyun seçir; sabit ölçülü oyunlar bu ayarı nəzərə almaya bilər.",
"Options.RenderScale.Label": "Daxili təsvir ölçüsü əmsalı",
"Options.RenderScale.Desc": "Oyunun ekran ölçüsündəki render hədəflərinə tətbiq olunan host təsvir ölçüsü vuruğu. 100%-dən aşağı təsvir keyfiyyətini sürətə dəyişir, yuxarı isə əksinə. Kölgə xəritələri, axtarış cədvəlləri, sıxılmış teksturlar və hesablama şeyderlərinin yazdığı səthlər həmişə öz ölçüsündə qalır.",
"Options.Display.Label": "Ekran",
"Options.Display.Desc": "Pəncərənin mərkəzə yerləşdirilməsi və tam ekran üçün istifadə olunan monitor.",
"Options.RefreshRate.Label": "Yenilənmə tezliyi",

View File

@ -220,6 +220,10 @@
"Options.WindowMode.Exclusive": "Exclusiva",
"Options.Resolution.Label": "Resolução",
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de tela cheia exclusiva.",
"Options.GuestResolution.Label": "Modo de exibição do jogo",
"Options.GuestResolution.Desc": "Resolução informada ao jogo. O padrão é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.",
"Options.RenderScale.Label": "Escala de resolução interna",
"Options.RenderScale.Desc": "Multiplicador da resolução do host aplicado aos alvos de renderização do tamanho da tela. Abaixo de 100% troca qualidade de imagem por velocidade; acima de 100% faz o contrário. Mapas de sombra, tabelas de consulta, texturas comprimidas e superfícies escritas por shaders de computação sempre mantêm o tamanho nativo.",
"Options.Display.Label": "Tela",
"Options.Display.Desc": "Monitor usado para centralização e tela cheia.",
"Options.RefreshRate.Label": "Taxa de atualização",

View File

@ -219,6 +219,10 @@
"Options.WindowMode.Exclusive": "Exklusiv",
"Options.Resolution.Label": "Auflösung",
"Options.Resolution.Desc": "Anfängliche Fenstergröße oder exklusive Vollbildauflösung.",
"Options.GuestResolution.Label": "Anzeigemodus des Spiels",
"Options.GuestResolution.Desc": "Dem Spiel gemeldete Bildschirmauflösung. Standard ist 1080p. Spiele wählen ihre interne Renderauflösung selbst; Spiele mit fester Auflösung können diese Einstellung ignorieren.",
"Options.RenderScale.Label": "Interne Auflösungsskalierung",
"Options.RenderScale.Desc": "Host-Auflösungsfaktor für die bildschirmgroßen Renderziele des Spiels. Unter 100 % wird Bildqualität gegen Geschwindigkeit getauscht, über 100 % umgekehrt. Shadow Maps, Lookup-Tabellen, komprimierte Texturen und von Compute-Shadern beschriebene Oberflächen behalten immer ihre native Größe.",
"Options.Display.Label": "Anzeige",
"Options.Display.Desc": "Monitor für Zentrierung und Vollbilddarstellung.",
"Options.RefreshRate.Label": "Bildwiederholrate",

View File

@ -219,6 +219,10 @@
"Options.WindowMode.Exclusive": "Eksklusiv",
"Options.Resolution.Label": "Opløsning",
"Options.Resolution.Desc": "Oprindelig vinduesstørrelse eller opløsning i eksklusiv fuldskærm.",
"Options.GuestResolution.Label": "Spillets skærmtilstand",
"Options.GuestResolution.Desc": "Skærmopløsning oplyst til spillet. Standard er 1080p. Spillet vælger den interne renderopløsning; spil med fast opløsning kan ignorere denne indstilling.",
"Options.RenderScale.Label": "Intern opløsningsskalering",
"Options.RenderScale.Desc": "Værtsopløsningsfaktor for spillets skærmstore rendermål. Under 100 % byttes billedkvalitet for hastighed; over 100 % omvendt. Skyggekort, opslagstabeller, komprimerede teksturer og overflader skrevet af compute-shaders beholder altid deres oprindelige størrelse.",
"Options.Display.Label": "Skærm",
"Options.Display.Desc": "Skærm, der bruges til centrering og fuldskærm.",
"Options.RefreshRate.Label": "Opdateringshastighed",

View File

@ -85,6 +85,10 @@
"Options.WindowMode.Exclusive": "Eksklusiivne",
"Options.Resolution.Label": "Eraldusvõime",
"Options.Resolution.Desc": "Algne akna suurus või eksklusiivse täisekraani eraldusvõime.",
"Options.GuestResolution.Label": "Mängu kuvarežiim",
"Options.GuestResolution.Desc": "Mängule teatatud ekraani eraldusvõime. Vaikimisi 1080p. Mäng valib sisemise renderduse eraldusvõime; fikseeritud eraldusvõimega mängud võivad seda sätet eirata.",
"Options.RenderScale.Label": "Sisemise eraldusvõime skaala",
"Options.RenderScale.Desc": "Hosti eraldusvõime kordaja mängu ekraanisuuruste renderdussihtide jaoks. Alla 100% vahetab pildikvaliteedi kiiruse vastu, üle 100% vastupidi. Varjukaardid, otsingutabelid, pakitud tekstuurid ja arvutusvarjundajate kirjutatud pinnad jäävad alati oma algsesse suurusesse.",
"Options.Display.Label": "Kuvar",
"Options.Display.Desc": "Monitor, mida kasutatakse tsentreerimiseks ja täisekraanrežiimiks.",
"Options.RefreshRate.Label": "Värskendussagedus",

View File

@ -85,6 +85,10 @@
"Options.WindowMode.Exclusive": "Exclusive",
"Options.Resolution.Label": "Resolution",
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
"Options.GuestResolution.Label": "Game display mode",
"Options.GuestResolution.Desc": "Display resolution reported to the game. Defaults to 1080p. Games choose their internal rendering resolution; fixed-resolution games may ignore this setting.",
"Options.RenderScale.Label": "Internal resolution scale",
"Options.RenderScale.Desc": "Host resolution multiplier applied to the game's screen-sized render targets. Below 100% trades image quality for speed; above 100% does the opposite. Shadow maps, lookup tables, compressed textures and compute-written surfaces always stay at their native size.",
"Options.Display.Label": "Display",
"Options.Display.Desc": "Monitor used for centering and fullscreen.",
"Options.RefreshRate.Label": "Refresh rate",

View File

@ -220,6 +220,10 @@
"Options.WindowMode.Exclusive": "Exclusiva",
"Options.Resolution.Label": "Resolución",
"Options.Resolution.Desc": "Tamaño inicial de la ventana o resolución de pantalla completa exclusiva.",
"Options.GuestResolution.Label": "Modo de pantalla del juego",
"Options.GuestResolution.Desc": "Resolución de pantalla comunicada al juego. El valor predeterminado es 1080p. El juego elige su resolución interna de renderizado; los juegos con resolución fija pueden ignorar este ajuste.",
"Options.RenderScale.Label": "Escala de resolución interna",
"Options.RenderScale.Desc": "Multiplicador de resolución del anfitrión aplicado a los destinos de renderizado del tamaño de la pantalla. Por debajo del 100 % se cambia calidad de imagen por velocidad; por encima, lo contrario. Los mapas de sombras, las tablas de consulta, las texturas comprimidas y las superficies escritas por sombreadores de cómputo conservan siempre su tamaño nativo.",
"Options.Display.Label": "Pantalla",
"Options.Display.Desc": "Monitor utilizado para centrar y mostrar a pantalla completa.",
"Options.RefreshRate.Label": "Frecuencia de actualización",

View File

@ -220,6 +220,10 @@
"Options.WindowMode.Exclusive": "Exclusif",
"Options.Resolution.Label": "Résolution",
"Options.Resolution.Desc": "Taille initiale de la fenêtre ou résolution du plein écran exclusif.",
"Options.GuestResolution.Label": "Mode d’affichage du jeu",
"Options.GuestResolution.Desc": "Résolution d’écran indiquée au jeu. La valeur par défaut est 1080p. Le jeu choisit sa résolution de rendu interne ; les jeux à résolution fixe peuvent ignorer ce réglage.",
"Options.RenderScale.Label": "Échelle de résolution interne",
"Options.RenderScale.Desc": "Multiplicateur de résolution hôte appliqué aux cibles de rendu plein écran du jeu. En dessous de 100 %, la qualité d’image est échangée contre de la vitesse ; au-dessus, l’inverse. Les cartes d’ombres, les tables de correspondance, les textures compressées et les surfaces écrites par les shaders de calcul gardent toujours leur taille native.",
"Options.Display.Label": "Écran",
"Options.Display.Desc": "Moniteur utilisé pour le centrage et le plein écran.",
"Options.RefreshRate.Label": "Fréquence de rafraîchissement",

View File

@ -220,6 +220,10 @@
"Options.WindowMode.Exclusive": "Kizárólagos",
"Options.Resolution.Label": "Felbontás",
"Options.Resolution.Desc": "Kezdeti ablakméret vagy kizárólagos teljes képernyős felbontás.",
"Options.GuestResolution.Label": "A játék megjelenítési módja",
"Options.GuestResolution.Desc": "A játéknak jelzett képernyőfelbontás. Alapértelmezés: 1080p. A belső renderelési felbontást a játék választja; a rögzített felbontású játékok figyelmen kívül hagyhatják ezt a beállítást.",
"Options.RenderScale.Label": "Belső felbontás skálázása",
"Options.RenderScale.Desc": "A gazdagép felbontásszorzója a játék képernyőméretű rendercéljaira. 100% alatt a képminőséget cseréli sebességre, 100% fölött fordítva. Az árnyéktérképek, a keresőtáblák, a tömörített textúrák és a compute shaderek által írt felületek mindig natív méretükben maradnak.",
"Options.Display.Label": "Kijelző",
"Options.Display.Desc": "A középre igazításhoz és teljes képernyőhöz használt monitor.",
"Options.RefreshRate.Label": "Frissítési gyakoriság",

View File

@ -224,6 +224,10 @@
"Options.WindowMode.Exclusive": "Esclusiva",
"Options.Resolution.Label": "Risoluzione",
"Options.Resolution.Desc": "Dimensione iniziale della finestra o risoluzione dello schermo intero esclusivo.",
"Options.GuestResolution.Label": "Modalità schermo del gioco",
"Options.GuestResolution.Desc": "Risoluzione dello schermo comunicata al gioco. Il valore predefinito è 1080p. Il gioco sceglie la risoluzione interna di rendering; i giochi con risoluzione fissa possono ignorare questa impostazione.",
"Options.RenderScale.Label": "Scala della risoluzione interna",
"Options.RenderScale.Desc": "Moltiplicatore della risoluzione host applicato ai target di rendering a schermo intero del gioco. Sotto il 100% si scambia qualità d'immagine con velocità; sopra il 100% avviene il contrario. Shadow map, tabelle di consultazione, texture compresse e superfici scritte dai compute shader restano sempre alla dimensione nativa.",
"Options.Display.Label": "Schermo",
"Options.Display.Desc": "Monitor utilizzato per il centraggio e lo schermo intero.",
"Options.RefreshRate.Label": "Frequenza di aggiornamento",

View File

@ -219,6 +219,10 @@
"Options.WindowMode.Exclusive": "排他",
"Options.Resolution.Label": "解像度",
"Options.Resolution.Desc": "初期ウィンドウサイズまたは排他フルスクリーンの解像度。",
"Options.GuestResolution.Label": "ゲームの表示モード",
"Options.GuestResolution.Desc": "ゲームに通知する画面解像度。既定値は1080pです。内部描画解像度はゲームが決定するため、解像度が固定されたゲームではこの設定が反映されない場合があります。",
"Options.RenderScale.Label": "内部解像度スケール",
"Options.RenderScale.Desc": "ゲームの画面サイズのレンダーターゲットに適用するホスト解像度の倍率です。100%未満は画質と引き換えに速度を得て、100%超はその逆になります。シャドウマップ、ルックアップテーブル、圧縮テクスチャ、コンピュートシェーダーが書き込むサーフェスは常に元のサイズのままです。",
"Options.Display.Label": "ディスプレイ",
"Options.Display.Desc": "中央配置とフルスクリーンに使用するモニター。",
"Options.RefreshRate.Label": "リフレッシュレート",

View File

@ -219,6 +219,10 @@
"Options.WindowMode.Exclusive": "독점",
"Options.Resolution.Label": "해상도",
"Options.Resolution.Desc": "초기 창 크기 또는 독점 전체 화면 해상도.",
"Options.GuestResolution.Label": "게임 디스플레이 모드",
"Options.GuestResolution.Desc": "게임에 전달하는 화면 해상도입니다. 기본값은 1080p입니다. 내부 렌더링 해상도는 게임이 결정하며, 해상도가 고정된 게임은 이 설정을 무시할 수 있습니다.",
"Options.RenderScale.Label": "내부 해상도 배율",
"Options.RenderScale.Desc": "게임의 화면 크기 렌더 타깃에 적용되는 호스트 해상도 배율입니다. 100% 미만은 화질을 속도와 맞바꾸고 100% 초과는 그 반대입니다. 섀도 맵, 룩업 테이블, 압축 텍스처, 컴퓨트 셰이더가 쓰는 표면은 항상 원래 크기를 유지합니다.",
"Options.Display.Label": "디스플레이",
"Options.Display.Desc": "가운데 배치와 전체 화면에 사용할 모니터.",
"Options.RefreshRate.Label": "새로 고침 빈도",

View File

@ -219,6 +219,10 @@
"Options.WindowMode.Exclusive": "Exclusief",
"Options.Resolution.Label": "Resolutie",
"Options.Resolution.Desc": "Initiële venstergrootte of resolutie voor exclusief volledig scherm.",
"Options.GuestResolution.Label": "Weergavemodus van het spel",
"Options.GuestResolution.Desc": "Schermresolutie die aan het spel wordt doorgegeven. Standaard 1080p. Het spel kiest de interne renderresolutie; spellen met een vaste resolutie kunnen deze instelling negeren.",
"Options.RenderScale.Label": "Interne resolutieschaal",
"Options.RenderScale.Desc": "Hostresolutievermenigvuldiger voor de schermgrote rendertargets van het spel. Onder 100% ruil je beeldkwaliteit in voor snelheid; boven 100% andersom. Schaduwkaarten, opzoektabellen, gecomprimeerde texturen en door compute-shaders beschreven oppervlakken behouden altijd hun oorspronkelijke grootte.",
"Options.Display.Label": "Beeldscherm",
"Options.Display.Desc": "Monitor die wordt gebruikt voor centrering en volledig scherm.",
"Options.RefreshRate.Label": "Verversingssnelheid",

View File

@ -85,6 +85,10 @@
"Options.WindowMode.Exclusive": "Wyłączne",
"Options.Resolution.Label": "Rozdzielczość",
"Options.Resolution.Desc": "Początkowy rozmiar okna lub rozdzielczość pełnego ekranu wyłącznego.",
"Options.GuestResolution.Label": "Tryb wyświetlania gry",
"Options.GuestResolution.Desc": "Rozdzielczość ekranu zgłaszana grze. Domyślnie 1080p. Gra wybiera wewnętrzną rozdzielczość renderowania; gry ze stałą rozdzielczością mogą zignorować to ustawienie.",
"Options.RenderScale.Label": "Skala rozdzielczości wewnętrznej",
"Options.RenderScale.Desc": "Mnożnik rozdzielczości hosta dla pełnoekranowych buforów renderowania gry. Poniżej 100% oddaje jakość obrazu za wydajność; powyżej 100% odwrotnie. Mapy cieni, tablice LUT, tekstury skompresowane i powierzchnie zapisywane przez shadery obliczeniowe zawsze zachowują natywny rozmiar.",
"Options.Display.Label": "Wyświetlacz",
"Options.Display.Desc": "Monitor używany do centrowania i trybu pełnoekranowego.",
"Options.RefreshRate.Label": "Częstotliwość odświeżania",

View File

@ -220,6 +220,10 @@
"Options.WindowMode.Exclusive": "Exclusivo",
"Options.Resolution.Label": "Resolução",
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de ecrã inteiro exclusivo.",
"Options.GuestResolution.Label": "Modo de apresentação do jogo",
"Options.GuestResolution.Desc": "Resolução do ecrã comunicada ao jogo. A predefinição é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.",
"Options.RenderScale.Label": "Escala de resolução interna",
"Options.RenderScale.Desc": "Multiplicador da resolução do anfitrião aplicado aos alvos de renderização do tamanho do ecrã. Abaixo de 100% troca qualidade de imagem por velocidade; acima de 100% faz o contrário. Mapas de sombras, tabelas de consulta, texturas comprimidas e superfícies escritas por sombreadores de computação mantêm sempre o tamanho nativo.",
"Options.Display.Label": "Ecrã",
"Options.Display.Desc": "Monitor utilizado para centrar e apresentar em ecrã inteiro.",
"Options.RefreshRate.Label": "Taxa de atualização",

View File

@ -81,6 +81,10 @@
"Options.WindowMode.Exclusive": "Эксклюзивный",
"Options.Resolution.Label": "Разрешение",
"Options.Resolution.Desc": "Начальный размер окна или разрешение эксклюзивного полноэкранного режима.",
"Options.GuestResolution.Label": "Режим экрана игры",
"Options.GuestResolution.Desc": "Разрешение экрана, сообщаемое игре. По умолчанию 1080p. Игра выбирает внутреннее разрешение рендеринга; игры с фиксированным разрешением могут игнорировать эту настройку.",
"Options.RenderScale.Label": "Масштаб внутреннего разрешения",
"Options.RenderScale.Desc": "Множитель разрешения хоста для полноэкранных целей рендеринга игры. Ниже 100% качество изображения обменивается на скорость, выше 100% — наоборот. Карты теней, таблицы поиска, сжатые текстуры и поверхности, записываемые вычислительными шейдерами, всегда сохраняют исходный размер.",
"Options.Display.Label": "Монитор",
"Options.Display.Desc": "Монитор, используемый для центрирования окна и полноэкранного режима.",
"Options.RefreshRate.Label": "Частота обновления",

View File

@ -63,6 +63,10 @@
"Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
"Options.Resolution.Label": "Çözünürlük",
"Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
"Options.GuestResolution.Label": "Oyunun ekran modu",
"Options.GuestResolution.Desc": "Oyuna bildirilen ekran çözünürlüğü. Varsayılan 1080p’dir. Dahili işleme çözünürlüğünü oyun seçer; sabit çözünürlüklü oyunlar bu ayarı yok sayabilir.",
"Options.RenderScale.Label": "Dahili çözünürlük ölçeği",
"Options.RenderScale.Desc": "Oyunun ekran boyutundaki işleme hedeflerine uygulanan ana makine çözünürlük çarpanı. %100'ün altında görüntü kalitesi hıza takas edilir, üzerinde ise tersi olur. Gölge haritaları, arama tabloları, sıkıştırılmış dokular ve hesaplama gölgelendiricilerinin yazdığı yüzeyler her zaman kendi boyutlarında kalır.",
"Options.Display.Label": "Ekran",
"Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
"Options.RefreshRate.Label": "Yenileme hızı",

View File

@ -45,6 +45,8 @@ public partial class MainWindow
GameLogLevelBox.ItemsSource = _logLevelChoices;
GameWindowModeBox.ItemsSource = _windowModeChoices;
GameScalingModeBox.ItemsSource = _scalingModeChoices;
GameGuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" };
GameRenderScaleBox.ItemsSource = GuiSettings.RenderScaleChoices;
GameHdrModeBox.ItemsSource = _hdrModeChoices;
GameOverlayModeBox.ItemsSource = _overlayModeChoices;
GameOverlayCornerBox.ItemsSource = _overlayCornerChoices;
@ -115,6 +117,8 @@ public partial class MainWindow
GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
GameGuestResolutionBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameRenderScaleBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameOverlayEnabledToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
GameOverlayModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
@ -216,6 +220,8 @@ public partial class MainWindow
_scalingModeChoices,
effective.ScalingMode,
"Fit");
GameGuestResolutionBox.SelectedItem = effective.GuestResolution;
GameRenderScaleBox.SelectedItem = GuiSettings.RenderScaleText(effective.RenderScale);
GameVSyncToggle.IsChecked = effective.VSync;
GameOverlayEnabledToggle.IsChecked = effective.OverlayEnabled;
GameOverlayModeBox.SelectedItem = FindChoice(_overlayModeChoices, effective.OverlayMode, "TitleBar");
@ -277,6 +283,8 @@ public partial class MainWindow
LogToFile = GameLogToFileToggle.IsChecked == true,
WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"),
Resolution = SelectedComboText(GameResolutionBox, "1920x1080"),
GuestResolution = SelectedComboText(GameGuestResolutionBox, "1920x1080"),
RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(GameRenderScaleBox, "100%")),
DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display
? display.Index
: 0,

View File

@ -871,6 +871,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameGuestResolutionRow"
Classes="optionRow"
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GameGuestResolutionBox"
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameRenderScaleRow"
Classes="optionRow"
Label="{Binding [Options.RenderScale.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.RenderScale.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GameRenderScaleBox"
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameDisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label],
@ -1505,6 +1521,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GuestResolutionRow"
Classes="optionRow"
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GuestResolutionBox" Classes="optionValue" VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="RenderScaleRow"
Classes="optionRow"
Label="{Binding [Options.RenderScale.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.RenderScale.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="RenderScaleBox" Classes="optionValue" VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="DisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label], Source={x:Static local:Localization.Instance}}"

View File

@ -356,6 +356,9 @@ public partial class MainWindow : Window
WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed");
DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged();
ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged();
GuestResolutionBox.SelectionChanged += (_, _) => _settings.GuestResolution = SelectedComboText(GuestResolutionBox, "1920x1080");
RenderScaleBox.SelectionChanged += (_, _) =>
_settings.RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(RenderScaleBox, "100%"));
RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged();
ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit");
VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true;
@ -1373,6 +1376,8 @@ public partial class MainWindow : Window
LogLevelBox.ItemsSource = _logLevelChoices;
WindowModeBox.ItemsSource = _windowModeChoices;
ScalingModeBox.ItemsSource = _scalingModeChoices;
GuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" };
RenderScaleBox.ItemsSource = GuiSettings.RenderScaleChoices;
HdrModeBox.ItemsSource = _hdrModeChoices;
BinkPlaybackBox.ItemsSource = _binkPlaybackChoices;
OverlayModeBox.ItemsSource = _overlayModeChoices;
@ -1448,6 +1453,8 @@ public partial class MainWindow : Window
LoadHostDisplayOptions();
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
VSyncToggle.IsChecked = _settings.VSync;
GuestResolutionBox.SelectedItem = GuiSettings.NormalizeGuestResolution(_settings.GuestResolution);
RenderScaleBox.SelectedItem = GuiSettings.RenderScaleText(_settings.RenderScale);
HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
OverlayEnabledToggle.IsChecked = _settings.OverlayEnabled;
OverlayModeBox.SelectedIndex = ChoiceIndex(_settings.OverlayMode, "Full", "Minimal", "TitleBar");
@ -2891,13 +2898,6 @@ public partial class MainWindow : Window
});
_appliedEnvironmentVariables.Add(BinkModeEnvironmentName);
Environment.SetEnvironmentVariable(
"SHARPEMU_RENDER_SCALE",
_settings.RenderResolutionScale.ToString(
"0.###",
System.Globalization.CultureInfo.InvariantCulture));
_appliedEnvironmentVariables.Add("SHARPEMU_RENDER_SCALE");
foreach (var entry in customEnvironment)
{
if (!CustomEnvironmentVariables.TryParseEntry(entry, out var name, out var value))
@ -3115,6 +3115,9 @@ public partial class MainWindow : Window
arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
arguments.Add($"--resolution={launch.Settings.Resolution}");
arguments.Add($"--guest-resolution={launch.Settings.GuestResolution}");
arguments.Add(
$"--render-scale={launch.Settings.RenderScale.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
arguments.Add($"--display={launch.Settings.DisplayIndex}");
arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");

View File

@ -26,6 +26,10 @@ public sealed class PerGameSettings
public string? Resolution { get; set; }
public string? GuestResolution { get; set; }
public float? RenderScale { get; set; }
public int? DisplayIndex { get; set; }
public int? RefreshRate { get; set; }
@ -52,6 +56,8 @@ public sealed class PerGameSettings
LogToFile is null &&
WindowMode is null &&
Resolution is null &&
GuestResolution is null &&
RenderScale is null &&
DisplayIndex is null &&
RefreshRate is null &&
ScalingMode is null &&
@ -145,6 +151,17 @@ public sealed class PerGameSettings
Resolution = null;
}
if (string.Equals(GuestResolution, global.GuestResolution, StringComparison.OrdinalIgnoreCase))
{
GuestResolution = null;
}
if (RenderScale is { } renderScale &&
GuiSettings.NormalizeRenderScale(renderScale) == GuiSettings.NormalizeRenderScale(global.RenderScale))
{
RenderScale = null;
}
if (DisplayIndex == global.DisplayIndex)
{
DisplayIndex = null;
@ -272,6 +289,8 @@ public sealed record EffectiveLaunchSettings(
bool LogToFile,
string WindowMode,
string Resolution,
string GuestResolution,
float RenderScale,
int DisplayIndex,
int RefreshRate,
string ScalingMode,
@ -289,6 +308,8 @@ public sealed record EffectiveLaunchSettings(
perGame?.LogToFile ?? global.LogToFile,
perGame?.WindowMode ?? global.WindowMode,
perGame?.Resolution ?? global.Resolution,
GuiSettings.NormalizeGuestResolution(perGame?.GuestResolution ?? global.GuestResolution),
GuiSettings.NormalizeRenderScale(perGame?.RenderScale ?? global.RenderScale),
Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
perGame?.ScalingMode ?? global.ScalingMode,

View File

@ -61,6 +61,23 @@ public sealed class GuestGpuMemory : IDisposable
Volatile.Write(ref _images, images);
}
// The host GPU detaches its stores after its caches have released every page watch.
public bool WaitForStoresDetached(TimeSpan timeout)
{
var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds;
while (Buffers is not null || Images is not null)
{
if (Environment.TickCount64 >= deadline)
{
return false;
}
Thread.Sleep(10);
}
return true;
}
// Retry after recovery, including a watch removed before the fault reached its store.
public bool TryResolveFault(FaultKind kind, ulong address)
{
@ -294,8 +311,11 @@ public sealed class GuestGpuMemory : IDisposable
}
}
// Enter the GPU worker before the caller takes locks used by GPU memory reads.
public void RunMappingChange(Action change)
// Enter the GPU worker before the caller takes locks used by GPU memory reads. A change that
// only maps addresses no live mapping covers touches nothing the GPU can be using, so it
// skips the drain; removing or replacing a mapping must wait for the GPU first. needsDrain is
// evaluated where the change runs, after every earlier mapping change has been applied.
public void RunMappingChange(Action change, Func<bool>? needsDrain = null)
{
using var requestScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.MappingRequest);
for (;;)
@ -322,7 +342,7 @@ public sealed class GuestGpuMemory : IDisposable
void ApplyChange(IGpuTickScheduler? scheduler)
{
// Finish callbacks before the mapping transaction takes its locks.
if (scheduler is { Active: true })
if (scheduler is { Active: true } && (needsDrain?.Invoke() ?? true))
{
using var drainScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.MappingDrain);
scheduler.FinishMemoryAccess();

View File

@ -8,8 +8,17 @@ public static class GuestGpuMemoryHook
private static readonly ulong _tracePage = ParseTracePage();
private static readonly bool _traceFirstDeviceFault = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GPU_MEMORY_ADDRESS"), "auto", StringComparison.OrdinalIgnoreCase);
// "coherence": the page of the first GPU-copy/guest divergence (SHARPEMU_TRACE_COHERENCE) is traced from then on.
private static readonly bool _traceFirstDivergence = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GPU_MEMORY_ADDRESS"), "coherence", StringComparison.OrdinalIgnoreCase);
private static long _selectedTracePage;
public static void SelectDivergenceTracePage(ulong address)
{
if (!_traceFirstDivergence || !TrySelectTracePage(ref _selectedTracePage, address)) return;
Trace(address, 1, "divergence-trace-selected");
}
public static void SelectDeviceFaultTracePage(ulong address)
{
if (!_traceFirstDeviceFault || !TrySelectTracePage(ref _selectedTracePage, address)) return;
@ -33,9 +42,9 @@ public static class GuestGpuMemoryHook
}
public static ulong TraceAddress =>
_traceFirstDeviceFault ? unchecked((ulong)Interlocked.Read(ref _selectedTracePage)) : _tracePage;
_traceFirstDeviceFault || _traceFirstDivergence ? unchecked((ulong)Interlocked.Read(ref _selectedTracePage)) : _tracePage;
public static bool TraceEnabled => _traceFirstDeviceFault || _tracePage != 0;
public static bool TraceEnabled => _traceFirstDeviceFault || _traceFirstDivergence || _tracePage != 0;
public static bool Traces(ulong address, ulong size) =>
OverlapsTracePage(TraceAddress, address, size);

View File

@ -122,12 +122,40 @@ public sealed class GuestPageTracker
return count;
}
public void MarkCpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Cpu, enable: true, create: true);
// Reports whether at least one page transitioned from clean to CPU-dirty.
public bool MarkCpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Cpu, enable: true, create: true);
public void MarkGpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Gpu, enable: true, create: true);
public void ClearGpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Gpu, enable: false, create: false);
// Marks the tracked pages of a range GPU-dirty after the fact, page by page under the region
// lock. A page the CPU dirtied meanwhile is left CPU-owned and counted as a conflict, since the
// two cannot both own it. Returns the number of such conflicting pages.
public int MarkGpuWrittenPages(ulong vaddr, ulong size)
{
RejectUploadCallbackReentry();
var conflicts = 0;
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
{
using var _ = region.Lock.Hold();
for (var page = offset; page < offset + bytes; page += PageBytes)
{
var pageBytes = Math.Min(PageBytes, offset + bytes - page);
if (region.IsModified(WriteOrigin.Cpu, page, pageBytes))
{
conflicts++;
continue;
}
region.ChangeState(WriteOrigin.Gpu, enable: true, region.BaseAddress + page, pageBytes);
}
return false;
});
return conflicts;
}
// True when every page of the range has a region, so the range is under tracking.
public bool HasRegion(ulong vaddr, ulong size)
{
@ -143,6 +171,32 @@ public sealed class GuestPageTracker
return true;
}
// Hot pages stay writable and are copied whole at every sync, so a ring the CPU fills a
// little at a time would end up copied in full forever. Once a frame they 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.
public void DecayCpuWriteHeat()
{
RejectUploadCallbackReentry();
for (var index = 0; index < _regions.Length; index++)
{
if (Volatile.Read(ref _regions[index]) is not { HasCpuWriteHeat: true } region)
{
continue;
}
region.Lock.Enter();
try
{
region.ResetCpuWriteHeat(region.BaseAddress, TrackerLayout.BlockBytes);
}
finally
{
region.Lock.Exit();
}
}
}
public void UntrackMemory(ulong vaddr, ulong size)
{
RejectUploadCallbackReentry();
@ -242,50 +296,110 @@ public sealed class GuestPageTracker
}
}
// Region locks stay held across uploadFunc only for a written range, which turns GPU-dirty.
// Receives the CPU-dirty runs an upload copies, then performs the upload while the regions are held.
public interface IUploadRangeSink
{
void Range(ulong address, ulong size);
void Upload();
}
private struct DelegateUploadSink(Action<ulong, ulong> rangeFunc, Action uploadFunc) : IUploadRangeSink
{
public readonly void Range(ulong address, ulong size) => rangeFunc(address, size);
public readonly void Upload() => uploadFunc();
}
// Forwards a region's upload runs to the sink and remembers the runs it cleared, for rollback.
private struct RegionUploadVisitor<TSink>(TSink sink, List<(TrackedRegion Region, ulong Address, ulong Size)> cleared)
: TrackedRegion.ICpuUploadVisitor
where TSink : struct, IUploadRangeSink
{
public TSink Sink = sink;
public readonly void Cleared(TrackedRegion region, ulong address, ulong size) => cleared.Add((region, address, size));
public void Upload(ulong address, ulong size) => Sink.Range(address, size);
}
// Uploads run on every bound buffer of every draw, so the bookkeeping lists are reused per thread.
[ThreadStatic]
private static List<TrackedRegion>? _scratchHeld;
[ThreadStatic]
private static List<(TrackedRegion Region, ulong Address, ulong Size)>? _scratchCleared;
public void ForEachUploadRange(ulong vaddr, ulong size, bool isWritten, Action<ulong, ulong> rangeFunc, Action uploadFunc,
bool preserveCpuWriteHotPages = true)
{
var sink = new DelegateUploadSink(rangeFunc, uploadFunc);
ForEachUploadRange(vaddr, size, isWritten, ref sink, preserveCpuWriteHotPages);
}
// Region locks stay held across the upload only for a written range, which turns GPU-dirty.
public void ForEachUploadRange<TSink>(ulong vaddr, ulong size, bool isWritten, ref TSink sink, bool preserveCpuWriteHotPages = true,
bool skipCpuWriteHotPages = false)
where TSink : struct, IUploadRangeSink
{
RejectUploadCallbackReentry();
VisitRegions(vaddr, size, create: true, static (_, _, _) => false);
var previousOwner = _uploadOwner;
_uploadOwner = this;
var held = new List<TrackedRegion>();
var cleared = new List<(TrackedRegion Region, ulong Address, ulong Size)>();
// Another tracker may upload from this callback. It needs its own rollback
// state; the reentry guard only rejects reentry into this tracker.
var held = previousOwner is null
? _scratchHeld ??= new List<TrackedRegion>()
: new List<TrackedRegion>();
var cleared = previousOwner is null
? _scratchCleared ??= new List<(TrackedRegion Region, ulong Address, ulong Size)>()
: new List<(TrackedRegion Region, ulong Address, ulong Size)>();
held.Clear();
cleared.Clear();
try
{
using (GpuMemoryAccessProfile.Measure(GpuMemoryAccessProfile.Operation.UploadTracking, size))
{
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
var visitor = new RegionUploadVisitor<TSink>(sink, cleared);
var preserveHotPages = !isWritten && preserveCpuWriteHotPages;
for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0)
{
var bytes = Math.Min(BlockBytes - offset, remaining);
remaining -= bytes;
if (Volatile.Read(ref _regions[index]) is not { } region)
{
continue;
}
region.Lock.Enter();
held.Add(region);
var address = region.BaseAddress + offset;
region.ForEachCpuUploadRange(
preserveHotPages: !isWritten && preserveCpuWriteHotPages,
address,
bytes,
(runAddress, runSize) => cleared.Add((region, runAddress, runSize)),
rangeFunc);
region.ForEachCpuUploadRange(preserveHotPages, region.BaseAddress + offset, bytes, ref visitor,
skipHotPages: !isWritten && skipCpuWriteHotPages);
if (!isWritten)
{
region.Lock.Exit();
held.Remove(region);
held.RemoveAt(held.Count - 1);
}
}
return false;
});
sink = visitor.Sink;
}
uploadFunc();
sink.Upload();
if (isWritten)
{
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0)
{
var bytes = Math.Min(BlockBytes - offset, remaining);
remaining -= bytes;
if (Volatile.Read(ref _regions[index]) is not { } region)
{
continue;
}
region.ChangeState(WriteOrigin.Gpu, enable: true, region.BaseAddress + offset, bytes);
region.Lock.Exit();
held.Remove(region);
return false;
});
}
}
}
catch
@ -333,6 +447,8 @@ public sealed class GuestPageTracker
region.Lock.Exit();
}
held.Clear();
cleared.Clear();
_uploadOwner = previousOwner;
}
}
@ -368,22 +484,25 @@ public sealed class GuestPageTracker
}
}
private void Mark(ulong vaddr, ulong size, WriteOrigin side, bool enable, bool create)
private bool Mark(ulong vaddr, ulong size, WriteOrigin side, bool enable, bool create)
{
RejectUploadCallbackReentry();
var changed = false;
VisitRegions(vaddr, size, create, (region, offset, bytes) =>
{
using var _ = region.Lock.Hold();
if (side == WriteOrigin.Cpu && enable)
{
region.MarkCpuWrite(region.BaseAddress + offset, bytes);
changed |= region.MarkCpuWrite(region.BaseAddress + offset, bytes);
}
else
{
region.ChangeState(side, enable, region.BaseAddress + offset, bytes);
changed = true;
}
return false;
});
return changed;
}
private void VisitDirtyRanges(ulong vaddr, ulong size, WriteOrigin side, bool clear, Action<ulong, ulong> visit) =>

View File

@ -72,9 +72,13 @@ public sealed class TrackedRegion
}
// Count only clean-to-dirty transitions. Repeated reads cannot make a page hot.
public void MarkCpuWrite(ulong address, ulong size)
// Returns whether this write changed ownership from clean to CPU-dirty.
// Callers use the result to avoid repeating protection bookkeeping for a
// page that was already CPU-owned.
public bool MarkCpuWrite(ulong address, ulong size)
{
var (start, end) = GetPageRange(address, size);
var changed = false;
for (var page = start; page < end; page++)
{
if (_cpuDirty.Get(page))
@ -82,6 +86,8 @@ public sealed class TrackedRegion
continue;
}
changed = true;
if (_recentCpuUploads.Get(page))
{
if (_repeatedCpuWrites.Get(page))
@ -101,7 +107,17 @@ public sealed class TrackedRegion
}
}
// The page is already writable and CPU-owned. Re-running ChangeState
// scans every page in the 4 MiB region and republishes the same summary,
// once for every managed guest-store; do neither when ownership did not
// change. Callers hold Lock while reading and updating these masks.
if (!changed)
{
return false;
}
ChangeState(WriteOrigin.Cpu, enable: true, address, size);
return true;
}
public void ChangeState(WriteOrigin side, bool enable, ulong address, ulong size)
@ -144,20 +160,32 @@ public sealed class TrackedRegion
}
}
// Receives the runs one upload clears and the runs it copies; a struct visitor keeps the walk allocation-free.
public interface ICpuUploadVisitor
{
void Cleared(TrackedRegion region, ulong address, ulong size);
void Upload(ulong address, ulong size);
}
// Hot read-only pages stay writable and dirty, so each obtain observes current CPU bytes.
public void ForEachCpuUploadRange(
bool preserveHotPages,
ulong address,
ulong size,
Action<ulong, ulong> visitCleared,
Action<ulong, ulong> visitUpload)
// skipHotPages leaves them out entirely, for a caller that already copied them since the
// last memory visibility point.
public void ForEachCpuUploadRange<TVisitor>(bool preserveHotPages, ulong address, ulong size, ref TVisitor visitor,
bool skipHotPages = false)
where TVisitor : struct, ICpuUploadVisitor
{
var (start, end) = GetPageRange(address, size);
var upload = new PageMask(_cpuDirty, start, end);
if (skipHotPages)
{
upload &= ~_hotCpuWrites;
}
var cleared = preserveHotPages ? upload & ~_hotCpuWrites : upload;
foreach (var (runStart, runEnd) in cleared)
{
visitCleared(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
visitor.Cleared(this, BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
_cpuDirty.UnsetRange(runStart, runEnd);
_recentCpuUploads.SetRange(runStart, runEnd);
}
@ -165,10 +193,13 @@ public sealed class TrackedRegion
UpdateCpuProtection(track: true);
foreach (var (runStart, runEnd) in upload)
{
visitUpload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
visitor.Upload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
}
}
// A racy hint for the per-frame decay; the decay itself runs under Lock.
public bool HasCpuWriteHeat => _hotCpuWrites.Any;
public void ResetCpuWriteHeat(ulong address, ulong size)
{
var (start, end) = GetPageRange(address, size);

View File

@ -5,9 +5,16 @@ namespace SharpEmu.HLE;
public static class GuestMemoryLayout
{
public const ulong DirectBytes = 16384UL * 1024 * 1024;
// The direct memory a PS5 game gets: 12.5 GiB of the console's 16 GiB, the rest being the
// system's. Games size their pools from sceKernelGetDirectMemorySize, so reporting the
// whole 16 GiB made them claim (and fill) memory a console never gives them.
// SHARPEMU_DIRECT_MEMORY_MB overrides it.
public static readonly ulong DirectBytes =
(ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_DIRECT_MEMORY_MB"), out var megabytes) && megabytes > 0
? megabytes
: 12800UL) * 1024 * 1024;
public const ulong FlexibleBytes = 448UL * 1024 * 1024;
public const ulong FlexibleOffset = DirectBytes;
public const ulong BackingBytes = DirectBytes + FlexibleBytes;
public static readonly ulong FlexibleOffset = DirectBytes;
public static readonly ulong BackingBytes = DirectBytes + FlexibleBytes;
public const ulong GuestPage = 0x4000;
}

View File

@ -462,20 +462,43 @@ internal static unsafe class PosixHostStubs
private static nint EmitSleep(byte* page, ref int offset)
{
// void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi).
var usleep = ResolveLibcExport("usleep");
// void Sleep(DWORD milliseconds in ecx) -> nanosleep(&req, &rem), resumed on EINTR. usleep
// would be shorter, but macOS rejects usleep(>= 1 s) with EINVAL, and a signal (the
// runtime's thread activations, for one) cuts either short: a sleep that returned at once
// on an ignored error turned UE's idle HTTP thread into a hot spin.
var nanosleep = ResolveLibcExport("nanosleep");
var errorLocation = ResolveLibcExport(OperatingSystem.IsMacOS() ? "__error" : "__errno_location");
var start = (nint)(page + offset);
Emit(page, ref offset, 0x56); // push rsi
Emit(page, ref offset, 0x57); // push rdi
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF
Emit(page, ref offset, 0x76, 0x05); // jbe +5
Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s)
Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000
EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64
Emit(page, ref offset, 0x53); // push rbx
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x20); // sub rsp, 0x20: req at [rsp], rem at [rsp+16]
Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx
Emit(page, ref offset, 0x31, 0xD2); // xor edx, edx
Emit(page, ref offset, 0xB9, 0xE8, 0x03, 0x00, 0x00); // mov ecx, 1000
Emit(page, ref offset, 0xF7, 0xF1); // div ecx: eax = seconds, edx = milliseconds
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax (tv_sec)
Emit(page, ref offset, 0x69, 0xD2, 0x40, 0x42, 0x0F, 0x00); // imul edx, edx, 1000000
Emit(page, ref offset, 0x48, 0x89, 0x54, 0x24, 0x08); // mov [rsp+8], rdx (tv_nsec)
// loop:
Emit(page, ref offset, 0x48, 0x89, 0xE7); // mov rdi, rsp
Emit(page, ref offset, 0x48, 0x8D, 0x74, 0x24, 0x10); // lea rsi, [rsp+16]
EmitMovRaxImm64(page, ref offset, nanosleep); // mov rax, imm64
Emit(page, ref offset, 0xFF, 0xD0); // call rax
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
Emit(page, ref offset, 0x85, 0xC0); // test eax, eax
Emit(page, ref offset, 0x74, 0x26); // jz done
EmitMovRaxImm64(page, ref offset, errorLocation); // mov rax, imm64
Emit(page, ref offset, 0xFF, 0xD0); // call rax
Emit(page, ref offset, 0x83, 0x38, 0x04); // cmp dword [rax], EINTR
Emit(page, ref offset, 0x75, 0x15); // jne done
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x10); // mov rax, [rsp+16]
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x18); // mov rax, [rsp+24]
Emit(page, ref offset, 0x48, 0x89, 0x44, 0x24, 0x08); // mov [rsp+8], rax
Emit(page, ref offset, 0xEB, 0xC2); // jmp loop
// done:
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x20); // add rsp, 0x20
Emit(page, ref offset, 0x5B); // pop rbx
Emit(page, ref offset, 0x5F); // pop rdi
Emit(page, ref offset, 0x5E); // pop rsi
Emit(page, ref offset, 0xC3); // ret

View File

@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Threading;
namespace SharpEmu.HLE.Host.Posix;
// View mappings bypass the anonymous-allocation tables. Keep their query state together.
@ -11,6 +13,12 @@ internal static class PosixViewRegions
uint[]? PageProtections, ulong PageBase);
private static readonly List<Region> Regions = new();
// The regions in address order, replaced whenever the list changes. Queries read it without
// Gate: every HLE import queries the guest stack's protection first, and Gate is held across
// mprotect by the GPU write tracker, so a locked query made each import wait on page traffic.
// Page protections change in place and are read racily, as a concurrent change always was.
private static Region[] _snapshot = [];
internal static void Replace(ulong address, ulong size, uint state, uint protection)
{
HostMemory.OnMappingChanged();
@ -46,6 +54,8 @@ internal static class PosixViewRegions
else
Regions.Add(region);
}
Volatile.Write(ref _snapshot, [.. Regions]);
}
// Mapping creates the storage. Fault-time protection updates must not allocate.
@ -66,9 +76,8 @@ internal static class PosixViewRegions
internal static bool TryQuery(ulong address, out HostMemory.BasicInfo info)
{
lock (Gate)
{
foreach (var region in Regions)
foreach (var region in Volatile.Read(ref _snapshot))
{
if (region.Start > address) break;
if (address >= region.End) continue;

View File

@ -149,6 +149,14 @@ public static unsafe class HostMemory
private static readonly object Gate = new();
private static readonly SortedList<ulong, Region> Regions = new();
// The regions in address order, replaced under Gate whenever a region is added or removed.
// Queries read it without the lock: Protect holds Gate across its mprotect call, and GPU
// write tracking protects pages constantly, so a locked query (one per HLE call reading
// stack arguments) waited tens of microseconds behind those syscalls.
private static Region[] RegionSnapshot = [];
private static void PublishRegionsLocked() => Volatile.Write(ref RegionSnapshot, [.. Regions.Values]);
private sealed class Region
{
public ulong Base;
@ -156,6 +164,10 @@ public static unsafe class HostMemory
public uint DefaultProtect;
public Dictionary<ulong, uint>? PageProtects;
// The overridden pages in address order, so a query finds where a protection run ends
// without visiting every override: write tracking leaves tens of thousands of them.
public SortedSet<ulong>? OverridePages;
public ulong End => Base + Size;
public uint ProtectAt(ulong pageAddress)
@ -269,6 +281,7 @@ public static unsafe class HostMemory
Size = alignedSize,
DefaultProtect = protect
};
PublishRegionsLocked();
return (void*)result;
}
@ -288,6 +301,7 @@ public static unsafe class HostMemory
HostMemory.OnMappingChanged();
Regions.Remove((ulong)address);
PublishRegionsLocked();
return munmap((nint)address, (nuint)region.Size) == 0;
}
}
@ -325,6 +339,10 @@ public static unsafe class HostMemory
{
info = default;
var pageAddress = AlignDown((ulong)address, PageSize);
if (TryQueryUnlocked(pageAddress, ref info))
{
return (nuint)sizeof(BasicInfo);
}
lock (Gate)
{
@ -340,30 +358,37 @@ public static unsafe class HostMemory
var pageProtects = region.PageProtects;
uint protect;
ulong runEnd;
if (pageProtects is null || pageProtects.Count == 0)
if (pageProtects is null || pageProtects.Count == 0 || region.OverridePages is not { } overridePages)
{
protect = region.DefaultProtect;
runEnd = region.End;
}
else if (pageProtects.TryGetValue(pageAddress, out protect))
{
// Overrides run while the next page is overridden with the same protection.
runEnd = pageAddress + PageSize;
while (runEnd < region.End &&
pageProtects.TryGetValue(runEnd, out var nextProtect) &&
nextProtect == protect)
foreach (var overrideAddress in runEnd < region.End ? overridePages.GetViewBetween(runEnd, region.End - 1) : [])
{
if (overrideAddress != runEnd || pageProtects[overrideAddress] != protect)
{
break;
}
runEnd += PageSize;
}
}
else
{
// The default protection runs to the next override.
protect = region.DefaultProtect;
runEnd = region.End;
foreach (var overrideAddress in pageProtects.Keys)
if (pageAddress + PageSize < region.End)
{
if (overrideAddress > pageAddress && overrideAddress < runEnd)
// The first element only: a view's Count walks the whole view.
foreach (var overrideAddress in overridePages.GetViewBetween(pageAddress + PageSize, region.End - 1))
{
runEnd = overrideAddress;
break;
}
}
}
@ -382,12 +407,18 @@ public static unsafe class HostMemory
// reported as a free block reaching to the next tracked region
// so scanning callers keep advancing.
var nextBase = ulong.MaxValue;
foreach (var regionBase in Regions.Keys)
var keys = Regions.Keys;
for (int low = 0, high = keys.Count - 1; low <= high;)
{
if (regionBase > pageAddress)
var middle = low + ((high - low) >> 1);
if (keys[middle] > pageAddress)
{
nextBase = regionBase;
break;
nextBase = keys[middle];
high = middle - 1;
}
else
{
low = middle + 1;
}
}
@ -416,6 +447,56 @@ public static unsafe class HostMemory
return false;
}
// Answers without Gate for untracked memory and for regions without per-page overrides;
// a region with overrides needs their dictionary, which only Gate protects. A concurrent
// Protect may land just before or after either way, as with the locked query.
private static bool TryQueryUnlocked(ulong pageAddress, ref BasicInfo info)
{
var regions = Volatile.Read(ref RegionSnapshot);
Region? candidate = null;
var nextBase = ulong.MaxValue;
for (int low = 0, high = regions.Length - 1; low <= high;)
{
var middle = low + ((high - low) >> 1);
if (regions[middle].Base <= pageAddress)
{
candidate = regions[middle];
low = middle + 1;
}
else
{
nextBase = regions[middle].Base;
high = middle - 1;
}
}
if (candidate is not null && pageAddress < candidate.End)
{
if (Volatile.Read(ref candidate.PageProtects) is { Count: > 0 })
{
return false;
}
info.BaseAddress = pageAddress;
info.AllocationBase = candidate.Base;
info.AllocationProtect = candidate.DefaultProtect;
info.RegionSize = candidate.End - pageAddress;
info.State = MEM_COMMIT;
info.Protect = candidate.DefaultProtect;
info.Type = MEM_PRIVATE;
return true;
}
info.BaseAddress = pageAddress;
info.AllocationBase = 0;
info.AllocationProtect = PAGE_NOACCESS;
info.RegionSize = (nextBase == ulong.MaxValue ? pageAddress + PageSize : nextBase) - pageAddress;
info.State = MEM_FREE_STATE;
info.Protect = PAGE_NOACCESS;
info.Type = 0;
return true;
}
private static bool TryFindRegionLocked(ulong address, out Region region)
{
region = null!;
@ -454,20 +535,26 @@ public static unsafe class HostMemory
{
region.DefaultProtect = protect;
region.PageProtects = null;
region.OverridePages = null;
return;
}
region.PageProtects ??= new Dictionary<ulong, uint>();
region.OverridePages ??= new SortedSet<ulong>();
var end = start + size;
for (var pageAddress = start; pageAddress < end; pageAddress += PageSize)
{
if (protect == region.DefaultProtect)
{
region.PageProtects.Remove(pageAddress);
if (region.PageProtects.Remove(pageAddress))
{
region.OverridePages.Remove(pageAddress);
}
}
else
{
region.PageProtects[pageAddress] = protect;
region.OverridePages.Add(pageAddress);
}
}
}

View File

@ -48,6 +48,42 @@ namespace LibAtrac9
DecodeSuperFrame(pcmOut);
}
/// <summary>
/// Decodes one frame from the beginning of <paramref name="atrac9Data"/>
/// and returns the byte count consumed, including the frame's byte
/// alignment. The decoder state is retained so consecutive calls can
/// decode frames from the same superframe.
/// </summary>
public int DecodeFrame(byte[] atrac9Data, short[][] pcmOut, int outputOffset, int frameIndex)
{
if (!_initialized) throw new InvalidOperationException("Decoder must be initialized before decoding.");
if (atrac9Data == null) throw new ArgumentNullException(nameof(atrac9Data));
if (pcmOut == null) throw new ArgumentNullException(nameof(pcmOut));
if (pcmOut.Length < Config.ChannelCount ||
outputOffset < 0 ||
outputOffset > Config.FrameSamples * Config.FramesPerSuperframe - Config.FrameSamples ||
frameIndex < 0 ||
frameIndex >= Config.FramesPerSuperframe)
{
throw new ArgumentException("Invalid ATRAC9 frame decode buffers or index");
}
for (var i = 0; i < Config.ChannelCount; i++)
{
if (pcmOut[i] == null || pcmOut[i].Length < outputOffset + Config.FrameSamples)
{
throw new ArgumentException("PCM buffer is too small");
}
}
Reader.SetBuffer(atrac9Data);
Frame.FrameIndex = frameIndex;
DecodeFrame(Reader, Frame);
PcmFloatToShort(pcmOut, outputOffset);
Reader.AlignPosition(8);
return (Reader.Position + 7) / 8;
}
private void ValidateDecodeBuffers(byte[] atrac9Buffer, short[][] pcmBuffer)
{
if (atrac9Buffer == null) throw new ArgumentNullException(nameof(atrac9Buffer));

View File

@ -357,7 +357,14 @@ namespace LibAtrac9
int precision = channel.Precisions[i] + 1;
if (precision <= maxHuffPrecision)
{
HuffmanCodebook huff = Tables.HuffmanSpectrum[channel.CodebookSet[i]][precision][Tables.QuantUnitToCodebookIndex[i]];
// A valid stream never selects a codebook the format leaves undefined.
HuffmanCodebook huff = Tables.HuffmanSpectrum[channel.CodebookSet[i]]?[precision]?[Tables.QuantUnitToCodebookIndex[i]];
if (huff == null)
{
throw new InvalidDataException(
$"The ATRAC9 block selects an undefined spectrum codebook: set={channel.CodebookSet[i]} precision={precision} unit={i}.");
}
int groupCount = subbandCount >> huff.ValueCountPower;
for (int j = 0; j < groupCount; j++)
{

View File

@ -170,8 +170,51 @@ public static class AcmExports
ExportName = "sceAcm_ConvReverb_SharedInput",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedInput(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
public static int AcmConvReverbSharedInput(CpuContext ctx)
{
var outputCount = unchecked((uint)ctx[CpuRegister.Rcx]);
var outputDescriptors = ReadStackArg64(ctx, 0);
if (outputDescriptors != 0)
{
Span<byte> descriptor = stackalloc byte[24];
for (uint index = 0; index < Math.Min(outputCount, 64u); index++)
{
if (!ctx.TryReadUInt64(outputDescriptors + (index * sizeof(ulong)), out var descriptorAddress) ||
descriptorAddress == 0)
{
continue;
}
if (!ctx.Memory.TryRead(descriptorAddress, descriptor))
{
continue;
}
var samples = BinaryPrimitives.ReadUInt32LittleEndian(descriptor);
var channels = BinaryPrimitives.ReadUInt32LittleEndian(descriptor[4..]);
var buffersAddress = BinaryPrimitives.ReadUInt64LittleEndian(descriptor[16..]);
if (samples == 0 || channels == 0 || channels > 64 || buffersAddress == 0 || samples > 65536)
{
continue;
}
for (uint channel = 0; channel < channels; channel++)
{
if (!ctx.TryReadUInt64(buffersAddress + (channel * sizeof(ulong)), out var bufferAddress) ||
bufferAddress == 0)
{
continue;
}
ClearGuestMemory(ctx, bufferAddress, (ulong)samples * sizeof(float));
}
}
}
// The batch record still advances when the job is recorded. The actual
// convolution remains unimplemented, but stale output must not be played.
return AdvanceBatchInfo(ctx, 1024);
}
[SysAbiExport(
Nid = "9nLbWmRDpa8",
@ -273,6 +316,29 @@ public static class AcmExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static ulong ReadStackArg64(CpuContext ctx, int index)
{
var address = ctx[CpuRegister.Rsp] + sizeof(ulong) + ((ulong)index * sizeof(ulong));
return ctx.TryReadUInt64(address, out var value) ? value : 0;
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
{
Span<byte> zeros = stackalloc byte[256];
var remaining = byteCount;
while (remaining != 0)
{
var chunk = (int)Math.Min(remaining, (ulong)zeros.Length);
if (!ctx.Memory.TryWrite(address, zeros[..chunk]))
{
return;
}
address += (ulong)chunk;
remaining -= (ulong)chunk;
}
}
private static void Trace(string message)
{
if (string.Equals(

View File

@ -841,7 +841,10 @@ public static partial class AgcExports
var payloadDwords = Math.Max(((uint)marker.Length + 4) / 4, 1);
var packetDwords = payloadDwords + 1;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
// The returned pointer must name reserved packet storage. Returning an unreserved
// cursor aliases the next packet and also bypasses the buffer-full callback.
// Only a marker from a foreign DCB writer is skipped, with a null packet pointer.
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress, isMarker: true) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, RPushMarker)))
{
return ReturnPointer(ctx, 0);
@ -884,7 +887,7 @@ public static partial class AgcExports
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress, isMarker: true) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RPopMarker)) ||
!TryWriteUInt32(ctx, commandAddress + 4, 0))
{

View File

@ -280,18 +280,22 @@ public static partial class AgcExports
ExportName = "sceAgcAcbJump",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
// (acb, cachePolicy, target, sizeDwords): an ACB jump always chains, so unlike
// sceAgcDcbJump it takes no mode argument.
public static int AcbJump(CpuContext ctx)
{
var acb = ctx[CpuRegister.Rdi];
var target = ctx[CpuRegister.Rsi];
var sizeDwords = (uint)ctx[CpuRegister.Rdx];
if (acb == 0)
var cachePolicy = (uint)ctx[CpuRegister.Rsi];
var target = ctx[CpuRegister.Rdx];
var sizeDwords = (uint)ctx[CpuRegister.Rcx];
if (acb == 0 || cachePolicy > 3)
{
return ReturnPointer(ctx, 0);
}
const uint chainMode = 1;
var control = 0x0F20_0000u |
((cachePolicy & 0x3u) << 28) |
(chainMode << 20) |
(sizeDwords & 0xFFFFFu);
if (!TryAllocateCommandDwords(ctx, acb, 4, out var cmd) ||

View File

@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
@ -133,13 +134,15 @@ public static partial class AgcExports
var commandBufferAddress = ctx[CpuRegister.Rdi];
var indexCount = (uint)ctx[CpuRegister.Rsi];
var indexAddress = ctx[CpuRegister.Rdx];
var modifier = (uint)ctx[CpuRegister.Rcx];
var modifier = ctx[CpuRegister.Rcx];
if (commandBufferAddress == 0 || modifier != 0x4000_0000)
if (commandBufferAddress == 0)
{
return ReturnPointer(ctx, 0);
}
NoteDrawModifier("sceAgcDcbDrawIndex", modifier);
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var baseCommand) ||
!TryWriteUInt32(ctx, baseCommand, Pm4(3, ItIndexBase, 0)) ||
!TryWriteUInt32(ctx, baseCommand + 4, (uint)indexAddress) ||
@ -239,11 +242,13 @@ public static partial class AgcExports
var commandBufferAddress = ctx[CpuRegister.Rdi];
var indexCount = (uint)ctx[CpuRegister.Rsi];
var modifier = ctx[CpuRegister.Rdx];
if (commandBufferAddress == 0 || modifier != 0x4000_0000)
if (commandBufferAddress == 0)
{
return ReturnPointer(ctx, 0);
}
NoteDrawModifier("sceAgcDcbDrawIndexAuto", modifier);
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 7, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(7, ItNop, RDrawIndexAuto)) ||
!TryWriteUInt32(ctx, commandAddress + 4, indexCount) ||
@ -457,4 +462,19 @@ public static partial class AgcExports
#pragma warning restore SHEM006
private static long _indirectDrawEmitRejectCount;
private static readonly ConcurrentDictionary<(string, ulong), byte> _reportedDrawModifiers = new();
// The modifier names the shader stage that receives the draw and the user registers the
// command processor fills for indirect draws. A direct draw emits the same packet for every
// modifier; rejecting the unfamiliar ones dropped whole passes, such as Unreal's volume
// rasterization through the geometry stage, without a trace.
private static void NoteDrawModifier(string export, ulong modifier)
{
if (modifier != 0x4000_0000 && _reportedDrawModifiers.TryAdd((export, modifier), 0))
{
Console.Error.WriteLine(
$"[LOADER][INFO] agc.draw_modifier {export} modifier=0x{modifier:X} stage={(uint)modifier >> 29} flags=0x{(uint)modifier & 7u:X}");
}
}
}

View File

@ -325,10 +325,11 @@ public static partial class AgcExports
}
// Tessellation-factor ring and hull-shader off-chip buffers are guest-driver
// configuration for on-hardware tessellation memory. Our translator handles
// shader execution directly, so there is no guest-side ring to program: the
// guest driver only needs these to report success so init proceeds. Games
// (e.g. Unity titles) call them during GPU setup and stall if unresolved.
// configuration for on-hardware tessellation memory. The hull writes its factors
// through its own buffer descriptor, so the factor ring needs no host state. The
// off-chip parameter decides how many hull groups may run together; it is kept for
// the tessellation draws. Games (e.g. Unity titles) call these during GPU setup and
// stall if unresolved.
[SysAbiExport(
Nid = "XlNp7jzGiPo",
ExportName = "sceAgcDriverSetTFRing",
@ -352,6 +353,7 @@ public static partial class AgcExports
TraceAgc(
$"agc.driver_set_hs_offchip_param buffer=0x{ctx[CpuRegister.Rdi]:X16} " +
$"param=0x{(uint)ctx[CpuRegister.Rsi]:X8}");
Gpu.TessellationOffchip.Configure((uint)ctx[CpuRegister.Rsi]);
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
}

View File

@ -64,19 +64,29 @@ public static partial class AgcExports
var commandBufferAddress = ctx[CpuRegister.Rdi];
var eventType = (uint)(ctx[CpuRegister.Rsi] & 0xFF);
var eventAddress = ctx[CpuRegister.Rdx];
if (commandBufferAddress == 0 || eventType > 0x3F || eventAddress != 0)
if (commandBufferAddress == 0 || eventType > 0x3F || (eventAddress != 0 && (eventType & ~1u) != 0x38))
{
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItEventWrite, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, eventType))
// Counter selection and counter dumps carry a payload in the address fields.
var hasAddress = (eventType & ~1u) == 0x38;
var packetDwords = hasAddress ? 4u : 2u;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItEventWrite, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, hasAddress ? eventType | 0x100u : eventType))
{
return ReturnPointer(ctx, 0);
}
TraceAgc($"agc.dcb_event_write buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} type={eventType}");
if (hasAddress &&
(!TryWriteUInt32(ctx, commandAddress + 8, (uint)eventAddress & ~7u) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(eventAddress >> 32))))
{
return ReturnPointer(ctx, 0);
}
TraceAgc($"agc.dcb_event_write buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} type={eventType} address=0x{eventAddress:X16}");
return ReturnPointer(ctx, commandAddress);
}

View File

@ -223,8 +223,7 @@ public static partial class AgcExports
descriptor[(int)ShaderCodeOffset..]);
var continuationSize = BinaryPrimitives.ReadUInt32LittleEndian(
descriptor[(int)ShaderSizeOffset..]);
if (continuationCodeAddress <= entryCodeAddress ||
continuationCodeAddress - entryCodeAddress > uint.MaxValue ||
if (continuationCodeAddress == entryCodeAddress ||
!IsValidDeclaredShaderSize(continuationSize) ||
!CanReadShaderRange(ctx, continuationCodeAddress, continuationSize))
{
@ -249,7 +248,8 @@ public static partial class AgcExports
}
}
var distance = continuationCodeAddress - entryCodeAddress;
var distance = continuationCodeAddress > entryCodeAddress
? continuationCodeAddress - entryCodeAddress : entryCodeAddress - continuationCodeAddress;
if (distance >= bestDistance)
{
continue;

View File

@ -488,10 +488,94 @@ public static partial class AgcExports
return fingerprint;
}
private static bool TryAllocateCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, out ulong commandAddress)
=> TryPrepareCommandDwords(ctx, commandBufferAddress, sizeDwords, true, out commandAddress);
private static bool TryAllocateCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, out ulong commandAddress,
bool isMarker = false)
=> TryPrepareCommandDwords(ctx, commandBufferAddress, sizeDwords, true, out commandAddress, isMarker);
private static bool TryPrepareCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor, out ulong commandAddress)
// Inline guest stores do not take the allocation lock. Remember the last ordinary
// packet writer so a diagnostic marker from another guest thread cannot reserve or
// overwrite that writer's unpublished inline packet (Yotei 1.512). Ordinary exports
// can transfer a buffer to a new writer. Scope identities to memory, not guest addresses
// reused by another game; guest handles survive migration between host threads.
private static readonly ConditionalWeakTable<object, ConcurrentDictionary<ulong, CommandBufferWriter>> _commandBufferWriters = new();
private readonly record struct CommandBufferWriter(ulong ThreadHandle, CpuContext Context)
{
public bool Matches(CpuContext ctx) => ThreadHandle != 0 && GuestThreadExecution.CurrentGuestThreadHandle != 0
? ThreadHandle == GuestThreadExecution.CurrentGuestThreadHandle
: ReferenceEquals(Context, ctx);
}
// Guest threads run HLE exports concurrently. Two exports appending to one command buffer
// both read its cursor before either stores it back, and the second packet overwrites the
// first (a Yōtei DCB lost the header of a SET_SH_REG to a concurrent PopMarker). The
// cursor update is made atomic per buffer; the lock is not held across the guest's
// buffer-full callback.
private static readonly object[] _commandBufferLocks = CreateCommandBufferLocks();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, byte> _reportedConcurrentCommandBuffers = new();
private static object[] CreateCommandBufferLocks()
{
var locks = new object[64];
for (var index = 0; index < locks.Length; index++)
{
locks[index] = new object();
}
return locks;
}
// The buffer each stripe's holder is appending to, so contention between two buffers that
// share a stripe is not reported as a race on one buffer.
private static readonly ulong[] _commandBufferLockOwners = new ulong[64];
private static bool TryPrepareCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor,
out ulong commandAddress, bool isMarker = false)
{
// Worker threads keep their command buffer objects at the same offset of stacks 16 MiB
// apart; a multiplicative hash keeps them on different stripes.
var stripe = (int)((commandBufferAddress * 0x9E3779B97F4A7C15UL) >> 58);
var gate = _commandBufferLocks[stripe];
if (!Monitor.TryEnter(gate))
{
if (Volatile.Read(ref _commandBufferLockOwners[stripe]) == commandBufferAddress &&
_reportedConcurrentCommandBuffers.TryAdd(commandBufferAddress, 0))
{
Console.Error.WriteLine(
$"[AGC][WARN] Two threads append to one command buffer: buf=0x{commandBufferAddress:X16} thread={Environment.CurrentManagedThreadId}.");
}
Monitor.Enter(gate);
}
try
{
Volatile.Write(ref _commandBufferLockOwners[stripe], commandBufferAddress);
var writers = _commandBufferWriters.GetValue(CanonicalMemory(ctx.Memory), static _ => new());
if (isMarker && writers.TryGetValue(commandBufferAddress, out var writer) && !writer.Matches(ctx))
{
commandAddress = 0;
return false;
}
// A first marker establishes a writer too; an ordinary export explicitly
// takes ownership, including a legitimate handoff between guest threads.
if (!writers.TryGetValue(commandBufferAddress, out writer) || !writer.Matches(ctx) ||
writer.ThreadHandle != GuestThreadExecution.CurrentGuestThreadHandle)
writers[commandBufferAddress] = new(GuestThreadExecution.CurrentGuestThreadHandle, ctx);
return TryPrepareCommandDwordsLocked(ctx, commandBufferAddress, sizeDwords, advanceCursor, gate,
writers, isMarker, out commandAddress);
}
finally
{
Volatile.Write(ref _commandBufferLockOwners[stripe], 0);
Monitor.Exit(gate);
}
}
private static bool TryPrepareCommandDwordsLocked(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor, object gate,
ConcurrentDictionary<ulong, CommandBufferWriter> writers, bool isMarker, out ulong commandAddress)
{
commandAddress = 0;
if (sizeDwords == 0 ||
@ -511,19 +595,31 @@ public static partial class AgcExports
var scheduler = GuestThreadExecution.Scheduler;
ulong callbackResult = 0;
string? callbackError = null;
if (callback == 0 ||
scheduler is null ||
!scheduler.TryCallGuestFunction(
ctx,
callback,
commandBufferAddress,
(ulong)sizeDwords + reservedDwords,
userData,
0,
0,
"agc_command_buffer_full",
out callbackResult,
out callbackError))
bool called;
// The callback is guest code; it may block or append to this buffer itself.
Monitor.Exit(gate);
try
{
called = callback != 0 &&
scheduler is not null &&
scheduler.TryCallGuestFunction(
ctx,
callback,
commandBufferAddress,
(ulong)sizeDwords + reservedDwords,
userData,
0,
0,
"agc_command_buffer_full",
out callbackResult,
out callbackError);
}
finally
{
Monitor.Enter(gate);
}
if (!called)
{
TraceAgc(
$"agc.cmd_alloc_callback_failed buf=0x{commandBufferAddress:X16} " +
@ -532,6 +628,11 @@ public static partial class AgcExports
return false;
}
// The callback ran with the gate released. An ordinary export may have
// handed the DCB to another writer while this marker was waiting for space.
if (isMarker && !writers[commandBufferAddress].Matches(ctx))
return false;
TraceAgc(
$"agc.cmd_alloc_callback_complete buf=0x{commandBufferAddress:X16} " +
$"callback=0x{callback:X16} result=0x{callbackResult:X16}");

View File

@ -419,24 +419,12 @@ public static class AjmExports
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
try
{
var config = new LibAtrac9.Atrac9Config(configData.ToArray());
Span<byte> info = stackalloc byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], unchecked((uint)config.ChannelCount));
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)config.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], unchecked((uint)config.FrameSamples));
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)config.SuperframeSamples));
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)config.SuperframeBytes));
return ctx.SetReturn(
ctx.Memory.TryWrite(outputAddress, info)
? 0
: OrbisAjmErrorInvalidParameter);
}
catch (InvalidDataException)
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
Span<byte> info = stackalloc byte[20];
return ctx.SetReturn(
Atrac9DecodeState.TryDescribeConfig(configData, info) &&
ctx.Memory.TryWrite(outputAddress, info)
? 0
: OrbisAjmErrorInvalidParameter);
}
[SysAbiExport(
@ -607,6 +595,78 @@ public static class AjmExports
return ctx.SetReturn(0);
}
/// <summary>
/// Enqueues a control job (initialize/reset) on an instance. The sideband input
/// carries the codec configuration; the sideband output starts with the basic
/// result the guest polls after the batch completes.
/// </summary>
[SysAbiExport(
Nid = "7FZsbyVRM4U",
ExportName = "sceAjmBatchJobControl",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchJobControl(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdi];
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
var flags = ctx[CpuRegister.Rdx];
var inputAddress = ctx[CpuRegister.Rcx];
var inputSize = ctx[CpuRegister.R8];
var outputAddress = ctx[CpuRegister.R9];
var outputSize = ReadStackArg64(ctx, 0);
if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize))
{
return ctx.SetReturn(OrbisAjmErrorJobCreation);
}
var status = Atrac9DecodeState.ResultInvalidParameter;
AjmInstanceState? instance = null;
if (TryGetInstance(instanceId, out instance))
{
status = Atrac9DecodeState.ResultInvalidParameter;
if (instance.Atrac9 is not null)
{
// The initialize sideband carries the four ATRAC9 config bytes after an
// eight-byte header; the config starts with its 0xFE sync byte.
Span<byte> sideband = stackalloc byte[(int)Math.Min(inputSize, 16UL)];
if (inputAddress != 0 && sideband.Length >= 4 && ctx.Memory.TryRead(inputAddress, sideband))
{
var configOffset = sideband.Length >= 12 &&
(sideband[8] is 0xFE or 0x30)
? 8
: 0;
if (sideband[configOffset] is 0xFE or 0x30)
{
var totalSamples = sideband.Length >= 4
? BinaryPrimitives.ReadUInt32LittleEndian(sideband)
: 0;
var skipSamples = sideband.Length >= 6
? BinaryPrimitives.ReadUInt16LittleEndian(sideband[4..])
: (ushort)0;
status = instance.Atrac9.TryInitialize(
sideband.Slice(configOffset, 4),
totalSamples,
skipSamples)
? 0
: Atrac9DecodeState.ResultInvalidParameter;
}
}
}
}
if (outputSize >= 8)
{
WriteBasicResult(ctx, outputAddress, status);
}
Trace(
$"batch_job_control instance=0x{instanceId:X8} flags=0x{flags:X16} " +
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} status=0x{status:X8} " +
$"atrac9={(instance?.Atrac9 is null ? "none" : "yes")} input={TraceBytes(ctx, inputAddress, inputSize)}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "3cAg7xN995U",
ExportName = "sceAjmBatchJobGetStatistics",
@ -804,7 +864,15 @@ public static class AjmExports
return ctx.SetReturn(0);
}
private static int AjmBatchJobDecodeCore(CpuContext ctx, bool multipleFrames)
[SysAbiExport(
Nid = "SJ3i0DXP8vg",
ExportName = "sceAjmBatchJobDecodeSplit",
Target = Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchJobDecodeSplit(CpuContext ctx) =>
AjmBatchJobDecodeCore(ctx, multipleFrames: true, split: true);
private static int AjmBatchJobDecodeCore(CpuContext ctx, bool multipleFrames, bool split = false)
{
var infoAddress = ctx[CpuRegister.Rdi];
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
@ -829,6 +897,19 @@ public static class AjmExports
0,
0);
}
else if (split)
{
if (instance.Codec != Atrac9CodecType || instance.Atrac9 is null ||
!TryCollectBuffers(ctx, true, inputAddress, inputSize, out var inputs, out var inputLength) ||
!TryCollectBuffers(ctx, true, outputAddress, outputSize, out var outputs, out var outputLength))
{
result = new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0);
}
else
{
result = DecodeAtrac9Scattered(ctx, instance, inputs, inputLength, outputs, outputLength, multipleFrames);
}
}
else if (instance.Mp3 is not null)
{
// GTA V Enhanced streams menu music through AJM MP3 (codec 0).
@ -1136,9 +1217,10 @@ public static class AjmExports
return;
}
// Layout is positional and driven purely by the job flags:
// Result, then Stream, Format, GaplessDecode, MFrame, CodecInfo — each
// only present when its flag is set and there is still room.
// The run sideband is positional and driven by the job flags. When the
// stream block is requested, +0x10 is the number of samples written
// since the last control job; the title uses it to end a stream exactly
// at its total.
Span<byte> sideband = stackalloc byte[
AjmSidebandResultBytes +
AjmSidebandStreamBytes +
@ -1149,7 +1231,6 @@ public static class AjmExports
BinaryPrimitives.WriteInt32LittleEndian(sideband, result.Status);
var offset = AjmSidebandResultBytes;
if ((flags & AjmJobSidebandFlagStream) != 0 && (ulong)(offset + AjmSidebandStreamBytes) <= size)
{
BinaryPrimitives.WriteInt32LittleEndian(sideband[offset..], result.InputConsumed);
@ -1158,9 +1239,10 @@ public static class AjmExports
offset += AjmSidebandStreamBytes;
}
var streamInfo = instance?.Atrac9?.Info;
if ((flags & AjmJobSidebandFlagFormat) != 0 && (ulong)(offset + AjmSidebandFormatBytes) <= size)
{
var channels = config?.ChannelCount ?? instance?.MaxChannels ?? 0;
var channels = config?.ChannelCount ?? streamInfo?.Channels ?? instance?.MaxChannels ?? 0;
BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], unchecked((uint)channels));
BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 4)..], ChannelMaskFor(channels));
BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 8)..], unchecked((uint)(config?.SampleRate ?? 0)));
@ -1377,6 +1459,12 @@ public static class AjmExports
multipleFrames ? sideband : sideband[..24]);
}
private static string TraceBytes(CpuContext ctx, ulong address, ulong size)
{
Span<byte> bytes = stackalloc byte[(int)Math.Min(size, 32UL)];
return address != 0 && ctx.Memory.TryRead(address, bytes) ? Convert.ToHexString(bytes) : "-";
}
private static void WriteBasicResult(CpuContext ctx, ulong resultAddress, int status)
{
if (resultAddress == 0)

View File

@ -1,5 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// Portions adapted from YoteiPC (at9_api.c) by llnternet, GPL-2.0.
// SPDX-License-Identifier: GPL-2.0-only
using System.Buffers.Binary;
using LibAtrac9;
@ -20,6 +21,14 @@ internal readonly record struct Atrac9DecodeResult(
ulong TotalDecodedSamples,
uint Frames);
internal readonly record struct Atrac9StreamInfo(
int Channels,
int SampleRate,
int SuperframeBytes,
int FramesPerSuperframe,
int FrameSamples,
int BytesPerFrame);
internal readonly record struct Atrac9Gapless(uint TotalSamples, ushort SkipSamples, ushort SkippedSamples);
internal sealed class Atrac9DecodeState
@ -32,6 +41,14 @@ internal sealed class Atrac9DecodeState
internal const int ResultCodecError = 0x40000000;
private const int MaxContainerHeaderBytes = 8 * 1024;
internal static readonly object DecoderInitGate = new();
private static readonly int[] SampleRates =
[
11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000,
44100, 48000, 64000, 88200, 96000, 128000, 176400, 192000,
];
private static readonly int[] FrameSamplePowers = [6, 6, 7, 7, 7, 8, 8, 8, 6, 6, 7, 7, 7, 8, 8, 8];
private static readonly int[] StandardChannelCounts = [1, 2, 2, 6, 8, 4];
private enum ContainerScan
{
@ -42,6 +59,10 @@ internal sealed class Atrac9DecodeState
private readonly object _gate = new();
private Atrac9Decoder? _decoder;
private Atrac9Decoder[]? _extendedDecoders;
private byte[]? _decoderConfig;
private byte[]? _extendedDecoderConfig;
private Atrac9StreamInfo? _info;
private byte[]? _configData;
private byte[]? _compressed;
private short[][]? _planarPcm;
@ -49,8 +70,17 @@ internal sealed class Atrac9DecodeState
private int _containerHeaderLength;
private int _compressedLength;
private ulong _totalDecodedSamples;
private Atrac9Gapless _gaplessInit;
private Atrac9Gapless _gapless;
private ulong _totalSampleLimit;
private ulong _skippedSamples;
// Every sample the gapless window removed: the lead-in and the trimmed tail.
private ulong _droppedSamples;
private byte[]? _superframeOutput;
private ushort _skipSamples;
private bool _decodeErrorLogged;
// Gapless state from sceAjmBatchJobSetGaplessDecode. It is applied again whenever
// the stream restarts, as the hardware re-arms its skip and sample counts.
private Atrac9Gapless? _gaplessInit;
public Atrac9Gapless Gapless
{
@ -58,7 +88,13 @@ internal sealed class Atrac9DecodeState
{
lock (_gate)
{
return _gapless;
var remaining = _totalSampleLimit == 0
? _gaplessInit?.TotalSamples ?? 0
: (uint)Math.Min(uint.MaxValue, _totalSampleLimit > _totalDecodedSamples ? _totalSampleLimit - _totalDecodedSamples : 0);
return new Atrac9Gapless(
remaining,
(ushort)(_skipSamples - Math.Min(_skippedSamples, _skipSamples)),
(ushort)Math.Min(ushort.MaxValue, _droppedSamples));
}
}
}
@ -68,14 +104,26 @@ internal sealed class Atrac9DecodeState
lock (_gate)
{
_gaplessInit = new Atrac9Gapless(totalSamples, skipSamples, 0);
if (reset || _gapless == default)
if (reset || (_totalSampleLimit == 0 && _skipSamples == 0))
{
_gapless = _gaplessInit;
ApplyGaplessNoLock();
}
}
}
private bool HasSampleLimit => _gaplessInit.TotalSamples is not 0 and not uint.MaxValue;
private void ApplyGaplessNoLock()
{
if (_gaplessInit is not { } gapless)
{
return;
}
// Zero and uint.MaxValue both leave the stream unbounded.
_totalSampleLimit = gapless.TotalSamples is 0 or uint.MaxValue ? 0 : _totalDecodedSamples + gapless.TotalSamples;
_skipSamples = gapless.SkipSamples;
_skippedSamples = 0;
_droppedSamples = 0;
}
public Atrac9Config? Config
{
@ -88,7 +136,18 @@ internal sealed class Atrac9DecodeState
}
}
public bool TryInitialize(ReadOnlySpan<byte> configData)
internal Atrac9StreamInfo? Info
{
get
{
lock (_gate)
{
return _info;
}
}
}
public bool TryInitialize(ReadOnlySpan<byte> configData, uint totalSamples = 0, ushort skipSamples = 0)
{
if (configData.Length < 4)
{
@ -97,34 +156,56 @@ internal sealed class Atrac9DecodeState
lock (_gate)
{
try
ClearNoLock();
if (!TryParseConfig(
configData,
out var info,
out var decoderConfig,
out var extendedDecoderConfig))
{
var normalizedConfig = configData[..4].ToArray();
var decoder = new Atrac9Decoder();
decoder.Initialize(normalizedConfig);
var config = decoder.Config;
_decoder = decoder;
_configData = normalizedConfig;
_compressed = new byte[config.SuperframeBytes];
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
_gapless = _gaplessInit;
Trace(
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
$"frames_per_superframe={config.FramesPerSuperframe}");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException)
{
Clear();
return false;
}
_configData = configData[..4].ToArray();
_info = info;
_decoderConfig = decoderConfig;
_extendedDecoderConfig = extendedDecoderConfig;
_compressed = new byte[info.SuperframeBytes];
_planarPcm = CreatePcmBuffer(info.Channels, info.FrameSamples * info.FramesPerSuperframe);
_compressedLength = 0;
_totalDecodedSamples = 0;
_totalSampleLimit = totalSamples;
_skipSamples = skipSamples;
_skippedSamples = 0;
_droppedSamples = 0;
if (totalSamples == 0 && skipSamples == 0)
{
ApplyGaplessNoLock();
}
try
{
lock (DecoderInitGate)
{
InitializeDecodersNoLock();
}
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
_decoder = null;
_extendedDecoders = null;
Trace($"decoder_unavailable config={Convert.ToHexString(_configData)} error={exception.GetType().Name}");
}
_containerHeaderLength = 0;
Trace(
$"initialized config={Convert.ToHexString(_configData)} channels={info.Channels} " +
$"rate={info.SampleRate} frame_samples={info.FrameSamples} " +
$"superframe_samples={info.FrameSamples * info.FramesPerSuperframe} " +
$"superframe_bytes={info.SuperframeBytes} frames_per_superframe={info.FramesPerSuperframe} " +
$"decoder={(_decoder is not null || _extendedDecoders is not null ? "yes" : "silence")}");
return true;
}
}
@ -138,13 +219,16 @@ internal sealed class Atrac9DecodeState
return;
}
var decoder = new Atrac9Decoder();
decoder.Initialize((byte[])_configData.Clone());
_decoder = decoder;
lock (DecoderInitGate)
{
InitializeDecodersNoLock();
}
_compressedLength = 0;
_totalDecodedSamples = 0;
_skippedSamples = 0;
_droppedSamples = 0;
_containerHeaderLength = 0;
_gapless = _gaplessInit;
ApplyGaplessNoLock();
if (_compressed is not null)
{
Array.Clear(_compressed);
@ -161,7 +245,7 @@ internal sealed class Atrac9DecodeState
{
lock (_gate)
{
if (_decoder is null || _compressed is null || _planarPcm is null)
if (_info is not { } info || _compressed is null || _planarPcm is null)
{
return new Atrac9DecodeResult(
ResultNotInitialized,
@ -171,8 +255,7 @@ internal sealed class Atrac9DecodeState
0);
}
var config = _decoder.Config;
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
var channels = requestedChannels > 0 ? requestedChannels : info.Channels;
if (channels is < 1 or > 16)
{
return new Atrac9DecodeResult(
@ -184,15 +267,17 @@ internal sealed class Atrac9DecodeState
}
var bytesPerSample = GetBytesPerSample(encoding);
var outputBytesPerSuperframe = checked(info.FrameSamples * info.FramesPerSuperframe * channels * bytesPerSample);
var consumed = 0;
var written = 0;
uint frames = 0;
var status = 0;
while (_compressedLength == config.SuperframeBytes ||
while (_compressedLength == info.SuperframeBytes ||
consumed < input.Length)
{
if (HasSampleLimit && _gapless.TotalSamples == 0)
// A stream that reached its gapless total takes no more input.
if (_totalSampleLimit != 0 && _totalDecodedSamples >= _totalSampleLimit)
{
break;
}
@ -223,73 +308,73 @@ internal sealed class Atrac9DecodeState
}
}
if (_compressedLength < config.SuperframeBytes)
if (_compressedLength < info.SuperframeBytes)
{
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
var copied = Math.Min(info.SuperframeBytes - _compressedLength, input.Length - consumed);
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
_compressedLength += copied;
consumed += copied;
}
if (_compressedLength < config.SuperframeBytes)
if (_compressedLength < info.SuperframeBytes)
{
status |= ResultPartialInput;
break;
}
var skip = Math.Min(config.SuperframeSamples, (int)_gapless.SkipSamples);
var samples = config.SuperframeSamples - skip;
if (HasSampleLimit)
{
samples = (int)Math.Min((uint)samples, _gapless.TotalSamples);
}
var outputBytes = checked(samples * channels * bytesPerSample);
if (output.Length - written < outputBytes)
// The gapless window trims the superframe: only the kept samples need room.
var fullSamples = info.FrameSamples * info.FramesPerSuperframe;
var dropSamples = _skippedSamples < _skipSamples
? (int)Math.Min((ulong)fullSamples, _skipSamples - _skippedSamples)
: 0;
var availableSamples = fullSamples - dropSamples;
var writableSamples = _totalSampleLimit == 0
? availableSamples
: (int)Math.Min((ulong)availableSamples, _totalSampleLimit > _totalDecodedSamples
? _totalSampleLimit - _totalDecodedSamples
: 0);
var sampleBytes = checked(channels * bytesPerSample);
if (output.Length - written < writableSamples * sampleBytes)
{
status |= ResultNotEnoughRoom;
break;
}
try
if (_superframeOutput is null || _superframeOutput.Length < outputBytesPerSuperframe)
{
_decoder.Decode(_compressed, _planarPcm);
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
Trace(
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
$"config={Convert.ToHexString(_configData ?? [])} " +
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
$"error={exception.GetType().Name}: {exception.Message}");
_compressedLength = 0;
return new Atrac9DecodeResult(
status | ResultInvalidData | ResultCodecError,
consumed,
written,
_totalDecodedSamples,
frames);
_superframeOutput = new byte[outputBytesPerSuperframe];
}
WriteInterleaved(
_planarPcm,
output.Slice(written, outputBytes),
skip,
samples,
channels,
encoding);
var superframe = _superframeOutput.AsSpan(0, outputBytesPerSuperframe);
var decoded = TryDecodeSuperframeNoLock(channels, encoding, superframe);
if (!decoded)
{
if (!_decodeErrorLogged)
{
Trace(
$"decode_failed superframe_bytes={info.SuperframeBytes} " +
$"config={Convert.ToHexString(_configData ?? [])} " +
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
"playing_silence");
_decodeErrorLogged = true;
}
superframe.Clear();
lock (DecoderInitGate)
{
InitializeDecodersNoLock();
}
}
_gapless = new Atrac9Gapless(
HasSampleLimit ? _gapless.TotalSamples - (uint)samples : _gapless.TotalSamples,
(ushort)(_gapless.SkipSamples - skip),
(ushort)Math.Min(ushort.MaxValue, _gapless.SkippedSamples + config.SuperframeSamples - samples));
written += outputBytes;
superframe.Slice(dropSamples * sampleBytes, writableSamples * sampleBytes)
.CopyTo(output.Slice(written, writableSamples * sampleBytes));
_skippedSamples += (ulong)dropSamples;
_droppedSamples += (ulong)(fullSamples - writableSamples);
written += writableSamples * sampleBytes;
_compressedLength = 0;
_totalDecodedSamples += unchecked((uint)samples);
frames += unchecked((uint)config.FramesPerSuperframe);
_totalDecodedSamples += unchecked((uint)writableSamples);
frames += unchecked((uint)info.FramesPerSuperframe);
if (!multipleFrames)
if (!multipleFrames || (_totalSampleLimit != 0 && _totalDecodedSamples >= _totalSampleLimit))
{
break;
}
@ -389,6 +474,24 @@ internal sealed class Atrac9DecodeState
return result;
}
internal static bool TryDescribeConfig(ReadOnlySpan<byte> configData, Span<byte> info)
{
lock (DecoderInitGate)
{
if (info.Length < 20 || !TryParseConfig(configData, out var streamInfo, out _, out _))
{
return false;
}
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], unchecked((uint)streamInfo.SuperframeBytes));
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)streamInfo.FramesPerSuperframe));
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], unchecked((uint)streamInfo.Channels));
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)streamInfo.FrameSamples));
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)streamInfo.BytesPerFrame));
return true;
}
}
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
encoding switch
{
@ -401,13 +504,12 @@ internal sealed class Atrac9DecodeState
private static void WriteInterleaved(
short[][] source,
Span<byte> destination,
int firstSample,
int samples,
int channels,
Atrac9PcmEncoding encoding)
{
var offset = 0;
for (var sample = firstSample; sample < firstSample + samples; sample++)
for (var sample = 0; sample < samples; sample++)
{
for (var channel = 0; channel < channels; channel++)
{
@ -444,9 +546,212 @@ internal sealed class Atrac9DecodeState
}
}
private void InitializeDecodersNoLock()
{
_decoder = null;
_extendedDecoders = null;
if (_decoderConfig is not null)
{
var decoder = new Atrac9Decoder();
decoder.Initialize((byte[])_decoderConfig.Clone());
_decoder = decoder;
}
else if (_extendedDecoderConfig is not null && _info is { } info)
{
var decoders = new Atrac9Decoder[info.Channels];
for (var index = 0; index < decoders.Length; index++)
{
decoders[index] = new Atrac9Decoder();
decoders[index].Initialize((byte[])_extendedDecoderConfig.Clone());
}
_extendedDecoders = decoders;
}
}
private bool TryDecodeSuperframeNoLock(int requestedChannels, Atrac9PcmEncoding encoding, Span<byte> output)
{
if (_info is not { } info || _planarPcm is null || _compressed is null)
{
return false;
}
try
{
if (_decoder is not null)
{
_decoder.Decode(_compressed, _planarPcm);
}
else if (_extendedDecoders is not null)
{
if (!TryDecodeExtendedNoLock(info))
{
return false;
}
}
else
{
output.Clear();
return true;
}
WriteInterleaved(
_planarPcm,
output,
info.FrameSamples * info.FramesPerSuperframe,
requestedChannels,
encoding);
return true;
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
Trace($"decode_exception error={exception.GetType().Name}: {exception.Message}");
return false;
}
}
private bool TryDecodeExtendedNoLock(Atrac9StreamInfo info)
{
// Multichannel block ordering and consumed-byte traversal follow
// YoteiPC (llnternet) at9_api.c, GPL-2.0, adapted to the stateful LibAtrac9 API.
if (_extendedDecoders is null || _planarPcm is null || _compressed is null)
{
return false;
}
var frames = info.FramesPerSuperframe;
var position = 0;
for (var frame = 0; frame < frames; frame++)
{
for (var channel = 0; channel < info.Channels; channel++)
{
if (!TryBeginExtendedBlock(_compressed, frame, ref position))
{
return false;
}
var remaining = _compressed.AsSpan(position).ToArray();
var used = _extendedDecoders[channel].DecodeFrame(
remaining,
_planarPcm[channel..(channel + 1)],
frame * info.FrameSamples,
frame);
if (used <= 0 || used > remaining.Length)
{
return false;
}
position += used;
}
}
return true;
}
internal static bool TryBeginExtendedBlock(ReadOnlySpan<byte> compressed, int frame, ref int position)
{
if (frame < 0 || position < 0 || position > compressed.Length)
{
return false;
}
if (frame != 0)
{
while (position < compressed.Length && (compressed[position] & 0x80) == 0)
{
position++;
}
}
return position < compressed.Length;
}
private static bool TryParseConfig(
ReadOnlySpan<byte> configData,
out Atrac9StreamInfo info,
out byte[]? decoderConfig,
out byte[]? extendedDecoderConfig)
{
info = default;
decoderConfig = null;
extendedDecoderConfig = null;
if (configData.Length < 4)
{
return false;
}
var c = configData[..4];
var sampleRateIndex = c[1] >> 4;
if (sampleRateIndex >= SampleRates.Length)
{
return false;
}
if (c[0] == 0xFE)
{
var frameBytes = (((c[2] << 8) | c[3]) >> 5) + 1;
var frames = 1 << ((c[3] >> 3) & 3);
var layout = (c[1] >> 1) & 7;
var channels = layout <= 5 ? StandardChannelCounts[layout] : 2;
var frameSamples = 1 << FrameSamplePowers[sampleRateIndex];
var normalized = c.ToArray();
if (layout == 7)
{
normalized[1] = (byte)((normalized[1] & ~0x0E) | (2 << 1));
}
else if (layout > 5)
{
normalized = null!;
}
info = new Atrac9StreamInfo(
channels,
SampleRates[sampleRateIndex],
checked(frameBytes * frames),
frames,
frameSamples,
frameBytes);
decoderConfig = normalized;
return true;
}
if (c[0] != 0x30 || (c[2] & 0xC0) != 0xC0)
{
return false;
}
var channelsExtended = ((c[1] & 0x0F) + 1) * 4;
var frameBytesExtended = (((c[2] & 7) << 6) | (c[3] >> 2)) + 1;
var sfIndex = c[3] & 3;
var framesExtended = 1 << sfIndex;
var frameSamplesExtended = 1 << FrameSamplePowers[sampleRateIndex];
var field = ((frameBytesExtended - 1) << 5) | (sfIndex << 3);
extendedDecoderConfig =
[
0xFE,
(byte)(sampleRateIndex << 4),
(byte)(field >> 8),
(byte)field,
];
info = new Atrac9StreamInfo(
channelsExtended,
SampleRates[sampleRateIndex],
checked(channelsExtended * frameBytesExtended * framesExtended),
framesExtended,
frameSamplesExtended,
frameBytesExtended);
return true;
}
private void Clear()
{
_decoder = null;
_extendedDecoders = null;
_decoderConfig = null;
_extendedDecoderConfig = null;
_info = null;
_configData = null;
_compressed = null;
_planarPcm = null;
@ -454,5 +759,12 @@ internal sealed class Atrac9DecodeState
_containerHeaderLength = 0;
_compressedLength = 0;
_totalDecodedSamples = 0;
_totalSampleLimit = 0;
_skippedSamples = 0;
_droppedSamples = 0;
_skipSamples = 0;
_decodeErrorLogged = false;
}
private void ClearNoLock() => Clear();
}

View File

@ -113,6 +113,7 @@ public static class AudioOut2Exports
private sealed class ContextState
{
private readonly object _paceGate = new();
private IHostAudioStream? _backend;
private long _nextAdvanceTimestamp;
public ContextState(ulong handle, uint frequency, uint grainSamples, uint queueDepth, IHostAudioStream? backend)
@ -121,14 +122,56 @@ public static class AudioOut2Exports
Frequency = frequency == 0 ? 48000 : frequency;
GrainSamples = grainSamples == 0 ? 256 : grainSamples;
QueueDepth = queueDepth == 0 ? 4 : queueDepth;
Backend = backend;
_backend = backend;
}
public ulong Handle { get; }
public uint Frequency { get; }
public uint GrainSamples { get; }
public uint QueueDepth { get; }
public IHostAudioStream? Backend { get; }
public IHostAudioStream? Backend => Volatile.Read(ref _backend);
public void AttachBackend(IHostAudioStream? backend) => Volatile.Write(ref _backend, backend);
public uint QueuedGrains
{
get
{
if (Backend is { } backend)
{
var queuedMilliseconds = backend.QueuedMilliseconds;
if (queuedMilliseconds >= 0)
{
var grainMilliseconds = (double)GrainSamples * 1000.0 / Frequency;
return Math.Min(
QueueDepth,
checked((uint)Math.Ceiling(queuedMilliseconds / grainMilliseconds)));
}
}
// A context can be intentionally backend-less (headless runs,
// unavailable host audio, or before lazy backend binding). In
// that case PaceAdvance is the queue: report the portion of
// its software clock that has not elapsed yet instead of
// claiming that every grain is free.
lock (_paceGate)
{
var remaining = _nextAdvanceTimestamp - Stopwatch.GetTimestamp();
if (remaining <= 0)
{
return 0;
}
var grainTicks = checked(
(long)Math.Ceiling(Stopwatch.Frequency * (double)GrainSamples / Frequency));
return Math.Min(
QueueDepth,
checked((uint)Math.Ceiling((double)remaining / grainTicks)));
}
}
}
public uint FreeGrains => QueueDepth - Math.Min(QueueDepth, QueuedGrains);
public long GrainTicks => (long)Math.Ceiling(Stopwatch.Frequency * (double)GrainSamples / Frequency);
@ -512,9 +555,14 @@ public static class AudioOut2Exports
}
else
{
// Push is the blocking point of a grain: it waits for the grain an Advance claimed, or
// claims one itself when the title does not advance separately.
ContextState.SleepUntil(context.QueueSlotFree(advancedGrain != 0 ? advancedGrain : context.ReserveGrain()));
// Push queues the grain an Advance claimed, or claims one itself when the title does not
// advance separately. A blocking push waits for its slot; a nonblocking one returns at
// once, as on the hardware, and the grain still counts in the queue level.
var grain = advancedGrain != 0 ? advancedGrain : context.ReserveGrain();
if (blocking != 0)
{
ContextState.SleepUntil(context.QueueSlotFree(grain));
}
}
return SetReturn(ctx, 0);
@ -833,6 +881,10 @@ public static class AudioOut2Exports
return SetReturn(ctx, 0);
}
// Fixed-size connected state. Do not trust r8/r9 for byte counts.
// Both Demon's Souls and Yotei derive their speaker layout from numChannels
// at +2. Yotei's startup poll waits for that count to become nonzero; it is
// not an independent active flag. +3 is padding, as in the struct above.
[SysAbiExport(
Nid = "gatEUKG+Ea4",
ExportName = "sceAudioOut2PortGetState",
@ -860,6 +912,10 @@ public static class AudioOut2Exports
Span<byte> state = stackalloc byte[PortStateSize];
state.Clear();
// +0x00 u16 output = CONNECTED_PRIMARY (1)
// +0x02 u8 channels = from port format when known, else 2
// +0x03 u8 padding = 0
// +0x04 s16 volume = 127 (full volume)
byte channels = 2;
if (Ports.TryGetValue(portHandle, out var port) &&
TryDecodeDataFormat(port.DataFormat, out var decodedChannels, out _, out _))
@ -1238,6 +1294,7 @@ public static class AudioOut2Exports
}
backendName = PrimaryBackendName;
context.AttachBackend(PrimaryBackend);
return PrimaryBackend;
}
@ -1269,6 +1326,7 @@ public static class AudioOut2Exports
}
backendName = SecondaryBackendName;
context.AttachBackend(SecondaryBackend);
return SecondaryBackend;
}
}

View File

@ -51,7 +51,8 @@ public static class AudioOutExports
int bytesPerSample,
bool isFloat,
bool preservesGuestFormat,
IHostAudioStream? backend)
IHostAudioStream? backend,
string backendName)
{
UserId = userId;
Type = type;
@ -63,6 +64,7 @@ public static class AudioOutExports
IsFloat = isFloat;
PreservesGuestFormat = preservesGuestFormat;
Backend = backend;
BackendName = backendName;
}
public int UserId { get; }
@ -75,6 +77,7 @@ public static class AudioOutExports
public bool IsFloat { get; }
public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; }
public string BackendName { get; }
public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f;
public int BufferByteLength =>
@ -195,7 +198,8 @@ public static class AudioOutExports
bytesPerSample,
isFloat,
preservesGuestFormat,
backend);
backend,
backendName);
Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
$"{channels} ch, {(isFloat ? "float32" : "s16")}, " +
@ -598,7 +602,7 @@ public static class AudioOutExports
{
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
Console.Error.WriteLine(
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : port.BackendName)}");
}
}

View File

@ -61,6 +61,7 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
private int _swsSourceHeight;
private AVPixelFormat _swsSourceFormat = AVPixelFormat.AV_PIX_FMT_NONE;
private bool _disposed;
private bool _disposing;
private readonly Channel<byte[]?> _workChannel =
Channel.CreateBounded<byte[]?>(new BoundedChannelOptions(WorkQueueCapacity)
@ -82,7 +83,7 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
private readonly Thread _worker;
private readonly Thread _scheduler;
// Cancelled (not just completed) on Dispose so both loops stop promptly instead of draining a backlog.
// Kept alive until both threads have finished accepted decode and presentation work.
private readonly CancellationTokenSource _workerCts = new();
private readonly object _protocolGate = new();
@ -205,7 +206,15 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
public bool EnqueueAccessUnit(byte[] accessUnit)
{
return _workChannel.Writer.TryWrite(accessUnit);
lock (_gate)
{
if (_disposed || _disposing)
{
return false;
}
return _workChannel.Writer.TryWrite(accessUnit);
}
}
/// <summary>Queues an end-of-stream drain: flush FFmpeg and emit one more buffered picture, if any.</summary>
@ -794,24 +803,28 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
{
lock (_gate)
{
if (_disposed)
if (_disposed || _disposing)
{
return;
}
_disposed = true;
_disposing = true;
}
// Outside _gate: the worker needs it to finish whatever item it's mid-call on.
_workerCts.Cancel();
// Stop accepting access units, then finish the work already accepted. The
// scheduler must stay alive while the worker drains its bounded frame queue.
RequestDrain();
_workChannel.Writer.TryComplete();
_worker.Join();
_frameQueue.Writer.TryComplete();
_worker.Join(TimeSpan.FromSeconds(2));
_scheduler.Join(TimeSpan.FromSeconds(2));
_scheduler.Join();
_workerCts.Cancel();
_workerCts.Dispose();
lock (_gate)
{
_disposed = true;
_disposing = false;
if (_swsContext != null)
{
ffmpeg.sws_freeContext(_swsContext);

View File

@ -283,6 +283,11 @@ public static class Videodec2Exports
return SetReturn(ctx, Ok);
}
// SceVideodec2OutputInfo after the caller's thisSize: frameWidth (+0x10) and frameHeight (+0x18)
// are 32-bit fields; framePitch (+0x14) lies between them.
internal static bool TryWritePictureDimensions(CpuContext ctx, ulong address, uint width, uint height) =>
ctx.TryWriteUInt32(address + 0x10, width) && ctx.TryWriteUInt32(address + 0x18, height);
[SysAbiExport(
Nid = "NtXRa3dRzU0",
ExportName = "sceVideodec2GetPictureInfo",

View File

@ -4,6 +4,7 @@
using System.Runtime.InteropServices;
using SharpEmu.HLE.GpuMemory;
using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Kernel;
using SharpEmu.ShaderCompiler.Vulkan;
using SharpEmu.Libs.Gpu.Vulkan;
using Silk.NET.Vulkan;
@ -11,6 +12,8 @@ using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Buffers;
// Turns the GPU fault bitmap into buffers: a compute pass lists faulted pages per area.
// The fault buffer holds two bitmaps of the same size: pages a shader needed but found
// unmapped, then pages a shader wrote through a device address. The same pass lists each.
public sealed unsafe class BdaFaultProcessor : IDisposable
{
private const int MaxPendingFaults = 8;
@ -27,6 +30,8 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
private readonly ulong _faultBufferSize;
private readonly GpuBuffer _faultBuffer;
private readonly GpuBuffer _downloadBuffer;
private readonly GpuBuffer _writtenDownloadBuffer;
private readonly DescriptorSet[] _writtenSets = new DescriptorSet[MaxPendingFaults];
private readonly GpuBuffer? _traceDownloadBuffer;
private bool _traceInitialized;
private readonly ulong[] _faultAreas = new ulong[MaxPendingFaults];
@ -46,10 +51,11 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
_pageCount = pageCount;
_faultBufferSize = pageCount / 8;
_faultBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.DeviceLocal, 0, GpuBuffer.AllFlags,
_faultBufferSize + (GuestGpuMemoryHook.TraceEnabled ? 32UL : 0UL));
2 * _faultBufferSize + (GuestGpuMemoryHook.TraceEnabled ? 32UL : 0UL));
if (GuestGpuMemoryHook.TraceEnabled)
_traceDownloadBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, MaxPendingFaults * 256);
_downloadBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, MaxPendingFaults * PageFaultAreaSize);
_writtenDownloadBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, MaxPendingFaults * PageFaultAreaSize);
var vk = device.Vk;
var bindings = stackalloc DescriptorSetLayoutBinding[2];
@ -117,11 +123,11 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
RequireSuccess(created, "Fault-buffer pipeline creation");
// One pre-written set per area replaces push descriptors; the bindings never change.
var poolSize = new DescriptorPoolSize { Type = DescriptorType.StorageBuffer, DescriptorCount = 2 * MaxPendingFaults };
var poolSize = new DescriptorPoolSize { Type = DescriptorType.StorageBuffer, DescriptorCount = 4 * MaxPendingFaults };
var poolInfo = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = MaxPendingFaults,
MaxSets = 2 * MaxPendingFaults,
PoolSizeCount = 1,
PPoolSizes = &poolSize,
};
@ -144,27 +150,35 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
RequireSuccess(vk.AllocateDescriptorSets(device.Device, &allocateInfo, sets), "Fault-buffer descriptor set allocation");
}
var infos = stackalloc DescriptorBufferInfo[2 * MaxPendingFaults];
var writes = stackalloc WriteDescriptorSet[2 * MaxPendingFaults];
fixed (DescriptorSet* sets = _writtenSets)
{
RequireSuccess(vk.AllocateDescriptorSets(device.Device, &allocateInfo, sets), "Written-page descriptor set allocation");
}
var infos = stackalloc DescriptorBufferInfo[4 * MaxPendingFaults];
var writes = stackalloc WriteDescriptorSet[4 * MaxPendingFaults];
for (var area = 0; area < MaxPendingFaults; area++)
{
infos[2 * area] = new DescriptorBufferInfo(_faultBuffer.Handle, 0, _faultBufferSize);
infos[2 * area + 1] = new DescriptorBufferInfo(_downloadBuffer.Handle, (ulong)area * PageFaultAreaSize, PageFaultAreaSize);
for (uint binding = 0; binding < 2; binding++)
var fault = 4 * area;
infos[fault] = new DescriptorBufferInfo(_faultBuffer.Handle, 0, _faultBufferSize);
infos[fault + 1] = new DescriptorBufferInfo(_downloadBuffer.Handle, (ulong)area * PageFaultAreaSize, PageFaultAreaSize);
infos[fault + 2] = new DescriptorBufferInfo(_faultBuffer.Handle, _faultBufferSize, _faultBufferSize);
infos[fault + 3] = new DescriptorBufferInfo(_writtenDownloadBuffer.Handle, (ulong)area * PageFaultAreaSize, PageFaultAreaSize);
for (uint binding = 0; binding < 4; binding++)
{
writes[2 * area + binding] = new WriteDescriptorSet
writes[fault + binding] = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = _sets[area],
DstBinding = binding,
DstSet = binding < 2 ? _sets[area] : _writtenSets[area],
DstBinding = binding & 1,
DescriptorCount = 1,
DescriptorType = DescriptorType.StorageBuffer,
PBufferInfo = infos + 2 * area + binding,
PBufferInfo = infos + fault + binding,
};
}
}
vk.UpdateDescriptorSets(device.Device, 2 * MaxPendingFaults, writes, 0, null);
vk.UpdateDescriptorSets(device.Device, 4 * MaxPendingFaults, writes, 0, null);
}
public GpuBuffer FaultBuffer
@ -173,7 +187,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
{
if (_traceDownloadBuffer is not null && !_traceInitialized)
{
_faultBuffer.Fill(_faultBufferSize, 32, 0);
_faultBuffer.Fill(2 * _faultBufferSize, 32, 0);
_traceInitialized = true;
}
return _faultBuffer;
@ -192,6 +206,8 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
var offset = _currentArea * PageFaultAreaSize;
_downloadBuffer.Mapped.Slice((int)offset, (int)PageFaultAreaSize).Clear();
_downloadBuffer.Flush(offset, PageFaultAreaSize);
_writtenDownloadBuffer.Mapped.Slice((int)offset, (int)PageFaultAreaSize).Clear();
_writtenDownloadBuffer.Flush(offset, PageFaultAreaSize);
var preBarrier = new BufferMemoryBarrier2
{
@ -202,7 +218,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Buffer = _faultBuffer.Handle,
Offset = 0,
Size = _faultBufferSize,
Size = 2 * _faultBufferSize,
};
var postBarrier = preBarrier;
postBarrier.DstAccessMask = AccessFlags2.ShaderWriteBit;
@ -218,6 +234,9 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &set, 0, null);
var threads = _pageCount / 32;
vk.CmdDispatch(command, (uint)((threads + 63) / 64), 1, 1);
var writtenSet = _writtenSets[_currentArea];
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &writtenSet, 0, null);
vk.CmdDispatch(command, (uint)((threads + 63) / 64), 1, 1);
VulkanSynchronization.PipelineBarrier(vk,
command, PipelineStageFlags.ComputeShaderBit, PipelineStageFlags.AllCommandsBit, DependencyFlags.ByRegionBit,
0, null, 1, &postBarrier, 0, null);
@ -226,9 +245,9 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
var scanTick = _scheduler.CurrentTick;
if (_traceDownloadBuffer is not null)
{
_traceDownloadBuffer.CopyFrom(_scheduler.Current, _faultBuffer, _faultBufferSize, area * 256UL, 32,
_traceDownloadBuffer.CopyFrom(_scheduler.Current, _faultBuffer, 2 * _faultBufferSize, area * 256UL, 32,
destinationAfter: AccessFlags.HostReadBit);
_faultBuffer.Fill(_faultBufferSize, 32, 0);
_faultBuffer.Fill(2 * _faultBufferSize, 32, 0);
}
_scheduler.QueueCompletionAction(() =>
{
@ -245,7 +264,17 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
var count = Math.Min((uint)faults[0], MaxPageFaults - 1);
for (var index = 1; index <= count; index++)
{
_faultRanges.Add(faults[index], _pageSize);
var insideMapping = KernelMemoryCompatExports.TryGetMappedRange(faults[index], out var mappingStart, out var mappingLength);
if (!insideMapping && !_cache.IsGuestMemoryMapped(faults[index], _pageSize))
{
continue;
}
var window = insideMapping
? GuestBufferCache.DeviceAddressFaultSpan(faults[index], _pageSize, mappingStart, mappingLength)
: new GuestSpan(faults[index], _pageSize);
_faultRanges.Add(window.Address, window.Size);
_cache.NoteDeviceAddressFault(window.Address, window.Size, insideMapping);
GuestGpuMemoryHook.SelectDeviceFaultTracePage(faults[index]);
if (SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.Traces(faults[index], _pageSize))
SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.Trace(faults[index], _pageSize,
@ -269,6 +298,15 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.Trace(start, size,
$"device-address-fault-prepared scan_tick={scanTick} submission_tick={_scheduler.CurrentTick} registered={_cache.IsRegionRegistered(start, size)}");
});
// Written pages beyond the list capacity stay in the bitmap for the next scan.
_writtenDownloadBuffer.Invalidate(offset, PageFaultAreaSize);
var written = MemoryMarshal.Cast<byte, ulong>(_writtenDownloadBuffer.Mapped.Slice((int)offset, (int)PageFaultAreaSize));
var writtenCount = Math.Min((uint)written[0], MaxPageFaults - 1);
for (var index = 1; index <= writtenCount; index++)
{
_cache.NoteDeviceAddressWrites(written[index], _pageSize);
}
_faultAreas[area] = 0;
});
@ -284,6 +322,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
vk.DestroyDescriptorPool(_device.Device, _pool, null);
vk.DestroyDescriptorSetLayout(_device.Device, _layout, null);
_downloadBuffer.Dispose();
_writtenDownloadBuffer.Dispose();
_traceDownloadBuffer?.Dispose();
_faultBuffer.Dispose();
}

View File

@ -112,6 +112,13 @@ public unsafe class GpuBuffer : IDisposable
}
}
if (result is Result.ErrorOutOfDeviceMemory or Result.ErrorOutOfHostMemory)
{
vk.DestroyBuffer(device.Device, _handle, null);
_handle = default;
throw new OutOfMemoryException($"vkAllocateMemory({usage}, 0x{size:X} bytes) failed with {result}");
}
RequireSuccess(result, $"vkAllocateMemory({usage}, 0x{size:X} bytes)");
RequireSuccess(vk.BindBufferMemory(device.Device, _handle, _memory, _memoryOffset), "vkBindBufferMemory");
_allocationSize = _slab?.Size ?? requirements.Size;
@ -132,6 +139,8 @@ public unsafe class GpuBuffer : IDisposable
{
throw SubmissionScheduler.Fatal("The buffer device address is unavailable.");
}
GpuBufferAddressBook.Added(this, _deviceAddress, scheduler.CurrentTick);
}
// A dedicated allocation arrives zeroed; keep that for a block another buffer used.
@ -149,6 +158,9 @@ public unsafe class GpuBuffer : IDisposable
public bool IsCoherent { get; }
// The host mapping of the whole buffer, or null for memory the host cannot map.
public byte* MappedPointer => _mapped;
public GpuBufferUsage Usage { get; }
public ulong CpuAddress { get; }
@ -168,6 +180,12 @@ public unsafe class GpuBuffer : IDisposable
public void AddStreamScore(int score) => StreamScore += score;
// The guest range whose CPU-write-hot pages a device-address read copied in the current
// memory visibility generation; the render thread owns these.
public long HotSyncGeneration;
public ulong HotSyncStart;
public ulong HotSyncEnd;
// The highest scheduler tick whose command buffer may write this buffer on the GPU.
// A readback of it only has to wait for that tick, not for all queued work.
public ulong LastGpuWriteTick { get; private set; }
@ -314,6 +332,11 @@ public unsafe class GpuBuffer : IDisposable
}
}
if (_deviceAddress != 0)
{
GpuBufferAddressBook.Removed(this, _scheduler.CurrentTick);
}
_device.Vk.DestroyBuffer(_device.Device, _handle, null);
if (_slab is { } slab)
{
@ -339,6 +362,7 @@ public unsafe class GpuBuffer : IDisposable
var (required, preferred, avoided) = usage switch
{
GpuBufferUsage.DeviceLocal => (MemoryPropertyFlags.None, MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.None),
GpuBufferUsage.Unified => (MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.HostCachedBit, MemoryPropertyFlags.None),
GpuBufferUsage.Upload => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit, MemoryPropertyFlags.DeviceLocalBit),
GpuBufferUsage.Download => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.HostCachedBit, MemoryPropertyFlags.DeviceLocalBit),
_ => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.None),
@ -392,6 +416,9 @@ public unsafe class GpuBuffer : IDisposable
};
}
// Every memory type the buffer may use is full; the owner can free memory and try again.
internal sealed class OutOfMemoryException(string message) : Exception(message);
private static void RequireSuccess(Result result, string operation)
{
if (result != Result.Success)

View File

@ -0,0 +1,83 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Buffers;
// Device-address ranges of the live buffers and of the most recently destroyed ones, so a
// device fault report can name the buffer a faulting address belongs (or belonged) to.
internal static class GpuBufferAddressBook
{
private readonly record struct Entry(ulong DeviceAddress, ulong Size, GpuBufferUsage Usage, ulong CpuAddress, long Serial, ulong CreatedTick, ulong DestroyedTick);
// A Yōtei transition retires more than 256 buffers before an old GPU address
// faults. Keep a bounded history large enough to identify those older ranges.
private const int DestroyedCapacity = 16384;
private static readonly object Gate = new();
private static readonly Dictionary<GpuBuffer, Entry> Live = new(ReferenceEqualityComparer.Instance);
private static readonly Entry[] Destroyed = new Entry[DestroyedCapacity];
private static int _destroyedNext;
private static long _serial;
public static void Added(GpuBuffer buffer, ulong deviceAddress, ulong tick)
{
lock (Gate)
{
Live[buffer] = new Entry(deviceAddress, buffer.Size, buffer.Usage, buffer.CpuAddress, ++_serial, tick, 0);
}
}
public static void Removed(GpuBuffer buffer, ulong tick)
{
lock (Gate)
{
if (Live.Remove(buffer, out var entry))
{
Destroyed[_destroyedNext] = entry with { DestroyedTick = tick };
_destroyedNext = (_destroyedNext + 1) % DestroyedCapacity;
}
}
}
// The live and destroyed buffers containing the address, else the live ones nearest to it.
public static string Describe(ulong address)
{
lock (Gate)
{
var text = new System.Text.StringBuilder();
foreach (var entry in Live.Values.Where(entry => Contains(entry, address)))
{
text.Append($" live:{Format(entry, address)}");
}
foreach (var entry in Destroyed.Where(entry => entry.Size != 0 && Contains(entry, address)))
{
text.Append($" destroyed:{Format(entry, address)}");
}
if (text.Length == 0)
{
var below = Live.Values.Where(entry => entry.DeviceAddress <= address)
.OrderByDescending(entry => entry.DeviceAddress).Take(1);
var above = Live.Values.Where(entry => entry.DeviceAddress > address)
.OrderBy(entry => entry.DeviceAddress).Take(1);
foreach (var entry in below.Concat(above))
{
text.Append($" nearest:{Format(entry, address)}");
}
var lowest = Live.Values.Select(entry => entry.DeviceAddress).DefaultIfEmpty().Min();
text.Append($" lowest_live=0x{lowest:X} live_count={Live.Count}");
}
return text.ToString();
}
}
private static bool Contains(Entry entry, ulong address) =>
address >= entry.DeviceAddress && address - entry.DeviceAddress < entry.Size;
private static string Format(Entry entry, ulong address) =>
$"[{entry.Usage} device=0x{entry.DeviceAddress:X}+0x{entry.Size:X} cpu=0x{entry.CpuAddress:X} " +
$"offset={(long)(address - entry.DeviceAddress)} serial={entry.Serial} created_tick={entry.CreatedTick} destroyed_tick={entry.DestroyedTick}]";
}

View File

@ -9,4 +9,6 @@ public enum GpuBufferUsage : byte
Upload,
Download,
Stream,
// Device-local memory the host can map: guest buffers on unified-memory devices, read back without a copy.
Unified,
}

View File

@ -7,7 +7,7 @@ namespace SharpEmu.Libs.Gpu.Buffers;
// The device facts the stores need: handles, memory types, limits, format support
// and the count of live device-memory allocations made through this object.
public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAllocator
{
private PhysicalDeviceMemoryProperties _memoryProperties;
// Queried for every draw target from the render thread and from other threads; a hit takes no lock.
@ -16,16 +16,38 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
private readonly System.Collections.Concurrent.ConcurrentDictionary<(Format, ImageType, ImageTiling, ImageUsageFlags, ImageCreateFlags), (Result Result, ImageFormatProperties Properties)> _imageFormatProperties = new();
private int _liveAllocations;
private int _peakAllocations;
private long _lastFailedAllocationBytes;
public ulong LastFailedAllocationBytes => (ulong)Interlocked.Read(ref _lastFailedAllocationBytes);
// VK_EXT_image_view_min_lod is enabled, so a view can clamp to a texture descriptor's MIN_LOD.
public bool ImageViewMinLodSupported { get; init; }
public bool CustomTwoSampleLocationsSupported { get; init; }
public GpuDeviceInfo(Vk vk, PhysicalDevice physicalDevice, Device device)
public GpuDeviceInfo(Vk vk, PhysicalDevice physicalDevice, Device device, bool memoryBudgetEnabled = false)
{
Vk = vk;
PhysicalDevice = physicalDevice;
Device = device;
vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out _memoryProperties);
DeviceLocalHeapBytes = CalculateDeviceLocalHeapBytes();
var queriedMemory = memoryBudgetEnabled
? QueryDeviceLocalMemory()
: (Budget: 0UL, Usage: 0UL, Available: 0UL);
if (queriedMemory.Budget != 0)
{
DeviceLocalBudgetBytes = queriedMemory.Budget;
DeviceLocalUsageBytes = queriedMemory.Usage;
DeviceLocalAvailableBytes = queriedMemory.Available;
HasMemoryBudget = true;
}
else
{
DeviceLocalBudgetBytes = DeviceLocalHeapBytes;
DeviceLocalUsageBytes = 0;
DeviceLocalAvailableBytes = DeviceLocalHeapBytes;
HasMemoryBudget = false;
}
vk.GetPhysicalDeviceProperties(physicalDevice, out var properties);
MinUniformBufferOffsetAlignment = Math.Max(properties.Limits.MinUniformBufferOffsetAlignment, 1);
MinStorageBufferOffsetAlignment = Math.Max(properties.Limits.MinStorageBufferOffsetAlignment, 1);
@ -34,9 +56,11 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
MaxMemoryAllocationCount = properties.Limits.MaxMemoryAllocationCount;
MaxComputeWorkGroupCount = (properties.Limits.MaxComputeWorkGroupCount[0], properties.Limits.MaxComputeWorkGroupCount[1], properties.Limits.MaxComputeWorkGroupCount[2]);
Slabs = new GpuMemorySlabs(this);
const FormatFeatureFlags blit = FormatFeatureFlags.BlitSrcBit | FormatFeatureFlags.BlitDstBit;
Images.RenderScalePolicy.ConfigureFormatSupport(format => (GetFormatProperties(format).OptimalTilingFeatures & blit) == blit);
}
// Shared chunks the small buffers are carved from; freed at device teardown.
// Shared chunks for small buffers; idle chunks can be returned under pressure.
internal GpuMemorySlabs Slabs { get; }
public Vk Vk { get; }
@ -45,10 +69,23 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
// created when a second queue family (the async readback queue) reads them.
public uint[]? SharedQueueFamilies { get; set; }
// Names a Vulkan object for debuggers (RenderDoc); a no-op unless debug labels are enabled.
public Action<ObjectType, ulong, string>? NameObject { get; set; }
public PhysicalDevice PhysicalDevice { get; }
public Device Device { get; }
public ulong DeviceLocalHeapBytes { get; }
public ulong DeviceLocalBudgetBytes { get; private set; }
public ulong DeviceLocalUsageBytes { get; private set; }
public ulong DeviceLocalAvailableBytes { get; private set; }
public bool HasMemoryBudget { get; }
public ulong MinUniformBufferOffsetAlignment { get; }
public ulong MinStorageBufferOffsetAlignment { get; }
@ -63,8 +100,88 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
public uint MemoryTypeCount => _memoryProperties.MemoryTypeCount;
private ulong CalculateDeviceLocalHeapBytes()
{
var usedHeaps = new bool[_memoryProperties.MemoryHeapCount];
fixed (PhysicalDeviceMemoryProperties* properties = &_memoryProperties)
{
var memoryTypes = &properties->MemoryTypes.Element0;
for (var index = 0u; index < properties->MemoryTypeCount; index++)
{
if ((memoryTypes[index].PropertyFlags & MemoryPropertyFlags.DeviceLocalBit) != 0)
{
usedHeaps[(int)memoryTypes[index].HeapIndex] = true;
}
}
var heaps = &properties->MemoryHeaps.Element0;
ulong total = 0;
for (var index = 0u; index < properties->MemoryHeapCount; index++)
{
if (usedHeaps[(int)index])
{
total += heaps[index].Size;
}
}
return total;
}
}
private (ulong Budget, ulong Usage, ulong Available) QueryDeviceLocalMemory()
{
var budget = new PhysicalDeviceMemoryBudgetPropertiesEXT
{
SType = StructureType.PhysicalDeviceMemoryBudgetPropertiesExt,
};
var properties = new PhysicalDeviceMemoryProperties2
{
SType = StructureType.PhysicalDeviceMemoryProperties2,
PNext = &budget,
};
Vk.GetPhysicalDeviceMemoryProperties2(PhysicalDevice, &properties);
var usedHeaps = new bool[_memoryProperties.MemoryHeapCount];
fixed (PhysicalDeviceMemoryProperties* memoryProperties = &_memoryProperties)
{
var memoryTypes = &memoryProperties->MemoryTypes.Element0;
for (var index = 0u; index < memoryProperties->MemoryTypeCount; index++)
{
if ((memoryTypes[index].PropertyFlags & MemoryPropertyFlags.DeviceLocalBit) != 0)
{
usedHeaps[(int)memoryTypes[index].HeapIndex] = true;
}
}
}
ulong totalBudget = 0;
ulong totalUsage = 0;
ulong totalAvailable = 0;
for (var index = 0u; index < _memoryProperties.MemoryHeapCount; index++)
{
if (usedHeaps[(int)index])
{
var heapBudget = budget.HeapBudget[index];
var heapUsage = budget.HeapUsage[index];
totalBudget = checked(totalBudget + heapBudget);
totalUsage = checked(totalUsage + heapUsage);
totalAvailable = checked(totalAvailable + CalculateAvailableBytes(heapBudget, heapUsage));
}
}
return totalBudget != 0
? (totalBudget, totalUsage, totalAvailable)
: (0, 0, 0);
}
internal static ulong CalculateAvailableBytes(ulong budget, ulong usage) =>
budget > usage ? budget - usage : 0;
public int LiveAllocations => Volatile.Read(ref _liveAllocations);
// Tests make an allocation of the given size fail as an exhausted device would.
internal Func<ulong, bool>? AllocationFailure { get; set; }
public int PeakAllocations => Volatile.Read(ref _peakAllocations);
public MemoryPropertyFlags GetMemoryTypeFlags(uint index)
@ -105,7 +222,12 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
{
fixed (MemoryAllocateInfo* pointer = &info)
{
var result = Vk.AllocateMemory(Device, pointer, null, out memory);
memory = default;
var result = AllocationFailure?.Invoke(info.AllocationSize) == true
? Result.ErrorOutOfDeviceMemory
: Vk.AllocateMemory(Device, pointer, null, out memory);
if (result != Result.Success)
Interlocked.Exchange(ref _lastFailedAllocationBytes, checked((long)info.AllocationSize));
if (result == Result.Success)
{
var live = Interlocked.Increment(ref _liveAllocations);

View File

@ -21,7 +21,7 @@ internal sealed unsafe class GpuMemorySlabs
private readonly GpuDeviceInfo _device;
private readonly object _gate = new();
private readonly Dictionary<(uint MemoryType, bool DeviceAddress), Pool> _pools = [];
private readonly List<DeviceMemory> _chunks = [];
private readonly Dictionary<DeviceMemory, Chunk> _chunks = [];
private readonly List<Block> _quarantine = [];
private int _foreignReaders;
@ -39,6 +39,50 @@ internal sealed unsafe class GpuMemorySlabs
Enumerable.Range(0, ClassCount).Select(_ => new Stack<(DeviceMemory, ulong, nint, bool)>()).ToArray();
}
private sealed class Chunk(uint memoryType, bool deviceAddress, int sizeClass)
{
public readonly uint MemoryType = memoryType;
public readonly bool DeviceAddress = deviceAddress;
public readonly int SizeClass = sizeClass;
// Quarantined blocks remain in use until foreign readers have finished.
public int InUse;
}
internal ulong AllocatedBytes { get { lock (_gate) return (ulong)_chunks.Count * ChunkSize; } }
private void DrainQuarantine()
{
if (Volatile.Read(ref _foreignReaders) != 0) return;
foreach (var released in _quarantine)
{
var chunk = _chunks[released.Memory];
chunk.InUse--;
_pools[(released.MemoryType, released.DeviceAddress)].Free[chunk.SizeClass]
.Push((released.Memory, released.Offset, released.MappedAddress, true));
}
_quarantine.Clear();
}
// Only blocks already retired after GPU completion may enter quarantine.
// Under pressure return completely idle chunks, retaining live neighbours.
public void ReleaseUnused()
{
lock (_gate)
{
DrainQuarantine();
foreach (var memory in _chunks.Where(pair => pair.Value.InUse == 0).Select(pair => pair.Key).ToArray())
{
var chunk = _chunks[memory];
var free = _pools[(chunk.MemoryType, chunk.DeviceAddress)].Free[chunk.SizeClass];
var kept = free.Where(block => block.Memory.Handle != memory.Handle).ToArray();
free.Clear();
for (var index = kept.Length - 1; index >= 0; index--) free.Push(kept[index]);
_chunks.Remove(memory);
_device.FreeMemory(memory);
}
}
}
// A readback on the second queue copies from buffers the render thread may retire (and
// recycle) while the copy is still pending. A dedicated allocation kept its old bytes
// after vkFreeMemory, which hid that race; a recycled block does not. Released blocks
@ -69,17 +113,7 @@ internal sealed unsafe class GpuMemorySlabs
var sizeClass = BitOperations.Log2(blockSize) - BitOperations.Log2(MinBlockSize);
lock (_gate)
{
if (_quarantine.Count != 0 && Volatile.Read(ref _foreignReaders) == 0)
{
foreach (var released in _quarantine)
{
var releasedClass = BitOperations.Log2(released.Size) - BitOperations.Log2(MinBlockSize);
_pools[(released.MemoryType, released.DeviceAddress)].Free[releasedClass]
.Push((released.Memory, released.Offset, released.MappedAddress, true));
}
_quarantine.Clear();
}
DrainQuarantine();
if (!_pools.TryGetValue((memoryType, deviceAddress), out var pool))
{
@ -94,6 +128,7 @@ internal sealed unsafe class GpuMemorySlabs
}
var (memory, offset, mapped, recycled) = free.Pop();
_chunks[memory].InUse++;
block = new Block(memory, offset, blockSize, mapped, memoryType, deviceAddress, recycled);
return true;
}
@ -112,7 +147,7 @@ internal sealed unsafe class GpuMemorySlabs
{
lock (_gate)
{
foreach (var chunk in _chunks)
foreach (var chunk in _chunks.Keys)
{
_device.FreeMemory(chunk);
}
@ -155,7 +190,8 @@ internal sealed unsafe class GpuMemorySlabs
mapped = (byte*)pointer;
}
_chunks.Add(memory);
_chunks.Add(memory, new Chunk(memoryType, deviceAddress,
BitOperations.Log2(blockSize) - BitOperations.Log2(MinBlockSize)));
// Push in reverse so blocks come out in address order.
for (var offset = ChunkSize - blockSize; ; offset -= blockSize)
{

View File

@ -0,0 +1,116 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using SharpEmu.HLE.GpuMemory;
namespace SharpEmu.Libs.Gpu.Buffers;
// SHARPEMU_WATCH_BLOCKS=addr:len[,addr:len...] (diagnostic): after every presented frame, logs each
// watched range whose bytes changed in guest memory or in the GPU copy, with both contents, and
// logs every cache event that touches a watched range (device-address write reports, uploads,
// writable bindings, CPU write faults). Ticks date each line against the GPU timeline.
public sealed unsafe partial class GuestBufferCache
{
private readonly record struct WatchedRange(ulong Address, int Length);
private static readonly WatchedRange[] WatchedRanges = ParseWatchedRanges(Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BLOCKS"));
private const int MaxWatchLines = 20000;
private readonly byte[]?[] _watchedGuest = new byte[WatchedRanges.Length][];
private readonly byte[]?[] _watchedGpu = new byte[WatchedRanges.Length][];
private long _watchFrame;
private static int _watchLines;
private static WatchedRange[] ParseWatchedRanges(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
var ranges = new List<WatchedRange>();
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var parts = item.Split(':');
if (ulong.TryParse(parts[0].Replace("0x", "", StringComparison.OrdinalIgnoreCase), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var address) &&
address != 0)
{
var length = parts.Length > 1 && int.TryParse(parts[1].Replace("0x", "", StringComparison.OrdinalIgnoreCase), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)
? Math.Clamp(parsed, 4, 256)
: 64;
ranges.Add(new WatchedRange(address, length));
}
}
return [.. ranges];
}
private static bool Watches(ulong address, ulong size)
{
foreach (var range in WatchedRanges)
{
if (address < range.Address + (ulong)range.Length && range.Address < address + size)
{
return true;
}
}
return false;
}
private void WatchEvent(ulong address, ulong size, string kind)
{
if (WatchedRanges.Length == 0 || !Watches(address, size) || Interlocked.Increment(ref _watchLines) > MaxWatchLines)
{
return;
}
Console.Error.WriteLine(FormattableString.Invariant(
$"[GPU][WATCH] frame={_watchFrame} event={kind} addr=0x{address:X} size=0x{size:X} submission_tick={_scheduler.CurrentTick} completed_tick={_scheduler.Timeline.CompletedTick}"));
}
public void WatchFrame()
{
if (WatchedRanges.Length == 0)
{
return;
}
_watchFrame++;
for (var index = 0; index < WatchedRanges.Length; index++)
{
var range = WatchedRanges[index];
var guest = new byte[range.Length];
if (!_backing.TryReadBacking(range.Address, guest))
{
guest = null;
}
byte[]? gpu = null;
if (FindOwner(range.Address, (ulong)range.Length) is { MappedPointer: not null, IsCoherent: true } owner)
{
gpu = new ReadOnlySpan<byte>(owner.MappedPointer + owner.Offset(range.Address), range.Length).ToArray();
}
var guestChanged = !Same(guest, _watchedGuest[index]);
var gpuChanged = !Same(gpu, _watchedGpu[index]);
_watchedGuest[index] = guest;
_watchedGpu[index] = gpu;
if ((!guestChanged && !gpuChanged) || Interlocked.Increment(ref _watchLines) > MaxWatchLines)
{
continue;
}
var page = range.Address & ~(TrackerLayout.PageBytes - 1);
var cpuDirty = _tracker.HasCpuDirtyPages(page, TrackerLayout.PageBytes);
var gpuDirty = _tracker.HasGpuDirtyPages(page, TrackerLayout.PageBytes);
var guestHex = guest is null ? "n/a" : Convert.ToHexString(guest);
var gpuHex = gpu is null ? "n/a" : Convert.ToHexString(gpu);
Console.Error.WriteLine(FormattableString.Invariant(
$"[GPU][WATCH] frame={_watchFrame} range=0x{range.Address:X} guest_changed={guestChanged} gpu_changed={gpuChanged} cpu_dirty={cpuDirty} gpu_dirty={gpuDirty} submission_tick={_scheduler.CurrentTick} completed_tick={_scheduler.Timeline.CompletedTick} guest={guestHex} gpu={gpuHex}"));
}
}
private static bool Same(byte[]? left, byte[]? right) =>
left is null ? right is null : right is not null && left.AsSpan().SequenceEqual(right);
}

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,11 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
public int RegisteredCount => _registered.Count;
public ulong RegisteredBytes { get; private set; }
private ulong _residentBytes;
private ulong _retiredBytes;
// Lookup removal does not release the GPU resource. Count it until disposal.
public ulong ResidentBytes => Interlocked.Read(ref _residentBytes);
public ulong RetiredBytes => Interlocked.Read(ref _retiredBytes);
public ResourceSlotIdentifier AllocateBuffer(TBuffer resource, ulong address, ulong size)
{
@ -69,6 +74,7 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
entry.State = BufferLifetimeState.Allocated;
entry.LastUseTick = 0;
}
Interlocked.Add(ref _residentBytes, size);
return identifier;
}
@ -137,6 +143,7 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
_registered.Remove(entry.Address);
_recency.Remove(entry.RecencyNode!);
RegisteredBytes -= entry.Size;
Interlocked.Add(ref _retiredBytes, entry.Size);
entry.State = BufferLifetimeState.Retiring;
}
@ -219,11 +226,14 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
private void ReleaseResource(Entry entry)
{
var resource = entry.Resource;
var retiring = entry.State == BufferLifetimeState.Retiring;
entry.Resource = null;
entry.State = BufferLifetimeState.Released;
if (!_disposed && entry.Identifier.Generation != uint.MaxValue)
_availableIndices.Push((int)entry.Identifier.Index);
resource?.Dispose();
Interlocked.Add(ref _residentBytes, unchecked(0UL - entry.Size));
if (retiring) Interlocked.Add(ref _retiredBytes, unchecked(0UL - entry.Size));
}
private Entry? FindEntry(ResourceSlotIdentifier identifier) =>

View File

@ -8,12 +8,14 @@ using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Buffers;
internal delegate bool GuestBufferSourceReader(ulong address, Span<byte> destination);
// The cache owns the staging ring; temporary sources retire after their submission.
internal sealed class GuestBufferUploader(
GpuDeviceInfo device, SubmissionScheduler scheduler, ICpuMemory guest, GpuRingBuffer staging)
{
public GpuBuffer? PrepareSource(ulong bufferAddress, Span<BufferCopy> regions,
ulong totalSize, ulong requestedAddress, ulong requestedSize)
ulong totalSize, ulong requestedAddress, ulong requestedSize, GuestBufferSourceReader? readSource = null)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferStagingUpload);
if (regions.IsEmpty)
@ -26,7 +28,7 @@ internal sealed class GuestBufferUploader(
foreach (ref var copy in regions)
{
ReadGuestBytes(bufferAddress + copy.DstOffset,
staging.Mapped.Slice((int)(baseOffset + copy.SrcOffset), (int)copy.Size), requestedAddress, requestedSize);
staging.Mapped.Slice((int)(baseOffset + copy.SrcOffset), (int)copy.Size), requestedAddress, requestedSize, readSource);
copy.SrcOffset += baseOffset;
}
@ -38,7 +40,7 @@ internal sealed class GuestBufferUploader(
foreach (ref readonly var copy in regions)
{
ReadGuestBytes(bufferAddress + copy.DstOffset,
temporary.Mapped.Slice((int)copy.SrcOffset, (int)copy.Size), requestedAddress, requestedSize);
temporary.Mapped.Slice((int)copy.SrcOffset, (int)copy.Size), requestedAddress, requestedSize, readSource);
}
temporary.Flush(0, totalSize);
@ -46,9 +48,9 @@ internal sealed class GuestBufferUploader(
return temporary;
}
private void ReadGuestBytes(ulong address, Span<byte> destination, ulong requestedAddress, ulong requestedSize)
private void ReadGuestBytes(ulong address, Span<byte> destination, ulong requestedAddress, ulong requestedSize, GuestBufferSourceReader? readSource)
{
if (!guest.TryRead(address, destination))
if (!(readSource is null ? guest.TryRead(address, destination) : readSource(address, destination)))
{
Console.Error.WriteLine($"[GPU][READ_FAILURE] addr=0x{address:X16} size=0x{destination.Length:X}");
Console.Error.WriteLine($"[GPU][READ_FAILURE] binding=0x{requestedAddress:X16} size=0x{requestedSize:X}");

View File

@ -0,0 +1,12 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Buffers;
public interface IDeviceMemoryAllocator
{
Result AllocateMemory(in MemoryAllocateInfo info, out DeviceMemory memory);
void FreeMemory(DeviceMemory memory);
}

View File

@ -11,6 +11,8 @@ public interface IGuestImageCache
{
ImageRegionInfo QueryRegion(ulong address, ulong size);
bool HasGpuModifiedImageBytes(ulong address, ulong size) => QueryRegion(address, size).GpuImageBytes;
bool ClearMetadata(ulong address);
bool OverlapsDccMetadata(ulong address, ulong size);
@ -19,5 +21,11 @@ public interface IGuestImageCache
void InvalidateMemoryFromGpu(ulong address, ulong size);
// A possible shader write through a raw buffer: only images that copy memory become stale.
void InvalidateMemoryCopiesFromGpu(ulong address, ulong size) { }
bool TrySynchronizeBufferFromImage(GpuBuffer buffer, ulong address, ulong size);
// Frees every image that can go and waits for retired ones, after an allocation failed.
void ReclaimForAllocation();
}

View File

@ -0,0 +1,297 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Buffers;
// A place in device memory an image is bound to: its own allocation, or a range of a block.
public readonly record struct OptimalImageMemoryAllocation(DeviceMemory Memory, ulong Offset, ulong Size, int Block, uint MemoryType = 0);
// Images share large device-memory blocks instead of one allocation each: a driver caps the
// number of live allocations (4096 on common Windows drivers) far below the number of textures
// a bindless title keeps resident. Images are all optimal-tiled, so no linear/optimal
// granularity separates them; each range only honours its own alignment.
public sealed unsafe class OptimalImageMemoryPool
{
public const ulong BlockSize = 256UL << 20;
public const ulong DedicatedThreshold = 64UL << 20;
// Freed memory kept for the next image instead of going back to the driver. The image
// cache recreates images all the time (a target that grows, overlapping views) mostly at
// the same sizes; handing each one back and allocating it again costs a driver call and
// a zeroed allocation every time, and capture tools keep every freed allocation alive.
public const int MaxEmptyBlocks = 4;
public const ulong RetainedDedicatedLimit = 1UL << 30;
private sealed class Block
{
public required DeviceMemory Memory;
public required uint MemoryType;
public required ulong Size;
public readonly SortedDictionary<ulong, ulong> Free = new();
public ulong Used;
}
private readonly IDeviceMemoryAllocator _device;
private readonly ulong _blockSize;
private readonly bool _deviceAddress;
private readonly List<Block?> _blocks = [];
private readonly List<(DeviceMemory Memory, ulong Size, uint MemoryType)> _retainedDedicated = [];
private ulong _retainedDedicatedBytes;
private readonly object _gate = new();
private long _allocatedBytes;
private long _placedBytes;
// `deviceAddress` allocates the blocks with VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT, so
// buffers with a shader device address can live in them.
public OptimalImageMemoryPool(IDeviceMemoryAllocator device, ulong blockSize = BlockSize, bool deviceAddress = false)
{
_device = device;
_blockSize = blockSize;
_deviceAddress = deviceAddress;
}
// Bytes reserved from the Vulkan driver, including free space left inside
// pooled blocks and dedicated allocations still held by the pool.
public ulong AllocatedBytes => (ulong)Math.Max(Volatile.Read(ref _allocatedBytes), 0);
// Bytes occupied by live image placements. This excludes free ranges inside
// pooled blocks and is therefore the useful fragmentation comparison.
public ulong PlacedBytes => (ulong)Math.Max(Volatile.Read(ref _placedBytes), 0);
public ulong UnusedBytes
{
get
{
lock (_gate) return checked((ulong)(_allocatedBytes - _placedBytes));
}
}
// Ranges placed in blocks, and blocks held from the driver (empty ones included).
public int Placements => Volatile.Read(ref _placements);
public int Blocks
{
get
{
lock (_gate)
{
return _blocks.Count(block => block is not null);
}
}
}
private int _placements;
public Result Allocate(in MemoryRequirements requirements, uint memoryType, out OptimalImageMemoryAllocation memory)
{
memory = default;
if (requirements.Size >= DedicatedThreshold)
{
lock (_gate)
{
var size = requirements.Size;
var retained = _retainedDedicated.FindIndex(entry => entry.Size == size && entry.MemoryType == memoryType);
if (retained >= 0)
{
var entry = _retainedDedicated[retained];
_retainedDedicated.RemoveAt(retained);
_retainedDedicatedBytes -= entry.Size;
memory = new OptimalImageMemoryAllocation(entry.Memory, 0, entry.Size, -1, entry.MemoryType);
Interlocked.Add(ref _placedBytes, checked((long)entry.Size));
return Result.Success;
}
}
var info = new MemoryAllocateInfo { SType = StructureType.MemoryAllocateInfo, AllocationSize = requirements.Size, MemoryTypeIndex = memoryType };
var dedicated = _device.AllocateMemory(info, out var handle);
if (dedicated == Result.Success)
{
memory = new OptimalImageMemoryAllocation(handle, 0, requirements.Size, -1, memoryType);
Interlocked.Add(ref _allocatedBytes, checked((long)requirements.Size));
Interlocked.Add(ref _placedBytes, checked((long)requirements.Size));
}
return dedicated;
}
var alignment = Math.Max(requirements.Alignment, 1UL);
lock (_gate)
{
for (var index = 0; index < _blocks.Count; index++)
{
if (_blocks[index] is { } block && block.MemoryType == memoryType && TryPlace(block, requirements.Size, alignment, out var offset))
{
memory = new OptimalImageMemoryAllocation(block.Memory, offset, requirements.Size, index);
Interlocked.Increment(ref _placements);
Interlocked.Add(ref _placedBytes, checked((long)requirements.Size));
return Result.Success;
}
}
var addressFlags = new MemoryAllocateFlagsInfo { SType = StructureType.MemoryAllocateFlagsInfo, Flags = MemoryAllocateFlags.DeviceAddressBit };
var blockInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
PNext = _deviceAddress ? &addressFlags : null,
AllocationSize = Math.Max(_blockSize, requirements.Size),
MemoryTypeIndex = memoryType,
};
var result = _device.AllocateMemory(blockInfo, out var blockMemory);
if (result == Result.ErrorOutOfDeviceMemory && blockInfo.AllocationSize > requirements.Size)
{
// A pooling policy must not reject an image whose actual storage
// still fits. Keep all allocation flags, but request its exact size.
blockInfo.AllocationSize = requirements.Size;
result = _device.AllocateMemory(blockInfo, out blockMemory);
}
if (result != Result.Success)
{
return result;
}
var created = new Block { Memory = blockMemory, MemoryType = memoryType, Size = blockInfo.AllocationSize };
created.Free.Add(0, blockInfo.AllocationSize);
var slot = _blocks.IndexOf(null);
if (slot < 0)
{
slot = _blocks.Count;
_blocks.Add(created);
}
else
{
_blocks[slot] = created;
}
TryPlace(created, requirements.Size, alignment, out var placed);
Interlocked.Add(ref _allocatedBytes, checked((long)blockInfo.AllocationSize));
Interlocked.Add(ref _placedBytes, checked((long)requirements.Size));
memory = new OptimalImageMemoryAllocation(blockMemory, placed, requirements.Size, slot);
Interlocked.Increment(ref _placements);
return Result.Success;
}
}
public void Free(in OptimalImageMemoryAllocation memory)
{
if (memory.Memory.Handle == 0)
{
return;
}
if (memory.Block < 0)
{
Interlocked.Add(ref _placedBytes, -checked((long)memory.Size));
lock (_gate)
{
_retainedDedicated.Add((memory.Memory, memory.Size, memory.MemoryType));
_retainedDedicatedBytes += memory.Size;
// Past the limit the oldest retained allocations go back to the driver.
while (_retainedDedicatedBytes > RetainedDedicatedLimit)
{
var oldest = _retainedDedicated[0];
_retainedDedicated.RemoveAt(0);
_retainedDedicatedBytes -= oldest.Size;
_device.FreeMemory(oldest.Memory);
Interlocked.Add(ref _allocatedBytes, -checked((long)oldest.Size));
}
}
return;
}
lock (_gate)
{
var block = _blocks[memory.Block] ?? throw new InvalidOperationException("The image memory block was already released.");
var start = memory.Offset;
var end = memory.Offset + memory.Size;
// Merge with the free ranges on either side.
foreach (var (freeStart, freeLength) in block.Free)
{
if (freeStart + freeLength == start)
{
start = freeStart;
block.Free.Remove(freeStart);
break;
}
}
if (block.Free.TryGetValue(end, out var nextLength))
{
block.Free.Remove(end);
end += nextLength;
}
block.Free[start] = end - start;
block.Used -= memory.Size;
Interlocked.Decrement(ref _placements);
Interlocked.Add(ref _placedBytes, -checked((long)memory.Size));
// An emptied block stays for the next images while few enough blocks are empty.
if (block.Used == 0 && _blocks.Count(candidate => candidate is { Used: 0 }) > MaxEmptyBlocks)
{
_device.FreeMemory(block.Memory);
_blocks[memory.Block] = null;
Interlocked.Add(ref _allocatedBytes, -checked((long)block.Size));
}
}
}
// Hands every retained allocation and empty block back to the driver, for memory pressure.
public void ReleaseRetained()
{
lock (_gate)
{
foreach (var (retained, size, _) in _retainedDedicated)
{
_device.FreeMemory(retained);
Interlocked.Add(ref _allocatedBytes, -checked((long)size));
}
_retainedDedicated.Clear();
_retainedDedicatedBytes = 0;
for (var index = 0; index < _blocks.Count; index++)
{
if (_blocks[index] is { Used: 0 } empty)
{
_device.FreeMemory(empty.Memory);
_blocks[index] = null;
Interlocked.Add(ref _allocatedBytes, -checked((long)empty.Size));
}
}
}
}
private static bool TryPlace(Block block, ulong size, ulong alignment, out ulong offset)
{
foreach (var (start, length) in block.Free)
{
var aligned = (start + alignment - 1) / alignment * alignment;
var padding = aligned - start;
if (padding > length || length - padding < size)
{
continue;
}
block.Free.Remove(start);
if (padding != 0)
{
block.Free[start] = padding;
}
var tail = length - padding - size;
if (tail != 0)
{
block.Free[aligned + size] = tail;
}
block.Used += size;
offset = aligned;
return true;
}
offset = 0;
return false;
}
}

View File

@ -0,0 +1,301 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Buffers;
// Pages the CPU and shaders both write. On the console both land in one memory. Here the page
// tracker gives each page a single owner, so a shader store through a device address into a page
// the CPU dirtied cannot make it GPU-owned: the next upload copied the whole guest page, stale
// where the shader wrote, over the GPU copy, and the guest never saw the shader's bytes.
//
// A page shaders wrote through device addresses gets a shadow once the CPU touches it (up to the
// capacity): the bytes both copies last agreed on. An upload copies only the bytes the CPU
// changed since (the shader's bytes in the GPU copy stay), and the bytes the GPU
// changed since are merged into guest memory where the CPU left them alone. A byte both changed
// keeps the CPU's value in guest memory, and the next upload carries it to the GPU copy.
internal sealed class SharedPageShadows(ulong pageBytes, int capacity)
{
private sealed class Shadow(object owner, byte[] bytes)
{
public object Owner = owner;
public readonly byte[] Bytes = bytes;
// The submission tick of the last upload staged for the page; the GPU copy lacks
// its bytes until that tick completes.
public ulong StagedTick;
}
private readonly Dictionary<ulong, Shadow> _pages = new();
private readonly object _gate = new();
private int _count;
private long _mergedBytes;
private long _skippedUploadBytes;
public int Count => Volatile.Read(ref _count);
// Bytes the GPU wrote that reached guest memory through a merge.
public long MergedBytes => Interlocked.Read(ref _mergedBytes);
// Bytes uploads left alone because the CPU had not changed them.
public long SkippedUploadBytes => Interlocked.Read(ref _skippedUploadBytes);
public bool Contains(ulong page)
{
if (Count == 0)
{
return false;
}
lock (_gate)
{
return _pages.ContainsKey(page);
}
}
// Starts shadowing a page from bytes both copies hold, which uploads staged up to tick may
// still change. A shader-written page is adopted when the CPU first touches it: that access
// downloads the page, so guest memory then matches the GPU copy. A page the CPU dirtied before
// its first shader write was reported is adopted from the GPU copy instead; it differs where
// the CPU wrote and where the shader wrote, the next upload copies both, and those shader
// bytes are lost once. False when the capacity is reached.
public bool Adopt(ulong page, object owner, ReadOnlySpan<byte> gpuPage, ulong tick = 0)
{
RequirePage(gpuPage.Length);
lock (_gate)
{
if (_pages.ContainsKey(page))
{
return true;
}
if (_pages.Count >= capacity)
{
return false;
}
_pages.Add(page, new Shadow(owner, gpuPage.ToArray()) { StagedTick = tick });
Volatile.Write(ref _count, _pages.Count);
return true;
}
}
// The cache copied the entire old buffer into its replacement. Its shared pages retain
// the same baseline; treating the replacement as unrelated would upload stale GPU bytes.
public void NoteOwnerCopy(object source, object target, ulong tick)
{
if (Count == 0) return;
lock (_gate)
{
foreach (var shadow in _pages.Values)
{
if (!ReferenceEquals(shadow.Owner, source)) continue;
shadow.Owner = target;
shadow.StagedTick = Math.Max(shadow.StagedTick, tick);
}
}
}
// A download wrote GPU bytes into guest memory; on shadowed pages both copies now agree there.
public void NoteDownloaded(ulong address, ReadOnlySpan<byte> bytes)
{
if (Count == 0 || bytes.IsEmpty)
{
return;
}
var end = address + (ulong)bytes.Length;
lock (_gate)
{
for (var page = address & ~(pageBytes - 1); page < end; page += pageBytes)
{
if (!_pages.TryGetValue(page, out var shadow))
{
continue;
}
var from = Math.Max(page, address);
var to = Math.Min(page + pageBytes, end);
bytes.Slice((int)(from - address), (int)(to - from)).CopyTo(shadow.Bytes.AsSpan((int)(from - page)));
}
}
}
// Merges the bytes the GPU changed since the shadow into guestPage, where the CPU did not
// change them, and returns the number of merged bytes (writeGuest receives each merged run as
// an offset and length). Only once every upload staged for the page has executed: before that
// the GPU copy still lacks staged CPU bytes, which would read as GPU changes. A page whose
// owner changed is shadowed again from the new copy. gpuPage must not change during the call.
public int PullGpuWrites(ulong page, object owner, ReadOnlySpan<byte> gpuPage, Span<byte> guestPage, ulong completedTick,
Action<int, int> writeGuest)
{
RequirePage(gpuPage.Length);
RequirePage(guestPage.Length);
lock (_gate)
{
if (!_pages.TryGetValue(page, out var shadow))
{
return 0;
}
if (!ReferenceEquals(shadow.Owner, owner))
{
shadow.Owner = owner;
gpuPage.CopyTo(shadow.Bytes);
return 0;
}
if (shadow.StagedTick > completedTick)
{
return 0;
}
var bytes = shadow.Bytes.AsSpan();
var merged = 0;
var index = 0;
while ((index += gpuPage[index..].CommonPrefixLength(bytes[index..])) < bytes.Length)
{
// A run of GPU changes; within it, the bytes the CPU left alone go to guest memory.
var runEnd = index;
while (runEnd < bytes.Length && gpuPage[runEnd] != bytes[runEnd])
{
runEnd++;
}
var write = -1;
for (var at = index; at <= runEnd; at++)
{
var take = at < runEnd && guestPage[at] == bytes[at];
if (take)
{
guestPage[at] = gpuPage[at];
write = write < 0 ? at : write;
}
else if (write >= 0)
{
writeGuest(write, at - write);
merged += at - write;
write = -1;
}
}
gpuPage[index..runEnd].CopyTo(bytes[index..]);
index = runEnd;
}
if (merged != 0)
{
Interlocked.Add(ref _mergedBytes, merged);
}
return merged;
}
}
// Narrows an upload to the bytes the CPU changed on shadowed pages. copies stage from
// staged (indexed by SrcOffset) into a buffer at targetAddress (indexed by DstOffset); copies
// over shadowed pages are split into the runs that differ from the shadow, which takes them.
// An upload into a new owner copies everything and shadows the page from it.
public void FilterUpload(List<BufferCopy> copies, object target, ulong targetAddress, ReadOnlySpan<byte> staged, ulong tick)
{
if (Count == 0)
{
return;
}
lock (_gate)
{
List<BufferCopy>? filtered = null;
for (var index = 0; index < copies.Count; index++)
{
var copy = copies[index];
var begin = targetAddress + copy.DstOffset;
var end = begin + copy.Size;
var emitted = begin;
for (var page = begin & ~(pageBytes - 1); page < end; page += pageBytes)
{
if (!_pages.TryGetValue(page, out var shadow))
{
continue;
}
if (filtered is null)
{
filtered = new List<BufferCopy>(copies.Count + 8);
for (var earlier = 0; earlier < index; earlier++)
{
filtered.Add(copies[earlier]);
}
}
var from = Math.Max(page, begin);
var to = Math.Min(page + pageBytes, end);
if (emitted < from)
{
filtered.Add(Slice(copy, begin, emitted, from - emitted));
}
emitted = to;
var source = staged.Slice(checked((int)(copy.SrcOffset + (from - begin))), checked((int)(to - from)));
var shadowBytes = shadow.Bytes.AsSpan(checked((int)(from - page)), source.Length);
if (!ReferenceEquals(shadow.Owner, target))
{
shadow.Owner = target;
filtered.Add(Slice(copy, begin, from, to - from));
shadow.StagedTick = Math.Max(shadow.StagedTick, tick);
source.CopyTo(shadowBytes);
continue;
}
var skipped = source.Length;
var at = 0;
while ((at += source[at..].CommonPrefixLength(shadowBytes[at..])) < source.Length)
{
var runEnd = at;
while (runEnd < source.Length && source[runEnd] != shadowBytes[runEnd])
{
runEnd++;
}
filtered.Add(Slice(copy, begin, from + (ulong)at, (ulong)(runEnd - at)));
// Only a copy that survives filtering can leave staged bytes missing
// from the GPU mapping. An eliminated upload has no completion to wait for.
shadow.StagedTick = Math.Max(shadow.StagedTick, tick);
source[at..runEnd].CopyTo(shadowBytes[at..]);
skipped -= runEnd - at;
at = runEnd;
}
Interlocked.Add(ref _skippedUploadBytes, skipped);
}
if (filtered is null)
{
continue;
}
if (emitted < end)
{
filtered.Add(Slice(copy, begin, emitted, end - emitted));
}
}
if (filtered is not null)
{
copies.Clear();
copies.AddRange(filtered);
}
}
}
private static BufferCopy Slice(BufferCopy copy, ulong begin, ulong address, ulong size) =>
new(copy.SrcOffset + (address - begin), copy.DstOffset + (address - begin), size);
private void RequirePage(int length)
{
if ((ulong)length != pageBytes)
{
throw new ArgumentException("A shadowed page span must cover exactly one page.");
}
}
}

View File

@ -582,10 +582,17 @@ public sealed class CommandStreamQueue
}
// Processes to empty; with the flag, heads still blocked after a full retry cycle are cancelled.
public IdleOutcome DrainForShutdown(bool cancelBlockedOnNoProgress)
// Once abandon reports true (the guest memory is gone), the remaining work is dropped unexecuted.
public IdleOutcome DrainForShutdown(bool cancelBlockedOnNoProgress, Func<bool>? abandon = null)
{
for (;;)
{
if (abandon?.Invoke() == true)
{
DiscardPending();
return Outcome;
}
var result = ProcessOne();
switch (result)
{
@ -635,6 +642,34 @@ public sealed class CommandStreamQueue
return progressed;
}
private void DiscardPending()
{
lock (_gate)
{
foreach (var queue in _queues)
{
if (queue.Count == 0)
{
continue;
}
_submissionCount -= queue.Count;
foreach (var dropped in queue)
{
if (dropped.Kind == CommandSubmissionKind.FrameBoundary)
{
_pendingBoundaries--;
}
}
queue.Clear();
_outcome = IdleOutcome.Cancelled;
}
Monitor.PulseAll(_gate);
}
}
private void CancelBlocked()
{
lock (_gate)

View File

@ -103,6 +103,16 @@ public sealed partial class GpuCommandInterpreter
private void DrawIndirectArguments(ulong packetAddress, uint opcode, ulong argumentsAddress, bool indexed)
{
if (!indexed && _host.ResolvesNonIndexedIndirectDrawOnGpu)
{
// The host reads the four arguments on the GPU, so a GPU-written argument buffer
// costs no wait for the GPU here; it reads them itself when the draw needs counts.
_deferredInstanceCountAddress = argumentsAddress + 4;
_host.DrawAuto(SubmitId, new DrawAutoArguments(packetAddress, opcode, 1, 1, 0, 0,
DrawOffsetSource.IndirectArguments, argumentsAddress));
return;
}
if (!indexed)
{
var vertexCount = ReadDword(argumentsAddress);

View File

@ -16,11 +16,11 @@ public sealed partial class GpuCommandInterpreter
var eventIndex = (payload[0] >> 8) & 0x7u;
var eventType = payload[0] & 0x3Fu;
ulong eventAddress = 0;
if (eventType == 0x39)
if (eventType == 0x39 || (eventType == 0x38 && packet.Length == 4))
{
if (packet.Length != 4)
{
throw _host.Fatal($"The occlusion event packet length is not supported: length={packet.Length} address=0x{packet.PacketAddress:X16}.");
throw _host.Fatal($"The addressed event packet length is not supported: length={packet.Length} address=0x{packet.PacketAddress:X16}.");
}
eventAddress = Address(payload[1], payload[2]);
@ -211,9 +211,25 @@ public sealed partial class GpuCommandInterpreter
}
}
internal uint PushMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload) => packet.Length - 1;
internal uint PushMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload)
{
if (Images.ImageDropTrace.Enabled)
{
Images.ImageDropTrace.PushMarker(this, payload);
}
internal uint PopMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload) => packet.Length - 1;
return packet.Length - 1;
}
internal uint PopMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload)
{
if (Images.ImageDropTrace.Enabled)
{
Images.ImageDropTrace.PopMarker(this);
}
return packet.Length - 1;
}
internal uint FlipPacket(in PacketContext packet, ReadOnlySpan<uint> payload)
{

View File

@ -435,15 +435,21 @@ public sealed partial class GpuCommandInterpreter
throw _host.Fatal($"The occlusion-counter dump is invalid: index=0x{eventIndex:X8} address=0x{eventAddress:X16}.");
}
// Publish visible occlusion results for each depth block.
// Until real sample queries are implemented, conservatively report visibility.
// A one-sample delta can disappear when the guest scales or reduces the counts.
const ulong visibleCounterStep = 0x2FFFFFF;
const ulong readyBit = 1UL << 63;
var result = readyBit | SyntheticOcclusionCounter;
for (var depthBlock = 0u; depthBlock < 16u; depthBlock++)
{
WriteQword(eventAddress + ((ulong)depthBlock * 2 * sizeof(ulong)), result);
// Query reducers can read a GPU-cached copy of this range. Use the same
// ordered transfer path as clears instead of changing only CPU backing.
var destination = eventAddress + ((ulong)depthBlock * 2 * sizeof(ulong));
_host.FillBuffer(destination, sizeof(uint), (uint)result, false);
_host.FillBuffer(destination + sizeof(uint), sizeof(uint), (uint)(result >> 32), false);
}
SyntheticOcclusionCounter = (SyntheticOcclusionCounter + 1) & (readyBit - 1);
SyntheticOcclusionCounter = (SyntheticOcclusionCounter + visibleCounterStep) & (readyBit - 1);
break;
}

View File

@ -42,7 +42,10 @@ public sealed partial class GpuCommandInterpreter
if (!satisfied)
{
Suspend();
return;
}
_host.NoteMemoryVisibilityPoint();
}
// The wait bit is PredicationZPassWaitOp: 0 (kWaitForQueryResults) stalls the command

View File

@ -443,6 +443,8 @@ public sealed partial class GpuCommandInterpreter
{
throw _host.Fatal($"The command stream cannot write guest memory: address=0x{address:X16} size={source.Length}.");
}
// Later commands in this stream may read the bytes through device addresses.
_host.NoteCommandProcessorWrite(address, (ulong)source.Length);
_lastWriteLength = 0;
if (source.Length is sizeof(uint) or sizeof(ulong))
{

View File

@ -38,7 +38,10 @@ public readonly record struct DrawAutoArguments(
uint InstanceCount,
uint FirstVertex,
uint FirstInstance,
DrawOffsetSource OffsetSource);
DrawOffsetSource OffsetSource,
// Nonzero when the counts are still in guest memory at this address, laid out as
// (vertexCount, instanceCount, firstVertex, firstInstance); the other counts are then 1/0.
ulong IndirectArgumentsAddress = 0);
public enum EndOfPipeWriteKind
{
@ -104,6 +107,17 @@ public interface ICommandStreamHost
void CopyBuffer(ulong destination, ulong source, ulong size, bool destinationIsGds, bool sourceIsGds);
// A satisfied wait packet or a command-processor memory write: CPU writes released by the
// waited-on value, or the written bytes, must become visible to the commands that follow,
// the same as at the start of a submission slice.
void NoteMemoryVisibilityPoint()
{
}
// A command-processor write of these bytes: only they changed, so a host may keep what it
// already synchronized for the rest of memory.
void NoteCommandProcessorWrite(ulong address, ulong size) => NoteMemoryVisibilityPoint();
void ReadGds(Span<uint> destination, uint wordOffset, uint wordCount);
void RecordEndOfPipe(in EndOfPipeWrite write);
@ -136,6 +150,9 @@ public interface ICommandStreamHost
// guest memory (DrawIndexedArguments.IndirectArgumentsAddress).
bool ResolvesIndirectDrawOnGpu => false;
// The host also draws non-indexed indirect draws from their arguments in guest memory.
bool ResolvesNonIndexedIndirectDrawOnGpu => false;
// Called when a queue reset packet clears the processor.
void OnQueueReset(int queueId);

View File

@ -161,7 +161,11 @@ public struct EnhancedQualityAntialiasingRegisters
public bool HighQualityIntersections;
public bool IncoherentReads;
public bool InterpolateComponentZ;
public bool InterpolateSourceZ;
public bool StaticAnchorAssociations;
public bool AlphaToMaskEqaaDisable;
public byte OverrasterizationAmount;
public bool EnablePostZOverrasterization;
public static EnhancedQualityAntialiasingRegisters Decode(uint value) => new()
{
@ -172,7 +176,11 @@ public struct EnhancedQualityAntialiasingRegisters
HighQualityIntersections = RegisterField.Bit(value, 16),
IncoherentReads = RegisterField.Bit(value, 17),
InterpolateComponentZ = RegisterField.Bit(value, 18),
InterpolateSourceZ = RegisterField.Bit(value, 19),
StaticAnchorAssociations = RegisterField.Bit(value, 20),
AlphaToMaskEqaaDisable = RegisterField.Bit(value, 21),
OverrasterizationAmount = (byte)RegisterField.Get(value, 24, 0x7),
EnablePostZOverrasterization = RegisterField.Bit(value, 27),
};
}
@ -194,6 +202,7 @@ public struct ColorControlRegisters
public struct DepthRenderOverrideRegisters
{
public bool ForceShaderDepthOrder;
public bool ForceZValid;
public bool ForceZDirty;
public bool ForceStencilValid;
@ -201,6 +210,7 @@ public struct DepthRenderOverrideRegisters
public static DepthRenderOverrideRegisters Decode(uint value) => new()
{
ForceShaderDepthOrder = RegisterField.Bit(value, 6),
ForceZValid = (value & 0x2000_0000u) != 0,
ForceZDirty = (value & 0x0800_0000u) != 0,
ForceStencilValid = (value & 0x4000_0000u) != 0,
@ -253,10 +263,11 @@ public struct DepthShaderControlRegisters
public bool DualExportEnable;
public bool ExecuteOnNoop;
public bool AlphaToMaskDisable;
public bool DepthBeforeShader;
public static DepthShaderControlRegisters Decode(uint value) => new()
{
RemainingBits = value & 0xFFFF_908Eu,
RemainingBits = value & 0xFFFF_808Eu,
ConservativeDepthExport = (byte)RegisterField.Get(value, 13, 0x3),
DepthExportOrder = (byte)RegisterField.Get(value, 4, 0x3),
KillEnable = RegisterField.Bit(value, 6),
@ -265,6 +276,7 @@ public struct DepthShaderControlRegisters
DualExportEnable = RegisterField.Bit(value, 9),
ExecuteOnNoop = RegisterField.Bit(value, 10),
AlphaToMaskDisable = RegisterField.Bit(value, 11),
DepthBeforeShader = RegisterField.Bit(value, 12),
};
}
@ -292,6 +304,17 @@ public sealed class SampleLocationRegisters
public ulong CentroidPriority;
public uint[] Locations = new uint[LocationCount];
// Four-bit signed offsets per coordinate, relative to the pixel center.
public (float X, float Y) Position(uint pixel, uint sample)
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(pixel, 4u);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(sample, 16u);
var packed = Locations[pixel * 4 + sample / 4] >> (int)((sample % 4) * 8);
var x = (int)(packed << 28) >> 28;
var y = (int)(packed << 24) >> 28;
return ((x + 8) / 16f, (y + 8) / 16f);
}
public SampleLocationRegisters Copy()
{
var copy = (SampleLocationRegisters)MemberwiseClone();
@ -366,7 +389,11 @@ public sealed class ContextRegisters
public ColorControlRegisters ColorControl = new();
public DepthRenderOverrideRegisters DepthRenderOverride;
public ScanModeRegisters ScanMode = new();
public uint ScanModeControl1;
public SampleLocationRegisters SampleLocations = new();
public uint ShaderSampleExclusionMask;
public uint SampleCoverageMaskX0Y0X1Y0 = uint.MaxValue;
public uint SampleCoverageMaskX0Y1X1Y1 = uint.MaxValue;
public AntialiasingConfigRegisters AntialiasingConfig;
public uint ShaderStages;
public DepthTargetWords DepthTarget;

View File

@ -205,6 +205,9 @@ public static class ShaderRegisterOffset
public const uint SpiShaderUserAccumLsHs0 = 0x132;
public const uint SpiShaderPgmLoLs = 0x148;
public const uint SpiShaderPgmHiLs = 0x149;
// Past the LS block, where gfx10 documents no register. Ghost of Yotei 2.002 writes 1 here
// from a shader register table at startup; no shader state depends on it.
public const uint SpiShaderUndocumented192 = 0x192;
public const uint ComputeStartX = 0x204;
public const uint ComputeStartY = 0x205;
public const uint ComputeStartZ = 0x206;
@ -226,6 +229,9 @@ public static class ShaderRegisterOffset
// User-config bank offsets the write table covers.
public static class UserConfigRegisterOffset
{
// Below the primitive-assembly block, where gfx10 to gfx12 document no register. Ghost of
// Yotei 2.002 writes 0 here from a user-config table, next to shader register 0x192.
public const uint UserConfigUndocumented191 = 0x191;
public const uint VgtPrimitiveType = 0x242;
public const uint VgtIndexType = 0x243;
public const uint VgtObjectId = 0x248;

View File

@ -21,8 +21,8 @@ internal static partial class RegisterWriters
{
DbCountControl, DbRenderOverride2, DbDfsmControl, DbRmiL2CacheControl, CbRmiGl2CacheControl,
TaBcBaseAddr, TaBcBaseAddrHi, CbDccControl, PaSuPointSize, PaSuPointMinMax, DbAlphaToMask, VgtDrawPayloadCntl,
VgtPrimitiveIdReset, PaClObjPrimIdCntl, PaSuVtxCntl, PaScFovWindowLr, PaScFovWindowTb, PaScModeCntl1,
PaScAaMaskX0Y0X1Y0, PaScAaMaskX0Y1X1Y1, PsShaderSampleExclusionMask, PaScBinnerCntl0, PaScBinnerCntl1,
VgtPrimitiveIdReset, PaClObjPrimIdCntl, PaSuVtxCntl, PaScFovWindowLr, PaScFovWindowTb,
PaScBinnerCntl0, PaScBinnerCntl1,
PaScConservativeRasterizationCntl,
})
{
@ -62,6 +62,7 @@ internal static partial class RegisterWriters
direct[PaClVteCntl] = ViewportTransformControlPacket;
direct[PaSuLineCntl] = LineControlPacket;
direct[PaScModeCntl0] = ScanModeControlPacket;
direct[PaScModeCntl1] = ScanModeControl1Packet;
direct[PaScAaConfig] = AntialiasingConfigPacket;
for (var offset = PaScAaSampleLocations0; offset < PaScAaSampleLocations0 + SampleLocationRegisters.LocationCount; offset++)
{
@ -168,13 +169,17 @@ internal static partial class RegisterWriters
CbDccControl, DbCountControl, DbSResultsCompareState0, DbSResultsCompareState1, DbRenderOverride2,
DbDfsmControl, DbRmiL2CacheControl, CbRmiGl2CacheControl, TaBcBaseAddr, TaBcBaseAddrHi, PaSuPointSize, PaSuPointMinMax,
SpiTmpringSize, VgtDrawPayloadCntl, VgtPrimitiveIdReset, PaClObjPrimIdCntl, PaScFovWindowLr, PaScFovWindowTb, PaScFsrEnable,
FsrRecursions0, FsrRecursions1, PaScModeCntl1, PaScAaMaskX0Y0X1Y0, PaScAaMaskX0Y1X1Y1, PaSuVtxCntl, PsShaderSampleExclusionMask,
FsrRecursions0, FsrRecursions1, PaSuVtxCntl,
PaScBinnerCntl0, PaScBinnerCntl1, PaScConservativeRasterizationCntl, DbAlphaToMask,
})
{
indirect[offset] = IgnoreEntry;
}
indirect[PsShaderSampleExclusionMask] = static (banks, _, value) => banks.Context.ShaderSampleExclusionMask = value;
indirect[PaScAaMaskX0Y0X1Y0] = static (banks, _, value) => banks.Context.SampleCoverageMaskX0Y0X1Y0 = value;
indirect[PaScAaMaskX0Y1X1Y1] = static (banks, _, value) => banks.Context.SampleCoverageMaskX0Y1X1Y1 = value;
indirect[SpiVsOutConfig] = static (banks, _, value) => banks.Context.ShaderInterface.VertexOutputConfiguration = value;
indirect[DbRenderOverride] = static (banks, _, value) => banks.Context.DepthRenderOverride = DepthRenderOverrideRegisters.Decode(value);
indirect[SpiShaderPosFormat] = static (banks, _, value) => banks.Context.ShaderInterface.PositionExportFormat = value;
@ -250,6 +255,7 @@ internal static partial class RegisterWriters
indirect[PaSuLineCntl] = LineControlEntry;
indirect[PaClVteCntl] = static (banks, _, value) => banks.Context.ScreenViewport.TransformControl = value;
indirect[PaScModeCntl0] = static (banks, _, value) => banks.Context.ScanMode = ScanModeRegisters.Decode(value);
indirect[PaScModeCntl1] = static (banks, _, value) => banks.Context.ScanModeControl1 = value;
indirect[PaScAaConfig] = static (banks, _, value) => banks.Context.AntialiasingConfig = AntialiasingConfigRegisters.Decode(value);
indirect[PaScCentroidPriority0] = CentroidPriorityEntry;
indirect[PaScCentroidPriority1] = CentroidPriorityEntry;
@ -404,6 +410,9 @@ internal static partial class RegisterWriters
private static uint ScanModeControlPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>
SingleValue(banks, in packet, offset, values, RegisterWriteTable.ContextIndirect[PaScModeCntl0]!);
private static uint ScanModeControl1Packet(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>
SingleValue(banks, in packet, offset, values, RegisterWriteTable.ContextIndirect[PaScModeCntl1]!);
private static uint AntialiasingConfigPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>
SingleValue(banks, in packet, offset, values, RegisterWriteTable.ContextIndirect[PaScAaConfig]!);

View File

@ -23,7 +23,6 @@ internal static partial class RegisterWriters
[
SpiShaderPaceIdPs, SpiGraphicsShaderControlPs, SpiShaderPaceIdGs, SpiShaderPgmRsrc4Gs, SpiGraphicsShaderControlGs,
SpiShaderUserDataAddrLoGs, SpiShaderUserDataAddrHiGs, SpiShaderPgmChksumHs, SpiShaderPgmRsrc4Hs, SpiGraphicsShaderControlHs,
SpiShaderUserDataAddrLoHs, SpiShaderUserDataAddrHiHs,
];
public static void FillShader(RegisterPacketWriter?[] direct, RegisterWriter?[] indirect)
@ -67,6 +66,8 @@ internal static partial class RegisterWriters
indirect[offset] = IgnoreEntry;
}
indirect[SpiShaderUndocumented192] = UndocumentedShaderEntry;
direct[SpiGraphicsShaderControlPs] = ForwardShaderPacket;
direct[SpiGraphicsShaderControlGs] = ForwardShaderPacket;
direct[SpiShaderUserDataAddrLoGs] = ForwardShaderPacket;
@ -75,6 +76,13 @@ internal static partial class RegisterWriters
direct[SpiShaderUserDataAddrLoHs] = ForwardShaderPacket;
direct[SpiShaderUserDataAddrHiHs] = ForwardShaderPacket;
// Unlike program addresses, these registers contain byte-address
// bits 0..31 and 32..47, consumed directly by merged LS/HS s0:s1.
indirect[SpiShaderUserDataAddrLoHs] = static (banks, _, value) =>
banks.Shader.Vertex.HullUserDataAddress = (banks.Shader.Vertex.HullUserDataAddress & 0xFFFF_0000_0000ul) | value;
indirect[SpiShaderUserDataAddrHiHs] = static (banks, _, value) =>
banks.Shader.Vertex.HullUserDataAddress = (banks.Shader.Vertex.HullUserDataAddress & uint.MaxValue) | ((ulong)(value & 0xFFFFu) << 32);
indirect[SpiShaderPgmLoHs] = static (banks, _, value) => banks.Shader.Vertex.HullAddress = RegisterField.WithLowAddress(banks.Shader.Vertex.HullAddress, value);
indirect[SpiShaderPgmHiHs] = static (banks, _, value) => banks.Shader.Vertex.HullAddress = RegisterField.WithHighAddress(banks.Shader.Vertex.HullAddress, value);
indirect[SpiShaderPgmRsrc1Hs] = static (banks, _, value) => banks.Shader.Vertex.HullResource1 = HullResource1.Decode(value);
@ -99,6 +107,17 @@ internal static partial class RegisterWriters
indirect[SpiShaderPgmRsrc2Vs] = static (banks, _, value) => banks.Shader.Vertex.LegacyVertexResource2 = value;
}
// The value is not applied, but each new one is logged so a rendering difference can be traced to it.
private static long _lastUndocumentedShaderValue = -1;
private static void UndocumentedShaderEntry(RegisterBanks banks, uint offset, uint value)
{
if (Interlocked.Exchange(ref _lastUndocumentedShaderValue, value) != value)
{
Console.Error.WriteLine($"[GPU][WARN] Undocumented shader register written: offset=0x{offset:X4} value=0x{value:X8}; it is not applied.");
}
}
private static uint PixelUserScalarsPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>
UserScalarsPacket(banks, in packet, offset, values, banks.Shader.Pixel.UserScalars, SpiShaderUserDataPs0, GraphicsUserScalarCount);

View File

@ -50,6 +50,17 @@ internal static partial class RegisterWriters
indirect[GdsOaCounter] = OrderedAppendEntry;
indirect[GdsOaAddress] = OrderedAppendEntry;
indirect[GeStereoCntl] = IgnoreEntry;
indirect[UserConfigUndocumented191] = UndocumentedUserConfigEntry;
}
private static long _lastUndocumentedUserConfigValue = -1;
private static void UndocumentedUserConfigEntry(RegisterBanks banks, uint offset, uint value)
{
if (Interlocked.Exchange(ref _lastUndocumentedUserConfigValue, value) != value)
{
Console.Error.WriteLine($"[GPU][WARN] Undocumented user-config register written: offset=0x{offset:X4} value=0x{value:X8}; it is not applied.");
}
}
private static uint PrimitiveTypePacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>

View File

@ -182,6 +182,7 @@ public sealed class VertexStageRegisters
public ulong ExportAddress;
public ulong LocalAddress;
public ulong HullAddress;
public ulong HullUserDataAddress;
public HullResource1 HullResource1;
public HullResource2 HullResource2;
public ulong GeometryAddress;

View File

@ -28,6 +28,9 @@ internal interface IGuestGpuBackend
// Compiles one permutation of a program over its resource plan and binding layout.
bool TryCompileProgram(ShaderCompileRequest request, out IGuestCompiledShader? shader, out string error);
(IGuestCompiledShader Vertex, IGuestCompiledShader Control) CompileTessellationBridge(
SharpEmu.ShaderCompiler.Resources.BindingLayout layout, Gen5TessellationDomain domain) =>
throw new NotSupportedException($"The {BackendName} backend does not support merged tessellation.");
/// <summary>Returns the backend's no-color-output fragment shader.</summary>
IGuestCompiledShader GetDepthOnlyFragmentShader();

View File

@ -0,0 +1,135 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Scheduling;
using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Images;
// The guest-resolution twin of a scaled image and the blits that bridge the two resolutions.
//
// Guest memory layouts never change. Every path that moves guest bytes, and every copy that
// pairs this image with one at another host resolution, runs against the twin at the guest
// extent and then blits across, so callers keep describing rectangles in guest pixels.
public sealed unsafe partial class CachedImage
{
private CachedImage? _guestSizedTwin;
private CachedImage GuestSizedTwin()
{
if (_guestSizedTwin is { } existing)
{
return existing;
}
// The twin is host-owned: dropping the guest placement keeps the image cache from
// tracking it and keeps RenderScalePolicy from scaling it in turn.
var description = Description;
description.Data = default;
description.Stencil = default;
description.Metadata = default;
description.HtileClearMask = uint.MaxValue;
_guestSizedTwin = new CachedImage(_device, _scheduler, _guestBacking, description);
return _guestSizedTwin;
}
// The image at guest resolution: this image when it is not scaled, otherwise a twin
// holding a downscaled copy of its current contents.
internal CachedImage AtGuestResolution()
{
if (!IsScaled)
{
return this;
}
var twin = GuestSizedTwin();
twin.BlitFrom(this);
return twin;
}
private void BlitFrom(CachedImage source)
{
var aspect = ViewFormatRules.FullAspects(Backing.Format);
if (source.Backing.ImageType != ImageType.Type2D || Backing.ImageType != ImageType.Type2D ||
source.Backing.Samples != 1 || Backing.Samples != 1 ||
source.Backing.Format != Backing.Format || (aspect & ImageAspectFlags.StencilBit) != 0)
{
throw SubmissionScheduler.Fatal(
$"A scaled image blit needs single-sample 2D images of one format without stencil: " +
$"sourceFormat={(int)source.Backing.Format} format={(int)Backing.Format} " +
$"sourceType={(int)source.Backing.ImageType} type={(int)Backing.ImageType} " +
$"sourceSamples={source.Backing.Samples} samples={Backing.Samples}.");
}
_scheduler.EndRendering();
var command = new CommandBuffer(_scheduler.Current.Handle);
source.Transition(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, null, command);
Transition(ImageLayout.TransferDstOptimal, AccessFlags.TransferWriteBit, null, command);
var levels = Math.Min(source.Backing.MipLevels, Backing.MipLevels);
var layers = Math.Min(source.Backing.Layers, Backing.Layers);
var regions = new ImageBlit[levels];
for (uint level = 0; level < levels; level++)
{
var subresource = new ImageSubresourceLayers(aspect, level, 0, layers);
regions[level] = new ImageBlit
{
SrcSubresource = subresource,
DstSubresource = subresource,
SrcOffsets = new ImageBlit.SrcOffsetsBuffer
{
Element1 = new Offset3D(
(int)Math.Max(source.Backing.Extent.Width >> (int)level, 1),
(int)Math.Max(source.Backing.Extent.Height >> (int)level, 1),
1),
},
DstOffsets = new ImageBlit.DstOffsetsBuffer
{
Element1 = new Offset3D(
(int)Math.Max(Backing.Extent.Width >> (int)level, 1),
(int)Math.Max(Backing.Extent.Height >> (int)level, 1),
1),
},
};
}
// Depth values must not be interpolated, and Vulkan forbids a filtered depth blit.
var filter = (aspect & ImageAspectFlags.DepthBit) != 0 ? Filter.Nearest : Filter.Linear;
fixed (ImageBlit* blits = regions)
{
_device.Vk.CmdBlitImage(command, source.Backing.Handle, ImageLayout.TransferSrcOptimal,
Backing.Handle, ImageLayout.TransferDstOptimal, (uint)regions.Length, blits, filter);
}
Transition(ReadyLayout, ReadyAccess, null, command);
}
// True when a copy between the two images would cross host resolutions. Host-owned
// helpers are built to match the image they mirror, so only guest-placed pairs qualify.
private bool CrossesRenderScale(CachedImage other) =>
RenderScale != other.RenderScale && IsGuestPlaced && other.IsGuestPlaced;
// Runs the copy at guest resolution: the scaled side contributes or receives its twin,
// so the rectangles the caller describes keep their guest meaning.
private bool TryCopyAcrossScales(CachedImage source, Action<CachedImage, CachedImage> copy)
{
if (!CrossesRenderScale(source))
{
return false;
}
var guestSource = source.AtGuestResolution();
if (!IsScaled)
{
copy(this, guestSource);
return true;
}
// The copy may touch only part of the image, so the twin starts from the current
// contents and the result is blitted back over the whole image.
var twin = GuestSizedTwin();
twin.BlitFrom(this);
copy(twin, guestSource);
BlitFrom(twin);
return true;
}
}

View File

@ -14,6 +14,14 @@ public sealed unsafe partial class CachedImage
{
private const AccessFlags WriteAccess = AccessFlags.TransferWriteBit | AccessFlags.ShaderWriteBit | AccessFlags.MemoryWriteBit;
private const AccessFlags TransferAccess = AccessFlags.TransferReadBit | AccessFlags.TransferWriteBit;
private const AccessFlags AnyWriteAccess = WriteAccess | AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit;
// Reads after reads in one layout need no barrier: the state keeps every reader, so the next
// writer still waits for all of them. (A sampled depth attachment followed by a draw that only
// tests depth would otherwise end the rendering scope.)
private static bool IsReadAfterRead(ImageAccessState state, ImageLayout layout, AccessFlags access) =>
state.Layout == layout && state.Layout != ImageLayout.Undefined && access != 0 && state.Access != 0 &&
(state.Access & AnyWriteAccess) == 0 && (access & AnyWriteAccess) == 0;
private const AccessFlags MemoryAccess = AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit;
private const ImageLayout ReadyLayout = ImageLayout.General;
private const AccessFlags ReadyAccess = AccessFlags.ShaderReadBit | AccessFlags.TransferReadBit;
@ -32,9 +40,12 @@ public sealed unsafe partial class CachedImage
};
// Barriers for the requested state; a repeated write always gets a barrier.
private static readonly List<ImageMemoryBarrier2> NoBarriers = new(0);
public (List<ImageMemoryBarrier2> Barriers, PipelineStageFlags SourceStages) GetBarriers(ImageLayout layout, AccessFlags access, PipelineStageFlags stage, SubresourceRange? range)
{
var barriers = new List<ImageMemoryBarrier2>();
// Most requests need no barrier; the list exists only once one does. Callers never add to it.
List<ImageMemoryBarrier2>? barriers = null;
PipelineStageFlags sourceStages = 0;
if ((access & (WriteAccess | AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit)) != 0)
{
@ -81,9 +92,13 @@ public sealed unsafe partial class CachedImage
var state = states[index];
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout != layout || state.Access != access || repeatedWrite)
if (IsReadAfterRead(state, layout, access))
{
barriers.Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
states[index] = new ImageAccessState(state.Stage | stage, state.Access | access, layout);
}
else if (state.Layout != layout || state.Access != access || repeatedWrite)
{
(barriers ??= new List<ImageMemoryBarrier2>(1)).Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
sourceStages |= state.Stage;
states[index] = new ImageAccessState(stage, access, layout);
}
@ -101,15 +116,54 @@ public sealed unsafe partial class CachedImage
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout == layout && state.Access == access && !repeatedWrite)
{
return (barriers, sourceStages);
return (barriers ?? NoBarriers, sourceStages);
}
barriers.Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
if (IsReadAfterRead(state, layout, access))
{
Backing.State = new ImageAccessState(state.Stage | stage, state.Access | access, layout);
return (barriers ?? NoBarriers, sourceStages);
}
(barriers ??= new List<ImageMemoryBarrier2>(1)).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);
}
// The layout every subresource in the range is in, or null when they differ.
public ImageLayout? UniformLayout(SubresourceRange range)
{
var states = Backing.SubresourceStates;
if (states == null)
{
return Backing.State.Layout;
}
var layers = Description.Resources.Layers;
if (Description.IsVolume)
{
range = range with { BaseLayer = 0, LayerCount = 1 };
}
ImageLayout? layout = null;
for (var level = range.BaseLevel; level < range.BaseLevel + range.LevelCount; level++)
{
for (var layer = range.BaseLayer; layer < range.BaseLayer + range.LayerCount; layer++)
{
var index = (int)(level * layers + layer);
if (index >= states.Count || (layout is { } known && known != states[index].Layout))
{
return null;
}
layout = states[index].Layout;
}
}
return layout;
}
public void Transition(ImageLayout layout, AccessFlags access, SubresourceRange? range, CommandBuffer command)
@ -134,7 +188,7 @@ public sealed unsafe partial class CachedImage
// Attachment accesses inside one rendering scope are already ordered by rasterization
// order, so a barrier that only repeats an attachment write in the same layout matters
// only to what runs after the scope; it waits for the scope to end instead of ending it.
if (OnlyRepeatsAttachmentAccess(barriers) &&
if (Backing.SampleLocations is null && OnlyRepeatsAttachmentAccess(barriers) &&
_scheduler.TryDeferUntilRenderingEnds(sourceStages == 0 ? PipelineStageFlags.TopOfPipeBit : sourceStages, stage, barriers))
{
return;
@ -166,9 +220,24 @@ public sealed unsafe partial class CachedImage
private void RecordBarriers(CommandBuffer command, PipelineStageFlags sourceStages, PipelineStageFlags destinationStages, BufferMemoryBarrier2* bufferBarrier, List<ImageMemoryBarrier2> imageBarriers)
{
var images = imageBarriers.ToArray();
// Sample locations chain a stack structure into each barrier, so those barriers are a
// copy; the caller's list never keeps that pointer.
Span<ImageMemoryBarrier2> images = Backing.SampleLocations is null
? System.Runtime.InteropServices.CollectionsMarshal.AsSpan(imageBarriers)
: imageBarriers.ToArray();
fixed (SampleLocationEXT* locations = Backing.SampleLocations)
fixed (ImageMemoryBarrier2* imagePointer = images)
{
var locationInfo = new SampleLocationsInfoEXT
{
SType = StructureType.SampleLocationsInfoExt,
SampleLocationsPerPixel = SampleCountFlags.Count2Bit,
SampleLocationGridSize = new Extent2D(2, 2),
SampleLocationsCount = 8,
PSampleLocations = locations,
};
if (locations != null)
for (var index = 0; index < images.Length; index++) imagePointer[index].PNext = &locationInfo;
VulkanSynchronization.PipelineBarrier(_device.Vk,
command, sourceStages == 0 ? PipelineStageFlags.TopOfPipeBit : sourceStages, destinationStages, DependencyFlags.ByRegionBit,
0, null, bufferBarrier == null ? 0u : 1u, bufferBarrier, (uint)images.Length, imagePointer);
@ -242,6 +311,16 @@ public sealed unsafe partial class CachedImage
public void UploadFromBuffer(ReadOnlySpan<BufferImageCopy> copies, VkBuffer buffer, ulong offset, ulong size)
{
// Guest bytes describe guest geometry, so a scaled image receives them through its
// guest-resolution twin and takes the result as a blit.
if (IsScaled)
{
var twin = GuestSizedTwin();
twin.UploadFromBuffer(copies, buffer, offset, size);
BlitFrom(twin);
return;
}
var command = BeginTransfer(copies, buffer, size);
var sanitized = SanitizeUploadCopies(copies);
var uploadCopies = sanitized is null ? copies : sanitized.AsSpan();
@ -260,6 +339,12 @@ public sealed unsafe partial class CachedImage
public void DownloadToBuffer(ReadOnlySpan<BufferImageCopy> copies, VkBuffer buffer, ulong offset, ulong size)
{
if (IsScaled)
{
AtGuestResolution().DownloadToBuffer(copies, buffer, offset, size);
return;
}
var command = BeginTransfer(copies, buffer, size);
var bufferBarrier = BufferBarrier(buffer, offset, size, MemoryAccess, AccessFlags.TransferWriteBit);
var (imageBarriers, sourceStages) = GetBarriers(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, PipelineStageFlags.TransferBit, null);
@ -298,6 +383,11 @@ public sealed unsafe partial class CachedImage
// Copies every shared mip level of the source; stencil is never copied here.
public void CopyFrom(CachedImage source)
{
if (TryCopyAcrossScales(source, static (destination, origin) => destination.CopyFrom(origin)))
{
return;
}
if (source.Backing.Samples != Backing.Samples)
{
throw SubmissionScheduler.Fatal($"An image copy needs equal sample counts: source={source.Backing.Samples} destination={Backing.Samples}.");
@ -367,6 +457,13 @@ public sealed unsafe partial class CachedImage
// Resolves or copies one mip of the source into one mip of this single-sample image.
public void ResolveFrom(CachedImage source, in SubresourceRange sourceRange, in SubresourceRange destinationRange)
{
var fromRange = sourceRange;
var toRange = destinationRange;
if (TryCopyAcrossScales(source, (destination, origin) => destination.ResolveFrom(origin, fromRange, toRange)))
{
return;
}
if (Backing.Samples != 1 || source.Backing.ImageType != ImageType.Type2D || Backing.ImageType != ImageType.Type2D ||
sourceRange.LevelCount != 1 || destinationRange.LevelCount != 1 ||
sourceRange.BaseLevel >= source.Backing.MipLevels || destinationRange.BaseLevel >= Backing.MipLevels ||
@ -383,16 +480,18 @@ public sealed unsafe partial class CachedImage
var destinationHeight = Math.Max(Backing.Extent.Height >> (int)destinationRange.BaseLevel, 1);
var copy = source.Backing.Samples == 1;
var formatsAgree = copy ? ViewFormatRules.AreCompatible(source.Backing.Format, Backing.Format) : source.Backing.Format == Backing.Format;
if (layers == 0 || Description.Extent.Width > sourceWidth || Description.Extent.Height > sourceHeight ||
Description.Extent.Width > destinationWidth || Description.Extent.Height > destinationHeight || !formatsAgree)
// The resolve moves host texels, so the guest extent crosses into this image's resolution.
var resolveExtent = RenderScalePolicy.ScaleExtent(Description.Extent, RenderScale);
if (layers == 0 || resolveExtent.Width > sourceWidth || resolveExtent.Height > sourceHeight ||
resolveExtent.Width > destinationWidth || resolveExtent.Height > destinationHeight || !formatsAgree)
{
throw SubmissionScheduler.Fatal(
$"The resolve extent or formats do not agree: extent={Description.Extent.Width}x{Description.Extent.Height} source={sourceWidth}x{sourceHeight} destination={destinationWidth}x{destinationHeight} layers={layers} sourceFormat={(int)source.Backing.Format} destinationFormat={(int)Backing.Format}.");
$"The resolve extent or formats do not agree: extent={resolveExtent.Width}x{resolveExtent.Height} source={sourceWidth}x{sourceHeight} destination={destinationWidth}x{destinationHeight} layers={layers} sourceFormat={(int)source.Backing.Format} destinationFormat={(int)Backing.Format}.");
}
var resolvedSource = sourceRange with { LayerCount = layers };
var resolvedDestination = destinationRange with { LayerCount = layers };
var extent = new Extent3D(Description.Extent.Width, Description.Extent.Height, 1);
var extent = new Extent3D(resolveExtent.Width, resolveExtent.Height, 1);
_scheduler.EndRendering();
var command = new CommandBuffer(_scheduler.Current.Handle);
source.Transition(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, resolvedSource, command);
@ -413,6 +512,13 @@ public sealed unsafe partial class CachedImage
public void CopyDepthStencilFrom(CachedImage source, in SubresourceRange range, in Extent3D extent, ImageAspectFlags aspects)
{
var copyRange = range;
var copyExtent = extent;
if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyDepthStencilFrom(origin, copyRange, copyExtent, aspects)))
{
return;
}
var requested = aspects & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit);
var available = ViewFormatRules.FullAspects(Backing.Format);
if (requested == 0 || (requested & ~available) != 0 || source.Backing.Format != Backing.Format ||
@ -468,22 +574,27 @@ public sealed unsafe partial class CachedImage
}
// A storage image holds the stencil bytes while the shader runs; the attachment stays the owner.
public CachedImage CreateStencilStorageImage()
// `width` x `height` is the storage view the shader binds, which can cover only the top-left
// part of the attachment (a dynamic-resolution pass): the shader then sees that size.
public CachedImage CreateStencilStorageImage(uint width, uint height)
{
if ((ViewFormatRules.FullAspects(Backing.Format) & ImageAspectFlags.StencilBit) == 0 ||
Backing.ImageType != ImageType.Type2D || Backing.Samples != 1 || Backing.MipLevels != 1)
Backing.ImageType != ImageType.Type2D || Backing.Samples != 1 || Backing.MipLevels != 1 ||
width == 0 || height == 0 || width > Backing.Extent.Width || height > Backing.Extent.Height)
{
throw SubmissionScheduler.Fatal("Stencil storage needs a single-sample, single-level 2D stencil image.");
throw SubmissionScheduler.Fatal(
$"Stencil storage needs a single-sample, single-level 2D stencil image covering the view: " +
$"view={width}x{height} attachment={Backing.Extent.Width}x{Backing.Extent.Height} mips={Backing.MipLevels} samples={Backing.Samples}.");
}
var description = ImageDescription.Create();
description.PixelFormat = Format.R8Uint;
description.GuestFormat = GuestPixelFormat.Bits8UInt;
description.Extent = Backing.Extent;
description.Extent = new Extent3D(width, height, 1);
description.Resources = new SubresourceCount(1, Backing.Layers);
description.Pitch = Backing.Extent.Width;
description.Pitch = width;
description.BytesPerBlock = 1;
return new CachedImage(_device, _scheduler, _guestBacking, description);
return new CachedImage(_device, _scheduler, _guestBacking, description, memoryPool: _memoryPool);
}
public void CopyStencilStorage(CachedImage storage, GpuBuffer buffer, bool writeBack)
@ -492,11 +603,12 @@ public sealed unsafe partial class CachedImage
Backing.ImageType != ImageType.Type2D || Backing.Samples != 1 || Backing.MipLevels != 1 ||
storage.Backing.Format != Format.R8Uint || storage.Backing.ImageType != ImageType.Type2D ||
storage.Backing.Samples != 1 || storage.Backing.MipLevels != 1 || storage.Backing.Layers != Backing.Layers ||
storage.Backing.Extent.Width != Backing.Extent.Width || storage.Backing.Extent.Height != Backing.Extent.Height)
storage.Backing.Extent.Width > Backing.Extent.Width || storage.Backing.Extent.Height > Backing.Extent.Height)
{
throw SubmissionScheduler.Fatal("The stencil storage image does not match its attachment.");
}
// Only the storage's region moves: the rest of the plane keeps its stencil values.
if (writeBack)
CopyThroughBuffer(storage, buffer, ImageAspectFlags.ColorBit, ImageAspectFlags.StencilBit);
else
@ -510,6 +622,11 @@ public sealed unsafe partial class CachedImage
private void CopyThroughBuffer(CachedImage source, GpuBuffer buffer, ImageAspectFlags sourceAspect, ImageAspectFlags destinationAspect)
{
if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyThroughBuffer(origin, buffer, sourceAspect, destinationAspect)))
{
return;
}
if (buffer.Handle.Handle == 0 || source.Backing.Samples != 1 || Backing.Samples != 1)
{
throw SubmissionScheduler.Fatal($"A copy through a buffer needs single-sample images and a buffer: sourceSamples={source.Backing.Samples} destinationSamples={Backing.Samples}.");
@ -531,10 +648,11 @@ public sealed unsafe partial class CachedImage
var command = new CommandBuffer(_scheduler.Current.Handle);
source.Transition(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, null, command);
Transition(ImageLayout.TransferDstOptimal, AccessFlags.TransferWriteBit, null, command);
// The copy covers the region both images have, from their top-left corner.
for (uint level = 0; level < levels; level++)
{
var width = Math.Max(source.Backing.Extent.Width >> (int)level, 1);
var height = Math.Max(source.Backing.Extent.Height >> (int)level, 1);
var width = Math.Max(Math.Min(source.Backing.Extent.Width, Backing.Extent.Width) >> (int)level, 1);
var height = Math.Max(Math.Min(source.Backing.Extent.Height, Backing.Extent.Height) >> (int)level, 1);
var sourceDepth = source.Backing.ImageType == ImageType.Type3D ? Math.Max(source.Backing.Extent.Depth >> (int)level, 1) : source.Backing.Layers;
var destinationDepth = Backing.ImageType == ImageType.Type3D ? Math.Max(Backing.Extent.Depth >> (int)level, 1) : Backing.Layers;
var slices = Math.Min(sourceDepth, destinationDepth);
@ -579,6 +697,11 @@ public sealed unsafe partial class CachedImage
// Copies a whole standalone image into one mip and layer of this image.
public void CopyMipFrom(CachedImage source, uint mip, uint layer)
{
if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyMipFrom(origin, mip, layer)))
{
return;
}
if (source.Backing.Samples != Backing.Samples || mip >= Backing.MipLevels || layer >= Backing.Layers)
{
throw SubmissionScheduler.Fatal($"The mip copy target is invalid: mip={mip} layer={layer} levels={Backing.MipLevels} layers={Backing.Layers} sourceSamples={source.Backing.Samples} destinationSamples={Backing.Samples}.");
@ -627,6 +750,11 @@ public sealed unsafe partial class CachedImage
// Copies a rectangle of the source's first mip into one mip of this image, at its origin.
public void CopyRegionFrom(CachedImage source, uint sourceX, uint sourceY, uint mip, uint width, uint height)
{
if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyRegionFrom(origin, sourceX, sourceY, mip, width, height)))
{
return;
}
if (mip >= Backing.MipLevels || sourceX + width > source.Backing.Extent.Width || sourceY + height > source.Backing.Extent.Height)
{
throw SubmissionScheduler.Fatal(

View File

@ -49,6 +49,7 @@ public sealed class ImageBacking
public ImageCreateFlags Flags;
public Image Handle;
public ImageAccessState State = ImageAccessState.Initial;
public SampleLocationEXT[]? SampleLocations;
public List<ImageAccessState>? SubresourceStates;
public DeviceMemory Memory;
public ulong AllocationSize;
@ -63,6 +64,9 @@ public sealed unsafe partial class CachedImage : IDisposable
private readonly SubmissionScheduler _scheduler;
private readonly IGuestBackedSpace _guestBacking;
private readonly ImageBackingPool? _pool;
private readonly OptimalImageMemoryPool? _memoryPool;
private readonly Func<string>? _memoryDiagnostics;
private OptimalImageMemoryAllocation? _memoryPlacement;
private ImageBackingPool.Key _poolKey;
private ulong _maybeCpuHash;
private bool _cpuDirty;
@ -73,8 +77,14 @@ public sealed unsafe partial class CachedImage : IDisposable
private bool _bufferHoldsGpuContents;
public ImageDescription Description;
// The host resolution multiplier of this image. Guest geometry in Description never
// changes; only the backing is larger or smaller, and guest bytes move through a
// guest-resolution twin (CachedImage.Transfers).
public readonly float RenderScale = 1.0f;
public readonly ImageBacking Backing = new();
public readonly List<CachedImageView> Views = new();
private readonly Dictionary<(uint Width, uint Height), CachedImage> _stencilStorageImages = new();
public ImageUses Uses;
public ImageBindingState Binding;
public bool Registered;
@ -86,14 +96,18 @@ public sealed unsafe partial class CachedImage : IDisposable
public int RecencyEntryIndex;
public CachedImage(GpuDeviceInfo device, SubmissionScheduler scheduler, IGuestBackedSpace guestBacking, in ImageDescription description,
ImageBackingPool? pool = null)
ImageBackingPool? pool = null, OptimalImageMemoryPool? memoryPool = null, Func<string>? memoryDiagnostics = null,
bool allowScaling = true)
{
_device = device;
_scheduler = scheduler;
_guestBacking = guestBacking;
_pool = pool;
_memoryPool = memoryPool;
_memoryDiagnostics = memoryDiagnostics;
Description = description;
Description.Validate();
RenderScale = allowScaling ? RenderScalePolicy.ScaleFor(Description) : 1.0f;
_cpuDirty = !ImageDescription.IsEmptyRange(Description.Data) && Description.Metadata.Compression == DisplayCompression.Uncompressed;
if (Description.PixelFormat == Format.Undefined)
{
@ -102,7 +116,13 @@ public sealed unsafe partial class CachedImage : IDisposable
Backing.Format = Description.PixelFormat;
Backing.ImageType = HostImageType(Description.Type);
Backing.Extent = Description.Extent;
var guestExtent = Description.FirstLevel == 0
? Description.Extent
: new Extent3D(
Math.Max(Description.Extent.Width >> (int)Description.FirstLevel, 1u),
Math.Max(Description.Extent.Height >> (int)Description.FirstLevel, 1u),
Description.Extent.Depth);
Backing.Extent = RenderScalePolicy.ScaleExtent(guestExtent, RenderScale);
Backing.GuestPitch = Description.Pitch;
Backing.Layers = Description.IsVolume ? 1 : Description.Resources.Layers;
Backing.MipLevels = Description.Resources.Levels;
@ -135,7 +155,7 @@ public sealed unsafe partial class CachedImage : IDisposable
Backing.Usage = create.Usage;
_poolKey = ImageBackingPool.KeyOf(create);
if (_pool is not null && _pool.TryTake(_poolKey, out Backing.Handle, out Backing.Memory, out Backing.AllocationSize))
if (_pool is not null && _pool.TryTake(_poolKey, out Backing.Handle, out Backing.Memory, out Backing.AllocationSize, out _memoryPlacement))
{
return;
}
@ -147,11 +167,26 @@ public sealed unsafe partial class CachedImage : IDisposable
throw CreateFailure(create, "vkCreateImage", createResult, 0);
}
vk.GetImageMemoryRequirements(device.Device, Backing.Handle, out var requirements);
var dedicated = new MemoryDedicatedRequirements { SType = StructureType.MemoryDedicatedRequirements };
var imageRequirements = new ImageMemoryRequirementsInfo2
{
SType = StructureType.ImageMemoryRequirementsInfo2, Image = Backing.Handle,
};
var requirements2 = new MemoryRequirements2
{
SType = StructureType.MemoryRequirements2, PNext = &dedicated,
};
vk.GetImageMemoryRequirements2(device.Device, &imageRequirements, &requirements2);
var requirements = requirements2.MemoryRequirements;
var dedicatedInfo = new MemoryDedicatedAllocateInfo
{
SType = StructureType.MemoryDedicatedAllocateInfo, Image = Backing.Handle,
};
var allocateInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
PNext = dedicated.RequiresDedicatedAllocation ? &dedicatedInfo : null,
};
var allocated = Result.ErrorOutOfDeviceMemory;
for (uint index = 0; index < device.MemoryTypeCount; index++)
@ -163,7 +198,16 @@ public sealed unsafe partial class CachedImage : IDisposable
}
allocateInfo.MemoryTypeIndex = index;
allocated = device.AllocateMemory(allocateInfo, out Backing.Memory);
if (_memoryPool is not null && !dedicated.RequiresDedicatedAllocation)
{
allocated = _memoryPool.Allocate(requirements, index, out var placement);
if (allocated == Result.Success)
{
_memoryPlacement = placement;
Backing.Memory = placement.Memory;
}
}
else allocated = device.AllocateMemory(allocateInfo, out Backing.Memory);
if (allocated == Result.Success)
{
break;
@ -173,17 +217,20 @@ public sealed unsafe partial class CachedImage : IDisposable
if (allocated != Result.Success)
{
vk.DestroyImage(device.Device, Backing.Handle, null);
device.FreeMemory(Backing.Memory);
ReleaseMemory();
Backing.Handle = default;
Backing.Memory = default;
// Running out of memory is recoverable: the cache can free images and try again.
if (allocated is Result.ErrorOutOfDeviceMemory or Result.ErrorOutOfHostMemory)
throw new ImageOutOfMemoryException(CreateFailureMessage(create, "vkAllocateMemory", allocated, requirements.Size));
throw CreateFailure(create, "vkAllocateMemory", allocated, requirements.Size);
}
var bindResult = vk.BindImageMemory(device.Device, Backing.Handle, Backing.Memory, 0);
var bindResult = vk.BindImageMemory(device.Device, Backing.Handle, Backing.Memory, _memoryPlacement?.Offset ?? 0);
if (bindResult != Result.Success)
{
vk.DestroyImage(device.Device, Backing.Handle, null);
device.FreeMemory(Backing.Memory);
ReleaseMemory();
Backing.Handle = default;
Backing.Memory = default;
throw CreateFailure(create, "vkBindImageMemory", bindResult, requirements.Size);
@ -192,11 +239,27 @@ public sealed unsafe partial class CachedImage : IDisposable
Backing.AllocationSize = requirements.Size;
}
private static Exception CreateFailure(in ImageCreateInfo create, string operation, Result result, ulong requiredBytes) =>
SubmissionScheduler.Fatal(
private void ReleaseMemory()
{
if (_memoryPlacement is { } placement) _memoryPool!.Free(placement);
else _device.FreeMemory(Backing.Memory);
_memoryPlacement = null;
}
private Exception CreateFailure(in ImageCreateInfo create, string operation, Result result, ulong requiredBytes) =>
SubmissionScheduler.Fatal(CreateFailureMessage(create, operation, result, requiredBytes));
private string CreateFailureMessage(in ImageCreateInfo create, string operation, Result result, ulong requiredBytes) =>
$"The image could not be created: operation={operation} result={result} required_bytes={requiredBytes} " +
$"extent={create.Extent.Width}x{create.Extent.Height}x{create.Extent.Depth} format={create.Format}({(int)create.Format}) " +
$"layers={create.ArrayLayers} levels={create.MipLevels} usage=0x{(uint)create.Usage:X} flags=0x{(uint)create.Flags:X}.");
$"layers={create.ArrayLayers} levels={create.MipLevels} usage=0x{(uint)create.Usage:X} flags=0x{(uint)create.Flags:X} " +
$"live_allocations={_device.LiveAllocations} allocation_limit={_device.MaxMemoryAllocationCount} " +
$"image_pool_allocated={_memoryPool?.AllocatedBytes ?? 0} image_pool_placed={_memoryPool?.PlacedBytes ?? 0} " +
$"last_failed_allocation_bytes={_device.LastFailedAllocationBytes} guest_address=0x{Description.Data.Address:X16} " +
(_memoryDiagnostics?.Invoke() ?? string.Empty);
// The device ran out of memory for the image's backing. Nothing was left allocated.
internal sealed class ImageOutOfMemoryException(string message) : Exception(message);
internal static bool TrySelectSupportedImageConfiguration(IImageFormatSupport device, ref ImageCreateInfo configuration, bool allowCompressedImageFallback)
{
@ -251,9 +314,12 @@ public sealed unsafe partial class CachedImage : IDisposable
_ => throw SubmissionScheduler.Fatal($"The image type is not a base type: type={(uint)type}."),
};
private static ImageCreateFlags CreateFlags(in ImageDescription description)
private ImageCreateFlags CreateFlags(in ImageDescription description)
{
ImageCreateFlags flags = 0;
if (_device.CustomTwoSampleLocationsSupported && description.Samples == 2 &&
DepthFormatRule.AspectTransferFormat(description.PixelFormat) != Format.Undefined)
flags |= ImageCreateFlags.CreateSampleLocationsCompatibleDepthBitExt;
if (DepthFormatRule.AspectTransferFormat(description.PixelFormat) == Format.Undefined)
{
flags |= ImageCreateFlags.CreateMutableFormatBit | ImageCreateFlags.CreateExtendedUsageBit;
@ -320,6 +386,13 @@ public sealed unsafe partial class CachedImage : IDisposable
return usage;
}
public bool IsScaled => RenderScale != 1.0f;
// Host-owned helpers - feedback snapshots, the stencil proxy, the guest-resolution twin -
// carry no guest placement and are created to match the image they mirror, so a copy
// between them and that image never crosses a resolution.
internal bool IsGuestPlaced => !ImageDescription.IsEmptyRange(Description.Data);
public void AssociateDepth(ResourceSlotIdentifier depthImage) => DepthOwner = depthImage;
internal ulong LastCpuWriteAddress { get; private set; }
@ -427,10 +500,29 @@ public sealed unsafe partial class CachedImage : IDisposable
_gpuModified = true;
_bufferHoldsGpuContents = false;
GpuWriteSequence = Interlocked.Increment(ref _gpuWriteCounter);
ContentSequence = GpuWriteSequence;
_guestPieceHashes = null;
}
public void ClearGpuModified() => _gpuModified = false;
// The GPU write the image contents reflect. A copy from a same-layout alias takes the
// source's sequence, so the alias is not copied back while neither image is written again.
public long ContentSequence { get; set; }
public void ClearGpuModified(
[System.Runtime.CompilerServices.CallerFilePath] string file = "",
[System.Runtime.CompilerServices.CallerLineNumber] int line = 0)
{
if (_gpuModified && ImageDropTrace.Enabled)
{
ImageDropTrace.Record(this, file, line);
}
_gpuModified = false;
}
// Advances whenever any image becomes GPU-modified, the only way an image starts to own
// guest bytes the CPU must not read.
public static long GpuWriteVersion => Interlocked.Read(ref _gpuWriteCounter);
public bool IsBufferModified => _bufferModified;
@ -453,7 +545,38 @@ public sealed unsafe partial class CachedImage : IDisposable
public bool IsWatched => WatchBegin != 0 && WatchEnd != 0;
public ulong AccountedSize => Backing.Exists ? (Description.Data.Size + 1023) & ~1023UL : 0;
// Account the device allocation, not the guest byte range. Optimal-tiled images can
// reserve more memory than their guest payload because of tiling, mip alignment and
// implementation-specific requirements. The backing allocation is the quantity the
// image memory pool actually reserves and releases.
public ulong AccountedSize
{
get
{
var bytes = Backing.Exists ? Backing.AllocationSize : 0;
foreach (var image in _stencilStorageImages.Values) bytes += image.AccountedSize;
return bytes;
}
}
// Queue order permits reuse: every preparation first refreshes the working
// image from the attachment and publishes its writes before the next use.
public CachedImage GetOrCreateStencilStorageImage(uint width, uint height)
{
if (!_stencilStorageImages.TryGetValue((width, height), out var image))
{
image = CreateStencilStorageImage(width, height);
_stencilStorageImages.Add((width, height), image);
}
return image;
}
public IEnumerable<ImageView> GetOwnedViews()
{
foreach (var cached in Views) yield return cached.View;
foreach (var storage in _stencilStorageImages.Values)
foreach (var view in storage.GetOwnedViews()) yield return view;
}
// Hashes the first and last partial tracker page of the guest data through the backing alias.
public ulong HashGuestEdges()
@ -604,6 +727,10 @@ public sealed unsafe partial class CachedImage : IDisposable
// Immediate destruction; the owner waits for GPU work before disposing.
public void Dispose()
{
foreach (var storage in _stencilStorageImages.Values) storage.Dispose();
_stencilStorageImages.Clear();
_guestSizedTwin?.Dispose();
_guestSizedTwin = null;
foreach (var cached in Views)
{
_device.Vk.DestroyImageView(_device.Device, cached.View, null);
@ -612,14 +739,15 @@ public sealed unsafe partial class CachedImage : IDisposable
Views.Clear();
if (Backing.Exists)
{
if (_pool is null || !_pool.TryReturn(_poolKey, Backing.Handle, Backing.Memory, Backing.AllocationSize))
if (_pool is null || !_pool.TryReturn(_poolKey, Backing.Handle, Backing.Memory, Backing.AllocationSize, _memoryPlacement, _memoryPool))
{
_device.Vk.DestroyImage(_device.Device, Backing.Handle, null);
_device.FreeMemory(Backing.Memory);
ReleaseMemory();
}
Backing.Handle = default;
Backing.Memory = default;
_memoryPlacement = null;
}
}
}

View File

@ -91,6 +91,7 @@ public sealed unsafe class GpuTiler : IDisposable
private Pipeline _d24ToD16;
private Pipeline _d32ToD16;
private Pipeline _swapBgra16;
private readonly Pipeline[] _narrowColorConversions = new Pipeline[4];
public GpuTiler(GpuDeviceInfo device, SubmissionScheduler scheduler, GpuRingBuffer stream)
{
@ -146,10 +147,14 @@ public sealed unsafe class GpuTiler : IDisposable
DestroyPipeline(_d24ToD16);
DestroyPipeline(_d32ToD16);
DestroyPipeline(_swapBgra16);
foreach (var pipeline in _narrowColorConversions)
DestroyPipeline(pipeline);
_pools.Dispose();
lock (_scratchGate)
{
_scratchDisposed = true;
_uploadRing?.Dispose();
_uploadRing = null;
foreach (var pooled in _scratchPool.Values)
{
foreach (var buffer in pooled)
@ -197,6 +202,27 @@ public sealed unsafe class GpuTiler : IDisposable
private readonly Dictionary<int, Stack<GpuBuffer>> _scratchPool = new();
private ulong _scratchPooledBytes;
// Scratches only return when their tick completes, so one long tick full of uploads
// (a streaming burst) holds all of them; past this budget the next draw ends the tick.
internal ulong OutstandingScratchBudget { get; set; } = 256UL << 20;
private ulong _outstandingScratch;
public bool ScratchOverBudget => Interlocked.Read(ref _outstandingScratch) > OutstandingScratchBudget;
internal ulong OutstandingScratchBytes => Interlocked.Read(ref _outstandingScratch);
internal ulong PooledScratchBytes { get { lock (_scratchGate) return _scratchPooledBytes; } }
public void ReleaseUnusedScratch()
{
lock (_scratchGate)
{
// Only completion callbacks return these buffers. Pending GPU uses
// remain outside the pool, so trimming cannot invalidate them.
foreach (var pooled in _scratchPool.Values)
while (pooled.TryPop(out var buffer)) buffer.Dispose();
_scratchPooledBytes = 0;
}
}
// The scratch stays alive through the current tick; the caller sees exactly `size` bytes.
private TilerBufferSpan AllocateScratch(ulong size)
{
@ -219,10 +245,53 @@ public sealed unsafe class GpuTiler : IDisposable
buffer ??= new GpuBuffer(_device, _scheduler, GpuBufferUsage.DeviceLocal, 0,
BufferUsageFlags.StorageBufferBit | BufferUsageFlags.TransferSrcBit | BufferUsageFlags.TransferDstBit,
shift >= 63 ? size : 1UL << shift);
_scheduler.QueueCompletionAction(() => ReturnScratch(shift, buffer));
var held = buffer.Size;
Interlocked.Add(ref _outstandingScratch, held);
_scheduler.QueueCompletionAction(() =>
{
Interlocked.Add(ref _outstandingScratch, unchecked(0UL - held));
ReturnScratch(shift, buffer);
});
return new TilerBufferSpan(buffer.Handle, 0, size);
}
// Upload scratch comes from one ring reused across uploads and ticks. Pooled scratch only
// returns when its tick completes, and a tick cannot end inside a draw, so a bindless
// draw that materializes thousands of textures would hold one scratch per texture.
// Every tile record starts with an all-commands barrier on its target range, which also
// orders it after earlier reads of that range, so reusing the ring stays safe.
private const ulong UploadRingSize = 256UL << 20;
private const ulong UploadRingAlignment = 256;
private GpuBuffer? _uploadRing;
private ulong _uploadRingOffset;
private TilerBufferSpan AllocateUploadScratch(ulong size)
{
size = (size + 3) & ~3UL;
if (size > UploadRingSize)
{
return AllocateScratch(size);
}
_uploadRing ??= new GpuBuffer(_device, _scheduler, GpuBufferUsage.DeviceLocal, 0,
BufferUsageFlags.StorageBufferBit | BufferUsageFlags.TransferSrcBit | BufferUsageFlags.TransferDstBit, UploadRingSize);
var alignment = Math.Max(UploadRingAlignment, StorageAlignment);
var offset = (_uploadRingOffset + alignment - 1) / alignment * alignment;
if (offset + size > UploadRingSize)
{
// Wrapping: all earlier ring accesses finish before the ring is written again.
offset = 0;
_scheduler.EndRendering();
var barrier = Barrier(_uploadRing.Handle, 0, UploadRingSize,
AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit);
VulkanSynchronization.PipelineBarrier(_device.Vk, new CommandBuffer(_scheduler.Current.Handle),
PipelineStageFlags.AllCommandsBit, PipelineStageFlags.AllCommandsBit, 0, 0, null, 1, &barrier, 0, null);
}
_uploadRingOffset = offset + size;
return new TilerBufferSpan(_uploadRing.Handle, offset, size);
}
private void ReturnScratch(int shift, GpuBuffer buffer)
{
lock (_scratchGate)
@ -601,9 +670,9 @@ public sealed unsafe class GpuTiler : IDisposable
var sourceBase = tiledOffset & (StorageAlignment - 1);
var dispatches = new List<TransferDispatch>();
Prepare(false, tiledCapacity, linearCapacity, transfers, sourceBase, 0, dispatches);
var scratch = AllocateScratch((linearCapacity + 3) & ~3UL);
Record(false, tiled, tiledOffset, tiledCapacity, scratch.Buffer, 0, scratch.Size, dispatches, true);
return new TilerBufferSpan(scratch.Buffer, 0, linearCapacity);
var scratch = AllocateUploadScratch(linearCapacity);
Record(false, tiled, tiledOffset, tiledCapacity, scratch.Buffer, scratch.Offset, scratch.Size, dispatches, true);
return new TilerBufferSpan(scratch.Buffer, scratch.Offset, linearCapacity);
}
public void Tile(VkBuffer linear, ulong linearOffset, ulong linearCapacity, VkBuffer tiled, ulong tiledOffset, ulong tiledCapacity, ReadOnlySpan<TileTransfer> transfers)
@ -709,10 +778,29 @@ public sealed unsafe class GpuTiler : IDisposable
pipeline = CreateComputePipeline(spirv, $"depth16 {direction} d32={useFloat32Depth}");
}
var sourceElement = direction == DepthConversionDirection.Widen ? 2UL : 4UL;
var targetElement = direction == DepthConversionDirection.Widen ? 4UL : 2UL;
var sourceActive = layout.Width * sourceElement;
var targetActive = layout.Width * targetElement;
ConvertElements(source, target, pipeline, direction == DepthConversionDirection.Widen ? 2u : 4u,
direction == DepthConversionDirection.Widen ? 4u : 2u, layout);
}
public void ConvertNarrowColor(TilerBufferSpan source, TilerBufferSpan target, uint channels, bool widen, in DepthConversionLayout layout)
{
if (channels is not (1 or 2))
throw new ArgumentOutOfRangeException(nameof(channels));
var wideOffset = widen ? target.Offset : source.Offset;
var wideRow = widen ? layout.TargetRowStride : layout.SourceRowStride;
var wideSlice = widen ? layout.TargetSliceStride : layout.SourceSliceStride;
if (((wideOffset | wideRow | wideSlice) & 3) != 0)
throw SubmissionScheduler.Fatal("The RGBA color conversion storage must be dword aligned.");
ref var pipeline = ref _narrowColorConversions[(channels - 1) * 2 + (widen ? 0 : 1)];
if (pipeline.Handle == 0)
pipeline = CreateComputePipeline(TilerShaders.CreateNarrowColorConversion(channels, widen), $"narrow color channels={channels} widen={widen}");
ConvertElements(source, target, pipeline, widen ? channels : 4u, widen ? 4u : channels, layout);
}
private void ConvertElements(TilerBufferSpan source, TilerBufferSpan target, Pipeline pipeline, uint sourceElement, uint targetElement, in DepthConversionLayout layout)
{
var sourceActive = (ulong)layout.Width * sourceElement;
var targetActive = (ulong)layout.Width * targetElement;
if (layout.Width == 0 || layout.SourceRowStride > uint.MaxValue || layout.TargetRowStride > uint.MaxValue)
{
throw SubmissionScheduler.Fatal($"The depth conversion layout is invalid: width={layout.Width} sourceRowStride={layout.SourceRowStride} targetRowStride={layout.TargetRowStride}.");
@ -845,7 +933,7 @@ public sealed unsafe class GpuTiler : IDisposable
throw SubmissionScheduler.Fatal($"The BGRA16 swap input size is invalid: size={input.Size}.");
}
var result = AllocateScratch(input.Size);
var result = AllocateUploadScratch(input.Size);
SwapBgra16(input, result, (uint)(input.Size / 8));
return result;
}

View File

@ -8,6 +8,119 @@ namespace SharpEmu.Libs.Gpu.Images;
// Garbage collection by recency and memory pressure, and the scheduled readback flush.
public sealed partial class GuestImageCache
{
// Cached images allocate their own device memory (see CachedImage), so the pressure
// includes retired images until GPU completion actually releases their memory.
private ulong CollectionMemoryBytes => TotalUsedMemory +
_imageMemoryPool.UnusedBytes + (_backingPool?.PooledBytes ?? 0);
// Allocation failure runs with the cache lock already held. Enumerate only
// on failure, so these diagnostics add no per-draw walk or logging traffic.
private string DescribeImageMemory()
{
var registered = 0;
ulong cleanBytes = 0, modifiedLinearBytes = 0, modifiedTiledBytes = 0;
_slots.ForEach((_, image) =>
{
if (!image.Registered) return;
registered++;
if (!image.IsGpuModified) cleanBytes += image.AccountedSize;
else if (image.Description.IsTiled) modifiedTiledBytes += image.AccountedSize;
else modifiedLinearBytes += image.AccountedSize;
});
return $"registered_images={registered} clean_image_bytes={cleanBytes} gpu_modified_linear_bytes={modifiedLinearBytes} " +
$"gpu_modified_tiled_bytes={modifiedTiledBytes} retired_image_bytes={Interlocked.Read(ref _retiredImageMemoryBytes)} " +
$"retained_backing_bytes={_backingPool?.PooledBytes ?? 0} collection_bytes={CollectionMemoryBytes} critical_bytes={_criticalMemoryBytes}";
}
// Allocation-time pressure is deliberately separate from the periodic sweep:
// a visibility-buffer burst can create thousands of images before the next
// frame boundary. Destruction still goes through DeleteImage's completion
// action, so no image is freed while the current tick can reference it.
private void CollectForAllocation(ulong requiredBytes)
{
FinishRetiredImagesForAllocation(requiredBytes);
// A burst can contain only newly touched images. Once the recency walk finds
// no evictable image, retrying it for every allocation in the same tick is
// pure repeated work; the next tick will make older images eligible.
if (_allocationCollectionBlocked)
{
return;
}
var current = CollectionMemoryBytes;
if (requiredBytes > _criticalMemoryBytes || current <= _criticalMemoryBytes - requiredBytes)
{
return;
}
var target = _criticalMemoryBytes - requiredBytes;
var blocked = false;
// Retired images still occupy memory until completion. Do not retire
// the entire old working set merely because that completion is pending.
while (CollectionMemoryBytes - (ulong)Interlocked.Read(ref _retiredImageMemoryBytes) > target)
{
var before = _totalUsedMemory;
Collect(_collectionTick, allowAggressive: true);
if (_totalUsedMemory == before)
{
blocked = true;
break;
}
}
_allocationCollectionBlocked = blocked;
FinishRetiredImagesForAllocation(requiredBytes);
}
// An allocation failed despite the budget. Retire every image collection allows, then let
// the retired ones be destroyed and return unused pool memory to the device.
public void ReclaimForAllocation()
{
using var held = _lock.Hold();
ReclaimAfterFailedAllocation();
}
private void ReclaimAfterFailedAllocation()
{
_allocationCollectionBlocked = false;
while (true)
{
var before = _totalUsedMemory;
Collect(_collectionTick, allowAggressive: true);
if (_totalUsedMemory == before)
break;
}
if (_scheduler.Active && !_scheduler.InsideTickCallback)
{
_lock.Exit();
try { _scheduler.Finish(); }
finally { _lock.Enter(); }
}
ReleaseUnusedMemoryCore();
}
private void FinishRetiredImagesForAllocation(ulong requiredBytes)
{
var retired = Interlocked.Read(ref _retiredImageMemoryBytes);
if (retired == 0 || !_scheduler.Active || _scheduler.InsideTickCallback)
return;
var resident = CollectionMemoryBytes;
if (!RetirementOverBudget && requiredBytes <= _criticalMemoryBytes &&
resident <= _criticalMemoryBytes - requiredBytes)
return;
// Completion can call back into the cache. Never wait while holding its
// region lock. Images touched by this preparation remain registered;
// only previously retired objects are destroyed by these callbacks.
_lock.Exit();
try { _scheduler.Finish(); }
finally { _lock.Enter(); }
ReleaseUnusedMemoryCore();
_allocationCollectionBlocked = false;
}
public void RunGarbageCollector() => RunGarbageCollector(endsFrame: true);
// Image ages are counted in frames. Collection also runs after guest submissions, so memory
@ -18,45 +131,84 @@ public sealed partial class GuestImageCache
{
using var held = _lock.Hold();
var tick = endsFrame ? _collectionTick++ : _collectionTick;
if (_totalUsedMemory < _collectionStartBytes)
_allocationCollectionBlocked = false;
if (MemoryUnderPressure)
ReleaseUnusedMemoryCore();
else
_imageMemoryPool.ReleaseRetained();
if (CollectionMemoryBytes < _collectionStartBytes)
{
return;
}
Collect(tick, allowAggressive: false);
if (_totalUsedMemory >= _criticalMemoryBytes)
// Mid-frame, the images older than this frame are the ones the rest of it samples
// again; evicting them below the critical level only recreates and uploads them.
if (endsFrame)
{
Collect(tick, allowAggressive: false);
}
if (CollectionMemoryBytes >= _criticalMemoryBytes)
{
Collect(tick, allowAggressive: true);
}
}
public void ReleaseUnusedMemory()
{
using var held = _lock.Hold();
ReleaseUnusedMemoryCore();
}
private void ReleaseUnusedMemoryCore()
{
// Release retained image objects before trimming the allocator blocks
// containing them. Neither operation touches a registered image.
_backingPool?.ReleaseRetained();
_imageMemoryPool.ReleaseRetained();
_device.Slabs.ReleaseUnused();
_tiler.ReleaseUnusedScratch();
}
private void Collect(ulong tick, bool allowAggressive)
{
var pressured = _totalUsedMemory >= _memoryPressureBytes;
var aggressive = allowAggressive && _totalUsedMemory >= _criticalMemoryBytes;
var age = aggressive ? 160UL : pressured ? 80UL : 16UL;
if (tick < age && !pressured && !aggressive)
{
return;
}
age = Math.Min(age, tick);
var pressured = CollectionMemoryBytes >= _memoryPressureBytes;
var aggressive = allowAggressive && CollectionMemoryBytes >= _criticalMemoryBytes;
// Under pressure, retain the current collection interval's bindings only.
// Recorded GPU references are protected by deferred destruction in DeleteImage;
// waiting more frames as memory fills prevents reclamation in slow titles.
var minimumAge = pressured ? 1UL : 16UL;
if (tick < minimumAge) return;
var age = minimumAge;
var deletions = aggressive ? 40 : pressured ? 20 : 10;
var candidates = new List<ResourceSlotIdentifier>(deletions);
// Deleting a depth image also deletes its stencil association, so the recency walk ends first.
_recencyQueue.ForEachItemAtOrBeforeTick(tick - age, imageIdentifier =>
{
candidates.Add(imageIdentifier);
return candidates.Count == deletions;
// A protected prefix must not hide older reclaimable images behind it.
// Stop after successful retirements below, not after visiting candidates.
return false;
});
foreach (var imageIdentifier in candidates)
{
if (deletions == 0)
if (deletions <= 0)
{
break;
}
deletions--;
if (pressured && _scheduler.Active && !_scheduler.InsideTickCallback &&
_tiler.ScratchOverBudget)
{
// Readback scratch and retired images are still resident until
// completion. Bound the batch before allocating the next copy.
// Completion publishes guest data and can re-enter this cache.
_lock.Exit();
try { _scheduler.Finish(); }
finally { _lock.Enter(); }
ReleaseUnusedMemoryCore();
}
var owner = _slots.TryGet(imageIdentifier);
if (owner == null || !owner.Registered || owner.DepthOwner.IsValid)
{
@ -65,8 +217,16 @@ public sealed partial class GuestImageCache
if (owner.IsGpuModified)
{
if (owner.IsMaybeCpuDirty)
{
if (owner.NeedsMaybeCpuHash)
continue;
_ = owner.ResolveMaybeCpuHash(owner.HashGuestEdges());
}
var safe = CanReadBack(owner);
if (safe && owner.Description.IsTiled)
// A dirty buffer overlap does not prove that it replaces the image contents.
if (!safe && owner.SafeToDownload)
{
continue;
}
@ -81,17 +241,18 @@ public sealed partial class GuestImageCache
continue;
}
owner.ClearGpuModified();
// Only the safe path published the contents first; the other one loses them.
owner.ClearGpuModified(safe ? "EvictionAfterDownload" : "EvictionWithoutDownload", 0);
}
DeleteImage(imageIdentifier);
if (_totalUsedMemory < _criticalMemoryBytes && aggressive)
deletions -= DeleteImage(imageIdentifier);
if (CollectionMemoryBytes < _criticalMemoryBytes && aggressive)
{
deletions >>= 2;
aggressive = false;
}
if (_totalUsedMemory < _memoryPressureBytes && pressured)
if (CollectionMemoryBytes < _memoryPressureBytes && pressured)
{
deletions >>= 1;
pressured = false;
@ -118,6 +279,7 @@ public sealed partial class GuestImageCache
// Test seams: thresholds, the recency order and the private state the tests inspect.
internal void SetCollectionThresholds(ulong trigger, ulong pressure, ulong critical, ulong tick)
{
_collectionThresholdsOverridden = true;
_collectionStartBytes = trigger;
_memoryPressureBytes = pressure;
_criticalMemoryBytes = critical;
@ -262,7 +424,7 @@ public sealed partial class GuestImageCache
}
metadata.Kind = SurfaceMetadataKind.HTile;
metadata.ClearMask = 0;
metadata.SetFromMask(0);
}
internal void RegisterDccMetadataForTest(ulong address, ulong size)

View File

@ -8,7 +8,7 @@ namespace SharpEmu.Libs.Gpu.Images;
public sealed partial class GuestImageCache
{
private const int LookupSlots = 256;
private const int LookupSlots = 4096;
private static readonly bool LookupMemoEnabled = Environment.GetEnvironmentVariable("SHARPEMU_IMAGE_LOOKUP_MEMO") != "0";

View File

@ -9,7 +9,7 @@ using Silk.NET.Vulkan;
namespace SharpEmu.Libs.Gpu.Images;
// Surface metadata (HTile, DCC, CMask, FMask) keyed by its guest address.
public sealed partial class GuestImageCache
public sealed unsafe partial class GuestImageCache
{
private unsafe void MaterializeDccClear(
ResourceSlotIdentifier imageIdentifier,
@ -276,17 +276,24 @@ public sealed partial class GuestImageCache
{
fillValue = 0;
using var held = _lock.Hold();
if (!_surfaceMetadata.TryGetValue(address, out var found) || found.Kind == SurfaceMetadataKind.PendingDcc || slice >= 32)
if (!_surfaceMetadata.TryGetValue(address, out var found) || found.Kind == SurfaceMetadataKind.PendingDcc)
{
return false;
}
fillValue = found.FillValue;
return (found.ClearMask & (1u << (int)slice)) != 0;
return found.IsSliceCleared(slice);
}
public bool IsMetadataCleared(ulong address, uint slice) => IsMetadataCleared(address, slice, out _);
private static SurfaceMetadata CreateMetadata(SurfaceMetadataKind kind, uint clearMask, uint fillValue = 0xffffffff, ulong fillSize = 0)
{
var metadata = new SurfaceMetadata { Kind = kind, FillValue = fillValue, FillSize = fillSize };
metadata.SetFromMask(clearMask);
return metadata;
}
public bool OverlapsDccMetadata(ulong address, ulong size)
{
using var held = _lock.Hold();
@ -311,7 +318,7 @@ public sealed partial class GuestImageCache
return false;
}
found.ClearMask = uint.MaxValue;
found.SetFromMask(uint.MaxValue);
return true;
}
@ -333,13 +340,13 @@ public sealed partial class GuestImageCache
if (!_surfaceMetadata.TryGetValue(address, out var found))
{
// The fill may precede color-target discovery; a pending entry stays invisible until then.
_surfaceMetadata.Add(address, new SurfaceMetadata { Kind = SurfaceMetadataKind.PendingDcc, ClearMask = dccClearMask, FillValue = fillValue, FillSize = size });
_surfaceMetadata.Add(address, CreateMetadata(SurfaceMetadataKind.PendingDcc, dccClearMask, fillValue, size));
return false;
}
if (found.Kind == SurfaceMetadataKind.PendingDcc)
{
found.ClearMask = dccClearMask;
found.SetFromMask(dccClearMask);
found.FillValue = fillValue;
found.FillSize = size;
return false;
@ -347,7 +354,7 @@ public sealed partial class GuestImageCache
if (found.Kind == SurfaceMetadataKind.Dcc)
{
found.ClearMask = dccClearMask;
found.SetFromMask(dccClearMask);
found.FillValue = fillValue;
found.FillSize = size;
return true;
@ -356,6 +363,64 @@ public sealed partial class GuestImageCache
return false;
}
// A shader read of a DCC-compressed surface decompresses through the metadata its descriptor
// names, so a fast-cleared surface reads back its clear color. A surface cleared and then only
// sampled never binds as a target again: the pending clear lands here, in command order before
// the read. The register code (0x20) needs the target's clear words and stays with the target bind.
public void ApplyPendingDccClear(ResourceSlotIdentifier imageIdentifier, ulong metadataAddress)
{
using var held = _lock.Hold();
var image = _slots[imageIdentifier];
if (image.Description.IsVolume || !image.Backing.Exists ||
!_surfaceMetadata.TryGetValue(metadataAddress, out var metadata) ||
metadata.Kind != SurfaceMetadataKind.Dcc ||
!ImageRequestBuilders.SupportsDccFixedClear(image.Description.PixelFormat) ||
!ImageRequestBuilders.TryFixedDccClearValue((byte)metadata.FillValue, out var value))
{
return;
}
// Only the slices the image holds; the fill also covers slices of a larger surface.
var layers = Math.Min(image.Backing.Layers, 32u);
var sliceMask = layers == 32 ? uint.MaxValue : (1u << (int)layers) - 1;
if ((metadata.ClearMask & sliceMask) == 0)
{
return;
}
var command = _scheduler.Current;
if (command.IsInvalid)
{
throw SubmissionScheduler.Fatal($"A pending DCC clear has no command buffer: address=0x{image.Description.Data.Address:X16}.");
}
command.EndRendering();
var native = new CommandBuffer(command.Handle);
for (var layer = 0u; layer < layers;)
{
if ((metadata.ClearMask & (1u << (int)layer)) == 0)
{
layer++;
continue;
}
var first = layer;
while (layer < layers && (metadata.ClearMask & (1u << (int)layer)) != 0)
{
metadata.ClearMask &= ~(1u << (int)layer);
layer++;
}
var range = new SubresourceRange(0, image.Backing.MipLevels, first, layer - first);
image.Transition(ImageLayout.TransferDstOptimal, AccessFlags.TransferWriteBit, range, native);
var vkRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, image.Backing.MipLevels, first, layer - first);
_device.Vk.CmdClearColorImage(native, image.Backing.Handle, ImageLayout.TransferDstOptimal, &value, 1, &vkRange);
}
WatchImage(imageIdentifier);
TakeGpuOwnership(image);
}
public static bool IsDccClearCode(byte code) => code is 0x00 or 0x20 or 0x40 or 0x80 or 0xc0;
public bool TryReadGuestDccClear(ulong metadataAddress, ulong sliceSize, uint slice, out ulong sliceAddress, out byte code)
@ -423,20 +488,7 @@ public sealed partial class GuestImageCache
public bool SetMetadataSlice(ulong address, uint slice, bool isClear)
{
using var held = _lock.Hold();
if (!_surfaceMetadata.TryGetValue(address, out var found) || found.Kind == SurfaceMetadataKind.PendingDcc || slice >= 32)
{
return false;
}
if (isClear)
{
found.ClearMask |= 1u << (int)slice;
}
else
{
found.ClearMask &= ~(1u << (int)slice);
}
return true;
return _surfaceMetadata.TryGetValue(address, out var found) && found.Kind != SurfaceMetadataKind.PendingDcc &&
found.SetSlice(slice, isClear);
}
}

View File

@ -236,6 +236,47 @@ public sealed partial class GuestImageCache
destination.ClearBufferModified();
}
// Two formats of one surface with the same layout are separate images over the same guest
// bytes, which the GPU writes through either of them. Before one is used, it copies the
// newest GPU contents among them, as unified guest memory would show them (the alias
// synchronization of other unified-memory emulators). Depth keeps its reinterpretation path.
private void SynchronizeAliases(ResourceSlotIdentifier imageIdentifier)
{
var image = _slots[imageIdentifier];
if (image.Description.IsDepth || ImageDescription.IsEmptyRange(image.Description.Data))
{
return;
}
var newest = ResourceSlotIdentifier.Invalid;
var newestContents = image.ContentSequence;
foreach (var aliasIdentifier in FindImagesInRange(image.Description.Data.Address, image.Description.Data.Size, pageOverlap: false))
{
if (aliasIdentifier == imageIdentifier || _slots.TryGet(aliasIdentifier) is not { } alias || !alias.Registered ||
alias.DepthOwner.IsValid || !alias.SafeToDownload || alias.ContentSequence <= newestContents ||
!IsSameLayoutAlias(image.Description, alias.Description))
{
continue;
}
newest = aliasIdentifier;
newestContents = alias.ContentSequence;
}
if (!newest.IsValid)
{
return;
}
CopyWholeImage(imageIdentifier, newest);
image.ContentSequence = newestContents;
}
private static bool IsSameLayoutAlias(in ImageDescription image, in ImageDescription alias) =>
!alias.IsDepth && alias.Data == image.Data && SameExtent(alias.Extent, image.Extent) && alias.Type == image.Type &&
alias.Samples == image.Samples && alias.BytesPerBlock == image.BytesPerBlock && alias.TileMode == image.TileMode &&
alias.Resources == image.Resources && SameMipLayout(alias, image);
private void CopyIntoMip(ResourceSlotIdentifier destinationImageIdentifier, ResourceSlotIdentifier sourceImageIdentifier, uint mip, uint layer)
{
RefreshCopySource(sourceImageIdentifier);
@ -421,7 +462,9 @@ public sealed partial class GuestImageCache
}
PrepareCopyTarget(replacement);
_blit.Reinterpret(cached, replacement);
// The conversion renders at the destination's guest extent, and a multisample
// destination never scales, so a scaled source contributes its guest-sized twin.
_blit.Reinterpret(cached.AtGuestResolution(), replacement);
TakeGpuOwnership(replacement);
}
else
@ -433,6 +476,32 @@ public sealed partial class GuestImageCache
return replacementImageIdentifier;
}
private static readonly bool DropWithoutPublishing =
Environment.GetEnvironmentVariable("SHARPEMU_IMAGE_DROP_WITHOUT_PUBLISH") == "1";
// Memory keeps its bytes when another image or a buffer write reuses part of it. An image
// whose GPU contents never reached guest memory moves them into the buffer over its range
// before it is dropped, so a later image over those bytes starts from them, not from stale
// guest memory. False when the contents cannot be copied; they are lost as before.
private bool PublishBeforeDrop(ResourceSlotIdentifier imageIdentifier)
{
var image = _slots[imageIdentifier];
if (DropWithoutPublishing || !image.IsGpuModified || image.DepthOwner.IsValid || !image.SafeToDownload)
{
return false;
}
var range = image.Description.Data;
if (_bufferCache.ObtainBufferForImageWriteBack(range.Address, range.Size) is not { } buffer ||
!TryDownloadImageToBuffer(image, buffer))
{
return false;
}
image.ClearGpuModified("PublishedToBuffer", 0);
return true;
}
private OverlapResolution ResolveOverlap(in ImageDescription requested, ImageRole role, ResourceSlotIdentifier cachedImageIdentifier, ResourceSlotIdentifier mergedImageIdentifier)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageOverlap);
@ -442,6 +511,11 @@ public sealed partial class GuestImageCache
return new OverlapResolution(mergedImageIdentifier);
}
// Resolving can allocate a replacement, and an allocation under memory pressure collects
// images not used this tick. The image being resolved is the copy source of that
// replacement; touching it keeps it out of the collection.
TouchImage(cached);
ref var cachedInfo = ref cached.Description;
var currentTick = _scheduler.CurrentTick;
var safeToDelete = currentTick - Math.Min(currentTick, cached.LastAccessTick) > TicksBeforeRemoval;
@ -475,6 +549,7 @@ public sealed partial class GuestImageCache
{
if (safeToDelete)
{
PublishBeforeDrop(cachedImageIdentifier);
ReleaseImage(cachedImageIdentifier);
}
@ -531,6 +606,7 @@ public sealed partial class GuestImageCache
if (requested.Data.Address >= cachedInfo.Data.Address && safeToDelete)
{
PublishBeforeDrop(cachedImageIdentifier);
ReleaseImage(cachedImageIdentifier);
}
@ -600,13 +676,41 @@ public sealed partial class GuestImageCache
}
var association = ResourceSlotIdentifier.Invalid;
var stale = new List<ResourceSlotIdentifier>();
foreach (var imageIdentifier in FindImagesInRange(stencil.Address, stencil.Size, pageOverlap: false))
{
var owner = _slots.TryGet(imageIdentifier);
if (owner != null && owner.Description.Data.Address == stencil.Address)
if (owner == null || owner.Description.Data.Address != stencil.Address)
{
continue;
}
// Only an image of exactly this span can stand for the plane: the stencil upload
// detiles the whole depth extent from it. A stencil proxy of another span is left
// over from an earlier surface at this address and is replaced; any other image of
// another size (a texture reading part of the plane) is a separate resource.
if (owner.Description.Data.Size == stencil.Size)
{
association = imageIdentifier;
}
else if (owner.DepthOwner.IsValid)
{
stale.Add(imageIdentifier);
}
}
// The replaced plane belongs to another depth image. What the GPU wrote to it reaches
// guest memory before the association goes, as when a depth image changes planes.
foreach (var imageIdentifier in stale)
{
var proxy = _slots[imageIdentifier];
if (proxy.IsGpuModified)
{
WriteBackStencilPlane(_slots[proxy.DepthOwner], proxy.Description.Data);
proxy.ClearGpuModified();
}
DeleteImage(imageIdentifier);
}
if (!association.IsValid)
@ -618,6 +722,7 @@ public sealed partial class GuestImageCache
}
var record = _slots[association];
TouchImage(record);
record.AssociateDepth(depthImageIdentifier);
return association;

View File

@ -72,15 +72,16 @@ public sealed partial class GuestImageCache
}
// Removes the image and its stencil associations; the slot is freed after the current tick.
private void DeleteImage(ResourceSlotIdentifier imageIdentifier)
private int DeleteImage(ResourceSlotIdentifier imageIdentifier)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageDelete);
var image = _slots.TryGet(imageIdentifier);
if (image == null || !image.Registered)
{
return;
return 0;
}
var retiredImages = 1;
if (!image.DepthOwner.IsValid)
{
var associations = new List<ResourceSlotIdentifier>();
@ -99,7 +100,7 @@ public sealed partial class GuestImageCache
associated.ClearGpuModified();
}
DeleteImage(association);
retiredImages += DeleteImage(association);
}
}
@ -108,29 +109,44 @@ public sealed partial class GuestImageCache
throw SubmissionScheduler.Fatal($"A GPU-modified image cannot be deleted before its contents are resolved: address=0x{image.Description.Data.Address:X16} size=0x{image.Description.Data.Size:X}.");
}
if (BindlessImageInvalidator is { } invalidate)
{
var views = image.GetOwnedViews().ToArray();
if (views.Length != 0) invalidate(views);
}
_scheduledReadbacks.Remove(imageIdentifier);
if (image.Description.HasMetadata)
{
_surfaceMetadata.Remove(image.Description.Metadata.Range.Address);
}
var retiredBytes = checked((long)image.AccountedSize);
RemoveFromIndex(imageIdentifier);
if (_scheduler.Active)
{
_scheduler.QueueCompletionAction(() => _slots.Erase(imageIdentifier));
Interlocked.Add(ref _retiredImageMemoryBytes, retiredBytes);
_scheduler.QueueCompletionAction(() =>
{
_slots.Erase(imageIdentifier);
Interlocked.Add(ref _retiredImageMemoryBytes, -retiredBytes);
});
}
else
{
_slots.Erase(imageIdentifier);
}
return retiredImages;
}
private void ReleaseImage(ResourceSlotIdentifier imageIdentifier)
private void ReleaseImage(ResourceSlotIdentifier imageIdentifier,
[System.Runtime.CompilerServices.CallerFilePath] string file = "",
[System.Runtime.CompilerServices.CallerLineNumber] int line = 0)
{
var image = _slots[imageIdentifier];
if (image.IsGpuModified)
{
image.ClearGpuModified();
image.ClearGpuModified(file, line);
}
DeleteImage(imageIdentifier);
@ -465,20 +481,56 @@ public sealed partial class GuestImageCache
return;
}
using var held = _lock.Hold();
ImageDropTrace.CauseAddress = address;
ImageDropTrace.CauseSize = size;
try
{
foreach (var imageIdentifier in FindImagesInRange(address, size, pageOverlap: true))
{
var image = _slots[imageIdentifier];
if (!image.Overlaps(address, size))
{
continue;
}
if (image.IsGpuModified)
{
// A partial write leaves the rest of the image's bytes in memory.
var range = image.Description.Data;
var fullyCovered = address <= range.Address && address + size >= range.Address + range.Size;
if (fullyCovered || !PublishBeforeDrop(imageIdentifier))
{
image.ClearGpuModified();
}
}
image.MarkBufferModified();
}
}
finally
{
ImageDropTrace.CauseSize = 0;
}
}
// A GPU-modified image owns its bytes; a raw binding can overlap it without writing there.
public void InvalidateMemoryCopiesFromGpu(ulong address, ulong size)
{
if (!IsValidRange(address, size))
{
return;
}
using var held = _lock.Hold();
foreach (var imageIdentifier in FindImagesInRange(address, size, pageOverlap: true))
{
var image = _slots[imageIdentifier];
if (!image.Overlaps(address, size))
if (image.DepthOwner.IsValid || image.IsGpuModified || !image.Overlaps(address, size))
{
continue;
}
if (image.IsGpuModified)
{
image.ClearGpuModified();
}
image.MarkBufferModified();
}
}
@ -506,6 +558,54 @@ public sealed partial class GuestImageCache
return new ImageRegionInfo(imagePages, imageBytes, gpuImageBytes);
}
// The GPU-modified images whose contents are not yet in a buffer and that overlap the range.
// An image the CPU or a buffer wrote over since holds superseded contents; memory is newer.
// The ranges' test without building them: resource reads ask it for every guest word
// and almost never meet such an image.
public bool HasUnsynchronizedGpuImage(ulong address, ulong size)
{
if (!IsValidRange(address, size)) return false;
using var held = _lock.Hold();
if (!ImagePageOwnerTable.TryGetPageRange(address, size, out var first, out var lastExclusive)) return false;
for (var page = first; page < lastExclusive; page++)
{
var owners = _pageOwners.Find(page);
if (owners is null) continue;
for (var ownerIndex = 0; ownerIndex < owners.Count; ownerIndex++)
{
if (IsUnsynchronizedGpuImage(_slots.TryGet(owners[ownerIndex]), address, size)) return true;
}
}
return false;
}
private static bool IsUnsynchronizedGpuImage(CachedImage? image, ulong address, ulong size) =>
image is not null && !image.DepthOwner.IsValid && image.GpuOverlaps(address, size) && !image.BufferHoldsGpuContents &&
!image.IsCpuDirty && !image.IsBufferModified;
public List<(ulong Address, ulong Size)> UnsynchronizedGpuImageRanges(ulong address, ulong size)
{
var ranges = new List<(ulong Address, ulong Size)>();
if (!IsValidRange(address, size)) return ranges;
using var held = _lock.Hold();
if (!ImagePageOwnerTable.TryGetPageRange(address, size, out var first, out var lastExclusive)) return ranges;
for (var page = first; page < lastExclusive; page++)
{
var owners = _pageOwners.Find(page);
if (owners is null) continue;
for (var ownerIndex = 0; ownerIndex < owners.Count; ownerIndex++)
{
var image = _slots.TryGet(owners[ownerIndex]);
if (IsUnsynchronizedGpuImage(image, address, size) &&
!ranges.Contains((image!.Description.Data.Address, image.Description.Data.Size)))
ranges.Add((image.Description.Data.Address, image.Description.Data.Size));
}
}
return ranges;
}
// Only byte overlap with a GPU-owned image can make a clean backing read unsafe.
public bool HasGpuModifiedImageBytes(ulong address, ulong size)
{

View File

@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.GpuMemory;
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Gpu.Vulkan;
@ -28,6 +29,7 @@ public sealed unsafe partial class GuestImageCache
public bool Tiled;
public bool SwapBgra16;
public bool Valid;
public TileElementLayout Element;
}
private sealed class ImageDownloadPlan
@ -48,6 +50,27 @@ public sealed unsafe partial class GuestImageCache
return size;
}
// A single-level linear target whose builder laid out one slice with the guest's pitch,
// which need not be aligned like a texture's. Textures leave the layout empty, and other
// shapes keep the format's linear layout.
private static MipLevelLayout[] GuestLinearLevels(in ImageDescription info, uint levels)
{
if (info.TileMode != GuestTileMode.Linear || levels != 1 || info.IsVolume || info.Resources.Layers == 0)
{
return [];
}
var slice = info.MipLayout[0];
if (slice.Size == 0 || slice.Pitch < info.Extent.Width || slice.Height < info.Extent.Height ||
(ulong)slice.Pitch * slice.Height * info.BytesPerBlock > slice.Size ||
slice.Size * info.Resources.Layers > info.Data.Size)
{
return [];
}
return [slice];
}
private static ColorTransferPlan PlanColorTransfer(CachedImage image, ImageRole role, TransferDirection direction)
{
ref readonly var info = ref image.Description;
@ -107,7 +130,23 @@ public sealed unsafe partial class GuestImageCache
plan.SwapBgra16 = info.Bgra16;
}
plan.Layout = TextureTransferLayout.Compute(format, info.Extent.Width, info.Extent.Height, info.Resources.Levels, layers, info.TileMode, info.Data.Size, allowDepthTile, volume, owner);
var guestLevels = info.FirstLevel + info.Resources.Levels;
var guestLinearLevels = GuestLinearLevels(info, guestLevels);
plan.Layout = TextureTransferLayout.Compute(format, info.Extent.Width, info.Extent.Height, guestLevels, layers, info.TileMode, info.Data.Size, allowDepthTile, volume, owner,
guestLinearLevels);
if (guestLinearLevels.Length == 0 && role == ImageRole.ColorTarget && info.TileMode == GuestTileMode.Linear && info.FirstLevel == 0 && info.Resources.Levels == 1 && !volume)
{
// Linear color targets use the target register's pitch, not texture row alignment.
var slice = checked((ulong)info.Pitch * info.Extent.Height * info.BytesPerBlock);
if (slice > info.Data.Size / layers)
throw SubmissionScheduler.Fatal("The linear color target exceeds its guest allocation.");
plan.Layout.Pitch = info.Pitch;
plan.Layout.SliceStride = info.Data.Size / layers;
plan.Layout.Mips[0] = new TransferMipLayout
{
Size = slice, RowLength = info.Pitch, ImageHeight = info.Extent.Height,
};
}
plan.Regions = plan.Layout.BuildCopies();
if (info.IsDepth)
{
@ -119,9 +158,13 @@ public sealed unsafe partial class GuestImageCache
}
}
plan.Tiled = plan.Layout.Surface.Description.TileMode != GuestTileMode.Linear;
if (!plan.Tiled && !TileGeometry.TryGetElementLayout(format, out plan.Element))
{
return plan;
}
if (plan.Tiled)
{
if (!plan.Layout.TryBuildTileTransfers(info.Data.Size, plan.Regions, info.Resources.Levels, out var tiles))
if (!plan.Layout.TryBuildTileTransfers(info.Data.Size, plan.Regions, guestLevels, out var tiles))
{
return plan;
}
@ -130,6 +173,19 @@ public sealed unsafe partial class GuestImageCache
plan.LinearSize = LinearSizeOf(plan.Tiles);
}
// Only the resident mips have host levels: guest level L lands on host level L - FirstLevel.
var firstLevel = info.FirstLevel;
if (firstLevel != 0)
{
plan.Regions.RemoveAll(region => region.ImageSubresource.MipLevel < firstLevel);
for (var index = 0; index < plan.Regions.Count; index++)
{
var region = plan.Regions[index];
region.ImageSubresource.MipLevel -= firstLevel;
plan.Regions[index] = region;
}
}
plan.Valid = true;
return plan;
}
@ -138,11 +194,14 @@ public sealed unsafe partial class GuestImageCache
SubmissionScheduler.Fatal(
$"The color-attachment upload is invalid: address=0x{info.Data.Address:X16} size=0x{info.Data.Size:X} layers={info.Resources.Layers} samples={info.Samples} backingSamples={image.Backing.Samples} compression={info.Metadata.Compression}.");
private static int _linearReadbackOverflowWarned;
private static ImageDownloadPlan PlanDownload(CachedImage image)
{
ref readonly var info = ref image.Description;
var plan = new ImageDownloadPlan { Depth = info.IsDepth };
if (info.Samples != 1 || image.Backing.Samples != 1)
// A streamed texture holds only its resident mips; the guest chain is not all there.
if (info.Samples != 1 || image.Backing.Samples != 1 || info.FirstLevel != 0)
{
return plan;
}
@ -161,6 +220,20 @@ public sealed unsafe partial class GuestImageCache
plan.Color = PlanColorTransfer(image, UploadRole(image), TransferDirection.Download);
plan.Valid = plan.Color.Valid;
// A linear layout computed for the format can need more bytes than the guest
// range holds. Copying it would write past the readback's storage.
if (plan.Valid && !plan.Color.Tiled &&
TextureTransferLayout.CopyFootprint(plan.Color.Regions, plan.Color.Element) > info.Data.Size)
{
plan.Valid = false;
if (Interlocked.Exchange(ref _linearReadbackOverflowWarned, 1) == 0)
{
Console.Error.WriteLine(
$"[GPU][WARN] A linear image readback was skipped: its copy layout needs 0x{TextureTransferLayout.CopyFootprint(plan.Color.Regions, plan.Color.Element):X} bytes " +
$"but the guest range is 0x{info.Data.Size:X}. address=0x{info.Data.Address:X16} format={info.GuestFormat} extent={info.Extent.Width}x{info.Extent.Height} levels={info.Resources.Levels}.");
}
}
return plan;
}
@ -230,11 +303,11 @@ public sealed unsafe partial class GuestImageCache
image.UploadFromBuffer(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(copies), linear.Buffer, linear.Offset, linear.Size);
}
private void UploadStencilPlane(CachedImage association, GpuBuffer source, ulong sourceOffset)
// The layout of a depth image's guest stencil plane at `plane`: one byte per texel.
private static ImageDescription StencilPlaneLayout(CachedImage depth, GuestSpan plane, string operation)
{
var destination = _slots[association.DepthOwner];
var info = destination.Description;
info.Data = association.Description.Data;
var info = depth.Description;
info.Data = plane;
info.GuestFormat = GuestPixelFormat.Bits8UInt;
info.BytesPerBlock = 1;
if (info.IsTiled)
@ -242,12 +315,89 @@ public sealed unsafe partial class GuestImageCache
info.Pitch = TileGeometry.DepthPitch(info.Extent.Width, 1);
}
if (info.Samples != 1 || destination.Backing.Samples != 1 || info.Resources.Layers == 0 || info.Data.Size % info.Resources.Layers != 0)
if (info.Samples != 1 || depth.Backing.Samples != 1 || info.Resources.Layers == 0 || info.Data.Size % info.Resources.Layers != 0)
{
throw SubmissionScheduler.Fatal(
$"The stencil upload is invalid: address=0x{info.Data.Address:X16} size=0x{info.Data.Size:X} layers={info.Resources.Layers} samples={info.Samples} backingSamples={destination.Backing.Samples}.");
$"The stencil {operation} is invalid: address=0x{info.Data.Address:X16} size=0x{info.Data.Size:X} layers={info.Resources.Layers} samples={info.Samples} backingSamples={depth.Backing.Samples}.");
}
return info;
}
// Writes the stencil aspect back to its guest plane once the current tick completes.
private void WriteBackStencilPlane(CachedImage depth, GuestSpan plane)
{
var info = StencilPlaneLayout(depth, plane, "write-back");
var download = _bufferCache.GetUtilityBuffer(GpuBufferUsage.Download);
if (!download.TryMap(plane.Size, out var offset, 4))
{
throw SubmissionScheduler.Fatal($"The reusable download ring cannot map the stencil plane: address=0x{plane.Address:X16} size=0x{plane.Size:X}.");
}
download.Commit();
var fullSliceSize = info.Data.Size / info.Resources.Layers;
var copies = DepthCopies(info, fullSliceSize, ImageAspectFlags.StencilBit);
if (info.IsTiled)
{
// Tiling fills only the plane's texels: the padding keeps its guest bytes.
if (!_backing.TryReadBacking(plane.Address, download.Mapped.Slice((int)offset, (int)plane.Size)))
{
throw SubmissionScheduler.Fatal($"The stencil plane cannot be read from guest memory: address=0x{plane.Address:X16} size=0x{plane.Size:X}.");
}
download.Flush(offset, plane.Size);
var tiles = DepthTileTransfers(info, DepthBlock(info), fullSliceSize);
_tiler.TileImage(depth, copies, download.Handle, offset, plane.Size, plane.Size, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(tiles));
}
else
{
for (var index = 0; index < copies.Length; index++)
{
copies[index].BufferOffset += offset;
}
depth.DownloadToBuffer(copies, download.Handle, offset, plane.Size);
}
var barrier = new BufferMemoryBarrier2
{
SType = StructureType.BufferMemoryBarrier2,
SrcAccessMask = AccessFlags2.MemoryWriteBit | AccessFlags2.TransferWriteBit | AccessFlags2.ShaderWriteBit,
DstAccessMask = AccessFlags2.HostReadBit,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Buffer = download.Handle,
Offset = offset,
Size = plane.Size,
};
_scheduler.EndRendering();
VulkanSynchronization.PipelineBarrier(_device.Vk, new CommandBuffer(_scheduler.Current.Handle), PipelineStageFlags.AllCommandsBit, PipelineStageFlags.HostBit, 0, 0, null, 1, &barrier, 0, null);
var backing = _backing;
// Until the tick completes guest memory holds the plane this replaces; an upload of the
// range waits for it (AwaitPublishingDownloads).
var publishing = new PublishingDownload(plane.Address, plane.Size, _scheduler.CurrentTick);
lock (_publishingDownloads) _publishingDownloads.Add(publishing);
_scheduler.QueuePriorityCompletionAction(() =>
{
try
{
download.Invalidate(offset, plane.Size);
if (!backing.TryWriteBacking(plane.Address, download.Mapped.Slice((int)offset, (int)plane.Size)))
{
throw SubmissionScheduler.Fatal($"The stencil plane could not be written to guest memory: address=0x{plane.Address:X16} size=0x{plane.Size:X}.");
}
}
finally
{
lock (_publishingDownloads) _publishingDownloads.Remove(publishing);
}
});
}
private void UploadStencilPlane(CachedImage association, GpuBuffer source, ulong sourceOffset)
{
var destination = _slots[association.DepthOwner];
var info = StencilPlaneLayout(destination, association.Description.Data, "upload");
var fullSliceSize = info.Data.Size / info.Resources.Layers;
var copies = DepthCopies(info, fullSliceSize, ImageAspectFlags.StencilBit);
var linear = new TilerBufferSpan(source.Handle, sourceOffset, source.Size - sourceOffset);
@ -260,6 +410,60 @@ public sealed unsafe partial class GuestImageCache
UploadRegions(destination, copies.ToList(), linear);
}
private static uint ExpandedColorChannels(CachedImage image) =>
image.Backing.Format is Format.R8G8B8A8Srgb or Format.R8G8B8A8Unorm
? image.Description.GuestFormat switch
{
GuestPixelFormat.Bits8Srgb => 1u,
GuestPixelFormat.Bits8_8Srgb => 2u,
_ => 0u,
}
: 0u;
private static List<BufferImageCopy> ExpandedColorRegions(List<BufferImageCopy> guestRegions, out ulong size)
{
var result = new List<BufferImageCopy>(guestRegions.Count);
size = 0;
foreach (var guest in guestRegions)
{
var wide = guest;
wide.BufferOffset = size;
var pitch = guest.BufferRowLength == 0 ? guest.ImageExtent.Width : guest.BufferRowLength;
var height = guest.BufferImageHeight == 0 ? guest.ImageExtent.Height : guest.BufferImageHeight;
size = checked(size + (ulong)pitch * height * 4);
result.Add(wide);
}
return result;
}
private void ConvertExpandedColor(TilerBufferSpan guest, TilerBufferSpan wide,
List<BufferImageCopy> guestRegions, List<BufferImageCopy> wideRegions, uint channels, bool widen)
{
for (var index = 0; index < guestRegions.Count; index++)
{
var region = guestRegions[index];
if (region.ImageExtent.Depth != 1 || region.ImageSubresource.LayerCount != 1)
throw SubmissionScheduler.Fatal("Expanded color transfers require one slice per copy region.");
var pitch = region.BufferRowLength == 0 ? region.ImageExtent.Width : region.BufferRowLength;
var height = region.BufferImageHeight == 0 ? region.ImageExtent.Height : region.BufferImageHeight;
var narrowRow = checked((ulong)pitch * channels);
var wideRow = checked((ulong)pitch * 4);
if (region.BufferOffset > guest.Size || wideRegions[index].BufferOffset > wide.Size)
throw SubmissionScheduler.Fatal("Expanded color transfer offset exceeds its buffer span.");
var narrow = new TilerBufferSpan(guest.Buffer, checked(guest.Offset + region.BufferOffset), guest.Size - region.BufferOffset);
var expanded = new TilerBufferSpan(wide.Buffer, checked(wide.Offset + wideRegions[index].BufferOffset), wide.Size - wideRegions[index].BufferOffset);
_tiler.ConvertNarrowColor(widen ? narrow : expanded, widen ? expanded : narrow, channels, widen,
new DepthConversionLayout
{
Width = region.ImageExtent.Width, Height = region.ImageExtent.Height, Layers = 1,
SourceRowStride = widen ? narrowRow : wideRow,
TargetRowStride = widen ? wideRow : narrowRow,
SourceSliceStride = checked((widen ? narrowRow : wideRow) * height),
TargetSliceStride = checked((widen ? wideRow : narrowRow) * height),
});
}
}
private void UploadFromBuffer(CachedImage image, in ImageRequest request, GpuBuffer source, ulong sourceOffset)
{
if (image.DepthOwner.IsValid)
@ -282,15 +486,31 @@ public sealed unsafe partial class GuestImageCache
var linear = new TilerBufferSpan(source.Handle, sourceOffset, info.Data.Size);
if (plan.Tiled)
{
// Detile scratch is a real device-local allocation. Evict cold
// image cache entries before requesting a large dedicated
// buffer, otherwise the cache thresholds cannot protect this
// path from a single oversized upload.
CollectForAllocation(plan.LinearSize);
linear = _tiler.Detile(source.Handle, sourceOffset, info.Data.Size, plan.LinearSize, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(plan.Tiles));
}
if (plan.SwapBgra16)
{
CollectForAllocation(linear.Size);
linear = _tiler.SwapBgra16(linear);
}
UploadRegions(image, plan.Regions, linear);
if (ExpandedColorChannels(image) is var channels && channels != 0)
{
var wideRegions = ExpandedColorRegions(plan.Regions, out var wideSize);
var wide = _tiler.GetScratchBuffer(wideSize);
ConvertExpandedColor(linear, wide, plan.Regions, wideRegions, channels, widen: true);
UploadRegions(image, wideRegions, wide);
}
else
{
UploadRegions(image, plan.Regions, linear);
}
return;
}
@ -309,6 +529,7 @@ public sealed unsafe partial class GuestImageCache
if (info.TileMode != GuestTileMode.Linear)
{
var tiles = DepthTileTransfers(info, block, fullSliceSize);
CollectForAllocation(info.Data.Size);
depthLinear = _tiler.Detile(source.Handle, sourceOffset, info.Data.Size, info.Data.Size, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(tiles));
}
@ -327,7 +548,9 @@ public sealed unsafe partial class GuestImageCache
throw SubmissionScheduler.Fatal($"The depth upload conversion size overflows: transferSlice={transferSlice} layers={layers}.");
}
var widened = _tiler.GetScratchBuffer(transferSlice * layers);
var widenedSize = transferSlice * layers;
CollectForAllocation(widenedSize);
var widened = _tiler.GetScratchBuffer(widenedSize);
_tiler.ConvertDepth16(depthLinear, widened, DepthConversionDirection.Widen, info.PixelFormat == Format.D32SfloatS8Uint, new DepthConversionLayout
{
Width = info.Extent.Width,
@ -358,6 +581,13 @@ public sealed unsafe partial class GuestImageCache
return;
}
AwaitPublishingDownloads(image.Description.Data);
if (!ReferenceEquals(_slots.TryGet(imageIdentifier), image))
{
// The image went away while the cache waited.
return;
}
var measureUpload = RenderPhaseProfile.ImageUploadDetailsEnabled;
var watchStarted = measureUpload ? System.Diagnostics.Stopwatch.GetTimestamp() : 0;
WatchImage(imageIdentifier);
@ -557,9 +787,31 @@ public sealed unsafe partial class GuestImageCache
}
var color = plan.Color;
if (ExpandedColorChannels(image) is var channels && channels != 0)
{
var wideRegions = ExpandedColorRegions(color.Regions, out var wideSize);
var wide = _tiler.GetScratchBuffer(wideSize);
image.DownloadToBuffer(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(wideRegions), wide.Buffer, 0, wide.Size);
var guest = color.Tiled
? _tiler.Detile(destination.Handle, destinationOffset, destinationSize, color.LinearSize,
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(color.Tiles))
: new TilerBufferSpan(destination.Handle, destinationOffset, destinationSize);
ConvertExpandedColor(guest, wide, color.Regions, wideRegions, channels, widen: false);
if (color.Tiled)
_tiler.Tile(guest.Buffer, guest.Offset, guest.Size, destination.Handle, destinationOffset, destinationSize,
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(color.Tiles));
return;
}
var swap = color.SwapBgra16 ? ColorChannelSwap.SwapBgra16 : ColorChannelSwap.None;
if (!color.Tiled)
{
var footprint = TextureTransferLayout.CopyFootprint(color.Regions, color.Element);
if (footprint > destinationSize)
{
throw SubmissionScheduler.Fatal(
$"A linear image download exceeds its destination: needed=0x{footprint:X} destination=0x{destinationSize:X} address=0x{image.Description.Data.Address:X16}.");
}
if (swap == ColorChannelSwap.SwapBgra16)
{
var linear = _tiler.GetScratchBuffer(destinationSize);
@ -624,17 +876,51 @@ public sealed unsafe partial class GuestImageCache
}
}
if (!selected.IsValid)
var synchronized = false;
if (selected.IsValid)
{
return false;
var target = selected;
if (_slots.TryGet(target) is { DepthOwner.IsValid: true } proxy)
{
target = proxy.DepthOwner;
}
synchronized = TryDownloadImageToBuffer(_slots[target], buffer);
}
if (_slots.TryGet(selected) is { DepthOwner.IsValid: true } proxy)
// Every other image the view covers is memory the shader can read or partly
// rewrite: its GPU-only contents move into the buffer before the bind hands
// the range to the buffer. Aliased images are left out, their order unknown.
var covered = new List<ResourceSlotIdentifier>();
foreach (var identifier in FindImagesInRange(address, size, pageOverlap: false))
{
selected = proxy.DepthOwner;
if (identifier != selected && _slots.TryGet(identifier) is { DepthOwner.IsValid: false } candidate &&
candidate.Description.Data.Address >= address &&
candidate.Description.Data.Size <= address + size - candidate.Description.Data.Address)
{
covered.Add(identifier);
}
}
var image = _slots[selected];
foreach (var identifier in covered)
{
var image = _slots[identifier];
if (image.BufferHoldsGpuContents || !CanReadBack(image) ||
(selected.IsValid && _slots[selected].Overlaps(image.Description.Data.Address, image.Description.Data.Size)) ||
covered.Any(other => other != identifier &&
_slots[other].Overlaps(image.Description.Data.Address, image.Description.Data.Size)))
{
continue;
}
synchronized |= TryDownloadImageToBuffer(image, buffer);
}
return synchronized;
}
private bool TryDownloadImageToBuffer(CachedImage image, GpuBuffer buffer)
{
ref readonly var info = ref image.Description;
if (!buffer.IsInBounds(info.Data.Address, 1))
{
@ -703,11 +989,46 @@ public sealed unsafe partial class GuestImageCache
}
}
// The kept levels must fit the bytes the buffer covers, as in a full readback.
if (!plan.Depth && !plan.Color.Tiled &&
TextureTransferLayout.CopyFootprint(plan.Color.Regions, plan.Color.Element) > copySize)
{
return false;
}
// The mip layout can end before the tile plan does (Silent Hill: a 128x128 slice planned at
// the exact end of the mip chain aborted the process). Cover the whole plan when the buffer
// holds it; otherwise keep the guest bytes instead of copying a partial image.
if (!plan.Depth && plan.Color.Tiled)
{
var tiledEnd = TiledEndOf(plan.Color.Tiles);
if (tiledEnd > copySize)
{
if (tiledEnd > available)
{
return false;
}
copySize = tiledEnd;
}
}
DownloadToBuffer(image, buffer, bufferOffset, copySize, plan);
image.MarkBufferHoldsGpuContents();
return true;
}
private static ulong TiledEndOf(List<TileTransfer> tiles)
{
var end = 0UL;
foreach (var tile in tiles)
{
end = Math.Max(end, tile.TiledOffset + tile.TiledSize);
}
return end;
}
// Publishes a GPU-owned image to guest memory after its tick completes; false when it cannot.
private bool TryDownloadToGuest(ResourceSlotIdentifier imageIdentifier)
{
@ -758,12 +1079,21 @@ public sealed unsafe partial class GuestImageCache
_scheduler.EndRendering();
VulkanSynchronization.PipelineBarrier(_device.Vk,new CommandBuffer(_scheduler.Current.Handle), PipelineStageFlags.AllCommandsBit, PipelineStageFlags.HostBit, 0, 0, null, 1, &barrier, 0, null);
var backing = _backing;
var publishing = new PublishingDownload(range.Address, range.Size, _scheduler.CurrentTick);
lock (_publishingDownloads) _publishingDownloads.Add(publishing);
_scheduler.QueuePriorityCompletionAction(() =>
{
download.Invalidate(offset, range.Size);
if (!backing.TryWriteBacking(range.Address, download.Mapped.Slice((int)offset, (int)range.Size)))
try
{
throw SubmissionScheduler.Fatal($"The image readback could not be written to guest memory: address=0x{range.Address:X16} size=0x{range.Size:X}.");
download.Invalidate(offset, range.Size);
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}.");
}
}
finally
{
lock (_publishingDownloads) _publishingDownloads.Remove(publishing);
}
});
// Completion actions wait for the priority readback before freeing spill buffers.
@ -771,6 +1101,45 @@ public sealed unsafe partial class GuestImageCache
return true;
}
private sealed record PublishingDownload(ulong Address, ulong Size, ulong Tick);
// Readbacks whose guest bytes are written only when their tick completes. Until then guest
// memory holds the contents they supersede.
private readonly List<PublishingDownload> _publishingDownloads = new();
// Waits until every readback that will publish into the range has written guest memory, so
// an upload does not read the bytes it replaces. The cache lock is released while waiting.
private void AwaitPublishingDownloads(GuestSpan range)
{
ulong tick = 0;
lock (_publishingDownloads)
{
foreach (var download in _publishingDownloads)
{
if (download.Address < range.Address + range.Size && range.Address < download.Address + download.Size)
tick = Math.Max(tick, download.Tick);
}
}
if (tick == 0)
{
return;
}
if (_scheduler.InsideTickCallback)
{
throw SubmissionScheduler.Fatal($"An image upload cannot wait for a pending readback from a completion callback: address=0x{range.Address:X16} size=0x{range.Size:X}.");
}
_lock.Exit();
try
{
_scheduler.Wait(tick);
_scheduler.WaitForPriorityOperations(tick);
}
finally { _lock.Enter(); }
}
// Clears the one image that owns exactly this range; false when no single owner or clear value fits.
public bool TryClearImageFromBuffer(ulong address, ulong size, uint packedClear)
{

View File

@ -17,6 +17,9 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
{
private const ulong TicksBeforeRemoval = 32;
private const ulong MiB = 1024 * 1024;
private const ulong GiB = 1024 * MiB;
private const ulong MinimumMemorySafetyMargin = 512 * MiB;
private const ulong NoBudgetImageCacheLimit = 4 * GiB;
private readonly GpuDeviceInfo _device;
private readonly SubmissionScheduler _scheduler;
@ -28,20 +31,30 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
private readonly IGuestBackedSpace _backing;
private readonly SlotTable<CachedImage> _slots = new();
private readonly ImageBackingPool? _backingPool;
private readonly OptimalImageMemoryPool _imageMemoryPool;
private readonly ImagePageOwnerTable _pageOwners = new();
private readonly Dictionary<Format, ResourceSlotIdentifier> _nullImages = new();
private readonly Dictionary<NullImageKey, ResourceSlotIdentifier> _nullImages = new();
private RecencyQueue<ResourceSlotIdentifier> _recencyQueue = new();
private readonly HashSet<ResourceSlotIdentifier> _scheduledReadbacks = new();
private readonly SortedDictionary<ulong, SurfaceMetadata> _surfaceMetadata = new();
private ulong _totalUsedMemory;
private long _retiredImageMemoryBytes;
private ulong _collectionStartBytes;
private ulong _memoryPressureBytes = 1536 * MiB;
private ulong _criticalMemoryBytes = 3072 * MiB;
private ulong _memoryPressureBytes;
private ulong _criticalMemoryBytes;
private ulong _collectionTick;
private bool _allocationCollectionBlocked;
private bool _collectionThresholdsOverridden;
private bool _collectionBudgetLogged;
private uint _queryEpoch;
private bool _readbackLinearImages;
private bool _disposed;
// The persistent Vulkan image heap must invalidate views before this cache
// destroys an evicted image. The callback is installed only when the device
// supports null descriptors and bindless images are active.
public Action<IReadOnlyList<ImageView>>? BindlessImageInvalidator { get; set; }
public GuestImageCache(GpuDeviceInfo device, SubmissionScheduler scheduler, PageGuard pages, GuestBufferCache bufferCache, IGuestBackedSpace backing, bool readbackLinearImages)
{
_device = device;
@ -50,12 +63,78 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
_bufferCache = bufferCache;
_backing = backing;
_readbackLinearImages = readbackLinearImages;
RefreshCollectionBudget();
_blit = new ColorToMultisampleDepthBlit(device, scheduler);
_tiler = new GpuTiler(device, scheduler, bufferCache.GetUtilityBuffer(GpuBufferUsage.Stream));
_backingPool = ImageBackingPool.Enabled ? new ImageBackingPool(device) : null;
_imageMemoryPool = new OptimalImageMemoryPool(device);
}
public ulong TotalUsedMemory => _totalUsedMemory;
// Set once, from the VRAM free when the device starts: recomputed later, the budget would
// count the cache's own images as used memory and shrink as the cache fills.
private void RefreshCollectionBudget()
{
if (_collectionThresholdsOverridden)
{
return;
}
var available = Math.Max(_device.DeviceLocalAvailableBytes, 1UL);
var imageBudget = ComputeImageCacheBudget(available, _device.HasMemoryBudget);
if (ImageCacheBudgetCap is { } cap)
{
imageBudget = Math.Min(imageBudget, cap);
}
_collectionStartBytes = Math.Max(imageBudget / 2, MiB);
_memoryPressureBytes = Math.Max(imageBudget * 3 / 5, MiB);
_criticalMemoryBytes = Math.Max(imageBudget * 7 / 10, MiB);
if (!_collectionBudgetLogged)
{
Console.Error.WriteLine(
$"[LOADER][INFO] Image cache budget source={(_device.HasMemoryBudget ? "VK_EXT_memory_budget" : "conservative heap fallback")} " +
$"heap={_device.DeviceLocalHeapBytes} budget={_device.DeviceLocalBudgetBytes} " +
$"usage={_device.DeviceLocalUsageBytes} available={_device.DeviceLocalAvailableBytes} " +
$"image_budget={imageBudget} thresholds={_collectionStartBytes}/{_memoryPressureBytes}/{_criticalMemoryBytes}");
_collectionBudgetLogged = true;
}
}
// SHARPEMU_IMAGE_CACHE_BUDGET_MB caps the image cache below its VRAM-derived budget, which
// otherwise keeps most free VRAM for cached images. A lower cap leaves room for other
// VRAM users, such as a frame-capture tool's copies of every resource.
private static readonly ulong? ImageCacheBudgetCap =
ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_IMAGE_CACHE_BUDGET_MB"), out var megabytes) && megabytes > 0
? megabytes * MiB
: null;
internal static ulong ComputeImageCacheBudget(ulong available, bool hasMemoryBudget)
{
available = Math.Max(available, 1UL);
var safetyMargin = Math.Max(available / 4, MinimumMemorySafetyMargin);
var imageBudget = available > safetyMargin
? available - safetyMargin
: Math.Max(available / 2, MiB);
if (!hasMemoryBudget)
{
// Without VK_EXT_memory_budget the physical heap size says nothing
// about allocations already held by the driver or other processes.
// Keep the historical conservative cap instead of treating all VRAM
// as available to the image cache.
imageBudget = Math.Min(imageBudget, NoBudgetImageCacheLimit);
}
return imageBudget;
}
public ulong TotalUsedMemory => _totalUsedMemory + (ulong)Interlocked.Read(ref _retiredImageMemoryBytes);
public bool MemoryUnderPressure => CollectionMemoryBytes >= _memoryPressureBytes;
public bool RetirementOverBudget => Interlocked.Read(ref _retiredImageMemoryBytes) > 256L * 1024 * 1024 ||
(Interlocked.Read(ref _retiredImageMemoryBytes) > 0 && CollectionMemoryBytes >= _criticalMemoryBytes);
public bool ScratchOverBudget => _tiler.ScratchOverBudget;
public int ImageCount => _slots.Count;
@ -98,6 +177,7 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
_disposed = true;
_slots.ForEach((_, image) => image.Dispose());
_backingPool?.Dispose();
_imageMemoryPool.ReleaseRetained();
_tiler.Dispose();
_blit.Dispose();
}
@ -116,6 +196,15 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
throw SubmissionScheduler.Fatal("An image lookup needs a command buffer that is recording.");
}
// Only the mips a streamed texture has mapped are resident. When the whole chain is
// mapped, the texture is the same image as any other view of that memory, such as the
// render target whose lower mip a downsample pass reads.
if (request.Description.FirstLevel != 0 &&
_bufferCache.IsGuestRangeMapped(request.Description.Data.Address, request.Description.Data.Size))
{
request = ImageRequestBuilders.WithWholeMipChain(request);
}
ValidateRequest(request);
if (ImageDescription.IsEmptyRange(request.Description.Data))
{
@ -240,6 +329,14 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
}
}
// A compute shader indexes a storage image in guest pixels and its dispatch grid does
// not shrink with the host resolution, so a scaled image would be written with holes
// (upscale) or redundantly (downscale). Such memory drops back to guest resolution.
if (result.IsValid && request.ShaderWrite && _slots[result].IsScaled)
{
result = ReplaceScaled(result);
}
if (!result.IsValid)
{
result = InsertImage(request.Description);
@ -296,6 +393,18 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
return image;
}
public CachedImage AcquireStencilStorageImage(ResourceSlotIdentifier imageIdentifier, uint width, uint height)
{
using var held = _lock.Hold();
var attachment = _slots[imageIdentifier];
TouchImage(attachment);
CollectForAllocation((ulong)width * height * attachment.Backing.Layers);
var before = attachment.AccountedSize;
var storage = attachment.GetOrCreateStencilStorageImage(width, height);
_totalUsedMemory += attachment.AccountedSize - before;
return storage;
}
public ImageView AcquireTextureView(ResourceSlotIdentifier imageIdentifier, in ImageRequest request)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageAcquire);
@ -308,6 +417,11 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
throw SubmissionScheduler.Fatal($"A texture must be found again before its view is acquired: address=0x{image.Description.Data.Address:X16} registered={image.Registered} proxy={image.DepthOwner.IsValid} rebind={image.Binding.NeedsRebind}.");
}
if (hasData)
{
SynchronizeAliases(imageIdentifier);
}
if (request.Role == ImageRole.StorageImage)
{
image.MarkGpuModified();
@ -359,6 +473,7 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
}
TouchImage(image);
SynchronizeAliases(imageIdentifier);
image.MarkGpuModified();
image.Uses.RenderTarget = true;
RefreshFromGuest(imageIdentifier, request);
@ -404,6 +519,7 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
}
TouchImage(image);
SynchronizeAliases(imageIdentifier);
image.MarkGpuModified();
image.Uses.DepthTarget = true;
RefreshFromGuest(imageIdentifier, request);
@ -413,13 +529,13 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
var address = request.Description.Metadata.Range.Address;
if (!_surfaceMetadata.TryGetValue(address, out var metadata))
{
_surfaceMetadata.Add(address, new SurfaceMetadata { Kind = SurfaceMetadataKind.HTile, ClearMask = image.Description.HtileClearMask });
_surfaceMetadata.Add(address, CreateMetadata(SurfaceMetadataKind.HTile, image.Description.HtileClearMask));
}
else if (metadata.Kind == SurfaceMetadataKind.PendingDcc)
{
// A pending DCC fill uses the DCC encoding; it must not become HTile state.
metadata.Kind = SurfaceMetadataKind.HTile;
metadata.ClearMask = image.Description.HtileClearMask;
metadata.SetFromMask(image.Description.HtileClearMask);
metadata.FillValue = 0xffffffff;
metadata.FillSize = 0;
}
@ -431,6 +547,11 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
if (request.Description.HasStencil)
{
if (image.Description.HasStencil && image.Description.Stencil != request.Description.Stencil)
{
ReleaseStencilPlane(imageIdentifier, image);
}
image.Description.Stencil = request.Description.Stencil;
RefreshStencilPlane(imageIdentifier, image, request.Description.Metadata.StencilCompressed);
}
@ -439,6 +560,34 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
return image.GetOrCreateView(request.View);
}
// The depth image holds one guest stencil plane at a time. Before it takes another one (a
// title that alternates stencil buffers under one depth buffer), the plane it holds goes
// back to guest memory when the GPU changed it, and its association is dropped, so a view
// of that plane reads guest memory instead of the depth image's new plane.
private void ReleaseStencilPlane(ResourceSlotIdentifier depthIdentifier, CachedImage depth)
{
var plane = depth.Description.Stencil;
var associations = new List<ResourceSlotIdentifier>();
foreach (var imageIdentifier in FindImagesInRange(plane.Address, plane.Size, pageOverlap: false))
{
if (_slots.TryGet(imageIdentifier) is { } candidate && candidate.DepthOwner == depthIdentifier)
{
associations.Add(imageIdentifier);
}
}
if (associations.Any(association => _slots[association].IsGpuModified))
{
WriteBackStencilPlane(depth, plane);
}
foreach (var association in associations)
{
_slots[association].ClearGpuModified();
DeleteImage(association);
}
}
// GFX10 packs the mips smaller than half a swizzle block into one block at the start of the chain.
// A title can write that block through a single-level view the size of the block (Unity's
// screen-space reflection blur writes mips 3 and up that way), which the cache keeps as its own
@ -582,10 +731,17 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
}
}
private readonly record struct NullImageKey(Format Format, GuestPixelFormat GuestFormat, GuestImageType Type, uint Samples, uint Layers);
private ResourceSlotIdentifier GetNullImage(in ImageRequest request)
{
var format = request.Description.PixelFormat;
if (_nullImages.TryGetValue(format, out var found))
var key = new NullImageKey(
request.Description.PixelFormat,
request.Description.GuestFormat,
request.Description.Type,
request.Description.Samples,
request.Description.Resources.Layers);
if (_nullImages.TryGetValue(key, out var found))
{
return found;
}
@ -593,23 +749,96 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
var description = ImageDescription.Create();
description.PixelFormat = request.Description.PixelFormat;
description.GuestFormat = request.Description.GuestFormat;
description.Type = GuestImageType.Color2D;
description.Type = request.Description.Type;
description.Extent = new Extent3D(1, 1, 1);
description.Resources = SubresourceCount.Single;
description.Resources = new SubresourceCount(1, key.Layers);
description.Pitch = 1;
description.BytesPerBlock = Math.Max(request.Description.BytesPerBlock, 1);
description.Samples = 1;
description.Samples = Math.Max(request.Description.Samples, 1);
description.TileMode = GuestTileMode.Linear;
description.MipLayout[0] = new MipLevelLayout { Offset = 0, Size = description.BytesPerBlock, Pitch = 1, Height = 1 };
var imageIdentifier = InsertImage(description);
_nullImages.Add(format, imageIdentifier);
_nullImages.Add(key, imageIdentifier);
return imageIdentifier;
}
// Guest ranges a compute shader has written through a storage image; see the storage
// branch of FindImage. Every image over such a range keeps its guest resolution.
private readonly List<GuestSpan> _unscaledRanges = new();
private bool IsUnscaledRange(in GuestSpan data)
{
if (ImageDescription.IsEmptyRange(data))
{
return false;
}
foreach (var range in _unscaledRanges)
{
if (GuestRangeOverlap.Bytes(range, data))
{
return true;
}
}
return false;
}
// Drops one image back to guest resolution for good; later images over the same guest
// memory are created unscaled too, so the decision cannot oscillate.
public void DemoteRenderScale(ResourceSlotIdentifier imageIdentifier)
{
using var held = _lock.Hold();
if (_slots[imageIdentifier].IsScaled)
{
ReplaceScaled(imageIdentifier);
}
}
private ResourceSlotIdentifier ReplaceScaled(ResourceSlotIdentifier cachedImageIdentifier)
{
var cached = _slots[cachedImageIdentifier];
_unscaledRanges.Add(cached.Description.Data);
var replacementImageIdentifier = InsertImage(cached.Description);
var replacement = _slots[replacementImageIdentifier];
replacement.Uses = cached.Uses;
if (cached.Binding.IsBound || cached.Binding.IsTarget)
{
cached.Binding.NeedsRebind = true;
}
CopyWholeImage(replacementImageIdentifier, cachedImageIdentifier);
ReleaseImage(cachedImageIdentifier);
return replacementImageIdentifier;
}
private ResourceSlotIdentifier InsertImage(in ImageDescription description)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageCreate);
var imageIdentifier = _slots.Insert(new CachedImage(_device, _scheduler, _backing, description, _backingPool));
var requiredBytes = (description.Data.Size + 1023) & ~1023UL;
CollectForAllocation(requiredBytes);
var allowScaling = !IsUnscaledRange(description.Data);
CachedImage image;
try
{
image = new CachedImage(_device, _scheduler, _backing, description, _backingPool, _imageMemoryPool, DescribeImageMemory, allowScaling);
}
catch (CachedImage.ImageOutOfMemoryException)
{
// The budget is an estimate: the device can still run out. Free everything that can
// go, wait for retired images to be destroyed, and try once more.
ReclaimAfterFailedAllocation();
try
{
image = new CachedImage(_device, _scheduler, _backing, description, _backingPool, _imageMemoryPool, DescribeImageMemory, allowScaling);
}
catch (CachedImage.ImageOutOfMemoryException again)
{
throw SubmissionScheduler.Fatal(again.Message);
}
}
var imageIdentifier = _slots.Insert(image);
if (!ImageDescription.IsEmptyRange(description.Data))
{
AddToIndex(imageIdentifier);

View File

@ -307,8 +307,9 @@ public static class GuestPixelFormats
[
new(GuestPixelFormat.Bits8UNorm, 1, 0, 1, true, false),
new(GuestPixelFormat.Bits8UScaled, 1, 0, 1, true, false),
new(GuestPixelFormat.Bits8SNorm, 0, 0, 1, false, false),
new(GuestPixelFormat.Bits8SNorm, 1, 0, 1, true, false),
new(GuestPixelFormat.Bits8UInt, 1, 0, 1, true, true),
new(GuestPixelFormat.Bits8SInt, 1, 0, 1, true, false, true),
new(GuestPixelFormat.Bits16UNorm, 2, 0, 2, true, false),
new(GuestPixelFormat.Bits16SNorm, 2, 0, 2, true, false),
new(GuestPixelFormat.Bits16UInt, 2, 0, 2, true, true),
@ -429,7 +430,9 @@ public static class GuestPixelFormats
private static readonly (GuestPixelFormat Guest, Format Host)[] HostFormats =
[
(GuestPixelFormat.Bits8UNorm, Format.R8Unorm),
(GuestPixelFormat.Bits8SNorm, Format.R8SNorm),
(GuestPixelFormat.Bits8UInt, Format.R8Uint),
(GuestPixelFormat.Bits8SInt, Format.R8Sint),
(GuestPixelFormat.Bits16UNorm, Format.R16Unorm),
(GuestPixelFormat.Bits16SNorm, Format.R16SNorm),
(GuestPixelFormat.Bits16UInt, Format.R16Uint),

View File

@ -27,7 +27,8 @@ public sealed unsafe class ImageBackingPool : IDisposable
ImageCreateFlags Flags,
ImageUsageFlags Usage);
private readonly record struct Entry(Image Handle, DeviceMemory Memory, ulong Size);
private readonly record struct Entry(Image Handle, DeviceMemory Memory, ulong Size,
OptimalImageMemoryAllocation? Placement, OptimalImageMemoryPool? Allocator);
private readonly GpuDeviceInfo _device;
private readonly Dictionary<Key, Stack<Entry>> _entries = new();
@ -35,6 +36,8 @@ public sealed unsafe class ImageBackingPool : IDisposable
private ulong _pooledBytes;
private bool _disposed;
internal ulong PooledBytes => _pooledBytes;
public ImageBackingPool(GpuDeviceInfo device) => _device = device;
// SHARPEMU_IMAGE_BACKING_POOL=0 destroys every retired image as before.
@ -45,23 +48,30 @@ public sealed unsafe class ImageBackingPool : IDisposable
new(create.Format, create.ImageType, create.Extent, create.ArrayLayers, create.MipLevels, create.Samples, create.Flags, create.Usage);
public bool TryTake(in Key key, out Image handle, out DeviceMemory memory, out ulong size)
=> TryTake(key, out handle, out memory, out size, out _);
internal bool TryTake(in Key key, out Image handle, out DeviceMemory memory, out ulong size,
out OptimalImageMemoryAllocation? placement)
{
if (_entries.TryGetValue(key, out var stack) && stack.TryPop(out var entry))
{
RemoveOrder(key, entry);
_pooledBytes -= entry.Size;
(handle, memory, size) = (entry.Handle, entry.Memory, entry.Size);
placement = entry.Placement;
return true;
}
handle = default;
memory = default;
size = 0;
placement = null;
return false;
}
// False when the pool is full for this key or disposed; the caller then destroys it.
public bool TryReturn(in Key key, Image handle, DeviceMemory memory, ulong size)
public bool TryReturn(in Key key, Image handle, DeviceMemory memory, ulong size,
OptimalImageMemoryAllocation? placement = null, OptimalImageMemoryPool? allocator = null)
{
if (_disposed || size > MaxPooledBytes)
{
@ -79,7 +89,7 @@ public sealed unsafe class ImageBackingPool : IDisposable
return false;
}
var entry = new Entry(handle, memory, size);
var entry = new Entry(handle, memory, size, placement, allocator);
stack.Push(entry);
_order.AddLast((key, entry));
_pooledBytes += size;
@ -125,7 +135,8 @@ public sealed unsafe class ImageBackingPool : IDisposable
private void Destroy(Entry entry)
{
_device.Vk.DestroyImage(_device.Device, entry.Handle, null);
_device.FreeMemory(entry.Memory);
if (entry.Placement is { } placement) entry.Allocator!.Free(placement);
else _device.FreeMemory(entry.Memory);
}
public void Dispose()
@ -136,6 +147,13 @@ public sealed unsafe class ImageBackingPool : IDisposable
}
_disposed = true;
ReleaseRetained();
}
// Entries arrive only after their last GPU submission completes. Trimming
// their storage under pressure does not disable subsequent backing reuse.
public void ReleaseRetained()
{
foreach (var (_, entry) in _order)
{
Destroy(entry);

View File

@ -89,6 +89,11 @@ public struct ImageDescription
public bool Bgra16;
public MipLevelLayouts MipLayout;
// The first guest mip the image holds. A streamed texture names only its resident mips
// (base level > 0); the host image starts there, while Extent and MipLayout describe the
// whole guest chain the data is laid out in.
public uint FirstLevel;
public static ImageDescription Create() => new()
{
HtileClearMask = uint.MaxValue,

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