Commit Graph

78 Commits

Author SHA1 Message Date
MechaCat02
5950dc86d5 [iterate-4A] jit: coverage — update-form loads/stores + mfspr/mtspr(LR/CTR) → 89.6% native
Second coverage batch, data-driven from the histogram. Diff harness validates
every op against the interpreter (MISMATCHES=0 over 47.96M blocks).

Update-form load/stores (24 ops: lbzu..stdux, lfsu..stfdux and their x-forms):
same access as the base form but with EA = gpr[ra] + offset (ra used directly —
update forms are illegal with ra==0) and rA := EA written back (zero-extended)
after the access. New ea_d_update/ea_x_update/write_ea_back helpers; loads write
rD then rA (so rA wins if rd==ra, matching the interpreter). stwu especially is
the standard stack-frame push in every function prologue.

mfspr/mtspr for LR and CTR only (64-bit field copies): the ubiquitous mflr/mtlr
prologue-epilogue pair and mtctr. covered() gates on the compile-time SPR number
so the offset always resolves; XER (packs CA/OV/SO) and the modelled SPRs stay
uncovered. This is the biggest single jump — mflr/mtlr gate almost every
non-leaf function's prologue and epilogue blocks.

Coverage (single-block, diff mode): 80.6% -> 82.8% (update forms) -> 89.6%
(mfspr/mtspr). 7/7 jit unit tests; foreground-validated per the bg-SIGTERM
finding. Carry-setting shifts (sraw*/srad*), rotate-double (rldic*), and XER
mfspr/mtspr remain for later batches; VMX128 is the long pole.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:06:42 +02:00
MechaCat02
0becc9185a [iterate-4A] jit: coverage — cheap integer ALU ops (+5pt native, chain -14% vs interp)
Data-driven from XENIA_JIT_HIST: after register caching the JIT was 73% native;
the frequent uncovered opcodes are VMX128 (the long pole), mtspr/mfspr, update-
form loads/stores, and a batch of cheap pure-register integer ops. This does the
cheap integer batch (each unblocks any block that only lacked it, and — since a
covered block can now chain through where an uncovered op used to break the
chain — the gain compounds: more native execution AND longer superblocks).

Added to covered() + emit_op, all validated against the interpreter by the diff
harness (none are sync_sensitive, so fully covered):
 - extsbx/extshx/extswx  — sign-extend byte/half/word (extsb/extsh write the
   i32-view zero-extended per the 32-bit ABI, CR0 i32; extsw sign-extends into
   the full 64, CR0 i64).
 - cntlzwx/cntlzdx        — count leading zeros (Cranelift clz).
 - negx (OE=0)           — rD = 0 - rA (ineg), full 64-bit, CR0 on low 32.
 - slwx/srwx             — word shifts; explicit `sh<32 ? shift : 0` select
   because the count is rB[58:63] (6 bits) and Cranelift's ishl/ushr mask to 5.
 - sldx/srdx             — doubleword shifts, `sh<64 ? shift : 0` (count 7 bits).
New emit_cr0_signed64 for the doubleword-result CR0 (a value with low32==0 but
high bits set is eq in the 32-bit view but not the 64-bit one). The carry-setting
shifts (sraw*/srad*, set XER-CA) and rotate-double (rldic*) are deferred to later
batches.

Validated (foreground per the bg-SIGTERM finding): 7/7 jit unit tests; diff
MISMATCHES=0 over 47.9M blocks; coverage 75.4% -> 80.6% (single-block, diff
mode). Perf (RUST_LOG=warn, -n 3e9 to amortize compiles): chain wall 57952ms ->
54748ms (-5.5% from coverage alone; run_block calls 110M -> 98M = more chaining),
chain -14.3% vs interp (63.9s) this session. -n 2e9 understates it (more blocks
compiled = more compile cost inside the STEP timer at under-amortized -n).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 06:33:01 +02:00
MechaCat02
e3cc6d11d0 [iterate-4A] jit: per-block GPR register caching — bodies now beat interp
Guest GPRs were reloaded from PpcContext memory on every use and stored back
after every write, forcing memory traffic that held the JIT bodies at mere
interpreter parity. Now each GPR is loaded ONCE per block into a Cranelift SSA
value (kept in a host register by the register allocator); writes update an
in-EmitCtx cache (RegCache: [Option<Value>;32] + dirty bits) without storing;
dirty GPRs are flushed to memory only at the block boundary (cache_flush at the
end of emit_node_body).

Sound because nothing a block calls back into touches guest GPRs: the memory
trampolines take addr/val as args (I pass the cached SSA values), and the
FP-punt runs interpreter FP ops that touch fpr/fpscr/cr only. So the cache never
goes stale mid-block; flushing at the boundary makes gpr[] interpreter-identical
for the runner and the next block. FPR/CR still go through memory (the FP-punt
writes them, which would need flush-around-punt — deferred).

emit_op / ea_d / ea_x / gpr32 / store_gpr32z rewired to cache_read_gpr /
cache_write_gpr; cache_read_gpr copies the Option out before re-borrowing (a
RefCell double-borrow would panic).

