Compare commits

..

12 Commits

Author SHA1 Message Date
MechaCat02
6bb4355e3d [iterate-3M] Fix Xenos shader CF/fetch decode so the textured logo binds
The publisher splash (title idx0) rendered FLAT in ours while canary samples
a texture: ours never decoded the logo's textured pixel shader
(E59B2B3D, a `tfetch2D` sprite) even though our guest IM_LOADs the exact same
microcode canary does (verified byte-identical against the Wine oracle). The
shader was misparsed as flat. Three coupled bugs in the ucode decoder, all
off vs canary `gpu/ucode.h`:

1. CF opcode table was off-by-one (`control_flow.rs`): mapped opcode 0→Exec
   and 1→Exit, but Xenos has 0=kNop, 1=kExec, 2=kExecEnd, 3..6/13..14 the
   cond-exec variants, 7/8 loop, 9/10 call/return, 11 condjmp, 12 alloc,
   15 mark-vs-fetch-done. So a real `kExec` clause was read as a terminal
   `Exit`, truncating the CF block and dropping every instruction (incl. the
   `tfetch`) after it. Added Nop/MarkVsFetchDone variants; parse now ends on
   an END-bit exec clause.

2. exec/loop `address` is an absolute instruction-triple index from shader
   dword 0, but indexed our post-CF `instructions` slice directly
   (`ucode/mod.rs`). Rebase addresses by the CF triple count so `address*3`
   lands on the right instruction.

3. Fetch instruction bitfields were wrong (`ucode/fetch.rs`): `const_index`
   read from bit 5 (actually `src_reg`) instead of bit 20, and texture
   `dimension` from dword1 instead of dword2 bit14. The logo's `tfetch ..,tf0`
   was read as `tf1`, whose empty fetch-constant failed to decode → no
   texture. Also the `sequence` fetch/ALU bit is bit[0] of each pair, not
   bit[1] (`shader_metrics.rs`, `translator.rs`, `xenos_interp.wgsl`).

Result (--gpu-inline, deterministic 2x): the active PS's `tfetch_slots` now
resolves slot 0, the tf0 fetch-constant decodes (fmt K8888), and
`gpu.texture.decode` fires (137x at -n 50M; texture_cache_entries 0→1, the
only golden field that changed — all draw/swap counts unchanged). The same
fixes correct the WGSL uber-shader's fetch/CF walk for the threaded/--ui path.

Added a regression test that parses the real E59B2B3D microcode and asserts a
tfetch slot is found. Golden re-baselined (texture_cache_entries 0→1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:53:35 +02:00
MechaCat02
3f5d5cf5f7 [iterate-2Z] Implement NtSetInformationFile FileRenameInformation for cache: files
The GamePart title-logo gate first-divergence: Sylpheed's asset cache
decompresses each packed resource to a staging `cache:\<hash><tail>.tmp`
file, then renames it into its final nested path `cache:\<hash>\<dir>\<file>`
(e.g. the title logo texture `\69d8e45c\e\534ffea`) via
NtSetInformationFile class 10 (XFileRenameInformation). Our handler treated
class 10 as a permissive no-op (catch-all `_ => STATUS_SUCCESS`), so the host
rename never happened: the nested target directories were created but left
EMPTY while the decompressed data stayed in the flat `.tmp` file. When the
title later reads back `\69d8e45c\...` to build the logo texture the read
misses, so the textured logo pixel shader (canary `E59B2B3D`, tfetch2D) is
never dispatched and the logo never renders.

Fix: implement class 10 faithfully, mirroring canary
`xboxkrnl_io_info.cc:226` (`X_FILE_RENAME_INFORMATION{ replace_existing@0,
root_dir_handle@4, ANSI_STRING@8 }` -> `file->Rename(TranslateAnsiPath)`).
Read the target path from the embedded ANSI_STRING at info_ptr+8, resolve it
against the host cache backing dir (`resolve_cache_path`), create the parent
dirs, `std::fs::rename` the backing file, and update the handle's `path` +
`host_path`. Non-cache (read-only VFS) sources keep the prior permissive
acknowledge. Verified at runtime: 20 renames/80M now move
`69d8e45ce534ffea.tmp -> 69d8e45c/e/534ffea` etc., and the nested cache tree
now matches canary's HostPathDevice layout byte-for-byte (data present, not
empty dirs).

Made `path::read_ansi_string` pub so the handler can parse the rename target.

Deterministic + golden-invariant: two `check --gpu-inline --stable-digest
-n 50000000` runs are byte-identical and the 50M stable digest is unchanged
(draws=718/swaps=147/6 shaders/tex=0); the logo read-back occurs later than
the observable window so GPU counters at 1B/2.5B are unchanged
(2.5B: draws=48734, swaps=16060, still 6 flat shaders, texture_decodes=0).
The fix is a verified-necessary precondition — without it the nested asset
read-back is guaranteed to miss. A downstream gate (the 2nd title thread's
load-completion post skipped when its notify target `[r29+8]==0`, and the
later read-back phase being beyond 2.5B) remains for follow-up.

New test: `nt_set_information_file_rename_moves_cache_file` (678 total, was
677).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:33:25 +02:00
MechaCat02
2f55d1fd7d [iterate-2X] Texture pipeline: un-stub RectangleList + draw-time texture decode
Two faithful, deterministic GPU-backend changes that make the texture path
correct for whatever textured draw the splash eventually dispatches. Both are
currently inert on Sylpheed (the textured logo draw is still gated downstream
— see below), but neither shifts the stable-digest golden, so they land safely.

1. Un-stub RectangleList primitive expansion (primitive.rs). The splash submits
   2819 RectangleList draws at 200M, all of which were REJECTED by the P3 stub
   (`gpu.primitive.rejected{rectangle_list}`) → only ~592 flat point/quad draws
   rasterized. Mirror canary's intent (primitive_processor.cc:389-456
   kRectangleListAsTriangleStrip) within our CPU index-rewrite idiom: emit each
   rect's 3 real vertices as one TriangleList triangle (v0,v1,v2), rejected=false,
   faithful host_vertex_count. The full quad (synthesized 4th corner v3=v0+v2-v1)
   needs real vertex fetch in vs_main — left as a documented TODO. Rejection
   warnings drop 2819→0.

2. Draw-time texture decode keyed off the active PS's real tfetch slots
   (gpu_system.rs + exports.rs vd_swap). Previously vd_swap decoded a hardcoded
   fetch-constant slot 0 at swap time. Now the DRAW handler parses the bound
   pixel shader (ucode::parse_shader), collects its tfetch fetch_const slots via
   new shader_metrics::tfetch_slots, reads each 6-dword fetch constant, and
   decode+caches it into GpuSystem::last_draw_textures. vd_swap publishes the
   first of these (UI binds one texture today), falling back to the legacy slot-0
   probe on flat-only frames. New span_max_version helper walks page_version over
   the trait (draw-time &dyn MemoryAccess lacks the heap's inherent
   max_page_version). Pure function of guest writes — deterministic.

Status: texture_decodes stays 0 on Sylpheed because all 6 live shaders are flat
(no tfetch); canary's textured logo shaders E59B2B3D/F7B1457 are not yet
dispatched by ours (a downstream title-state gate, the next frontier). The full
P5 decode→publish→upload→sample path is already wired; this makes the decode
side key off the real shader instead of a guess.

Validation: stable-digest golden sylpheed_n50m unchanged (draws=718 swaps=147
tex=0), regenerated twice byte-identical; 200M run shows 0 RectangleList
rejections. cargo test --workspace green (677, +2: rectangle_list_expansion,
tfetch_slots_extracts_texture_fetch_constants). No temp hooks. Branch only;
not pushed/merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:34:43 +02:00
MechaCat02
a91f4c550b [iterate-2W] Sustain the title present loop: viewport-size register + ISR CPU impersonation
The title's per-frame loop (sub_822F1AA8) is clock-B-paced and only re-fires
when the swap count [controller+88] changes, which advances only on source=1
CP swap-complete interrupts. Each present batch the guest submits (via the
sub_824CE348 -> sub_824BF4D0 builder) ends with a WAIT_REG_MEM on a per-CPU
swap-acknowledge fence [GCTX+0] (GCTX = [device+10772]); the GPU parks there
until the graphics ISR (sub_824BE9A0) clears that CPU's bit. Two coupled gaps
kept ours emitting only ONE source=1 then dead-locking (draws plateaued at 28,
run halted ~19.27M):

1. GPU MMIO register 0x1961 (AVIVO_D1MODE_VIEWPORT_SIZE) read as 0. The swap
   callback sub_824CE2B8 divides by its low 12 bits (display height) as a
   refresh-pacing term, so a 0 read tripped its `twi` divide-by-zero guard and
   aborted the ISR before it reached the fence-clear. Mirror canary
   GraphicsSystem::ReadRegister (graphics_system.cc:311): return 0x050002D0
   (1280x720).

2. The ISR ran on an arbitrary borrowed thread, so [r13+268] (the PCR
   processor number) did not match the interrupt's target CPU. The ISR clears
   `1 << current_cpu` from the fence; running on the wrong CPU cleared the
   wrong bit and the fence (bit 2, from cpu_mask 0x4) never reached 0. Carry
   the target CPU through the interrupt queue (bit index of the PM4_INTERRUPT
   cpu_mask for CP, 2 for vsync per canary DispatchInterruptCallback(0, 2)) and
   impersonate it on the borrowed thread's PCR around the ISR, mirroring canary
   EmulateCPInterruptDPC -> XThread::SetActiveCpu.

With both fixes the fence clears, the GPU drains each present batch, source=1
sustains per-present, clock B advances, and the loop runs continuously. Draws
climb linearly with the budget (no re-stall): 50M 28->718, 200M ->3411,
1B ->18734; swaps 2->147/950/6060. No "Unanticipated CPU_INTERRUPT" trap.
Inline-deterministic (--stable-digest byte-identical x2); n50m golden
re-baselined. 675 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:49:32 +02:00
MechaCat02
66bd805726 [iterate-2V] VdSwap: stop bumping primary CP_RB_WPTR out-of-band (canary-faithful)
Ours' `vd_swap` wrote its 64-dword XE_SWAP block at the guest's reserved
`buffer_ptr` slot AND then bumped the primary ring `CP_RB_WPTR` out-of-band
via `state.gpu.extend_write_ptr_by(64)`. That bump was a bug: `buffer_ptr`
(~0x4add6efc) is NOT inside the primary ring (base ~0x4adcd000, 8192 dwords)
— it lives ~10k dwords past it, in the renderer indirect-buffer region. The
bogus WPTR bump pushed the GPU read-pointer PAST the guest's real
write-pointer; the drain treated the overshoot as a circular wrap and
re-executed the splash's draw indirect-buffers ~2×, inflating draws to 78
(the real splash geometry is ~28 draws; 12 INDIRECT_BUFFERs vs the real 6).

Canary's `VdSwap_entry` (xenia-canary xboxkrnl_video.cc:518-548) writes the
fetch-constant patch + PM4_XE_SWAP + NOP pad into the reserved slot and
returns — it NEVER touches CP_RB_WPTR. The guest advances the primary ring
write-pointer itself via its own doorbell once it has populated the slot;
swap-complete CP interrupts come only from the game's in-stream PM4_INTERRUPT
packets, never from VdSwap.

This fix removes only the out-of-band `extend_write_ptr_by(64)` call, keeping
the buffer_ptr block write intact and byte-faithful to canary. Effect at
`--gpu-inline -n 50M`: draws 78→28, INDIRECT_BUFFER 12→6 (re-execution
artifact gone), swaps 4→2. The run now halts at ~19.27M instructions (worker
threads exit) instead of spinning to 50M, because removing the corruption
unmasks the real per-present-interrupt deadlock — the title loop needs a
per-present PM4_INTERRUPT that the stalled game never submits. That deadlock
is a SEPARATE, known gate tracked/addressed elsewhere; it is intentionally
NOT papered over here.

Re-baselined golden crates/xenia-app/tests/golden/sylpheed_n50m.json to the
new honest values (regenerated twice, byte-identical). sylpheed_n2m.json is
unaffected (draws=0 at 2M). cargo test --workspace: 675 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:58:05 +02:00
MechaCat02
ad9c8e4cb8 [iterate-2U] VdGlobalDevice: allocate a real device cell so the swap counter (clock B) can advance
Sylpheed's title loop re-runs its per-frame manager update sub_821741C8
only when "clock B" ([controller+88], the swap count) changes. Clock B's
sole source is the CP swap-complete callback sub_824CE2B8, which bumps
[gfx+15160] via the TWO-LEVEL deref [[VdGlobalDevice]+0]+15160, where
VdGlobalDevice is the kernel variable export 0x01BE at guest .data
0x82000750.

