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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Independent reviewer of the P4 branch found two issues:
(1) BLOCKING — subfx and subfcx OE handlers still called the legacy
`overflow::sum_overflow_64(true_diff, result32 as u64)` while batch 6
had migrated all add* sites to the inline `true_sum != (result32 as i32)
as i128` form. The legacy helper compares `true_diff` against
`(result32 as u64) as i64 as i128`, which views any bit-31-set result
as a positive i64 (e.g. result=0x80000000 → +2147483648 in i64). For a
legitimate i32::MIN result with no actual 32-bit overflow, this caused
spurious OV=1.
Concrete repro now caught by `subfo_no_spurious_ov_when_result_has_bit31_set`:
r3=1, r4=0x80000001 → result=0x80000000, true_diff=-2147483648, no OV.
Pre-fix: spurious OV=1.
(2) Minor — `mulli_overflow_wraps_to_32` rubber-stamped: with ra=0x80000000
and imm=2, both pre-fix (`as i64 as u64`) and post-fix (`as u32 as u64`)
write the same value. Replaced with ra=u64::MAX (polluted upper bits) where
pre-fix writes 0xFFFFFFFF_FFFFFFFE and post-fix writes 0x00000000_FFFFFFFE.
Fixes:
- interpreter.rs subfx/subfcx OE: switch to inline 32-bit predicate
matching the rest of batch 6.
- subfo_sets_xer_ov_on_min_minus_one: renamed and updated to test 32-bit
overflow (r4=0x80000000 - 1 = 0x7FFFFFFF, OV=1).
- New: subfo_no_spurious_ov_when_result_has_bit31_set (PPCBUG-017
review-fix regression).
- New: subfco_no_spurious_ov_when_result_has_bit31_set (same for PPCBUG-007).
- mulli_overflow_wraps_to_32: redesigned with polluted upper bits to
actually discriminate pre/post fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 4 batch 6: latent writeback truncation (4c) and CR0 catch-all (4d).
~13 PPCBUGs across all remaining 32-bit ABI ALU sites.
Latent writeback (4c) — the 4a/4b fixes already eliminate the upstream
poisoning, but a defensive truncation here catches any future regression:
- PPCBUG-012 addx, PPCBUG-013 addcx, PPCBUG-014 addex, PPCBUG-015 addzex,
PPCBUG-016 addmex, PPCBUG-017 subfx — all rewritten to compute on u32
operands and write `as u64`. CA computed via 32-bit unsigned compare.
Overflow now uses `true_sum != (result32 as i32) as i128` (32-bit
predicate, since sum_overflow_64 is i64-bounded).
- PPCBUG-032 andx/orx/xorx — CR0 catch-all only (results inherit upper
bits from operands; once those are clean, no truncation needed).
CR0 catch-all (4d) — fix the `update_cr_signed(0, X as i64)` pattern at
every 32-bit-ABI Rc=1 path:
- PPCBUG-020 catch-all: applied to mulhwx, mulhwux, divwux, mullwx (was
already done in batch 4), addx/addcx/addex/addzex/addmex/subfx (now in
4c above), andx/orx/xorx, andix, andisx, slwx, srwx, cntlzwx,
rlwinmx, rlwimix, rlwnmx, mullwx (already), divwx (already),
srawx/srawix (already in batch 4).
- PPCBUG-023 andisx: now correctly classifies bit-31 results as CR0.LT.
- PPCBUG-024 rlwinmx, PPCBUG-025 rlwimix, PPCBUG-026 rlwnmx.
- PPCBUG-044 slwx/srwx: bit-31 result like 0x80000000 now CR0.LT.
64-bit ABI ops (rldicl/rldicr/rldic/rldimi/rldcl/rldcr, sldx/srdx/sradx/
sradix, mulhdx/mulhdux/mulldx, divdx/divdux, cntlzdx) intentionally retain
the 64-bit `as i64` form per ISA — these are 64-bit-mode instructions.
Updated old tests:
- addo_sets_xer_ov_on_signed_overflow_and_stickies_so: i32::MAX + 1 → INT_MIN.
- addx_rc_uses_64bit_compare_not_32bit: renamed to ..._uses_32bit_compare_in_xbox_abi
with assertions flipped to the correct 32-bit ABI behavior.
New tests:
- andisx_sign_bit_set_classifies_lt (PPCBUG-023).
- slwx_high_bit_result_classifies_lt (PPCBUG-044).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 4 batch 5: 5 PPCBUGs in the load family. lha/lhax/lhau/lhaux
sign-extended halfword results to u64 (active poisoning for negative
halfwords); lwa/lwax/lwaux sign-extended u32 results.
- PPCBUG-095/096/097/098 lha[ux]: `as i16 as i64 as u64` →
`as i16 as i32 as u32 as u64`. Sign-extend to i32 then zero-extend.
Common trigger: int16_t struct fields, PCM samples, packed vertex
deltas. Memory 0x8000 was producing 0xFFFFFFFF_FFFF8000.
- PPCBUG-105 lwa/lwax/lwaux: `as i32 as i64 as u64` → `as u64`.
Per-canary the 64-bit-mode form sign-extends, but in 32-bit ABI we
must zero-extend (canary's behavior is rescued by x86 register
zeroing in JIT; pure interpreter has no escape). Memory 0x80000000
was producing 0xFFFFFFFF_80000000.
Tests:
- lha_negative_halfword_zero_extends_upper (PPCBUG-095).
- lhaux_negative_halfword_clean_writeback (PPCBUG-098 + EA update).
- lwa_high_bit_set_zero_extends_upper (PPCBUG-105).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 4 batch 3: 6 PPCBUGs in the same-shape-as-addis (4b) sub-section.
All share the pattern of computing on 64-bit values when the 32-bit ABI
requires u32 arithmetic.
- PPCBUG-001 addi: `li rT, -1` produced 0xFFFFFFFF_FFFFFFFF; now 0x00000000_FFFFFFFF.
- PPCBUG-002 addic: writeback truncated + CA from u32 unsigned compare
matching canary's `AddDidCarry`.
- PPCBUG-003 addicx: same plus CR0 i32 view (regression vs. the frozen
ppc-manual snapshot which had the correct form).
- PPCBUG-004 mulli: 64-bit signed product now truncated to 32 bits.
- PPCBUG-005 subficx: writeback + CA in u32 space; removes the bits-32-63
pollution from sign-extended negative SIMM.
- PPCBUG-007 subfcx: defensive 32-bit truncation of CA compare. Same shape
as the compare that broke addis (0x828F3F98 / 0x828F3F68 case).
Tests:
- addi_li_neg_one_zero_extends_upper (PPCBUG-001).
- addic_carry_uses_32bit_compare (PPCBUG-002).
- mulli_overflow_wraps_to_32 (PPCBUG-004).
- subficx_neg_simm_zero_extends (PPCBUG-005).
- subfcx_addis_incident_case (PPCBUG-007 — exact addis-incident case).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>