Validated: 7/7 jit unit tests; diff MISMATCHES=0 over 47.9M blocks (foreground).
Perf (foreground, RUST_LOG=warn, -n 3e9 to amortize compile cost — -n<=1.5e9 is
compile-dominated and misleads): interp 95.8 MIPS -> jit-no-chain 100.2 MIPS
(bodies now BEAT interp, were parity) -> chain 100.4 MIPS / 57.95s = -15.6% wall
vs interp (was ~-6% at B2), dispatch 18.9%, 110M calls (1.95x merge). Same-batch
-n 2e9: interp 44.8s -> chain 41.0s = -8.6%. Register caching is what makes the
bodies fast enough that chaining's dispatch cut converts to real wall time
(B2's bodies had slowed to ~82 MIPS and cancelled the win).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 06:15:16 +02:00
MechaCat02
4b78c67605 [iterate-4A] jit: superblock chaining B2 — bcx two-way + loop back-edges
Extends B1's forward-linear superblock into a full same-page CFG: chains
through conditional bcx (BOTH directions) and loop back-edges (a successor
already in the chain jumps to its existing IR block), not just unconditional
bx + fall-through. This roughly doubles the block-merge ratio (run_block calls
145.7M interp -> 116.2M B1 (1.25x) -> 74.7M B2 (1.95x)) and halves the
run_superblock dispatch bucket (30.4% interp -> 26.2% B1 -> 16.2% B2).

Mechanism:
 - enumerate_chain is now a two-pass CFG builder: BFS the reachable, chainable
   (same-page / covered / non-thunk) blocks deduped by start PC (so a back-edge
   becomes a loop, not a new node), capped at MAX_CHAIN_NODES=64; then resolve
   each terminator to Succ edges (node indices or Exit). bx/fall -> One; bcx ->
   Two{taken,fall} (each side Node or Exit); bclrx/bcctrx/uncovered -> Exit.
 - emit_bcx extracted from emit_op's bcx arm: computes the taken predicate ONCE
   (decrementing CTR at most once) and returns it; emit_node_body captures it so
   the two-way boundary branches on the SAME value (recomputing would double the
   CTR decrement). Single-block compile is unchanged (ignores the return).
 - No Cranelift Variables/phi needed for the loop CFG: the budget/MMIO state
   lives in memory (cycle_count, *mmio_count), so emit_stop reloads it and
   compares against entry-sampled constants (cycle_entry/mmio_entry), which the
   entry block dominates across back-edges. Budget = (cycle_count - cycle_entry)
   >= remaining, correct across loop iterations; a native loop spins until the
   budget is spent then hands back — same bound as the runner. MMIO check still
   skipped for load/store-free blocks. seal_all_blocks handles the arbitrary CFG.
 - Degenerate guard fixed: compile single-block only when the ENTRY has no
   chainable successor (Succ::Exit) — a one-node self-loop is NOT degenerate.

Validation: 3 new unit tests (bcx always-taken chains to target; bcx not-taken
falls through; self-loop runs natively until budget then stops — all bit-equal
to the interpreter over the same PCs) -> 7/7 jit tests pass. Diff MISMATCHES=0
over 47.95M blocks (the emit_bcx refactor didn't perturb op bodies).
XENIA_JIT_CHAIN=1 movie plays: tid25 resumes (decode handoff / interleaving
preserved under the deeper chaining).

Perf finding (honest): despite ~2x merge and half the dispatch, wall is ~parity
with B1 (both ~-6% vs interp same-batch). step_block MIPS dropped (~82 vs B1
~102) because the deeper/bigger native superblocks cost more per boundary (B1's
compile-time-constant budget became a runtime cycle_count reload, needed for
loops) and Cranelift's default regalloc spills more in larger functions. The
dispatch savings are real but offset by native-code overhead that grows with
chain length. Converting the extra chaining into wall time now needs
host-register allocation (tighter code) — the clear next lever. Env gate,
diff-off-under-chain, and probe/mem-watch exclusivity unchanged from B1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:25:21 +02:00
MechaCat02
2c17a511f7 [iterate-4A] jit: native superblock chaining B1 (bx + fall-through), −6.4% wall
Increment B1 of block-linking: compile a chain of same-page, fully-covered
blocks into ONE Cranelift function whose internal block-to-block edges are
direct machine jumps, eliminating the per-block return to run_block and the
run_superblock dispatch tax for the chain. Env-gated XENIA_JIT_CHAIN (off by
default), and additionally disabled under the diff harness or with diagnostic
probes / mem-watch armed (chaining runs several blocks natively without the
per-block-entry observation those need, so they are mutually exclusive).

Mechanism (cranelift-jit can't patch finalized code, so no QEMU-style TB
chaining): SUPERBLOCK COMPILATION. compile_chain enumerates the forward-linear
chain from the entry — following static bx targets and fall-throughs, all
same-page (so the entry page_version is a sound cache key), outside the thunk
band, and covered — and emits one node per block plus a shared exit. At every
internal boundary it re-checks the two runtime stop-conditions exactly as the
Rust runner does: an MMIO touch (*mmio_count advanced vs the entry sample) and
the instruction budget (cumulative >= remaining, passed as a runtime param);
either stops the chain with pc/cycle/timebase already correct, and the runner
re-dispatches from the clean boundary. B1 scope is forward-linear: a bx or
fall-through continues; a conditional bcx, dynamic bclrx/bcctrx, off-page /
thunk / uncovered / already-visited successor ends the chain (B2 adds bcx
two-way + loop back-edges). Being *more* conservative (stopping earlier) is
always safe.

Correctness rests on composition: each node body is the same emit_node_body IR
the single-block path emits (already diff-validated bit-exact), so only the
chain glue is new — validated by two new unit tests (multi-block chain vs
interpreter over the same PCs; budget cut at the exact boundary) plus e2e.
Boundary MMIO check is skipped for load/store-free blocks (a pure-ALU block
can't advance mmio_count), dropping a memory load+compare per ALU boundary.

Plumbing: build_block exposed (xenia-cpu); KernelState::thunk_band getter;
MemEnv mmio_count null-safety (points at a static zero when no flat mapping);
CompiledFn gains a remaining-budget param (single-block ignores it); all
compiled entries share the (ctx, mem_env, remaining) ABI.

Validation: diff MISMATCHES=0 over 25.0M blocks (single-block op bodies
unbroken by the emit_node_body refactor); XENIA_JIT_CHAIN=1 movie plays,
tid25 resumes, source-read=12; fair perf (RUST_LOG=warn) interp 44.1s vs
chain 41.3s = −6.4% wall — run_superblock "other" 30.4%->26.2%, run_block
calls 145.7M->116.2M (1.25x block merge), step_block at parity (100.6->102.4
MIPS; the earlier "slower" reading was cranelift IR-logging inside the STEP
timer). Uncommitted probe knobs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:55:12 +02:00
MechaCat02
5d5dc5dd89 [iterate-4A] jit: cache MemEnv fast_mem + expose mmio_count (prep for block chaining)
Behavior-neutral prep on top of the inline-load parity milestone:

  * JitCache caches mem.fast_mem() (resolved once — the mapping is invariant for
    a run) so compiled blocks build their MemEnv without a virtual call per
    execution. MemEnv::from_fast(mem, Option<FastMem>) is the non-virtual
    constructor. Measured impact is within noise (96.5 -> 96.4 MIPS) but it
    removes redundant per-block work.
  * FastMem/MemEnv gain mmio_count: a pointer to GuestMemory's monotonic MMIO
    access counter. Unused for now; the upcoming superblock-chaining JIT will
    sample it across a block boundary to stop chaining on an MMIO touch
    (preserving the interpreter's fine-grained MMIO ordering).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:02:05 +02:00
MechaCat02
da0509b4ac [iterate-4A] jit: inline load fast-path (skip trampoline) — JIT reaches interpreter parity
Loads no longer call a trampoline on the common path. When memory exposes a flat
mapping and the address is proven non-MMIO and committed, the compiled block
loads directly from `membase + ea` (byte-swapped) with zero calls; otherwise it
falls back to the read trampoline.

Mechanism:
  * MemoryAccess::fast_mem() -> Option<FastMem { membase, page_table, mmio_mask,
    mmio_value }>. Default None; GuestMemory returns Some (flat 4 GiB mapping,
    page-table pointer, MMIO aperture pair). Wrappers that intercept accesses
    (the recompiler's speculative OverlayMemory) inherit None, so the fast path
    is disabled under the diff harness and their interception is preserved.
  * MemEnv carries membase/page_table/mmio_mask/mmio_value; MemEnv::new(mem)
    fills them (or nulls when fast_mem() is None).
  * emit_inline_load emits a 5-block diamond: membase==0 -> slow (disabled);
    (ea & mmio_mask) == mmio_value -> slow (maybe MMIO); page_table[ea>>12]
    COMMIT bit (49) clear -> slow (unmapped; the mapped check is mandatory —
    unmapped pages are PROT_NONE and a raw load would fault); else fast load
    membase+ea, bswap, zero-extend. Wired all integer loads (lbz/lhz/lwz/ld +
    x-forms) and FP loads (lfs/lfsx reuse the diamond then bitcast/fpromote;
    lfd/lfdx bit-copy). Stores and FP-punt still trampoline.

Validation (three ways, since the diff harness disables the fast path):
  * unit test jit_inline_load_matches_interpreter — real GuestMemory, both the
    mapped (fast) and unmapped (slow) address bit-match the interpreter.
  * e2e XENIA_JIT=1 boot+movie plays, clean exit.
  * diff regression: checked 149.2M blocks, MISMATCHES=0 (validates the
    unchanged slow/ALU/branch/FP logic).

Measured (2e9 instr, block-exec MIPS): interpreter 98.2 / JIT trampoline-loads
93.8 / JIT inline-loads 96.5 — inline loads recover the trampoline overhead,
bringing the JIT to parity (~1.7% slower, within noise). Beating the interpreter
needs the levers it can't do: block-linking (30% dispatch), host registers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:44:56 +02:00
MechaCat02
d91311c486 [iterate-4A] jit: FP arithmetic via interpreter-punt (diff-clean, 73.48% native)
Solve the FP-arithmetic fpscr problem without lowering fpscr into IR. Add a
generic single-instruction shim, xj_interp_op(ctx, env, raw, addr), that decodes
and runs one instruction through the interpreter's execute() on the live
context/memory — bit-exact by construction. This lets a block containing FP math
still be JIT-compiled: the surrounding integer/memory/branch ops run as machine
code and only the FP op calls back into Rust (the technique production JITs use
for complex ops). Validated on a full boot+movie run (movie plays, clean exit):

  checked 147.2M blocks, 73.48% native (108.2M, up from 39.82%!), MISMATCHES=0

The fpscr bookkeeping (rounding-mode-dependent rounding, sticky exception bits,
FPRF classification) was too intricate to emit correctly in IR; punting sidesteps
it entirely while still capturing the coverage.

is_fp_punt allowlist (all verified always-Continue, non-branch):
  faddx/faddsx, fsubx/fsubsx, fmulx/fmulsx, fdivx/fdivsx, fmaddx/fmaddsx,
  fmsubx/fmsubsx, fnmaddx/fnmaddsx, fnmsubx/fnmsubsx, frspx, fsqrtx, fresx,
  frsqrtex, fselx, fnabsx, fcmpu, fcmpo.
covered() returns true for them; emit_op emits `call interp(ctx, env, raw,
addr)`. execute() advances pc by 4 (harmless — the block epilogue stamps end_pc
for non-branch blocks; cycle_count is bumped once per block, not by execute).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:18:06 +02:00
MechaCat02
704e87a2a4 [iterate-4A] jit: FP-move tier — lfs/stfs/lfd/stfd + fmr/fabs/fneg (diff-clean, 39.82% native)
Cover the fpscr-free FP moves. Validated bit-exact via the in-process
differential harness on a full boot+movie run (movie plays, clean exit):

  checked 146.2M blocks, 39.82% native (up from 38.78%), MISMATCHES=0

Coverage (all fpscr-free — pure data movement, no rounding flags):
  * FP loads: lfs/lfsx (load single, IEEE-widen to the f64 FPR via
    read32 -> bitcast F32 -> fpromote F64), lfd/lfdx (pure 64-bit bit copy).
  * FP stores: stfs/stfsx (fdemote f64 -> f32 -> bitcast i32 -> write32),
    stfd/stfdx (pure 64-bit bit copy). Stores reuse the reservation-kicking
    write trampolines.
  * FP reg moves (Rc=0 only — the `.` forms update CR1 from fpscr): fmr (bit
    copy), fabs (band_imm i64::MAX, clear sign), fneg (bxor_imm i64::MIN, flip
    sign) — bit ops that exactly match Rust f64 copy/abs/neg.

Note: the +1% lift is small because the movie's FP-heavy blocks almost always
mix an FP load with FP *arithmetic* (fadds/fmuls/fmadds), which stays
uncovered — it updates fpscr (FPRF/FI/FR, invalid-op flags), not yet lowered.
The moves that landed are the pure data-copy blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:09:15 +02:00
MechaCat02
6475f4ba97 [iterate-4A] jit: memory tier — integer loads/stores via trampolines (diff-clean, 38.78% native)
Add the memory tier: compiled loads/stores call `extern "C"` trampolines that
dispatch through the `MemoryAccess` trait, so MMIO dispatch, mem-watch,
page_version, and mmio_access_count stay bit-identical to the interpreter.
Validated bit-exact via the in-process differential harness on a full
boot+movie run (movie plays, clean exit):

  checked 148.2M blocks, 38.78% native (57.5M, up from 22.02%), MISMATCHES=0

Mechanism:
  * 8 trampolines: xj_read8/16/32/64(env, addr) and
    xj_write8/16/32/64(ctx, env, addr, val). Registered with the JITBuilder via
    symbol(), declared Linkage::Import in Jit::new (FuncIds in TrampIds), and
    re-referenced into each compiled function via declare_func_in_func.
  * emit_op now takes an EmitCtx { ctxp, memenv, trampoline FuncRefs }.
  * Store trampolines replicate the interpreter's pre-store reservation
    invalidation (store_reservation_kick) — an ordinary store to a reserved
    line must be observed by stwcx peers. They receive the PpcContext pointer so
    they can read ctx.reservation_table; the diff clone clears it (None), so
    speculation never touches shared reservation state.

Coverage added (all mirroring execute() exactly):
  * loads (zero-extended, non-update): lbz/lbzx, lhz/lhzx, lwz/lwzx, ld/ldx.
  * stores (non-update): stb/stbx, sth/sthx, stw/stwx, std/stdx.
  EA via ea_d (D-form disp) / ea_x (X-form indexed): ra==0 => 0 base, then
  truncate to 32 bits. Update (u) forms and algebraic sign-extending loads
  (lha/lwa) are not lowered yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:57:19 +02:00
MechaCat02
15d0d7e0bd [iterate-4A] jit: cover integer ALU + compares w/ CR emission (diff-clean, 22% native)
Grow the covered opcode set from branches-only to the hot integer core.
Validated bit-exact vs the interpreter via the in-process differential harness
on a full boot+movie run (movie plays, clean exit):

  checked 147.9M blocks, 22.02% native (32.6M, up from 6.57%), MISMATCHES=0

New coverage (all mirroring interpreter::execute exactly):
  * add/sub: addx, subfx (OE=0 only — the overflow path is not lowered yet).
    Full 64-bit result; CR0 (when Rc) from the low-32 signed value.
  * reg-reg logical, 64-bit-preserving: orx (excluding the 0x7FFFFB78 db16cyc
    spin hint, which yields), andx, xorx.
  * reg-reg logical, u32-truncating (zeroes the upper 32): norx, nandx, andcx,
    orcx — stored zero-extended via store_gpr32z.
  * immediate logical: ori/oris/xori/xoris (64-bit, no CR); andi./andis.
    (always update CR0).
  * rlwinmx: rotate-left-word + mask (mask computed at emit time from the mb/me
    immediates); zeroes upper 32; CR0 when Rc.
  * compares: cmp/cmpi (signed, 64- or 32-bit per L), cmpl/cmpli (unsigned);
    write the crfd() field. Immediates sign- or zero-extended per form.

CR-write emission:
  * emit_store_cr(field, lt, gt, eq): stores the four CrField bytes
    {lt@0,gt@1,eq@2,so@3}; so = (xer_so != 0).
  * emit_cr0_signed32(val32): the Rc-form CR0 update — signed compare of the
    32-bit result against zero. Reuses emit_store_cr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 07:36:49 +02:00
MechaCat02
56dbf52a5f [iterate-4A] jit: wire compiled-block cache + cover bx/bcx/bclrx (diff-clean, 6.57% native)
Wire the Cranelift block-JIT into execution and add branch coverage. Both
increments validated bit-exact against the interpreter via the in-process
differential harness on a full boot+movie run (movie plays, clean exit):

  wiring only (addi/addis): checked 146.8M blocks, 0.01% native, MISMATCHES=0
  + branches (bx/bcx/bclrx): checked 148.5M blocks, 6.57% native (9.76M), MISMATCHES=0

jit.rs
  * JitCache: direct-mapped 64K-slot compiled-block cache keyed (start_pc,
    page_version) identically to BlockCache, so self-modifying / DMA'd code
    invalidates native code the same way. Caches the None ("uncovered") verdict
    so an uncovered block is compile-attempted at most once per (pc,version).
    Owns the Jit/JITModule (keeps every CompiledFn valid for its lifetime).
  * covered(): addi/addis + bx/bcx/bclrx. bcctrx excluded (indirect target +
    dispatch_rec diagnostic hook the native path would skip).
  * pc-handling refactor: a branch terminator writes pc itself (writes_pc());
    the block epilogue stores end_pc only for straight-line (max-len / page-
    boundary) blocks. cycle/timebase still += N (covered ops never fault/yield;
    a branch is always the last instruction).
  * Branch lowering uses immediate targets — the interpreter's ctx.pc equals the
    instruction address at emit time, so bx/bcx relative targets are constants.
    emit_branch_taken mirrors the interpreter: optional CTR decrement, ctr_ok =
    (ctr as u32 vs 0) inverted by BO3, cond_ok = CR-bit BI byte == BO1 (both BO
    sub-cases const-fold), combined with select. bclrx reads lr&!3 before the LK
    link overwrites lr.

recompiler.rs
  * run_block / diff_step take &mut JitCache. run_block runs the native fn when
    get_or_compile returns one (else interpreter fallback); diff_step runs it on
    the speculative clone/OverlayMemory so every native block is diff-checked.
  * report_jit_summary(): native-vs-interpreted block counts (XENIA_JIT / _DIFF).

main.rs
  * WorkerCtx owns a JitCache; run_superblock routing passes it to
    diff_step/run_block; report_jit_summary() at clean exit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 07:28:26 +02:00
MechaCat02
cc7ff58cb0 [iterate-4A] jit: Cranelift block-JIT scaffolding + addi/addis PoC (pipeline proven)
Stage-2 foundation. Adds cranelift-jit/frontend/module/codegen 0.128.4 (the
1.90-compatible line; 0.133 needs Rust 1.94) and a new crates/xenia-cpu/src/jit.rs.

Jit owns a JITModule (and thus all compiled code memory). compile(&DecodedBlock)
lowers a block to native code ONLY if every opcode is covered() — otherwise
returns None and the caller interprets the whole block (coverage grows
opcode-by-opcode). ABI: extern "C" fn(*mut PpcContext, *const MemEnv) -> u32
(StepResult discriminant); guest registers are loaded/stored directly from the
#[repr(C)] PpcContext at offset_of! offsets. A covered block is straight-line and
always runs to completion, so pc advances to end_pc and cycle_count/timebase bump
by the instruction count once — matching the interpreter's per-instruction bump.

Covered set so far: addi, addis. Unit test jit_matches_interpreter_addi_block
compiles a 32x addi block and asserts the JIT's r3/pc/cycle_count/timebase match
the interpreter exactly — proves module setup, offset_of register access, IR
emission, the extern "C" calling convention, and cycle/pc accounting end-to-end.

Not yet wired into run_superblock (needs a compiled-block cache + routing); every
future opcode will be validated against the interpreter via the M0 XENIA_JIT_DIFF
harness before it counts as covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:45:31 +02:00
MechaCat02
07cd272412 [iterate-4A] jit: opcode-frequency histogram (XENIA_JIT_HIST) for coverage targeting
Gated 1-in-16 sampled opcode histogram in the recompiler (tallied in run_block,
printed sorted with cumulative % at clean exit). Drives which opcodes the JIT
lowers first. Boot+movie result: workload is FP-heavy — addi 15%, lwz 12%,
rlwinm 10%, lfs 9%, stfs 7.5%, lfsx 5%, fmaddsx 4.6%, bc 3.7%, fmulsx 3.6%,
stw 3.4% ... top-20 = 90%, ~33% floating-point. This is why we go straight to a
Cranelift machine-code JIT (which attacks the FP op bodies) rather than a
closure-threaded stage (which only removes dispatch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:36:53 +02:00
MechaCat02
ac289bca5e [iterate-4A] jit M0: recompiler seam + in-process differential harness
Foundation for a staged PPC block-recompiler (plan: closure-threaded ->
Cranelift -> block-linking). No codegen yet — proves the integration seam and
the correctness harness before any lowering exists.

New crates/xenia-cpu/src/recompiler.rs:
- run_block: executes a DecodedBlock; M0 falls back to the interpreter's
  execute() for every opcode, so it is bit-identical to step_block. Later
  stages dispatch lowered ops here and fall back only for uncompiled opcodes.
- gates jit_enabled()/diff_enabled() (XENIA_JIT / XENIA_JIT_DIFF, cached).
- In-process differential harness (diff_step): the interpreter is AUTHORITATIVE
  (drives real ctx+mem, so a JIT bug can never corrupt a run); the JIT runs
  SPECULATIVELY on a ctx clone against OverlayMemory (writes buffered in a byte
  HashMap, reads fall through to real pre-block memory), then registers are
  compared. Blocks touching MMIO or sync_sensitive (reservation/barrier) are
  skipped — a device callback can't be run twice and reservation state is
  shared cross-thread. report_diff_summary() prints checked/skipped/mismatch.

Why in-process: the guest is only COARSELY deterministic — coord_idle_advance
ticks vsync from wall-clock when idle, so two separate runs are not bit-exact
and a cross-run signature compare would measure jitter, not JIT divergence.

Supporting changes: PpcContext #[derive(Clone)] (harness clears the speculative
clone's reservation_table Arc); is_mmio() on the MemoryAccess trait (default
false) + GuestMemory impl via find_mmio; execute() made pub(crate); run_superblock
routes the block body (DIFF->diff_step, JIT->run_block, else step_block).

Validated: full boot+movie XENIA_JIT_DIFF=1 run = checked 218.4M blocks,
skipped(mmio/sync) 511.6K (0.23%), MISMATCHES=0 CLEAN; movie plays (ADVreads=30,
tid25 resumes); diff-mode ~2x slower (test-only path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:25:18 +02:00
MechaCat02
2c883e9d5e [iterate-4A] diagnostics: XENIA_PROFILE wall-time profiler + probe/tooling snapshot
Handoff snapshot of the env-gated diagnostic scaffolding used across the
intro-video RE. Kept out of the milestone commits (645feb8..5573ac1) to keep
those clean; committed here so nothing is lost on handoff.

New — XENIA_PROFILE wall-time profiler (crates/xenia-gpu/src/prof.rs):
  Coarse buckets attributing playback wall time to interpreter (step_block),
  kernel HLE (call_export), block decode/cache (lookup_or_build), texture
  decode, host draw, and present; prints periodic snapshots (every 500M guest
  instr, or every 500 presents) + a clean-exit report. Hot path is gated on a
  cached is_on() (one relaxed load) so it is zero-cost when XENIA_PROFILE is
  unset. Call sites: main.rs run_superblock / parallel worker (step_block,
  lookup_or_build, call_export), texture_cache ensure_cached, render.rs present
  + dispatch_xenos_draws.

  First profile (movie playback, headless single-thread lockstep): effective
  ~35 MIPS; interpreter body ~40% @ ~95-102 MIPS; texture decode 0.3% (cache
  works); present ~0%; the rest is per-block dispatch + scheduler plumbing
  (~13 instr/block over 229M blocks). Overhead-bound, not interpreter-body
  bound; the levers are coarser execution units (superblock chaining) and
  ultimately a JIT.

Pre-existing read-only probe knobs (were uncommitted; env-gated, observe-only):
  XENIA_RET_CAPTURE_PC/_REG/_MEM, LOG_RESUMES, LOG_WAITS, LOG_SIGNAL,
  FORCE_TID, STARVE_LIMIT, INCUMBENT_PICK, INSTR_PER_MS, DUMP_FRAME,
  DUMP_WGSL, BIND_LOG, CONST_LOG, DISPATCH_REC, AUDIT_PC_TRACE.

Tooling: sylph-run.sh (movie oracle loop, 180s default timeout),
  zq.py (DuckDB disasm/xref helper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:43:53 +02:00
MechaCat02
645feb8f5b [iterate-4A] intro-video: fix decode-timeout clock, feeder starvation, k_8 texture decode
Three layered root causes kept ADV.wmv from playing. This lands the first
three fixes: the intro video now decodes end-to-end and uploads its YUV
planes. The on-screen composite still needs a multi-texture render path
(root #3, tracked separately — the shader interpreter binds one texture slot
but the YUV->RGB pass samples three).

1. Clock scale (xenia-kernel/state.rs): INSTRUCTIONS_PER_MS 10_000 -> 1_000_000.
   KeTimeStampBundle tick_count = global_clock / INSTRUCTIONS_PER_MS. At 10_000
   (~10 MIPS; global_clock further inflated ~58x by the serialized scheduler
   summing busy-spin) the movie handler's 2000 ms software-decode deadline
   (sub_821B4968 @0x821b68c0) tripped on a legitimate ~20M-instruction
   720p-YUV420 decode and aborted playback (canary never enters that wait
   loop). 1_000_000 sits in the validated [~600k, ~2.77M] window that fits
   both the movie (decode <=2000 ms) and boot (the worker-hub +66 ms gate
   still elapses before the movie, ~66M instr). XENIA_INSTR_PER_MS overrides.

2. Scheduler fairness (xenia-cpu/scheduler.rs): pick_runnable's equal-priority
   tiebreak now prefers the incumbent (running_idx) so decrement_quantum's
   quantum rotation sticks. Previously each round re-picked the lowest index,
   so co-located equal-priority threads never alternated and the movie's demux
   feeder (tid24, co-located on hw=1) starved until the STARVE_LIMIT=4096
   backstop -- the decode ring never refilled. Priority preemption unaffected.
   XENIA_INCUMBENT_PICK=0 rolls back.

3. k_8 texture decode (xenia-gpu/texture_cache.rs + xenia-ui/texture_cache_host.rs):
   the video uploads its YUV420 planes as linear k_8 textures (Y 1280x720,
   U/V 640x360); with no k_8 decoder ensure_cached rejected them and the frames
   never reached the GPU. Adds decode_k8 (1 byte/texel expanded to Rgba8Unorm)
   + the host Rgba8Unorm mapping.

Read-only diagnostic probe knobs used to find these remain uncommitted in the
working tree. Boot goldens re-baselined in a follow-up commit (the clock change
intentionally moves the digest; see tests/golden/README.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:34:01 +02:00
MechaCat02
23189b95af [iterate-4A] Milestone-2: XMA audio decoder + RE tooling (dispatch recorder, analyzer vtable-fix, non-perturbing probes)
Milestone-2 (intro video dat/movie/ADV.wmv) audio path + major RE tooling.

XMA AUDIO (built, working, deterministic, tested):
- APU MMIO 0x7FEA0000 + 320x64B register-mapped context array; real XMACreateContext/Release
  (xma.rs); real FFmpeg xma2 decoder XMA_CONTEXT_DATA->S16BE PCM (xma_decode.rs, xma2_codec.rs,
  ffmpeg-sys-next). Decode runs synchronously on the CPU thread (deterministic, no host thread).
- Audio-worker scheduler fix (main.rs LR_HALT restore + scheduler.rs): the XAudio render-callback
  worker was wrongly exited after ~2 deliveries; now survives -> guest drives XMA decode (70 kicks).
- XAudioSubmitRenderDriverFrame made faithful. Golden sylpheed_n50m re-baselined; tests pass.

RE TOOLING:
- Runtime indirect-dispatch recorder (dispatch_rec.rs): records (call-site->target, r3, lr);
  env-gated XENIA_DISPATCH_REC, filters XENIA_DISPATCH_REC_TARGETS/_SITES; deterministic, observe-only.
- Repaired static analyzer (vtables.rs): vtable extraction silently fragmented vtables with
  non-function head slots (missed the XMV engine vtable). Fixed via vptr-write-anchoring -> engine
  fully typed (vtables 722->1150 on rebuild).
- Fixed probe HEISENBUG (main.rs run_superblock): --audit-pc-probe-hex/--mem-watch no longer disable
  superblock chaining; probes fire inside the chain loop -> scheduling identical armed-vs-unarmed,
  movie subsystem now observable. Fixed a --quiet bug swallowing armed trace reports.

VIDEO still doesn't play (B, guest-side): the XMV engine never issues begin-playback (sub_825076F0,
vtable 0x8200a1e8 slot21) -> never primes -> 2000ms timeout. Narrowed to the ARM2 engine-setup
wrappers; no honest our-side gate-fix (masking forbidden). See HANDOFF-iterate-4A-milestone2.md for
new-machine setup (incl. the FFmpeg apt deps + sylpheed.db regeneration) and continuation pointers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:38:19 +02:00
MechaCat02
acb29db444 [iterate-3AL] Superblock dispatch: chain basic blocks per slot-visit (~1.6x boot-to-splash)
Replace the one-basic-block-per-slot-per-round lockstep dispatch with a
SUPERBLOCK runner: each slot-visit chains straight-line blocks through
their terminating branches up to a deterministic instruction budget,
amortizing the per-round (timebase/coord/round_schedule) and per-slot
(worker_prologue) dispatch tax over ~128 instructions instead of ~6.

Yield-points (end the chain, return to the round) are pure functions of
guest state, preserving the lockstep cross-thread interleaving correctness:
  - non-Continue step result (Yield/SystemCall/Trap/Unimpl/Halted);
    db16cyc Yield is the spin-wait producer hand-off.
  - sync-sensitive block: lwarx/ldarx/stwcx./stdcx. or sync/eieio/isync
    (new PpcOpcode::is_sync_sensitive, flagged on DecodedBlock at build).
  - MMIO touch: new GuestMemory::mmio_access_count() watermark, sampled
    per block, keeps GPU/register ordering at one-block granularity.
  - next PC leaves ordinary guest code (import thunk / halt sentinel /
    unmapped) -> hand to the full worker_prologue next round.
  - instruction budget reached.

Instruction-count/clock accounting stays exact: per-block cycle_count
deltas are summed and handed to worker_epilogue once (instruction_count +
decrement_quantum advance by the precise retired count). XENIA_SUPERBLOCK_BUDGET=1
reproduces the old one-block schedule byte-for-byte.

Budget tuned to 128 (env-overridable): boot progression stays healthy up
to 256, sharp cliff at ~384 (a boot producer/consumer handoff starves);
128 is 3x below the cliff. Also scale the inline-GPU per-round fairness
cap with the budget (flat 64 throttled GPU command processing 17x under
superblocks and collapsed the present loop).

PERF (check -n 100M --gpu-inline): 25.3 -> 42.7 MIPS (1.69x); 1B: 26.0 ->
41.4 MIPS (1.59x). Callgrind n=5M: host instructions 2.178B -> 1.507B
(-31%); worker_prologue -90%, coord_pre_round -91%, begin_slot_visit /
round_schedule_into / coord_post_round / update_timestamp_bundle each
~-90%; interpreter execute byte-identical (real work unchanged).

GATES: C1 boot progression 150M draws 7391/swaps 2164 (baseline 7415/2172),
1B draws 88547/swaps 29228 linear no stall, K8888 decode + RTs=2 intact.
C2 determinism: n50m stable digest byte-identical across fresh runs;
golden re-baselined intentionally (pacing-only deltas: imports 333453->243387,
draws 1274->1279). C3 milestone-1 render: texture_decodes/draws/swaps/
present cadence track baseline (3AJ fade-in pacing preserved). C4: 690
tests green (+2 sync_sensitive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:31:54 +02:00
MechaCat02
dc1320cd4b [iterate-3AK] Perf quick-wins: ~21% faster boot-to-splash (22→27 MIPS)
Profile-driven low-risk optimizations attacking the ~48% per-block /
per-round host-bookkeeping tax found by the callgrind profile. Measured
on the bounded headless workload `check -n 100000000 --gpu-inline`:
baseline ~4490 ms (22.3 MIPS) -> ~3700 ms (27.0 MIPS), +21%.

Tier A (determinism-neutral; n50m golden byte-IDENTICAL, exit 0):
1. mem-watch write path: gate capture_mem_watch_old/check_mem_watch
   behind one has_mem_watch() predicted branch in write_u8/16/32/64 +
   write_bulk so the common (no-watch) store does no out-of-line call.
   check_mem_watch (4.8%) gone from the profile.
2. round-schedule alloc churn: add Scheduler::round_schedule_into filling
   a reusable [u8; HW_THREAD_COUNT] stack buffer; the lockstep round loop
   no longer __rust_alloc/__rust_dealloc a Vec<u8> per round. Identical
   ordering/RNG-advance. __rust_alloc/dealloc gone from the profile.
3. probe-firing: hoist a single KernelState::any_probe_active() guard to
   worker_prologue so the four fire_*_if_match calls don't happen at all
   when no probe is configured (was 4x call overhead/visit). All four
   gone from the profile.
4. thunk-map hash: range-reject pc against the registered import-thunk
   address band (KernelState::pc_in_thunk_band, two int compares) before
   the thunk_map.get(&pc) HashMap lookup. hash_one (4.3%) gone.

Tier B (#5, time-granularity change — LANDED, no re-baseline needed):
5. update_timestamp_bundle: throttle to a 0.25 ms quantum (only re-write
   the KeTimeStampBundle when the deterministic clock advanced >= 2500
   units). Inclusive cost 8.65% -> 1.08%. The quantum is far below the
   1 ms granularity any guest deadline math needs (tick_count stays
   fresh; the hub gate is +66 ms; the fade-in is vsync-counter driven per
   3AH, not this bundle). VERIFIED: n50m stable digest BYTE-IDENTICAL to
   the existing golden (so no re-baseline), 150M boot reaches the splash
   (draws=7415, swaps=2172, gpu.texture.decode{K8888}=448, RTs=2 — all
   match the post-3AJ baseline), 688 tests green, release n50m oracle ok.

Remaining headroom: interpreter::execute (13%), decrement_quantum (8%),
step_block (7%) are now the top self-costs — the structural superblock/
JIT lever is the next step for the larger gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:05:53 +02:00
MechaCat02
6d8a2817a3 [iterate-3R] Fix --ui boot throttle: demote idle-advance scheduler log to DEBUG
The publisher-splash draws first appear ~30M instructions and ramp through
80M-150M. Headless `check` reaches that window and renders the textured logo
(DRAW_INDX=568, gpu.texture.decode{K8888}=279 at -n 150M). The interactive
`exec --ui` path appeared to "self-terminate at ~8.8s / ~33M" before reaching
the splash.

Root cause (measured, not inferred): NO termination and NO guest halt. The
`exec --ui` default path runs at the INFO log level (headless `check` runs
`--quiet` = WARN). During the boot idle-spin the scheduler has no Ready thread
for long stretches and `advance_to_next_wake_if_due` fires once per timed-wait
deadline-wake — hundreds of thousands of times. That `tracing::info!` emitted
~286K lines / 154 MB to disk in ~25s, throttling the guest so hard that the
instruction count crawled (deadline raced to 325M timebase while instructions
stayed near ~5M). The verbose run never terminated — it was alive and
logging-bound. Quiet `--ui` (no flood) reaches 161M instr / 2636 GPU draws in
~31s, exactly tracking headless.

Fix: demote the per-deadline-wake log from INFO to DEBUG (it is a hot-path
scheduler internal). Default `exec --ui` now emits ~1.6K log lines instead of
~235K over the same window; the idle-advance flood drops 286700 -> 0 at INFO,
and boot flows to the splash window. Log-level only: deterministic golden
byte-identical (n50m --stable-digest --expect matches, exit 0), 679 tests green.

Refutes the iterate-3O/3Q "8.8s --ui auto-close / BreakOuter" hypothesis (T1-T5
all negative): the cause was logging wall-clock cost, not a window/present
deadlock, watchdog, or guest exception.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:47:16 +02:00
MechaCat02
f75bc96d17 [iterate-2H] PPC spin/yield/sync hint-class audit: lock no-over-yield + barrier-decode invariants
Audited the full PowerPC spin/yield/sync/SMT-priority-hint instruction class
against the canary oracle (ppc_emit_alu.cc InstrEmit_orx / ppc_emit_memory.cc
sync/eieio/isync) and against what Project Sylpheed actually executes (static
scan of the extracted image + disasm of the spin sites 0x824D1328 /
0x824C17AC / 0x824D3CF8).

Findings (no behavior change required — the class is already faithful):
  - or rX,rX,rX SMT priority hints: canary special-cases EXACTLY 0x7FFFFB78
    (db16cyc) -> DelayExecution; every OTHER or-self form -> Nop. Ours already
    matches (only 0x7FFFFB78 yields). Image scan: the documented priority
    hints or 1/2/3/6/26..30 do NOT appear in Sylpheed at all; the only SMT
    spin hint used is or 31,31,31 (db16cyc), already handled in de21c7a. The
    854 `or 8,8,8` etc. are compiler register self-moves (plain no-ops), not
    spin hints.
  - sync / lwsync / ptesync share XO=598 -> all decode to PpcOpcode::sync
    (canary keys on XO only, identical); eieio (XO=854), isync (XO=150) decode
    correctly. All are value-neutral no-ops under the single-host model,
    matching canary MemoryBarrier/Nop. unimpl=0 in a 200M run confirms none
    trap. tlbsync is not implemented by canary either and is unused by Sylpheed.
  - mftb-based timed back-off (loop at 0x824D3CF8: mftb delta vs timeout, with
    db16cyc between polls and a timeout escape) relies on the already-landed
    db16cyc yield + coherent global-clock timebase; no deadlock, no new gap.
  - ori 0,0,0 canonical nop (140 sites) is value-neutral; matches canary Nop.

Lands two regression tests that lock the audited invariants so a future change
cannot over-yield on a benign priority hint (which would perturb the
deterministic schedule) or break the sync L-field decode:
  - test_smt_priority_hints_are_nops_not_yields
  - test_lwsync_ptesync_eieio_isync_decode_as_benign_noops

Determinism preserved (tests-only): two cold lockstep `check -n 5M` (no
persist) byte-identical; golden digest unchanged (no re-baseline). Full
workspace suite green. 200M cascade unchanged (packets~172M, draws=0,
shaders=0, swaps=1) — confirms the hint class is exhausted; the render gate is
now downstream (tid14 0x109c per-job completion event), not CPU semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:53:54 +02:00
MechaCat02
de21c7a544 [iterate-2G] db16cyc spin-hint cooperative yield: unblock title-screen 0x10a0 gate
The silph title state machine (tid13) blocked on event 0x10a0, never signaled.
Root: the event's producer chain runs on the silph worker (entry 0x821C4AD0,
our tid14), which was starved. tid14 shares a HW slot with a guest spinlock/
barrier participant (sub_824D1328, entry 0x824D2940) that busy-spins on the
db16cyc hint `or r31,r31,r31` (encoding 0x7FFFFB78) at 0x824D140C. Under our
round-robin lockstep the spinner consumed its whole block every round and
starved the co-located tid14 (only 9 progress hits over 200M instr) — so the
producer never reached the event-create/duplicate/signal dance the canary
oracle performs (handle F80000E8 set by the submitter F8000044 via a duplicated
handle).

Fix (canary-faithful): recognize the db16cyc spin hint exactly as canary's
InstrEmit_orx does (code 0x7FFFFB78 -> DelayExecution) and surface it as a new
StepResult::Yield. The scheduler's yield_current() promotes every Ready peer on
the slot past STARVE_LIMIT so begin_slot_visit picks one next round, then they
reset and the spinner reclaims the slot — fair alternation, no priority
inversion, pure function of slot state (deterministic).

Result (lockstep, cache-persist, -n 200M): tid14 progresses past its old stall
into a real wait; tid13 advances off 0x10a0 to a new event; hub/submitter
re-enter their wait loops. imports 280k->592k, packets 124M->164M, swaps 1->2.
draws still 0 (the splash's first draw is a further-upstream gate).

Determinism preserved (two cold n50m runs byte-identical). n50m golden
re-baselined (imports 90296->339766, swaps 1->2; draws unchanged 0). n2m
golden unchanged (db16cyc not reached in first 2M). Tests 670/670.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:38:17 +02:00
MechaCat02
f3b7e8b760 [iterate-2F] Scheduler anti-starvation floor: fix job-4 handoff render gate
The lockstep scheduler's pick_runnable is strict priority
(max_by_key (priority, -idx)). On a cooperative single-host HW slot,
a CPU-bound spinner that never blocks (the silph poll loop pinned by
affinity to hw=5) wins pick_runnable every round forever, permanently
starving a co-located peer (the submitter, tid6) that the spinner is
actually waiting on. On real hardware those threads run on separate SMT
contexts concurrently, so the spinner never starves the submitter; ours
collapses them onto one slot with no anti-starvation, turning priority
(or equal-priority index order) into permanent starvation.

The starved submitter never dequeued job-4 -> the worker-hub (tid5)
blocked INFINITE on completion event 0x1080 -> silph (tid13) wedged on
0x1078 -> no vsync -> draws_seen=0, the publisher splash never renders.
(decrement_quantum's within-slot rotation is dead: begin_slot_visit
unconditionally re-pick_runnable()s each round, discarding the rotated
running_idx. The fix is therefore evaluated at pick time, not via that
discarded rotation.)

Fix (Option A, bounded anti-starvation, deterministic):
- Add per-thread steps_starved counter to GuestThread.
- begin_slot_visit increments it for every Ready peer passed over this
  visit, resets it to 0 for the picked thread.
- pick_runnable selects by effective_priority: once steps_starved
  reaches STARVE_LIMIT (4096) the thread is lifted to i32::MAX and wins
  exactly one pick, then resets. The genuinely higher-priority thread
  still wins ~4095/4096 visits -- the boost grants periodic forward
  progress only, it does NOT invert priority. Pure function of
  counter/priority/index -> deterministic (no wall-clock, no RNG).

Cascade (lockstep exec, XENIA_CACHE_PERSIST=1, -n 200M):
- submitter dequeue sub_82458508 now fires 4x (was 3x); the 4th job
  (buf 0x40baa2c0) is dequeued at cycle 6.15M.
- hub tid5 leaves Blocked(0x1080) -> now Ready (no more INFINITE wait).
- GPU packets 0 -> 116,101,363 (command stream now flowing).
- tid13 (silph::UImpl) advances past the old 0x1078 wedge to a NEW
  downstream wait (handle 0x10a0); 3 new threads spawn (tid14/15/16).
- draws_seen still 0 -> the splash's first draw is a NEW downstream gate,
  not this starvation.

Determinism: two cold lockstep `check -n 5M` runs byte-identical (full
and stable digests). New n50m stable digest deterministic across two
cold runs. Golden re-baselined: instructions 50000007->50000003,
imports 92317->90296 (trajectory shift from the changed pick order).

Tests: 666/666 (+1 test_anti_starvation_bounded_progress).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:02:02 +02:00
MechaCat02
7e2603a9e5 [iterate-2E] Extend coherent monotonic clock to lockstep (timebase-desync livelock fix)
Lockstep livelocked the scheduler the same way --parallel did before
0332d19: the kernel deadline-arithmetic (`now_basis_at`) read per-thread
`ctx(hw_id).timebase`, but a parked/poll thread has `running_idx == None`
so `Scheduler::ctx()` returns `idle_ctx` (timebase 0). A poll thread (tid=7,
a `KeWaitForSingleObject` loop with a 30ms relative timeout) computing its
deadline via `parse_timeout` therefore read `now = 0` and registered
`deadline = 0 + 3000 = 3000` — a constant ~7.78M units in the past.
`coord_idle_advance` then re-armed that same constant 3000 deadline forever,
pinning virtual time and starving every other thread's real future deadline.

Render-gate impact: the submitter (tid=6) re-enters a 16ms-timeout
WaitForMultiple after its first jobs; that timeout never fired because vtime
was pinned at 3000, so virtual time never reached real future deadlines.

Fix (Option A — mirror the parallel fix): drive the existing deterministic
`Scheduler::global_clock` in lockstep too (floored up once per outer round
to `stats.instruction_count`, a pure function of retired guest instructions —
no wall-clock), and route `KernelState::now_basis_at` through `global_clock()`
in BOTH modes. New `Scheduler::advance_global_clock_to(now)` floor-up keeps it
monotone alongside `advance_all_timebases_to`. Parallel behavior unchanged
(it already read `global_clock()`).

Verified (lockstep, 50M):
- DETERMINISM: two cold `check -n 5M` and two cold `-n 50M` runs byte-identical.
- LIVELOCK GONE: "advanced to deadline" went from 592,679 fires / 2 unique
  values / 562,084 pinned at 3000  ->  18,586 fires / 18,567 unique /
  0 pinned, strictly increasing 5.4M -> 50M. Poll thread tid=7 now ends
  Blocked with a real future deadline Some(60002824) instead of spin-Ready
  on the past 3000.
- imports 1,790,936 -> 92,317 at 50M (the spin no longer burns import calls).

Cascade (lockstep, XENIA_CACHE_PERSIST=1, -n 200M): engine now runs to budget
instead of hard-deadlocking. Hub enqueue (sub_82458068) 4x; submitter dequeue
(sub_82458508) still 3x — the lost 4th-job HANDOFF (count/notify between
sub_82458068's tail and the submitter queue) is a SEPARATE downstream gate,
not the timebase. New gate: tid=5 (hub) Blocked INFINITE on event 0x1080
(job-4 completion); tid=6 (submitter) Ready, parked in WaitForMultiple
(sub_824AB214), loop-top stops at cycle 6.23M. draws still 0, VdSwap 1.

Golden re-baseline (same commit): sylpheed_n50m
  instructions 50000004 -> 50000007, imports 1790936 -> 92317
  (swaps/draws/RTs/shaders/textures unchanged). sylpheed_n2m unchanged
  (livelock onsets after 2M). Suite 665/665 + oracle green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:42:28 +02:00
MechaCat02
0332d1990d [Track 2] Parallel-scoped global clock fixes timebase-desync livelock
In --parallel mode a long run livelocked: the scheduler spun
"advanced to deadline 3000 waking hw=2 idx=0" ~14k times in
microseconds. Root cause: each guest thread owns ctx.timebase
(+1/instr in step_block), and all kernel deadline arithmetic read
Scheduler::ctx(hw_id).timebase as "now". But the parallel worker
extracts its PpcContext via mem::replace(ctx_mut_ref, PpcContext::new())
— leaving a ZEROED timebase in the slot while it steps unlocked — and
advance_all_timebases_to only walks runqueue (never idle_ctx). So the
coordinator's coord_pre_round drain and a woken thread's parse_timeout
could read a zeroed/stale basis decoupled from the deadline the
scheduler just advanced to. The thread re-armed the same constant
deadline forever; the global clock never moved.

Fix: add a single monotonic Scheduler::global_clock, advanced by the
per-block retired-instruction count on each parallel writeback and
floored up by advance_all_timebases_to. Kernel deadline reads route
through KernelState::now_basis_at(hw_id), which returns global_clock
ONLY when parallel_active; lockstep keeps reading the exact pre-existing
ctx(hw_id).timebase expression, so the deterministic lockstep trace is
byte-identical (sylpheed_n50m golden unchanged, zero re-baseline).

Verified:
- 50M --parallel run completes (was: hung). Deadlines now strictly
  increasing 5.4M -> 49.1M (18097 unique of 18116; max repeat 2) vs
  pre-fix constant 3000 x ~14000.
- sylpheed_n50m golden byte-identical via plain `check` (no persist).
- Full suite 665/665 green.

Note: an intermittent parallel hang/crash (~1-2/20 at -n 5M) is
pre-existing (master 1/20, this build 2/20 — within noise) and distinct
from the timebase livelock: it is a parallel-race class (e.g. the
unsafe block_ptr deref in run_execution_parallel). Tracked separately;
lockstep remains the recommendation for long runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:32:14 +02:00
MechaCat02
48b19e490f [Prong A] Three 32-bit ABI PPCBUG siblings corrected to canary semantics
Second differential audit, lead prong: hunt siblings of PPCBUG-020 (the
word-form ALU truncation fixed in 341196a, whose "32-bit ABI / MSR.SF=0"
premise was false — Xenon is a 64-bit core). Found three more band-aids of the
same class, each verified against the canary oracle. All three are genuine
oracle/ISA divergences but INERT on Sylpheed's lockstep trace (sylpheed_n50m
golden digest unchanged; no re-baseline). Fixed + directed tests anyway to
close the band-aid class (per audit decision).

1. slw/srw shift-count mask (PPCBUG-044 site). Ours tested the full u32 count
   `< 32`; canary InstrEmit_slwx/srwx mask `rb & 0x3F` then test bit 5. A count
   like 0x40 (low-6-bits 0) must pass the value through, not zero it. Fixed both
   to `& 0x3F`. The 32-bit CR0 i32-view is unchanged (genuinely 32-bit).

2. sraw/srawi result extension (PPCBUG-041/042/043 "writeback truncation").
   Ours zero-extended the 32-bit arithmetic-shift result (`result as u32 as u64`);
   PowerISA + canary InstrEmit_srawx/srawix SIGN-extend it (`f.SignExtend`, the
   `(i64.s)&¬m` fill). 0x80000000>>1 is now 0xFFFFFFFF_C0000000, not
   0x00000000_C0000000. CA math and CR0 view byte-identical.

3. mtspr CTR width (PPCBUG-054). Ours stored `val as u32 as u64`, dropping the
   upper 32 bits; CTR is a 64-bit SPR and canary InstrEmit_mtspr stores the full
   GPR (`f.StoreCTR(rt)`). A later `mfspr rX, CTR` now round-trips correctly.
   bdnz/bcctr still consume only CTR's low 32 bits (the bcx zero-TEST truncation
   at line ~922 MATCHES canary's `f.Truncate(ctr, INT32_TYPE)` — left untouched).

Tests: updated srawx_negative_value_sign_extends_upper,
srawix_high_count_negative_input_sign_extends_all_ones, and
mtspr_ctr_keeps_full_64_bits (formerly premise-defending the bugs —
reading-error #24). Added slwx/srwx 6-bit-mask tests, mfspr_ctr round-trip, and
the rlwinm MB>ME wraparound-mask test (plan-requested gap closure). 665/665.

Left correct (re-confirmed vs canary, do NOT touch): bcx/bclr CTR 32-bit test,
divw/divwu zero-extend quotient (canary f.ZeroExtend, ISA upper undefined),
extsb/extsh, logical-NOT chain, mulhw/mulhwu, srawx 0x3F mask, pixel pack/unpack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:25:41 +02:00
MechaCat02
341196a111 [Issue-1 PPCBUG-020] Word-form ALU ops produce full 64-bit results
Xenon is a 64-bit PPC core (32-bit *pointer* ABI, but 64-bit registers and
integer arithmetic). The interpreter was truncating every word-form integer
ALU writeback to 32 bits and zero-extending, on a false "MSR.SF=0 / 32-bit
ABI" premise. This silently corrupted any genuine 64-bit value flowing through
word-form arithmetic.

Confirmed load-bearing via runtime ours-vs-canary capture: Sylpheed's
millisecond->LARGE_INTEGER timeout converter sub_824ACA88 does
`clrldi; mulli r11,r11,-10000; std`. For a 16 ms wait the correct result is
-160000 = 0xFFFFFFFF_FFFD8F00 (relative). canary stores exactly that; ours'
truncating `mulli` stored 0x00000000_FFFD8F00 (positive) -> the i64 timeout
read as a huge *absolute* deadline -> a ~26000x over-wait that froze the main
frame loop. After the fix the timeout matches canary and the previously-frozen
frame/worker loops run (parallel boot NtWaitForMultipleObjectsEx 94 -> 30428;
KeWaitForSingleObject/critical-section loops resume).

Fix mirrors canary's INT64 emitters (ppc_emit_alu.cc) op-by-op for the 17
data-losing word-form ops: addis, addic(.), subfic(.), mulli, add(c/e/ze/me)x,
subf(c/e/ze/me)x, negx, mullwx. Only the result *writeback* widens to full
64 bit; the 32-bit carry (XER[CA]) and overflow (XER[OV]) computations and the
CR0 i32 view are preserved byte-identical (the low 32 bits of the new result
equal the old truncated result), so this is a strict no-op for clean 32-bit
values and only restores the previously-zeroed upper bits for genuine 64-bit
values. Genuinely-32-bit ops (rlwinm/slw/srw/cmpw, mulhw/divw whose upper bits
are ISA-undefined) are left untouched.

Updated 7 unit tests that asserted the truncation (they encoded the bug) to the
canary-correct full-64-bit values. Re-baselined the sylpheed_n50m golden
(imports 40454 -> 1790936: the unwedged frame/worker loops now cycle under the
instruction-count timebase); sylpheed_n2m unchanged (pre-frame-loop). Lockstep
determinism preserved (two 50M runs identical). Full suite 660/660.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:21:11 +02:00
MechaCat02
b20c99f141 [Subsystem-fixes] 6 verified ours-vs-canary divergence fixes
From the 2026-06-12 5-subsystem differential audit. All verified against
canary as oracle; 660/660 workspace tests green (655 + 5 new).

1. nt_create_event polarity (exports.rs) — `manual_reset = gpr[5] != 0`
   was INVERTED. Canary xboxkrnl_threading.cc:668 `Initialize(!event_type,..)`
   + xevent.cc:41 (type 0 = NotificationEvent = manual, type 1 = Sync = auto).
   Now `== 0`. Was the dormant 2.AI fix on chore/portable-snapshot, never
   merged. The Ke-path was already correct; only the Nt-path was wrong.

2. 2.AF deadline drain (main.rs coord_pre_round) — expired KeWait/KeDelay
   deadlines never fired under load because advance_to_next_wake_if_due was
   only called in coord_idle_advance (no-Ready-threads path). Added a
   per-round drain loop; covers BOTH lockstep and parallel outer loops since
   both call coord_pre_round. Was the dormant 2.AF fix, never merged.

3. handle slab-recycle ABA guard (state.rs + scheduler.rs) — release_handle_slot
   (my round-34 regression) recycled a closed slot even with a thread still
   parked on it, risking a stale-waiter wake when the slot is re-minted. Added
   Scheduler::any_thread_waiting_on; decline to recycle a still-waited slot.

4. vpkpx pixel-pack (vmx.rs) — wrong field mapping (~100% mismatch). Now
   exact canary ppc_emit_altivec.cc:1795 shift/mask (red 6b out[15:10] from
   w[24:19], green out[9:5] from w[14:10], blue out[4:0] from w[7:3]; no
   fabricated alpha bit). +unit test.

5. VFS GDFX attribute plumbing (vfs/*, exports.rs query fns) — VfsEntry now
   carries the real on-disc attribute byte (GDFX dirent +12, canary
   disc_image_device.cc:136/154) instead of inferring directory-ness from
   path shape. Query exports report the real FILE_ATTRIBUTE_* bits. Candidate
   driver of the XamShowDirtyDiscErrorUI gate. +tests.

6. MmGetPhysicalAddress region-aware mirror (exports.rs) — flat 0x1FFFFFFF
   mask missed canary's +0x1000 host_address_offset for 0xE0000000+ mirror
   (memory.cc:2317). Read-only query; proven byte-identical 50M digest. +test.

Investigated and intentionally NOT changed:
- zero-on-recommit: no-op; ours has no region-reuse path (bump allocators,
  free is a stub).
- 32-bit ALU writeback truncation (PPCBUG-020): documented-deliberate; premise
  (MSR.SF=0) is questionable but flipping it is out of scope here.
- KeSetEvent/NtSetEvent return value: ours returns true previous state
  (hardware-faithful); canary returns constant 1 — NOT an ours bug.

sylpheed_n50m golden will need re-baselining (legit behavior change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 14:57:38 +02:00
MechaCat02
db90ad0f7d [AUDIT-059 R-D2] Phase D auto-signal POC confirms audit-049 wedge diagnosis
Hook NtCreateEvent for the silph::UImpl tid=13 chain (entry=0x821748F0,
start_context=0x4024a840, frame-1 LR=0x821CB15C inside sub_821CB030+0x128)
and auto-signal the resulting handle after XENIA_SILPH_UI_AUTOSIGNAL_DELAY
instructions. Env-gated; default off.

SR4 verdict B (partial unwedge):
- handle 0x1078 signal_attempts 0->1
- tid=13 Blocked(WaitAny[0x1078]) -> Ready pc=0x824a9108
- ExCreateThread 10 -> 12 (new silph::UImpl tid=14, worker tid=15)
- New downstream wedges 0x1084 + 0x1088
- cxx_throw runtime_error on tid=5 inside R26 dispatcher
  (BST not-registered instance lhs=0x715a7af0)
- VdSwap stays 1; no draws (POC is diagnostic, not final fix)

Confirms Phase C diagnosis end-to-end. The real signaler must (a) drive
NtSetEvent on the silph KEVENT AND (b) register the dispatcher's BST
instance upstream; this POC only does (a).

Reading-error class #20: ctx.lr at kernel export entry is the thunk
wrapper's return slot, NOT the guest caller's post-bl PC. Walk back-chain
1 step to get frames[1].lr.

Reading-error class #21: --parallel and lockstep have SEPARATE outer
loops in main.rs (run_execution_parallel line 2928 vs run_execution
line 2706). Per-round hooks must be wired in BOTH paths.

Files:
- crates/xenia-cpu/src/scheduler.rs: GuestThread.start_entry/start_context
  fields + spawn() population + current_thread_entry_and_ctx() helper
- crates/xenia-kernel/src/state.rs: AutoSignalPending struct, env-parsed
  silph_autosignal_delay, pending Vec, last_cycle_hint, set_now_cycle_hint,
  maybe_register_silph_autosignal (walks back-chain), fire_due_silph_autosignals
- crates/xenia-kernel/src/exports.rs: hook in nt_create_event
- crates/xenia-app/src/main.rs: fire-site + cycle hint in both outer loops
- audit-runs/audit-059-handle-disambiguation/round-D2-autosignal-poc/FINDINGS.md

Tests 655/655 green. Default behavior byte-identical when env unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 18:38:38 +02:00
MechaCat02
9ab986ec09 fix(cpu): SWAPBUG-001 — revert addi 32-bit truncation
The addi opcode was truncating its result to 32 bits per the post-P4-batch3
"32-bit ABI" rationale (commit bf8208e). Hunk-level bisection during the
2026-05 audit (M11) isolated this single cast as the cause of the
post-P8 swap regression: swaps dropped 2 → 1 and the renderer lost a
frame. PowerISA mandates sign-extension to 64 bits; canary does not
truncate addi. The truncation was a canary-divergent over-extension
of the addis fix (which IS canary-divergent by design, see
addis at interpreter.rs:121-134).

The addi_li_neg_one_zero_extends_upper test encoded the wrong invariant.
Replaced with a sign-extension test asserting canonical PowerISA
behavior (gpr[3] == 0xFFFF_FFFF_FFFF_FFFF for `li r3, -1`).

Verification at -n 100M lockstep:
  swaps:                1 → 2     (gate met)
  draws:                0 → 0     (unchanged — gated by Phase C+D+E)
  instructions:         ~100M (unchanged)
  imports:              11.4M → 987k    (game escapes retry loop)
  packets:              281M → 57M      (same)
  interrupts_delivered: 629 → 630
Tests: 551 passing (unchanged). Lockstep determinism: byte-identical
across two 100M runs except packets (±5%, GPU-thread-race noise floor).

Closes SWAPBUG-001 / PPCBUG-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:37:51 +02:00
MechaCat02
09c6c927bd refactor(cpu): fpscr round_single_toward_zero — collapse duplicate-branch ULP step
Post-P8 review nit: the if/else branches were identical
(`adj_bits - 1` either way). Both positive and negative finite f32
values use the IEEE-754 sign bit as the MSB, and subtracting 1 from
`to_bits()` always reduces magnitude by one ULP. Replace the
mock-conditional with the unconditional form + a comment explaining
why one operation works for both signs.

No behavior change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:45:55 +02:00
MechaCat02
f1166d0f75 fix(cpu): revert PPCBUG-105 — lwa/lwax/lwaux sign-extend per PowerISA
Post-P8 end-to-end review caught an ISA deviation introduced by P4
batch 5. The original code used `as i32 as i64 as u64` (correct
PowerISA sign-extension; canary's `SignExtend(INT64_TYPE)`). My P4
batch 5 commit (20a730d) changed all three to `as u64` (zero-extend),
citing the audit's "32-bit-ABI hazard" note for PPCBUG-105.

This deviation is wrong per PowerISA and any 64-bit-mode kernel code
that uses `lwa rT, off(rA)` will silently produce the wrong rT for
negative words (e.g. memory 0x80000000 should yield 0xFFFFFFFF_80000000
but was yielding 0x00000000_80000000).

Restore ISA-spec sign-extension for all three forms (lwa, lwax, lwaux).
The audit's 32-bit-ABI hazard concern was speculative — there's no
evidence that Xbox 360 user code emits `lwa` (compilers use `lwz`).
If a real bug surfaces from a 32-bit-ABI consumer that feeds an
`lwa`-loaded value into a u64 unsigned compare, that's a separate
issue to debug at the consumer site.

Test renamed: lwa_high_bit_set_zero_extends_upper → lwa_sign_extends_to_i64
with assertion flipped to expect 0xFFFFFFFF_80000000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:43:47 +02:00
MechaCat02
1f9696ad47 test(cpu): rename vmsum3fp_… to vmaddfp_lane_fma per reviewer nit
P8 review feedback (non-blocking): the test fn name said vmsum3fp but
the encoding/body actually tests vmaddfp. Rename + clarify comment;
no behavior change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:22:39 +02:00
MechaCat02
261480616c test(cpu): PPCBUG-240/277/278/316/321/370/490/517 P8 batch 4 — VMX integer/permute/load-store
Phase 8 batch 4 — VMX integer + permute/pack + multiply-sum + load/store.

12 new tests:
- VMX add/sub (240): vaddubm byte add, vsubuwm word sub.
- VMX compare (277): vcmpequb lane mask.
- VMX min/max (278): vmaxsw signed lane max.
- VMX shift/rotate (316): vsl 128-bit left shift, vsraw arithmetic per-lane.
- VMX logical (321): vand lane-wise AND.
- VMX permute (370): vsldoi byte concatenation + shift.
- VMX multiply-sum (490): vmaddfp lane FMA.
- VMX load/store (517): lvx aligned quadword load, stvx aligned store,
  lvebx byte-lane load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:16:51 +02:00
MechaCat02
ebfd18a64e test(cpu): PPCBUG-187/208/228/438/439/440 P8 batch 3 — FPU + VMX float
Phase 8 batch 3 — FPU and VMX float test gap closure.

14 new tests:
- Single FPU (187): fadds, fmuls
- Double FPU (208): fmul, fdiv (zero-numerator), fneg, fabs, fmr
- FPU convert/compare (228): fcmpu, fcfid
- VMX float compare (438): vcmpeqfp lane mask
- VMX rounding (439): vrfip, vrfim, vrfiz
- VMX convert (440): vctsxs saturation to INT_MAX/INT_MIN

The VMX VX-form encoding nit (XO is 11 bits at PPC 21-31, host bits 10-0,
with bit 0 the LSB — not bit 1) was caught by initial test failures and
fixed before commit. VC-form (vcmpeqfp) has the same "XO at bit 0" layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:14:10 +02:00
MechaCat02
2d223eee69 test(cpu): PPCBUG-091/100/109-111/118/127/129/132/146-147/153/163/171 P8 batch 2 — load/store
Phase 8 batch 2 — load/store test gap closure.

15 new tests across the load/store opcodes:
- lbz zero-extend (091), lwbrx byte-swap (109/110), lwarx smoke (111),
  ld doubleword (118), lmw + lswi (127), lswx with XER TBC (127),
  lfs single-to-double widening (129).
- stb (132), sth, stw (146), std (153), stmw + stswx (163), stfs (171).

`lswx_uses_xer_tbc_for_byte_count` and `stswx_uses_xer_tbc_for_byte_count`
specifically lock in the new XER TBC infrastructure landed in P6 (68c0ee5);
both opcodes were permanent no-ops before that.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:10:26 +02:00
MechaCat02
9827b03f1a test(cpu): PPCBUG-055/067/070/081-085/089 P8 batch 1 — branch/CR/SPR/sync
Phase 8 batch 1 — test gap closure for the branch/CR-logical/SPR/MSR/
FPSCR/cache+sync groups.

12 new tests across the affected groups:
- PPCBUG-055 branch: blr, bctr, bcl-LK-on-not-taken
- PPCBUG-070 CR logical: cror, crand, crxor (crclr idiom)
- PPCBUG-067 trap+sc: sc smoke, tw TO=0 never-traps
- PPCBUG-081-085 SPR/MSR/FPSCR moves: mfcr 8-field assembly, mtfsb1/mtfsb0
- PPCBUG-089 cache+sync: sync state-non-mutation smoke

These groups previously had near-zero unit test coverage. New tests lock
in the current ISA-correct behavior; would catch a regression in any of
the dispatch/encoding/result paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:08:54 +02:00
MechaCat02
5ece5e315f refactor(cpu): mcrfs uses fpscr::VX_ALL constant per reviewer nit
P6 review nit: replace the inline `const VX_ALL_MASK` in the mcrfs arm
with the existing `fpscr::VX_ALL` constant (single source of truth).
Behaviorally identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 13:56:34 +02:00
MechaCat02
99e7814836 test(cpu): PPCBUG-022 verify mulld_ov INT_MIN*-1 + auto-resolved markers
Phase 6 batch 4 — overflow/cleanup verification.

- PPCBUG-022 mulld_ov INT_MIN * -1: the audit-claimed missing edge case
  is actually handled by `i64::checked_mul()` (returns None when the
  result would be -i64::MIN = i64::MAX+1, which doesn't fit). New
  regression tests in overflow.rs confirm: INT_MIN * -1 overflows;
  INT_MIN * 1 doesn't; (INT_MIN+1) * -1 = INT_MAX, no overflow.
  Audit's claim was incorrect; documented by the new tests.
- PPCBUG-021 (overflow.rs OE checks at bit 63): largely auto-resolved
  by P4 batch 6 (16993bb), which switched all 32-bit ABI ops to inline
  `true_sum != (result32 as i32) as i128`. Helpers like add_ov_64 are
  now only called from 64-bit ABI ops where bit-63 is correct.
- PPCBUG-027 (rlwimix upper-32 zeros): auto-resolved by P4 (rlwimix
  now writes via `as u32 as u64` truncation).
- PPCBUG-039 (cntlzdx 32-bit-ABI): wontfix per audit — only matters
  if a 32-bit-ABI binary emits cntlzd, which compilers don't.

Remaining low-impact items (PPCBUG-642 ISA-undefined fmt_bcctr decr,
PPCBUG-643/644 SIMM/D-form hex display, PPCBUG-367/368 vupkhpx/vpkpx
channel ordering, PPCBUG-487/495 vsum operand naming, PPCBUG-515/516
lvebx/lvsr documentation, PPCBUG-601 decode_op6 invariant doc) are
left for a P9 or follow-up batch — they're cosmetic/test-coverage
items rather than correctness bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 13:51:43 +02:00
MechaCat02
0f2a26c460 fix(cpu): PPCBUG-068/078/080 mcrfs VX recompute + mtmsrd L=1 + mfvscr zero
Phase 6 batch 3 — SPR/MSR/VSCR semantics.

- PPCBUG-078 mtmsrd L=1: PowerISA requires partial-MSR-write — only
  MSR[EE] (u64 bit 15) and MSR[RI] (u64 bit 0) modified, all other
  MSR bits preserved. Used by kernel code to toggle external interrupts.
  Previously merged with mtmsr (full overwrite), silently corrupting
  MSR for any L=1 caller.
- PPCBUG-080 mfvscr: ISA places VSCR in the rightmost word of VD with
  bytes 0-11 zeroed. Previously copied the full 128-bit ctx.vscr,
  leaking stale upper data to guest. Now zero-extends per canary.
- PPCBUG-068 mcrfs VX summary: when mcrfs clears VX* exception bits,
  the VX summary bit at FPSCR[2] must be recomputed (clears if all
  contributors are 0; remains 1 otherwise). Previously left stale,
  causing subsequent CR-test sequences to misread the FPU state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 13:50:10 +02:00
MechaCat02
68c0ee55ce fix(cpu): PPCBUG-123/124/125/126/161/162/566 XER TBC + lswi/stswi/lmw
Phase 6 batch 2 — XER TBC enabling + load/store-multiple cleanups.

- PPCBUG-123/124/161/566 (coupled): XER TBC field was unmodelled —
  `ctx.xer()` always returned 0 in bits 0-6, and `ctx.set_xer()`
  silently discarded any TBC writes. Result: `lswx` and `stswx` were
  permanent no-ops (the `while bytes_left > 0` loop never executed).
  Fix: add `pub xer_tbc: u8` to `PpcContext`; wire into `xer()` and
  `set_xer()`. Initialize to 0 in `PpcContext::new()`. lswx/stswx
  bodies are correct as-is once the infrastructure is wired.

- PPCBUG-125 lmw: PowerISA marks `lmw rT, D(rA)` invalid when rA is
  in [rT..31]; canary skips the write to rA to preserve the EA base.
  Now matches canary.

- PPCBUG-126/162 lswi/stswi: replaced `instr.rb()` with `instr.nb()`
  for the NB field. Both accessors return identical values today
  (bits 16-20), but the maintenance hazard from the misnomer is now
  removed. A future `rb()` type-system refactor would have broken
  lswi/stswi silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 13:48:03 +02:00
MechaCat02
d96986a10e fix(cpu): PPCBUG-063/064/065 trap PC + sc LEV + twi typed-trap logging
Phase 6 batch 1 — trap/sc semantics.

- PPCBUG-063 trap PC: previously ctx.pc was incremented to CIA+4 BEFORE
  StepResult::Trap returned, forcing handlers to .wrapping_sub(4) to
  recover the faulting instruction address. Now ctx.pc stays at CIA on
  trap, matching SRR0 semantics on real hardware. Critical for any
  future SEH/exception-delivery path (e.g. the Sylpheed C++ throw work).
- PPCBUG-065 typed-trap logging: `twi 31, r0, IMM` is the Xbox 360
  CRT/kernel typed-trap convention encoding C++ exception class via
  SIMM. The trace now logs the SIMM type code when this pattern fires.
  Routing the type code via a StepResult payload requires an enum
  extension (multiple consumer sites) that's deferred.
- PPCBUG-064 sc LEV logging: `sc 2` is the Xbox 360 hypervisor-call
  convention; canary dispatches it to a different handler than `sc 0`.
  Now logs a warning when LEV != 0. Routing LEV=2 to a HypervisorCall
  variant also requires a StepResult enum extension; deferred.

The two enum-extension follow-ups can land as a structural sub-batch
once a clear consumer (SEH dispatch, hypervisor-call HLE) is in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 13:42:50 +02:00
MechaCat02
05f2f72c71 refactor(cpu): vrfin uses stdlib f32::round_ties_even() per reviewer nit
P5 review feedback (non-blocking): replace the inline round-to-even
implementation with the stable stdlib intrinsic (Rust 1.77+).
Functionally equivalent; cleaner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:37:54 +02:00
MechaCat02
6fe2cbf251 fix(cpu): PPCBUG-426/427/433 single-FMA vnmsubfp + vctsxs NaN saturation
Phase 5 batch 6 (5f): saturation and FMA-rounding fixes.

- PPCBUG-426 vnmsubfp: was `bi - ai * ci` (two rounding steps); now
  `-ai.mul_add(ci, -bi)` which is mathematically equivalent (= bi - ai*ci)
  but uses a single FMA round per ISA.
- PPCBUG-427 vnmsubfp128: same single-FMA fix.
- PPCBUG-433 vctsxs / vcfpsxws128 NaN saturation: AltiVec ISA saturates
  NaN to INT_MIN (0x80000000); xenia returned 0. The vctuxs (unsigned)
  NaN→0 is correct per ISA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:31:10 +02:00
MechaCat02
6ba8f83c30 fix(cpu): PPCBUG-184 fresx pre-quantize input to f32 (canary parity)
Phase 5 batch 5 (5e): minimal-viable fix for the estimate-precision
family. Hardware Xenon `fres` produces a ~12-bit LUT estimate; xenia
and canary both produce a fully IEEE single reciprocal, but canary
pre-quantizes the f64 input to f32 to at least match the input
precision. Now matches canary.

PPCBUG-428..431 (vrefp/vrsqrtefp/vexptefp/vlogefp) already operate on
f32 inputs naturally (no f64 → f32 quantization step needed); the
estimate-precision deviation is purely the output side. Newton-Raphson
convergence is unaffected. Documented in audit-findings.md as
LOW-impact full-fix-requires-LUT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:29:07 +02:00
MechaCat02
538fa5ab74 fix(cpu): PPCBUG-435/436/437 VSCR.NJ subnormal flush for VMX float
Phase 5 batch 4 (5d) — partial: VSCR.NJ subnormal flush for VMX float
arithmetic. Xbox 360 always boots with NJ=1, so games expect inputs
and outputs flushed to ±0.

- PPCBUG-435 vaddfp/vaddfp128/vsubfp/vsubfp128/vmulfp128: previously
  no flush at all on these opcodes (only vmaddfp family flushed).
  Now flushes both inputs and output per Canary's unconditional model.
- PPCBUG-436 vmsum3fp128/vmsum4fp128: per-product intermediates now
  flushed individually (was only the final sum).
- PPCBUG-437 vmaddfp/vmaddfp128/vmaddcfp128/vnmsubfp/vnmsubfp128:
  outputs now flushed (inputs were already flushed).

PPCBUG-185 (FPSCR.NI flush for scalar FPU) deferred — requires adding
a NI bit constant and post-op flush wrapper across all *sx arms; will
land in a focused sub-batch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:26:36 +02:00
MechaCat02
49bf74fae6 fix(cpu): PPCBUG-223/224/225/229/230 FPU XX bit on inexact conversions
Phase 5 batch 3 (5c) — partial: targeted XX-on-inexact fixes for the
float-to-int and double-to-single conversion family. (PPCBUG-180/200,
the broader update_after_op XX/FR/FI rework, deferred to a focused
sub-batch.)

- PPCBUG-225 frspx: set XX when the f64→f32 round produces a different
  value (i.e. precision loss). Almost every frsp call is inexact —
  previously games polling FPSCR.XX never saw the set bit after a frsp.
- PPCBUG-224 fcfidx: set XX when the i64 input has > 53 significant
  bits (precision lost in conversion to f64).
- PPCBUG-229 fctidx/fctidzx: set XX when input is non-integer (fractional
  part discarded by the conversion).
- PPCBUG-230 fctiwx/fctiwzx: same shape for word-width conversions.
- PPCBUG-223 verified already correct in current code (fcmpo sets
  VXSNAN/VXVC on NaN operands; the audit-cited drift was already fixed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:22:47 +02:00
MechaCat02
26b98975c3 fix(cpu): PPCBUG-181/182/183/202/203/205 FMA VXISI + NaN sign preservation
Phase 5 batch 2 (5b): VXISI / NaN handling for the FMA family.

The 8 FMA opcodes (fmaddx/fmaddsx/fmsubx/fmsubsx/fnmaddx/fnmaddsx/fnmsubx/
fnmsubsx) all share two fix shapes:

1. VXISI on the add/sub step. The previous code passed `a*c` to
   check_invalid_add, which has separate rounding from the FMA. In
   extreme cases this gives the wrong sign (PPCBUG-202) or wrong infinity
   status. Worse, fmsub/fnmadd/fnmsub had NO add-step VXISI check at all
   (PPCBUG-181/182/203). The fnmsub pattern is the canonical Newton-
   Raphson step — the most common FPU path in Xbox 360 graphics code.

2. NaN sign preservation in fnmadd/fnmsub. ISA Book I §4.3.4 forbids
   negation of a NaN FMA result; Rust's unary `-` flips the IEEE-754
   sign bit (PPCBUG-183/205).

Fixes:
- fpscr.rs: new helper `check_invalid_fma_add(ctx, a, c, b, sub)` that
  derives VXISI from input properties (mathematical-product sign +
  b sign) instead of from the lossy `a*c` value. Also covers SNaN.
- interpreter.rs: all 8 FMA arms now use the new helper; fnmadd[s]/
  fnmsub[s] gate the negation on `!fma.is_nan()`.

Tests:
- fmsub_inf_minus_inf_sets_vxisi: regression for PPCBUG-203.
- fnmadd_nan_input_preserves_nan_sign: regression for PPCBUG-205.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:20:02 +02:00
MechaCat02
f6a444b9d1 fix(cpu): PPCBUG-221+227 round_to_i64 + PPCBUG-432 vrfin round-to-even
Phase 5 batch 1 (5a): round-to-int correctness.

PPCBUG-221+227 (coupled): round_to_i64 NearestEven tie-breaking used
`(diff - 0.5).abs() < f64::EPSILON` to detect half-integers, but for
|v| > 2^52 every f64 value is an exact integer (v.trunc() == v), giving
diff == 0. The buggy check fell through to v.round() (round-half-away-
from-zero), giving wrong results for large odd half-integers. Replaced
with a fractional-part-only check that's exact for |v| <= 2^52 and
degenerates to truncation above.

PPCBUG-432: vrfin/vrfin128 used Rust's `f32::round()` which is round-
half-away-from-zero. ISA requires round-to-nearest-even (banker's
rounding). Implemented inline.

PPCBUG-201 (FPSCR.RN for double arithmetic) deferred — requires
MXCSR-set/restore wrappers around 10+ FPU arms; will land in a focused
sub-batch after the remaining 5a-5f fixes.

Tests:
- round_to_i64_nearest_even_on_tie: extended with 0.5, 1.5, -0.5, -1.5.
- round_to_i64_non_tie_cases: 0.4/0.6 (non-tie sanity).
- round_to_i32_nearest_even_on_tie: PPCBUG-227 coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:13:08 +02:00