Ours patched that import slot with literal 0 (the old "passed through to
Vd* shims, write 0" behaviour). Consequences, both confirmed at runtime:
  * the guest's graphics init stores its D3D device object via
    `stw r31, 0([0x82000750])` (sub_824C6DC0 @0x824C6F18) — with the slot
    0, that store lands at address 0;
  * the swap callback reads [[0x82000750]] = [0] = 0 and increments
    [0+15160] (the null page) instead of the real device's swap counter.
So [gfx+15160] never moved, clock B stayed frozen at 0, sub_821741C8
fired exactly once, and the game submitted one render batch (the 78-draw
splash) then stalled.

Fix mirrors xenia-canary RegisterVideoExports (xboxkrnl_video.cc:557-564)
exactly: allocate a 4-byte cell, point the import slot at it, zero the
cell. The guest then stores its device into the cell, and the callback's
two-level deref resolves correctly. Verified: [0x82000750] now holds a
real cell whose [+0] is the device (gfx state), the swap callback bumps
[gfx+15160] 0->1, clock B advances, and the per-frame chain steps forward
(sub_821741C8 fires 1->2x, GamePart update sub_821C7CB8 0->1x).

Determinism: --gpu-inline digest re-baselined and byte-identical across
runs. The fix shifts the early execution trajectory (clock B unfreezing),
so the n50m golden moves imports 451500->178937 and instructions
50000001->50000014; draws/swaps/RTs/shaders unchanged (78/4/2/3). n2m
golden unchanged (early boot, pre-fix-effect). 675 workspace tests green;
sylpheed_n50m oracle green.

Note: this breaks the FIRST hard blocker (clock B could never advance at
all). Full per-frame sustain (draws past 78) needs a further step: each
GamePart update must submit a per-frame command buffer (with PM4_INTERRUPT)
during the asset-streaming phase to keep generating CP interrupts; ours
currently produces only the single seed interrupt from the initial batch,
so the chain advances once and re-stalls. Tracked for the next iterate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:20:08 +02:00
MechaCat02
873c197ff1 [iterate-2T] VdSwap: route present through ring PM4_XE_SWAP, drop out-of-band swap interrupt
Make ours' VdSwap present path faithful to xenia-canary `VdSwap_entry`
(xboxkrnl_video.cc:518-548): write the reserved 64-dword ring slot with a
PM4_TYPE0 fetch-constant patch + PM4_TYPE3(PM4_XE_SWAP) + NOP padding, then
let the natural drain consume the swap packet in command-stream order. Remove
the synthetic CP swap-complete interrupt that `notify_xe_swap` raised
out-of-band.

Root found this session (the actual present-path bug): ours' `notify_xe_swap`
pushed an `InterruptSource::Swap` (→ INTERRUPT_SOURCE_CP) interrupt directly
from the VdSwap HLE, decoupled from the GPU command stream. When that interrupt
reached the graphics ISR `sub_824BE9A0` before D3D had armed its swap-callback
slot (`[gfx+10772]+16` still the `0xBADF00D` placeholder), the ISR took its
error path and hit the assert "ERR[D3D]: Unanticipated CPU_INTERRUPT. Sign of a
corrupt command buffer?" (`bl sub_824C5DF0; twi` at 0x824BE9DC) — 2x per run on
master. Canary's VdSwap raises NO interrupt; swap-complete CP interrupts come
only from in-stream PM4_INTERRUPT packets, which are naturally ordered after the
callback-arming Type-0 writes. Routing the swap through the ring packet matches
that ordering and eliminates the trap (2 -> 0).

Canary oracle confirmation (muted, audit_mem_watch + audit_jit_prolog_pc):
canary's early/loading loop is present-driven — swap counter [gfx+15160]
(0xBE56CA38) advances ~per-vblank from vblank 65 onward, reaching 0xD02 (3330)
in ~60s via 6184 CP source=1 interrupts, with VdSwap called only ONCE. So the
present interrupts are entirely in-stream, not from the VdSwap export.

This is a correctness/faithfulness fix; it does NOT cascade. draws stay 78 at
200M and 1B because the upstream gate persists: the game submits one render
batch then stalls (renderer sub_82506xxx 0x; 2nd title thread 0x821748F0 never
spawns). The per-frame loop sub_822F1AA8 runs ~1207 iterations on vsync but
clock B (swap count) only advances ~once, so the manager update sub_821741C8
fires once. That is the iterate-2Q/2F title-pipeline gate, not a present/
interrupt bug. swaps 3 -> 4 (the in-stream PM4_XE_SWAP now drains).

Deterministic in inline mode (n50m --gpu-inline --stable-digest regenerated
byte-identical twice; golden re-baselined: swaps 3 -> 4). cargo test --workspace
675 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:20:02 +02:00
MechaCat02
1ae472bd2b [iterate-2S] GPU: implement CP SCRATCH_REG memory writeback — arms Sylpheed's swap-callback slot
Sylpheed renders the splash (draws=78, iterate-2O) then plateaus: the
title's per-frame manager (sub_821741C8) only re-fires when "clock B"
([gfx+15160], swap count) changes, which only the CP swap-complete
callback sub_824CE2B8 increments. The graphics ISR sub_824BE9A0
indirect-calls that callback via [[gfx+10772]+16] on CP (source=1)
interrupts, but the slot stayed NULL so the callback never ran.

Root (runtime-verified, ours-side GPU): the guest arms the slot through
the Xenos CP scratch-register writeback path, which ours never
implemented. The arming IB (drained by ours at 0x4adf5180) contains a
Type-0 register write of the callback PC 0x824ce2b8 into SCRATCH_REG4
(0x057C). On hardware/canary, writing a SCRATCH_REG{n} mirrors the value
to SCRATCH_ADDR + n*4 in memory when the matching SCRATCH_UMSK bit is
set. Runtime values: SCRATCH_ADDR=0x0b1d5000 (the [gfx+10772]
descriptor), SCRATCH_UMSK=0x20033 (bit 4 set), so SCRATCH_REG4 ->
0x0b1d5010 = descriptor+16 = the callback slot (0x4b1d5010). Ours
decoded the Type-0 write into the register file but performed no
writeback (case a: drained-but-mishandled), so the slot stayed NULL.

Fix mirrors canary's CommandProcessor::HandleSpecialRegisterWrite
(command_processor.cc:545-552): a scratch_register_writeback() helper
called from handle_type0/handle_type1 after every register write; for
SCRATCH_REG0..7 with the UMSK bit set, it writes the value (big-endian,
as mem.write_u32 already stores) to SCRATCH_ADDR + n*4 (projected via
physical_to_backing). Deterministic given identical register state;
proven by unit test.

Cascade (verified by runtime probe): slot 0x4b1d5010 now armed with
0x824ce2b8; on the 2-3 CP interrupts that fire, the ISR reads the slot
and bcctrl's into sub_824CE2B8 (runs 2x; 0x cascade on master);
sub_824CE2B8 increments clock B ([gfx+15160]). The cascade does NOT yet
reach draws>78: there are only ~3 CP interrupts (from the initial 9825-
packet batch), and the title render loop stalls upstream (the iterate-2Q
title-respawn gate) before it submits more PM4_INTERRUPT work, so the
callback can't bootstrap a self-sustaining loop. This is the remaining
update-17/18 arming gap closed; the upstream stall is the next gate.

The default threaded GPU backend drains the ring on a separate host
thread, so with the callback now doing work the exact CP-interrupt
delivery instruction varies run to run (pre-existing GPU-thread race).
Pin the n50m oracle test to --gpu-inline (instruction-count
deterministic) and re-baseline its golden; bit-exact across repeated
runs. New unit test scratch_reg_write_mirrors_to_memory_when_umsk_enabled.

Tests: 675 pass (was 674). Golden re-baselined + determinism verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:30 +02:00
MechaCat02
034ec8b47f [iterate-2O] GPU: drain indirect buffers correctly — Sylpheed renders splash (draws 0→78)
Ours' GPU never drained the D3D driver's system command buffer past the first
11-dword indirect buffer, so DRAW_INDX / reg-0x57C-arm packets never executed
and draws stayed 0 (the long-hunted render gate; see UPDATE-18). Runtime tracing
(temporary, removed) showed the guest submits 6 INDIRECT_BUFFER packets at boot
(CP_RB_WPTR 22→37) but ours executed exactly ONE IB and then spun 15.7M packets
inside it. Three coupled command-processor bugs, all corrected to match canary:

1. `sync_with_mmio` applied the primary CP_RB_WPTR to whichever ring was active,
   including an executing indirect buffer — `37 % 11 = 3` clobbered the IB's
   write pointer so its read pointer looped 0→2→5→0 forever and never popped
   back to the primary ring. CP_RB_WPTR governs ONLY the primary ring; while an
   IB executes, the primary is the bottom of the IB stack. Canary executes each
   IB through a separate `RingBuffer reader_` (command_processor.cc), so the
   primary write pointer is structurally inapplicable to an IB.

2. Indirect buffers were treated as circular rings: read wrapped at `size_dwords`
   (`11 % 11 = 0`) and never reached the fixed write pointer, so even without the
   clobber the IB could not terminate. An IB is a fixed *linear* sub-stream; add
   `RingBufferView.indirect` and drain `[0, ib_size)` monotonically, then pop.

3. `is_ready` only checked the active ring, so an IB that now correctly exhausts
   would never get `execute_one` called again to pop back to the primary ring
   (whose WPTR may have advanced). Check the whole IB stack.

Also: the ring was sized `1 << size_log2` bytes (1024 dwords) vs canary's
`1 << (size_log2 + 3)` (8192 dwords) — an 8× undersize that desynced WPTR-wrap
math from the guest. Fixed in `GpuSystem::initialize_ring_buffer` (and the
dead bookkeeping copy in `vd_initialize_ring_buffer`).

Cascade (deterministic; threaded-default backend, byte-identical across runs):
reg 0x57C now written, IB jumps 1→12, packets 15.7M→9,825, and the splash
renders — draws 0→78, shaders 0→3, render_targets 0→2, swaps 2→3 — stable at
50M / 200M / 1B. Boot then reaches a new downstream gate (draws plateau at 78,
interrupts keep climbing → engine alive, not deadlocked).

golden `sylpheed_n50m.json` re-baselined (draws 78). `cargo test --workspace`
green (674; +2 ring_view regression tests). vd_swap's synthetic-swap
short-circuit is now redundant but left untouched (cascade works without
changing it); cleaning it up is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:06:16 +02:00
MechaCat02
93f60a3ba0 [iterate-2M] PCR+0x10C (PRCB.current_cpu): init per-HW-thread to unwedge spin-barrier
Ours never initialized the PRCB `current_cpu` byte at PCR+0x10C
(prcb_data@0x100 + current_cpu@0xC). Canary sets it from
`GetFakeCpuNumber(affinity)` (xthread.cc:847 `pcr->prcb_data.current_cpu =
cpu_index`), which equals the HW thread id ours already writes at PCR+0x2C.
Left unwritten it read 0 for every thread.

Guest spin-barrier `sub_824D1328` (used by the audio/update pump threads at
entries 0x824D2878 / 0x824D2940, ours tid 9 / tid 10) indexes a per-HW-thread
occupancy byte array via `lbz r11, 268(r13)` then `stbx ..., [array+index]`.
With index 0 for all threads, every thread marked slot 0; the multi-byte
rendezvous signature it then spins on (`ld [obj+0x164]` compared against the
packed per-slot expectation) could never assemble. Both pump threads busied at
pc 0x824d140c/0x824d1410 forever (Ready, 5M+ barrier iterations) and never ran
their `KeSetEvent` loops — so the events they signal (the 21k-per-thread
heartbeat in canary) never fired, starving the downstream worker handshake.

Fix: write `hw_id` to PCR+0x10C alongside PCR+0x2C in both the static thread
image init (thread.rs) and the dynamic PcrWriter (state.rs, used by scheduler
spawn + affinity migration) so the two stay in sync.

Runtime-verified BOTH engines. Post-fix the pump threads escape the barrier
(barrier iterations 5M+ -> 3) and advance into their loop bodies, now correctly
Blocked(WaitAny) at pc 0x824d28d0 / 0x824d29c0 (was spinning at 0x824d140c).
imports at n50M 339,766 -> 451,508; deterministic (two cold runs byte-identical).
draws still 0 (a later, separate render gate). golden re-baselined.
cargo test --workspace: 672 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:08:46 +02:00
MechaCat02
2bdb93e51e [iterate-2K] GPU physical-mirror aliasing: ring/IB/RPtr/resolve read wrong host region
Root cause (physical-mirror aliasing gap → GPU read wrong region → ring never
truly drained → render worker ring-space wait → no frame → no draw):

The Xbox 360 maps its 512 MB of physical DRAM into several virtual mirror
windows differing only in cache policy — bare physical (0x0xxxxxxx),
write-combine (0x4xxxxxxx), and cached 0xA/0xC/0xExxxxxxx — all aliasing
addr & 0x1FFF_FFFF. Ours has one flat membase and `heap_alloc`
(MmAllocatePhysicalMemoryEx) commits physical backing in the 0x4xxxxxxx
window. The guest masks its CP-ring allocation base to bare physical
(0x4adcc000 & 0x1FFFFFFF = 0x0adcc000) before handing it to
VdInitializeRingBuffer, and PM4 INDIRECT_BUFFER / writeback / resolve
pointers are likewise bare-physical. Ours stored those verbatim and read
`membase + 0x0adcc000`, a never-committed zero-filled page — so the GPU
drained ~718k zero PM4 headers, never executed the real Type3/DRAW stream,
and the RPtr writeback landed on a zero page the render worker (tid=8) polls,
freezing it forever.

Fix (GPU/Vd-boundary translation, not memory-layer): add
`physical_to_backing(addr)` deriving the committed backing exactly from
`heap_alloc`'s placement (0x4000_0000 | (addr & 0x1FFF_FFFF), idempotent for
the WC window, flat for non-physical code/stack). Apply it at every point the
GPU/kernel consumes a guest physical address: ring base
(initialize_ring_buffer), RPtr writeback (enable_rptr_writeback), PM4
INDIRECT_BUFFER pointer, WAIT_REG_MEM / COND_WRITE memory poll+write,
REG_TO_MEM / MEM_WRITE / EVENT_WRITE* / LOAD_ALU_CONSTANT / IM_LOAD addresses,
the resolve dest write, and the vd_swap frontbuffer present read. This was
chosen over memory-layer aliasing because the latter re-projects every CPU
load/store and corrupts the guest's flat 0xA/0xC/0xE accesses (it caused an
early PC=0xfffffffc fault).

Two adjacent GPU-backend gates this exposed and also fixed (canary-faithful):
- WaitCmp::from_wait_info was off by one vs canary's MatchValueAndRef
  selector (it decoded wait_info&7==3 as NotEqual instead of Equal),
  inverting the standard CP coherency wait so the GPU parked forever on the
  first INDIRECT_BUFFER. Remapped to 1=Less..7=Always, 0=Never.
- Added MakeCoherent: a WAIT polling COHER_STATUS_HOST clears the status bit
  (mirrors command_processor.cc:801-838) so the coherency handshake resolves.

Result: the GPU now decodes the real Type3 packets at 0x4adcc000 (ME_INIT,
INDIRECT_BUFFER → real Type0/WAIT_REG_MEM at 0x4adf5080) instead of
zero-headers; RPtr at 0x408619fc advances (0x13, 0x16, … written by the GPU
worker); the frame loop sub_822F1AA8 actively writes the controller at
0x40d09a40 (0x20→0x21→0x23); no fault, full 200M/1B budget runs clean.

draws_seen is still 0: the remaining gate is upstream and separate — the main
frame loop never sets controller bit-28 (frame-ready) at [0x40d09a40] (stalls
at 0x23, the known iterate-2C state-divergence gate), so the guest never
enqueues a render IB; the GPU only ever replays the init IB. This fix
correctly unblocks the GPU ring/IB/RPtr data path (gate-2 GPU backend); the
bit-28 frame-ready gate is the next target.

Stable golden (sylpheed_n50m) unchanged (draws/swaps/RTs/shaders identical at
50M); regenerated twice byte-identical. cargo test --workspace: 672 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:39:57 +02:00
MechaCat02
ed2e0e72fd [iterate-2J] KeTimeStampBundle deterministic tick: fix frozen+mislaid guest clock
The xboxkrnl data export KeTimeStampBundle (ordinal 0x00AD, import slot
0x820007d0 — confirmed via sylpheed.db imports table) was set up with TWO
defects in the import-patch pass:

  1. FROZEN: the block was written once at boot and never updated, so every
     field stayed a constant for the whole run (observed: the guest's clock
     reader sub_824AA830 = [[0x820007d0]+0x10] returned a constant
     0x01d6bc0c from 5M..150M instructions).
  2. WRONG LAYOUT: it stuffed the FILETIME high-dword at +0x10. The canonical
     X_TIME_STAMP_BUNDLE (xenia-canary kernel_state.h) is:
       +0x00 interrupt_time u64 (100ns since boot)
       +0x08 system_time    u64 (FILETIME 100ns since 1601)
       +0x10 tick_count      u32 (milliseconds since boot)
       +0x14 padding
     so [block+0x10] is tick_count in ms, not a FILETIME dword.

Fix (deterministic, no wall-clock):
  * Initialize the block with the correct field layout (tick_count = 0 at
    boot, system_time = FILETIME base, interrupt_time = 0).
  * Store the block VA on KernelState::timestamp_bundle_addr during the
    import patch.
  * Add KernelState::update_timestamp_bundle(mem, clock) and call it every
    round in BOTH the lockstep (run_execution) and parallel
    (run_execution_parallel) outer loops, right where the deterministic
    Scheduler::global_clock is advanced. The clock is the retired-instruction
    monotonic global_clock, so every guest-visible time value stays a pure
    function of guest progress (lockstep byte-reproducible).
  * Cadence: 1 global_clock unit = 100ns (coherent with parse_timeout, which
    divides 100ns timeouts by 100 onto the same basis), so
    INSTRUCTIONS_PER_MS = 10_000. tick_count now advances 0 -> ~4999ms over
    a 50M-instruction window. Also make KeQuerySystemTime read the same
    100ns clock instead of a frozen FILETIME constant.

Verification: tick_count at 0x40002010 now advances (deadline arm at
0x82450d0c stores clock+66 = 0x260,0x269,...,0x51d,... advancing, vs the
frozen 0x01d6bc4e before the fix). Determinism: two cold --stable-digest
runs are byte-identical; the n50m golden is UNCHANGED (the clock-affected
counter is not in the stable digest). 672/672 tests pass.

HONEST CAVEAT — the predicted render cascade did NOT materialize on this
branch. The diagnosed consuming gate at 0x82450b10 (the clock-vs-deadline
compare in the worker-hub channel loop sub_82450A68) is unreachable here:
the loop always branches away at 0x82450b0c ([this+220] >= channel-index),
so the hub already dispatches sub_82450B68 342x in BOTH the frozen and
fixed builds. Guest trajectory (imports 339766@50M / 1738001@200M /
9212446@1B), draws (0), swaps (2) and thread topology (tid14 Ready, not
blocked on 0x109c) are identical frozen-vs-fixed. This commit is therefore
a correct latent-clock-bug fix and determinism-safe prerequisite, NOT the
render unblock. The 0x109c/tid14 starvation premise was not reproduced at
f75bc96; the next gate must be re-localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:54:44 +02:00
20 changed files with 1238 additions and 235 deletions

View File

