mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 10:56:20 +08:00
94153955b0
* [ShaderCompiler.Metal] MSL translator core: dispatcher, EXEC model, compute stage
The Metal codegen backend, rebuilt on the merged backend-neutral
abstractions (replacing the pre-abstraction spike): consumes
(Gen5ShaderState, Gen5ShaderEvaluation) and emits MSL text; the renderer
owns MTLLibrary compilation, mirroring the emitters-produce-bytes rule
the Vulkan sibling documents.
The execution model mirrors Gen5SpirvTranslator: one invocation per GCN
lane (wave32 — natively the Apple simdgroup width), a typeless uint
register file with as_type<float> bitcasts, EXEC/VCC as per-lane bools
whose guest-visible mask registers materialize via simd_ballot, and the
same PC-dispatcher loop over basic blocks with the
SHARPEMU_SHADER_MAX_STEPS iteration guard and the dominating-scalar-
definition dataflow for buffer binding resolution. Unlike SPIR-V, MSL
permits shared prelude functions, so unaligned/subdword buffer access
is a range-checked device-uchar* helper instead of per-site inlining;
buffer byte lengths and the compute dispatch limit travel in one
reserved SharpEmuUniforms constant buffer (Metal has no OpArrayLength).
This first slice covers the compute entry point end to end: scalar/
vector ALU core (moves, int/float arithmetic, FMA family, shifts,
bitfield ops, min/max/med3, conversions, transcendentals with the Tau
scale on sin/cos), the full VCmp/VCmpx compare matrix writing VCC/EXEC,
the saveexec family, scalar compares and SCC-updating SOP2 forms, lane
ops (readfirstlane, mbcnt), VOP3 abs/neg/clamp/omod modifiers, scalar
memory, and raw global/buffer loads, stores, and atomics with EXEC
guards. Unsupported opcodes fail loudly with pc + mnemonic. SDWA/DPP,
typed format loads, LDS, images, and the pixel/vertex stages follow in
the next phases.
Tests live in their own self-contained project (the per-backend model:
depends only on the codegen under test): hand-assembled synthetic
fixtures drive the real decoder end to end, structural assertions and
golden-MSL comparisons run on every platform since translation is pure
text generation, and goldens regenerate via SHARPEMU_UPDATE_GOLDENS=1.
* [ShaderCompiler.Metal] Real-device runtime tests: compile + execute on the GPU
Lifts the spike's objc_msgSend LibraryImport harness (MTLDevice /
MTLCompileOptions with fast-math off, as a real Metal backend must
compile) onto the new translator contract: the guest data buffer binds
at index 0 and the SharpEmuUniforms constant buffer (dispatch limit +
buffer byte lengths) at index 1.
Three runtime tiers on hosts with a Metal device (no-op elsewhere so
Windows/Linux CI stays green): every fixture's emitted MSL must be
accepted by the OS runtime Metal compiler; the exec-store program must
produce bit-exact GPU results including the EXEC-masked store that must
not land; and a scalar countdown loop must iterate through the PC
dispatcher (s_cmp_lg_u32 + s_cbranch_scc1 across five round trips).
The loop fixture also fixes its own hand-assembly: s_sub_i32 sets SCC
to signed overflow, not result-nonzero, so the loop condition uses an
explicit compare.
* [ShaderCompiler.Metal] Phase 2: scalar/vector ALU parity with the SPIR-V translator
Ports the remaining ALU semantics from Gen5SpirvTranslator.Alu so the two
codegens cannot disagree on instruction behavior:
- Carry/borrow family (v_add_co/_ci, v_sub_co/_rev, v_subb/_rev) with the
carry mask written to the VOP3 scalar destination or VCC, ANDed with
EXEC; v_mad_u64_u32 with the 64-bit pair result and carry-out.
- Full SDWA support: byte/word source selects with sign-extension,
integer abs/neg modifiers, and destination-select merge (zero-fill,
sign-extend, preserve) into the previous register value.
- DPP16/DPP8: quad permute, row shl/shr/ror, mirror/half-mirror,
broadcast and xor controls via simd_shuffle, bound-control and
row/bank write-enable masks, fetch-inactive handling; DPP-predicated
compares merge into VCC.
- Lane ops: readfirstlane from the first EXEC-active lane via
ballot+ctz, readlane/writelane, permlane16/permlanex16.
- 64-bit scalar ops over SGPR pairs in real ulong arithmetic (logic
family, shifts, bfe/bfm with width clamping, wqm quad expansion,
cselect, mov, s_getpc) plus the B32/B64 saveexec families.
- Sopk forms decode the signed 16-bit immediate and s_cmpk compares the
destination register; SOPC scalar compares including s_bitcmp0/1.
- Conversions: f16<->f32 via as_type<half>, pkrtz with round-to-zero
mantissa truncation, pknorm via pack_float_to_{s,u}norm2x16, pk_u8
byte insert, off_f32_i4 table, rpi/flr rounding; cube id/sc/tc/ma
decision trees; v_cmp_class_f32; VCCZ/EXECZ/SCC readable as data.
Also fixes a real phase-1 bug the reference surfaced: fmamk/fmaak
sources arrive in natural order from the decoder, so all MAD/FMA forms
are fma(src0, src1, src2) — the previous operand swap computed
v1*v2+K for v_fmamk (should be v1*K+v2). The regenerated fmac golden
shows the corrected expansion, and mirroring the SPIR-V translator,
v_mul_u32_u24 is a full 32-bit multiply (only the hi/mad forms mask).
All 13 Metal tests pass including the real-GPU execution tier.
* [ShaderCompiler.Metal] Phase 3: typed format loads, LDS, and D16 subdword memory
Typed MUBUF/MTBUF loads convert through the descriptor's GFX10 unified
format at execution time, mirroring the SPIR-V translator: the prelude
bakes a 128-entry format table from the shared Gfx10UnifiedFormat
decoder (compiled shaders may be reused with new SRDs, so decoding must
stay dynamic), per-component layouts for the legacy DATA_FORMAT values
drive range-checked unaligned loads, NUM_FORMAT conversion handles
unorm/snorm (clamped at -1)/uscaled/sscaled/uint/sint/float including
f16 and the 10/11-bit unsigned mini-floats of 10_11_11 / 11_11_10, the
missing-component default is one in the format's domain, and dst_sel
swizzling comes from descriptor word 3. Format stores stay raw dword
stores like the reference.
LDS lands as 32 KB of threadgroup memory (gated on the program actually
using DS ops so occupancy is not taxed): ds_read/write b32/b64/b96/b128,
the write2/read2 pairs including st64 scaling, and ds_add_u32 as a
relaxed threadgroup atomic, with EXEC-guarded writes and the address
masked into bounds. Subdword loads/stores gain the D16/D16Hi variants
that merge into one half of the destination register (and shift the
source for high stores), classified the same way as the reference.
New GPU-executed fixture: an LDS round trip (write literal, s_barrier,
read back, store to the buffer) passes bit-exact on a real Metal device
alongside the existing tiers.
* [ShaderCompiler.Metal] Phase 4: pixel stage, images, and interpolation
The pixel entry points land with the same contract as the SPIR-V
translator (single-target and MRT forms, validated for unique guest
slots and dense host locations): the emitted fragment function takes a
stage_in struct carrying [[position]] plus the interpolated attributes
discovered from the program's V_INTERP controls, writes an output
struct with one [[color(hostLocation)]] attachment per binding typed by
its Float/Sint/Uint kind, seeds pixel-input VGPRs in SPI_PS_INPUT_ADDR
compact order from the fragment coordinate, keeps EXEC masking through
translation, and discards lanes that exit with EXEC off. Exports write
MRT targets per component under EXEC (disabled components keep their
previous value) including compressed half-pair exports; vertex-target
exports no-op until the vertex stage.
Images arrive as texture2d<float|int|uint> arguments (storage bindings
as access::read_write) with samplers alongside, classified from the
descriptor's unified format via the shared Gfx10UnifiedFormat decoder
and resolved per instruction with the same dominating-scalar-definition
scheme as buffers. The sample matrix covers implicit LOD, SampleL/Lz,
SampleB, SampleD gradients, PCF compare (manual reference<=texel,
broadcast r,r,r,1), per-lane texel offsets folded into normalized
coordinates by the selected mip extent (Metal sample offsets must be
constants), gather4 including compare and offset forms, clamped
ImageLoad/Mip, bounds-checked EXEC-guarded ImageStore, GetResinfo, and
A16 packed addresses / D16 packed data in both directions.
Graphics stages model LDS as per-invocation scratch (the SPIR-V
Private-array trick) instead of threadgroup memory. Wave ops keep the
invocation's real simdgroup in every stage — Apple fragment simdgroups
make that the same model as compute, where the SPIR-V translator
instead emulates a single logical lane; both round-trip EXEC masks
consistently.
The pixel fixture (interpolated attr0.xy plus inline constants exported
to MRT0) is golden-pinned, structurally asserted, and accepted by the
OS Metal compiler on a real device.
* [ShaderCompiler.Metal] Phase 5: vertex stage and fixed presenter shaders
The vertex entry point completes the four-entry-point contract: the
emitted vertex function takes fetched attributes as a stage_in struct
([[attribute(location)]], bound by the backend via MTLVertexDescriptor
from the reflected vertex inputs), returns [[position]] plus one
[[user(locnN)]] param output per export target 32..63 — unioned with
requiredVertexOutputCount so Metal's exact vertex-out/fragment-in
interface match succeeds, with unexported locations zero-filled —
seeds v5/v8 from [[vertex_id]]/[[instance_id]], intercepts buffer loads
the evaluator captured as fixed-function vertex inputs, and applies the
same EXEC-selected component rules to position/param exports (disabled
components default to 0,0,0,1) including compressed half pairs.
MSL vertex functions have no simdgroup attributes, so the vertex stage
models a single logical wave lane exactly like the SPIR-V translator's
graphics path: lane 0, ballot degrades to 0/1, and lane-shuffle ops
would fail Metal compilation loudly (no real guest vertex shader uses
them).
MslFixedShaders mirrors SpirvFixedShaders for the presenter surface:
the fullscreen-triangle vertex stage (position from the vertex index,
screen-space UV broadcast to every requested attribute location), the
copy/solid/attribute diagnostic fragments, and the output-free
depth-only fragment. Metal forbids "main", so each carries a stable
entry name.
The vertex fixture (constant position + one param export) is
golden-pinned and structurally asserted; it and all five fixed shaders
are accepted by the OS Metal compiler on a real device.
* [ShaderCompiler.Metal] Author static MSL blocks as template files
The prelude helpers (buffer access, ballot, tables), the format-load
conversion functions, and all five fixed presenter shaders move out of
AppendLine walls into Templates/*.msl embedded resources — real Metal
source with syntax highlighting and reviewable diffs — rendered by a
small {{placeholder}} substituter that fails loudly on any
unsubstituted token. Substitution points are deliberately few: the
stage-dependent ballot expression (vertex has no simdgroup attributes),
the baked GFX10 format table and layout cases, and the fixed shaders'
parameters. Per-instruction body emission stays programmatic, where a
template cannot express it.
Behavior-identical by construction: the golden files are untouched and
the whole suite — including the real-device execution and compile
tiers over the templated output — passes against them unchanged. The
.msl files carry no license headers (they would leak into every emitted
shader), so REUSE.toml annotates the Templates directory instead.
* [ShaderCompiler.Metal] Cover the MSL goldens in REUSE.toml
The golden files are verbatim emitter output regenerated by the test
suite; license headers inside them would either break the byte-exact
comparison or force the emitter to write SPDX text into every shader.
Annotate the Goldens directory like the Templates one.
* [ShaderCompiler.Metal] Address review: dominating-binding parity and harness binding indices
The buffer-binding fallback now mirrors the SPIR-V translator: a candidate
binding is accepted only when the descriptor registers hold the exact same
scalar definitions at the target PC as at one of the binding's own access
points (HasSameScalarDefinitions), instead of merely being non-conflicting
at the target. Resolutions are cached per PC like the reference.
The runtime test harness no longer hardcodes buffer indices 0/1 and a
20-byte uniforms blob: TryExecuteSingleThread takes the data/uniforms bind
indices, and ExecuteOrThrow derives them plus the uniforms size from the
compiled shader's GlobalMemoryBindings per the translator contract.
* [Gpu] Add the Metal guest-GPU backend: shader compilation and formats
First phase of the Metal backend behind the IGuestGpuBackend seam. The
backend compiles all three shader stages through Gen5MslTranslator and
exposes the guest render-target format table (mirroring the Vulkan table
case for case; guest format 9 maps to BGR10A2, the Metal layout matching
Vulkan's A2R10G10B10 pack). Wave64 compute is rejected with a clear error
until the two-pass emulation exists.
SHARPEMU_GPU_BACKEND=metal opts in on macOS; Vulkan stays the default on
every platform until the Metal presenter reaches parity. Presenter-side
methods fail loudly instead of dropping guest frames silently.
* [Gpu] Add the Metal presenter core: AppKit window, CAMetalLayer, CPU-frame path
The presenter opens an NSWindow hosting a CAMetalLayer and drives a manually
pumped NSApplication event loop, structured like the Vulkan presenter's
poll-and-render loop and posted onto HostMainThread the same way (AppKit
traps off the process main thread). All OS access goes through objc_msgSend
LibraryImport bindings declared locally — no windowing or binding packages on
this path, which is what keeps it NativeAOT-clean. Struct-returning ObjC
calls are avoided entirely so one calling convention works under Rosetta.
Presents CPU-produced BGRA frames and the splash through a fullscreen
triangle with a dedicated present fragment stage that flips V: with Metal's
y-up NDC the shared fullscreen triangle puts UV (0,0) at the bottom of the
screen while textures keep v=0 at the top. Frames letterbox via the viewport,
and nextDrawable paces the loop at presentation rate.
Guest-image submission now returns false (callers use their CPU-readback
fallback, which the presenter can show); draw and compute submission still
fail loudly pending later phases.
* [Gpu] Complete the guest-GPU seam: lift the AGC bypass surface onto the backend
The abstraction left AGC and VideoOut calling VulkanVideoPresenter statics
directly for guest work ordering (EnterGuestQueue, SubmitOrderedGuestAction,
SubmitOrderedGuestFlipWait, WaitForGuestWork), guest-image lifecycle (initial
data seeding, writes, fills, extents, upload tracking), the texture-content
cache probe, guest memory attachment, storage-offset alignment, perf
counters, and presenter close. With a non-Vulkan backend selected those
calls silently hit a never-started Vulkan presenter.
All of it now crosses IGuestGpuBackend: the Vulkan backend delegates to the
existing presenter statics (no behavior change), and the Metal backend
answers exactly like a presenter that is not running (sequence 0, image
unknown), which keeps callers on the same inline/CPU fallbacks they take
today. TextureContentIdentity moves to the seam types, and the bounded
AGC-to-presenter transfer pool becomes the backend-neutral GuestDataPool
(one pool by necessity: the AGC layer rents, the presenter returns).
AgcExports snapshots the backend's offset alignment once — it was a const
before and is read in per-draw loops (shader-key hashing, offset rounding).
* [Gpu] Metal guest work queue and guest images: ordered flips, writes, fills, blits
Mirrors the Vulkan presenter's execution model. AGC submissions become work
items consumed by the render loop in logical-guest-queue order: FIFO within
each guest queue, ready queues scheduled round-robin, completion tracked as
a contiguous sequence plus an out-of-order set, and producer backpressure
(count and payload caps) that consumer-enqueued follow-ups bypass to avoid
self-deadlock. The drain is budgeted (12ms, 256 items) so a backlog cannot
starve the Cocoa event pump or the present.
Guest images are Metal textures keyed by guest address, created on first
use from the registered display-buffer format tag (byte-identical encoding
to the Vulkan backend) and seeded once from pending initial data or guest
memory, since PS5 render targets alias guest memory. Coherence mirrors the
Vulkan design: DMA-style writes swap in a freshly written texture (never
mutating one an in-flight present may sample), fills clear through a
hazard-tracked render pass, and same-extent blits copy on the GPU.
Ordered flips capture the named image into an immutable version at their
exact queue position, so later work cannot change the frame a flip
selected; flip waits complete by queue position alone. Presentation picks
the newest ready queued guest frame (retiring superseded captures),
re-resolving mutable address-keyed textures at encode time so a write swap
never leaves a stale handle.
* [Gpu] Metal translated draws: pipelines, render state, bindings, write-back
Executes the seam's translated-draw surface on Metal. Offscreen, depth-only,
and storage draws are ordered guest work rendering into guest-addressed
images (published targets register as flip sources exactly like the Vulkan
backend); onscreen draws and recognized fixed-function draws ride the
presentation and render at present time into a pooled target.
Pipelines are built from GuestRenderState and cached by shader identity plus
a state hash: guest CB blend factor/op codes, write masks (bit-reversed for
MTLColorWriteMask), depth ZFUNC (bit-identical to MTLCompareFunction),
vertex attribute formats decoded from the same guest (dataFormat,
numberFormat) table the Vulkan backend uses, and RDNA 2:10:10:10 mapped to
Metal's R-low-bits 1010102 layout. Guest viewports pass through unchanged —
Metal accepts the negative heights PS5 games program, which is also how the
Vulkan backend inherits its orientation. Rect lists draw as 4-vertex strips;
Metal has no triangle fans, so those degrade to lists with a one-time warn.
Bindings follow the Gen5MslTranslator contract: global buffers at their flat
slot on both stages, SharpEmuUniforms (dispatch limit + buffer byte lengths)
after them, textures/samplers at the image slots with samplers decoded from
the raw guest descriptor words, and vertex streams at slot 26+ so they never
collide. Writable global buffers write back to guest memory before the work
item completes, preserving the CPU-visible GPU-write ordering point that
WaitForGuestWork promises. Feedback reads of a live render target sample a
blit snapshot; pooled guest data returns to GuestDataPool after upload.
Known simplifications for follow-up: textures upload a single mip level, and
the texture-content cache stays unclaimed (IsTextureContentCached=false)
until write-tracker-driven eviction exists, trading upload bandwidth for
correctness.
* [Gpu] Metal compute dispatch: the last seam gap
Guest compute dispatches are ordered guest work like draws. The uniforms
contract carries the per-axis dispatch limit (explicit thread counts when
the guest supplied them, groups x threadgroup size otherwise) so the
kernel's bounds guard clamps the overshoot threads of the last threadgroup;
threadgroup dimensions come from the translated shader, which bakes them at
compile time. Compute pipeline states cache per shader handle.
Storage images are shared live through the guest-image registry: a
dispatch's writes are visible to later draws, blits, and flips of the same
address, the address registers as a flip source at submit, and writer
sequences keep presentation waiting on exactly the work that produced the
frame. Writable buffers write back to guest memory before the work item
completes — the CPU-visible ordering point the returned sequence promises
through WaitForGuestWork.
Metal has no dispatch-base; nonzero base groups execute without the offset
behind a one-time warning until the emitted kernel grows base support.
SHARPEMU_SKIP_ALL_COMPUTE=1 skips all dispatches for hang isolation, same
as the Vulkan backend. With this the Metal backend implements the entire
IGuestGpuBackend surface — nothing throws.
* [Gpu] Address review: real bytes-per-pixel in guest-image uploads
Guest-image uploads hard-coded 4 bytes per texel, which mis-strided
Rgba16*/Rg32Float/Rgba32Float images and, in the guest-memory seed and
storage-snapshot paths, could make replaceRegion read past the managed
buffer. Texel width now comes from the pixel format, and
ReplaceTextureContents clamps the row count to what the source buffer
actually holds, so no caller can overread regardless of pitch and format.
RGBA8 initial data seeds only 4-byte-texel images; wider formats seed from
guest memory, whose layout is the image's native one. Extent byte counts
use the real texel width too.
Also restores the reference's comment on the deliberate single-item
backpressure admit: with no payload outstanding, refusing an oversized item
would wait forever since nothing is left to drain.
* [ShaderCompiler] First real-game fixes: SSendmsg no-op, scalar-state buffer declaration
Bring-up against a real title (2D engine, NGG shaders) found every draw
rejected at translation: RDNA2 NGG shaders bracket their exports with
s_sendmsg (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space, and
neither translator handled the opcode — it fell through to the scalar-ALU
guard and failed with 'missing scalar destination'. Both translators now
treat SSendmsg as a no-op alongside SNop/SWaitcnt: exports are translated
directly, so the hardware message is moot. This was a shared gap, not a
backend one; the Vulkan path would reject the same shaders.
With translation unblocked, the OS Metal compiler rejected the emitted MSL:
the body reads the per-dispatch scalar-state buffer (initial SGPRs plus
per-binding byte biases) as b{initialScalarBufferIndex}, but the kernel
signature only declared the stage's own global bindings, so the name never
existed. The signature now declares it (const device — it is only read) at
its flat slot.
The presenter also logs one line when it first presents real content,
making 'window up but nothing shown' diagnosable from the log alone.
Verified: the title goes from 100% draw misses and a black screen to
~58k translated draws per minute and 4K frames presenting.
* [Gpu] Metal presenter: NSTimer-driven render loop under [NSApp run]
Replaces the hand-pumped event loop with a real running main loop. The
presenter now creates the NSApplication, orders the CAMetalLayer-backed
window on screen, and calls [NSApp run] so Core Animation's run-loop observer
actually commits presented drawables to the window server — without a running
loop the layer never composites and the window stays black regardless of what
is rendered into the drawable.
The per-frame work moves into RenderFrame, driven by a repeating NSTimer on
the main run loop (a tiny NSObject subclass whose onFrame: is an
UnmanagedCallersOnly callback, registered via the ObjC runtime — no binding
package). CADisplayLink is the natural choice and was tried first, but its
callback never fires in this process; proven in isolation against a bare
AppKit harness where a timer fires and composites and the display link does
not — the emulator runs as x86-64 under Rosetta and the display-server-backed
link is not serviced there. nextDrawable still blocks to the display, so the
timer only needs to keep up, not pace precisely.
Also fixes window sizing (the fixed 1280x720 window was being sized from the
guest 4K display mode, which macOS clamps while the layer keeps 4K geometry —
nothing visible), makes the metal layer the view's backing layer (wantsLayer
before setLayer) with an explicit frame, and stops both the AppKit loop and
the CFRunLoop on window close.
* [Gpu] Metal draws: normalize inverted viewports, resolve flips to drawn content
Two correctness fixes surfaced bringing a real title up. Guests program
Vulkan-style negative-height viewports for y-up rendering; Metal's NDC is
already y-up and rasterizes nothing for a negative height, so the viewport is
converted to the equivalent non-inverted rect (origin shifted, height
negated) with the same on-screen mapping.
Ordered flips now prefer produced content. A flip names the display buffer's
start address, but games render into the pixel surface past the buffer's
metadata block; the resolver takes the exact-address image when GPU work
wrote it, else the nearest same-extent GPU-written image within the buffer's
plausible metadata window, else the exact-address image even if only
seeded — so a flip presents the drawn frame rather than an empty seed. A
GpuWritten flag on guest images (set by draws, writes, blits, and dispatch
storage) distinguishes produced content from a speculative guest-memory
seed.
* [ShaderCompiler] Metal translator: per-stage uniforms slot, VCC/EXEC as data, exit branches
Three correctness fixes found by running a real game against the Vulkan
backend's behavior:
- Gen5MslShader carries UniformsBufferIndex: each stage emits its
SharpEmuUniforms argument at globalBufferBase + totalGlobalBufferCount,
and stages sharing a draw can disagree, so the presenter must bind the
buffer per stage (Metal API validation: "missing Buffer binding at
index 7 for sharpemu_uniforms").
- VCC (s106:s107) and EXEC (s126:s127) live in the scalar register file
as raw 32-bit values with the per-lane bools as synced views. Programs
legally park plain data in VCC (s_buffer_load into s[106] and then
v_rcp_f32 of it); the bool-only model returned ballot masks instead.
- A branch to (or past) the program's end is an exit, matching the
SPIR-V translator: sprite alpha-kill shaders use this to skip their
tail and were rejected ("branch target outside program"), silently
dropping every draw that used them.
Also: pixel-stage ballots use the per-lane form (this thread's own bit),
since simd_ballot is undefined inside the divergent dispatcher loop, and
v_readfirstlane returns the lane's own value under that model.
* [Gpu] Metal presenter: per-stage uniforms bind, keyboard input, perf overlay, title parity
- Bind SharpEmuUniforms at each stage's declared slot (see the paired
translator change); one shared index left the vertex stage's slot
unbound, zeroing its bounds-checked loads and killing interpolants.
- Vertex attribute byte offsets move onto the vertex descriptor (buffers
bind at zero) and join the pipeline cache key, which they were silently
missing from once baked into the pipeline.
- Unresolvable draw textures log a throttled warning instead of silently
binding nothing.
- Keyboard input: an NSView subclass records keyDown/keyUp and feeds the
POSIX host-input seam with a Windows-VK to macOS-keycode map covering
the keys pad emulation polls; SHARPEMU_METAL_AUTOKEY scripts key
presses for headless runs.
- Perf overlay (F1) drawn like the Vulkan presenter: CPU-rasterized
panel uploaded to a small texture and composited with the present
pipeline, with RecordPresent/RecordDraw feeding real numbers.
- Window title gains the selected GPU suffix and refreshes when the
guest registers its application name; the layer is marked opaque so
guest alpha never reaches the compositor.
* [Audio] Quiet sceAudioOutOutput on ports disposed by host shutdown
Closing the window disposes audio ports while guest audio threads are
still draining their last buffers; every remaining output then failed
the port lookup and logged a WARN per buffer (~190/s) until process
exit. Report success for missing ports once shutdown has begun; a bad
handle during normal operation still returns INVALID_ARGUMENT.
* [ShaderCompiler] Metal graphics stages model a single logical wave lane
The pixel stage used the real thread_index_in_simdgroup with all-ones
ballots while the vertex stage modeled lane 0 with 1-bit ballots, and
VReadlaneB32 still emitted a real simd_shuffle — reading another
fragment's register. Metal leaves simdgroup ops undefined inside the
divergent while(active){switch(pc)} dispatcher (empirically they
corrupted EXEC reconstruction), so graphics stages cannot use them.
Unify vertex and pixel on the SPIR-V translator's no-subgroup fallback:
one logical wave lane (lane 0), ballots degrade to bit 0, and the
shuffle-select family (readlane, readfirstlane, DPP16/DPP8 selects,
permlane16) resolves to the lane's own value. Writelane keeps the
lane-compare against the constant lane, matching the reference
fallback. Compute is untouched: its threads map one-to-one onto real
simdgroup lanes and still shuffle for real.
Entry parameter lists now always emit trailing commas and are closed by
one helper, so stage-specific trailing parameters no longer dictate
ordering.
* [ShaderCompiler] Metal compute mirrors the SPIR-V translator's wave semantics
Compute threads map one-to-one onto real simdgroup lanes, so restore
real simd_ballot for the compute prelude (the per-lane form was a
graphics fix that swept compute along) — masks parked in VCC/EXEC now
hold each lane's actual bit, and mbcnt/cndmask/saveexec read real
masks. VReadfirstlaneB32 broadcasts from the first guest-active lane
(ballot of EXEC, then ctz), matching the SPIR-V translator's explicit
first-active-lane broadcast rather than SPIR-V BroadcastFirst's
first-host-active semantics.
The wave64 gate moves into the translator and only rejects programs
that contain wave-sensitive operations (the SPIR-V translator's
subgroup-usage predicates: shuffle family, readfirstlane, wave control,
mbcnt, or VCC/EXEC operands). A wave64 kernel without them executes
identically per-thread on 32-wide Apple simdgroups, so it now
translates instead of being dropped.
* [Gpu] Metal draw textures resolve like the Vulkan presenter
Sampling a live guest target previously required the exact current
image at the descriptor's address; anything else silently bound
nothing. Mirror the Vulkan presenter's resolution chain:
- A descriptor naming a guest depth target's write or read address
samples the depth image (identity channel select). Depth32Float
cannot blit to a color format, so the ordered snapshot round-trips
through a private staging buffer into an R32Float texture.
- Replacing a render target at the same guest address (new extent or
format) retires the old image into a bounded variant cache instead of
releasing it, and resolution scores the current image plus variants
by descriptor match — exact extent over view format over
initialization, active image breaking ties. Larger images qualify
only for tiled descriptors, matching IsCompatibleGuestImageAlias.
- The throttled unresolved-texture warning remains the detector for
anything the chain still cannot resolve.
* [ShaderCompiler] Document the Metal translator's wave-size model
Audit outcome for wave64 fidelity, no behavior change: every B64 mask
op, saveexec, and VCCZ/EXECZ test already reads and writes the full
register pair, lane indices never exceed 31 by construction, and the
GPU-executing runtime tests cover 64-bit exec save/restore. Record the
model in the class header.
* [Gpu] Plumb CB_BLEND constant color through both backends
The CONSTANT_COLOR / CONSTANT_ALPHA blend factors were mapped by both
backends but nothing ever supplied the constant, so any draw using them
blended against transparent black. Decode CB_BLEND_RED..ALPHA (the
constants existed unused) into GuestRenderState.BlendConstant, set it
dynamically per draw on both sides: Vulkan declares the blend-constants
dynamic state and calls CmdSetBlendConstants beside the viewport,
Metal calls setBlendColorRed:green:blue:alpha: in EncodeRenderState.
* [Gpu] Metal per-draw uploads bump-allocate from shared arena pages
Every draw created one MTLBuffer and one managed copy per binding
(padded guest globals, uniforms, vertex and index bytes), which
dominated allocation churn at hundreds of MB/s of garbage and held the
guest flip rate well under the display rate. Uploads now bump-allocate
256-aligned slices from 8 MiB shared-storage arena pages and bind by
offset; pages recycle once the last command buffer that referenced
them reports completion, polled at each drain so the ObjC interop
stays block-free.
Write-backs carry the slice's data pointer directly (the page outlives
the command buffer the caller waits on), the alignment-bias contract is
preserved by placing data at the bias inside its slice, and the padded
copies, per-draw buffer releases, and per-draw byte arrays are gone.
In-game on the test title: allocation rate ~795 to ~564 MB/s (the
remainder is the AGC-side per-draw guest snapshots), GC per stats
window ~30/30/17 to 16/16/15, CPU ~150 to ~131%. Metal validation
stays clean.
* [Gpu] Metal draw-texture cache: skip per-draw guest texel copies
Mirror the Vulkan presenter's identity-keyed texture cache: once the
render thread decodes a draw texture, the AGC submit thread skips the
guest-memory read/detile/copy for that identity entirely (the generic
IsTextureContentCached hook, which the Metal backend previously
hardcoded to false) and the render thread serves the cached MTLTexture
without re-uploading. GuestImageWriteTracker write-protects the source
pages; a guest CPU write evicts the entry at the next drain, and the
skip/eviction race self-heals by reading the texels directly.
Eviction differs from Vulkan in one deliberate way: dirty entries are
collected by address rather than identity, since ConsumeDirty clears
the flag on first read and several identities (same texels, different
samplers) can share one address.
Dreaming Sarah in-game on an M5 Max: guest flips 47 -> 60 (display
rate, matching Vulkan), ALLOC 564 -> 41 MB/s, gen0 GC 16 -> 5 per
second, CPU 131% -> 72%. Metal API validation clean; all 25 shader
compiler tests pass.
* [Gpu] Metal snapshot pool: recycle feedback-read textures and staging
Feedback reads created and destroyed an MTLTexture per draw (and for
depth sampling a private staging MTLBuffer too). Pool both with the
upload-arena lifecycle: acquisitions are tagged with the command buffer
that samples them at commit, and return to a bounded free list once it
reports completion. The command queue is serial, so the earlier
snapshot-blit command buffer is necessarily complete by then as well.
Dreaming Sarah renders correctly in-game; Metal API validation clean;
all 25 shader compiler tests pass. (The depth-sample path is exercised
only by inspection — no testable title samples depth yet.)
* [Gpu] Metal batched guest commands: one command buffer per drain
Draws and compute dispatches encode into a shared batch command buffer
committed once per drain instead of one commit per work item, mirroring
the Vulkan presenter's batched guest commands. Ordering inside the
batch is by encoder sequence: draw textures are now pre-resolved before
the consuming render or compute encoder opens, so feedback-read
snapshot blits encode into the batch (after the passes that rendered
the source) rather than committing ahead of them in separate command
buffers. Flips, image writes/blits, ordered actions, CPU-visible
write-backs, and every drain exit flush the batch first, preserving
the serial-queue ordering and WaitForGuestWork contracts.
Dreaming Sarah in-game on an M5 Max: CPU 72% -> 59% at a steady 60
guest flips; Metal API validation clean; all 25 shader compiler tests
pass.
* [Gpu] Metal vertex streams: share buffer slots, reject overflow gracefully
void Terrarium aborts with '-[MTLVertexAttributeDescriptorInternal
setBufferIndex:]: buffer index (31) must be < 31': every vertex
attribute got its own buffer slot from base 26, so six streams walk
past Metal's last vertex-stage buffer index (30) and the framework
assertion kills the process (reported by vladdenisov on PR #283).
Attributes of an interleaved vertex arrive from AGC as one stream
each, all reading the same guest buffer — assign slots by unique
(base address, stride, length) so those share one slot and one
upload. A draw whose unique streams still overflow the range is
skipped with a throttled warning instead of aborting. The assigned
slot keys the pipeline cache alongside the attribute offset, since
aliasing changes the baked vertex descriptor.
Dreaming Sarah renders correctly in-game at 60 flips with Metal API
validation clean; all 25 shader compiler tests pass. (void Terrarium
itself is not testable here — no decrypted copy.)
* [Gpu] Metal: drain guest work on enqueue, not only at render ticks
The Vulkan presenter's render loop is pulsed when guest work arrives
and waits at most a few milliseconds; the Metal render loop drained
guest work only inside its NSTimer tick, so every guest submit-then-
wait round-trip (release-mem labels, event writes, CPU-visible write-
backs) cost up to a full frame interval. Games that chain several such
waits per frame crawl: void Terrarium ran at 14 guest flips against
Vulkan's display rate, and input-to-effect latency suffered everywhere.
Enqueueing guest work now schedules a coalesced onGuestWork: message
onto the main run loop via performSelectorOnMainThread (block-free,
matching the NSTimer trampoline pattern), which drains the queue
immediately. A producer blocked on a full queue schedules the same
wake before waiting. void Terrarium's title menu: 14 -> 59 flips/s;
Dreaming Sarah unchanged at 60 with validation clean.
* [ShaderCompiler] Metal samplers: per-stage compact slots, not texture slots
Sampler argument indices copied the global texture slot (image binding
base + index), but Metal exposes only 16 sampler slots per stage
against 31 texture slots — a draw whose stages sample more than 16
images total emitted [[sampler(16+)]] and the MSL failed to compile
('sampler attribute parameter is out of bounds'), dropping the draw
(void Terrarium's in-game scenes).
Samplers now count sampled (non-storage) images from zero within each
stage, and Gen5MslShader carries the image-index -> sampler-slot map
plus the stage's image binding base so the presenter binds each
stage's samplers exactly where its shader declared them. A stage that
samples more than 16 images fails translation loudly. All 25 shader
compiler tests pass; goldens unchanged (single-texture fixtures keep
sampler 0).
* [Gpu] Metal draw textures: native guest formats, BC blocks, channel select
The draw-texture path assumed every texture was RGBA8: created
Rgba8Unorm, uploaded 4 bytes per pixel, and rejected anything whose
texel copy was smaller than W*H*4 as undersized. Games shipping
BC-compressed atlases (void Terrarium's entire in-game art) rendered
black, and because the rejected textures were never created they were
never content-cached — the AGC layer re-read and re-detiled megabytes
per draw (1.6 GB/s allocation, gen2 collections every second, 8 guest
flips).
Map guest texture formats to Metal case for case with the Vulkan
table (BC1-BC7 upload raw blocks — Mac-family GPUs sample them
natively — plus the 8/16/32-bit linear formats), size expectations
with the same block-aware byte math AGC uses, and honor the
descriptor's DST_SEL channel select through the texture swizzle,
mirroring Vulkan's component mapping. Unmapped codes keep the RGBA8
fallback.
void Terrarium now reaches gameplay past New Game: 49-54 guest
flips (from 8), no undersized-texture warnings, validation clean.
Dreaming Sarah unchanged at 60. All 25 shader compiler tests pass.
* [Gpu] Metal feedback reads: one snapshot per content version
Every draw sampling a live guest image blitted a fresh full-texture
snapshot, so compositing games that sample their render target on
most draws (void Terrarium: ~100 of ~105 draws per frame) pushed
gigabytes per second of blit traffic through the driver.
Guest images now carry a content version, bumped by every draw that
targets them, image write, blit destination, storage dispatch, and
guest-memory seed. The feedback-read path reuses one cached snapshot
until the version moves, so the blit happens per content change
instead of per draw. The image holds the snapshot's retain; consuming
command buffers keep replaced snapshots alive until they complete,
and retire/replace/write paths release the cache with the image.
void Terrarium in-game: 49 -> 58 guest flips at higher draw
throughput (Vulkan reference runs the same scene at 17-20 fps).
Dreaming Sarah unchanged at 60; validation clean; 25/25 tests pass.
* [Core] Pre-visit tracked texture pages before managed guest writes
A managed write into a page the guest-image write tracker has
protected dies with a fatal AccessViolation: the runtime surfaces
SIGSEGV in managed code as an exception before the resumable signal
bridge can restore access, unlike native guest stores which recover
through TryHandleWriteFault. Dead Cells crashed exactly there — an
AGC release-mem label write (CpuContext.TryWriteUInt64 on the render
thread) landing on a page the texture cache tracks.
TryWrite now calls GuestImageWriteTracker.NotifyManagedWrite up
front, unprotecting and dirtying any tracked pages in the span before
the copy — the hook existed for precisely this but had no callers.
Since this puts the tracker on every managed guest-write path, the
range snapshot now carries its overall bounds (one immutable object,
so the intersection test is always consistent with the array), letting
the common no-texture-pages case reject in a few instructions.
Dead Cells no longer crashes; Dreaming Sarah and void Terrarium
unaffected; all 25 shader compiler tests pass.
* [VideoOut] Name the active GPU backend in the macOS window title
macOS can run either backend — Vulkan through MoltenVK or native Metal
via SHARPEMU_GPU_BACKEND — so the window title now ends with the one in
use, e.g. "... · Apple M5 Max (Metal)" or "(Vulkan)". The suffix is
appended in SetSelectedGpuName (the single point both presenters call
to fold in the GPU name) and gated to macOS, so Windows and Linux
titles are unchanged. The name comes from a new BackendName on the
guest-GPU seam.
* [Gpu] Metal window: resizable, native full-screen, live drawable sizing
Add NSWindowStyleMaskResizable so the window can be dragged to any size
and set NSWindowCollectionBehaviorFullScreenPrimary so the green button
enters native full-screen instead of zooming. CAMetalLayer does not
track its drawable size to bounds on its own (even as a view's backing
layer), so the render loop matches drawableSize to the layer's current
bounds x contentsScale before each nextDrawable — a no-op on the common
unchanged tick. The present pass already aspect-fit letterboxes into the
drawable, so any window aspect ratio scales the frame without distortion.
Reading -bounds needs the x86-64 stret ABI for its 32-byte CGRect
return, added as SendStretRect. Verified live: drag-resize and
full-screen both scale correctly with Metal API validation clean.
* [ShaderCompiler] Metal wave64 compute: emulate cross-lane ops via scratch bridge
Replace the wave64 loud rejection with emulation, mirroring the SPIR-V
translator. A 64-lane guest wave is two 32-wide Apple simdgroups
co-resident in one threadgroup (Metal packs thread_index_in_threadgroup
0-31 into simdgroup 0, 32-63 into simdgroup 1), so sharpemu_lane becomes
thread_index_in_threadgroup & 63 and cross-lane ops that span the full
wave rendezvous the two halves through threadgroup scratch:
- ballot into EXEC/VCC/SGPR pairs: each half's simd_ballot is written to
its scratch slot, a threadgroup_barrier syncs, and all lanes recombine
the 64-bit mask into the low/high register pair (centralized in
EmitBallotStore, which the wave32 path shares).
- read-first-lane: broadcasts the lowest active lane's value across both
halves through a scratch slot (EmitWave64ReadFirstLane).
- mbcnt lo/hi: 64-lane thread-mask math (no cross-lane op, just correct
per-lane masks; lanes >= 32 would overflow a 32-bit shift, so split).
The barriers are safe because the guest's scalar PC keeps all 64 lanes
lockstep through the dispatcher. Scope matches the SPIR-V reference: the
scratch is indexed by half, so correct for a one-wave (64-thread)
workgroup, and readlane across halves stays a 32-wide shuffle. Wave-
agnostic wave64 kernels still translate per-thread unchanged.
Verified on the real GPU (MetalRuntimeTests): the emitted wave64 MSL
compiles, and a 64-lane dispatch runs through the bridge barriers
without deadlocking, returning the broadcast value. All 27 tests pass;
Dreaming Sarah (60/60) and void Terrarium (in-game, 58 flips) show the
shared wave32 ballot path is unaffected.
* [Gpu] Metal samplers: bind through an argument buffer, lifting the 16-slot cap
Metal exposes only 16 direct [[sampler(N)]] slots per stage, but real
shaders sample more (void Terrarium's scene shader: 17 images) and were
dropped at translation. Route samplers through a per-stage argument
buffer instead: the MSL declares a Gen5Samplers struct (one sampler per
sampled image, [[id(N)]]) taken as constant& at a buffer slot past the
stage's globals/uniforms/scalar-state, and the runtime writes each
sampler's Tier 2 gpuResourceID into an arena slice bound there. Textures
stay on direct [[texture(N)]] slots (31 is enough). One sampler per
image keeps them distinct, matching the SPIR-V/Vulkan path — no dedup,
so no wrong-sampler artifacts.
Verified argument buffers lift the limit on Apple Silicon (20-sampler
pipeline probe). Dreaming Sarah renders correctly at 60/60 with Metal
API validation clean; the void Terrarium scene shader that exceeded the
limit now compiles and runs (draws 74 -> 102/frame); all 27 shader
compiler tests pass, goldens unchanged (fixtures sample nothing).
* [Gpu] Metal: Shared storage for CPU-populated, GPU-sampled textures
The MTLTextureDescriptor default is Managed, which on unified memory needs
an explicit host->device sync we never issue after replaceRegion, so the
GPU can sample stale texels. These textures are CPU-uploaded and GPU-read,
so Shared (coherent, no sync on Apple Silicon) is the correct mode.
* [HLE] Add missing AGC/AudioOut/Pad exports blocking Unity+FMOD titles
Four exports were unresolved and hard-stalled GPU/audio/input init in
Unity titles (Lunar Lander Beyond froze there before opening VideoOut):
- sceAgcDriverSetTFRing / sceAgcDriverSetHsOffchipParam: tessellation-ring
and hull-shader off-chip config. We translate shaders directly, so these
only need to report success for init to proceed.
- sceAudioOutGetPortState: report a connected primary output at full volume.
- scePadDeviceClassGetExtendedInformation: report a standard pad (no special
peripheral) so device-class probes resolve.
Generic HLE, backend-agnostic (helps the Vulkan path equally).
* [VideoOut] RegisterBuffers2: mask the 32-bit category, accept COMPRESSED
sceVideoOutRegisterBuffers2's category is a 32-bit SceVideoOutBufferCategory
passed on the stack, but we read the full 64-bit slot — whose upper word
carries stale GNM magic (0xC0DEC0DE...) the caller never cleared. The old
check then rejected every call as INVALID_VALUE, so buffer registration
failed and no frame ever presented. Mask to 32 bits and accept both
UNCOMPRESSED (0) and COMPRESSED (1); we present either identically.
Fixes Lunar Lander Beyond reaching its window (now presents 3840x2160).
* [HLE] Stub sceAudioPropagation (3D-audio) so Astro Bot boots past its assert
Astro Bot hard-crashed right after the splash: it calls
sceAudioPropagationSystemQueryMemory during audio init, and because the
whole libSceAudioPropagation module was unimplemented the call failed, so
the game asserted (AudioPropagationContext.cpp:43) and executed int 0x41 to
abort — an unrecoverable trap that kills the process.
We don't model acoustic propagation (geometry-driven reverb/occlusion is a
quality feature, not a correctness gate). The API is placement-style, so
QueryMemory reports a buffer size and the rest succeed as no-ops: the system
lives in the caller's own buffer. All 39 entry points stubbed; the game now
boots past the assert to the presenter. Backend-agnostic HLE.
* [Kernel] pthread_cond_wait: don't spuriously EPERM an untracked mutex
pthread_cond_wait/timedwait required our host-side mutex tracking to show
the calling thread as the owner, else it returned EPERM. But libkernel's
uncontended mutex fast-path locks the mutex word in guest memory directly,
without an HLE call, so we often never observe the lock and see owner==0.
Real pthread_cond_wait requires the caller to hold the mutex but does not
verify it for normal mutexes, so EPERM here is doubly wrong: it spins the
guest (Hades hammered this millions of times/sec) and, worse, skips the
unlock — leaving the mutex held and wedging every thread that later blocks
on pthread_mutex_lock. When the mutex reads as untracked (owner==0), adopt
ownership so the unlock/wait/re-lock cycle is balanced and actually releases
it. Genuine ownership violations (owned by another thread) still error.
Eliminates the EPERM storm and converts the resulting livelock into correct
blocking; no effect on games that lock through the HLE (owner already set).
* [Core] SSE4a EXTRQ patch: read the xmm register from ModRM, not xmm2
The loader rewrites Sony's AMD-only SSE4a EXTRQ+blend idiom into SSE4.1 at
boot, because Rosetta 2 and Intel hosts raise #UD -> SIGILL on EXTRQ. The
matcher hard-coded the source register to xmm2 (ModRM 0xC2), but the compiler
allocates it freely: Dead Cells (PPSA15552) emits the identical idiom against
xmm1, so it slipped through unpatched and the game died with SIGILL right
after the first frame.
Read the register from the ModRM r/m field instead, covering xmm0-xmm7, and
require it to be consistent across the EXTRQ and the blend. The pure
match/encode logic is extracted into Sse4aExtrqBlendPatch, isolated from the
native page-patching, and unit-tested for every register plus the round trip
and rejection cases; DirectExecutionBackend just applies it.
Dead Cells now patches its xmm1 idioms and boots past the first frame.
* [Ngs2] Implement non-allocator sceNgs2SystemCreate / sceNgs2RackCreate
Dead Cells uses the non-allocator NGS2 create entry points, which were
unimplemented. sceNgs2SystemCreate came back as an unresolved import, so the
game got a garbage system handle; every downstream sceNgs2RackCreate /
sceNgs2RackGetVoiceHandle then failed, the voice handle stayed null, and once
gameplay started the audio path polled sceNgs2VoiceGetState/VoiceControl on
the null voice forever — freezing the game in-level at FLIP 0.
The non-allocator forms differ only in a caller buffer (rsi/rcx) vs an
allocator callback; the system/option and out-handle arguments sit at the
same positions, so they alias the existing WithAllocator implementations.
Resolves the NGS2 InvalidVoiceHandle storm (591+/run -> 0).
* [SaveData] Real save subsystem: ~/SharpEmu/Saves/<titleId>, events, full CRUD
Rework the SaveData HLE from a partial stub into a working subsystem:
- Storage moves to ~/SharpEmu/Saves/<titleId>/<dirName>/ (was next to the
exe under user/savedata/<userId>/<titleId>), overridable via
SHARPEMU_SAVEDATA_DIR. Metadata (title/subtitle/detail/userParam) and icon
live in <slot>/sce_sys/. Pure path + param.json logic is isolated in a new
SaveDataStorage type and unit-tested.
- Async event model: sceSaveDataGetEventResult now resolves (was an
unresolved import a save worker polled forever), returning queued completion
events or a clean 'no event' status; SyncSaveDataMemory posts a
SAVE_DATA_MEMORY_SYNC_END event. Plus GetEventInfo/SetEventInfo/register
callbacks.
- New exports: Mount/Mount2/Mount5/Umount, Delete/Delete5, GetParam/SetParam,
SaveIcon/SaveIconByPath/LoadIcon, GetAllSize/GetProgress/GetMountInfo/
IsMounted/GetSaveDataCount/GetMountedSaveDataCount/Abort, Initialize/
Initialize2/Terminate, SaveDataMemory v1 aliases.
- Mounts are tracked so Umount2 really unregisters the /savedata0 mapping
(new KernelMemoryCompatExports.UnregisterGuestPathMount) and params/icons
resolve against the live mount; DirNameSearch surfaces param.json titles.
15 new unit tests (storage layout/sanitize/metadata + mount/event/param/delete
exports); full suite 277 passing.
* [Gpu] Metal: Cmd+F1 toggles Apple's Metal Performance HUD
Plain F1 keeps the built-in CPU-rasterized perf overlay; Cmd+F1 now toggles
the system Metal Performance HUD on the CAMetalLayer, Metal backend only.
Command-modified keys never reach keyDown: (AppKit routes them through the
key-equivalent chain), so the input view gains a performKeyEquivalent:
override that claims Cmd+F1 (also silencing the system beep) and leaves
everything else to the responder chain.
Configured per Apple's 'Customizing Metal Performance HUD':
developerHUDProperties with mode=default + logging=default, plus
MTL_HUD_LOG_SHADER_ENABLED=1 passed directly in the dictionary — HUD,
per-frame statistics logging, and shader-compile logging all enabled from
one property set; mode=disabled hides it again. Guarded by a
respondsToSelector: check for older macOS.
* [Gpu] Metal: also catch Cmd+F1 in keyDown: for the HUD toggle
Function keys reach keyDown: even with Command held (AppKit only reroutes
some chords through performKeyEquivalent:), so the HUD toggle was never
firing there. Handle Cmd+F1 in both the keyDown: and performKeyEquivalent:
paths, and keep it out of MetalHostInput so it can't also flip the plain-F1
perf overlay.
* [Audio] Diagnostics: NGS2 voice-param dump + AudioOut peak-amplitude trace
Two gated traces (idiomatic SHARPEMU_LOG_* style) that pinpoint where audio
dies for NGS2-based games:
- SHARPEMU_LOG_NGS2 now walks the sceNgs2VoiceControl param list and logs each
{size,id} block header + payload bytes, confirming the real layout
(header = u32 size, u32 id; waveform-block param id=0x10000001 carries the
guest PCM pointer at +8; rate param id=0x10000005 carries the resample ratio).
- SHARPEMU_LOG_AUDIO_OUT logs sceAudioOutOutput call count and the peak
amplitude of each submitted buffer.
Finding on void Terrarium: sceAudioOutOutput is called thousands of times on
both 8ch/float32 ports, but every buffer has peak=0.0 — the guest submits pure
silence. The host path (AudioOut -> PCM convert -> CoreAudio) is proven correct;
the silence originates in Ngs2SystemRender, which zeroes the output buffer
instead of mixing voices. Restoring audio for NGS2 games requires a real NGS2
software mixer (next).
* [Audio] NGS2 software mixer: decode + mix PS-ADPCM voices
NGS2-based games were silent because sceNgs2SystemRender only zeroed the
output buffer. This adds a real software mixer:
- Ngs2VagDecoder: clean-room PS-ADPCM ("VAGp") decoder producing mono PCM16
with loop points resolved from the exact per-frame flag values (3=loop
start, 6=loop end, 1/7=one-shot end).
- Voice control now parses the SceNgs2VoiceParamHead command list, decodes the
waveform-blocks param's VAGp container once, and arms the voice.
- sceNgs2SystemRender mixes every armed voice belonging to the system into the
leading grain of the render buffer as interleaved float32 (nearest-sample
resample from the source rate to 48 kHz, additive into the front L/R pair),
which is exactly what games copy to sceAudioOutOutput.
Verified on void Terrarium: previously peak=0.0 silence at AudioOut, now real
audible SFX/music. Voices are still armed on waveform assignment rather than an
explicit kick, so pooled/duplicate voices can overlap — trigger-state handling
is a follow-up.
* [Gpu] AGC: latch GPU-wait satisfaction to the produced value
Fixes a lost-wakeup race that stalled games at a black/splash screen. When a
RELEASE_MEM packet writes a completion label, the guest frequently resets that
label to 0 immediately to reuse it next frame. Our wake path
(GpuWaitRegistry.CollectSatisfied) re-reads *current* guest memory, so if the
reset lands before the wake pass runs, the transient satisfied window is missed
and the suspended DCB waits forever — even though the producing write executed
(traced as wrote=True) and its producer is marked completed.
RELEASE_MEM producers now call GpuWaitRegistry.LatchSatisfiedByValue with the
value they actually wrote, recording satisfaction at the moment of the write for
any waiter that value satisfies. CollectSatisfied honors the latch regardless of
the current (possibly-reset) memory value. This is fail-closed: a waiter only
latches when a real producer wrote a genuinely satisfying value.
Verified: Astro Bot's DEADBEEF sentinel wait (dcb.graphics waiting on a
release_mem label) that was permanently stuck is now resolved; void Terrarium is
unregressed (runs, audio intact, no producerless stalls). Astro still has
separate unresolved blockers (producer-behind-its-own-wait cascades and
producer=none-observed labels) tracked for follow-up; WRITE_DATA/DMA_DATA
producers could latch too but are left out until there is evidence they race.
* [Gpu] AGC: retry indirect dispatches whose GPU-computed dims aren't ready
GPU-driven games (Astro Bot) build their frame on the GPU: a compute dispatch
writes the thread-group dimensions for the next DISPATCH_INDIRECT into a guest
buffer. Our AGC parser reads those dimensions on the CPU at parse time, which
runs before the producing dispatch has executed on the render thread — so it
read 0/0/0 and dropped the work (agc.dispatch_reject zero-dimension), leaving
the scene unrendered (black) and cascading into stuck cross-queue fence waits.
Instead of dropping a zero-dimension INDIRECT dispatch, suspend the DCB on its
dimensions buffer (reusing the WAIT_REG_MEM suspend/resume + GpuWaitRegistry
machinery) until the producer writes non-zero dims, then re-parse and dispatch.
A bounded per-wait deadline (150 ms) resumes-and-drops a genuinely empty
indirect dispatch so it can never stall the queue, making the change
non-regressive: worst case matches the old drop behavior after a short wait.
Direct dispatches (dims inline) are unaffected.
Result: Astro Bot goes from a permanent black screen to actually rendering
(the presenter reports "Metal VideoOut presenting 3840x2160"). void Terrarium —
which issues no indirect dispatches — is unregressed (runs, audio intact, zero
rejects). Astro then hits a separate, newly-reached downstream crash (guest
TBB worker thread_set_state failure) tracked for follow-up.
* [ShaderCompiler] Metal: keep compute shaders within read_write and LDS limits
Two Metal limits made real Astro Bot compute shaders fail to compile/create,
which dropped their dispatches and cascaded into stuck GPU waits (splash hang):
- Textures with access::read_write are capped at 8 per function, but every
storage image was declared read_write. Track each binding's actual access
during body emission (ImageLoad->read, ImageStore->write, ImageAtomic and a
load+store sharing one binding->read_write) and emit the minimal qualifier,
so read-only/write-only storage images no longer count against the cap.
- Threadgroup memory is capped at 32 KB. A shader requesting the full 32 KB of
LDS plus the separate 3-dword wave64 bridge overflowed by 12 bytes. Alias the
bridge into the top of the LDS allocation when both are used, mirroring the
SPIR-V translator's _waveScratchInLds path, keeping the total at 32 KB.
Verified on Astro Bot: "read_write access exceeds maximum (8)" and "Threadgroup
memory size (32780) exceeds maximum (32768)" are both gone; the 27 MSL golden
tests still pass (no golden used a storage image or LDS+wave64 shader).
* [Gpu] AGC: break cross-queue GPU wait deadlocks with a produced-value fallback
Real GPU-driven titles (Astro Bot) drive graphics and compute queues with
mutually dependent WAIT_REG_MEM fences: graphics waits on a compute EOP label,
compute waits on a graphics label. On hardware the two queues run concurrently
so the cycle resolves, but our submission parser is serial, so a label that gets
written -> reset for reuse -> re-waited across queues can wedge forever. The
latch fix helped the write-then-consume race but the cycle re-formed each frame
(graphics stuck at 3 flips, compute queues permanently suspended).
Producers now record the last value they wrote to each label
(GpuWaitRegistry.RecordProduced). A new deadlock breaker
(CollectDeadlockBroken, run from DrainResumableDcbs) releases any waiter stuck
past a 500 ms deadline whose condition is satisfied by that recorded value —
i.e. a real producer signalled the label at least once, guest memory has just
since been reset. It never fabricates a value, and the long deadline means
legitimate fences (which complete within a frame) never trip it.
Verified: Astro Bot goes from 3 flips (wedged on splash) to 25, loads its
splash level ("LevelDocument Loaded: ps_logo") and produces 2432x1368 frame
content. void Terrarium is untouched — 0 deadlock-break events, 1020 flips,
audio intact (its waits resolve far under the deadline). Tunable via
SHARPEMU_GPU_DEADLOCK_BREAK_MS.
* [Cpu] SSE4a EXTRQ patch: cover any blend destination register, not just xmm0
The EXTRQ+VPBLENDD idiom rewrite only matched when the blend destination was
xmm0 (VEX.vvvv byte 0x79). Sony's toolchain allocates that register freely: a
Dead Cells build emits `EXTRQ xmm4,0x28,0x00 ; VPBLENDD xmm3,xmm3,xmm4,2`
(VEX byte 0x61, dest xmm3). That instance stayed unpatched, so the AMD-only
EXTRQ reached Rosetta 2 and raised #UD -> SIGILL (0xC000001D) the moment the
game entered gameplay (loading level PrisonStart).
Read the destination register from VPBLENDD's VEX.vvvv / ModRM.reg as well as
the source from the ModRM r/m field, and emit PINSRD into that destination. Both
are still constrained to xmm0-xmm7 by the fixed VEX prefix. Match/encode stay in
the unit-tested helper.
Verified: Dead Cells now patches 14 EXTRQ blends (previously 0 on this build),
no SIGILL, and reaches PrisonStart. 15 patch unit tests pass, including the exact
xmm3/xmm4 bytes that faulted.
* [HLE] Implement Dead Cells' remaining unresolved imports
Three imports Dead Cells calls during boot/level-load were unresolved, so they
returned no defined value:
- scePadGetHandle (libScePad): returns the primary pad's handle (polled every
frame for input); same validation as scePadOpen.
- sceNpEntitlementAccessGetAddcontEntitlementInfo (libSceNpEntitlementAccess):
singular add-on-content lookup; we own no DLC, so zero the info out and return
OK, matching the existing list variant.
- sceNpUniversalDataSystemEventPropertyArraySetString: telemetry setter, dropped.
Dead Cells now boots with zero unresolved imports. (It still stalls later at
PrisonStart level-load — a separate GPU/threading issue, not an import gap.)
* [Kernel] Fix pthread mutex deadlock: trylock semantics + stale-waiter clog
Hades hard-froze during boot on a "free but reserved" mutex: owner==0 yet
every acquisition failed forever. Two independent defects in the pthread
mutex compat layer combined to wedge it, both traced from real runs.
1. trylock incorrectly required an empty wait queue. POSIX
pthread_mutex_trylock succeeds whenever the mutex is not currently held
and owes no fairness to queued waiters; gating it on Waiters.Count==0 made
a spin-on-trylock loop (which the game runs) spin forever against a single
undrainable waiter even though owner==0. trylock now acquires on owner==0;
the blocking lock still honours FIFO so genuine blocked waiters are not
starved by a barging locker.
2. cond_timedwait timeouts leaked mutex re-acquire waiters. A cond wait's
timeout enqueues a re-acquire waiter whose wake hand-off can be lost,
orphaning it in the mutex queue. Multiple orphans from one thread piled at
the FIFO head; the unlock hand-off then woke a dead wake-key and the mutex
never drained. A thread can hold at most one pending acquisition on a
mutex, so EnqueueMutexWaiterLocked now prunes any prior waiter for the same
thread before enqueueing — collapsing the leaked pile.
Verified: Hades advances from a hard freeze at ~4.9M HLE calls (main and a
worker both blocked on the same free mutex) to 24.9M calls with no stall,
reaching the save-data/user-service boot stage. void tRrLM behaves
identically with and without the change (no regression); all 268 Libs tests
pass.
15259 lines
620 KiB
C#
15259 lines
620 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using Silk.NET.Core;
|
|
using Silk.NET.Core.Native;
|
|
using Silk.NET.Maths;
|
|
using SharpEmu.HLE;
|
|
using SharpEmu.Libs.Agc;
|
|
using SharpEmu.Libs.Bink;
|
|
using Silk.NET.Input;
|
|
using SharpEmu.Libs.Gpu;
|
|
using SharpEmu.ShaderCompiler;
|
|
using SharpEmu.ShaderCompiler.Vulkan;
|
|
using Silk.NET.Vulkan;
|
|
using Silk.NET.Vulkan.Extensions.KHR;
|
|
using Silk.NET.Vulkan.Extensions.EXT;
|
|
using Silk.NET.Windowing;
|
|
using System.Buffers.Binary;
|
|
using System.Diagnostics;
|
|
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using VkBuffer = Silk.NET.Vulkan.Buffer;
|
|
using VkSemaphore = Silk.NET.Vulkan.Semaphore;
|
|
|
|
namespace SharpEmu.Libs.VideoOut;
|
|
|
|
internal readonly record struct VulkanRenderTargetFormat(
|
|
Format Format,
|
|
Gen5PixelOutputKind OutputKind)
|
|
{
|
|
public bool IsInteger => OutputKind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
|
|
}
|
|
|
|
internal sealed record VulkanTranslatedGuestDraw(
|
|
byte[] VertexSpirv,
|
|
byte[] PixelSpirv,
|
|
IReadOnlyList<GuestDrawTexture> Textures,
|
|
IReadOnlyList<GuestMemoryBuffer> GlobalMemoryBuffers,
|
|
IReadOnlyList<GuestVertexBuffer> VertexBuffers,
|
|
uint AttributeCount,
|
|
uint VertexCount,
|
|
uint InstanceCount,
|
|
uint PrimitiveType,
|
|
GuestIndexBuffer? IndexBuffer,
|
|
GuestRenderState RenderState);
|
|
|
|
internal sealed record VulkanOffscreenGuestDraw(
|
|
VulkanTranslatedGuestDraw Draw,
|
|
IReadOnlyList<GuestRenderTarget> Targets,
|
|
GuestDepthTarget? DepthTarget,
|
|
bool PublishTarget,
|
|
ulong ShaderAddress);
|
|
|
|
internal sealed record VulkanComputeGuestDispatch(
|
|
ulong ShaderAddress,
|
|
byte[] ComputeSpirv,
|
|
IReadOnlyList<GuestDrawTexture> Textures,
|
|
IReadOnlyList<GuestMemoryBuffer> GlobalMemoryBuffers,
|
|
uint GroupCountX,
|
|
uint GroupCountY,
|
|
uint GroupCountZ,
|
|
uint BaseGroupX,
|
|
uint BaseGroupY,
|
|
uint BaseGroupZ,
|
|
uint LocalSizeX,
|
|
uint LocalSizeY,
|
|
uint LocalSizeZ,
|
|
bool IsIndirect,
|
|
bool WritesGlobalMemory,
|
|
uint ThreadCountX = uint.MaxValue,
|
|
uint ThreadCountY = uint.MaxValue,
|
|
uint ThreadCountZ = uint.MaxValue);
|
|
|
|
internal sealed record VulkanOrderedGuestAction(
|
|
Action Action,
|
|
string DebugName);
|
|
|
|
internal sealed record VulkanOrderedGuestFlip(
|
|
long Version,
|
|
int VideoOutHandle,
|
|
int DisplayBufferIndex,
|
|
ulong Address,
|
|
uint Width,
|
|
uint Height,
|
|
uint PitchInPixel);
|
|
|
|
internal sealed record VulkanOrderedGuestFlipWait(
|
|
long Version,
|
|
int VideoOutHandle,
|
|
int DisplayBufferIndex);
|
|
|
|
internal readonly record struct VulkanGuestQueueIdentity(
|
|
string Name,
|
|
ulong SubmissionId)
|
|
{
|
|
public static VulkanGuestQueueIdentity Default { get; } = new("host.default", 0);
|
|
}
|
|
|
|
internal static unsafe class VulkanVideoPresenter
|
|
{
|
|
// Standalone CLI launches use a desktop-sized surface. The embedded GUI
|
|
// always takes its dimensions from the native child control instead.
|
|
private const uint DefaultWindowWidth = 1920;
|
|
private const uint DefaultWindowHeight = 1080;
|
|
|
|
internal enum StorageImageComponentKind
|
|
{
|
|
Float,
|
|
Sint,
|
|
Uint,
|
|
}
|
|
|
|
internal readonly record struct SpirvStorageImageContract(
|
|
SpirvImageFormat Format,
|
|
StorageImageComponentKind ComponentKind);
|
|
|
|
internal static bool TryReadSpirvStorageImageContracts(
|
|
ReadOnlySpan<byte> spirv,
|
|
out SpirvStorageImageContract[] contracts,
|
|
out string error)
|
|
{
|
|
contracts = [];
|
|
error = string.Empty;
|
|
if (spirv.Length < 5 * sizeof(uint) ||
|
|
BinaryPrimitives.ReadUInt32LittleEndian(spirv) != 0x07230203u)
|
|
{
|
|
error = "invalid-spirv-header";
|
|
return false;
|
|
}
|
|
|
|
var componentTypes = new Dictionary<uint, StorageImageComponentKind>();
|
|
var storageImageTypes = new Dictionary<uint, SpirvStorageImageContract>();
|
|
var uniformConstantPointers = new Dictionary<uint, uint>();
|
|
var uniformConstantVariables = new List<(uint Id, uint PointerType)>();
|
|
var bindings = new Dictionary<uint, uint>();
|
|
for (var offset = 5 * sizeof(uint); offset < spirv.Length;)
|
|
{
|
|
var instruction = BinaryPrimitives.ReadUInt32LittleEndian(
|
|
spirv.Slice(offset, sizeof(uint)));
|
|
var wordCount = checked((int)(instruction >> 16));
|
|
var byteCount = checked(wordCount * sizeof(uint));
|
|
if (wordCount == 0 || offset + byteCount > spirv.Length)
|
|
{
|
|
error = "invalid-spirv-instruction-size";
|
|
return false;
|
|
}
|
|
|
|
switch ((SpirvOp)(instruction & 0xFFFFu))
|
|
{
|
|
case SpirvOp.TypeInt when wordCount >= 4:
|
|
componentTypes[ReadSpirvWord(spirv, offset, 1)] =
|
|
ReadSpirvWord(spirv, offset, 3) != 0
|
|
? StorageImageComponentKind.Sint
|
|
: StorageImageComponentKind.Uint;
|
|
break;
|
|
case SpirvOp.TypeFloat when wordCount >= 3:
|
|
componentTypes[ReadSpirvWord(spirv, offset, 1)] =
|
|
StorageImageComponentKind.Float;
|
|
break;
|
|
case SpirvOp.TypeImage when wordCount >= 9 &&
|
|
ReadSpirvWord(spirv, offset, 7) == 2:
|
|
var imageType = ReadSpirvWord(spirv, offset, 1);
|
|
var componentType = ReadSpirvWord(spirv, offset, 2);
|
|
if (!componentTypes.TryGetValue(componentType, out var componentKind))
|
|
{
|
|
error = $"unknown-storage-component-type({componentType})";
|
|
return false;
|
|
}
|
|
|
|
storageImageTypes[imageType] = new SpirvStorageImageContract(
|
|
(SpirvImageFormat)ReadSpirvWord(spirv, offset, 8),
|
|
componentKind);
|
|
break;
|
|
case SpirvOp.TypePointer when wordCount >= 4 &&
|
|
ReadSpirvWord(spirv, offset, 2) ==
|
|
(uint)SpirvStorageClass.UniformConstant:
|
|
uniformConstantPointers[ReadSpirvWord(spirv, offset, 1)] =
|
|
ReadSpirvWord(spirv, offset, 3);
|
|
break;
|
|
case SpirvOp.Variable when wordCount >= 4 &&
|
|
ReadSpirvWord(spirv, offset, 3) ==
|
|
(uint)SpirvStorageClass.UniformConstant:
|
|
uniformConstantVariables.Add((
|
|
ReadSpirvWord(spirv, offset, 2),
|
|
ReadSpirvWord(spirv, offset, 1)));
|
|
break;
|
|
case SpirvOp.Decorate when wordCount >= 4 &&
|
|
ReadSpirvWord(spirv, offset, 2) ==
|
|
(uint)SpirvDecoration.Binding:
|
|
bindings[ReadSpirvWord(spirv, offset, 1)] =
|
|
ReadSpirvWord(spirv, offset, 3);
|
|
break;
|
|
}
|
|
|
|
offset += byteCount;
|
|
}
|
|
|
|
var result = new List<(uint Binding, SpirvStorageImageContract Contract)>();
|
|
foreach (var variable in uniformConstantVariables)
|
|
{
|
|
if (!uniformConstantPointers.TryGetValue(variable.PointerType, out var imageType) ||
|
|
!storageImageTypes.TryGetValue(imageType, out var contract))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!bindings.TryGetValue(variable.Id, out var binding) ||
|
|
result.Any(entry => entry.Binding == binding))
|
|
{
|
|
error = $"invalid-storage-image-binding({variable.Id})";
|
|
return false;
|
|
}
|
|
|
|
result.Add((binding, contract));
|
|
}
|
|
|
|
contracts = result
|
|
.OrderBy(static entry => entry.Binding)
|
|
.Select(static entry => entry.Contract)
|
|
.ToArray();
|
|
return true;
|
|
}
|
|
|
|
private static uint ReadSpirvWord(
|
|
ReadOnlySpan<byte> spirv,
|
|
int instructionOffset,
|
|
int wordIndex) =>
|
|
BinaryPrimitives.ReadUInt32LittleEndian(
|
|
spirv.Slice(
|
|
instructionOffset + wordIndex * sizeof(uint),
|
|
sizeof(uint)));
|
|
|
|
internal static bool TryValidateStorageImageContract(
|
|
SpirvStorageImageContract shaderContract,
|
|
uint guestFormat,
|
|
uint guestNumberType,
|
|
bool supportsStorage,
|
|
out Format vulkanFormat,
|
|
out string error)
|
|
{
|
|
vulkanFormat = Presenter.GetStorageImageFormat(
|
|
Presenter.GetTextureFormat(guestFormat, guestNumberType));
|
|
var guestComponentKind = guestNumberType switch
|
|
{
|
|
4 => StorageImageComponentKind.Uint,
|
|
5 => StorageImageComponentKind.Sint,
|
|
_ => StorageImageComponentKind.Float,
|
|
};
|
|
if (shaderContract.ComponentKind != guestComponentKind)
|
|
{
|
|
error = $"component-kind-mismatch(spirv={shaderContract.ComponentKind}," +
|
|
$"guest={guestComponentKind})";
|
|
return false;
|
|
}
|
|
|
|
if (shaderContract.Format != SpirvImageFormat.Unknown &&
|
|
TryGetVulkanStorageImageFormat(shaderContract.Format, out var typedFormat) &&
|
|
typedFormat != vulkanFormat)
|
|
{
|
|
error = $"typed-format-mismatch(spirv={shaderContract.Format}/{typedFormat}," +
|
|
$"guest={guestFormat}/num={guestNumberType},vk={vulkanFormat})";
|
|
return false;
|
|
}
|
|
|
|
if (shaderContract.Format != SpirvImageFormat.Unknown &&
|
|
!TryGetVulkanStorageImageFormat(shaderContract.Format, out _))
|
|
{
|
|
error = $"unsupported-spirv-storage-format({shaderContract.Format})";
|
|
return false;
|
|
}
|
|
|
|
if (!supportsStorage)
|
|
{
|
|
error = $"vulkan-storage-feature-missing(vk={vulkanFormat})";
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetVulkanStorageImageFormat(
|
|
SpirvImageFormat format,
|
|
out Format vulkanFormat)
|
|
{
|
|
vulkanFormat = format switch
|
|
{
|
|
SpirvImageFormat.Rgba32f => Format.R32G32B32A32Sfloat,
|
|
SpirvImageFormat.Rgba16f => Format.R16G16B16A16Sfloat,
|
|
SpirvImageFormat.R32f => Format.R32Sfloat,
|
|
SpirvImageFormat.Rgba8 => Format.R8G8B8A8Unorm,
|
|
SpirvImageFormat.Rgba8Snorm => Format.R8G8B8A8SNorm,
|
|
SpirvImageFormat.Rg32f => Format.R32G32Sfloat,
|
|
SpirvImageFormat.Rg16f => Format.R16G16Sfloat,
|
|
SpirvImageFormat.R11fG11fB10f => Format.B10G11R11UfloatPack32,
|
|
SpirvImageFormat.R16f => Format.R16Sfloat,
|
|
SpirvImageFormat.Rgba16 => Format.R16G16B16A16Unorm,
|
|
SpirvImageFormat.Rgb10A2 => Format.A2B10G10R10UnormPack32,
|
|
SpirvImageFormat.Rg16 => Format.R16G16Unorm,
|
|
SpirvImageFormat.Rg8 => Format.R8G8Unorm,
|
|
SpirvImageFormat.R16 => Format.R16Unorm,
|
|
SpirvImageFormat.R8 => Format.R8Unorm,
|
|
SpirvImageFormat.Rgba16Snorm => Format.R16G16B16A16SNorm,
|
|
SpirvImageFormat.Rg16Snorm => Format.R16G16SNorm,
|
|
SpirvImageFormat.Rg8Snorm => Format.R8G8SNorm,
|
|
SpirvImageFormat.R16Snorm => Format.R16SNorm,
|
|
SpirvImageFormat.R8Snorm => Format.R8SNorm,
|
|
SpirvImageFormat.Rgba32i => Format.R32G32B32A32Sint,
|
|
SpirvImageFormat.Rgba16i => Format.R16G16B16A16Sint,
|
|
SpirvImageFormat.Rgba8i => Format.R8G8B8A8Sint,
|
|
SpirvImageFormat.R32i => Format.R32Sint,
|
|
SpirvImageFormat.Rg32i => Format.R32G32Sint,
|
|
SpirvImageFormat.Rg16i => Format.R16G16Sint,
|
|
SpirvImageFormat.Rg8i => Format.R8G8Sint,
|
|
SpirvImageFormat.R16i => Format.R16Sint,
|
|
SpirvImageFormat.R8i => Format.R8Sint,
|
|
SpirvImageFormat.Rgba32ui => Format.R32G32B32A32Uint,
|
|
SpirvImageFormat.Rgba16ui => Format.R16G16B16A16Uint,
|
|
SpirvImageFormat.Rgba8ui => Format.R8G8B8A8Uint,
|
|
SpirvImageFormat.R32ui => Format.R32Uint,
|
|
SpirvImageFormat.Rgb10A2ui => Format.A2B10G10R10UintPack32,
|
|
SpirvImageFormat.Rg32ui => Format.R32G32Uint,
|
|
SpirvImageFormat.Rg16ui => Format.R16G16Uint,
|
|
SpirvImageFormat.Rg8ui => Format.R8G8Uint,
|
|
SpirvImageFormat.R16ui => Format.R16Uint,
|
|
SpirvImageFormat.R8ui => Format.R8Uint,
|
|
_ => Format.Undefined,
|
|
};
|
|
return vulkanFormat != Format.Undefined;
|
|
}
|
|
|
|
// Vulkan's portable upper bound for minStorageBufferOffsetAlignment is
|
|
// 256 bytes. Using that fixed power of two (instead of racing the render
|
|
// thread's physical-device query) gives shader translation and descriptor
|
|
// creation one stable aliasing contract on every conformant device.
|
|
internal const ulong GuestStorageBufferOffsetAlignment = 256;
|
|
// The pending queue and per-render drain budget bound how much guest GPU
|
|
// work can be buffered ahead of the presenter. Draws are batched into
|
|
// shared command buffers, so draining a large batch per render tick is
|
|
// cheap; small caps here throttle games that issue more than a handful
|
|
// of draws per frame to a fraction of the display rate. The pending cap
|
|
// stays tighter than the drain budget because queued draws pin their
|
|
// pooled guest-data arrays until the render thread uploads them.
|
|
// The Cocoa event loop must stay responsive while guest work is pending,
|
|
// but Windows and Linux render on a dedicated host thread. Keeping the
|
|
// macOS item limit everywhere throttles draw-heavy games well below their
|
|
// display rate before the byte budget is remotely close to full.
|
|
private static readonly int _maxPendingGuestWorkItems =
|
|
int.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_ITEMS"),
|
|
out var pendingGuestWorkItems) && pendingGuestWorkItems > 0
|
|
? pendingGuestWorkItems
|
|
: OperatingSystem.IsMacOS() ? 64 : 512;
|
|
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
|
|
// A captured 4K flip can consume tens of MiB of device-local memory.
|
|
// Retain only a short presentation queue while always preserving the
|
|
// newest generation; older immutable versions are retired immediately.
|
|
private const int MaxPendingGuestFlipVersions = 4;
|
|
// A count-only queue bound is not a memory bound: one compute dispatch can
|
|
// carry dozens of full-resolution texture snapshots. At 4K, 64 queued
|
|
// dispatches retained more than 12 GiB of managed byte arrays before the
|
|
// render thread could upload them. Keep the count cap for small work and
|
|
// independently apply backpressure to the actual retained payload.
|
|
private static readonly ulong _maxPendingGuestWorkBytes =
|
|
(ulong.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_MB"),
|
|
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
|
|
? pendingGuestWorkMb
|
|
: 256UL) * 1024UL * 1024UL;
|
|
private static readonly int _maxGuestWorkPerRender =
|
|
int.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_MAX_GUEST_WORK_PER_RENDER"),
|
|
out var guestWorkPerRender) && guestWorkPerRender > 0
|
|
? guestWorkPerRender
|
|
: OperatingSystem.IsMacOS() ? 256 : 1024;
|
|
// On macOS the whole window loop — including Render() and its guest-work
|
|
// drain — runs on the process main thread, so draining a large backlog of
|
|
// slow guest work (heavy compute) blocks the Cocoa event pump and marks the
|
|
// window "Not Responding" while starving the swapchain present. Cap the
|
|
// wall-clock time spent draining per Render() call; leftover work stays
|
|
// queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides
|
|
// (0 disables the cap); default 12ms keeps the macOS window interactive at
|
|
// ~60Hz. Windows and Linux use a dedicated render thread, so they drain
|
|
// without a time budget by default.
|
|
private static readonly long _renderWorkBudgetTicks =
|
|
(long.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
|
|
out var renderBudgetMs) && renderBudgetMs >= 0
|
|
? renderBudgetMs
|
|
: OperatingSystem.IsMacOS() ? 12L : 0L) *
|
|
System.Diagnostics.Stopwatch.Frequency / 1000L;
|
|
// Max time the main-thread Render() will block waiting for a frame slot's
|
|
// GPU fence before skipping the frame and returning to the event pump.
|
|
// Prevents the window freezing behind a slow-compute GPU backlog.
|
|
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms on macOS. The
|
|
// dedicated Windows/Linux render thread may wait for its frame slot so it
|
|
// does not drop guest-work drain opportunities under normal GPU load.
|
|
private static readonly ulong _frameSlotWaitBudgetNs =
|
|
ulong.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
|
|
out var frameWaitMs) && frameWaitMs > 0
|
|
? frameWaitMs * 1_000_000UL
|
|
: OperatingSystem.IsMacOS() ? 8_000_000UL : ulong.MaxValue;
|
|
// Cap the guest-submission fence wait so a GPU submission whose fence never
|
|
// signals (a mistranslated compute shader that hangs the Metal queue) cannot
|
|
// freeze the render thread forever and starve the swapchain present.
|
|
// SHARPEMU_FENCE_WAIT_TIMEOUT_MS overrides; default 3s.
|
|
private static readonly ulong _guestFenceWaitTimeoutNs =
|
|
ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_FENCE_WAIT_TIMEOUT_MS"), out var fenceMs) && fenceMs > 0
|
|
? fenceMs * 1_000_000UL
|
|
: 3_000_000_000UL;
|
|
// When making room in the in-flight submission queue from the macOS MAIN
|
|
// thread (Render() -> guest-work drain), block only this long per attempt
|
|
// instead of the full fence timeout. If a slow/capped compute submission
|
|
// isn't done yet, proceed anyway: the in-flight cap is soft, the fence and
|
|
// command-buffer pools are dynamic so a brief overshoot is safe, and the
|
|
// queue drains as GPU completions land on later frames. This keeps the
|
|
// window responsive (event pump runs) under a heavy compute backlog instead
|
|
// of the main thread sitting in vkWaitForFences for up to 3s per chunk.
|
|
// SHARPEMU_SUBMISSION_CAPACITY_WAIT_MS overrides; default 100ms; 0 restores
|
|
// the full blocking wait.
|
|
private static readonly ulong _submissionCapacityWaitNs =
|
|
ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_SUBMISSION_CAPACITY_WAIT_MS"), out var capMs)
|
|
? capMs * 1_000_000UL
|
|
: 100_000_000UL;
|
|
private static readonly HashSet<string> _tracedFenceTimeouts = new();
|
|
private static long _guestQueueBackpressureTraceCount;
|
|
// Diagnostic: skip every compute dispatch (mistranslated compute shaders
|
|
// run long / GPU-hang and starve the present). Isolates whether the
|
|
// geometry+composite path renders on its own.
|
|
private static readonly bool _skipAllCompute =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1";
|
|
// Diagnostic: skip compute dispatches whose GroupCountZ is at least this,
|
|
// to isolate a specific tall dispatch (e.g. Demon's Souls' 27x15x72 froxel
|
|
// shader that hangs the Metal queue) without needing its ASLR-varying
|
|
// address. 0 disables.
|
|
private static readonly uint _skipTallComputeZ =
|
|
uint.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_SKIP_TALL_COMPUTE_Z"), out var z)
|
|
? z
|
|
: 0;
|
|
private const uint GuestPrimitiveRectList = 0x11;
|
|
|
|
private static readonly object _gate = new();
|
|
private readonly record struct PendingGuestWork(
|
|
object Work,
|
|
ulong PayloadBytes,
|
|
long Sequence,
|
|
long RequiredSequence,
|
|
long EnqueuedTicks,
|
|
VulkanGuestQueueIdentity Queue);
|
|
|
|
// PS5 exposes independent graphics and asynchronous-compute queues. A
|
|
// single host FIFO adds dependencies that do not exist in the guest: one
|
|
// slow ACB dispatch can delay a graphics clear until the CPU has reused
|
|
// that heap. Keep FIFO order within each logical guest queue and schedule
|
|
// ready queues round-robin. Explicit WAIT_REG_MEM packets remain the only
|
|
// mechanism that orders one logical queue behind another.
|
|
private static readonly Dictionary<string, LinkedList<PendingGuestWork>>
|
|
_pendingGuestWorkByQueue = new(StringComparer.Ordinal);
|
|
private static readonly List<string> _pendingGuestQueueSchedule = [];
|
|
private static int _pendingGuestQueueCursor;
|
|
private static int _pendingGuestWorkCount;
|
|
private static ulong _pendingGuestWorkBytes;
|
|
// A flip names an image that was rendered earlier in the command stream.
|
|
// Keep a small FIFO of those flips instead of replacing an incomplete one
|
|
// with the next frame: the guest can enqueue the next frame before the
|
|
// render thread reaches the previous image, which otherwise starves
|
|
// presentation indefinitely.
|
|
private static readonly Queue<Presentation> _pendingGuestImagePresentations = new();
|
|
private static readonly Dictionary<ulong, long> _guestImageWorkSequences = new();
|
|
private static readonly Dictionary<ulong, uint> _availableGuestImages = new();
|
|
private static readonly Dictionary<(int Handle, int BufferIndex), long>
|
|
_lastOrderedGuestFlipVersions = new();
|
|
private static long _orderedGuestFlipVersionSequence;
|
|
// Storage-image initialization is copied only by the first queued writer.
|
|
// Later dispatches targeting the same image must not each retain another
|
|
// multi-megabyte guest-memory snapshot while waiting for that first writer
|
|
// to reach the presenter. Reference counts let failed/completed work
|
|
// retire its reservation without leaving a permanent false cache hit.
|
|
private readonly record struct PendingGuestImageUpload(int Count, long OwnerSequence);
|
|
private static readonly Dictionary<(ulong Address, uint Format), PendingGuestImageUpload>
|
|
_pendingGuestImageUploads = new();
|
|
private static readonly Dictionary<ulong, byte[]> _pendingGuestImageInitialData = new();
|
|
private static readonly Dictionary<ulong, (uint Width, uint Height, ulong ByteCount)>
|
|
_guestImageExtents = new();
|
|
private static readonly bool _traceGuestImageEvents =
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_DRAWS"),
|
|
"1",
|
|
StringComparison.Ordinal) ||
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_EVENTS"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
private static readonly bool _traceGuestWorkCompletion =
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WORK_COMPLETION"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
|
|
_tracedGuestImageSubmissions = [];
|
|
private static Thread? _thread;
|
|
private static VulkanHostSurface? _hostSurface;
|
|
private static VulkanHostSurface? _hostSurfacePendingDetach;
|
|
internal static event Action<VulkanHostSurface>? FirstHostFramePresented;
|
|
private static Presentation? _latestPresentation;
|
|
private static byte[]? _copyFragmentSpirv;
|
|
private static uint _windowWidth;
|
|
private static uint _windowHeight;
|
|
private static bool _closed;
|
|
private static bool _presenterCloseRequested;
|
|
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
|
|
private const uint NvidiaVendorId = 0x10DE;
|
|
private const uint AmdVendorId = 0x1002;
|
|
// Other GPU PCI vendor IDs, for reference when adding future rules:
|
|
// Intel 0x8086, Apple 0x106B, Qualcomm 0x5143 (Windows-on-ARM), Microsoft software 0x1414.
|
|
private const int LastResortPenalty = 1000;
|
|
private const string PortabilityEnumerationExtensionName = "VK_KHR_portability_enumeration";
|
|
private const string PortabilitySubsetExtensionName = "VK_KHR_portability_subset";
|
|
private const int GlfwPlatformHint = 0x00050003;
|
|
private const int GlfwPlatformWin32 = 0x00060001;
|
|
private const int GlfwPlatformCocoa = 0x00060002;
|
|
private const int GlfwPlatformWayland = 0x00060003;
|
|
private const int GlfwPlatformX11 = 0x00060004;
|
|
private const int GlfwPlatformNull = 0x00060005;
|
|
|
|
internal static int ScorePhysicalDevice(
|
|
PhysicalDeviceProperties properties,
|
|
string name,
|
|
string? deviceOverride)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(deviceOverride))
|
|
{
|
|
return name.Contains(deviceOverride, StringComparison.OrdinalIgnoreCase) ? 1000 : -1000;
|
|
}
|
|
|
|
var score = properties.DeviceType switch
|
|
{
|
|
PhysicalDeviceType.DiscreteGpu => 300,
|
|
PhysicalDeviceType.VirtualGpu => 100,
|
|
PhysicalDeviceType.IntegratedGpu => 50,
|
|
PhysicalDeviceType.Cpu => 20,
|
|
_ => 10,
|
|
};
|
|
|
|
if (properties.VendorID == NvidiaVendorId)
|
|
{
|
|
score += 500;
|
|
}
|
|
|
|
score -= ComputeDevicePenalty(properties, OperatingSystem.IsWindows());
|
|
|
|
return score;
|
|
}
|
|
|
|
internal static int ComputeDevicePenalty(PhysicalDeviceProperties properties, bool isWindows)
|
|
{
|
|
var penalty = 0;
|
|
|
|
if (isWindows &&
|
|
properties.DeviceType == PhysicalDeviceType.IntegratedGpu &&
|
|
properties.VendorID == AmdVendorId)
|
|
{
|
|
penalty += LastResortPenalty;
|
|
}
|
|
|
|
return penalty;
|
|
}
|
|
|
|
private static bool _splashHidden;
|
|
private static long _enqueuedGuestWorkSequence;
|
|
// Largest contiguous completed sequence, retained for compact diagnostics.
|
|
// Per-queue scheduling can complete a later global id first, so correctness
|
|
// checks use IsGuestWorkCompletedLocked rather than numeric <= comparisons.
|
|
private static long _completedGuestWorkSequence;
|
|
private static readonly HashSet<long> _completedGuestWorkOutOfOrder = [];
|
|
private static readonly Dictionary<string, long> _lastEnqueuedGuestWorkByQueue =
|
|
new(StringComparer.Ordinal);
|
|
private static long _executingGuestWorkSequence;
|
|
[ThreadStatic]
|
|
private static VulkanGuestQueueIdentity? _submittingGuestQueue;
|
|
[ThreadStatic]
|
|
private static bool _enqueueAsImmediateQueueFollowup;
|
|
[ThreadStatic]
|
|
private static LinkedListNode<PendingGuestWork>? _immediateFollowupTail;
|
|
|
|
private sealed class GuestQueueScope : IDisposable
|
|
{
|
|
private readonly VulkanGuestQueueIdentity? _previous;
|
|
private bool _disposed;
|
|
|
|
public GuestQueueScope(VulkanGuestQueueIdentity queue)
|
|
{
|
|
_previous = _submittingGuestQueue;
|
|
_submittingGuestQueue = queue;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
_submittingGuestQueue = _previous;
|
|
}
|
|
}
|
|
|
|
public static IDisposable EnterGuestQueue(
|
|
string queueName,
|
|
ulong submissionId) =>
|
|
new GuestQueueScope(new VulkanGuestQueueIdentity(
|
|
string.IsNullOrWhiteSpace(queueName) ? "guest.unknown" : queueName,
|
|
submissionId));
|
|
|
|
private static long CurrentSubmittingQueueTailLocked()
|
|
{
|
|
var queue = _submittingGuestQueue;
|
|
return queue is { } identity &&
|
|
_lastEnqueuedGuestWorkByQueue.TryGetValue(identity.Name, out var tail)
|
|
? tail
|
|
: 0;
|
|
}
|
|
|
|
private static bool ShouldTraceGuestImageSubmissionsForDiagnostics()
|
|
{
|
|
return string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool ShouldSamplePresentedGuestImageForDiagnostics(long frame)
|
|
{
|
|
var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
|
|
if (string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// A 4K Vulkan readback is deliberately synchronous and can take
|
|
// several seconds on Linux. The lightweight "present" mode only
|
|
// needs one proof that the final image is non-black.
|
|
return frame == 1;
|
|
}
|
|
|
|
return string.Equals(mode, "1", StringComparison.Ordinal) &&
|
|
(frame is 1 or 30 or 120 || frame % 600 == 0);
|
|
}
|
|
|
|
public static void EnsureStarted(uint width, uint height)
|
|
{
|
|
if (width == 0 || height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed || _thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
var hasSplash = PngSplashLoader.TryLoad(
|
|
out var splashPixels,
|
|
out var splashWidth,
|
|
out var splashHeight);
|
|
lock (_gate)
|
|
{
|
|
if (_closed || _thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowWidth = width;
|
|
_windowHeight = height;
|
|
_latestPresentation ??= _splashHidden
|
|
? new Presentation(
|
|
CreateBlackFrame(width, height),
|
|
width,
|
|
height,
|
|
1,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: 0,
|
|
IsSplash: false)
|
|
: hasSplash
|
|
? new Presentation(
|
|
splashPixels,
|
|
splashWidth,
|
|
splashHeight,
|
|
1,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: 0,
|
|
IsSplash: true)
|
|
: new Presentation(
|
|
null,
|
|
width,
|
|
height,
|
|
0,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: 0,
|
|
IsSplash: false);
|
|
if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
|
|
{
|
|
// The GUI keeps its native child hidden while the launcher is
|
|
// loading. Reveal it for the real VideoOut splash rather than
|
|
// substituting a launcher-side image.
|
|
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
|
|
}
|
|
StartPresenterLocked();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects a same-process native surface for the next VideoOut session.
|
|
/// This is intentionally rejected once the presenter has started: Vulkan
|
|
/// surface ownership cannot change under an active swapchain.
|
|
/// </summary>
|
|
public static bool TryAttachHostSurface(VulkanHostSurface surface)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(surface);
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_thread is not null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ResetHostSessionStateLocked();
|
|
_hostSurface = surface;
|
|
_closed = false;
|
|
_presenterCloseRequested = false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Releases the host surface after the guest session has stopped.
|
|
/// </summary>
|
|
public static void DetachHostSurface(VulkanHostSurface surface)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(surface);
|
|
|
|
lock (_gate)
|
|
{
|
|
if (!ReferenceEquals(_hostSurface, surface))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_thread is null)
|
|
{
|
|
_hostSurface = null;
|
|
}
|
|
else
|
|
{
|
|
_hostSurfacePendingDetach = surface;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static bool UsesHostSurface
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _hostSurface is not null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
|
|
{
|
|
// This marker crosses the GUI child-process pipe. The in-process
|
|
// event remains for hosts that embed the renderer directly.
|
|
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
|
|
var callback = FirstHostFramePresented;
|
|
if (callback is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
callback(surface);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private static void ResetHostSessionStateLocked()
|
|
{
|
|
_latestPresentation = null;
|
|
_splashHidden = false;
|
|
_pendingGuestWorkByQueue.Clear();
|
|
_pendingGuestQueueSchedule.Clear();
|
|
_pendingGuestQueueCursor = 0;
|
|
_pendingGuestWorkCount = 0;
|
|
_pendingGuestWorkBytes = 0;
|
|
_pendingGuestImagePresentations.Clear();
|
|
_guestImageWorkSequences.Clear();
|
|
_availableGuestImages.Clear();
|
|
_lastOrderedGuestFlipVersions.Clear();
|
|
_orderedGuestFlipVersionSequence = 0;
|
|
_pendingGuestImageUploads.Clear();
|
|
_pendingGuestImageInitialData.Clear();
|
|
_guestImageExtents.Clear();
|
|
_enqueuedGuestWorkSequence = 0;
|
|
_completedGuestWorkSequence = 0;
|
|
_completedGuestWorkOutOfOrder.Clear();
|
|
_lastEnqueuedGuestWorkByQueue.Clear();
|
|
_executingGuestWorkSequence = 0;
|
|
_hostSurfacePendingDetach = null;
|
|
}
|
|
|
|
public static void HideSplashScreen()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_splashHidden = true;
|
|
if (_closed || _latestPresentation is not { IsSplash: true } latest)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = latest.Sequence + 1;
|
|
_latestPresentation = new Presentation(
|
|
CreateBlackFrame(latest.Width, latest.Height),
|
|
latest.Width,
|
|
latest.Height,
|
|
sequence,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: 0,
|
|
IsSplash: false);
|
|
Console.Error.WriteLine("[LOADER][INFO] Vulkan VideoOut hid splash");
|
|
}
|
|
}
|
|
|
|
public static void Submit(byte[] bgraFrame, uint width, uint height)
|
|
{
|
|
if (bgraFrame.Length != checked((int)(width * height * 4)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
_latestPresentation = new Presentation(
|
|
bgraFrame,
|
|
width,
|
|
height,
|
|
sequence,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: 0,
|
|
IsSplash: false);
|
|
if (_thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowWidth = width;
|
|
_windowHeight = height;
|
|
StartPresenterLocked();
|
|
}
|
|
}
|
|
|
|
public static void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height)
|
|
{
|
|
if (drawKind == GuestDrawKind.None || width == 0 || height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
_latestPresentation is { Pixels: null } latest &&
|
|
latest.DrawKind == drawKind &&
|
|
latest.Width == width &&
|
|
latest.Height == height)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
_latestPresentation = new Presentation(
|
|
null,
|
|
width,
|
|
height,
|
|
sequence,
|
|
drawKind,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(),
|
|
IsSplash: false);
|
|
if (_thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowWidth = width;
|
|
_windowHeight = height;
|
|
StartPresenterLocked();
|
|
}
|
|
}
|
|
|
|
public static void SubmitTranslatedDraw(
|
|
byte[] pixelSpirv,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint width,
|
|
uint height,
|
|
uint attributeCount,
|
|
byte[]? vertexSpirv = null,
|
|
uint vertexCount = 3,
|
|
uint instanceCount = 1,
|
|
uint primitiveType = 4,
|
|
GuestIndexBuffer? indexBuffer = null,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
|
GuestRenderState? renderState = null)
|
|
{
|
|
if (pixelSpirv.Length == 0 || width == 0 || height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
_latestPresentation = new Presentation(
|
|
null,
|
|
width,
|
|
height,
|
|
sequence,
|
|
GuestDrawKind.None,
|
|
new VulkanTranslatedGuestDraw(
|
|
vertexSpirv ?? [],
|
|
pixelSpirv,
|
|
textures.ToArray(),
|
|
globalMemoryBuffers.ToArray(),
|
|
vertexBuffers?.ToArray() ?? [],
|
|
attributeCount,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
renderState ?? GuestRenderState.Default),
|
|
RequiredGuestWorkSequence: CurrentSubmittingQueueTailLocked(),
|
|
IsSplash: false);
|
|
if (_thread is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowWidth = width;
|
|
_windowHeight = height;
|
|
StartPresenterLocked();
|
|
}
|
|
}
|
|
|
|
public static void SubmitOffscreenTranslatedDraw(
|
|
byte[] pixelSpirv,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
GuestRenderTarget target,
|
|
byte[]? vertexSpirv = null,
|
|
uint vertexCount = 3,
|
|
uint instanceCount = 1,
|
|
uint primitiveType = 4,
|
|
GuestIndexBuffer? indexBuffer = null,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
|
GuestRenderState? renderState = null,
|
|
GuestDepthTarget? depthTarget = null,
|
|
ulong shaderAddress = 0)
|
|
{
|
|
SubmitOffscreenTranslatedDraw(
|
|
pixelSpirv,
|
|
textures,
|
|
globalMemoryBuffers,
|
|
attributeCount,
|
|
[target],
|
|
vertexSpirv,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
vertexBuffers,
|
|
renderState,
|
|
depthTarget,
|
|
shaderAddress);
|
|
}
|
|
|
|
// Manual scans (targets are <= 8) so the per-draw validation does not
|
|
// allocate LINQ iterators/closures or a Distinct HashSet.
|
|
private static bool AnyRenderTargetInvalid(IReadOnlyList<GuestRenderTarget> targets)
|
|
{
|
|
foreach (var target in targets)
|
|
{
|
|
if (target.Address == 0 || target.Width == 0 || target.Height == 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool RenderTargetsMismatchedOrAliased(IReadOnlyList<GuestRenderTarget> targets, GuestRenderTarget first)
|
|
{
|
|
for (var i = 0; i < targets.Count; i++)
|
|
{
|
|
if (targets[i].Width != first.Width || targets[i].Height != first.Height)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (var j = i + 1; j < targets.Count; j++)
|
|
{
|
|
if (targets[i].Address == targets[j].Address)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public static void SubmitOffscreenTranslatedDraw(
|
|
byte[] pixelSpirv,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
IReadOnlyList<GuestRenderTarget> targets,
|
|
byte[]? vertexSpirv = null,
|
|
uint vertexCount = 3,
|
|
uint instanceCount = 1,
|
|
uint primitiveType = 4,
|
|
GuestIndexBuffer? indexBuffer = null,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
|
GuestRenderState? renderState = null,
|
|
GuestDepthTarget? depthTarget = null,
|
|
ulong shaderAddress = 0)
|
|
{
|
|
if (pixelSpirv.Length == 0 ||
|
|
targets.Count == 0 ||
|
|
targets.Count > 8 ||
|
|
AnyRenderTargetInvalid(targets))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var firstTarget = targets[0];
|
|
if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched dimensions or aliased targets.");
|
|
return;
|
|
}
|
|
|
|
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.submit_call kind=SubmitOffscreenTranslatedDraw " +
|
|
$"targets={targets.Count} first=0x{firstTarget.Address:X16} " +
|
|
$"{firstTarget.Width}x{firstTarget.Height} textures={textures.Count}");
|
|
}
|
|
|
|
var effectiveRenderState = renderState ?? GuestRenderState.Default;
|
|
if (effectiveRenderState.Blends.Count == 1 && targets.Count > 1)
|
|
{
|
|
var broadcastBlends = new GuestBlendState[targets.Count];
|
|
Array.Fill(broadcastBlends, effectiveRenderState.Blends[0]);
|
|
effectiveRenderState = effectiveRenderState with
|
|
{
|
|
Blends = broadcastBlends,
|
|
};
|
|
}
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
var guestTextureFormat = GetGuestTextureFormat(
|
|
target.Format,
|
|
target.NumberType);
|
|
if (guestTextureFormat != 0)
|
|
{
|
|
_availableGuestImages[target.Address] = guestTextureFormat;
|
|
}
|
|
}
|
|
|
|
var workSequence = EnqueueGuestWorkLocked(
|
|
new VulkanOffscreenGuestDraw(
|
|
new VulkanTranslatedGuestDraw(
|
|
vertexSpirv ?? [],
|
|
pixelSpirv,
|
|
textures.ToArray(),
|
|
globalMemoryBuffers.ToArray(),
|
|
vertexBuffers?.ToArray() ?? [],
|
|
attributeCount,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
effectiveRenderState),
|
|
targets.ToArray(),
|
|
depthTarget,
|
|
PublishTarget: true,
|
|
shaderAddress));
|
|
foreach (var target in targets)
|
|
{
|
|
_guestImageWorkSequences[target.Address] = workSequence;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void SubmitDepthOnlyTranslatedDraw(
|
|
byte[] pixelSpirv,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
GuestDepthTarget depthTarget,
|
|
byte[]? vertexSpirv = null,
|
|
uint vertexCount = 3,
|
|
uint instanceCount = 1,
|
|
uint primitiveType = 4,
|
|
GuestIndexBuffer? indexBuffer = null,
|
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
|
GuestRenderState? renderState = null,
|
|
ulong shaderAddress = 0)
|
|
{
|
|
if (pixelSpirv.Length == 0 ||
|
|
depthTarget.Address == 0 ||
|
|
depthTarget.Width == 0 ||
|
|
depthTarget.Height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnqueueGuestWorkLocked(
|
|
new VulkanOffscreenGuestDraw(
|
|
new VulkanTranslatedGuestDraw(
|
|
vertexSpirv ?? [],
|
|
pixelSpirv,
|
|
textures.ToArray(),
|
|
globalMemoryBuffers.ToArray(),
|
|
vertexBuffers?.ToArray() ?? [],
|
|
attributeCount,
|
|
vertexCount,
|
|
instanceCount,
|
|
primitiveType,
|
|
indexBuffer,
|
|
renderState ?? GuestRenderState.Default),
|
|
[new GuestRenderTarget(
|
|
Address: 0,
|
|
depthTarget.Width,
|
|
depthTarget.Height,
|
|
Format: 10,
|
|
NumberType: 0)],
|
|
depthTarget,
|
|
PublishTarget: false,
|
|
shaderAddress));
|
|
}
|
|
}
|
|
|
|
private sealed record VulkanGuestImageWrite(
|
|
ulong Address,
|
|
byte[]? Pixels,
|
|
uint FillValue);
|
|
|
|
/// <summary>
|
|
/// Reports the extent of a live guest image so DMA writes to its backing
|
|
/// memory can be mirrored into the Vulkan image (PS5 render targets alias
|
|
/// guest memory, so CP DMA fills/copies are visible to later GPU reads).
|
|
/// </summary>
|
|
internal static bool TryGetGuestImageExtent(
|
|
ulong address,
|
|
out uint width,
|
|
out uint height,
|
|
out ulong byteCount)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_guestImageExtents.TryGetValue(address, out var extent))
|
|
{
|
|
(width, height, byteCount) = extent;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
width = 0;
|
|
height = 0;
|
|
byteCount = 0;
|
|
return false;
|
|
}
|
|
|
|
internal static void SubmitGuestImageFill(ulong address, uint fillValue)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_closed || !_guestImageExtents.ContainsKey(address))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_guestImageWorkSequences[address] = EnqueueGuestWorkLocked(
|
|
new VulkanGuestImageWrite(address, null, fillValue));
|
|
}
|
|
}
|
|
|
|
internal static void SubmitGuestImageWrite(ulong address, byte[] pixels)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_closed || !_guestImageExtents.ContainsKey(address))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_guestImageWorkSequences[address] = EnqueueGuestWorkLocked(
|
|
new VulkanGuestImageWrite(address, pixels, 0));
|
|
}
|
|
}
|
|
|
|
private static long _perfDrawCount;
|
|
private static long _perfDrawTicks;
|
|
private static long _perfPipelineCreations;
|
|
private static long _perfSpirvCompilations;
|
|
|
|
internal static (long Draws, double DrawMs, long Pipelines, long SpirvCompilations)
|
|
ReadAndResetPerfCounters()
|
|
{
|
|
var draws = Interlocked.Exchange(ref _perfDrawCount, 0);
|
|
var ticks = Interlocked.Exchange(ref _perfDrawTicks, 0);
|
|
var pipelines = Interlocked.Exchange(ref _perfPipelineCreations, 0);
|
|
var spirv = Interlocked.Exchange(ref _perfSpirvCompilations, 0);
|
|
return (draws, ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency, pipelines, spirv);
|
|
}
|
|
|
|
internal static void CountSpirvCompilation() =>
|
|
Interlocked.Increment(ref _perfSpirvCompilations);
|
|
|
|
internal static IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _guestImageExtents
|
|
.Select(entry => (
|
|
entry.Key,
|
|
entry.Value.Width,
|
|
entry.Value.Height,
|
|
entry.Value.ByteCount))
|
|
.ToArray();
|
|
}
|
|
}
|
|
|
|
public static void SubmitStorageTranslatedDraw(
|
|
byte[] pixelSpirv,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint attributeCount,
|
|
uint width,
|
|
uint height,
|
|
ulong shaderAddress = 0)
|
|
{
|
|
if (pixelSpirv.Length == 0 ||
|
|
width == 0 ||
|
|
height == 0 ||
|
|
textures.All(texture => !texture.IsStorage))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnqueueGuestWorkLocked(
|
|
new VulkanOffscreenGuestDraw(
|
|
new VulkanTranslatedGuestDraw(
|
|
[],
|
|
pixelSpirv,
|
|
textures.ToArray(),
|
|
globalMemoryBuffers.ToArray(),
|
|
[],
|
|
attributeCount,
|
|
3,
|
|
1,
|
|
4,
|
|
null,
|
|
GuestRenderState.Default),
|
|
[new GuestRenderTarget(
|
|
Address: 0,
|
|
width,
|
|
height,
|
|
Format: 12,
|
|
NumberType: 7)],
|
|
DepthTarget: null,
|
|
PublishTarget: false,
|
|
shaderAddress));
|
|
}
|
|
}
|
|
|
|
public static long SubmitComputeDispatch(
|
|
ulong shaderAddress,
|
|
byte[] computeSpirv,
|
|
IReadOnlyList<GuestDrawTexture> textures,
|
|
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
|
uint groupCountX,
|
|
uint groupCountY,
|
|
uint groupCountZ,
|
|
uint baseGroupX,
|
|
uint baseGroupY,
|
|
uint baseGroupZ,
|
|
uint localSizeX,
|
|
uint localSizeY,
|
|
uint localSizeZ,
|
|
bool isIndirect,
|
|
bool writesGlobalMemory,
|
|
uint threadCountX = uint.MaxValue,
|
|
uint threadCountY = uint.MaxValue,
|
|
uint threadCountZ = uint.MaxValue)
|
|
{
|
|
if (computeSpirv.Length == 0 ||
|
|
groupCountX == 0 ||
|
|
groupCountY == 0 ||
|
|
groupCountZ == 0 ||
|
|
textures.All(texture => !texture.IsStorage) &&
|
|
!writesGlobalMemory)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
long workSequence;
|
|
lock (_gate)
|
|
{
|
|
if (_closed)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
workSequence = EnqueueGuestWorkLocked(
|
|
new VulkanComputeGuestDispatch(
|
|
shaderAddress,
|
|
computeSpirv,
|
|
textures.ToArray(),
|
|
globalMemoryBuffers.ToArray(),
|
|
groupCountX,
|
|
groupCountY,
|
|
groupCountZ,
|
|
baseGroupX,
|
|
baseGroupY,
|
|
baseGroupZ,
|
|
localSizeX,
|
|
localSizeY,
|
|
localSizeZ,
|
|
isIndirect,
|
|
writesGlobalMemory,
|
|
threadCountX,
|
|
threadCountY,
|
|
threadCountZ));
|
|
foreach (var key in GetStorageImageUploadKeys(textures))
|
|
{
|
|
_pendingGuestImageUploads[key] =
|
|
_pendingGuestImageUploads.TryGetValue(key, out var pendingUpload)
|
|
? pendingUpload with { Count = checked(pendingUpload.Count + 1) }
|
|
: new PendingGuestImageUpload(1, workSequence);
|
|
}
|
|
|
|
foreach (var texture in textures)
|
|
{
|
|
if (texture.IsStorage && texture.Address != 0)
|
|
{
|
|
_guestImageWorkSequences[texture.Address] = workSequence;
|
|
}
|
|
}
|
|
}
|
|
|
|
return workSequence;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues a CPU-visible PM4 side effect behind all GPU work submitted
|
|
/// before it. The render thread flushes its open batch and waits for the
|
|
/// corresponding guest fences before invoking the action.
|
|
/// </summary>
|
|
public static long SubmitOrderedGuestAction(Action action, string debugName)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(action);
|
|
lock (_gate)
|
|
{
|
|
return _closed || _thread is null
|
|
? 0
|
|
: EnqueueGuestWorkLocked(new VulkanOrderedGuestAction(action, debugName));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sequence currently being executed by the single guest-work consumer.
|
|
/// Intended only for address-filtered lifetime diagnostics emitted from a
|
|
/// guest-work callback before <see cref="CompleteGuestWork"/> advances it.
|
|
/// </summary>
|
|
public static long CurrentGuestWorkSequenceForDiagnostics =>
|
|
Volatile.Read(ref _executingGuestWorkSequence);
|
|
|
|
private static bool IsGuestWorkCompletedLocked(long sequence) =>
|
|
sequence <= 0 ||
|
|
sequence <= _completedGuestWorkSequence ||
|
|
_completedGuestWorkOutOfOrder.Contains(sequence);
|
|
|
|
public static bool WaitForGuestWork(
|
|
long workSequence,
|
|
int timeoutMilliseconds = System.Threading.Timeout.Infinite)
|
|
{
|
|
if (workSequence <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var waitIndefinitely = timeoutMilliseconds == System.Threading.Timeout.Infinite;
|
|
var deadline = waitIndefinitely
|
|
? long.MaxValue
|
|
: Environment.TickCount64 + Math.Max(timeoutMilliseconds, 1);
|
|
lock (_gate)
|
|
{
|
|
if (_traceGuestWorkCompletion)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_work_wait_enter sequence={workSequence} " +
|
|
$"contiguous_completed={_completedGuestWorkSequence} " +
|
|
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
|
|
}
|
|
while (!_closed && !IsGuestWorkCompletedLocked(workSequence))
|
|
{
|
|
if (!waitIndefinitely)
|
|
{
|
|
var remaining = deadline - Environment.TickCount64;
|
|
if (remaining <= 0)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan guest work wait timed out " +
|
|
$"sequence={workSequence} contiguous_completed={_completedGuestWorkSequence} " +
|
|
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
|
|
return false;
|
|
}
|
|
|
|
System.Threading.Monitor.Wait(
|
|
_gate,
|
|
checked((int)Math.Min(remaining, 1_000)));
|
|
continue;
|
|
}
|
|
|
|
// CPU-visible GPU writes are ordering points in the guest
|
|
// command stream. First-use shader compilation can take more
|
|
// than a minute on MoltenVK; timing out would let the guest
|
|
// consume stale zero-filled buffers and permanently corrupt
|
|
// the frame. Closing the presenter pulses this monitor, so an
|
|
// unbounded correctness wait remains interruptible.
|
|
System.Threading.Monitor.Wait(_gate, 1_000);
|
|
}
|
|
|
|
var completed = IsGuestWorkCompletedLocked(workSequence);
|
|
if (_traceGuestWorkCompletion)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_work_wait_exit sequence={workSequence} " +
|
|
$"completed={completed} contiguous_completed={_completedGuestWorkSequence} " +
|
|
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
|
|
}
|
|
return completed;
|
|
}
|
|
}
|
|
|
|
public static bool TrySubmitGuestImage(
|
|
ulong address,
|
|
uint width,
|
|
uint height,
|
|
uint pitchInPixel)
|
|
{
|
|
var traceSubmission = false;
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
!_availableGuestImages.ContainsKey(address))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
traceSubmission =
|
|
_tracedGuestImageSubmissions.Add((address, width, height));
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
var requiredWorkSequence = _guestImageWorkSequences.TryGetValue(
|
|
address,
|
|
out var imageWorkSequence)
|
|
? imageWorkSequence
|
|
: _completedGuestWorkSequence;
|
|
var presentation = new Presentation(
|
|
null,
|
|
width,
|
|
height,
|
|
sequence,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
// Wait only for the work that last wrote this image, not for
|
|
// every later command the guest has already queued. Requiring
|
|
// the global tail makes a fast guest permanently outrun the
|
|
// renderer and turns every flip into a dropped black frame.
|
|
RequiredGuestWorkSequence: requiredWorkSequence,
|
|
IsSplash: false,
|
|
GuestImageAddress: address);
|
|
_latestPresentation = presentation;
|
|
_pendingGuestImagePresentations.Enqueue(presentation);
|
|
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
|
|
{
|
|
_pendingGuestImagePresentations.Dequeue();
|
|
}
|
|
}
|
|
|
|
if (traceSubmission)
|
|
{
|
|
var effectivePitch = pitchInPixel == 0 ? width : pitchInPixel;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.submit_guest_image addr=0x{address:X16} " +
|
|
$"size={width}x{height} pitch={effectivePitch}");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues an AGC flip at its exact position in the logical guest queue.
|
|
/// The presenter captures the named image into an immutable Vulkan image
|
|
/// before it executes later work from the same queue. Presentation then
|
|
/// consumes that captured generation rather than the mutable render target.
|
|
/// </summary>
|
|
public static bool TrySubmitOrderedGuestImageFlip(
|
|
int videoOutHandle,
|
|
int displayBufferIndex,
|
|
ulong address,
|
|
uint width,
|
|
uint height,
|
|
uint pitchInPixel)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
_thread is null ||
|
|
!_availableGuestImages.ContainsKey(address))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var version = ++_orderedGuestFlipVersionSequence;
|
|
_lastOrderedGuestFlipVersions[(videoOutHandle, displayBufferIndex)] = version;
|
|
return EnqueueGuestWorkLocked(
|
|
new VulkanOrderedGuestFlip(
|
|
version,
|
|
videoOutHandle,
|
|
displayBufferIndex,
|
|
address,
|
|
width,
|
|
height,
|
|
pitchInPixel)) > 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Preserves sceAgcDcbWaitUntilSafeForRendering in queue order. Because an
|
|
/// ordered flip first copies the mutable render target into an immutable
|
|
/// generation on the same Vulkan queue, reaching this marker proves later
|
|
/// rendering cannot change the frame selected by that flip. No CPU wait or
|
|
/// event-loop stall is required.
|
|
/// </summary>
|
|
public static long SubmitOrderedGuestFlipWait(
|
|
int videoOutHandle,
|
|
int displayBufferIndex)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
var version = _lastOrderedGuestFlipVersions.TryGetValue(
|
|
(videoOutHandle, displayBufferIndex),
|
|
out var lastVersion)
|
|
? lastVersion
|
|
: 0;
|
|
return _closed || _thread is null
|
|
? 0
|
|
: EnqueueGuestWorkLocked(
|
|
new VulkanOrderedGuestFlipWait(
|
|
version,
|
|
videoOutHandle,
|
|
displayBufferIndex));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// On PS5 a render target aliases guest memory, so CPU-prefilled pixels are
|
|
/// visible before the first draw. Our Vulkan images start undefined, so the
|
|
/// first draw into a new address must seed the image from guest memory.
|
|
/// </summary>
|
|
internal static bool GuestImageWantsInitialData(ulong address)
|
|
{
|
|
if (address == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
return !_availableGuestImages.ContainsKey(address) &&
|
|
!_pendingGuestImageInitialData.ContainsKey(address);
|
|
}
|
|
}
|
|
|
|
internal static void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_pendingGuestImageInitialData[address] = rgbaPixels;
|
|
}
|
|
}
|
|
|
|
internal static ulong GetGuestImageByteCount(uint format, uint width, uint height)
|
|
{
|
|
var blockBytes = format switch
|
|
{
|
|
169 or 170 or 175 or 176 => 8UL,
|
|
171 or 172 or 173 or 174 or
|
|
177 or 178 or 179 or 180 or 181 or 182 => 16UL,
|
|
_ => 0UL,
|
|
};
|
|
if (blockBytes != 0)
|
|
{
|
|
return checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * blockBytes);
|
|
}
|
|
|
|
var bytesPerPixel = format switch
|
|
{
|
|
1 => 1UL,
|
|
2 or 3 or 16 or 17 or 19 => 2UL,
|
|
11 or 12 => 8UL,
|
|
13 => 12UL,
|
|
14 => 16UL,
|
|
_ => 4UL,
|
|
};
|
|
return checked((ulong)width * height * bytesPerPixel);
|
|
}
|
|
|
|
private static byte[]? TakeGuestImageInitialData(ulong address)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (!_pendingGuestImageInitialData.TryGetValue(address, out var data))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
_pendingGuestImageInitialData.Remove(address);
|
|
return data;
|
|
}
|
|
}
|
|
|
|
// Mirror of the render thread's texture-cache identities, readable from
|
|
// the guest submit thread. The AGC translator used to allocate and copy
|
|
// every referenced texture's texels out of guest memory on every draw,
|
|
// only for the presenter to discard the bytes on a cache hit — for a
|
|
// scene sampling large textures this was by far the dominant CPU cost
|
|
// (gigabytes/second of allocation, page faults and GC pressure).
|
|
// ConcurrentDictionary keyed set: reads happen per texture per draw on
|
|
// the guest submit thread and must not contend with the render thread's
|
|
// mutations.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
|
|
TextureContentIdentity, byte> _cachedTextureIdentities = new();
|
|
|
|
// Guest memory handle for render-thread self-healing: when a draw whose
|
|
// texel copy was skipped misses the texture cache (eviction, cache
|
|
// clear, or any other race), the presenter re-reads the texels itself
|
|
// instead of showing a fallback pattern.
|
|
private static volatile SharpEmu.HLE.ICpuMemory? _guestMemory;
|
|
|
|
internal static void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
|
|
_guestMemory = memory;
|
|
|
|
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
|
|
_cachedTextureIdentities.ContainsKey(identity);
|
|
|
|
private static void MarkTextureContentCached(in TextureContentIdentity identity) =>
|
|
_cachedTextureIdentities.TryAdd(identity, 0);
|
|
|
|
private static void UnmarkTextureContentCached(in TextureContentIdentity identity) =>
|
|
_cachedTextureIdentities.TryRemove(identity, out _);
|
|
|
|
private static void ClearCachedTextureIdentities() =>
|
|
_cachedTextureIdentities.Clear();
|
|
|
|
internal static bool IsGuestImageAvailable(
|
|
ulong address,
|
|
uint format,
|
|
uint numberType)
|
|
{
|
|
var guestFormat = GetGuestTextureFormat(format, numberType);
|
|
if (address == 0 || guestFormat == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
|
|
availableFormat == guestFormat;
|
|
}
|
|
}
|
|
|
|
// Display buffers registered through sceVideoOutRegisterBuffers remain
|
|
// valid flip targets even before AGC has rendered into them.
|
|
internal static void RegisterKnownDisplayBuffer(ulong address, uint guestFormat)
|
|
{
|
|
if (address == 0 || guestFormat == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
_availableGuestImages[address] = guestFormat;
|
|
}
|
|
}
|
|
|
|
internal static bool IsGpuGuestImageAvailable(
|
|
ulong address,
|
|
uint format,
|
|
uint numberType) =>
|
|
IsGuestImageAvailable(address, format, numberType);
|
|
|
|
/// <summary>
|
|
/// Returns whether a storage image already exists on the presenter or an
|
|
/// earlier queued dispatch owns its one-time guest-memory initialization.
|
|
/// This is intentionally separate from <see cref="IsGuestImageAvailable"/>:
|
|
/// a pending image may skip a duplicate upload but is not yet safe for a
|
|
/// flip/presentation lookup.
|
|
/// </summary>
|
|
internal static bool IsGuestImageUploadKnown(
|
|
ulong address,
|
|
uint format,
|
|
uint numberType)
|
|
{
|
|
var guestFormat = GetGuestTextureFormat(format, numberType);
|
|
if (address == 0 || guestFormat == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
|
|
availableFormat == guestFormat ||
|
|
_pendingGuestImageUploads.ContainsKey((address, guestFormat));
|
|
}
|
|
}
|
|
|
|
public static bool TrySubmitGuestImageBlit(
|
|
ulong sourceAddress,
|
|
uint sourceWidth,
|
|
uint sourceHeight,
|
|
uint sourceFormat,
|
|
uint sourceNumberType,
|
|
ulong destinationAddress,
|
|
uint destinationWidth,
|
|
uint destinationHeight,
|
|
uint destinationFormat,
|
|
uint destinationNumberType)
|
|
{
|
|
if (sourceAddress == 0 ||
|
|
destinationAddress == 0 ||
|
|
sourceWidth == 0 ||
|
|
sourceHeight == 0 ||
|
|
destinationWidth == 0 ||
|
|
destinationHeight == 0 ||
|
|
!TryGetCopyFragmentShader(out var fragmentSpirv))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
!_availableGuestImages.ContainsKey(sourceAddress) ||
|
|
GetGuestTextureFormat(destinationFormat, 0) == 0)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
SubmitOffscreenTranslatedDraw(
|
|
fragmentSpirv,
|
|
[
|
|
new GuestDrawTexture(
|
|
sourceAddress,
|
|
sourceWidth,
|
|
sourceHeight,
|
|
sourceFormat,
|
|
sourceNumberType,
|
|
[],
|
|
IsFallback: false,
|
|
IsStorage: false),
|
|
],
|
|
[],
|
|
attributeCount: 1,
|
|
new GuestRenderTarget(
|
|
destinationAddress,
|
|
destinationWidth,
|
|
destinationHeight,
|
|
destinationFormat,
|
|
destinationNumberType));
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetCopyFragmentShader(out byte[] spirv)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_copyFragmentSpirv is not null)
|
|
{
|
|
spirv = _copyFragmentSpirv;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
spirv = SpirvFixedShaders.CreateCopyFragment();
|
|
|
|
lock (_gate)
|
|
{
|
|
_copyFragmentSpirv ??= spirv;
|
|
spirv = _copyFragmentSpirv;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static uint GetGuestTextureFormat(uint format, uint numberType) =>
|
|
IsKnownGuestTextureFormat(format)
|
|
? 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu)
|
|
: 0;
|
|
|
|
internal static bool TryDecodeRenderTargetFormat(
|
|
uint dataFormat,
|
|
uint numberType,
|
|
out VulkanRenderTargetFormat result)
|
|
{
|
|
var format = (dataFormat, numberType) switch
|
|
{
|
|
(4, 4) => Format.R32Uint,
|
|
(4, 5) => Format.R32Sint,
|
|
(4, 7) => Format.R32Sfloat,
|
|
(5, 4) => Format.R16G16Uint,
|
|
(5, 5) => Format.R16G16Sint,
|
|
(5, 7) => Format.R16G16Sfloat,
|
|
(6, 7) or (7, 7) => Format.B10G11R11UfloatPack32,
|
|
(9, _) => Format.A2R10G10B10UnormPack32,
|
|
(10, 4) => Format.R8G8B8A8Uint,
|
|
(10, 5) => Format.R8G8B8A8Sint,
|
|
(10, 9) => Format.R8G8B8A8Srgb,
|
|
(10, _) => Format.R8G8B8A8Unorm,
|
|
(11, 7) => Format.R32G32Sfloat,
|
|
(12, 4) => Format.R16G16B16A16Uint,
|
|
(12, 5) => Format.R16G16B16A16Sint,
|
|
(12, 7) => Format.R16G16B16A16Sfloat,
|
|
(13, 7) or (14, 7) => Format.R32G32B32A32Sfloat,
|
|
(20, 0) => Format.R32Uint,
|
|
(29, 0) or (4, 0) => Format.R32Sfloat,
|
|
(1, 0) or (36, 0) => Format.R8Unorm,
|
|
(49, 0) => Format.R8Uint,
|
|
(3, 0) => Format.R8G8Unorm,
|
|
(5, 0) => Format.R16G16Unorm,
|
|
(7, 0) => Format.B10G11R11UfloatPack32,
|
|
(12, 0) => Format.R16G16B16A16Unorm,
|
|
(13, 0) or (14, 0) => Format.R32G32B32A32Sfloat,
|
|
(22, 0) or (71, 0) => Format.R16G16B16A16Sfloat,
|
|
(56, 0) or (62, 0) or (64, 0) => Format.R8G8B8A8Unorm,
|
|
(75, 0) => Format.R32G32Sfloat,
|
|
_ => Format.Undefined,
|
|
};
|
|
|
|
if (format == Format.Undefined)
|
|
{
|
|
result = default;
|
|
return false;
|
|
}
|
|
|
|
var outputKind = format switch
|
|
{
|
|
Format.R8Uint or Format.R32Uint or Format.R16G16Uint or
|
|
Format.R8G8B8A8Uint or Format.R16G16B16A16Uint => Gen5PixelOutputKind.Uint,
|
|
Format.R32Sint or Format.R16G16Sint or Format.R8G8B8A8Sint or
|
|
Format.R16G16B16A16Sint => Gen5PixelOutputKind.Sint,
|
|
_ => Gen5PixelOutputKind.Float,
|
|
};
|
|
result = new VulkanRenderTargetFormat(format, outputKind);
|
|
return true;
|
|
}
|
|
|
|
private static bool IsKnownGuestTextureFormat(uint format) =>
|
|
format is >= 1 and <= 19 or 34 or >= 169 and <= 182;
|
|
|
|
private static byte[] CreateBlackFrame(uint width, uint height)
|
|
{
|
|
if (width == 0 || height == 0 || width > 8192 || height > 8192)
|
|
{
|
|
width = 1;
|
|
height = 1;
|
|
}
|
|
|
|
var pixels = GC.AllocateUninitializedArray<byte>(checked((int)(width * height * 4)));
|
|
pixels.AsSpan().Clear();
|
|
for (var offset = 3; offset < pixels.Length; offset += 4)
|
|
{
|
|
pixels[offset] = 0xFF;
|
|
}
|
|
|
|
return pixels;
|
|
}
|
|
|
|
private static void StartPresenterLocked()
|
|
{
|
|
if (_hostSurface is null && HostMainThread.IsAvailable)
|
|
{
|
|
// AppKit (and therefore GLFW) traps when touched off the process
|
|
// main thread on macOS, so hand the whole window loop to the
|
|
// main-thread pump the CLI parked for us. _thread only marks the
|
|
// presenter as running; Run() clears it on exit either way.
|
|
_thread = Thread.CurrentThread;
|
|
HostMainThread.SetShutdownRequestHandler(RequestClose);
|
|
HostMainThread.Post(Run);
|
|
return;
|
|
}
|
|
|
|
_thread = new Thread(Run)
|
|
{
|
|
IsBackground = true,
|
|
Name = "SharpEmu Vulkan VideoOut",
|
|
};
|
|
_thread.Start();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks a running presenter to close its window; used at emulator
|
|
/// shutdown so a main-thread-hosted window loop returns to the pump.
|
|
/// </summary>
|
|
public static void RequestClose()
|
|
{
|
|
Volatile.Write(ref _presenterCloseRequested, true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which cannot
|
|
/// find the app-local MoltenVK on macOS (Homebrew's Vulkan libraries are
|
|
/// arm64-only and this is an x86-64 process). GLFW 3.4 accepts the
|
|
/// loader entry point directly instead, so hand it MoltenVK's
|
|
/// vkGetInstanceProcAddr before any window exists.
|
|
/// </summary>
|
|
private static unsafe void InitializeMacVulkanLoader()
|
|
{
|
|
if (!OperatingSystem.IsMacOS())
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
nint vulkan = 0;
|
|
foreach (var candidate in new[]
|
|
{
|
|
Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
|
|
Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
|
|
"libvulkan.1.dylib",
|
|
"libMoltenVK.dylib",
|
|
})
|
|
{
|
|
if (System.Runtime.InteropServices.NativeLibrary.TryLoad(candidate, out vulkan))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (vulkan == 0 ||
|
|
!System.Runtime.InteropServices.NativeLibrary.TryGetExport(
|
|
vulkan, "vkGetInstanceProcAddr", out var procAddr))
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] No Vulkan loader for GLFW; place a universal libMoltenVK.dylib " +
|
|
"next to SharpEmu as libvulkan.1.dylib.");
|
|
return;
|
|
}
|
|
|
|
var glfw = System.Runtime.InteropServices.NativeLibrary.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "libglfw.3.dylib"));
|
|
var initVulkanLoader = (delegate* unmanaged<nint, void>)
|
|
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitVulkanLoader");
|
|
initVulkanLoader(procAddr);
|
|
Console.Error.WriteLine("[LOADER][INFO] GLFW Vulkan loader wired to MoltenVK.");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][WARN] GLFW Vulkan loader setup failed: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private static unsafe void PreferX11OnLinuxWayland()
|
|
{
|
|
if (!OperatingSystem.IsLinux() ||
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_WAYLAND"),
|
|
"1",
|
|
StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] Wayland session without an X server (DISPLAY unset); " +
|
|
"cannot steer GLFW to XWayland. Set SHARPEMU_ENABLE_WAYLAND=1 to use native Wayland.");
|
|
return;
|
|
}
|
|
|
|
if (!TryLoadGlfw(out var glfw))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var initHint = (delegate* unmanaged<int, int, void>)
|
|
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitHint");
|
|
initHint(GlfwPlatformHint, GlfwPlatformX11);
|
|
Console.Error.WriteLine(
|
|
"[LOADER][INFO] Wayland session detected; requested GLFW X11/XWayland backend.");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Could not set GLFW X11 platform hint: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private static unsafe void LogGlfwPlatformInUse()
|
|
{
|
|
if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var getPlatform = (delegate* unmanaged<int>)
|
|
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwGetPlatform");
|
|
var platform = getPlatform();
|
|
var label = platform switch
|
|
{
|
|
GlfwPlatformWin32 => "Win32",
|
|
GlfwPlatformCocoa => "Cocoa",
|
|
GlfwPlatformWayland => "Wayland",
|
|
GlfwPlatformX11 => "X11",
|
|
GlfwPlatformNull => "Null",
|
|
_ => $"0x{platform:X}",
|
|
};
|
|
Console.Error.WriteLine($"[LOADER][INFO] GLFW windowing platform in use: {label}");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][WARN] Could not query GLFW platform: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private static bool TryLoadGlfw(out nint handle)
|
|
{
|
|
var name = OperatingSystem.IsMacOS() ? "libglfw.3.dylib" : "libglfw.so.3";
|
|
return System.Runtime.InteropServices.NativeLibrary.TryLoad(
|
|
Path.Combine(AppContext.BaseDirectory, name), out handle) ||
|
|
System.Runtime.InteropServices.NativeLibrary.TryLoad(name, out handle);
|
|
}
|
|
|
|
private static void Run()
|
|
{
|
|
uint width;
|
|
uint height;
|
|
VulkanHostSurface? hostSurface;
|
|
lock (_gate)
|
|
{
|
|
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
|
|
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
|
|
hostSurface = _hostSurface;
|
|
}
|
|
|
|
if (hostSurface is null)
|
|
{
|
|
PreferX11OnLinuxWayland();
|
|
InitializeMacVulkanLoader();
|
|
}
|
|
|
|
try
|
|
{
|
|
using var presenter = new Presenter(width, height, hostSurface);
|
|
presenter.Run();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][ERROR] Vulkan VideoOut presenter failed: {exception}");
|
|
}
|
|
finally
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_closed = true;
|
|
_thread = null;
|
|
if (_hostSurfacePendingDetach is not null &&
|
|
ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
|
|
{
|
|
_hostSurface = null;
|
|
_hostSurfacePendingDetach = null;
|
|
}
|
|
System.Threading.Monitor.PulseAll(_gate);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool TryTakePresentation(long presentedSequence, out Presentation presentation)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
// Guest flips are retained in submission order. The renderer is
|
|
// deliberately allowed to lag a frame or two behind the guest
|
|
// while it drains expensive work, so use the first completed flip
|
|
// rather than repeatedly asking only for the newest one.
|
|
while (_pendingGuestImagePresentations.Count > 0 &&
|
|
_pendingGuestImagePresentations.Peek().Sequence <= presentedSequence)
|
|
{
|
|
_pendingGuestImagePresentations.Dequeue();
|
|
}
|
|
|
|
if (_pendingGuestImagePresentations.Count > 0)
|
|
{
|
|
var pending = _pendingGuestImagePresentations.Peek();
|
|
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
|
|
{
|
|
presentation = _pendingGuestImagePresentations.Dequeue();
|
|
TryReplaceWithBinkFrame(ref presentation);
|
|
return true;
|
|
}
|
|
|
|
presentation = default;
|
|
return false;
|
|
}
|
|
|
|
if (_latestPresentation is not { } latest ||
|
|
latest.Sequence == presentedSequence ||
|
|
!IsGuestWorkCompletedLocked(latest.RequiredGuestWorkSequence))
|
|
{
|
|
if (_latestPresentation is { } rej &&
|
|
rej.GuestImageAddress != 0 &&
|
|
rej.Sequence != presentedSequence &&
|
|
_tracedGuestImagePresentRejections.Add(rej.Sequence))
|
|
{
|
|
var reason = rej.Sequence == presentedSequence
|
|
? "already-presented(seq==presented)"
|
|
: !IsGuestWorkCompletedLocked(rej.RequiredGuestWorkSequence)
|
|
? $"work-not-done(req={rej.RequiredGuestWorkSequence}>" +
|
|
$"contiguous_done={_completedGuestWorkSequence})"
|
|
: "unknown";
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.guest_present_rejected addr=0x{rej.GuestImageAddress:X16} " +
|
|
$"seq={rej.Sequence} presentedSeq={presentedSequence} reason={reason}");
|
|
}
|
|
|
|
presentation = default;
|
|
return false;
|
|
}
|
|
|
|
presentation = latest;
|
|
TryReplaceWithBinkFrame(ref presentation);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static void TryReplaceWithBinkFrame(ref Presentation presentation)
|
|
{
|
|
if (!Bink2MovieBridge.TryDecodeNextFrame(out var pixels, out var width, out var height))
|
|
{
|
|
return;
|
|
}
|
|
|
|
presentation = new Presentation(
|
|
pixels,
|
|
width,
|
|
height,
|
|
presentation.Sequence,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
presentation.RequiredGuestWorkSequence,
|
|
IsSplash: false);
|
|
}
|
|
|
|
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
|
|
|
|
private static bool HasPendingGuestPresentation(long presentedSequence)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _pendingGuestImagePresentations.Count > 0 ||
|
|
_latestPresentation is { } latest && latest.Sequence > presentedSequence;
|
|
}
|
|
}
|
|
|
|
private static long EnqueueGuestWorkLocked(object work)
|
|
{
|
|
var payloadBytes = GetGuestWorkPayloadBytes(work);
|
|
var backpressureLogged = false;
|
|
// Work executed by the render-thread consumer can enqueue an ordered
|
|
// same-queue completion marker. Blocking that consumer on the normal
|
|
// producer backpressure limit deadlocks a full queue: no other thread
|
|
// can drain an item to make room for the marker. The consumer has
|
|
// already removed the current item, and each immediate follow-up is
|
|
// bounded by that item, so admitting it cannot cause unbounded growth.
|
|
while (!_enqueueAsImmediateQueueFollowup &&
|
|
!_closed &&
|
|
_thread is not null &&
|
|
(_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
|
|
// Always admit one item when no payload is outstanding, even
|
|
// when that single item exceeds the configured budget. This
|
|
// avoids an impossible wait while still bounding the normal
|
|
// multi-item backlog.
|
|
(_pendingGuestWorkBytes != 0 &&
|
|
payloadBytes > _maxPendingGuestWorkBytes -
|
|
Math.Min(_pendingGuestWorkBytes, _maxPendingGuestWorkBytes))))
|
|
{
|
|
if (!backpressureLogged)
|
|
{
|
|
backpressureLogged = true;
|
|
var traceCount = Interlocked.Increment(
|
|
ref _guestQueueBackpressureTraceCount);
|
|
if (traceCount <= 16 || (traceCount & (traceCount - 1)) == 0)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_queue_backpressure " +
|
|
$"count={traceCount} " +
|
|
$"queued={_pendingGuestWorkCount} " +
|
|
$"logical_queues={_pendingGuestWorkByQueue.Count} " +
|
|
$"retained_mb={_pendingGuestWorkBytes / (1024 * 1024)} " +
|
|
$"incoming_mb={payloadBytes / (1024 * 1024)} " +
|
|
$"budget_mb={_maxPendingGuestWorkBytes / (1024 * 1024)} " +
|
|
$"work={work.GetType().Name}" +
|
|
GetGuestWorkPayloadBreakdown(work));
|
|
}
|
|
}
|
|
|
|
System.Threading.Monitor.Wait(_gate);
|
|
}
|
|
|
|
if (_closed)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var queue = _submittingGuestQueue ?? VulkanGuestQueueIdentity.Default;
|
|
var sequence = ++_enqueuedGuestWorkSequence;
|
|
_lastEnqueuedGuestWorkByQueue[queue.Name] = sequence;
|
|
var requiredSequence = GetGuestWorkDependencyLocked(work);
|
|
if (!_pendingGuestWorkByQueue.TryGetValue(queue.Name, out var pendingQueue))
|
|
{
|
|
pendingQueue = new LinkedList<PendingGuestWork>();
|
|
_pendingGuestWorkByQueue.Add(queue.Name, pendingQueue);
|
|
_pendingGuestQueueSchedule.Add(queue.Name);
|
|
}
|
|
|
|
var pending = new PendingGuestWork(
|
|
work,
|
|
payloadBytes,
|
|
sequence,
|
|
requiredSequence,
|
|
System.Diagnostics.Stopwatch.GetTimestamp(),
|
|
queue);
|
|
if (_traceGuestWorkCompletion)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_work_enqueue sequence={sequence} " +
|
|
$"required={requiredSequence} queue={queue.Name} " +
|
|
$"immediate={_enqueueAsImmediateQueueFollowup} " +
|
|
$"work={work.GetType().Name}");
|
|
}
|
|
if (_enqueueAsImmediateQueueFollowup &&
|
|
_immediateFollowupTail is { List: not null } tail &&
|
|
ReferenceEquals(tail.List, pendingQueue))
|
|
{
|
|
_immediateFollowupTail = pendingQueue.AddAfter(tail, pending);
|
|
}
|
|
else if (_enqueueAsImmediateQueueFollowup)
|
|
{
|
|
_immediateFollowupTail = pendingQueue.AddFirst(pending);
|
|
}
|
|
else
|
|
{
|
|
pendingQueue.AddLast(pending);
|
|
}
|
|
RecordGuestImageWritersLocked(work, sequence);
|
|
_pendingGuestWorkCount++;
|
|
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
|
|
// Wake the embedded render loop parked in WaitForRenderWork; without a
|
|
// pulse it only notices new work when its timed wait expires.
|
|
System.Threading.Monitor.PulseAll(_gate);
|
|
return sequence;
|
|
}
|
|
|
|
private static long GetGuestWorkDependencyLocked(object work)
|
|
{
|
|
IReadOnlyList<GuestDrawTexture> textures = work switch
|
|
{
|
|
VulkanOffscreenGuestDraw draw => draw.Draw.Textures,
|
|
VulkanComputeGuestDispatch compute => compute.Textures,
|
|
_ => Array.Empty<GuestDrawTexture>(),
|
|
};
|
|
var required = 0L;
|
|
foreach (var texture in textures)
|
|
{
|
|
if (!texture.IsStorage ||
|
|
texture.Address == 0 ||
|
|
texture.RgbaPixels.Length != 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var format = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
|
if (_pendingGuestImageUploads.TryGetValue(
|
|
(texture.Address, format),
|
|
out var pendingUpload))
|
|
{
|
|
required = Math.Max(required, pendingUpload.OwnerSequence);
|
|
}
|
|
}
|
|
|
|
return required;
|
|
}
|
|
|
|
private static void RecordGuestImageWritersLocked(object work, long sequence)
|
|
{
|
|
static IEnumerable<ulong> StorageAddresses(
|
|
IReadOnlyList<GuestDrawTexture> textures) =>
|
|
textures
|
|
.Where(static texture => texture.IsStorage && texture.Address != 0)
|
|
.Select(static texture => texture.Address);
|
|
|
|
IEnumerable<ulong> addresses = work switch
|
|
{
|
|
VulkanOffscreenGuestDraw draw =>
|
|
(draw.PublishTarget
|
|
? draw.Targets
|
|
.Where(static target => target.Address != 0)
|
|
.Select(static target => target.Address)
|
|
: Enumerable.Empty<ulong>())
|
|
.Concat(StorageAddresses(draw.Draw.Textures)),
|
|
VulkanComputeGuestDispatch compute => StorageAddresses(compute.Textures),
|
|
VulkanGuestImageWrite imageWrite when imageWrite.Address != 0 =>
|
|
new[] { imageWrite.Address },
|
|
_ => Array.Empty<ulong>(),
|
|
};
|
|
foreach (var address in addresses.Distinct())
|
|
{
|
|
_guestImageWorkSequences[address] = sequence;
|
|
}
|
|
}
|
|
|
|
private static bool TryTakeGuestWork(out PendingGuestWork work)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
var queuesToProbe = _pendingGuestQueueSchedule.Count;
|
|
while (_pendingGuestQueueSchedule.Count > 0 && queuesToProbe > 0)
|
|
{
|
|
if (_pendingGuestQueueCursor >= _pendingGuestQueueSchedule.Count)
|
|
{
|
|
_pendingGuestQueueCursor = 0;
|
|
}
|
|
|
|
var queueName = _pendingGuestQueueSchedule[_pendingGuestQueueCursor];
|
|
if (!_pendingGuestWorkByQueue.TryGetValue(queueName, out var queue) ||
|
|
queue.First is not { } first)
|
|
{
|
|
_pendingGuestWorkByQueue.Remove(queueName);
|
|
_pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor);
|
|
queuesToProbe = Math.Min(
|
|
queuesToProbe,
|
|
_pendingGuestQueueSchedule.Count);
|
|
continue;
|
|
}
|
|
|
|
work = first.Value;
|
|
if (!IsGuestWorkCompletedLocked(work.RequiredSequence))
|
|
{
|
|
_pendingGuestQueueCursor =
|
|
(_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count;
|
|
queuesToProbe--;
|
|
continue;
|
|
}
|
|
|
|
queue.RemoveFirst();
|
|
_pendingGuestWorkCount--;
|
|
if (queue.Count == 0)
|
|
{
|
|
_pendingGuestWorkByQueue.Remove(queueName);
|
|
_pendingGuestQueueSchedule.RemoveAt(_pendingGuestQueueCursor);
|
|
}
|
|
else
|
|
{
|
|
_pendingGuestQueueCursor =
|
|
(_pendingGuestQueueCursor + 1) % _pendingGuestQueueSchedule.Count;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
work = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void CompleteGuestWork(in PendingGuestWork pending)
|
|
{
|
|
SharpEmu.HLE.GuestImageWriteTracker.FlushPendingDiagnostics();
|
|
lock (_gate)
|
|
{
|
|
if (_traceGuestWorkCompletion)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_work_complete_enter " +
|
|
$"sequence={pending.Sequence} work={pending.Work.GetType().Name} " +
|
|
$"contiguous_completed={_completedGuestWorkSequence} " +
|
|
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
|
|
}
|
|
_pendingGuestWorkBytes = pending.PayloadBytes >= _pendingGuestWorkBytes
|
|
? 0
|
|
: _pendingGuestWorkBytes - pending.PayloadBytes;
|
|
ReleasePendingGuestImageUploadsLocked(pending.Work);
|
|
if (pending.Sequence == _completedGuestWorkSequence + 1)
|
|
{
|
|
_completedGuestWorkSequence = pending.Sequence;
|
|
while (_completedGuestWorkOutOfOrder.Remove(
|
|
_completedGuestWorkSequence + 1))
|
|
{
|
|
_completedGuestWorkSequence++;
|
|
}
|
|
}
|
|
else if (pending.Sequence > _completedGuestWorkSequence)
|
|
{
|
|
// Debug.Assert calls are compiled out of Release builds, so
|
|
// never put the state mutation inside its argument. Doing so
|
|
// discarded every out-of-order completion in normal runs and
|
|
// left the first immediate follow-up as a permanent sequence
|
|
// hole, blocking all later CPU-visible GPU waits.
|
|
var added = _completedGuestWorkOutOfOrder.Add(pending.Sequence);
|
|
System.Diagnostics.Debug.Assert(
|
|
added,
|
|
"A guest work sequence must complete exactly once.");
|
|
}
|
|
System.Threading.Monitor.PulseAll(_gate);
|
|
if (_traceGuestWorkCompletion)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_work_complete_exit " +
|
|
$"sequence={pending.Sequence} contiguous_completed={_completedGuestWorkSequence} " +
|
|
$"out_of_order={_completedGuestWorkOutOfOrder.Count}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static ulong GetGuestWorkPayloadBytes(object work) => work switch
|
|
{
|
|
VulkanComputeGuestDispatch compute => SaturatingAdd(
|
|
GetTexturePayloadBytes(compute.Textures),
|
|
GetGlobalBufferPayloadBytes(compute.GlobalMemoryBuffers)),
|
|
VulkanOffscreenGuestDraw offscreen => GetDrawPayloadBytes(offscreen.Draw),
|
|
VulkanGuestImageWrite { Pixels: { } pixels } => (ulong)pixels.LongLength,
|
|
_ => 0,
|
|
};
|
|
|
|
private static string GetGuestWorkPayloadBreakdown(object work)
|
|
{
|
|
static ulong SumTextures(IReadOnlyList<GuestDrawTexture> textures) =>
|
|
GetTexturePayloadBytes(textures) / (1024 * 1024);
|
|
static ulong SumGlobals(IReadOnlyList<GuestMemoryBuffer> buffers) =>
|
|
GetGlobalBufferPayloadBytes(buffers) / (1024 * 1024);
|
|
|
|
return work switch
|
|
{
|
|
VulkanOffscreenGuestDraw offscreen =>
|
|
$" textures_mb={SumTextures(offscreen.Draw.Textures)}" +
|
|
$" globals_mb={SumGlobals(offscreen.Draw.GlobalMemoryBuffers)}" +
|
|
$" vertex_mb={offscreen.Draw.VertexBuffers.Aggregate(0UL, static (sum, buffer) => SaturatingAdd(sum, (ulong)buffer.Data.LongLength)) / (1024 * 1024)}" +
|
|
$" index_mb={(ulong)(offscreen.Draw.IndexBuffer?.Data.LongLength ?? 0) / (1024 * 1024)}" +
|
|
$" vertex_lengths=[{string.Join(',', offscreen.Draw.VertexBuffers.Select(static buffer => $"{buffer.Length}/{buffer.Data.LongLength}:s{buffer.Stride}:o{buffer.OffsetBytes}"))}]" +
|
|
$" global_lengths=[{string.Join(',', offscreen.Draw.GlobalMemoryBuffers.Select(static buffer => buffer.Length))}]",
|
|
VulkanComputeGuestDispatch compute =>
|
|
$" textures_mb={SumTextures(compute.Textures)}" +
|
|
$" globals_mb={SumGlobals(compute.GlobalMemoryBuffers)}" +
|
|
$" global_lengths=[{string.Join(',', compute.GlobalMemoryBuffers.Select(static buffer => buffer.Length))}]",
|
|
_ => string.Empty,
|
|
};
|
|
}
|
|
|
|
private static ulong GetDrawPayloadBytes(VulkanTranslatedGuestDraw draw)
|
|
{
|
|
var bytes = GetTexturePayloadBytes(draw.Textures);
|
|
bytes = SaturatingAdd(bytes, GetGlobalBufferPayloadBytes(draw.GlobalMemoryBuffers));
|
|
var uniqueVertexData = new HashSet<byte[]>(
|
|
System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
|
foreach (var vertex in draw.VertexBuffers)
|
|
{
|
|
if (uniqueVertexData.Add(vertex.Data))
|
|
{
|
|
bytes = SaturatingAdd(bytes, (ulong)vertex.Data.LongLength);
|
|
}
|
|
}
|
|
|
|
if (draw.IndexBuffer is { } index)
|
|
{
|
|
bytes = SaturatingAdd(bytes, (ulong)index.Data.LongLength);
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
private static ulong GetTexturePayloadBytes(
|
|
IReadOnlyList<GuestDrawTexture> textures)
|
|
{
|
|
var bytes = 0UL;
|
|
foreach (var texture in textures)
|
|
{
|
|
bytes = SaturatingAdd(bytes, (ulong)texture.RgbaPixels.LongLength);
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
private static ulong GetGlobalBufferPayloadBytes(
|
|
IReadOnlyList<GuestMemoryBuffer> buffers)
|
|
{
|
|
var bytes = 0UL;
|
|
foreach (var buffer in buffers)
|
|
{
|
|
bytes = SaturatingAdd(bytes, (ulong)buffer.Data.LongLength);
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
private static ulong SaturatingAdd(ulong left, ulong right) =>
|
|
ulong.MaxValue - left < right ? ulong.MaxValue : left + right;
|
|
|
|
private static void ReleasePendingGuestImageUploadsLocked(object work)
|
|
{
|
|
if (work is not VulkanComputeGuestDispatch compute)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var key in GetStorageImageUploadKeys(compute.Textures))
|
|
{
|
|
if (!_pendingGuestImageUploads.TryGetValue(key, out var pendingUpload))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (pendingUpload.Count <= 1)
|
|
{
|
|
_pendingGuestImageUploads.Remove(key);
|
|
}
|
|
else
|
|
{
|
|
_pendingGuestImageUploads[key] = pendingUpload with
|
|
{
|
|
Count = pendingUpload.Count - 1,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
private static HashSet<(ulong Address, uint Format)> GetStorageImageUploadKeys(
|
|
IReadOnlyList<GuestDrawTexture> textures)
|
|
{
|
|
var keys = new HashSet<(ulong Address, uint Format)>();
|
|
foreach (var texture in textures)
|
|
{
|
|
if (!texture.IsStorage || texture.Address == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var format = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
|
if (format != 0)
|
|
{
|
|
keys.Add((texture.Address, format));
|
|
}
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
internal static bool ShouldAttachGuestDepth(
|
|
GuestDepthTarget? target,
|
|
GuestDepthState state) =>
|
|
target is not null &&
|
|
(state.TestEnable || state.WriteEnable || state.ClearEnable);
|
|
|
|
private readonly record struct Presentation(
|
|
byte[]? Pixels,
|
|
uint Width,
|
|
uint Height,
|
|
long Sequence,
|
|
GuestDrawKind DrawKind,
|
|
VulkanTranslatedGuestDraw? TranslatedDraw,
|
|
long RequiredGuestWorkSequence,
|
|
bool IsSplash,
|
|
ulong GuestImageAddress = 0,
|
|
long GuestImageVersion = 0);
|
|
|
|
private sealed class Presenter : IDisposable
|
|
{
|
|
private const string FullscreenBarycentricVertexSpirv =
|
|
"AwIjBwAAAQALAAgAMgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ACAAAAAAABAAAAG1haW4AAAAADQAAABoAAAApAAAAAwADAAIAAADCAQAABQAEAAQAAABtYWluAAAAAAUABgALAAAAZ2xfUGVyVmVydGV4AAAAAAYABgALAAAAAAAAAGdsX1Bvc2l0aW9uAAYABwALAAAAAQAAAGdsX1BvaW50U2l6ZQAAAAAGAAcACwAAAAIAAABnbF9DbGlwRGlzdGFuY2UABgAHAAsAAAADAAAAZ2xfQ3VsbERpc3RhbmNlAAUAAwANAAAAAAAAAAUABgAaAAAAZ2xfVmVydGV4SW5kZXgAAAUABQAdAAAAaW5kZXhhYmxlAAAABQAFACkAAABiYXJ5Y2VudHJpYwAFAAUALwAAAGluZGV4YWJsZQAAAEcAAwALAAAAAgAAAEgABQALAAAAAAAAAAsAAAAAAAAASAAFAAsAAAABAAAACwAAAAEAAABIAAUACwAAAAIAAAALAAAAAwAAAEgABQALAAAAAwAAAAsAAAAEAAAARwAEABoAAAALAAAAKgAAAEcABAApAAAAHgAAAAAAAAATAAIAAgAAACEAAwADAAAAAgAAABYAAwAGAAAAIAAAABcABAAHAAAABgAAAAQAAAAVAAQACAAAACAAAAAAAAAAKwAEAAgAAAAJAAAAAQAAABwABAAKAAAABgAAAAkAAAAeAAYACwAAAAcAAAAGAAAACgAAAAoAAAAgAAQADAAAAAMAAAALAAAAOwAEAAwAAAANAAAAAwAAABUABAAOAAAAIAAAAAEAAAArAAQADgAAAA8AAAAAAAAAFwAEABAAAAAGAAAAAgAAACsABAAIAAAAEQAAAAMAAAAcAAQAEgAAABAAAAARAAAAKwAEAAYAAAATAAAAAACAvywABQAQAAAAFAAAABMAAAATAAAAKwAEAAYAAAAVAAAAAABAQCwABQAQAAAAFgAAABUAAAATAAAALAAFABAAAAAXAAAAEwAAABUAAAAsAAYAEgAAABgAAAAUAAAAFgAAABcAAAAgAAQAGQAAAAEAAAAOAAAAOwAEABkAAAAaAAAAAQAAACAABAAcAAAABwAAABIAAAAgAAQAHgAAAAcAAAAQAAAAKwAEAAYAAAAhAAAAAAAAACsABAAGAAAAIgAAAAAAgD8gAAQAJgAAAAMAAAAHAAAAIAAEACgAAAADAAAAEAAAADsABAAoAAAAKQAAAAMAAAAsAAUAEAAAACoAAAAiAAAAIQAAACwABQAQAAAAKwAAACEAAAAiAAAALAAFABAAAAAsAAAAIQAAACEAAAAsAAYAEgAAAC0AAAAqAAAAKwAAACwAAAA2AAUAAgAAAAQAAAAAAAAAAwAAAPgAAgAFAAAAOwAEABwAAAAdAAAABwAAADsABAAcAAAALwAAAAcAAAA9AAQADgAAABsAAAAaAAAAPgADAB0AAAAYAAAAQQAFAB4AAAAfAAAAHQAAABsAAAA9AAQAEAAAACAAAAAfAAAAUQAFAAYAAAAjAAAAIAAAAAAAAABRAAUABgAAACQAAAAgAAAAAQAAAFAABwAHAAAAJQAAACMAAAAkAAAAIQAAACIAAABBAAUAJgAAACcAAAANAAAADwAAAD4AAwAnAAAAJQAAAD0ABAAOAAAALgAAABoAAAA+AAMALwAAAC0AAABBAAUAHgAAADAAAAAvAAAALgAAAD0ABAAQAAAAMQAAADAAAAA+AAMAKQAAADEAAAD9AAEAOAABAA==";
|
|
|
|
private const string FullscreenBarycentricFragmentSpirv =
|
|
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
|
|
|
|
private readonly IWindow? _window;
|
|
private readonly VulkanHostSurface? _hostSurface;
|
|
private int _lastHostResizeGeneration;
|
|
private bool _embeddedLoopClosed;
|
|
private const int MaxInFlightGuestSubmissions = 8;
|
|
private Vk _vk = null!;
|
|
private KhrSurface _surfaceApi = null!;
|
|
private KhrSwapchain _swapchainApi = null!;
|
|
private delegate* unmanaged<Device, DebugUtilsObjectNameInfoEXT*, Result> _setDebugUtilsObjectName;
|
|
private delegate* unmanaged<CommandBuffer, DebugUtilsLabelEXT*, void> _cmdBeginDebugUtilsLabel;
|
|
private delegate* unmanaged<CommandBuffer, void> _cmdEndDebugUtilsLabel;
|
|
private Instance _instance;
|
|
private SurfaceKHR _surface;
|
|
private DebugUtilsMessengerEXT _debugMessenger;
|
|
private ExtDebugUtils? _debugUtils;
|
|
private PhysicalDevice _physicalDevice;
|
|
private uint _maxComputeWorkGroupCountX;
|
|
private uint _maxComputeWorkGroupCountY;
|
|
private uint _maxComputeWorkGroupCountZ;
|
|
private uint _maxComputeWorkGroupSizeX;
|
|
private uint _maxComputeWorkGroupSizeY;
|
|
private uint _maxComputeWorkGroupSizeZ;
|
|
private uint _maxComputeWorkGroupInvocations;
|
|
private ulong _minStorageBufferOffsetAlignment = 1;
|
|
private bool _supportsIndependentBlend;
|
|
private uint _maxColorAttachments;
|
|
private Device _device;
|
|
private PipelineCache _pipelineCache;
|
|
private string? _pipelineCachePath;
|
|
private bool _pipelineCacheDirty;
|
|
private long _lastPipelineCacheSaveTick;
|
|
private Queue _queue;
|
|
private uint _queueFamilyIndex;
|
|
private SwapchainKHR _swapchain;
|
|
private Image[] _swapchainImages = [];
|
|
private ImageView[] _swapchainImageViews = [];
|
|
private Framebuffer[] _framebuffers = [];
|
|
private bool[] _imageInitialized = [];
|
|
private Format _swapchainFormat;
|
|
private Extent2D _extent;
|
|
private RenderPass _renderPass;
|
|
private PipelineLayout _pipelineLayout;
|
|
private Pipeline _barycentricPipeline;
|
|
private CommandPool _commandPool;
|
|
private CommandBuffer _commandBuffer;
|
|
private CommandBuffer _presentationCommandBuffer;
|
|
// Presentation runs with multiple frames in flight: each frame slot
|
|
// owns a command buffer, an acquire semaphore and a fence, so the CPU
|
|
// can record frame N+1 while the GPU still executes frame N. The
|
|
// per-present vkQueueWaitIdle this replaces made CPU and GPU costs
|
|
// strictly additive. Render-finished semaphores are per swapchain
|
|
// image because the presentation engine may still wait on them after
|
|
// the frame fence has signaled.
|
|
private const int MaxFramesInFlight = 2;
|
|
private CommandBuffer[] _frameCommandBuffers = [];
|
|
private VkSemaphore[] _frameImageAvailable = [];
|
|
private VkSemaphore[] _renderFinishedPerImage = [];
|
|
private Fence[] _frameFences = [];
|
|
private bool[] _frameFencePending = [];
|
|
private ulong[] _frameTimelines = [];
|
|
private TranslatedDrawResources?[] _frameTranslatedResources = [];
|
|
private GuestImageResource?[] _frameGuestImageVersions = [];
|
|
private int _currentFrameSlot;
|
|
// Monotonic submission/completion counters across every queue submit
|
|
// (guest batches, compute chunks and presents). Fences on a single
|
|
// queue signal in submission order, so "timeline <= completed" means
|
|
// the GPU is done with everything submitted up to that point; this
|
|
// lets evicted resources be destroyed without a queue drain.
|
|
private ulong _submitTimeline;
|
|
private ulong _completedTimeline;
|
|
private readonly Queue<(TextureResource Texture, ulong RetireTimeline)>
|
|
_deferredTextureDestroys = new();
|
|
private readonly Queue<(TranslatedDrawResources Resources, ulong RetireTimeline)>
|
|
_deferredResourceDestroys = new();
|
|
private readonly Queue<(GuestImageResource Image, ulong RetireTimeline)>
|
|
_deferredGuestImageVersionDestroys = new();
|
|
private readonly Stack<Fence> _recycledGuestFences = new();
|
|
private readonly Stack<CommandBuffer> _recycledGuestCommandBuffers = new();
|
|
private readonly List<(VkBuffer Buffer, DeviceMemory Memory)> _batchRetireBuffers = new();
|
|
private const int MaxRecycledGuestFences = 32;
|
|
private const int MaxRecycledGuestCommandBuffers = 32;
|
|
private VkBuffer _stagingBuffer;
|
|
private DeviceMemory _stagingMemory;
|
|
private ulong _stagingSize;
|
|
// Perf overlay: CPU-rasterized panel copied through per-slot staging
|
|
// buffers into one image, then blitted onto the swapchain.
|
|
private Image _overlayImage;
|
|
private DeviceMemory _overlayImageMemory;
|
|
private bool _overlayImageInitialized;
|
|
private VkBuffer[] _overlayStagingBuffers = [];
|
|
private DeviceMemory[] _overlayStagingMemory = [];
|
|
private nint[] _overlayStagingMapped = [];
|
|
private long _presentedSequence;
|
|
private long _presentNotTakenLoggedSequence = long.MinValue;
|
|
private bool _vulkanReady;
|
|
private bool _firstFramePresented;
|
|
private bool _firstHostFramePresented;
|
|
private bool _firstGuestDrawPresented;
|
|
private bool _splashPresented;
|
|
private Presentation? _lastHostSplashPresentation;
|
|
private Presentation? _pendingHostSplashReplay;
|
|
private bool _swapchainRecreateDeferred;
|
|
private bool _tracedPresentedSwapchain;
|
|
private bool _swapchainReadbackPending;
|
|
private long _presentedSwapchainCount;
|
|
private static int _guestImageDumpSequence;
|
|
private readonly System.Collections.Concurrent.ConcurrentQueue<GuestImageResource> _pendingAliasImageDumps = new();
|
|
private bool _deviceLost;
|
|
private bool _deviceLostLogged;
|
|
private int _directPresentationCount;
|
|
private readonly Dictionary<ulong, long> _presentedGuestImageTraceCounts = new();
|
|
private readonly Dictionary<ulong, GuestImageResource> _guestImages = new();
|
|
private readonly record struct GuestImageVariantKey(
|
|
ulong Address,
|
|
uint Width,
|
|
uint Height,
|
|
uint MipLevels,
|
|
uint GuestFormat,
|
|
Format Format);
|
|
|
|
// A single guest allocation may be rebound through several RT descriptors.
|
|
// Keep the inactive Vulkan images instead of destroying their contents each
|
|
// time the guest switches size or format at the same address.
|
|
private readonly Dictionary<GuestImageVariantKey, GuestImageResource>
|
|
_guestImageVariants = new();
|
|
private readonly Dictionary<long, GuestImageResource> _guestImageVersions = new();
|
|
private readonly HashSet<long> _capturedGuestFlipVersions = [];
|
|
private readonly record struct GuestDepthKey(
|
|
ulong Address,
|
|
ulong ReadAddress,
|
|
uint Width,
|
|
uint Height,
|
|
uint GuestFormat,
|
|
uint SwizzleMode);
|
|
|
|
private readonly Dictionary<GuestDepthKey, GuestDepthResource> _guestDepthImages = new();
|
|
private readonly Dictionary<GuestDepthKey, ulong> _depthOnlyColorAddresses = new();
|
|
private ulong _nextDepthOnlyColorAddress = 0xFFFF_FF00_0000_0000UL;
|
|
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureCacheHits = new();
|
|
private readonly HashSet<(ulong Address, uint Width, uint Height, uint DstSelect)> _tracedDepthTextureAliases = new();
|
|
private readonly HashSet<(ulong Address, uint Width, uint Height)> _tracedDepthExtentFallbacks = new();
|
|
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureUploads = new();
|
|
private readonly HashSet<(ulong Address, uint Width, uint Height, uint Format)> _dumpedTextures = new();
|
|
private readonly HashSet<(ulong Address, uint Width, uint Height, uint Format)> _tracedTextureUploadContents = new();
|
|
private readonly HashSet<(ulong Address, int ActualSize, ulong ExpectedSize, Format Format)>
|
|
_rejectedGuestImageUploads = new();
|
|
private readonly HashSet<(ulong Address, int Size)> _tracedGlobalBuffers = new();
|
|
private readonly HashSet<(ulong Address, ulong Size)> _tracedGlobalWritebacks = new();
|
|
private readonly HashSet<(ulong Shader, uint X, uint Y, uint Z, string Reason)>
|
|
_rejectedComputeDispatches = new();
|
|
private int _tracedSmallGlobalWritebackEvents;
|
|
private int _tracedLargeGlobalWritebackEvents;
|
|
private readonly HashSet<ulong> _tracedGuestImageContents = new();
|
|
private readonly Dictionary<ulong, int> _tracedGuestWriteCounts = new();
|
|
private readonly Dictionary<int, int> _pixelSpirvWriteCounts = new();
|
|
private int _tracedVertexBufferCount;
|
|
private bool _tracedTitleDraw;
|
|
// Compute translation can produce an equivalent new byte array on a
|
|
// later submit. Reference identity turns that into an expensive new
|
|
// MoltenVK pipeline compilation every frame, so key the cache by the
|
|
// program content and descriptor-layout shape instead.
|
|
private readonly Dictionary<ComputePipelineKey, Pipeline> _computePipelines = new();
|
|
private readonly Dictionary<GraphicsPipelineKey, Pipeline> _graphicsPipelines = new();
|
|
private readonly Dictionary<GuestSampler, Sampler> _samplers = new();
|
|
private readonly Dictionary<byte[], string> _shaderDigests =
|
|
new(ReferenceEqualityComparer.Instance);
|
|
private readonly Dictionary<DescriptorLayoutKey, DescriptorLayoutBundle>
|
|
_descriptorLayouts = new();
|
|
private readonly VulkanHostBufferPool _hostBufferPool;
|
|
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
|
|
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
|
|
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
|
|
new(StringComparer.Ordinal);
|
|
private readonly Stack<DescriptorPool> _recycledDescriptorPools = new();
|
|
private VulkanGuestQueueIdentity _activeGuestQueue =
|
|
VulkanGuestQueueIdentity.Default;
|
|
private long _activeGuestWorkSequence;
|
|
|
|
private readonly record struct GraphicsPipelineKey(
|
|
string VertexShader,
|
|
string FragmentShader,
|
|
string RenderTargetLayout,
|
|
bool HasDepthAttachment,
|
|
PrimitiveTopology Topology,
|
|
string BlendLayout,
|
|
string ResourceLayout,
|
|
string VertexLayout,
|
|
GuestRasterState Raster,
|
|
GuestDepthState Depth);
|
|
|
|
private readonly record struct DescriptorLayoutKey(
|
|
ShaderStageFlags Stages,
|
|
string Resources);
|
|
|
|
private readonly record struct ComputePipelineKey(
|
|
string ShaderDigest,
|
|
string Resources);
|
|
|
|
private sealed record DescriptorLayoutBundle(
|
|
DescriptorSetLayout DescriptorSetLayout,
|
|
PipelineLayout PipelineLayout);
|
|
|
|
private readonly record struct DirtyGuestBufferRange(ulong Offset, ulong Length);
|
|
|
|
private sealed class GuestBufferAllocation
|
|
{
|
|
public string QueueName = VulkanGuestQueueIdentity.Default.Name;
|
|
public ulong BaseAddress;
|
|
public ulong Size;
|
|
public VkBuffer Buffer;
|
|
public DeviceMemory Memory;
|
|
public nint Mapped;
|
|
public byte[] Shadow = [];
|
|
public ulong LastUseTimeline;
|
|
public List<DirtyGuestBufferRange> DirtyRanges { get; } = [];
|
|
}
|
|
|
|
private const string SharedReadOnlyGuestBufferQueue = "shared.readonly";
|
|
|
|
private sealed class TranslatedDrawResources
|
|
{
|
|
public string DebugName = "SharpEmu translated";
|
|
public PipelineLayout PipelineLayout;
|
|
public Pipeline Pipeline;
|
|
public bool PipelineCached;
|
|
public bool DescriptorLayoutCached;
|
|
public DescriptorSetLayout DescriptorSetLayout;
|
|
public DescriptorPool DescriptorPool;
|
|
public DescriptorSet DescriptorSet;
|
|
public TextureResource[] Textures = [];
|
|
public GlobalBufferResource[] GlobalMemoryBuffers = [];
|
|
public VertexBufferResource[] VertexBuffers = [];
|
|
public VkBuffer IndexBuffer;
|
|
public DeviceMemory IndexMemory;
|
|
public bool Index32Bit;
|
|
public uint VertexCount = 3;
|
|
public uint InstanceCount = 1;
|
|
public PrimitiveTopology Topology = PrimitiveTopology.TriangleList;
|
|
public GuestBlendState[] Blends = [GuestBlendState.Default];
|
|
public GuestBlendConstant BlendConstant;
|
|
// Vulkan format of this draw's color target. Needed to suppress
|
|
// blending on formats Metal cannot blend (integer / 32-bit float),
|
|
// which otherwise makes vkCreateGraphicsPipelines fail and can
|
|
// trip a Metal validation assertion.
|
|
public Format[] TargetFormats = [Format.Undefined];
|
|
public GuestRect? Scissor;
|
|
public GuestViewport? Viewport;
|
|
public GuestRasterState Raster = GuestRasterState.Default;
|
|
public GuestDepthState Depth = GuestDepthState.Default;
|
|
public bool HasDepthAttachment;
|
|
// Layout keys are needed twice per draw (pipeline lookup and
|
|
// descriptor-layout lookup); cache the built strings.
|
|
public string? ResourceLayoutKey;
|
|
public string? VertexLayoutKey;
|
|
public RenderPass TransientRenderPass;
|
|
public Framebuffer TransientFramebuffer;
|
|
}
|
|
|
|
private sealed class TextureResource
|
|
{
|
|
public ulong Address;
|
|
public VkBuffer StagingBuffer;
|
|
public DeviceMemory StagingMemory;
|
|
public Image Image;
|
|
public DeviceMemory ImageMemory;
|
|
public ImageView View;
|
|
public uint Width;
|
|
public uint Height;
|
|
public uint RowLength;
|
|
public uint DstSelect;
|
|
public bool NeedsUpload;
|
|
public bool OwnsStorage;
|
|
public bool IsStorage;
|
|
public bool Cached;
|
|
public ulong CpuContentFingerprint;
|
|
public bool UpdatesCpuContent;
|
|
public GuestSampler SamplerState;
|
|
public Sampler Sampler;
|
|
public GuestImageResource? GuestImage;
|
|
public GuestDepthResource? GuestDepth;
|
|
// A sampled render-target alias cannot remain bound to the same
|
|
// image while that image is a color attachment. The per-draw
|
|
// snapshot uses this source to copy the target's pre-draw contents
|
|
// before the render pass begins. The snapshot itself is owned by
|
|
// this TextureResource and retires with the draw fence.
|
|
public GuestImageResource? FeedbackSource;
|
|
public GuestDepthResource? DepthFeedbackSource;
|
|
}
|
|
|
|
private sealed class GlobalBufferResource
|
|
{
|
|
public ulong BaseAddress;
|
|
public bool Writable;
|
|
public bool WriteBackToGuest;
|
|
public VkBuffer Buffer;
|
|
public DeviceMemory Memory;
|
|
public nint Mapped;
|
|
// DescriptorOffset/Size include the shader-visible byte bias.
|
|
public ulong Offset;
|
|
public ulong Size;
|
|
// GuestOffset/Size identify only the original guest resource and
|
|
// are used for dirty writeback; descriptor padding must not be
|
|
// published over unrelated guest bytes.
|
|
public ulong GuestOffset;
|
|
public ulong GuestSize;
|
|
public GuestBufferAllocation? Allocation;
|
|
}
|
|
|
|
private sealed class VertexBufferResource
|
|
{
|
|
public VkBuffer Buffer;
|
|
public DeviceMemory Memory;
|
|
public bool OwnsBuffer;
|
|
public ulong Size;
|
|
public uint Location;
|
|
public uint ComponentCount;
|
|
public uint DataFormat;
|
|
public uint NumberFormat;
|
|
public uint Stride;
|
|
public uint OffsetBytes;
|
|
}
|
|
|
|
private const Format DepthFormat = Format.D32Sfloat;
|
|
|
|
private sealed class GuestDepthResource
|
|
{
|
|
public GuestDepthKey Key;
|
|
public ulong Address;
|
|
public ulong ReadAddress;
|
|
public ulong WriteAddress;
|
|
public uint Width;
|
|
public uint Height;
|
|
public uint GuestFormat;
|
|
public uint SwizzleMode;
|
|
public Image Image;
|
|
public DeviceMemory Memory;
|
|
public ImageView View;
|
|
public Dictionary<uint, ImageView> SampleViews { get; } = new();
|
|
public bool Initialized;
|
|
public ImageLayout Layout = ImageLayout.Undefined;
|
|
public float GuestClearDepth = 1f;
|
|
public float ClearDepth = 1f;
|
|
public string InitializationSource = "none";
|
|
}
|
|
|
|
private sealed class DepthFramebufferResource
|
|
{
|
|
public required GuestDepthResource Depth;
|
|
public RenderPass LoadRenderPass;
|
|
public RenderPass ColorClearRenderPass;
|
|
public RenderPass DepthClearRenderPass;
|
|
public RenderPass BothClearRenderPass;
|
|
public Framebuffer Framebuffer;
|
|
}
|
|
|
|
private sealed class GuestImageResource
|
|
{
|
|
public ulong Address;
|
|
public long FlipVersion;
|
|
public uint Width;
|
|
public uint Height;
|
|
public uint MipLevels;
|
|
public uint GuestFormat;
|
|
public Format Format;
|
|
public Image Image;
|
|
public DeviceMemory Memory;
|
|
public ImageView View;
|
|
public ImageView[] MipViews = [];
|
|
public Dictionary<(Format Format, uint MipLevel, uint LevelCount, uint DstSelect), ImageView> FormatViews { get; } = new();
|
|
public RenderPass RenderPass;
|
|
public RenderPass InitialRenderPass;
|
|
public Framebuffer Framebuffer;
|
|
public Dictionary<GuestDepthKey, DepthFramebufferResource> DepthFramebuffers { get; } = new();
|
|
public bool Initialized;
|
|
public bool InitialUploadPending;
|
|
public bool IsCpuBacked;
|
|
public ulong CpuContentFingerprint;
|
|
public bool SupportsStorageUsage;
|
|
}
|
|
|
|
private sealed record PendingGuestSubmission(
|
|
Fence Fence,
|
|
CommandBuffer CommandBuffer,
|
|
IReadOnlyList<TranslatedDrawResources> Resources,
|
|
IReadOnlyList<GuestImageResource> TraceImages,
|
|
IReadOnlyList<(VkBuffer Buffer, DeviceMemory Memory)> RetireBuffers,
|
|
ulong Timeline,
|
|
string DebugName,
|
|
VulkanGuestQueueIdentity Queue,
|
|
long WorkSequence);
|
|
|
|
public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
|
|
{
|
|
_hostSurface = hostSurface;
|
|
_hostBufferPool = new VulkanHostBufferPool(
|
|
MaximumCachedHostBufferBytes,
|
|
DestroyHostBufferAllocation);
|
|
|
|
if (_hostSurface is not null)
|
|
{
|
|
_hostSurface.UpdatePixelSize(
|
|
_hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
|
|
_hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
|
|
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
|
|
return;
|
|
}
|
|
|
|
var options = WindowOptions.DefaultVulkan;
|
|
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
|
|
options.Title = VideoOutExports.GetWindowTitle();
|
|
options.WindowBorder = WindowBorder.Fixed;
|
|
options.VSync = true;
|
|
options.FramesPerSecond = 60;
|
|
options.UpdatesPerSecond = 60;
|
|
_window = Window.Create(options);
|
|
_window.Load += Initialize;
|
|
_window.Render += Render;
|
|
_window.Closing += () =>
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan VideoOut window closing; " +
|
|
$"requested={Volatile.Read(ref _presenterCloseRequested)} " +
|
|
$"deviceLost={_deviceLost}");
|
|
VideoOutExports.NotifyPresentationWindowClosed();
|
|
DisposeVulkan();
|
|
};
|
|
}
|
|
|
|
public void Run()
|
|
{
|
|
if (_window is not null)
|
|
{
|
|
_window.Run();
|
|
return;
|
|
}
|
|
|
|
Initialize();
|
|
while (!_embeddedLoopClosed)
|
|
{
|
|
Render(0);
|
|
// Guest draws and flips execute on this thread. An unconditional
|
|
// Thread.Sleep here is quantized to the Windows timer period
|
|
// (~15.6ms) and throttles every hosted game far below the GLFW
|
|
// path. Wait only while there is genuinely no render work.
|
|
WaitForRenderWork();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
DisposeVulkan();
|
|
try
|
|
{
|
|
_window?.Dispose();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan VideoOut window dispose skipped during render loop: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private void Initialize()
|
|
{
|
|
if (_window is not null)
|
|
{
|
|
LogGlfwPlatformInUse();
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
try
|
|
{
|
|
Pad.HostWindowInput.Attach(_window.CreateInput());
|
|
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
|
|
{
|
|
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
|
|
_window.SetWindowIcon(ref icon);
|
|
}
|
|
}
|
|
|
|
WaitForRenderDocAttachIfRequested();
|
|
_vk = Vk.GetApi();
|
|
CreateInstance();
|
|
CreateSurface();
|
|
SelectPhysicalDevice();
|
|
CreateDevice();
|
|
CreatePipelineCache();
|
|
CreateSwapchain();
|
|
CreateCommandResources();
|
|
CreateGuestDrawResources();
|
|
_vulkanReady = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
|
|
}
|
|
|
|
private static void WaitForRenderDocAttachIfRequested()
|
|
{
|
|
var value = Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC_WAIT");
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(value, "enter", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Waiting for RenderDoc attach before Vulkan init. pid={Environment.ProcessId}. Press Enter to continue.");
|
|
_ = Console.ReadLine();
|
|
return;
|
|
}
|
|
|
|
var seconds = 15;
|
|
if (int.TryParse(value, out var parsedSeconds))
|
|
{
|
|
seconds = Math.Clamp(parsedSeconds, 1, 300);
|
|
}
|
|
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Waiting {seconds}s for RenderDoc attach before Vulkan init. pid={Environment.ProcessId}");
|
|
Thread.Sleep(TimeSpan.FromSeconds(seconds));
|
|
}
|
|
|
|
private bool IsInstanceExtensionAvailable(string extensionName)
|
|
{
|
|
uint extensionCount = 0;
|
|
if (_vk.EnumerateInstanceExtensionProperties((byte*)null, &extensionCount, null) != Result.Success ||
|
|
extensionCount == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var properties = new ExtensionProperties[extensionCount];
|
|
fixed (ExtensionProperties* propertyPointer = properties)
|
|
{
|
|
if (_vk.EnumerateInstanceExtensionProperties(
|
|
(byte*)null,
|
|
&extensionCount,
|
|
propertyPointer) != Result.Success)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var expected = Encoding.UTF8.GetBytes(extensionName);
|
|
for (var index = 0; index < extensionCount; index++)
|
|
{
|
|
if (Utf8NullTerminatedEquals(propertyPointer[index].ExtensionName, expected))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool Utf8NullTerminatedEquals(byte* actual, ReadOnlySpan<byte> expected)
|
|
{
|
|
for (var index = 0; index < expected.Length; index++)
|
|
{
|
|
if (actual[index] != expected[index])
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return actual[expected.Length] == 0;
|
|
}
|
|
|
|
private void LoadDebugUtilsCommands()
|
|
{
|
|
if (!_vulkanDebugUtilsEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var setObjectName = _vk.GetDeviceProcAddr(_device, "vkSetDebugUtilsObjectNameEXT");
|
|
var beginLabel = _vk.GetDeviceProcAddr(_device, "vkCmdBeginDebugUtilsLabelEXT");
|
|
var endLabel = _vk.GetDeviceProcAddr(_device, "vkCmdEndDebugUtilsLabelEXT");
|
|
_setDebugUtilsObjectName =
|
|
(delegate* unmanaged<Device, DebugUtilsObjectNameInfoEXT*, Result>)
|
|
setObjectName.Handle;
|
|
_cmdBeginDebugUtilsLabel =
|
|
(delegate* unmanaged<CommandBuffer, DebugUtilsLabelEXT*, void>)
|
|
beginLabel.Handle;
|
|
_cmdEndDebugUtilsLabel =
|
|
(delegate* unmanaged<CommandBuffer, void>)
|
|
endLabel.Handle;
|
|
|
|
if (_setDebugUtilsObjectName is not null)
|
|
{
|
|
Console.Error.WriteLine("[LOADER][INFO] Vulkan debug labels enabled.");
|
|
}
|
|
}
|
|
|
|
private void SetDebugName(ObjectType objectType, ulong objectHandle, string name)
|
|
{
|
|
if (_setDebugUtilsObjectName is null ||
|
|
_device.Handle == 0 ||
|
|
objectHandle == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var bytes = NullTerminatedUtf8(name);
|
|
fixed (byte* namePointer = bytes)
|
|
{
|
|
var info = new DebugUtilsObjectNameInfoEXT
|
|
{
|
|
SType = StructureType.DebugUtilsObjectNameInfoExt,
|
|
ObjectType = objectType,
|
|
ObjectHandle = objectHandle,
|
|
PObjectName = namePointer,
|
|
};
|
|
_ = _setDebugUtilsObjectName(_device, &info);
|
|
}
|
|
}
|
|
|
|
private void BeginDebugLabel(CommandBuffer commandBuffer, string name)
|
|
{
|
|
if (_cmdBeginDebugUtilsLabel is null ||
|
|
commandBuffer.Handle == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var bytes = NullTerminatedUtf8(name);
|
|
fixed (byte* namePointer = bytes)
|
|
{
|
|
var label = new DebugUtilsLabelEXT
|
|
{
|
|
SType = StructureType.DebugUtilsLabelExt,
|
|
PLabelName = namePointer,
|
|
};
|
|
label.Color[0] = 0.20f;
|
|
label.Color[1] = 0.60f;
|
|
label.Color[2] = 1.00f;
|
|
label.Color[3] = 1.00f;
|
|
_cmdBeginDebugUtilsLabel(commandBuffer, &label);
|
|
}
|
|
}
|
|
|
|
private void EndDebugLabel(CommandBuffer commandBuffer)
|
|
{
|
|
if (_cmdEndDebugUtilsLabel is not null &&
|
|
commandBuffer.Handle != 0)
|
|
{
|
|
_cmdEndDebugUtilsLabel(commandBuffer);
|
|
}
|
|
}
|
|
|
|
private static byte[] NullTerminatedUtf8(string value)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(value);
|
|
Array.Resize(ref bytes, bytes.Length + 1);
|
|
return bytes;
|
|
}
|
|
|
|
private static string BuildComputeDebugName(VulkanComputeGuestDispatch dispatch)
|
|
{
|
|
var storage = dispatch.Textures.FirstOrDefault(texture => texture.IsStorage && texture.Address != 0);
|
|
return storage is null
|
|
? $"SharpEmu compute cs=0x{dispatch.ShaderAddress:X16} " +
|
|
$"{dispatch.GroupCountX}x{dispatch.GroupCountY}x{dispatch.GroupCountZ}"
|
|
: $"SharpEmu compute cs=0x{dispatch.ShaderAddress:X16} " +
|
|
$"storage=0x{storage.Address:X16} " +
|
|
$"{storage.Width}x{storage.Height} fmt{storage.Format} " +
|
|
$"{dispatch.GroupCountX}x{dispatch.GroupCountY}x{dispatch.GroupCountZ}";
|
|
}
|
|
|
|
private static string GuestImageDebugName(GuestRenderTarget target, Format format) =>
|
|
$"SharpEmu guest 0x{target.Address:X16} {target.Width}x{target.Height} " +
|
|
$"fmt{target.Format}/{format}";
|
|
|
|
private static string TextureDebugName(GuestDrawTexture texture, Format format) =>
|
|
$"SharpEmu texture 0x{texture.Address:X16} {texture.Width}x{texture.Height} " +
|
|
$"fmt{texture.Format}/{format}";
|
|
|
|
private static bool IsTitleDraw(IReadOnlyList<GuestVertexBuffer> vertexBuffers)
|
|
{
|
|
foreach (var buffer in vertexBuffers)
|
|
{
|
|
if (buffer.Location == 0 &&
|
|
buffer.ComponentCount == 4 &&
|
|
buffer.DataFormat == 10 &&
|
|
buffer.NumberFormat == 0 &&
|
|
buffer.Stride == 16 &&
|
|
buffer.OffsetBytes == 12 &&
|
|
buffer.Length == 67568)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool AnyTargetAddressMatches(
|
|
IReadOnlyList<GuestImageResource>? targets,
|
|
string environmentVariable)
|
|
{
|
|
if (targets is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
if (AddressListContains(environmentVariable, target.Address))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void CreateInstance()
|
|
{
|
|
var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
|
|
byte* validationLayerName = null;
|
|
|
|
try
|
|
{
|
|
var applicationInfo = new ApplicationInfo
|
|
{
|
|
SType = StructureType.ApplicationInfo,
|
|
PApplicationName = applicationName,
|
|
ApplicationVersion = Vk.MakeVersion(0, 0, 1),
|
|
PEngineName = applicationName,
|
|
EngineVersion = Vk.MakeVersion(0, 0, 1),
|
|
ApiVersion = Vk.Version12,
|
|
};
|
|
|
|
var hostExtensionNames = _hostSurface is null
|
|
? null
|
|
: GetHostSurfaceExtensions(_hostSurface.Kind);
|
|
byte** extensions;
|
|
uint extensionCount;
|
|
if (hostExtensionNames is not null)
|
|
{
|
|
extensions = null;
|
|
extensionCount = (uint)hostExtensionNames.Length;
|
|
}
|
|
else if (_window?.VkSurface is { } glfwSurface)
|
|
{
|
|
extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
|
|
}
|
|
|
|
byte* debugUtilsExtension = null;
|
|
byte* portabilityExtension = null;
|
|
var instanceCreateFlags = InstanceCreateFlags.None;
|
|
var enabledExtensionCount = (int)extensionCount;
|
|
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
|
|
var allocatedHostExtensions = hostExtensionNames is null
|
|
? null
|
|
: new nint[hostExtensionNames.Length];
|
|
for (var index = 0; index < (int)extensionCount; index++)
|
|
{
|
|
if (hostExtensionNames is null)
|
|
{
|
|
enabledExtensions[index] = extensions[index];
|
|
}
|
|
else
|
|
{
|
|
var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
|
|
allocatedHostExtensions![index] = (nint)extension;
|
|
enabledExtensions[index] = extension;
|
|
}
|
|
}
|
|
|
|
if (_vulkanDebugUtilsEnabled &&
|
|
IsInstanceExtensionAvailable(DebugUtilsExtensionName))
|
|
{
|
|
debugUtilsExtension = (byte*)SilkMarshal.StringToPtr(DebugUtilsExtensionName);
|
|
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
|
|
}
|
|
|
|
if (IsInstanceExtensionAvailable(PortabilityEnumerationExtensionName))
|
|
{
|
|
// MoltenVK is a portability (non-conformant) implementation;
|
|
// without this flag + extension the loader hides it.
|
|
portabilityExtension = (byte*)SilkMarshal.StringToPtr(PortabilityEnumerationExtensionName);
|
|
enabledExtensions[enabledExtensionCount++] = portabilityExtension;
|
|
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
|
|
}
|
|
|
|
if (_vulkanValidationEnabled &&
|
|
IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
|
|
{
|
|
validationLayerName = (byte*)SilkMarshal.StringToPtr("VK_LAYER_KHRONOS_validation");
|
|
}
|
|
else if (_vulkanValidationEnabled)
|
|
{
|
|
Console.Error.WriteLine("[LOADER][WARN] SHARPEMU_VK_VALIDATION=1 but VK_LAYER_KHRONOS_validation not found (Vulkan SDK installed?).");
|
|
}
|
|
|
|
var layers = stackalloc byte*[1];
|
|
if (validationLayerName is not null)
|
|
{
|
|
layers[0] = validationLayerName;
|
|
}
|
|
|
|
var createInfo = new InstanceCreateInfo
|
|
{
|
|
SType = StructureType.InstanceCreateInfo,
|
|
Flags = instanceCreateFlags,
|
|
PApplicationInfo = &applicationInfo,
|
|
EnabledExtensionCount = (uint)enabledExtensionCount,
|
|
PpEnabledExtensionNames = enabledExtensions,
|
|
EnabledLayerCount = validationLayerName is not null ? 1u : 0u,
|
|
PpEnabledLayerNames = validationLayerName is not null ? layers : null,
|
|
};
|
|
|
|
try
|
|
{
|
|
Check(_vk.CreateInstance(&createInfo, null, out _instance), "vkCreateInstance");
|
|
if (!_vk.TryGetInstanceExtension(_instance, out _surfaceApi))
|
|
{
|
|
throw new InvalidOperationException("VK_KHR_surface is unavailable.");
|
|
}
|
|
|
|
if (validationLayerName is not null && _vk.TryGetInstanceExtension(_instance, out ExtDebugUtils debugUtils))
|
|
{
|
|
_debugUtils = debugUtils;
|
|
RegisterDebugMessenger(debugUtils);
|
|
Console.Error.WriteLine("[LOADER][INFO] Vulkan Validation Layers active (SHARPEMU_VK_VALIDATION=1).");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (debugUtilsExtension is not null)
|
|
{
|
|
SilkMarshal.Free((nint)debugUtilsExtension);
|
|
}
|
|
if (portabilityExtension is not null)
|
|
{
|
|
SilkMarshal.Free((nint)portabilityExtension);
|
|
}
|
|
if (allocatedHostExtensions is not null)
|
|
{
|
|
foreach (var extension in allocatedHostExtensions)
|
|
{
|
|
SilkMarshal.Free(extension);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
SilkMarshal.Free((nint)applicationName);
|
|
if (validationLayerName is not null)
|
|
{
|
|
SilkMarshal.Free((nint)validationLayerName);
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsDeviceExtensionAvailable(string extensionName)
|
|
{
|
|
uint extensionCount = 0;
|
|
if (_vk.EnumerateDeviceExtensionProperties(_physicalDevice, (byte*)null, &extensionCount, null) != Result.Success ||
|
|
extensionCount == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var properties = new ExtensionProperties[extensionCount];
|
|
fixed (ExtensionProperties* propertyPointer = properties)
|
|
{
|
|
if (_vk.EnumerateDeviceExtensionProperties(
|
|
_physicalDevice,
|
|
(byte*)null,
|
|
&extensionCount,
|
|
propertyPointer) != Result.Success)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var expected = Encoding.UTF8.GetBytes(extensionName);
|
|
for (var index = 0; index < extensionCount; index++)
|
|
{
|
|
if (Utf8NullTerminatedEquals(propertyPointer[index].ExtensionName, expected))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool IsInstanceLayerAvailable(string layerName)
|
|
{
|
|
uint layerCount = 0;
|
|
if (_vk.EnumerateInstanceLayerProperties(&layerCount, null) != Result.Success || layerCount == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var properties = new LayerProperties[layerCount];
|
|
fixed (LayerProperties* propertyPointer = properties)
|
|
{
|
|
if (_vk.EnumerateInstanceLayerProperties(&layerCount, propertyPointer) != Result.Success)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var expected = Encoding.UTF8.GetBytes(layerName);
|
|
for (var index = 0; index < layerCount; index++)
|
|
{
|
|
if (Utf8NullTerminatedEquals(propertyPointer[index].LayerName, expected))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
private void RegisterDebugMessenger(ExtDebugUtils debugUtils)
|
|
{
|
|
var messengerInfo = new DebugUtilsMessengerCreateInfoEXT
|
|
{
|
|
SType = StructureType.DebugUtilsMessengerCreateInfoExt,
|
|
MessageSeverity = DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt
|
|
| DebugUtilsMessageSeverityFlagsEXT.WarningBitExt,
|
|
MessageType = DebugUtilsMessageTypeFlagsEXT.ValidationBitExt
|
|
| DebugUtilsMessageTypeFlagsEXT.PerformanceBitExt
|
|
| DebugUtilsMessageTypeFlagsEXT.GeneralBitExt,
|
|
PfnUserCallback = new PfnDebugUtilsMessengerCallbackEXT(DebugCallback),
|
|
};
|
|
|
|
Check(debugUtils.CreateDebugUtilsMessenger(_instance, &messengerInfo, null, out _debugMessenger),
|
|
"vkCreateDebugUtilsMessengerEXT");
|
|
}
|
|
|
|
private static unsafe uint DebugCallback(
|
|
DebugUtilsMessageSeverityFlagsEXT severity,
|
|
DebugUtilsMessageTypeFlagsEXT type,
|
|
DebugUtilsMessengerCallbackDataEXT* callbackData,
|
|
void* userData)
|
|
{
|
|
var message = SilkMarshal.PtrToString((nint)callbackData->PMessage);
|
|
var prefix = severity switch
|
|
{
|
|
DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt => "[VULKAN][ERROR]",
|
|
DebugUtilsMessageSeverityFlagsEXT.WarningBitExt => "[VULKAN][WARN]",
|
|
_ => "[VULKAN][INFO]",
|
|
};
|
|
Console.Error.WriteLine($"{prefix} {message}");
|
|
return Vk.False;
|
|
}
|
|
private void CreateSurface()
|
|
{
|
|
if (_hostSurface is not null)
|
|
{
|
|
CreateHostSurface(_hostSurface);
|
|
return;
|
|
}
|
|
|
|
var instanceHandle = new VkHandle(_instance.Handle);
|
|
var surfaceHandle = _window!.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
|
|
_surface = new SurfaceKHR(surfaceHandle.Handle);
|
|
}
|
|
|
|
private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
|
|
{
|
|
VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
|
|
VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
|
|
VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
|
|
_ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
|
|
};
|
|
|
|
private void CreateHostSurface(VulkanHostSurface hostSurface)
|
|
{
|
|
switch (hostSurface.Kind)
|
|
{
|
|
case VulkanHostSurfaceKind.Win32:
|
|
CreateWin32HostSurface(hostSurface);
|
|
return;
|
|
case VulkanHostSurfaceKind.Xlib:
|
|
CreateXlibHostSurface(hostSurface);
|
|
return;
|
|
case VulkanHostSurfaceKind.Metal:
|
|
CreateMetalHostSurface(hostSurface);
|
|
return;
|
|
default:
|
|
throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
|
|
}
|
|
}
|
|
|
|
private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
|
|
{
|
|
if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
|
|
{
|
|
throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
|
|
}
|
|
|
|
var createInfo = new Win32SurfaceCreateInfoKHR
|
|
{
|
|
SType = StructureType.Win32SurfaceCreateInfoKhr,
|
|
Hinstance = hostSurface.DisplayHandle != 0
|
|
? hostSurface.DisplayHandle
|
|
: GetModuleHandleW(null),
|
|
Hwnd = hostSurface.WindowHandle,
|
|
};
|
|
Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
|
|
}
|
|
|
|
private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
|
|
{
|
|
if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
|
|
{
|
|
throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
|
|
}
|
|
|
|
var createInfo = new XlibSurfaceCreateInfoKHR
|
|
{
|
|
SType = StructureType.XlibSurfaceCreateInfoKhr,
|
|
Dpy = (nint*)hostSurface.DisplayHandle,
|
|
Window = hostSurface.WindowHandle,
|
|
};
|
|
Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
|
|
}
|
|
|
|
private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
|
|
{
|
|
var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
|
|
if (proc == 0)
|
|
{
|
|
throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
|
|
}
|
|
|
|
var createInfo = new MetalSurfaceCreateInfoEXT
|
|
{
|
|
SType = StructureType.MetalSurfaceCreateInfoExt,
|
|
PLayer = (nint*)hostSurface.MetalLayerHandle,
|
|
};
|
|
var createSurface = (delegate* unmanaged<Instance, MetalSurfaceCreateInfoEXT*, AllocationCallbacks*, SurfaceKHR*, Result>)proc.Handle;
|
|
SurfaceKHR surface;
|
|
Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
|
|
_surface = surface;
|
|
}
|
|
|
|
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
|
private static extern nint GetModuleHandleW(string? moduleName);
|
|
|
|
private void SelectPhysicalDevice()
|
|
{
|
|
uint deviceCount = 0;
|
|
Check(_vk.EnumeratePhysicalDevices(_instance, &deviceCount, null), "vkEnumeratePhysicalDevices");
|
|
if (deviceCount == 0)
|
|
{
|
|
throw new InvalidOperationException("No Vulkan physical device was found.");
|
|
}
|
|
|
|
var devices = new PhysicalDevice[deviceCount];
|
|
fixed (PhysicalDevice* devicePointer = devices)
|
|
{
|
|
Check(_vk.EnumeratePhysicalDevices(_instance, &deviceCount, devicePointer), "vkEnumeratePhysicalDevices");
|
|
}
|
|
|
|
var deviceOverride = Environment.GetEnvironmentVariable("SHARPEMU_VK_DEVICE");
|
|
var bestScore = int.MinValue;
|
|
var found = false;
|
|
foreach (var device in devices)
|
|
{
|
|
uint queueCount = 0;
|
|
_vk.GetPhysicalDeviceQueueFamilyProperties(device, &queueCount, null);
|
|
var queues = new QueueFamilyProperties[queueCount];
|
|
fixed (QueueFamilyProperties* queuePointer = queues)
|
|
{
|
|
_vk.GetPhysicalDeviceQueueFamilyProperties(device, &queueCount, queuePointer);
|
|
}
|
|
|
|
for (uint index = 0; index < queueCount; index++)
|
|
{
|
|
var supportsGraphics = (queues[index].QueueFlags & QueueFlags.GraphicsBit) != 0;
|
|
_surfaceApi.GetPhysicalDeviceSurfaceSupport(device, index, _surface, out var supportsPresent);
|
|
if (!supportsGraphics || !supportsPresent)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_vk.GetPhysicalDeviceProperties(device, out var properties);
|
|
var name = SilkMarshal.PtrToString((nint)properties.DeviceName) ?? string.Empty;
|
|
var score = ScorePhysicalDevice(properties, name, deviceOverride);
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan candidate: {name} ({properties.DeviceType}) score={score}");
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
_physicalDevice = device;
|
|
_queueFamilyIndex = index;
|
|
found = true;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found)
|
|
{
|
|
throw new InvalidOperationException("No Vulkan graphics/present queue was found.");
|
|
}
|
|
|
|
LoadComputeDeviceLimits();
|
|
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
|
|
_maxColorAttachments = selected.Limits.MaxColorAttachments;
|
|
var selectedName = SilkMarshal.PtrToString((nint)selected.DeviceName) ?? "unknown";
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
|
|
VideoOutExports.SetSelectedGpuName(selectedName);
|
|
if (_window is not null)
|
|
{
|
|
_window.Title = VideoOutExports.GetWindowTitle();
|
|
}
|
|
}
|
|
|
|
private void LoadComputeDeviceLimits()
|
|
{
|
|
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var properties);
|
|
var subgroupSizeControl = new PhysicalDeviceSubgroupSizeControlProperties
|
|
{
|
|
SType = StructureType.PhysicalDeviceSubgroupSizeControlProperties,
|
|
};
|
|
var subgroup = new PhysicalDeviceSubgroupProperties
|
|
{
|
|
SType = StructureType.PhysicalDeviceSubgroupProperties,
|
|
PNext = &subgroupSizeControl,
|
|
};
|
|
var properties2 = new PhysicalDeviceProperties2
|
|
{
|
|
SType = StructureType.PhysicalDeviceProperties2,
|
|
PNext = &subgroup,
|
|
};
|
|
_vk.GetPhysicalDeviceProperties2(_physicalDevice, &properties2);
|
|
_maxComputeWorkGroupCountX = properties.Limits.MaxComputeWorkGroupCount[0];
|
|
_maxComputeWorkGroupCountY = properties.Limits.MaxComputeWorkGroupCount[1];
|
|
_maxComputeWorkGroupCountZ = properties.Limits.MaxComputeWorkGroupCount[2];
|
|
_maxComputeWorkGroupSizeX = properties.Limits.MaxComputeWorkGroupSize[0];
|
|
_maxComputeWorkGroupSizeY = properties.Limits.MaxComputeWorkGroupSize[1];
|
|
_maxComputeWorkGroupSizeZ = properties.Limits.MaxComputeWorkGroupSize[2];
|
|
_maxComputeWorkGroupInvocations = properties.Limits.MaxComputeWorkGroupInvocations;
|
|
_minStorageBufferOffsetAlignment = Math.Max(
|
|
properties.Limits.MinStorageBufferOffsetAlignment,
|
|
1UL);
|
|
if (GuestStorageBufferOffsetAlignment %
|
|
_minStorageBufferOffsetAlignment != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Vulkan storage-buffer alignment " +
|
|
$"{_minStorageBufferOffsetAlignment} is not compatible with " +
|
|
$"the portable alias alignment " +
|
|
$"{GuestStorageBufferOffsetAlignment}");
|
|
}
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan compute limits groups=" +
|
|
$"{_maxComputeWorkGroupCountX}x{_maxComputeWorkGroupCountY}x{_maxComputeWorkGroupCountZ} " +
|
|
$"local={_maxComputeWorkGroupSizeX}x{_maxComputeWorkGroupSizeY}x" +
|
|
$"{_maxComputeWorkGroupSizeZ} invocations={_maxComputeWorkGroupInvocations} " +
|
|
$"storage_alignment={_minStorageBufferOffsetAlignment}");
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan subgroup default={subgroup.SubgroupSize} " +
|
|
$"stages={subgroup.SupportedStages} ops={subgroup.SupportedOperations} " +
|
|
$"size_control={subgroupSizeControl.MinSubgroupSize}-" +
|
|
$"{subgroupSizeControl.MaxSubgroupSize} " +
|
|
$"required_stages={subgroupSizeControl.RequiredSubgroupSizeStages} " +
|
|
$"max_compute_subgroups=" +
|
|
$"{subgroupSizeControl.MaxComputeWorkgroupSubgroups}");
|
|
}
|
|
|
|
private void CreateDevice()
|
|
{
|
|
var priority = 1.0f;
|
|
var queueInfo = new DeviceQueueCreateInfo
|
|
{
|
|
SType = StructureType.DeviceQueueCreateInfo,
|
|
QueueFamilyIndex = _queueFamilyIndex,
|
|
QueueCount = 1,
|
|
PQueuePriorities = &priority,
|
|
};
|
|
_vk.GetPhysicalDeviceFeatures(_physicalDevice, out var supportedFeatures);
|
|
_supportsIndependentBlend = supportedFeatures.IndependentBlend;
|
|
var enabledFeatures = new PhysicalDeviceFeatures
|
|
{
|
|
IndependentBlend = supportedFeatures.IndependentBlend,
|
|
VertexPipelineStoresAndAtomics = supportedFeatures.VertexPipelineStoresAndAtomics,
|
|
FragmentStoresAndAtomics = supportedFeatures.FragmentStoresAndAtomics,
|
|
ShaderInt64 = supportedFeatures.ShaderInt64,
|
|
ShaderImageGatherExtended = supportedFeatures.ShaderImageGatherExtended,
|
|
ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats,
|
|
ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat,
|
|
ShaderStorageImageWriteWithoutFormat = supportedFeatures.ShaderStorageImageWriteWithoutFormat,
|
|
RobustBufferAccess = supportedFeatures.RobustBufferAccess,
|
|
};
|
|
|
|
if (!supportedFeatures.RobustBufferAccess)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support robustBufferAccess " +
|
|
"translated shaders performing out-of-bounds buffer access may cause device loss.");
|
|
}
|
|
|
|
if (!supportedFeatures.ShaderInt64)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support shaderInt64 " +
|
|
"translated shaders using 64-bit integers will fail.");
|
|
}
|
|
|
|
if (!supportedFeatures.VertexPipelineStoresAndAtomics || !supportedFeatures.FragmentStoresAndAtomics)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support vertexPipelineStoresAndAtomics/fragmentStoresAndAtomics " +
|
|
"translated shaders using storage buffers in vertex/fragment stages may fail.");
|
|
}
|
|
|
|
if (!supportedFeatures.ShaderImageGatherExtended)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support shaderImageGatherExtended " +
|
|
"translated shaders using image gather with offsets/LOD/bias will fail.");
|
|
}
|
|
|
|
if (!supportedFeatures.ShaderStorageImageReadWithoutFormat ||
|
|
!supportedFeatures.ShaderStorageImageWriteWithoutFormat)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support shaderStorageImage(Read|Write)WithoutFormat " +
|
|
"translated shaders using unformatted storage image load/store will fail.");
|
|
}
|
|
|
|
var maintenance8Features = new PhysicalDeviceMaintenance8FeaturesKHR
|
|
{
|
|
SType = StructureType.PhysicalDeviceMaintenance8FeaturesKhr,
|
|
};
|
|
var robustness2Features = new PhysicalDeviceRobustness2FeaturesEXT
|
|
{
|
|
SType = StructureType.PhysicalDeviceRobustness2FeaturesExt,
|
|
PNext = &maintenance8Features,
|
|
};
|
|
var featuresQuery = new PhysicalDeviceFeatures2
|
|
{
|
|
SType = StructureType.PhysicalDeviceFeatures2,
|
|
PNext = &robustness2Features,
|
|
};
|
|
_vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery);
|
|
var supportsMaintenance8 = maintenance8Features.Maintenance8;
|
|
var supportsRobustBufferAccess2 = robustness2Features.RobustBufferAccess2;
|
|
var supportsRobustImageAccess2 = robustness2Features.RobustImageAccess2;
|
|
var supportsNullDescriptor = robustness2Features.NullDescriptor;
|
|
var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor;
|
|
if (!supportsMaintenance8)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support VK_KHR_maintenance8 " +
|
|
"translated shaders using a dynamic texel offset on non-gather image samples will fail.");
|
|
}
|
|
|
|
if (!supportsRobustImageAccess2)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] GPU does not support VK_EXT_robustness2 robustImageAccess2 " +
|
|
"translated shaders performing out-of-bounds image access may cause device loss.");
|
|
}
|
|
|
|
var swapchainExtension = (byte*)SilkMarshal.StringToPtr("VK_KHR_swapchain");
|
|
var maintenance8Extension = (byte*)SilkMarshal.StringToPtr("VK_KHR_maintenance8");
|
|
var robustness2Extension = (byte*)SilkMarshal.StringToPtr("VK_EXT_robustness2");
|
|
var portabilitySubsetExtension = (byte*)SilkMarshal.StringToPtr(PortabilitySubsetExtensionName);
|
|
try
|
|
{
|
|
var extensions = stackalloc byte*[4];
|
|
var extensionCount = 0u;
|
|
extensions[extensionCount++] = swapchainExtension;
|
|
if (supportsMaintenance8)
|
|
{
|
|
extensions[extensionCount++] = maintenance8Extension;
|
|
}
|
|
|
|
if (supportsRobustness2)
|
|
{
|
|
extensions[extensionCount++] = robustness2Extension;
|
|
}
|
|
|
|
if (IsDeviceExtensionAvailable(PortabilitySubsetExtensionName))
|
|
{
|
|
// The spec requires enabling this when the (MoltenVK)
|
|
// device advertises it.
|
|
extensions[extensionCount++] = portabilitySubsetExtension;
|
|
}
|
|
|
|
maintenance8Features.Maintenance8 = supportsMaintenance8;
|
|
maintenance8Features.PNext = null;
|
|
robustness2Features.RobustBufferAccess2 =
|
|
supportsRobustBufferAccess2 && supportedFeatures.RobustBufferAccess;
|
|
robustness2Features.RobustImageAccess2 = supportsRobustImageAccess2;
|
|
robustness2Features.NullDescriptor = supportsNullDescriptor;
|
|
robustness2Features.PNext = supportsMaintenance8 ? &maintenance8Features : null;
|
|
var features2 = new PhysicalDeviceFeatures2
|
|
{
|
|
SType = StructureType.PhysicalDeviceFeatures2,
|
|
PNext = supportsRobustness2
|
|
? &robustness2Features
|
|
: (supportsMaintenance8 ? &maintenance8Features : null),
|
|
Features = enabledFeatures,
|
|
};
|
|
var createInfo = new DeviceCreateInfo
|
|
{
|
|
SType = StructureType.DeviceCreateInfo,
|
|
PNext = &features2,
|
|
QueueCreateInfoCount = 1,
|
|
PQueueCreateInfos = &queueInfo,
|
|
EnabledExtensionCount = extensionCount,
|
|
PpEnabledExtensionNames = extensions,
|
|
};
|
|
|
|
Check(_vk.CreateDevice(_physicalDevice, &createInfo, null, out _device), "vkCreateDevice");
|
|
}
|
|
finally
|
|
{
|
|
SilkMarshal.Free((nint)swapchainExtension);
|
|
SilkMarshal.Free((nint)maintenance8Extension);
|
|
SilkMarshal.Free((nint)robustness2Extension);
|
|
SilkMarshal.Free((nint)portabilitySubsetExtension);
|
|
}
|
|
|
|
_vk.GetDeviceQueue(_device, _queueFamilyIndex, 0, out _queue);
|
|
LoadDebugUtilsCommands();
|
|
if (!_vk.TryGetDeviceExtension(_instance, _device, out _swapchainApi))
|
|
{
|
|
throw new InvalidOperationException("VK_KHR_swapchain is unavailable.");
|
|
}
|
|
}
|
|
|
|
private void CreatePipelineCache()
|
|
{
|
|
var cacheMode = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE");
|
|
// Vulkan cache blobs carry the implementation's compatibility
|
|
// header and are rejected/rebuilt below when the device or driver
|
|
// changes. MoltenVK compilation of a large translated shader can
|
|
// take ten seconds, so discarding a valid cache at every launch is
|
|
// much more harmful than using Vulkan's normal persistence path.
|
|
// Keep an explicit opt-out for diagnostics and read-only systems.
|
|
var persistentCacheEnabled =
|
|
!string.Equals(cacheMode, "0", StringComparison.Ordinal);
|
|
_pipelineCachePath = persistentCacheEnabled ? GetPipelineCachePath() : null;
|
|
byte[] initialData = [];
|
|
try
|
|
{
|
|
if (_pipelineCachePath is not null && File.Exists(_pipelineCachePath))
|
|
{
|
|
initialData = File.ReadAllBytes(_pipelineCachePath);
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan pipeline cache read failed: {exception.Message}");
|
|
}
|
|
|
|
var result = TryCreatePipelineCache(initialData, out _pipelineCache);
|
|
if (result != Result.Success && initialData.Length != 0)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan pipeline cache rejected ({result}); rebuilding it.");
|
|
result = TryCreatePipelineCache([], out _pipelineCache);
|
|
}
|
|
|
|
if (result != Result.Success)
|
|
{
|
|
_pipelineCache = default;
|
|
_pipelineCachePath = null;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan pipeline cache unavailable: {result}");
|
|
return;
|
|
}
|
|
|
|
SetDebugName(
|
|
ObjectType.PipelineCache,
|
|
_pipelineCache.Handle,
|
|
_pipelineCachePath is null
|
|
? "SharpEmu in-memory pipeline cache"
|
|
: "SharpEmu persistent pipeline cache");
|
|
_lastPipelineCacheSaveTick = Environment.TickCount64;
|
|
if (_pipelineCachePath is null)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][INFO] Vulkan pipeline cache ready: memory-only " +
|
|
"(persistence disabled with SHARPEMU_VK_PIPELINE_CACHE=0).");
|
|
}
|
|
else
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan pipeline cache ready: path={_pipelineCachePath} initial={initialData.Length} bytes");
|
|
}
|
|
}
|
|
|
|
private Result TryCreatePipelineCache(byte[] initialData, out PipelineCache pipelineCache)
|
|
{
|
|
fixed (byte* initialDataPointer = initialData)
|
|
{
|
|
var createInfo = new PipelineCacheCreateInfo
|
|
{
|
|
SType = StructureType.PipelineCacheCreateInfo,
|
|
InitialDataSize = (nuint)initialData.Length,
|
|
PInitialData = initialData.Length == 0 ? null : initialDataPointer,
|
|
};
|
|
return _vk.CreatePipelineCache(
|
|
_device,
|
|
&createInfo,
|
|
null,
|
|
out pipelineCache);
|
|
}
|
|
}
|
|
|
|
private static string GetPipelineCachePath()
|
|
{
|
|
var configured = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_PATH");
|
|
if (!string.IsNullOrWhiteSpace(configured))
|
|
{
|
|
return Path.GetFullPath(
|
|
Environment.ExpandEnvironmentVariables(configured));
|
|
}
|
|
|
|
var root = OperatingSystem.IsMacOS()
|
|
? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
"Library",
|
|
"Caches")
|
|
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
|
return Path.Combine(root, "SharpEmu", "vulkan-pipeline-cache.bin");
|
|
}
|
|
|
|
private void MarkPipelineCacheDirty()
|
|
{
|
|
if (_pipelineCache.Handle == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_pipelineCacheDirty = true;
|
|
// Exporting MoltenVK's cache can itself serialize the compiler.
|
|
// Gameplay may discover dozens of expensive pipelines in one
|
|
// frame, so saving after every slow creation compounds a warm-up
|
|
// hitch into a multi-minute stall. Coalesce all creations into one
|
|
// periodic snapshot; shutdown still forces a final save.
|
|
if (Environment.TickCount64 - _lastPipelineCacheSaveTick >= 30_000)
|
|
{
|
|
SavePipelineCache(force: false);
|
|
}
|
|
}
|
|
|
|
private void SavePipelineCache(bool force)
|
|
{
|
|
if (_pipelineCache.Handle == 0 || string.IsNullOrWhiteSpace(_pipelineCachePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!force && !_pipelineCacheDirty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
nuint size = 0;
|
|
var result = _vk.GetPipelineCacheData(
|
|
_device,
|
|
_pipelineCache,
|
|
&size,
|
|
null);
|
|
if (result != Result.Success || size == 0 || size > 256u * 1024u * 1024u)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan pipeline cache query failed: result={result} size={size}");
|
|
return;
|
|
}
|
|
|
|
var data = new byte[checked((int)size)];
|
|
fixed (byte* dataPointer = data)
|
|
{
|
|
result = _vk.GetPipelineCacheData(
|
|
_device,
|
|
_pipelineCache,
|
|
&size,
|
|
dataPointer);
|
|
}
|
|
|
|
if (result != Result.Success)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan pipeline cache export failed: {result}");
|
|
return;
|
|
}
|
|
|
|
if (size != (nuint)data.Length)
|
|
{
|
|
Array.Resize(ref data, checked((int)size));
|
|
}
|
|
|
|
var directory = Path.GetDirectoryName(_pipelineCachePath);
|
|
if (!string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var temporaryPath = _pipelineCachePath + $".{Environment.ProcessId}.tmp";
|
|
File.WriteAllBytes(temporaryPath, data);
|
|
File.Move(temporaryPath, _pipelineCachePath, overwrite: true);
|
|
_pipelineCacheDirty = false;
|
|
_lastPipelineCacheSaveTick = Environment.TickCount64;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan pipeline cache saved: path={_pipelineCachePath} bytes={data.Length}");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan pipeline cache save failed: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private void CreateSwapchain()
|
|
{
|
|
Check(
|
|
_surfaceApi.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out var capabilities),
|
|
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
|
|
|
|
uint formatCount = 0;
|
|
Check(
|
|
_surfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &formatCount, null),
|
|
"vkGetPhysicalDeviceSurfaceFormatsKHR");
|
|
var formats = new SurfaceFormatKHR[formatCount];
|
|
fixed (SurfaceFormatKHR* formatPointer = formats)
|
|
{
|
|
Check(
|
|
_surfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &formatCount, formatPointer),
|
|
"vkGetPhysicalDeviceSurfaceFormatsKHR");
|
|
}
|
|
|
|
var surfaceFormat = ChooseSurfaceFormat(formats);
|
|
_swapchainFormat = surfaceFormat.Format;
|
|
_extent = ChooseExtent(capabilities);
|
|
var presentMode = ChoosePresentMode();
|
|
var imageCount = capabilities.MinImageCount + 1;
|
|
if (capabilities.MaxImageCount != 0)
|
|
{
|
|
imageCount = Math.Min(imageCount, capabilities.MaxImageCount);
|
|
}
|
|
|
|
var compositeAlpha = ChooseCompositeAlpha(capabilities.SupportedCompositeAlpha);
|
|
var createInfo = new SwapchainCreateInfoKHR
|
|
{
|
|
SType = StructureType.SwapchainCreateInfoKhr,
|
|
Surface = _surface,
|
|
MinImageCount = imageCount,
|
|
ImageFormat = surfaceFormat.Format,
|
|
ImageColorSpace = surfaceFormat.ColorSpace,
|
|
ImageExtent = _extent,
|
|
ImageArrayLayers = 1,
|
|
ImageUsage =
|
|
ImageUsageFlags.TransferDstBit |
|
|
ImageUsageFlags.TransferSrcBit |
|
|
ImageUsageFlags.ColorAttachmentBit,
|
|
ImageSharingMode = SharingMode.Exclusive,
|
|
PreTransform = capabilities.CurrentTransform,
|
|
CompositeAlpha = compositeAlpha,
|
|
PresentMode = presentMode,
|
|
Clipped = true,
|
|
};
|
|
|
|
Check(_swapchainApi.CreateSwapchain(_device, &createInfo, null, out _swapchain), "vkCreateSwapchainKHR");
|
|
|
|
uint swapchainImageCount = 0;
|
|
Check(
|
|
_swapchainApi.GetSwapchainImages(_device, _swapchain, &swapchainImageCount, null),
|
|
"vkGetSwapchainImagesKHR");
|
|
_swapchainImages = new Image[swapchainImageCount];
|
|
fixed (Image* imagePointer = _swapchainImages)
|
|
{
|
|
Check(
|
|
_swapchainApi.GetSwapchainImages(_device, _swapchain, &swapchainImageCount, imagePointer),
|
|
"vkGetSwapchainImagesKHR");
|
|
}
|
|
|
|
_imageInitialized = new bool[swapchainImageCount];
|
|
}
|
|
|
|
private PresentModeKHR ChoosePresentMode()
|
|
{
|
|
// MAILBOX never blocks vkQueuePresentKHR on vblank, so a slow
|
|
// frame does not quantize the frame rate down to 30/20 fps the
|
|
// way FIFO does; the guest side is already paced by PaceFlip.
|
|
// FIFO is the only mode guaranteed by the spec and remains the
|
|
// fallback (MoltenVK typically exposes FIFO + IMMEDIATE only).
|
|
uint modeCount = 0;
|
|
if (_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
|
|
_physicalDevice,
|
|
_surface,
|
|
&modeCount,
|
|
null) != Result.Success ||
|
|
modeCount == 0)
|
|
{
|
|
return PresentModeKHR.FifoKhr;
|
|
}
|
|
|
|
var modes = stackalloc PresentModeKHR[(int)modeCount];
|
|
if (_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
|
|
_physicalDevice,
|
|
_surface,
|
|
&modeCount,
|
|
modes) != Result.Success)
|
|
{
|
|
return PresentModeKHR.FifoKhr;
|
|
}
|
|
|
|
for (var index = 0u; index < modeCount; index++)
|
|
{
|
|
if (modes[index] == PresentModeKHR.MailboxKhr)
|
|
{
|
|
return PresentModeKHR.MailboxKhr;
|
|
}
|
|
}
|
|
|
|
return PresentModeKHR.FifoKhr;
|
|
}
|
|
|
|
private void CreateCommandResources()
|
|
{
|
|
var poolInfo = new CommandPoolCreateInfo
|
|
{
|
|
SType = StructureType.CommandPoolCreateInfo,
|
|
Flags = CommandPoolCreateFlags.ResetCommandBufferBit,
|
|
QueueFamilyIndex = _queueFamilyIndex,
|
|
};
|
|
Check(_vk.CreateCommandPool(_device, &poolInfo, null, out _commandPool), "vkCreateCommandPool");
|
|
|
|
var allocateInfo = new CommandBufferAllocateInfo
|
|
{
|
|
SType = StructureType.CommandBufferAllocateInfo,
|
|
CommandPool = _commandPool,
|
|
Level = CommandBufferLevel.Primary,
|
|
CommandBufferCount = MaxFramesInFlight,
|
|
};
|
|
_frameCommandBuffers = new CommandBuffer[MaxFramesInFlight];
|
|
fixed (CommandBuffer* frameCommandBuffers = _frameCommandBuffers)
|
|
{
|
|
Check(
|
|
_vk.AllocateCommandBuffers(_device, &allocateInfo, frameCommandBuffers),
|
|
"vkAllocateCommandBuffers");
|
|
}
|
|
|
|
var semaphoreInfo = new SemaphoreCreateInfo
|
|
{
|
|
SType = StructureType.SemaphoreCreateInfo,
|
|
};
|
|
var fenceInfo = new FenceCreateInfo
|
|
{
|
|
SType = StructureType.FenceCreateInfo,
|
|
};
|
|
_frameImageAvailable = new VkSemaphore[MaxFramesInFlight];
|
|
_frameFences = new Fence[MaxFramesInFlight];
|
|
_frameFencePending = new bool[MaxFramesInFlight];
|
|
_frameTimelines = new ulong[MaxFramesInFlight];
|
|
_frameTranslatedResources = new TranslatedDrawResources?[MaxFramesInFlight];
|
|
_frameGuestImageVersions = new GuestImageResource?[MaxFramesInFlight];
|
|
for (var slot = 0; slot < MaxFramesInFlight; slot++)
|
|
{
|
|
Check(
|
|
_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _frameImageAvailable[slot]),
|
|
"vkCreateSemaphore");
|
|
Check(
|
|
_vk.CreateFence(_device, &fenceInfo, null, out _frameFences[slot]),
|
|
"vkCreateFence(frame)");
|
|
}
|
|
|
|
_renderFinishedPerImage = new VkSemaphore[_swapchainImages.Length];
|
|
for (var image = 0; image < _renderFinishedPerImage.Length; image++)
|
|
{
|
|
Check(
|
|
_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _renderFinishedPerImage[image]),
|
|
"vkCreateSemaphore");
|
|
}
|
|
|
|
_currentFrameSlot = 0;
|
|
_commandBuffer = _frameCommandBuffers[0];
|
|
_presentationCommandBuffer = _commandBuffer;
|
|
|
|
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
|
|
CreateOverlayResources();
|
|
}
|
|
|
|
private void CreateOverlayResources()
|
|
{
|
|
const ulong overlayBytes = PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4;
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
ImageType = ImageType.Type2D,
|
|
Format = Format.B8G8R8A8Unorm,
|
|
Extent = new Extent3D(PerfOverlay.PanelWidth, PerfOverlay.PanelHeight, 1),
|
|
MipLevels = 1,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(_vk.CreateImage(_device, &imageInfo, null, out _overlayImage), "vkCreateImage(overlay)");
|
|
_vk.GetImageMemoryRequirements(_device, _overlayImage, out var requirements);
|
|
var memoryInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
requirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(
|
|
_vk.AllocateMemory(_device, &memoryInfo, null, out _overlayImageMemory),
|
|
"vkAllocateMemory(overlay)");
|
|
Check(
|
|
_vk.BindImageMemory(_device, _overlayImage, _overlayImageMemory, 0),
|
|
"vkBindImageMemory(overlay)");
|
|
_overlayImageInitialized = false;
|
|
|
|
_overlayStagingBuffers = new VkBuffer[MaxFramesInFlight];
|
|
_overlayStagingMemory = new DeviceMemory[MaxFramesInFlight];
|
|
_overlayStagingMapped = new nint[MaxFramesInFlight];
|
|
for (var slot = 0; slot < MaxFramesInFlight; slot++)
|
|
{
|
|
_overlayStagingBuffers[slot] = CreateBuffer(
|
|
overlayBytes,
|
|
BufferUsageFlags.TransferSrcBit,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out _overlayStagingMemory[slot]);
|
|
void* mapped;
|
|
Check(
|
|
_vk.MapMemory(_device, _overlayStagingMemory[slot], 0, overlayBytes, 0, &mapped),
|
|
"vkMapMemory(overlay staging)");
|
|
_overlayStagingMapped[slot] = (nint)mapped;
|
|
}
|
|
}
|
|
|
|
private void RecordOverlayBlit(uint imageIndex, int frameSlot)
|
|
{
|
|
if (_overlayImage.Handle == 0 || _overlayStagingMapped.Length <= frameSlot)
|
|
{
|
|
return;
|
|
}
|
|
|
|
int pendingWork;
|
|
lock (_gate)
|
|
{
|
|
pendingWork = _pendingGuestWorkCount;
|
|
}
|
|
|
|
var pixels = new Span<byte>(
|
|
(void*)_overlayStagingMapped[frameSlot],
|
|
PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4);
|
|
PerfOverlay.Fill(pixels, pendingWork, _pendingGuestSubmissions.Count);
|
|
|
|
var toTransferDst = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = _overlayImageInitialized ? AccessFlags.TransferReadBit : 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = _overlayImageInitialized
|
|
? ImageLayout.TransferSrcOptimal
|
|
: ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _overlayImage,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0, 0, null, 0, null, 1, &toTransferDst);
|
|
|
|
var copyRegion = new BufferImageCopy
|
|
{
|
|
ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
|
|
ImageExtent = new Extent3D(PerfOverlay.PanelWidth, PerfOverlay.PanelHeight, 1),
|
|
};
|
|
_vk.CmdCopyBufferToImage(
|
|
_commandBuffer,
|
|
_overlayStagingBuffers[frameSlot],
|
|
_overlayImage,
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©Region);
|
|
|
|
var toTransferSrc = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _overlayImage,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
var swapchainToDst = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = ImageLayout.PresentSrcKhr,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
var preBlitBarriers = stackalloc ImageMemoryBarrier[2] { toTransferSrc, swapchainToDst };
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit | PipelineStageFlags.ColorAttachmentOutputBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0, 0, null, 0, null, 2, preBlitBarriers);
|
|
|
|
const int margin = 12;
|
|
var panelWidth = (int)Math.Min(PerfOverlay.PanelWidth, _extent.Width - margin);
|
|
var panelHeight = (int)Math.Min(PerfOverlay.PanelHeight, _extent.Height - margin);
|
|
// Source and destination are both B8G8R8A8 and the panel is not
|
|
// scaled. MoltenVK has corrupted pixels outside the blit region
|
|
// for this transfer-on-swapchain path (horizontal red/yellow
|
|
// scanlines across the entire window). An exact image copy has
|
|
// the required semantics and avoids the driver's blit conversion
|
|
// path altogether.
|
|
var copy = new ImageCopy
|
|
{
|
|
SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
|
|
DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
|
|
SrcOffset = new Offset3D(0, 0, 0),
|
|
DstOffset = new Offset3D(margin, margin, 0),
|
|
Extent = new Extent3D((uint)panelWidth, (uint)panelHeight, 1),
|
|
};
|
|
_vk.CmdCopyImage(
|
|
_commandBuffer,
|
|
_overlayImage,
|
|
ImageLayout.TransferSrcOptimal,
|
|
_swapchainImages[imageIndex],
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©);
|
|
|
|
var swapchainToPresent = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = 0,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.PresentSrcKhr,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.BottomOfPipeBit,
|
|
0, 0, null, 0, null, 1, &swapchainToPresent);
|
|
_overlayImageInitialized = true;
|
|
}
|
|
|
|
private CommandBuffer AllocateGuestCommandBuffer()
|
|
{
|
|
// The pool has ResetCommandBufferBit, so vkBeginCommandBuffer
|
|
// implicitly resets recycled buffers.
|
|
if (_recycledGuestCommandBuffers.TryPop(out var recycled))
|
|
{
|
|
return recycled;
|
|
}
|
|
|
|
var allocateInfo = new CommandBufferAllocateInfo
|
|
{
|
|
SType = StructureType.CommandBufferAllocateInfo,
|
|
CommandPool = _commandPool,
|
|
Level = CommandBufferLevel.Primary,
|
|
CommandBufferCount = 1,
|
|
};
|
|
CommandBuffer commandBuffer;
|
|
Check(
|
|
_vk.AllocateCommandBuffers(
|
|
_device,
|
|
&allocateInfo,
|
|
out commandBuffer),
|
|
"vkAllocateCommandBuffers(guest)");
|
|
return commandBuffer;
|
|
}
|
|
|
|
private Fence AcquireGuestFence()
|
|
{
|
|
// Recycled fences were reset when they were collected.
|
|
if (_recycledGuestFences.TryPop(out var recycled))
|
|
{
|
|
return recycled;
|
|
}
|
|
|
|
var fenceInfo = new FenceCreateInfo
|
|
{
|
|
SType = StructureType.FenceCreateInfo,
|
|
};
|
|
Fence fence;
|
|
Check(
|
|
_vk.CreateFence(_device, &fenceInfo, null, out fence),
|
|
"vkCreateFence(guest)");
|
|
return fence;
|
|
}
|
|
|
|
private void ReleaseGuestCommandBuffer(CommandBuffer commandBuffer)
|
|
{
|
|
if (_recycledGuestCommandBuffers.Count < MaxRecycledGuestCommandBuffers)
|
|
{
|
|
_recycledGuestCommandBuffers.Push(commandBuffer);
|
|
return;
|
|
}
|
|
|
|
_vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer);
|
|
}
|
|
|
|
private void ReleaseGuestFence(Fence fence, bool needsReset)
|
|
{
|
|
if (_recycledGuestFences.Count < MaxRecycledGuestFences)
|
|
{
|
|
if (needsReset)
|
|
{
|
|
Check(_vk.ResetFences(_device, 1, &fence), "vkResetFences(guest)");
|
|
}
|
|
|
|
_recycledGuestFences.Push(fence);
|
|
return;
|
|
}
|
|
|
|
_vk.DestroyFence(_device, fence, null);
|
|
}
|
|
|
|
// Translated draws are recorded into a shared command buffer and
|
|
// submitted once per drained work batch: on MoltenVK every
|
|
// vkQueueSubmit is a Metal command-buffer commit (~0.8ms), which used
|
|
// to be paid per draw and dominated the frame time.
|
|
private CommandBuffer _batchCommandBuffer;
|
|
private bool _batchOpen;
|
|
private int _batchDrawCount;
|
|
private readonly List<TranslatedDrawResources> _batchResources = new();
|
|
private readonly List<GuestImageResource> _batchTraceImages = new();
|
|
|
|
// Consecutive draws into the same target stay inside one render pass:
|
|
// on MoltenVK every render pass is a Metal render encoder, and one
|
|
// encoder per draw was the dominant per-draw fixed cost after submit
|
|
// batching. The pass closes when the target changes, when a draw
|
|
// needs transfer/storage work outside a pass, or when the batch
|
|
// flushes.
|
|
private GuestImageResource? _openPassTarget;
|
|
|
|
private void CloseOpenTranslatedRenderPass()
|
|
{
|
|
if (_openPassTarget is not { } target)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_openPassTarget = null;
|
|
_vk.CmdEndRenderPass(_batchCommandBuffer);
|
|
var toShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ColorAttachmentWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.ColorAttachmentOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = target.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_batchCommandBuffer,
|
|
PipelineStageFlags.ColorAttachmentOutputBit,
|
|
PipelineStageFlags.FragmentShaderBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toShaderRead);
|
|
}
|
|
|
|
private CommandBuffer BeginBatchedGuestCommands()
|
|
{
|
|
if (_batchOpen)
|
|
{
|
|
return _batchCommandBuffer;
|
|
}
|
|
|
|
_batchCommandBuffer = AllocateGuestCommandBuffer();
|
|
var beginInfo = new CommandBufferBeginInfo
|
|
{
|
|
SType = StructureType.CommandBufferBeginInfo,
|
|
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
|
};
|
|
Check(
|
|
_vk.BeginCommandBuffer(_batchCommandBuffer, &beginInfo),
|
|
"vkBeginCommandBuffer(batch)");
|
|
_batchOpen = true;
|
|
_batchDrawCount = 0;
|
|
return _batchCommandBuffer;
|
|
}
|
|
|
|
private void FlushBatchedGuestCommands()
|
|
{
|
|
if (!_batchOpen)
|
|
{
|
|
return;
|
|
}
|
|
|
|
CloseOpenTranslatedRenderPass();
|
|
_batchOpen = false;
|
|
try
|
|
{
|
|
Check(_vk.EndCommandBuffer(_batchCommandBuffer), "vkEndCommandBuffer(batch)");
|
|
SubmitGuestCommandBuffer(
|
|
_batchCommandBuffer,
|
|
_batchResources.ToArray(),
|
|
_batchTraceImages.ToArray(),
|
|
_batchRetireBuffers.Count > 0 ? _batchRetireBuffers.ToArray() : []);
|
|
}
|
|
catch
|
|
{
|
|
// The batch never reached the queue: release everything it
|
|
// owned here so the stale lists cannot ride into the next
|
|
// batch's submission.
|
|
foreach (var resources in _batchResources)
|
|
{
|
|
DestroyTranslatedDrawResources(resources);
|
|
}
|
|
|
|
foreach (var (buffer, memory) in _batchRetireBuffers)
|
|
{
|
|
_vk.DestroyBuffer(_device, buffer, null);
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
|
|
ReleaseGuestCommandBuffer(_batchCommandBuffer);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_batchResources.Clear();
|
|
_batchTraceImages.Clear();
|
|
_batchRetireBuffers.Clear();
|
|
_batchCommandBuffer = default;
|
|
}
|
|
}
|
|
|
|
private void SubmitGuestCommandBuffer(
|
|
CommandBuffer commandBuffer,
|
|
IReadOnlyList<TranslatedDrawResources> resources,
|
|
IReadOnlyList<GuestImageResource> traceImages,
|
|
IReadOnlyList<(VkBuffer Buffer, DeviceMemory Memory)>? retireBuffers = null,
|
|
IReadOnlyList<TranslatedDrawResources>? referencedResources = null)
|
|
{
|
|
var fence = AcquireGuestFence();
|
|
try
|
|
{
|
|
var submitInfo = new SubmitInfo
|
|
{
|
|
SType = StructureType.SubmitInfo,
|
|
CommandBufferCount = 1,
|
|
PCommandBuffers = &commandBuffer,
|
|
};
|
|
Check(
|
|
_vk.QueueSubmit(_queue, 1, &submitInfo, fence),
|
|
"vkQueueSubmit(guest)");
|
|
}
|
|
catch
|
|
{
|
|
ReleaseGuestFence(fence, needsReset: false);
|
|
throw;
|
|
}
|
|
|
|
_submitTimeline++;
|
|
foreach (var referenced in referencedResources ?? resources)
|
|
{
|
|
foreach (var globalBuffer in referenced.GlobalMemoryBuffers)
|
|
{
|
|
if (globalBuffer.Allocation is not { } allocation)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
allocation.LastUseTimeline = Math.Max(
|
|
allocation.LastUseTimeline,
|
|
_submitTimeline);
|
|
if (globalBuffer.Writable && globalBuffer.WriteBackToGuest)
|
|
{
|
|
MarkGuestBufferDirty(
|
|
allocation,
|
|
globalBuffer.GuestOffset,
|
|
globalBuffer.GuestSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
_pendingGuestSubmissions.Enqueue(
|
|
new PendingGuestSubmission(
|
|
fence,
|
|
commandBuffer,
|
|
resources,
|
|
traceImages,
|
|
retireBuffers ?? [],
|
|
_submitTimeline,
|
|
resources.Count > 0 ? resources[0].DebugName : "batch",
|
|
_activeGuestQueue,
|
|
_activeGuestWorkSequence));
|
|
_lastSubmittedTimelineByGuestQueue[_activeGuestQueue.Name] =
|
|
_submitTimeline;
|
|
}
|
|
|
|
private void TransitionNewGuestImageToSampled(Image image, uint mipLevels)
|
|
{
|
|
var commandBuffer = AllocateGuestCommandBuffer();
|
|
var beginInfo = new CommandBufferBeginInfo
|
|
{
|
|
SType = StructureType.CommandBufferBeginInfo,
|
|
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
|
};
|
|
Check(
|
|
_vk.BeginCommandBuffer(commandBuffer, &beginInfo),
|
|
"vkBeginCommandBuffer(guest image init)");
|
|
var barrier = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = 0,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = image,
|
|
SubresourceRange = ColorSubresourceRange(0, mipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.AllCommandsBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&barrier);
|
|
Check(
|
|
_vk.EndCommandBuffer(commandBuffer),
|
|
"vkEndCommandBuffer(guest image init)");
|
|
// Same-queue submission order makes the transition visible to any
|
|
// later use of the image; no CPU-side wait is needed.
|
|
SubmitGuestCommandBuffer(commandBuffer, [], []);
|
|
}
|
|
|
|
private void EnsureGuestSubmissionCapacity()
|
|
{
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
|
|
{
|
|
// Bounded wait so the macOS main thread returns to its event
|
|
// pump promptly under a slow-compute backlog; if the oldest
|
|
// isn't done yet we proceed (soft cap, dynamic pools).
|
|
CollectCompletedGuestSubmissions(
|
|
waitForOldest: true,
|
|
maxWaitNs: _submissionCapacityWaitNs == 0
|
|
? _guestFenceWaitTimeoutNs
|
|
: _submissionCapacityWaitNs);
|
|
}
|
|
}
|
|
|
|
private void WaitForAllGuestSubmissions()
|
|
{
|
|
while (_pendingGuestSubmissions.Count != 0)
|
|
{
|
|
CollectCompletedGuestSubmissions(waitForOldest: true);
|
|
}
|
|
}
|
|
|
|
private void CollectCompletedGuestSubmissions(bool waitForOldest, ulong maxWaitNs = 0)
|
|
{
|
|
if (waitForOldest && _pendingGuestSubmissions.TryPeek(out var oldest))
|
|
{
|
|
var fence = oldest.Fence;
|
|
// maxWaitNs==0 => the full "is this submission hung" timeout,
|
|
// which also emits the one-shot hang warning below. A shorter
|
|
// capacity-probe wait (maxWaitNs>0) must NOT report a hang: the
|
|
// submission is still tracked and will be collected once the GPU
|
|
// finishes it on a later frame.
|
|
var isProbeWait = maxWaitNs != 0 && maxWaitNs < _guestFenceWaitTimeoutNs;
|
|
var waitNs = maxWaitNs != 0 ? maxWaitNs : _guestFenceWaitTimeoutNs;
|
|
var result = _vk.WaitForFences(
|
|
_device,
|
|
1,
|
|
&fence,
|
|
true,
|
|
waitNs);
|
|
if (result == Result.Timeout)
|
|
{
|
|
// A GPU submission whose fence never signals (typically a
|
|
// mistranslated compute shader that hangs the Metal queue)
|
|
// would otherwise block the render thread forever, starving
|
|
// the swapchain present (black screen). Log the culprit and
|
|
// continue so at least the last good frame can be shown.
|
|
if (!isProbeWait && _tracedFenceTimeouts.Add(oldest.DebugName))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.fence_wait_timeout submission='{oldest.DebugName}' " +
|
|
$"— GPU work not completing after {_guestFenceWaitTimeoutNs / 1_000_000}ms; " +
|
|
"render thread continuing (present not blocked).");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (result == Result.ErrorDeviceLost)
|
|
{
|
|
_deviceLost = true;
|
|
}
|
|
else
|
|
{
|
|
Check(result, $"vkWaitForFences(guest: {oldest.DebugName})");
|
|
}
|
|
}
|
|
|
|
while (_pendingGuestSubmissions.TryPeek(out var submission))
|
|
{
|
|
var status = _vk.GetFenceStatus(_device, submission.Fence);
|
|
if (status == Result.NotReady && !_deviceLost)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (status == Result.ErrorDeviceLost)
|
|
{
|
|
// Pending fences never signal on a lost device; retire the
|
|
// submission anyway so teardown and back-pressure survive.
|
|
_deviceLost = true;
|
|
}
|
|
else if (status != Result.NotReady)
|
|
{
|
|
Check(status, $"vkGetFenceStatus(guest: {submission.DebugName})");
|
|
}
|
|
|
|
_pendingGuestSubmissions.Dequeue();
|
|
|
|
if (!_deviceLost)
|
|
{
|
|
foreach (var image in submission.TraceImages)
|
|
{
|
|
TraceGuestImageContents(image);
|
|
}
|
|
}
|
|
|
|
foreach (var resources in submission.Resources)
|
|
{
|
|
DestroyTranslatedDrawResources(resources);
|
|
}
|
|
|
|
foreach (var (buffer, memory) in submission.RetireBuffers)
|
|
{
|
|
_vk.DestroyBuffer(_device, buffer, null);
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
|
|
ReleaseGuestCommandBuffer(submission.CommandBuffer);
|
|
ReleaseGuestFence(submission.Fence, needsReset: true);
|
|
if (submission.Timeline > _completedTimeline)
|
|
{
|
|
_completedTimeline = submission.Timeline;
|
|
}
|
|
}
|
|
|
|
ProcessDeferredTextureDestroys();
|
|
}
|
|
|
|
private void WaitForAllGuestSubmissionsForCpuVisibility()
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
while (_pendingGuestSubmissions.TryPeek(out var oldest))
|
|
{
|
|
var fence = oldest.Fence;
|
|
Check(
|
|
_vk.WaitForFences(_device, 1, &fence, true, ulong.MaxValue),
|
|
$"vkWaitForFences(cpu visibility: {oldest.DebugName})");
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
}
|
|
}
|
|
|
|
private void WaitForActiveGuestQueueSubmissionsForCpuVisibility()
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
if (!_lastSubmittedTimelineByGuestQueue.TryGetValue(
|
|
_activeGuestQueue.Name,
|
|
out var targetTimeline) ||
|
|
targetTimeline <= _completedTimeline)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingGuestSubmission? target = null;
|
|
foreach (var submission in _pendingGuestSubmissions)
|
|
{
|
|
if (submission.Timeline == targetTimeline)
|
|
{
|
|
target = submission;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (target is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Guest queue '{_activeGuestQueue.Name}' lost pending timeline " +
|
|
$"{targetTimeline} (completed={_completedTimeline}).");
|
|
}
|
|
|
|
var waitStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
var fence = target.Fence;
|
|
Check(
|
|
_vk.WaitForFences(_device, 1, &fence, true, ulong.MaxValue),
|
|
$"vkWaitForFences(queue visibility: {_activeGuestQueue.Name})");
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
var waitedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - waitStart) *
|
|
1000.0 / System.Diagnostics.Stopwatch.Frequency;
|
|
if (_traceVulkanShaderEnabled)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.queue_visibility queue={_activeGuestQueue.Name} " +
|
|
$"submission={_activeGuestQueue.SubmissionId} " +
|
|
$"target_timeline={targetTimeline} completed_timeline={_completedTimeline} " +
|
|
$"waited_ms={waitedMs:F3}");
|
|
}
|
|
}
|
|
|
|
private void ExecuteOrderedGuestAction(VulkanOrderedGuestAction work)
|
|
{
|
|
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
|
|
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
|
|
work.Action();
|
|
if (_traceVulkanShaderEnabled)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.ordered_action queue={_activeGuestQueue.Name} " +
|
|
$"submission={_activeGuestQueue.SubmissionId} " +
|
|
$"work_sequence={_activeGuestWorkSequence} name='{work.DebugName}'");
|
|
}
|
|
}
|
|
|
|
private void ExecuteOrderedGuestFlip(VulkanOrderedGuestFlip work)
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
_guestImages.TryGetValue(work.Address, out var source);
|
|
if (_deviceLost ||
|
|
source is null ||
|
|
!source.Initialized)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.flip_capture_failed version={work.Version} " +
|
|
$"queue={_activeGuestQueue.Name} addr=0x{work.Address:X16} " +
|
|
$"found={(source is not null)} initialized={(source?.Initialized ?? false)}");
|
|
return;
|
|
}
|
|
|
|
EnsureGuestSubmissionCapacity();
|
|
var snapshot = CreateGuestFlipSnapshot(source, work.Version);
|
|
var commandBuffer = AllocateGuestCommandBuffer();
|
|
var submitted = false;
|
|
try
|
|
{
|
|
var beginInfo = new CommandBufferBeginInfo
|
|
{
|
|
SType = StructureType.CommandBufferBeginInfo,
|
|
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
|
};
|
|
Check(
|
|
_vk.BeginCommandBuffer(commandBuffer, &beginInfo),
|
|
"vkBeginCommandBuffer(flip capture)");
|
|
|
|
var barriers = stackalloc ImageMemoryBarrier[2];
|
|
barriers[0] = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ShaderReadBit |
|
|
AccessFlags.ShaderWriteBit |
|
|
AccessFlags.ColorAttachmentWriteBit |
|
|
AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
barriers[1] = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = snapshot.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
PipelineStageFlags.AllCommandsBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
2,
|
|
barriers);
|
|
|
|
var copy = new ImageCopy
|
|
{
|
|
SrcSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit, 0, 0, 1),
|
|
DstSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit, 0, 0, 1),
|
|
Extent = new Extent3D(source.Width, source.Height, 1),
|
|
};
|
|
_vk.CmdCopyImage(
|
|
commandBuffer,
|
|
source.Image,
|
|
ImageLayout.TransferSrcOptimal,
|
|
snapshot.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©);
|
|
|
|
barriers[0] = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferReadBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit |
|
|
AccessFlags.ShaderWriteBit |
|
|
AccessFlags.ColorAttachmentWriteBit,
|
|
OldLayout = ImageLayout.TransferSrcOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
barriers[1] = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = snapshot.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.AllCommandsBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
2,
|
|
barriers);
|
|
|
|
Check(
|
|
_vk.EndCommandBuffer(commandBuffer),
|
|
"vkEndCommandBuffer(flip capture)");
|
|
SubmitGuestCommandBuffer(commandBuffer, [], []);
|
|
submitted = true;
|
|
snapshot.Initialized = true;
|
|
_guestImageVersions.Add(work.Version, snapshot);
|
|
_capturedGuestFlipVersions.Add(work.Version);
|
|
|
|
lock (_gate)
|
|
{
|
|
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
|
|
var presentation = new Presentation(
|
|
null,
|
|
work.Width,
|
|
work.Height,
|
|
sequence,
|
|
GuestDrawKind.None,
|
|
TranslatedDraw: null,
|
|
RequiredGuestWorkSequence: _activeGuestWorkSequence,
|
|
IsSplash: false,
|
|
GuestImageAddress: work.Address,
|
|
GuestImageVersion: work.Version);
|
|
_latestPresentation = presentation;
|
|
_pendingGuestImagePresentations.Enqueue(presentation);
|
|
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
|
|
{
|
|
_pendingGuestImagePresentations.Dequeue();
|
|
}
|
|
}
|
|
|
|
CollectAbandonedGuestImageVersions();
|
|
|
|
var effectivePitch = work.PitchInPixel == 0
|
|
? work.Width
|
|
: work.PitchInPixel;
|
|
TraceVulkanShader(
|
|
$"vk.flip_capture version={work.Version} " +
|
|
$"queue={_activeGuestQueue.Name} submission={_activeGuestQueue.SubmissionId} " +
|
|
$"work_sequence={_activeGuestWorkSequence} addr=0x{work.Address:X16} " +
|
|
$"size={work.Width}x{work.Height} pitch={effectivePitch}");
|
|
}
|
|
finally
|
|
{
|
|
if (!submitted)
|
|
{
|
|
ReleaseGuestCommandBuffer(commandBuffer);
|
|
DestroyGuestImage(snapshot);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ExecuteOrderedGuestFlipWait(VulkanOrderedGuestFlipWait work)
|
|
{
|
|
var captured = work.Version != 0 &&
|
|
_capturedGuestFlipVersions.Contains(work.Version);
|
|
TraceVulkanShader(
|
|
$"vk.flip_wait_safe version={work.Version} " +
|
|
$"queue={_activeGuestQueue.Name} submission={_activeGuestQueue.SubmissionId} " +
|
|
$"handle={work.VideoOutHandle} index={work.DisplayBufferIndex} " +
|
|
$"capture_complete={(captured ? 1 : 0)}");
|
|
// Demon's Souls executes wait-safe markers before their flip capture;
|
|
// an assert here would fail-fast the process, so warn once instead.
|
|
// Dedup on a flag, not the (per-frame-unique) version, to bound growth.
|
|
if (work.Version != 0 && !captured && !_loggedFlipWaitOrderViolation)
|
|
{
|
|
_loggedFlipWaitOrderViolation = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.flip_wait_order version={work.Version} " +
|
|
"executed before its flip capture; continuing.");
|
|
}
|
|
}
|
|
|
|
private bool _loggedFlipWaitOrderViolation;
|
|
|
|
private GuestImageResource CreateGuestFlipSnapshot(
|
|
GuestImageResource source,
|
|
long version)
|
|
{
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
ImageType = ImageType.Type2D,
|
|
Format = source.Format,
|
|
Extent = new Extent3D(source.Width, source.Height, 1),
|
|
MipLevels = 1,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage = ImageUsageFlags.TransferSrcBit |
|
|
ImageUsageFlags.TransferDstBit |
|
|
ImageUsageFlags.SampledBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(
|
|
_vk.CreateImage(_device, &imageInfo, null, out var image),
|
|
"vkCreateImage(flip snapshot)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
|
var allocationInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
requirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
DeviceMemory memory = default;
|
|
try
|
|
{
|
|
Check(
|
|
_vk.AllocateMemory(_device, &allocationInfo, null, out memory),
|
|
"vkAllocateMemory(flip snapshot)");
|
|
Check(
|
|
_vk.BindImageMemory(_device, image, memory, 0),
|
|
"vkBindImageMemory(flip snapshot)");
|
|
}
|
|
catch
|
|
{
|
|
if (memory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
_vk.DestroyImage(_device, image, null);
|
|
throw;
|
|
}
|
|
|
|
SetDebugName(
|
|
ObjectType.Image,
|
|
image.Handle,
|
|
$"guest flip v{version} source 0x{source.Address:X16}");
|
|
return new GuestImageResource
|
|
{
|
|
Address = source.Address,
|
|
FlipVersion = version,
|
|
Width = source.Width,
|
|
Height = source.Height,
|
|
MipLevels = 1,
|
|
GuestFormat = source.GuestFormat,
|
|
Format = source.Format,
|
|
Image = image,
|
|
Memory = memory,
|
|
};
|
|
}
|
|
|
|
private static byte[]? TryReadGuestTexturePixels(GuestDrawTexture texture)
|
|
{
|
|
var memory = _guestMemory;
|
|
if (memory is null || texture.Address == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var width = Math.Max(texture.Width, 1);
|
|
var height = Math.Max(texture.Height, 1);
|
|
var rowLength = texture.TileMode == 0
|
|
? Math.Max(texture.Pitch, width)
|
|
: width;
|
|
var byteCount = GetTextureByteCount(texture.Format, rowLength, height);
|
|
if (byteCount == 0 || byteCount > int.MaxValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var pixels = new byte[(int)byteCount];
|
|
return memory.TryRead(texture.Address, pixels) ? pixels : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a skipped draw's pooled data arrays: draws dropped before
|
|
/// resource creation would otherwise strand their rented buffers.
|
|
/// </summary>
|
|
private static void ReturnPooledGuestData(VulkanTranslatedGuestDraw draw)
|
|
{
|
|
var returned = new HashSet<byte[]>(
|
|
System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
|
foreach (var buffer in draw.GlobalMemoryBuffers)
|
|
{
|
|
if (buffer.Pooled && returned.Add(buffer.Data))
|
|
{
|
|
GuestDataPool.Shared.Return(buffer.Data);
|
|
}
|
|
}
|
|
|
|
foreach (var buffer in draw.VertexBuffers)
|
|
{
|
|
if (buffer.Pooled && returned.Add(buffer.Data))
|
|
{
|
|
GuestDataPool.Shared.Return(buffer.Data);
|
|
}
|
|
}
|
|
|
|
if (draw.IndexBuffer is { Pooled: true } indexBuffer &&
|
|
returned.Add(indexBuffer.Data))
|
|
{
|
|
GuestDataPool.Shared.Return(indexBuffer.Data);
|
|
}
|
|
}
|
|
|
|
private void ProcessDeferredTextureDestroys()
|
|
{
|
|
while (_deferredTextureDestroys.TryPeek(out var entry) &&
|
|
entry.RetireTimeline <= _completedTimeline)
|
|
{
|
|
_deferredTextureDestroys.Dequeue();
|
|
DestroyCachedTextureResource(entry.Texture);
|
|
}
|
|
|
|
while (_deferredResourceDestroys.TryPeek(out var resourceEntry) &&
|
|
resourceEntry.RetireTimeline <= _completedTimeline)
|
|
{
|
|
_deferredResourceDestroys.Dequeue();
|
|
DestroyTranslatedDrawResources(resourceEntry.Resources);
|
|
}
|
|
|
|
while (_deferredGuestImageVersionDestroys.TryPeek(out var imageEntry) &&
|
|
imageEntry.RetireTimeline <= _completedTimeline)
|
|
{
|
|
_deferredGuestImageVersionDestroys.Dequeue();
|
|
DestroyGuestImage(imageEntry.Image);
|
|
TraceVulkanShader(
|
|
$"vk.flip_retired version={imageEntry.Image.FlipVersion} " +
|
|
$"timeline={imageEntry.RetireTimeline} reason=presentation-dropped");
|
|
}
|
|
}
|
|
|
|
private void WaitFrameSlot(int slot) => TryWaitFrameSlot(slot, ulong.MaxValue);
|
|
|
|
// Returns false when the slot's fence is still unsignaled after
|
|
// timeoutNs (the GPU is behind, e.g. a slow-compute backlog). Callers
|
|
// on the macOS main thread must NOT wait forever here or the Cocoa
|
|
// event pump stalls and the window goes "Not Responding" (F1 overlay /
|
|
// close stop working). A bounded wait lets Render() skip the frame and
|
|
// return to the pump; the fence still signals later and the frame is
|
|
// retried.
|
|
private bool TryWaitFrameSlot(int slot, ulong timeoutNs)
|
|
{
|
|
if (_frameFencePending.Length <= slot || !_frameFencePending[slot])
|
|
{
|
|
if (_frameGuestImageVersions.Length > slot &&
|
|
_frameGuestImageVersions[slot] is { } unsubmittedVersion)
|
|
{
|
|
_frameGuestImageVersions[slot] = null;
|
|
_capturedGuestFlipVersions.Remove(unsubmittedVersion.FlipVersion);
|
|
DestroyGuestImage(unsubmittedVersion);
|
|
TraceVulkanShader(
|
|
$"vk.flip_retired version={unsubmittedVersion.FlipVersion} " +
|
|
$"frame_slot={slot} reason=frame-not-submitted");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
var fence = _frameFences[slot];
|
|
var waitResult = _vk.WaitForFences(_device, 1, &fence, true, timeoutNs);
|
|
if (waitResult == Result.Timeout)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Check(waitResult, "vkWaitForFences(frame)");
|
|
Check(_vk.ResetFences(_device, 1, &fence), "vkResetFences(frame)");
|
|
_frameFencePending[slot] = false;
|
|
if (_frameTimelines[slot] > _completedTimeline)
|
|
{
|
|
_completedTimeline = _frameTimelines[slot];
|
|
}
|
|
|
|
if (_frameTranslatedResources[slot] is { } translated)
|
|
{
|
|
_frameTranslatedResources[slot] = null;
|
|
DestroyTranslatedDrawResources(translated);
|
|
}
|
|
|
|
if (_frameGuestImageVersions[slot] is { } guestImageVersion)
|
|
{
|
|
_frameGuestImageVersions[slot] = null;
|
|
_capturedGuestFlipVersions.Remove(guestImageVersion.FlipVersion);
|
|
DestroyGuestImage(guestImageVersion);
|
|
TraceVulkanShader(
|
|
$"vk.flip_retired version={guestImageVersion.FlipVersion} " +
|
|
$"frame_slot={slot} timeline={_frameTimelines[slot]}");
|
|
}
|
|
|
|
ProcessDeferredTextureDestroys();
|
|
return true;
|
|
}
|
|
|
|
private void WaitAllFrameSlots()
|
|
{
|
|
for (var slot = 0; slot < _frameFencePending.Length; slot++)
|
|
{
|
|
WaitFrameSlot(slot);
|
|
}
|
|
}
|
|
|
|
private void CollectAbandonedGuestImageVersions()
|
|
{
|
|
if (_guestImageVersions.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
HashSet<long> referencedVersions;
|
|
lock (_gate)
|
|
{
|
|
referencedVersions = _pendingGuestImagePresentations
|
|
.Select(static presentation => presentation.GuestImageVersion)
|
|
.Where(static version => version != 0)
|
|
.ToHashSet();
|
|
if (_latestPresentation is { GuestImageVersion: not 0 } latest)
|
|
{
|
|
referencedVersions.Add(latest.GuestImageVersion);
|
|
}
|
|
}
|
|
|
|
foreach (var entry in _guestImageVersions.ToArray())
|
|
{
|
|
if (referencedVersions.Contains(entry.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_guestImageVersions.Remove(entry.Key);
|
|
_capturedGuestFlipVersions.Remove(entry.Key);
|
|
_deferredGuestImageVersionDestroys.Enqueue(
|
|
(entry.Value, _submitTimeline));
|
|
TraceVulkanShader(
|
|
$"vk.flip_retire_deferred version={entry.Key} " +
|
|
$"timeline={_submitTimeline} reason=presentation-dropped");
|
|
}
|
|
}
|
|
|
|
// Used on teardown paths that already drained the queue (device
|
|
// wait-idle): releases per-slot state without touching fences that
|
|
// were never submitted.
|
|
private void DrainFrameSlots()
|
|
{
|
|
WaitAllFrameSlots();
|
|
_completedTimeline = _submitTimeline;
|
|
ProcessDeferredTextureDestroys();
|
|
}
|
|
|
|
private IReadOnlyList<GuestImageResource> GetTraceImages(
|
|
TranslatedDrawResources resources,
|
|
IReadOnlyList<GuestImageResource>? renderTargets = null,
|
|
ulong shaderAddress = 0)
|
|
{
|
|
if (!_traceGuestImagesEnabled &&
|
|
!_traceGuestImageAddressFilterEnabled &&
|
|
GuestImageTraceInterval() is null)
|
|
{
|
|
return Array.Empty<GuestImageResource>();
|
|
}
|
|
|
|
var candidates = new HashSet<GuestImageResource>();
|
|
foreach (var renderTarget in renderTargets ?? [])
|
|
{
|
|
candidates.Add(renderTarget);
|
|
}
|
|
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if ((texture.IsStorage || _traceGuestImageAddressFilterEnabled) &&
|
|
texture.GuestImage is { } image)
|
|
{
|
|
candidates.Add(image);
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
.Where(image => ShouldTraceGuestImageContents(image, shaderAddress))
|
|
.ToArray();
|
|
}
|
|
|
|
private void CreateGuestDrawResources()
|
|
{
|
|
var colorAttachment = new AttachmentDescription
|
|
{
|
|
Format = _swapchainFormat,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
LoadOp = AttachmentLoadOp.Clear,
|
|
StoreOp = AttachmentStoreOp.Store,
|
|
StencilLoadOp = AttachmentLoadOp.DontCare,
|
|
StencilStoreOp = AttachmentStoreOp.DontCare,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
FinalLayout = ImageLayout.PresentSrcKhr,
|
|
};
|
|
var colorReference = new AttachmentReference
|
|
{
|
|
Attachment = 0,
|
|
Layout = ImageLayout.ColorAttachmentOptimal,
|
|
};
|
|
var subpass = new SubpassDescription
|
|
{
|
|
PipelineBindPoint = PipelineBindPoint.Graphics,
|
|
ColorAttachmentCount = 1,
|
|
PColorAttachments = &colorReference,
|
|
};
|
|
var dependency = new SubpassDependency
|
|
{
|
|
SrcSubpass = Vk.SubpassExternal,
|
|
DstSubpass = 0,
|
|
SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
|
|
DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
|
|
DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
|
|
};
|
|
var renderPassInfo = new RenderPassCreateInfo
|
|
{
|
|
SType = StructureType.RenderPassCreateInfo,
|
|
AttachmentCount = 1,
|
|
PAttachments = &colorAttachment,
|
|
SubpassCount = 1,
|
|
PSubpasses = &subpass,
|
|
DependencyCount = 1,
|
|
PDependencies = &dependency,
|
|
};
|
|
Check(
|
|
_vk.CreateRenderPass(
|
|
_device,
|
|
&renderPassInfo,
|
|
null,
|
|
out var swapchainRenderPass),
|
|
"vkCreateRenderPass");
|
|
if (swapchainRenderPass.Handle == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"vkCreateRenderPass returned a null swapchain render pass");
|
|
}
|
|
|
|
_renderPass = swapchainRenderPass;
|
|
|
|
_swapchainImageViews = new ImageView[_swapchainImages.Length];
|
|
_framebuffers = new Framebuffer[_swapchainImages.Length];
|
|
for (var index = 0; index < _swapchainImages.Length; index++)
|
|
{
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = _swapchainImages[index],
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = _swapchainFormat,
|
|
Components = new ComponentMapping(
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity),
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out _swapchainImageViews[index]),
|
|
"vkCreateImageView");
|
|
|
|
var imageView = _swapchainImageViews[index];
|
|
var framebufferInfo = new FramebufferCreateInfo
|
|
{
|
|
SType = StructureType.FramebufferCreateInfo,
|
|
RenderPass = swapchainRenderPass,
|
|
AttachmentCount = 1,
|
|
PAttachments = &imageView,
|
|
Width = _extent.Width,
|
|
Height = _extent.Height,
|
|
Layers = 1,
|
|
};
|
|
Check(
|
|
_vk.CreateFramebuffer(_device, &framebufferInfo, null, out _framebuffers[index]),
|
|
"vkCreateFramebuffer");
|
|
}
|
|
|
|
var layoutInfo = new PipelineLayoutCreateInfo
|
|
{
|
|
SType = StructureType.PipelineLayoutCreateInfo,
|
|
};
|
|
Check(
|
|
_vk.CreatePipelineLayout(_device, &layoutInfo, null, out _pipelineLayout),
|
|
"vkCreatePipelineLayout");
|
|
CreateBarycentricPipeline();
|
|
}
|
|
|
|
private void CreateBarycentricPipeline()
|
|
{
|
|
var vertexBytes = Convert.FromBase64String(FullscreenBarycentricVertexSpirv);
|
|
var fragmentBytes = Convert.FromBase64String(FullscreenBarycentricFragmentSpirv);
|
|
var vertexModule = CreateShaderModule(vertexBytes);
|
|
var fragmentModule = CreateShaderModule(fragmentBytes);
|
|
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
|
|
try
|
|
{
|
|
var shaderStages = stackalloc PipelineShaderStageCreateInfo[2];
|
|
shaderStages[0] = new PipelineShaderStageCreateInfo
|
|
{
|
|
SType = StructureType.PipelineShaderStageCreateInfo,
|
|
Stage = ShaderStageFlags.VertexBit,
|
|
Module = vertexModule,
|
|
PName = entryPoint,
|
|
};
|
|
shaderStages[1] = new PipelineShaderStageCreateInfo
|
|
{
|
|
SType = StructureType.PipelineShaderStageCreateInfo,
|
|
Stage = ShaderStageFlags.FragmentBit,
|
|
Module = fragmentModule,
|
|
PName = entryPoint,
|
|
};
|
|
|
|
var vertexInput = new PipelineVertexInputStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineVertexInputStateCreateInfo,
|
|
};
|
|
var inputAssembly = new PipelineInputAssemblyStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineInputAssemblyStateCreateInfo,
|
|
Topology = PrimitiveTopology.TriangleList,
|
|
};
|
|
var viewport = new Viewport(0, 0, _extent.Width, _extent.Height, 0, 1);
|
|
var scissor = new Rect2D(new Offset2D(0, 0), _extent);
|
|
var viewportState = new PipelineViewportStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineViewportStateCreateInfo,
|
|
ViewportCount = 1,
|
|
PViewports = &viewport,
|
|
ScissorCount = 1,
|
|
PScissors = &scissor,
|
|
};
|
|
var rasterization = new PipelineRasterizationStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineRasterizationStateCreateInfo,
|
|
PolygonMode = PolygonMode.Fill,
|
|
CullMode = CullModeFlags.None,
|
|
FrontFace = FrontFace.CounterClockwise,
|
|
LineWidth = 1,
|
|
};
|
|
var multisample = new PipelineMultisampleStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineMultisampleStateCreateInfo,
|
|
RasterizationSamples = SampleCountFlags.Count1Bit,
|
|
};
|
|
var colorBlendAttachment = new PipelineColorBlendAttachmentState
|
|
{
|
|
ColorWriteMask =
|
|
ColorComponentFlags.RBit |
|
|
ColorComponentFlags.GBit |
|
|
ColorComponentFlags.BBit |
|
|
ColorComponentFlags.ABit,
|
|
};
|
|
var colorBlend = new PipelineColorBlendStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineColorBlendStateCreateInfo,
|
|
AttachmentCount = 1,
|
|
PAttachments = &colorBlendAttachment,
|
|
};
|
|
var pipelineInfo = new GraphicsPipelineCreateInfo
|
|
{
|
|
SType = StructureType.GraphicsPipelineCreateInfo,
|
|
StageCount = 2,
|
|
PStages = shaderStages,
|
|
PVertexInputState = &vertexInput,
|
|
PInputAssemblyState = &inputAssembly,
|
|
PViewportState = &viewportState,
|
|
PRasterizationState = &rasterization,
|
|
PMultisampleState = &multisample,
|
|
PColorBlendState = &colorBlend,
|
|
Layout = _pipelineLayout,
|
|
RenderPass = _renderPass,
|
|
Subpass = 0,
|
|
};
|
|
Check(
|
|
_vk.CreateGraphicsPipelines(
|
|
_device,
|
|
_pipelineCache,
|
|
1,
|
|
&pipelineInfo,
|
|
null,
|
|
out _barycentricPipeline),
|
|
"vkCreateGraphicsPipelines");
|
|
MarkPipelineCacheDirty();
|
|
}
|
|
finally
|
|
{
|
|
SilkMarshal.Free((nint)entryPoint);
|
|
_vk.DestroyShaderModule(_device, fragmentModule, null);
|
|
_vk.DestroyShaderModule(_device, vertexModule, null);
|
|
}
|
|
}
|
|
|
|
private ShaderModule CreateShaderModule(byte[] code)
|
|
{
|
|
fixed (byte* codePointer = code)
|
|
{
|
|
var createInfo = new ShaderModuleCreateInfo
|
|
{
|
|
SType = StructureType.ShaderModuleCreateInfo,
|
|
CodeSize = (nuint)code.Length,
|
|
PCode = (uint*)codePointer,
|
|
};
|
|
Check(
|
|
_vk.CreateShaderModule(_device, &createInfo, null, out var module),
|
|
"vkCreateShaderModule");
|
|
return module;
|
|
}
|
|
}
|
|
|
|
private TranslatedDrawResources CreateTranslatedDrawResources(
|
|
VulkanTranslatedGuestDraw draw,
|
|
RenderPass renderPass,
|
|
IReadOnlyList<Format> renderTargetFormats,
|
|
Extent2D extent,
|
|
IReadOnlyList<GuestImageResource>? feedbackTargets = null,
|
|
bool hasDepthAttachment = false,
|
|
GuestDepthResource? feedbackDepth = null)
|
|
{
|
|
var isTitleDraw = IsTitleDraw(draw.VertexBuffers);
|
|
var forceFullscreenVertex = _forceFullscreenPipeline ||
|
|
_forceFullscreenVertex ||
|
|
isTitleDraw && _forceTitleFullscreenVertex ||
|
|
AnyTargetAddressMatches(
|
|
feedbackTargets,
|
|
"SHARPEMU_FORCE_FULLSCREEN_VERTEX_TARGETS");
|
|
var forceRasterState = _forceFullscreenPipeline ||
|
|
_forceDefaultRasterState ||
|
|
isTitleDraw && _forceTitleDefaultRasterState ||
|
|
AnyTargetAddressMatches(
|
|
feedbackTargets,
|
|
"SHARPEMU_FORCE_DEFAULT_RASTER_STATE_TARGETS");
|
|
var forceTitleSolidFragment =
|
|
_forceTitleSolidFragment &&
|
|
isTitleDraw;
|
|
var forceSolidFragment = forceTitleSolidFragment ||
|
|
_forceFullscreenPipeline ||
|
|
_forceSolidFragment ||
|
|
AnyTargetAddressMatches(
|
|
feedbackTargets,
|
|
"SHARPEMU_FORCE_SOLID_FRAGMENT_TARGETS");
|
|
var attributeFragmentLocation =
|
|
_forceAttributeFragmentLocation.GetValueOrDefault();
|
|
var forceAttributeFragment =
|
|
_forceAttributeFragmentLocation.HasValue &&
|
|
AnyTargetAddressMatches(
|
|
feedbackTargets,
|
|
"SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT_TARGETS");
|
|
var vertexSpirv = forceFullscreenVertex
|
|
? SpirvFixedShaders.CreateFullscreenVertex(0)
|
|
: draw.VertexSpirv;
|
|
var fragmentSpirv = forceSolidFragment
|
|
? SpirvFixedShaders.CreateSolidFragment(1f, 0f, 1f, 1f)
|
|
: forceAttributeFragment
|
|
? SpirvFixedShaders.CreateAttributeFragment(attributeFragmentLocation)
|
|
: draw.PixelSpirv;
|
|
if (forceSolidFragment && !string.IsNullOrWhiteSpace(_fixedFragmentDumpPath))
|
|
{
|
|
File.WriteAllBytes(_fixedFragmentDumpPath, fragmentSpirv);
|
|
}
|
|
if (draw.RenderState.Blends.Count != renderTargetFormats.Count)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"color attachment formats and blend states must have matching counts");
|
|
}
|
|
if (vertexSpirv.Length == 0 &&
|
|
!TryCompileFullscreenVertexShader(
|
|
draw.AttributeCount,
|
|
out vertexSpirv,
|
|
out var vertexError))
|
|
{
|
|
throw new InvalidOperationException($"translated vertex shader failed: {vertexError}");
|
|
}
|
|
|
|
var resources = new TranslatedDrawResources
|
|
{
|
|
DebugName = "SharpEmu draw",
|
|
Textures = new TextureResource[draw.Textures.Count],
|
|
GlobalMemoryBuffers =
|
|
new GlobalBufferResource[draw.GlobalMemoryBuffers.Count],
|
|
VertexBuffers = new VertexBufferResource[draw.VertexBuffers.Count],
|
|
VertexCount = GetDrawVertexCount(draw.PrimitiveType, draw.VertexCount, draw.IndexBuffer),
|
|
InstanceCount = Math.Max(draw.InstanceCount, 1),
|
|
Topology = GetPrimitiveTopology(draw.PrimitiveType),
|
|
Blends = draw.RenderState.Blends.ToArray(),
|
|
BlendConstant = draw.RenderState.BlendConstant,
|
|
Scissor = draw.RenderState.Scissor,
|
|
Viewport = draw.RenderState.Viewport,
|
|
Raster = draw.RenderState.Raster,
|
|
Depth = draw.RenderState.Depth,
|
|
HasDepthAttachment = hasDepthAttachment,
|
|
TargetFormats = renderTargetFormats.ToArray(),
|
|
};
|
|
if (forceFullscreenVertex)
|
|
{
|
|
resources.VertexCount = 3;
|
|
resources.InstanceCount = 1;
|
|
resources.Topology = PrimitiveTopology.TriangleList;
|
|
}
|
|
if (forceRasterState)
|
|
{
|
|
resources.Blends = Enumerable.Repeat(
|
|
GuestBlendState.Default,
|
|
renderTargetFormats.Count).ToArray();
|
|
resources.Scissor = null;
|
|
resources.Viewport = null;
|
|
resources.Raster = GuestRasterState.Default;
|
|
resources.Depth = GuestDepthState.Default;
|
|
}
|
|
if (isTitleDraw && _forceTitleDefaultBlend)
|
|
{
|
|
resources.Blends = Enumerable.Repeat(
|
|
GuestBlendState.Default,
|
|
renderTargetFormats.Count).ToArray();
|
|
}
|
|
if (isTitleDraw && _forceTitleDefaultViewportScissor)
|
|
{
|
|
resources.Scissor = null;
|
|
resources.Viewport = null;
|
|
}
|
|
if (isTitleDraw && _forceTitleDisableCull)
|
|
{
|
|
resources.Raster = resources.Raster with
|
|
{
|
|
CullFront = false,
|
|
CullBack = false,
|
|
};
|
|
}
|
|
if (isTitleDraw && _forceTitleDisableDepth)
|
|
{
|
|
resources.Depth = GuestDepthState.Default;
|
|
}
|
|
if (isTitleDraw && _traceTitleState)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.title_state " +
|
|
$"viewport={resources.Viewport} scissor={resources.Scissor} " +
|
|
$"raster={resources.Raster} depth={resources.Depth} " +
|
|
$"blends=[{string.Join(',', resources.Blends)}]");
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var texture in draw.Textures)
|
|
{
|
|
// Skip address-0 storage bindings here: the real resolution
|
|
// path uses a scratch image for those, but this warm-up pass
|
|
// called ResolveStorageGuestImage directly, which throws on
|
|
// address 0 and dropped the whole draw (Demon's Souls G-buffer
|
|
// normals/IDs passes -> lighting had no input -> black).
|
|
if (texture.IsStorage && texture.Address != 0)
|
|
{
|
|
_ = ResolveStorageGuestImage(texture);
|
|
}
|
|
}
|
|
|
|
for (var index = 0; index < draw.Textures.Count; index++)
|
|
{
|
|
var texture = draw.Textures[index];
|
|
var resolved = ResolveTextureResource(texture);
|
|
var feedbackTarget = !texture.IsStorage
|
|
? feedbackTargets?.FirstOrDefault(target =>
|
|
ReferenceEquals(resolved.GuestImage, target))
|
|
: null;
|
|
// ResolveTextureResource may deliberately decline an
|
|
// address alias when the descriptor is incompatible with
|
|
// the render-target image. Only snapshot an alias which
|
|
// actually resolved to the target; a separately uploaded
|
|
// texture has no Vulkan attachment feedback hazard.
|
|
resources.Textures[index] =
|
|
feedbackDepth is not null &&
|
|
!texture.IsStorage &&
|
|
ReferenceEquals(resolved.GuestDepth, feedbackDepth)
|
|
? CreateDepthFeedbackSnapshot(texture, feedbackDepth)
|
|
:
|
|
feedbackTarget is not null &&
|
|
!texture.IsStorage &&
|
|
ReferenceEquals(resolved.GuestImage, feedbackTarget)
|
|
? CreateRenderTargetFeedbackSnapshot(texture, feedbackTarget)
|
|
: resolved;
|
|
}
|
|
|
|
PrepareGuestBufferAllocations(draw.GlobalMemoryBuffers);
|
|
for (var index = 0; index < draw.GlobalMemoryBuffers.Count; index++)
|
|
{
|
|
resources.GlobalMemoryBuffers[index] =
|
|
CreateGlobalBufferResource(draw.GlobalMemoryBuffers[index]);
|
|
}
|
|
|
|
var sharedVertexResources = new Dictionary<
|
|
byte[], VertexBufferResource>(
|
|
System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
|
for (var index = 0; index < draw.VertexBuffers.Count; index++)
|
|
{
|
|
var guestVertex = draw.VertexBuffers[index];
|
|
if (sharedVertexResources.TryGetValue(
|
|
guestVertex.Data,
|
|
out var sharedVertex))
|
|
{
|
|
resources.VertexBuffers[index] =
|
|
CreateVertexBufferAlias(sharedVertex, guestVertex);
|
|
}
|
|
else
|
|
{
|
|
var vertexResource =
|
|
CreateVertexBufferResource(guestVertex);
|
|
resources.VertexBuffers[index] = vertexResource;
|
|
sharedVertexResources.Add(guestVertex.Data, vertexResource);
|
|
}
|
|
}
|
|
|
|
if (draw.IndexBuffer is { Length: > 0 } indexBuffer)
|
|
{
|
|
resources.IndexBuffer = CreateHostBuffer(
|
|
indexBuffer.Data.AsSpan(0, indexBuffer.Length),
|
|
BufferUsageFlags.IndexBufferBit,
|
|
out resources.IndexMemory,
|
|
out _);
|
|
resources.Index32Bit = indexBuffer.Is32Bit;
|
|
if (indexBuffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(indexBuffer.Data);
|
|
}
|
|
}
|
|
|
|
CreateTranslatedDescriptorResources(
|
|
resources,
|
|
ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit);
|
|
CreateTranslatedPipeline(
|
|
resources,
|
|
vertexSpirv,
|
|
fragmentSpirv,
|
|
renderPass,
|
|
renderTargetFormats,
|
|
extent);
|
|
return resources;
|
|
}
|
|
catch
|
|
{
|
|
DestroyTranslatedDrawResources(resources);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
var returnedVertexData = new HashSet<byte[]>(
|
|
System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
|
foreach (var vertex in draw.VertexBuffers)
|
|
{
|
|
if (vertex.Pooled && returnedVertexData.Add(vertex.Data))
|
|
{
|
|
GuestDataPool.Shared.Return(vertex.Data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private TranslatedDrawResources CreateComputeDispatchResources(
|
|
VulkanComputeGuestDispatch dispatch)
|
|
{
|
|
var traceResources = dispatch.Textures.Count >= 8;
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.compute_resources begin groups={dispatch.GroupCountX}x" +
|
|
$"{dispatch.GroupCountY}x{dispatch.GroupCountZ} textures={dispatch.Textures.Count}");
|
|
}
|
|
|
|
var resources = new TranslatedDrawResources
|
|
{
|
|
DebugName = BuildComputeDebugName(dispatch),
|
|
Textures = new TextureResource[dispatch.Textures.Count],
|
|
GlobalMemoryBuffers =
|
|
new GlobalBufferResource[dispatch.GlobalMemoryBuffers.Count],
|
|
};
|
|
|
|
try
|
|
{
|
|
for (var index = 0; index < dispatch.Textures.Count; index++)
|
|
{
|
|
var texture = dispatch.Textures[index];
|
|
// Address-zero storage descriptors are valid scratch bindings.
|
|
// ResolveTextureResource creates their transient image below;
|
|
// pre-resolving them as guest-backed images throws and drops
|
|
// the entire compute dispatch before that path can run.
|
|
if (texture.IsStorage && texture.Address != 0)
|
|
{
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.compute_resources storage[{index}] begin " +
|
|
$"addr=0x{texture.Address:X16} fmt={texture.Format} " +
|
|
$"size={texture.Width}x{texture.Height} " +
|
|
$"view_mips={texture.BaseMipLevel}+{texture.MipLevels} " +
|
|
$"resource_mips={texture.ResourceMipLevels} " +
|
|
$"relative_level={texture.MipLevel}");
|
|
}
|
|
|
|
_ = ResolveStorageGuestImage(texture);
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader($"vk.compute_resources storage[{index}] ready");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader("vk.compute_resources resolve begin");
|
|
}
|
|
|
|
for (var index = 0; index < dispatch.Textures.Count; index++)
|
|
{
|
|
resources.Textures[index] =
|
|
ResolveTextureResource(dispatch.Textures[index]);
|
|
}
|
|
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader("vk.compute_resources resolve ready");
|
|
}
|
|
|
|
PrepareGuestBufferAllocations(dispatch.GlobalMemoryBuffers);
|
|
for (var index = 0; index < dispatch.GlobalMemoryBuffers.Count; index++)
|
|
{
|
|
resources.GlobalMemoryBuffers[index] =
|
|
CreateGlobalBufferResource(dispatch.GlobalMemoryBuffers[index]);
|
|
}
|
|
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader("vk.compute_resources descriptors begin");
|
|
}
|
|
|
|
CreateTranslatedDescriptorResources(resources, ShaderStageFlags.ComputeBit);
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader("vk.compute_resources descriptors ready");
|
|
}
|
|
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.compute_resources pipeline begin " +
|
|
$"cs=0x{dispatch.ShaderAddress:X16} " +
|
|
$"spirv={dispatch.ComputeSpirv.Length} " +
|
|
$"textures={resources.Textures.Length} " +
|
|
$"globals={resources.GlobalMemoryBuffers.Length}");
|
|
}
|
|
|
|
CreateComputePipeline(resources, dispatch.ComputeSpirv);
|
|
if (traceResources)
|
|
{
|
|
TraceVulkanShader("vk.compute_resources pipeline ready");
|
|
}
|
|
|
|
return resources;
|
|
}
|
|
catch
|
|
{
|
|
DestroyTranslatedDrawResources(resources);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static bool TryCompileFullscreenVertexShader(
|
|
uint attributeCount,
|
|
out byte[] spirv,
|
|
out string error)
|
|
{
|
|
spirv = [];
|
|
error = string.Empty;
|
|
if (attributeCount > 32)
|
|
{
|
|
error = $"too many interpolated attributes: {attributeCount}";
|
|
return false;
|
|
}
|
|
|
|
spirv = SpirvFixedShaders.CreateFullscreenVertex(attributeCount);
|
|
return true;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private void CreateTranslatedDescriptorResources(
|
|
TranslatedDrawResources resources,
|
|
ShaderStageFlags stageFlags)
|
|
{
|
|
var textureCount = resources.Textures.Length;
|
|
var sampledImageCount = resources.Textures.Count(texture => !texture.IsStorage);
|
|
var storageImageCount = textureCount - sampledImageCount;
|
|
var globalBufferCount = resources.GlobalMemoryBuffers.Length;
|
|
var bindingCount = textureCount + (globalBufferCount == 0 ? 0 : 1);
|
|
var layout = GetOrCreateDescriptorLayout(resources, stageFlags, bindingCount);
|
|
resources.DescriptorSetLayout = layout.DescriptorSetLayout;
|
|
resources.PipelineLayout = layout.PipelineLayout;
|
|
resources.DescriptorLayoutCached = true;
|
|
if (bindingCount == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var setLayout = layout.DescriptorSetLayout;
|
|
|
|
var poolSizes = new DescriptorPoolSize[
|
|
(sampledImageCount == 0 ? 0 : 1) +
|
|
(storageImageCount == 0 ? 0 : 1) +
|
|
(globalBufferCount == 0 ? 0 : 1)];
|
|
var poolSizeIndex = 0;
|
|
if (sampledImageCount != 0)
|
|
{
|
|
poolSizes[poolSizeIndex++] = new DescriptorPoolSize
|
|
{
|
|
Type = DescriptorType.CombinedImageSampler,
|
|
DescriptorCount = (uint)sampledImageCount,
|
|
};
|
|
}
|
|
|
|
if (storageImageCount != 0)
|
|
{
|
|
poolSizes[poolSizeIndex++] = new DescriptorPoolSize
|
|
{
|
|
Type = DescriptorType.StorageImage,
|
|
DescriptorCount = (uint)storageImageCount,
|
|
};
|
|
}
|
|
|
|
if (globalBufferCount != 0)
|
|
{
|
|
poolSizes[poolSizeIndex] = new DescriptorPoolSize
|
|
{
|
|
Type = DescriptorType.StorageBuffer,
|
|
DescriptorCount = (uint)globalBufferCount,
|
|
};
|
|
}
|
|
|
|
if (_recycledDescriptorPools.TryPop(out var recycledPool))
|
|
{
|
|
Check(
|
|
_vk.ResetDescriptorPool(_device, recycledPool, 0),
|
|
"vkResetDescriptorPool");
|
|
resources.DescriptorPool = recycledPool;
|
|
}
|
|
else
|
|
{
|
|
// Generously sized so any draw's set fits, making the pool
|
|
// recyclable regardless of the draw's binding mix. AAA titles
|
|
// (e.g. Demon's Souls) bind well over 32 textures in a single
|
|
// descriptor set, so the sampled-image budget in particular
|
|
// must be large enough to avoid a per-draw dynamic fallback.
|
|
var genericPoolSizes = stackalloc DescriptorPoolSize[3];
|
|
genericPoolSizes[0] = new DescriptorPoolSize
|
|
{
|
|
Type = DescriptorType.CombinedImageSampler,
|
|
DescriptorCount = 256,
|
|
};
|
|
genericPoolSizes[1] = new DescriptorPoolSize
|
|
{
|
|
Type = DescriptorType.StorageImage,
|
|
DescriptorCount = 64,
|
|
};
|
|
genericPoolSizes[2] = new DescriptorPoolSize
|
|
{
|
|
Type = DescriptorType.StorageBuffer,
|
|
DescriptorCount = 64,
|
|
};
|
|
var poolInfo = new DescriptorPoolCreateInfo
|
|
{
|
|
SType = StructureType.DescriptorPoolCreateInfo,
|
|
MaxSets = 1,
|
|
PoolSizeCount = 3,
|
|
PPoolSizes = genericPoolSizes,
|
|
};
|
|
DescriptorPool descriptorPool;
|
|
Check(
|
|
_vk.CreateDescriptorPool(
|
|
_device,
|
|
&poolInfo,
|
|
null,
|
|
out descriptorPool),
|
|
"vkCreateDescriptorPool");
|
|
resources.DescriptorPool = descriptorPool;
|
|
}
|
|
|
|
var allocateInfo = new DescriptorSetAllocateInfo
|
|
{
|
|
SType = StructureType.DescriptorSetAllocateInfo,
|
|
DescriptorPool = resources.DescriptorPool,
|
|
DescriptorSetCount = 1,
|
|
PSetLayouts = &setLayout,
|
|
};
|
|
DescriptorSet descriptorSet;
|
|
Check(
|
|
_vk.AllocateDescriptorSets(_device, &allocateInfo, out descriptorSet),
|
|
"vkAllocateDescriptorSets");
|
|
resources.DescriptorSet = descriptorSet;
|
|
|
|
var imageInfos = new DescriptorImageInfo[textureCount];
|
|
var bufferInfos = new DescriptorBufferInfo[globalBufferCount];
|
|
var writes = new WriteDescriptorSet[bindingCount];
|
|
fixed (DescriptorImageInfo* imageInfoPointer = imageInfos)
|
|
fixed (DescriptorBufferInfo* bufferInfoPointer = bufferInfos)
|
|
fixed (WriteDescriptorSet* writePointer = writes)
|
|
{
|
|
var writeIndex = 0;
|
|
if (globalBufferCount != 0)
|
|
{
|
|
for (var index = 0; index < globalBufferCount; index++)
|
|
{
|
|
bufferInfoPointer[index] = new DescriptorBufferInfo
|
|
{
|
|
Buffer = resources.GlobalMemoryBuffers[index].Buffer,
|
|
Offset = resources.GlobalMemoryBuffers[index].Offset,
|
|
Range = resources.GlobalMemoryBuffers[index].Size,
|
|
};
|
|
}
|
|
|
|
writePointer[writeIndex++] = new WriteDescriptorSet
|
|
{
|
|
SType = StructureType.WriteDescriptorSet,
|
|
DstSet = resources.DescriptorSet,
|
|
DstBinding = 0,
|
|
DescriptorCount = (uint)globalBufferCount,
|
|
DescriptorType = DescriptorType.StorageBuffer,
|
|
PBufferInfo = bufferInfoPointer,
|
|
};
|
|
}
|
|
|
|
for (var index = 0; index < textureCount; index++)
|
|
{
|
|
var isStorage = resources.Textures[index].IsStorage;
|
|
if (!isStorage &&
|
|
resources.Textures[index].Sampler.Handle == 0)
|
|
{
|
|
resources.Textures[index].Sampler =
|
|
CreateSampler(resources.Textures[index].SamplerState);
|
|
}
|
|
|
|
imageInfoPointer[index] = new DescriptorImageInfo
|
|
{
|
|
Sampler = isStorage ? default : resources.Textures[index].Sampler,
|
|
ImageView = resources.Textures[index].View,
|
|
ImageLayout = isStorage ||
|
|
resources.Textures[index].GuestImage is { } guestImage &&
|
|
resources.Textures.Any(
|
|
texture =>
|
|
texture.IsStorage &&
|
|
texture.GuestImage == guestImage)
|
|
? ImageLayout.General
|
|
: ImageLayout.ShaderReadOnlyOptimal,
|
|
};
|
|
writePointer[writeIndex++] = new WriteDescriptorSet
|
|
{
|
|
SType = StructureType.WriteDescriptorSet,
|
|
DstSet = resources.DescriptorSet,
|
|
DstBinding = (uint)(index + 1),
|
|
DescriptorCount = 1,
|
|
DescriptorType = isStorage
|
|
? DescriptorType.StorageImage
|
|
: DescriptorType.CombinedImageSampler,
|
|
PImageInfo = &imageInfoPointer[index],
|
|
};
|
|
}
|
|
|
|
_vk.UpdateDescriptorSets(
|
|
_device,
|
|
(uint)bindingCount,
|
|
writePointer,
|
|
0,
|
|
null);
|
|
}
|
|
}
|
|
|
|
private void CreateTranslatedPipeline(
|
|
TranslatedDrawResources resources,
|
|
byte[] vertexSpirv,
|
|
byte[] fragmentSpirv,
|
|
RenderPass renderPass,
|
|
IReadOnlyList<Format> renderTargetFormats,
|
|
Extent2D extent)
|
|
{
|
|
var pipelineKey = new GraphicsPipelineKey(
|
|
GetShaderDigest(vertexSpirv),
|
|
GetShaderDigest(fragmentSpirv),
|
|
string.Join(',', renderTargetFormats.Select(format => (uint)format)),
|
|
resources.HasDepthAttachment,
|
|
resources.Topology,
|
|
string.Join(';', resources.Blends.Select(blend =>
|
|
$"{(blend.Enable ? 1 : 0)}:{blend.ColorSrcFactor}:{blend.ColorDstFactor}:" +
|
|
$"{blend.ColorFunc}:{blend.AlphaSrcFactor}:{blend.AlphaDstFactor}:" +
|
|
$"{blend.AlphaFunc}:{(blend.SeparateAlphaBlend ? 1 : 0)}:{blend.WriteMask}")),
|
|
GetResourceLayoutKey(resources),
|
|
GetVertexLayoutKey(resources),
|
|
resources.Raster,
|
|
resources.HasDepthAttachment ? resources.Depth : GuestDepthState.Default);
|
|
if (_graphicsPipelines.TryGetValue(pipelineKey, out var cachedPipeline))
|
|
{
|
|
resources.Pipeline = cachedPipeline;
|
|
resources.PipelineCached = true;
|
|
return;
|
|
}
|
|
|
|
var vertexModule = CreateShaderModule(vertexSpirv);
|
|
var fragmentModule = CreateShaderModule(fragmentSpirv);
|
|
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
|
|
try
|
|
{
|
|
var shaderStages = stackalloc PipelineShaderStageCreateInfo[2];
|
|
shaderStages[0] = new PipelineShaderStageCreateInfo
|
|
{
|
|
SType = StructureType.PipelineShaderStageCreateInfo,
|
|
Stage = ShaderStageFlags.VertexBit,
|
|
Module = vertexModule,
|
|
PName = entryPoint,
|
|
};
|
|
shaderStages[1] = new PipelineShaderStageCreateInfo
|
|
{
|
|
SType = StructureType.PipelineShaderStageCreateInfo,
|
|
Stage = ShaderStageFlags.FragmentBit,
|
|
Module = fragmentModule,
|
|
PName = entryPoint,
|
|
};
|
|
|
|
var vertexBindingDescriptions =
|
|
new VertexInputBindingDescription[resources.VertexBuffers.Length];
|
|
var vertexAttributeDescriptions =
|
|
new VertexInputAttributeDescription[resources.VertexBuffers.Length];
|
|
for (var index = 0; index < resources.VertexBuffers.Length; index++)
|
|
{
|
|
var vertexBuffer = resources.VertexBuffers[index];
|
|
vertexBindingDescriptions[index] = new VertexInputBindingDescription
|
|
{
|
|
Binding = (uint)index,
|
|
Stride = vertexBuffer.Stride == 0
|
|
? Math.Max(vertexBuffer.ComponentCount, 1) * sizeof(float)
|
|
: vertexBuffer.Stride,
|
|
InputRate = VertexInputRate.Vertex,
|
|
};
|
|
vertexAttributeDescriptions[index] = new VertexInputAttributeDescription
|
|
{
|
|
Location = vertexBuffer.Location,
|
|
Binding = (uint)index,
|
|
Format = ToVkVertexFormat(
|
|
vertexBuffer.DataFormat,
|
|
vertexBuffer.NumberFormat,
|
|
vertexBuffer.ComponentCount),
|
|
Offset = 0,
|
|
};
|
|
}
|
|
|
|
fixed (VertexInputBindingDescription* vertexBindingPointerBase = vertexBindingDescriptions)
|
|
fixed (VertexInputAttributeDescription* vertexAttributePointerBase = vertexAttributeDescriptions)
|
|
{
|
|
var vertexInput = new PipelineVertexInputStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineVertexInputStateCreateInfo,
|
|
VertexBindingDescriptionCount = (uint)vertexBindingDescriptions.Length,
|
|
PVertexBindingDescriptions = vertexBindingDescriptions.Length == 0
|
|
? null
|
|
: vertexBindingPointerBase,
|
|
VertexAttributeDescriptionCount = (uint)vertexAttributeDescriptions.Length,
|
|
PVertexAttributeDescriptions = vertexAttributeDescriptions.Length == 0
|
|
? null
|
|
: vertexAttributePointerBase,
|
|
};
|
|
var inputAssembly = new PipelineInputAssemblyStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineInputAssemblyStateCreateInfo,
|
|
Topology = resources.Topology,
|
|
// Metal always applies primitive restart to strip/fan
|
|
// topologies with the max index as the cut value, so
|
|
// match that here. Enabling it for lists is what makes
|
|
// MoltenVK warn ("Metal does not support disabling
|
|
// primitive restart"); lists never carry a restart
|
|
// index, so leaving it off for them is both correct
|
|
// and warning-free.
|
|
PrimitiveRestartEnable = RequiresPrimitiveRestart(resources.Topology),
|
|
};
|
|
var viewport = new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
|
|
var scissor = new Rect2D(new Offset2D(0, 0), extent);
|
|
var viewportState = new PipelineViewportStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineViewportStateCreateInfo,
|
|
ViewportCount = 1,
|
|
PViewports = &viewport,
|
|
ScissorCount = 1,
|
|
PScissors = &scissor,
|
|
};
|
|
var raster = resources.Raster;
|
|
var cullMode = CullModeFlags.None;
|
|
if (raster.CullFront)
|
|
{
|
|
cullMode |= CullModeFlags.FrontBit;
|
|
}
|
|
|
|
if (raster.CullBack)
|
|
{
|
|
cullMode |= CullModeFlags.BackBit;
|
|
}
|
|
|
|
var rasterization = new PipelineRasterizationStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineRasterizationStateCreateInfo,
|
|
// Wireframe (PolygonMode.Line) needs the fillModeNonSolid
|
|
// device feature and is effectively unused by shipping
|
|
// titles, so fall back to a solid fill.
|
|
PolygonMode = PolygonMode.Fill,
|
|
CullMode = cullMode,
|
|
FrontFace = raster.FrontFaceClockwise
|
|
? FrontFace.Clockwise
|
|
: FrontFace.CounterClockwise,
|
|
LineWidth = 1,
|
|
};
|
|
var multisample = new PipelineMultisampleStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineMultisampleStateCreateInfo,
|
|
RasterizationSamples = SampleCountFlags.Count1Bit,
|
|
};
|
|
var colorBlendAttachments = stackalloc PipelineColorBlendAttachmentState[resources.Blends.Length];
|
|
for (var index = 0; index < resources.Blends.Length; index++)
|
|
{
|
|
var blend = resources.Blends[index];
|
|
colorBlendAttachments[index] = new PipelineColorBlendAttachmentState
|
|
{
|
|
BlendEnable = blend.Enable &&
|
|
IsBlendableFormat(renderTargetFormats[index]),
|
|
SrcColorBlendFactor = ToVkBlendFactor(blend.ColorSrcFactor),
|
|
DstColorBlendFactor = ToVkBlendFactor(blend.ColorDstFactor),
|
|
ColorBlendOp = ToVkBlendOp(blend.ColorFunc),
|
|
SrcAlphaBlendFactor = blend.SeparateAlphaBlend
|
|
? ToVkBlendFactor(blend.AlphaSrcFactor)
|
|
: ToVkBlendFactor(blend.ColorSrcFactor),
|
|
DstAlphaBlendFactor = blend.SeparateAlphaBlend
|
|
? ToVkBlendFactor(blend.AlphaDstFactor)
|
|
: ToVkBlendFactor(blend.ColorDstFactor),
|
|
AlphaBlendOp = blend.SeparateAlphaBlend
|
|
? ToVkBlendOp(blend.AlphaFunc)
|
|
: ToVkBlendOp(blend.ColorFunc),
|
|
ColorWriteMask = ToVkColorWriteMask(blend.WriteMask),
|
|
};
|
|
}
|
|
var colorBlend = new PipelineColorBlendStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineColorBlendStateCreateInfo,
|
|
AttachmentCount = (uint)resources.Blends.Length,
|
|
PAttachments = colorBlendAttachments,
|
|
};
|
|
var dynamicStateValues = stackalloc DynamicState[3];
|
|
dynamicStateValues[0] = DynamicState.Viewport;
|
|
dynamicStateValues[1] = DynamicState.Scissor;
|
|
// CB_BLEND_RED..ALPHA vary per draw without a pipeline
|
|
// identity change, so the constant stays dynamic.
|
|
dynamicStateValues[2] = DynamicState.BlendConstants;
|
|
var dynamicState = new PipelineDynamicStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineDynamicStateCreateInfo,
|
|
DynamicStateCount = 3,
|
|
PDynamicStates = dynamicStateValues,
|
|
};
|
|
var depth = resources.Depth;
|
|
var depthStencil = new PipelineDepthStencilStateCreateInfo
|
|
{
|
|
SType = StructureType.PipelineDepthStencilStateCreateInfo,
|
|
DepthTestEnable = depth.TestEnable,
|
|
DepthWriteEnable = depth.WriteEnable,
|
|
DepthCompareOp = ToVkCompareOp(depth.CompareOp),
|
|
DepthBoundsTestEnable = false,
|
|
StencilTestEnable = false,
|
|
};
|
|
var pipelineInfo = new GraphicsPipelineCreateInfo
|
|
{
|
|
SType = StructureType.GraphicsPipelineCreateInfo,
|
|
StageCount = 2,
|
|
PStages = shaderStages,
|
|
PVertexInputState = &vertexInput,
|
|
PInputAssemblyState = &inputAssembly,
|
|
PViewportState = &viewportState,
|
|
PRasterizationState = &rasterization,
|
|
PMultisampleState = &multisample,
|
|
PColorBlendState = &colorBlend,
|
|
PDepthStencilState = resources.HasDepthAttachment ? &depthStencil : null,
|
|
PDynamicState = &dynamicState,
|
|
Layout = resources.PipelineLayout,
|
|
RenderPass = renderPass,
|
|
Subpass = 0,
|
|
};
|
|
Pipeline pipeline;
|
|
Check(
|
|
_vk.CreateGraphicsPipelines(
|
|
_device,
|
|
_pipelineCache,
|
|
1,
|
|
&pipelineInfo,
|
|
null,
|
|
out pipeline),
|
|
"vkCreateGraphicsPipelines(translated)");
|
|
MarkPipelineCacheDirty();
|
|
resources.Pipeline = pipeline;
|
|
resources.PipelineCached = true;
|
|
_graphicsPipelines.Add(pipelineKey, pipeline);
|
|
Interlocked.Increment(ref _perfPipelineCreations);
|
|
SetDebugName(
|
|
ObjectType.Pipeline,
|
|
pipeline.Handle,
|
|
$"SharpEmu graphics ps={fragmentSpirv.Length}b attrs={resources.Textures.Length}");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
SilkMarshal.Free((nint)entryPoint);
|
|
_vk.DestroyShaderModule(_device, fragmentModule, null);
|
|
_vk.DestroyShaderModule(_device, vertexModule, null);
|
|
}
|
|
}
|
|
|
|
private DescriptorLayoutBundle GetOrCreateDescriptorLayout(
|
|
TranslatedDrawResources resources,
|
|
ShaderStageFlags stageFlags,
|
|
int bindingCount)
|
|
{
|
|
var key = new DescriptorLayoutKey(stageFlags, GetResourceLayoutKey(resources));
|
|
if (_descriptorLayouts.TryGetValue(key, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
DescriptorSetLayout descriptorSetLayout = default;
|
|
if (bindingCount != 0)
|
|
{
|
|
var bindings = new DescriptorSetLayoutBinding[bindingCount];
|
|
var bindingOffset = 0;
|
|
if (resources.GlobalMemoryBuffers.Length != 0)
|
|
{
|
|
bindings[bindingOffset++] = new DescriptorSetLayoutBinding
|
|
{
|
|
Binding = 0,
|
|
DescriptorType = DescriptorType.StorageBuffer,
|
|
DescriptorCount = (uint)resources.GlobalMemoryBuffers.Length,
|
|
StageFlags = stageFlags,
|
|
};
|
|
}
|
|
|
|
for (var index = 0; index < resources.Textures.Length; index++)
|
|
{
|
|
bindings[bindingOffset + index] = new DescriptorSetLayoutBinding
|
|
{
|
|
Binding = (uint)(index + 1),
|
|
DescriptorType = resources.Textures[index].IsStorage
|
|
? DescriptorType.StorageImage
|
|
: DescriptorType.CombinedImageSampler,
|
|
DescriptorCount = 1,
|
|
StageFlags = stageFlags,
|
|
};
|
|
}
|
|
|
|
fixed (DescriptorSetLayoutBinding* bindingPointer = bindings)
|
|
{
|
|
var descriptorInfo = new DescriptorSetLayoutCreateInfo
|
|
{
|
|
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
|
BindingCount = (uint)bindings.Length,
|
|
PBindings = bindingPointer,
|
|
};
|
|
Check(
|
|
_vk.CreateDescriptorSetLayout(
|
|
_device,
|
|
&descriptorInfo,
|
|
null,
|
|
out descriptorSetLayout),
|
|
"vkCreateDescriptorSetLayout");
|
|
}
|
|
}
|
|
|
|
var pipelineInfo = new PipelineLayoutCreateInfo
|
|
{
|
|
SType = StructureType.PipelineLayoutCreateInfo,
|
|
};
|
|
if (descriptorSetLayout.Handle != 0)
|
|
{
|
|
pipelineInfo.SetLayoutCount = 1;
|
|
pipelineInfo.PSetLayouts = &descriptorSetLayout;
|
|
}
|
|
|
|
var computePushConstantRange = new PushConstantRange
|
|
{
|
|
StageFlags = ShaderStageFlags.ComputeBit,
|
|
Offset = 0,
|
|
Size = 3 * sizeof(uint),
|
|
};
|
|
if ((stageFlags & ShaderStageFlags.ComputeBit) != 0)
|
|
{
|
|
pipelineInfo.PushConstantRangeCount = 1;
|
|
pipelineInfo.PPushConstantRanges = &computePushConstantRange;
|
|
}
|
|
|
|
PipelineLayout pipelineLayout;
|
|
Check(
|
|
_vk.CreatePipelineLayout(
|
|
_device,
|
|
&pipelineInfo,
|
|
null,
|
|
out pipelineLayout),
|
|
"vkCreatePipelineLayout");
|
|
var created = new DescriptorLayoutBundle(descriptorSetLayout, pipelineLayout);
|
|
_descriptorLayouts.Add(key, created);
|
|
return created;
|
|
}
|
|
|
|
private string GetShaderDigest(byte[] spirv)
|
|
{
|
|
if (_shaderDigests.TryGetValue(spirv, out var digest))
|
|
{
|
|
return digest;
|
|
}
|
|
|
|
digest = Convert.ToHexString(SHA256.HashData(spirv));
|
|
_shaderDigests.Add(spirv, digest);
|
|
return digest;
|
|
}
|
|
|
|
private static string GetResourceLayoutKey(TranslatedDrawResources resources) =>
|
|
resources.ResourceLayoutKey ??= BuildResourceLayoutKey(resources);
|
|
|
|
private static string GetVertexLayoutKey(TranslatedDrawResources resources) =>
|
|
resources.VertexLayoutKey ??= BuildVertexLayoutKey(resources);
|
|
|
|
private static string BuildResourceLayoutKey(TranslatedDrawResources resources)
|
|
{
|
|
var key = new StringBuilder();
|
|
key.Append(resources.GlobalMemoryBuffers.Length).Append(':');
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
key.Append(texture.IsStorage ? 'S' : 'T');
|
|
}
|
|
|
|
return key.ToString();
|
|
}
|
|
|
|
private static string BuildVertexLayoutKey(TranslatedDrawResources resources)
|
|
{
|
|
var key = new StringBuilder();
|
|
foreach (var buffer in resources.VertexBuffers)
|
|
{
|
|
key.Append(buffer.Location).Append(',')
|
|
.Append(buffer.ComponentCount).Append(',')
|
|
.Append(buffer.DataFormat).Append(',')
|
|
.Append(buffer.NumberFormat).Append(',')
|
|
.Append(buffer.Stride == 0
|
|
? Math.Max(buffer.ComponentCount, 1) * sizeof(float)
|
|
: buffer.Stride)
|
|
.Append(';');
|
|
}
|
|
|
|
return key.ToString();
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private void CreateComputePipeline(
|
|
TranslatedDrawResources resources,
|
|
byte[] computeSpirv)
|
|
{
|
|
var pipelineKey = new ComputePipelineKey(
|
|
GetShaderDigest(computeSpirv),
|
|
GetResourceLayoutKey(resources));
|
|
if (_computePipelines.TryGetValue(pipelineKey, out var cachedPipeline))
|
|
{
|
|
resources.Pipeline = cachedPipeline;
|
|
resources.PipelineCached = true;
|
|
return;
|
|
}
|
|
|
|
var computeModule = CreateShaderModule(computeSpirv);
|
|
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
|
|
try
|
|
{
|
|
var stage = new PipelineShaderStageCreateInfo
|
|
{
|
|
SType = StructureType.PipelineShaderStageCreateInfo,
|
|
Stage = ShaderStageFlags.ComputeBit,
|
|
Module = computeModule,
|
|
PName = entryPoint,
|
|
};
|
|
var pipelineInfo = new ComputePipelineCreateInfo
|
|
{
|
|
SType = StructureType.ComputePipelineCreateInfo,
|
|
Flags = PipelineCreateFlags.CreateDispatchBaseBit,
|
|
Stage = stage,
|
|
Layout = resources.PipelineLayout,
|
|
};
|
|
Pipeline pipeline;
|
|
Check(
|
|
_vk.CreateComputePipelines(
|
|
_device,
|
|
_pipelineCache,
|
|
1,
|
|
&pipelineInfo,
|
|
null,
|
|
out pipeline),
|
|
"vkCreateComputePipelines(translated)");
|
|
MarkPipelineCacheDirty();
|
|
resources.Pipeline = pipeline;
|
|
resources.PipelineCached = true;
|
|
SetDebugName(
|
|
ObjectType.Pipeline,
|
|
pipeline.Handle,
|
|
$"SharpEmu compute cs={computeSpirv.Length}b");
|
|
_computePipelines.Add(pipelineKey, pipeline);
|
|
}
|
|
finally
|
|
{
|
|
SilkMarshal.Free((nint)entryPoint);
|
|
_vk.DestroyShaderModule(_device, computeModule, null);
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private TextureResource ResolveTextureResource(GuestDrawTexture texture)
|
|
{
|
|
if (texture.IsStorage)
|
|
{
|
|
return ResolveStorageImageResource(texture);
|
|
}
|
|
|
|
if (texture.Address != 0 &&
|
|
TryResolveGuestDepthTexture(texture, out var depthTexture))
|
|
{
|
|
return depthTexture;
|
|
}
|
|
|
|
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
|
|
if (texture.Address != 0 &&
|
|
TryResolveGuestImageAlias(texture, vkFormat, out var guestImage) &&
|
|
TryGetOrCreateGuestImageView(
|
|
guestImage,
|
|
vkFormat,
|
|
mipLevel: texture.BaseMipLevel,
|
|
levelCount: texture.MipLevels,
|
|
dstSelect: texture.DstSelect,
|
|
out var view))
|
|
{
|
|
if (ShouldTraceVulkanResources() &&
|
|
_tracedTextureCacheHits.Add(
|
|
(texture.Address, texture.Width, texture.Height, vkFormat)))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.texture_cache_hit addr=0x{texture.Address:X16} " +
|
|
$"size={texture.Width}x{texture.Height} " +
|
|
$"image_format={guestImage.Format} view_format={vkFormat}");
|
|
}
|
|
|
|
if (guestImage.Width != texture.Width ||
|
|
guestImage.Height != texture.Height)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.texture_cache_alias addr=0x{texture.Address:X16} " +
|
|
$"texture={texture.Width}x{texture.Height} " +
|
|
$"image={guestImage.Width}x{guestImage.Height} " +
|
|
$"tile={texture.TileMode} format={vkFormat}");
|
|
}
|
|
|
|
if (string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
|
|
"alias",
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
_tracedGuestImageContents.Add(guestImage.Address))
|
|
{
|
|
// Deferred: reading back here would clobber the command
|
|
// buffer mid-recording; drained after the next present.
|
|
_pendingAliasImageDumps.Enqueue(guestImage);
|
|
}
|
|
|
|
if (TryCreateCpuTextureRefreshResource(
|
|
texture,
|
|
guestImage,
|
|
view,
|
|
out var refreshResource))
|
|
{
|
|
return refreshResource;
|
|
}
|
|
|
|
return new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
Image = guestImage.Image,
|
|
View = view,
|
|
Width = guestImage.Width,
|
|
Height = guestImage.Height,
|
|
RowLength = guestImage.Width,
|
|
DstSelect = texture.DstSelect,
|
|
SamplerState = texture.Sampler,
|
|
GuestImage = guestImage,
|
|
};
|
|
}
|
|
|
|
if (ShouldTraceVulkanResources() && texture.Address != 0)
|
|
{
|
|
if (_guestImages.TryGetValue(texture.Address, out var missImage))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.alias_miss addr=0x{texture.Address:X16} " +
|
|
$"reason={(IsCompatibleGuestImageAlias(texture, missImage) ? "format" : "size")} " +
|
|
$"tex={texture.Width}x{texture.Height}/f{texture.Format}/n{texture.NumberType}/vk{vkFormat} " +
|
|
$"img={missImage.Width}x{missImage.Height}/imgfmt{missImage.Format} " +
|
|
$"init={missImage.Initialized}");
|
|
}
|
|
else
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.alias_miss addr=0x{texture.Address:X16} " +
|
|
$"reason=absent tex={texture.Width}x{texture.Height}/f{texture.Format}/n{texture.NumberType}");
|
|
}
|
|
}
|
|
|
|
return GetOrCreateCachedTextureResource(texture);
|
|
}
|
|
|
|
private bool TryCreateCpuTextureRefreshResource(
|
|
GuestDrawTexture texture,
|
|
GuestImageResource guestImage,
|
|
ImageView view,
|
|
out TextureResource resource)
|
|
{
|
|
resource = default!;
|
|
if (!guestImage.IsCpuBacked ||
|
|
guestImage.Width != texture.Width ||
|
|
guestImage.Height != texture.Height ||
|
|
guestImage.MipLevels != 1 ||
|
|
texture.RgbaPixels.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var rowLength = texture.TileMode == 0
|
|
? Math.Max(texture.Pitch, texture.Width)
|
|
: texture.Width;
|
|
var expectedSize = GetTextureByteCount(texture.Format, rowLength, texture.Height);
|
|
if (expectedSize == 0 || expectedSize > int.MaxValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var pixels = texture.RgbaPixels.Length == (int)expectedSize
|
|
? texture.RgbaPixels
|
|
: CreateFallbackTexturePixels(texture.Format, rowLength, texture.Height, expectedSize);
|
|
var fingerprint = ComputeTextureContentFingerprint(pixels);
|
|
if ((guestImage.Initialized || guestImage.InitialUploadPending) &&
|
|
guestImage.CpuContentFingerprint == fingerprint)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var uploadPixels = texture.Format == 13
|
|
? ExpandRgb32Pixels(pixels)
|
|
: pixels;
|
|
var debugName = TextureDebugName(texture, guestImage.Format);
|
|
var (stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
|
|
uploadPixels,
|
|
$"{debugName} refresh staging");
|
|
TraceVulkanShader(
|
|
$"vk.texture_refresh addr=0x{texture.Address:X16} " +
|
|
$"size={texture.Width}x{texture.Height} bytes={uploadPixels.Length}");
|
|
resource = new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
StagingBuffer = stagingBuffer,
|
|
StagingMemory = stagingMemory,
|
|
Image = guestImage.Image,
|
|
View = view,
|
|
Width = guestImage.Width,
|
|
Height = guestImage.Height,
|
|
RowLength = rowLength,
|
|
DstSelect = texture.DstSelect,
|
|
NeedsUpload = true,
|
|
SamplerState = texture.Sampler,
|
|
GuestImage = guestImage,
|
|
CpuContentFingerprint = fingerprint,
|
|
UpdatesCpuContent = true,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
private bool TryResolveGuestImageAlias(
|
|
GuestDrawTexture texture,
|
|
Format viewFormat,
|
|
out GuestImageResource guestImage)
|
|
{
|
|
GuestImageResource? best = null;
|
|
var bestScore = int.MinValue;
|
|
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
|
|
|
void Consider(GuestImageResource candidate, bool isActive)
|
|
{
|
|
if (!IsUsableGuestImageAlias(texture, viewFormat, candidate))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Prefer an exact descriptor match. Initialization is important,
|
|
// but it must not make a differently sized alias outrank the image
|
|
// that the texture descriptor actually names.
|
|
var score = 0;
|
|
if (candidate.Width == texture.Width &&
|
|
candidate.Height == texture.Height)
|
|
{
|
|
score += 32;
|
|
}
|
|
if (candidate.Format == viewFormat)
|
|
{
|
|
score += 16;
|
|
}
|
|
if (candidate.GuestFormat == guestFormat)
|
|
{
|
|
score += 8;
|
|
}
|
|
if (candidate.Initialized)
|
|
{
|
|
score += 4;
|
|
}
|
|
if (candidate.MipLevels == texture.ResourceMipLevels)
|
|
{
|
|
score += 2;
|
|
}
|
|
if (isActive)
|
|
{
|
|
score += 1;
|
|
}
|
|
|
|
if (score > bestScore)
|
|
{
|
|
best = candidate;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
if (_guestImages.TryGetValue(texture.Address, out var active))
|
|
{
|
|
Consider(active, isActive: true);
|
|
}
|
|
|
|
foreach (var (key, candidate) in _guestImageVariants)
|
|
{
|
|
if (key.Address == texture.Address)
|
|
{
|
|
Consider(candidate, isActive: false);
|
|
}
|
|
}
|
|
|
|
if (best is not null)
|
|
{
|
|
guestImage = best;
|
|
if (ShouldTraceVulkanResources())
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.texture_variant_hit " +
|
|
$"addr=0x{texture.Address:X16} " +
|
|
$"tex={texture.Width}x{texture.Height}/{viewFormat} " +
|
|
$"image={best.Width}x{best.Height}/{best.Format} " +
|
|
$"initialized={best.Initialized}");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
guestImage = null!;
|
|
return false;
|
|
}
|
|
|
|
private static bool IsUsableGuestImageAlias(
|
|
GuestDrawTexture texture,
|
|
Format viewFormat,
|
|
GuestImageResource guestImage) =>
|
|
texture.BaseMipLevel < guestImage.MipLevels &&
|
|
IsCompatibleGuestImageAlias(texture, guestImage) &&
|
|
IsCompatibleViewFormat(guestImage.Format, viewFormat);
|
|
|
|
private bool TryResolveGuestDepthTexture(
|
|
GuestDrawTexture texture,
|
|
out TextureResource resource)
|
|
{
|
|
foreach (var depth in _guestDepthImages.Values)
|
|
{
|
|
if (texture.Address != depth.Address &&
|
|
texture.Address != depth.ReadAddress &&
|
|
texture.Address != depth.WriteAddress)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (texture.Width > depth.Width || texture.Height > depth.Height)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!depth.SampleViews.TryGetValue(texture.DstSelect, out var view))
|
|
{
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = depth.Image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = DepthFormat,
|
|
Components = ToVkComponentMapping(texture.DstSelect),
|
|
SubresourceRange = new ImageSubresourceRange(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
1,
|
|
0,
|
|
1),
|
|
};
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out view),
|
|
"vkCreateImageView(depth sample)");
|
|
SetDebugName(
|
|
ObjectType.ImageView,
|
|
view.Handle,
|
|
$"SharpEmu guest depth sample 0x{depth.Address:X16} " +
|
|
$"dst=0x{texture.DstSelect:X3}");
|
|
depth.SampleViews.Add(texture.DstSelect, view);
|
|
}
|
|
|
|
if (_tracedDepthTextureAliases.Add(
|
|
(texture.Address, texture.Width, texture.Height, texture.DstSelect)))
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.depth_texture_alias addr=0x{texture.Address:X16} " +
|
|
$"depth=0x{depth.Address:X16} " +
|
|
$"texture={texture.Width}x{texture.Height} " +
|
|
$"surface={depth.Width}x{depth.Height} dst=0x{texture.DstSelect:X3}");
|
|
}
|
|
resource = new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
Image = depth.Image,
|
|
View = view,
|
|
Width = depth.Width,
|
|
Height = depth.Height,
|
|
RowLength = depth.Width,
|
|
DstSelect = texture.DstSelect,
|
|
SamplerState = texture.Sampler,
|
|
GuestDepth = depth,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
resource = null!;
|
|
return false;
|
|
}
|
|
|
|
private readonly Dictionary<TextureContentIdentity, TextureResource> _textureCache = new();
|
|
|
|
/// <summary>
|
|
/// Guest textures are static assets in the common case, but every draw
|
|
/// used to restage and reupload them (a fresh image, device memory and
|
|
/// staging buffer per draw). Cache the uploaded resource per descriptor
|
|
/// identity and invalidate through the CPU write tracker, so animated
|
|
/// or streamed texture memory still refreshes.
|
|
/// </summary>
|
|
private TextureResource GetOrCreateCachedTextureResource(GuestDrawTexture texture)
|
|
{
|
|
if (texture.Address == 0)
|
|
{
|
|
return CreateTextureResource(texture);
|
|
}
|
|
|
|
var key = new TextureContentIdentity(
|
|
texture.Address,
|
|
texture.Width,
|
|
texture.Height,
|
|
texture.Format,
|
|
texture.NumberType,
|
|
texture.DstSelect,
|
|
texture.TileMode,
|
|
texture.Pitch,
|
|
texture.Sampler);
|
|
if (_textureCache.TryGetValue(key, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
// Empty pixels mean the submit thread skipped the guest-memory
|
|
// copy because this identity was marked cached; a miss here is
|
|
// an invalidation race (eviction, cache clear). Self-heal by
|
|
// reading the texels directly rather than rendering a fallback.
|
|
if (texture.RgbaPixels.Length == 0)
|
|
{
|
|
var refreshed = TryReadGuestTexturePixels(texture);
|
|
if (refreshed is null)
|
|
{
|
|
return CreateTextureResource(texture);
|
|
}
|
|
|
|
texture = texture with { RgbaPixels = refreshed };
|
|
}
|
|
|
|
var resource = CreateTextureResource(texture);
|
|
if (resource.OwnsStorage)
|
|
{
|
|
resource.Cached = true;
|
|
_textureCache[key] = resource;
|
|
MarkTextureContentCached(key);
|
|
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
|
texture.Address,
|
|
(ulong)texture.RgbaPixels.Length,
|
|
CurrentGuestWorkSequenceForDiagnostics,
|
|
"vulkan.texture-cache");
|
|
}
|
|
|
|
return resource;
|
|
}
|
|
|
|
private void EvictDirtyCachedTextures()
|
|
{
|
|
if (_textureCache.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
List<TextureContentIdentity>? evicted = null;
|
|
foreach (var entry in _textureCache)
|
|
{
|
|
if (SharpEmu.HLE.GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
|
|
{
|
|
(evicted ??= []).Add(entry.Key);
|
|
}
|
|
}
|
|
|
|
if (evicted is null && _textureCache.Count <= 2048)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Destruction is deferred until every submission that may still
|
|
// reference the texture has completed (fences signal in queue
|
|
// order), so eviction never has to drain the GPU. An open batch
|
|
// is flushed first so the retire timeline exactly covers every
|
|
// recorded reference (nothing may guess which submission lands
|
|
// next on the shared queue).
|
|
if (_batchOpen)
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
}
|
|
|
|
var retireTimeline = _submitTimeline;
|
|
if (_textureCache.Count > 2048)
|
|
{
|
|
foreach (var entry in _textureCache)
|
|
{
|
|
_deferredTextureDestroys.Enqueue((entry.Value, retireTimeline));
|
|
}
|
|
|
|
_textureCache.Clear();
|
|
ClearCachedTextureIdentities();
|
|
return;
|
|
}
|
|
|
|
foreach (var key in evicted!)
|
|
{
|
|
if (_textureCache.Remove(key, out var resource))
|
|
{
|
|
UnmarkTextureContentCached(key);
|
|
_deferredTextureDestroys.Enqueue((resource, retireTimeline));
|
|
SharpEmu.HLE.GuestImageWriteTracker.Rearm(key.Address);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DestroyCachedTextureResource(TextureResource texture)
|
|
{
|
|
texture.Cached = false;
|
|
if (texture.View.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, texture.View, null);
|
|
}
|
|
|
|
if (texture.Image.Handle != 0 && texture.GuestImage is null)
|
|
{
|
|
_vk.DestroyImage(_device, texture.Image, null);
|
|
if (texture.ImageMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, texture.ImageMemory, null);
|
|
}
|
|
}
|
|
|
|
if (texture.StagingBuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyBuffer(_device, texture.StagingBuffer, null);
|
|
}
|
|
|
|
if (texture.StagingMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, texture.StagingMemory, null);
|
|
}
|
|
}
|
|
|
|
private static bool IsCompatibleGuestImageAlias(
|
|
GuestDrawTexture texture,
|
|
GuestImageResource guestImage)
|
|
{
|
|
if (guestImage.Width == texture.Width &&
|
|
guestImage.Height == texture.Height)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (texture.TileMode == 0 ||
|
|
texture.Width == 0 ||
|
|
texture.Height == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return texture.Width <= guestImage.Width &&
|
|
texture.Height <= guestImage.Height;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private TextureResource ResolveStorageImageResource(GuestDrawTexture texture)
|
|
{
|
|
if (texture.Address == 0)
|
|
{
|
|
return CreateStorageScratchResource(texture);
|
|
}
|
|
|
|
var guestImage = ResolveStorageGuestImage(texture);
|
|
var vkFormat = GetStorageImageFormat(
|
|
GetTextureFormat(texture.Format, texture.NumberType));
|
|
if (!SupportsStorageImage(vkFormat))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Storage image format {vkFormat} is unsupported for guest " +
|
|
$"format={texture.Format}/num={texture.NumberType}.");
|
|
}
|
|
var selectedMipLevel = GetStorageMipLevel(texture);
|
|
var view = GetOrCreateGuestImageIdentityView(
|
|
guestImage,
|
|
vkFormat,
|
|
selectedMipLevel,
|
|
levelCount: 1);
|
|
var resource = new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
Image = guestImage.Image,
|
|
View = view,
|
|
Width = guestImage.Width,
|
|
Height = guestImage.Height,
|
|
RowLength = guestImage.Width,
|
|
DstSelect = texture.DstSelect,
|
|
IsStorage = true,
|
|
SamplerState = texture.Sampler,
|
|
GuestImage = guestImage,
|
|
};
|
|
|
|
if (!guestImage.Initialized &&
|
|
!guestImage.InitialUploadPending &&
|
|
texture.MipLevel == 0)
|
|
{
|
|
var expectedSize = GetTextureByteCount(
|
|
texture.Format,
|
|
texture.Width,
|
|
texture.Height);
|
|
if ((ulong)texture.RgbaPixels.Length == expectedSize &&
|
|
texture.RgbaPixels.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
|
|
{
|
|
var uploadPixels = texture.Format == 13
|
|
? ExpandRgb32Pixels(texture.RgbaPixels)
|
|
: texture.RgbaPixels;
|
|
var uploadSize = (ulong)uploadPixels.Length;
|
|
resource.StagingBuffer = CreateBuffer(
|
|
uploadSize,
|
|
BufferUsageFlags.TransferSrcBit,
|
|
MemoryPropertyFlags.HostVisibleBit |
|
|
MemoryPropertyFlags.HostCoherentBit,
|
|
out resource.StagingMemory);
|
|
|
|
void* mapped;
|
|
Check(
|
|
_vk.MapMemory(
|
|
_device,
|
|
resource.StagingMemory,
|
|
0,
|
|
uploadSize,
|
|
0,
|
|
&mapped),
|
|
"vkMapMemory(storage texture)");
|
|
fixed (byte* source = uploadPixels)
|
|
{
|
|
System.Buffer.MemoryCopy(
|
|
source,
|
|
mapped,
|
|
uploadPixels.Length,
|
|
uploadPixels.Length);
|
|
}
|
|
|
|
_vk.UnmapMemory(_device, resource.StagingMemory);
|
|
resource.NeedsUpload = true;
|
|
guestImage.InitialUploadPending = true;
|
|
TraceVulkanShader(
|
|
$"vk.storage_upload addr=0x{texture.Address:X16} " +
|
|
$"size={texture.Width}x{texture.Height} " +
|
|
$"logical_bytes={expectedSize} upload_bytes={uploadSize}");
|
|
}
|
|
}
|
|
|
|
return resource;
|
|
}
|
|
|
|
private TextureResource CreateStorageScratchResource(GuestDrawTexture texture)
|
|
{
|
|
var width = Math.Max(texture.Width, 1);
|
|
var height = Math.Max(texture.Height, 1);
|
|
var vkFormat = GetStorageImageFormat(
|
|
GetTextureFormat(texture.Format, texture.NumberType));
|
|
if (!SupportsStorageImage(vkFormat))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Storage scratch format {vkFormat} is unsupported for guest " +
|
|
$"format={texture.Format}/num={texture.NumberType}.");
|
|
}
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
ImageType = ImageType.Type2D,
|
|
Format = vkFormat,
|
|
Extent = new Extent3D(width, height, 1),
|
|
MipLevels = 1,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage =
|
|
ImageUsageFlags.SampledBit |
|
|
ImageUsageFlags.StorageBit |
|
|
ImageUsageFlags.TransferSrcBit |
|
|
ImageUsageFlags.TransferDstBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(
|
|
_vk.CreateImage(_device, &imageInfo, null, out var image),
|
|
"vkCreateImage(storage scratch)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
|
var allocationInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
requirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(
|
|
_vk.AllocateMemory(_device, &allocationInfo, null, out var memory),
|
|
"vkAllocateMemory(storage scratch)");
|
|
Check(
|
|
_vk.BindImageMemory(_device, image, memory, 0),
|
|
"vkBindImageMemory(storage scratch)");
|
|
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = vkFormat,
|
|
Components = new ComponentMapping(
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity),
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out var view),
|
|
"vkCreateImageView(storage scratch)");
|
|
SetDebugName(ObjectType.Image, image.Handle, $"SharpEmu scratch storage {width}x{height} {vkFormat}");
|
|
SetDebugName(ObjectType.ImageView, view.Handle, $"SharpEmu scratch storage {width}x{height} {vkFormat} view");
|
|
|
|
var guestImage = new GuestImageResource
|
|
{
|
|
Address = 0,
|
|
Width = width,
|
|
Height = height,
|
|
MipLevels = 1,
|
|
GuestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType),
|
|
Format = vkFormat,
|
|
Image = image,
|
|
Memory = memory,
|
|
View = view,
|
|
SupportsStorageUsage = true,
|
|
};
|
|
|
|
return new TextureResource
|
|
{
|
|
Address = 0,
|
|
Image = image,
|
|
ImageMemory = memory,
|
|
View = view,
|
|
Width = width,
|
|
Height = height,
|
|
RowLength = width,
|
|
DstSelect = texture.DstSelect,
|
|
OwnsStorage = true,
|
|
IsStorage = true,
|
|
SamplerState = texture.Sampler,
|
|
GuestImage = guestImage,
|
|
};
|
|
}
|
|
|
|
private GuestImageResource ResolveStorageGuestImage(GuestDrawTexture texture)
|
|
{
|
|
if (texture.Address == 0)
|
|
{
|
|
throw new InvalidOperationException("Storage image has no guest address.");
|
|
}
|
|
|
|
var format = GetStorageImageFormat(
|
|
GetTextureFormat(texture.Format, texture.NumberType));
|
|
var guestImage = GetOrCreateGuestImage(
|
|
new GuestRenderTarget(
|
|
texture.Address,
|
|
texture.Width,
|
|
texture.Height,
|
|
texture.Format,
|
|
texture.NumberType,
|
|
texture.ResourceMipLevels),
|
|
format,
|
|
requiresStorage: true);
|
|
var selectedMipLevel = GetStorageMipLevel(texture);
|
|
if (selectedMipLevel >= guestImage.MipLevels)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Storage mip {selectedMipLevel} (base {texture.BaseMipLevel} + relative " +
|
|
$"{texture.MipLevel}) exceeds image mip count {guestImage.MipLevels}.");
|
|
}
|
|
|
|
return guestImage;
|
|
}
|
|
|
|
private static uint GetStorageMipLevel(GuestDrawTexture texture)
|
|
{
|
|
// IMAGE_STORE targets BASE_LEVEL and IMAGE_STORE_MIP's operand is
|
|
// expressed in resource-view space, so Vulkan's absolute image
|
|
// subresource is descriptor base plus the instruction-relative
|
|
// mip. Sampled views achieve the same mapping through their view
|
|
// base in ResolveTextureResource.
|
|
var selectedMipLevel = (ulong)texture.BaseMipLevel + texture.MipLevel;
|
|
if (selectedMipLevel > uint.MaxValue)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Storage mip overflow (base {texture.BaseMipLevel} + relative {texture.MipLevel}).");
|
|
}
|
|
|
|
return (uint)selectedMipLevel;
|
|
}
|
|
|
|
private TextureResource CreateTextureResource(GuestDrawTexture texture)
|
|
{
|
|
var width = Math.Max(texture.Width, 1);
|
|
var height = Math.Max(texture.Height, 1);
|
|
var rowLength = texture.TileMode == 0
|
|
? Math.Max(texture.Pitch, width)
|
|
: width;
|
|
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
|
|
|
|
var expectedSize = GetTextureByteCount(texture.Format, rowLength, height);
|
|
if (ShouldTraceVulkanResources() &&
|
|
_tracedTextureUploads.Add((texture.Address, width, height, vkFormat)))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.texture addr=0x{texture.Address:X16} " +
|
|
$"fmt={texture.Format} num={texture.NumberType} vk={vkFormat} " +
|
|
$"size={width}x{height} row={rowLength} tile={texture.TileMode} " +
|
|
$"dst=0x{texture.DstSelect:X3} " +
|
|
$"bytes={texture.RgbaPixels.Length} expected={expectedSize}");
|
|
}
|
|
var pixels = texture.RgbaPixels.Length == (int)expectedSize
|
|
? texture.RgbaPixels
|
|
: CreateFallbackTexturePixels(texture.Format, rowLength, height, expectedSize);
|
|
if (AddressListContains("SHARPEMU_FORCE_WHITE_TEXTURE_TARGETS", texture.Address))
|
|
{
|
|
pixels = pixels.ToArray();
|
|
pixels.AsSpan().Fill(0xFF);
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.texture_force_white addr=0x{texture.Address:X16} " +
|
|
$"size={width}x{height} bytes={pixels.Length}");
|
|
}
|
|
DumpTextureUpload(texture, pixels, rowLength, width, height);
|
|
TraceTextureUploadContents(texture, pixels, rowLength, width, height, vkFormat);
|
|
var uploadPixels = texture.Format == 13
|
|
? ExpandRgb32Pixels(pixels)
|
|
: pixels;
|
|
var contentFingerprint = ComputeTextureContentFingerprint(pixels);
|
|
|
|
var (stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
|
|
uploadPixels,
|
|
$"{TextureDebugName(texture, vkFormat)} staging");
|
|
|
|
var supportsAttachmentUsage = !IsBlockCompressedFormat(vkFormat);
|
|
var supportsStorageUsage = supportsAttachmentUsage &&
|
|
SupportsStorageImage(vkFormat);
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
Flags = supportsAttachmentUsage
|
|
? ImageCreateFlags.CreateMutableFormatBit | ImageCreateFlags.CreateExtendedUsageBit
|
|
: 0,
|
|
ImageType = ImageType.Type2D,
|
|
Format = vkFormat,
|
|
Extent = new Extent3D(width, height, 1),
|
|
MipLevels = 1,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage = supportsAttachmentUsage
|
|
? ImageUsageFlags.TransferDstBit |
|
|
ImageUsageFlags.SampledBit |
|
|
ImageUsageFlags.ColorAttachmentBit |
|
|
(supportsStorageUsage ? ImageUsageFlags.StorageBit : (ImageUsageFlags)0) |
|
|
ImageUsageFlags.TransferSrcBit
|
|
: ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(_vk.CreateImage(_device, &imageInfo, null, out var image), "vkCreateImage(texture)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var imageRequirements);
|
|
var memoryInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = imageRequirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
imageRequirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(_vk.AllocateMemory(_device, &memoryInfo, null, out var imageMemory), "vkAllocateMemory(texture)");
|
|
Check(_vk.BindImageMemory(_device, image, imageMemory, 0), "vkBindImageMemory(texture)");
|
|
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = vkFormat,
|
|
Components = ToVkComponentMapping(texture.DstSelect),
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(texture)");
|
|
var debugName = TextureDebugName(texture, vkFormat);
|
|
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
|
|
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
|
|
var resource = new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
StagingBuffer = stagingBuffer,
|
|
StagingMemory = stagingMemory,
|
|
Image = image,
|
|
ImageMemory = imageMemory,
|
|
View = view,
|
|
Width = width,
|
|
Height = height,
|
|
RowLength = rowLength,
|
|
DstSelect = texture.DstSelect,
|
|
NeedsUpload = true,
|
|
OwnsStorage = true,
|
|
SamplerState = texture.Sampler,
|
|
CpuContentFingerprint = contentFingerprint,
|
|
UpdatesCpuContent = texture.Address != 0,
|
|
};
|
|
|
|
if (texture.Address != 0 &&
|
|
!_guestImages.ContainsKey(texture.Address))
|
|
{
|
|
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
|
var guestImage = new GuestImageResource
|
|
{
|
|
Address = texture.Address,
|
|
Width = width,
|
|
Height = height,
|
|
MipLevels = 1,
|
|
GuestFormat = guestFormat,
|
|
Format = vkFormat,
|
|
Image = image,
|
|
Memory = imageMemory,
|
|
View = view,
|
|
InitialUploadPending = true,
|
|
IsCpuBacked = true,
|
|
CpuContentFingerprint = contentFingerprint,
|
|
SupportsStorageUsage = supportsStorageUsage,
|
|
};
|
|
_guestImages.Add(texture.Address, guestImage);
|
|
resource.OwnsStorage = false;
|
|
resource.GuestImage = guestImage;
|
|
lock (_gate)
|
|
{
|
|
if (guestFormat != 0)
|
|
{
|
|
_availableGuestImages[texture.Address] = guestFormat;
|
|
}
|
|
|
|
_guestImageExtents[texture.Address] =
|
|
(width, height, expectedSize);
|
|
}
|
|
}
|
|
|
|
return resource;
|
|
}
|
|
|
|
private (VkBuffer Buffer, DeviceMemory Memory) CreateTextureStagingBuffer(
|
|
byte[] pixels,
|
|
string debugName)
|
|
{
|
|
var size = (ulong)pixels.Length;
|
|
var buffer = CreateBuffer(
|
|
size,
|
|
BufferUsageFlags.TransferSrcBit,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out var memory);
|
|
void* mapped;
|
|
Check(_vk.MapMemory(_device, memory, 0, size, 0, &mapped), "vkMapMemory(texture)");
|
|
fixed (byte* source = pixels)
|
|
{
|
|
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
|
|
}
|
|
_vk.UnmapMemory(_device, memory);
|
|
SetDebugName(ObjectType.Buffer, buffer.Handle, debugName);
|
|
return (buffer, memory);
|
|
}
|
|
|
|
private static ulong ComputeTextureContentFingerprint(ReadOnlySpan<byte> pixels)
|
|
{
|
|
const ulong offsetBasis = 14695981039346656037;
|
|
const ulong prime = 1099511628211;
|
|
|
|
var h0 = offsetBasis ^ (ulong)pixels.Length;
|
|
var h1 = offsetBasis;
|
|
var h2 = offsetBasis;
|
|
var h3 = offsetBasis;
|
|
|
|
var words = MemoryMarshal.Cast<byte, ulong>(pixels);
|
|
var index = 0;
|
|
var blockEnd = words.Length - (words.Length & 3);
|
|
for (; index < blockEnd; index += 4)
|
|
{
|
|
h0 = (h0 ^ words[index]) * prime;
|
|
h1 = (h1 ^ words[index + 1]) * prime;
|
|
h2 = (h2 ^ words[index + 2]) * prime;
|
|
h3 = (h3 ^ words[index + 3]) * prime;
|
|
}
|
|
|
|
for (; index < words.Length; index++)
|
|
{
|
|
h0 = (h0 ^ words[index]) * prime;
|
|
}
|
|
|
|
var hash = h0;
|
|
hash = (hash ^ h1) * prime;
|
|
hash = (hash ^ h2) * prime;
|
|
hash = (hash ^ h3) * prime;
|
|
|
|
foreach (var value in pixels[(words.Length * sizeof(ulong))..])
|
|
{
|
|
hash = (hash ^ value) * prime;
|
|
}
|
|
|
|
return hash;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a draw-local sampled image containing the render target's
|
|
/// value immediately before the draw. RDNA permits a pixel shader to
|
|
/// read an attachment while producing its replacement value, whereas
|
|
/// core Vulkan does not permit the same subresource to be both a
|
|
/// sampled image and a color attachment. Keeping the two images
|
|
/// distinct preserves the guest's read-before-write behavior without
|
|
/// a queue idle or an undefined Vulkan feedback loop.
|
|
/// </summary>
|
|
private TextureResource CreateRenderTargetFeedbackSnapshot(
|
|
GuestDrawTexture texture,
|
|
GuestImageResource source)
|
|
{
|
|
var image = default(Image);
|
|
var memory = default(DeviceMemory);
|
|
var view = default(ImageView);
|
|
try
|
|
{
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
Flags =
|
|
ImageCreateFlags.CreateMutableFormatBit |
|
|
ImageCreateFlags.CreateExtendedUsageBit,
|
|
ImageType = ImageType.Type2D,
|
|
Format = source.Format,
|
|
Extent = new Extent3D(source.Width, source.Height, 1),
|
|
MipLevels = source.MipLevels,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage =
|
|
ImageUsageFlags.TransferDstBit |
|
|
ImageUsageFlags.SampledBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(
|
|
_vk.CreateImage(_device, &imageInfo, null, out image),
|
|
"vkCreateImage(render-target feedback snapshot)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
|
var memoryInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
requirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(
|
|
_vk.AllocateMemory(_device, &memoryInfo, null, out memory),
|
|
"vkAllocateMemory(render-target feedback snapshot)");
|
|
Check(
|
|
_vk.BindImageMemory(_device, image, memory, 0),
|
|
"vkBindImageMemory(render-target feedback snapshot)");
|
|
|
|
var viewFormat = GetTextureFormat(texture.Format, texture.NumberType);
|
|
if (!IsCompatibleViewFormat(source.Format, viewFormat))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Feedback view format {viewFormat} is incompatible with " +
|
|
$"render-target format {source.Format}.");
|
|
}
|
|
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = viewFormat,
|
|
Components = ToVkComponentMapping(texture.DstSelect),
|
|
SubresourceRange = ColorSubresourceRange(0, source.MipLevels),
|
|
};
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out view),
|
|
"vkCreateImageView(render-target feedback snapshot)");
|
|
|
|
var debugName =
|
|
$"SharpEmu feedback 0x{source.Address:X16} " +
|
|
$"{source.Width}x{source.Height} {source.Format}->{viewFormat}";
|
|
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
|
|
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
|
|
TraceVulkanShader(
|
|
$"vk.feedback_snapshot_create addr=0x{source.Address:X16} " +
|
|
$"size={source.Width}x{source.Height} mips={source.MipLevels} " +
|
|
$"image_format={source.Format} view_format={viewFormat} " +
|
|
$"dst=0x{texture.DstSelect:X3}");
|
|
|
|
return new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
Image = image,
|
|
ImageMemory = memory,
|
|
View = view,
|
|
Width = source.Width,
|
|
Height = source.Height,
|
|
RowLength = source.Width,
|
|
DstSelect = texture.DstSelect,
|
|
OwnsStorage = true,
|
|
SamplerState = texture.Sampler,
|
|
FeedbackSource = source,
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
if (view.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, view, null);
|
|
}
|
|
|
|
if (image.Handle != 0)
|
|
{
|
|
_vk.DestroyImage(_device, image, null);
|
|
}
|
|
|
|
if (memory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private TextureResource CreateDepthFeedbackSnapshot(
|
|
GuestDrawTexture texture,
|
|
GuestDepthResource source)
|
|
{
|
|
var image = default(Image);
|
|
var memory = default(DeviceMemory);
|
|
var view = default(ImageView);
|
|
try
|
|
{
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
ImageType = ImageType.Type2D,
|
|
Format = DepthFormat,
|
|
Extent = new Extent3D(source.Width, source.Height, 1),
|
|
MipLevels = 1,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(
|
|
_vk.CreateImage(_device, &imageInfo, null, out image),
|
|
"vkCreateImage(depth feedback snapshot)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
|
var memoryInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
requirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(
|
|
_vk.AllocateMemory(_device, &memoryInfo, null, out memory),
|
|
"vkAllocateMemory(depth feedback snapshot)");
|
|
Check(
|
|
_vk.BindImageMemory(_device, image, memory, 0),
|
|
"vkBindImageMemory(depth feedback snapshot)");
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = DepthFormat,
|
|
Components = ToVkComponentMapping(texture.DstSelect),
|
|
SubresourceRange = new ImageSubresourceRange(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
1,
|
|
0,
|
|
1),
|
|
};
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out view),
|
|
"vkCreateImageView(depth feedback snapshot)");
|
|
SetDebugName(
|
|
ObjectType.Image,
|
|
image.Handle,
|
|
$"SharpEmu depth feedback 0x{source.Address:X16} image");
|
|
SetDebugName(
|
|
ObjectType.ImageView,
|
|
view.Handle,
|
|
$"SharpEmu depth feedback 0x{source.Address:X16} view");
|
|
return new TextureResource
|
|
{
|
|
Address = texture.Address,
|
|
Image = image,
|
|
ImageMemory = memory,
|
|
View = view,
|
|
Width = source.Width,
|
|
Height = source.Height,
|
|
RowLength = source.Width,
|
|
DstSelect = texture.DstSelect,
|
|
OwnsStorage = true,
|
|
SamplerState = texture.Sampler,
|
|
DepthFeedbackSource = source,
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
if (view.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, view, null);
|
|
}
|
|
if (image.Handle != 0)
|
|
{
|
|
_vk.DestroyImage(_device, image, null);
|
|
}
|
|
if (memory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void DumpTextureUpload(
|
|
GuestDrawTexture texture,
|
|
byte[] pixels,
|
|
uint rowLength,
|
|
uint width,
|
|
uint height)
|
|
{
|
|
if (!string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_TEXTURES"),
|
|
"1",
|
|
StringComparison.Ordinal) ||
|
|
texture.IsFallback ||
|
|
texture.IsStorage ||
|
|
GetTextureBytesPerPixel(texture.Format) != 4 ||
|
|
width == 0 ||
|
|
height == 0 ||
|
|
!_dumpedTextures.Add((texture.Address, width, height, texture.Format)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var rowBytes = checked((int)rowLength * 4);
|
|
var visibleRowBytes = checked((int)width * 4);
|
|
if (pixels.Length < checked(rowBytes * (int)height))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var directory = Path.Combine(AppContext.BaseDirectory, "texture-dumps");
|
|
Directory.CreateDirectory(directory);
|
|
var path = Path.Combine(
|
|
directory,
|
|
$"tex-{texture.Address:X16}-{width}x{height}-fmt{texture.Format}-row{rowLength}.bmp");
|
|
WriteRgbaBmp(path, pixels, rowBytes, visibleRowBytes, (int)width, (int)height);
|
|
}
|
|
|
|
private void TraceTextureUploadContents(
|
|
GuestDrawTexture texture,
|
|
byte[] pixels,
|
|
uint rowLength,
|
|
uint width,
|
|
uint height,
|
|
Format format)
|
|
{
|
|
if (!_traceGuestImageAddressFilterEnabled ||
|
|
!AddressListContains("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS", texture.Address) ||
|
|
!_tracedTextureUploadContents.Add(
|
|
(texture.Address, width, height, texture.Format)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var bytesPerPixel = checked((uint)GetTextureBytesPerPixel(texture.Format));
|
|
var nonzeroBytes = 0L;
|
|
ulong hash = 14695981039346656037UL;
|
|
foreach (var value in pixels)
|
|
{
|
|
nonzeroBytes += value == 0 ? 0 : 1;
|
|
hash = (hash ^ value) * 1099511628211UL;
|
|
}
|
|
|
|
var centerOffset = checked(
|
|
((int)(height / 2) * (int)rowLength + (int)(width / 2)) *
|
|
(int)bytesPerPixel);
|
|
var center = centerOffset + bytesPerPixel <= pixels.Length
|
|
? Convert.ToHexString(
|
|
pixels.AsSpan(centerOffset, checked((int)bytesPerPixel)))
|
|
: "out-of-range";
|
|
Console.Error.WriteLine(
|
|
"[LOADER][TRACE] " +
|
|
$"vk.texture_upload_contents addr=0x{texture.Address:X16} " +
|
|
$"size={width}x{height} row={rowLength} format={format} " +
|
|
$"guest_format={texture.Format} nonzero_bytes={nonzeroBytes}/{pixels.Length} " +
|
|
$"nonblack_pixels={CountNonblackPixels(pixels, format, bytesPerPixel)}/{(ulong)rowLength * height} " +
|
|
$"center={center} sample_unique={CountSampledUniquePixels(pixels, bytesPerPixel)} " +
|
|
$"hash=0x{hash:X16}");
|
|
|
|
var directory =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_DIR");
|
|
if (string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Directory.CreateDirectory(directory);
|
|
var sequence = Interlocked.Increment(ref _guestImageDumpSequence);
|
|
var path = Path.Combine(
|
|
directory,
|
|
$"{sequence:D4}-texture-0x{texture.Address:X16}-{width}x{height}-" +
|
|
$"row{rowLength}-fmt{texture.Format}-{format}.rgba");
|
|
File.WriteAllBytes(path, pixels);
|
|
}
|
|
|
|
private static void WriteRgbaBmp(
|
|
string path,
|
|
byte[] rgba,
|
|
int sourceRowBytes,
|
|
int visibleRowBytes,
|
|
int width,
|
|
int height)
|
|
{
|
|
const int fileHeaderSize = 14;
|
|
const int infoHeaderSize = 40;
|
|
const int bytesPerPixel = 4;
|
|
var pixelBytes = checked(width * height * bytesPerPixel);
|
|
var fileSize = fileHeaderSize + infoHeaderSize + pixelBytes;
|
|
var output = new byte[fileSize];
|
|
|
|
output[0] = (byte)'B';
|
|
output[1] = (byte)'M';
|
|
WriteUInt32(output, 2, (uint)fileSize);
|
|
WriteUInt32(output, 10, fileHeaderSize + infoHeaderSize);
|
|
WriteUInt32(output, 14, infoHeaderSize);
|
|
WriteInt32(output, 18, width);
|
|
WriteInt32(output, 22, -height);
|
|
WriteUInt16(output, 26, 1);
|
|
WriteUInt16(output, 28, 32);
|
|
WriteUInt32(output, 34, (uint)pixelBytes);
|
|
|
|
var destinationOffset = fileHeaderSize + infoHeaderSize;
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
var sourceOffset = y * sourceRowBytes;
|
|
for (var x = 0; x < visibleRowBytes; x += bytesPerPixel)
|
|
{
|
|
var destination = destinationOffset + y * visibleRowBytes + x;
|
|
output[destination + 0] = rgba[sourceOffset + x + 2];
|
|
output[destination + 1] = rgba[sourceOffset + x + 1];
|
|
output[destination + 2] = rgba[sourceOffset + x + 0];
|
|
output[destination + 3] = rgba[sourceOffset + x + 3];
|
|
}
|
|
}
|
|
|
|
File.WriteAllBytes(path, output);
|
|
}
|
|
|
|
private static void WriteUInt16(byte[] output, int offset, ushort value)
|
|
{
|
|
output[offset + 0] = (byte)value;
|
|
output[offset + 1] = (byte)(value >> 8);
|
|
}
|
|
|
|
private static void WriteUInt32(byte[] output, int offset, uint value)
|
|
{
|
|
output[offset + 0] = (byte)value;
|
|
output[offset + 1] = (byte)(value >> 8);
|
|
output[offset + 2] = (byte)(value >> 16);
|
|
output[offset + 3] = (byte)(value >> 24);
|
|
}
|
|
|
|
private static void WriteInt32(byte[] output, int offset, int value) =>
|
|
WriteUInt32(output, offset, unchecked((uint)value));
|
|
|
|
private Sampler CreateSampler(GuestSampler sampler)
|
|
{
|
|
if (_samplers.TryGetValue(sampler, out var cachedSampler))
|
|
{
|
|
return cachedSampler;
|
|
}
|
|
|
|
var minLod = DecodeSamplerMipFilter(sampler) == 0
|
|
? 0f
|
|
: DecodeSamplerMinLod(sampler);
|
|
var maxLod = DecodeSamplerMipFilter(sampler) == 0
|
|
? 0f
|
|
: DecodeSamplerMaxLod(sampler);
|
|
var samplerInfo = new SamplerCreateInfo
|
|
{
|
|
SType = StructureType.SamplerCreateInfo,
|
|
MagFilter = ToVkFilter(DecodeSamplerMagFilter(sampler)),
|
|
MinFilter = ToVkFilter(DecodeSamplerMinFilter(sampler)),
|
|
MipmapMode = ToVkMipFilter(DecodeSamplerMipFilter(sampler)),
|
|
AddressModeU = ToVkSamplerAddressMode(DecodeSamplerClampX(sampler)),
|
|
AddressModeV = ToVkSamplerAddressMode(DecodeSamplerClampY(sampler)),
|
|
AddressModeW = ToVkSamplerAddressMode(DecodeSamplerClampZ(sampler)),
|
|
MipLodBias = DecodeSamplerLodBias(sampler),
|
|
CompareEnable = DecodeSamplerDepthCompare(sampler) != 0,
|
|
CompareOp = ToVkCompareOp(DecodeSamplerDepthCompare(sampler)),
|
|
MinLod = minLod,
|
|
MaxLod = Math.Max(minLod, maxLod),
|
|
BorderColor = ToVkBorderColor(DecodeSamplerBorderColor(sampler)),
|
|
};
|
|
Sampler vkSampler;
|
|
Check(
|
|
_vk.CreateSampler(_device, &samplerInfo, null, out vkSampler),
|
|
"vkCreateSampler(texture)");
|
|
_samplers.Add(sampler, vkSampler);
|
|
return vkSampler;
|
|
}
|
|
|
|
private static ComponentMapping ToVkComponentMapping(uint dstSelect)
|
|
{
|
|
return new ComponentMapping(
|
|
ToVkComponentSwizzle(dstSelect & 0x7),
|
|
ToVkComponentSwizzle((dstSelect >> 3) & 0x7),
|
|
ToVkComponentSwizzle((dstSelect >> 6) & 0x7),
|
|
ToVkComponentSwizzle((dstSelect >> 9) & 0x7));
|
|
}
|
|
|
|
private static ComponentSwizzle ToVkComponentSwizzle(uint selector) =>
|
|
selector switch
|
|
{
|
|
0 => ComponentSwizzle.Zero,
|
|
1 => ComponentSwizzle.One,
|
|
4 => ComponentSwizzle.R,
|
|
5 => ComponentSwizzle.G,
|
|
6 => ComponentSwizzle.B,
|
|
7 => ComponentSwizzle.A,
|
|
_ => ComponentSwizzle.Identity,
|
|
};
|
|
|
|
private static byte[] ExpandRgb32Pixels(byte[] pixels)
|
|
{
|
|
var texelCount = pixels.Length / 12;
|
|
var expanded = new byte[checked(texelCount * 16)];
|
|
for (var texel = 0; texel < texelCount; texel++)
|
|
{
|
|
System.Buffer.BlockCopy(pixels, texel * 12, expanded, texel * 16, 12);
|
|
expanded[texel * 16 + 14] = 0x80;
|
|
expanded[texel * 16 + 15] = 0x3F;
|
|
}
|
|
|
|
return expanded;
|
|
}
|
|
|
|
private GlobalBufferResource CreateGlobalBufferResource(
|
|
GuestMemoryBuffer guestBuffer)
|
|
{
|
|
if (guestBuffer.BaseAddress == 0)
|
|
{
|
|
return CreateTransientGlobalBufferResource(guestBuffer);
|
|
}
|
|
|
|
var size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint));
|
|
var endAddress = checked(guestBuffer.BaseAddress + size);
|
|
GuestBufferAllocation? allocation = null;
|
|
var allocationPriority = -1;
|
|
// This runs for every bound global buffer. Preserve the previous
|
|
// stable queue preference without allocating LINQ sort state.
|
|
foreach (var candidate in _guestBufferAllocations)
|
|
{
|
|
if (candidate.BaseAddress > guestBuffer.BaseAddress ||
|
|
candidate.BaseAddress + candidate.Size < endAddress)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var candidatePriority = string.Equals(
|
|
candidate.QueueName,
|
|
_activeGuestQueue.Name,
|
|
StringComparison.Ordinal)
|
|
? 2
|
|
: string.Equals(
|
|
candidate.QueueName,
|
|
SharedReadOnlyGuestBufferQueue,
|
|
StringComparison.Ordinal)
|
|
? 1
|
|
: 0;
|
|
if (candidatePriority > allocationPriority)
|
|
{
|
|
allocation = candidate;
|
|
allocationPriority = candidatePriority;
|
|
}
|
|
}
|
|
|
|
if (allocation is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"no Vulkan guest buffer allocation covers " +
|
|
$"0x{guestBuffer.BaseAddress:X16}-0x{endAddress:X16}");
|
|
}
|
|
|
|
var guestOffset = guestBuffer.BaseAddress - allocation.BaseAddress;
|
|
var descriptorOffset = guestOffset &
|
|
~(GuestStorageBufferOffsetAlignment - 1);
|
|
var byteBias = guestOffset - descriptorOffset;
|
|
if (descriptorOffset % _minStorageBufferOffsetAlignment != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"guest buffer alias offset 0x{descriptorOffset:X} is not aligned to Vulkan's " +
|
|
$"minStorageBufferOffsetAlignment={_minStorageBufferOffsetAlignment}");
|
|
}
|
|
|
|
var expectedBias = guestBuffer.BaseAddress &
|
|
(GuestStorageBufferOffsetAlignment - 1);
|
|
if (byteBias != expectedBias)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"guest buffer allocation base 0x{allocation.BaseAddress:X16} " +
|
|
$"does not satisfy alias alignment " +
|
|
$"{GuestStorageBufferOffsetAlignment}");
|
|
}
|
|
|
|
var source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
|
|
var shadow = allocation.Shadow.AsSpan(checked((int)guestOffset), guestBuffer.Length);
|
|
if (!source.SequenceEqual(shadow))
|
|
{
|
|
var sharedReadOnly = string.Equals(
|
|
allocation.QueueName,
|
|
SharedReadOnlyGuestBufferQueue,
|
|
StringComparison.Ordinal);
|
|
if (sharedReadOnly &&
|
|
(allocation.LastUseTimeline > _completedTimeline ||
|
|
IsGuestBufferAllocationReferencedByOpenBatch(allocation)))
|
|
{
|
|
return CreateVersionedReadOnlyGlobalBufferResource(
|
|
guestBuffer,
|
|
expectedBias,
|
|
size);
|
|
}
|
|
|
|
// HOST_COHERENT does not permit racing a mapped CPU write with
|
|
// an in-flight shader access. Retire prior users, publish their
|
|
// dirty ranges to guest memory, then upload the current guest
|
|
// bytes (which may be newer than the parser's captured array).
|
|
if (!sharedReadOnly)
|
|
{
|
|
// Writable aliases are private to one logical guest queue.
|
|
// Retiring unrelated queues here recreates the global FIFO
|
|
// and turns routine buffer refreshes into queue-wide stalls.
|
|
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
|
|
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
|
|
}
|
|
// Populate the cached shadow copy first and write it out to the
|
|
// mapped allocation in one pass. The mapped memory is
|
|
// HOST_VISIBLE|HOST_COHERENT (write-combined on most drivers),
|
|
// so CPU reads from it are uncached and orders of magnitude
|
|
// slower than heap reads — never use it as a copy source.
|
|
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, shadow) != true)
|
|
{
|
|
source.CopyTo(shadow);
|
|
}
|
|
|
|
shadow.CopyTo(new Span<byte>(
|
|
(void*)(allocation.Mapped + checked((nint)guestOffset)),
|
|
guestBuffer.Length));
|
|
}
|
|
|
|
if (ShouldTraceVulkanResources() &&
|
|
_tracedGlobalBuffers.Add((guestBuffer.BaseAddress, guestBuffer.Length)))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.global_buffer base=0x{guestBuffer.BaseAddress:X16} " +
|
|
$"bytes={guestBuffer.Length}");
|
|
}
|
|
if (guestBuffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(guestBuffer.Data);
|
|
}
|
|
|
|
return new GlobalBufferResource
|
|
{
|
|
BaseAddress = guestBuffer.BaseAddress,
|
|
Writable = guestBuffer.Writable,
|
|
WriteBackToGuest = guestBuffer.WriteBackToGuest,
|
|
Buffer = allocation.Buffer,
|
|
Memory = allocation.Memory,
|
|
Mapped = allocation.Mapped + checked((nint)guestOffset),
|
|
Offset = descriptorOffset,
|
|
Size = checked((size + byteBias + 3) & ~3UL),
|
|
GuestOffset = guestOffset,
|
|
GuestSize = size,
|
|
Allocation = allocation,
|
|
};
|
|
}
|
|
|
|
private bool IsGuestBufferAllocationReferencedByOpenBatch(
|
|
GuestBufferAllocation allocation)
|
|
{
|
|
if (!_batchOpen)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var resources in _batchResources)
|
|
{
|
|
foreach (var globalBuffer in resources.GlobalMemoryBuffers)
|
|
{
|
|
if (ReferenceEquals(globalBuffer?.Allocation, allocation))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private GlobalBufferResource CreateVersionedReadOnlyGlobalBufferResource(
|
|
GuestMemoryBuffer guestBuffer,
|
|
ulong byteBias,
|
|
ulong guestSize)
|
|
{
|
|
var descriptorSize = checked((guestSize + byteBias + 3) & ~3UL);
|
|
var descriptorLength = checked((int)descriptorSize);
|
|
var snapshot = GuestDataPool.Shared.Rent(descriptorLength);
|
|
try
|
|
{
|
|
var snapshotData = snapshot.AsSpan(0, descriptorLength);
|
|
snapshotData.Clear();
|
|
guestBuffer.Data.AsSpan(0, guestBuffer.Length).CopyTo(
|
|
snapshotData[checked((int)byteBias)..]);
|
|
|
|
var buffer = CreateHostBuffer(
|
|
snapshotData,
|
|
BufferUsageFlags.StorageBufferBit,
|
|
out var memory,
|
|
out var mapped);
|
|
return new GlobalBufferResource
|
|
{
|
|
BaseAddress = guestBuffer.BaseAddress,
|
|
Writable = false,
|
|
WriteBackToGuest = false,
|
|
Buffer = buffer,
|
|
Memory = memory,
|
|
Mapped = mapped + checked((nint)byteBias),
|
|
Offset = 0,
|
|
Size = descriptorSize,
|
|
GuestOffset = byteBias,
|
|
GuestSize = guestSize,
|
|
};
|
|
}
|
|
finally
|
|
{
|
|
GuestDataPool.Shared.Return(snapshot);
|
|
if (guestBuffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(guestBuffer.Data);
|
|
}
|
|
}
|
|
}
|
|
|
|
private GlobalBufferResource CreateTransientGlobalBufferResource(
|
|
GuestMemoryBuffer guestBuffer)
|
|
{
|
|
var buffer = CreateHostBuffer(
|
|
guestBuffer.Data.AsSpan(0, guestBuffer.Length),
|
|
BufferUsageFlags.StorageBufferBit,
|
|
out var memory,
|
|
out var mapped);
|
|
if (guestBuffer.Pooled)
|
|
{
|
|
GuestDataPool.Shared.Return(guestBuffer.Data);
|
|
}
|
|
|
|
return new GlobalBufferResource
|
|
{
|
|
BaseAddress = 0,
|
|
Writable = false,
|
|
WriteBackToGuest = false,
|
|
Buffer = buffer,
|
|
Memory = memory,
|
|
Mapped = mapped,
|
|
Offset = 0,
|
|
Size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint)),
|
|
GuestOffset = 0,
|
|
GuestSize = (ulong)Math.Max(guestBuffer.Length, sizeof(uint)),
|
|
};
|
|
}
|
|
|
|
private void PrepareGuestBufferAllocations(
|
|
IReadOnlyList<GuestMemoryBuffer> buffers)
|
|
{
|
|
if (buffers.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var ranges = new List<(ulong Start, ulong End, bool Writable)>(buffers.Count);
|
|
foreach (var buffer in buffers)
|
|
{
|
|
if (buffer.BaseAddress == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var size = (ulong)Math.Max(buffer.Length, sizeof(uint));
|
|
var alignedStart = buffer.BaseAddress &
|
|
~(GuestStorageBufferOffsetAlignment - 1);
|
|
var paddedEnd = checked(buffer.BaseAddress + size + 3) & ~3UL;
|
|
ranges.Add((
|
|
alignedStart,
|
|
paddedEnd,
|
|
buffer.Writable && buffer.WriteBackToGuest));
|
|
}
|
|
|
|
if (ranges.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ranges.Sort(static (left, right) => left.Start.CompareTo(right.Start));
|
|
var merged = new List<(ulong Start, ulong End, bool Writable)>(ranges.Count);
|
|
foreach (var range in ranges)
|
|
{
|
|
if (merged.Count == 0 || range.Start > merged[^1].End)
|
|
{
|
|
merged.Add(range);
|
|
continue;
|
|
}
|
|
|
|
var previous = merged[^1];
|
|
merged[^1] = (
|
|
previous.Start,
|
|
Math.Max(previous.End, range.End),
|
|
previous.Writable || range.Writable);
|
|
}
|
|
|
|
foreach (var range in merged)
|
|
{
|
|
EnsureGuestBufferAllocation(
|
|
range.Start,
|
|
range.End,
|
|
range.Writable
|
|
? _activeGuestQueue.Name
|
|
: SharedReadOnlyGuestBufferQueue);
|
|
}
|
|
}
|
|
|
|
private void EnsureGuestBufferAllocation(
|
|
ulong requestedStart,
|
|
ulong requestedEnd,
|
|
string queueName)
|
|
{
|
|
var start = requestedStart;
|
|
var end = requestedEnd;
|
|
List<GuestBufferAllocation> overlaps;
|
|
do
|
|
{
|
|
overlaps = _guestBufferAllocations
|
|
.Where(allocation =>
|
|
string.Equals(
|
|
allocation.QueueName,
|
|
queueName,
|
|
StringComparison.Ordinal) &&
|
|
allocation.BaseAddress < end &&
|
|
start < allocation.BaseAddress + allocation.Size)
|
|
.ToList();
|
|
var expandedStart = overlaps.Aggregate(
|
|
start,
|
|
static (value, allocation) => Math.Min(value, allocation.BaseAddress));
|
|
var expandedEnd = overlaps.Aggregate(
|
|
end,
|
|
static (value, allocation) =>
|
|
Math.Max(value, allocation.BaseAddress + allocation.Size));
|
|
if (expandedStart == start && expandedEnd == end)
|
|
{
|
|
break;
|
|
}
|
|
|
|
start = expandedStart;
|
|
end = expandedEnd;
|
|
}
|
|
while (true);
|
|
|
|
if (overlaps.Count == 1 &&
|
|
overlaps[0].BaseAddress <= requestedStart &&
|
|
overlaps[0].BaseAddress + overlaps[0].Size >= requestedEnd)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (overlaps.Count > 0)
|
|
{
|
|
// Growing/merging an aliased allocation is rare. Synchronize
|
|
// only this structural transition so no in-flight descriptor
|
|
// can observe storage being replaced underneath it.
|
|
WaitForAllGuestSubmissionsForCpuVisibility();
|
|
WriteBackAllDirtyGuestBuffers();
|
|
}
|
|
|
|
var replacement = CreateGuestBufferAllocation(start, end, queueName);
|
|
foreach (var overlap in overlaps)
|
|
{
|
|
_guestBufferAllocations.Remove(overlap);
|
|
DestroyGuestBufferAllocation(overlap);
|
|
}
|
|
|
|
_guestBufferAllocations.Add(replacement);
|
|
_guestBufferAllocations.Sort(static (left, right) =>
|
|
{
|
|
var queueOrder = string.CompareOrdinal(left.QueueName, right.QueueName);
|
|
return queueOrder != 0
|
|
? queueOrder
|
|
: left.BaseAddress.CompareTo(right.BaseAddress);
|
|
});
|
|
TraceVulkanShader(
|
|
$"vk.guest_buffer_allocation queue={queueName} " +
|
|
$"base=0x{start:X16} bytes={replacement.Size} " +
|
|
$"merged={overlaps.Count}");
|
|
}
|
|
|
|
private GuestBufferAllocation CreateGuestBufferAllocation(
|
|
ulong start,
|
|
ulong end,
|
|
string queueName)
|
|
{
|
|
var size = checked(end - start);
|
|
if (size == 0 || size > int.MaxValue)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"guest buffer allocation is outside the supported host span: " +
|
|
$"base=0x{start:X16} bytes={size}");
|
|
}
|
|
|
|
var buffer = CreateBuffer(
|
|
size,
|
|
BufferUsageFlags.StorageBufferBit,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out var memory);
|
|
void* mapped;
|
|
Check(_vk.MapMemory(_device, memory, 0, size, 0, &mapped), "vkMapMemory(guest buffer)");
|
|
var shadow = new byte[checked((int)size)];
|
|
_ = _guestMemory?.TryRead(start, shadow);
|
|
shadow.CopyTo(new Span<byte>(mapped, shadow.Length));
|
|
SetDebugName(
|
|
ObjectType.Buffer,
|
|
buffer.Handle,
|
|
$"SharpEmu guest VA 0x{start:X16}-0x{end:X16}");
|
|
return new GuestBufferAllocation
|
|
{
|
|
QueueName = queueName,
|
|
BaseAddress = start,
|
|
Size = size,
|
|
Buffer = buffer,
|
|
Memory = memory,
|
|
Mapped = (nint)mapped,
|
|
Shadow = shadow,
|
|
};
|
|
}
|
|
|
|
private void DestroyGuestBufferAllocation(GuestBufferAllocation allocation)
|
|
{
|
|
if (allocation.Mapped != 0)
|
|
{
|
|
_vk.UnmapMemory(_device, allocation.Memory);
|
|
}
|
|
|
|
_vk.DestroyBuffer(_device, allocation.Buffer, null);
|
|
_vk.FreeMemory(_device, allocation.Memory, null);
|
|
}
|
|
|
|
private VertexBufferResource CreateVertexBufferResource(
|
|
GuestVertexBuffer guestBuffer)
|
|
{
|
|
ReadOnlySpan<byte> source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
|
|
byte[]? forcedVertexColors = null;
|
|
if (_forceTitleVertexColorWhite &&
|
|
guestBuffer.Location == 0 &&
|
|
guestBuffer.ComponentCount == 4 &&
|
|
guestBuffer.DataFormat == 10 &&
|
|
guestBuffer.NumberFormat == 0 &&
|
|
guestBuffer.Stride == 16 &&
|
|
guestBuffer.OffsetBytes == 12 &&
|
|
guestBuffer.Length == 67568)
|
|
{
|
|
forcedVertexColors = source.ToArray();
|
|
for (var offset = 12; offset + 3 < forcedVertexColors.Length; offset += 16)
|
|
{
|
|
forcedVertexColors[offset] = 0xFF;
|
|
forcedVertexColors[offset + 1] = 0xFF;
|
|
forcedVertexColors[offset + 2] = 0xFF;
|
|
}
|
|
|
|
source = forcedVertexColors;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.vertex_force_title_color_white " +
|
|
$"base=0x{guestBuffer.BaseAddress:X16} bytes={guestBuffer.Length}");
|
|
}
|
|
var buffer = CreateHostBuffer(
|
|
source,
|
|
BufferUsageFlags.VertexBufferBit,
|
|
out var memory,
|
|
out _);
|
|
var size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint));
|
|
if (_setDebugUtilsObjectName is not null)
|
|
{
|
|
SetDebugName(
|
|
ObjectType.Buffer,
|
|
buffer.Handle,
|
|
$"SharpEmu vertex loc{guestBuffer.Location} " +
|
|
$"0x{guestBuffer.BaseAddress:X16} {guestBuffer.Length}b");
|
|
}
|
|
if (_tracedVertexBufferCount++ < 64)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.vertex_buffer loc={guestBuffer.Location} " +
|
|
$"base=0x{guestBuffer.BaseAddress:X16} stride={guestBuffer.Stride} " +
|
|
$"offset={guestBuffer.OffsetBytes} comps={guestBuffer.ComponentCount} " +
|
|
$"fmt={guestBuffer.DataFormat}/num={guestBuffer.NumberFormat} " +
|
|
$"bytes={guestBuffer.Length}");
|
|
}
|
|
|
|
return CreateVertexBufferResource(
|
|
buffer,
|
|
memory,
|
|
size,
|
|
guestBuffer,
|
|
ownsBuffer: true);
|
|
}
|
|
|
|
private static VertexBufferResource CreateVertexBufferResource(
|
|
VkBuffer buffer,
|
|
DeviceMemory memory,
|
|
ulong size,
|
|
GuestVertexBuffer guestBuffer,
|
|
bool ownsBuffer)
|
|
{
|
|
return new VertexBufferResource
|
|
{
|
|
Buffer = buffer,
|
|
Memory = memory,
|
|
OwnsBuffer = ownsBuffer,
|
|
Size = size,
|
|
Location = guestBuffer.Location,
|
|
ComponentCount = guestBuffer.ComponentCount,
|
|
DataFormat = guestBuffer.DataFormat,
|
|
NumberFormat = guestBuffer.NumberFormat,
|
|
Stride = guestBuffer.Stride,
|
|
OffsetBytes = guestBuffer.OffsetBytes,
|
|
};
|
|
}
|
|
|
|
private static VertexBufferResource CreateVertexBufferAlias(
|
|
VertexBufferResource shared,
|
|
GuestVertexBuffer guestBuffer) => new()
|
|
{
|
|
Buffer = shared.Buffer,
|
|
Memory = shared.Memory,
|
|
OwnsBuffer = false,
|
|
Size = shared.Size,
|
|
Location = guestBuffer.Location,
|
|
ComponentCount = guestBuffer.ComponentCount,
|
|
DataFormat = guestBuffer.DataFormat,
|
|
NumberFormat = guestBuffer.NumberFormat,
|
|
Stride = guestBuffer.Stride,
|
|
OffsetBytes = guestBuffer.OffsetBytes,
|
|
};
|
|
|
|
private VkBuffer CreateHostBuffer(
|
|
ReadOnlySpan<byte> data,
|
|
BufferUsageFlags usage,
|
|
out DeviceMemory memory,
|
|
out nint mapped)
|
|
{
|
|
var size = (ulong)Math.Max(data.Length, sizeof(uint));
|
|
var capacity = BitOperations.RoundUpToPowerOf2(size);
|
|
var key = new VulkanHostBufferPoolKey(usage, capacity);
|
|
|
|
VulkanHostBufferAllocation allocation;
|
|
if (_hostBufferPool.TryRent(key, out var pooled))
|
|
{
|
|
allocation = pooled;
|
|
}
|
|
else
|
|
{
|
|
var buffer = CreateBuffer(
|
|
capacity,
|
|
usage,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out var allocatedMemory);
|
|
// Persistently mapped: map/unmap per draw was a measurable
|
|
// share of the per-draw fixed cost, and HOST_COHERENT memory
|
|
// may legally stay mapped for its lifetime.
|
|
void* persistentMapping;
|
|
Check(
|
|
_vk.MapMemory(_device, allocatedMemory, 0, capacity, 0, &persistentMapping),
|
|
"vkMapMemory(host persistent)");
|
|
allocation = new VulkanHostBufferAllocation(
|
|
buffer,
|
|
allocatedMemory,
|
|
key,
|
|
(nint)persistentMapping);
|
|
_hostBufferPool.Register(allocation);
|
|
}
|
|
|
|
memory = allocation.Memory;
|
|
mapped = allocation.Mapped;
|
|
fixed (byte* source = data)
|
|
{
|
|
System.Buffer.MemoryCopy(
|
|
source,
|
|
(void*)allocation.Mapped,
|
|
checked((long)allocation.Key.Capacity),
|
|
data.Length);
|
|
}
|
|
|
|
return allocation.Buffer;
|
|
}
|
|
|
|
private void RecycleHostBuffer(VkBuffer buffer, DeviceMemory memory)
|
|
{
|
|
if (buffer.Handle == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_hostBufferPool.Return(buffer, memory))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_vk.DestroyBuffer(_device, buffer, null);
|
|
if (memory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
}
|
|
|
|
private void DestroyHostBufferAllocation(VulkanHostBufferAllocation allocation)
|
|
{
|
|
_vk.UnmapMemory(_device, allocation.Memory);
|
|
_vk.DestroyBuffer(_device, allocation.Buffer, null);
|
|
_vk.FreeMemory(_device, allocation.Memory, null);
|
|
}
|
|
|
|
private static PrimitiveTopology GetPrimitiveTopology(uint primitiveType) =>
|
|
primitiveType switch
|
|
{
|
|
1 => PrimitiveTopology.PointList,
|
|
2 => PrimitiveTopology.LineList,
|
|
3 => PrimitiveTopology.LineStrip,
|
|
5 => PrimitiveTopology.TriangleFan,
|
|
6 => PrimitiveTopology.TriangleStrip,
|
|
GuestPrimitiveRectList => PrimitiveTopology.TriangleStrip,
|
|
_ => PrimitiveTopology.TriangleList,
|
|
};
|
|
|
|
// Strip and fan topologies are the ones for which a restart index
|
|
// splits primitives; list topologies never restart.
|
|
private static bool RequiresPrimitiveRestart(PrimitiveTopology topology) =>
|
|
topology is PrimitiveTopology.LineStrip
|
|
or PrimitiveTopology.TriangleStrip
|
|
or PrimitiveTopology.TriangleFan;
|
|
|
|
private static Format ToVkVertexFormat(
|
|
uint dataFormat,
|
|
uint numberFormat,
|
|
uint componentCount) =>
|
|
(dataFormat, numberFormat) switch
|
|
{
|
|
(1, 0) => Format.R8Unorm,
|
|
(1, 1) => Format.R8SNorm,
|
|
(1, 4) => Format.R8Uint,
|
|
(1, 5) => Format.R8Sint,
|
|
(1, 9) => Format.R8Srgb,
|
|
(2, 0) => Format.R16Unorm,
|
|
(2, 1) => Format.R16SNorm,
|
|
(2, 4) => Format.R16Uint,
|
|
(2, 5) => Format.R16Sint,
|
|
(2, 7) => Format.R16Sfloat,
|
|
(3, 0) => Format.R8G8Unorm,
|
|
(3, 1) => Format.R8G8SNorm,
|
|
(3, 4) => Format.R8G8Uint,
|
|
(3, 5) => Format.R8G8Sint,
|
|
(3, 9) => Format.R8G8Srgb,
|
|
(4, 4) => Format.R32Uint,
|
|
(4, 5) => Format.R32Sint,
|
|
(4, 7) => Format.R32Sfloat,
|
|
(5, 0) => Format.R16G16Unorm,
|
|
(5, 1) => Format.R16G16SNorm,
|
|
(5, 2) => Format.R16G16Uscaled,
|
|
(5, 3) => Format.R16G16Sscaled,
|
|
(5, 4) => Format.R16G16Uint,
|
|
(5, 5) => Format.R16G16Sint,
|
|
(5, 7) => Format.R16G16Sfloat,
|
|
(6, 7) => Format.B10G11R11UfloatPack32,
|
|
(7, 7) => Format.B10G11R11UfloatPack32,
|
|
(8, 0) => Format.A2B10G10R10UnormPack32,
|
|
(8, 1) => Format.A2B10G10R10SNormPack32,
|
|
(8, 2) => Format.A2B10G10R10UscaledPack32,
|
|
(8, 3) => Format.A2B10G10R10SscaledPack32,
|
|
(8, 4) => Format.A2B10G10R10UintPack32,
|
|
(8, 5) => Format.A2B10G10R10SintPack32,
|
|
// RDNA COLOR_2_10_10_10 stores component 0 (R) in bits
|
|
// 0..9 and A in 30..31. Vulkan names that exact bit layout
|
|
// A2B10G10R10_PACK32 (the packed name is MSB-to-LSB).
|
|
(9, 0) => Format.A2B10G10R10UnormPack32,
|
|
(9, 1) => Format.A2B10G10R10SNormPack32,
|
|
(9, 2) => Format.A2B10G10R10UscaledPack32,
|
|
(9, 3) => Format.A2B10G10R10SscaledPack32,
|
|
(9, 4) => Format.A2B10G10R10UintPack32,
|
|
(9, 5) => Format.A2B10G10R10SintPack32,
|
|
(10, 0) => Format.R8G8B8A8Unorm,
|
|
(10, 1) => Format.R8G8B8A8SNorm,
|
|
(10, 2) => Format.R8G8B8A8Uscaled,
|
|
(10, 3) => Format.R8G8B8A8Sscaled,
|
|
(10, 4) => Format.R8G8B8A8Uint,
|
|
(10, 5) => Format.R8G8B8A8Sint,
|
|
(10, 9) => Format.R8G8B8A8Srgb,
|
|
(11, 4) => Format.R32G32Uint,
|
|
(11, 5) => Format.R32G32Sint,
|
|
(11, 7) => Format.R32G32Sfloat,
|
|
(12, 0) => Format.R16G16B16A16Unorm,
|
|
(12, 1) => Format.R16G16B16A16SNorm,
|
|
(12, 2) => Format.R16G16B16A16Uscaled,
|
|
(12, 3) => Format.R16G16B16A16Sscaled,
|
|
(12, 4) => Format.R16G16B16A16Uint,
|
|
(12, 5) => Format.R16G16B16A16Sint,
|
|
(12, 6) => Format.R16G16B16A16SNorm,
|
|
(12, 7) => Format.R16G16B16A16Sfloat,
|
|
(13, 4) => Format.R32G32B32Uint,
|
|
(13, 5) => Format.R32G32B32Sint,
|
|
(13, 7) => Format.R32G32B32Sfloat,
|
|
(14, 4) => Format.R32G32B32A32Uint,
|
|
(14, 5) => Format.R32G32B32A32Sint,
|
|
(14, 7) => Format.R32G32B32A32Sfloat,
|
|
(16, 0) => Format.B5G6R5UnormPack16,
|
|
(17, 0) => Format.R5G5B5A1UnormPack16,
|
|
(19, 0) => Format.R4G4B4A4UnormPack16,
|
|
(34, 7) => Format.E5B9G9R9UfloatPack32,
|
|
_ => ToVkFloatVertexFormat(componentCount),
|
|
};
|
|
|
|
private static Format ToVkFloatVertexFormat(uint componentCount) =>
|
|
componentCount switch
|
|
{
|
|
1 => Format.R32Sfloat,
|
|
2 => Format.R32G32Sfloat,
|
|
3 => Format.R32G32B32Sfloat,
|
|
4 => Format.R32G32B32A32Sfloat,
|
|
_ => Format.R32Sfloat,
|
|
};
|
|
|
|
private static ulong GetVertexBindingOffset(VertexBufferResource vertexBuffer)
|
|
{
|
|
if (vertexBuffer.OffsetBytes < vertexBuffer.Size)
|
|
{
|
|
return vertexBuffer.OffsetBytes;
|
|
}
|
|
|
|
TraceVulkanShader(
|
|
$"vk.vertex_offset_oob loc={vertexBuffer.Location} " +
|
|
$"offset={vertexBuffer.OffsetBytes} size={vertexBuffer.Size}");
|
|
return 0;
|
|
}
|
|
|
|
private static uint GetDrawVertexCount(
|
|
uint primitiveType,
|
|
uint vertexCount,
|
|
GuestIndexBuffer? indexBuffer)
|
|
{
|
|
if (primitiveType == GuestPrimitiveRectList && indexBuffer is null)
|
|
{
|
|
return 4;
|
|
}
|
|
|
|
return vertexCount;
|
|
}
|
|
|
|
private static BlendFactor ToVkBlendFactor(uint factor) =>
|
|
factor switch
|
|
{
|
|
0 => BlendFactor.Zero,
|
|
1 => BlendFactor.One,
|
|
2 => BlendFactor.SrcColor,
|
|
3 => BlendFactor.OneMinusSrcColor,
|
|
4 => BlendFactor.SrcAlpha,
|
|
5 => BlendFactor.OneMinusSrcAlpha,
|
|
6 => BlendFactor.DstAlpha,
|
|
7 => BlendFactor.OneMinusDstAlpha,
|
|
8 => BlendFactor.DstColor,
|
|
9 => BlendFactor.OneMinusDstColor,
|
|
10 => BlendFactor.SrcAlphaSaturate,
|
|
13 => BlendFactor.ConstantColor,
|
|
14 => BlendFactor.OneMinusConstantColor,
|
|
15 => BlendFactor.Src1Color,
|
|
16 => BlendFactor.OneMinusSrc1Color,
|
|
17 => BlendFactor.Src1Alpha,
|
|
18 => BlendFactor.OneMinusSrc1Alpha,
|
|
19 => BlendFactor.ConstantAlpha,
|
|
20 => BlendFactor.OneMinusConstantAlpha,
|
|
_ => BlendFactor.One,
|
|
};
|
|
|
|
private static BlendOp ToVkBlendOp(uint function) =>
|
|
function switch
|
|
{
|
|
0 => BlendOp.Add,
|
|
1 => BlendOp.Subtract,
|
|
2 => BlendOp.Min,
|
|
3 => BlendOp.Max,
|
|
4 => BlendOp.ReverseSubtract,
|
|
_ => BlendOp.Add,
|
|
};
|
|
|
|
private static uint DecodeSamplerClampX(GuestSampler sampler) =>
|
|
sampler.Word0 & 0x7u;
|
|
|
|
private static uint DecodeSamplerClampY(GuestSampler sampler) =>
|
|
(sampler.Word0 >> 3) & 0x7u;
|
|
|
|
private static uint DecodeSamplerClampZ(GuestSampler sampler) =>
|
|
(sampler.Word0 >> 6) & 0x7u;
|
|
|
|
private static uint DecodeSamplerDepthCompare(GuestSampler sampler) =>
|
|
(sampler.Word0 >> 12) & 0x7u;
|
|
|
|
private static float DecodeSamplerMinLod(GuestSampler sampler) =>
|
|
(sampler.Word1 & 0xFFFu) / 256.0f;
|
|
|
|
private static float DecodeSamplerMaxLod(GuestSampler sampler) =>
|
|
((sampler.Word1 >> 12) & 0xFFFu) / 256.0f;
|
|
|
|
private static float DecodeSamplerLodBias(GuestSampler sampler)
|
|
{
|
|
var raw = sampler.Word2 & 0x3FFFu;
|
|
var signed = (short)((raw ^ 0x2000u) - 0x2000u);
|
|
return signed / 256.0f;
|
|
}
|
|
|
|
private static uint DecodeSamplerMagFilter(GuestSampler sampler) =>
|
|
(sampler.Word2 >> 20) & 0x3u;
|
|
|
|
private static uint DecodeSamplerMinFilter(GuestSampler sampler) =>
|
|
(sampler.Word2 >> 22) & 0x3u;
|
|
|
|
private static uint DecodeSamplerMipFilter(GuestSampler sampler) =>
|
|
(sampler.Word2 >> 26) & 0x3u;
|
|
|
|
private static uint DecodeSamplerBorderColor(GuestSampler sampler) =>
|
|
(sampler.Word3 >> 30) & 0x3u;
|
|
|
|
private static SamplerAddressMode ToVkSamplerAddressMode(uint mode) =>
|
|
mode switch
|
|
{
|
|
0 => SamplerAddressMode.Repeat,
|
|
1 => SamplerAddressMode.MirroredRepeat,
|
|
2 => SamplerAddressMode.ClampToEdge,
|
|
3 or 5 or 7 => SamplerAddressMode.MirrorClampToEdge,
|
|
4 or 6 => SamplerAddressMode.ClampToBorder,
|
|
_ => SamplerAddressMode.ClampToEdge,
|
|
};
|
|
|
|
private static Filter ToVkFilter(uint filter) =>
|
|
filter is 1 or 3 ? Filter.Linear : Filter.Nearest;
|
|
|
|
private static SamplerMipmapMode ToVkMipFilter(uint filter) =>
|
|
filter == 2 ? SamplerMipmapMode.Linear : SamplerMipmapMode.Nearest;
|
|
|
|
private static CompareOp ToVkCompareOp(uint compare) =>
|
|
compare switch
|
|
{
|
|
1 => CompareOp.Less,
|
|
2 => CompareOp.Equal,
|
|
3 => CompareOp.LessOrEqual,
|
|
4 => CompareOp.Greater,
|
|
5 => CompareOp.NotEqual,
|
|
6 => CompareOp.GreaterOrEqual,
|
|
7 => CompareOp.Always,
|
|
_ => CompareOp.Never,
|
|
};
|
|
|
|
private static BorderColor ToVkBorderColor(uint color) =>
|
|
color switch
|
|
{
|
|
1 => BorderColor.FloatTransparentBlack,
|
|
2 => BorderColor.FloatOpaqueWhite,
|
|
_ => BorderColor.FloatOpaqueBlack,
|
|
};
|
|
|
|
private static ColorComponentFlags ToVkColorWriteMask(uint mask)
|
|
{
|
|
var flags = default(ColorComponentFlags);
|
|
if ((mask & 1u) != 0)
|
|
{
|
|
flags |= ColorComponentFlags.RBit;
|
|
}
|
|
|
|
if ((mask & 2u) != 0)
|
|
{
|
|
flags |= ColorComponentFlags.GBit;
|
|
}
|
|
|
|
if ((mask & 4u) != 0)
|
|
{
|
|
flags |= ColorComponentFlags.BBit;
|
|
}
|
|
|
|
if ((mask & 8u) != 0)
|
|
{
|
|
flags |= ColorComponentFlags.ABit;
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
|
|
private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent)
|
|
{
|
|
if (scissor is not { } rect)
|
|
{
|
|
return new GuestRect(0, 0, extent.Width, extent.Height);
|
|
}
|
|
|
|
var left = Math.Clamp(rect.X, 0, checked((int)extent.Width));
|
|
var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height));
|
|
var right = Math.Clamp(
|
|
rect.X + checked((int)rect.Width),
|
|
left,
|
|
checked((int)extent.Width));
|
|
var bottom = Math.Clamp(
|
|
rect.Y + checked((int)rect.Height),
|
|
top,
|
|
checked((int)extent.Height));
|
|
return new GuestRect(
|
|
left,
|
|
top,
|
|
checked((uint)(right - left)),
|
|
checked((uint)(bottom - top)));
|
|
}
|
|
|
|
private static readonly float ViewportDebugEpsilon = float.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_VIEWPORT_EPSILON"),
|
|
System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out var viewportEpsilon)
|
|
? viewportEpsilon
|
|
: 0f;
|
|
|
|
private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent)
|
|
{
|
|
if (viewport is not { } rect)
|
|
{
|
|
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
|
|
}
|
|
|
|
// Do NOT trim the rectangle to the render target: Vulkan allows
|
|
// viewports that extend beyond the framebuffer (rendering is
|
|
// confined by the scissor), and trimming changes the guest's
|
|
// scale and offset. That skews texel addressing on 1:1 draws -
|
|
// source rows get skipped or duplicated - which shredded the
|
|
// game's pre-composed tile surfaces. Only guard what the spec
|
|
// requires: a positive width and hardware viewport bounds.
|
|
const float bound = 32767f;
|
|
var x = Math.Clamp(rect.X, -bound, bound);
|
|
var y = Math.Clamp(rect.Y, -bound, bound);
|
|
var width = Math.Clamp(rect.Width, 1e-3f, bound);
|
|
var height = Math.Clamp(rect.Height, -bound, bound);
|
|
if (height == 0f)
|
|
{
|
|
height = extent.Height;
|
|
}
|
|
|
|
var minDepth = Math.Clamp(rect.MinDepth, 0f, 1f);
|
|
var maxDepth = Math.Clamp(rect.MaxDepth, minDepth, 1f);
|
|
return new Viewport(x, y, width, height, minDepth, maxDepth);
|
|
}
|
|
|
|
private static byte[] CreateFallbackTexturePixels(uint format, uint width, uint height, ulong expectedSize)
|
|
{
|
|
if (format is 9 or 10)
|
|
{
|
|
return CreateBlackFrame(width, height);
|
|
}
|
|
|
|
return new byte[checked((int)expectedSize)];
|
|
}
|
|
|
|
private static ulong GetTextureBytesPerPixel(uint format) =>
|
|
format switch
|
|
{
|
|
1 => 1UL,
|
|
2 => 2UL,
|
|
3 => 2UL,
|
|
4 => 4UL,
|
|
5 => 4UL,
|
|
6 => 4UL,
|
|
7 => 4UL,
|
|
9 => 4UL,
|
|
10 => 4UL,
|
|
11 => 8UL,
|
|
12 => 8UL,
|
|
13 => 12UL,
|
|
14 => 16UL,
|
|
16 => 2UL,
|
|
17 => 2UL,
|
|
19 => 2UL,
|
|
_ => 4UL,
|
|
};
|
|
|
|
private static ulong GetTextureByteCount(uint format, uint width, uint height)
|
|
=> GetGuestImageByteCount(format, width, height);
|
|
|
|
private static ulong GetVulkanImageByteCount(Format format, uint width, uint height)
|
|
{
|
|
var blockBytes = format switch
|
|
{
|
|
Format.BC1RgbUnormBlock or
|
|
Format.BC1RgbSrgbBlock or
|
|
Format.BC1RgbaUnormBlock or
|
|
Format.BC1RgbaSrgbBlock or
|
|
Format.BC4UnormBlock or
|
|
Format.BC4SNormBlock => 8UL,
|
|
Format.BC2UnormBlock or
|
|
Format.BC2SrgbBlock or
|
|
Format.BC3UnormBlock or
|
|
Format.BC3SrgbBlock or
|
|
Format.BC5UnormBlock or
|
|
Format.BC5SNormBlock or
|
|
Format.BC6HUfloatBlock or
|
|
Format.BC6HSfloatBlock or
|
|
Format.BC7UnormBlock or
|
|
Format.BC7SrgbBlock => 16UL,
|
|
_ => 0UL,
|
|
};
|
|
if (blockBytes != 0)
|
|
{
|
|
return checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * blockBytes);
|
|
}
|
|
|
|
var bitsPerTexel = GetFormatCompatibilityClass(format);
|
|
if (bitsPerTexel == 0)
|
|
{
|
|
bitsPerTexel = format switch
|
|
{
|
|
Format.B5G6R5UnormPack16 or
|
|
Format.R5G5B5A1UnormPack16 or
|
|
Format.R4G4B4A4UnormPack16 => 16,
|
|
Format.B8G8R8A8Unorm or
|
|
Format.B8G8R8A8Srgb => 32,
|
|
_ => 0,
|
|
};
|
|
}
|
|
|
|
return bitsPerTexel == 0
|
|
? 0
|
|
: checked((ulong)width * height * bitsPerTexel / 8);
|
|
}
|
|
|
|
private bool SupportsColorAttachment(Format format)
|
|
{
|
|
_vk.GetPhysicalDeviceFormatProperties(_physicalDevice, format, out var properties);
|
|
return (properties.OptimalTilingFeatures & FormatFeatureFlags.ColorAttachmentBit) != 0;
|
|
}
|
|
|
|
private bool SupportsStorageImage(Format format)
|
|
{
|
|
_vk.GetPhysicalDeviceFormatProperties(_physicalDevice, format, out var properties);
|
|
return (properties.OptimalTilingFeatures & FormatFeatureFlags.StorageImageBit) != 0;
|
|
}
|
|
|
|
internal static Format GetTextureFormat(uint format, uint numberType) =>
|
|
(format, numberType) switch
|
|
{
|
|
(9, _) => Format.A2B10G10R10UnormPack32,
|
|
(1, 0) => Format.R8Unorm,
|
|
(1, 1) => Format.R8SNorm,
|
|
(1, 2) => Format.R8Uscaled,
|
|
(1, 3) => Format.R8Sscaled,
|
|
(1, 4) => Format.R8Uint,
|
|
(1, 5) => Format.R8Sint,
|
|
(2, 7) => Format.R16Sfloat,
|
|
(2, 0) => Format.R16Unorm,
|
|
(2, 1) => Format.R16SNorm,
|
|
(2, 2) => Format.R16Uscaled,
|
|
(2, 3) => Format.R16Sscaled,
|
|
(2, 4) => Format.R16Uint,
|
|
(2, 5) => Format.R16Sint,
|
|
(3, 0) => Format.R8G8Unorm,
|
|
(3, 1) => Format.R8G8SNorm,
|
|
(3, 2) => Format.R8G8Uscaled,
|
|
(3, 3) => Format.R8G8Sscaled,
|
|
(3, 4) => Format.R8G8Uint,
|
|
(3, 5) => Format.R8G8Sint,
|
|
(4, 4) => Format.R32Uint,
|
|
(4, 5) => Format.R32Sint,
|
|
(4, 7) => Format.R32Sfloat,
|
|
(5, 0) => Format.R16G16Unorm,
|
|
(5, 4) => Format.R16G16Uint,
|
|
(5, 5) => Format.R16G16Sint,
|
|
(5, 7) => Format.R16G16Sfloat,
|
|
(6, 7) => Format.B10G11R11UfloatPack32,
|
|
(7, 7) => Format.B10G11R11UfloatPack32,
|
|
(8, 0) => Format.A2B10G10R10UnormPack32,
|
|
(8, 1) => Format.A2B10G10R10SNormPack32,
|
|
(8, 2) => Format.A2B10G10R10UscaledPack32,
|
|
(8, 3) => Format.A2B10G10R10SscaledPack32,
|
|
(8, 4) => Format.A2B10G10R10UintPack32,
|
|
(8, 5) => Format.A2B10G10R10SintPack32,
|
|
(10, 0) => Format.R8G8B8A8Unorm,
|
|
(10, 4) => Format.R8G8B8A8Uint,
|
|
(10, 5) => Format.R8G8B8A8Sint,
|
|
(10, 9) => Format.R8G8B8A8Srgb,
|
|
(1, 9) => Format.R8Srgb,
|
|
(3, 9) => Format.R8G8Srgb,
|
|
(11, 4) => Format.R32G32Uint,
|
|
(11, 5) => Format.R32G32Sint,
|
|
(11, 7) => Format.R32G32Sfloat,
|
|
(12, 0) => Format.R16G16B16A16Unorm,
|
|
(12, 4) => Format.R16G16B16A16Uint,
|
|
(12, 5) => Format.R16G16B16A16Sint,
|
|
(12, 7) => Format.R16G16B16A16Sfloat,
|
|
(13, 4) => Format.R32G32B32A32Uint,
|
|
(13, 5) => Format.R32G32B32A32Sint,
|
|
(13, _) => Format.R32G32B32A32Sfloat,
|
|
(14, 4) => Format.R32G32B32A32Uint,
|
|
(14, 5) => Format.R32G32B32A32Sint,
|
|
(14, 7) => Format.R32G32B32A32Sfloat,
|
|
(16, 0) => Format.B5G6R5UnormPack16,
|
|
(17, 0) => Format.R5G5B5A1UnormPack16,
|
|
(19, 0) => Format.R4G4B4A4UnormPack16,
|
|
(34, 7) => Format.E5B9G9R9UfloatPack32,
|
|
(169, _) => Format.BC1RgbaUnormBlock,
|
|
(170, _) => Format.BC1RgbaSrgbBlock,
|
|
(171, _) => Format.BC2UnormBlock,
|
|
(172, _) => Format.BC2SrgbBlock,
|
|
(173, _) => Format.BC3UnormBlock,
|
|
(174, _) => Format.BC3SrgbBlock,
|
|
(175, 1) => Format.BC4SNormBlock,
|
|
(175, _) => Format.BC4UnormBlock,
|
|
(176, _) => Format.BC4SNormBlock,
|
|
(177, 1) => Format.BC5SNormBlock,
|
|
(177, _) => Format.BC5UnormBlock,
|
|
(178, _) => Format.BC5SNormBlock,
|
|
(179, _) => Format.BC6HUfloatBlock,
|
|
(180, _) => Format.BC6HSfloatBlock,
|
|
(181, _) => Format.BC7UnormBlock,
|
|
(182, _) => Format.BC7SrgbBlock,
|
|
_ => Format.R8G8B8A8Unorm,
|
|
};
|
|
|
|
internal static Format GetStorageImageFormat(Format format) =>
|
|
format switch
|
|
{
|
|
Format.R8Srgb => Format.R8Unorm,
|
|
Format.R8G8Srgb => Format.R8G8Unorm,
|
|
Format.R8G8B8A8Srgb => Format.R8G8B8A8Unorm,
|
|
Format.BC1RgbaSrgbBlock => Format.BC1RgbaUnormBlock,
|
|
Format.BC2SrgbBlock => Format.BC2UnormBlock,
|
|
Format.BC3SrgbBlock => Format.BC3UnormBlock,
|
|
Format.BC7SrgbBlock => Format.BC7UnormBlock,
|
|
_ => format,
|
|
};
|
|
|
|
private static Format GetRenderTargetFormat(uint format, uint numberType) =>
|
|
(format, numberType) switch
|
|
{
|
|
(4, 4) => Format.R32Uint,
|
|
(4, 5) => Format.R32Sint,
|
|
(4, 7) => Format.R32Sfloat,
|
|
(5, 4) => Format.R16G16Uint,
|
|
(5, 5) => Format.R16G16Sint,
|
|
(5, 7) => Format.R16G16Sfloat,
|
|
(6, 7) => Format.B10G11R11UfloatPack32,
|
|
(7, 7) => Format.B10G11R11UfloatPack32,
|
|
(9, _) => Format.A2B10G10R10UnormPack32,
|
|
(10, 9) => Format.R8G8B8A8Srgb,
|
|
(10, 4) => Format.R8G8B8A8Uint,
|
|
(10, 5) => Format.R8G8B8A8Sint,
|
|
(10, _) => Format.R8G8B8A8Unorm,
|
|
(11, 7) => Format.R32G32Sfloat,
|
|
(12, 4) => Format.R16G16B16A16Uint,
|
|
(12, 5) => Format.R16G16B16A16Sint,
|
|
(12, 7) => Format.R16G16B16A16Sfloat,
|
|
(13, 7) => Format.R32G32B32A32Sfloat,
|
|
(14, 7) => Format.R32G32B32A32Sfloat,
|
|
(_, 0) => GetTextureFormat(format, numberType),
|
|
(_, 9) => GetTextureFormat(format, numberType),
|
|
_ => Format.Undefined,
|
|
};
|
|
private static bool IsBlockCompressedFormat(Format format) =>
|
|
format is Format.BC1RgbaUnormBlock or
|
|
Format.BC1RgbaSrgbBlock or
|
|
Format.BC2UnormBlock or
|
|
Format.BC2SrgbBlock or
|
|
Format.BC3UnormBlock or
|
|
Format.BC3SrgbBlock or
|
|
Format.BC4UnormBlock or
|
|
Format.BC4SNormBlock or
|
|
Format.BC5UnormBlock or
|
|
Format.BC5SNormBlock or
|
|
Format.BC6HUfloatBlock or
|
|
Format.BC6HSfloatBlock or
|
|
Format.BC7UnormBlock or
|
|
Format.BC7SrgbBlock;
|
|
|
|
private VkBuffer CreateBuffer(
|
|
ulong size,
|
|
BufferUsageFlags usage,
|
|
MemoryPropertyFlags memoryFlags,
|
|
out DeviceMemory memory)
|
|
{
|
|
var bufferInfo = new BufferCreateInfo
|
|
{
|
|
SType = StructureType.BufferCreateInfo,
|
|
Size = size,
|
|
Usage = usage,
|
|
SharingMode = SharingMode.Exclusive,
|
|
};
|
|
Check(_vk.CreateBuffer(_device, &bufferInfo, null, out var buffer), "vkCreateBuffer");
|
|
|
|
_vk.GetBufferMemoryRequirements(_device, buffer, out var requirements);
|
|
var memoryInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, memoryFlags),
|
|
};
|
|
Check(_vk.AllocateMemory(_device, &memoryInfo, null, out memory), "vkAllocateMemory");
|
|
Check(_vk.BindBufferMemory(_device, buffer, memory, 0), "vkBindBufferMemory");
|
|
return buffer;
|
|
}
|
|
|
|
private void CreateStagingBuffer(ulong size)
|
|
{
|
|
_stagingBuffer = CreateBuffer(
|
|
size,
|
|
BufferUsageFlags.TransferSrcBit | BufferUsageFlags.TransferDstBit,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out _stagingMemory);
|
|
_stagingSize = size;
|
|
}
|
|
|
|
private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags)
|
|
{
|
|
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties);
|
|
var memoryTypes = &properties.MemoryTypes.Element0;
|
|
for (uint index = 0; index < properties.MemoryTypeCount; index++)
|
|
{
|
|
if ((typeBits & (1u << (int)index)) != 0 &&
|
|
(memoryTypes[index].PropertyFlags & requiredFlags) == requiredFlags)
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException("No compatible Vulkan host-visible memory type was found.");
|
|
}
|
|
|
|
private const uint MaxComputeZSlicesPerSubmission = 8;
|
|
// An indirect guest dispatch above this size is not credible frame work
|
|
// (at the minimum 64-thread group used by the captured title this is
|
|
// already over one billion invocations). Treat it as poisoned
|
|
// indirect-command data and quarantine it instead of feeding a host
|
|
// API a multi-billion-workgroup command. This is validation, not a
|
|
// clamp: the raw dimensions remain visible in the trace so the
|
|
// producer can be fixed without changing the guest value.
|
|
private const ulong MaxCredibleGuestWorkgroupsPerDispatch = 16UL * 1024 * 1024;
|
|
|
|
private void ExecuteComputeDispatch(VulkanComputeGuestDispatch work)
|
|
{
|
|
var perfStart = Stopwatch.GetTimestamp();
|
|
Interlocked.Increment(ref _perfDrawCount);
|
|
PerfOverlay.RecordDraw();
|
|
try
|
|
{
|
|
ExecuteComputeDispatchCore(work);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Add(
|
|
ref _perfDrawTicks,
|
|
Stopwatch.GetTimestamp() - perfStart);
|
|
}
|
|
}
|
|
|
|
private void ExecuteComputeDispatchCore(VulkanComputeGuestDispatch work)
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
if (_deviceLost)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_skipAllCompute ||
|
|
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
|
|
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.compute_skip cs=0x{work.ShaderAddress:X16} " +
|
|
$"groups={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ} " +
|
|
$"textures={work.Textures.Count}");
|
|
return;
|
|
}
|
|
|
|
if (!TryValidateComputeDispatch(work, out var validationError))
|
|
{
|
|
LogRejectedComputeDispatch(work, validationError);
|
|
return;
|
|
}
|
|
|
|
if (!TryValidateStorageImageBindings(work, out validationError))
|
|
{
|
|
LogRejectedComputeDispatch(work, validationError);
|
|
return;
|
|
}
|
|
|
|
TranslatedDrawResources? resources = null;
|
|
CommandBuffer commandBuffer = default;
|
|
var submitted = false;
|
|
var chunksSubmitted = 0;
|
|
try
|
|
{
|
|
EnsureGuestSubmissionCapacity();
|
|
resources = CreateComputeDispatchResources(work);
|
|
|
|
var batchCount = Math.Max(
|
|
1u,
|
|
(uint)Math.Ceiling(work.GroupCountZ / (double)MaxComputeZSlicesPerSubmission));
|
|
var threadLimits = stackalloc uint[3]
|
|
{
|
|
work.ThreadCountX,
|
|
work.ThreadCountY,
|
|
work.ThreadCountZ,
|
|
};
|
|
|
|
for (var batchIndex = 0u; batchIndex < batchCount; batchIndex++)
|
|
{
|
|
var zStart = batchIndex * MaxComputeZSlicesPerSubmission;
|
|
var zCount = Math.Min(MaxComputeZSlicesPerSubmission, work.GroupCountZ - zStart);
|
|
var isFirstBatch = batchIndex == 0;
|
|
var isLastBatch = batchIndex == batchCount - 1;
|
|
|
|
if (!isFirstBatch)
|
|
{
|
|
// Each chunk is its own queue submission; without
|
|
// this the in-flight submission cap only applies to
|
|
// the first chunk of a tall dispatch.
|
|
EnsureGuestSubmissionCapacity();
|
|
}
|
|
|
|
commandBuffer = AllocateGuestCommandBuffer();
|
|
_commandBuffer = commandBuffer;
|
|
var beginInfo = new CommandBufferBeginInfo
|
|
{
|
|
SType = StructureType.CommandBufferBeginInfo,
|
|
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
|
};
|
|
Check(
|
|
_vk.BeginCommandBuffer(_commandBuffer, &beginInfo),
|
|
"vkBeginCommandBuffer(compute)");
|
|
|
|
BeginDebugLabel(_commandBuffer, resources.DebugName);
|
|
if (isFirstBatch)
|
|
{
|
|
RecordGlobalBufferVisibilityBarrier(
|
|
_commandBuffer,
|
|
resources,
|
|
PipelineStageFlags.ComputeShaderBit);
|
|
RecordTextureUploads(resources, PipelineStageFlags.ComputeShaderBit);
|
|
RecordStorageImagesForWrite(resources, PipelineStageFlags.ComputeShaderBit);
|
|
}
|
|
else
|
|
{
|
|
// Chunks are submitted without CPU waits; this
|
|
// barrier orders them against the previous chunk's
|
|
// shader writes on the same queue.
|
|
var chunkBarrier = new MemoryBarrier
|
|
{
|
|
SType = StructureType.MemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ShaderWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit | AccessFlags.ShaderWriteBit,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.ComputeShaderBit,
|
|
PipelineStageFlags.ComputeShaderBit,
|
|
0,
|
|
1,
|
|
&chunkBarrier,
|
|
0,
|
|
null,
|
|
0,
|
|
null);
|
|
}
|
|
|
|
_vk.CmdBindPipeline(
|
|
_commandBuffer,
|
|
PipelineBindPoint.Compute,
|
|
resources.Pipeline);
|
|
if (resources.DescriptorSet.Handle != 0)
|
|
{
|
|
var descriptorSet = resources.DescriptorSet;
|
|
_vk.CmdBindDescriptorSets(
|
|
_commandBuffer,
|
|
PipelineBindPoint.Compute,
|
|
resources.PipelineLayout,
|
|
0,
|
|
1,
|
|
&descriptorSet,
|
|
0,
|
|
null);
|
|
}
|
|
|
|
_vk.CmdPushConstants(
|
|
_commandBuffer,
|
|
resources.PipelineLayout,
|
|
ShaderStageFlags.ComputeBit,
|
|
0,
|
|
3 * sizeof(uint),
|
|
threadLimits);
|
|
|
|
RecordChunkedComputeDispatch(_commandBuffer, work, zStart, zCount);
|
|
|
|
if (isLastBatch)
|
|
{
|
|
RecordStorageImagesForRead(resources, PipelineStageFlags.ComputeShaderBit);
|
|
}
|
|
|
|
EndDebugLabel(_commandBuffer);
|
|
Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer(compute)");
|
|
|
|
TraceVulkanShader(
|
|
$"vk.compute_submit cs=0x{work.ShaderAddress:X16} " +
|
|
$"batch={batchIndex}/{batchCount} z={zStart}..{zStart + zCount}");
|
|
if (isLastBatch)
|
|
{
|
|
SubmitGuestCommandBuffer(
|
|
commandBuffer,
|
|
[resources],
|
|
GetTraceImages(resources, shaderAddress: work.ShaderAddress));
|
|
submitted = true;
|
|
}
|
|
else
|
|
{
|
|
SubmitGuestCommandBuffer(
|
|
commandBuffer,
|
|
[],
|
|
[],
|
|
referencedResources: [resources]);
|
|
chunksSubmitted++;
|
|
commandBuffer = default;
|
|
}
|
|
}
|
|
|
|
MarkSampledImagesInitialized(resources);
|
|
MarkStorageImagesInitialized(resources, traceContents: false);
|
|
if (work.WritesGlobalMemory)
|
|
{
|
|
// The CPU submit thread may immediately consume an indirect
|
|
// argument written by this dispatch. Wait for the specific
|
|
// guest fences and publish only dirty ranges; a queue-wide
|
|
// idle unnecessarily serialized presentation work too.
|
|
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
|
|
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
|
|
}
|
|
TraceVulkanShader(
|
|
$"vk.compute_dispatch groups={work.GroupCountX}x" +
|
|
$"{work.GroupCountY}x{work.GroupCountZ} " +
|
|
$"base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ} " +
|
|
$"textures={work.Textures.Count} cs=0x{work.ShaderAddress:X16} " +
|
|
$"batches={batchCount}");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (TryMarkDeviceLost(exception))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][ERROR] Vulkan compute dispatch failed " +
|
|
$"cs=0x{work.ShaderAddress:X16}: {exception.Message}");
|
|
}
|
|
finally
|
|
{
|
|
_commandBuffer = _presentationCommandBuffer;
|
|
if (!submitted && commandBuffer.Handle != 0)
|
|
{
|
|
_vk.FreeCommandBuffers(
|
|
_device,
|
|
_commandPool,
|
|
1,
|
|
&commandBuffer);
|
|
}
|
|
|
|
if (!submitted && resources is not null)
|
|
{
|
|
if (chunksSubmitted > 0)
|
|
{
|
|
// Earlier chunks were submitted with empty resource
|
|
// lists and may still execute against these
|
|
// pipelines/images; destroy only after every
|
|
// submission issued so far has completed.
|
|
_deferredResourceDestroys.Enqueue((resources, _submitTimeline));
|
|
}
|
|
else
|
|
{
|
|
DestroyTranslatedDrawResources(resources);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool TryValidateComputeDispatch(
|
|
VulkanComputeGuestDispatch work,
|
|
out string error)
|
|
{
|
|
if (work.LocalSizeX == 0 || work.LocalSizeY == 0 || work.LocalSizeZ == 0)
|
|
{
|
|
error = "zero-local-size";
|
|
return false;
|
|
}
|
|
|
|
if (work.LocalSizeX > _maxComputeWorkGroupSizeX ||
|
|
work.LocalSizeY > _maxComputeWorkGroupSizeY ||
|
|
work.LocalSizeZ > _maxComputeWorkGroupSizeZ)
|
|
{
|
|
error =
|
|
$"local-size-exceeds-device({work.LocalSizeX}x{work.LocalSizeY}x{work.LocalSizeZ}>" +
|
|
$"{_maxComputeWorkGroupSizeX}x{_maxComputeWorkGroupSizeY}x{_maxComputeWorkGroupSizeZ})";
|
|
return false;
|
|
}
|
|
|
|
var localInvocations =
|
|
(ulong)work.LocalSizeX * work.LocalSizeY * work.LocalSizeZ;
|
|
if (localInvocations > _maxComputeWorkGroupInvocations)
|
|
{
|
|
error =
|
|
$"local-invocations-exceed-device({localInvocations}>" +
|
|
$"{_maxComputeWorkGroupInvocations})";
|
|
return false;
|
|
}
|
|
|
|
if ((ulong)work.BaseGroupX + work.GroupCountX > _maxComputeWorkGroupCountX ||
|
|
(ulong)work.BaseGroupY + work.GroupCountY > _maxComputeWorkGroupCountY ||
|
|
(ulong)work.BaseGroupZ + work.GroupCountZ > _maxComputeWorkGroupCountZ)
|
|
{
|
|
error =
|
|
$"group-range-exceeds-device(base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ}," +
|
|
$"count={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ}," +
|
|
$"limit={_maxComputeWorkGroupCountX}x{_maxComputeWorkGroupCountY}x" +
|
|
$"{_maxComputeWorkGroupCountZ})";
|
|
return false;
|
|
}
|
|
|
|
if (work.IsIndirect)
|
|
{
|
|
ulong totalWorkgroups;
|
|
try
|
|
{
|
|
totalWorkgroups = checked(
|
|
(ulong)work.GroupCountX * work.GroupCountY * work.GroupCountZ);
|
|
}
|
|
catch (OverflowException)
|
|
{
|
|
error = "indirect-workgroup-count-overflow";
|
|
return false;
|
|
}
|
|
|
|
if (totalWorkgroups > MaxCredibleGuestWorkgroupsPerDispatch)
|
|
{
|
|
error =
|
|
$"poisoned-indirect-workgroup-count({totalWorkgroups}>" +
|
|
$"{MaxCredibleGuestWorkgroupsPerDispatch})";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private bool TryValidateStorageImageBindings(
|
|
VulkanComputeGuestDispatch work,
|
|
out string error)
|
|
{
|
|
var storageTextures = work.Textures
|
|
.Where(static texture => texture.IsStorage)
|
|
.ToArray();
|
|
if (storageTextures.Length == 0)
|
|
{
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
if (!TryReadSpirvStorageImageContracts(
|
|
work.ComputeSpirv,
|
|
out var shaderContracts,
|
|
out error))
|
|
{
|
|
error = $"storage-contract-parse-failed({error})";
|
|
return false;
|
|
}
|
|
|
|
if (shaderContracts.Length != storageTextures.Length)
|
|
{
|
|
error = $"storage-binding-count-mismatch(spirv={shaderContracts.Length}," +
|
|
$"guest={storageTextures.Length})";
|
|
return false;
|
|
}
|
|
|
|
for (var index = 0; index < storageTextures.Length; index++)
|
|
{
|
|
var texture = storageTextures[index];
|
|
var shaderContract = shaderContracts[index];
|
|
var vulkanFormat = GetStorageImageFormat(
|
|
GetTextureFormat(texture.Format, texture.NumberType));
|
|
if (!TryValidateStorageImageContract(
|
|
shaderContract,
|
|
texture.Format,
|
|
texture.NumberType,
|
|
SupportsStorageImage(vulkanFormat),
|
|
out _,
|
|
out var bindingError))
|
|
{
|
|
error = $"storage-binding[{index}]-invalid(" +
|
|
$"addr=0x{texture.Address:X16},reason={bindingError})";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private void LogRejectedComputeDispatch(
|
|
VulkanComputeGuestDispatch work,
|
|
string reason)
|
|
{
|
|
if (_rejectedComputeDispatches.Count >= 256 ||
|
|
!_rejectedComputeDispatches.Add(
|
|
(work.ShaderAddress,
|
|
work.GroupCountX,
|
|
work.GroupCountY,
|
|
work.GroupCountZ,
|
|
reason)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.compute_reject cs=0x{work.ShaderAddress:X16} " +
|
|
$"source={(work.IsIndirect ? "indirect" : "direct")} " +
|
|
$"groups={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ} " +
|
|
$"base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ} " +
|
|
$"local={work.LocalSizeX}x{work.LocalSizeY}x{work.LocalSizeZ} " +
|
|
$"reason={reason}");
|
|
}
|
|
|
|
private void RecordGlobalBufferVisibilityBarrier(
|
|
CommandBuffer commandBuffer,
|
|
TranslatedDrawResources resources,
|
|
PipelineStageFlags destinationStages)
|
|
{
|
|
if (resources.GlobalMemoryBuffers.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Queue submission order alone is not a shader-memory dependency.
|
|
// This makes stores through any aliased guest view available to
|
|
// later vertex/fragment/compute reads and writes on the same queue.
|
|
var barrier = new MemoryBarrier
|
|
{
|
|
SType = StructureType.MemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ShaderWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit | AccessFlags.ShaderWriteBit,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
PipelineStageFlags.AllCommandsBit,
|
|
destinationStages,
|
|
0,
|
|
1,
|
|
&barrier,
|
|
0,
|
|
null,
|
|
0,
|
|
null);
|
|
}
|
|
|
|
private static void MarkGuestBufferDirty(
|
|
GuestBufferAllocation allocation,
|
|
ulong offset,
|
|
ulong length)
|
|
{
|
|
if (length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var start = offset;
|
|
var end = checked(offset + length);
|
|
for (var index = allocation.DirtyRanges.Count - 1; index >= 0; index--)
|
|
{
|
|
var existing = allocation.DirtyRanges[index];
|
|
var existingEnd = existing.Offset + existing.Length;
|
|
if (end < existing.Offset || existingEnd < start)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
start = Math.Min(start, existing.Offset);
|
|
end = Math.Max(end, existingEnd);
|
|
allocation.DirtyRanges.RemoveAt(index);
|
|
}
|
|
|
|
allocation.DirtyRanges.Add(new DirtyGuestBufferRange(start, end - start));
|
|
}
|
|
|
|
private void WriteBackAllDirtyGuestBuffers(string? queueName = null)
|
|
{
|
|
var memory = _guestMemory;
|
|
if (memory is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var allocation in _guestBufferAllocations)
|
|
{
|
|
if (queueName is not null &&
|
|
!string.Equals(allocation.QueueName, queueName, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (allocation.DirtyRanges.Count != 0 &&
|
|
allocation.LastUseTimeline > _completedTimeline)
|
|
{
|
|
// A mapped HOST_COHERENT allocation still cannot be read
|
|
// by the CPU while a shader may be writing it. Callers
|
|
// normally retire the relevant fences first; keep this
|
|
// helper fail-closed if a future path forgets to do so.
|
|
TraceVulkanShader(
|
|
$"vk.global_writeback_deferred base=0x{allocation.BaseAddress:X16} " +
|
|
$"last_use={allocation.LastUseTimeline} completed={_completedTimeline}");
|
|
continue;
|
|
}
|
|
|
|
for (var index = allocation.DirtyRanges.Count - 1; index >= 0; index--)
|
|
{
|
|
var range = allocation.DirtyRanges[index];
|
|
if (range.Length == 0 || range.Length > int.MaxValue)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var mappedBytes = new ReadOnlySpan<byte>(
|
|
(void*)(allocation.Mapped + checked((nint)range.Offset)),
|
|
checked((int)range.Length));
|
|
var shadowBytes = allocation.Shadow.AsSpan(
|
|
checked((int)range.Offset),
|
|
mappedBytes.Length);
|
|
var guestAddress = allocation.BaseAddress + range.Offset;
|
|
var changedBytes = 0UL;
|
|
var changedRuns = 0;
|
|
var changedPages = 0;
|
|
var writtenRuns = 0;
|
|
var writtenPages = 0;
|
|
var failedRuns = 0;
|
|
var unreadablePages = 0;
|
|
var fallbackWrites = 0;
|
|
var firstChangedOffset = -1;
|
|
allocation.DirtyRanges.RemoveAt(index);
|
|
|
|
// A writable descriptor only identifies a potential write
|
|
// range. Publishing the entire mapped view would overwrite
|
|
// unrelated live CPU data with its old snapshot. Compare
|
|
// against the last synchronized image. For each changed
|
|
// page, start with current guest bytes and overlay only the
|
|
// shader changes before one bounded write. This preserves
|
|
// live CPU changes in unchanged bytes without degenerating
|
|
// into millions of writes for alternating output patterns.
|
|
const int pageSize = 4096;
|
|
const int unreadableMergeGap = 16;
|
|
var livePageBuffer = GuestDataPool.Shared.Rent(pageSize);
|
|
var pageRuns = new List<(int Start, int Length)>(64);
|
|
try
|
|
{
|
|
for (var pageStart = 0;
|
|
pageStart < mappedBytes.Length;
|
|
pageStart += pageSize)
|
|
{
|
|
var pageEnd = Math.Min(pageStart + pageSize, mappedBytes.Length);
|
|
pageRuns.Clear();
|
|
var cursor = pageStart;
|
|
while (cursor < pageEnd)
|
|
{
|
|
while (cursor < pageEnd &&
|
|
mappedBytes[cursor] == shadowBytes[cursor])
|
|
{
|
|
cursor++;
|
|
}
|
|
|
|
if (cursor == pageEnd)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var runStart = cursor;
|
|
while (cursor < pageEnd &&
|
|
mappedBytes[cursor] != shadowBytes[cursor])
|
|
{
|
|
cursor++;
|
|
}
|
|
|
|
var runLength = cursor - runStart;
|
|
pageRuns.Add((runStart, runLength));
|
|
changedRuns++;
|
|
changedBytes += (ulong)runLength;
|
|
if (firstChangedOffset < 0)
|
|
{
|
|
firstChangedOffset = runStart;
|
|
}
|
|
}
|
|
|
|
if (pageRuns.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
changedPages++;
|
|
var pageLength = pageEnd - pageStart;
|
|
var livePage = livePageBuffer.AsSpan(0, pageLength);
|
|
if (memory.TryRead(guestAddress + (ulong)pageStart, livePage))
|
|
{
|
|
foreach (var run in pageRuns)
|
|
{
|
|
mappedBytes.Slice(run.Start, run.Length).CopyTo(
|
|
livePage.Slice(run.Start - pageStart, run.Length));
|
|
}
|
|
|
|
if (memory.TryWrite(guestAddress + (ulong)pageStart, livePage))
|
|
{
|
|
foreach (var run in pageRuns)
|
|
{
|
|
mappedBytes.Slice(run.Start, run.Length).CopyTo(
|
|
shadowBytes.Slice(run.Start, run.Length));
|
|
}
|
|
|
|
writtenPages++;
|
|
writtenRuns += pageRuns.Count;
|
|
continue;
|
|
}
|
|
|
|
foreach (var run in pageRuns)
|
|
{
|
|
failedRuns++;
|
|
MarkGuestBufferDirty(
|
|
allocation,
|
|
range.Offset + (ulong)run.Start,
|
|
(ulong)run.Length);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// A partial/unreadable edge cannot be safely
|
|
// reconstructed as a page. Fall back to bounded
|
|
// changed spans, coalescing only tiny gaps.
|
|
unreadablePages++;
|
|
for (var runIndex = 0; runIndex < pageRuns.Count; runIndex++)
|
|
{
|
|
var firstRunIndex = runIndex;
|
|
var mergedStart = pageRuns[runIndex].Start;
|
|
var mergedEnd = mergedStart + pageRuns[runIndex].Length;
|
|
while (runIndex + 1 < pageRuns.Count &&
|
|
pageRuns[runIndex + 1].Start - mergedEnd <=
|
|
unreadableMergeGap)
|
|
{
|
|
runIndex++;
|
|
mergedEnd = pageRuns[runIndex].Start +
|
|
pageRuns[runIndex].Length;
|
|
}
|
|
|
|
var lastRunIndex = runIndex;
|
|
var mergedLength = mergedEnd - mergedStart;
|
|
var mergedLive = livePageBuffer.AsSpan(0, mergedLength);
|
|
if (memory.TryRead(
|
|
guestAddress + (ulong)mergedStart,
|
|
mergedLive))
|
|
{
|
|
for (var overlayIndex = firstRunIndex;
|
|
overlayIndex <= lastRunIndex;
|
|
overlayIndex++)
|
|
{
|
|
var run = pageRuns[overlayIndex];
|
|
mappedBytes.Slice(run.Start, run.Length).CopyTo(
|
|
mergedLive.Slice(
|
|
run.Start - mergedStart,
|
|
run.Length));
|
|
}
|
|
|
|
fallbackWrites++;
|
|
if (memory.TryWrite(
|
|
guestAddress + (ulong)mergedStart,
|
|
mergedLive))
|
|
{
|
|
for (var overlayIndex = firstRunIndex;
|
|
overlayIndex <= lastRunIndex;
|
|
overlayIndex++)
|
|
{
|
|
var run = pageRuns[overlayIndex];
|
|
mappedBytes.Slice(run.Start, run.Length).CopyTo(
|
|
shadowBytes.Slice(run.Start, run.Length));
|
|
}
|
|
|
|
writtenRuns += lastRunIndex - firstRunIndex + 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Even the merged span crosses an unreadable
|
|
// edge. Exact changed runs remain safe because
|
|
// they never carry stale gap bytes.
|
|
for (var exactIndex = firstRunIndex;
|
|
exactIndex <= lastRunIndex;
|
|
exactIndex++)
|
|
{
|
|
var run = pageRuns[exactIndex];
|
|
var changed = mappedBytes.Slice(run.Start, run.Length);
|
|
fallbackWrites++;
|
|
if (memory.TryWrite(
|
|
guestAddress + (ulong)run.Start,
|
|
changed))
|
|
{
|
|
changed.CopyTo(shadowBytes.Slice(
|
|
run.Start,
|
|
run.Length));
|
|
writtenRuns++;
|
|
}
|
|
else
|
|
{
|
|
failedRuns++;
|
|
MarkGuestBufferDirty(
|
|
allocation,
|
|
range.Offset + (ulong)run.Start,
|
|
(ulong)run.Length);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
GuestDataPool.Shared.Return(livePageBuffer);
|
|
}
|
|
|
|
var probe = mappedBytes[..Math.Min(mappedBytes.Length, 256)];
|
|
var nonzero = 0;
|
|
foreach (var value in probe)
|
|
{
|
|
nonzero += value == 0 ? 0 : 1;
|
|
}
|
|
|
|
var firstForRange = _tracedGlobalWritebacks.Count < 256 &&
|
|
_tracedGlobalWritebacks.Add((guestAddress, range.Length));
|
|
var traceSmallMutation = range.Length <= 4096 &&
|
|
_tracedSmallGlobalWritebackEvents++ < 1024;
|
|
var traceLargeMutation = range.Length >= 1024 * 1024 &&
|
|
_tracedLargeGlobalWritebackEvents++ < 256;
|
|
if (firstForRange || traceSmallMutation || traceLargeMutation)
|
|
{
|
|
var head = firstChangedOffset >= 0
|
|
? mappedBytes.Slice(
|
|
firstChangedOffset,
|
|
Math.Min(mappedBytes.Length - firstChangedOffset, 32))
|
|
: ReadOnlySpan<byte>.Empty;
|
|
TraceVulkanShader(
|
|
$"vk.global_writeback base=0x{guestAddress:X16} " +
|
|
$"potential_bytes={mappedBytes.Length} changed_bytes={changedBytes} " +
|
|
$"changed_runs={changedRuns} changed_pages={changedPages} " +
|
|
$"written_pages={writtenPages} written_runs={writtenRuns} " +
|
|
$"unreadable_pages={unreadablePages} " +
|
|
$"fallback_writes={fallbackWrites} failed_runs={failedRuns} " +
|
|
$"probe_nonzero={nonzero}/{probe.Length} " +
|
|
$"changed_head={Convert.ToHexString(head)}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RecordChunkedComputeDispatch(
|
|
CommandBuffer commandBuffer,
|
|
VulkanComputeGuestDispatch work,
|
|
uint zStart,
|
|
uint zCount)
|
|
{
|
|
const uint maxWorkgroupsPerCommand = 4096;
|
|
ulong commandCount = 0;
|
|
var maxXChunk = Math.Max(
|
|
1u,
|
|
Math.Min(
|
|
work.GroupCountX,
|
|
Math.Min(_maxComputeWorkGroupCountX, maxWorkgroupsPerCommand)));
|
|
for (var x = 0u; x < work.GroupCountX;)
|
|
{
|
|
var countX = Math.Min(maxXChunk, work.GroupCountX - x);
|
|
var xyBudget = Math.Max(maxWorkgroupsPerCommand / countX, 1u);
|
|
var maxYChunk = Math.Max(
|
|
1u,
|
|
Math.Min(
|
|
work.GroupCountY,
|
|
Math.Min(_maxComputeWorkGroupCountY, xyBudget)));
|
|
for (var y = 0u; y < work.GroupCountY;)
|
|
{
|
|
var countY = Math.Min(maxYChunk, work.GroupCountY - y);
|
|
var xyzBudget = Math.Max(xyBudget / countY, 1u);
|
|
var maxZChunk = Math.Max(
|
|
1u,
|
|
Math.Min(
|
|
zCount,
|
|
Math.Min(_maxComputeWorkGroupCountZ, xyzBudget)));
|
|
for (var z = 0u; z < zCount;)
|
|
{
|
|
var countZ = Math.Min(maxZChunk, zCount - z);
|
|
_vk.CmdDispatchBase(
|
|
commandBuffer,
|
|
checked(work.BaseGroupX + x),
|
|
checked(work.BaseGroupY + y),
|
|
checked(work.BaseGroupZ + zStart + z),
|
|
countX,
|
|
countY,
|
|
countZ);
|
|
commandCount++;
|
|
z += countZ;
|
|
}
|
|
|
|
y += countY;
|
|
}
|
|
|
|
x += countX;
|
|
}
|
|
|
|
if (commandCount > 1)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.compute_chunked cs=0x{work.ShaderAddress:X16} " +
|
|
$"groups={work.GroupCountX}x{work.GroupCountY}x{work.GroupCountZ} " +
|
|
$"base={work.BaseGroupX}x{work.BaseGroupY}x{work.BaseGroupZ} " +
|
|
$"z_range={zStart}..{zStart + zCount} commands={commandCount} " +
|
|
$"command_budget={maxWorkgroupsPerCommand} " +
|
|
$"device_limit={_maxComputeWorkGroupCountX}x" +
|
|
$"{_maxComputeWorkGroupCountY}x{_maxComputeWorkGroupCountZ}");
|
|
}
|
|
}
|
|
|
|
private void ExecuteOffscreenDraw(VulkanOffscreenGuestDraw work)
|
|
{
|
|
if (_deviceLost || work.Targets.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var perfStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
Interlocked.Increment(ref _perfDrawCount);
|
|
PerfOverlay.RecordDraw();
|
|
try
|
|
{
|
|
ExecuteOffscreenDrawCore(work);
|
|
}
|
|
finally
|
|
{
|
|
// Single atomic add per draw: staging -start/+end separately
|
|
// let the stats window reset land between them and report
|
|
// huge negative draw_ms values.
|
|
Interlocked.Add(
|
|
ref _perfDrawTicks,
|
|
System.Diagnostics.Stopwatch.GetTimestamp() - perfStart);
|
|
}
|
|
}
|
|
|
|
private void ExecuteOffscreenDrawCore(VulkanOffscreenGuestDraw work)
|
|
{
|
|
if (work.Targets.Count > _maxColorAttachments)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan skipped MRT draw requesting {work.Targets.Count} color attachments; " +
|
|
$"the selected device supports {_maxColorAttachments}.");
|
|
ReturnPooledGuestData(work.Draw);
|
|
return;
|
|
}
|
|
|
|
var targetFormats = new VulkanRenderTargetFormat[work.Targets.Count];
|
|
for (var index = 0; index < targetFormats.Length; index++)
|
|
{
|
|
var target = work.Targets[index];
|
|
if (!TryDecodeRenderTargetFormat(target.Format, target.NumberType, out targetFormats[index]) ||
|
|
!SupportsColorAttachment(targetFormats[index].Format))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan skipped MRT draw with unsupported color target " +
|
|
$"format={target.Format} number_type={target.NumberType}.");
|
|
ReturnPooledGuestData(work.Draw);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (work.Draw.RenderState.Blends.Count != targetFormats.Length)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched attachment/blend counts.");
|
|
ReturnPooledGuestData(work.Draw);
|
|
return;
|
|
}
|
|
|
|
var normalizedBlends = GuestBlendStateNormalizer.NormalizeIntegerAttachments(
|
|
work.Draw.RenderState.Blends,
|
|
targetFormats.Select(static format => format.IsInteger).ToArray(),
|
|
out var normalizedBlendCount);
|
|
var draw = normalizedBlendCount == 0
|
|
? work.Draw
|
|
: work.Draw with
|
|
{
|
|
RenderState = work.Draw.RenderState with { Blends = normalizedBlends },
|
|
};
|
|
|
|
if (!_supportsIndependentBlend)
|
|
{
|
|
for (var index = 1; index < draw.RenderState.Blends.Count; index++)
|
|
{
|
|
if (draw.RenderState.Blends[index] !=
|
|
draw.RenderState.Blends[0])
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][WARN] Vulkan skipped MRT draw requiring unsupported independentBlend.");
|
|
ReturnPooledGuestData(work.Draw);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
var formats = new Format[targetFormats.Length];
|
|
for (var index = 0; index < targetFormats.Length; index++)
|
|
{
|
|
formats[index] = targetFormats[index].Format;
|
|
}
|
|
|
|
var hasStorageFeedback = false;
|
|
foreach (var texture in work.Draw.Textures)
|
|
{
|
|
if (!texture.IsStorage || texture.Address == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var target in work.Targets)
|
|
{
|
|
if (target.Address != 0 && target.Address == texture.Address)
|
|
{
|
|
hasStorageFeedback = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hasStorageFeedback)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hasStorageFeedback)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
|
|
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
|
|
"sampled aliases use ordered snapshots");
|
|
ReturnPooledGuestData(work.Draw);
|
|
return;
|
|
}
|
|
|
|
var targets = new GuestImageResource[work.Targets.Count];
|
|
EnsureGuestSubmissionCapacity();
|
|
for (var index = 0; index < targets.Length; index++)
|
|
{
|
|
var targetDescriptor = work.Targets[index].Address == 0 &&
|
|
work.DepthTarget is { } depthOnlyTarget
|
|
? GetDepthOnlyColorTarget(depthOnlyTarget)
|
|
: work.Targets[index];
|
|
targets[index] = GetOrCreateGuestImage(targetDescriptor, formats[index]);
|
|
if (work.Targets[index].Address != 0 &&
|
|
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
|
|
!targets[index].Initialized &&
|
|
(ulong)initialData.Length ==
|
|
GetTextureByteCount(
|
|
targetDescriptor.Format,
|
|
targets[index].Width,
|
|
targets[index].Height))
|
|
{
|
|
UploadGuestImageInitialData(targets[index], initialData);
|
|
}
|
|
}
|
|
|
|
var firstTarget = targets[0];
|
|
TranslatedDrawResources? resources = null;
|
|
CommandBuffer commandBuffer = default;
|
|
var submitted = false;
|
|
RenderPass transientRenderPass = default;
|
|
Framebuffer transientFramebuffer = default;
|
|
try
|
|
{
|
|
var extent = new Extent2D(firstTarget.Width, firstTarget.Height);
|
|
var clearDepthForDraw = draw.RenderState.Depth.ClearEnable;
|
|
if (work.DepthTarget?.ReadOnly == true && draw.RenderState.Depth.WriteEnable)
|
|
{
|
|
draw = draw with
|
|
{
|
|
RenderState = draw.RenderState with
|
|
{
|
|
Depth = draw.RenderState.Depth with { WriteEnable = false },
|
|
},
|
|
};
|
|
}
|
|
GuestDepthResource? depth = null;
|
|
DepthFramebufferResource? depthFramebuffer = null;
|
|
if (ShouldAttachGuestDepth(
|
|
work.DepthTarget,
|
|
draw.RenderState.Depth) &&
|
|
work.DepthTarget is { } depthTarget)
|
|
{
|
|
var resolution = GuestDepthExtentResolver.Resolve(
|
|
depthTarget,
|
|
firstTarget.Width,
|
|
firstTarget.Height,
|
|
draw.Textures);
|
|
var effectiveDepthTarget = resolution.IsUsable &&
|
|
(resolution.Width != depthTarget.Width ||
|
|
resolution.Height != depthTarget.Height)
|
|
? depthTarget with
|
|
{
|
|
Width = resolution.Width,
|
|
Height = resolution.Height,
|
|
}
|
|
: depthTarget;
|
|
|
|
depth = GetOrCreateGuestDepth(effectiveDepthTarget);
|
|
PrepareFirstUseDepth(depth, draw.RenderState.Depth);
|
|
if (clearDepthForDraw)
|
|
{
|
|
depth.GuestClearDepth = effectiveDepthTarget.ClearDepth;
|
|
depth.ClearDepth = effectiveDepthTarget.ClearDepth;
|
|
}
|
|
if (targets.Length == 1)
|
|
{
|
|
depthFramebuffer = GetOrCreateDepthFramebuffer(firstTarget, depth);
|
|
}
|
|
}
|
|
|
|
if (clearDepthForDraw)
|
|
{
|
|
// DB_RENDER_CONTROL.DEPTH_CLEAR_ENABLE makes this a DB
|
|
// clear operation. The draw still produces color, but its
|
|
// interpolated vertex Z is not the guest clear value.
|
|
draw = draw with
|
|
{
|
|
RenderState = draw.RenderState with
|
|
{
|
|
Depth = draw.RenderState.Depth with
|
|
{
|
|
TestEnable = false,
|
|
WriteEnable = false,
|
|
ClearEnable = false,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
var renderPass = depthFramebuffer is null
|
|
? firstTarget.Initialized
|
|
? firstTarget.RenderPass
|
|
: firstTarget.InitialRenderPass
|
|
: firstTarget.Initialized
|
|
? depth!.Initialized && !clearDepthForDraw
|
|
? depthFramebuffer.LoadRenderPass
|
|
: depthFramebuffer.DepthClearRenderPass
|
|
: depth!.Initialized && !clearDepthForDraw
|
|
? depthFramebuffer.ColorClearRenderPass
|
|
: depthFramebuffer.BothClearRenderPass;
|
|
var framebuffer = depthFramebuffer?.Framebuffer ?? firstTarget.Framebuffer;
|
|
if (targets.Length > 1)
|
|
{
|
|
(renderPass, framebuffer) = CreateRenderPassAndFramebuffer(
|
|
formats,
|
|
targets.Select(target => target.MipViews.Length > 0
|
|
? target.MipViews[0]
|
|
: target.View).ToArray(),
|
|
firstTarget.Width,
|
|
firstTarget.Height,
|
|
targets.Select(target =>
|
|
target.Initialized || target.InitialUploadPending).ToArray(),
|
|
depth,
|
|
depth?.Initialized == true && !clearDepthForDraw);
|
|
transientRenderPass = renderPass;
|
|
transientFramebuffer = framebuffer;
|
|
}
|
|
|
|
resources = CreateTranslatedDrawResources(
|
|
draw,
|
|
renderPass,
|
|
formats,
|
|
extent,
|
|
targets,
|
|
hasDepthAttachment: depth is not null,
|
|
feedbackDepth: depth);
|
|
resources.TransientRenderPass = transientRenderPass;
|
|
resources.TransientFramebuffer = transientFramebuffer;
|
|
transientRenderPass = default;
|
|
transientFramebuffer = default;
|
|
resources.DebugName =
|
|
$"SharpEmu offscreen mrt={targets.Length} " +
|
|
$"first=0x{work.Targets[0].Address:X16} " +
|
|
$"{firstTarget.Width}x{firstTarget.Height}";
|
|
|
|
commandBuffer = BeginBatchedGuestCommands();
|
|
_commandBuffer = commandBuffer;
|
|
|
|
// Lifetime: recorded commands reference these resources, so
|
|
// they join the batch before recording and are destroyed only
|
|
// after the batch's fence signals.
|
|
_batchResources.Add(resources);
|
|
submitted = true;
|
|
|
|
BeginDebugLabel(_commandBuffer, resources.DebugName);
|
|
var hasStorageImages = false;
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (texture is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
hasStorageImages |= texture.IsStorage;
|
|
}
|
|
|
|
CloseOpenTranslatedRenderPass();
|
|
RecordGlobalBufferVisibilityBarrier(
|
|
_commandBuffer,
|
|
resources,
|
|
PipelineStageFlags.VertexShaderBit |
|
|
PipelineStageFlags.FragmentShaderBit);
|
|
RecordRenderTargetFeedbackSnapshots(
|
|
resources,
|
|
PipelineStageFlags.FragmentShaderBit);
|
|
RecordDepthFeedbackSnapshots(
|
|
resources,
|
|
PipelineStageFlags.FragmentShaderBit);
|
|
RecordTextureUploads(resources, PipelineStageFlags.FragmentShaderBit);
|
|
RecordStorageImagesForWrite(resources, PipelineStageFlags.FragmentShaderBit);
|
|
|
|
var toColorAttachments = stackalloc ImageMemoryBarrier[targets.Length];
|
|
var anyPriorContents = false;
|
|
for (var index = 0; index < targets.Length; index++)
|
|
{
|
|
var hasPriorContents =
|
|
targets[index].Initialized || targets[index].InitialUploadPending;
|
|
anyPriorContents |= hasPriorContents;
|
|
toColorAttachments[index] = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = hasPriorContents ? AccessFlags.ShaderReadBit : 0,
|
|
DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
|
|
OldLayout = hasPriorContents
|
|
? ImageLayout.ShaderReadOnlyOptimal
|
|
: ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.ColorAttachmentOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = targets[index].Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
}
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
anyPriorContents
|
|
? PipelineStageFlags.AllCommandsBit
|
|
: PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.ColorAttachmentOutputBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
(uint)targets.Length,
|
|
toColorAttachments);
|
|
|
|
if (depth is not null &&
|
|
depth.Layout == ImageLayout.ShaderReadOnlyOptimal)
|
|
{
|
|
var toDepthAttachment = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ShaderReadBit,
|
|
DstAccessMask =
|
|
AccessFlags.DepthStencilAttachmentReadBit |
|
|
AccessFlags.DepthStencilAttachmentWriteBit,
|
|
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
NewLayout = ImageLayout.DepthStencilAttachmentOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = depth.Image,
|
|
SubresourceRange = new ImageSubresourceRange(
|
|
ImageAspectFlags.DepthBit, 0, 1, 0, 1),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.FragmentShaderBit |
|
|
PipelineStageFlags.ComputeShaderBit,
|
|
PipelineStageFlags.EarlyFragmentTestsBit |
|
|
PipelineStageFlags.LateFragmentTestsBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toDepthAttachment);
|
|
}
|
|
|
|
BeginTranslatedRenderPass(
|
|
renderPass,
|
|
framebuffer,
|
|
extent,
|
|
colorAttachmentCount: targets.Length,
|
|
hasDepthAttachment: depth is not null,
|
|
clearDepth: depth?.ClearDepth ?? 1f);
|
|
RecordTranslatedDrawInPass(resources, extent);
|
|
_vk.CmdEndRenderPass(_commandBuffer);
|
|
|
|
var toShaderRead = stackalloc ImageMemoryBarrier[targets.Length];
|
|
for (var index = 0; index < targets.Length; index++)
|
|
{
|
|
toShaderRead[index] = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ColorAttachmentWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.ColorAttachmentOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = targets[index].Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
}
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.ColorAttachmentOutputBit,
|
|
PipelineStageFlags.FragmentShaderBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
(uint)targets.Length,
|
|
toShaderRead);
|
|
|
|
if (hasStorageImages)
|
|
{
|
|
RecordStorageImagesForRead(resources, PipelineStageFlags.FragmentShaderBit);
|
|
}
|
|
|
|
EndDebugLabel(_commandBuffer);
|
|
|
|
var traceImages = GetTraceImages(resources, targets, work.ShaderAddress);
|
|
_batchTraceImages.AddRange(traceImages);
|
|
if (++_batchDrawCount >= 64 ||
|
|
(_traceGuestImageShaderFilterEnabled && traceImages.Count != 0))
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
}
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
target.Initialized = true;
|
|
target.InitialUploadPending = false;
|
|
}
|
|
if (depth is not null)
|
|
{
|
|
depth.Initialized = true;
|
|
depth.Layout = ImageLayout.DepthStencilAttachmentOptimal;
|
|
if (clearDepthForDraw)
|
|
{
|
|
depth.InitializationSource = "guest-depth-clear";
|
|
}
|
|
else if (draw.RenderState.Depth.WriteEnable)
|
|
{
|
|
depth.InitializationSource = "translated-depth-write";
|
|
}
|
|
}
|
|
MarkSampledImagesInitialized(resources);
|
|
MarkStorageImagesInitialized(resources, traceContents: false);
|
|
|
|
if (work.PublishTarget)
|
|
{
|
|
for (var index = 0; index < targets.Length; index++)
|
|
{
|
|
var guestTextureFormat = VulkanVideoPresenter.GetGuestTextureFormat(
|
|
work.Targets[index].Format,
|
|
work.Targets[index].NumberType);
|
|
if (guestTextureFormat == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
_availableGuestImages[targets[index].Address] = guestTextureFormat;
|
|
}
|
|
}
|
|
}
|
|
|
|
var tracePixelSpirv = false;
|
|
if (_tracePixelSpirvBytes > 0 &&
|
|
_tracePixelSpirvBytes == work.Draw.PixelSpirv.Length)
|
|
{
|
|
var pixelWriteCount = _pixelSpirvWriteCounts.TryGetValue(
|
|
_tracePixelSpirvBytes,
|
|
out var previousPixelWriteCount)
|
|
? previousPixelWriteCount + 1
|
|
: 1;
|
|
_pixelSpirvWriteCounts[_tracePixelSpirvBytes] = pixelWriteCount;
|
|
tracePixelSpirv =
|
|
pixelWriteCount == _tracePixelSpirvOccurrence;
|
|
}
|
|
var traceTitleDraw =
|
|
!_tracedTitleDraw &&
|
|
_traceTitleDrawEnabled &&
|
|
IsTitleDraw(work.Draw.VertexBuffers);
|
|
_tracedTitleDraw |= traceTitleDraw;
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
var traceAddressWrite =
|
|
ShouldTraceGuestImageWriteForDiagnostics(target.Address);
|
|
var traceSmallWrites = _traceGuestWritesMode == "small" &&
|
|
target.Width <= 512 && target.Height <= 256;
|
|
var traceLargeWrites =
|
|
(_traceGuestWritesMode == "large" ||
|
|
_traceLargeGuestWriteOrdinal != 0) &&
|
|
target.Width >= 2560 && target.Height >= 1440;
|
|
if (traceAddressWrite || traceSmallWrites ||
|
|
traceLargeWrites || tracePixelSpirv || traceTitleDraw)
|
|
{
|
|
var writeCount = _tracedGuestWriteCounts.TryGetValue(
|
|
target.Address,
|
|
out var previousCount)
|
|
? previousCount + 1
|
|
: 1;
|
|
_tracedGuestWriteCounts[target.Address] = writeCount;
|
|
var shouldTraceWrite = tracePixelSpirv || traceTitleDraw
|
|
? true
|
|
: traceAddressWrite && _traceGuestWriteOrdinal > 0
|
|
? writeCount == _traceGuestWriteOrdinal
|
|
: _traceLargeGuestWriteOrdinal != 0
|
|
? writeCount == _traceLargeGuestWriteOrdinal
|
|
: writeCount <=
|
|
(traceLargeWrites ? 2 : traceSmallWrites ? 48 : 3);
|
|
if (traceAddressWrite || shouldTraceWrite)
|
|
{
|
|
var sampledTextures = string.Join(
|
|
',',
|
|
work.Draw.Textures.Select(texture =>
|
|
$"0x{texture.Address:X}:{texture.Width}x{texture.Height}:" +
|
|
$"f{texture.Format}:n{texture.NumberType}:" +
|
|
$"storage={(texture.IsStorage ? 1 : 0)}"));
|
|
var pixelDigest = Convert.ToHexString(
|
|
SHA256.HashData(work.Draw.PixelSpirv).AsSpan(0, 4));
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.guest_write_sample " +
|
|
$"addr=0x{target.Address:X16} write={writeCount} " +
|
|
$"vs_bytes={work.Draw.VertexSpirv.Length} " +
|
|
$"ps_bytes={work.Draw.PixelSpirv.Length} ps_hash={pixelDigest} " +
|
|
$"vertices={work.Draw.VertexCount} instances={work.Draw.InstanceCount} " +
|
|
$"primitive=0x{work.Draw.PrimitiveType:X} " +
|
|
$"readback={(shouldTraceWrite ? 1 : 0)} textures=[{sampledTextures}]");
|
|
}
|
|
|
|
if (shouldTraceWrite)
|
|
{
|
|
_commandBuffer = _presentationCommandBuffer;
|
|
FlushBatchedGuestCommands();
|
|
Check(
|
|
_vk.QueueWaitIdle(_queue),
|
|
"vkQueueWaitIdle(guest write trace)");
|
|
TraceGuestImageContents(target);
|
|
}
|
|
}
|
|
}
|
|
if (_traceVulkanShaderEnabled)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.offscreen_draw mrt={targets.Length} " +
|
|
$"size={firstTarget.Width}x{firstTarget.Height} " +
|
|
$"textures={work.Draw.Textures.Count}");
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (TryMarkDeviceLost(exception))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_gate)
|
|
{
|
|
foreach (var target in work.Targets)
|
|
{
|
|
if (!_guestImages.TryGetValue(target.Address, out var failedTarget) ||
|
|
!failedTarget.Initialized)
|
|
{
|
|
_availableGuestImages.Remove(target.Address);
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][ERROR] Vulkan offscreen draw failed " +
|
|
$"mrt={work.Targets.Count}: {exception.Message}");
|
|
}
|
|
finally
|
|
{
|
|
_commandBuffer = _presentationCommandBuffer;
|
|
// The command buffer is the shared batch; it is submitted and
|
|
// freed by FlushBatchedGuestCommands. Resources joined the
|
|
// batch list before recording, so only pre-recording failures
|
|
// (submitted still false) own their cleanup here.
|
|
if (!submitted && resources is not null)
|
|
{
|
|
DestroyTranslatedDrawResources(resources);
|
|
}
|
|
|
|
if (transientFramebuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyFramebuffer(_device, transientFramebuffer, null);
|
|
}
|
|
|
|
if (transientRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, transientRenderPass, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private void ExecuteGuestImageWrite(VulkanGuestImageWrite work)
|
|
{
|
|
if (_deviceLost || !_guestImages.TryGetValue(work.Address, out var target))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (work.Pixels is { } pixels)
|
|
{
|
|
if (pixels.Length > 0)
|
|
{
|
|
UploadGuestImageInitialData(target, pixels);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Recorded into the shared batch command buffer: recording order
|
|
// preserves queue-order semantics against earlier batched draws,
|
|
// and the fill no longer costs a submit + full queue drain.
|
|
var commandBuffer = BeginBatchedGuestCommands();
|
|
CloseOpenTranslatedRenderPass();
|
|
var toTransferDst = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = target.Initialized ? AccessFlags.ShaderReadBit : 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = target.Initialized
|
|
? ImageLayout.ShaderReadOnlyOptimal
|
|
: ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = target.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
target.Initialized
|
|
? PipelineStageFlags.FragmentShaderBit
|
|
: PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toTransferDst);
|
|
|
|
var clearValue = new ClearColorValue(
|
|
(work.FillValue & 0xFF) / 255f,
|
|
((work.FillValue >> 8) & 0xFF) / 255f,
|
|
((work.FillValue >> 16) & 0xFF) / 255f,
|
|
((work.FillValue >> 24) & 0xFF) / 255f);
|
|
var range = ColorSubresourceRange(0, target.MipLevels);
|
|
_vk.CmdClearColorImage(
|
|
commandBuffer,
|
|
target.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
&clearValue,
|
|
1,
|
|
&range);
|
|
|
|
var toShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = target.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.FragmentShaderBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toShaderRead);
|
|
target.Initialized = true;
|
|
}
|
|
|
|
private void UploadGuestImageInitialData(GuestImageResource target, byte[] pixels)
|
|
{
|
|
var guestDataFormat = (target.GuestFormat & 0x8000_0000u) != 0
|
|
? (target.GuestFormat >> 8) & 0x1FFu
|
|
: 0;
|
|
var uploadPixels = guestDataFormat == 13
|
|
? ExpandRgb32Pixels(pixels)
|
|
: pixels;
|
|
var expectedByteCount = GetVulkanImageByteCount(
|
|
target.Format,
|
|
target.Width,
|
|
target.Height);
|
|
if (expectedByteCount == 0 || (ulong)uploadPixels.Length != expectedByteCount)
|
|
{
|
|
if (_rejectedGuestImageUploads.Add(
|
|
(target.Address, uploadPixels.Length, expectedByteCount, target.Format)))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan rejected incompatible guest image upload " +
|
|
$"addr=0x{target.Address:X16} size={target.Width}x{target.Height} " +
|
|
$"format={target.Format} bytes={uploadPixels.Length} expected={expectedByteCount}");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var byteCount = (ulong)uploadPixels.Length;
|
|
var staging = CreateBuffer(
|
|
byteCount,
|
|
BufferUsageFlags.TransferSrcBit,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out var stagingMemory);
|
|
try
|
|
{
|
|
void* mapped;
|
|
Check(
|
|
_vk.MapMemory(_device, stagingMemory, 0, byteCount, 0, &mapped),
|
|
"vkMapMemory(guest image init)");
|
|
fixed (byte* source = uploadPixels)
|
|
{
|
|
System.Buffer.MemoryCopy(
|
|
source,
|
|
mapped,
|
|
uploadPixels.Length,
|
|
uploadPixels.Length);
|
|
}
|
|
|
|
_vk.UnmapMemory(_device, stagingMemory);
|
|
|
|
// Recorded into the shared batch; the staging buffer joins
|
|
// the batch's retire list and is destroyed when the batch
|
|
// fence signals, so the upload costs no queue drain.
|
|
var commandBuffer = BeginBatchedGuestCommands();
|
|
CloseOpenTranslatedRenderPass();
|
|
|
|
var toTransferDst = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = target.Initialized ? AccessFlags.ShaderReadBit : 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = target.Initialized
|
|
? ImageLayout.ShaderReadOnlyOptimal
|
|
: ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = target.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
target.Initialized
|
|
? PipelineStageFlags.FragmentShaderBit
|
|
: PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toTransferDst);
|
|
|
|
var copyRegion = new BufferImageCopy
|
|
{
|
|
BufferOffset = 0,
|
|
BufferRowLength = 0,
|
|
BufferImageHeight = 0,
|
|
ImageSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit,
|
|
0,
|
|
0,
|
|
1),
|
|
ImageOffset = default,
|
|
ImageExtent = new Extent3D(target.Width, target.Height, 1),
|
|
};
|
|
_vk.CmdCopyBufferToImage(
|
|
commandBuffer,
|
|
staging,
|
|
target.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©Region);
|
|
|
|
var toShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = target.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, target.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.FragmentShaderBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toShaderRead);
|
|
|
|
target.Initialized = true;
|
|
_batchRetireBuffers.Add((staging, stagingMemory));
|
|
staging = default;
|
|
stagingMemory = default;
|
|
if (_traceGuestImageEvents)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[GIMG] seeded addr=0x{target.Address:X} " +
|
|
$"{target.Width}x{target.Height}");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (staging.Handle != 0)
|
|
{
|
|
_vk.DestroyBuffer(_device, staging, null);
|
|
}
|
|
|
|
if (stagingMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, stagingMemory, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
private GuestImageResource GetOrCreateGuestImage(
|
|
GuestRenderTarget target,
|
|
Format format,
|
|
bool requiresStorage = false)
|
|
{
|
|
var supportsStorageUsage = SupportsStorageImage(format);
|
|
if (requiresStorage && !supportsStorageUsage)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Storage image format {format} is unsupported for guest " +
|
|
$"address 0x{target.Address:X16}.");
|
|
}
|
|
|
|
var mipLevels = ClampMipLevels(target.Width, target.Height, target.MipLevels);
|
|
var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType);
|
|
var requestedKey = new GuestImageVariantKey(
|
|
target.Address,
|
|
target.Width,
|
|
target.Height,
|
|
mipLevels,
|
|
guestFormat,
|
|
format);
|
|
if (_guestImages.TryGetValue(target.Address, out var existing))
|
|
{
|
|
if (existing.Width == target.Width &&
|
|
existing.Height == target.Height &&
|
|
existing.MipLevels == mipLevels &&
|
|
existing.GuestFormat == guestFormat &&
|
|
existing.Format == format)
|
|
{
|
|
if (requiresStorage && !existing.SupportsStorageUsage)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Guest image 0x{target.Address:X16} was created without storage usage.");
|
|
}
|
|
|
|
existing.IsCpuBacked = false;
|
|
existing.CpuContentFingerprint = 0;
|
|
if (existing.RenderPass.Handle == 0 && !requiresStorage)
|
|
{
|
|
var attachmentView = existing.MipViews.Length > 0
|
|
? existing.MipViews[0]
|
|
: existing.View;
|
|
var promoted = CreateRenderPassAndFramebuffer(
|
|
existing.Format,
|
|
attachmentView,
|
|
existing.Width,
|
|
existing.Height);
|
|
existing.RenderPass = promoted.RenderPass;
|
|
existing.InitialRenderPass = promoted.InitialRenderPass;
|
|
existing.Framebuffer = promoted.Framebuffer;
|
|
var promotedRenderPass = promoted.RenderPass;
|
|
var promotedInitialRenderPass = promoted.InitialRenderPass;
|
|
var promotedFramebuffer = promoted.Framebuffer;
|
|
var promotedName = GuestImageDebugName(target, format);
|
|
SetDebugName(ObjectType.RenderPass, promotedRenderPass.Handle, $"{promotedName} renderpass");
|
|
SetDebugName(ObjectType.RenderPass, promotedInitialRenderPass.Handle, $"{promotedName} initial-renderpass");
|
|
SetDebugName(ObjectType.Framebuffer, promotedFramebuffer.Handle, $"{promotedName} framebuffer");
|
|
}
|
|
|
|
return existing;
|
|
}
|
|
|
|
if (_traceGuestImageEvents)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[GIMG] recreate addr=0x{target.Address:X} " +
|
|
$"old={existing.Width}x{existing.Height}/{existing.Format}/m{existing.MipLevels} " +
|
|
$"new={target.Width}x{target.Height}/{format}/m{mipLevels} " +
|
|
$"initialized={existing.Initialized}");
|
|
}
|
|
|
|
_guestImageVariants.Add(
|
|
new GuestImageVariantKey(
|
|
existing.Address,
|
|
existing.Width,
|
|
existing.Height,
|
|
existing.MipLevels,
|
|
existing.GuestFormat,
|
|
existing.Format),
|
|
existing);
|
|
_guestImages.Remove(target.Address);
|
|
lock (_gate)
|
|
{
|
|
_availableGuestImages.Remove(target.Address);
|
|
_guestImageExtents.Remove(target.Address);
|
|
}
|
|
|
|
// Address-filtered readback diagnostics should inspect the
|
|
// replacement image too; a resized/reformatted allocation at
|
|
// the same guest address is a different piece of evidence.
|
|
_tracedGuestImageContents.Remove(target.Address);
|
|
|
|
SharpEmu.HLE.GuestImageWriteTracker.Untrack(target.Address);
|
|
}
|
|
|
|
if (_guestImageVariants.Remove(requestedKey, out var retained))
|
|
{
|
|
if (requiresStorage && !retained.SupportsStorageUsage)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Retained guest image 0x{target.Address:X16} was created without storage usage.");
|
|
}
|
|
|
|
retained.IsCpuBacked = false;
|
|
retained.CpuContentFingerprint = 0;
|
|
_guestImages.Add(target.Address, retained);
|
|
lock (_gate)
|
|
{
|
|
_guestImageExtents[target.Address] = (
|
|
target.Width,
|
|
target.Height,
|
|
GetTextureByteCount(target.Format, target.Width, target.Height));
|
|
}
|
|
|
|
if (target.Width <= 1920 && target.Height <= 1080)
|
|
{
|
|
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
|
target.Address,
|
|
(ulong)target.Width * target.Height * GetTextureBytesPerPixel(target.Format),
|
|
CurrentGuestWorkSequenceForDiagnostics,
|
|
"vulkan.render-target");
|
|
}
|
|
|
|
if (_traceGuestImageEvents)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[GIMG] retained addr=0x{target.Address:X} " +
|
|
$"{target.Width}x{target.Height} fmt={format} " +
|
|
$"initialized={retained.Initialized}");
|
|
}
|
|
|
|
return retained;
|
|
}
|
|
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
Flags =
|
|
ImageCreateFlags.CreateMutableFormatBit |
|
|
ImageCreateFlags.CreateExtendedUsageBit,
|
|
ImageType = ImageType.Type2D,
|
|
Format = format,
|
|
Extent = new Extent3D(target.Width, target.Height, 1),
|
|
MipLevels = mipLevels,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage =
|
|
ImageUsageFlags.ColorAttachmentBit |
|
|
ImageUsageFlags.SampledBit |
|
|
(supportsStorageUsage ? ImageUsageFlags.StorageBit : (ImageUsageFlags)0) |
|
|
ImageUsageFlags.TransferSrcBit |
|
|
ImageUsageFlags.TransferDstBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(_vk.CreateImage(_device, &imageInfo, null, out var image), "vkCreateImage(offscreen)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
|
var allocationInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(
|
|
requirements.MemoryTypeBits,
|
|
MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(
|
|
_vk.AllocateMemory(_device, &allocationInfo, null, out var memory),
|
|
"vkAllocateMemory(offscreen)");
|
|
Check(_vk.BindImageMemory(_device, image, memory, 0), "vkBindImageMemory(offscreen)");
|
|
// Rendering and uploads only define the mips they touch; define the whole
|
|
// chain once so full-chain sampled binds never read Undefined layout.
|
|
TransitionNewGuestImageToSampled(image, mipLevels);
|
|
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = format,
|
|
Components = new ComponentMapping(
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity,
|
|
ComponentSwizzle.Identity),
|
|
SubresourceRange = ColorSubresourceRange(0, mipLevels),
|
|
};
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out var view),
|
|
"vkCreateImageView(offscreen)");
|
|
|
|
var mipViews = new ImageView[mipLevels];
|
|
for (uint mipLevel = 0; mipLevel < mipLevels; mipLevel++)
|
|
{
|
|
viewInfo.SubresourceRange = ColorSubresourceRange(mipLevel, 1);
|
|
ImageView mipView;
|
|
Check(
|
|
_vk.CreateImageView(
|
|
_device,
|
|
&viewInfo,
|
|
null,
|
|
out mipView),
|
|
"vkCreateImageView(offscreen mip)");
|
|
mipViews[mipLevel] = mipView;
|
|
}
|
|
|
|
RenderPass renderPass = default;
|
|
RenderPass initialRenderPass = default;
|
|
Framebuffer framebuffer = default;
|
|
if (!requiresStorage)
|
|
{
|
|
(renderPass, initialRenderPass, framebuffer) =
|
|
CreateRenderPassAndFramebuffer(
|
|
format,
|
|
mipViews[0],
|
|
target.Width,
|
|
target.Height);
|
|
}
|
|
|
|
var resource = new GuestImageResource
|
|
{
|
|
Address = target.Address,
|
|
Width = target.Width,
|
|
Height = target.Height,
|
|
MipLevels = mipLevels,
|
|
GuestFormat = guestFormat,
|
|
Format = format,
|
|
Image = image,
|
|
Memory = memory,
|
|
View = view,
|
|
MipViews = mipViews,
|
|
RenderPass = renderPass,
|
|
InitialRenderPass = initialRenderPass,
|
|
Framebuffer = framebuffer,
|
|
SupportsStorageUsage = supportsStorageUsage,
|
|
};
|
|
var debugName = GuestImageDebugName(target, format);
|
|
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
|
|
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
|
|
for (var mipLevel = 0; mipLevel < mipViews.Length; mipLevel++)
|
|
{
|
|
SetDebugName(
|
|
ObjectType.ImageView,
|
|
mipViews[mipLevel].Handle,
|
|
$"{debugName} mip{mipLevel}");
|
|
}
|
|
if (renderPass.Handle != 0)
|
|
{
|
|
SetDebugName(ObjectType.RenderPass, renderPass.Handle, $"{debugName} renderpass");
|
|
SetDebugName(ObjectType.RenderPass, initialRenderPass.Handle, $"{debugName} initial-renderpass");
|
|
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
|
|
}
|
|
_guestImages.Add(target.Address, resource);
|
|
lock (_gate)
|
|
{
|
|
_guestImageExtents[target.Address] = (
|
|
target.Width,
|
|
target.Height,
|
|
GetTextureByteCount(target.Format, target.Width, target.Height));
|
|
}
|
|
|
|
if (target.Width <= 1920 && target.Height <= 1080)
|
|
{
|
|
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
|
target.Address,
|
|
(ulong)target.Width * target.Height * GetTextureBytesPerPixel(target.Format),
|
|
CurrentGuestWorkSequenceForDiagnostics,
|
|
"vulkan.render-target");
|
|
}
|
|
|
|
if (_traceGuestImageEvents)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[GIMG] created-as-rt addr=0x{target.Address:X} " +
|
|
$"{target.Width}x{target.Height} fmt={format}");
|
|
}
|
|
|
|
return resource;
|
|
}
|
|
|
|
private (RenderPass RenderPass, RenderPass InitialRenderPass, Framebuffer Framebuffer)
|
|
CreateRenderPassAndFramebuffer(
|
|
Format format,
|
|
ImageView attachmentView,
|
|
uint width,
|
|
uint height)
|
|
{
|
|
var load = CreateRenderPassAndFramebuffer(
|
|
[format], [attachmentView], width, height, [true], null, false);
|
|
var initial = CreateRenderPassAndFramebuffer(
|
|
[format], [attachmentView], width, height, [false], null, false);
|
|
_vk.DestroyFramebuffer(_device, initial.Framebuffer, null);
|
|
return (load.RenderPass, initial.RenderPass, load.Framebuffer);
|
|
}
|
|
|
|
private (RenderPass RenderPass, Framebuffer Framebuffer) CreateRenderPassAndFramebuffer(
|
|
IReadOnlyList<Format> formats,
|
|
IReadOnlyList<ImageView> attachmentViews,
|
|
uint width,
|
|
uint height) =>
|
|
CreateRenderPassAndFramebuffer(
|
|
formats,
|
|
attachmentViews,
|
|
width,
|
|
height,
|
|
Enumerable.Repeat(true, formats.Count).ToArray(),
|
|
null,
|
|
false);
|
|
|
|
private (RenderPass RenderPass, Framebuffer Framebuffer) CreateRenderPassAndFramebuffer(
|
|
IReadOnlyList<Format> formats,
|
|
IReadOnlyList<ImageView> attachmentViews,
|
|
uint width,
|
|
uint height,
|
|
IReadOnlyList<bool> initialized,
|
|
GuestDepthResource? depth,
|
|
bool depthInitialized)
|
|
{
|
|
if (formats.Count == 0 ||
|
|
formats.Count != attachmentViews.Count ||
|
|
formats.Count != initialized.Count)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"render target formats, views, and initialization states must have matching counts");
|
|
}
|
|
|
|
var attachmentCount = formats.Count + (depth is null ? 0 : 1);
|
|
var attachments = stackalloc AttachmentDescription[attachmentCount];
|
|
var colorReferences = stackalloc AttachmentReference[formats.Count];
|
|
var views = stackalloc ImageView[attachmentCount];
|
|
for (var index = 0; index < formats.Count; index++)
|
|
{
|
|
attachments[index] = new AttachmentDescription
|
|
{
|
|
Format = formats[index],
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
LoadOp = initialized[index]
|
|
? AttachmentLoadOp.Load
|
|
: AttachmentLoadOp.Clear,
|
|
StoreOp = AttachmentStoreOp.Store,
|
|
StencilLoadOp = AttachmentLoadOp.DontCare,
|
|
StencilStoreOp = AttachmentStoreOp.DontCare,
|
|
InitialLayout = ImageLayout.ColorAttachmentOptimal,
|
|
FinalLayout = ImageLayout.ColorAttachmentOptimal,
|
|
};
|
|
colorReferences[index] = new AttachmentReference
|
|
{
|
|
Attachment = (uint)index,
|
|
Layout = ImageLayout.ColorAttachmentOptimal,
|
|
};
|
|
views[index] = attachmentViews[index];
|
|
}
|
|
|
|
AttachmentReference depthReference = default;
|
|
if (depth is not null)
|
|
{
|
|
attachments[formats.Count] = new AttachmentDescription
|
|
{
|
|
Format = DepthFormat,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
LoadOp = depthInitialized
|
|
? AttachmentLoadOp.Load
|
|
: AttachmentLoadOp.Clear,
|
|
StoreOp = AttachmentStoreOp.Store,
|
|
StencilLoadOp = AttachmentLoadOp.DontCare,
|
|
StencilStoreOp = AttachmentStoreOp.DontCare,
|
|
InitialLayout = depthInitialized
|
|
? ImageLayout.DepthStencilAttachmentOptimal
|
|
: ImageLayout.Undefined,
|
|
FinalLayout = ImageLayout.DepthStencilAttachmentOptimal,
|
|
};
|
|
depthReference = new AttachmentReference
|
|
{
|
|
Attachment = (uint)formats.Count,
|
|
Layout = ImageLayout.DepthStencilAttachmentOptimal,
|
|
};
|
|
views[formats.Count] = depth.View;
|
|
}
|
|
|
|
var subpass = new SubpassDescription
|
|
{
|
|
PipelineBindPoint = PipelineBindPoint.Graphics,
|
|
ColorAttachmentCount = (uint)formats.Count,
|
|
PColorAttachments = colorReferences,
|
|
PDepthStencilAttachment = depth is null ? null : &depthReference,
|
|
};
|
|
var renderPassInfo = new RenderPassCreateInfo
|
|
{
|
|
SType = StructureType.RenderPassCreateInfo,
|
|
AttachmentCount = (uint)attachmentCount,
|
|
PAttachments = attachments,
|
|
SubpassCount = 1,
|
|
PSubpasses = &subpass,
|
|
};
|
|
Check(
|
|
_vk.CreateRenderPass(_device, &renderPassInfo, null, out var renderPass),
|
|
"vkCreateRenderPass(offscreen)");
|
|
|
|
var framebufferInfo = new FramebufferCreateInfo
|
|
{
|
|
SType = StructureType.FramebufferCreateInfo,
|
|
RenderPass = renderPass,
|
|
AttachmentCount = (uint)attachmentCount,
|
|
PAttachments = views,
|
|
Width = width,
|
|
Height = height,
|
|
Layers = 1,
|
|
};
|
|
Check(
|
|
_vk.CreateFramebuffer(_device, &framebufferInfo, null, out var framebuffer),
|
|
"vkCreateFramebuffer(offscreen)");
|
|
|
|
return (renderPass, framebuffer);
|
|
}
|
|
|
|
private (Image Image, DeviceMemory Memory, ImageView View) CreateDepthAttachment(
|
|
uint width,
|
|
uint height)
|
|
{
|
|
var imageInfo = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
ImageType = ImageType.Type2D,
|
|
Format = DepthFormat,
|
|
Extent = new Extent3D(Math.Max(width, 1), Math.Max(height, 1), 1),
|
|
MipLevels = 1,
|
|
ArrayLayers = 1,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage =
|
|
ImageUsageFlags.DepthStencilAttachmentBit |
|
|
ImageUsageFlags.SampledBit |
|
|
ImageUsageFlags.TransferSrcBit |
|
|
ImageUsageFlags.TransferDstBit,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
Check(_vk.CreateImage(_device, &imageInfo, null, out var image), "vkCreateImage(depth)");
|
|
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
|
var memoryInfo = new MemoryAllocateInfo
|
|
{
|
|
SType = StructureType.MemoryAllocateInfo,
|
|
AllocationSize = requirements.Size,
|
|
MemoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit),
|
|
};
|
|
Check(_vk.AllocateMemory(_device, &memoryInfo, null, out var memory), "vkAllocateMemory(depth)");
|
|
Check(_vk.BindImageMemory(_device, image, memory, 0), "vkBindImageMemory(depth)");
|
|
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = DepthFormat,
|
|
SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.DepthBit, 0, 1, 0, 1),
|
|
};
|
|
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(depth)");
|
|
return (image, memory, view);
|
|
}
|
|
|
|
private GuestDepthResource GetOrCreateGuestDepth(GuestDepthTarget target)
|
|
{
|
|
var key = new GuestDepthKey(
|
|
target.Address,
|
|
target.ReadAddress,
|
|
target.Width,
|
|
target.Height,
|
|
target.GuestFormat,
|
|
target.SwizzleMode);
|
|
if (_guestDepthImages.TryGetValue(key, out var existing))
|
|
{
|
|
existing.GuestClearDepth = target.ClearDepth;
|
|
if (!existing.Initialized && existing.InitializationSource == "none")
|
|
{
|
|
existing.ClearDepth = target.ClearDepth;
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
var (image, memory, view) = CreateDepthAttachment(target.Width, target.Height);
|
|
var resource = new GuestDepthResource
|
|
{
|
|
Key = key,
|
|
Address = target.Address,
|
|
ReadAddress = target.ReadAddress,
|
|
WriteAddress = target.WriteAddress,
|
|
Width = target.Width,
|
|
Height = target.Height,
|
|
GuestFormat = target.GuestFormat,
|
|
SwizzleMode = target.SwizzleMode,
|
|
Image = image,
|
|
Memory = memory,
|
|
View = view,
|
|
GuestClearDepth = target.ClearDepth,
|
|
ClearDepth = target.ClearDepth,
|
|
};
|
|
SetDebugName(
|
|
ObjectType.Image,
|
|
image.Handle,
|
|
$"SharpEmu guest depth 0x{target.Address:X16} {target.Width}x{target.Height}");
|
|
SetDebugName(
|
|
ObjectType.ImageView,
|
|
view.Handle,
|
|
$"SharpEmu guest depth view 0x{target.Address:X16}");
|
|
_guestDepthImages.Add(key, resource);
|
|
if (_traceGuestImageEvents || _traceVulkanShaderEnabled)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[GIMG] created-depth addr=0x{target.Address:X} " +
|
|
$"read=0x{target.ReadAddress:X} write=0x{target.WriteAddress:X} " +
|
|
$"{target.Width}x{target.Height} zfmt={target.GuestFormat} " +
|
|
$"sw={target.SwizzleMode} clear={target.ClearDepth:0.######}");
|
|
}
|
|
|
|
return resource;
|
|
}
|
|
|
|
private static void PrepareFirstUseDepth(
|
|
GuestDepthResource depth,
|
|
GuestDepthState state)
|
|
{
|
|
if (depth.Initialized || depth.InitializationSource != "none")
|
|
{
|
|
return;
|
|
}
|
|
|
|
var effectiveClear = depth.GuestClearDepth;
|
|
var source = "guest-clear";
|
|
if (state.TestEnable && !state.WriteEnable)
|
|
{
|
|
switch (state.CompareOp)
|
|
{
|
|
case 1: // Less
|
|
case 3: // LessOrEqual
|
|
effectiveClear = 1f;
|
|
source = "neutral-first-use";
|
|
break;
|
|
case 4: // Greater
|
|
case 6: // GreaterOrEqual
|
|
effectiveClear = 0f;
|
|
source = "neutral-first-use";
|
|
break;
|
|
case 7: // Always
|
|
break;
|
|
default: // Never, Equal, NotEqual
|
|
source = "guest-clear-ambiguous";
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] Vulkan has no neutral first-use depth clear " +
|
|
$"addr=0x{depth.Address:X16} compare={state.CompareOp}; " +
|
|
$"using guest clear {depth.GuestClearDepth:0.######}.");
|
|
break;
|
|
}
|
|
}
|
|
|
|
depth.ClearDepth = effectiveClear;
|
|
depth.InitializationSource = source;
|
|
if (_traceDepthInitialization)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.depth_init " +
|
|
$"addr=0x{depth.Address:X16} size={depth.Width}x{depth.Height} " +
|
|
$"source={source} guest_clear={depth.GuestClearDepth:0.######} " +
|
|
$"effective_clear={effectiveClear:0.######} compare={state.CompareOp} " +
|
|
$"test={(state.TestEnable ? 1 : 0)} write={(state.WriteEnable ? 1 : 0)} " +
|
|
$"initialized=0");
|
|
}
|
|
}
|
|
|
|
private GuestRenderTarget GetDepthOnlyColorTarget(GuestDepthTarget depth)
|
|
{
|
|
var key = new GuestDepthKey(
|
|
depth.Address,
|
|
depth.ReadAddress,
|
|
depth.Width,
|
|
depth.Height,
|
|
depth.GuestFormat,
|
|
depth.SwizzleMode);
|
|
if (!_depthOnlyColorAddresses.TryGetValue(key, out var address))
|
|
{
|
|
address = _nextDepthOnlyColorAddress;
|
|
_nextDepthOnlyColorAddress = checked(_nextDepthOnlyColorAddress + 0x1000_0000UL);
|
|
_depthOnlyColorAddresses.Add(key, address);
|
|
}
|
|
|
|
// The translated fragment module still declares a color output,
|
|
// even for a guest depth-only pass. A private, never-published
|
|
// color attachment keeps that output legal while the persistent
|
|
// guest DB surface remains the only observable result.
|
|
return new GuestRenderTarget(
|
|
address,
|
|
depth.Width,
|
|
depth.Height,
|
|
Format: 10,
|
|
NumberType: 0);
|
|
}
|
|
|
|
private DepthFramebufferResource GetOrCreateDepthFramebuffer(
|
|
GuestImageResource color,
|
|
GuestDepthResource depth)
|
|
{
|
|
if (color.DepthFramebuffers.TryGetValue(depth.Key, out var existing))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
if (depth.Width < color.Width || depth.Height < color.Height)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"guest depth 0x{depth.Address:X16} extent {depth.Width}x{depth.Height} " +
|
|
$"is smaller than color target 0x{color.Address:X16} " +
|
|
$"{color.Width}x{color.Height}");
|
|
}
|
|
|
|
var attachmentView = color.MipViews.Length > 0 ? color.MipViews[0] : color.View;
|
|
var loadRenderPass = CreateDepthRenderPass(
|
|
color.Format,
|
|
clearColor: false,
|
|
clearDepth: false);
|
|
var colorClearRenderPass = CreateDepthRenderPass(
|
|
color.Format,
|
|
clearColor: true,
|
|
clearDepth: false);
|
|
var depthClearRenderPass = CreateDepthRenderPass(
|
|
color.Format,
|
|
clearColor: false,
|
|
clearDepth: true);
|
|
var bothClearRenderPass = CreateDepthRenderPass(
|
|
color.Format,
|
|
clearColor: true,
|
|
clearDepth: true);
|
|
var attachments = stackalloc ImageView[2];
|
|
attachments[0] = attachmentView;
|
|
attachments[1] = depth.View;
|
|
var framebufferInfo = new FramebufferCreateInfo
|
|
{
|
|
SType = StructureType.FramebufferCreateInfo,
|
|
RenderPass = loadRenderPass,
|
|
AttachmentCount = 2,
|
|
PAttachments = attachments,
|
|
Width = color.Width,
|
|
Height = color.Height,
|
|
Layers = 1,
|
|
};
|
|
Check(
|
|
_vk.CreateFramebuffer(_device, &framebufferInfo, null, out var framebuffer),
|
|
"vkCreateFramebuffer(offscreen depth)");
|
|
var resource = new DepthFramebufferResource
|
|
{
|
|
Depth = depth,
|
|
LoadRenderPass = loadRenderPass,
|
|
ColorClearRenderPass = colorClearRenderPass,
|
|
DepthClearRenderPass = depthClearRenderPass,
|
|
BothClearRenderPass = bothClearRenderPass,
|
|
Framebuffer = framebuffer,
|
|
};
|
|
var name = $"SharpEmu color 0x{color.Address:X16} depth 0x{depth.Address:X16}";
|
|
SetDebugName(ObjectType.RenderPass, loadRenderPass.Handle, $"{name} load");
|
|
SetDebugName(ObjectType.RenderPass, colorClearRenderPass.Handle, $"{name} color-clear");
|
|
SetDebugName(ObjectType.RenderPass, depthClearRenderPass.Handle, $"{name} depth-clear");
|
|
SetDebugName(ObjectType.RenderPass, bothClearRenderPass.Handle, $"{name} both-clear");
|
|
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{name} framebuffer");
|
|
color.DepthFramebuffers.Add(depth.Key, resource);
|
|
return resource;
|
|
}
|
|
|
|
private RenderPass CreateDepthRenderPass(
|
|
Format colorFormat,
|
|
bool clearColor,
|
|
bool clearDepth)
|
|
{
|
|
var attachments = stackalloc AttachmentDescription[2];
|
|
attachments[0] = new AttachmentDescription
|
|
{
|
|
Format = colorFormat,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
LoadOp = clearColor ? AttachmentLoadOp.Clear : AttachmentLoadOp.Load,
|
|
StoreOp = AttachmentStoreOp.Store,
|
|
StencilLoadOp = AttachmentLoadOp.DontCare,
|
|
StencilStoreOp = AttachmentStoreOp.DontCare,
|
|
InitialLayout = ImageLayout.ColorAttachmentOptimal,
|
|
FinalLayout = ImageLayout.ColorAttachmentOptimal,
|
|
};
|
|
attachments[1] = new AttachmentDescription
|
|
{
|
|
Format = DepthFormat,
|
|
Samples = SampleCountFlags.Count1Bit,
|
|
LoadOp = clearDepth ? AttachmentLoadOp.Clear : AttachmentLoadOp.Load,
|
|
StoreOp = AttachmentStoreOp.Store,
|
|
StencilLoadOp = AttachmentLoadOp.DontCare,
|
|
StencilStoreOp = AttachmentStoreOp.DontCare,
|
|
InitialLayout = clearDepth
|
|
? ImageLayout.Undefined
|
|
: ImageLayout.DepthStencilAttachmentOptimal,
|
|
FinalLayout = ImageLayout.DepthStencilAttachmentOptimal,
|
|
};
|
|
var colorReference = new AttachmentReference
|
|
{
|
|
Attachment = 0,
|
|
Layout = ImageLayout.ColorAttachmentOptimal,
|
|
};
|
|
var depthReference = new AttachmentReference
|
|
{
|
|
Attachment = 1,
|
|
Layout = ImageLayout.DepthStencilAttachmentOptimal,
|
|
};
|
|
var subpass = new SubpassDescription
|
|
{
|
|
PipelineBindPoint = PipelineBindPoint.Graphics,
|
|
ColorAttachmentCount = 1,
|
|
PColorAttachments = &colorReference,
|
|
PDepthStencilAttachment = &depthReference,
|
|
};
|
|
var dependency = new SubpassDependency
|
|
{
|
|
SrcSubpass = Vk.SubpassExternal,
|
|
DstSubpass = 0,
|
|
SrcStageMask = PipelineStageFlags.LateFragmentTestsBit,
|
|
DstStageMask =
|
|
PipelineStageFlags.EarlyFragmentTestsBit |
|
|
PipelineStageFlags.LateFragmentTestsBit,
|
|
SrcAccessMask = AccessFlags.DepthStencilAttachmentWriteBit,
|
|
DstAccessMask =
|
|
AccessFlags.DepthStencilAttachmentReadBit |
|
|
AccessFlags.DepthStencilAttachmentWriteBit,
|
|
DependencyFlags = DependencyFlags.ByRegionBit,
|
|
};
|
|
var createInfo = new RenderPassCreateInfo
|
|
{
|
|
SType = StructureType.RenderPassCreateInfo,
|
|
AttachmentCount = 2,
|
|
PAttachments = attachments,
|
|
SubpassCount = 1,
|
|
PSubpasses = &subpass,
|
|
DependencyCount = 1,
|
|
PDependencies = &dependency,
|
|
};
|
|
Check(
|
|
_vk.CreateRenderPass(_device, &createInfo, null, out var renderPass),
|
|
"vkCreateRenderPass(offscreen depth)");
|
|
return renderPass;
|
|
}
|
|
|
|
private static uint ClampMipLevels(uint width, uint height, uint requestedMipLevels)
|
|
{
|
|
var largestDimension = Math.Max(width, height);
|
|
uint maximumMipLevels = 1;
|
|
while (largestDimension > 1)
|
|
{
|
|
largestDimension >>= 1;
|
|
maximumMipLevels++;
|
|
}
|
|
|
|
return Math.Min(Math.Max(requestedMipLevels, 1u), maximumMipLevels);
|
|
}
|
|
|
|
private void DestroyGuestImage(GuestImageResource resource)
|
|
{
|
|
foreach (var depthFramebuffer in resource.DepthFramebuffers.Values)
|
|
{
|
|
DestroyDepthFramebuffer(depthFramebuffer);
|
|
}
|
|
resource.DepthFramebuffers.Clear();
|
|
|
|
foreach (var view in resource.FormatViews.Values)
|
|
{
|
|
if (view.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, view, null);
|
|
}
|
|
}
|
|
resource.FormatViews.Clear();
|
|
|
|
if (resource.Framebuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyFramebuffer(_device, resource.Framebuffer, null);
|
|
}
|
|
|
|
if (resource.RenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resource.RenderPass, null);
|
|
}
|
|
|
|
if (resource.InitialRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resource.InitialRenderPass, null);
|
|
}
|
|
|
|
if (resource.View.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, resource.View, null);
|
|
}
|
|
|
|
foreach (var mipView in resource.MipViews)
|
|
{
|
|
if (mipView.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, mipView, null);
|
|
}
|
|
}
|
|
|
|
if (resource.Image.Handle != 0)
|
|
{
|
|
_vk.DestroyImage(_device, resource.Image, null);
|
|
}
|
|
|
|
if (resource.Memory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, resource.Memory, null);
|
|
}
|
|
|
|
}
|
|
|
|
private void DestroyDepthFramebuffer(DepthFramebufferResource resource)
|
|
{
|
|
if (resource.Framebuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyFramebuffer(_device, resource.Framebuffer, null);
|
|
}
|
|
|
|
if (resource.LoadRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resource.LoadRenderPass, null);
|
|
}
|
|
if (resource.ColorClearRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resource.ColorClearRenderPass, null);
|
|
}
|
|
if (resource.DepthClearRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resource.DepthClearRenderPass, null);
|
|
}
|
|
if (resource.BothClearRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resource.BothClearRenderPass, null);
|
|
}
|
|
}
|
|
|
|
private void DestroyGuestDepth(GuestDepthResource resource)
|
|
{
|
|
foreach (var sampleView in resource.SampleViews.Values)
|
|
{
|
|
if (sampleView.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, sampleView, null);
|
|
}
|
|
}
|
|
resource.SampleViews.Clear();
|
|
|
|
if (resource.View.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, resource.View, null);
|
|
}
|
|
if (resource.Image.Handle != 0)
|
|
{
|
|
_vk.DestroyImage(_device, resource.Image, null);
|
|
}
|
|
if (resource.Memory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, resource.Memory, null);
|
|
}
|
|
}
|
|
|
|
private bool TryGetOrCreateGuestImageView(
|
|
GuestImageResource resource,
|
|
Format format,
|
|
uint mipLevel,
|
|
uint levelCount,
|
|
uint dstSelect,
|
|
out ImageView view)
|
|
{
|
|
try
|
|
{
|
|
view = GetOrCreateGuestImageView(resource, format, mipLevel, levelCount, dstSelect);
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
view = default;
|
|
TraceVulkanShader(
|
|
$"vk.texture_alias_view_failed addr=0x{resource.Address:X16} " +
|
|
$"image_format={resource.Format} view_format={format}: {exception.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private ImageView GetOrCreateGuestImageView(
|
|
GuestImageResource resource,
|
|
Format format,
|
|
uint mipLevel,
|
|
uint levelCount,
|
|
uint dstSelect = 0xFAC)
|
|
{
|
|
if (mipLevel >= resource.MipLevels)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"View mip {mipLevel} exceeds image mip count {resource.MipLevels}.");
|
|
}
|
|
|
|
levelCount = Math.Max(levelCount, 1);
|
|
levelCount = Math.Min(levelCount, resource.MipLevels - mipLevel);
|
|
if (format == resource.Format && dstSelect == 0xFAC)
|
|
{
|
|
if (mipLevel == 0 && levelCount == resource.MipLevels)
|
|
{
|
|
return resource.View;
|
|
}
|
|
|
|
if (levelCount == 1)
|
|
{
|
|
return resource.MipViews[mipLevel];
|
|
}
|
|
}
|
|
|
|
if (!IsCompatibleViewFormat(resource.Format, format))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Incompatible image view format {format} for image {resource.Format}.");
|
|
}
|
|
|
|
var key = (format, mipLevel, levelCount, dstSelect);
|
|
if (resource.FormatViews.TryGetValue(key, out var existing))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
var viewInfo = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = resource.Image,
|
|
ViewType = ImageViewType.Type2D,
|
|
Format = format,
|
|
Components = ToVkComponentMapping(dstSelect),
|
|
SubresourceRange = ColorSubresourceRange(mipLevel, levelCount),
|
|
};
|
|
ImageView view;
|
|
Check(
|
|
_vk.CreateImageView(_device, &viewInfo, null, out view),
|
|
"vkCreateImageView(guest alias)");
|
|
resource.FormatViews.Add(key, view);
|
|
SetDebugName(
|
|
ObjectType.ImageView,
|
|
view.Handle,
|
|
$"SharpEmu guest 0x{resource.Address:X16} alias {format} mip{mipLevel}+{levelCount}");
|
|
TraceVulkanShader(
|
|
$"vk.texture_alias_view addr=0x{resource.Address:X16} " +
|
|
$"image_format={resource.Format} view_format={format} " +
|
|
$"mip={mipLevel} levels={levelCount} dst=0x{dstSelect:X3}");
|
|
return view;
|
|
}
|
|
|
|
private ImageView GetOrCreateGuestImageIdentityView(
|
|
GuestImageResource resource,
|
|
Format format,
|
|
uint mipLevel,
|
|
uint levelCount) =>
|
|
GetOrCreateGuestImageView(
|
|
resource,
|
|
format,
|
|
mipLevel,
|
|
levelCount,
|
|
dstSelect: 0xFAC);
|
|
|
|
internal static bool IsCompatibleViewFormat(Format imageFormat, Format viewFormat)
|
|
{
|
|
if (imageFormat == viewFormat)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var imageClass = GetFormatCompatibilityClass(imageFormat);
|
|
return imageClass != 0 && imageClass == GetFormatCompatibilityClass(viewFormat);
|
|
}
|
|
|
|
private static uint GetFormatCompatibilityClass(Format format) =>
|
|
format switch
|
|
{
|
|
Format.R8Unorm or
|
|
Format.R8Uint or
|
|
Format.R8Sint => 8,
|
|
Format.R16Sfloat => 16,
|
|
Format.R32Uint or
|
|
Format.R32Sint or
|
|
Format.R32Sfloat or
|
|
Format.R16G16Unorm or
|
|
Format.R16G16Uint or
|
|
Format.R16G16Sint or
|
|
Format.R16G16Sfloat or
|
|
Format.R8G8B8A8Unorm or
|
|
Format.R8G8B8A8Srgb or
|
|
Format.R8G8B8A8Uint or
|
|
Format.R8G8B8A8Sint or
|
|
Format.A2R10G10B10UnormPack32 or
|
|
Format.A2B10G10R10UnormPack32 or
|
|
Format.B10G11R11UfloatPack32 => 32,
|
|
Format.R32G32Uint or
|
|
Format.R32G32Sint or
|
|
Format.R32G32Sfloat or
|
|
Format.R16G16B16A16Unorm or
|
|
Format.R16G16B16A16Uint or
|
|
Format.R16G16B16A16Sint or
|
|
Format.R16G16B16A16Sfloat => 64,
|
|
Format.R32G32B32Sfloat => 96,
|
|
Format.R32G32B32A32Uint or
|
|
Format.R32G32B32A32Sint or
|
|
Format.R32G32B32A32Sfloat => 128,
|
|
_ => 0,
|
|
};
|
|
|
|
private void WaitForRenderWork()
|
|
{
|
|
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 ||
|
|
Array.Exists(_frameFencePending, static pending => pending);
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
_pendingGuestWorkCount > 0 ||
|
|
(_latestPresentation is { } latest &&
|
|
latest.Sequence != _presentedSequence &&
|
|
latest.RequiredGuestWorkSequence <= _completedGuestWorkSequence))
|
|
{
|
|
return;
|
|
}
|
|
|
|
System.Threading.Monitor.Wait(_gate, gpuWorkInFlight ? 1 : 8);
|
|
}
|
|
}
|
|
|
|
private bool _overlayHotkeyWasDown;
|
|
|
|
private void PollPerfOverlayHotkey()
|
|
{
|
|
// POSIX hosts route F1 through the GLFW window's keyboard events
|
|
// (HostWindowInput.Attach). Windows never attaches that path — its
|
|
// input comes from user32 polling — so sample the hotkey here for
|
|
// both the standalone window and the embedded host surface.
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
const int VkF1 = 0x70;
|
|
var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
|
|
var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
|
|
if (down && !_overlayHotkeyWasDown)
|
|
{
|
|
PerfOverlay.Toggle();
|
|
}
|
|
|
|
_overlayHotkeyWasDown = down;
|
|
}
|
|
|
|
private void Render(double _)
|
|
{
|
|
try
|
|
{
|
|
RenderCore();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
// Device loss can strike between any two Vulkan calls in the frame;
|
|
// keep the window loop pumping instead of tearing the presenter down.
|
|
if (!TryMarkDeviceLost(exception))
|
|
{
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RenderCore()
|
|
{
|
|
if (Volatile.Read(ref _presenterCloseRequested))
|
|
{
|
|
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
|
|
if (_window is not null)
|
|
{
|
|
_window.Close();
|
|
}
|
|
else
|
|
{
|
|
_embeddedLoopClosed = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
PollPerfOverlayHotkey();
|
|
|
|
if (_hostSurface is not null)
|
|
{
|
|
_hostSurface.RefreshChildProcessPixelSize();
|
|
if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
|
|
{
|
|
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
|
|
RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
|
|
_pendingHostSplashReplay = _lastHostSplashPresentation;
|
|
}
|
|
}
|
|
|
|
if (!_vulkanReady)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_deviceLost)
|
|
{
|
|
// Drain queued work so producers aren't back-pressured, then
|
|
// return without any Vulkan call (fences never signal post-loss).
|
|
while (TryTakeGuestWork(out var lostWork))
|
|
{
|
|
CompleteGuestWork(lostWork);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Reuse of a frame slot waits only on that slot's fence, keeping
|
|
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
|
|
var frameSlot = _currentFrameSlot;
|
|
if (!TryWaitFrameSlot(frameSlot, _frameSlotWaitBudgetNs))
|
|
{
|
|
// The GPU is still finishing this slot's previous frame (slow
|
|
// compute backlog). Don't block the macOS main thread — return
|
|
// to the Cocoa event pump so the window keeps handling input
|
|
// (F1 overlay, drag, close) and redrawing. The frame is retried
|
|
// next Render(); the fence signals once the GPU catches up.
|
|
return;
|
|
}
|
|
|
|
_presentationCommandBuffer = _frameCommandBuffers[frameSlot];
|
|
_commandBuffer = _presentationCommandBuffer;
|
|
if (!_deviceLost)
|
|
{
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
}
|
|
|
|
EvictDirtyCachedTextures();
|
|
var completedWork = 0;
|
|
var renderWorkDeadline = _renderWorkBudgetTicks > 0
|
|
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
|
|
: long.MaxValue;
|
|
while (completedWork < _maxGuestWorkPerRender)
|
|
{
|
|
// Never block the macOS main thread waiting for in-flight GPU
|
|
// work to drain. If submission is at capacity (a slow-compute
|
|
// backlog), stop processing and let the event pump run; the
|
|
// remaining queued work is picked up on later frames as the GPU
|
|
// completions free up capacity (collected non-blockingly here).
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
if (OperatingSystem.IsMacOS() &&
|
|
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (!TryTakeGuestWork(out var pendingGuestWork))
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (!string.Equals(
|
|
_activeGuestQueue.Name,
|
|
pendingGuestWork.Queue.Name,
|
|
StringComparison.Ordinal))
|
|
{
|
|
// A host command buffer must never contain commands from
|
|
// two independent guest queues: an ordered action fences
|
|
// only its own queue's predecessor submissions.
|
|
FlushBatchedGuestCommands();
|
|
}
|
|
|
|
_activeGuestQueue = pendingGuestWork.Queue;
|
|
_activeGuestWorkSequence = pendingGuestWork.Sequence;
|
|
Volatile.Write(
|
|
ref _executingGuestWorkSequence,
|
|
pendingGuestWork.Sequence);
|
|
using var guestQueueScope = EnterGuestQueue(
|
|
pendingGuestWork.Queue.Name,
|
|
pendingGuestWork.Queue.SubmissionId);
|
|
_enqueueAsImmediateQueueFollowup = true;
|
|
_immediateFollowupTail = null;
|
|
var work = pendingGuestWork.Work;
|
|
|
|
var traceWork = ShouldTracePresentedGuestImageContentsForDiagnostics();
|
|
var workStart = traceWork ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
|
if (traceWork && work is VulkanComputeGuestDispatch or VulkanOffscreenGuestDraw)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.render_work_enter #{completedWork} " +
|
|
$"sequence={pendingGuestWork.Sequence} " +
|
|
$"queue={pendingGuestWork.Queue.Name} " +
|
|
$"submission={pendingGuestWork.Queue.SubmissionId} " +
|
|
$"queued_ms={(System.Diagnostics.Stopwatch.GetTimestamp() - pendingGuestWork.EnqueuedTicks) * 1000.0 / System.Diagnostics.Stopwatch.Frequency:F3} " +
|
|
work.GetType().Name);
|
|
}
|
|
try
|
|
{
|
|
switch (work)
|
|
{
|
|
case VulkanOffscreenGuestDraw offscreenDraw:
|
|
ExecuteOffscreenDraw(offscreenDraw);
|
|
break;
|
|
case VulkanComputeGuestDispatch computeDispatch:
|
|
ExecuteComputeDispatch(computeDispatch);
|
|
break;
|
|
case VulkanGuestImageWrite guestImageWrite:
|
|
ExecuteGuestImageWrite(guestImageWrite);
|
|
break;
|
|
case VulkanOrderedGuestAction orderedAction:
|
|
ExecuteOrderedGuestAction(orderedAction);
|
|
break;
|
|
case VulkanOrderedGuestFlip orderedFlip:
|
|
ExecuteOrderedGuestFlip(orderedFlip);
|
|
break;
|
|
case VulkanOrderedGuestFlipWait flipWait:
|
|
ExecuteOrderedGuestFlipWait(flipWait);
|
|
break;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
CompleteGuestWork(pendingGuestWork);
|
|
_enqueueAsImmediateQueueFollowup = false;
|
|
_immediateFollowupTail = null;
|
|
Volatile.Write(ref _executingGuestWorkSequence, 0);
|
|
}
|
|
|
|
if (workStart != 0)
|
|
{
|
|
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - workStart)
|
|
* 1000.0 / System.Diagnostics.Stopwatch.Frequency;
|
|
if (elapsedMs > 250.0)
|
|
{
|
|
var desc = work switch
|
|
{
|
|
VulkanComputeGuestDispatch c => $"compute cs=0x{c.ShaderAddress:X16} groups={c.GroupCountX}x{c.GroupCountY}x{c.GroupCountZ}",
|
|
VulkanOffscreenGuestDraw d =>
|
|
$"draw mrt={d.Targets.Count} " +
|
|
$"rt=0x{d.Targets[0].Address:X16} " +
|
|
$"{d.Targets[0].Width}x{d.Targets[0].Height}",
|
|
_ => work.GetType().Name,
|
|
};
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.slow_render_work {elapsedMs:F0}ms " +
|
|
$"queue={pendingGuestWork.Queue.Name} " +
|
|
$"submission={pendingGuestWork.Queue.SubmissionId} " +
|
|
$"sequence={pendingGuestWork.Sequence}: {desc}");
|
|
}
|
|
}
|
|
|
|
completedWork++;
|
|
|
|
// Return to the main-thread event pump + present once the
|
|
// per-frame budget is spent; remaining guest work is drained
|
|
// on subsequent Render() calls. Without this a compute-heavy
|
|
// backlog freezes the window (macOS "Not Responding").
|
|
if (System.Diagnostics.Stopwatch.GetTimestamp() >= renderWorkDeadline)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
FlushBatchedGuestCommands();
|
|
CollectAbandonedGuestImageVersions();
|
|
|
|
if (!TryTakePresentation(_presentedSequence, out var presentation))
|
|
{
|
|
if (_pendingHostSplashReplay is { } splash)
|
|
{
|
|
presentation = splash;
|
|
_pendingHostSplashReplay = null;
|
|
}
|
|
else
|
|
{
|
|
// A render-loop tick with no newer flip is normal. Warn only when
|
|
// an actual queued presentation is waiting on unfinished guest work.
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
|
HasPendingGuestPresentation(_presentedSequence) &&
|
|
_presentNotTakenLoggedSequence != _presentedSequence)
|
|
{
|
|
_presentNotTakenLoggedSequence = _presentedSequence;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.present_not_taken seq={_presentedSequence} " +
|
|
"— presentation submitted but its required guest work isn't complete; nothing shown.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_hostSurface is not null)
|
|
{
|
|
if (presentation.IsSplash && presentation.Pixels is not null)
|
|
{
|
|
_lastHostSplashPresentation = presentation;
|
|
}
|
|
else
|
|
{
|
|
_lastHostSplashPresentation = null;
|
|
_pendingHostSplashReplay = null;
|
|
}
|
|
}
|
|
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.present_taken addr=0x{presentation.GuestImageAddress:X16} " +
|
|
$"version={presentation.GuestImageVersion} " +
|
|
$"drawKind={presentation.DrawKind} hasPixels={presentation.Pixels is not null} " +
|
|
$"hasTranslatedDraw={presentation.TranslatedDraw is not null}");
|
|
}
|
|
|
|
if (presentation.Pixels is null &&
|
|
presentation.DrawKind != GuestDrawKind.FullscreenBarycentric &&
|
|
presentation.TranslatedDraw is null &&
|
|
presentation.GuestImageAddress == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
byte[]? pixels = null;
|
|
if (presentation.Pixels is { } sourcePixels)
|
|
{
|
|
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
|
|
? sourcePixels
|
|
: _hostSurface is not null
|
|
? ScaleBgraCoverBilinear(
|
|
sourcePixels,
|
|
presentation.Width,
|
|
presentation.Height,
|
|
_extent.Width,
|
|
_extent.Height)
|
|
: ScaleBgra(
|
|
sourcePixels,
|
|
presentation.Width,
|
|
presentation.Height,
|
|
_extent.Width,
|
|
_extent.Height);
|
|
if ((ulong)pixels.Length > _stagingSize)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
TranslatedDrawResources? translatedResources = null;
|
|
GuestImageResource? presentedGuestImage = null;
|
|
var ownsPresentedGuestImageVersion = false;
|
|
if (presentation.GuestImageVersion != 0)
|
|
{
|
|
ownsPresentedGuestImageVersion = _guestImageVersions.Remove(
|
|
presentation.GuestImageVersion,
|
|
out presentedGuestImage);
|
|
}
|
|
else if (presentation.GuestImageAddress != 0)
|
|
{
|
|
_guestImages.TryGetValue(
|
|
presentation.GuestImageAddress,
|
|
out presentedGuestImage);
|
|
}
|
|
|
|
if (presentation.GuestImageAddress != 0 &&
|
|
(presentedGuestImage is null || !presentedGuestImage.Initialized))
|
|
{
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.present_dropped addr=0x{presentation.GuestImageAddress:X16} " +
|
|
$"version={presentation.GuestImageVersion} " +
|
|
$"found={(presentedGuestImage is not null)} " +
|
|
$"initialized={(presentedGuestImage?.Initialized ?? false)} " +
|
|
$"— no swapchain present this frame (black).");
|
|
}
|
|
|
|
if (ownsPresentedGuestImageVersion && presentedGuestImage is not null)
|
|
{
|
|
DestroyGuestImage(presentedGuestImage);
|
|
}
|
|
|
|
return;
|
|
}
|
|
if (ownsPresentedGuestImageVersion)
|
|
{
|
|
System.Diagnostics.Debug.Assert(
|
|
_frameGuestImageVersions[frameSlot] is null,
|
|
"A reusable frame slot cannot still own a flip version.");
|
|
_frameGuestImageVersions[frameSlot] = presentedGuestImage;
|
|
}
|
|
if (presentedGuestImage is not null)
|
|
{
|
|
_directPresentationCount++;
|
|
var traceAddressedPresentation =
|
|
ShouldTraceAddressedPresentedGuestImage(presentedGuestImage);
|
|
if (traceAddressedPresentation ||
|
|
ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
|
(_directPresentationCount is 1 or 30 or 120 ||
|
|
_directPresentationCount % 600 == 0))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.present_sample frame={_directPresentationCount} " +
|
|
$"addr=0x{presentedGuestImage.Address:X16}");
|
|
}
|
|
}
|
|
|
|
if (presentation.TranslatedDraw is { } translatedDraw)
|
|
{
|
|
try
|
|
{
|
|
translatedResources = CreateTranslatedDrawResources(
|
|
translatedDraw,
|
|
_renderPass,
|
|
[_swapchainFormat],
|
|
_extent);
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
|
!_firstGuestDrawPresented &&
|
|
translatedResources.Textures is
|
|
[
|
|
{ GuestImage: { } guestImage },
|
|
] &&
|
|
_tracedGuestImageContents.Add(guestImage.Address))
|
|
{
|
|
TraceGuestImageContents(guestImage);
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_presentedSequence = presentation.Sequence;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][ERROR] Vulkan VideoOut translated draw setup failed: {exception.Message}");
|
|
return;
|
|
}
|
|
}
|
|
|
|
uint imageIndex;
|
|
var acquireResult = _swapchainApi.AcquireNextImage(
|
|
_device,
|
|
_swapchain,
|
|
ulong.MaxValue,
|
|
_frameImageAvailable[frameSlot],
|
|
default,
|
|
&imageIndex);
|
|
if (acquireResult == Result.ErrorOutOfDateKhr)
|
|
{
|
|
RecreateSwapchainResources("vkAcquireNextImageKHR", acquireResult);
|
|
if (translatedResources is not null)
|
|
{
|
|
DestroyTranslatedDrawResources(translatedResources);
|
|
}
|
|
if (ownsPresentedGuestImageVersion && presentedGuestImage is not null)
|
|
{
|
|
if (_frameGuestImageVersions.Length > frameSlot &&
|
|
ReferenceEquals(
|
|
_frameGuestImageVersions[frameSlot],
|
|
presentedGuestImage))
|
|
{
|
|
_frameGuestImageVersions[frameSlot] = null;
|
|
_capturedGuestFlipVersions.Remove(
|
|
presentedGuestImage.FlipVersion);
|
|
DestroyGuestImage(presentedGuestImage);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
CheckSwapchainResult(acquireResult, "vkAcquireNextImageKHR");
|
|
var recreateAfterPresent = acquireResult == Result.SuboptimalKhr;
|
|
|
|
if (pixels is not null)
|
|
{
|
|
// The staging buffer is shared across frame slots; a CPU
|
|
// pixel upload (splash / host frames) degrades to serial
|
|
// presentation rather than corrupting an in-flight copy.
|
|
WaitAllFrameSlots();
|
|
void* mapped;
|
|
Check(
|
|
_vk.MapMemory(_device, _stagingMemory, 0, (ulong)pixels.Length, 0, &mapped),
|
|
"vkMapMemory");
|
|
fixed (byte* source = pixels)
|
|
{
|
|
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
|
|
}
|
|
_vk.UnmapMemory(_device, _stagingMemory);
|
|
}
|
|
|
|
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
|
|
var beginInfo = new CommandBufferBeginInfo
|
|
{
|
|
SType = StructureType.CommandBufferBeginInfo,
|
|
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
|
};
|
|
Check(_vk.BeginCommandBuffer(_commandBuffer, &beginInfo), "vkBeginCommandBuffer");
|
|
|
|
PipelineStageFlags waitStage;
|
|
if (pixels is not null)
|
|
{
|
|
RecordUpload(imageIndex);
|
|
waitStage = PipelineStageFlags.TransferBit;
|
|
}
|
|
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
|
|
{
|
|
var clearValue = default(ClearValue);
|
|
var renderPassInfo = new RenderPassBeginInfo
|
|
{
|
|
SType = StructureType.RenderPassBeginInfo,
|
|
RenderPass = _renderPass,
|
|
Framebuffer = _framebuffers[imageIndex],
|
|
RenderArea = new Rect2D(new Offset2D(0, 0), _extent),
|
|
ClearValueCount = 1,
|
|
PClearValues = &clearValue,
|
|
};
|
|
_vk.CmdBeginRenderPass(
|
|
_commandBuffer,
|
|
&renderPassInfo,
|
|
SubpassContents.Inline);
|
|
_vk.CmdBindPipeline(
|
|
_commandBuffer,
|
|
PipelineBindPoint.Graphics,
|
|
_barycentricPipeline);
|
|
_vk.CmdDraw(_commandBuffer, 3, 1, 0, 0);
|
|
_vk.CmdEndRenderPass(_commandBuffer);
|
|
waitStage = PipelineStageFlags.ColorAttachmentOutputBit;
|
|
}
|
|
else if (presentedGuestImage is not null)
|
|
{
|
|
RecordGuestImageBlit(imageIndex, presentedGuestImage);
|
|
waitStage = PipelineStageFlags.TransferBit;
|
|
}
|
|
else if (translatedResources is not null)
|
|
{
|
|
RecordTranslatedDraw(imageIndex, translatedResources);
|
|
waitStage = PipelineStageFlags.AllCommandsBit;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Unsupported translated guest draw: {presentation.DrawKind}.");
|
|
}
|
|
|
|
if (PerfOverlay.Enabled)
|
|
{
|
|
RecordOverlayBlit(imageIndex, frameSlot);
|
|
}
|
|
|
|
Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer");
|
|
|
|
var imageAvailable = _frameImageAvailable[frameSlot];
|
|
var commandBuffer = _commandBuffer;
|
|
var renderFinished = _renderFinishedPerImage[imageIndex];
|
|
var submitInfo = new SubmitInfo
|
|
{
|
|
SType = StructureType.SubmitInfo,
|
|
WaitSemaphoreCount = 1,
|
|
PWaitSemaphores = &imageAvailable,
|
|
PWaitDstStageMask = &waitStage,
|
|
CommandBufferCount = 1,
|
|
PCommandBuffers = &commandBuffer,
|
|
SignalSemaphoreCount = 1,
|
|
PSignalSemaphores = &renderFinished,
|
|
};
|
|
Check(
|
|
_vk.QueueSubmit(_queue, 1, &submitInfo, _frameFences[frameSlot]),
|
|
"vkQueueSubmit");
|
|
_submitTimeline++;
|
|
_frameTimelines[frameSlot] = _submitTimeline;
|
|
_frameFencePending[frameSlot] = true;
|
|
_frameTranslatedResources[frameSlot] = translatedResources;
|
|
if (translatedResources is not null)
|
|
{
|
|
// CPU-side layout bookkeeping only; later command buffers are
|
|
// recorded after this submission, so queue order makes the
|
|
// flags valid before any dependent GPU work runs.
|
|
MarkSampledImagesInitialized(translatedResources);
|
|
MarkStorageImagesInitialized(translatedResources);
|
|
}
|
|
|
|
var swapchain = _swapchain;
|
|
var presentInfo = new PresentInfoKHR
|
|
{
|
|
SType = StructureType.PresentInfoKhr,
|
|
WaitSemaphoreCount = 1,
|
|
PWaitSemaphores = &renderFinished,
|
|
SwapchainCount = 1,
|
|
PSwapchains = &swapchain,
|
|
PImageIndices = &imageIndex,
|
|
};
|
|
var presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
|
|
if (presentResult == Result.ErrorOutOfDateKhr)
|
|
{
|
|
// The submitted frame still executes; RecreateSwapchainResources
|
|
// drains it (and every frame slot) before destroying anything.
|
|
RecreateSwapchainResources("vkQueuePresentKHR", presentResult);
|
|
return;
|
|
}
|
|
|
|
CheckSwapchainResult(presentResult, "vkQueuePresentKHR");
|
|
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
|
|
VideoOutExports.ReportPresentedFrame();
|
|
PerfOverlay.RecordPresent();
|
|
if (_hostSurface is not null && !_firstHostFramePresented)
|
|
{
|
|
_firstHostFramePresented = true;
|
|
NotifyFirstHostFramePresented(_hostSurface);
|
|
}
|
|
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
|
|
{
|
|
// Diagnostics read back GPU memory and need this frame done.
|
|
WaitFrameSlot(frameSlot);
|
|
if (_swapchainReadbackPending)
|
|
{
|
|
TraceSwapchainReadback();
|
|
}
|
|
|
|
while (_pendingAliasImageDumps.TryDequeue(out var aliasImage))
|
|
{
|
|
TraceGuestImageContents(aliasImage);
|
|
}
|
|
}
|
|
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
_imageInitialized[imageIndex] = true;
|
|
_currentFrameSlot = (frameSlot + 1) % MaxFramesInFlight;
|
|
_presentedSequence = presentation.Sequence;
|
|
if (presentation.IsSplash && !_splashPresented)
|
|
{
|
|
_splashPresented = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut presented splash: " +
|
|
$"{presentation.Width}x{presentation.Height}");
|
|
}
|
|
else if (!presentation.IsSplash && !_firstFramePresented)
|
|
{
|
|
_firstFramePresented = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut presented first frame: " +
|
|
$"{presentation.Width}x{presentation.Height}");
|
|
}
|
|
|
|
if (pixels is null && !_firstGuestDrawPresented)
|
|
{
|
|
_firstGuestDrawPresented = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut presented guest frame: " +
|
|
(presentedGuestImage is not null
|
|
? $"image=0x{presentedGuestImage.Address:X16} " +
|
|
$"{presentedGuestImage.Width}x{presentedGuestImage.Height}"
|
|
: presentation.TranslatedDraw is null
|
|
? $"{presentation.DrawKind}"
|
|
: $"shader textures={presentation.TranslatedDraw.Textures.Count}"));
|
|
}
|
|
|
|
if (recreateAfterPresent)
|
|
{
|
|
RecreateSwapchainResources("present suboptimal", Result.SuboptimalKhr);
|
|
}
|
|
}
|
|
|
|
private void TraceGuestImageContents(GuestImageResource image)
|
|
{
|
|
var bytesPerPixel = GetReadbackBytesPerPixel(image.Format);
|
|
if (bytesPerPixel == 0)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"[LOADER][TRACE] " +
|
|
$"vk.guest_image addr=0x{image.Address:X16} " +
|
|
$"format={image.Format} readback=unsupported");
|
|
return;
|
|
}
|
|
|
|
var byteCount = checked((ulong)image.Width * image.Height * bytesPerPixel);
|
|
var buffer = CreateBuffer(
|
|
byteCount,
|
|
BufferUsageFlags.TransferDstBit,
|
|
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
|
out var memory);
|
|
try
|
|
{
|
|
Check(
|
|
_vk.ResetCommandBuffer(_commandBuffer, 0),
|
|
"vkResetCommandBuffer(guest readback)");
|
|
var beginInfo = new CommandBufferBeginInfo
|
|
{
|
|
SType = StructureType.CommandBufferBeginInfo,
|
|
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
|
};
|
|
Check(
|
|
_vk.BeginCommandBuffer(_commandBuffer, &beginInfo),
|
|
"vkBeginCommandBuffer(guest readback)");
|
|
|
|
var toTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ShaderReadBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = image.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.FragmentShaderBit |
|
|
PipelineStageFlags.ComputeShaderBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toTransfer);
|
|
|
|
var region = new BufferImageCopy
|
|
{
|
|
ImageSubresource = new ImageSubresourceLayers
|
|
{
|
|
AspectMask = ImageAspectFlags.ColorBit,
|
|
LayerCount = 1,
|
|
},
|
|
ImageExtent = new Extent3D(image.Width, image.Height, 1),
|
|
};
|
|
_vk.CmdCopyImageToBuffer(
|
|
_commandBuffer,
|
|
image.Image,
|
|
ImageLayout.TransferSrcOptimal,
|
|
buffer,
|
|
1,
|
|
®ion);
|
|
|
|
var toShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferReadBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferSrcOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = image.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.FragmentShaderBit |
|
|
PipelineStageFlags.ComputeShaderBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toShaderRead);
|
|
|
|
Check(
|
|
_vk.EndCommandBuffer(_commandBuffer),
|
|
"vkEndCommandBuffer(guest readback)");
|
|
var commandBuffer = _commandBuffer;
|
|
var submitInfo = new SubmitInfo
|
|
{
|
|
SType = StructureType.SubmitInfo,
|
|
CommandBufferCount = 1,
|
|
PCommandBuffers = &commandBuffer,
|
|
};
|
|
Check(
|
|
_vk.QueueSubmit(_queue, 1, &submitInfo, default),
|
|
"vkQueueSubmit(guest readback)");
|
|
Check(
|
|
_vk.QueueWaitIdle(_queue),
|
|
"vkQueueWaitIdle(guest readback)");
|
|
|
|
void* mapped;
|
|
Check(
|
|
_vk.MapMemory(_device, memory, 0, byteCount, 0, &mapped),
|
|
"vkMapMemory(guest readback)");
|
|
try
|
|
{
|
|
var bytes = new ReadOnlySpan<byte>(mapped, checked((int)byteCount));
|
|
if (GuestImageTraceInterval() is not null && bytesPerPixel == 4)
|
|
{
|
|
long r = 0, g = 0, b = 0, a = 0, samples = 0;
|
|
for (var offset = 0; offset + 4 <= bytes.Length; offset += 4 * 251)
|
|
{
|
|
r += bytes[offset];
|
|
g += bytes[offset + 1];
|
|
b += bytes[offset + 2];
|
|
a += bytes[offset + 3];
|
|
samples++;
|
|
}
|
|
|
|
if (samples > 0)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[RB] addr=0x{image.Address:X} " +
|
|
$"mean={r / samples},{g / samples},{b / samples},A{a / samples} " +
|
|
$"sample_unique={CountSampledUniquePixels(bytes, bytesPerPixel)}");
|
|
}
|
|
|
|
if (++_intervalReadbackCount % 25 == 0)
|
|
{
|
|
DumpGuestImageBytes(image, bytes);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var nonzeroBytes = 0L;
|
|
ulong hash = 14695981039346656037UL;
|
|
foreach (var value in bytes)
|
|
{
|
|
nonzeroBytes += value == 0 ? 0 : 1;
|
|
hash = (hash ^ value) * 1099511628211UL;
|
|
}
|
|
|
|
var nonblackPixels = CountNonblackPixels(
|
|
bytes,
|
|
image.Format,
|
|
bytesPerPixel);
|
|
var centerOffset = checked(
|
|
((int)(image.Height / 2) * (int)image.Width +
|
|
(int)(image.Width / 2)) *
|
|
(int)bytesPerPixel);
|
|
var center = Convert.ToHexString(
|
|
bytes.Slice(centerOffset, (int)bytesPerPixel));
|
|
Console.Error.WriteLine(
|
|
"[LOADER][TRACE] " +
|
|
$"vk.guest_image addr=0x{image.Address:X16} " +
|
|
$"size={image.Width}x{image.Height} format={image.Format} " +
|
|
$"nonzero_bytes={nonzeroBytes}/{byteCount} " +
|
|
$"nonblack_pixels={nonblackPixels}/{(ulong)image.Width * image.Height} " +
|
|
$"center={center} sample_unique={CountSampledUniquePixels(bytes, bytesPerPixel)} " +
|
|
$"hash=0x{hash:X16}");
|
|
DumpGuestImageBytes(image, bytes);
|
|
}
|
|
finally
|
|
{
|
|
_vk.UnmapMemory(_device, memory);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_vk.DestroyBuffer(_device, buffer, null);
|
|
_vk.FreeMemory(_device, memory, null);
|
|
}
|
|
}
|
|
|
|
private static int CountSampledUniquePixels(
|
|
ReadOnlySpan<byte> bytes,
|
|
uint bytesPerPixel)
|
|
{
|
|
if (bytesPerPixel == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var unique = new HashSet<ulong>();
|
|
var stride = checked((int)bytesPerPixel * 251);
|
|
for (var offset = 0;
|
|
offset + bytesPerPixel <= bytes.Length;
|
|
offset += stride)
|
|
{
|
|
ulong hash = 14695981039346656037UL;
|
|
for (var index = 0; index < bytesPerPixel; index++)
|
|
{
|
|
hash = (hash ^ bytes[offset + index]) * 1099511628211UL;
|
|
}
|
|
|
|
unique.Add(hash);
|
|
if (unique.Count > 256)
|
|
{
|
|
return 257;
|
|
}
|
|
}
|
|
|
|
return unique.Count;
|
|
}
|
|
|
|
private static void DumpGuestImageBytes(
|
|
GuestImageResource image,
|
|
ReadOnlySpan<byte> bytes)
|
|
{
|
|
var directory =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_DIR");
|
|
if (string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Directory.CreateDirectory(directory);
|
|
var sequence = Interlocked.Increment(ref _guestImageDumpSequence);
|
|
var path = Path.Combine(
|
|
directory,
|
|
$"{sequence:D4}-0x{image.Address:X16}-{image.Width}x{image.Height}-{image.Format}.rgba");
|
|
File.WriteAllBytes(path, bytes.ToArray());
|
|
}
|
|
|
|
// Metal cannot blend into integer render targets or 32-bit-per-channel
|
|
// float targets (unsupported on Apple-family GPUs). Enabling blend on
|
|
// one makes vkCreateGraphicsPipelines fail with ErrorInitializationFailed
|
|
// (and trips a Metal "not blendable" validation assertion), so the draw
|
|
// is silently dropped. Force blend off for those; blending on an integer
|
|
// target is meaningless on real hardware anyway.
|
|
private static bool IsBlendableFormat(Format format) =>
|
|
format switch
|
|
{
|
|
Format.R8Uint or Format.R8Sint or
|
|
Format.R8G8B8A8Uint or Format.R8G8B8A8Sint or
|
|
Format.R16G16Uint or Format.R16G16Sint or
|
|
Format.R16G16B16A16Uint or Format.R16G16B16A16Sint or
|
|
Format.R32Uint or Format.R32Sint or
|
|
Format.R32G32Uint or Format.R32G32Sint or
|
|
Format.R32G32B32A32Uint or Format.R32G32B32A32Sint or
|
|
Format.R32Sfloat or
|
|
Format.R32G32Sfloat or
|
|
Format.R32G32B32A32Sfloat => false,
|
|
_ => true,
|
|
};
|
|
|
|
private static uint GetReadbackBytesPerPixel(Format format) =>
|
|
format switch
|
|
{
|
|
Format.R8Unorm or
|
|
Format.R8Uint or
|
|
Format.R8Sint => 1,
|
|
Format.R8G8Unorm or
|
|
Format.R8G8Uint or
|
|
Format.R8G8Sint => 2,
|
|
Format.R32Uint or
|
|
Format.R32Sint or
|
|
Format.R32Sfloat or
|
|
Format.B10G11R11UfloatPack32 or
|
|
Format.R16G16Uint or
|
|
Format.R16G16Sint or
|
|
Format.R16G16Sfloat or
|
|
Format.R8G8B8A8Uint or
|
|
Format.R8G8B8A8Sint or
|
|
Format.R8G8B8A8Unorm or
|
|
Format.A2R10G10B10UnormPack32 or
|
|
Format.A2B10G10R10UnormPack32 => 4,
|
|
Format.R16G16B16A16Uint or
|
|
Format.R16G16B16A16Sint or
|
|
Format.R16G16B16A16Sfloat => 8,
|
|
Format.R32G32Uint or
|
|
Format.R32G32Sint or
|
|
Format.R32G32Sfloat => 8,
|
|
Format.R32G32B32A32Uint or
|
|
Format.R32G32B32A32Sint or
|
|
Format.R32G32B32A32Sfloat => 16,
|
|
_ => 0,
|
|
};
|
|
|
|
private static long CountNonblackPixels(
|
|
ReadOnlySpan<byte> bytes,
|
|
Format format,
|
|
uint bytesPerPixel)
|
|
{
|
|
var count = 0L;
|
|
for (var offset = 0; offset < bytes.Length; offset += (int)bytesPerPixel)
|
|
{
|
|
var pixel = bytes.Slice(offset, (int)bytesPerPixel);
|
|
var hasColor = format switch
|
|
{
|
|
Format.A2R10G10B10UnormPack32 or
|
|
Format.A2B10G10R10UnormPack32 =>
|
|
(BitConverter.ToUInt32(pixel) & 0x3FFFFFFFu) != 0,
|
|
Format.R8G8B8A8Uint or
|
|
Format.R8G8B8A8Sint or
|
|
Format.R8G8B8A8Unorm =>
|
|
pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0,
|
|
Format.R16G16B16A16Uint or
|
|
Format.R16G16B16A16Sint or
|
|
Format.R16G16B16A16Sfloat =>
|
|
pixel[..6].IndexOfAnyExcept((byte)0) >= 0,
|
|
_ => pixel.IndexOfAnyExcept((byte)0) >= 0,
|
|
};
|
|
count += hasColor ? 1 : 0;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private void RecordTranslatedDraw(uint imageIndex, TranslatedDrawResources resources)
|
|
{
|
|
BeginDebugLabel(_commandBuffer, "SharpEmu swapchain draw");
|
|
RecordGlobalBufferVisibilityBarrier(
|
|
_commandBuffer,
|
|
resources,
|
|
PipelineStageFlags.VertexShaderBit |
|
|
PipelineStageFlags.FragmentShaderBit);
|
|
RecordTextureUploads(resources, PipelineStageFlags.FragmentShaderBit);
|
|
RecordStorageImagesForWrite(resources, PipelineStageFlags.FragmentShaderBit);
|
|
RecordTranslatedGraphicsPass(
|
|
resources,
|
|
_renderPass,
|
|
_framebuffers[imageIndex],
|
|
_extent);
|
|
RecordStorageImagesForRead(resources, PipelineStageFlags.FragmentShaderBit);
|
|
EndDebugLabel(_commandBuffer);
|
|
}
|
|
|
|
private void RecordTextureUploads(
|
|
TranslatedDrawResources resources,
|
|
PipelineStageFlags shaderStage)
|
|
{
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (texture.GuestDepth is { } depth)
|
|
{
|
|
RecordGuestDepthForSampling(depth, shaderStage);
|
|
}
|
|
|
|
if (!texture.NeedsUpload)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var toTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = texture.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toTransfer);
|
|
|
|
var copyRegion = new BufferImageCopy
|
|
{
|
|
BufferRowLength = texture.RowLength > texture.Width
|
|
? texture.RowLength
|
|
: 0,
|
|
ImageSubresource = new ImageSubresourceLayers
|
|
{
|
|
AspectMask = ImageAspectFlags.ColorBit,
|
|
LayerCount = 1,
|
|
},
|
|
ImageExtent = new Extent3D(texture.Width, texture.Height, 1),
|
|
};
|
|
_vk.CmdCopyBufferToImage(
|
|
_commandBuffer,
|
|
texture.StagingBuffer,
|
|
texture.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©Region);
|
|
|
|
var toShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = texture.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toShaderRead);
|
|
if (texture.Cached)
|
|
{
|
|
// The queue executes command buffers in submission order,
|
|
// so once this upload is recorded every later draw can
|
|
// reuse the image without restaging it.
|
|
texture.NeedsUpload = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RecordGuestDepthForSampling(
|
|
GuestDepthResource depth,
|
|
PipelineStageFlags shaderStage)
|
|
{
|
|
if (depth.Layout == ImageLayout.ShaderReadOnlyOptimal)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!depth.Initialized)
|
|
{
|
|
var depthRange = new ImageSubresourceRange(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
1,
|
|
0,
|
|
1);
|
|
var toTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = depth.Layout,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = depth.Image,
|
|
SubresourceRange = depthRange,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toTransfer);
|
|
var clearValue = new ClearDepthStencilValue(depth.ClearDepth, 0);
|
|
_vk.CmdClearDepthStencilImage(
|
|
_commandBuffer,
|
|
depth.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
&clearValue,
|
|
1,
|
|
&depthRange);
|
|
depth.Initialized = true;
|
|
if (depth.InitializationSource == "none")
|
|
{
|
|
depth.InitializationSource = "sample-clear";
|
|
}
|
|
depth.Layout = ImageLayout.TransferDstOptimal;
|
|
}
|
|
|
|
var barrier = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = depth.Layout == ImageLayout.TransferDstOptimal
|
|
? AccessFlags.TransferWriteBit
|
|
: AccessFlags.DepthStencilAttachmentWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = depth.Layout,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = depth.Image,
|
|
SubresourceRange = new ImageSubresourceRange(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
1,
|
|
0,
|
|
1),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
depth.Layout == ImageLayout.TransferDstOptimal
|
|
? PipelineStageFlags.TransferBit
|
|
: PipelineStageFlags.LateFragmentTestsBit,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&barrier);
|
|
depth.Layout = ImageLayout.ShaderReadOnlyOptimal;
|
|
}
|
|
|
|
private void RecordRenderTargetFeedbackSnapshots(
|
|
TranslatedDrawResources resources,
|
|
PipelineStageFlags shaderStage)
|
|
{
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (texture.FeedbackSource is not { } source)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Initialize every destination mip to a deterministic zero.
|
|
// Render-target writes currently populate mip 0; leaving the
|
|
// remaining sampled mips undefined turns guest LOD selection
|
|
// into driver-dependent colored garbage.
|
|
var destinationToTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = texture.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, source.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&destinationToTransfer);
|
|
|
|
var clearValue = new ClearColorValue(0f, 0f, 0f, 0f);
|
|
// Avoid overlapping a clear and copy on mip 0: without an
|
|
// intervening dependency two transfer writes to one
|
|
// subresource are not ordered merely because they were
|
|
// recorded in that order. Initialized sources overwrite mip
|
|
// 0 directly and clear only the otherwise undefined tail.
|
|
var clearBaseMip = source.Initialized ? 1u : 0u;
|
|
if (clearBaseMip < source.MipLevels)
|
|
{
|
|
var destinationRange = ColorSubresourceRange(
|
|
clearBaseMip,
|
|
source.MipLevels - clearBaseMip);
|
|
_vk.CmdClearColorImage(
|
|
_commandBuffer,
|
|
texture.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
&clearValue,
|
|
1,
|
|
&destinationRange);
|
|
}
|
|
|
|
if (source.Initialized)
|
|
{
|
|
var sourceToTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.ShaderReadBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
// Only mip 0 has defined render-target contents.
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
shaderStage |
|
|
PipelineStageFlags.ColorAttachmentOutputBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&sourceToTransfer);
|
|
|
|
var copy = new ImageCopy
|
|
{
|
|
SrcSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit,
|
|
0,
|
|
0,
|
|
1),
|
|
DstSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit,
|
|
0,
|
|
0,
|
|
1),
|
|
Extent = new Extent3D(source.Width, source.Height, 1),
|
|
};
|
|
_vk.CmdCopyImage(
|
|
_commandBuffer,
|
|
source.Image,
|
|
ImageLayout.TransferSrcOptimal,
|
|
texture.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©);
|
|
|
|
var sourceToShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferReadBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferSrcOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&sourceToShaderRead);
|
|
}
|
|
|
|
var destinationToShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = texture.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, source.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&destinationToShaderRead);
|
|
|
|
TraceVulkanShader(
|
|
$"vk.feedback_snapshot_copy addr=0x{source.Address:X16} " +
|
|
$"size={source.Width}x{source.Height} initialized={source.Initialized}");
|
|
}
|
|
}
|
|
|
|
private void RecordDepthFeedbackSnapshots(
|
|
TranslatedDrawResources resources,
|
|
PipelineStageFlags shaderStage)
|
|
{
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (texture.DepthFeedbackSource is not { } source)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var depthRange = new ImageSubresourceRange(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
1,
|
|
0,
|
|
1);
|
|
var destinationToTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = texture.Image,
|
|
SubresourceRange = depthRange,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&destinationToTransfer);
|
|
|
|
if (source.Initialized)
|
|
{
|
|
var sourceToTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = source.Layout == ImageLayout.ShaderReadOnlyOptimal
|
|
? AccessFlags.ShaderReadBit
|
|
: AccessFlags.DepthStencilAttachmentWriteBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = source.Layout,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = depthRange,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
shaderStage |
|
|
PipelineStageFlags.EarlyFragmentTestsBit |
|
|
PipelineStageFlags.LateFragmentTestsBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&sourceToTransfer);
|
|
var copy = new ImageCopy
|
|
{
|
|
SrcSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
0,
|
|
1),
|
|
DstSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.DepthBit,
|
|
0,
|
|
0,
|
|
1),
|
|
Extent = new Extent3D(source.Width, source.Height, 1),
|
|
};
|
|
_vk.CmdCopyImage(
|
|
_commandBuffer,
|
|
source.Image,
|
|
ImageLayout.TransferSrcOptimal,
|
|
texture.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©);
|
|
|
|
var sourceToAttachment = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferReadBit,
|
|
DstAccessMask =
|
|
AccessFlags.DepthStencilAttachmentReadBit |
|
|
AccessFlags.DepthStencilAttachmentWriteBit,
|
|
OldLayout = ImageLayout.TransferSrcOptimal,
|
|
NewLayout = ImageLayout.DepthStencilAttachmentOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = depthRange,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.EarlyFragmentTestsBit |
|
|
PipelineStageFlags.LateFragmentTestsBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&sourceToAttachment);
|
|
source.Layout = ImageLayout.DepthStencilAttachmentOptimal;
|
|
}
|
|
else
|
|
{
|
|
var clearValue = new ClearDepthStencilValue(source.ClearDepth, 0);
|
|
_vk.CmdClearDepthStencilImage(
|
|
_commandBuffer,
|
|
texture.Image,
|
|
ImageLayout.TransferDstOptimal,
|
|
&clearValue,
|
|
1,
|
|
&depthRange);
|
|
}
|
|
|
|
var destinationToShader = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = texture.Image,
|
|
SubresourceRange = depthRange,
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&destinationToShader);
|
|
}
|
|
}
|
|
|
|
private void RecordStorageImagesForWrite(
|
|
TranslatedDrawResources resources,
|
|
PipelineStageFlags shaderStage)
|
|
{
|
|
var transitioned = new HashSet<GuestImageResource>();
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (!texture.IsStorage ||
|
|
texture.GuestImage is not { } guestImage ||
|
|
!transitioned.Add(guestImage))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var barrier = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask =
|
|
guestImage.Initialized || guestImage.InitialUploadPending
|
|
? AccessFlags.ShaderReadBit
|
|
: 0,
|
|
DstAccessMask =
|
|
AccessFlags.ShaderReadBit |
|
|
AccessFlags.ShaderWriteBit,
|
|
OldLayout =
|
|
guestImage.Initialized || guestImage.InitialUploadPending
|
|
? ImageLayout.ShaderReadOnlyOptimal
|
|
: ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.General,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = guestImage.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, guestImage.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
guestImage.Initialized || guestImage.InitialUploadPending
|
|
? shaderStage
|
|
: PipelineStageFlags.TopOfPipeBit,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&barrier);
|
|
}
|
|
}
|
|
|
|
private void RecordStorageImagesForRead(
|
|
TranslatedDrawResources resources,
|
|
PipelineStageFlags shaderStage)
|
|
{
|
|
var transitioned = new HashSet<GuestImageResource>();
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (!texture.IsStorage ||
|
|
texture.GuestImage is not { } guestImage ||
|
|
!transitioned.Add(guestImage))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var barrier = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask =
|
|
AccessFlags.ShaderReadBit |
|
|
AccessFlags.ShaderWriteBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.General,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = guestImage.Image,
|
|
SubresourceRange = ColorSubresourceRange(0, guestImage.MipLevels),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
shaderStage,
|
|
shaderStage,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&barrier);
|
|
}
|
|
}
|
|
|
|
private void MarkStorageImagesInitialized(
|
|
TranslatedDrawResources resources,
|
|
bool traceContents = true)
|
|
{
|
|
List<GuestImageResource>? traceImages = null;
|
|
lock (_gate)
|
|
{
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (!texture.IsStorage ||
|
|
texture.Address == 0 ||
|
|
texture.GuestImage is not { } guestImage)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
guestImage.Initialized = true;
|
|
guestImage.InitialUploadPending = false;
|
|
if (guestImage.GuestFormat != 0)
|
|
{
|
|
_availableGuestImages[texture.Address] = guestImage.GuestFormat;
|
|
}
|
|
|
|
if (traceContents &&
|
|
ShouldTraceGuestImageContents(guestImage))
|
|
{
|
|
traceImages ??= [];
|
|
traceImages.Add(guestImage);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (traceImages is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var image in traceImages)
|
|
{
|
|
TraceGuestImageContents(image);
|
|
}
|
|
}
|
|
|
|
private static void MarkSampledImagesInitialized(
|
|
TranslatedDrawResources resources)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (!texture.NeedsUpload ||
|
|
texture.IsStorage ||
|
|
texture.Address == 0 ||
|
|
texture.GuestImage is not { } guestImage)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
guestImage.Initialized = true;
|
|
guestImage.InitialUploadPending = false;
|
|
if (texture.UpdatesCpuContent)
|
|
{
|
|
guestImage.CpuContentFingerprint = texture.CpuContentFingerprint;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool ShouldTraceGuestImageContents(
|
|
GuestImageResource image,
|
|
ulong shaderAddress = 0)
|
|
{
|
|
if (image.Address == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_traceGuestImageShaderFilterEnabled &&
|
|
!AddressListContains(
|
|
"SHARPEMU_TRACE_GUEST_IMAGE_SHADER_ADDRS",
|
|
shaderAddress))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if ((_traceGuestImageWidth > 0 && image.Width != _traceGuestImageWidth) ||
|
|
(_traceGuestImageHeight > 0 && image.Height != _traceGuestImageHeight))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(_traceGuestImageFormat) &&
|
|
(!Enum.TryParse<Format>(
|
|
_traceGuestImageFormat,
|
|
ignoreCase: true,
|
|
out var expectedFormat) ||
|
|
image.Format != expectedFormat))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var addressMatched = ShouldTraceGuestImageAddressForDiagnostics(image.Address);
|
|
if (addressMatched && _traceGuestImageOccurrence > 0)
|
|
{
|
|
var count = _guestImageTraceCounts.TryGetValue(image.Address, out var previous)
|
|
? previous + 1
|
|
: 1;
|
|
_guestImageTraceCounts[image.Address] = count;
|
|
return count == _traceGuestImageOccurrence;
|
|
}
|
|
|
|
var broadTrace =
|
|
ShouldTraceGuestImageContentsForDiagnostics() &&
|
|
image.Width >= 1280 &&
|
|
image.Height >= 720;
|
|
if (GuestImageTraceInterval() is { } interval)
|
|
{
|
|
var addressFilter = Environment.GetEnvironmentVariable(
|
|
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS");
|
|
if (!string.IsNullOrWhiteSpace(addressFilter) && !addressMatched)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (image.Width < 1280 || image.Height < 720)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_globalGuestImageDrawCount++;
|
|
if (_globalGuestImageDrawCount < GuestImageTraceStartAfter() ||
|
|
_intervalReadbackCount > 3000)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var count = _guestImageTraceCounts.TryGetValue(image.Address, out var previous)
|
|
? previous + 1
|
|
: 1;
|
|
_guestImageTraceCounts[image.Address] = count;
|
|
return count % interval == 0;
|
|
}
|
|
|
|
return (addressMatched || broadTrace) &&
|
|
_tracedGuestImageContents.Add(image.Address);
|
|
}
|
|
|
|
private readonly Dictionary<ulong, long> _guestImageTraceCounts = new();
|
|
private long _globalGuestImageDrawCount;
|
|
private long _intervalReadbackCount;
|
|
|
|
private static long? _cachedGuestImageTraceInterval = long.MinValue;
|
|
private static long _cachedGuestImageTraceStartAfter;
|
|
|
|
// SHARPEMU_TRACE_GUEST_IMAGES=every:N[@M] — read back 1280x720+ guest
|
|
// images every Nth draw into each, starting after M total such draws.
|
|
private static long? GuestImageTraceInterval()
|
|
{
|
|
if (_cachedGuestImageTraceInterval == long.MinValue)
|
|
{
|
|
var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
|
|
long? interval = null;
|
|
if (mode is not null && mode.StartsWith("every:", StringComparison.Ordinal))
|
|
{
|
|
var spec = mode["every:".Length..];
|
|
var at = spec.IndexOf('@');
|
|
var intervalText = at < 0 ? spec : spec[..at];
|
|
if (long.TryParse(intervalText, out var parsed) && parsed > 0)
|
|
{
|
|
interval = parsed;
|
|
}
|
|
|
|
if (at >= 0 && long.TryParse(spec[(at + 1)..], out var after) && after > 0)
|
|
{
|
|
_cachedGuestImageTraceStartAfter = after;
|
|
}
|
|
}
|
|
|
|
_cachedGuestImageTraceInterval = interval;
|
|
}
|
|
|
|
return _cachedGuestImageTraceInterval;
|
|
}
|
|
|
|
private static long GuestImageTraceStartAfter()
|
|
{
|
|
_ = GuestImageTraceInterval();
|
|
return _cachedGuestImageTraceStartAfter;
|
|
}
|
|
|
|
// Diagnostics toggles are read once: these run per draw / per cached
|
|
// texture hit, and env lookups plus string parsing are far too
|
|
// expensive there (and non-trivially so under Rosetta 2).
|
|
private static readonly bool _vulkanValidationEnabled =
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_VK_VALIDATION"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
// Object names and command labels are useful in RenderDoc and validation
|
|
// captures, but formatting them and calling the debug-utils driver hooks
|
|
// for every draw is measurable overhead in normal gameplay.
|
|
private static readonly bool _vulkanDebugUtilsEnabled =
|
|
_vulkanValidationEnabled ||
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_VK_DEBUG_LABELS"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
private static readonly string? _traceGuestImagesMode =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
|
|
private static readonly bool _traceGuestImagesEnabled =
|
|
string.Equals(_traceGuestImagesMode, "1", StringComparison.Ordinal);
|
|
private static readonly bool _tracePresentedGuestImagesEnabled =
|
|
_traceGuestImagesEnabled ||
|
|
string.Equals(_traceGuestImagesMode, "present", StringComparison.OrdinalIgnoreCase);
|
|
private static readonly bool _traceVulkanResourcesEnabled =
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VK_RESOURCES"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
private static readonly bool _traceVulkanShaderEnabled =
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC"),
|
|
"1",
|
|
StringComparison.Ordinal) ||
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC_SHADER"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
private static readonly bool _traceDepthInitialization =
|
|
string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_DEPTH_INIT"),
|
|
"1",
|
|
StringComparison.Ordinal);
|
|
private static readonly long _traceGuestImageOccurrence =
|
|
long.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_OCCURRENCE"),
|
|
out var traceGuestImageOccurrence) &&
|
|
traceGuestImageOccurrence > 0
|
|
? traceGuestImageOccurrence
|
|
: 0;
|
|
private static readonly uint _traceGuestImageWidth =
|
|
uint.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_WIDTH"),
|
|
out var traceGuestImageWidth)
|
|
? traceGuestImageWidth
|
|
: 0;
|
|
private static readonly uint _traceGuestImageHeight =
|
|
uint.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_HEIGHT"),
|
|
out var traceGuestImageHeight)
|
|
? traceGuestImageHeight
|
|
: 0;
|
|
private static readonly string? _traceGuestImageFormat =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_FORMAT");
|
|
private static readonly long _tracePresentedGuestImageOccurrence =
|
|
long.TryParse(
|
|
Environment.GetEnvironmentVariable(
|
|
"SHARPEMU_TRACE_PRESENTED_GUEST_IMAGE_OCCURRENCE"),
|
|
out var tracePresentedGuestImageOccurrence) &&
|
|
tracePresentedGuestImageOccurrence > 0
|
|
? tracePresentedGuestImageOccurrence
|
|
: 0;
|
|
private static readonly bool _traceGuestImageShaderFilterEnabled =
|
|
!string.IsNullOrWhiteSpace(
|
|
Environment.GetEnvironmentVariable(
|
|
"SHARPEMU_TRACE_GUEST_IMAGE_SHADER_ADDRS"));
|
|
private static readonly bool _traceGuestImageAddressFilterEnabled =
|
|
!string.IsNullOrWhiteSpace(
|
|
Environment.GetEnvironmentVariable(
|
|
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
|
private static readonly bool _forceFullscreenPipeline =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
|
|
private static readonly bool _forceFullscreenVertex =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_VERTEX") == "1";
|
|
private static readonly bool _forceTitleFullscreenVertex =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_FULLSCREEN_VERTEX") == "1";
|
|
private static readonly bool _forceDefaultRasterState =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_DEFAULT_RASTER_STATE") == "1";
|
|
private static readonly bool _forceTitleDefaultRasterState =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_RASTER_STATE") == "1";
|
|
private static readonly bool _forceTitleSolidFragment =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_SOLID_FRAGMENT") == "1";
|
|
private static readonly bool _forceSolidFragment =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_SOLID_FRAGMENT") == "1";
|
|
private static readonly uint? _forceAttributeFragmentLocation =
|
|
uint.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT"),
|
|
out var forceAttributeFragmentLocation)
|
|
? forceAttributeFragmentLocation
|
|
: null;
|
|
private static readonly string? _fixedFragmentDumpPath =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_FIXED_SOLID_FRAGMENT");
|
|
private static readonly bool _forceTitleDefaultBlend =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_BLEND") == "1";
|
|
private static readonly bool _forceTitleDefaultViewportScissor =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_VIEWPORT_SCISSOR") == "1";
|
|
private static readonly bool _forceTitleDisableCull =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DISABLE_CULL") == "1";
|
|
private static readonly bool _forceTitleDisableDepth =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DISABLE_DEPTH") == "1";
|
|
private static readonly bool _traceTitleState =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_STATE") == "1";
|
|
private static readonly bool _forceTitleVertexColorWhite =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_VERTEX_COLOR_WHITE") == "1";
|
|
private static readonly bool _chunkedDrawsEnabled =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_CHUNKED_DRAWS") == "1";
|
|
private static readonly string? _traceGuestWritesMode =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITES");
|
|
private static readonly long _traceGuestWriteOrdinal =
|
|
long.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITE_ORDINAL"),
|
|
out var traceGuestWriteOrdinal)
|
|
? traceGuestWriteOrdinal
|
|
: 0;
|
|
private static readonly long _traceLargeGuestWriteOrdinal =
|
|
ParseTraceLargeGuestWriteOrdinal(_traceGuestWritesMode);
|
|
private static readonly int _tracePixelSpirvBytes =
|
|
int.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_BYTES"),
|
|
out var tracePixelSpirvBytes)
|
|
? tracePixelSpirvBytes
|
|
: 0;
|
|
private static readonly int _tracePixelSpirvOccurrence =
|
|
int.TryParse(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_OCCURRENCE"),
|
|
out var tracePixelSpirvOccurrence)
|
|
? Math.Max(tracePixelSpirvOccurrence, 1)
|
|
: 1;
|
|
private static readonly bool _traceTitleDrawEnabled =
|
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_DRAW") == "1";
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
|
|
string,
|
|
(bool Wildcard, ulong[] Addresses)> _cachedAddressLists = new();
|
|
|
|
private static long ParseTraceLargeGuestWriteOrdinal(string? mode)
|
|
{
|
|
return mode is not null &&
|
|
mode.StartsWith("large@", StringComparison.Ordinal) &&
|
|
long.TryParse(mode.AsSpan("large@".Length), out var ordinal) &&
|
|
ordinal > 0
|
|
? ordinal
|
|
: 0;
|
|
}
|
|
|
|
private static bool ShouldTraceGuestImageContentsForDiagnostics() =>
|
|
_traceGuestImagesEnabled;
|
|
|
|
private static bool ShouldTraceGuestImageAddressForDiagnostics(ulong address)
|
|
{
|
|
return AddressListContains(
|
|
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS",
|
|
address);
|
|
}
|
|
|
|
private static bool ShouldTraceGuestImageWriteForDiagnostics(ulong address)
|
|
{
|
|
return AddressListContains(
|
|
"SHARPEMU_TRACE_GUEST_WRITES",
|
|
address);
|
|
}
|
|
|
|
private static bool AddressListContains(
|
|
string environmentVariable,
|
|
ulong address)
|
|
{
|
|
var (wildcard, addresses) = _cachedAddressLists.GetOrAdd(
|
|
environmentVariable,
|
|
static name => ParseAddressList(Environment.GetEnvironmentVariable(name)));
|
|
return wildcard || Array.IndexOf(addresses, address) >= 0;
|
|
}
|
|
|
|
private static (bool Wildcard, ulong[] Addresses) ParseAddressList(string? addresses)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(addresses))
|
|
{
|
|
return (false, []);
|
|
}
|
|
|
|
var parsedAddresses = new List<ulong>();
|
|
foreach (var token in addresses.Split(
|
|
[',', ';', ' ', '\t'],
|
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
if (token == "*")
|
|
{
|
|
return (true, []);
|
|
}
|
|
|
|
var span = token.AsSpan();
|
|
if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
span = span[2..];
|
|
}
|
|
|
|
if (ulong.TryParse(
|
|
span,
|
|
System.Globalization.NumberStyles.HexNumber,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out var parsed))
|
|
{
|
|
parsedAddresses.Add(parsed);
|
|
}
|
|
}
|
|
|
|
return (false, parsedAddresses.ToArray());
|
|
}
|
|
|
|
private static bool ShouldTracePresentedGuestImageContentsForDiagnostics() =>
|
|
_tracePresentedGuestImagesEnabled;
|
|
|
|
private bool ShouldTraceAddressedPresentedGuestImage(GuestImageResource image)
|
|
{
|
|
if (!AddressListContains(
|
|
"SHARPEMU_TRACE_PRESENTED_GUEST_IMAGE_ADDRS",
|
|
image.Address))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var count = _presentedGuestImageTraceCounts.TryGetValue(
|
|
image.Address,
|
|
out var previous)
|
|
? previous + 1
|
|
: 1;
|
|
_presentedGuestImageTraceCounts[image.Address] = count;
|
|
return _tracePresentedGuestImageOccurrence == 0
|
|
? count == 1
|
|
: count == _tracePresentedGuestImageOccurrence;
|
|
}
|
|
|
|
private static bool ShouldTraceVulkanResources() =>
|
|
_traceVulkanResourcesEnabled;
|
|
|
|
private void RecordTranslatedGraphicsPass(
|
|
TranslatedDrawResources resources,
|
|
RenderPass renderPass,
|
|
Framebuffer framebuffer,
|
|
Extent2D extent)
|
|
{
|
|
BeginTranslatedRenderPass(renderPass, framebuffer, extent);
|
|
RecordTranslatedDrawInPass(resources, extent);
|
|
_vk.CmdEndRenderPass(_commandBuffer);
|
|
}
|
|
|
|
private void BeginTranslatedRenderPass(
|
|
RenderPass renderPass,
|
|
Framebuffer framebuffer,
|
|
Extent2D extent,
|
|
int colorAttachmentCount = 1,
|
|
bool hasDepthAttachment = false,
|
|
float clearDepth = 1f)
|
|
{
|
|
colorAttachmentCount = Math.Max(colorAttachmentCount, 1);
|
|
var clearValueCount = colorAttachmentCount + (hasDepthAttachment ? 1 : 0);
|
|
var clearValues = stackalloc ClearValue[clearValueCount];
|
|
for (var index = 0; index < colorAttachmentCount; index++)
|
|
{
|
|
clearValues[index] = default;
|
|
}
|
|
// Reverse-Z is not assumed; clear depth to 1.0 (far) so a standard
|
|
// LessOrEqual/Less test keeps the nearest fragment.
|
|
if (hasDepthAttachment)
|
|
{
|
|
clearValues[colorAttachmentCount] = new ClearValue
|
|
{
|
|
DepthStencil = new ClearDepthStencilValue(clearDepth, 0),
|
|
};
|
|
}
|
|
var renderPassInfo = new RenderPassBeginInfo
|
|
{
|
|
SType = StructureType.RenderPassBeginInfo,
|
|
RenderPass = renderPass,
|
|
Framebuffer = framebuffer,
|
|
RenderArea = new Rect2D(new Offset2D(0, 0), extent),
|
|
ClearValueCount = (uint)clearValueCount,
|
|
PClearValues = clearValues,
|
|
};
|
|
_vk.CmdBeginRenderPass(
|
|
_commandBuffer,
|
|
&renderPassInfo,
|
|
SubpassContents.Inline);
|
|
}
|
|
|
|
private void RecordTranslatedDrawInPass(
|
|
TranslatedDrawResources resources,
|
|
Extent2D extent)
|
|
{
|
|
_vk.CmdBindPipeline(
|
|
_commandBuffer,
|
|
PipelineBindPoint.Graphics,
|
|
resources.Pipeline);
|
|
if (resources.DescriptorSet.Handle != 0)
|
|
{
|
|
var descriptorSet = resources.DescriptorSet;
|
|
_vk.CmdBindDescriptorSets(
|
|
_commandBuffer,
|
|
PipelineBindPoint.Graphics,
|
|
resources.PipelineLayout,
|
|
0,
|
|
1,
|
|
&descriptorSet,
|
|
0,
|
|
null);
|
|
}
|
|
|
|
var drawScissor = ClampScissor(resources.Scissor, extent);
|
|
if (drawScissor.Width == 0 || drawScissor.Height == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var drawViewport = ClampViewport(resources.Viewport, extent);
|
|
if (ViewportDebugEpsilon != 0f)
|
|
{
|
|
drawViewport.X += ViewportDebugEpsilon;
|
|
drawViewport.Y += ViewportDebugEpsilon;
|
|
}
|
|
_vk.CmdSetViewport(_commandBuffer, 0, 1, &drawViewport);
|
|
// CB_BLEND_RED..ALPHA feed the CONSTANT_COLOR/CONSTANT_ALPHA factors.
|
|
var blendConstants = stackalloc float[4]
|
|
{
|
|
resources.BlendConstant.Red,
|
|
resources.BlendConstant.Green,
|
|
resources.BlendConstant.Blue,
|
|
resources.BlendConstant.Alpha,
|
|
};
|
|
_vk.CmdSetBlendConstants(_commandBuffer, blendConstants);
|
|
if (resources.VertexBuffers.Length != 0)
|
|
{
|
|
var buffers = stackalloc VkBuffer[resources.VertexBuffers.Length];
|
|
var offsets = stackalloc ulong[resources.VertexBuffers.Length];
|
|
for (var index = 0; index < resources.VertexBuffers.Length; index++)
|
|
{
|
|
buffers[index] = resources.VertexBuffers[index].Buffer;
|
|
offsets[index] = GetVertexBindingOffset(resources.VertexBuffers[index]);
|
|
}
|
|
|
|
_vk.CmdBindVertexBuffers(
|
|
_commandBuffer,
|
|
0,
|
|
(uint)resources.VertexBuffers.Length,
|
|
buffers,
|
|
offsets);
|
|
}
|
|
|
|
// Replaying a full-screen primitive once per 512x512 scissor tile
|
|
// multiplies an ordinary 4K composite into 32 complete draws. On
|
|
// MoltenVK this starves the render thread and makes the guest fall
|
|
// behind its own flip queue. Vulkan clips a normal fullscreen draw
|
|
// efficiently; keep tiling only as an explicit driver diagnostic.
|
|
var maxPixelsPerDraw = _chunkedDrawsEnabled
|
|
? 512u * 512u
|
|
: uint.MaxValue;
|
|
var rowsPerDraw = Math.Max(
|
|
1u,
|
|
Math.Min(drawScissor.Height, maxPixelsPerDraw / Math.Max(drawScissor.Width, 1u)));
|
|
var drawCount = 0u;
|
|
for (var y = 0u; y < drawScissor.Height; y += rowsPerDraw)
|
|
{
|
|
var scissor = new Rect2D(
|
|
new Offset2D(
|
|
drawScissor.X,
|
|
checked(drawScissor.Y + (int)y)),
|
|
new Extent2D(
|
|
drawScissor.Width,
|
|
Math.Min(rowsPerDraw, drawScissor.Height - y)));
|
|
_vk.CmdSetScissor(_commandBuffer, 0, 1, &scissor);
|
|
|
|
if (resources.IndexBuffer.Handle != 0)
|
|
{
|
|
_vk.CmdBindIndexBuffer(
|
|
_commandBuffer,
|
|
resources.IndexBuffer,
|
|
0,
|
|
resources.Index32Bit ? IndexType.Uint32 : IndexType.Uint16);
|
|
_vk.CmdDrawIndexed(
|
|
_commandBuffer,
|
|
resources.VertexCount,
|
|
resources.InstanceCount,
|
|
0,
|
|
0,
|
|
0);
|
|
}
|
|
else
|
|
{
|
|
_vk.CmdDraw(
|
|
_commandBuffer,
|
|
resources.VertexCount,
|
|
resources.InstanceCount,
|
|
0,
|
|
0);
|
|
}
|
|
|
|
drawCount++;
|
|
}
|
|
|
|
if (drawCount > 1)
|
|
{
|
|
TraceVulkanShader(
|
|
$"vk.graphics_chunked target={extent.Width}x{extent.Height} " +
|
|
$"draws={drawCount} rows={rowsPerDraw} " +
|
|
$"scissor={drawScissor.X},{drawScissor.Y},{drawScissor.Width}x{drawScissor.Height} " +
|
|
$"viewport={drawViewport.X:0.###},{drawViewport.Y:0.###}," +
|
|
$"{drawViewport.Width:0.###}x{drawViewport.Height:0.###} " +
|
|
$"name={resources.DebugName}");
|
|
}
|
|
}
|
|
|
|
private void DestroyTranslatedDrawResources(TranslatedDrawResources resources)
|
|
{
|
|
if (resources.TransientFramebuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyFramebuffer(_device, resources.TransientFramebuffer, null);
|
|
}
|
|
|
|
if (resources.TransientRenderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, resources.TransientRenderPass, null);
|
|
}
|
|
|
|
foreach (var texture in resources.Textures)
|
|
{
|
|
if (texture is null || texture.Cached)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (texture.OwnsStorage && texture.View.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, texture.View, null);
|
|
}
|
|
|
|
if (texture.OwnsStorage && texture.Image.Handle != 0)
|
|
{
|
|
_vk.DestroyImage(_device, texture.Image, null);
|
|
}
|
|
|
|
if (texture.OwnsStorage && texture.ImageMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, texture.ImageMemory, null);
|
|
}
|
|
|
|
if (texture.StagingBuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyBuffer(_device, texture.StagingBuffer, null);
|
|
}
|
|
|
|
if (texture.StagingMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, texture.StagingMemory, null);
|
|
}
|
|
|
|
if (texture.NeedsUpload &&
|
|
texture.GuestImage is { Initialized: false } guestImage)
|
|
{
|
|
guestImage.InitialUploadPending = false;
|
|
}
|
|
}
|
|
|
|
foreach (var globalBuffer in resources.GlobalMemoryBuffers)
|
|
{
|
|
if (globalBuffer is null || globalBuffer.Allocation is not null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
RecycleHostBuffer(globalBuffer.Buffer, globalBuffer.Memory);
|
|
}
|
|
|
|
foreach (var vertexBuffer in resources.VertexBuffers)
|
|
{
|
|
if (vertexBuffer is null || !vertexBuffer.OwnsBuffer)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
RecycleHostBuffer(vertexBuffer.Buffer, vertexBuffer.Memory);
|
|
}
|
|
|
|
RecycleHostBuffer(resources.IndexBuffer, resources.IndexMemory);
|
|
|
|
if (!resources.PipelineCached && resources.Pipeline.Handle != 0)
|
|
{
|
|
_vk.DestroyPipeline(_device, resources.Pipeline, null);
|
|
}
|
|
|
|
if (resources.DescriptorPool.Handle != 0)
|
|
{
|
|
if (_recycledDescriptorPools.Count < 256)
|
|
{
|
|
_recycledDescriptorPools.Push(resources.DescriptorPool);
|
|
}
|
|
else
|
|
{
|
|
_vk.DestroyDescriptorPool(_device, resources.DescriptorPool, null);
|
|
}
|
|
}
|
|
|
|
if (!resources.DescriptorLayoutCached &&
|
|
resources.PipelineLayout.Handle != 0)
|
|
{
|
|
_vk.DestroyPipelineLayout(_device, resources.PipelineLayout, null);
|
|
}
|
|
|
|
if (!resources.DescriptorLayoutCached &&
|
|
resources.DescriptorSetLayout.Handle != 0)
|
|
{
|
|
_vk.DestroyDescriptorSetLayout(_device, resources.DescriptorSetLayout, null);
|
|
}
|
|
}
|
|
|
|
private void RecordUpload(uint imageIndex)
|
|
{
|
|
var oldLayout = _imageInitialized[imageIndex]
|
|
? ImageLayout.PresentSrcKhr
|
|
: ImageLayout.Undefined;
|
|
var toTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = _imageInitialized[imageIndex] ? AccessFlags.MemoryReadBit : 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = oldLayout,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
_imageInitialized[imageIndex]
|
|
? PipelineStageFlags.BottomOfPipeBit
|
|
: PipelineStageFlags.TopOfPipeBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toTransfer);
|
|
|
|
var copyRegion = new BufferImageCopy
|
|
{
|
|
ImageSubresource = new ImageSubresourceLayers
|
|
{
|
|
AspectMask = ImageAspectFlags.ColorBit,
|
|
LayerCount = 1,
|
|
},
|
|
ImageExtent = new Extent3D(_extent.Width, _extent.Height, 1),
|
|
};
|
|
_vk.CmdCopyBufferToImage(
|
|
_commandBuffer,
|
|
_stagingBuffer,
|
|
_swapchainImages[imageIndex],
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
©Region);
|
|
|
|
var toPresent = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.MemoryReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.PresentSrcKhr,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.BottomOfPipeBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&toPresent);
|
|
}
|
|
|
|
private void RecordGuestImageBlit(
|
|
uint imageIndex,
|
|
GuestImageResource source)
|
|
{
|
|
var presentedCount = Interlocked.Increment(ref _presentedSwapchainCount);
|
|
var periodicDumpInterval = SwapchainDumpInterval();
|
|
var traceDestination =
|
|
ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
|
(!_tracedPresentedSwapchain ||
|
|
periodicDumpInterval > 0 && presentedCount % periodicDumpInterval == 0);
|
|
_tracedPresentedSwapchain |= traceDestination;
|
|
BeginDebugLabel(
|
|
_commandBuffer,
|
|
$"SharpEmu present image 0x{source.Address:X16}");
|
|
|
|
var sourceToTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
// An offscreen target is last written as a color attachment,
|
|
// then put in ShaderReadOnlyOptimal for later sampling. A
|
|
// layout-only handoff to ShaderRead does not make that write
|
|
// visible to this transfer when no shader sample occurs in
|
|
// between. NVIDIA's Linux driver exposed the resulting stale
|
|
// (usually black) image while Windows drivers happened to
|
|
// tolerate it. Include all preceding writes before blitting
|
|
// the image into the swapchain.
|
|
SrcAccessMask = AccessFlags.MemoryWriteBit | AccessFlags.ShaderReadBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
var destinationToTransfer = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = _imageInitialized[imageIndex]
|
|
? AccessFlags.MemoryReadBit
|
|
: 0,
|
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
|
OldLayout = _imageInitialized[imageIndex]
|
|
? ImageLayout.PresentSrcKhr
|
|
: ImageLayout.Undefined,
|
|
NewLayout = ImageLayout.TransferDstOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
var barriers = stackalloc ImageMemoryBarrier[2];
|
|
barriers[0] = sourceToTransfer;
|
|
barriers[1] = destinationToTransfer;
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.AllCommandsBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
2,
|
|
barriers);
|
|
|
|
var sourceX = 0u;
|
|
var sourceY = 0u;
|
|
var sourceWidth = source.Width;
|
|
var sourceHeight = source.Height;
|
|
if (_hostSurface is not null)
|
|
{
|
|
// The embedded GUI fills its game surface like CSS
|
|
// object-fit: cover. Preserve the guest aspect ratio and crop
|
|
// only the excess instead of distorting every video frame.
|
|
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
|
|
(ulong)_extent.Width * sourceHeight;
|
|
if (sourceIsWider)
|
|
{
|
|
sourceWidth = Math.Max(1u, (uint)((ulong)sourceHeight * _extent.Width / _extent.Height));
|
|
sourceX = (source.Width - sourceWidth) / 2;
|
|
}
|
|
else
|
|
{
|
|
sourceHeight = Math.Max(1u, (uint)((ulong)sourceWidth * _extent.Height / _extent.Width));
|
|
sourceY = (source.Height - sourceHeight) / 2;
|
|
}
|
|
}
|
|
|
|
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
|
|
{
|
|
Element0 = new Offset3D(checked((int)sourceX), checked((int)sourceY), 0),
|
|
Element1 = new Offset3D(
|
|
checked((int)(sourceX + sourceWidth)),
|
|
checked((int)(sourceY + sourceHeight)),
|
|
1),
|
|
};
|
|
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
|
|
{
|
|
Element0 = new Offset3D(0, 0, 0),
|
|
Element1 = new Offset3D(
|
|
checked((int)_extent.Width),
|
|
checked((int)_extent.Height),
|
|
1),
|
|
};
|
|
var region = new ImageBlit
|
|
{
|
|
SrcSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit,
|
|
0,
|
|
0,
|
|
1),
|
|
SrcOffsets = sourceOffsets,
|
|
DstSubresource = new ImageSubresourceLayers(
|
|
ImageAspectFlags.ColorBit,
|
|
0,
|
|
0,
|
|
1),
|
|
DstOffsets = destinationOffsets,
|
|
};
|
|
// Nearest keeps integer upscales pixel-crisp, but any fractional
|
|
// scale (e.g. a 3840x2160 guest frame into a 2560x1440 swapchain)
|
|
// must blend neighbours or it silently drops every Nth source
|
|
// row/column, which shreds 1-2px features in the guest frame.
|
|
var isIntegerUpscale =
|
|
sourceWidth != 0 && sourceHeight != 0 &&
|
|
_extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
|
|
_extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
|
|
_vk.CmdBlitImage(
|
|
_commandBuffer,
|
|
source.Image,
|
|
ImageLayout.TransferSrcOptimal,
|
|
_swapchainImages[imageIndex],
|
|
ImageLayout.TransferDstOptimal,
|
|
1,
|
|
®ion,
|
|
isIntegerUpscale ? Filter.Nearest : Filter.Linear);
|
|
|
|
if (traceDestination)
|
|
{
|
|
var destinationToReadback = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.TransferReadBit,
|
|
OldLayout = ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.TransferSrcOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.TransferBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
1,
|
|
&destinationToReadback);
|
|
|
|
var copyRegion = new BufferImageCopy
|
|
{
|
|
ImageSubresource = new ImageSubresourceLayers
|
|
{
|
|
AspectMask = ImageAspectFlags.ColorBit,
|
|
LayerCount = 1,
|
|
},
|
|
ImageExtent = new Extent3D(_extent.Width, _extent.Height, 1),
|
|
};
|
|
_vk.CmdCopyImageToBuffer(
|
|
_commandBuffer,
|
|
_swapchainImages[imageIndex],
|
|
ImageLayout.TransferSrcOptimal,
|
|
_stagingBuffer,
|
|
1,
|
|
©Region);
|
|
_swapchainReadbackPending = true;
|
|
}
|
|
|
|
var sourceToShaderRead = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = AccessFlags.TransferReadBit,
|
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
|
OldLayout = ImageLayout.TransferSrcOptimal,
|
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = source.Image,
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
var destinationToPresent = new ImageMemoryBarrier
|
|
{
|
|
SType = StructureType.ImageMemoryBarrier,
|
|
SrcAccessMask = traceDestination
|
|
? AccessFlags.TransferReadBit
|
|
: AccessFlags.TransferWriteBit,
|
|
DstAccessMask = AccessFlags.MemoryReadBit,
|
|
OldLayout = traceDestination
|
|
? ImageLayout.TransferSrcOptimal
|
|
: ImageLayout.TransferDstOptimal,
|
|
NewLayout = ImageLayout.PresentSrcKhr,
|
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
|
Image = _swapchainImages[imageIndex],
|
|
SubresourceRange = ColorSubresourceRange(),
|
|
};
|
|
barriers[0] = sourceToShaderRead;
|
|
barriers[1] = destinationToPresent;
|
|
_vk.CmdPipelineBarrier(
|
|
_commandBuffer,
|
|
PipelineStageFlags.TransferBit,
|
|
PipelineStageFlags.AllCommandsBit,
|
|
0,
|
|
0,
|
|
null,
|
|
0,
|
|
null,
|
|
2,
|
|
barriers);
|
|
EndDebugLabel(_commandBuffer);
|
|
}
|
|
|
|
private void TraceSwapchainReadback()
|
|
{
|
|
_swapchainReadbackPending = false;
|
|
var byteCount = checked((ulong)_extent.Width * _extent.Height * 4);
|
|
void* mapped;
|
|
Check(
|
|
_vk.MapMemory(_device, _stagingMemory, 0, byteCount, 0, &mapped),
|
|
"vkMapMemory(swapchain readback)");
|
|
try
|
|
{
|
|
var bytes = new ReadOnlySpan<byte>(mapped, checked((int)byteCount));
|
|
var nonzeroBytes = 0L;
|
|
var nonblackPixels = 0L;
|
|
ulong hash = 14695981039346656037UL;
|
|
for (var offset = 0; offset < bytes.Length; offset += 4)
|
|
{
|
|
var b0 = bytes[offset];
|
|
var b1 = bytes[offset + 1];
|
|
var b2 = bytes[offset + 2];
|
|
var b3 = bytes[offset + 3];
|
|
nonzeroBytes += b0 == 0 ? 0 : 1;
|
|
nonzeroBytes += b1 == 0 ? 0 : 1;
|
|
nonzeroBytes += b2 == 0 ? 0 : 1;
|
|
nonzeroBytes += b3 == 0 ? 0 : 1;
|
|
nonblackPixels += b0 != 0 || b1 != 0 || b2 != 0 ? 1 : 0;
|
|
hash = (hash ^ b0) * 1099511628211UL;
|
|
hash = (hash ^ b1) * 1099511628211UL;
|
|
hash = (hash ^ b2) * 1099511628211UL;
|
|
hash = (hash ^ b3) * 1099511628211UL;
|
|
}
|
|
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.swapchain_image size={_extent.Width}x{_extent.Height} " +
|
|
$"format={_swapchainFormat} nonzero_bytes={nonzeroBytes}/{byteCount} " +
|
|
$"nonblack_pixels={nonblackPixels}/{(ulong)_extent.Width * _extent.Height} " +
|
|
$"hash=0x{hash:X16}");
|
|
|
|
var dumpDir = Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_DIR");
|
|
if (!string.IsNullOrWhiteSpace(dumpDir))
|
|
{
|
|
Directory.CreateDirectory(dumpDir);
|
|
var seq = Interlocked.Increment(ref _guestImageDumpSequence);
|
|
var path = Path.Combine(
|
|
dumpDir,
|
|
$"present-{seq:D4}-{_extent.Width}x{_extent.Height}-{_swapchainFormat}.bgra");
|
|
File.WriteAllBytes(path, bytes.ToArray());
|
|
Console.Error.WriteLine($"[LOADER][TRACE] vk.swapchain_dump path={path}");
|
|
// Continuous readback is intentionally opt-in: each 1080p frame
|
|
// is several megabytes and synchronously waits for the GPU.
|
|
if (string.Equals(
|
|
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_DUMP_CONTINUOUS"),
|
|
"1",
|
|
StringComparison.Ordinal))
|
|
{
|
|
_tracedPresentedSwapchain = false;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_vk.UnmapMemory(_device, _stagingMemory);
|
|
}
|
|
}
|
|
|
|
private static readonly long _swapchainDumpInterval = ParseSwapchainDumpInterval();
|
|
|
|
private static long SwapchainDumpInterval() => _swapchainDumpInterval;
|
|
|
|
private static long ParseSwapchainDumpInterval()
|
|
{
|
|
var raw = Environment.GetEnvironmentVariable("SHARPEMU_SWAPCHAIN_DUMP_EVERY");
|
|
return long.TryParse(raw, out var interval) && interval > 0 ? interval : 0;
|
|
}
|
|
|
|
private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities)
|
|
{
|
|
if (capabilities.CurrentExtent.Width != uint.MaxValue)
|
|
{
|
|
var fallbackWidth = _extent.Width != 0
|
|
? _extent.Width
|
|
: DefaultWindowWidth;
|
|
var fallbackHeight = _extent.Height != 0
|
|
? _extent.Height
|
|
: DefaultWindowHeight;
|
|
return new Extent2D(
|
|
ClampSurfaceExtent(
|
|
capabilities.CurrentExtent.Width,
|
|
fallbackWidth,
|
|
capabilities.MinImageExtent.Width,
|
|
capabilities.MaxImageExtent.Width),
|
|
ClampSurfaceExtent(
|
|
capabilities.CurrentExtent.Height,
|
|
fallbackHeight,
|
|
capabilities.MinImageExtent.Height,
|
|
capabilities.MaxImageExtent.Height));
|
|
}
|
|
|
|
var size = GetFramebufferSize();
|
|
return new Extent2D(
|
|
ClampSurfaceExtent(
|
|
(uint)Math.Max(size.X, 1),
|
|
DefaultWindowWidth,
|
|
capabilities.MinImageExtent.Width,
|
|
capabilities.MaxImageExtent.Width),
|
|
ClampSurfaceExtent(
|
|
(uint)Math.Max(size.Y, 1),
|
|
DefaultWindowHeight,
|
|
capabilities.MinImageExtent.Height,
|
|
capabilities.MaxImageExtent.Height));
|
|
}
|
|
|
|
private Vector2D<int> GetFramebufferSize()
|
|
{
|
|
if (_window is not null)
|
|
{
|
|
return _window.FramebufferSize;
|
|
}
|
|
|
|
if (_hostSurface is not null)
|
|
{
|
|
return new Vector2D<int>(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
|
|
}
|
|
|
|
return new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
|
|
}
|
|
|
|
private static uint ClampSurfaceExtent(
|
|
uint value,
|
|
uint fallback,
|
|
uint minimum,
|
|
uint maximum)
|
|
{
|
|
value = value <= 1 && fallback > 1 ? fallback : value;
|
|
minimum = Math.Max(minimum, 1u);
|
|
maximum = Math.Max(maximum, minimum);
|
|
return Math.Clamp(value, minimum, maximum);
|
|
}
|
|
|
|
private static SurfaceFormatKHR ChooseSurfaceFormat(IReadOnlyList<SurfaceFormatKHR> formats)
|
|
{
|
|
foreach (var format in formats)
|
|
{
|
|
if (format.Format is Format.B8G8R8A8Srgb or Format.B8G8R8A8Unorm &&
|
|
format.ColorSpace == ColorSpaceKHR.SpaceSrgbNonlinearKhr)
|
|
{
|
|
return format;
|
|
}
|
|
}
|
|
|
|
return formats.Count > 0
|
|
? formats[0]
|
|
: throw new InvalidOperationException("The Vulkan surface exposes no pixel formats.");
|
|
}
|
|
|
|
private static CompositeAlphaFlagsKHR ChooseCompositeAlpha(CompositeAlphaFlagsKHR supported)
|
|
{
|
|
foreach (var candidate in new[]
|
|
{
|
|
CompositeAlphaFlagsKHR.OpaqueBitKhr,
|
|
CompositeAlphaFlagsKHR.PreMultipliedBitKhr,
|
|
CompositeAlphaFlagsKHR.PostMultipliedBitKhr,
|
|
CompositeAlphaFlagsKHR.InheritBitKhr,
|
|
})
|
|
{
|
|
if ((supported & candidate) != 0)
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException("The Vulkan surface exposes no composite alpha mode.");
|
|
}
|
|
|
|
private static ImageSubresourceRange ColorSubresourceRange(
|
|
uint baseMipLevel = 0,
|
|
uint levelCount = 1) =>
|
|
new()
|
|
{
|
|
AspectMask = ImageAspectFlags.ColorBit,
|
|
BaseMipLevel = baseMipLevel,
|
|
LevelCount = levelCount,
|
|
LayerCount = 1,
|
|
};
|
|
|
|
private static byte[] ScaleBgra(byte[] source, uint sourceWidth, uint sourceHeight, uint width, uint height)
|
|
{
|
|
var destination = new byte[checked((int)(width * height * 4))];
|
|
for (uint y = 0; y < height; y++)
|
|
{
|
|
var sourceY = (uint)(((ulong)y * sourceHeight) / height);
|
|
for (uint x = 0; x < width; x++)
|
|
{
|
|
var sourceX = (uint)(((ulong)x * sourceWidth) / width);
|
|
var sourceOffset = checked((int)(((ulong)sourceY * sourceWidth + sourceX) * 4));
|
|
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
|
|
source.AsSpan(sourceOffset, 4).CopyTo(destination.AsSpan(destinationOffset, 4));
|
|
}
|
|
}
|
|
|
|
return destination;
|
|
}
|
|
|
|
private static byte[] ScaleBgraCoverBilinear(
|
|
byte[] source,
|
|
uint sourceWidth,
|
|
uint sourceHeight,
|
|
uint width,
|
|
uint height)
|
|
{
|
|
var destination = new byte[checked((int)(width * height * 4))];
|
|
var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
|
|
var cropWidth = sourceIsWider
|
|
? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
|
|
: sourceWidth;
|
|
var cropHeight = sourceIsWider
|
|
? sourceHeight
|
|
: Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
|
|
var offsetX = (sourceWidth - cropWidth) / 2;
|
|
var offsetY = (sourceHeight - cropHeight) / 2;
|
|
var maxSourceX = offsetX + cropWidth - 1;
|
|
var maxSourceY = offsetY + cropHeight - 1;
|
|
|
|
for (uint y = 0; y < height; y++)
|
|
{
|
|
for (uint x = 0; x < width; x++)
|
|
{
|
|
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
|
|
float blue = 0;
|
|
float green = 0;
|
|
float red = 0;
|
|
float alpha = 0;
|
|
for (var sampleY = 0; sampleY < 2; sampleY++)
|
|
{
|
|
var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
|
|
var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
|
|
var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
|
|
var fractionY = scaledY - MathF.Floor(scaledY);
|
|
for (var sampleX = 0; sampleX < 2; sampleX++)
|
|
{
|
|
var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
|
|
var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
|
|
var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
|
|
var fractionX = scaledX - MathF.Floor(scaledX);
|
|
var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
|
|
var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
|
|
var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
|
|
var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
|
|
|
|
var topBlue = source[sourceOffset00] +
|
|
((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
|
|
var bottomBlue = source[sourceOffset01] +
|
|
((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
|
|
blue += topBlue + ((bottomBlue - topBlue) * fractionY);
|
|
|
|
var topGreen = source[sourceOffset00 + 1] +
|
|
((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
|
|
var bottomGreen = source[sourceOffset01 + 1] +
|
|
((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
|
|
green += topGreen + ((bottomGreen - topGreen) * fractionY);
|
|
|
|
var topRed = source[sourceOffset00 + 2] +
|
|
((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
|
|
var bottomRed = source[sourceOffset01 + 2] +
|
|
((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
|
|
red += topRed + ((bottomRed - topRed) * fractionY);
|
|
|
|
var topAlpha = source[sourceOffset00 + 3] +
|
|
((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
|
|
var bottomAlpha = source[sourceOffset01 + 3] +
|
|
((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
|
|
alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
|
|
}
|
|
}
|
|
|
|
destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
|
|
destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
|
|
destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
|
|
destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
|
|
}
|
|
}
|
|
|
|
return destination;
|
|
}
|
|
|
|
private void DisposeVulkan()
|
|
{
|
|
if (!_vulkanReady)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_debugUtils is not null && _debugMessenger.Handle != 0)
|
|
{
|
|
_debugUtils.DestroyDebugUtilsMessenger(_instance, _debugMessenger, null);
|
|
}
|
|
_vulkanReady = false;
|
|
_vk.DeviceWaitIdle(_device);
|
|
SavePipelineCache(force: true);
|
|
DrainFrameSlots();
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
ClearCachedTextureIdentities();
|
|
foreach (var pipeline in _computePipelines.Values)
|
|
{
|
|
_vk.DestroyPipeline(_device, pipeline, null);
|
|
}
|
|
_computePipelines.Clear();
|
|
foreach (var pipeline in _graphicsPipelines.Values)
|
|
{
|
|
_vk.DestroyPipeline(_device, pipeline, null);
|
|
}
|
|
_graphicsPipelines.Clear();
|
|
foreach (var layout in _descriptorLayouts.Values)
|
|
{
|
|
_vk.DestroyPipelineLayout(_device, layout.PipelineLayout, null);
|
|
if (layout.DescriptorSetLayout.Handle != 0)
|
|
{
|
|
_vk.DestroyDescriptorSetLayout(
|
|
_device,
|
|
layout.DescriptorSetLayout,
|
|
null);
|
|
}
|
|
}
|
|
_descriptorLayouts.Clear();
|
|
while (_recycledDescriptorPools.TryPop(out var recycledDescriptorPool))
|
|
{
|
|
_vk.DestroyDescriptorPool(_device, recycledDescriptorPool, null);
|
|
}
|
|
foreach (var sampler in _samplers.Values)
|
|
{
|
|
_vk.DestroySampler(_device, sampler, null);
|
|
}
|
|
_samplers.Clear();
|
|
_shaderDigests.Clear();
|
|
WriteBackAllDirtyGuestBuffers();
|
|
foreach (var allocation in _guestBufferAllocations)
|
|
{
|
|
DestroyGuestBufferAllocation(allocation);
|
|
}
|
|
_guestBufferAllocations.Clear();
|
|
_hostBufferPool.Dispose();
|
|
foreach (var guestImage in _guestImages.Values)
|
|
{
|
|
DestroyGuestImage(guestImage);
|
|
}
|
|
_guestImages.Clear();
|
|
foreach (var guestImageVariant in _guestImageVariants.Values)
|
|
{
|
|
DestroyGuestImage(guestImageVariant);
|
|
}
|
|
_guestImageVariants.Clear();
|
|
foreach (var guestImageVersion in _guestImageVersions.Values)
|
|
{
|
|
DestroyGuestImage(guestImageVersion);
|
|
}
|
|
_guestImageVersions.Clear();
|
|
_capturedGuestFlipVersions.Clear();
|
|
while (_deferredGuestImageVersionDestroys.TryDequeue(out var deferredVersion))
|
|
{
|
|
DestroyGuestImage(deferredVersion.Image);
|
|
}
|
|
foreach (var guestDepth in _guestDepthImages.Values)
|
|
{
|
|
DestroyGuestDepth(guestDepth);
|
|
}
|
|
_guestDepthImages.Clear();
|
|
lock (_gate)
|
|
{
|
|
_availableGuestImages.Clear();
|
|
_lastOrderedGuestFlipVersions.Clear();
|
|
}
|
|
DestroySwapchainResources();
|
|
if (_device.Handle != 0)
|
|
{
|
|
if (_pipelineCache.Handle != 0)
|
|
{
|
|
_vk.DestroyPipelineCache(_device, _pipelineCache, null);
|
|
_pipelineCache = default;
|
|
}
|
|
_vk.DestroyDevice(_device, null);
|
|
_device = default;
|
|
}
|
|
if (_surface.Handle != 0)
|
|
{
|
|
_surfaceApi.DestroySurface(_instance, _surface, null);
|
|
_surface = default;
|
|
}
|
|
if (_instance.Handle != 0)
|
|
{
|
|
_vk.DestroyInstance(_instance, null);
|
|
_instance = default;
|
|
}
|
|
}
|
|
|
|
private void RecreateSwapchainResources(string operation, Result result)
|
|
{
|
|
if (_device.Handle == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Check(
|
|
_surfaceApi.GetPhysicalDeviceSurfaceCapabilities(
|
|
_physicalDevice,
|
|
_surface,
|
|
out var capabilities),
|
|
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
|
|
var framebufferSize = GetFramebufferSize();
|
|
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
|
|
var surfaceWidth = hasFixedExtent
|
|
? capabilities.CurrentExtent.Width
|
|
: (uint)Math.Max(framebufferSize.X, 0);
|
|
var surfaceHeight = hasFixedExtent
|
|
? capabilities.CurrentExtent.Height
|
|
: (uint)Math.Max(framebufferSize.Y, 0);
|
|
if (surfaceWidth <= 1 || surfaceHeight <= 1)
|
|
{
|
|
if (!_swapchainRecreateDeferred)
|
|
{
|
|
_swapchainRecreateDeferred = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut deferred swapchain recreation: " +
|
|
$"surface={surfaceWidth}x{surfaceHeight}");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
_swapchainRecreateDeferred = false;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut recreating swapchain after {operation}: {result}");
|
|
_vk.DeviceWaitIdle(_device);
|
|
DrainFrameSlots();
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
DestroySwapchainResources();
|
|
CreateSwapchain();
|
|
CreateCommandResources();
|
|
CreateGuestDrawResources();
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut recreated swapchain: " +
|
|
$"{_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
|
|
}
|
|
|
|
private void DestroySwapchainResources()
|
|
{
|
|
if (_stagingBuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyBuffer(_device, _stagingBuffer, null);
|
|
_stagingBuffer = default;
|
|
}
|
|
if (_stagingMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, _stagingMemory, null);
|
|
_stagingMemory = default;
|
|
_stagingSize = 0;
|
|
}
|
|
foreach (var semaphore in _frameImageAvailable)
|
|
{
|
|
if (semaphore.Handle != 0)
|
|
{
|
|
_vk.DestroySemaphore(_device, semaphore, null);
|
|
}
|
|
}
|
|
_frameImageAvailable = [];
|
|
foreach (var semaphore in _renderFinishedPerImage)
|
|
{
|
|
if (semaphore.Handle != 0)
|
|
{
|
|
_vk.DestroySemaphore(_device, semaphore, null);
|
|
}
|
|
}
|
|
_renderFinishedPerImage = [];
|
|
if (_overlayImage.Handle != 0)
|
|
{
|
|
_vk.DestroyImage(_device, _overlayImage, null);
|
|
_overlayImage = default;
|
|
}
|
|
if (_overlayImageMemory.Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, _overlayImageMemory, null);
|
|
_overlayImageMemory = default;
|
|
}
|
|
for (var slot = 0; slot < _overlayStagingBuffers.Length; slot++)
|
|
{
|
|
if (_overlayStagingBuffers[slot].Handle != 0)
|
|
{
|
|
_vk.DestroyBuffer(_device, _overlayStagingBuffers[slot], null);
|
|
}
|
|
if (_overlayStagingMemory[slot].Handle != 0)
|
|
{
|
|
_vk.FreeMemory(_device, _overlayStagingMemory[slot], null);
|
|
}
|
|
}
|
|
_overlayStagingBuffers = [];
|
|
_overlayStagingMemory = [];
|
|
_overlayStagingMapped = [];
|
|
_overlayImageInitialized = false;
|
|
foreach (var fence in _frameFences)
|
|
{
|
|
if (fence.Handle != 0)
|
|
{
|
|
_vk.DestroyFence(_device, fence, null);
|
|
}
|
|
}
|
|
_frameFences = [];
|
|
_frameFencePending = [];
|
|
_frameTimelines = [];
|
|
_frameTranslatedResources = [];
|
|
_frameGuestImageVersions = [];
|
|
while (_recycledGuestFences.TryPop(out var recycledFence))
|
|
{
|
|
_vk.DestroyFence(_device, recycledFence, null);
|
|
}
|
|
if (_barycentricPipeline.Handle != 0)
|
|
{
|
|
_vk.DestroyPipeline(_device, _barycentricPipeline, null);
|
|
_barycentricPipeline = default;
|
|
}
|
|
if (_pipelineLayout.Handle != 0)
|
|
{
|
|
_vk.DestroyPipelineLayout(_device, _pipelineLayout, null);
|
|
_pipelineLayout = default;
|
|
}
|
|
foreach (var framebuffer in _framebuffers)
|
|
{
|
|
if (framebuffer.Handle != 0)
|
|
{
|
|
_vk.DestroyFramebuffer(_device, framebuffer, null);
|
|
}
|
|
}
|
|
if (_renderPass.Handle != 0)
|
|
{
|
|
_vk.DestroyRenderPass(_device, _renderPass, null);
|
|
_renderPass = default;
|
|
}
|
|
foreach (var imageView in _swapchainImageViews)
|
|
{
|
|
if (imageView.Handle != 0)
|
|
{
|
|
_vk.DestroyImageView(_device, imageView, null);
|
|
}
|
|
}
|
|
if (_commandPool.Handle != 0)
|
|
{
|
|
// Destroying the pool frees every command buffer allocated
|
|
// from it, including recycled and per-frame ones.
|
|
_recycledGuestCommandBuffers.Clear();
|
|
_frameCommandBuffers = [];
|
|
_vk.DestroyCommandPool(_device, _commandPool, null);
|
|
_commandPool = default;
|
|
_commandBuffer = default;
|
|
_presentationCommandBuffer = default;
|
|
}
|
|
if (_swapchain.Handle != 0)
|
|
{
|
|
_swapchainApi.DestroySwapchain(_device, _swapchain, null);
|
|
_swapchain = default;
|
|
}
|
|
|
|
_swapchainImages = [];
|
|
_swapchainImageViews = [];
|
|
_framebuffers = [];
|
|
_imageInitialized = [];
|
|
}
|
|
|
|
private static void CheckSwapchainResult(Result result, string operation)
|
|
{
|
|
if (result is Result.Success or Result.SuboptimalKhr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (result == Result.ErrorDeviceLost)
|
|
{
|
|
throw new VulkanDeviceLostException(operation);
|
|
}
|
|
|
|
throw new InvalidOperationException($"{operation} failed with {result}.");
|
|
}
|
|
|
|
private static void Check(Result result, string operation)
|
|
{
|
|
if (result == Result.ErrorDeviceLost)
|
|
{
|
|
throw new VulkanDeviceLostException(operation);
|
|
}
|
|
|
|
if (result != Result.Success)
|
|
{
|
|
throw new InvalidOperationException($"{operation} failed with {result}.");
|
|
}
|
|
}
|
|
|
|
// Typed so the frame-boundary catch can recognize device loss without
|
|
// depending on the exact wording of the exception message.
|
|
private sealed class VulkanDeviceLostException(string operation)
|
|
: InvalidOperationException($"{operation} failed with {Result.ErrorDeviceLost}.");
|
|
|
|
private bool TryMarkDeviceLost(Exception exception)
|
|
{
|
|
// Prefer the typed signal; fall back to the message for losses that
|
|
// surface through other layers (e.g. Silk.NET bindings).
|
|
if (exception is not VulkanDeviceLostException &&
|
|
!exception.Message.Contains(nameof(Result.ErrorDeviceLost), StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_deviceLost = true;
|
|
if (!_deviceLostLogged)
|
|
{
|
|
_deviceLostLogged = true;
|
|
Console.Error.WriteLine(
|
|
"[LOADER][ERROR] Vulkan device lost; dropping subsequent guest GPU work. " +
|
|
exception.Message);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static void TraceVulkanShader(string message)
|
|
{
|
|
if (!_traceVulkanShaderEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Console.Error.WriteLine($"[LOADER][TRACE] {message}");
|
|
}
|
|
}
|
|
}
|