@@ -1497,16 +1497,28 @@ fn cmd_exec_inner(
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x00AD) => {
// KeTimeStampBundle — 0x18 block with FILETIME at +0 and
// interrupt-time u64 at +0x10. Mirrors the clock used by
// KeQuerySystemTime so fast-path readers see consistent values.
// KeTimeStampBundle — X_TIME_STAMP_BUNDLE (canary layout,
// kernel_state.h): +0x00 interrupt_time u64, +0x08
// system_time u64 (FILETIME 100ns), +0x10 tick_count u32
// (milliseconds since boot), +0x14 padding. The guest's
// worker-hub channel-dispatch loop (sub_82450A68 @
// 0x82450b10) polls [block+0x10] (tick_count) and gates
// dispatch on a `tick_count + 66` (ms) deadline. The block
// MUST be ticked over the run or that deadline never
// elapses (tid14 0x109c starvation gate). Initialize to a
// zero-uptime base; KernelState::update_timestamp_bundle
// ticks it every round from the deterministic global_clock.
let block = alloc_zero(0x18, &mut mem, &mut kernel);
if block != 0 {
let fake_time: u64 = 132_500_000_000_000_000; // ~2021 FILETIME
mem.write_u32(block, (fake_time >> 32) as u32);
mem.write_u32(block + 4, fake_time as u32);
mem.write_u32(block + 0x10, (fake_time >> 32) as u32);
mem.write_u32(block + 0x14, fake_time as u32);
// FILETIME base (~2021) so system_time is plausible.
let fake_time: u64 = 132_500_000_000_000_000;
mem.write_u32(block, 0); // interrupt_time hi
mem.write_u32(block + 4, 0); // interrupt_time lo
mem.write_u32(block + 0x08, (fake_time >> 32) as u32); // system_time hi
mem.write_u32(block + 0x0C, fake_time as u32); // system_time lo
mem.write_u32(block + 0x10, 0); // tick_count (ms) = 0 at boot
mem.write_u32(block + 0x14, 0); // padding
kernel.timestamp_bundle_addr = block;
}
mem.write_u32(addr, block);
}
@@ -1528,8 +1540,19 @@ fn cmd_exec_inner(
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x01BE) => {
// VdGlobalDevice — passed through to Vd* shims. Write 0.
mem.write_u32(addr, 0);
// VdGlobalDevice — a *pointer to* a global D3D-device cell.
// Mirror xenia-canary RegisterVideoExports (xboxkrnl_video.cc:
// 557-564): allocate a 4-byte cell, point the import slot at
// it, and zero the cell. The guest's graphics init then stores
// its device object INTO the cell (e.g. sub_824C6DC0 @
// 0x824C6F18 `stw r31, 0([0x82000750])`), and the swap-complete
// callback sub_824CE2B8 reads it back via the two-level
// `[[VdGlobalDevice]+0]+15160` to bump the swap counter (clock
// B). Writing 0 directly here (the old behaviour) made that
// store land at address 0 and the swap counter never advance —
// freezing the title-loop's per-frame manager update.
let cell = alloc_zero(0x4, &mut mem, &mut kernel);
mem.write_u32(addr, cell);
}
("xboxkrnl.exe", 0x01C0) => {
// VdGpuClockInMHz
@@ -2315,10 +2338,22 @@ fn coord_post_round(
}
if kernel.gpu.has_pending_interrupts() {
for _pi in kernel.gpu.take_pending_interrupts() {
for pi in kernel.gpu.take_pending_interrupts() {
// Canary `ExecutePacketType3_INTERRUPT` dispatches the callback
// once per set bit of `cpu_mask` with that bit's index as the
// target CPU (`DispatchInterruptCallback(1, n)`). The guest's
// swap-acknowledge fence stores `cpu_mask`, and the ISR clears
// `1 << current_cpu` from it — so the ISR must run impersonating
// the masked CPU or the fence never reaches 0. Sylpheed uses a
// single-bit mask (`0x4` → CPU 2); take the lowest set bit.
let cpu = if pi.cpu_mask == 0 {
xenia_kernel::interrupts::VSYNC_TARGET_CPU
} else {
pi.cpu_mask.trailing_zeros().min(5) as u8
};
kernel
.interrupts
.queue_interrupt(xenia_kernel::INTERRUPT_SOURCE_CP);
.queue_interrupt(xenia_kernel::INTERRUPT_SOURCE_CP, cpu);
}
}
@@ -2852,6 +2887,12 @@ fn run_execution(
kernel
.scheduler
.advance_global_clock_to(stats.instruction_count);
// ITERATE-2J — tick the KeTimeStampBundle (ordinal 0x00AD) from the
// same deterministic clock so the guest's worker-hub tick_count
// deadline gate (`[block+0x10] + 66` ms) actually elapses. Without
// this the block is frozen at boot and the hub spins forever,
// starving tid14 on event 0x109c.
kernel.update_timestamp_bundle(mem, kernel.scheduler.global_clock());
kernel.fire_due_silph_autosignals(stats.instruction_count);
dispatch_graphics_interrupts(
kernel,
@@ -3296,6 +3337,16 @@ fn run_execution_parallel(
guard.fire_due_silph_autosignals(s.instruction_count);
}
// ITERATE-2J — tick the KeTimeStampBundle (ordinal 0x00AD) from
// the parallel-mode coherent global_clock (summed per-block
// retired instructions). Same fix as the lockstep loop: keeps the
// guest's worker-hub tick_count deadline gate advancing so it
// dispatches channel-3 and unblocks tid14 on event 0x109c.
{
let clock = guard.scheduler.global_clock();
guard.update_timestamp_bundle(mem, clock);
}
// Iterate-2.BE — host-driven synchronous ISR dispatch.
// Runs under the kernel lock while workers are still parked
// at the phaser B2 barrier (the coordinator hasn't published
@@ -3506,7 +3557,17 @@ fn dispatch_graphics_interrupts(
None
};
/// X_KPCR offset of `prcb_data.current_cpu` (canary `xthread.cc`
/// `SetActiveCpu` → `pcr.prcb_data.current_cpu`). The guest graphics
/// ISR reads it via `lbz r10, 268(r13)` to decide which per-CPU bit of
/// the swap-acknowledge fence to clear.
const PCR_CURRENT_CPU_OFF: u32 = 268;
while let Some(source) = kernel.interrupts.peek_next() {
let target_cpu = kernel
.interrupts
.peek_next_cpu()
.unwrap_or(xenia_kernel::interrupts::VSYNC_TARGET_CPU);
// Victim selection: Ready first, then Blocked (canary's
// `XThread::GetCurrentThread()` analog — any live thread will
// do for borrowing context). Skip Idle/Exited/ServicingIrq.
@@ -3576,6 +3637,19 @@ fn dispatch_graphics_interrupts(
saved
};
// Impersonate the interrupt's target CPU on the borrowed thread's
// PCR, mirroring canary `EmulateCPInterruptDPC` →
// `XThread::SetActiveCpu(cpu)`. The guest swap-complete ISR clears
// `1 << [pcr.current_cpu]` from the per-present swap-acknowledge
// fence; if it runs on the wrong CPU it clears the wrong bit and
// the GPU's trailing `WAIT_REG_MEM` on that fence never releases —
// stranding the present/title loop. Save/restore so borrowing a
// thread doesn't permanently rewrite its processor number.
let pcr_addr = (kernel.scheduler.ctx_mut_ref(target_ref).gpr[13] as u32)
.wrapping_add(PCR_CURRENT_CPU_OFF);
let saved_cpu = mem.read_u8(pcr_addr);
mem.write_u8(pcr_addr, target_cpu);
// Stash the previous `scheduler.current` (call_export reaches
// it; imports the ISR calls must dispatch on the borrowed
// thread). Restore on the way out.
@@ -3668,6 +3742,7 @@ fn dispatch_graphics_interrupts(
// Restore the borrowed context.
saved.restore(kernel.scheduler.ctx_mut_ref(target_ref));
mem.write_u8(pcr_addr, saved_cpu);
kernel.scheduler.current = prev_current;
kernel.interrupts.delivered += 1;

View File

@@ -1,10 +1,10 @@
{
"instructions": 50000000,
"imports": 339766,
"instructions": 50000014,
"imports": 352251,
"unimpl": 0,
"draws": 0,
"swaps": 2,
"unique_render_targets": 0,
"shader_blobs_live": 0,
"texture_cache_entries": 0
"draws": 718,
"swaps": 147,
"unique_render_targets": 2,
"shader_blobs_live": 6,
"texture_cache_entries": 1
}

View File

@@ -57,6 +57,16 @@ fn run_oracle(label: &str, max_instr: u64, golden_rel: &str) {
&iso,
"-n",
&max_instr_str,
// Pin the inline (single-threaded) GPU backend. The default
// threaded backend drains the ring on a separate host thread,
// so the exact instruction at which a CP interrupt is queued —
// and therefore when the guest's swap-complete ISR callback runs
// (iterate-2S armed it via SCRATCH_REG writeback) — varies run to
// run. Inline draining is instruction-count-deterministic, which
// is what a regression golden needs. (The threaded path is the
// documented "GPU thread race" the stable-digest already warns
// about.)
"--gpu-inline",
"--stable-digest",
"--expect",
&golden_str,

View File

@@ -28,6 +28,80 @@ use crate::primitive::{self, ProcessedPrimitive};
use crate::register_file::RegisterFile;
use crate::ring_view::RingBufferView;
/// The guest-virtual window that physical allocations are committed into.
/// `xenia-kernel`'s `heap_alloc` bumps its cursor through `0x4000_0000..=
/// 0x6FFF_FFFF` and commits the host backing for `MmAllocatePhysicalMemoryEx`
/// there, so this write-combine mirror is the canonical home of physical DRAM.
/// Keep in sync with `KernelState::heap_cursor`'s initial value.
pub const PHYSICAL_BACKING_BASE: u32 = 0x4000_0000;
/// Re-project a guest *physical* address — as handed to the Vd/GPU ABI and
/// embedded in PM4 pointers (`INDIRECT_BUFFER`, `WAIT_REG_MEM`-memory,
/// `MEM_WRITE`, `EVENT_WRITE*`, `IM_LOAD`, …) — onto the guest-virtual window
/// where its host backing is actually committed.
///
/// The Xbox 360 maps its 512 MB of physical DRAM into several virtual mirror
/// windows that differ only in cache policy: bare physical (`0x0xxxxxxx`),
/// write-combine (`0x4xxxxxxx`), and the cached `0xA/0xC/0xExxxxxxx` mirrors —
/// all aliasing `addr & 0x1FFF_FFFF`. On real hardware (and in xenia-canary
/// via overlapping `mmap`s) these are literally the same bytes.
///
/// Ours has a single flat `membase` and `MmAllocatePhysicalMemoryEx` commits
/// physical backing in the write-combine `0x4xxxxxxx` window. The guest then
/// masks its allocation base to *bare physical* before passing it to
/// `VdInitializeRingBuffer` / `VdEnableRingBufferRPtrWriteBack`, and PM4
/// pointers are likewise bare-physical. A flat `membase + phys` access
/// therefore hits a never-committed, zero-filled page instead of the committed
/// `0x4xxxxxxx` backing — so the GPU decoded zero PM4 headers and never ran
/// the real command stream.
///
/// Projecting any physical-mirror address back onto the `0x4xxxxxxx` window
/// lands on the page `heap_alloc` actually backed, regardless of which mirror
/// the guest used (idempotent for `0x4xxxxxxx` itself). The projection is
/// derived from `heap_alloc`'s placement, not a guess — if that window ever
/// moves, `PHYSICAL_BACKING_BASE` must move with it.
///
/// This is deliberately applied only at the GPU/Vd boundary (where addresses
/// arrive in their bare-physical form), NOT on the CPU's flat load/store path:
/// the guest CPU already accesses its allocations through the `0x4xxxxxxx`
/// base, and non-physical guest-virtual addresses (image `0x82xxxxxx`, stacks
/// `0x7xxxxxxx`) must stay flat.
#[inline]
pub fn physical_to_backing(addr: u32) -> u32 {
match addr {
0x0000_0000..=0x1FFF_FFFF
| 0x4000_0000..=0x4FFF_FFFF
| 0xA000_0000..=0xBFFF_FFFF
| 0xC000_0000..=0xDFFF_FFFF
| 0xE000_0000..=0xFFFF_FFFF => PHYSICAL_BACKING_BASE | (addr & 0x1FFF_FFFF),
_ => addr,
}
}
/// Max guest page-version over the `[base, base+len)` span, walking 4 KiB
/// pages via the `MemoryAccess` trait's `page_version`.
///
/// The concrete heap exposes an inherent `max_page_version(base, len)`, but
/// the draw handler only holds `&dyn MemoryAccess` (which carries the coarser
/// `page_version(addr)` accessor). This is byte-equivalent to
/// `heap::max_page_version` and stays a pure function of the per-page write
/// counters (no wall-clock), so texture-decode timing remains deterministic.
fn span_max_version(mem: &dyn MemoryAccess, base: u32, len: u32) -> u64 {
const PAGE: u32 = 0x1000;
let last = base.saturating_add(len.saturating_sub(1));
let mut page = base & !(PAGE - 1);
let last_page = last & !(PAGE - 1);
let mut max = 0u64;
loop {
max = max.max(mem.page_version(page));
if page >= last_page {
break;
}
page = page.wrapping_add(PAGE);
}
max
}
/// Cached Xenos microcode blob, produced by `PM4_IM_LOAD*` packets.
#[derive(Debug, Clone)]
pub struct ShaderBlob {
@@ -58,21 +132,37 @@ pub enum WaitCmp {
GreaterEq,
/// value > ref
Greater,
/// Always — caller wants to sleep regardless.
/// Always — caller wants to sleep regardless (selector bit 7).
Always,
/// Never matches — `wait_info & 7 == 0` selects bit 0 of canary's
/// selector word, which is always zero.
Never,
}
impl WaitCmp {
/// Interpret the lower 3 bits of `wait_info` per canary's `MatchValueAndRef`.
/// Interpret the lower 3 bits of `wait_info` per canary's `MatchValueAndRef`
/// (`pm4_command_processor_implement.h:685-696`). Canary forms a selector
/// `((value<ref)<<1) | ((value<=ref)<<2) | ((value==ref)<<3) |
/// ((value!=ref)<<4) | ((value>=ref)<<5) | ((value>ref)<<6) | (1<<7)` and
/// evaluates `(selector >> (wait_info & 7)) & 1`. So the index is the bit
/// position: 1=Less, 2=LessEq, 3=Equal, 4=NotEqual, 5=GreaterEq,
/// 6=Greater, 7=always-true, 0=never (bit 0 is always clear).
///
/// GPUBUG: the prior mapping was off by one (it started at `0 => Less`),
/// so `wait_info & 7 == 3` decoded as `NotEqual` instead of `Equal`. That
/// inverted the standard CP coherency wait
/// (`WAIT_REG_MEM COHER_STATUS_HOST, Equal 0`): the GPU parked forever on
/// the first INDIRECT_BUFFER and never reached any draw.
pub fn from_wait_info(wait_info: u32) -> Self {
match wait_info & 0x7 {
0 => WaitCmp::Less,
1 => WaitCmp::LessEq,
2 => WaitCmp::Equal,
3 => WaitCmp::NotEqual,
4 => WaitCmp::GreaterEq,
5 => WaitCmp::Greater,
_ => WaitCmp::Always,
1 => WaitCmp::Less,
2 => WaitCmp::LessEq,
3 => WaitCmp::Equal,
4 => WaitCmp::NotEqual,
5 => WaitCmp::GreaterEq,
6 => WaitCmp::Greater,
7 => WaitCmp::Always,
_ => WaitCmp::Never,
}
}
@@ -85,6 +175,7 @@ impl WaitCmp {
WaitCmp::GreaterEq => value >= reference,
WaitCmp::Greater => value > reference,
WaitCmp::Always => true,
WaitCmp::Never => false,
}
}
}
@@ -333,6 +424,12 @@ pub struct GpuSystem {
/// on every texture-fetch resolution; the UI thread sees the decoded
/// bytes via `UiBridge::publish_texture`.
pub texture_cache: crate::texture_cache::TextureCache,
/// P5b: textures decoded at the most recent `PM4_DRAW_INDX*`, keyed off
/// the *active* pixel shader's real `tfetch` fetch-constant slots (not a
/// hardcoded slot). `vd_swap` publishes the first of these to the UI so
/// the replay binds the texture the draw actually samples. Cleared and
/// repopulated each draw; empty when the active PS issues no `tfetch`.
pub last_draw_textures: Vec<(crate::texture_cache::TextureKey, Vec<u8>)>,
/// 10 MiB shadow of the Xenos EDRAM. Written by clear-resolves and
/// (future) host-render-target readback; read by the resolve byte-copy
/// path that writes tiled pixels into guest memory. Allocated once at
@@ -364,6 +461,7 @@ impl GpuSystem {
rt_cache: crate::render_target_cache::RenderTargetCache::new(),
last_resolve: None,
texture_cache: crate::texture_cache::TextureCache::new(),
last_draw_textures: Vec::new(),
edram: crate::edram::ShadowEdram::new(),
}
}
@@ -536,14 +634,21 @@ impl GpuSystem {
/// Release.
pub fn sync_with_mmio(&mut self) {
let wptr_dwords = self.mmio.cp_rb_wptr.load(Ordering::Acquire);
if wptr_dwords != self.ring.write_offset_dwords && self.ring.size_dwords != 0 {
self.ring.write_offset_dwords = wptr_dwords % self.ring.size_dwords;
// CP_RB_WPTR governs ONLY the primary ring. While an indirect buffer
// is executing, the active `self.ring` is a fixed linear sub-stream
// and the primary ring is saved at the bottom of the IB stack —
// applying the (primary) write pointer to the IB would corrupt its
// extent (e.g. `wptr % ib_size`) and strand the GPU mid-buffer.
let primary = self.ib_stack.first_mut().unwrap_or(&mut self.ring);
if wptr_dwords != primary.write_offset_dwords && primary.size_dwords != 0 {
primary.write_offset_dwords = wptr_dwords % primary.size_dwords;
}
// Mirror our read pointer (Release pairs with any guest-side
let primary_rptr = primary.read_offset_dwords;
// Mirror the *primary* read pointer (Release pairs with any guest-side
// Acquire-load of CP_RB_RPTR for ring writeback bookkeeping).
self.mmio
.cp_rb_rptr
.store(self.ring.read_offset_dwords, Ordering::Release);
.store(primary_rptr, Ordering::Release);
}
/// True iff `execute_one` is expected to make progress without blocking.
@@ -551,7 +656,11 @@ impl GpuSystem {
if let Some(block) = &self.pending_block {
return block.is_satisfied(mem, &self.register_file);
}
self.ring.has_pending()
// Pending work may be in the active ring OR in a saved caller ring
// further down the IB stack (an exhausted IB still needs `execute_one`
// to pop back and resume the primary ring, whose WPTR may have since
// advanced).
self.ring.has_pending() || self.ib_stack.iter().any(|r| r.has_pending())
}
/// Execute exactly one PM4 packet. Returns [`ExecOutcome::Idle`] when
@@ -561,6 +670,12 @@ impl GpuSystem {
pub fn execute_one(&mut self, mem: &dyn MemoryAccess) -> ExecOutcome {
// 0) If currently parked, probe the condition and either wake up or stay blocked.
if let Some(block) = self.pending_block.clone() {
// Re-service the CP coherency handshake on each probe so a
// COHER_STATUS_HOST wait can clear (canary does this in its WAIT
// loop body, not just at entry).
if let GpuBlock::WaitRegMem { poll_addr, is_memory: false, .. } = &block {
self.make_coherent(*poll_addr);
}
if block.is_satisfied(mem, &self.register_file) {
tracing::debug!(?block, "gpu: wait satisfied — resuming");
self.pending_block = None;
@@ -642,10 +757,13 @@ impl GpuSystem {
width,
height,
});
self.pending_interrupts.push(PendingInterrupt {
source: InterruptSource::Swap,
cpu_mask: 0x1,
});
// iterate-2T: do NOT raise a CP swap-complete interrupt here. Canary's
// `VdSwap`/PM4_XE_SWAP path raises no interrupt; swap-complete CP
// interrupts come ONLY from in-stream `PM4_INTERRUPT` packets, which
// are naturally ordered after D3D has armed the swap-callback slot.
// Synthesizing one out of band (as we did pre-2T) delivered a CP
// interrupt while the slot still held the `0xBADF00D` placeholder,
// tripping the graphics ISR's "Unanticipated CPU_INTERRUPT" assert.
tracing::info!(
frame = self.swap_counter,
fb = format_args!("{frontbuffer_phys:#010x}"),
@@ -657,9 +775,21 @@ impl GpuSystem {
/// Called by `VdInitializeRingBuffer` to give us the primary ring.
pub fn initialize_ring_buffer(&mut self, base: u32, size_log2: u32) {
let size_bytes = 1u32 << size_log2.min(31);
// Canary `CommandProcessor::InitializeRingBuffer` (command_processor.cc:
// 436): `primary_buffer_size_ = 1 << (size_log2 + 3)` *bytes*. The
// `VdInitializeRingBuffer` `r4` argument is log2(size-in-quadwords),
// so the byte size is `1 << (size_log2 + 3)` (× 8 bytes/quadword), i.e.
// `1 << (size_log2 + 1)` dwords. (Sylpheed passes size_log2=12 →
// 32768 bytes / 8192 dwords; the previous `1 << size_log2` undersized
// the ring 8× and desynced WPTR wrap math from the guest.)
let size_bytes = 1u32 << size_log2.saturating_add(3).min(31);
// The guest hands us a bare *physical* ring base; project it onto the
// committed backing window so ring reads hit real PM4 packets (see
// `physical_to_backing`).
let base = physical_to_backing(base);
self.ring.base = base;
self.ring.size_dwords = size_bytes / 4;
self.ring.indirect = false;
self.ring.read_offset_dwords = 0;
// `write_offset` is driven by the guest — start at 0 so the ring
// appears empty until MMIO writes advance it.
@@ -675,6 +805,10 @@ impl GpuSystem {
/// Called by `VdEnableRingBufferRPtrWriteBack` to record where the guest
/// expects us to mirror `read_offset_dwords`.
pub fn enable_rptr_writeback(&mut self, addr: u32, block_log2: u32) {
// The guest registers a bare *physical* writeback address and polls
// the same allocation through its `0x4xxxxxxx` base; project so our
// RPtr store lands on the page the guest actually reads.
let addr = physical_to_backing(addr);
self.ring.rptr_writeback_addr = addr;
self.ring.rptr_writeback_block_dwords = 1u32 << block_log2.min(31);
tracing::info!(
@@ -724,6 +858,58 @@ impl GpuSystem {
/// upstream packet effects (memory writes, register file updates
/// the guest reads via subsequent MMIO) happen-before the
/// CPU-visible RPTR bump.
/// Service a CP coherency request, mirroring canary's
/// `CommandProcessor::MakeCoherent` (`command_processor.cc:801-838`).
///
/// The guest requests a vertex/texture-cache flush by writing
/// `COHER_STATUS_HOST` with its status bit (bit 31) set, then spins on a
/// `WAIT_REG_MEM COHER_STATUS_HOST, Equal 0`. We have no host cache to
/// flush (memory is shared, coherency is implicit), so completing the
/// request is simply clearing the register — which lets the wait satisfy.
/// No-op unless `poll_addr` is `COHER_STATUS_HOST` and its status bit is
/// set, so it is safe to call on every coherency-register WAIT probe.
fn make_coherent(&mut self, poll_addr: u32) {
if poll_addr != reg::COHER_STATUS_HOST {
return;
}
let status = self.register_file.read(reg::COHER_STATUS_HOST);
if status & 0x8000_0000 != 0 {
self.register_file.write(reg::COHER_STATUS_HOST, 0);
}
}
/// CP scratch-register memory writeback, mirroring canary's
/// `CommandProcessor::HandleSpecialRegisterWrite`
/// (`command_processor.cc:545-552`). Every register write runs through
/// here; when the target is one of the eight `SCRATCH_REG{n}`
/// (`0x0578..=0x057F`) **and** the matching bit in `SCRATCH_UMSK` is set,
/// the value is also written (big-endian, as `mem.write_u32` already
/// stores) to `SCRATCH_ADDR + n*4` in guest physical memory.
///
/// Sylpheed arms its CP swap-complete interrupt callback through this
/// path: it programs `SCRATCH_ADDR` to the GPU command-block descriptor
/// (`[gfx+10772]`, runtime `0x0b1d5000`), `SCRATCH_UMSK` bit 4, then a
/// Type-0 write of the callback PC `0x824ce2b8` into `SCRATCH_REG4`
/// (`0x057C`). The writeback lands it at descriptor+16 (`0x4b1d5010`),
/// which the graphics ISR (`sub_824BE9A0`) reads via `[[gfx+10772]+16]`
/// and `bcctrl`s to fire the swap-complete callback. Without this
/// writeback the slot stayed NULL, the ISR skipped the callback, the
/// swap counter never advanced, and the title's per-frame manager
/// re-fired once then plateaued.
fn scratch_register_writeback(&self, mem: &dyn MemoryAccess, index: u32, value: u32) {
if !(reg::SCRATCH_REG0..=reg::SCRATCH_REG7).contains(&index) {
return;
}
let scratch_reg = index - reg::SCRATCH_REG0;
let umsk = self.register_file.read(reg::SCRATCH_UMSK);
if (1u32 << scratch_reg) & umsk == 0 {
return;
}
let scratch_addr = self.register_file.read(reg::SCRATCH_ADDR);
let mem_addr = physical_to_backing(scratch_addr.wrapping_add(scratch_reg * 4));
mem.write_u32(mem_addr, value);
}
fn writeback_read_ptr(&mut self, mem: &dyn MemoryAccess) {
if self.ring.rptr_writeback_addr != 0 && self.ring.is_initialized() {
mem.write_u32_fence(
@@ -748,6 +934,7 @@ impl GpuSystem {
let value = mem.read_u32(dword_addr);
let target = if write_one { base_index } else { base_index + i };
self.register_file.write(target, value);
self.scratch_register_writeback(mem, target, value);
}
tracing::trace!(
base = format_args!("{base_index:#x}"),
@@ -770,6 +957,8 @@ impl GpuSystem {
let b = mem.read_u32(b_addr);
self.register_file.write(reg_index_1, a);
self.register_file.write(reg_index_2, b);
self.scratch_register_writeback(mem, reg_index_1, a);
self.scratch_register_writeback(mem, reg_index_2, b);
tracing::trace!(
r1 = format_args!("{reg_index_1:#x}"),
r2 = format_args!("{reg_index_2:#x}"),
@@ -816,7 +1005,9 @@ impl GpuSystem {
}
pm4::PM4_INDIRECT_BUFFER | pm4::PM4_INDIRECT_BUFFER_PFD => {
self.stats.indirect_buffer_jumps += 1;
let ib_ptr = self.read_payload(mem, 1);
// The IB pointer is a guest *physical* address — project it
// onto the committed backing window (see `physical_to_backing`).
let ib_ptr = physical_to_backing(self.read_payload(mem, 1));
let ib_size = self.read_payload(mem, 2);
// Advance past the IB header + payload before recursing so
// the return location is correct.
@@ -832,6 +1023,10 @@ impl GpuSystem {
write_offset_dwords: ib_size, // IB is fully-written at jump time
rptr_writeback_addr: 0,
rptr_writeback_block_dwords: 0,
// Linear sub-stream: drain [0, ib_size) then pop. Never
// wraps, and `sync_with_mmio`'s CP_RB_WPTR must not touch
// it (canary executes IBs through a separate reader).
indirect: true,
};
tracing::debug!(
ib_ptr = format_args!("{ib_ptr:#010x}"),
@@ -854,7 +1049,8 @@ impl GpuSystem {
let is_memory = (wait_info & 0x10) != 0;
let cmp = WaitCmp::from_wait_info(wait_info);
let poll_addr = if is_memory {
poll_addr_raw & !3
// Physical memory poll address → committed backing.
physical_to_backing(poll_addr_raw & !3)
} else {
poll_addr_raw
};
@@ -865,6 +1061,12 @@ impl GpuSystem {
mask,
cmp,
};
// A WAIT polling COHER_STATUS_HOST is the CP coherency
// handshake: service it now so the status bit clears (see
// `make_coherent`), exactly as canary does in its WAIT loop.
if !is_memory {
self.make_coherent(poll_addr);
}
if block.is_satisfied(mem, &self.register_file) {
// Condition already true; proceed past this packet.
tracing::trace!(?block, "gpu: WAIT_REG_MEM immediately satisfied");
@@ -908,7 +1110,7 @@ impl GpuSystem {
pm4::PM4_REG_TO_MEM => {
// payload[0] = reg_index, payload[1] = mem addr
let reg_index = self.read_payload(mem, 1) & 0x1FFF;
let dst = self.read_payload(mem, 2) & !3;
let dst = physical_to_backing(self.read_payload(mem, 2) & !3);
let value = self.register_file.read(reg_index);
mem.write_u32(dst, value);
tracing::trace!(
@@ -920,7 +1122,7 @@ impl GpuSystem {
}
pm4::PM4_MEM_WRITE => {
// payload[0] = dst, payload[1..=count-1] = values
let mut dst = self.read_payload(mem, 1) & !3;
let mut dst = physical_to_backing(self.read_payload(mem, 1) & !3);
for i in 2..=count {
let val = self.read_payload(mem, i);
mem.write_u32(dst, val);
@@ -936,7 +1138,7 @@ impl GpuSystem {
let mask = self.read_payload(mem, 4);
let is_memory = (wait_info & 0x10) != 0;
let cmp = WaitCmp::from_wait_info(wait_info);
let poll_addr = if is_memory { poll_raw & !3 } else { poll_raw };
let poll_addr = if is_memory { physical_to_backing(poll_raw & !3) } else { poll_raw };
let cur_raw = if is_memory {
mem.read_u32(poll_addr)
} else {
@@ -946,7 +1148,7 @@ impl GpuSystem {
let write_addr = self.read_payload(mem, 5);
let write_data = self.read_payload(mem, 6);
if (wait_info & 0x100) != 0 {
mem.write_u32(write_addr & !3, write_data);
mem.write_u32(physical_to_backing(write_addr & !3), write_data);
} else {
self.register_file
.write(write_addr & 0x1FFF, write_data);
@@ -965,7 +1167,7 @@ impl GpuSystem {
// payload[0] = initiator (bit 31: write counter, else write `value`)
// payload[1] = address, payload[2] = value
let initiator = self.read_payload(mem, 1);
let address = self.read_payload(mem, 2);
let address = physical_to_backing(self.read_payload(mem, 2));
let value = self.read_payload(mem, 3);
self.register_file
.write(reg::VGT_EVENT_INITIATOR, initiator & 0x3F);
@@ -993,7 +1195,7 @@ impl GpuSystem {
// payload[0] = initiator, [1] = address. Writes 6 u16 extents
// (min/max x/y/z) — we're not tracking scissors yet, so write zeros.
let initiator = self.read_payload(mem, 1);
let address = self.read_payload(mem, 2) & !3;
let address = physical_to_backing(self.read_payload(mem, 2) & !3);
self.register_file
.write(reg::VGT_EVENT_INITIATOR, initiator & 0x3F);
self.handle_event_initiator(initiator & 0x3F, mem);
@@ -1094,6 +1296,60 @@ impl GpuSystem {
);
self.last_draw = Some(ds);
self.last_primitive = Some(processed);
// P5b: decode the textures the *active pixel shader* actually
// samples. Parse the bound PS, collect its `tfetch`
// fetch-constant slots, read each 6-dword fetch constant from
// the register file, and decode+cache it. `vd_swap` publishes
// the result. Empty for flat (no-tfetch) shaders — the
// dominant case on Sylpheed's current splash, where this stays
// inert until the textured logo draw is reached.
self.last_draw_textures.clear();
if let Some(ps_key) = self.active_ps_key {
// Collect slots under an immutable borrow of `shader_blobs`,
// then drop it before mutating `texture_cache`.
let slots: Vec<u8> = match self.shader_blobs.get(&ps_key) {
Some(blob) => {
let parsed = crate::ucode::parse_shader(&blob.dwords);
crate::shader_metrics::tfetch_slots(&parsed)
}
None => Vec::new(),
};
for slot in slots {
let mut fetch6 = [0u32; 6];
for (k, w) in fetch6.iter_mut().enumerate() {
*w = self
.register_file
.read(CONST_BASE_FETCH + slot as u32 * 6 + k as u32);
}
let Some(key) = crate::texture_cache::decode_fetch_constant(fetch6) else {
continue;
};
let bi = key.format.block_info();
let span_bytes = (key.pitch_texels as u32)
* (key.height as u32)
* (bi.bytes_per_block as u32)
/ (bi.block_w as u32);
let version = span_max_version(mem, key.base_address, span_bytes.max(4));
match self.texture_cache.ensure_cached(key, version, mem) {
Ok(entry) => {
self.last_draw_textures.push((entry.key, entry.bytes.clone()));
metrics::counter!(
"gpu.texture.decode",
"fmt" => format!("{:?}", key.format),
)
.increment(1);
}
Err(e) => {
metrics::counter!(
"gpu.texture.reject",
"reason" => format!("{e:?}"),
)
.increment(1);
}
}
}
}
}
pm4::PM4_SET_CONSTANT | pm4::PM4_SET_SHADER_CONSTANTS => {
// payload[0] = offset_type — bits[10:0] index, bits[23:16] type
@@ -1123,7 +1379,7 @@ impl GpuSystem {
}
pm4::PM4_LOAD_ALU_CONSTANT => {
// payload[0] = source mem addr, [1] = offset_type, [2] = size_dwords
let src = self.read_payload(mem, 1) & !3;
let src = physical_to_backing(self.read_payload(mem, 1) & !3);
let offset_type = self.read_payload(mem, 2);
let size_dwords = self.read_payload(mem, 3);
let index = offset_type & 0x7FF;
@@ -1155,7 +1411,7 @@ impl GpuSystem {
}
v
} else {
let addr = self.read_payload(mem, 1) & !3;
let addr = physical_to_backing(self.read_payload(mem, 1) & !3);
let mut v = Vec::with_capacity(size_dwords as usize);
for i in 0..size_dwords {
v.push(mem.read_u32(addr + i * 4));
@@ -1373,11 +1629,31 @@ pub mod reg {
/// `XE_GPU_REG_D1MODE_VBLANK_VLINE_STATUS` (Canary register_table.inc:1126).
/// Bit 0 = VBLANK_INT_OCCURRED.
pub const D1MODE_VBLANK_VLINE_STATUS: u32 = 0x1951;
/// `XE_GPU_REG_D1MODE_VIEWPORT_SIZE` / `AVIVO_D1MODE_VIEWPORT_SIZE`
/// (Canary `register_table.inc:1134`). Packs the active display resolution
/// as `(width << 16) | height` with 12-bit fields. The guest's
/// swap-complete interrupt callback (`sub_824CE2B8`) divides by the low
/// 12 bits (`height`) as a refresh-pacing term, so a 0 read makes its
/// `twi` divide-by-zero guard trap and abort the ISR before it clears the
/// swap-acknowledge fence. Canary returns the constant below from
/// `GraphicsSystem::ReadRegister` (graphics_system.cc:311).
pub const D1MODE_VIEWPORT_SIZE: u32 = 0x1961;
/// `XE_GPU_REG_VGT_EVENT_INITIATOR` — set by EVENT_WRITE.
pub const VGT_EVENT_INITIATOR: u32 = 0x21F9;
/// `XE_GPU_REG_COHER_STATUS_HOST` — coherency bits
/// (Canary `register_table.inc:530`).
pub const COHER_STATUS_HOST: u32 = 0x0A31;
/// `XE_GPU_REG_SCRATCH_UMSK` — bitmask of which `SCRATCH_REG{n}` writes are
/// mirrored to memory (Canary `register_table.inc:139`).
pub const SCRATCH_UMSK: u32 = 0x01DC;
/// `XE_GPU_REG_SCRATCH_ADDR` — base physical address of the scratch
/// writeback block (Canary `register_table.inc:141`).
pub const SCRATCH_ADDR: u32 = 0x01DD;
/// `XE_GPU_REG_SCRATCH_REG0` — first of 8 CP scratch registers
/// (`0x0578..=0x057F`, Canary `register_table.inc:331-338`).
pub const SCRATCH_REG0: u32 = 0x0578;
/// `XE_GPU_REG_SCRATCH_REG7` — last CP scratch register.
pub const SCRATCH_REG7: u32 = 0x057F;
}
/// 32-bit FNV-1a over a u32 seed + a slice of u32s. Used to derive a
@@ -1468,6 +1744,38 @@ mod tests {
assert_eq!(gpu.register_file.read(0x101), 0xCAFE_BABE);
}
#[test]
fn scratch_reg_write_mirrors_to_memory_when_umsk_enabled() {
// Mirrors Sylpheed's CP swap-callback arming: SCRATCH_ADDR points at a
// descriptor, SCRATCH_UMSK enables bit 4, and a Type-0 write of the
// callback PC into SCRATCH_REG4 (0x57C) must land at SCRATCH_ADDR + 16.
let mut gpu = GpuSystem::new();
let mut mem = build_mem();
gpu.initialize_ring_buffer(0x4000_0000, 10);
// Program SCRATCH_ADDR = 0x4000_1000 (physical-mirror identity), and
// SCRATCH_UMSK = bit 4 only (so SCRATCH_REG4 mirrors, REG3 does not).
gpu.register_file.write(reg::SCRATCH_ADDR, 0x4000_1000);
gpu.register_file.write(reg::SCRATCH_UMSK, 1 << 4);
// Type0 write run: base = SCRATCH_REG3 (0x57B), count = 2 → writes
// 0x11111111 → SCRATCH_REG3 (UMSK bit 3 clear), 0x824CE2B8 →
// SCRATCH_REG4 (UMSK bit 4 set → mirrored to ADDR + 4*4 = +16).
const SCRATCH_REG3: u32 = 0x057B;
let hdr = (1u32 << 16) | SCRATCH_REG3;
mem.write_u32(0x4000_0000, hdr);
mem.write_u32(0x4000_0004, 0x1111_1111);
mem.write_u32(0x4000_0008, 0x824C_E2B8);
gpu.extend_write_ptr(3);
assert!(matches!(gpu.execute_one(&mut mem), ExecOutcome::Stepped { .. }));
// SCRATCH_REG3 (bit 3 clear) must NOT mirror; SCRATCH_REG4 (bit 4 set)
// must mirror to SCRATCH_ADDR + 16.
assert_eq!(mem.read_u32(0x4000_1000 + 12), 0, "reg3 must not mirror");
assert_eq!(
mem.read_u32(0x4000_1000 + 16),
0x824C_E2B8,
"reg4 must mirror to SCRATCH_ADDR+16"
);
}
#[test]
fn wait_reg_mem_blocks_then_unblocks_when_mem_changes() {
let mut gpu = GpuSystem::new();
@@ -1477,8 +1785,9 @@ mod tests {
// header
let hdr = (3u32 << 30) | ((5u32 - 1) << 16) | ((pm4::PM4_WAIT_REG_MEM as u32) << 8);
mem.write_u32(0x4000_0000, hdr);
// wait_info: is_memory=1 (bit 4), cmp=equal (bits 2:0 = 2)
mem.write_u32(0x4000_0004, 0x12);
// wait_info: is_memory=1 (bit 4), cmp=equal (bits 2:0 = 3, per canary's
// MatchValueAndRef selector: 1=Less, 2=LessEq, 3=Equal, …).
mem.write_u32(0x4000_0004, 0x13);
mem.write_u32(0x4000_0008, 0x4000_1000);
mem.write_u32(0x4000_000C, 0x42);
mem.write_u32(0x4000_0010, 0xFFFF_FFFF);

View File

@@ -34,7 +34,7 @@ pub mod xenos_constants;
pub use gpu_system::{
ExecOutcome, GpuBlock, GpuMmio, GpuStats, GpuSystem, InterruptSource, PendingInterrupt,
ShaderBlob, SwapNotification, WaitCmp,
PHYSICAL_BACKING_BASE, ShaderBlob, SwapNotification, WaitCmp, physical_to_backing,
};
pub use handle::{
DrainReply, GpuBackend, GpuCommand, GpuDigestSnapshot, GpuHandle, GpuWorker,

View File

@@ -58,6 +58,15 @@ pub fn build_region(mmio: &GpuMmio) -> MmioRegion {
reg::D1MODE_VBLANK_VLINE_STATUS => {
read_vblank_status.load(Ordering::Relaxed)
}
// AVIVO_D1MODE_VIEWPORT_SIZE: the active display resolution
// (1280x720) packed as `(width << 16) | height`. Canary
// serves this constant from `GraphicsSystem::ReadRegister`
// (graphics_system.cc:311). The guest swap-complete interrupt
// callback divides by the low 12 bits (`height = 0x2D0`); a 0
// read trips its `twi` divide-guard and aborts the ISR before
// it acknowledges the per-present swap fence — which strands
// the present/title loop. Mirror canary exactly.
reg::D1MODE_VIEWPORT_SIZE => 0x0500_02D0,
_ => {
tracing::trace!(
reg = format_args!("{reg_index:#x}"),

View File

@@ -5,9 +5,8 @@
//! rectangles) we rewrite indices on the CPU side so the host just sees a
//! triangle list. Ground truth: `xenia-canary/src/xenia/gpu/primitive_processor.h/cc`.
//!
//! P3 scope: only the shapes Sylpheed's UI + early gameplay paths need
//! (list, strip, fan). Rectangle + quad expansions are stubs logged via
//! `tracing::warn!` for later.
//! Scope: list, strip, fan, quad, and rectangle expansions are all handled
//! (rectangles via CPU triangle-list rewrite — see `expand_rectangles`).
use crate::draw_state::{IndexSize, PrimitiveType};
@@ -138,18 +137,43 @@ fn expand_quads(indices: Option<&[u32]>, vertex_count: u32) -> ProcessedPrimitiv
}
/// Rectangle lists: a Xenos-specific primitive where each group of 3
/// vertices defines a right-angle rectangle by its three non-repeated
/// corners (the 4th is derived). The uber-shader doesn't support this yet;
/// the ucode translator will emulate it as a geometry-stage fake. For P3
/// we emit an empty draw.
fn expand_rectangles(_indices: Option<&[u32]>, _vertex_count: u32) -> ProcessedPrimitive {
tracing::warn!("gpu: rectangle list primitive not yet implemented (P3 stub)");
metrics::counter!("gpu.primitive.rejected", "reason" => "rectangle_list").increment(1);
/// vertices defines a rectangle; the 4th corner is extrapolated as
/// `v3 = v0 + v2 - v1` (parallelogram completion). Canary expands this in a
/// host vertex-shader variant (`kRectangleListAsTriangleStrip`,
/// `primitive_processor.cc:389-456`): a 4-vertex triangle strip per rect with
/// the 4th corner synthesized *in the VS* from the host-vertex index.
///
/// Our replay pipeline has no host-VS corner synthesis (and the procedural
/// `vs_main` does not consume `rewritten_indices` yet), so we mirror the
/// `expand_quads`/`expand_fan` CPU idiom and emit the 3 real vertices of each
/// rect as one triangle list `(v0,v1,v2)` — the visible lower half of the
/// rect. This un-rejects the draw and gives a faithful `host_vertex_count`.
///
/// TODO: once `vs_main` does real vertex fetch + interpolation, upgrade to the
/// full quad — 6 indices `[v0,v1,v2, v2,v1,v3]` with a synthesized `v3` corner
/// — mirroring canary's `kRectangleListAsTriangleStrip`.
fn expand_rectangles(indices: Option<&[u32]>, vertex_count: u32) -> ProcessedPrimitive {
let rect_count = vertex_count / 3;
let mut out = Vec::with_capacity(3 * rect_count as usize);
let get = |i: u32| -> u32 {
match indices {
Some(buf) => buf[i as usize],
None => i,
}
};
for r in 0..rect_count {
let base = r * 3;
out.push(get(base));
out.push(get(base + 1));
out.push(get(base + 2));
}
let host_vertex_count = out.len() as u32;
metrics::counter!("gpu.primitive.expanded", "shape" => "rectangle_list").increment(1);
ProcessedPrimitive {
topology: HostTopology::TriangleList,
rewritten_indices: Some(Vec::new()),
host_vertex_count: 0,
rejected: true,
rewritten_indices: Some(out),
host_vertex_count,
rejected: false,
}
}
@@ -213,6 +237,17 @@ mod tests {
assert_eq!(idx, vec![0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7]);
}
#[test]
fn rectangle_list_expansion() {
// 2 rects (6 verts) → one triangle (v0,v1,v2) per rect, not rejected.
let p = process(PrimitiveType::RectangleList, 6, None);
let idx = p.rewritten_indices.unwrap();
assert_eq!(idx, vec![0, 1, 2, 3, 4, 5]);
assert_eq!(p.topology, HostTopology::TriangleList);
assert_eq!(p.host_vertex_count, 6);
assert!(!p.rejected);
}
#[test]
fn widen_u16_indices_big_endian() {
// 3 indices [1, 2, 0x1234] in BE u16.

View File

@@ -364,7 +364,11 @@ pub fn copy_to_memory(
// Destination coordinates are 0-based against `dest_base` — the
// base already points at the top-left of the copy rectangle.
let dst_off = tiled_2d_offset(dx, dy, pitch_aligned, bpp_log2);
let dst_addr = info.dest_base.wrapping_add(dst_off);
// `dest_base` is a bare guest *physical* address; project onto the
// committed backing window so resolved pixels land where the guest
// (and `vd_swap`'s frontbuffer read) actually see them.
let dst_addr =
crate::gpu_system::physical_to_backing(info.dest_base.wrapping_add(dst_off));
if info.source_is_64bpp {
let (lo, hi) = match single_sample_idx {

View File

@@ -32,6 +32,16 @@ pub struct RingBufferView {
/// `VdEnableRingBufferRPtrWriteBack`). We always write back eagerly, so
/// we don't actually use this for scheduling — kept for observability.
pub rptr_writeback_block_dwords: u32,
/// True for an indirect-buffer (`INDIRECT_BUFFER`) view. An IB is a fixed
/// *linear* sub-stream, not a circular ring: it is fully written when the
/// GPU jumps to it, so the read pointer advances monotonically from `0` to
/// `size_dwords` and then the buffer is exhausted (the caller ring is
/// popped). It must NOT wrap, and the primary `CP_RB_WPTR` must not be
/// applied to it. Mirrors canary `ExecuteIndirectBuffer`, which executes
/// the IB through a separate `RingBuffer reader_` and restores the primary
/// reader afterward (command_processor.cc). Circular (primary-ring)
/// semantics are used when this is `false`.
pub indirect: bool,
}
impl RingBufferView {
@@ -46,7 +56,16 @@ impl RingBufferView {
/// True if there is pending unread data to consume.
pub fn has_pending(&self) -> bool {
self.is_initialized() && self.read_offset_dwords != self.write_offset_dwords
if !self.is_initialized() {
return false;
}
if self.indirect {
// Linear sub-stream: exhausted once the read pointer reaches the
// (fixed) write pointer. Never wraps.
self.read_offset_dwords < self.write_offset_dwords
} else {
self.read_offset_dwords != self.write_offset_dwords
}
}
/// Number of dwords we can consume without wrapping past the write ptr.
@@ -54,7 +73,10 @@ impl RingBufferView {
if !self.is_initialized() {
return 0;
}
if self.write_offset_dwords >= self.read_offset_dwords {
if self.indirect {
self.write_offset_dwords
.saturating_sub(self.read_offset_dwords)
} else if self.write_offset_dwords >= self.read_offset_dwords {
self.write_offset_dwords - self.read_offset_dwords
} else {
// write has wrapped — we can read up to the end of the ring.
@@ -62,14 +84,20 @@ impl RingBufferView {
}
}
/// Advance the read pointer by `dwords`, wrapping at `size_dwords`.
/// Advance the read pointer by `dwords`. Circular rings wrap at
/// `size_dwords`; an indirect buffer advances linearly (no wrap) so it
/// terminates exactly at its fixed write pointer.
pub fn advance_read(&mut self, dwords: u32) {
if self.size_dwords == 0 {
return;
}
if self.indirect {
self.read_offset_dwords = self.read_offset_dwords.saturating_add(dwords);
} else {
self.read_offset_dwords =
(self.read_offset_dwords + dwords) % self.size_dwords;
}
}
/// Guest address for the dword at relative offset `i` from the current
/// read pointer. `None` if uninitialized.
@@ -77,7 +105,11 @@ impl RingBufferView {
if !self.is_initialized() {
return None;
}
let off = (self.read_offset_dwords + offset_dwords) % self.size_dwords;
let off = if self.indirect {
self.read_offset_dwords.saturating_add(offset_dwords)
} else {
(self.read_offset_dwords + offset_dwords) % self.size_dwords
};
Some(self.base.wrapping_add(off.wrapping_mul(4)))
}
}
@@ -120,4 +152,52 @@ mod tests {
assert_eq!(v.addr_at_offset(1), Some(0x4000_0000));
assert_eq!(v.addr_at_offset(2), Some(0x4000_0004));
}
#[test]
fn indirect_buffer_drains_linearly_and_terminates() {
// An indirect buffer is a fixed linear sub-stream: read advances from
// 0 to `size_dwords` and then is exhausted — it must NOT wrap back to
// 0 (which previously caused an infinite re-read of a system command
// buffer; iterate-2O). write_offset == size, exactly as the
// INDIRECT_BUFFER handler sets it.
let mut ib = RingBufferView {
base: 0x4adf_5080,
size_dwords: 11,
read_offset_dwords: 0,
write_offset_dwords: 11,
rptr_writeback_addr: 0,
rptr_writeback_block_dwords: 0,
indirect: true,
};
assert!(ib.has_pending());
// Drain the exact packet layout observed for Sylpheed's init IB:
// 2 + 3 + 6 dwords = 11.
ib.advance_read(2);
assert!(ib.has_pending());
ib.advance_read(3);
assert!(ib.has_pending());
ib.advance_read(6); // reaches 11 == write
assert_eq!(ib.read_offset_dwords, 11);
assert!(
!ib.has_pending(),
"indirect buffer must terminate at write ptr, not wrap to 0"
);
// addr_at_offset must not modulo-wrap for an indirect buffer.
ib.read_offset_dwords = 9;
assert_eq!(ib.addr_at_offset(1), Some(0x4adf_5080 + 10 * 4));
}
#[test]
fn indirect_flag_does_not_affect_circular_ring() {
// Sanity: a circular (primary) ring still wraps as before.
let mut v = RingBufferView::new();
v.base = 0x4adc_c000;
v.size_dwords = 8192;
v.read_offset_dwords = 8190;
v.write_offset_dwords = 2;
assert!(v.has_pending());
v.advance_read(4); // (8190 + 4) % 8192 = 2
assert_eq!(v.read_offset_dwords, 2);
assert!(!v.has_pending());
}
}

View File

@@ -45,8 +45,9 @@ pub fn emit_for(parsed: &ParsedShader, stage: &'static str) {
parsed.instructions[base + 1],
parsed.instructions[base + 2],
];
// sequence bit layout: 2 bits per triple, hi bit = is-fetch.
let is_fetch = ((sequence >> (i * 2 + 1)) & 1) != 0;
// sequence: 2 bits per instruction — bit[0]=fetch(1)/ALU(0),
// bit[1]=serialize (Xenos `ucode.h:226`).
let is_fetch = ((sequence >> (i * 2)) & 1) != 0;
if is_fetch {
match decode_fetch(words) {
FetchInstruction::Vertex(_) => vfetch_count += 1,
@@ -174,6 +175,50 @@ pub fn emit_for(parsed: &ParsedShader, stage: &'static str) {
}
}
/// Collect the unique texture-fetch-constant slot indices a shader samples.
///
/// Walks the same exec-clause / sequence-bitmap path as [`emit_for`] but only
/// extracts `TextureFetch.fetch_const` slots, deduplicated and in first-seen
/// order. The GPU draw handler uses this to decide which fetch constants to
/// decode + cache at draw time (keyed off the *active* pixel shader's real
/// `tfetch` instructions rather than a hardcoded slot).
pub fn tfetch_slots(parsed: &ParsedShader) -> Vec<u8> {
let mut slots: Vec<u8> = Vec::new();
for clause in &parsed.cf {
if let ControlFlowInstruction::Exec {
address,
count,
sequence,
..
} = clause
{
for i in 0..(*count as usize) {
let base = (*address as usize + i) * 3;
if base + 2 >= parsed.instructions.len() {
break;
}
// sequence: 2 bits per instruction — bit[0]=fetch(1)/ALU(0),
// bit[1]=serialize (Xenos `ucode.h:226`).
let is_fetch = ((sequence >> (i * 2)) & 1) != 0;
if !is_fetch {
continue;
}
let words = [
parsed.instructions[base],
parsed.instructions[base + 1],
parsed.instructions[base + 2],
];
if let FetchInstruction::Texture(tf) = decode_fetch(words) {
if !slots.contains(&tf.fetch_const) {
slots.push(tf.fetch_const);
}
}
}
}
}
slots
}
fn mark_feature(buf: &mut Vec<&'static str>, name: &'static str) {
if !buf.contains(&name) {
buf.push(name);
@@ -298,6 +343,46 @@ mod tests {
emit_for(&shader, "vs");
}
/// `tfetch_slots` should extract the fetch-constant slot of a texture
/// fetch (and dedup), and return empty for a flat ALU-only shader.
#[test]
fn tfetch_slots_extracts_texture_fetch_constants() {
// word0: opcode TEXTURE_FETCH (0x01) in low 5 bits, const_index=3 in
// bits[24:20] (Xenos `ucode.h:844`) → 0x01 | (3 << 20).
let tfetch_w0: u32 = 0x01 | (3u32 << 20);
let shader = ParsedShader {
cf: vec![
ControlFlowInstruction::Exec {
address: 0,
count: 2,
// instruction 0 is a fetch (bit[0] of its 2-bit field set),
// instruction 1 is ALU. is_fetch = (sequence >> (i*2)) & 1.
sequence: 0b00_01,
is_end: false,
predicated: false,
predicate_condition: false,
},
ControlFlowInstruction::Exit,
],
instructions: vec![tfetch_w0, 0, 0, /* ALU triple */ 0, 0, 0],
};
assert_eq!(tfetch_slots(&shader), vec![3]);
// Flat shader: no fetch bits → no slots.
let flat = ParsedShader {
cf: vec![ControlFlowInstruction::Exec {
address: 0,
count: 1,
sequence: 0,
is_end: false,
predicated: false,
predicate_condition: false,
}],
instructions: vec![0, 0, 0],
};
assert!(tfetch_slots(&flat).is_empty());
}
/// P8: a shader containing `LoopStart` should mark `cf_loop` as used
/// so the HUD can surface which deferred feature a game triggers.
#[test]

View File

@@ -56,6 +56,7 @@ const CF_KIND_LOOP_END: u32 = 5u;
const CF_KIND_COND_JMP: u32 = 6u;
const CF_KIND_COND_CALL: u32 = 7u;
const CF_KIND_RETURN: u32 = 8u;
const CF_KIND_NOP: u32 = 9u;
const CF_KIND_UNKNOWN: u32 = 15u;
// ── Alloc-kind codes (mirrors `xenia_gpu::ucode::cf_alloc_kind`). ──────
@@ -628,8 +629,8 @@ const VFMT_32_32_32_FLOAT: u32 = 57u;
// layout in `ucode.h:690`):
// w0 [4:0] opcode
// w0 [10:5] src_reg[5:0]
// w0 [17:11] dst_reg[6:0] + must-be-one
// w0 [21:17] const_index[4:0], [23:22] const_index_sel[1:0]
// w0 [17:12] dst_reg[5:0]
// w0 [24:20] const_index[4:0], [26:25] const_index_sel[1:0]
// w1 [21:16] format[5:0]
// w2 [7:0] stride (in dwords)
// w2 [30:8] offset (signed, in dwords)
@@ -641,9 +642,9 @@ fn interpret_vertex_fetch(t: u32) {
let w0 = vs_instr_dword(t, 0u);
let w1 = vs_instr_dword(t, 1u);
let w2 = vs_instr_dword(t, 2u);
let fetch_const = (w0 >> 5u) & 0x1Fu;
let dst_reg = (w0 >> 10u) & 0x7Fu;
let src_reg = (w0 >> 17u) & 0x7Fu;
let fetch_const = (w0 >> 20u) & 0x1Fu;
let dst_reg = (w0 >> 12u) & 0x3Fu;
let src_reg = (w0 >> 5u) & 0x3Fu;
let format = (w1 >> 16u) & 0x3Fu;
let stride = w2 & 0xFFu;
@@ -773,20 +774,20 @@ fn interpret_texture_fetch(t: u32, is_vertex: bool) {
} else {
w0 = ps_instr_dword(t, 0u);
}
let dst_reg = (w0 >> 10u) & 0x7Fu;
let src_reg = (w0 >> 17u) & 0x7Fu;
let uv = registers[src_reg & 0x7Fu].xy;
let dst_reg = (w0 >> 12u) & 0x3Fu;
let src_reg = (w0 >> 5u) & 0x3Fu;
let uv = registers[src_reg & 0x3Fu].xy;
let sample = textureSampleLevel(xenos_tex, xenos_samp, uv, 0.0);
registers[dst_reg & 0x7Fu] = sample;
registers[dst_reg & 0x3Fu] = sample;
}
// Walk an Exec clause's instruction triples.
// sequence: 2-bit-per-triple bitmap. Bit 0 of a pair = serialize flag
// (we ignore in MVP); bit 1 = is-fetch.
// sequence: 2-bit-per-instruction bitmap. Bit 0 of a pair = fetch(1)/ALU(0);
// bit 1 = serialize (ignored). (Xenos `ucode.h:226`.)
fn exec_vs(address: u32, count: u32, sequence: u32) {
for (var i: u32 = 0u; i < count; i = i + 1u) {
let t = address + i;
let is_fetch = ((sequence >> (i * 2u + 1u)) & 1u) != 0u;
let is_fetch = ((sequence >> (i * 2u)) & 1u) != 0u;
if is_fetch {
let opcode = vs_instr_dword(t, 0u) & 0x1Fu;
// 0x00 = vertex fetch, 0x01 = texture fetch.
@@ -803,7 +804,7 @@ fn exec_vs(address: u32, count: u32, sequence: u32) {
fn exec_ps(address: u32, count: u32, sequence: u32) {
for (var i: u32 = 0u; i < count; i = i + 1u) {
let t = address + i;
let is_fetch = ((sequence >> (i * 2u + 1u)) & 1u) != 0u;
let is_fetch = ((sequence >> (i * 2u)) & 1u) != 0u;
if is_fetch {
interpret_texture_fetch(t, false);
} else {
@@ -962,6 +963,9 @@ fn walk_cf_vs() {
// No call stack — mark and continue.
reject_mask |= REJECT_CF_CALL;
}
case CF_KIND_NOP: {
// kNop padding / kMarkVsFetchDone hint — no-op, just advance.
}
default: { reject_mask |= REJECT_CF_JUMP; }
}
if stop { break; }

View File

@@ -237,6 +237,10 @@ impl EmitCtx {
current_alloc = *kind;
}
ControlFlowInstruction::Exit => break,
// Non-executing CF clauses: padding (`kNop`) and the
// vertex-fetch-done hint (`kMarkVsFetchDone`). Skip them.
ControlFlowInstruction::Nop
| ControlFlowInstruction::MarkVsFetchDone => {}
ControlFlowInstruction::LoopStart { .. }
| ControlFlowInstruction::LoopEnd { .. } => return Err(reject::CF_LOOP),
ControlFlowInstruction::CondJmp { .. } => return Err(reject::CF_COND),
@@ -284,7 +288,9 @@ impl EmitCtx {
parsed.instructions[base + 1],
parsed.instructions[base + 2],
];
let is_fetch = ((sequence >> (i * 2 + 1)) & 1) != 0;
// sequence: 2 bits per instruction — bit[0]=fetch(1)/ALU(0),
// bit[1]=serialize (Xenos `ucode.h:226`).
let is_fetch = ((sequence >> (i * 2)) & 1) != 0;
if is_fetch {
match decode_fetch(words) {
FetchInstruction::Vertex(vf) => self.emit_vfetch(&vf)?,

View File

@@ -43,7 +43,15 @@ pub enum ControlFlowInstruction {
Return,
/// `kAlloc` — pre-allocate export registers (position, interpolators, colors).
Alloc { size: u32, kind: AllocKind },
/// Exit the shader (terminal).
/// `kNop` — fills space in the CF block; executes nothing, does not end
/// the shader. (Xenos opcode 0.)
Nop,
/// `kMarkVsFetchDone` — hint that no more vertex fetches will be performed.
/// (Xenos opcode 15.) Non-terminating.
MarkVsFetchDone,
/// Exit the shader (terminal). Synthesized — Xenos has no dedicated exit
/// opcode; the shader ends after an `Exec`/`CondExec` clause with the
/// END bit set (`is_end`). Retained for callers/tests that reference it.
Exit,
/// Unknown / unhandled opcode.
Unknown { opcode: u8 },
@@ -93,37 +101,45 @@ fn decode_single(payload: u64) -> ControlFlowInstruction {
let predicated = ((payload >> 28) & 1) != 0;
let predicate_condition = ((payload >> 29) & 1) != 0;
// Xenos `ControlFlowOpcode` (canary `ucode.h:86-160`):
// 0 kNop, 1 kExec, 2 kExecEnd, 3 kCondExec, 4 kCondExecEnd,
// 5 kCondExecPred, 6 kCondExecPredEnd, 7 kLoopStart, 8 kLoopEnd,
// 9 kCondCall, 10 kReturn, 11 kCondJmp, 12 kAlloc,
// 13 kCondExecPredClean, 14 kCondExecPredCleanEnd, 15 kMarkVsFetchDone.
// All exec variants share the address(12)/count(3)/sequence(12) layout
// of `ControlFlowExecInstruction`; the `*End` variants terminate the
// shader. (Prior table was off-by-one — it mapped 0→Exec and 1→Exit,
// so a real `kExec` clause was misread as a terminal `Exit`, truncating
// the CF block and dropping every `tfetch` in it.)
let exec = |is_end: bool| ControlFlowInstruction::Exec {
address: (payload & 0xFFF) as u32,
count: ((payload >> 12) & 0x7) as u32,
sequence: ((payload >> 16) & 0xFFF) as u32,
is_end,
predicated,
predicate_condition,
};
match opcode {
0 => ControlFlowInstruction::Exec {
address: (payload & 0xFFF) as u32,
count: ((payload >> 12) & 0x7) as u32,
sequence: ((payload >> 16) & 0xFFF) as u32,
is_end: false,
predicated,
predicate_condition,
},
1 => ControlFlowInstruction::Exit,
2 => ControlFlowInstruction::Exec {
address: (payload & 0xFFF) as u32,
count: ((payload >> 12) & 0x7) as u32,
sequence: ((payload >> 16) & 0xFFF) as u32,
is_end: true,
predicated,
predicate_condition,
},
6 => ControlFlowInstruction::LoopStart {
0 => ControlFlowInstruction::Nop,
1 => exec(false),
2 => exec(true),
3 => exec(false),
4 => exec(true),
5 => exec(false),
6 => exec(true),
7 => ControlFlowInstruction::LoopStart {
address: (payload & 0x3FF) as u32,
loop_id: ((payload >> 16) & 0x1F) as u32,
},
7 => ControlFlowInstruction::LoopEnd {
8 => ControlFlowInstruction::LoopEnd {
address: (payload & 0x3FF) as u32,
loop_id: ((payload >> 16) & 0x1F) as u32,
},
8 => ControlFlowInstruction::CondCall {
9 => ControlFlowInstruction::CondCall {
target: (payload & 0x3FF) as u32,
},
9 => ControlFlowInstruction::Return,
10 => ControlFlowInstruction::CondJmp {
10 => ControlFlowInstruction::Return,
11 => ControlFlowInstruction::CondJmp {
target: (payload & 0x3FF) as u32,
predicated,
predicate_condition,
@@ -132,6 +148,9 @@ fn decode_single(payload: u64) -> ControlFlowInstruction {
size: (payload & 0x7) as u32,
kind: AllocKind::from_bits(((payload >> 4) & 0x7) as u32),
},
13 => exec(false),
14 => exec(true),
15 => ControlFlowInstruction::MarkVsFetchDone,
other => ControlFlowInstruction::Unknown { opcode: other },
}
}
@@ -141,12 +160,49 @@ mod tests {
use super::*;
#[test]
fn opcode_exit_decodes() {
// opcode 1 (Exit) in bits 44..47 of A's 48-bit payload.
fn opcode_nop_and_exec_decode() {
// Xenos opcode 0 = kNop (non-terminating padding).
let payload: u64 = 0u64 << 44;
let (hi, lo) = ((payload & 0xFFFF_FFFF) as u32, ((payload >> 32) & 0xFFFF) as u32);
assert_eq!(decode_cf_pair(hi, lo, 0).0, ControlFlowInstruction::Nop);
// Xenos opcode 1 = kExec (executes instructions; NOT a terminal exit).
let payload: u64 = 1u64 << 44;
let (hi, lo) = ((payload & 0xFFFF_FFFF) as u32, ((payload >> 32) & 0xFFFF) as u32);
let cf = decode_cf_pair(hi, lo, 0).0;
assert_eq!(cf, ControlFlowInstruction::Exit);
match decode_cf_pair(hi, lo, 0).0 {
ControlFlowInstruction::Exec { is_end, .. } => assert!(!is_end),
other => panic!("opcode 1 should be non-end Exec, got {other:?}"),
}
// Xenos opcode 15 = kMarkVsFetchDone (non-terminating hint).
let payload: u64 = 15u64 << 44;
let (hi, lo) = ((payload & 0xFFFF_FFFF) as u32, ((payload >> 32) & 0xFFFF) as u32);
assert_eq!(
decode_cf_pair(hi, lo, 0).0,
ControlFlowInstruction::MarkVsFetchDone
);
}
#[test]
fn real_logo_shader_has_tfetch_clauses() {
// The publisher-logo pixel shader E59B2B3DA4AA9008 (captured from the
// canary oracle, byte-identical to the microcode our guest IM_LOADs).
// Regression for iterate-3M: the old off-by-one opcode table decoded
// its leading `kExec` (opcode 1) as a terminal `Exit`, truncating the
// CF block so the `tfetch2D` never appeared → flat splash.
let ucode: [u32; 24] = [
0x00011002, 0x00001200, 0xC4000000, 0x00004003, 0x00002200, 0x00000000,
0x10082021, 0x1F1FF688, 0x00004000, 0xC8080001, 0x001B1B00, 0xC1020000,
0xC8070000, 0x00C0C000, 0xC1020000, 0xC8070001, 0x00C01B00, 0xC1000100,
0xC80F8000, 0x00000000, 0xC2010100, 0x00000000, 0x00000000, 0x00000000,
];
let p = crate::ucode::parse_shader(&ucode);
let exec_clauses = p
.cf
.iter()
.filter(|c| matches!(c, ControlFlowInstruction::Exec { .. }))
.count();
assert!(exec_clauses >= 1, "expected >=1 Exec clause, cf={:?}", p.cf);
let slots = crate::shader_metrics::tfetch_slots(&p);
assert!(!slots.is_empty(), "expected tfetch slots, got none; cf={:?}", p.cf);
}
#[test]

View File

@@ -54,23 +54,32 @@ pub mod op {
}
pub fn decode_fetch(words: [u32; 3]) -> FetchInstruction {
// Fetch dword0 bitfields (Xenos `ucode.h:740-749` vfetch / `844-845`
// tfetch): opcode_value:5, src_reg:6, src_reg_am:1, dst_reg:6,
// dst_reg_am:1, (fetch_valid_only|must_be_one):1, const_index:5 @ bit20,
// ... The prior decoder read `const_index` from bit 5 (which is actually
// `src_reg`), so every fetch reported the wrong fetch-constant slot — the
// logo `tfetch2D ..., tf0` was read as `tf1`, and slot 1's empty constant
// failed to decode → no texture. The texture-fetch `dimension` lives in
// dword2 bits 14..15, not dword1.
let w0 = words[0];
let w1 = words[1];
let w2 = words[2];
let opcode = (w0 & 0x1F) as u8;
match opcode {
op::VERTEX_FETCH => FetchInstruction::Vertex(VertexFetch {
fetch_const: ((w0 >> 5) & 0x1F) as u8,
src_register: ((w0 >> 17) & 0x7F) as u8,
dest_register: ((w0 >> 10) & 0x7F) as u8,
dest_write_mask: ((w1 >> 23) & 0xF) as u8,
fetch_const: ((w0 >> 20) & 0x1F) as u8,
src_register: ((w0 >> 5) & 0x3F) as u8,
dest_register: ((w0 >> 12) & 0x3F) as u8,
dest_write_mask: (w1 & 0xF) as u8,
raw: words,
}),
op::TEXTURE_FETCH => FetchInstruction::Texture(TextureFetch {
fetch_const: ((w0 >> 5) & 0x1F) as u8,
src_register: ((w0 >> 17) & 0x7F) as u8,
dest_register: ((w0 >> 10) & 0x7F) as u8,
dest_write_mask: ((w1 >> 23) & 0xF) as u8,
dimension: ((w1 >> 29) & 0x3) as u8,
fetch_const: ((w0 >> 20) & 0x1F) as u8,
src_register: ((w0 >> 5) & 0x3F) as u8,
dest_register: ((w0 >> 12) & 0x3F) as u8,
dest_write_mask: (w1 & 0xF) as u8,
dimension: ((w2 >> 14) & 0x3) as u8,
raw: words,
}),
_ => FetchInstruction::Unknown { opcode, raw: words },
@@ -83,8 +92,9 @@ mod tests {
#[test]
fn decode_vertex_fetch() {
// opcode=0 (vertex), fetch_const=5, src=2, dest=7.
let w0 = 0u32 | (5 << 5) | (7 << 10) | (2 << 17);
// opcode=0 (vertex). Xenos dword0: src_reg@bit5, dst_reg@bit12,
// const_index@bit20. fetch_const=5, src=2, dest=7.
let w0 = 0u32 | (2 << 5) | (7 << 12) | (5 << 20);
let v = decode_fetch([w0, 0, 0]);
match v {
FetchInstruction::Vertex(vf) => {
@@ -98,11 +108,16 @@ mod tests {
#[test]
fn decode_texture_fetch() {
let w0 = 1u32 | (3 << 5) | (4 << 10) | (1 << 17);
let t = decode_fetch([w0, (2u32 << 29), 0]);
// opcode=1 (texture). const_index@bit20=3, src@bit5=1, dst@bit12=4.
// dimension lives in dword2 bits 14..15.
let w0 = 1u32 | (1 << 5) | (4 << 12) | (3 << 20);
let w2 = 2u32 << 14;
let t = decode_fetch([w0, 0, w2]);
match t {
FetchInstruction::Texture(tf) => {
assert_eq!(tf.fetch_const, 3);
assert_eq!(tf.src_register, 1);
assert_eq!(tf.dest_register, 4);
assert_eq!(tf.dimension, 2);
}
other => panic!("expected Texture, got {other:?}"),

View File

@@ -48,6 +48,9 @@ pub mod cf_kind {
pub const COND_JMP: u32 = 6;
pub const COND_CALL: u32 = 7;
pub const RETURN: u32 = 8;
/// Non-executing CF clause: `kNop` padding or `kMarkVsFetchDone` hint.
/// The WGSL CF walker treats this as a no-op (advance, do not reject).
pub const NOP: u32 = 9;
pub const UNKNOWN: u32 = 15;
}
@@ -136,6 +139,7 @@ fn encode_cf(c: ControlFlowInstruction) -> (u32, u32, u32) {
}
CondCall { target } => (cf_kind::COND_CALL, target, 0),
Return => (cf_kind::RETURN, 0, 0),
Nop | MarkVsFetchDone => (cf_kind::NOP, 0, 0),
Unknown { opcode } => (cf_kind::UNKNOWN, opcode as u32, 0),
}
}
@@ -164,9 +168,11 @@ pub struct ParsedShader {
}
/// Decode a shader blob. `raw_dwords` is a host-endian slice of the entire
/// microcode buffer (control flow + instructions). Heuristic: CF dword count
/// is encoded in the first word's low 12 bits of the last exec clause —
/// canary iterates until it hits a clause of kind `Exit`. We do the same.
/// microcode buffer (control flow + instructions). The CF block is implicitly
/// bounded: we walk clause-pair rows until one terminates the shader (an
/// `Exec`/`CondExec` clause with the END bit set, per Xenos). Everything after
/// that row is the instruction block; exec/loop addresses are then rebased to
/// be relative to it.
pub fn parse_shader(raw_dwords: &[u32]) -> ParsedShader {
let mut cf = Vec::new();
// CF clauses are 48-bit (word1 lo 16 + word0 = 48 or so per canary's
@@ -175,22 +181,50 @@ pub fn parse_shader(raw_dwords: &[u32]) -> ParsedShader {
while i + 2 < raw_dwords.len() {
let a = decode_cf_pair(raw_dwords[i], raw_dwords[i + 1], raw_dwords[i + 2]);
let (first, second) = a;
let seen_exit = matches!(
first,
ControlFlowInstruction::Exit | ControlFlowInstruction::Unknown { .. }
) || matches!(
second,
ControlFlowInstruction::Exit | ControlFlowInstruction::Unknown { .. }
);
// The CF block ends after the clause that terminates the shader: an
// `Exec` with the END bit set (Xenos `kExecEnd`/`kCondExec*End`), a
// synthetic `Exit`, or an `Unknown` opcode (decode ran off the CF
// block into instruction data — stop defensively). `Nop` padding
// does NOT terminate. (Previously this stopped on the first `Exit`,
// but with the corrected opcode table opcode 1 is `kExec`, not exit,
// so real exec clauses kept the parse going as intended.)
let terminates = |cf: &ControlFlowInstruction| {
matches!(
cf,
ControlFlowInstruction::Exec { is_end: true, .. }
| ControlFlowInstruction::Exit
| ControlFlowInstruction::Unknown { .. }
)
};
let seen_end = terminates(&first) || terminates(&second);
cf.push(first);
cf.push(second);
i += 3;
if seen_exit {
if seen_end {
break;
}
}
// Everything after `i` dwords is the instruction block.
let instructions = raw_dwords[i..].to_vec();
// Xenos exec/loop `address` fields are absolute instruction-triple indices
// counted from shader dword 0, but `instructions` here begins *after* the
// CF block. Rebase those addresses to be relative to the instruction block
// (subtract the CF triple count) so `address * 3` indexes `instructions`
// directly. (Without this, every exec read 3 dwords too far per CF triple —
// the publisher-logo `tfetch` triple was skipped → flat splash.)
let cf_triples = (i / 3) as u32;
for clause in cf.iter_mut() {
match clause {
ControlFlowInstruction::Exec { address, .. } => {
*address = address.saturating_sub(cf_triples);
}
ControlFlowInstruction::LoopStart { address, .. }
| ControlFlowInstruction::LoopEnd { address, .. } => {
*address = address.saturating_sub(cf_triples);
}
_ => {}
}
}
ParsedShader { cf, instructions }
}
@@ -235,15 +269,19 @@ mod tests {
}
#[test]
fn trivial_exit_clause_stops_parsing() {
// Two clauses: [NOP (kind=0), EXIT (kind=1)] encoded per canary.
// Exit clause is opcode 1 in the top 4 bits of the upper 16 bits.
let w0 = 0u32; // clause A body
let w1 = (1u32 << 12) << 16; // upper 16 bits = 0x1000 → opcode=1 (EXIT) for clause A
let w2 = 0u32;
let p = parse_shader(&[w0, w1, w2, 0xDEAD_BEEF]);
fn exec_end_clause_stops_parsing() {
// Row: clause B = kExecEnd (opcode 2) terminates the CF block.
// 48-bit payload of B occupies hi16(word1) + word2; opcode lives in
// bits 44..47 of that payload. Put opcode 2 there: payload bit 44 set
// for the `2` → (2 << 44). In B's framing, bits 16..47 come from
// word2, so word2 bit (44-16)=28 region holds the opcode nibble.
let b_payload: u64 = 2u64 << 44; // kExecEnd
// B = lo16 from hi16(word1), hi from word2. Reconstruct word1/word2.
let word1 = ((b_payload & 0xFFFF) as u32) << 16; // B's low 16 bits → hi16(word1)
let word2 = ((b_payload >> 16) & 0xFFFF_FFFF) as u32;
let p = parse_shader(&[0, word1, word2, 0xDEAD_BEEF]);
assert!(!p.cf.is_empty());
// Exit detected → remaining dword is instruction data.
// ExecEnd detected in the first row → remaining dword is instruction data.
assert_eq!(p.instructions, vec![0xDEAD_BEEF]);
}
}

View File

@@ -486,12 +486,20 @@ fn ke_query_performance_frequency(ctx: &mut PpcContext, _mem: &GuestMemory, _sta
ctx.gpr[3] = 50_000_000; // 50 MHz
}
fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
let time_ptr = ctx.gpr[3] as u32;
if time_ptr != 0 {
let fake_time: u64 = 132_500_000_000_000_000; // ~2021 FILETIME
mem.write_u32(time_ptr, (fake_time >> 32) as u32);
mem.write_u32(time_ptr + 4, fake_time as u32);
// ITERATE-2J — advance with the same deterministic clock the
// KeTimeStampBundle uses (1 global_clock unit ≈ 100 ns) so a guest
// that polls KeQuerySystemTime for elapsed time also sees forward
// progress instead of a frozen constant. FILETIME base (~2021) +
// 100-ns-unit clock.
const FILETIME_BASE: u64 = 132_500_000_000_000_000;
let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
let now = state.now_basis_at(hw_id);
let system_time = FILETIME_BASE.wrapping_add(now);
mem.write_u32(time_ptr, (system_time >> 32) as u32);
mem.write_u32(time_ptr + 4, system_time as u32);
}
}
@@ -1644,6 +1652,79 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
return;
}
// XFileRenameInformation (10): move the backing file to a new path.
// Sylpheed's asset-cache decompresses each packed resource to a staging
// `cache:\<hash><tail>.tmp` then renames it into its final nested path
// `cache:\<hash>\<dir>\<file>`. Without an actual host-FS rename the
// nested target stays empty, the later read-back of the decompressed
// asset (e.g. the title logo texture `\69d8e45c\e\534ffea`) misses, and
// the logo never loads. Mirror canary `xboxkrnl_io_info.cc:226`
// (`X_FILE_RENAME_INFORMATION{ replace_existing@0, root_dir_handle@4,
// ansi_string@8 }` → `file->Rename(TranslateAnsiPath(ansi_string))`).
if info_class == 10 {
// Read the target path from the embedded ANSI_STRING at info_ptr+8.
let target_raw = match crate::path::read_ansi_string(mem, info_ptr + 8) {
Some(s) if !s.is_empty() => s,
_ => {
const STATUS_OBJECT_NAME_INVALID: u64 = 0xC000_0033;
ctx.gpr[3] = STATUS_OBJECT_NAME_INVALID;
return;
}
};
// Resolve the destination against the host cache backing dir. We only
// support renames within the writable `cache:` mount (the only place
// a guest can create files); disc/synth entries are read-only.
let new_host = state.resolve_cache_path(&target_raw);
// Current backing host path of the handle.
let old_host = match state.objects.get(&handle) {
Some(KernelObject::File { host_path: Some(hp), .. }) => Some(hp.clone()),
Some(KernelObject::File { .. }) => None,
_ => {
ctx.gpr[3] = STATUS_INVALID_HANDLE;
return;
}
};
let status: u64 = match (old_host, new_host) {
(Some(old), Some(new)) => {
if let Some(parent) = new.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::rename(&old, &new) {
Ok(()) => {
// Update the handle so subsequent I/O targets the new
// host path + guest path.
if let Some(KernelObject::File { path, host_path, .. }) =
state.objects.get_mut(&handle)
{
*path = crate::path::normalize_path(&target_raw);
*host_path = Some(new.clone());
}
tracing::info!(
"NtSetInformationFile rename cache {:?} -> {:?} ({:?})",
old, new, target_raw
);
STATUS_SUCCESS
}
Err(e) => {
tracing::warn!(
"NtSetInformationFile rename {:?} -> {:?} failed: {}",
old, new, e
);
STATUS_UNSUCCESSFUL
}
}
}
// Non-cache (read-only VFS) source/target: acknowledge without a
// host move, matching the prior permissive behaviour.
_ => STATUS_SUCCESS,
};
if iosb_ptr != 0 {
write_io_status_block(mem, iosb_ptr, status as u32, info_length);
}
ctx.gpr[3] = status;
return;
}
// Handle lookup.
let Some(KernelObject::File { size, position, host_path, .. }) = state.objects.get_mut(&handle) else {
ctx.gpr[3] = STATUS_INVALID_HANDLE;
@@ -2875,10 +2956,12 @@ fn vd_initialize_ring_buffer(ctx: &mut PpcContext, _mem: &GuestMemory, state: &m
// packets directly into ring memory at the current WPTR (the GPU
// backend lives on a worker thread under `--gpu-thread` so we can't
// read its `ring.base` from the kernel side without a channel hop).
// Per canary: size_log2 is log2(size in BYTES), so size in dwords =
// 2^size_log2 / 4 = 1 << (size_log2 - 2).
// Per canary `CommandProcessor::InitializeRingBuffer`: the ring is
// `1 << (size_log2 + 3)` bytes = `1 << (size_log2 + 1)` dwords (`r4` is
// log2 of the size in quadwords). Kept in sync with
// `GpuSystem::initialize_ring_buffer`. (Currently bookkeeping-only.)
state.ring_base = ptr;
state.ring_size_dwords = if size_log2 >= 2 { 1u32 << (size_log2 - 2) } else { 0 };
state.ring_size_dwords = 1u32 << (size_log2 + 1);
ctx.gpr[3] = 0;
}
@@ -2989,53 +3072,87 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// xboxkrnl_video.cc:479. Currently skipped (see below).
let _ = fetch_dwords; // silence unused — will be live again under the deferred path
// The original M2b path zero-filled buffer_ptr (in the system command
// buffer) and bumped WPTR by 64 to expose the game's own ring writes.
// Keep that untouched — the game still expects buffer_ptr to be a
// skippable scratch area, and the bump still exposes any game-batched
// PM4 packets for the drain.
// iterate-2V: mirror xenia-canary `VdSwap_entry` (xboxkrnl_video.cc:518-548)
// FAITHFULLY. The game reserves 64 dwords (256 bytes) in the primary ring
// at `buffer_ptr`; canary writes a `PM4_TYPE0(SHADER_CONSTANT_FETCH_00_0)`
// fetch-constant patch followed by `PM4_TYPE3(PM4_XE_SWAP)`, then pads with
// NOPs — and **NEVER touches `CP_RB_WPTR`**. The game advances the primary
// ring write-pointer itself via its own doorbell once it has finished
// populating the reserved slot, so VdSwap only fills the bytes.
//
// iterate-2V FIX (the bug this removes): a prior revision bumped the
// primary ring `CP_RB_WPTR` out-of-band here (`extend_write_ptr_by(64)`).
// But `buffer_ptr` (~0x4add6efc) is NOT inside the primary ring (base
// ~0x4adcd000, 8192 dwords) — it lives ~10k dwords past it, in the
// renderer indirect-buffer region. The bogus WPTR bump pushed the GPU
// read-pointer PAST the guest's real write-pointer, the drain treated the
// overshoot as a circular wrap, and **re-executed the splash's draw
// indirect-buffers ~2×** — inflating draws to 78 (real splash ≈ 28; 12
// INDIRECT_BUFFERs vs the real 6). Canary's `VdSwap_entry` writes the
// block and returns; the swap-complete CP interrupt comes only from the
// game's own in-stream `PM4_INTERRUPT` packets, never from VdSwap.
if buffer_ptr != 0 {
for i in 0..64u32 {
mem.write_u32(buffer_ptr + i * 4, xenia_gpu::pm4::make_packet_type2());
let mut off = 0u32;
let mut put = |i: &mut u32, v: u32| {
mem.write_u32(buffer_ptr + *i * 4, v);
*i += 1;
};
// PM4_TYPE0 fetch-constant slot-0 patch (6 dwords payload). The
// base_address field is patched to the physical frontbuffer so the
// bloom/blur "sample frame N for frame N+1" path reads the right page.
let mut patched = fetch_dwords;
patched[1] = (patched[1] & 0x0000_0FFF) | ((frontbuffer_addr >> 12) << 12);
put(
&mut off,
xenia_gpu::pm4::make_packet_type0(
xenia_gpu::gpu_system::CONST_BASE_FETCH as u16,
6,
),
);
for d in patched {
put(&mut off, d);
}
// PM4_TYPE3(PM4_XE_SWAP, 4 dwords): signature, frontbuffer_phys, w, h.
put(
&mut off,
xenia_gpu::pm4::make_packet_type3(xenia_gpu::pm4::PM4_XE_SWAP, 4),
);
put(&mut off, xenia_gpu::pm4::SWAP_SIGNATURE);
put(&mut off, frontbuffer_addr);
put(&mut off, width);
put(&mut off, height);
// Pad the remainder with NOP (Type-2) packets.
while off < 64 {
put(&mut off, xenia_gpu::pm4::make_packet_type2());
}
}
state.gpu.extend_write_ptr_by(64);
// NOTE: We deliberately do NOT bump `CP_RB_WPTR` here (see the iterate-2V
// comment above). The drain below consumes only the packets the game has
// legitimately advanced the write-pointer over.
// GPUBUG-DRAIN-001: notify the swap directly.
//
// Per xenia-canary `VdSwap_entry` (xboxkrnl_video.cc:438-521), the
// textbook approach is to inject `PM4_TYPE0(SHADER_CONSTANT_FETCH_00_0)`
// (fetch-constant slot-0 patch for the Sylpheed bloom/blur "frame N+1"
// sample) followed by `PM4_TYPE3(PM4_XE_SWAP)` directly into the
// primary ring at WPTR, then let the natural drain consume them.
//
// That works in **pure lockstep** (drain runs at every kernel callback
// boundary, ring has at most a few hundred packets pending). It
// **does not** work under `--parallel` (CPU + GPU ring contention) —
// observed empirically: vd_swap's `drain_to_current_wptr` consumes
// 8-10 million game-batched IB packets in the 900 ms inline-deadline
// window without reaching our tail-injected PM4_XE_SWAP. Under
// threaded backend the worker has the same deadline. Either:
// (a) the safety-net direct notify (below) fires and gets the swap
// counted — but if the worker *eventually* drains past our
// injected packet later it would double-count,
// (b) we extend the deadline so far that vd_swap blocks for many
// seconds — unreasonable for a kernel callback.
//
// Skip the ring injection unconditionally and post `notify_xe_swap`
// directly. The drain still runs (game packets execute as normal).
// **Trade-off**: the slot-0 fetch-constant patch is deferred —
// tracked as GPUBUG-FETCH-PATCH-001. Sylpheed currently has draws=0,
// so a stale slot 0 has no observable effect.
// Drain the ring up to whatever the game has actually submitted; any
// in-stream `PM4_INTERRUPT` / draw packets execute in order. The
// reserved-slot PM4_XE_SWAP is consumed by the GPU only once the game
// advances its own doorbell over it. The swap-counter safety net below
// keeps host swap bookkeeping live in the meantime.
let drained = state.gpu.drain_to_current_wptr(mem);
tracing::debug!(drained, "VdSwap: drained PM4 packets");
// Direct swap notification. Inline mode bumps `swaps_seen`
// synchronously; threaded mode posts a `GpuCommand::NotifyXeSwap`
// and the worker bumps it asynchronously.
// Safety net: if the drain did NOT reach our PM4_XE_SWAP this call (e.g.
// an undersized inline deadline left game-batched packets pending), still
// bump the host swap counter so the UI present + swap stats stay live.
// Skip when the in-stream PM4_XE_SWAP already recorded this frontbuffer
// (avoids double-counting). This path does NOT raise a CP interrupt.
if frontbuffer_addr != 0 && width > 0 && height > 0 {
let already_swapped = state
.gpu
.as_inline_mut()
.map(|g| g.last_swap.map(|s| s.frontbuffer_phys) == Some(frontbuffer_addr))
.unwrap_or(false);
if !already_swapped {
state.gpu.notify_xe_swap(frontbuffer_addr, width, height);
}
}
// The remaining vd_swap work (UI publish: shader blobs, constants,
// texture cache, frontbuffer detile, ui.notify_swap) reads
@@ -3072,16 +3189,17 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
);
ui.publish_assets(blobs, constants);
// P5: try to decode the primary texture (fetch constant slot 0).
// Slot 0 is the convention most games use for their main bound
// texture at draw time; full N-slot binding waits for P6+. If the
// slot is unset or the format isn't supported (magenta stub kicks
// in host-side), we skip.
//
// Texture fetch constants live at `CONST_BASE_FETCH + slot*6` in
// the register file; we read the 6 dwords, decode the key, hit
// the CPU cache (with page-version freshness), and clone the
// decoded bytes across the bridge.
// P5b: publish the texture the last draw's *active pixel shader*
// actually sampled. The GPU draw handler decodes the PS's real
// `tfetch` fetch-constant slots into `last_draw_textures`; we publish
// the first (the UI binds a single texture today). When the last draw
// used a flat (no-tfetch) shader the list is empty, so we fall back to
// the legacy slot-0 probe to preserve behavior on flat-only frames.
let published = gpu_inline.last_draw_textures.first().cloned().or_else(|| {
// Fallback: probe fetch constant slot 0 directly. Texture fetch
// constants live at `CONST_BASE_FETCH + slot*6` in the register
// file; read 6 dwords, decode the key, hit the CPU cache with
// page-version freshness, clone the bytes across the bridge.
const TEX_SLOT: u32 = 0;
let mut fetch6 = [0u32; 6];
for (i, slot) in fetch6.iter_mut().enumerate() {
@@ -3089,10 +3207,9 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
.register_file
.read(xenia_gpu::gpu_system::CONST_BASE_FETCH + TEX_SLOT * 6 + i as u32);
}
let published = if let Some(key) = xenia_gpu::texture_cache::decode_fetch_constant(fetch6)
{
// Span over the entire tiled texture footprint to pick the
// max page version covering it.
let key = xenia_gpu::texture_cache::decode_fetch_constant(fetch6)?;
// Span over the entire tiled texture footprint to pick the max
// page version covering it.
let bi = key.format.block_info();
let span_bytes = (key.pitch_texels as u32)
* (key.height as u32)
@@ -3110,9 +3227,7 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
None
}
}
} else {
None
};
});
metrics::gauge!("gpu.texture_cache.entries")
.set(gpu_inline.texture_cache.len() as f64);
ui.publish_texture(published);
@@ -3161,13 +3276,18 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// safer to cap the read at the known total size to avoid OOB.
let mut tiled = Vec::with_capacity(total_tiled_bytes);
let mut ok = true;
// The frontbuffer is a guest *physical* address; project onto the
// committed backing window (see `xenia_gpu::physical_to_backing`)
// so the present reads the pixels the GPU resolved, not a stale /
// zero mirror page.
let fb_backing = xenia_gpu::physical_to_backing(swap.frontbuffer_phys);
for i in 0..total_tiled_bytes {
// read_u8 is cheap — the VirtualMemory handler returns 0
// for unmapped pages so we get a recognisable dark frame
// rather than a crash if the address turned out bogus.
let addr = swap.frontbuffer_phys.wrapping_add(i as u32);
let addr = fb_backing.wrapping_add(i as u32);
tiled.push(mem.read_u8(addr));
if addr < swap.frontbuffer_phys {
if addr < fb_backing {
ok = false;
break;
}
@@ -5534,6 +5654,67 @@ mod tests {
}
}
/// `NtSetInformationFile` class 10 (`XFileRenameInformation`) must move
/// the backing host file to the new `cache:` path and update the handle.
/// Mirrors Sylpheed's asset-cache `.tmp` → `\<hash>\<dir>\<file>` move;
/// without it the nested target stays empty and the decompressed asset
/// (logo texture) never reads back. Faithful to canary `file->Rename`.
#[test]
fn nt_set_information_file_rename_moves_cache_file() {
let (mut ctx, mut mem, mut state) = fresh();
// Real temp cache root + a staging `.tmp` file with known bytes.
let root = std::env::temp_dir().join(format!("xenia-rs-rename-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let old_host = root.join("69d8e45ce534ffea.tmp");
std::fs::write(&old_host, b"LOGOTEX!").unwrap();
state.cache_root = Some(root.clone());
// Open handle whose backing host_path is the staging file.
let handle = state.alloc_handle_for(KernelObject::File {
path: "69d8e45ce534ffea.tmp".to_string(),
size: 8,
position: 0,
data: Arc::new(Vec::new()),
dir_enum_pos: None,
host_path: Some(old_host.clone()),
});
// X_FILE_RENAME_INFORMATION { replace@0, root_dir@4, ANSI_STRING@8 }.
// ANSI_STRING { len u16, max u16, buf u32 } at info_ptr+8; buffer holds
// the target path "cache:\69d8e45c\e\534ffea".
let info_ptr = SCRATCH_BASE + 0x100;
let str_buf = SCRATCH_BASE + 0x200;
let target = b"cache:\\69d8e45c\\e\\534ffea";
for (i, b) in target.iter().enumerate() {
mem.write_u8(str_buf + i as u32, *b);
}
mem.write_u32(info_ptr, 0); // replace_existing
mem.write_u32(info_ptr + 4, 0); // root_dir_handle
mem.write_u16(info_ptr + 8, target.len() as u16); // ANSI_STRING.Length
mem.write_u16(info_ptr + 10, target.len() as u16); // MaximumLength
mem.write_u32(info_ptr + 12, str_buf); // Buffer
let iosb_ptr = SCRATCH_BASE + 0x140;
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = iosb_ptr as u64;
ctx.gpr[5] = info_ptr as u64;
ctx.gpr[6] = 16;
ctx.gpr[7] = 10; // XFileRenameInformation
nt_set_information_file(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
// Staging file gone; nested target exists with the same bytes.
let new_host = root.join("69d8e45c").join("e").join("534ffea");
assert!(!old_host.exists(), "staging .tmp should be moved away");
assert_eq!(std::fs::read(&new_host).unwrap(), b"LOGOTEX!");
// Handle now points at the new host + guest path.
match state.objects.get(&handle) {
Some(KernelObject::File { host_path: Some(hp), path, .. }) => {
assert_eq!(hp, &new_host);
assert_eq!(path, "cache:/69d8e45c/e/534ffea");
}
_ => panic!("file handle lost or host_path missing"),
}
let _ = std::fs::remove_dir_all(&root);
}
/// Read-only VFS — truncating to a different size must fail with
/// `STATUS_UNSUCCESSFUL`, matching Canary's error path when
/// `file->SetLength(...)` can't honour the request.

View File

@@ -30,6 +30,12 @@ use xenia_cpu::ThreadRef;
pub const INTERRUPT_SOURCE_VSYNC: u32 = 0;
pub const INTERRUPT_SOURCE_CP: u32 = 1;
/// The processor the graphics ISR impersonates for a v-sync interrupt.
/// Canary hard-codes this: `MarkVblank` → `DispatchInterruptCallback(0, 2)`
/// (graphics_system.cc:478). CP interrupts instead use the bit index of the
/// `PM4_INTERRUPT` `cpu_mask`.
pub const VSYNC_TARGET_CPU: u8 = 2;
/// Guest-registered V-sync / graphics-interrupt callback (from
/// `VdSetGraphicsInterruptCallback`).
#[derive(Debug, Clone, Copy)]
@@ -145,9 +151,16 @@ pub type PendingLocalIrq = [std::sync::atomic::AtomicU8;
pub struct InterruptState {
/// Registered callback (set by `VdSetGraphicsInterruptCallback`).
pub callback: Option<GraphicsInterruptCallback>,
/// Bounded FIFO of pending interrupt sources awaiting injection.
/// Push-back on queue, pop-front on inject. Over-cap pushes drop.
pub pending: VecDeque<u32>,
/// Bounded FIFO of pending interrupts awaiting injection, as
/// `(source, target_cpu)`. Push-back on queue, pop-front on inject.
/// Over-cap pushes drop. `target_cpu` is the processor the graphics
/// ISR must impersonate (canary `XThread::SetActiveCpu` / the
/// `DispatchInterruptCallback(source, cpu)` argument): the bit index
/// of the CP `PM4_INTERRUPT` `cpu_mask` for source=1, and a fixed `2`
/// for vsync (canary `DispatchInterruptCallback(0, 2)`). The ISR reads
/// it from the PCR (`[r13+268]`) to clear the matching per-CPU bit of
/// the swap-acknowledge fence.
pub pending: VecDeque<(u32, u8)>,
/// When `Some`, some HW thread is currently running a callback; on
/// return-to-sentinel we restore this and clear the flag.
pub saved: Option<SavedCallbackCtx>,
@@ -211,8 +224,9 @@ impl InterruptState {
});
}
/// Queue an interrupt for the next safe injection point.
pub fn queue_interrupt(&mut self, source: u32) {
/// Queue an interrupt for the next safe injection point. `cpu` is the
/// processor the ISR must impersonate (see `pending`).
pub fn queue_interrupt(&mut self, source: u32, cpu: u8) {
if self.callback.is_none() {
self.dropped += 1;
return;
@@ -221,18 +235,23 @@ impl InterruptState {
self.dropped += 1;
return;
}
self.pending.push_back(source);
self.pending.push_back((source, cpu));
}
/// Peek at the next pending source without removing it.
pub fn peek_next(&self) -> Option<u32> {
self.pending.front().copied()
self.pending.front().map(|&(source, _)| source)
}
/// Peek at the target CPU of the next pending interrupt.
pub fn peek_next_cpu(&self) -> Option<u8> {
self.pending.front().map(|&(_, cpu)| cpu)
}
/// Pop the next pending source (called by the injector after it has
/// committed to dispatching it).
pub fn take_next(&mut self) -> Option<u32> {
self.pending.pop_front()
self.pending.pop_front().map(|(source, _)| source)
}
/// **Legacy** — instruction-count v-sync ticker. Kept for unit tests
@@ -249,7 +268,7 @@ impl InterruptState {
let periods = self.vsync_accumulator / VSYNC_INSTR_PERIOD;
self.vsync_accumulator %= VSYNC_INSTR_PERIOD;
for _ in 0..periods {
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
}
true
}
@@ -288,7 +307,7 @@ impl InterruptState {
self.last_vsync_instant = Some(anchor + advance);
let to_queue = (periods as usize).min(INTERRUPT_QUEUE_CAP);
for _ in 0..to_queue {
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
}
true
}
@@ -306,7 +325,7 @@ mod tests {
#[test]
fn queue_interrupt_drops_without_callback() {
let mut s = InterruptState::default();
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
assert_eq!(s.dropped, 1);
assert!(s.pending.is_empty());
}
@@ -315,9 +334,9 @@ mod tests {
fn queue_interrupt_fifo_preserves_order() {
let mut s = InterruptState::default();
s.set_callback(0x1000, 0xAB);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
s.queue_interrupt(INTERRUPT_SOURCE_CP);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
s.queue_interrupt(INTERRUPT_SOURCE_CP, 2);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
assert_eq!(s.dropped, 0);
// FIFO: take_next hands them out in push order.
assert_eq!(s.take_next(), Some(INTERRUPT_SOURCE_VSYNC));
@@ -331,11 +350,11 @@ mod tests {
let mut s = InterruptState::default();
s.set_callback(0x1000, 0xAB);
for _ in 0..INTERRUPT_QUEUE_CAP {
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
}
// Over-cap: drops rather than evicting the oldest.
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
assert_eq!(s.dropped, 2);
assert_eq!(s.pending.len(), INTERRUPT_QUEUE_CAP);
}

View File

@@ -13,7 +13,7 @@ use xenia_memory::{GuestMemory, MemoryAccess};
/// u16 Length
/// u16 MaximumLength
/// u32 Buffer (guest pointer)
fn read_ansi_string(mem: &GuestMemory, ptr: u32) -> Option<String> {
pub fn read_ansi_string(mem: &GuestMemory, ptr: u32) -> Option<String> {
if ptr == 0 {
return None;
}

View File

@@ -17,6 +17,16 @@ impl PcrWriter for GuestMemoryPcr<'_> {
// `GuestMemory::write_u32` takes `&self` post-M2 trait flip; the
// wrapping `&'a GuestMemory` is sufficient.
self.0.write_u32(pcr_base + 0x2C, hw_id as u32);
// PRCB.current_cpu byte at PCR+0x10C (prcb_data@0x100 + current_cpu@0xC).
// Canary writes `GetFakeCpuNumber(affinity)` here (xthread.cc:847
// `pcr->prcb_data.current_cpu = cpu_index`), which equals the HW thread
// id we already compute. Guest spin-barriers (e.g. sub_824D1328, used by
// the audio/update pump threads at entries 0x824D2878/0x824D2940) index a
// per-HW-thread occupancy array by `lbz r11, 268(r13)` = this byte. Left
// unwritten it stayed 0 for every thread, so all threads collided on
// slot 0 and the multi-thread rendezvous signature never assembled —
// the pump threads spun forever and never fired their KeSetEvent loops.
self.0.write_u8(pcr_base + 0x10C, hw_id);
}
}
@@ -354,6 +364,16 @@ pub struct KernelState {
/// [`Self::fire_due_silph_autosignals`] on the first visit where
/// the pending queue is non-empty but no entry is due yet.
pub silph_autosignal_diag_logged: bool,
/// ITERATE-2J — guest VA of the `KeTimeStampBundle` block (xboxkrnl
/// data export ordinal 0x00AD). Set during the import-patch pass in
/// `xenia-app`. Zero until then. The guest's worker-hub channel
/// dispatch loop polls `[block+0x10]` (`tick_count`, milliseconds) and
/// gates dispatch on a `tick_count + 66` deadline; if the block is
/// never re-written that deadline never elapses and the hub spins
/// forever (the tid14 0x109c starvation gate). The run loop ticks this
/// block every round from the deterministic `global_clock` via
/// [`Self::update_timestamp_bundle`].
pub timestamp_bundle_addr: u32,
}
/// ITERATE-2C Phase D — one queued auto-signal. `deadline_cycle` is
@@ -444,6 +464,7 @@ impl KernelState {
silph_autosignal_pending: Vec::new(),
last_cycle_hint: 0,
silph_autosignal_diag_logged: false,
timestamp_bundle_addr: 0,
};
crate::exports::register_exports(&mut state);
crate::xam::register_exports(&mut state);
@@ -862,6 +883,57 @@ impl KernelState {
self.last_cycle_hint = now_cycle;
}
/// ITERATE-2J — tick the `KeTimeStampBundle` block (xboxkrnl ordinal
/// 0x00AD) from the deterministic monotonic clock so the guest sees a
/// clock that *advances*.
///
/// `clock` is the scheduler's `global_clock` — a pure function of
/// retired guest instructions (see [`Self::now_basis_at`] /
/// `Scheduler::global_clock`). Lockstep floors it up to
/// `stats.instruction_count` each round; parallel sums per-block
/// retired counts. Using it (rather than wall-clock) keeps every
/// guest-visible time value a deterministic function of guest progress,
/// so lockstep stays byte-reproducible.
///
/// ## Cadence
/// The existing kernel time math (`parse_timeout` in `exports.rs`)
/// already treats **1 `global_clock` unit ≈ 100 ns**: it converts a
/// signed 100-ns `LARGE_INTEGER` timeout to a deadline by dividing the
/// magnitude by 100 and adding it to `now` (= `global_clock`). To stay
/// coherent with that, this method uses the same scale:
///
/// * `interrupt_time` / `system_time` (100-ns units): `clock` (with a
/// FILETIME epoch base added to `system_time`).
/// * `tick_count` (milliseconds): `clock / INSTRUCTIONS_PER_MS` where
/// `INSTRUCTIONS_PER_MS = 10_000` (10_000 × 100 ns = 1 ms).
///
/// At 10_000 clock-units/ms, the guest's `tick_count + 66` ms hub
/// deadline elapses by ~660_000 retired instructions — very early in a
/// ~1 B-instruction boot — while a 16 ms `KeWait` timeout
/// (`parse_timeout`: 160_000 units) still resolves to 16 ms of
/// tick_count, so no timeout collapses to "instant". The two readers
/// share one scale.
pub fn update_timestamp_bundle(&self, mem: &GuestMemory, clock: u64) {
let block = self.timestamp_bundle_addr;
if block == 0 {
return;
}
const INSTRUCTIONS_PER_MS: u64 = 10_000;
// FILETIME epoch base (~2021) so `system_time` is a plausible
// absolute wall-clock; matches the constant used by
// `ke_query_system_time`. interrupt_time is "since boot" so it
// starts at the clock origin (no epoch offset).
const FILETIME_BASE: u64 = 132_500_000_000_000_000;
let interrupt_time: u64 = clock;
let system_time: u64 = FILETIME_BASE.wrapping_add(clock);
let tick_count: u32 = (clock / INSTRUCTIONS_PER_MS) as u32;
// BE writes (write_u64/write_u32 use to_be_bytes) — guest is BE.
mem.write_u64(block, interrupt_time); // +0x00 interrupt_time
mem.write_u64(block + 0x08, system_time); // +0x08 system_time
mem.write_u32(block + 0x10, tick_count); // +0x10 tick_count (ms)
mem.write_u32(block + 0x14, 0); // +0x14 padding
}
/// ITERATE-2C Phase D — register a freshly-allocated event for
/// auto-signal after the configured delay, **iff** the creating
/// thread matches the silph::UImpl tid=13 chain that wedges in

View File

@@ -57,6 +57,11 @@ pub fn allocate_thread_image(
mem.write_u32(pcr_base, tls_base);
mem.write_u32(pcr_base + 0x2C, hw_thread_id as u32);
mem.write_u32(pcr_base + 0x100, 0x1000);
// +0x10C prcb_data.current_cpu — canary `pcr->prcb_data.current_cpu`
// (PRCB@0x100 + current_cpu@0xC). Guest spin-barriers index a
// per-HW-thread slot array by `lbz r11, 268(r13)` = this byte; it
// must equal the HW thread id (== PCR+0x2C). See state.rs PcrWriter.
mem.write_u8(pcr_base + 0x10C, hw_thread_id);
mem.write_u32(pcr_base + 0x150, 0);
Some(ThreadImage {