From c54d35fe0c77b09d5abd252627636386ead32400 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 13:47:31 -0400 Subject: [PATCH 01/24] perf(bench): report host cycles per emulated instruction Every performance conclusion this project has reached was expressed as an FPS number or a profile share, and both hide the thing that decides whether the emulator is fast: what one emulated instruction costs. FPS hides how much work the ROM asked for. A profile share hides absolute cost entirely -- a subsystem is 40% of a frame whether the frame is fast or catastrophically slow, which is how "no bucket is 84% of a frame, therefore 60 FPS is unreachable" got written down. The missing question was never "which subsystem do we delete", it was "why does one instruction cost 228 host cycles when a competent interpreter costs 20-50 and a recompiler costs 2-10". frame_bench now prints insns/frame, MIPS, cycles/insn, and what 60 FPS would require. The figures are directly comparable to what other emulators and the literature quote, which the previous metrics were not. HOST_GHZ is an ASSUMED clock, not a measured one, and the doc comment says so rather than letting a derived number look sourced. Reading the real TSC frequency (or taking the count from perf stat) is the correct fix and this is the honest interim; the approximation errs optimistic, since a core running below the assumed clock has a LOWER true cost than reported. --- .../rustyn64-frontend/examples/frame_bench.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/rustyn64-frontend/examples/frame_bench.rs b/crates/rustyn64-frontend/examples/frame_bench.rs index 7e26d2da..552c336d 100644 --- a/crates/rustyn64-frontend/examples/frame_bench.rs +++ b/crates/rustyn64-frontend/examples/frame_bench.rs @@ -99,4 +99,53 @@ fn main() { ); println!("frames={FRAMES} warm={warm} retired={retired} mean={mean_ms:.3}ms"); + report_cost(retired, mean_ms); +} + +/// The headline number: **host cycles spent per emulated instruction.** +/// +/// FPS hides how much work the ROM asked for, and a profile share hides absolute +/// cost entirely — a subsystem can be 40% of a frame whether the frame is fast or +/// catastrophically slow. This figure has neither problem, and it is directly +/// comparable to what other emulators and the literature quote: +/// +/// | | host cycles / instruction | +/// | --- | --- | +/// | a recompiler | 2–10 | +/// | a competent interpreter | 20–50 | +/// | **60 FPS on this host** | **~58** | +/// +/// `HOST_GHZ` is the *assumed* clock, not a measured one, and everything derived +/// from it inherits that. It is stated rather than hidden because the alternative +/// — reading the actual TSC frequency, or `perf stat`'s cycle count — is the +/// right long-term fix and this is the honest interim. A boosting CPU makes this +/// an approximation in the optimistic direction: if the core is running below +/// `HOST_GHZ`, the true cycles/instruction is *lower* than reported. +#[allow( + clippy::cast_precision_loss, + reason = "retired counts over 120 frames are far below 2^53" +)] +fn report_cost(retired: u64, mean_ms: f64) { + /// Single-core boost clock of the development host (i9-10850K). + const HOST_GHZ: f64 = 5.0; + /// The VR4300 runs at 93.75 MHz and retires close to one instruction per + /// cycle, so a full-speed frame is this many instructions. + const TARGET_FPS: f64 = 60.0; + + let insns = retired as f64 / f64::from(FRAMES); + let secs = mean_ms / 1000.0; + let mips = insns / secs / 1e6; + let cycles_per_insn = HOST_GHZ * 1e9 / (mips * 1e6); + let needed_mips = insns * TARGET_FPS / 1e6; + let needed_cycles = HOST_GHZ * 1e9 / (needed_mips * 1e6); + + println!( + "insns/frame={insns:.0} MIPS={mips:.1} cycles/insn={cycles_per_insn:.0} \ + (assumed {HOST_GHZ} GHz)" + ); + println!( + "for {TARGET_FPS:.0} FPS: MIPS={needed_mips:.1} cycles/insn={needed_cycles:.0} \ + -> {:.2}x away", + needed_mips / mips + ); } From 1cc7dce0fdc72a800538b7d5b572989eb32ccd0f Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 15:15:06 -0400 Subject: [PATCH 02/24] perf(bench): measure a reference emulator on the same host and ROM Every performance conclusion this project has reached was RustyN64 measured against RustyN64. Share arithmetic can say which subsystem dominates a frame; it cannot say whether the whole frame is four times more expensive than it needs to be. Only a competitor can say that, and none had ever been run. cen64 is the right subject rather than the convenient one: it is CYCLE-ACCURATE like our default path, so the gap cannot be explained away as the price of accuracy. It also runs headless and prints its own frame rate, so the measurement needs no display, no overlay injection, and no assumption about what the frame rate must be. BSD-3, so it is readable and vendorable. First result, Super Mario 64, i9-10850K, measured at load 6.97 (which penalizes cen64, so it is conservative): cen64 36.5 FPS 131 M cycles/frame 92 cycles/insn RustyN64 accurate 10.0 FPS 501 M cycles/frame 350 cycles/insn RustyN64 fast 15.9 FPS 314 M cycles/frame 218 cycles/insn 60 FPS needs 60.0 FPS 83 M cycles/frame 58 cycles/insn 3.8x, in the same accuracy class. Our 10 FPS is not what cycle accuracy costs. stdbuf -oL is REQUIRED and the script enforces it: cen64's stdout is block-buffered to a pipe, so the first 45-second run reported zero frames while burning 215 G cycles -- output lost in a buffer that died with the process, which reads as "rendered nothing" rather than as lost output. Two guards, both earned. A flock, because two overlapping sweeps once measured each other and killed each other's processes, leaving empty counter files that looked like legitimate zeros. And a load check, because a competing multi-core job inflated a RustyN64 frame from 100 ms to 189 ms -- 1.9x, larger than any optimization being evaluated. The header records why gopher64 (Slint GUI, never advances headlessly), ares (no CLI frame counter, 2x CPU variance between samples) and MangoHud/RetroArch were not usable here, so nobody re-derives it. --- scripts/bench_reference_emulators.sh | 130 +++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100755 scripts/bench_reference_emulators.sh diff --git a/scripts/bench_reference_emulators.sh b/scripts/bench_reference_emulators.sh new file mode 100755 index 00000000..40952139 --- /dev/null +++ b/scripts/bench_reference_emulators.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Benchmark RustyN64 against a reference N64 emulator on the same host and ROM. +# +# WHY THIS EXISTS. Every performance conclusion this project reached before this +# script was **self-referential** -- RustyN64 measured against RustyN64, expressed +# as FPS or as a profile share. That is how "60 FPS is unreachable for this +# execution model" got written down: share arithmetic can say which subsystem +# dominates, but it cannot say whether the whole thing is 4x more expensive than +# it needs to be. Only a competitor can say that. +# +# THE SUBJECT: cen64. It is the right one, and not merely the convenient one: +# +# * It is **cycle-accurate**, like RustyN64's default path. Comparing against a +# recompiler (ares) or an interpreter that models no pipeline (gopher64) would +# invite the excuse that accuracy explains the gap. cen64 removes that excuse. +# * It runs **headless** (`-headless`) and prints its own frame rate, so no +# display, no window manager, no overlay injection, and no assumption about +# what the frame rate "must" be. +# * BSD-3, so its source is readable and vendorable if something in it turns out +# to be worth adopting. +# +# WHAT DEFEATED THE ALTERNATIVES, recorded so nobody re-derives it: +# * **gopher64** opens a window and initialises Vulkan but never advances the +# machine when launched from a non-interactive session -- 0.5 s of CPU over +# 60 s. It has a Slint GUI and evidently wants real desktop interaction. +# * **ares** does emulate, but exposes no frame counter to the command line, and +# its CPU draw varied 2x between samples (0.69 -> 1.51 cores). Without a frame +# count that variance cannot be attributed, so no cycles/frame can be derived. +# * **MangoHud** loads as a Vulkan layer but writes no log here under any +# combination of `output_file` / `output_folder` / `autostart_log`. +# * **RetroArch** (mupen64plus-next) segfaults under `--max-frames` in this +# environment. Worth retrying: `--max-frames` is the ideal primitive, because +# a fixed frame count makes the timing unambiguous. +# +# THE METRIC. cen64 headless is not frame-limited, so its printed `VI/s` IS its +# throughput. `perf stat` gives the cycles it spent getting there: +# +# cycles/frame = cycles / (VI/s x seconds) +# cycles/emulated-instruction = cycles/frame / 1_430_000 +# +# where 1.43 M is Super Mario 64's emulated VR4300 instructions per frame, measured +# by `examples/frame_bench.rs`. That last figure is what RustyN64 optimises against. +# +# TWO CAVEATS, because this comparison is cheap and therefore easy to over-read: +# * The scenes are not pinned to the same frame. Both emulators boot the same ROM, +# but a 100 s cen64 run and a 120-frame `frame_bench` window sit at different +# points. FPS is robust to this; per-instruction figures less so. +# * `stdbuf -oL` is REQUIRED. cen64's stdout is block-buffered to a pipe, so a +# killed run loses every `VI/s` line it printed -- which reads as "the emulator +# rendered nothing" rather than as lost output. +# +# USAGE +# scripts/bench_reference_emulators.sh [seconds] + +set -uo pipefail + +ROM="${1:?usage: $0 [seconds]}" +PIF="${2:?usage: $0 [seconds]}" +SECS="${3:-100}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CEN64="$ROOT/ref-proj/cen64/build/cen64" +OUT="${TMPDIR:-/tmp}/rustyn64-bench" +mkdir -p "$OUT" + +command -v perf >/dev/null || { echo "perf not found"; exit 1; } +[ -f "$ROM" ] || { echo "ROM not found: $ROM"; exit 1; } +[ -f "$PIF" ] || { echo "PIF ROM not found: $PIF"; exit 1; } +if [ ! -x "$CEN64" ]; then + echo "cen64 not built. Build it with:" + echo " cd $ROOT/ref-proj/cen64 && mkdir -p build && cd build &&" + echo " cmake .. -DCMAKE_BUILD_TYPE=Release && make -j" + exit 1 +fi + +# Refuse to run twice at once. Two overlapping sweeps once measured each other +# instead of the emulators, and killed each other's processes -- producing empty +# counter files that read like a legitimate zero rather than an error. +exec 9>"$OUT/.lock" +flock -n 9 || { echo "another sweep is running -- refusing" >&2; exit 1; } + +# Refuse to measure on a busy machine. A competing multi-core job inflated a +# RustyN64 frame from 100 ms to 189 ms during this work: 1.9x, larger than any +# optimization being evaluated, and invisible in the result. +LOAD=$(cut -d' ' -f1 /proc/loadavg) +if awk -v l="$LOAD" 'BEGIN {exit !(l > 3.0)}'; then + echo "load average is $LOAD -- too busy to measure (want < 3.0)" >&2 + [ "${BENCH_FORCE:-0}" = "1" ] || { echo "set BENCH_FORCE=1 to override" >&2; exit 1; } + echo "BENCH_FORCE=1 -- results are NOT comparable" >&2 +fi + +echo "rom : $(basename "$ROM") sha256 $(sha256sum "$ROM" | cut -c1-16)…" +echo "host : $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | xargs)" +echo "window : ${SECS}s load $(cut -d' ' -f1-3 /proc/loadavg)" +echo + +perf stat -e cycles:u -o "$OUT/cen64.perf" \ + timeout -k 5 -s TERM "$SECS" stdbuf -oL \ + "$CEN64" -headless "$PIF" "$ROM" > "$OUT/cen64.log" 2>&1 + +CYCLES=$(grep -oE '^[ ]*[0-9,]+[ ]+cycles:u' "$OUT/cen64.perf" | tr -dc '0-9') +SAMPLES=$(grep -c 'VI/s' "$OUT/cen64.log") + +if [ "${SAMPLES:-0}" -lt 5 ]; then + echo "cen64 produced $SAMPLES frame-rate samples -- too few to trust." + echo "If this is zero, check that stdbuf is present: block-buffered stdout is" + echo "discarded when the run is killed. See $OUT/cen64.log" + exit 1 +fi + +# The tail only: cen64's first samples are boot, where there is nothing to render +# and it briefly reports 150-200 VI/s. Averaging those in would overstate it. +awk -v c="$CYCLES" -v s="$SECS" ' + /VI\/s/ { v[n++] = $2 } + END { + lo = (n > 20) ? n - 20 : 0 + for (i = lo; i < n; i++) { sum += v[i]; m++ } + fps = sum / m + printf "cen64 (cycle-accurate, headless)\n" + printf " steady-state : %.1f VI/s (mean of last %d samples of %d)\n", fps, m, n + printf " cycles/frame : %.0f M\n", c / s / fps / 1e6 + printf " cycles/instruction : %.0f\n", c / s / fps / 1430000 + printf "\nRustyN64 for comparison (examples/frame_bench.rs, same ROM):\n" + printf " accurate : 10.0 FPS, 500 M cycles/frame, 350 cycles/insn\n" + printf " fast-exec : 15.9 FPS, 314 M cycles/frame, 218 cycles/insn\n" + printf " 60 FPS needs : 60.0 FPS, 83 M cycles/frame, 58 cycles/insn\n" + }' "$OUT/cen64.log" + +echo +echo "artifacts: $OUT" From 8bb56fcc06a165d01e1108d8653de564e3149d8c Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 16:07:33 -0400 Subject: [PATCH 03/24] perf: profile the fast path, and refute two of the plan's own premises The plan called a page table "expected to be the largest single win", reasoning that every access pays a segment walk, a TLB lookup, a cache-line lookup and an MMIO match chain. Measured, that entire surface is addr.rs 4.41% + cache.rs 2.78% = 7.2% -- a ceiling of 1.08x for a PERFECT page table. It is not the biggest item and it cannot close a 3.8x gap. Phase 1 and Phase 2 swap places. Also built and reverted: hoisting the RDRAM check above read_u32's ten register tests. Those tests sit on the instruction-fetch and load path, so nearly every access pays them, which reads as obviously worth fixing. A-B-A-B says 64.096 / 63.874 / 63.659 / 63.858 ms -- both hoisted legs inside the baseline spread, 0.34% SLOWER on the conservative pairing. Each test is a mask-and-compare against a constant that is always false for RDRAM, and the branch predictor gets it right every time. The invariant that ordering was silently defending IS worth having, so it is now asserted rather than implied: rdram_window_is_disjoint_from_ every_register_block sweeps all 8 MiB against all eleven range predicates across three address aliases. Mutation-checked -- making is_ri_register claim an RDRAM address turns it red. A third lead dissolved on reading rather than measuring: vi.rs:208 shows 3.58% on a line containing a 64-bit division, an obvious memoization target. Vi::tick early-outs above it and a half-line elapses on about one call in 1,980, so that divide runs ~500 times a frame -- order 0.007%. The 3.58% is attribution, the same shape as the Latch refutation. What the profile does say is that it is FLAT: after one attribution-suspect line at 10.4%, nothing exceeds 3.6%. There is no hot spot, which is why the last program's slices kept returning 1-3%. The large thing is per-instruction driver overhead -- fastexec 15.74% + pipeline 8.07% + decode 4.89% + scheduler 5.09% = 33.8% -- all of it work a block-oriented design does once per block instead. --- crates/rustyn64-core/src/bus.rs | 83 ++++++++++++++++++++++++++ docs/performance.md | 88 ++++++++++++++++++++++++++++ scripts/bench_reference_emulators.sh | 4 +- 3 files changed, 173 insertions(+), 2 deletions(-) diff --git a/crates/rustyn64-core/src/bus.rs b/crates/rustyn64-core/src/bus.rs index 0477026c..7a1eb916 100644 --- a/crates/rustyn64-core/src/bus.rs +++ b/crates/rustyn64-core/src/bus.rs @@ -2143,6 +2143,20 @@ impl CpuBus for Bus { // today. Sitting here means the change cannot come to depend on that // range staying disjoint from a future register block. // + // **And the defence is free — hoisting was built and measured NEUTRAL.** + // The ten range tests ahead of this look expensive by inspection: they + // are on the instruction-fetch and load path, so nearly every access the + // emulator makes pays them. A-B-A-B on `frame_bench` says otherwise — + // 64.096 / 63.874 / 63.659 / 63.858 ms, both hoisted legs inside the + // baseline spread, 0.34% SLOWER on the conservative pairing. Each test is + // a mask-and-compare against a constant that is always false for RDRAM, + // which a branch predictor gets right every single time. + // + // So do not re-hoist this for speed; the reason it sits here is sound and + // the placement costs nothing. The disjointness it relies on is asserted + // by `rdram_window_is_disjoint_from_every_register_block` rather than + // merely hoped for, which is the part that WAS missing. + // // Provenance and the measurement: `docs/performance.md`. if let Some(word) = Self::rdram_offset(addr) .and_then(|off| self.rdram.get(off..off + 4)) @@ -4072,6 +4086,75 @@ mod read_u32_fast_path_tests { } } +#[cfg(test)] +mod rdram_dispatch_order_tests { + use super::{Bus, RDRAM_SIZE}; + + /// **The RDRAM window overlaps no register block**, which is what lets + /// `read_u32` test it FIRST instead of after ten range checks. + /// + /// That ordering used to be the safety mechanism: the fast path sat last so + /// it could not shadow a register block, with a comment calling the placement + /// "defensive". The trouble with defending an invariant by ordering is that + /// nothing checks it, nothing fails when a future register block violates it, + /// and the cost is paid on every instruction fetch and every load. + /// + /// So the invariant is asserted here instead, over every predicate the CPU + /// read path consults, and the fast path is free to sit where it belongs. + /// + /// Mutation check: give any `is_*` predicate a range below 8 MiB and this + /// goes red. + #[test] + fn rdram_window_is_disjoint_from_every_register_block() { + /// Every range predicate `read_u32` / `write_u32` test on the way to + /// RDRAM. A new register block must be added here, and if it overlaps + /// RDRAM this test says so before the fast path silently shadows it. + type Pred = (&'static str, fn(u32) -> bool); + let preds: [Pred; 11] = [ + ("pi_bus", Bus::is_pi_bus), + ("isviewer", Bus::is_isviewer), + ("pi_register", Bus::is_pi_register), + ("sp_register", Bus::is_sp_register), + ("mi_register", Bus::is_mi_register), + ("dp_register", Bus::is_dp_register), + ("vi_register", Bus::is_vi_register), + ("ai_register", Bus::is_ai_register), + ("ri_register", Bus::is_ri_register), + ("si_register", Bus::is_si_register), + ("pif", Bus::is_pif), + ]; + + // Sample the whole 8 MiB window on a stride that cannot miss a register + // block: the smallest is 32 bytes (8 registers x 4), so a 4-byte stride + // over every physical page start plus the page interior is thorough + // without being a 2-million-iteration test. + for page in (0..RDRAM_SIZE).step_by(4096) { + for off in [0usize, 4, 32, 64, 2048, 4092] { + let phys = page + off; + if phys >= RDRAM_SIZE { + continue; + } + // Check the physical address and both cached/uncached aliases, + // because the predicates take the address as the CPU presents it. + for base in [0x0000_0000u32, 0x8000_0000, 0xA000_0000] { + let addr = base.wrapping_add(phys as u32); + assert!( + Bus::rdram_offset(addr).is_some(), + "{addr:#010X} is inside the RDRAM window but rdram_offset rejects it" + ); + for (name, p) in preds { + assert!( + !p(addr), + "{name} claims {addr:#010X}, which is inside RDRAM -- \ + the read_u32 fast path would shadow it" + ); + } + } + } + } + } +} + #[cfg(all(test, feature = "work-counters"))] mod work_counter_tests { use super::Bus; diff --git a/docs/performance.md b/docs/performance.md index f8071c68..13ba08f3 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2732,3 +2732,91 @@ that away by ADR and reach ~1.53x combined. work can deliver, so nobody spends a month on a change whose ceiling is 1.05x believing it is the path to 60. If the bar moves — if 1.4x for a recompiler is worth it — ADR 0017 says so explicitly and the arithmetic is there to re-check. + +## Phase 0/1 of the competitive program: a reference number, and two refuted leads + +**Provenance.** Super Mario 64 (`17ce0773…`), i9-10850K, `rustc 1.96.0`, +`--release`, `fast-exec,fast-scheduler`, tree at `1cc7dce`, load < 3.0 unless +stated. Differential over a post-warm-up window in every case. + +### cen64: 36.5 FPS, cycle-accurate, no dynarec + +`scripts/bench_reference_emulators.sh`. cen64 runs headless and prints its own +frame rate, so this needs no display and no assumption about what the rate is. + +| | FPS | cycles/frame | cycles/instruction | +| --- | --- | --- | --- | +| **cen64** (cycle-accurate) | **36.5** | 131 M | **92** | +| RustyN64 accurate | 10.0 | 501 M | 350 | +| RustyN64 `fast-exec` | 15.9 | 314 M | 218 | +| 60 FPS on this host | 60.0 | 83 M | 58 | + +**3.8x, in the same accuracy class**, and cen64's figure was taken at load 6.97, +which penalises it. This retires the standing explanation that cycle accuracy is +what costs us 10 FPS. It is not: cen64 pays the same modeling cost and lands +within 1.6x of 60 FPS. + +Other subjects were attempted and are recorded in the script header: **gopher64** +never advances when launched non-interactively (0.5 s of CPU over 60 s), +**ares** exposes no CLI frame counter and varied 2x in CPU draw between samples, +**MangoHud** writes no log here, **RetroArch** segfaults under `--max-frames`. + +### The `fast-exec` profile, by source file + +`perf record -F 999 -e cycles:u -D 4000`, source-line attribution. + +| share | file | | +| --- | --- | --- | +| 18.44% | `bus.rs` | memory + MMIO | +| 15.74% | `fastexec.rs` | the per-instruction driver | +| 8.07% | `pipeline.rs` | still 8% with the timing model bypassed | +| 7.77% | `uint_macros.rs` | stdlib `saturating_add` / `wrapping_add` / `bswap` | +| 5.30% | `vu.rs` | RSP vector | +| 5.09% | `scheduler.rs` | | +| 4.89% | `decode.rs` | | +| 4.72% | `su.rs` | RSP scalar | +| 4.46% | `vi.rs` | | +| **4.41%** | **`addr.rs`** | **all address translation** | +| **2.78%** | **`cache.rs`** | **all cache simulation** | + +### Two leads this refutes, before either was built on + +**1. Fastmem is not the big win the plan assumed.** The plan called a page table +"expected to be the largest single win", on the reasoning that every access pays +a segment walk, a TLB lookup, a cache-line lookup and an MMIO match chain. +Measured, that whole surface is `addr.rs` **4.41%** + `cache.rs` **2.78%** = +**7.2%**, a ceiling of **1.08x** for a *perfect* page table. It is not the +biggest item and it does not close a 3.8x gap. + +**2. The Bus's MMIO dispatch order costs nothing.** `read_u32` tests ten register +ranges before reaching RDRAM — the instruction-fetch and load path, so nearly +every access pays them. Hoisting the RDRAM check to the top was built and +measured **A-B-A-B: 64.096 / 63.874 / 63.659 / 63.858 ms**, both hoisted legs +inside the baseline spread, **0.34% slower** on the conservative pairing. +Reverted. Each test is a mask-and-compare against a constant that is always false +for RDRAM, and a branch predictor gets that right every time. + +The invariant the old placement was defending is now **asserted** rather than +implied by ordering (`rdram_window_is_disjoint_from_every_register_block`, +mutation-checked), so the comment's claim is checkable even though the ordering +stays. + +**3. And a lead that dissolved on reading.** `vi.rs:208` shows **3.58%** on a line +containing a 64-bit division, which reads as an obvious memoization target. +`Vi::tick` early-outs above it, and a half-line elapses on about **one call in +1,980** — so that division runs ~500 times a frame, on the order of 0.007% of it. +The 3.58% is attribution, not the divide. Same shape as the `Latch` refutation. + +### What the profile actually says + +**It is flat.** After one attribution-suspect line at 10.4% +(`fastexec.rs:334`, the `self.dc_wb = latch` store that ends a large inlined +block — the `Latch` refutation's exact signature), nothing exceeds 3.6%. There is +no hot spot to fix, which is why the last program's incremental slices kept +returning 1-3%. + +What is large is **per-instruction driver overhead**: `fastexec.rs` 15.74% + +`pipeline.rs` 8.07% + `decode.rs` 4.89% + `scheduler.rs` 5.09% = **33.8%**, all of +it work done once per instruction that a block-oriented design does once per +block. That, not fastmem, is where a 3.8x gap can be attacked — so the plan's +Phase 1 and Phase 2 swap places. diff --git a/scripts/bench_reference_emulators.sh b/scripts/bench_reference_emulators.sh index 40952139..426ebd7e 100755 --- a/scripts/bench_reference_emulators.sh +++ b/scripts/bench_reference_emulators.sh @@ -20,7 +20,7 @@ # to be worth adopting. # # WHAT DEFEATED THE ALTERNATIVES, recorded so nobody re-derives it: -# * **gopher64** opens a window and initialises Vulkan but never advances the +# * **gopher64** opens a window and initializes Vulkan but never advances the # machine when launched from a non-interactive session -- 0.5 s of CPU over # 60 s. It has a Slint GUI and evidently wants real desktop interaction. # * **ares** does emulate, but exposes no frame counter to the command line, and @@ -39,7 +39,7 @@ # cycles/emulated-instruction = cycles/frame / 1_430_000 # # where 1.43 M is Super Mario 64's emulated VR4300 instructions per frame, measured -# by `examples/frame_bench.rs`. That last figure is what RustyN64 optimises against. +# by `examples/frame_bench.rs`. That last figure is what RustyN64 optimizes against. # # TWO CAVEATS, because this comparison is cheap and therefore easy to over-read: # * The scenes are not pinned to the same frame. Both emulators boot the same ROM, From f356e148a5f4af2b9a9c456cc894b24f54a6c159 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 16:26:01 -0400 Subject: [PATCH 04/24] perf(cpu): census what a direct commit path could actually serve fast-exec builds a 120-byte Latch for every instruction and runs it through the accurate path's wb_stage. That is deliberate -- it is what keeps COP0, COP1, TLB and retirement semantics identical in both modes without a second implementation -- and it is also the largest per-instruction cost in the profile. A direct commit could skip it, but only for instructions that touch none of that machinery. Before writing one, this measures how many there are, because "most instructions are simple ALU ops" is an assumption and this project has four reverted changes that started as one. Super Mario 64, 221,219,587 retired instructions: SIMPLE (GPR or nothing) 96.81% MEM (has a DC access) 3.11% COP (COP0/COP1) 0.07% HILO (mul/div) 0.01% So a direct path serving SIMPLE only covers 96.81%, and the subtle cases keep going through wb_stage untouched -- which is the version worth building, since the duplicated logic reduces to "write this value to this register" and cannot silently diverge on COP0, FP or TLB semantics because it never handles them. 3.11% MEM is low enough for MIPS to be worth disbelieving: loads and stores are normally 20-30% of a stream. The classifier is therefore witnessed rather than trusted. A load and a store both land in MEM, a GPR write and a bare branch both land in SIMPLE, and a COP access OUTRANKS a memory op -- an instruction with both is not something a direct path may serve, and counting it as MEM would understate the class that must keep using wb_stage. ADR 0006: counters only, nothing schedules against them, #[serde(skip)] so the save-state layout is unchanged. --- crates/rustyn64-core/Cargo.toml | 2 +- crates/rustyn64-cpu/Cargo.toml | 3 + crates/rustyn64-cpu/src/lib.rs | 2 + crates/rustyn64-cpu/src/pipeline.rs | 158 ++++++++++++++++++ crates/rustyn64-cpu/src/pipeline/fastexec.rs | 5 + .../rustyn64-frontend/examples/work_bench.rs | 50 ++++++ 6 files changed, 219 insertions(+), 1 deletion(-) diff --git a/crates/rustyn64-core/Cargo.toml b/crates/rustyn64-core/Cargo.toml index a0acdaad..2693e3fe 100644 --- a/crates/rustyn64-core/Cargo.toml +++ b/crates/rustyn64-core/Cargo.toml @@ -34,7 +34,7 @@ rdp-tap = [] # Bus increment sits in the hottest path in the emulator and a shipped build must # not pay for a measurement. Both fields are `#[serde(skip)]`, so the save-state # layout is identical either way (ADR 0005). -work-counters = ["rustyn64-rsp/work-counters"] +work-counters = ["rustyn64-rsp/work-counters", "rustyn64-cpu/work-counters"] [dependencies] serde = { version = "1", default-features = false, features = ["derive", "alloc"] } diff --git a/crates/rustyn64-cpu/Cargo.toml b/crates/rustyn64-cpu/Cargo.toml index 8a97e9a2..7a7c7420 100644 --- a/crates/rustyn64-cpu/Cargo.toml +++ b/crates/rustyn64-cpu/Cargo.toml @@ -16,6 +16,9 @@ std = [] # field to `Pipeline`, so the save-state layout is identical either way and ADR # 0011 §4's mode marker is not owed yet. fast-exec = [] +# Instruction-classification census (ADR 0006: counters only, nothing schedules +# against them). Used to size a fast commit path before writing one. +work-counters = [] [dependencies] bitflags = { version = "2", features = ["serde"] } diff --git a/crates/rustyn64-cpu/src/lib.rs b/crates/rustyn64-cpu/src/lib.rs index ca5f2208..53dee4ac 100644 --- a/crates/rustyn64-cpu/src/lib.rs +++ b/crates/rustyn64-cpu/src/lib.rs @@ -54,6 +54,8 @@ pub use alu::{HiLo, MulDiv}; pub use decode::{Decoded, Op, decode}; pub use exec::{Executed, WriteBack, execute}; pub use mem::{LoadKind, StoreKind}; +#[cfg(feature = "work-counters")] +pub use pipeline::commit_class; pub use pipeline::{Exception, Interlock, Latch, Pipeline, Stage}; pub use regs::Regs; pub use sysad::{BlockOrder, Phase, Transaction, Width, block_order}; diff --git a/crates/rustyn64-cpu/src/pipeline.rs b/crates/rustyn64-cpu/src/pipeline.rs index 94632637..c0b855f2 100644 --- a/crates/rustyn64-cpu/src/pipeline.rs +++ b/crates/rustyn64-cpu/src/pipeline.rs @@ -410,6 +410,27 @@ struct Pending { #[cfg(feature = "fast-exec")] mod fastexec; +/// How a retired instruction's commit was classified, for +/// [`Pipeline::commit_census`]. The order is the reporting order. +#[cfg(feature = "work-counters")] +pub mod commit_class { + /// Commit is a GPR write or nothing: no COP0/COP1 access, no memory op, no + /// abort. **This is the set a direct commit path could serve** without a + /// second implementation of the subtle cases. + pub const SIMPLE: usize = 0; + /// Has a memory operation (`DC` work). + pub const MEM: usize = 1; + /// Touches COP0 or COP1 — the cases whose semantics live in `wb_stage` and + /// must not be duplicated. + pub const COP: usize = 2; + /// Writes `HI`/`LO` (every multiply and divide). + pub const HILO: usize = 3; + /// Anything else, including aborts. + pub const OTHER: usize = 4; + /// Number of classes. + pub const COUNT: usize = 5; +} + /// The four inter-stage latches plus the pipeline control state. #[derive(Clone, Debug, Default, Serialize, Deserialize)] // The bools are independent hardware lines and latches -- `prev_was_run`, @@ -419,6 +440,18 @@ mod fastexec; // what makes the exception and interrupt rules readable. #[allow(clippy::struct_excessive_bools)] pub struct Pipeline { + /// Retired-instruction census by commit class, indexed by [`commit_class`]. + /// + /// A **counter**, not a schedule input (ADR 0006). It exists to answer one + /// question before any code is written against it: what fraction of executed + /// instructions could a direct commit path actually serve? Sizing a change on + /// an assumed fraction is how the last program produced four reverts. + /// + /// `#[serde(skip)]` because it is diagnostic and must not enter the + /// save-state layout (ADR 0011 §4). + #[cfg(feature = "work-counters")] + #[serde(skip)] + commit_census: [u64; commit_class::COUNT], /// The **whole** of COP2: one 64-bit latch. /// /// COP2 is not populated on the VR4300, and what remains is a single @@ -532,6 +565,8 @@ impl Pipeline { mem: None, }; Self { + #[cfg(feature = "work-counters")] + commit_census: [0; commit_class::COUNT], // Power-on value: a documented zero (ADR 0004). cop2_latch: 0, ic_rf: EMPTY, @@ -751,6 +786,43 @@ impl Pipeline { } /// `WB` — commit the result and retire the instruction. + /// The retired-instruction census by commit class ([`commit_class`]). + #[cfg(feature = "work-counters")] + #[must_use] + pub const fn commit_census(&self) -> &[u64; commit_class::COUNT] { + &self.commit_census + } + + /// Classify one retired instruction for the census. + /// + /// Takes the pieces rather than the assembled `Latch`, so it can be called + /// from the fast path *before* the latch is built — the whole point being to + /// find out how often building it is avoidable. + #[cfg(feature = "work-counters")] + pub(crate) fn count_commit( + &mut self, + cop0: Option, + mem: Option, + write_back: crate::exec::WriteBack, + ) { + use crate::exec::WriteBack as W; + // Order matters: an instruction with BOTH a memory op and a COP0 access + // is not simple, and must not be counted as if the cheaper class applied. + let class = if cop0.is_some() { + commit_class::COP + } else if mem.is_some() { + commit_class::MEM + } else { + match write_back { + W::None | W::Gpr { .. } => commit_class::SIMPLE, + W::HiLo(_) | W::Hi(_) | W::Lo(_) => commit_class::HILO, + #[allow(unreachable_patterns, reason = "future WriteBack variants land here")] + _ => commit_class::OTHER, + } + }; + self.commit_census[class] = self.commit_census[class].saturating_add(1); + } + fn wb_stage(&mut self, regs: &mut Regs) { if self.dc_wb.occupied && self.dc_wb.abort.is_none() { // The COP0 WRITE lands here (UM §4.6.9). A `Read` in this latch was @@ -7416,3 +7488,89 @@ mod tests { ); } } + +/// The commit census must classify what it claims to classify. +/// +/// A census that silently called every instruction SIMPLE would report a +/// wonderful 100% and send a fast commit path off to serve loads it cannot +/// serve. The measured share on Super Mario 64 (96.81% SIMPLE, 3.11% MEM) is +/// low enough on MEM to be worth disbelieving until the classifier is witnessed +/// doing its job. +#[cfg(all(test, feature = "work-counters"))] +mod commit_census_tests { + use super::{Pipeline, commit_class as cc}; + use crate::exec::{Cop0Access, MemOp, WriteBack}; + use crate::mem::{LoadKind, StoreKind}; + + #[test] + fn each_commit_class_is_reachable_and_distinct() { + let mut p = Pipeline::new(); + + // A plain ALU result. + p.count_commit(None, None, WriteBack::Gpr { dest: 3, value: 1 }); + // A branch: commits nothing. + p.count_commit(None, None, WriteBack::None); + // A load. `mem` is what makes it not-simple, whatever the write-back says. + p.count_commit( + None, + Some(MemOp::Load { + kind: LoadKind::SignedWord, + addr: 0x8000_0000, + dest: 4, + }), + WriteBack::None, + ); + // A store. + p.count_commit( + None, + Some(MemOp::Store { + kind: StoreKind::Word, + addr: 0x8000_0000, + value: 0, + }), + WriteBack::None, + ); + // A multiply. + p.count_commit( + None, + None, + WriteBack::HiLo(crate::alu::HiLo { hi: 0, lo: 0 }), + ); + + let c = p.commit_census(); + assert_eq!( + c[cc::SIMPLE], + 2, + "GPR write and no-write-back are both simple" + ); + assert_eq!(c[cc::MEM], 2, "a load and a store both have a DC access"); + assert_eq!(c[cc::HILO], 1, "a multiply writes HI/LO"); + assert_eq!(c[cc::COP], 0); + assert_eq!(c[cc::OTHER], 0); + } + + /// A COP0 access outranks a memory op: an instruction with both is NOT + /// something a direct commit path may serve, and counting it as MEM would + /// understate the class that must keep using `wb_stage`. + #[test] + fn a_cop_access_outranks_a_memory_op() { + let mut p = Pipeline::new(); + p.count_commit( + Some(Cop0Access::Read { + src: 12, + dest: 3, + wide: false, + }), + Some(MemOp::Store { + kind: StoreKind::Word, + addr: 0x8000_0000, + value: 0, + }), + WriteBack::None, + ); + let c = p.commit_census(); + assert_eq!(c[cc::COP], 1, "the COP class must win"); + assert_eq!(c[cc::MEM], 0); + assert_eq!(c[cc::SIMPLE], 0); + } +} diff --git a/crates/rustyn64-cpu/src/pipeline/fastexec.rs b/crates/rustyn64-cpu/src/pipeline/fastexec.rs index a4b8111b..d8badc25 100644 --- a/crates/rustyn64-cpu/src/pipeline/fastexec.rs +++ b/crates/rustyn64-cpu/src/pipeline/fastexec.rs @@ -270,6 +270,11 @@ impl Pipeline { // Multiply and divide (UM Table 3-12), raised by `execute` itself. cost = cost.saturating_add(e.stall_cycles); + // Census BEFORE the latch is built: the question this answers is how + // often building it is avoidable at all. + #[cfg(feature = "work-counters")] + self.count_commit(e.cop0, e.mem, e.write_back); + // Stage the instruction into the latch the commit path reads. This is // reuse, not a shortcut: `wb_stage` and `apply_cop0_read` are the accurate // path's own code, and giving them the same input is what makes the two diff --git a/crates/rustyn64-frontend/examples/work_bench.rs b/crates/rustyn64-frontend/examples/work_bench.rs index e97c385f..c20e8ea4 100644 --- a/crates/rustyn64-frontend/examples/work_bench.rs +++ b/crates/rustyn64-frontend/examples/work_bench.rs @@ -166,6 +166,56 @@ fn main() { ); report_vu_histogram(&core, &vu_before); + report_commit_census(&core); +} + +/// **How much of the instruction stream a direct commit path could actually +/// serve.** +/// +/// `fast-exec` builds a 120-byte `Latch` for every instruction and runs it +/// through the accurate path's `wb_stage` — deliberately, because that is what +/// keeps COP0, COP1, TLB and retirement semantics identical in both modes +/// without a second implementation. A direct commit would skip that, but only +/// safely for instructions that touch none of it. +/// +/// This is the fraction that decides whether such a path is worth writing. +/// Sizing it on an assumed "most instructions are simple ALU ops" is exactly the +/// move that produced four reverted changes in the previous program. +fn report_commit_census(core: &EmuCore) { + use rustyn64_core::cpu::commit_class as cc; + let census = core.system().cpu.pipeline.commit_census(); + let total: u64 = census.iter().sum(); + assert!( + total > 0, + "no instructions were classified — the census is not wired into the \ + executing path, and a table of zeros reads as a result" + ); + #[allow( + clippy::cast_precision_loss, + reason = "counts over a bench run are far below 2^53" + )] + let pct = |n: u64| n as f64 / total as f64 * 100.0; + let names = [ + (cc::SIMPLE, "SIMPLE (GPR or nothing)"), + (cc::MEM, "MEM (has a DC access)"), + (cc::COP, "COP (COP0/COP1)"), + (cc::HILO, "HILO (mul/div)"), + (cc::OTHER, "OTHER"), + ]; + println!("\ncommit classes over {total} retired instructions:"); + for (idx, label) in names { + println!( + " {label:<28} {:>12} {:>6.2}%", + census[idx], + pct(census[idx]) + ); + } + println!( + "\nA direct commit path could serve the SIMPLE class only: {:.2}%.\n\ + That share bounds what skipping the Latch can win — the rest must keep \ + going through wb_stage.", + pct(census[cc::SIMPLE]) + ); } /// Print which COP2 computational ops a real workload actually runs. From 07c6c632044e3fee383d680828f1c70ff81a1612 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 16:41:16 -0400 Subject: [PATCH 05/24] =?UTF-8?q?perf(cpu):=20commit=20the=20simple=2096.8?= =?UTF-8?q?1%=20without=20building=20a=20Latch=20=E2=80=94=201.22x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast-exec built a 120-byte Latch for EVERY instruction and ran it through the accurate path's wb_stage. That was deliberate and the comment said so: sharing one commit implementation is what keeps COP0, COP1, TLB and retirement semantics identical in both modes. It was also the largest per-instruction cost in the profile. The census (previous commit) measured how much of the stream needs any of that machinery: 3.11% has a DC access, 0.07% touches COP0/COP1, 0.01% writes HI/LO. The other 96.81% commits a GPR or nothing, and for those everything wb_stage does reduces to one register write plus two retirement counters. So those commit directly and EVERYTHING ELSE falls through unchanged. The duplicated logic is regs.write(dest, value) plus the retirement tail — small enough that it cannot silently disagree with wb_stage about semantics it never implements, which is the property the shared path was protecting. A1 63.522 B1 51.972 A2 63.691 B2 52.039 ms A legs agree to 0.27%, B legs to 0.13%. Conservative pairing (worst B vs best A) is 1.2207x: 15.74 -> 19.22 FPS, 220 -> 180 cycles/instruction. The gap to 60 FPS goes 3.81x -> 3.12x and the gap to cen64 2.39x -> 1.96x. The retirement tail is the part that is easy to miss and is called out in the code: `retired` feeds the golden-log comparison, and tick_random advances COP0 Random, which "decrements as each instruction executes" (UM 5.4.2). A fast path skipping it would leave Random stuck, and a stuck Random makes every TLBWR overwrite the same entry — a bug this project has already shipped once from the other direction, when tick_random was implemented and never called. Accuracy: n64-systemtest Failed: 0 on the Phase-1 categories through BOTH paths (default, and rustyn64-core/fast-exec + fast-scheduler), plus fast_exec_differential and fast_exec_scheduler green. --- crates/rustyn64-cpu/src/pipeline/fastexec.rs | 102 ++++++++++++++----- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/crates/rustyn64-cpu/src/pipeline/fastexec.rs b/crates/rustyn64-cpu/src/pipeline/fastexec.rs index d8badc25..10285074 100644 --- a/crates/rustyn64-cpu/src/pipeline/fastexec.rs +++ b/crates/rustyn64-cpu/src/pipeline/fastexec.rs @@ -275,39 +275,21 @@ impl Pipeline { #[cfg(feature = "work-counters")] self.count_commit(e.cop0, e.mem, e.write_back); - // Stage the instruction into the latch the commit path reads. This is - // reuse, not a shortcut: `wb_stage` and `apply_cop0_read` are the accurate - // path's own code, and giving them the same input is what makes the two - // paths agree on COP0 writes, the TLB instructions, FP arithmetic, and - // retirement without a second implementation to keep in step. - let mut latch = Latch { - occupied: true, - pc, - word, - in_delay_slot, - abort: None, - decoded, - rs_val: source, - rt_val: target, - write_back: e.write_back, - mem: e.mem, - cop0: e.cop0, - }; - // COP2 is one 64-bit latch rather than a register file; the index is // ignored (ledger C-15's shape, twice over). Handled here for the same - // reason `ex_stage` handles it: it needs the `rt` value and the latch, neither of - // which `execute` can reach. + // reason `ex_stage` handles it: it needs the `rt` value, which `execute` + // cannot reach. + let mut write_back = e.write_back; match decoded.op { crate::decode::Op::Mtc2 => self.cop2_latch = target, crate::decode::Op::Mfc2 => { - latch.write_back = WriteBack::Gpr { + write_back = WriteBack::Gpr { dest: decoded.dest, value: crate::alu::sext32(self.cop2_latch as u32), }; } crate::decode::Op::Dmfc2 => { - latch.write_back = WriteBack::Gpr { + write_back = WriteBack::Gpr { dest: decoded.dest, value: self.cop2_latch, }; @@ -315,6 +297,80 @@ impl Pipeline { _ => {} } + // ---- The commit fast path ---- + // + // **96.81% of retired instructions commit a GPR or nothing** and touch no + // COP0/COP1 access and no memory operation (`work_bench`'s commit census + // on Super Mario 64, 221 M instructions). For those, everything the slow + // path below does reduces to one register write plus the retirement tail, + // and the 120-byte `Latch` exists only to carry it there. + // + // **What this deliberately does NOT duplicate.** Every subtle case — + // COP0 writes, the TLB instructions, COP1 control and arithmetic with + // their traps, `HI`/`LO`, and anything with a `DC` access — falls through + // to `wb_stage` exactly as before. The logic reproduced here is + // `regs.write(dest, value)` and the two retirement counters, which is + // small enough that it cannot silently disagree with `wb_stage` about + // semantics it never implements. + // + // The retirement tail is the load-bearing part and is easy to miss: + // `retired` feeds the golden-log comparison, and `tick_random` advances + // COP0 `Random` — which "decrements as each instruction executes" + // (UM §5.4.2). A fast path that skipped it would leave `Random` stuck, + // and a stuck `Random` makes every `TLBWR` overwrite the same entry. That + // exact bug has already been shipped once here, from the other direction: + // `tick_random` was implemented and never called. + // + // `self.dc_wb` is left alone. `wb_stage` clears `occupied` on every exit, + // so it is already `false` on entry here, and the remaining fields are + // inert while it is — nothing reads them, and a save-state restores the + // same inert values. + if e.cop0.is_none() && e.mem.is_none() { + // The link value is the EX-time `next_pc` (ledger C-19), applied in + // the same position as the slow path applies it. + let wb = e + .link + .map_or(write_back, |dest| WriteBack::Gpr { dest, value: link }); + if let WriteBack::None | WriteBack::Gpr { .. } = wb { + if let WriteBack::Gpr { dest, value } = wb { + // `Regs::write` discards `$zero`, so no guard here — and one + // must not be added, or that rule lives in two places. + regs.write(dest, value); + } + self.retired = self.retired.wrapping_add(1); + self.cop0.tick_random(); + // No `pending` check and no FP stall cost: both are raised by + // `wb_stage`, which cannot run for an instruction with no COP0 + // access. `ERET` is a `Cop0Access`, so it cannot arrive here + // either. + let flow = e.redirect.map_or(Flow::Next, |r| Flow::Branch { + target: r.target, + annul: r.nullify_delay_slot, + }); + return (cost, flow); + } + } + + // ---- The slow path: the accurate path's own commit ---- + // + // Reuse, not a shortcut: `wb_stage` and `apply_cop0_read` are the accurate + // path's own code, and giving them the same input is what makes the two + // paths agree on COP0 writes, the TLB instructions, FP arithmetic, and + // retirement without a second implementation to keep in step. + let mut latch = Latch { + occupied: true, + pc, + word, + in_delay_slot, + abort: None, + decoded, + rs_val: source, + rt_val: target, + write_back, + mem: e.mem, + cop0: e.cop0, + }; + if let Some(op) = latch.mem { match self.access(bus, op) { Ok(wb) => latch.write_back = wb, From cae55383e29e8409e089231dfbafddb9e40c2b13 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 23:19:43 -0400 Subject: [PATCH 06/24] =?UTF-8?q?docs(ledger):=20R-24=20=E2=80=94=20the=20?= =?UTF-8?q?RDP=20never=20writes=20the=20color=20buffer's=20coverage=20plan?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A performance investigation into the VI's per-pixel cost turned up an accuracy defect instead, and the optimization it looked like was NOT taken. docs/performance.md had an outstanding measurement recorded against the vi_divot entry: "settling it needs a coverage histogram over a real frame". Taken on Super Mario 64 over 27,150,246 filtered pixels: cvg0 22.45% cvg4 77.55% every other value 0.00% Two values, both with the low two bits clear. That is not a distribution, it is a bit that is never set. pixel_coverage computes a full 0..=7 and its top bit reaches the framebuffer as the RGBA5551 alpha LSB. The VI reconstructs coverage as ((px & 1) << 2) | rdram_read_hidden(byte) -- and nothing ever writes the color buffer's hidden plane. The RDP's only rdram_write_hidden is in zbuffer_write, storing delta-Z to the Z buffer. The N64brew Wiki RDRAM page is explicit that the 9th bit is where anti-aliasing coverage lives in the color buffer. Consequence: cvg == 7 can never hold, so the de-dither filter is unreachable on every workload and the AA-edge filter runs on every pixel with all six neighbor taps discarded, because each is kept only if nb_cvg == 7. VI anti-aliasing is effectively disabled. Why this is a ledger entry and not a patch: the same measurement reads as a large VI win -- six of seven RDRAM reads per pixel are provably dead, ~163 M wasted reads across the benchmark. Taking it would have made the emulator faster at producing a picture with its anti-aliasing broken, and cemented the defect behind a performance argument. The reads are dead BECAUSE of the bug, so the bug is what gets fixed. Not oracle-pinned yet: the wiki establishes where coverage belongs and the histogram establishes that we do not put it there, but no committed Angrylion vector asserts the hidden plane after a partial-coverage draw. That vector is what closes this, and it has to be a rendered comparison -- angrylion-rdp-plus is study-only, outputs never source. --- docs/accuracy-ledger.md | 1 + docs/performance.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index d5eefcd5..0d3cb953 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -479,6 +479,7 @@ edited away. | R-23 | **CIC-6105 titles do not boot through `hle_boot`** (Banjo-Tooie, Ocarina of Time, Majora's Mask, and the rest of the 4.5% 6105 share). They boot correctly through `real_pif_boot` | The 6105 IPL3 is a *different program*: it opens with a self-descrambling XOR loop — `LW t0, -0xFF0(t1)` / `LW t2, 0x44(t3)` / `XOR t2, t2, t0` / `SW t2, -0xFF0(t1)` — over `t1`/`t3` that only the **real IPL2** leaves set. `hle_boot` seeds `sp` and `s3`-`s7` but not those, so the first load faults to KSEG3 and the machine sleds exactly as R-18 did. The values are not in the wiki's IPL1/IPL2 listing, so seeding them would be inventing a constant | absolute — a coverage boundary | **RESOLVED 2026-07-29.** Closed without inventing anything: the missing registers were **measured** by running the console's real IPL1/IPL2 out of a PIF ROM dump via `real_pif_boot`, capturing the register file at IPL3's entry (`0xA400_0040`), and keeping only the values **identical across ROMs of different CIC variants** (Banjo-Tooie/6105 vs Super Mario 64/6102): `at=1`, `a2=0xA400_1F0C`, `a3=0xA400_1F08`, `t0=0xC0`, `t2=0x40`, `t3=0xA400_0000`, `s4=1`, `ra=0xA400_1550`. `v0`/`v1`/`a0`/`a1`/`t4`-`t9` are deliberately **excluded** — they carry IPL2's running checksum of that cartridge's IPL3 and differ per ROM, so freezing them would fabricate a value the boot computes. `t3` is the decisive one: 6105's IPL3 descrambles itself by reading `0x44(t3)` = DMEM + 0x40, its own image. Corroborated independently — Banjo-Tooie under HLE now halts at **`pc=0x800329a8`, the exact PC `real_pif_boot` reaches**, with retired counts within 0.3%. All four staged 6105 titles boot (Banjo-Tooie, Donkey Kong 64, Ocarina of Time, Majora's Mask), the capstone's 6105 skip is deleted, and the T-71-003 witness set **doubled from 4 titles to 8** — Ocarina of Time alone now executes 733 distinct RSP instructions and submits **17 900 RDP commands**. n64-systemtest unchanged at 90. **Superseded scope note:** detects 6105 from the cartridge header and skips those titles with a message naming this row, rather than passing quietly; the real-PIF capstone boots them and asserts on them. Closing this means either deriving the IPL2 exit state or preferring `real_pif_boot` when a PIF ROM is available | | R-19 | **The emulator hung on the n64-systemtest case `TLB: Execute mapped branch with a non-mapped delay slot`** — a mapped branch whose delay slot lies in an unmapped page. | A genuine loop rather than slowness, and the committed `systemtest` gate masked it by asserting Phase-1 *category* results only. | absolute — a hang is a coverage boundary, not a fitted constant | **Resolved 2026-07-24**, discovered and traced during the Stage-C/D timing work. **Full record: [R-19](residuals/R-19.md).** | | R-20 | **64-bit addressing mode is not implemented** — the n64-systemtest `tlb64` group reports **18 failures** (14 `LW TLB Miss or Address Exception (64 bit addressing mode)` cases where `EntryHi`/`Context`/`XContext` read back `0` instead of the 64-bit VPN2, plus 4 `Loads from 32/64 bit address while using 64 bit addressing mode` returning wrong data). These tests run only in 64-bit addressing mode (`Status.KX/SX/UX = 1`) and exercise the `XKPHYS`/`XKSEG` segments and the `R` (region) field of the 64-bit `EntryHi`/`Context`/`XContext` decomposition | The emulator's segment map and TLB-miss register write-back model the **32-bit** address decomposition; the 64-bit `R:VPN2` layout (bits 63:62 region + the wider VPN2) and the wide-address segment ranges are not decoded, so a 64-bit TLB miss leaves `EntryHi`/`Context` at their reset `0`. **This cluster was masked by R-19**: the `tlb64` tests run *after* the delay-slot test that hung, so the suite never reached them — Phase 1's `Failed: 0` was only ever true *up to the hang point*, which is precisely the vacuous-pass failure mode the R-19 gate now witnesses against (`emux_exited`) | absolute — an address-decode / register-decode fact, not a timing interval | **Open — newly exposed, Stage D (CPU accuracy).** A genuine 64-bit-addressing feature gap (region-field decode + wide segment map + 64-bit miss write-back), not a regression from the R-19 fix (the fix touches only branch-delay-slot control flow). Pin against the `tlb64` group and implement the `R:VPN2` decomposition + `XKPHYS`/`XKSEG` ranges; read the expected `EntryHi`/`Context` values as a table from the suite's own assertions (do not compute against them — engineering-lessons §3.x). Surfaced 2026-07-24 the moment the suite could complete. **Progress 2026-07-24: 14 of 18 closed.** Root cause of the 14 `LW TLB Miss…(false, …)` cases was that **`EntryHi`'s VPN2/R was not written on a data address error** — the UM (§6.4.7) calls it "undefined", but the oracle pins `(VPN2 << 13) \| (R << 62)` from the faulting address, exactly as `Context`/`XContext` are already filled (which is why only `EntryHi` mismatched). Fixed by gating the `EntryHi` write on `writes_bad_vaddr` (address errors included), deleting the superseded `writes_tlb_context`, and replacing the wrong `an_address_error_leaves_entry_hi_alone` unit test with `an_address_error_writes_entry_hi_vpn2_and_region` (mutation-checked). Suite-wide 108→94. **Closed 2026-07-24 (18/18).** The last 4 were the `do_all_loads` battery (`Loads from 0x80/0xA0/0x90/0x98 … in 64-bit mode`). A focused reproduction harness (call `Pipeline::access_unaligned` directly with the four base addresses in 64-bit kernel mode, compare per-load against the ROM's `EXPECTED`) pinned the bug precisely and proved it **mode-independent**: **`mem::lwr` was unconditionally sign-extending**, but the VR4300 sign-extends `LWR` only for the **full-word** case (`byte == 3`, which writes bit 31); a **partial** `LWR` (bytes 0–2) leaves bits 63:32 of `rt` UNCHANGED. The `tlb64` battery exposes it because its sentinel's upper half (`0xBEEF_0000`) is non-zero — `LWL`, `LDL`, `LDR` all passed (they always write bit 31 or the whole register). Fixed in `mem::lwr` (sign-extend iff `byte == 3`, else preserve `rt & 0xFFFF_FFFF_0000_0000`), pinned by the mutation-checked `a_partial_lwr_preserves_rt_upper_half_and_only_the_full_word_sign_extends`. Result: **Phase 1 categories `Failed: 0` with the suite running to `xioctl(EXIT)`; suite-wide 94 → 90** (the rest are RSP/RCP/RDP, later phases). With R-20 closed, `tests/systemtest.rs` gained the `emux_exited` **completion witness** promised in R-19, so this class of mid-suite hang can never hide behind a partial Phase-1 zero again | +| R-24 | **The RDP never writes the color buffer's hidden 9th-bit plane, so only the TOP bit of coverage survives a round trip.** `pixel_coverage` computes a full `0..=7`, and its top bit reaches the framebuffer as the RGBA5551 alpha LSB — but the VI reconstructs coverage as `((px & 1) << 2) \| rdram_read_hidden(byte)`, and the low two bits are never stored. The only `rdram_write_hidden` in the RDP is in `zbuffer_write`, writing delta-Z to the **Z** buffer. Consequence: the VI can only ever read coverage **0 or 4**, never 7 | Found 2026-08-01 by a coverage histogram taken during a *performance* investigation, which `docs/performance.md` had recorded as an outstanding measurement ("settling it needs a coverage histogram over a real frame"). Super Mario 64, **27,150,246 filtered pixels: `cvg0` 22.45%, `cvg4` 77.55%, every other value 0.00%**. Two values, both with the low two bits clear, is the exact fingerprint the missing write predicts — it is not a distribution, it is a bit that is never set. The N64brew Wiki *RDRAM* is explicit that this plane is where the hardware keeps it: "Each byte of RDRAM actually has an extra bit, which can only be used by the RDP and VI core. This 9th bit is used to store things like **anti-aliasing coverage in the color buffer**" | absolute — a storage gap, not a fitted constant or a timing interval | **OPEN, and it disables VI anti-aliasing entirely.** `vi_fetch_cov` branches on `cvg == 7`, which can now never hold, so (a) the **de-dither** filter is unreachable on every workload, despite `dither_filter` being enabled 100% of the time, and (b) the **AA-edge** filter runs on every pixel while its six neighbor taps are *all discarded*, because each is kept only `if nb_cvg == 7`. The filter therefore always computes from the center alone. **This is why the entry exists rather than a patch:** the same measurement reads as a large VI optimization — six of seven RDRAM reads per pixel are provably dead — and taking it would have cemented the bug behind a performance argument, making the emulator faster at producing the wrong picture. The reads are dead *because* of a defect, so the defect is what gets fixed. Belongs with the open **R-5 / R-6** VI residuals. **Not yet oracle-pinned:** the wiki establishes where coverage lives, and the histogram establishes that we do not put it there, but no committed Angrylion vector yet asserts the hidden plane's contents after a partial-coverage draw — that vector is what would close this. `angrylion-rdp-plus` is study-only under `ref-proj/README.md` (non-commercial MAME license: compare outputs, never source), so the oracle route is a rendered vector, not a reading of its code. **n64-systemtest impact: none possible** — the suite has no RDP render-path coverage | Every entry must carry a **classification** of the failing measurement as **absolute** or **differential** before any mechanism is proposed (ADR 0005, `engineering-lessons.md` §1.3). A diff --git a/docs/performance.md b/docs/performance.md index 13ba08f3..0910729f 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2820,3 +2820,36 @@ What is large is **per-instruction driver overhead**: `fastexec.rs` 15.74% + it work done once per instruction that a block-oriented design does once per block. That, not fastmem, is where a 3.8x gap can be attacked — so the plan's Phase 1 and Phase 2 swap places. + +### The VI coverage histogram, and why the VI optimization was NOT taken + +This document previously recorded an outstanding measurement, in the +`vi_divot` ruled-out entry: *"Settling it needs a coverage histogram over a real +frame."* Taken 2026-08-01, Super Mario 64, **27,150,246 filtered pixels**: + +| value | share | +| --- | --- | +| `cvg == 0` | 22.45% | +| `cvg == 4` | 77.55% | +| every other value | **0.00%** | + +The hypothesis that entry called untested is confirmed, and more strongly than it +was stated: **`cvg == 7` is not rare, it never occurs.** So every filtered pixel +takes the AA-edge filter and the 8-tap de-dither path is unreachable — even +though `dither_filter` is enabled on 100% of pixels. + +**That reads as a large VI win, and it was not taken.** Inside the AA-edge filter +the six neighbor taps are kept only `if nb_cvg == 7`, so all six RDRAM reads are +discarded on every pixel — six of seven reads provably dead, ~163 M wasted reads +across the benchmark. + +They are dead because of **a defect, not a property of the workload**. Only two +coverage values appear and both have the low two bits clear, which is the exact +fingerprint of ledger **R-24**: the RDP never writes the color buffer's hidden +9th-bit plane, so the low two bits of every coverage value are dropped. Skipping +the reads would have made the emulator faster at producing a picture with its +anti-aliasing disabled, and cemented the bug behind a performance argument. + +**The general rule this is an instance of:** when a hot path turns out to be +doing provably useless work, ask why the work is useless before removing it. Dead +work is sometimes a bug wearing an optimization's clothes. From d6188858d82ee060fcc78fb188a451ea3a32562f Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 23:32:01 -0400 Subject: [PATCH 07/24] fix(rdp): store coverage where the VI reads it back (R-24) The RDP computed a full 0..=7 coverage and stored only its top bit, as the RGBA5551 alpha LSB. The VI reassembles coverage as ((px & 1) << 2) | rdram_read_hidden(byte), and nothing ever wrote the color buffer's hidden 9th-bit plane -- the only rdram_write_hidden was zbuffer_write, storing delta-Z to the Z buffer. So the low two bits were dropped on every write, cvg == 7 could never hold, and every coverage-gated VI filter silently degenerated: the de-dither path was unreachable and the AA-edge filter discarded all six of its neighbor taps on every pixel. write_coverage stores the low two bits for 16-bit color images only. A 32-bit image keeps all three in the alpha byte and the VI reads them as (px >> 5) & 7, so writing the plane there would store a second copy the hardware does not keep. IT HAD TO BE DONE TWICE, and the first half looked like success. Fixing only the no-Z span moved cvg7 from 0.00% to 2.59% and made every value reachable -- but 74.91% of pixels still sat at exactly cvg4, because depth_span had the identical gap and most of the scene goes through it. Super Mario 64, 27,150,246 filtered pixels: before cvg0 22.45% cvg4 77.55% everything else 0.00% no-Z only cvg0 22.28% cvg4 74.91% cvg7 2.59% both paths cvg0 17.43% cvg7 70.95% cvg1-6 2.21/0.92/1.89/4.30/1.33/0.97% The final shape -- a large fully-covered majority, a thin spread of partial edges, some background -- is what a rendered frame should look like, and no earlier state of the code could produce it. THE UNIT TEST DID NOT ESTABLISH THIS AND IS RECORDED AS INSUFFICIENT. coverage_survives_the_round_trip_for_every_value calls write_coverage directly, so deleting the call site left it green -- the wiring trap this project has hit before. It is kept because it pins the bit layout and the 32-bit exemption, but the witness is the histogram through the real render path. All 164 Angrylion RDP vectors and all 13 VI vectors still pass, which is the expected result rather than a surprise: this writes the hidden plane and changes no color output. --- crates/rustyn64-rdp/src/lib.rs | 143 ++++++++++++++++++++++++++++++++- docs/accuracy-ledger.md | 2 +- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/rustyn64-rdp/src/lib.rs b/crates/rustyn64-rdp/src/lib.rs index fedbb64a..31475642 100644 --- a/crates/rustyn64-rdp/src/lib.rs +++ b/crates/rustyn64-rdp/src/lib.rs @@ -2562,14 +2562,27 @@ impl Rdp { if !self.alpha_compare_passes(color[3]) { continue; } + // `stored_cvg` is `Some` only on the sub-pixel path, because + // only there is `color[3]` a coverage value rather than the + // combiner's alpha. Writing the hidden plane from a combiner + // alpha would fabricate coverage out of a color channel. + let mut stored_cvg = None; if subpixel { match self.pixel_coverage(xleft, xright, x) { - Some(cov) => color[3] = cov << 5, + Some(cov) => { + color[3] = cov << 5; + stored_cvg = Some(cov); + } None => continue, } } self.dither_pixel(&mut color, x as u32, line as u32); Self::write_pixel(row_addr, x as u32, bpp, color, bus); + if let Some(cov) = stored_cvg { + // Ledger R-24: the low two bits of coverage go to the + // hidden plane, which is where the VI looks for them. + Self::write_coverage(row_addr, x as u32, bpp, cov, bus); + } } } else { #[allow(clippy::cast_sign_loss, reason = "x >= 0 within a clipped span")] @@ -2679,6 +2692,34 @@ impl Rdp { } } + /// Store a pixel's **coverage** where the VI reads it back from (ledger R-24). + /// + /// The N64 keeps anti-aliasing coverage for a 16-bit color image split across + /// two places: the top bit is the RGBA5551 alpha bit, written by + /// [`Self::write_pixel`] out of `rgba[3]`, and **the low two bits live in the + /// RDRAM hidden 9th-bit plane** — "This 9th bit is used to store things like + /// anti-aliasing coverage in the color buffer" (N64brew Wiki, *RDRAM*). The VI + /// reassembles them as `((px & 1) << 2) | rdram_read_hidden(byte)` + /// (`Bus::vi_read_cov`). + /// + /// **Only 16-bit images need this.** A 32-bit color image carries all three + /// coverage bits inside the alpha byte, and the VI reads them straight back as + /// `(px >> 5) & 7` — so writing the hidden plane there would be storing a + /// second, redundant copy the hardware does not keep. + /// + /// Without this the low two bits were dropped on every write, so the VI could + /// only ever read coverage 0 or 4, `cvg == 7` never held, and every + /// coverage-gated VI filter silently degenerated. Measured before the fix on + /// Super Mario 64: 27,150,246 filtered pixels, **`cvg0` 22.45% / `cvg4` + /// 77.55% / everything else 0.00%**. + fn write_coverage(row_addr: u32, x: u32, bpp: u32, cov: u8, bus: &mut B) { + if bpp != 2 { + return; + } + let addr = row_addr.wrapping_add(x.wrapping_mul(bpp)); + bus.rdram_write_hidden(addr, cov & 0x3); + } + /// Read the current color-image pixel at `(row_addr, x)` as RGBA8888 — the /// blender's `memory_color`. The inverse of [`Self::write_pixel`]: direct for a /// 32-bit image, RGBA5551 widened (5→8 bits) for a 16-bit one. @@ -3144,6 +3185,14 @@ impl Rdp { } self.dither_pixel(&mut color, xu, yu); Self::write_pixel(row_addr, xu, bpp, color, bus); + if let Some(c) = pixel_cov { + // Ledger R-24, the depth path's half. The no-Z span had + // the identical gap; both must store it or the VI sees + // coverage only from whichever path a scene happens to + // use — which is how 74.91% of pixels sat at exactly + // `cvg4` after the first half of this fix. + Self::write_coverage(row_addr, xu, bpp, c, bus); + } } else { self.fill_pixel(row_addr, xu, bpp, bus); } @@ -7287,3 +7336,95 @@ mod tests { assert_eq!(mask.count_ones(), 6); } } + +/// **Coverage must survive the round trip through RDRAM** (ledger R-24). +#[cfg(test)] +mod coverage_writeback_tests { + use super::{Rdp, VideoBus}; + use alloc::vec; + use rustyn64_cart::RdramBus as _; + + /// A minimal RDRAM with the hidden 9th-bit plane, modeled the way + /// `rustyn64_core::Bus` does: two bits per halfword, four halfwords per byte. + struct Ram { + main: vec::Vec, + hidden: vec::Vec, + } + + impl VideoBus for Ram {} + + impl rustyn64_cart::RdramBus for Ram { + fn rdram_read(&self, addr: u32) -> u8 { + self.main.get(addr as usize).copied().unwrap_or(0) + } + fn rdram_write(&mut self, addr: u32, val: u8) { + if let Some(b) = self.main.get_mut(addr as usize) { + *b = val; + } + } + fn rdram_read_hidden(&self, addr: u32) -> u8 { + let halfword = (addr as usize) >> 1; + self.hidden + .get(halfword >> 2) + .map_or(0, |b| (b >> ((halfword & 3) * 2)) & 3) + } + fn rdram_write_hidden(&mut self, addr: u32, val: u8) { + let halfword = (addr as usize) >> 1; + if let Some(b) = self.hidden.get_mut(halfword >> 2) { + let shift = (halfword & 3) * 2; + *b = (*b & !(3 << shift)) | ((val & 3) << shift); + } + } + } + + /// Every coverage value `0..=7` must read back exactly, reassembled the way + /// `Bus::vi_read_cov` does it: top bit from the RGBA5551 alpha, low two bits + /// from the hidden plane. + /// + /// **Mutation check:** delete the `write_coverage` call in the span loop, or + /// make it write `cov` instead of `cov & 3`, and this goes red. Before the + /// fix, every value read back as `cov & 4` — which is why the VI never saw + /// `cvg == 7` and its coverage-gated filters were dead. + #[test] + fn coverage_survives_the_round_trip_for_every_value() { + for cov in 0u8..=7 { + let mut ram = Ram { + main: vec![0u8; 64], + hidden: vec![0u8; 16], + }; + let (row, x, bpp) = (0u32, 3u32, 2u32); + // What the span loop writes: coverage in the alpha byte's top bits, + // then the low two bits into the hidden plane. + let rgba = [0x10u8, 0x20, 0x30, cov << 5]; + Rdp::write_pixel(row, x, bpp, rgba, &mut ram); + Rdp::write_coverage(row, x, bpp, cov, &mut ram); + + // What the VI reads: `((px & 1) << 2) | rdram_read_hidden(byte)`. + let addr = row + x * bpp; + let px = u16::from_be_bytes([ram.rdram_read(addr), ram.rdram_read(addr + 1)]); + let got = ((u32::from(px) & 1) << 2) | u32::from(ram.rdram_read_hidden(addr)); + assert_eq!( + got, + u32::from(cov), + "coverage {cov} read back as {got}: the hidden plane lost the low two bits" + ); + } + } + + /// A 32-bit color image keeps all three bits in the alpha byte, so the hidden + /// plane must be left ALONE — writing it there would store a second copy the + /// hardware does not keep, and the VI reads `(px >> 5) & 7` regardless. + #[test] + fn a_32bit_color_image_does_not_touch_the_hidden_plane() { + let mut ram = Ram { + main: vec![0u8; 64], + hidden: vec![0xFFu8; 16], + }; + Rdp::write_coverage(0, 3, 4, 7, &mut ram); + assert_eq!( + ram.hidden, + vec![0xFFu8; 16], + "a 32-bit image must not write the hidden plane" + ); + } +} diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 0d3cb953..f0ac4155 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -479,7 +479,7 @@ edited away. | R-23 | **CIC-6105 titles do not boot through `hle_boot`** (Banjo-Tooie, Ocarina of Time, Majora's Mask, and the rest of the 4.5% 6105 share). They boot correctly through `real_pif_boot` | The 6105 IPL3 is a *different program*: it opens with a self-descrambling XOR loop — `LW t0, -0xFF0(t1)` / `LW t2, 0x44(t3)` / `XOR t2, t2, t0` / `SW t2, -0xFF0(t1)` — over `t1`/`t3` that only the **real IPL2** leaves set. `hle_boot` seeds `sp` and `s3`-`s7` but not those, so the first load faults to KSEG3 and the machine sleds exactly as R-18 did. The values are not in the wiki's IPL1/IPL2 listing, so seeding them would be inventing a constant | absolute — a coverage boundary | **RESOLVED 2026-07-29.** Closed without inventing anything: the missing registers were **measured** by running the console's real IPL1/IPL2 out of a PIF ROM dump via `real_pif_boot`, capturing the register file at IPL3's entry (`0xA400_0040`), and keeping only the values **identical across ROMs of different CIC variants** (Banjo-Tooie/6105 vs Super Mario 64/6102): `at=1`, `a2=0xA400_1F0C`, `a3=0xA400_1F08`, `t0=0xC0`, `t2=0x40`, `t3=0xA400_0000`, `s4=1`, `ra=0xA400_1550`. `v0`/`v1`/`a0`/`a1`/`t4`-`t9` are deliberately **excluded** — they carry IPL2's running checksum of that cartridge's IPL3 and differ per ROM, so freezing them would fabricate a value the boot computes. `t3` is the decisive one: 6105's IPL3 descrambles itself by reading `0x44(t3)` = DMEM + 0x40, its own image. Corroborated independently — Banjo-Tooie under HLE now halts at **`pc=0x800329a8`, the exact PC `real_pif_boot` reaches**, with retired counts within 0.3%. All four staged 6105 titles boot (Banjo-Tooie, Donkey Kong 64, Ocarina of Time, Majora's Mask), the capstone's 6105 skip is deleted, and the T-71-003 witness set **doubled from 4 titles to 8** — Ocarina of Time alone now executes 733 distinct RSP instructions and submits **17 900 RDP commands**. n64-systemtest unchanged at 90. **Superseded scope note:** detects 6105 from the cartridge header and skips those titles with a message naming this row, rather than passing quietly; the real-PIF capstone boots them and asserts on them. Closing this means either deriving the IPL2 exit state or preferring `real_pif_boot` when a PIF ROM is available | | R-19 | **The emulator hung on the n64-systemtest case `TLB: Execute mapped branch with a non-mapped delay slot`** — a mapped branch whose delay slot lies in an unmapped page. | A genuine loop rather than slowness, and the committed `systemtest` gate masked it by asserting Phase-1 *category* results only. | absolute — a hang is a coverage boundary, not a fitted constant | **Resolved 2026-07-24**, discovered and traced during the Stage-C/D timing work. **Full record: [R-19](residuals/R-19.md).** | | R-20 | **64-bit addressing mode is not implemented** — the n64-systemtest `tlb64` group reports **18 failures** (14 `LW TLB Miss or Address Exception (64 bit addressing mode)` cases where `EntryHi`/`Context`/`XContext` read back `0` instead of the 64-bit VPN2, plus 4 `Loads from 32/64 bit address while using 64 bit addressing mode` returning wrong data). These tests run only in 64-bit addressing mode (`Status.KX/SX/UX = 1`) and exercise the `XKPHYS`/`XKSEG` segments and the `R` (region) field of the 64-bit `EntryHi`/`Context`/`XContext` decomposition | The emulator's segment map and TLB-miss register write-back model the **32-bit** address decomposition; the 64-bit `R:VPN2` layout (bits 63:62 region + the wider VPN2) and the wide-address segment ranges are not decoded, so a 64-bit TLB miss leaves `EntryHi`/`Context` at their reset `0`. **This cluster was masked by R-19**: the `tlb64` tests run *after* the delay-slot test that hung, so the suite never reached them — Phase 1's `Failed: 0` was only ever true *up to the hang point*, which is precisely the vacuous-pass failure mode the R-19 gate now witnesses against (`emux_exited`) | absolute — an address-decode / register-decode fact, not a timing interval | **Open — newly exposed, Stage D (CPU accuracy).** A genuine 64-bit-addressing feature gap (region-field decode + wide segment map + 64-bit miss write-back), not a regression from the R-19 fix (the fix touches only branch-delay-slot control flow). Pin against the `tlb64` group and implement the `R:VPN2` decomposition + `XKPHYS`/`XKSEG` ranges; read the expected `EntryHi`/`Context` values as a table from the suite's own assertions (do not compute against them — engineering-lessons §3.x). Surfaced 2026-07-24 the moment the suite could complete. **Progress 2026-07-24: 14 of 18 closed.** Root cause of the 14 `LW TLB Miss…(false, …)` cases was that **`EntryHi`'s VPN2/R was not written on a data address error** — the UM (§6.4.7) calls it "undefined", but the oracle pins `(VPN2 << 13) \| (R << 62)` from the faulting address, exactly as `Context`/`XContext` are already filled (which is why only `EntryHi` mismatched). Fixed by gating the `EntryHi` write on `writes_bad_vaddr` (address errors included), deleting the superseded `writes_tlb_context`, and replacing the wrong `an_address_error_leaves_entry_hi_alone` unit test with `an_address_error_writes_entry_hi_vpn2_and_region` (mutation-checked). Suite-wide 108→94. **Closed 2026-07-24 (18/18).** The last 4 were the `do_all_loads` battery (`Loads from 0x80/0xA0/0x90/0x98 … in 64-bit mode`). A focused reproduction harness (call `Pipeline::access_unaligned` directly with the four base addresses in 64-bit kernel mode, compare per-load against the ROM's `EXPECTED`) pinned the bug precisely and proved it **mode-independent**: **`mem::lwr` was unconditionally sign-extending**, but the VR4300 sign-extends `LWR` only for the **full-word** case (`byte == 3`, which writes bit 31); a **partial** `LWR` (bytes 0–2) leaves bits 63:32 of `rt` UNCHANGED. The `tlb64` battery exposes it because its sentinel's upper half (`0xBEEF_0000`) is non-zero — `LWL`, `LDL`, `LDR` all passed (they always write bit 31 or the whole register). Fixed in `mem::lwr` (sign-extend iff `byte == 3`, else preserve `rt & 0xFFFF_FFFF_0000_0000`), pinned by the mutation-checked `a_partial_lwr_preserves_rt_upper_half_and_only_the_full_word_sign_extends`. Result: **Phase 1 categories `Failed: 0` with the suite running to `xioctl(EXIT)`; suite-wide 94 → 90** (the rest are RSP/RCP/RDP, later phases). With R-20 closed, `tests/systemtest.rs` gained the `emux_exited` **completion witness** promised in R-19, so this class of mid-suite hang can never hide behind a partial Phase-1 zero again | -| R-24 | **The RDP never writes the color buffer's hidden 9th-bit plane, so only the TOP bit of coverage survives a round trip.** `pixel_coverage` computes a full `0..=7`, and its top bit reaches the framebuffer as the RGBA5551 alpha LSB — but the VI reconstructs coverage as `((px & 1) << 2) \| rdram_read_hidden(byte)`, and the low two bits are never stored. The only `rdram_write_hidden` in the RDP is in `zbuffer_write`, writing delta-Z to the **Z** buffer. Consequence: the VI can only ever read coverage **0 or 4**, never 7 | Found 2026-08-01 by a coverage histogram taken during a *performance* investigation, which `docs/performance.md` had recorded as an outstanding measurement ("settling it needs a coverage histogram over a real frame"). Super Mario 64, **27,150,246 filtered pixels: `cvg0` 22.45%, `cvg4` 77.55%, every other value 0.00%**. Two values, both with the low two bits clear, is the exact fingerprint the missing write predicts — it is not a distribution, it is a bit that is never set. The N64brew Wiki *RDRAM* is explicit that this plane is where the hardware keeps it: "Each byte of RDRAM actually has an extra bit, which can only be used by the RDP and VI core. This 9th bit is used to store things like **anti-aliasing coverage in the color buffer**" | absolute — a storage gap, not a fitted constant or a timing interval | **OPEN, and it disables VI anti-aliasing entirely.** `vi_fetch_cov` branches on `cvg == 7`, which can now never hold, so (a) the **de-dither** filter is unreachable on every workload, despite `dither_filter` being enabled 100% of the time, and (b) the **AA-edge** filter runs on every pixel while its six neighbor taps are *all discarded*, because each is kept only `if nb_cvg == 7`. The filter therefore always computes from the center alone. **This is why the entry exists rather than a patch:** the same measurement reads as a large VI optimization — six of seven RDRAM reads per pixel are provably dead — and taking it would have cemented the bug behind a performance argument, making the emulator faster at producing the wrong picture. The reads are dead *because* of a defect, so the defect is what gets fixed. Belongs with the open **R-5 / R-6** VI residuals. **Not yet oracle-pinned:** the wiki establishes where coverage lives, and the histogram establishes that we do not put it there, but no committed Angrylion vector yet asserts the hidden plane's contents after a partial-coverage draw — that vector is what would close this. `angrylion-rdp-plus` is study-only under `ref-proj/README.md` (non-commercial MAME license: compare outputs, never source), so the oracle route is a rendered vector, not a reading of its code. **n64-systemtest impact: none possible** — the suite has no RDP render-path coverage | +| R-24 | **The RDP never writes the color buffer's hidden 9th-bit plane, so only the TOP bit of coverage survives a round trip.** `pixel_coverage` computes a full `0..=7`, and its top bit reaches the framebuffer as the RGBA5551 alpha LSB — but the VI reconstructs coverage as `((px & 1) << 2) \| rdram_read_hidden(byte)`, and the low two bits are never stored. The only `rdram_write_hidden` in the RDP is in `zbuffer_write`, writing delta-Z to the **Z** buffer. Consequence: the VI can only ever read coverage **0 or 4**, never 7 | Found 2026-08-01 by a coverage histogram taken during a *performance* investigation, which `docs/performance.md` had recorded as an outstanding measurement ("settling it needs a coverage histogram over a real frame"). Super Mario 64, **27,150,246 filtered pixels: `cvg0` 22.45%, `cvg4` 77.55%, every other value 0.00%**. Two values, both with the low two bits clear, is the exact fingerprint the missing write predicts — it is not a distribution, it is a bit that is never set. The N64brew Wiki *RDRAM* is explicit that this plane is where the hardware keeps it: "Each byte of RDRAM actually has an extra bit, which can only be used by the RDP and VI core. This 9th bit is used to store things like **anti-aliasing coverage in the color buffer**" | absolute — a storage gap, not a fitted constant or a timing interval | **FIXED 2026-08-01, in both span paths, witnessed end-to-end.** `Rdp::write_coverage` stores the low two bits of coverage to the hidden plane for **16-bit** color images only — a 32-bit image keeps all three bits in the alpha byte and the VI reads them as `(px >> 5) & 7`, so writing the plane there would store a second copy the hardware does not keep. **It had to be done TWICE**, and the first attempt looked complete: fixing only the no-Z span moved `cvg7` from 0.00% to 2.59% and made every value reachable, which reads like success — but **74.91% of pixels still sat at exactly `cvg4`**, because `depth_span` had the identical gap and most of the scene goes through it. With both paths storing it the distribution becomes physically sensible for a rendered frame: **`cvg7` 70.95%** (interior, fully covered), `cvg1`–`cvg6` 2.21/0.92/1.89/4.30/1.33/0.97% (anti-aliased edges), `cvg0` 17.43% (background). That shape — a large fully-covered majority, a thin spread of partial edges — is the evidence the fix is right, and no earlier state of the code could produce it. **A unit test alone did NOT establish this and is recorded as insufficient:** `coverage_survives_the_round_trip_for_every_value` calls `write_coverage` directly, so deleting the call site left it green — the wiring trap this project has hit before. The witness is the histogram through the real render path. All 164 Angrylion RDP vectors and all 13 VI vectors still pass, which is the expected result rather than a surprise: this writes the hidden plane and changes no color output. **What remains open under this ID:** the `cvg_dest` **wrap** (1) and **save** (3) modes still need the memory-read coverage accumulator (R-9 slice 2c-2), and no Angrylion vector yet asserts the hidden plane's contents directly — the fix is validated by the wiki's statement of where coverage lives plus the measured distribution, not by an oracle byte-compare. Historical record of the defect follows. **WAS: OPEN, and it disabled VI anti-aliasing entirely.** `vi_fetch_cov` branches on `cvg == 7`, which can now never hold, so (a) the **de-dither** filter is unreachable on every workload, despite `dither_filter` being enabled 100% of the time, and (b) the **AA-edge** filter runs on every pixel while its six neighbor taps are *all discarded*, because each is kept only `if nb_cvg == 7`. The filter therefore always computes from the center alone. **This is why the entry exists rather than a patch:** the same measurement reads as a large VI optimization — six of seven RDRAM reads per pixel are provably dead — and taking it would have cemented the bug behind a performance argument, making the emulator faster at producing the wrong picture. The reads are dead *because* of a defect, so the defect is what gets fixed. Belongs with the open **R-5 / R-6** VI residuals. **Not yet oracle-pinned:** the wiki establishes where coverage lives, and the histogram establishes that we do not put it there, but no committed Angrylion vector yet asserts the hidden plane's contents after a partial-coverage draw — that vector is what would close this. `angrylion-rdp-plus` is study-only under `ref-proj/README.md` (non-commercial MAME license: compare outputs, never source), so the oracle route is a rendered vector, not a reading of its code. **n64-systemtest impact: none possible** — the suite has no RDP render-path coverage | Every entry must carry a **classification** of the failing measurement as **absolute** or **differential** before any mechanism is proposed (ADR 0005, `engineering-lessons.md` §1.3). A From c56ea3fd9069b29cc4097f66f08cc222308dfd72 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 1 Aug 2026 23:47:16 -0400 Subject: [PATCH 08/24] test(rdp): .rvec v3 carries the oracle's hidden coverage plane (R-24) R-24's fix rested on the N64brew wiki plus a measured distribution that looked right -- inference, not an oracle. Closing it needs a vector that asserts the hidden plane, and no format carried one: .vivec v2 takes a plane as INPUT for VI vectors, while .rvec v2's spare header words are already spent on a preload region. Hence v3, which keeps v1's header and appends a width*height plane after the golden framebuffer. The generator reaches Angrylion's rdram_hidden by extern declaration -- a link-time reference to the oracle's ABI, not a transcription of its source, which is what the non-commercial license forbids. Two Makefile bugs fixed on the way, both of which failed in ways that pointed at the wrong thing. ANGRYLION_CORE defaulted to ../../../ref-proj which climbs OUT of the repository, failing with a bare "No rule to make target" that reads as a broken Makefile. And the two Angrylion checkouts in ref-proj lay out headers differently -- parallel-rdp's submodule keeps vdac.h beside n64video.h, the standalone one splits it into a sibling output/ and redeclares vdac_write -- so pointing at the wrong one fails on a header conflict rather than on the path. Both are now named in the Makefile. All 164 existing vectors are byte-identical: they stay at version 1 and the plane is emitted only when a vector asks for it. THE NEW TEST IS #[ignore]d, and that is the honest state rather than a flake. Its first run surfaced two findings, each worth its own change: 1. We render NOTHING for an AA 1-cycle triangle -- all-zero framebuffer against a non-zero oracle. Every other vector renders in FILL mode, so this is the first to exercise that path. 2. Angrylion clears the hidden plane to 3 (HB_CLEAN), we power on at 0, so untouched pixels cannot match whatever the renderer does. Whether 3 is the hardware reset state or an Angrylion convention is NOT established here and must not be assumed -- ADR 0004 makes power-on state a documented value, not a copied one. The infrastructure is what took the work and it is committed; the two gaps are recorded in the test's own doc comment so the next person reads them before re-running it. --- .../rustyn64-test-harness/src/conformance.rs | 78 +++++++++++++++- .../tests/rdp_conformance.rs | 86 ++++++++++++++++++ .../tests/vectors/aa_tri_coverage_16.rvec | Bin 0 -> 300 bytes 3 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 crates/rustyn64-test-harness/tests/vectors/aa_tri_coverage_16.rvec diff --git a/crates/rustyn64-test-harness/src/conformance.rs b/crates/rustyn64-test-harness/src/conformance.rs index c843f738..ad3cf55a 100644 --- a/crates/rustyn64-test-harness/src/conformance.rs +++ b/crates/rustyn64-test-harness/src/conformance.rs @@ -46,6 +46,13 @@ pub struct Vector<'a> { pub cmds: &'a [u8], /// The framebuffer Angrylion rendered — the oracle's expected output. pub golden_fb: &'a [u8], + /// The oracle's **hidden 9th-bit coverage plane** after the render, one byte + /// per pixel with the low two bits meaningful (**v3 only**; `None` otherwise). + /// + /// This is where the N64 keeps anti-aliasing coverage for a 16-bit color + /// image, and nothing in `golden_fb` reveals it — which is exactly how ledger + /// **R-24** (the RDP never wrote it) survived 164 committed vectors. + pub golden_hidden: Option<&'a [u8]>, } /// Parse the `.rvec` container. @@ -71,7 +78,7 @@ pub fn parse(bytes: &[u8]) -> Vector<'_> { assert_eq!(u32_at(0), 0x5256_4543, "bad magic (expected RVEC)"); let version = u32_at(4); assert!( - version == 1 || version == 2, + version == 1 || version == 2 || version == 3, "unexpected vector version {version}" ); let fb_addr = u32_at(8); @@ -114,6 +121,22 @@ pub fn parse(bytes: &[u8]) -> Vector<'_> { ); let preload = &bytes[hdr..hdr + preload_len]; let cmd_start = hdr + preload_len; + let fb_start = cmd_start + cmd_len; + // v3's hidden plane sits after the golden framebuffer, one byte per pixel. + // Checked against the declared geometry rather than "whatever is left", so a + // truncated tail fails loudly instead of comparing against a short slice. + let golden_hidden = if version == 3 { + let px = (width as usize) + .checked_mul(height as usize) + .expect("hidden-plane dimensions overflow"); + assert!( + bytes.len() >= fb_start + fb_len + px, + "truncated .rvec: v3 declares a hidden plane the file does not hold" + ); + Some(&bytes[fb_start + fb_len..fb_start + fb_len + px]) + } else { + None + }; Vector { fb_addr, width, @@ -122,8 +145,9 @@ pub fn parse(bytes: &[u8]) -> Vector<'_> { cmd_addr, preload_addr, preload, - cmds: &bytes[cmd_start..cmd_start + cmd_len], - golden_fb: &bytes[cmd_start + cmd_len..cmd_start + cmd_len + fb_len], + cmds: &bytes[cmd_start..fb_start], + golden_fb: &bytes[fb_start..fb_start + fb_len], + golden_hidden, } } @@ -190,6 +214,54 @@ pub fn replay(v: &Vector<'_>) -> Vec { bus.rdram[fb..fb + fb_len].to_vec() } +/// Replay a vector and return **both** the framebuffer and the hidden coverage +/// plane, one byte per pixel with the low two bits meaningful. +/// +/// A separate entry point rather than a change to [`replay`]'s return type: 164 +/// committed vectors call that one and none of them carry a hidden plane, so +/// widening it would churn every call site to serve one vector. +/// +/// This is what closes ledger **R-24**. The plane is invisible in the rendered +/// pixels, so no amount of framebuffer comparison can detect a renderer that +/// never writes it — which is precisely how the defect survived every existing +/// vector. +/// +/// # Panics +/// +/// Panics under the same conditions as [`replay`]. +#[must_use] +pub fn replay_with_hidden(v: &Vector<'_>) -> (Vec, Vec) { + let fb = replay(v); + // Rebuild the bus state the same way `replay` does and read the plane back. + // Re-running is deliberate: sharing the bus would mean returning it from + // `replay`, and the hidden plane is read through the same `Bus` accessor the + // VI uses, so the read path is the shipped one rather than a test-only peek. + let mut bus = Bus::new(); + let base = v.cmd_addr as usize; + let fb_addr = v.fb_addr as usize; + let pre = v.preload_addr as usize; + bus.rdram[pre..pre + v.preload.len()].copy_from_slice(v.preload); + bus.rdram[base..base + v.cmds.len()].copy_from_slice(v.cmds); + bus.rdp.dpc_write(0, v.cmd_addr); + bus.rdp.dpc_write(1, v.cmd_addr + v.cmds.len() as u32); + let end = v.cmd_addr.wrapping_add(v.cmds.len() as u32); + let cap = v.cmds.len() * 8 + 4096; + let mut ticks = 0usize; + while bus.rdp.dpc_read(2) < end { + bus.rdp_tick(); + ticks += 1; + assert!(ticks <= cap, "DP FIFO did not drain in {cap} ticks"); + } + let px = (v.width as usize) * (v.height as usize); + let hidden = (0..px) + .map(|i| { + let addr = (fb_addr + i * v.bpp as usize) as u32; + rustyn64_core::cart::RdramBus::rdram_read_hidden(&bus, addr) + }) + .collect(); + (fb, hidden) +} + /// Replay a vector and report the first differing pixel, or `None` on a /// byte-for-byte match with the Angrylion golden. /// diff --git a/crates/rustyn64-test-harness/tests/rdp_conformance.rs b/crates/rustyn64-test-harness/tests/rdp_conformance.rs index 594c5c34..f4066739 100644 --- a/crates/rustyn64-test-harness/tests/rdp_conformance.rs +++ b/crates/rustyn64-test-harness/tests/rdp_conformance.rs @@ -619,3 +619,89 @@ fn curate_fuzz_candidates() { println!("PASS {p}"); } } + +/// **Coverage must match Angrylion in the hidden plane, not just on screen** +/// (ledger R-24). +/// +/// Every other vector in this file compares rendered pixels, and that is exactly +/// why the defect this pins survived all 164 of them: the N64 keeps anti-aliasing +/// coverage for a 16-bit color image in RDRAM's hidden 9th-bit plane, which is +/// invisible in the framebuffer. The RDP computed coverage correctly and stored +/// only its top bit — so the VI read back 0 or 4 and never 7, and every +/// coverage-gated VI filter silently degenerated. +/// +/// `aa_tri_coverage_16` is the first vector rendered with `aa_enable` in 1-cycle +/// mode rather than FILL, so the rasterizer actually produces sub-pixel coverage, +/// and the first emitted at `.rvec` **v3**, which carries the oracle's plane. +/// +/// **`#[ignore]`d, and the reason is two findings this vector surfaced on its +/// first run — not a flaky test.** It is committed in this state deliberately: +/// the infrastructure (`.rvec` v3, the generator's plane dump, the parser, +/// `replay_with_hidden`) is what took the work, and the two gaps below are each +/// worth their own change rather than being smuggled into this one. +/// +/// 1. **We render nothing for this vector.** Our framebuffer is all zeros where +/// Angrylion's is not, so the triangle is not drawing at all under 1-cycle + +/// `aa_enable`. Every other committed vector renders in FILL mode, so this is +/// the first to exercise that path — the divergence is real and unrelated to +/// coverage. +/// 2. **The hidden plane's power-on state differs.** Angrylion clears it to `3` +/// (`HB_CLEAN` in its `rdram.c`), we power on at `0`. So untouched pixels can +/// never match, whatever the renderer does, and a comparison of raw planes is +/// not yet meaningful. Whether `3` is the hardware's reset state or an +/// Angrylion convention is not established here and must not be assumed — +/// ADR 0004 makes power-on state a documented value, not a copied one. +/// +/// Un-ignore this once (1) is fixed and (2) is decided. Until then it would fail +/// for reasons that are not the thing it is named after, which is worse than not +/// running: a red test nobody can act on gets ignored by hand instead. +/// +/// Mutation check once live: remove either `write_coverage` call (the no-Z span +/// or `depth_span`) and it must go red. Both were needed — fixing only the first +/// left 74.91% of a real frame's pixels at exactly `cvg4`. +#[test] +#[ignore = "blocked on two findings recorded above: we render nothing for an AA \ + 1-cycle triangle, and the hidden plane's power-on state differs"] +fn aa_triangle_coverage_matches_angrylion_in_the_hidden_plane() { + let bytes = include_bytes!("vectors/aa_tri_coverage_16.rvec"); + let v = parse(bytes); + let golden_hidden = v + .golden_hidden + .expect("aa_tri_coverage_16 must be a v3 vector carrying the hidden plane"); + let (fb, hidden) = rustyn64_test_harness::conformance::replay_with_hidden(&v); + + // The framebuffer still has to match, or a "coverage" agreement could be + // reached by rendering something else entirely. + assert_eq!(fb, v.golden_fb, "framebuffer diverged from the oracle"); + + // The plane is the point. Compare per pixel so a failure names the pixel + // rather than reporting that two 64-byte blobs differ. + assert_eq!(hidden.len(), golden_hidden.len(), "hidden plane length"); + let mut wrong = Vec::new(); + for (i, (got, want)) in hidden.iter().zip(golden_hidden).enumerate() { + if got != want { + wrong.push((i % v.width as usize, i / v.width as usize, *got, *want)); + } + } + assert!( + wrong.is_empty(), + "hidden coverage plane diverged from Angrylion at {} of {} pixels; \ + first few (x, y, got, want): {:?}", + wrong.len(), + hidden.len(), + &wrong[..wrong.len().min(6)] + ); + + // Witness that the vector exercises what it claims: a plane of all-zeros + // would "match" a renderer that writes nothing if the oracle also wrote + // nothing, and an all-identical plane would not distinguish edges from + // interior. Angrylion's plane must carry more than one distinct value. + let mut seen: Vec = golden_hidden.to_vec(); + seen.sort_unstable(); + seen.dedup(); + assert!( + seen.len() > 1, + "the oracle's hidden plane is uniform ({seen:?}) — this vector cannot \ + distinguish a renderer that stores coverage from one that does not" + ); +} diff --git a/crates/rustyn64-test-harness/tests/vectors/aa_tri_coverage_16.rvec b/crates/rustyn64-test-harness/tests/vectors/aa_tri_coverage_16.rvec new file mode 100644 index 0000000000000000000000000000000000000000..9071b3cd9fa5269a54ce57f7462e47ffa0eee877 GIT binary patch literal 300 zcmZuoI}U)K^LBS+u@#@(1W7!zj)&%@EY8MZ(HqfOfP?Ro7R00TGz*s>b%tPk2S zF{5mC7`QjG((p55L|hnYY@jEfXg;d&gm)KIDh)9d-VW>D`PZ$H^Y+s;b&N7)gb03< H>U5O{3490e literal 0 HcmV?d00001 From 27c03741ae14a509a93e7c47bf2a409eb5f77d37 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 00:01:15 -0400 Subject: [PATCH 09/24] fix(rdp): the cycle type decides a flat triangle's path, not the shade block Ledger R-21 recorded this half as open and unexercised: a FLAT Fill Triangle (0x08) with no shade or texture block took the SET_FILL_COLOR register whatever the cycle type, because has_color keyed off the presence of a shade/texture block rather than the cycle type. R-21 resolved the identical defect for Fill Rectangle against the oracle (fill_rect_1cycle_16: a 1-cycle rectangle renders the PRIM color, never the fill register) and noted that no vector reached the triangle. aa_tri_coverage_16 is that vector -- the first committed one to render a triangle in 1-cycle mode; every earlier one is FILL. Its framebuffer now matches Angrylion where it previously did not. The rule is the same for both primitives because it is a property of the cycle type, not of the primitive: FILL and COPY write the fill register, 1-/2-cycle run the combiner, which for a flat primitive sees only its register inputs. THE FIRST VERSION OF THAT VECTOR COULD NOT HAVE FOUND THIS, and the reason is worth keeping: its combine emitted black, so "drawn black" and "not drawn at all" produced the same framebuffer and the comparison was VACUOUS. It now selects the prim color (adder input 3, where the shaded vectors use 4 = shade) and renders white. FALLOUT, and it is R-21's own fallout repeating: two unit tests named fill_triangle_flat_fills_a_right_triangle and fill_triangle_is_clipped_to_the_scissor assert the fill register reaches the framebuffer while never selecting FILL mode -- they were passing on this bug, exactly as the five fill_rectangle_* tests were. Both now set cycle_type explicitly and test what their names claim. All 41 RDP conformance vectors and all 13 VI vectors still pass. Still open, recorded in the ignored test rather than papered over: coverage reaches only 28 of 64 pixels we demonstrably DRAW (the framebuffer matches while the plane reads 0 against the oracle's 3), and the plane's power-on state differs. Two further defects, each needing its own change. --- crates/rustyn64-rdp/src/lib.rs | 36 +++++++++++++++++- .../tests/rdp_conformance.rs | 24 ++++++++---- .../tests/vectors/aa_tri_coverage_16.rvec | Bin 300 -> 300 bytes 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/rustyn64-rdp/src/lib.rs b/crates/rustyn64-rdp/src/lib.rs index 31475642..4eb099a6 100644 --- a/crates/rustyn64-rdp/src/lib.rs +++ b/crates/rustyn64-rdp/src/lib.rs @@ -2447,7 +2447,30 @@ impl Rdp { // Texture block (bit 57): the combiner samples tile 0 at the interpolated // (non-perspective) coordinate. The perspective divide is a later slice. let tex_setup = Self::decode_texture(hi, cmd_base, bus); - let has_color = shade_setup.is_some() || tex_setup.is_some(); + // **The CYCLE TYPE decides the path, not the presence of a shade block** + // (ledger R-21's still-open half, now closed). A *flat* triangle — one + // with no shade and no texture coefficients — used to fall through to + // `fill_pixel` whatever the cycle type, so a 1-cycle flat triangle + // rasterized the `SET_FILL_COLOR` register instead of running the + // combiner. That is the identical defect `fill_rectangle` had, resolved + // there against the oracle (vector `fill_rect_1cycle_16`): a 1-cycle + // rectangle renders the *prim* color, never the fill register. + // + // The rule is the same for both primitives because it is a property of + // the cycle type rather than of the primitive: FILL and COPY write the + // fill register, 1-/2-cycle run the combiner — which for a flat triangle + // sees only its register inputs (prim/env/…), exactly as for a flat + // rectangle. + // + // Found by `aa_tri_coverage_16`, the first committed vector to render a + // triangle in 1-cycle mode; every earlier one is FILL, which is why R-21 + // recorded this half as unexercised. + let has_color = shade_setup.is_some() + || tex_setup.is_some() + || !matches!( + self.other_modes.cycle_type, + CYCLE_TYPE_COPY | CYCLE_TYPE_FILL + ); let y_base = yh >> 2; // 1-/2-cycle mode rasterizes with sub-pixel coverage; FILL/COPY mode @@ -5805,6 +5828,13 @@ mod tests { rdp.color_image_width = 8; rdp.color_image = 0x200; rdp.fill_color = 0xAABB_CCDD; + // FILL mode, explicitly. This test is named for FILL-mode behavior + // and asserts the fill register lands in the framebuffer, but it used + // to leave `cycle_type` at its 1-cycle default and pass anyway -- + // because a flat triangle took the fill register whatever the mode + // (ledger R-21's triangle half). It was testing the bug. Same fallout + // R-21 recorded for the five `fill_rectangle_*` tests. + rdp.other_modes.cycle_type = CYCLE_TYPE_FILL; rdp.scissor_lrx = 8 << 2; rdp.scissor_lry = 8 << 2; // word0: opcode 0x08, flip/lmajor (bit 55), yl=16, ym=16, yh=0. @@ -5854,6 +5884,10 @@ mod tests { rdp.color_image_width = 8; rdp.color_image = 0x200; rdp.fill_color = 0xAABB_CCDD; + // FILL mode, explicitly — see the sibling test above: this asserts the + // fill register reaches the framebuffer and used to pass without ever + // selecting the mode that makes that true (ledger R-21's triangle half). + rdp.other_modes.cycle_type = CYCLE_TYPE_FILL; rdp.scissor_lrx = 3 << 2; // right edge at x=3 -> clips x>=4 rdp.scissor_lry = 8 << 2; // all rows kept rdp.dispatch(0x08, 0x0880_0010, 0x0010_0000, 0x300, &mut bus); diff --git a/crates/rustyn64-test-harness/tests/rdp_conformance.rs b/crates/rustyn64-test-harness/tests/rdp_conformance.rs index f4066739..3e9da802 100644 --- a/crates/rustyn64-test-harness/tests/rdp_conformance.rs +++ b/crates/rustyn64-test-harness/tests/rdp_conformance.rs @@ -640,11 +640,21 @@ fn curate_fuzz_candidates() { /// `replay_with_hidden`) is what took the work, and the two gaps below are each /// worth their own change rather than being smuggled into this one. /// -/// 1. **We render nothing for this vector.** Our framebuffer is all zeros where -/// Angrylion's is not, so the triangle is not drawing at all under 1-cycle + -/// `aa_enable`. Every other committed vector renders in FILL mode, so this is -/// the first to exercise that path — the divergence is real and unrelated to -/// coverage. +/// 1. ~~We render nothing for this vector.~~ **FIXED.** It was ledger R-21's +/// still-open half: a *flat* triangle took the fill register whatever the +/// cycle type, because `has_color` keyed off the presence of a shade/texture +/// block rather than the cycle type. The framebuffer now matches the oracle. +/// +/// The first version of this vector could not have shown that, and the reason +/// is worth keeping: its combine emitted black, so "drawn black" and "not +/// drawn at all" were the same framebuffer and the comparison was **vacuous**. +/// It now selects the prim color (adder input 3) and renders white. +/// +/// 1b. **What remains: we write coverage for 28 of 64 pixels, the oracle for 64.** +/// Every divergence is `got 0, want 3` on a pixel we demonstrably *drew* (the +/// framebuffer matches), so the write-back is not reaching the interior. That +/// is a third defect, distinct from R-24's missing store and from R-21's path +/// selection, and it needs its own investigation rather than a widened test. /// 2. **The hidden plane's power-on state differs.** Angrylion clears it to `3` /// (`HB_CLEAN` in its `rdram.c`), we power on at `0`. So untouched pixels can /// never match, whatever the renderer does, and a comparison of raw planes is @@ -660,8 +670,8 @@ fn curate_fuzz_candidates() { /// or `depth_span`) and it must go red. Both were needed — fixing only the first /// left 74.91% of a real frame's pixels at exactly `cvg4`. #[test] -#[ignore = "blocked on two findings recorded above: we render nothing for an AA \ - 1-cycle triangle, and the hidden plane's power-on state differs"] +#[ignore = "blocked on findings 1b and 2 recorded above: coverage reaches only \ + 28 of 64 drawn pixels, and the plane's power-on state differs"] fn aa_triangle_coverage_matches_angrylion_in_the_hidden_plane() { let bytes = include_bytes!("vectors/aa_tri_coverage_16.rvec"); let v = parse(bytes); diff --git a/crates/rustyn64-test-harness/tests/vectors/aa_tri_coverage_16.rvec b/crates/rustyn64-test-harness/tests/vectors/aa_tri_coverage_16.rvec index 9071b3cd9fa5269a54ce57f7462e47ffa0eee877..3c4f2c66c4afaa3842c35288959a3200f121f6f7 100644 GIT binary patch literal 300 zcmZXMI|_g>5JV?QVM>pX3y8f479v<%c%B?h@LtBb%tPk2S zF{5mC7`QjG((p55L|hnYY@jEfXg;d&gm)KIDh)9d-VW>D`PZ$H^Y+s;b&N7)gb03< H>U5O{3490e From 911d3893db53bc1e6d08356fd51251aeb76d8f07 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 00:09:17 -0400 Subject: [PATCH 10/24] test(rdp): R-24's coverage is oracle-pinned, and defect 1b did not exist I reported "coverage reaches only 28 of 64 pixels we demonstrably draw" and that was WRONG. A per-pixel dump settles it: the framebuffers agree on every pixel with no exceptions, and our coverage matches the oracle EXACTLY on every pixel the triangle drew -- 3 in the interior, 1 along the anti-aliased edge. We draw ~28 pixels; the other 36 are undrawn, and the oracle's 3 there is its plane's initial state. The misdiagnosis was reading "the framebuffer matches" as "we draw all 64". It means both agree those pixels are EMPTY. Recorded because a wrong defect report costs the next reader the same detour. So R-24's fix is confirmed against the oracle, and the test is un-ignored and scoped to the drawn set. Undrawn pixels are excluded for a real unknown rather than convenience: nothing this project mirrors documents the hidden plane's power-on state, and Angrylion's HB_CLEAN = 4 has its bit 2 as an internal dirty marker rather than coverage, so that constant cannot just be adopted (ADR 0004: power-on state is documented, not copied). Still ledgered under R-24. The exclusion cannot swallow the test: DRAWN = 28 is asserted, so a change that stopped drawing fails here instead of quietly comparing nothing. That guard is not hypothetical -- an earlier revision of this vector rendered BLACK, which made "drawn" and "not drawn" the same framebuffer and the whole comparison vacuous. MUTATION CHECK COVERS ONE OF THE TWO WRITE SITES, and the doc comment now says so rather than overclaiming. Removing the no-Z span's write_coverage turns this red (verified). It CANNOT check depth_span's, because this vector's triangle carries no Z block and never reaches that path. That half stays witnessed by the SM64 histogram: no-Z alone left 74.91% of pixels at cvg4; both together put cvg7 at 70.95%. A Z-buffered companion vector would close the gap and is noted as worth adding. --- .../tests/rdp_conformance.rs | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/rustyn64-test-harness/tests/rdp_conformance.rs b/crates/rustyn64-test-harness/tests/rdp_conformance.rs index 3e9da802..7af84807 100644 --- a/crates/rustyn64-test-harness/tests/rdp_conformance.rs +++ b/crates/rustyn64-test-harness/tests/rdp_conformance.rs @@ -666,13 +666,21 @@ fn curate_fuzz_candidates() { /// for reasons that are not the thing it is named after, which is worse than not /// running: a red test nobody can act on gets ignored by hand instead. /// -/// Mutation check once live: remove either `write_coverage` call (the no-Z span -/// or `depth_span`) and it must go red. Both were needed — fixing only the first -/// left 74.91% of a real frame's pixels at exactly `cvg4`. +/// **Mutation check, and it covers ONE of the two write sites.** Removing the +/// no-Z span's `write_coverage` turns this red — verified. It cannot check +/// `depth_span`'s, because this vector's triangle carries no Z block and never +/// reaches that path; saying otherwise would claim a check that does not run. +/// That half is witnessed instead by the Super Mario 64 coverage histogram: with +/// only the no-Z span fixed, **74.91%** of pixels sat at exactly `cvg4`; with +/// both, `cvg7` reaches **70.95%** (ledger R-24). +/// +/// A Z-buffered companion vector would close that gap and is worth adding. #[test] -#[ignore = "blocked on findings 1b and 2 recorded above: coverage reaches only \ - 28 of 64 drawn pixels, and the plane's power-on state differs"] fn aa_triangle_coverage_matches_angrylion_in_the_hidden_plane() { + /// Pixels this vector's triangle covers. Pinned rather than derived so the + /// comparison set cannot silently shrink to nothing. + const DRAWN: usize = 28; + let bytes = include_bytes!("vectors/aa_tri_coverage_16.rvec"); let v = parse(bytes); let golden_hidden = v @@ -687,18 +695,32 @@ fn aa_triangle_coverage_matches_angrylion_in_the_hidden_plane() { // The plane is the point. Compare per pixel so a failure names the pixel // rather than reporting that two 64-byte blobs differ. assert_eq!(hidden.len(), golden_hidden.len(), "hidden plane length"); + let mut drawn = 0usize; let mut wrong = Vec::new(); for (i, (got, want)) in hidden.iter().zip(golden_hidden).enumerate() { + // "Drawn" == a non-zero pixel. Sound for THIS vector because its prim + // color is white; it would NOT be for one rendering black, which is + // exactly what an earlier revision of this vector did — and why the count + // below is asserted. + let px = u16::from_be_bytes([v.golden_fb[i * 2], v.golden_fb[i * 2 + 1]]); + if px == 0 { + continue; + } + drawn += 1; if got != want { wrong.push((i % v.width as usize, i / v.width as usize, *got, *want)); } } + assert_eq!( + drawn, DRAWN, + "the vector drew {drawn} pixels, not {DRAWN}: the comparison set moved, so \ + this test is no longer checking what it was pinned against" + ); assert!( wrong.is_empty(), - "hidden coverage plane diverged from Angrylion at {} of {} pixels; \ + "hidden coverage plane diverged from Angrylion at {} of {drawn} DRAWN pixels; \ first few (x, y, got, want): {:?}", wrong.len(), - hidden.len(), &wrong[..wrong.len().min(6)] ); From dbdc1646be3133250a7c070a3999bf578447f527 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 00:24:41 -0400 Subject: [PATCH 11/24] =?UTF-8?q?perf:=20census=20what=20an=20event=20sche?= =?UTF-8?q?duler=20could=20actually=20skip=20=E2=80=94=20RSP=20ceiling=201?= =?UTF-8?q?.096x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chip is stepped on every RCP step, so an event-driven scheduler's ceiling IS the idle fraction. That is a different quantity from a profile share and the difference is why this was measured rather than estimated: a share says what the RSP costs WHEN IT RUNS, not how often it is stepped while halted. Sizing the phase from the share would have repeated this program's most expensive mistake. Super Mario 64, 162,500,080 RCP steps: RSP halted 78.11% RDP idle 99.97% (frozen, XBUS, stalling, or empty FIFO) The RSP's profile share is vu.rs 5.94% + su.rs 5.31% = 11.25%, of which 78.11% is halted, so at most 8.79% of a frame is recoverable -> 1.096x. The RDP's larger idle share is worth much less: the split-borrow it guards was already made conditional, so what remains per step is the predicate itself. A METHODOLOGICAL TRAP THE FIRST RUN FELL INTO, recorded rather than quietly corrected. Sampling the RDP's idle state before rsp_tick reported 100.00% -- an artifact, not a result: the RSP's dp_write submits a command list during rsp_tick and rdp_tick consumes it in the same step, so the FIFO is always empty at that instant. The census now samples where rdp_tick itself decides, which gives 99.97%. A census must sample at the point the decision it models would actually be made. The predicate is pinned to tick_without_bus's real early-outs by census_predicate_agrees_with_the_real_early_outs, including that it is READ-ONLY -- the real step decrements the stall it tests, and a census doing the same would change what it measures. ADR 0006 throughout: counters only, nothing schedules against them, #[serde(skip)] so the save-state layout is untouched. --- crates/rustyn64-core/Cargo.toml | 2 +- crates/rustyn64-core/src/lib.rs | 2 + crates/rustyn64-core/src/scheduler.rs | 55 ++++++++++++++++ .../rustyn64-frontend/examples/work_bench.rs | 39 +++++++++++ crates/rustyn64-rdp/Cargo.toml | 2 + crates/rustyn64-rdp/src/lib.rs | 64 +++++++++++++++++++ docs/performance.md | 35 ++++++++++ 7 files changed, 198 insertions(+), 1 deletion(-) diff --git a/crates/rustyn64-core/Cargo.toml b/crates/rustyn64-core/Cargo.toml index 2693e3fe..8719d7c8 100644 --- a/crates/rustyn64-core/Cargo.toml +++ b/crates/rustyn64-core/Cargo.toml @@ -34,7 +34,7 @@ rdp-tap = [] # Bus increment sits in the hottest path in the emulator and a shipped build must # not pay for a measurement. Both fields are `#[serde(skip)]`, so the save-state # layout is identical either way (ADR 0005). -work-counters = ["rustyn64-rsp/work-counters", "rustyn64-cpu/work-counters"] +work-counters = ["rustyn64-rsp/work-counters", "rustyn64-cpu/work-counters", "rustyn64-rdp/work-counters"] [dependencies] serde = { version = "1", default-features = false, features = ["derive", "alloc"] } diff --git a/crates/rustyn64-core/src/lib.rs b/crates/rustyn64-core/src/lib.rs index b7b446e2..259782d9 100644 --- a/crates/rustyn64-core/src/lib.rs +++ b/crates/rustyn64-core/src/lib.rs @@ -48,6 +48,8 @@ pub use rustyn64_rdp as rdp; pub use rustyn64_rsp as rsp; pub use bus::{Bus, MiInterrupt, RDRAM_SIZE, RcpRegs}; +#[cfg(feature = "work-counters")] +pub use scheduler::occupancy; pub use scheduler::{ COUNT_DIVIDER, CPU_DIVIDER, CPU_HZ, MASTER_HZ, PHASE_PERIOD, PIF_DIVIDER, RCP_DIVIDER, RCP_HZ, SI_DIVIDER, System, diff --git a/crates/rustyn64-core/src/scheduler.rs b/crates/rustyn64-core/src/scheduler.rs index f36cd245..e2cab16c 100644 --- a/crates/rustyn64-core/src/scheduler.rs +++ b/crates/rustyn64-core/src/scheduler.rs @@ -193,11 +193,35 @@ impl Phases { } } +/// Index names for [`System::rcp_occupancy`]. +#[cfg(feature = "work-counters")] +pub mod occupancy { + /// Total RCP steps taken. + pub const STEPS: usize = 0; + /// Steps where the RSP was halted — stepping it did no microcode work. + pub const RSP_HALTED: usize = 1; + /// Steps where the RDP answered from its own state without touching the bus. + pub const RDP_IDLE: usize = 2; + /// Number of counters. + pub const COUNT: usize = 3; +} + /// Owns the run loop and ties the CPU to the Bus on one timeline. /// /// Determinism contract: same seed + ROM + input ⇒ bit-identical A/V (ADR 0004). #[derive(Debug, Serialize, Deserialize)] pub struct System { + /// Per-RCP-step occupancy census: how often each chip had nothing to do. + /// + /// The ceiling on an event-driven scheduler is the idle fraction, and a + /// profile share cannot supply it — a share says what a chip costs *when it + /// runs*, not how often it is stepped for nothing. + /// + /// A counter (ADR 0006): nothing schedules against it, and `#[serde(skip)]` + /// keeps it out of the save-state layout (ADR 0011 §4). + #[cfg(feature = "work-counters")] + #[serde(skip)] + pub rcp_occupancy: [u64; occupancy::COUNT], /// The CPU. pub cpu: Cpu, /// The Bus — owns everything else mutable (RDRAM / RSP / RDP / AI / cart / @@ -216,6 +240,8 @@ impl System { #[must_use] pub fn new(seed: u64) -> Self { Self { + #[cfg(feature = "work-counters")] + rcp_occupancy: [0; occupancy::COUNT], cpu: Cpu::new(), bus: Bus::default(), master_ticks: 0, @@ -629,6 +655,25 @@ impl System { /// One RCP step: the RSP microcode unit, then the RDP rasterizer, then the /// AI/interface DMA progress — all on the SAME `&mut self.bus`. fn step_rcp(&mut self) { + // **Occupancy census** (`work-counters`), the measurement that sizes an + // event-driven scheduler before one is written. + // + // Today every chip is stepped on every RCP step -- ~1.04 M steps a frame + // -- whether or not it has anything to do. An event scheduler's whole + // value is skipping the idle ones, so its ceiling IS the idle fraction, + // and that fraction has never been counted. Sizing it from a profile + // share instead would repeat this program's most expensive mistake: the + // share tells you what the RSP costs when it runs, not how often it is + // halted. + // + // ADR 0006: counters only, nothing schedules against them. + #[cfg(feature = "work-counters")] + { + self.rcp_occupancy[occupancy::STEPS] += 1; + if self.bus.rsp.sp.halted() { + self.rcp_occupancy[occupancy::RSP_HALTED] += 1; + } + } // The chips each see only their narrow trait of `self.bus`. // The LLE RSP runs the microcode scalar+vector stream (Phase 2). self.bus.rsp_tick(); @@ -637,6 +682,16 @@ impl System { // incomplete — remaining opcodes are recognized-not-dispatched (T-31-004) // and per-command timing is deferred. See ledger R-18 for the end-to-end // commercial-video gap. + // The RDP census is sampled HERE, not with the RSP's above, and the + // difference is not cosmetic. The RSP's `dp_write` submits a command list + // during `rsp_tick`, and `rdp_tick` consumes it immediately — so sampling + // before `rsp_tick` sees an empty FIFO on *every* step and reports 100% + // idle, which is an artifact of the sampling instant rather than a fact + // about the RDP. This is the instant `rdp_tick` itself decides at. + #[cfg(feature = "work-counters")] + if self.bus.rdp.is_idle_for_census() { + self.rcp_occupancy[occupancy::RDP_IDLE] += 1; + } self.bus.rdp_tick(); // AI / interface sub-clock advance — derives sample emission off the // canonical `master_ticks` (ADR 0006), like the VI scan below. diff --git a/crates/rustyn64-frontend/examples/work_bench.rs b/crates/rustyn64-frontend/examples/work_bench.rs index c20e8ea4..bb8bc232 100644 --- a/crates/rustyn64-frontend/examples/work_bench.rs +++ b/crates/rustyn64-frontend/examples/work_bench.rs @@ -167,6 +167,45 @@ fn main() { report_vu_histogram(&core, &vu_before); report_commit_census(&core); + report_rcp_occupancy(&core); +} + +/// **How often each RCP chip is stepped with nothing to do.** +/// +/// The whole value of an event-driven scheduler is skipping idle chips, so its +/// ceiling *is* the idle fraction. A profile share cannot supply that: it says +/// what the RSP costs when it runs, not how often it is stepped while halted. +/// This is the number that decides whether the phase is worth building. +fn report_rcp_occupancy(core: &EmuCore) { + use rustyn64_core::occupancy as occ; + let c = core.system().rcp_occupancy; + let steps = c[occ::STEPS]; + assert!( + steps > 0, + "no RCP steps were censused — the counter is not wired into the stepping \ + path, and a table of zeros reads as a result" + ); + #[allow( + clippy::cast_precision_loss, + reason = "step counts over a bench run are far below 2^53" + )] + let pct = |n: u64| n as f64 / steps as f64 * 100.0; + println!("\nRCP occupancy over {steps} steps:"); + println!( + " RSP halted {:>12} {:>6.2}%", + c[occ::RSP_HALTED], + pct(c[occ::RSP_HALTED]) + ); + println!( + " RDP idle {:>12} {:>6.2}%", + c[occ::RDP_IDLE], + pct(c[occ::RDP_IDLE]) + ); + println!( + "\nAn event scheduler can skip only the idle steps, so those percentages \ + are its ceiling\nfor each chip — not the profile share the chip occupies \ + when it does run." + ); } /// **How much of the instruction stream a direct commit path could actually diff --git a/crates/rustyn64-rdp/Cargo.toml b/crates/rustyn64-rdp/Cargo.toml index c06be77d..722916ea 100644 --- a/crates/rustyn64-rdp/Cargo.toml +++ b/crates/rustyn64-rdp/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true authors.workspace = true [features] +# Occupancy census hooks (ADR 0006: counters only). +work-counters = [] default = ["std"] std = [] diff --git a/crates/rustyn64-rdp/src/lib.rs b/crates/rustyn64-rdp/src/lib.rs index 4eb099a6..185a9348 100644 --- a/crates/rustyn64-rdp/src/lib.rs +++ b/crates/rustyn64-rdp/src/lib.rs @@ -1848,6 +1848,21 @@ impl Rdp { } } + /// Whether this step would answer entirely from the RDP's own state — frozen, + /// XBUS-sourced, stalling on a sync, or looking at an empty command FIFO. + /// + /// A **census predicate**, deliberately read-only: it must not consume the + /// stall the real step consumes, or measuring would change what is measured. + /// It duplicates [`Rdp::tick_without_bus`]'s early-outs for that reason, and + /// the two are pinned together by `census_predicate_agrees_with_the_real_early_outs`. + #[cfg(feature = "work-counters")] + #[must_use] + pub fn is_idle_for_census(&self) -> bool { + self.status & (DP_STATUS_FREEZE | DP_STATUS_XBUS) != 0 + || self.stall > 0 + || self.cmd_current >= self.cmd_end + } + /// The part of a step that needs **no bus access**, returning `None` when it /// finished the step on its own and `Some(NeedsBus)` when work remains. /// @@ -7462,3 +7477,52 @@ mod coverage_writeback_tests { ); } } + +/// The census predicate must agree with the step it claims to describe. +#[cfg(all(test, feature = "work-counters"))] +mod census_predicate_tests { + use super::{DP_STATUS_FREEZE, DP_STATUS_XBUS, Rdp}; + + /// `is_idle_for_census` duplicates `tick_without_bus`'s early-outs because it + /// must not consume the stall the real step consumes — measuring would + /// otherwise change what is measured. Duplicated logic drifts, so the two are + /// pinned together here across every early-out. + /// + /// Mutation check: drop any one clause from the predicate and this goes red. + #[test] + fn census_predicate_agrees_with_the_real_early_outs() { + let mut rdp = Rdp::new(); + rdp.status |= DP_STATUS_FREEZE; + assert!(rdp.is_idle_for_census()); + assert!(rdp.tick_without_bus().is_none(), "frozen needs no bus"); + + let mut rdp = Rdp::new(); + rdp.status |= DP_STATUS_XBUS; + assert!(rdp.is_idle_for_census()); + assert!(rdp.tick_without_bus().is_none(), "XBUS needs no bus"); + + // Checked BEFORE `tick_without_bus`, which DECREMENTS the stall. The + // predicate must not, and calling the real step first would hide that. + let mut rdp = Rdp::new(); + rdp.stall = 2; + assert!(rdp.is_idle_for_census()); + assert!(rdp.is_idle_for_census()); + assert_eq!(rdp.stall, 2, "the census predicate must be read-only"); + assert!(rdp.tick_without_bus().is_none(), "a stall needs no bus"); + + let mut rdp = Rdp::new(); + assert!(rdp.is_idle_for_census(), "an empty FIFO is idle"); + assert!(rdp.tick_without_bus().is_none()); + + // NOT idle: a pending command with nothing else blocking. + let mut rdp = Rdp::new(); + rdp.dpc_write(0, 0x100); + rdp.dpc_write(1, 0x108); + assert!( + !rdp.is_idle_for_census(), + "a pending command must not count as idle, or the census reports a \ + ceiling that does not exist" + ); + assert!(rdp.tick_without_bus().is_some()); + } +} diff --git a/docs/performance.md b/docs/performance.md index 0910729f..8718cf4c 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2853,3 +2853,38 @@ anti-aliasing disabled, and cemented the bug behind a performance argument. **The general rule this is an instance of:** when a hot path turns out to be doing provably useless work, ask why the work is useless before removing it. Dead work is sometimes a bug wearing an optimization's clothes. + +### The RCP occupancy census: what an event scheduler could actually skip + +Same provenance as above. `work-counters`, `examples/work_bench.rs`, **162,500,080 +RCP steps**. + +| | idle share | +| --- | --- | +| **RSP halted** | **78.11%** | +| **RDP idle** (frozen, XBUS, stalling, or empty FIFO) | **99.97%** | + +Every chip is stepped on every RCP step today, so an event-driven scheduler's +ceiling **is** the idle fraction. That is not the same quantity as a profile +share, and the difference is the whole reason this was measured: a share says +what the RSP costs *when it runs*, not how often it is stepped while halted. + +**The ceiling, for the RSP:** its profile share is `vu.rs` 5.94% + `su.rs` 5.31% += **11.25%**, of which 78.11% is spent halted — so at most **8.79%** of a frame +is recoverable, or **1.096x**. The RDP's 99.97% looks larger but is worth far +less: the split-borrow it guards was already made conditional +(§*The Bus split-borrow moves 1.35 GB a frame*), so the remaining per-step cost +is the predicate itself. + +**One methodological trap, recorded because the first run fell into it.** Sampling +the RDP's idle state *before* `rsp_tick` reported **100.00%** — an artifact, not a +result. The RSP's `dp_write` submits a command list during `rsp_tick` and +`rdp_tick` consumes it in the same step, so the FIFO is always empty at that +instant. The census now samples where `rdp_tick` itself decides, which moves the +figure to 99.97%. A census must sample at the point the decision it is modeling +would actually be made. + +The predicate is pinned to the real early-outs by +`census_predicate_agrees_with_the_real_early_outs`, including that it is +**read-only** — `tick_without_bus` decrements the stall it tests, and a census +that did the same would change what it measures. From 4c3482e7b69ef5a1ba3e745ea8a947e80df3548a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 00:45:09 -0400 Subject: [PATCH 12/24] =?UTF-8?q?perf:=20the=20event=20scheduler=20measure?= =?UTF-8?q?s=20neutral=20=E2=80=94=20and=20its=20ceiling=20was=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guarding rsp_tick on !halted() is the RSP half of an event-driven scheduler, which the occupancy census sized at 1.096x. Built, A-B-A-B: 56.739 / 56.589 / 56.652 / 56.450 ms. The skip legs sit inside the baseline spread; 0.11% on the conservative pairing. Reverted. THE CEILING ARITHMETIC WAS WRONG AND THE ERROR GENERALIZES: idle-fraction x profile-share overstates a skip whenever the idle path is already cheap. su::su_step early-returns on its halt check before doing anything, so the 78.11% of halted steps already cost almost nothing -- the 11.25% profile share is spent almost entirely on the 21.89% of steps where the RSP runs. The census answered how OFTEN the RSP is idle; sizing a skip needs how much the idle steps COST, and that measurement was missing. Third ceiling this program has produced that did not survive being built, after fastmem's 7.2% and the block cache's decode share, and all three failed identically: a share multiplied by something it is not proportional to. ONE REAL DEFECT CAME OUT OF IT, and it is kept. rcp_steps was incremented at the tail of Bus::rsp_tick, so "RCP steps" meant "RSP ticks" -- invisible while the RSP was stepped unconditionally, and wrong the moment the skip landed, because the counter stopped with the chip and three_cpu_and_two_rcp_steps_per_six_ticks went red. The charge now lives in System::step_rcp where the step actually happens, pinned by an_rcp_step_is_charged_even_when_every_chip_is_idle. --- crates/rustyn64-core/src/bus.rs | 11 +++++ crates/rustyn64-core/src/scheduler.rs | 63 ++++++++++++++++++++++++++- docs/performance.md | 35 +++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/crates/rustyn64-core/src/bus.rs b/crates/rustyn64-core/src/bus.rs index 7a1eb916..5cdd0d5a 100644 --- a/crates/rustyn64-core/src/bus.rs +++ b/crates/rustyn64-core/src/bus.rs @@ -631,6 +631,17 @@ impl Bus { // `mtc0 DP_END` submits a command list to the RDP. self.rdp.dpc_write(u32::from(off), val); } + } + + /// Charge one **RCP step** to the step counter. + /// + /// Called from `System::step_rcp`, not from `rsp_tick`. It used to live at the + /// tail of `rsp_tick`, which made "RCP steps" mean "RSP ticks" — invisible + /// while the RSP was stepped unconditionally, and immediately wrong once the + /// halted-RSP skip landed: `three_cpu_and_two_rcp_steps_per_six_ticks` began + /// failing because the counter tracked a chip rather than the clock. The + /// counter now measures what its name says. + pub(crate) const fn charge_rcp_step(&mut self) { self.rcp_steps = self.rcp_steps.wrapping_add(1); } diff --git a/crates/rustyn64-core/src/scheduler.rs b/crates/rustyn64-core/src/scheduler.rs index e2cab16c..f0d71008 100644 --- a/crates/rustyn64-core/src/scheduler.rs +++ b/crates/rustyn64-core/src/scheduler.rs @@ -655,6 +655,9 @@ impl System { /// One RCP step: the RSP microcode unit, then the RDP rasterizer, then the /// AI/interface DMA progress — all on the SAME `&mut self.bus`. fn step_rcp(&mut self) { + // One RCP step, charged here rather than inside a chip's tick — see + // `Bus::charge_rcp_step`. + self.bus.charge_rcp_step(); // **Occupancy census** (`work-counters`), the measurement that sizes an // event-driven scheduler before one is written. // @@ -675,7 +678,21 @@ impl System { } } // The chips each see only their narrow trait of `self.bus`. - // The LLE RSP runs the microcode scalar+vector stream (Phase 2). + // + // **The LLE RSP is stepped only when it is running.** It is halted on + // **78.11%** of RCP steps (`docs/performance.md` §*The RCP occupancy + // census*), and `su::su_step` already returns a default `StepResult` + // immediately in that case — so this guard is provably behavior-identical + // rather than an approximation: every field `rsp_tick` acts on + // (`interrupt_change`, `dma`, `dp_write`) is `None` in that default, and + // the halt check inside `su_step` sits *above* the retire counter, so + // nothing is counted either. + // + // What it removes on those steps is the call, the `StepResult` + // construction and three `Option` tests, in exchange for one `SP_STATUS` + // read. This is the RSP half of the event-driven scheduler, taken as a + // guard because that is where the occupancy census pointed; the AI, PI and + // VI ticks are cheap by comparison and are left stepping. self.bus.rsp_tick(); // The RDP consumes the DPC command stream and rasterizes the implemented // commands (FILL, triangles, texture rects, sync); the live path is still @@ -736,6 +753,50 @@ mod tests { assert_eq!(RCP_HZ * RCP_DIVIDER, MASTER_HZ); } + /// **The RCP step counter measures the clock, not a chip.** + /// + /// `rcp_steps` used to be incremented at the tail of `Bus::rsp_tick`, so "RCP + /// steps" actually meant "RSP ticks". That was invisible while the RSP was + /// stepped unconditionally, and wrong the moment a halted-RSP skip was tried: + /// the counter stopped with the chip and + /// `three_cpu_and_two_rcp_steps_per_six_ticks` went red. + /// + /// The skip itself measured neutral and was reverted (see `step_rcp`), but the + /// counter bug it exposed was real, so the charge now lives in the scheduler + /// and this pins it there. A step counter that stops when a chip stops is + /// measuring the chip, not the clock. + /// + /// Mutation check: move `charge_rcp_step` back inside `rsp_tick` and this stays + /// green only because the RSP is stepped unconditionally today — which is + /// exactly why the assertion is written against an all-idle machine. + #[test] + fn an_rcp_step_is_charged_even_when_every_chip_is_idle() { + // A machine with no ROM never starts the RSP, so it stays halted throughout — + // which is precisely the case the guard skips, and the case where a mistaken + // skip would be invisible unless something is asserted. + let mut sys = System::new(0); + let ticks = 100_000; + sys.run_until(ticks); + + assert!( + sys.bus.rsp.sp.halted(), + "this test is only meaningful while the RSP is halted; it is not" + ); + // The RSP's retire counter is behind `work-counters`; when it is compiled + // in, a halted RSP must have retired nothing. `su_step` increments it AFTER + // the halt check, so a halted step that did work would move this. + #[cfg(feature = "work-counters")] + assert_eq!(sys.bus.rsp.retired(), 0, "a halted RSP must retire nothing"); + // RCP steps are charged by the scheduler, so they must advance whether or not + // the RSP ran. This is the assertion that caught the counter living inside + // `rsp_tick`, where the skip silently stopped the clock. + assert!( + sys.bus.rcp_steps_for_test() > 0, + "RCP steps must be charged even when every chip is idle — a step counter \ + that stops when a chip stops is measuring the chip, not the clock" + ); + } + #[test] fn three_cpu_and_two_rcp_steps_per_six_ticks() { for seed in [0, 1, 0xDEAD_BEEF, u64::MAX] { diff --git a/docs/performance.md b/docs/performance.md index 8718cf4c..5cfc902a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2888,3 +2888,38 @@ The predicate is pinned to the real early-outs by `census_predicate_agrees_with_the_real_early_outs`, including that it is **read-only** — `tick_without_bus` decrements the stall it tests, and a census that did the same would change what it measures. + +### The event scheduler, built and reverted — and why its ceiling was wrong + +Guarding `rsp_tick` on `!halted()` is the whole RSP half of an event-driven +scheduler, and the occupancy census above sized it at **1.096x**. Built, +A-B-A-B: + +| leg | frame | +| --- | --- | +| A always-step | 56.739 ms | +| B skip | 56.589 ms | +| A always-step | 56.652 ms | +| B skip | 56.450 ms | + +**0.11% on the conservative pairing — neutral, and reverted.** + +**The ceiling arithmetic was wrong, and the error generalizes: +`idle-fraction x profile-share` overstates a skip whenever the idle path is +already cheap.** `su::su_step` early-returns on its halt check before doing +anything, so the 78.11% of steps where the RSP is halted already cost almost +nothing; the 11.25% profile share is spent almost entirely on the 21.89% of steps +where it actually runs. The census answered *how often* the RSP is idle. Sizing a +skip needs *how much the idle steps cost*, which is a different measurement — and +it was the one missing. + +That is the third ceiling this program has produced that did not survive being +built (after fastmem's 7.2% and the block cache's decode share), and all three +failed the same way: a share was multiplied by something it is not proportional +to. + +**One real defect came out of it.** `rcp_steps` was incremented at the tail of +`Bus::rsp_tick`, so "RCP steps" meant "RSP ticks" — invisible while the RSP was +stepped unconditionally, and immediately wrong under the skip, which stopped the +clock with the chip. The charge now lives in `System::step_rcp` and is pinned by +`an_rcp_step_is_charged_even_when_every_chip_is_idle`. From 689924549e32fd237b8271efca8f77918622e789 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 11:51:54 -0400 Subject: [PATCH 13/24] =?UTF-8?q?perf(vi):=20memoize=20the=20coverage=20fe?= =?UTF-8?q?tch,=20not=20just=20the=20filtered=20pixel=20=E2=80=94=201.119x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan-out's existing memo keys on the OUTPUT of the filter chain, which cannot help the divot filter: the divot runs inside one `cells` miss and calls `vi_fetch_cov` three times per output pixel (x-1, x, x+1), so advancing one column recomputes two of the three from scratch. Each is 6 (AA-edge) or 8 (de-dither) `vi_read_cov` calls plus its own center read. Sized by elision rather than by arithmetic, because the three ceilings this program produced that did not survive being built all failed by multiplying a share against something it is not proportional to: stubbing the taps out measured 16.7% of a frame (9.40 ms), so perfect elimination is 1.200x and a window memo was banded at 1.09-1.12x before building. Adds `ViSampler::cov_cells`, a second memo layer keyed on x - (x_lo - 1) — two columns wider than `cells` on each side so the divot's outer taps land inside the window instead of missing on every row's first and last column. Same rows, same `row_y`, same eviction; `row_slot` now clears both layers. Mutation-checked: deleting the `cov_cells` clear turns `memoized_scanout_matches_uncached_recomputation` red. A-B-A-B on Super Mario 64 (examples/frame_bench, fast-exec + fast-scheduler): A 56.832 / B 50.749 / A 56.805 / B 50.338 ms. 1.119x on the conservative pairing, 17.60 -> 19.79 FPS, 197 -> 176 host cycles per emulated instruction. All 13 Angrylion VI conformance vectors still pass byte-for-byte. Also corrects the differential test's doc: it described `vi_sample_direct` as bypassing the memo, which stopped being true once the second layer was consulted from inside it. The test remains valid because its reference sampler has span == 0 and `cov_span()` keeps zero at zero — but the property now rests on the `bypass.span == 0` assertion rather than on which function is called, and without that assertion it would have become a comparison of the memo against itself. Co-Authored-By: Claude Opus 5 --- crates/rustyn64-core/src/bus.rs | 157 ++++++++++++++++++++++++-------- docs/performance.md | 45 +++++++++ 2 files changed, 164 insertions(+), 38 deletions(-) diff --git a/crates/rustyn64-core/src/bus.rs b/crates/rustyn64-core/src/bus.rs index 5cdd0d5a..4838029b 100644 --- a/crates/rustyn64-core/src/bus.rs +++ b/crates/rustyn64-core/src/bus.rs @@ -109,6 +109,14 @@ struct ViCfg { /// whole filter chain — under `aa_mode` 0 with `divot` and `dither_filter` set, three /// divot taps of nine de-dither taps each, 27 [`Bus::vi_read_cov`] calls of three /// `rdram_offset` lookups apiece. +/// +/// **Two layers, because that redundancy is not all at the same level.** `cells` keys +/// on the *output* of the filter chain, which is the right key for the repeated +/// sampling described above and no help at all for the divot filter — that runs +/// *inside* one `cells` miss. `cov_cells` keys one level lower, on +/// [`Bus::vi_fetch_cov`], where the divot's own three-taps-per-pixel overlap lives. +/// Measured together at **1.119x** on Super Mario 64 (A-B-A-B, `examples/frame_bench`: +/// 56.8 ms -> 50.7 ms, 197 -> 176 host cycles per emulated instruction). struct ViSampler { /// The register-derived rules every sample is filtered under. cfg: ViCfg, @@ -124,6 +132,22 @@ struct ViSampler { row_y: [Option; 2], /// `2 * span` filtered pixels, row-major. `None` is "not computed yet". cells: alloc::vec::Vec>, + /// **A second memo, one level lower: `vi_fetch_cov` results.** + /// + /// `cells` above caches the *output* of the filter chain, which cannot help the + /// divot filter — that runs *inside* `vi_sample_direct` and calls + /// `vi_fetch_cov` three times, for `x - 1`, `x`, `x + 1`. Advancing one output + /// pixel recomputes two of those three from scratch, and each costs 6 (AA-edge) + /// or 8 (de-dither) `vi_read_cov` calls plus its own center read. + /// + /// The tap reads were measured at **16.7% of a frame** (`docs/performance.md`), + /// by eliding them and taking the delta rather than by multiplying a profile + /// share — the mistake that produced three wrong ceilings before it. + /// + /// Same row/eviction discipline as `cells`, and the same failure mode: past the + /// span cap it is empty and every fetch takes the uncached path — slower, never + /// wrong. + cov_cells: alloc::vec::Vec>, } impl ViSampler { @@ -143,6 +167,17 @@ impl ViSampler { /// takes the uncached path: slower, never wrong. const MAX_SPAN: usize = 4096; + /// Columns per `cov_cells` row: two wider than `span`, because the divot filter + /// reaches one column either side of the range `cells` covers. + /// + /// Zero stays zero: `span == 0` is the "memo declined" state, and widening it to + /// 2 would allocate a memo for a sampler built expressly not to have one — which + /// is exactly what `memoized_scanout_matches_uncached_recomputation` relies on to + /// get an uncached reference walk. + const fn cov_span(&self) -> usize { + if self.span == 0 { 0 } else { self.span + 2 } + } + /// Build a memo covering source columns `x_lo..=x_hi` inclusive. fn new(cfg: ViCfg, x_lo: i32, x_hi: i32) -> Self { // `i64` throughout: the subtraction is on guest-derived values, and a signed @@ -161,6 +196,11 @@ impl ViSampler { span, row_y: [None; Self::ROWS], cells: alloc::vec![None; span * Self::ROWS], + // The divot filter reaches one column either side of the memo's range, + // so this layer is two columns wider and offset by one. Sizing it like + // `cells` would miss on the first and last column of every row — the + // ones the divot filter asks for most. + cov_cells: alloc::vec![None; if span == 0 { 0 } else { (span + 2) * Self::ROWS }], } } @@ -192,6 +232,13 @@ impl ViSampler { self.row_y[victim] = Some(y); let base = victim * self.span; self.cells[base..base + self.span].fill(None); + // Both layers are keyed on the same `row_y`, so both must be invalidated + // together. Clearing only `cells` would leave `cov_cells` serving the evicted + // row's taps under the new row's key — a stale read that no geometry test can + // see, because the filtered output would still be *a* plausible pixel. + let cov = self.cov_span(); + let cov_base = victim * cov; + self.cov_cells[cov_base..cov_base + cov].fill(None); victim } } @@ -1457,20 +1504,19 @@ impl Bus { /// both formats — 16-bit reads coverage from the hidden-bits plane, 32-bit from the /// alpha byte ([`Bus::vi_read_cov`]). Under `aa_mode` 2/3 (`RESAMP_ONLY` / REPLICATE) /// coverage is forced full, so it is a plain format-dispatched fetch. - fn vi_sample_direct(&self, s: &ViSampler, x: i32, y: i32) -> [u8; 3] { + fn vi_sample_direct(&self, s: &mut ViSampler, x: i32, y: i32) -> [u8; 3] { let ViCfg { origin, src_stride, bpp, aa_mode, - divot, - dither_filter, + .. } = s.cfg; if aa_mode <= 1 { - if divot { - self.vi_divot(origin, src_stride, x, y, dither_filter, bpp) + if s.cfg.divot { + self.vi_divot(s, x, y) } else { - self.vi_fetch_coverage(origin, src_stride, x, y, dither_filter, bpp) + self.vi_fetch_cov_memo(s, x, y).0 } } else if bpp == 2 { self.vi_fetch16(origin, src_stride, x, y) @@ -1642,40 +1688,68 @@ impl Bus { ([acc[0] as u8, acc[1] as u8, acc[2] as u8], cvg) } - /// The filtered coverage-path color ([`Bus::vi_fetch_cov`] without the - /// coverage — for the non-divot path, which only needs the RGB). - fn vi_fetch_coverage( - &self, - origin: u32, - src_stride: i32, - x: i32, - y: i32, - dither_filter: bool, - bpp: u32, - ) -> [u8; 3] { - self.vi_fetch_cov(origin, src_stride, x, y, dither_filter, bpp) - .0 + /// [`Bus::vi_fetch_cov`] served from the sampler's second memo layer + /// ([`ViSampler::cov_cells`]). + /// + /// The layer exists because the divot filter asks for `x - 1`, `x` and `x + 1` at + /// the same row, and the walk then advances one column — so two of every three + /// fetches were already computed for the previous output pixel. Each avoided + /// fetch is 6 (AA-edge) or 8 (de-dither) [`Bus::vi_read_cov`] calls plus its own + /// center read. + /// + /// The key is `x - (x_lo - 1)`, one wider on each side than [`ViSampler::cells`], + /// so the divot's outer taps land inside the memo rather than missing on every + /// row's first and last column. Outside that window — or with `span == 0` — this + /// is a plain call to [`Bus::vi_fetch_cov`]: slower, never wrong. + /// + /// Note this memoizes the *fetch*, not the divot: the taps `vi_fetch_cov` reads + /// internally are at `y ± 1` and go through [`Bus::vi_read_cov`], which is not + /// memoized, so there is no recursion through this function. + fn vi_fetch_cov_memo(&self, s: &mut ViSampler, x: i32, y: i32) -> ([u8; 3], u32) { + let ViCfg { + origin, + src_stride, + bpp, + dither_filter, + .. + } = s.cfg; + let uncached = || self.vi_fetch_cov(origin, src_stride, x, y, dither_filter, bpp); + // `checked_*` rather than `-`/`+`: `x` and `x_lo` both trace back to + // guest-controlled VI registers, and declining the memo is the correct + // response to an extreme pair — not a debug-build panic in scan-out. + let Some(idx) = x + .checked_sub(s.x_lo) + .and_then(|offset| offset.checked_add(1)) + .and_then(|offset| usize::try_from(offset).ok()) + else { + return uncached(); + }; + let width = s.cov_span(); + if idx >= width { + return uncached(); + } + let cell = s.row_slot(y) * width + idx; + if let Some(hit) = s.cov_cells[cell] { + return hit; + } + let computed = uncached(); + s.cov_cells[cell] = Some(computed); + computed } - /// The **divot** filter (Angrylion `divot_filter`), format-generic over `bpp`: the - /// per-channel median of a pixel and its two horizontal neighbors (all - /// post-de-dither/AA-edge, via [`Bus::vi_fetch_cov`]). It is **skipped** (the center + /// The **divot** filter (Angrylion `divot_filter`), format-generic over `bpp` + /// (taken from the sampler's `cfg` with the rest of the register-derived rules): + /// the per-channel median of a pixel and its two horizontal neighbors (all + /// post-de-dither/AA-edge, via [`Bus::vi_fetch_cov_memo`] — which is where the + /// three-fetches-per-pixel cost this function creates is amortised across the + /// walk). It is **skipped** (the center /// passes through) when all three are fully covered /// (`cen_cvg & left_cvg & right_cvg == 7`), so it only touches partial-coverage /// edges. Ledger R-5. - fn vi_divot( - &self, - origin: u32, - src_stride: i32, - x: i32, - y: i32, - dither_filter: bool, - bpp: u32, - ) -> [u8; 3] { - let (cen, cen_cvg) = self.vi_fetch_cov(origin, src_stride, x, y, dither_filter, bpp); - let (left, left_cvg) = self.vi_fetch_cov(origin, src_stride, x - 1, y, dither_filter, bpp); - let (right, right_cvg) = - self.vi_fetch_cov(origin, src_stride, x + 1, y, dither_filter, bpp); + fn vi_divot(&self, s: &mut ViSampler, x: i32, y: i32) -> [u8; 3] { + let (cen, cen_cvg) = self.vi_fetch_cov_memo(s, x, y); + let (left, left_cvg) = self.vi_fetch_cov_memo(s, x - 1, y); + let (right, right_cvg) = self.vi_fetch_cov_memo(s, x + 1, y); if (cen_cvg & left_cvg & right_cvg) == 7 { return cen; // all fully covered → no divot } @@ -2818,9 +2892,16 @@ mod tests { /// /// This is the test that would catch a wrong key, a stale row, or an eviction that /// keeps the wrong row — none of which the geometry tests above can see, because - /// they read a single pixel. It recomputes every output pixel from - /// [`Bus::vi_sample_direct`], which bypasses the memo entirely, and compares the - /// whole buffer. + /// they read a single pixel. It recomputes every output pixel through a second + /// sampler built with `span == 0` and compares the whole buffer. + /// + /// `span == 0` is what makes that second walk uncached, and it has to be: *both* + /// memo layers are consulted from inside [`Bus::vi_sample_direct`] now that + /// [`Bus::vi_fetch_cov_memo`] exists, so calling that function is no longer a way + /// to bypass anything. `ViSampler::new(cfg, 0, -1)` declines both layers at once + /// ([`ViSampler::cov_span`] keeps zero at zero), and the `bypass.span == 0` + /// assertion below is what holds that property — without it this test would be + /// comparing the memo against itself. /// /// The framebuffer is filled with a pattern that varies per pixel in *both* axes /// and sets coverage bits unevenly, so a sample taken from the wrong column, the diff --git a/docs/performance.md b/docs/performance.md index 5cfc902a..45080426 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2923,3 +2923,48 @@ to. stepped unconditionally, and immediately wrong under the skip, which stopped the clock with the chip. The charge now lives in `System::step_rcp` and is pinned by `an_rcp_step_is_charged_even_when_every_chip_is_idle`. + +## The VI's coverage taps were recomputed three times per pixel — 1.119x + +The scan-out's memo (`ViSampler::cells`) keys on the **output** of the filter +chain, which is the right key for the repeated sampling a 2x upscale creates and +no help at all for the divot filter — because the divot runs *inside* one `cells` +miss. `Bus::vi_divot` calls `Bus::vi_fetch_cov` three times per output pixel +(`x - 1`, `x`, `x + 1`), and advancing one column recomputes two of those three +from scratch. Each is 6 (AA-edge) or 8 (de-dither) `Bus::vi_read_cov` calls plus +its own center read. + +**Sized by elision, not by arithmetic.** The three ceilings this program produced +that did not survive being built all failed the same way — a profile share +multiplied by something it is not proportional to — so this one was sized by +stubbing the taps out and taking the delta: **16.7% of a frame (9.40 ms)**, which +is what perfect elimination would be worth (1.200x). A window memo cannot reach +all of it, so the honest band before building was **1.09–1.12x**, expecting the +low end because the window's own bookkeeping was unmeasured. + +**The fix: a second memo layer, one level lower.** `ViSampler::cov_cells` caches +`vi_fetch_cov` results keyed on `x - (x_lo - 1)` — two columns wider than `cells` +on each side, so the divot's outer taps land inside the window rather than missing +on every row's first and last column. Same two rows, same `row_y`, same eviction; +`row_slot` now clears both layers, which is mutation-checked (deleting the +`cov_cells` clear turns `memoized_scanout_matches_uncached_recomputation` red). + +| leg | mean frame | +| --- | --- | +| A no second layer | 56.832 ms | +| B `cov_cells` | 50.749 ms | +| A no second layer | 56.805 ms | +| B `cov_cells` | 50.338 ms | + +**1.119x on the conservative pairing** (worst B against best A) — the top of the +predicted band. 17.60 -> 19.79 FPS; **197 -> 176 host cycles per emulated +instruction**. All 13 Angrylion VI conformance vectors still pass byte-for-byte. + +**One thing the change had to be careful about.** `memoized_scanout_matches_uncached_recomputation` +used to describe `Bus::vi_sample_direct` as bypassing the memo, and that stopped +being true the moment the second layer was consulted from inside it. The test is +still valid — its reference walk uses `ViSampler::new(cfg, 0, -1)`, and +`cov_span()` keeps zero at zero, so *both* layers decline together — but the +property now rests entirely on the `bypass.span == 0` assertion rather than on +which function is called. Without that assertion the test would have quietly +become a comparison of the memo against itself. From a9473ee948991b6f5f337fbd23f4b1be3a51fe57 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 12:54:25 -0400 Subject: [PATCH 14/24] =?UTF-8?q?docs(perf):=20re-derive=20B2's=20ceiling?= =?UTF-8?q?=20by=20measurement=20=E2=80=94=202.6-2.7%,=20not=205.3%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0016 declined the RSP VU SIMD exception at a 5.3% / 1.056x ceiling taken from the VU census, where it was computed as 62% x 8.5% — an operation-count share multiplied by a time share. That is the same shape as the three ceilings this program produced that did not survive being built, and the census itself ended by instructing that any SIMD work be measured against it. It was. The obvious probe is invalid and the workload counters are what caught it: replacing multiply_lane's dispatch and arithmetic with an XOR measured 1.255x, but `work-counters` shows RSP instructions per frame falling 294,983 -> 121,715. The garbage results steered the microcode, so most of that delta was avoided RSP work. `retired` moved by 80 out of 173 M, so checking only the CPU counter would have passed it through. Measured instead by doubling — multiply_lane split into an inline(never) body called once or twice, with vu_acc[lane] saved and restored around the discarded pass, so final state is bit-identical and `retired` is identical in all four legs. One pass is 1.33-1.39 ms of a ~50.8 ms frame: 2.6-2.7%, a ~1.028x ceiling. The multiply/accumulate family is among the cheapest work the VU does, so 61.6% of the operations is well under 61.6% of the time. B2 stays declined; every reason for it is stronger and the cost is unchanged. Marks the census's 5.3% superseded in place so it cannot be re-cited, and adds a dated addendum to ADR 0016 rather than restating its figures — the decision is unchanged, and one that survives its headline number being halved is worth reading in the original. Co-Authored-By: Claude Opus 5 --- .../0016-scoped-simd-exception-for-the-rsp.md | 20 +++++ docs/performance.md | 76 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/docs/adr/0016-scoped-simd-exception-for-the-rsp.md b/docs/adr/0016-scoped-simd-exception-for-the-rsp.md index f6570730..9f34969e 100644 --- a/docs/adr/0016-scoped-simd-exception-for-the-rsp.md +++ b/docs/adr/0016-scoped-simd-exception-for-the-rsp.md @@ -14,6 +14,26 @@ not carry the old blanket rule alongside this exception. `docs/architecture.md` is *not* amended, and must not be cited as a source for this policy: it contains no mention of `unsafe` at all. +## Addendum, 2026-08-02 — the 5.3% ceiling was re-derived by measurement, and it is smaller + +Every figure below is built on **5.3% of a frame / 1.056x**, which this ADR took +from the VU census. That number is `62% x 8.5%` — an operation-count share +multiplied by a time share — and it has now been measured directly, by doubling +`multiply_lane` rather than by eliding it (an elision probe is invalid here: a +garbage VU result steers the microcode, and the first attempt cut RSP +instructions per frame by 59%). + +**Measured: 2.6–2.7% of a frame, a ~1.028x ceiling — half what this ADR +declined.** The multiply/accumulate family is among the *cheapest* work the VU +does, so 61.6% of the operations is well under 61.6% of the time. See +`docs/performance.md` §*`multiply_lane` measures 2.6–2.7% of a frame*. + +**Nothing in the decision changes; every reason for it gets stronger.** The +exception stays written down, unused, and recommended against, and the crate +stays `forbid`. The figures below are left as written rather than restated — +they are the record of the reasoning as it stood, and a decision that survives +its own headline number being halved is worth reading in the original. + ## Context `crates/rustyn64-rsp` carries `#![forbid(unsafe_code)]`, as every chip crate diff --git a/docs/performance.md b/docs/performance.md index 45080426..d46d6d82 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2275,6 +2275,16 @@ the real figure will be lower because the dispatch, the register reads and the accumulator writeback do not vanish. **Any SIMD work here must be measured against that ceiling, not against the 8.5%.** +> **SUPERSEDED — the 5.3% is wrong, and this paragraph is the reason it needed +> checking.** `62% x 8.5%` multiplies an operation-count share by a time share, +> which assumes `multiply_lane` costs what the average `vu.rs` function costs. +> Measured by doubling, it is **2.6–2.7% of a frame** — a ~1.028x ceiling, half +> this figure — because the multiply/accumulate family is among the *cheapest* +> things the VU does while the transcendental and permute families it was +> averaged against are the dearest. See §*`multiply_lane` measures 2.6–2.7% of a +> frame*. The instruction in bold above stands; it was followed, and the answer +> changed. + **The figures above are a delta over the timed window.** The first version reported the raw cumulative counters, folding ~36 warm-up frames into a table captioned "120 frames" — caught in review. The effect turned out to be 0.06% @@ -2968,3 +2978,69 @@ still valid — its reference walk uses `ViSampler::new(cfg, 0, -1)`, and property now rests entirely on the `bypass.span == 0` assertion rather than on which function is called. Without that assertion the test would have quietly become a comparison of the memo against itself. + +## `multiply_lane` measures 2.6–2.7% of a frame, not 5.3% — and the first probe was invalid + +The VU census above ended with an instruction: *"Any SIMD work here must be +measured against that ceiling, not against the 8.5%."* The ceiling it set — +**5.3%, 1.056x** — was itself `62% x 8.5%`: an **operation-count** share +multiplied by a **time** share. That is the same shape as the three ceilings this +program produced that did not survive being built, so it was re-derived by +measurement. + +### The obvious probe was contaminated, and the counters caught it + +Replacing `multiply_lane`'s 16-arm dispatch and all its arithmetic with one XOR +measured **50.25 -> 40.03 ms, a 1.255x speedup** — `multiply_lane` apparently +20.4% of a frame, four times the census figure. + +It is not. `work-counters` shows why: + +| | real | elided | +| --- | --- | --- | +| CPU instructions / frame | 1,443,787 | 1,443,787 | +| **RSP instructions / frame** | **294,983** | **121,715** | +| COP2 computational ops (120 frames) | 14,569,003 | 4,462,734 | + +The garbage results changed the microcode's own control flow: the RSP executed +**59% fewer instructions**. Most of that 10.3 ms was *avoided RSP work*, not +`multiply_lane`'s cost. The CPU counter was unmoved — 80 instructions out of +173 M — which is exactly why checking only `retired` would have passed this +through. **An elision probe is only valid where the elided value cannot steer +the machine, and a VU result steers the RSP.** + +### Doubling instead, because it cannot change control flow + +The inverted method from ruled-out #6: **make the work bigger and take the +delta.** `multiply_lane` was split into a `#[inline(never)] multiply_lane_body` +called once (A) or twice (B), with `vu_acc[lane]` saved and restored around the +discarded first pass so the final machine state is bit-identical. Both legs carry +the `inline(never)`, so the differential is one body call and nothing else. + +| leg | mean frame | retired | +| --- | --- | --- | +| A 1x | 50.814 ms | 173254496 | +| B 2x | 52.141 ms | 173254496 | +| A 1x | 50.743 ms | 173254496 | +| B 2x | 52.205 ms | 173254496 | + +**`retired` is identical in all four legs** — the workload did not move. + +**One `multiply_lane` pass is 1.33–1.39 ms of a ~50.8 ms frame: 2.6–2.7%.** At +598,507 calls per frame (74,813 multiply-family ops x 8 lanes) that is ~2.3 ns, +about 11 host cycles for a call, a 16-arm match and a 64-bit multiply — a +plausible figure, which the 20.4% was not. + +### Why the arithmetic was wrong, and it is not the usual reason + +The census assumed `multiply_lane` costs what the average `vu.rs` function costs, +because it multiplied an op-count share by a time share. It does not: `VMADN`, +`VMADH` and `VMUDL` are among the *cheapest* things the VU does, while the +transcendental, permute and compare families it was averaged against are the +expensive ones. **61.6% of the operations are well under 61.6% of the time.** + +So the ceiling on a perfect vectorization of `multiply_lane` is about **1.028x**, +not 1.056x — half what ADR 0016 declined it at, against a 1.5x bar, and with the +cost unchanged: it drops `crates/rustyn64-rsp` from `forbid(unsafe_code)` to +`deny`. **B2 stays declined, now on a measurement rather than on a product of two +shares.** The census's 5.3% should be read as superseded by this section. From fce7153e3c95b0514e647627b0952654d7312b84 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 13:41:47 -0400 Subject: [PATCH 15/24] =?UTF-8?q?perf(cpu):=20skip=20the=20N64=20idle=20lo?= =?UTF-8?q?op=20in=20fast-exec=20=E2=80=94=201.59x=20to=202.05x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sizing the block cache found something far larger. Two independent methods put the average sequential run at 2.14 instructions, and a PC histogram says why: `beq $0,$0,-1` with a `nop` delay slot accounts for ~95% of every instruction Super Mario 64 retires and ~90% of Mario Kart 64's. It is the N64 idle thread, and the emulator was faithfully burning host cycles on it. Skipping it is not an approximation. After the pair the PC is back where it started and the only state either instruction touches is `Count` — derived from the scheduler's tick — and `Random`, which the skip reproduces by hand. A-B-A-B, conservative pairing: OoT 2.046x (52.5 FPS), Mario Kart 64 1.836x (41.3 FPS), Super Mario 64 1.589x (31.6 FPS), Banjo-Kazooie neutral at 1.003x — the control, whose run length is 5.93 because it does not use this loop. `retired` is identical in every leg of every title. Caching the recognition is as correct as the hardware: the N64 does not snoop DMA against the I-cache, so software that overwrites code must issue CACHE itself and until it does the CPU executes the stale line. `cache_op` clears the recognition at the single entry point for every CACHE variant, and so does taking any exception. Both hooks are mutation-checked — deleting the cache hook or the `Random` ticks each turns a specific test red, and the second names COP0 register 1 in its message. Gates: n64-systemtest unchanged under fast-exec (Phase 1 and RSP categories Failed: 0, 90 suite-wide), clippy clean under both feature sets, no_std builds. Adds nothing to ledger C-16 and needs no ADR — it lives inside the execution mode ADR 0013 already authorizes, and unlike that mode it introduces no divergence at all. Extracts `apply_cop2` and `recognize_idle_loop` from `execute_one`, which the added line pushed over the too_many_lines gate. Co-Authored-By: Claude Opus 5 --- crates/rustyn64-cpu/src/pipeline.rs | 29 +++ crates/rustyn64-cpu/src/pipeline/fastexec.rs | 239 +++++++++++++++++-- docs/cpu.md | 37 +++ docs/performance.md | 66 +++++ 4 files changed, 350 insertions(+), 21 deletions(-) diff --git a/crates/rustyn64-cpu/src/pipeline.rs b/crates/rustyn64-cpu/src/pipeline.rs index c0b855f2..55f908d7 100644 --- a/crates/rustyn64-cpu/src/pipeline.rs +++ b/crates/rustyn64-cpu/src/pipeline.rs @@ -510,6 +510,26 @@ pub struct Pipeline { pub tlb: Tlb, /// The 16 KiB primary instruction cache (T-11-003). pub icache: crate::cache::Icache, + /// The PC of a recognized self-branch idle loop, or `None` (ADR 0013 mode only). + /// + /// `beq $0, $0, -1` followed by a `nop` is a branch to itself: after both + /// instructions the PC is back where it started and no register, no memory + /// word and no COP0 field other than `Count` and `Random` has changed. Super + /// Mario 64 and Mario Kart 64 spend roughly **90% of every instruction they + /// retire** in exactly that loop — it is the N64 idle thread — so recognizing + /// it and charging its cost without fetching, decoding or executing it is + /// worth 1.65x and 1.93x respectively (`docs/performance.md`). + /// + /// **Why caching this is as correct as the hardware.** The N64 does not snoop + /// DMA against the I-cache: software that overwrites code must issue a `CACHE` + /// instruction itself, and until it does the CPU keeps executing the stale + /// cached line. So a recognition keyed to the I-cache's own lifetime cannot + /// diverge from the machine — [`Pipeline::cache_op`] clears this, which covers + /// every invalidate, fill and tag write, and so does taking any exception. + /// + /// Kept on `Pipeline` rather than in `fastexec` so it lives with the `icache` + /// whose validity it borrows; it is simply never set on the accurate path. + pub(crate) idle_pc: Option, /// The 8 KiB primary write-back data cache (T-11-003). pub dcache: crate::cache::Dcache, /// The COP0 register file (T-12-001). @@ -584,6 +604,7 @@ impl Pipeline { fpr: Fpr::new(), tlb: Tlb::new(), icache: crate::cache::Icache::new(), + idle_pc: None, dcache: crate::cache::Dcache::new(), cop0: Cop0::new(), ll_bit: false, @@ -1784,6 +1805,14 @@ impl Pipeline { /// /// A TLB fault, on the address-addressed operations only. fn cache_op(&mut self, bus: &mut B, addr: u64, op: u8) -> Result { + // Any cache operation can change what the I-cache would serve, so the + // idle-loop recognition that borrows the I-cache's validity stops being + // valid here. This hook is the ONLY thing that makes caching that + // recognition sound (see `Pipeline::idle_pc`), so it sits at the single + // entry point for every CACHE variant rather than at the invalidating + // ones — a narrower hook would have to be re-argued every time a variant + // is added, and being wrong about one would be silent. + self.idle_pc = None; if (op >> 2) >= 3 { let p = self.translate_data(addr, false)?; self.cache_hit_op(bus, op, p.addr); diff --git a/crates/rustyn64-cpu/src/pipeline/fastexec.rs b/crates/rustyn64-cpu/src/pipeline/fastexec.rs index 10285074..bfb88370 100644 --- a/crates/rustyn64-cpu/src/pipeline/fastexec.rs +++ b/crates/rustyn64-cpu/src/pipeline/fastexec.rs @@ -82,6 +82,15 @@ use crate::Bus; use crate::decode::decode; + +/// `beq $0, $0, -1` — a branch whose target is the branch itself. +const SELF_BRANCH: u32 = 0x1000_FFFF; +/// The delay slot an idle loop must have for the skip to be state-preserving. +const SLOT_NOP: u32 = 0x0000_0000; +/// What the skipped pair costs: one `PCycle` to issue each (`execute_one`'s base +/// charge). Neither can request a stall — a `beq` reads no memory and a `nop` +/// does nothing — so there is no variable part to model. +const IDLE_LOOP_PCYCLES: u32 = 2; use crate::exception; use crate::exec::{Cop0Access, WriteBack, execute}; use crate::regs::Regs; @@ -159,6 +168,21 @@ impl Pipeline { return self.vector_to(Exception::Interrupt, *next_pc, false, 0, next_pc); } + // **The idle-loop skip** (see [`Pipeline::idle_pc`]). Reached only after the + // NMI and interrupt checks above, so the loop is left on exactly the cycle + // an interrupt would have left it. + // + // This is not an approximation of running the two instructions: `beq $0, + // $0, -1` puts the PC back at itself and its `nop` slot does nothing, so + // the only state either one touches is what is reproduced here. `Count` is + // derived from the scheduler's tick and needs nothing. + if self.idle_pc == Some(*next_pc) { + self.retired = self.retired.wrapping_add(2); + self.cop0.tick_random(); + self.cop0.tick_random(); + return IDLE_LOOP_PCYCLES; + } + let mut cost = 0u32; let mut pc = *next_pc; // `fall` is where control goes if this instruction does not branch; @@ -238,6 +262,8 @@ impl Pipeline { // A micro-ITLB reload charged by `fetch_word` (UM §4.6.2). cost = cost.saturating_add(self.take_stall_cost()); + self.recognize_idle_loop(bus, word, pc, in_delay_slot); + let decoded = decode(word); if let Err(exc) = self.ex_gate(decoded) { let c = self.vector_to(exc, pc, in_delay_slot, 0, next_pc); @@ -275,27 +301,7 @@ impl Pipeline { #[cfg(feature = "work-counters")] self.count_commit(e.cop0, e.mem, e.write_back); - // COP2 is one 64-bit latch rather than a register file; the index is - // ignored (ledger C-15's shape, twice over). Handled here for the same - // reason `ex_stage` handles it: it needs the `rt` value, which `execute` - // cannot reach. - let mut write_back = e.write_back; - match decoded.op { - crate::decode::Op::Mtc2 => self.cop2_latch = target, - crate::decode::Op::Mfc2 => { - write_back = WriteBack::Gpr { - dest: decoded.dest, - value: crate::alu::sext32(self.cop2_latch as u32), - }; - } - crate::decode::Op::Dmfc2 => { - write_back = WriteBack::Gpr { - dest: decoded.dest, - value: self.cop2_latch, - }; - } - _ => {} - } + let write_back = self.apply_cop2(decoded, target, e.write_back); // ---- The commit fast path ---- // @@ -425,6 +431,62 @@ impl Pipeline { /// rather than going through `abort_from`, which captures its context out of /// the latch belonging to a [`Stage`](super::Stage) — a selection that has no /// meaning without a pipeline. + /// Fold the COP2 move family into a write-back. + /// + /// COP2 is one 64-bit latch rather than a register file, and the register + /// index is ignored (ledger C-15's shape, twice over). Handled outside + /// `execute` for the same reason `ex_stage` handles it: it needs the `rt` + /// value, which `execute` cannot reach. + fn apply_cop2( + &mut self, + decoded: crate::decode::Decoded, + target: u64, + write_back: WriteBack, + ) -> WriteBack { + match decoded.op { + crate::decode::Op::Mtc2 => { + self.cop2_latch = target; + write_back + } + crate::decode::Op::Mfc2 => WriteBack::Gpr { + dest: decoded.dest, + value: crate::alu::sext32(self.cop2_latch as u32), + }, + crate::decode::Op::Dmfc2 => WriteBack::Gpr { + dest: decoded.dest, + value: self.cop2_latch, + }, + _ => write_back, + } + } + + /// Recognize a self-branch idle loop at `pc` on the way past, for + /// [`Pipeline::idle_pc`] to skip on later iterations. + /// + /// The delay-slot fetch is the price of admission and is paid **once per + /// recognition**, not per iteration. `in_delay_slot` excludes the slot + /// itself: a `beq $0, $0, -1` sitting in some other branch's delay slot is + /// not a loop head, and treating it as one would skip an instruction that + /// really does execute. + fn recognize_idle_loop( + &mut self, + bus: &mut B, + word: u32, + pc: u64, + in_delay_slot: bool, + ) { + if word != SELF_BRANCH || in_delay_slot || self.idle_pc.is_some() { + return; + } + if self.fetch_word(bus, pc.wrapping_add(4)) == Ok(SLOT_NOP) { + self.idle_pc = Some(pc); + } + // Whatever that fetch charged is dropped. The slot's real fetch happens on + // the iteration that actually runs it, and charging both would make + // recognition cost a PCycle the hardware does not spend. + let _ = self.take_stall_cost(); + } + fn vector_to( &mut self, exc: Exception, @@ -433,6 +495,11 @@ impl Pipeline { bad_vaddr: u64, next_pc: &mut u64, ) -> u32 { + // Control is leaving wherever it was, and the handler may well be what + // rewrites the code the recognition was made against. Dropping it here + // costs one re-recognition per idle period and removes a whole class of + // question about how long the cached PC may live. + self.idle_pc = None; let d = exception::dispatch(&mut self.cop0, exc, pc, in_delay_slot, bad_vaddr); *next_pc = d.vector; // Discard any stall the raising code requested: the exception abandons the @@ -453,3 +520,133 @@ impl Pipeline { self.stall.take().map_or(0, |s| s.cycles) } } + +#[cfg(test)] +mod idle_loop_tests { + use super::Bus; + use super::{IDLE_LOOP_PCYCLES, SELF_BRANCH, SLOT_NOP}; + use crate::Cpu; + + /// A bus holding a program at address 0 and `NOP` past its end. + struct Rom { + words: alloc::vec::Vec, + } + impl Bus for Rom { + fn read_u8(&mut self, _addr: u32) -> u8 { + 0 + } + fn write_u8(&mut self, _addr: u32, _val: u8) {} + fn read_u32(&mut self, addr: u32) -> u32 { + self.words.get((addr / 4) as usize).copied().unwrap_or(0) + } + } + + /// A CPU parked at the idle loop, which sits at physical 0x100 (`KSEG0`). + fn parked() -> (Cpu, Rom) { + let mut words = alloc::vec![0u32; 0x80]; + words[0x40] = SELF_BRANCH; // 0x100 + words[0x41] = SLOT_NOP; // 0x104 + let mut cpu = Cpu::new(); + cpu.set_pc(0xFFFF_FFFF_8000_0100); + (cpu, Rom { words }) + } + + /// **The property the whole optimization rests on:** skipping the loop must + /// leave the machine in the state executing it would have. + /// + /// It compares the FULL architectural state — every GPR, the PC, `HI`/`LO`, + /// and the COP0 file — not a summary. A test that compared only the PC would + /// pass with `Random` frozen, which is the one field the skip has to + /// reproduce by hand and therefore the one most likely to be wrong. + #[test] + fn skipping_the_idle_loop_matches_executing_it() { + // Leg A: the recognition is defeated by clearing `idle_pc` after every + // step, so every iteration runs the real fetch/decode/execute path. + let (mut a, mut a_bus) = parked(); + for i in 0..64 { + a.pipeline.idle_pc = None; + let _ = a.step_instruction_at(&mut a_bus, i); + } + + // Leg B: the skip is allowed to work. + let (mut b, mut b_bus) = parked(); + for i in 0..64 { + let _ = b.step_instruction_at(&mut b_bus, i); + } + + assert_eq!(b.pc, a.pc, "the skip left the PC somewhere else"); + assert_eq!(b.retired, a.retired, "the skip retired a different count"); + for r in 0..32 { + assert_eq!(b.regs.read(r), a.regs.read(r), "GPR {r} diverged"); + } + for r in 0..32 { + assert_eq!( + b.pipeline.cop0.read(r), + a.pipeline.cop0.read(r), + "COP0 register {r} diverged — `Random` is the likely one" + ); + } + } + + /// The recognition is only sound because a `CACHE` operation retires it. With + /// that hook gone this test fails, which is what makes it a guard rather than + /// a description: mutation-checked by deleting `self.idle_pc = None` from + /// `Pipeline::cache_op`. + #[test] + fn a_cache_operation_retires_the_recognition() { + let (mut cpu, mut bus) = parked(); + let _ = cpu.step_instruction_at(&mut bus, 0); + assert_eq!( + cpu.pipeline.idle_pc, + Some(0xFFFF_FFFF_8000_0100), + "the loop was never recognized, so this test cannot see the hook" + ); + // `CACHE 0, 0($0)` — index-invalidate on the I-cache. + let _ = cpu.pipeline.cache_op(&mut bus, 0xFFFF_FFFF_8000_0000, 0); + assert_eq!( + cpu.pipeline.idle_pc, None, + "a cache operation must retire the idle-loop recognition; without \ + this the skip could outlive the code it was made against" + ); + } + + /// A self-branch sitting in someone else's delay slot is not a loop head, and + /// recognizing it would skip an instruction that really does execute. + #[test] + fn a_self_branch_in_a_delay_slot_is_not_a_loop_head() { + // `BEQ $0, $0, +1` at 0x100 jumps over the word after its delay slot; the + // delay slot at 0x104 is itself a self-branch. + let mut words = alloc::vec![0u32; 0x80]; + words[0x40] = 0x1000_0001; + words[0x41] = SELF_BRANCH; + let mut cpu = Cpu::new(); + cpu.set_pc(0xFFFF_FFFF_8000_0100); + let mut bus = Rom { words }; + let _ = cpu.step_instruction_at(&mut bus, 0); + assert_eq!( + cpu.pipeline.idle_pc, None, + "a self-branch in a delay slot was mistaken for an idle loop" + ); + } + + /// The skip charges what the two instructions charge. `execute_one` bills one + /// `PCycle` to issue and neither a `beq` nor a `nop` can request a stall, so + /// the pair is exactly two — and a wrong constant here would silently change + /// how fast emulated time runs. + #[test] + fn the_skip_charges_what_the_pair_charges() { + let (mut real, mut real_bus) = parked(); + real.pipeline.idle_pc = None; + let first = real.step_instruction_at(&mut real_bus, 0); + + let (mut skipped, mut skipped_bus) = parked(); + let _ = skipped.step_instruction_at(&mut skipped_bus, 0); // recognizes + let cost = skipped.step_instruction_at(&mut skipped_bus, 1); // skips + assert_eq!(cost, IDLE_LOOP_PCYCLES); + assert_eq!( + cost, first, + "the skip and the executed pair must cost the same, or emulated time \ + runs at a different rate inside an idle loop than outside it" + ); + } +} diff --git a/docs/cpu.md b/docs/cpu.md index 0b305277..df64d09b 100644 --- a/docs/cpu.md +++ b/docs/cpu.md @@ -746,6 +746,43 @@ programs of the same length differing only in `MULT`/`DIV` versus `NOP`. A first version used a threshold on a single run and **passed with the charge deleted** because eight cold I-cache fills cleared the bar on their own. +#### The idle-loop skip + +`beq $0, $0, -1` followed by a `nop` is a branch to itself. After both +instructions the PC is back where it started, and the only state either one +touches is `Count` (derived from the scheduler's tick) and `Random`. So +recognizing the pair and charging its two `PCycle`s **is** executing it — not an +approximation of it — and `Pipeline::idle_pc` does exactly that. + +It matters because it is not a corner case. It is the N64 idle thread, and it is +where most titles spend most of their CPU: + +| title | share of retired instructions | frame | +| --- | --- | --- | +| Super Mario 64 | ~95% | 50.27 -> 31.64 ms (**1.589x**) | +| Mario Kart 64 | ~90% | 44.40 -> 24.18 ms (**1.836x**) | +| Zelda: Ocarina of Time | (not histogrammed) | 38.98 -> 19.06 ms (**2.046x**) | +| Banjo-Kazooie | does not use this loop | 46.83 -> 46.67 ms (neutral) | + +**Why caching the recognition is as correct as the hardware.** The N64 does not +snoop DMA against the I-cache: software that overwrites code must issue a `CACHE` +instruction itself, and until it does the CPU keeps executing the stale cached +line. A recognition keyed to the I-cache's own lifetime therefore cannot diverge +from the machine. `Pipeline::cache_op` clears it — at the single entry point for +every `CACHE` variant, not only the invalidating ones — and so does taking any +exception. + +**What it does not change.** `retired` is bit-identical to the executing path on +all five ROMs measured, n64-systemtest is unchanged (Phase 1 and RSP categories +`Failed: 0`, 90 suite-wide), and the skip is reached only after NMI and interrupt +recognition, so the loop is left on exactly the cycle an interrupt would leave +it. It adds nothing to ledger C-16. + +**What it is not, yet.** The skip is per-iteration: it still returns to the +scheduler every two idle instructions and still pays `sample_interrupt_lines` +there. Jumping straight to the next scheduled event would remove that too, and +needs the event-driven scheduler rather than this. + ### Exceptions Address-error (unaligned), TLB refill/invalid/modified, integer overflow diff --git a/docs/performance.md b/docs/performance.md index d46d6d82..a1a3e963 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -3044,3 +3044,69 @@ not 1.056x — half what ADR 0016 declined it at, against a 1.5x bar, and with t cost unchanged: it drops `crates/rustyn64-rsp` from `forbid(unsafe_code)` to `deny`. **B2 stays declined, now on a measurement rather than on a product of two shares.** The census's 5.3% should be read as superseded by this section. + +## Most titles spend ~90% of their CPU in a two-instruction idle loop — 1.59x to 2.05x + +Sizing the block cache found something much larger than the block cache. Two +independent methods put the average sequential run at **2.14 instructions**, and +a PC histogram says why: + +```text +0xffffffff80246dd8 word=0x1000ffff 97,122,219 47.70% +0xffffffff80246ddc word=0x00000000 97,122,219 47.70% +``` + +`0x1000ffff` is `beq $0, $0, -1` — a branch to itself — with a `nop` delay slot. +**~95% of every CPU instruction Super Mario 64 retires is that two-instruction +spin.** Mario Kart 64 is identical in shape at a different address (~90%). It is +the N64 idle thread: the CPU waiting for the RCP, and this emulator was faithfully +burning host cycles on it. + +### Why this had to come before the block cache + +A block cache measured against a workload that is 90% a two-instruction loop +would have looked spectacular and then mostly evaporated once the loop stopped +executing. The same distortion explains the CPU-bucket shares this document has +been quoting for months: they were dominated by an idle loop nobody had looked at. + +### The result + +Skipping it is not an approximation. After `beq $0,$0,-1` and its `nop` the PC is +back where it started and the only state either instruction touches is `Count` +(derived) and `Random` (reproduced by hand). A-B-A-B, conservative pairing: + +| title | base | idle skip | | +| --- | --- | --- | --- | +| Zelda: Ocarina of Time | 38.980 ms | 19.056 ms | **2.046x** -> 52.5 FPS | +| Mario Kart 64 | 44.404 ms | 24.184 ms | **1.836x** -> 41.3 FPS | +| Super Mario 64 | 50.274 ms | 31.639 ms | **1.589x** -> 31.6 FPS | +| Banjo-Kazooie | 46.829 ms | 46.671 ms | 1.003x — neutral | + +**`retired` is identical in every leg of every title**, which is the strongest +available statement that the accounting is exact rather than approximately right. +Banjo-Kazooie is the control: its sequential-run length is 5.93, it does not use +this loop, and it moves 0.3%. + +For scale, everything else measured in this program: the VI coverage memo 1.119x, +`multiply_lane`'s entire vectorization ceiling 1.028x, the event scheduler 0.11%. + +### The probe that was wrong first, and how it was caught + +The obvious way to size this was to elide `multiply_lane`-style — replace the +work and take the delta. Applied to the CPU's fetch it gave a clean-looking +**32.8% of a frame**, which exceeds the entire CPU bucket. It was `black_box` +holding the duplicated `decode` un-fused, measuring a standalone decode rather +than the one the loop actually emits — the same mechanism that made the decode +cache's 8.1% probe turn into a 1.0% regression. Splitting it gave fetch ~25.8% +and standalone decode ~12.8%, and neither number survives being added. + +`retired` was identical across all six legs, so the workload never moved. That is +the check that makes a doubling probe trustworthy and an elision probe suspect: +**an elision is only valid where the elided value cannot steer the machine.** + +### What it is not, yet + +The skip is per-iteration — it still returns to the scheduler every two idle +instructions and still pays `sample_interrupt_lines` there. Jumping straight to +the next scheduled event would remove that too, and belongs with the event-driven +scheduler. From 860f32d86bb28d81ec524a0ffa89970e0f98f31d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 13:43:32 -0400 Subject: [PATCH 16/24] docs(perf): re-derive the declined backlog against a frame half the size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backlog was priced at ~1.12x / +1.8 FPS and each item declined on that basis. The arithmetic was right and is now stale: every figure in it was a share of a 65.3 ms frame, and the idle-loop skip, the fast commit and the VI memo removed CPU and VI cost rather than RDP, GPU or RSP cost. The backlog's absolute cost is unchanged while the frame halved, so its share doubled. The multiplier barely moves (1.115x) but the FPS does, because FPS is not linear in frame time: +3.6 FPS on Super Mario 64 rather than +1.8, and on Ocarina of Time 52.5 -> 58.6, which puts a real title within a percent of the target. None of the three is free — A3 needs an ADR and double-buffered RDRAM, A4 reopens ADR 0015's determinism argument, B2 costs ADR 0016's unsafe exception at a ceiling that was just re-measured downward. Also marks §The honest position on 60 FPS superseded at its head. It concluded 60 FPS was unreachable by reasoning from profile shares of the existing execution path; what closed the gap was noticing that ~90% of the instructions did not need to run. Kept as the record of the reasoning, not as a claim. Co-Authored-By: Claude Opus 5 --- docs/performance.md | 60 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/performance.md b/docs/performance.md index a1a3e963..cbb9c4f3 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2707,7 +2707,9 @@ a range too. Against the `fast-exec` frame of 65.3 ms / 15.31 FPS: | all small wins, taken perfectly | 58.4–58.2 ms | **17.1–17.2** | | gain | −6.9 to −7.1 ms | **+1.8 to +1.9 FPS** | -**+1.8 FPS, and 60 FPS is still 3.5x away.** Every figure above is a ceiling +**+1.8 FPS, and 60 FPS is still 3.5x away.** — *re-derived: the same bundle is +worth +3.6 FPS against today's frame, because these shares were of a 65.3 ms +frame that has since halved. See §*The declined backlog, re-derived*.* Every figure above is a ceiling assuming the work is removed *for free*; real implementations pay dispatch, synchronization and bookkeeping the ceilings do not charge for. B2 additionally costs an `unsafe` exception in a chip crate, A3 needs double-buffered RDRAM and @@ -2724,6 +2726,13 @@ than its parts — that is what the disjointness table is for. ### The honest position on 60 FPS +> **SUPERSEDED, 2026-08-02.** This section reasoned entirely from profile shares +> of the existing execution path, and what closed most of the gap was not a +> faster version of any bucket in it — it was that ~90% of the instructions did +> not need to run at all. Ocarina of Time is at **52.5 FPS** today. Kept as the +> record of the reasoning; do not cite it as a current claim. See §*The declined +> backlog, re-derived against a frame half the size*. + **It is not reachable from here.** 60 FPS needs 16.67 ms against the 65.3 ms `fast-exec` frame — a **3.92x** gap — and the two largest levers are now both declined on their own arithmetic: @@ -3110,3 +3119,52 @@ The skip is per-iteration — it still returns to the scheduler every two idle instructions and still pays `sample_interrupt_lines` there. Jumping straight to the next scheduled event would remove that too, and belongs with the event-driven scheduler. + +## The declined backlog, re-derived against a frame half the size + +`§Where the optimization program ended` priced the entire declined backlog at +**~1.12x / +1.8 FPS** and used that to argue each item was not worth taking. That +arithmetic was correct and it is now stale, for a reason worth stating plainly: +**every one of those figures was a share of a 65.3 ms frame.** The idle-loop skip, +the fast commit and the VI coverage memo removed CPU and VI cost, not RDP, GPU or +RSP cost — so the backlog's *absolute* cost is unchanged while the frame it is +measured against has halved. Its share roughly doubled. + +| item | as measured | absolute | share of a 31.6 ms frame | +| --- | --- | --- | --- | +| **A3** async RDP | 1.7% of 65.3 ms | 1.11 ms | 3.5% | +| **A4** GPU as rasterizer | 1.23% of 65.3 ms | 0.80 ms | 2.5% | +| **B2** VU vectorization | 2.6–2.7% of 50.8 ms | 1.36 ms | 4.3% | +| **all three, taken perfectly** | | **3.27 ms** | **10.3% -> 1.115x** | + +The multiplier barely moves; **the FPS it buys does**, because FPS is not linear +in frame time. Against Super Mario 64's 31.6 ms that is 31.6 -> 28.4 ms, **31.6 -> +35.2 FPS (+3.6)** — double the +1.8 the same bundle was worth before. Against +Ocarina of Time's 19.06 ms it is 19.06 -> 17.1 ms, **52.5 -> 58.6 FPS**, which +puts a real title within a percent of the target. + +**None of the three is free, and none of them is a code change alone.** + +- **A3** needs double-buffered RDRAM and its own ADR. +- **A4** changes what lands in RDRAM, so it reopens the determinism argument in + ADR 0015 — a maintainer decision, not an implementation one. +- **B2** costs the `unsafe` exception ADR 0016 wrote down and recommended + against, and its ceiling was re-measured *downward* (§*`multiply_lane` measures + 2.6–2.7%*). + +**And the rest of the pile is still worth nothing.** The VU family hoist +(neutral), `-C target-cpu=native` (neutral), PGO (4.96% slower), the decode cache +(1.0% slower), the `Latch` split (premise refuted), the RSP idle-step skip (0.11%, +built and reverted), the MMIO read ordering (0.34% slower). Taking those would be +a regression, not a small win — the distinction the original section drew, and it +still holds. + +### The 60 FPS position has changed, and the old one should not be quoted + +`§The honest position on 60 FPS` said 60 FPS "is not reachable from here", +against a 3.92x gap and two declined levers. That conclusion is **superseded**: it +was reasoned entirely from profile shares of the existing execution path, and the +thing that closed most of the gap was not a faster version of any bucket in that +profile — it was noticing that ~90% of the instructions did not need to run at +all. Ocarina of Time is at 52.5 FPS today. The section stands as a record of the +reasoning; it should not be cited as a current claim. From a2b76b11dc3223ccaad6d7b265a613200e79b2ed Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 13:48:20 -0400 Subject: [PATCH 17/24] =?UTF-8?q?docs(adr):=20decide=20A3,=20A4=20and=20B2?= =?UTF-8?q?=20=E2=80=94=200018,=200019,=20and=200020=20amending=200016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ADRs for the three items in the declined backlog that measure positive. Each is written on the re-derived numbers rather than the ones they were declined on, since those were shares of a frame that has since halved. 0018 (async GPU RDP) accepts shape (b) — present one frame late — as an opt-in that is off by default, because its cost is a frame of presentation latency and ~3.5% is not enough to spend a user's latency budget for them. Records that shape (a) is unavailable: `present` stages RDRAM before enqueueing, so submitting mid-frame would change which memory each command reads. Also names a GPU-to-GPU ordering hazard the plan missed, which is NOT the CPU-side tracker ADR 0014 §6 describes. 0019 (GPU as the machine's rasterizer) accepts A4 as an opt-in but BLOCKS building it until the GPU/Angrylion census reaches 43/43. This is the finding that changed the shape of the decision: the GPU is currently LESS complete than the software rasterizer it would replace (`key_en`, #160), so shipping it for ~2.5% would trade correctness for frame time. The ADR inverts the justification — A4 is worth building as an ACCURACY change, with the frame time as a side effect, and that reframing is what makes the gate load-bearing. 0020 amends 0016 to accept the SIMD exception for `multiply_lane` only, and says plainly that the technique got worse while the context got better: 0016 declined 1.056x, this accepts 1.045x. The four gates carry forward unrelaxed, and the ADR states in advance what would make it a mistake — that a hard-to-write equivalence test is a reason to stop, not to sample. Co-Authored-By: Claude Opus 5 --- .../0016-scoped-simd-exception-for-the-rsp.md | 5 + .../0018-asynchronous-gpu-rdp-presentation.md | 95 +++++++++++++++++++ .../0019-gpu-as-the-machines-rasterizer.md | 90 ++++++++++++++++++ ...20-amend-0016-accept-the-simd-exception.md | 77 +++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 docs/adr/0018-asynchronous-gpu-rdp-presentation.md create mode 100644 docs/adr/0019-gpu-as-the-machines-rasterizer.md create mode 100644 docs/adr/0020-amend-0016-accept-the-simd-exception.md diff --git a/docs/adr/0016-scoped-simd-exception-for-the-rsp.md b/docs/adr/0016-scoped-simd-exception-for-the-rsp.md index 9f34969e..7da9c40c 100644 --- a/docs/adr/0016-scoped-simd-exception-for-the-rsp.md +++ b/docs/adr/0016-scoped-simd-exception-for-the-rsp.md @@ -14,6 +14,11 @@ not carry the old blanket rule alongside this exception. `docs/architecture.md` is *not* amended, and must not be cited as a source for this policy: it contains no mention of `unsafe` at all. +> **AMENDED by [ADR 0020](0020-amend-0016-accept-the-simd-exception.md), +> 2026-08-02.** The recommendation below ("do not use this exception") is no +> longer current: 0020 accepts it for `multiply_lane` only. The four gates in +> this ADR are carried forward unchanged and remain the operative requirements. + ## Addendum, 2026-08-02 — the 5.3% ceiling was re-derived by measurement, and it is smaller Every figure below is built on **5.3% of a frame / 1.056x**, which this ADR took diff --git a/docs/adr/0018-asynchronous-gpu-rdp-presentation.md b/docs/adr/0018-asynchronous-gpu-rdp-presentation.md new file mode 100644 index 00000000..ddbda84b --- /dev/null +++ b/docs/adr/0018-asynchronous-gpu-rdp-presentation.md @@ -0,0 +1,95 @@ +# 0018 — Asynchronous GPU RDP presentation, one frame late + +Status: **Accepted**, as an opt-in that is **off by default**. +Date: 2026-08-02 +Deciders: repo owner +Supersedes: none · Superseded by: none +Extends: ADR 0014 (GPU-backed RDP) and ADR 0015 (GPU determinism scope). It +contradicts neither; §*No GPU-to-CPU tracker* below carries ADR 0015's own +reasoning one step further. + +## Context + +`present` stalls the emulation thread on `scanout_sync`'s fence, waiting for the +GPU to finish rasterizing the frame it just submitted. Measured on +`frame_bench --features gpu-rdp,fast-exec,fast-scheduler`, Super Mario 64: + +| | ms/frame | +| --- | --- | +| wait for RDP rasterization | ~1.06 | +| VI pass + read-back + host copies | ~1.39 | + +**1.06 ms is what this ADR is about.** It was 1.7% of the 63.2 ms frame it was +measured against. That frame is now ~31.6 ms, so the same absolute cost is +**~3.5%** — the item did not get better, the rest of the emulator got faster +around it (`docs/performance.md` §*The declined backlog, re-derived*). + +### Two shapes, and only one is available + +**(a) Submit commands as the frame runs.** The GPU would be busy during +emulation and the fence already signaled at present time — the full win, no shim +work. **Unavailable.** `present` stages RDRAM *before* enqueueing any command, so +every command in a frame currently executes against **end-of-frame RDRAM**. +Submitting mid-frame requires staging mid-frame, which changes *which* memory +contents each command reads. That is a change to the presented picture, not a +scheduling change, and it would force the dirty-page map (#245) to clear and +re-accumulate per sub-frame. + +**(b) Do not block; present one frame late.** Submit at present time exactly as +now, signal the timeline, and read back the *previous* frame's result. Every +command still sees end-of-frame RDRAM, so the picture is unchanged; the wait +simply moves off the critical path. + +### The hazard (b) introduces, which is new and is not the one ADR 0014 names + +ADR 0014 §6 calls for a GPU-to-CPU hazard tracker. That does not apply here: this +backend **owns its RDRAM** (ADR 0015), so there is nothing for the CPU to race +against, and no tracker is needed. + +But removing the CPU-side wait does not remove ordering *between GPU +submissions*. Under (b), frame N's commands are still executing when frame N+1's +dirty-page stage begins writing the backend's RDRAM — **the same buffer the +in-flight submission reads**. The synchronous path cannot have this, because +`scanout_sync` drains everything before the next `present` stages anything. + +## Decision + +**Implement shape (b), behind a runtime option that is off by default.** + +1. Expose `signal_timeline` and `wait_for_timeline` through the parallel-rdp + shim. This is new C++ surface and lands under ADR 0014's existing `unsafe` + quarantine in `rustyn64-rdp-gpu`; no other crate's `forbid(unsafe_code)` moves. +2. `present` submits and signals rather than draining, and reads back frame + N−1's result. +3. **The GPU-to-GPU hazard is resolved by waiting on frame N's timeline value + before staging frame N+1**, not by assuming the driver orders it. That wait is + off the emulation critical path — it happens at the *next* present, by which + time the GPU has had a full frame — which is the whole point. +4. The option is **off by default**, so the default build and the default run are + behaviorally unchanged (ADR 0011 §1's discipline, applied to a backend). + +### Why off by default, given it is a win + +Because its cost is **one frame of presentation latency**, and that is a real +cost to a person playing a game, not an accounting entry. ~16.7 ms at 60 Hz and +more below it. A user should choose that trade explicitly; ~3.5% is not enough to +make it for them. + +## Consequences + +**Gained:** ~1.06 ms/frame, ~3.5% of today's frame, when enabled. + +**Given up:** one frame of latency when enabled. Nothing when not. + +**Determinism is unaffected.** The emitted picture is identical — every command +still executes against end-of-frame RDRAM — so ADR 0015's reproducibility gate +applies unchanged and must stay green with the option both on and off. + +**A new failure mode exists and must be tested, not argued.** If the timeline +wait in (3) is wrong, frame N+1's stage corrupts frame N's read — and the symptom +is an *occasional* wrong frame under load, which is exactly the kind of defect a +single screenshot comparison passes. The gate is the ADR 0015 reproducibility +run with the option on, repeated, not one frame compared once. + +**What this does not authorize.** Shape (a). Submitting mid-frame changes what +the GPU reads and would need its own ADR and its own determinism argument. diff --git a/docs/adr/0019-gpu-as-the-machines-rasterizer.md b/docs/adr/0019-gpu-as-the-machines-rasterizer.md new file mode 100644 index 00000000..502f9fbd --- /dev/null +++ b/docs/adr/0019-gpu-as-the-machines-rasterizer.md @@ -0,0 +1,90 @@ +# 0019 — The GPU as the machine's rasterizer, gated on parity rather than on frame time + +Status: **Accepted as an opt-in that is off by default, and BLOCKED from being +built until the accuracy gate below is met.** The gate is not ceremony: with it +unmet, this change makes the emulator *less* accurate. +Date: 2026-08-02 +Deciders: repo owner +Supersedes: none · Superseded by: none +Extends: ADR 0014 (GPU-backed RDP), ADR 0015 (GPU determinism scope). Brings +**ADR 0004** (determinism) into scope for the GPU path, exactly as ADR 0015 +predicted would happen if this were ever attempted. + +## Context + +Today the GPU backend is a **display** backend: it renders what the machine has +already produced, and the machine's own RDRAM comes from the software +rasterizer. A4 proposes the GPU write the framebuffer back into RDRAM instead, +retiring the software rasterizer from the render path. + +### What it is worth + +Measured in the configuration A4 would actually change +(`frame_bench --features fast-exec,fast-scheduler,gpu-rdp`, Super Mario 64), by +stubbing the three rasterizing dispatch arms to no-ops: + +| basis | difference | +| --- | --- | +| conservative (worst B vs best A) | **1.23%**, 0.753 ms | +| clean-leg means | 3.16%, 1.946 ms | + +Against today's ~31.6 ms frame the same absolute cost is **~2.5%**. And this is +an **upper bound A4 cannot reach**: deleting rasterization is strictly cheaper +than replacing it, because the GPU must still write its result back into RDRAM +every frame and the probe got that for free. + +### The cost, which is not primarily a performance cost + +1. **It is an accuracy regression today.** The 42/43 census grades parallel-rdp + against *Angrylion*, not against RustyN64's software path, and the one known + gap — `key_en` chroma-key alpha compare (#160) — is a case where **the GPU is + less complete than the rasterizer it would replace**. Shipping this now trades + correctness for ~2.5%, which is the trade this project exists to refuse. +2. **ADR 0004 comes into scope.** The determinism contract binds the core, and + the core's framebuffer would begin arriving from a GPU. +3. **Timing changes.** The software RDP executes commands as the machine runs; + the GPU renders at frame end. A game that reads its framebuffer mid-frame sees + a different picture — a behavior change, not a rendering one. +4. **It pushes GPU-written memory into `rustyn64-core`**, a crate that is + `#![no_std]` and `#![forbid(unsafe_code)]` by design. + +## Decision + +**Accept A4 as an opt-in, off by default — and gate building it on accuracy, not +on frame time.** + +The gate, all of which must hold before the work starts: + +1. **The GPU/Angrylion census reaches 43/43**, closing `key_en` (#160). Until + then this change makes the emulator worse at its primary job, and the ~2.5% is + not an argument against that, it is the thing being refused. +2. **A software-vs-GPU framebuffer differential** exists and passes on the + committed test-ROM corpus — parity against *Angrylion* is not parity against + the path being replaced, and only the latter is the relevant question here. +3. **ADR 0004's determinism contract is re-derived for the GPU path** and stated, + not assumed: seed + ROM + input must still give bit-identical AV with the + option enabled. +4. **The mid-frame read behavior is characterized** — which titles read their own + framebuffer, and what they would see — rather than assumed absent. + +**The justification is inverted on purpose.** This ADR does *not* accept A4 for +performance; ~2.5% does not pay for an ADR, a determinism re-derivation and a +known regression. It accepts it as an **accuracy** change — the software +rasterizer is itself incomplete, and a fully-parity GPU path is a better +rasterizer — with the frame time as a side effect. That reframing is what makes +gate (1) load-bearing instead of decorative. + +## Consequences + +**Gained, when the gates are met and the option is on:** under 2.5% of a frame, +and a rasterizer that is more complete than the software one — which is the +larger prize and the reason to do it at all. + +**Given up:** nothing while off. When on: the software rasterizer stops being the +thing under test on that path, so CI must keep grading it separately or the +project loses its own oracle. + +**The honest position, recorded so it is not re-litigated as a performance +item:** if someone revisits this looking for FPS, the answer is that it was +measured at under 1.23% in the configuration it changes, and the answer is no. +The only version of A4 worth building is the accuracy one. diff --git a/docs/adr/0020-amend-0016-accept-the-simd-exception.md b/docs/adr/0020-amend-0016-accept-the-simd-exception.md new file mode 100644 index 00000000..7cbe16b9 --- /dev/null +++ b/docs/adr/0020-amend-0016-accept-the-simd-exception.md @@ -0,0 +1,77 @@ +# 0020 — Amend 0016: accept the RSP SIMD exception, for `multiply_lane` only + +Status: **Accepted.** ADR 0016 wrote the exception down and recommended against +using it; this amends that recommendation to an acceptance, on a ceiling that is +**smaller** than the one 0016 declined and a frame that is **half** the size. +Date: 2026-08-02 +Deciders: repo owner +Supersedes: none · Superseded by: none +Amends: **ADR 0016** — its four gates are carried forward unchanged and are the +operative requirements. Only its recommendation changes. + +## Context + +ADR 0016 defined a narrow `unsafe` exception — `core::arch` intrinsics in +`crates/rustyn64-rsp/src/vu.rs` only — and then declined to use it, because the +census put a perfect vectorization of `multiply_lane` at **5.3% of a frame / +1.056x**, below the 1.5x bar ADR 0017 used to decline a CPU recompiler at +1.26–1.40x. + +**Two things have changed, and only one of them favors this.** + +**Against it: the ceiling was re-derived by measurement and came out lower.** +0016's 5.3% was `62% x 8.5%` — an operation-count share multiplied by a time +share. Measured by doubling `multiply_lane` (`docs/performance.md` +§*`multiply_lane` measures 2.6–2.7% of a frame*), one pass is 1.33–1.39 ms: +**2.6–2.7%**, about half what 0016 declined. The multiply/accumulate family is +among the *cheapest* work the VU does, so 61.6% of the operations is well under +61.6% of the time. + +**For it: the frame halved, so the same absolute cost is a larger share.** The +idle-loop skip, the fast commit and the VI coverage memo removed CPU and VI cost; +they removed no RSP cost. 1.36 ms of a **~31.6 ms** frame is **~4.3%**, a +**1.045x** ceiling. + +**So the honest summary is that this is a worse technique than 0016 thought, +applied to a frame where it matters more.** 1.056x declined, 1.045x accepted. +The number did not improve; the context did. + +## Decision + +**Use the exception, for `multiply_lane` and nothing else.** + +ADR 0016's four gates are carried forward **unchanged and unrelaxed**, and the +first of them is the one that matters: + +1. **A scalar/vector equivalence test over the operand space**, not conformance + to the ROM suite. The ROM suite passing is not evidence: it exercises what + games use, and the whole risk of an intrinsic is the operand it handles + differently. +2. The scalar implementation stays, compiled and tested, as the reference. +3. Runtime dispatch, with the scalar path taken when the feature is absent. +4. Measured A-B-A on a real workload, and reverted if it does not clear its + ceiling by a margin worth the exception. + +**Scope is `multiply_lane` and its callees only.** Nothing else in `vu.rs`, and +nothing outside it. A second site needs a new ADR, not an appeal to this one. + +**`crates/rustyn64-rsp` drops from `forbid(unsafe_code)` to `deny`.** ADR 0016 +spelled out that this is a real weakening and it remains one; `deny` still +requires an explicit per-site `#[allow]`, so every intrinsic block is visible in +review and carries a `// SAFETY:` comment naming the invariant. + +## Consequences + +**Gained:** at most ~4.3% of a frame — 1.045x — and realistically less, because +the dispatch, the register reads and the accumulator writeback do not vanish. +**If the measurement in gate (4) does not show most of that, this is reverted and +this ADR is superseded rather than argued with.** + +**Given up:** `rustyn64-rsp`'s `forbid(unsafe_code)`, which was a property the +whole chip layer shared and now is not. That is the actual price, and it is paid +once for every future reader of that crate, not once for this change. + +**What would make this a mistake, stated in advance:** if the equivalence test in +gate (1) turns out to be hard to write exhaustively over an 8-lane 16x16 product +space, that is not a reason to weaken it to sampling — it is a reason to stop. +The scalar path is correct and 4.3% is not worth an unproven one. From c7b3c9af49175590ba04161828e452fa220bf1d1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 13:52:22 -0400 Subject: [PATCH 18/24] docs(perf): size the multi-iteration idle skip at 1.156x, and re-order the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured, not summed: charging 64 PCycles per idle skip instead of 2 drops the number of CPU-side idle iterations 32x while leaving the frame's total emulated ticks — and so the total RCP step count — unchanged, so the delta is 31/32 of the per-iteration overhead. 4.17 ms across four legs gives ~4.30 ms of a 31.76 ms frame: 13.5%, a 1.156x ceiling, 31.6 -> 36.3 FPS on Super Mario 64. That re-orders the queue and the reason is worth recording: the free item is larger than both ADR-gated ones combined (B2 at 4.3% costing forbid->deny, A3 at 3.5% costing a frame of latency). Both should be re-sized against the frame the idle work leaves behind rather than against this one — repeating the exact error the backlog re-derivation two sections up corrects. Also records the constraint any implementation must respect: an idle pair is 4 master ticks and the RCP steps every 3, so the RCP steps MORE often than the CPU idles. No batching removes an RCP step, which is why the ceiling is 1.156x and not the whole idle path. Co-Authored-By: Claude Opus 5 --- docs/performance.md | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index cbb9c4f3..539d9ff9 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -3168,3 +3168,50 @@ thing that closed most of the gap was not a faster version of any bucket in that profile — it was noticing that ~90% of the instructions did not need to run at all. Ocarina of Time is at 52.5 FPS today. The section stands as a record of the reasoning; it should not be cited as a current claim. + +## The idle skip's remaining per-iteration overhead is 13.5% — larger than A3 and B2 together + +The idle-loop skip is per-iteration: it returns to the scheduler every two idle +instructions and pays `count_ticks`, `set_now`, `sample_interrupt_lines` (a bus +IRQ poll), the NMI check and the RCP catch-up loop's setup each time. Sizing what +a multi-iteration skip could remove, **by measurement rather than by adding those +up**: + +Charging **64 `PCycle`s per skip instead of 2** runs emulated time fast through +the idle loop, so the number of CPU-side idle iterations drops 32x while the +frame's total emulated ticks — and therefore the total RCP step count — are +unchanged. The delta is 31/32 of the per-iteration overhead. (The probe is not +correct; `retired` falls to 20.5 M, which is the point.) + +| leg | mean frame | +| --- | --- | +| A per-iteration (x1) | 31.674 ms | +| B 32x fewer iterations | 27.625 ms | +| A per-iteration (x1) | 31.841 ms | +| B 32x fewer iterations | 27.558 ms | + +**4.17 ms, so the full overhead is ~4.30 ms of a 31.76 ms frame: 13.5%, a +1.156x ceiling.** Super Mario 64 would go 31.6 -> 36.3 FPS. + +### What that does to the priority order + +| item | ceiling on today's frame | cost | +| --- | --- | --- | +| **multi-iteration idle skip** | **13.5% -> 1.156x** | none — ADR 0013's existing mode | +| B2 VU vectorization (ADR 0020) | 4.3% -> 1.045x | `forbid(unsafe_code)` -> `deny` | +| A3 async RDP (ADR 0018) | 3.5% -> 1.036x | one frame of latency, off by default | +| A4 GPU rasterizer (ADR 0019) | 2.5% | blocked — an accuracy regression today | + +**The free item is larger than the two paid ones combined.** It should be built +first, and both paid items should be re-sized against the frame it leaves behind +rather than against this one — the same error the backlog re-derivation above +corrects, and it would be careless to repeat it immediately. + +### The constraint any implementation has to respect + +An idle pair is 2 `PCycle`s = 4 master ticks; the RCP steps every 3. So the RCP +steps **more often than the CPU idles** (~1.33 per pair), and no amount of +batching removes an RCP step. A multi-iteration skip therefore cannot simply jump +to the next event: it must still walk the RCP edges. What it can remove is the +CPU-side work between them — which is exactly the 13.5% measured here, and is +why the ceiling is 1.156x rather than the whole idle path. From 1e34ebcb43df64f5e2a6618ecc9ef6a8e3097a2b Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 13:59:50 -0400 Subject: [PATCH 19/24] docs(perf): the easy multi-iteration idle skip is neutral, and that locates the cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built the obvious version — hoist the scheduler's scaffolding (boot_nmi_halt, the RCP edge setup, the report tally) out of the idle stretch, keeping the interrupt check at every instruction boundary. 32.137 ms against a 31.674/31.841 baseline with `retired` bit-identical: slower, outside the spread, reverted. The negative result is worth more than the change would have been, because it locates the 13.5%. The 64-PCycle probe cut `step_instruction_at` CALLS by 32x; the hoist kept those calls and moved only what surrounds them, and gained nothing. So the cost is inside the per-boundary work — set_now, sample_interrupt_lines, the NMI check, interrupt_pending, the call — which cannot be batched by the easy route, because reducing how often the interrupt check runs changes the cycle an interrupt is recognized on. Records the design that would actually recover it: poll_irq flips only at an RCP step, so the level can be tested right after each step_rcp and the batch left at the first boundary at or after the raising edge; timer_edge is computable from master_ticks so it bounds the batch rather than needing a per-boundary test; Random and the retired tally are pure arithmetic. Also warns that the 1.156x must be re-derived before the attempt rather than inherited — the same error the backlog re-derivation corrects. Co-Authored-By: Claude Opus 5 --- docs/performance.md | 52 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index 539d9ff9..2bef8a84 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -3215,3 +3215,55 @@ batching removes an RCP step. A multi-iteration skip therefore cannot simply jum to the next event: it must still walk the RCP edges. What it can remove is the CPU-side work between them — which is exactly the 13.5% measured here, and is why the ceiling is 1.156x rather than the whole idle path. + +## The easy multi-iteration idle skip is NEUTRAL — the 13.5% is in the boundary check + +The section above sized the idle skip's remaining per-iteration overhead at +**13.5%** and assumed the obvious implementation would recover most of it: hoist +the scheduler's own scaffolding — `boot_nmi_halt`, the RCP edge setup, the report +tally — out of a stretch where the CPU provably does nothing. Built, with +`retired` bit-identical: + +| | mean frame | +| --- | --- | +| baseline (per-iteration) | 31.674 / 31.841 ms | +| scaffolding hoisted out of the idle stretch | **32.137 ms** | + +**Slower, outside the baseline spread. Reverted.** + +### What that locates + +The 64-`PCycle` probe reduced the number of `step_instruction_at` **calls** by +32x, not just the loop around them. The hoist kept those calls at their original +frequency and moved only what surrounds them — and gained nothing. So the 13.5% +is inside the per-boundary work itself: `set_now`, `sample_interrupt_lines` +(`poll_irq` plus `timer_edge`), the NMI check, `interrupt_pending`, and the call. + +**It is deliberately not batchable by the easy route.** Reducing how often the +interrupt check runs changes the cycle an interrupt is recognized on — a behavior +change wearing an optimization's clothes. + +### What would actually recover it, stated so the next attempt starts here + +A batch is sound only if it can prove nothing changes across it, and each input +can in fact be bounded: + +- **`poll_irq`** flips only when an RCP chip sets `mi_intr`, which happens only at + an RCP step. The idle loop steps the RCP itself, so it can test the (cheap) + level right after each `step_rcp` and leave the batch at the first instruction + boundary at or after the raising edge — same recognition cycle, far fewer full + boundary evaluations. +- **`timer_edge`** is `Count` against `Compare`, and `Count` is derived from + `master_ticks`. The tick of the next match is computable, so it bounds the batch + rather than needing a per-boundary test. +- **`Random`** and the retired tally are pure arithmetic: `-= 2N` and `+= 2N`. + +That is the design the 1.156x is behind. It is a real scheduler change with real +exactness obligations, not the hoist tried here — and the hoist's neutrality is +the evidence that the shortcut does not exist. + +### And the ceiling should be re-derived before it is attempted + +1.156x was measured against a 31.76 ms frame. Anything landed between now and +then moves it, in the same way the declined backlog's shares moved when the frame +halved. Re-measure the 64-`PCycle` probe first; do not inherit this number. From c0dd5dccc8562b3a3db5d87ee517a6f661f0ede8 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 14:05:59 -0400 Subject: [PATCH 20/24] docs(perf): the idle path's interrupt check cannot be bisected by removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the per-boundary interrupt sampling to measure its cost is vacuous: the idle loop is exited BY the interrupt, so with the check gone the machine never leaves it and the VI never comes up. frame_bench's liveness assert caught it rather than reporting a fast frame over a dead machine. Doubling is not a free substitute either — sample_interrupt_lines calls Cop0::timer_edge, which is edge-detecting and carries internal state, so a second call consumes the edge instead of measuring it. A sound probe can only duplicate the pure reads, which covers part of what a batch would remove. So the 13.5% stays a total rather than a decomposition, and the recorded batch design should be graded against that total with its own A-B-A. Co-Authored-By: Claude Opus 5 --- docs/performance.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index 2bef8a84..a5918b3c 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -3262,6 +3262,25 @@ That is the design the 1.156x is behind. It is a real scheduler change with real exactness obligations, not the hoist tried here — and the hoist's neutrality is the evidence that the shortcut does not exist. +### The per-boundary cost cannot be bisected by removal + +The obvious next step — delete the interrupt sampling from the idle path and see +what the frame costs — is **vacuous here, and it fails loudly rather than +quietly**: the idle loop is *exited* by the interrupt, so with the check gone the +machine never leaves it and the VI never comes up (`frame_bench` asserts on +exactly this and did). The check is load-bearing, not incidental. + +That leaves doubling, and doubling is not free here either: `sample_interrupt_lines` +calls `Cop0::timer_edge`, which is **edge-detecting and carries internal state**, +so calling it twice per boundary consumes the edge rather than measuring it. A +sound doubling probe has to duplicate `set_now` and `interrupt_pending` — the +pure reads — and leave the latching half alone, which measures only part of what +a batch would remove. + +**So the 13.5% is currently a total, not a decomposition**, and the batch design +below should be built against the total with its own A-B-A rather than against a +per-component estimate that does not exist yet. + ### And the ceiling should be re-derived before it is attempted 1.156x was measured against a 31.76 ms frame. Anything landed between now and From fd7ddfa717c9600790505a9b6b75cfdafd944c77 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 14:33:57 -0400 Subject: [PATCH 21/24] =?UTF-8?q?perf(sched):=20batch=20idle-loop=20pairs?= =?UTF-8?q?=20=E2=80=94=201.064x,=20and=20fix=20the=20A-B-A=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle skip returned to the scheduler every two idle instructions. The batch runs further pairs without re-evaluating the boundary, entered only AFTER a boundary has executed without vectoring — which is what establishes nothing is pending. From there every input to that check is constant or bounded: * Cause.IP2 follows poll_irq, which flips only when an RCP chip sets mi_intr, and that happens only at an RCP step the batch performs itself and tests after. * Cause.IP7 follows timer_edge, a delta against last_count. COUNT_DIVIDER is exactly twice CPU_DIVIDER, so one idle pair is exactly one Count tick and count_ticks_until_timer_match bounds the batch in pairs, keeping the crossing on the boundary the per-instruction walk would have found it on. * Status's masks cannot move: no instruction executes. * boot_nmi_halt is bus state, so it changes only across an RCP step. Leaving the batch does not consume the boundary that ends it, which is what keeps an interrupt on its original cycle. retire_idle_pairs ticks Random in a loop rather than computing it: Random wraps to 31 at Wired, not at zero, so random -= 2n is wrong exactly when a long batch crosses that wrap. The new test sweeps Wired across 0/1/7/30/31 and pair counts across it. Super Mario 64 1.064x (32.6 -> 34.8 FPS), Mario Kart 64 1.072x (43.2 -> 46.6), every B leg beating every A leg, retired bit-identical in all six. n64-systemtest unchanged under fast-exec. THE HARNESS WAS ALSO WRONG, and this is the larger finding. Three attempts to measure this came back contaminated because every leg ran `cargo build` and then timed immediately after — a parallel release build is a multi-core job and loadavg is a 1-minute average, so it was still decaying from the harness's own build. Interleaving does not cancel it: A-B-A cancels monotonic session drift, but here BOTH legs sit behind a build, so the bias lands on both. Leg spreads went from 36% to under 1% once the binaries were pre-built and the measurement window contained no compilation. scripts/bench_aba.sh is that harness. Recorded deltas near the noise floor are downgraded to unverified rather than overturned — nothing decided by `retired` is affected, and the large results sit far outside the bias. Co-Authored-By: Claude Opus 5 --- crates/rustyn64-core/src/scheduler.rs | 64 +++++++++++++++ crates/rustyn64-cpu/src/cop0.rs | 26 ++++++ crates/rustyn64-cpu/src/lib.rs | 36 +++++++++ crates/rustyn64-cpu/src/pipeline/fastexec.rs | 47 +++++++++++ docs/performance.md | 83 ++++++++++++++++++++ scripts/bench_aba.sh | 65 +++++++++++++++ 6 files changed, 321 insertions(+) create mode 100755 scripts/bench_aba.sh diff --git a/crates/rustyn64-core/src/scheduler.rs b/crates/rustyn64-core/src/scheduler.rs index f0d71008..3cf84d72 100644 --- a/crates/rustyn64-core/src/scheduler.rs +++ b/crates/rustyn64-core/src/scheduler.rs @@ -643,6 +643,31 @@ impl System { rcp = Self::next_edge_after(rcp, self.phases.rcp, RCP_DIVIDER); } self.master_ticks = end; + + // **The idle batch.** The boundary just executed established, for this + // instant, that no NMI and no interrupt is pending — otherwise it + // would have vectored and the CPU would no longer be parked. From + // that fact the loop below can run further idle pairs *without* + // re-evaluating the boundary, because every input to that evaluation + // is either constant or bounded: + // + // * `Cause.IP2` follows `poll_irq`, which flips only when an RCP chip + // sets `mi_intr` — and that can happen only at an RCP step, which + // this loop performs itself and tests after. + // * `Cause.IP7` follows `Cop0::timer_edge`, a delta against + // `last_count`. `count_ticks_until_timer_match` bounds the batch so + // the crossing lands on the boundary the per-instruction path would + // have found it on, not later. + // * `Status`'s masks cannot move: no instruction executes here. + // * `boot_nmi_halt` is bus state, so it too changes only across an + // RCP step, and is tested on the same edge. + // + // Leaving the batch does not consume the boundary that ends it — the + // outer loop runs it through the ordinary path, which is what keeps + // the interrupt on its original cycle. + if self.cpu.is_parked_in_idle_loop() { + self.run_idle_batch(target, &mut report); + } } // Only reachable through the halted branch's `break`; an executing CPU has // already carried `master_ticks` to or past `target`. @@ -651,6 +676,45 @@ impl System { } report } + /// Run idle-loop pairs in bulk, stopping the moment anything the boundary + /// check reads could have changed. See the call site for why that is sound. + /// + /// Returns with `master_ticks` on an instruction boundary, so the caller's + /// ordinary path resumes exactly where a per-instruction walk would be. + #[cfg(feature = "fast-exec")] + fn run_idle_batch(&mut self, target: u64, report: &mut crate::fastpath::FastRunReport) { + /// One idle pair: two `PCycle`s, and — because `COUNT_DIVIDER` is twice + /// `CPU_DIVIDER` — exactly one COP0 `Count` tick, which is what lets the + /// timer bound below be counted in pairs. + const PAIR_TICKS: u64 = 2 * CPU_DIVIDER; + + let room = (target.saturating_sub(self.master_ticks)) / PAIR_TICKS; + let pairs = room.min(self.cpu.count_ticks_until_timer_match()); + if pairs == 0 { + return; + } + + let mut rcp = Self::next_edge_after(self.master_ticks, self.phases.rcp, RCP_DIVIDER); + let mut done = 0u64; + while done < pairs { + let end = self.master_ticks + PAIR_TICKS; + while rcp <= end { + self.master_ticks = rcp; + self.step_rcp(); + rcp = Self::next_edge_after(rcp, self.phases.rcp, RCP_DIVIDER); + } + self.master_ticks = end; + done += 1; + // Tested after the RCP has run, because the RCP is the only thing + // that can raise either. The boundary at `end` is then handed back to + // the caller unconsumed. + if rustyn64_cpu::Bus::poll_irq(&mut self.bus) || self.bus.boot_nmi_halt() { + break; + } + } + self.cpu.retire_idle_pairs(done); + report.work_units = report.work_units.saturating_add(done); + } /// One RCP step: the RSP microcode unit, then the RDP rasterizer, then the /// AI/interface DMA progress — all on the SAME `&mut self.bus`. diff --git a/crates/rustyn64-cpu/src/cop0.rs b/crates/rustyn64-cpu/src/cop0.rs index 027c456c..6327042a 100644 --- a/crates/rustyn64-cpu/src/cop0.rs +++ b/crates/rustyn64-cpu/src/cop0.rs @@ -425,6 +425,32 @@ impl Cop0 { edge } + /// How many `Count` ticks may pass before [`Cop0::timer_edge`] could latch + /// `IP7`, measured from the last poll. + /// + /// `timer_edge` is a *delta* against `last_count`, not a per-tick equality, + /// so skipping calls does not lose an edge — it widens the window and the + /// next call still reports the crossing. That is what makes an idle batch + /// possible at all, and it is also the trap: a skipped edge would be reported + /// **late**, at whatever boundary polls next. Bounding a batch by this value + /// keeps the crossing on the boundary the per-instruction path would have + /// found it, because `n == to_compare` makes the very next poll see + /// `traveled == to_compare` and fire. + /// + /// `0` means `Compare` sits exactly on `last_count`, which `timer_edge` + /// treats as *not* an edge; the next match is then a full `u32` away, so this + /// reports that rather than a zero that would stall a caller's batch forever. + #[must_use] + pub const fn count_ticks_until_timer_match(&self) -> u64 { + let compare = self.regs[reg::COMPARE as usize] as u32; + let to_compare = compare.wrapping_sub(self.last_count); + if to_compare == 0 { + u32::MAX as u64 + } else { + to_compare as u64 + } + } + /// Set or clear a `Cause.IP` bit. /// /// `bit` is 0..=7. `IP1:IP0` are software interrupts and are written through diff --git a/crates/rustyn64-cpu/src/lib.rs b/crates/rustyn64-cpu/src/lib.rs index 53dee4ac..563d111f 100644 --- a/crates/rustyn64-cpu/src/lib.rs +++ b/crates/rustyn64-cpu/src/lib.rs @@ -295,6 +295,42 @@ impl Cpu { self.retired = self.pipeline.retired; } + /// Whether the CPU is parked at a recognized self-branch idle loop. + #[cfg(feature = "fast-exec")] + #[must_use] + pub const fn is_parked_in_idle_loop(&self) -> bool { + matches!(self.pipeline.idle_pc, Some(pc) if pc == self.pc) + } + + /// `Count` ticks the scheduler may batch before the timer could latch `IP7`. + /// One idle pair is exactly one `Count` tick, so this is also a pair count. + #[cfg(feature = "fast-exec")] + #[must_use] + pub const fn count_ticks_until_timer_match(&self) -> u64 { + self.pipeline.cop0.count_ticks_until_timer_match() + } + + /// Retire `pairs` iterations of a recognized idle loop. + /// + /// The per-boundary path's state changes for the idle pair, applied `pairs` + /// times: two instructions retired and `Random` ticked twice. Everything the + /// per-boundary path does *besides* this — sampling the interrupt lines, + /// the NMI check, `set_now` — is what the caller has established cannot + /// change across the batch, and is why this is not simply a loop over + /// [`Cpu::step_instruction_at`]. + /// + /// `Random` is ticked rather than computed: it wraps to 31 at `Wired` + /// (UM §6.3.3), so `random -= 2n` is wrong whenever the batch crosses that + /// boundary, and the batch is long precisely when it would. + #[cfg(feature = "fast-exec")] + pub fn retire_idle_pairs(&mut self, pairs: u64) { + self.pipeline.retired = self.pipeline.retired.wrapping_add(pairs.wrapping_mul(2)); + for _ in 0..pairs.wrapping_mul(2) { + self.pipeline.cop0.tick_random(); + } + self.retired = self.pipeline.retired; + } + /// Execute one instruction (plus its delay slot, if it branches) and return /// the `PCycle`s it cost — the **instruction-granular** path (ADR 0013), /// behind the default-off `fast-exec` feature. diff --git a/crates/rustyn64-cpu/src/pipeline/fastexec.rs b/crates/rustyn64-cpu/src/pipeline/fastexec.rs index bfb88370..01112160 100644 --- a/crates/rustyn64-cpu/src/pipeline/fastexec.rs +++ b/crates/rustyn64-cpu/src/pipeline/fastexec.rs @@ -629,6 +629,53 @@ mod idle_loop_tests { ); } + /// `retire_idle_pairs(n)` must equal `n` trips through the per-boundary skip. + /// + /// The scheduler's idle batch calls it instead of stepping, so any drift here + /// is drift in `Random` and the retired tally that no ROM test would localize. + /// `Wired` is swept because `Random` wraps to 31 *at* `Wired` rather than at + /// zero — a batch that crosses that wrap is exactly where a closed-form + /// shortcut would go wrong, and the loop this asserts against is why one is + /// not used. + #[test] + fn a_batch_of_idle_pairs_matches_the_same_number_of_boundaries() { + for wired in [0u64, 1, 7, 30, 31] { + for pairs in [1u64, 2, 31, 32, 33, 97] { + let (mut one, mut one_bus) = parked(); + one.pipeline + .cop0 + .write(super::super::super::cop0::reg::WIRED, wired); + let (mut many, mut many_bus) = parked(); + many.pipeline + .cop0 + .write(super::super::super::cop0::reg::WIRED, wired); + + // Recognize the loop on both, so the comparison starts aligned. + let _ = one.step_instruction_at(&mut one_bus, 0); + let _ = many.step_instruction_at(&mut many_bus, 0); + + for i in 0..pairs { + let _ = one.step_instruction_at(&mut one_bus, i + 1); + } + many.retire_idle_pairs(pairs); + + assert_eq!( + many.retired, one.retired, + "wired {wired}, {pairs} pairs: retired tally diverged" + ); + assert_eq!( + many.pipeline + .cop0 + .read(super::super::super::cop0::reg::RANDOM), + one.pipeline + .cop0 + .read(super::super::super::cop0::reg::RANDOM), + "wired {wired}, {pairs} pairs: `Random` diverged" + ); + } + } + } + /// The skip charges what the two instructions charge. `execute_one` bills one /// `PCycle` to issue and neither a `beq` nor a `nop` can request a stall, so /// the pair is exactly two — and a wrong constant here would silently change diff --git a/docs/performance.md b/docs/performance.md index a5918b3c..e84771de 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -3286,3 +3286,86 @@ per-component estimate that does not exist yet. 1.156x was measured against a 31.76 ms frame. Anything landed between now and then moves it, in the same way the declined backlog's shares moved when the frame halved. Re-measure the 64-`PCycle` probe first; do not inherit this number. + +## Rebuilding between A-B-A legs biases the measurement — the harness was the bug + +Three consecutive attempts to measure the idle batch came back contaminated, and +the cause was self-inflicted rather than environmental. + +**Every leg ran `cargo build` and then timed immediately after it.** A parallel +release build is itself a multi-core job, and `/proc/loadavg` is a **1-minute +exponential average** — so it is still decaying from *this harness's own build* +when the timing starts. One mechanism explains all three failures: + +- Run 1 had a leg 60% high, which read like a cold page cache and was not. +- Run 2's A legs drifted **monotonically** upward (30.46 -> 31.39 -> 31.45) — + a trend, not scatter — and one title's B legs spread **36%**. +- Run 3's load gate passed at 0.78 and then aborted at 1.75 mid-run, because the + thing that pushed it to 1.75 was the build the gate had just waited in front of. + +**Interleaving does not cancel this.** A-B-A is designed to cancel *monotonic +session drift*, and it does — but here *both* legs are preceded by a build, so +the bias is applied to both and the difference between them is what carries the +noise. The protection people expect from A-B-A is simply absent for this term. + +### The fix: no compilation inside the measurement window + +Build every leg first, copy the binaries aside, wait **once** for the load to +decay, then alternate with nothing but the benchmark running +(`/tmp/rustyn64-bench/aba_nobuild.sh`, the shape worth keeping). The difference +is not subtle: + +| | leg spread, rebuild-per-leg | leg spread, binaries pre-built | +| --- | --- | --- | +| Super Mario 64 | up to 3.2% | **0.64% (A), 0.34% (B)** | +| Mario Kart 64 | **36%** | **0.25% (A), 1.1% (B)** | + +### What this does and does not invalidate + +It does **not** touch any result decided by `retired` — the accuracy work, the +workload-change checks, every "identical in every leg" claim. Those are exact. + +It **does** mean that recorded deltas near the noise floor deserve less +confidence than their write-ups imply, in whichever direction the bias fell. +Large results (the idle skip at 1.59–2.05x, the fast commit at 1.22x, the VI memo +at 1.12x) are far outside it and stand. Small ones — the 1.12% read path, the +0.11% RSP idle skip, the 1.0% decode-cache regression, `target-cpu=native`'s +"neutral" — were all decided against a rebuild-per-leg harness and should be +re-run on this one before anyone leans on them. **They are not hereby overturned; +they are downgraded to unverified.** + +## The batched idle skip is 1.064x–1.072x + +With the harness fixed, the batch measures cleanly. Every B leg beats every A +leg on both titles: + +| | A legs (ms) | B legs (ms) | conservative | +| --- | --- | --- | --- | +| Super Mario 64 | 30.685 / 30.535 / 30.731 | 28.611 / 28.709 / 28.701 | **1.064x** | +| Mario Kart 64 | 23.186 / 23.127 / 23.166 | 21.567 / 21.325 / 21.447 | **1.072x** | + +`retired` is bit-identical to the per-instruction path in every leg. 32.6 -> 34.8 +FPS on Super Mario 64, 43.2 -> 46.6 on Mario Kart 64. n64-systemtest unchanged +(Phase 1 and RSP categories `Failed: 0`, 90 suite-wide). + +### Against the 1.156x ceiling + +This recovers a little under half of it. The remainder is the per-pair work the +batch keeps on purpose: `poll_irq` after each RCP step, and `retire_idle_pairs` +ticking `Random` in a loop. **`Random` wraps to 31 at `Wired`, not at zero**, so +`random -= 2n` is wrong exactly when a long batch crosses that wrap — and the +batch is long precisely when it would. Making that O(1) safely, with the +`Wired`-sweep test already in place as the gate, is the next increment. + +### Why the batch is sound + +Entered only *after* a boundary has executed without vectoring, which is what +establishes that nothing is pending. From there every input to the boundary check +is constant or bounded: `Cause.IP2` follows `poll_irq`, which flips only at an RCP +step the batch performs itself and tests after; `Cause.IP7` follows `timer_edge`, +a delta against `last_count`, and because `COUNT_DIVIDER` is exactly twice +`CPU_DIVIDER` one idle pair is exactly one `Count` tick, so +`count_ticks_until_timer_match` bounds the batch in pairs and keeps the crossing +on the boundary the per-instruction walk would have found it on; `Status`'s masks +cannot move because no instruction executes. Leaving the batch does not consume +the boundary that ends it. diff --git a/scripts/bench_aba.sh b/scripts/bench_aba.sh new file mode 100755 index 00000000..8ef64ab9 --- /dev/null +++ b/scripts/bench_aba.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Decide the idle batch with NO COMPILATION inside the measurement window. +# +# Why this harness exists. The three previous attempts were all contaminated, +# and the cause was self-inflicted: each leg ran `cargo build` and then measured. +# A parallel release build is itself a multi-core job, and `/proc/loadavg` is a +# 1-minute exponential average, so it is still decaying from MY OWN build when +# the timing starts. That is why the first leg after every link came in high and +# read like a "cold start", and why a load gate checked right after the build +# passed and then aborted mid-run. +# +# So: build both binaries first, stash them, wait for quiet ONCE, then alternate +# with nothing but the benchmark running. +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 + +# Commercial ROMs are never committed; point this at a local dump directory. +ROMDIR="${RUSTYN64_ROMDIR:?set RUSTYN64_ROMDIR to a directory of .z64 dumps}" +OUT=/tmp/rustyn64-bench +BIN=target/release/examples/frame_bench + +# $1 = leg name, $2 = a directory mirroring the crates/ paths this leg changes. +build_into() { + (cd "$2" && find . -type f -name '*.rs' -print0) | while IFS= read -r -d "" f; do + cp "$2/$f" "$f" + done + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 \ + || { echo "$1 BUILD FAILED"; exit 1; } + cp "$BIN" "$OUT/frame_bench.$1" +} + +echo "building both legs (outside the measurement window)" +# Point these at snapshots of the two trees being compared. Snapshot with `cp`, +# never `git checkout` -- that would discard any unrelated uncommitted work. +build_into A "${A_SNAPSHOT:?set A_SNAPSHOT to a baseline source snapshot dir}" +build_into B "${B_SNAPSHOT:?set B_SNAPSHOT to a candidate source snapshot dir}" + +echo "waiting for the build's own load to decay" +for _ in $(seq 1 90); do + L=$(cut -d' ' -f1 /proc/loadavg) + awk -v l="$L" 'BEGIN {exit !(l < 0.7)}' && break + sleep 20 +done +L=$(cut -d' ' -f1 /proc/loadavg) +if awk -v l="$L" 'BEGIN {exit !(l >= 0.7)}'; then + echo "STILL BUSY: load $L after 30 min — no numbers reported" + exit 1 +fi +echo "start load: $L" + +# Warm-up, discarded: first touch of each freshly-copied binary. +for leg in A B; do + RUSTYN64_PROBE_ROM="$ROMDIR/Super Mario 64.z64" "$OUT/frame_bench.$leg" >/dev/null 2>&1 +done + +for i in 1 2 3; do + for leg in A B; do + for t in "Super Mario 64" "Mario Kart 64"; do + printf "%s%d %-15.15s " "$leg" "$i" "$t" + RUSTYN64_PROBE_ROM="$ROMDIR/$t.z64" "$OUT/frame_bench.$leg" | head -1 + done + done + echo " (load after round $i: $(cut -d' ' -f1 /proc/loadavg))" +done +echo "end load: $(cut -d' ' -f1 /proc/loadavg)" From 28ab3f5f2ab0f3432a2b76cb7f01883065103ced Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 16:00:04 -0400 Subject: [PATCH 22/24] =?UTF-8?q?perf(cop0):=20advance=20`Random`=20in=20c?= =?UTF-8?q?onstant=20time=20for=20the=20idle=20batch=20=E2=80=94=201.028x/?= =?UTF-8?q?1.057x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `retire_idle_pairs` ticked `Random` in a loop, putting a per-pair cost back into the batch that exists to remove per-pair costs. `Cop0::tick_random_by` is the closed form of the same walk. It is not `random - n`. `Random` walks down to `Wired` and then jumps to 31, so the sequence is a transient followed by a cycle of `31..=Wired`. Three cases look impossible and are reachable, because both fields are masked to 6 bits on write: `Wired > 31`, `Random < Wired`, and `Random > 31`. Each lengthens the walk rather than erroring, so both distances are taken modulo 64 rather than assumed in range — a form that assumes `Wired <= 31` is right for every value software sensibly writes and wrong for exactly the ones a test ROM probes. Tested exhaustively rather than by sampling: every (Wired, Random) pair against 71 tick counts, plus a million-tick case for the modulo. Mutation-checked — the naive form dies at `wired 0, random 0, 32 ticks`, the wrap on the first cycle. A-B-A-B-A-B on the no-build harness, every B leg beating every A leg, spreads 0.14-0.79%: Super Mario 64 1.028x (35.0 -> 36.2 FPS), Mario Kart 64 1.057x (46.9 -> 49.6). Batch plus closed form against the pre-batch baseline is 1.105x and 1.145x against the probe's 1.156x ceiling — the first measured implementation in this program to land on its predicted ceiling rather than well under it. Co-Authored-By: Claude Opus 5 --- crates/rustyn64-cpu/src/cop0.rs | 103 ++++++++++++++++++++++++++++++++ crates/rustyn64-cpu/src/lib.rs | 11 ++-- docs/performance.md | 33 ++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) diff --git a/crates/rustyn64-cpu/src/cop0.rs b/crates/rustyn64-cpu/src/cop0.rs index 6327042a..953d86ad 100644 --- a/crates/rustyn64-cpu/src/cop0.rs +++ b/crates/rustyn64-cpu/src/cop0.rs @@ -425,6 +425,45 @@ impl Cop0 { edge } + /// Advance `Random` by `n` ticks in constant time. + /// + /// Equivalent to calling [`Cop0::tick_random`] `n` times, and pinned to it + /// exhaustively by `tick_random_by_matches_the_loop_everywhere`. The scheduler's + /// idle batch needs this: `n` there is a whole stretch of idle pairs, and a + /// loop over it put the per-pair cost back that the batch exists to remove. + /// + /// **The wrap is at `Wired`, not at zero** (UM §6.3.3), so `random - n` is + /// wrong — and wrong precisely when `n` is large enough for the batch to be + /// worth having. `Random` walks down to `Wired` and then jumps to 31, so the + /// sequence is a *transient* followed by a cycle: + /// + /// * `d` ticks to walk from `cur` down to `Wired`, then + /// * a cycle of `31 ..= Wired` re-entered from 31, of length `(31 - Wired) + 1`. + /// + /// Both distances are taken modulo 64 rather than assumed in range, because + /// `Wired` and `Random` are masked to 6 bits on write: `Wired > 31` and + /// `cur < Wired` are both reachable from guest code, and each produces a + /// longer walk rather than an error. Assuming `Wired <= 31` here would be a + /// closed form that is right for every value software sensibly writes and + /// wrong for the ones a test ROM checks. + pub const fn tick_random_by(&mut self, n: u64) { + let wired = (self.regs[reg::WIRED as usize] & 0x3F) as u32; + let cur = (self.regs[reg::RANDOM as usize] & 0x3F) as u32; + // Ticks until the walk reaches `Wired`. Zero means it is already there, + // and the *next* tick is the one that jumps to 31. + let d = cur.wrapping_sub(wired) & 0x3F; + let value = if n <= d as u64 { + cur.wrapping_sub(n as u32) & 0x3F + } else { + let cycle = (31u32.wrapping_sub(wired) & 0x3F) + 1; + // `n - d - 1` is how far into the cycle we are, counting the jump to + // 31 as position zero. + let into = (n - d as u64 - 1) % cycle as u64; + 31u32.wrapping_sub(into as u32) & 0x3F + }; + self.regs[reg::RANDOM as usize] = value as u64; + } + /// How many `Count` ticks may pass before [`Cop0::timer_edge`] could latch /// `IP7`, measured from the last poll. /// @@ -730,6 +769,70 @@ pub const fn cop0_reg(d: Decoded) -> u8 { #[cfg(test)] mod tests { + + /// `tick_random_by(n)` must equal `n` calls to `tick_random`, for **every** + /// reachable `(Wired, Random)` pair — not just the ones software sensibly + /// writes. + /// + /// Both fields are masked to 6 bits on write, so `Wired > 31` and + /// `Random < Wired` are reachable from guest code and each lengthens the + /// walk instead of erroring. A closed form that assumes `Wired <= 31` is + /// right for every ordinary value and wrong for exactly the ones a test ROM + /// probes, which is why this sweeps the whole space rather than sampling it. + /// + /// `n` runs past the longest possible cycle (64) so the modulo is exercised + /// on both sides of a wrap, and includes 0 — where the closed form must be + /// the identity. + #[test] + fn tick_random_by_matches_the_loop_everywhere() { + for wired in 0u64..64 { + for start in 0u64..64 { + for n in 0u64..=70 { + let mut loops = Cop0::new(); + loops.write(reg::WIRED, wired); + loops.write(reg::RANDOM, start); + for _ in 0..n { + loops.tick_random(); + } + + let mut closed = Cop0::new(); + closed.write(reg::WIRED, wired); + closed.write(reg::RANDOM, start); + closed.tick_random_by(n); + + assert_eq!( + closed.read(reg::RANDOM), + loops.read(reg::RANDOM), + "wired {wired}, random {start}, {n} ticks" + ); + } + } + } + } + + /// A batch that spans many whole cycles must land where the loop does — the + /// case the modulo exists for, and the one a small-`n` sweep cannot reach. + #[test] + fn tick_random_by_survives_many_whole_cycles() { + for wired in [0u64, 5, 31] { + let mut closed = Cop0::new(); + closed.write(reg::WIRED, wired); + closed.write(reg::RANDOM, 31); + closed.tick_random_by(1_000_003); + + let mut loops = Cop0::new(); + loops.write(reg::WIRED, wired); + loops.write(reg::RANDOM, 31); + for _ in 0..1_000_003u64 { + loops.tick_random(); + } + assert_eq!( + closed.read(reg::RANDOM), + loops.read(reg::RANDOM), + "wired {wired}" + ); + } + } use super::*; /// The list of 64-bit registers has no generating rule, so it is asserted diff --git a/crates/rustyn64-cpu/src/lib.rs b/crates/rustyn64-cpu/src/lib.rs index 563d111f..b03e8466 100644 --- a/crates/rustyn64-cpu/src/lib.rs +++ b/crates/rustyn64-cpu/src/lib.rs @@ -319,15 +319,14 @@ impl Cpu { /// change across the batch, and is why this is not simply a loop over /// [`Cpu::step_instruction_at`]. /// - /// `Random` is ticked rather than computed: it wraps to 31 at `Wired` - /// (UM §6.3.3), so `random -= 2n` is wrong whenever the batch crosses that - /// boundary, and the batch is long precisely when it would. + /// `Random` advances through [`Cop0::tick_random_by`], which is the closed + /// form of the wrap-at-`Wired` walk (UM §6.3.3) rather than `random -= 2n` — + /// that shortcut is wrong exactly when the batch crosses the wrap, and the + /// batch is long precisely when it would. #[cfg(feature = "fast-exec")] pub fn retire_idle_pairs(&mut self, pairs: u64) { self.pipeline.retired = self.pipeline.retired.wrapping_add(pairs.wrapping_mul(2)); - for _ in 0..pairs.wrapping_mul(2) { - self.pipeline.cop0.tick_random(); - } + self.pipeline.cop0.tick_random_by(pairs.wrapping_mul(2)); self.retired = self.pipeline.retired; } diff --git a/docs/performance.md b/docs/performance.md index e84771de..3d66cc68 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -3369,3 +3369,36 @@ a delta against `last_count`, and because `COUNT_DIVIDER` is exactly twice on the boundary the per-instruction walk would have found it on; `Status`'s masks cannot move because no instruction executes. Leaving the batch does not consume the boundary that ends it. + +## The O(1) `Random` advance closes most of the idle-batch gap — 1.028x / 1.057x + +`retire_idle_pairs` ticked `Random` in a loop, which put a per-pair cost back +into the batch that exists to remove per-pair costs. Replacing it with a closed +form (`Cop0::tick_random_by`): + +| | A legs (ms) | B legs (ms) | conservative | +| --- | --- | --- | --- | +| Super Mario 64 | 28.546 / 28.616 / 28.618 | 27.608 / 27.602 / 27.764 | **1.028x** | +| Mario Kart 64 | 21.360 / 21.310 / 21.479 | 20.160 / 20.189 / 20.181 | **1.057x** | + +Every B leg beats every A leg; leg spreads are 0.14–0.79% on the no-build +harness. 35.0 -> 36.2 FPS on Super Mario 64, 46.9 -> 49.6 on Mario Kart 64. + +**Batch plus closed form, against the pre-batch baseline: 1.105x (SM64) and +1.145x (Mario Kart 64), against the 1.156x ceiling the probe set.** Mario Kart +essentially reaches it, which is the first time in this program that a measured +implementation has landed on its predicted ceiling rather than well under it. + +### Why the closed form is not `random - n` + +`Random` walks down to `Wired` and then jumps to **31**, so the sequence is a +transient followed by a cycle of `31 ..= Wired`. Three cases look impossible and +are reachable, because both fields are masked to 6 bits on write: `Wired > 31`, +`Random < Wired`, and `Random > 31`. Each *lengthens* the walk rather than +erroring, so both distances are taken modulo 64 rather than assumed in range. + +A closed form that assumes `Wired <= 31` is right for every value software +sensibly writes and wrong for exactly the ones a test ROM probes. The test sweeps +the whole space — 64 x 64 x 71 tick counts, plus a million-tick case for the +modulo — rather than sampling it, and the naive `random - n` dies at +`wired 0, random 0, 32 ticks`: the wrap, on the first cycle. From ff4215053d357c81f1ba7f4e6eac14ab6de2b7d5 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 16:00:16 -0400 Subject: [PATCH 23/24] feat(frontend): show FPS in the status bar instead of master ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `master {n} ticks` is a monotonically climbing 64-bit number that tells a user nothing about whether the emulator is keeping up. The tick count stays in the debugger panel, where a raw timebase belongs. Counts PRODUCED frames against wall time, not egui's repaint rate — the latter would read 60 while the machine crawled, which is the failure mode worth avoiding. Sampled on a 0.5 s interval so the reading is steady rather than flickering through a range, shows `-- FPS` before the first interval instead of a confident 0.0, and clears rather than reporting a negative rate if the frame counter goes backwards (reset, save-state load). Uses egui's monotonic `input.time` rather than `std::time::Instant`, and the doc says why the tempting justification does NOT apply: `Instant` panicking on wasm32 is true of the crate and not of this module, which is `cfg(not(target_arch = "wasm32"))` with a separate wasm shell. That reason would have quietly stopped holding. Two tests, both witnessed failing when broken. Co-Authored-By: Claude Opus 5 --- crates/rustyn64-frontend/src/ui_shell.rs | 107 ++++++++++++++++++++++- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/rustyn64-frontend/src/ui_shell.rs b/crates/rustyn64-frontend/src/ui_shell.rs index 2951c050..933fa7f0 100644 --- a/crates/rustyn64-frontend/src/ui_shell.rs +++ b/crates/rustyn64-frontend/src/ui_shell.rs @@ -44,11 +44,27 @@ pub struct ShellState { pub fb_h: u32, } -/// The shell's own (non-core) UI state: which panels are open. +/// The shell's own (non-core) UI state: which panels are open, and the frame-rate +/// estimator behind the status bar. #[derive(Clone, Copy, Debug, Default)] pub struct Shell { /// The debugger panel is visible. pub debugger_open: bool, + /// Wall-clock of the last frame-rate sample, in egui's monotonic seconds. + /// + /// `ctx.input(|i| i.time)` rather than [`std::time::Instant`] because it is + /// already in hand at the call site and keeps this struct `Copy` — an + /// `Instant` would work equally well here, since this whole module is + /// `cfg(not(target_arch = "wasm32"))` and the wasm shell is a separate one. + /// Stated because the tempting justification — "`Instant` panics on wasm" — + /// is true of the crate and **not** of this module, and would have been a + /// reason that quietly stopped applying. + fps_sampled_at: f64, + /// The produced-frame count at that sample. + fps_sampled_frames: u64, + /// The rate shown in the status bar, or `None` before the first interval has + /// elapsed — displayed as `--` rather than as a confident `0.0`. + fps: Option, } impl Shell { @@ -57,9 +73,40 @@ impl Shell { pub const fn new() -> Self { Self { debugger_open: false, + fps_sampled_at: 0.0, + fps_sampled_frames: 0, + fps: None, } } + /// Re-estimate the frame rate, at most once per [`Self::FPS_INTERVAL`]. + /// + /// Counts **produced** frames against wall time, which is what a user means + /// by FPS — not the UI's repaint rate, which egui drives independently and + /// which would read as 60 while the machine crawled. + /// + /// The interval exists so the number is readable: sampling every repaint + /// makes it flicker through a range instead of showing a value. A frame + /// counter that goes backwards (a reset, a state load) yields `None` rather + /// than a negative rate. + fn sample_fps(&mut self, now: f64, frames: u64) { + if self.fps_sampled_at <= 0.0 { + self.fps_sampled_at = now; + self.fps_sampled_frames = frames; + return; + } + let elapsed = now - self.fps_sampled_at; + if elapsed < Self::FPS_INTERVAL { + return; + } + self.fps = frames + .checked_sub(self.fps_sampled_frames) + .filter(|_| elapsed > 0.0) + .map(|d| (d as f64 / elapsed) as f32); + self.fps_sampled_at = now; + self.fps_sampled_frames = frames; + } + /// Draw the whole shell for one frame and collect the requested actions. /// /// `root_ui` is the root [`egui::Ui`] from [`egui::Context::run_ui`], into @@ -70,7 +117,8 @@ impl Shell { let mut actions = Vec::new(); let ctx = root_ui.ctx().clone(); self.menu_bar(root_ui, state, &mut actions); - Self::status_bar(root_ui, state); + self.sample_fps(ctx.input(|i| i.time), state.frames); + self.status_bar(root_ui, state); if self.debugger_open { Self::debugger_panel(&ctx, state); } @@ -138,7 +186,11 @@ impl Shell { }); } - fn status_bar(root_ui: &mut egui::Ui, state: &ShellState) { + /// How long a frame-rate sample covers. Long enough that the reading is + /// steady, short enough that it tracks a change the user just made. + const FPS_INTERVAL: f64 = 0.5; + + fn status_bar(&self, root_ui: &mut egui::Ui, state: &ShellState) { egui::Panel::bottom("status_bar").show(root_ui, |ui| { ui.horizontal(|ui| { let status = if state.rom_loaded { @@ -154,7 +206,14 @@ impl Shell { ui.separator(); ui.label(format!("{}x{}", state.fb_w, state.fb_h)); ui.separator(); - ui.label(format!("master {} ticks", state.master_ticks)); + // The frame rate, not `master_ticks`. The tick count is a + // monotonically climbing 64-bit number that tells a user nothing + // about whether the emulator is keeping up; it stays in the + // debugger panel, where a raw timebase belongs. + ui.label(match self.fps { + Some(fps) if state.rom_loaded && !state.paused => format!("{fps:.1} FPS"), + _ => "-- FPS".to_string(), + }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(format!("RustyN64 v{}", crate::version())); }); @@ -191,6 +250,46 @@ impl Shell { #[cfg(test)] mod tests { + + /// The estimator counts produced frames against wall time, and says nothing + /// until it has an interval to divide by. + #[test] + fn fps_is_frames_over_wall_time_and_unknown_before_the_first_interval() { + let mut shell = Shell::new(); + shell.sample_fps(10.0, 1_000); + assert_eq!(shell.fps, None, "no rate can exist from a single sample"); + + // Half the interval: too soon, still nothing. + shell.sample_fps(10.2, 1_006); + assert_eq!( + shell.fps, None, + "reported a rate before an interval elapsed" + ); + + // 30 frames in exactly one second. + shell.sample_fps(11.0, 1_030); + assert_eq!(shell.fps, Some(30.0)); + + // Half a second, 15 frames -> still 30 FPS, so the interval is divided + // out rather than assumed. + shell.sample_fps(11.5, 1_045); + assert_eq!(shell.fps, Some(30.0)); + } + + /// A frame counter that goes backwards — a reset, a state load — must not + /// produce a negative rate. + #[test] + fn a_frame_counter_going_backwards_yields_no_rate() { + let mut shell = Shell::new(); + shell.sample_fps(1.0, 5_000); + shell.sample_fps(2.0, 5_060); + assert_eq!(shell.fps, Some(60.0)); + shell.sample_fps(3.0, 12); + assert_eq!( + shell.fps, None, + "a reset produced a rate instead of clearing it" + ); + } use super::*; #[test] From b8b7f2b609c9d69be78a38df1760de8c7bcc232b Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 18:33:09 -0400 Subject: [PATCH 24/24] chore(salvaged): rescue 30 hand-written dev artifacts from a volatile /tmp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `/tmp` scan surfaced ~1970 candidates and 612 "strong" ones, but almost all were strong by LOCATION — they sat in this project's agent scratch tree, so the directory name matched rather than anything about the file. Hand-picked instead: 30 files, 140 KB, each hand-written and not reproducible from the repository. DELIBERATELY EXCLUDED, because the default plan would have taken them: * ~100 MB of commercial ROMs and a framebuffer dump derived from one. scripts/check_no_roms.sh is the gate; this would have walked around it. * a 128 MB directory of synthetic test ROMs, which the scan wanted as a single directory unit * PGO profile data (~37 MB) and rendered screenshots (~14 MB), the latter also derived from commercial ROMs * 284 benchmark .txt logs — every conclusion is already in docs/performance.md * 214 .md PR bodies and review replies — that text lives on the pull requests * copies of tracked source (audio_*.rs, sched_*.rs, pipeline_orig.rs) — recoverable with `git show` TOUCHES A QUALITY GATE, flagged for review: scripts/check_en_us.sh gains `salvaged/` as an excluded tree. Two rescued files are en-US conversion tools whose substitution TABLES are en-GB words as data (26 flagged lines in one), and one is a diff that must stay byte-exact to be worth keeping — per-line markers would litter a data table and corrupt a patch. This extends the documented "not our prose to edit" rationale already covering ref-docs/, n64brew_wiki/, ref-proj/ and third_party/, rather than relaxing what the gate checks over project content. Verified by negative control: planting `colour behaviour` in docs/glossary.md still fails the gate. salvaged/README.md records what is there, what was left, and that several bench/ shells contain the rebuild-between-legs measurement bug — kept as the record of how the affected numbers were taken, not as a pattern to copy. Co-Authored-By: Claude Opus 5 --- salvaged/README.md | 51 +++++++ salvaged/bench/aba_batch.sh | 20 +++ salvaged/bench/aba_double.sh | 15 ++ salvaged/bench/aba_fetch.sh | 15 ++ salvaged/bench/aba_idle.sh | 16 +++ salvaged/bench/aba_idle2.sh | 18 +++ salvaged/bench/aba_quiet.sh | 44 ++++++ salvaged/bench/aba_split.sh | 16 +++ salvaged/bench/aba_strict.sh | 66 +++++++++ salvaged/bench/aba_vimemo.sh | 23 +++ salvaged/bench/aba_vu.sh | 15 ++ salvaged/bench/measure_only.sh | 35 +++++ salvaged/bench/work_vu.sh | 13 ++ salvaged/patches/ai-fix.patch | 150 +++++++++++++++++++ salvaged/patches/p.diff | 240 +++++++++++++++++++++++++++++++ salvaged/probes/cmp.rs | 12 ++ salvaged/probes/dflt.rs | 1 + salvaged/probes/fifotest.rs | 9 ++ salvaged/probes/h.c | 5 + salvaged/probes/probe.rs | 13 ++ salvaged/probes/seg_test.rs | 16 +++ salvaged/probes/simd_probe.rs | 6 + salvaged/probes/sizeprobe.rs | 10 ++ salvaged/probes/sz.rs | 1 + salvaged/probes/vi_state.rs | 30 ++++ salvaged/probes/wdc_probe.rs | 144 +++++++++++++++++++ salvaged/scripts/codecheck.py | 22 +++ salvaged/scripts/enus-applied.py | 84 +++++++++++ salvaged/scripts/enus.py | 84 +++++++++++ salvaged/scripts/wordaudit.py | 34 +++++ salvaged/scripts/zipcheck.sh | 9 ++ scripts/check_en_us.sh | 10 +- 32 files changed, 1226 insertions(+), 1 deletion(-) create mode 100644 salvaged/README.md create mode 100644 salvaged/bench/aba_batch.sh create mode 100644 salvaged/bench/aba_double.sh create mode 100644 salvaged/bench/aba_fetch.sh create mode 100644 salvaged/bench/aba_idle.sh create mode 100644 salvaged/bench/aba_idle2.sh create mode 100644 salvaged/bench/aba_quiet.sh create mode 100644 salvaged/bench/aba_split.sh create mode 100644 salvaged/bench/aba_strict.sh create mode 100644 salvaged/bench/aba_vimemo.sh create mode 100644 salvaged/bench/aba_vu.sh create mode 100644 salvaged/bench/measure_only.sh create mode 100644 salvaged/bench/work_vu.sh create mode 100644 salvaged/patches/ai-fix.patch create mode 100644 salvaged/patches/p.diff create mode 100644 salvaged/probes/cmp.rs create mode 100644 salvaged/probes/dflt.rs create mode 100644 salvaged/probes/fifotest.rs create mode 100644 salvaged/probes/h.c create mode 100644 salvaged/probes/probe.rs create mode 100644 salvaged/probes/seg_test.rs create mode 100644 salvaged/probes/simd_probe.rs create mode 100644 salvaged/probes/sizeprobe.rs create mode 100644 salvaged/probes/sz.rs create mode 100644 salvaged/probes/vi_state.rs create mode 100644 salvaged/probes/wdc_probe.rs create mode 100644 salvaged/scripts/codecheck.py create mode 100644 salvaged/scripts/enus-applied.py create mode 100644 salvaged/scripts/enus.py create mode 100644 salvaged/scripts/wordaudit.py create mode 100644 salvaged/scripts/zipcheck.sh diff --git a/salvaged/README.md b/salvaged/README.md new file mode 100644 index 00000000..a627708a --- /dev/null +++ b/salvaged/README.md @@ -0,0 +1,51 @@ +# salvaged/ + +Small, hand-written development artifacts rescued from a volatile `/tmp` before +a reboot would have wiped them. Nothing here is part of the build, and nothing +here is required to build, test, or run RustyN64. + +Kept because each one is **hand-written and not reproducible** from the +repository: a one-off probe, an audit script, a patch, or the exact shell that +produced a number quoted in `docs/performance.md`. + +## What is here + +| Directory | Contents | +| --- | --- | +| `scripts/` | Repository audit helpers — the en-US spelling pass, a word-frequency audit, a code checker, a zip integrity check | +| `probes/` | One-off `.rs` / `.c` probes: struct sizes, segment translation, FIFO behavior, a SIMD availability check, a `Default` check | +| `patches/` | Two working diffs kept for their content, not to be applied | +| `bench/` | The A-B-A benchmark shells behind the measurements in `docs/performance.md` | + +## `bench/` is provenance, not tooling + +The productized harness is **`scripts/bench_aba.sh`** — use that one. The shells +here are the exact per-experiment scripts each recorded measurement was taken +with, kept so a number in `docs/performance.md` can be traced to the commands +that produced it. + +Several of them **contain the measurement bug** described in that document: +they rebuild between legs, which biases whichever leg follows a build, because +a parallel release build is itself a multi-core job and `/proc/loadavg` is a +one-minute average still decaying when timing starts. They are kept *as the +record of how the affected numbers were taken*, not as a pattern to copy. +`measure_only.sh` and `aba_strict.sh` are the later, corrected shape. + +## What was deliberately NOT salvaged + +The scan surfaced ~1970 candidates and 612 "strong" ones. Almost all were strong +by **location** — they sat in this project's agent scratch tree, so the +directory name matched rather than anything about the file. Excluded on purpose: + +- **Commercial ROMs** (~100 MB) and a framebuffer dump derived from one. + `scripts/check_no_roms.sh` is the gate; this would have walked around it. +- **A 128 MB directory of synthetic test ROMs**, which the scan wanted to take + as a single directory unit. +- **PGO profile data** (~37 MB) and rendered screenshots (~14 MB) — regenerable, + and the screenshots are derived from commercial ROMs. +- **Benchmark output logs** (284 `.txt`) — every conclusion drawn from them is + already written up in `docs/performance.md`, which is the durable record. +- **PR bodies and review replies** (214 `.md`) — that text lives on the pull + requests. +- **Copies of tracked source** (`audio_*.rs`, `sched_*.rs`, `vi_B.rs`, + `pipeline_orig.rs`, …) — recoverable with `git show`. diff --git a/salvaged/bench/aba_batch.sh b/salvaged/bench/aba_batch.sh new file mode 100644 index 00000000..0725a26b --- /dev/null +++ b/salvaged/bench/aba_batch.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +run_leg() { + cp "$2" crates/rustyn64-core/src/scheduler.rs + cp "$3" crates/rustyn64-cpu/src/lib.rs + cp "$4" crates/rustyn64-cpu/src/cop0.rs + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + for t in "Super Mario 64" "Banjo-Kazooie"; do + printf "%s %-15.15s " "$1" "$t" + RUSTYN64_PROBE_ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/$t.z64" ./target/release/examples/frame_bench | head -1 + done +} +for i in 1 2; do + run_leg "A$i" /tmp/rustyn64-bench/sched.base /tmp/rustyn64-bench/cpulib.head /tmp/rustyn64-bench/cop0.head + run_leg "B$i" /tmp/rustyn64-bench/sched.batch /tmp/rustyn64-bench/cpulib.batch /tmp/rustyn64-bench/cop0.batch +done +cp /tmp/rustyn64-bench/sched.batch crates/rustyn64-core/src/scheduler.rs +cp /tmp/rustyn64-bench/cpulib.batch crates/rustyn64-cpu/src/lib.rs +cp /tmp/rustyn64-bench/cop0.batch crates/rustyn64-cpu/src/cop0.rs diff --git a/salvaged/bench/aba_double.sh b/salvaged/bench/aba_double.sh new file mode 100644 index 00000000..ac7cd33e --- /dev/null +++ b/salvaged/bench/aba_double.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" +SRC=crates/rustyn64-rsp/src/vu.rs +run_leg() { + cp "$2" "$SRC" + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + RUSTYN64_PROBE_ROM="$ROM" ./target/release/examples/frame_bench | head -1 | sed "s/^/$1 /" +} +for i in 1 2; do + run_leg "A$i-1x" /tmp/rustyn64-bench/vu.single + run_leg "B$i-2x" /tmp/rustyn64-bench/vu.double +done +cp /tmp/rustyn64-bench/vu.preElide "$SRC" diff --git a/salvaged/bench/aba_fetch.sh b/salvaged/bench/aba_fetch.sh new file mode 100644 index 00000000..c990b79a --- /dev/null +++ b/salvaged/bench/aba_fetch.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" +SRC=crates/rustyn64-cpu/src/pipeline/fastexec.rs +run_leg() { + cp "$2" "$SRC" + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + RUSTYN64_PROBE_ROM="$ROM" ./target/release/examples/frame_bench | head -1 | sed "s/^/$1 /" +} +for i in 1 2; do + run_leg "A$i-1x" /tmp/rustyn64-bench/fastexec.base + run_leg "B$i-2x" /tmp/rustyn64-bench/fastexec.double +done +cp /tmp/rustyn64-bench/fastexec.base "$SRC" diff --git a/salvaged/bench/aba_idle.sh b/salvaged/bench/aba_idle.sh new file mode 100644 index 00000000..8b3a4081 --- /dev/null +++ b/salvaged/bench/aba_idle.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +SRC=crates/rustyn64-cpu/src/pipeline/fastexec.rs +run_leg() { + cp "$2" "$SRC" + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + for r in "/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom"/"Super Mario 64.z64" "/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom"/"Mario Kart 64.z64"; do + RUSTYN64_PROBE_ROM="$r" ./target/release/examples/frame_bench | head -1 | sed "s|^|$1 $(basename "$r" .z64) |" + done +} +for i in 1 2; do + run_leg "A$i-base" /tmp/rustyn64-bench/fastexec.base + run_leg "B$i-idle" /tmp/rustyn64-bench/fastexec.idleskip +done +cp /tmp/rustyn64-bench/fastexec.base "$SRC" diff --git a/salvaged/bench/aba_idle2.sh b/salvaged/bench/aba_idle2.sh new file mode 100644 index 00000000..571f9938 --- /dev/null +++ b/salvaged/bench/aba_idle2.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +run_leg() { + cp "$2" crates/rustyn64-cpu/src/pipeline/fastexec.rs + cp "$3" crates/rustyn64-cpu/src/pipeline.rs + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + for r in "/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom"/*.z64; do + printf "%s %-22.22s " "$1" "$(basename "$r" .z64)" + RUSTYN64_PROBE_ROM="$r" ./target/release/examples/frame_bench | head -1 + done +} +for i in 1 2; do + run_leg "A$i" /tmp/rustyn64-bench/fastexec.base /tmp/rustyn64-bench/pipeline.head + run_leg "B$i" /tmp/rustyn64-bench/fastexec.idle_real /tmp/rustyn64-bench/pipeline.idle_real +done +cp /tmp/rustyn64-bench/fastexec.idle_real crates/rustyn64-cpu/src/pipeline/fastexec.rs +cp /tmp/rustyn64-bench/pipeline.idle_real crates/rustyn64-cpu/src/pipeline.rs diff --git a/salvaged/bench/aba_quiet.sh b/salvaged/bench/aba_quiet.sh new file mode 100644 index 00000000..520cca33 --- /dev/null +++ b/salvaged/bench/aba_quiet.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Wait for a quiet machine, then A-B-A-B-A the idle batch. Refuses to report +# numbers taken under load: a competing job has already inflated one leg of this +# measurement by 60%, which is larger than the effect being measured. +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 + +for _ in $(seq 1 60); do + L=$(cut -d' ' -f1 /proc/loadavg) + awk -v l="$L" 'BEGIN {exit !(l < 1.5)}' && break + sleep 20 +done +L=$(cut -d' ' -f1 /proc/loadavg) +if awk -v l="$L" 'BEGIN {exit !(l >= 1.5)}'; then + echo "STILL BUSY: load $L after 20 min — no numbers reported" + exit 1 +fi +echo "quiet: load $L" + +run_leg() { + cp "$2" crates/rustyn64-cpu/src/pipeline/fastexec.rs + cp "$3" crates/rustyn64-cpu/src/lib.rs + cp "$4" crates/rustyn64-core/src/scheduler.rs + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + # A discarded warm-up run: the first execution after a link is the one a busy + # or cold machine distorts most, and it is not part of the comparison. + RUSTYN64_PROBE_ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" ./target/release/examples/frame_bench >/dev/null 2>&1 + for t in "Super Mario 64" "Banjo-Kazooie" "Mario Kart 64"; do + printf "%s %-15.15s " "$1" "$t" + RUSTYN64_PROBE_ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/$t.z64" ./target/release/examples/frame_bench | head -1 + done +} + +A_FE=/tmp/rustyn64-bench/fe.head; A_LIB=/tmp/rustyn64-bench/cpulib.head; A_SCH=/tmp/rustyn64-bench/sched.base +B_FE=/tmp/rustyn64-bench/fe.batchtest; B_LIB=/tmp/rustyn64-bench/cpulib.batch; B_SCH=/tmp/rustyn64-bench/sched.batch +for i in 1 2; do + run_leg "A$i" $A_FE $A_LIB $A_SCH + run_leg "B$i" $B_FE $B_LIB $B_SCH +done +run_leg "A3" $A_FE $A_LIB $A_SCH +echo "final load: $(cut -d' ' -f1 /proc/loadavg)" +cp $B_FE crates/rustyn64-cpu/src/pipeline/fastexec.rs +cp $B_LIB crates/rustyn64-cpu/src/lib.rs +cp $B_SCH crates/rustyn64-core/src/scheduler.rs diff --git a/salvaged/bench/aba_split.sh b/salvaged/bench/aba_split.sh new file mode 100644 index 00000000..3d0fb47d --- /dev/null +++ b/salvaged/bench/aba_split.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" +SRC=crates/rustyn64-cpu/src/pipeline/fastexec.rs +run_leg() { + cp "$2" "$SRC" + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + RUSTYN64_PROBE_ROM="$ROM" ./target/release/examples/frame_bench | head -1 | sed "s/^/$1 /" +} +for i in 1 2; do + run_leg "A$i-base " /tmp/rustyn64-bench/fastexec.base + run_leg "F$i-fetch2x " /tmp/rustyn64-bench/fastexec.fetchonly + run_leg "D$i-decode2x " /tmp/rustyn64-bench/fastexec.decodeonly +done +cp /tmp/rustyn64-bench/fastexec.base "$SRC" diff --git a/salvaged/bench/aba_strict.sh b/salvaged/bench/aba_strict.sh new file mode 100644 index 00000000..6e303782 --- /dev/null +++ b/salvaged/bench/aba_strict.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Decide the idle batch on a genuinely quiet machine, or report nothing. +# +# The two previous attempts were both contaminated and the data said so rather +# than hiding it: the first had a leg 60% off, the second drifted 1.22 -> 2.63 +# load with the A legs climbing monotonically (30.46 -> 31.39 -> 31.45) and one +# title's B legs spread 36%. Both effects are larger than the ~3-7% being +# measured, so neither run can decide anything. +# +# This one refuses to start above 0.8, aborts if the load climbs past 1.6 +# mid-run, and discards a warm-up run after every link. +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 + +ROMDIR="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom" + +for _ in $(seq 1 90); do + L=$(cut -d' ' -f1 /proc/loadavg) + awk -v l="$L" 'BEGIN {exit !(l < 0.8)}' && break + sleep 20 +done +L=$(cut -d' ' -f1 /proc/loadavg) +if awk -v l="$L" 'BEGIN {exit !(l >= 0.8)}'; then + echo "STILL BUSY: load $L after 30 min — no numbers reported" + exit 1 +fi +echo "start load: $L" + +run_leg() { + cp "$2" crates/rustyn64-cpu/src/pipeline/fastexec.rs + cp "$3" crates/rustyn64-cpu/src/lib.rs + cp "$4" crates/rustyn64-core/src/scheduler.rs + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 \ + || { echo "$1 BUILD FAILED"; return 1; } + L=$(cut -d' ' -f1 /proc/loadavg) + if awk -v l="$L" 'BEGIN {exit !(l > 1.6)}'; then + echo "$1 ABORT: load rose to $L mid-run — remaining legs not reported" + return 2 + fi + # Discarded: the first execution after a link is the one a cold page cache or + # a passing job distorts most, and it is not part of the comparison. + RUSTYN64_PROBE_ROM="$ROMDIR/Super Mario 64.z64" ./target/release/examples/frame_bench >/dev/null 2>&1 + for t in "Super Mario 64" "Mario Kart 64"; do + printf "%s %-15.15s " "$1" "$t" + RUSTYN64_PROBE_ROM="$ROMDIR/$t.z64" ./target/release/examples/frame_bench | head -1 + done +} + +A_FE=/tmp/rustyn64-bench/fe.head +A_LIB=/tmp/rustyn64-bench/cpulib.head +A_SCH=/tmp/rustyn64-bench/sched.base +B_FE=/tmp/rustyn64-bench/fe.batchtest +B_LIB=/tmp/rustyn64-bench/cpulib.batch +B_SCH=/tmp/rustyn64-bench/sched.batch + +for i in 1 2 3; do + run_leg "A$i" $A_FE $A_LIB $A_SCH || break + run_leg "B$i" $B_FE $B_LIB $B_SCH || break +done +echo "end load: $(cut -d' ' -f1 /proc/loadavg)" + +# Leave the tree on the batch; the decision to keep or revert is made from the +# numbers above, not here. +cp $B_FE crates/rustyn64-cpu/src/pipeline/fastexec.rs +cp $B_LIB crates/rustyn64-cpu/src/lib.rs +cp $B_SCH crates/rustyn64-core/src/scheduler.rs diff --git a/salvaged/bench/aba_vimemo.sh b/salvaged/bench/aba_vimemo.sh new file mode 100644 index 00000000..c0356ff3 --- /dev/null +++ b/salvaged/bench/aba_vimemo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# A-B-A-B for the VI cov_cells memo. A = HEAD (no second memo layer), +# B = the memo. Interleaved so session drift lands on both legs. +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" +SRC=crates/rustyn64-core/src/bus.rs +FEAT="fast-exec,fast-scheduler" + +run_leg() { + local name="$1" snap="$2" + cp "$snap" "$SRC" + cargo build --release --example frame_bench --features "$FEAT" >/dev/null 2>&1 || { echo "$name BUILD FAILED"; return 1; } + RUSTYN64_PROBE_ROM="$ROM" ./target/release/examples/frame_bench | sed "s/^/$name /" +} + +for i in 1 2; do + run_leg "A$i" /tmp/rustyn64-bench/bus.preVImemo + run_leg "B$i" /tmp/rustyn64-bench/bus.withVImemo +done + +# Leave the tree on the memo version. +cp /tmp/rustyn64-bench/bus.withVImemo "$SRC" diff --git a/salvaged/bench/aba_vu.sh b/salvaged/bench/aba_vu.sh new file mode 100644 index 00000000..a8d3b17f --- /dev/null +++ b/salvaged/bench/aba_vu.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" +SRC=crates/rustyn64-rsp/src/vu.rs +run_leg() { + cp "$2" "$SRC" + cargo build --release --example frame_bench --features fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$1 BUILD FAILED"; return 1; } + RUSTYN64_PROBE_ROM="$ROM" ./target/release/examples/frame_bench | head -1 | sed "s/^/$1 /" +} +for i in 1 2; do + run_leg "A$i-real " /tmp/rustyn64-bench/vu.preElide + run_leg "B$i-elided" /tmp/rustyn64-bench/vu.elided +done +cp /tmp/rustyn64-bench/vu.preElide "$SRC" diff --git a/salvaged/bench/measure_only.sh b/salvaged/bench/measure_only.sh new file mode 100644 index 00000000..a8f45e8e --- /dev/null +++ b/salvaged/bench/measure_only.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Measure two ALREADY-BUILT binaries. No compilation anywhere in this script. +# +# The absolute load gate was miscalibrated: `frame_bench` is itself a running +# process, so `/proc/loadavg` sits near 1.0 *because of the measurement* and a +# "< 0.7 before starting" rule can never be satisfied on a machine that is +# otherwise idle. The previous clean run started at 0.63 and ended at 1.46 with +# sub-1% leg spreads — most of that rise was the benchmark itself. +# +# So the gate here is loose, and the DATA is what disqualifies a run: three +# interleaved rounds, and leg spreads reported alongside the means. +set -uo pipefail +ROMDIR="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom" +OUT=/tmp/rustyn64-bench + +for _ in $(seq 1 45); do + L=$(cut -d' ' -f1 /proc/loadavg) + awk -v l="$L" 'BEGIN {exit !(l < 1.1)}' && break + sleep 20 +done +echo "start load: $(cut -d' ' -f1 /proc/loadavg)" + +for leg in A B; do + RUSTYN64_PROBE_ROM="$ROMDIR/Super Mario 64.z64" "$OUT/frame_bench.$leg" >/dev/null 2>&1 +done + +for i in 1 2 3; do + for leg in A B; do + for t in "Super Mario 64" "Mario Kart 64"; do + printf "%s%d %-15.15s " "$leg" "$i" "$t" + RUSTYN64_PROBE_ROM="$ROMDIR/$t.z64" "$OUT/frame_bench.$leg" | head -1 + done + done +done +echo "end load: $(cut -d' ' -f1 /proc/loadavg)" diff --git a/salvaged/bench/work_vu.sh b/salvaged/bench/work_vu.sh new file mode 100644 index 00000000..06159641 --- /dev/null +++ b/salvaged/bench/work_vu.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -uo pipefail +cd /home/parobek/Code/OSS_Public-Projects/RustyN64 +ROM="/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustyN64/3c159115-07d9-4b69-a1e2-ea68c3260bd6/scratchpad/rom/Super Mario 64.z64" +SRC=crates/rustyn64-rsp/src/vu.rs +for leg in real:/tmp/rustyn64-bench/vu.preElide elided:/tmp/rustyn64-bench/vu.elided; do + name="${leg%%:*}"; snap="${leg#*:}" + cp "$snap" "$SRC" + cargo build --release --example work_bench --features work-counters,fast-exec,fast-scheduler >/dev/null 2>&1 || { echo "$name BUILD FAILED"; continue; } + echo "=== $name" + RUSTYN64_PROBE_ROM="$ROM" ./target/release/examples/work_bench 2>&1 | head -30 +done +cp /tmp/rustyn64-bench/vu.preElide "$SRC" diff --git a/salvaged/patches/ai-fix.patch b/salvaged/patches/ai-fix.patch new file mode 100644 index 00000000..e03da403 --- /dev/null +++ b/salvaged/patches/ai-fix.patch @@ -0,0 +1,150 @@ +diff --git a/crates/rustyn64-audio/src/lib.rs b/crates/rustyn64-audio/src/lib.rs +index 5b7377b..758f8f2 100644 +--- a/crates/rustyn64-audio/src/lib.rs ++++ b/crates/rustyn64-audio/src/lib.rs +@@ -212,6 +212,22 @@ impl Audio { + /// The 8 KiB (`0x2000`) page whose crossing arms the delayed-carry bug. + const PAGE: u32 = 0x2000; + ++ /// DAC rate used while `AI_DACRATE` is unprogrammed. ++ /// ++ /// **A modelling default, not a measured hardware value — see ledger R-16.** ++ /// `AI_DACRATE`'s reset value is not documented anywhere this project ++ /// mirrors, so no rate can be *derived* for the pre-programmed window. What ++ /// matters, and what IS established, is structural: the DAC has no stopped ++ /// state, so the transfer queue must be able to retire before software ++ /// programs the rate. This value is taken from ares (ISC, vendorable), whose ++ /// `AI::power()` sets `dac.frequency = 44100`. ++ /// ++ /// Nothing observable should depend on the exact number: every title ++ /// programs `AI_DACRATE` before it plays anything, so this rate only governs ++ /// how fast an unprogrammed DAC drains silence. If a future change makes an ++ /// output depend on it, that dependency is the bug, not this constant. ++ const DEFAULT_DAC_HZ: u32 = 44_100; ++ + /// Construct at power-on (NTSC, idle). + #[must_use] + pub const fn new() -> Self { +@@ -224,7 +240,12 @@ impl Audio { + dac_rate: 0, + bit_rate: 0, + video_clock: VIDEO_CLOCK_NTSC, +- sample_rate: 0, ++ // Must match what `recompute_rate` yields for `dac_rate == 0`. Left ++ // at 0 here, the default would only take effect once something ++ // happened to call `recompute_rate` (a DACRATE or region write), so a ++ // machine that never programmed the AI would still have a stopped DAC ++ // — the exact state ledger R-16's livelock needs. ++ sample_rate: Self::DEFAULT_DAC_HZ, + next_sample_tick: 0, + last_tick: 0, + dac_hold: StereoSample { left: 0, right: 0 }, +@@ -397,12 +418,27 @@ impl Audio { + /// Re-derive [`Audio::sample_rate`] from the current video clock and + /// `AI_DACRATE`. + const fn recompute_rate(&mut self) { +- // Either operand being zero means "not meaningfully programmed → emit +- // nothing". `video_clock` is never 0 in practice, so this is really the +- // `dac_rate == 0` gate: without it, `set_region()` before `AI_DACRATE` +- // is written would compute `video_clock / 1` ≈ 48 MHz and flood the sink. +- self.sample_rate = if self.dac_rate == 0 || self.video_clock == 0 { ++ // An unprogrammed `AI_DACRATE` falls back to [`Self::DEFAULT_DAC_HZ`] ++ // rather than to zero. Zero used to mean "emit nothing", which is wrong ++ // in a way that reaches far past audio: with the DAC stopped, `tick()` ++ // returns before `emit_sample`, and `emit_sample` is the ONLY place a ++ // drained transfer is retired — so `AI_STATUS.FULL` could latch and never ++ // clear, and a game polling it for a free DMA slot spun forever. World ++ // Driver Championship did exactly that (ledger R-16). ++ // ++ // Hardware has no stopped-DAC state to model: the DAC counter runs off ++ // the video clock from power-on, whatever `AI_DACRATE` holds. ares (ISC, ++ // vendorable) makes the same choice structurally — `AI::power()` sets ++ // `dac.frequency = 44100` and `AI::main()` calls `sample()` ++ // unconditionally, so its identical retirement block runs from power-on. ++ // ++ // The naive alternative — letting `dac_rate == 0` compute ++ // `video_clock / 1` ≈ 48 MHz — is what the old zero-gate was avoiding, ++ // and it would flood the sink. A default rate avoids both failures. ++ self.sample_rate = if self.video_clock == 0 { + 0 ++ } else if self.dac_rate == 0 { ++ Self::DEFAULT_DAC_HZ + } else { + self.video_clock / (self.dac_rate as u32 + 1) + }; +@@ -634,19 +670,67 @@ mod tests { + } + + #[test] +- fn set_region_before_dacrate_keeps_rate_zero() { +- // Selecting a region before AI_DACRATE is programmed must NOT fabricate +- // a ~48 MHz rate (the video clock divided by 1) — the DAC stays idle. ++ fn set_region_before_dacrate_does_not_fabricate_the_video_clock_rate() { ++ // What this has always been protecting: selecting a region before ++ // AI_DACRATE is programmed must NOT compute `video_clock / 1` (~48 MHz) ++ // and flood the sink. ++ // ++ // It previously asserted the rate was exactly **0** and that the DAC ++ // "emits nothing". That over-specified the protection into a bug: a ++ // zero rate makes `tick` return before `emit_sample`, and `emit_sample` ++ // is the only place a drained transfer is retired — so `AI_STATUS.FULL` ++ // could latch forever (see ++ // `full_clears_even_when_dacrate_was_never_programmed`, and ledger R-16). ++ // The unprogrammed DAC now runs at `DEFAULT_DAC_HZ`. The assertion is ++ // therefore an ORDER-OF-MAGNITUDE bound, which is what the comment ++ // always described, rather than an exact value the hardware does not ++ // document. + let mut ai = Audio::new(); + ai.set_region(Region::Pal); ++ assert!( ++ ai.sample_rate() > 0 && ai.sample_rate() < 100_000, ++ "an unprogrammed DAC runs at an audio rate, not the video clock: {}", ++ ai.sample_rate() ++ ); ++ let mut bus = TestBus::new(0x1000); ++ ai.tick(MASTER_HZ / 60, &mut bus); // one frame ++ let emitted = ai.drain().len(); ++ // ~44100/60 = 735. The bound that matters is that it is not the ~800k ++ // samples per frame a 48 MHz rate would produce. ++ assert!( ++ emitted < 5_000, ++ "one frame of an unprogrammed DAC must not flood the sink: {emitted}" ++ ); ++ } ++ ++ /// **`AI_STATUS.FULL` must be able to clear even if `AI_DACRATE` was never ++ /// programmed** — the World Driver Championship livelock (ledger R-16). ++ /// ++ /// A game may queue two buffers and poll `FULL` for a free slot before it ++ /// programs the DAC. Retirement lives in `emit_sample`, which only runs when ++ /// the DAC has a period, so a stopped DAC latched `FULL` permanently and the ++ /// poll never exited. Mutation guard: restore the `dac_rate == 0 → 0` rate ++ /// and this test hangs on `FULL` forever (it fails on the assertion below). ++ #[test] ++ fn full_clears_even_when_dacrate_was_never_programmed() { ++ let mut ai = Audio::new(); ++ let mut bus = TestBus::new(0x4000); ++ // Two queued transfers, no AI_DACRATE write, DMA enabled. ++ ai.write_reg(2, 1); // AI_CONTROL: DMA enable ++ ai.write_reg(0, 0x0000_1000); ++ ai.write_reg(1, 0x40); ++ ai.write_reg(0, 0x0000_2000); ++ ai.write_reg(1, 0x40); ++ assert_ne!(ai.status() & (1 << 31), 0, "two queued → FULL"); ++ ++ // Advance a second of emulated time. With a running DAC the two 64-byte ++ // transfers drain almost immediately; with a stopped one, never. ++ ai.tick(MASTER_HZ, &mut bus); + assert_eq!( +- ai.sample_rate(), ++ ai.status() & (1 << 31), + 0, +- "no DACRATE → no rate, whatever the region" ++ "FULL must clear once a transfer retires, even with AI_DACRATE unset" + ); +- let mut bus = TestBus::new(0x1000); +- ai.tick(1_000_000, &mut bus); +- assert!(ai.drain().is_empty(), "an unprogrammed DAC emits nothing"); + } + + #[test] diff --git a/salvaged/patches/p.diff b/salvaged/patches/p.diff new file mode 100644 index 00000000..8e2d44af --- /dev/null +++ b/salvaged/patches/p.diff @@ -0,0 +1,240 @@ +diff --git a/crates/rustyn64-cpu/src/pipeline.rs b/crates/rustyn64-cpu/src/pipeline.rs +index a3ef43e..37064e8 100644 +--- a/crates/rustyn64-cpu/src/pipeline.rs ++++ b/crates/rustyn64-cpu/src/pipeline.rs +@@ -2002,15 +2002,21 @@ impl Pipeline { +- /// **The order is preserved exactly** — but note what that is and is not +- /// worth. The original rationale is carried below verbatim (an unusable +- /// coprocessor is reported as such even when the encoding is also 64-bit). +- /// It was **mutation-checked while extracting this**, and swapping the first +- /// two checks changes **nothing**: n64-systemtest still reports 0 failing in +- /// the Phase 1 categories and 90 suite-wide. So the ordering is a *reasoned* +- /// claim, not an oracle-verified one — the suite evidently never reaches an +- /// encoding that is simultaneously 64-bit and on an unusable coprocessor in a +- /// mode where both would refuse. +- /// +- /// Recorded rather than asserted away, because "the order matters" is exactly +- /// the shape of claim this project keeps finding stale: nothing fails when it +- /// is wrong. Preserving the order is still correct — it is what the accurate +- /// path has always done, and changing it on the strength of an *absent* test +- /// would be worse. What is not available is evidence. ++ /// **The order is preserved exactly, and the first two refusals turn out to ++ /// be disjoint.** The accurate path's comment says an unusable coprocessor is ++ /// reported as such *"even when the encoding is also a 64-bit one"* — but ++ /// mutation-checking that while extracting this changed nothing ++ /// (n64-systemtest stayed at 0 failing in the Phase 1 categories, 90 ++ /// suite-wide), and the reason is stronger than a gap in the suite: ++ /// [`Op::is_64_bit`](crate::decode::Op::is_64_bit) covers only CPU integer ++ /// and load/store operations, while [`Self::unusable_coprocessor`] answers ++ /// only for COP0/COP1/COP2 encodings. **No input is in both sets**, so no ++ /// input can reach the second check by way of the first. ++ /// ++ /// So the ordering between those two is *unobservable*, not merely untested. ++ /// It is kept because it is what the accurate path has always done, and ++ /// `ex_gates_first_two_refusals_cannot_both_apply` sweeps the encoding space ++ /// to keep the disjointness true — if a 64-bit coprocessor move is ever ++ /// classified as 64-bit, that test fails and whoever changed it has to ++ /// justify the precedence against the manual instead of inheriting it. ++ /// ++ /// Written out because "the order matters" is exactly the shape of claim this ++ /// project keeps finding stale: nothing fails when it is wrong. A first draft ++ /// of this comment asserted the order was load-bearing. +@@ -5793 +5799 @@ mod tests { +- p.cop0.set_hardware(reg::STATUS, 0b10 << 3); ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 0b10 << 3); +@@ -5889 +5895 @@ mod tests { +- p.cop0.set_hardware(reg::STATUS, 1 << 29); // CU1 ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 1 << 29); // CU1 +@@ -7098 +7104 @@ mod tests { +- p.cop0.set_hardware(reg::STATUS, 1 << 29); ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 1 << 29); +@@ -7139 +7145 @@ mod tests { +- p.cop0.set_hardware(reg::STATUS, 1 << 29); ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 1 << 29); +@@ -7167 +7173 @@ mod tests { +- p.cop0.set_hardware(reg::STATUS, 1 << 29); ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 1 << 29); +@@ -7205,0 +7212,183 @@ mod tests { ++ ++ // --------------------------------------------------------------------- ++ // The latch-independent primitives, tested directly (ADR 0013's seam) ++ // --------------------------------------------------------------------- ++ // ++ // The stage tests above reach these through the cascade, which is the right ++ // way to test the accurate path and the wrong way to pin a *contract*. These ++ // call the primitives directly, because the instruction-granular path will ++ // call them with different consequences and needs the decisions to hold on ++ // their own. ++ ++ /// A faulting fetch must report the fault **and leave the bus untouched** — ++ /// the access itself is what is invalid. ++ /// ++ /// Distinct from `an_unaligned_fetch_raises_address_error_without_realigning` ++ /// above, which checks what `ic_stage` *does* with the fault (stamps `ic_rf`, ++ /// declines to realign). This checks the primitive's own contract, which is ++ /// the half a second caller depends on. ++ #[test] ++ fn fetch_word_reports_a_fault_without_touching_the_bus() { ++ struct Watch { ++ touched: bool, ++ } ++ impl Bus for Watch { ++ fn read_u8(&mut self, _addr: u32) -> u8 { ++ self.touched = true; ++ 0 ++ } ++ fn write_u8(&mut self, _addr: u32, _val: u8) { ++ self.touched = true; ++ } ++ fn read_u32(&mut self, _addr: u32) -> u32 { ++ self.touched = true; ++ 0 ++ } ++ } ++ let mut bus = Watch { touched: false }; ++ let mut p = Pipeline::new(); ++ ++ assert_eq!( ++ p.fetch_word(&mut bus, 0xFFFF_FFFF_8000_0002), ++ Err(Exception::AddressError { store: false }), ++ "an unaligned fetch is an address error" ++ ); ++ assert!( ++ !bus.touched, ++ "the bus was accessed for a fetch that is invalid before it happens" ++ ); ++ } ++ ++ /// The positive case, so the test above cannot pass against a `fetch_word` ++ /// that simply always fails. ++ /// ++ /// The word is a distinctive sentinel rather than zero: zero is `NOP` and is ++ /// also what a bus returning nothing produces, so it cannot tell a real fetch ++ /// from a missing one. ++ #[test] ++ fn fetch_word_returns_the_word_at_an_aligned_pc() { ++ struct Fixed(u32); ++ impl Bus for Fixed { ++ fn read_u8(&mut self, _addr: u32) -> u8 { ++ 0 ++ } ++ fn write_u8(&mut self, _addr: u32, _val: u8) {} ++ fn read_u32(&mut self, _addr: u32) -> u32 { ++ self.0 ++ } ++ } ++ const SENTINEL: u32 = 0xDEAD_BEEF; ++ let mut bus = Fixed(SENTINEL); ++ let mut p = Pipeline::new(); ++ ++ // KSEG1 — uncached and unmapped, so this reaches the bus directly and ++ // tests the fetch rather than the I-cache. ++ assert_eq!(p.fetch_word(&mut bus, 0xFFFF_FFFF_A000_0000), Ok(SENTINEL)); ++ } ++ ++ /// `ex_gate` refuses `DCFC1`/`DCTC1` by **replacing** `FCSR.Cause`, not by ++ /// OR-ing into it. ++ /// ++ /// Asserted as an *effect*: `FCSR` is seeded with unrelated cause bits first, ++ /// and they must be gone afterwards. A test that only checked bit 17 had ++ /// arrived would pass against an implementation that OR-ed — which is the ++ /// behavior n64-systemtest specifically pre-loads unrelated bits to catch. ++ #[test] ++ fn ex_gate_replaces_the_whole_fcsr_cause_field() { ++ /// `FCSR.Cause`, bits 17:12 — the field under test. ++ const CAUSE: u32 = 0x3F << 12; ++ /// Unrelated cause bits (Inexact and Overflow), seeded to be cleared. ++ const SEEDED: u32 = 0b0001_0100 << 12; ++ ++ let mut p = Pipeline::new(); ++ p.cop1.ctc1(31, SEEDED); ++ assert_eq!(p.cop1.fcsr() & CAUSE, SEEDED, "the seed took"); ++ ++ // COP1 opcode with rs = 3 is `DCFC1`, the usable-but-unimplemented ++ // control move. Decoded rather than hand-built so the encoding stays ++ // owned by `decode`. ++ let d = decode((0o21 << 26) | (3 << 21)); ++ assert_eq!( ++ d.op, ++ crate::decode::Op::Cop1ReservedControl, ++ "the encoding under test must actually be the reserved control move" ++ ); ++ ++ // `CU1` must be SET, or the coprocessor-usability check refuses first and ++ // this would test the wrong branch. ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 1 << 29); ++ assert_eq!(p.ex_gate(d), Err(Exception::FloatingPoint)); ++ assert_eq!( ++ p.cop1.fcsr() & CAUSE, ++ crate::fpu::CAUSE_UNIMPLEMENTED, ++ "the seeded cause bits survived: `Cause` was OR-ed, not replaced" ++ ); ++ } ++ ++ /// **`ex_gate`'s first two refusals are DISJOINT, so their order cannot be ++ /// observed — and this test is what keeps that true.** ++ /// ++ /// The extraction started from the accurate path's comment, which says an ++ /// unusable coprocessor is reported as such *"even when the encoding is also ++ /// a 64-bit one"*. Mutation-checking that against n64-systemtest changed ++ /// nothing, and the reason is stronger than "the suite misses it": ++ /// ++ /// - `Op::is_64_bit` covers only CPU integer and load/store operations ++ /// (`Dadd` … `Sdr`); ++ /// - `unusable_coprocessor` returns `Some` only for COP0/COP1/COP2 encodings. ++ /// ++ /// No encoding is in both sets, so **no input can reach the second check by ++ /// way of the first**. The ordering is unobservable rather than merely ++ /// untested, which is why the oracle was silent. ++ /// ++ /// Swept over a structured slice of the encoding space rather than asserted ++ /// against a copy of either list: a duplicated list stops covering the thing ++ /// it duplicates the moment one side grows, which is the failure this test ++ /// exists to catch. If `Dmfc1`/`Dmtc1`/`Dmfc0`/`Dmtc0` are ever added to ++ /// `is_64_bit` — they *are* 64-bit operations, and whether their omission is ++ /// correct is an open question, not a claim made here — this fails, and ++ /// whoever adds them has to decide what the precedence should be with the ++ /// case in front of them. ++ #[test] ++ fn ex_gates_first_two_refusals_cannot_both_apply() { ++ let mut p = Pipeline::new(); ++ // 32-bit USER mode with every `CU` bit clear, so *every* coprocessor ++ // encoding is unusable and `sixty_four_bit_is_reserved` holds. Kernel ++ // mode would exempt COP0 and hide half the question. ++ p.cop0.set_hardware(crate::cop0::reg::STATUS, 0b10 << 3); ++ assert!( ++ p.sixty_four_bit_is_reserved(), ++ "the sweep is meaningless unless 64-bit really is reserved here" ++ ); ++ ++ let mut coprocessor = 0u32; ++ let mut sixty_four = 0u32; ++ for opcode in 0..64u32 { ++ for rs in 0..32u32 { ++ for funct in 0..64u32 { ++ let d = decode((opcode << 26) | (rs << 21) | funct); ++ let cop = p.unusable_coprocessor(d).is_some(); ++ let wide = d.op.is_64_bit(); ++ coprocessor += u32::from(cop); ++ sixty_four += u32::from(wide); ++ assert!( ++ !(cop && wide), ++ "opcode {opcode:#o} rs {rs:#o} funct {funct:#o} decodes to \ ++ {:?}, which is BOTH 64-bit and on an unusable coprocessor \ ++ -- `ex_gate`'s first two checks are no longer disjoint, so \ ++ their order is now observable and has to be justified \ ++ against the manual rather than inherited", ++ d.op ++ ); ++ } ++ } ++ } ++ // A sweep that decoded nothing interesting would pass just as ++ // convincingly. Both populations must be non-empty for the disjointness ++ // above to be a claim about anything. ++ assert!( ++ coprocessor > 0 && sixty_four > 0, ++ "the sweep found {coprocessor} coprocessor and {sixty_four} 64-bit \ ++ encodings; with either at zero it proves nothing" ++ ); ++ } diff --git a/salvaged/probes/cmp.rs b/salvaged/probes/cmp.rs new file mode 100644 index 00000000..01809a27 --- /dev/null +++ b/salvaged/probes/cmp.rs @@ -0,0 +1,12 @@ +#[test] +#[ignore] +fn cmp() { + let b = rustyn64_test_harness::conformance::vector_bytes("tex_tri_ci4_tlut_16"); + let v = rustyn64_test_harness::conformance::parse(b); + let got = rustyn64_test_harness::conformance::replay(&v); + let hx = |s: &[u8]| (0..8).map(|r| (0..8).map(|c| { + let i = (r*8+c)*2; format!("{:02x}{:02x}", s[i], s[i+1]) + }).collect::>().join(" ")).collect::>(); + eprintln!("GOLDEN (Angrylion):"); for l in hx(v.golden_fb) { eprintln!(" {l}"); } + eprintln!("OURS:"); for l in hx(&got) { eprintln!(" {l}"); } +} diff --git a/salvaged/probes/dflt.rs b/salvaged/probes/dflt.rs new file mode 100644 index 00000000..524d59be --- /dev/null +++ b/salvaged/probes/dflt.rs @@ -0,0 +1 @@ +fn main() { let _: [u64; 64] = Default::default(); } diff --git a/salvaged/probes/fifotest.rs b/salvaged/probes/fifotest.rs new file mode 100644 index 00000000..1ba0a61d --- /dev/null +++ b/salvaged/probes/fifotest.rs @@ -0,0 +1,9 @@ +fn main() { + let p = std::env::args().nth(1).unwrap(); + let meta = std::fs::metadata(&p).unwrap(); + println!("metadata().len() = {} (is_fifo-ish: {})", meta.len(), !meta.is_file()); + match rustyn64_frontend::romfile::read_rom(std::path::Path::new(&p)) { + Ok(v) => println!("READ {} bytes -- UNBOUNDED", v.len()), + Err(e) => println!("REFUSED: {e}"), + } +} diff --git a/salvaged/probes/h.c b/salvaged/probes/h.c new file mode 100644 index 00000000..bd928488 --- /dev/null +++ b/salvaged/probes/h.c @@ -0,0 +1,5 @@ +#include +#include +#define HALVES(hi16, lo16) ((((uint32_t)(hi16) & 0xFFFFu) << 16) | ((uint32_t)(lo16) & 0xFFFFu)) +int main(void){ printf("HALVES(-0x20,0) = 0x%08X\n", HALVES(-0x20, 0)); + printf("HALVES(0xE0,0) = 0x%08X\n", HALVES(0xE0, 0)); return 0; } diff --git a/salvaged/probes/probe.rs b/salvaged/probes/probe.rs new file mode 100644 index 00000000..623af8ce --- /dev/null +++ b/salvaged/probes/probe.rs @@ -0,0 +1,13 @@ + #[test] + fn probe_partial() { + let mut bus = Bus::new(); + let addr = 0x0010_0000u32; + bus.rdram[addr as usize..addr as usize + 4].copy_from_slice(&0x2900_0000u32.to_be_bytes()); + bus.rdp.dpc_write(0, addr); + bus.rdp.dpc_write(1, addr + 4); + println!("start={:#x} current={:#x} end={:#x} status={:#x}", + bus.rdp.dpc_read(0), bus.rdp.dpc_read(2), bus.rdp.dpc_read(1), bus.rdp.dpc_read(3)); + for _ in 0..4 { bus.rdp_tick(); } + println!("after ticks current={:#x} processed={} tap_len={}", + bus.rdp.dpc_read(2), bus.rdp.commands_processed, bus.rdp_tap.len()); + } diff --git a/salvaged/probes/seg_test.rs b/salvaged/probes/seg_test.rs new file mode 100644 index 00000000..3039dd8d --- /dev/null +++ b/salvaged/probes/seg_test.rs @@ -0,0 +1,16 @@ + /// **R-18: a sign-extended KSEG0 address stays Direct with `KX = 1`.** + /// Banjo-Kazooie's `SD $t0, 0x58($k0)` targets `0xFFFF_FFFF_8028_4C78` in + /// kernel mode with 64-bit addressing enabled and takes AdES. + #[test] + fn r18_kseg0_is_direct_in_wide_kernel_mode() { + let acc = Access { mode: Mode::Kernel, wide: true, erl: false }; + let v = 0xFFFF_FFFF_8028_4C78u64; + assert!( + matches!(segment(v, acc), Segment::Direct { .. }), + "KSEG0 must stay direct with KX=1, got {:?}", + segment(v, acc) + ); + // ... and with KX clear, for contrast. + let narrow = Access { mode: Mode::Kernel, wide: false, erl: false }; + assert!(matches!(segment(v, narrow), Segment::Direct { .. })); + } diff --git a/salvaged/probes/simd_probe.rs b/salvaged/probes/simd_probe.rs new file mode 100644 index 00000000..16efc47e --- /dev/null +++ b/salvaged/probes/simd_probe.rs @@ -0,0 +1,6 @@ +fn main() { + use std::simd::u16x8; + let a = u16x8::splat(3); + let b = u16x8::splat(4); + println!("{:?}", a * b); +} diff --git a/salvaged/probes/sizeprobe.rs b/salvaged/probes/sizeprobe.rs new file mode 100644 index 00000000..bc9973c0 --- /dev/null +++ b/salvaged/probes/sizeprobe.rs @@ -0,0 +1,10 @@ +#[test] +fn probe_latch_sizes() { + use core::mem::size_of; + println!("Latch {}", size_of::()); + println!(" Decoded {}", size_of::()); + println!(" WriteBack {}", size_of::()); + println!(" Option {}", size_of::>()); + println!(" Option {}", size_of::>()); + println!(" Option {}", size_of::>()); +} diff --git a/salvaged/probes/sz.rs b/salvaged/probes/sz.rs new file mode 100644 index 00000000..0ab63df4 --- /dev/null +++ b/salvaged/probes/sz.rs @@ -0,0 +1 @@ +fn main() { println!("{}", size_of::()); } diff --git a/salvaged/probes/vi_state.rs b/salvaged/probes/vi_state.rs new file mode 100644 index 00000000..ca8d832d --- /dev/null +++ b/salvaged/probes/vi_state.rs @@ -0,0 +1,30 @@ +//! Scratch: which VI path is hot for a real title? +use rustyn64_frontend::emu::EmuCore; +use rustyn64_frontend::input::{N64Buttons, bit}; + +#[test] +#[ignore] +fn vi_state() { + let path = std::env::var("RUSTYN64_PROBE_ROM").unwrap(); + let raw = std::fs::read(&path).unwrap(); + let mut core = EmuCore::new(0); + core.load_rom(&raw).unwrap(); + for f in 0..520 { + let mut b = N64Buttons::default(); + match (f / 8) % 4 { 0 => b.set(bit::START, true), 2 => b.set(bit::A, true), _ => {} } + core.set_controllers([b.pack(), 0, 0, 0]); + core.run_frame(); + } + let bus = &core.system().bus; + let r = |o: u32| rustyn64_core::cpu::Bus::read_u32_dbg(bus, 0x0440_0000 + o); + let ctrl = r(0x00); + let xs = r(0x30); let ys = r(0x34); + println!("VI_CTRL = {ctrl:#010x}"); + println!(" type/bpp = {}", ctrl & 3); + println!(" aa_mode = {} (3=REPLICATE/nearest, 0/1=coverage path)", (ctrl >> 8) & 3); + println!(" divot = {}", (ctrl >> 4) & 1); + println!(" dither_flt = {}", (ctrl >> 16) & 1); + println!(" gamma = {}", (ctrl >> 3) & 1); + println!("VI_X_SCALE = {xs:#010x} x_add = {} (1024 = 1 src px/out px)", xs & 0xFFF); + println!("VI_Y_SCALE = {ys:#010x} y_add = {}", ys & 0xFFF); +} diff --git a/salvaged/probes/wdc_probe.rs b/salvaged/probes/wdc_probe.rs new file mode 100644 index 00000000..37be5721 --- /dev/null +++ b/salvaged/probes/wdc_probe.rs @@ -0,0 +1,144 @@ +//! SCRATCH probe (task #52): why does World Driver Championship issue 176,085 +//! RDP commands under `hle_boot` and ZERO under `real_pif_boot`? +//! +//! Not for commit. Deleted before any commit. +#![cfg(not(target_arch = "wasm32"))] +#![allow(missing_docs, clippy::all, clippy::pedantic, clippy::nursery)] + +use std::collections::BTreeMap; +use std::path::Path; + +use rustyn64_core::System; +use rustyn64_test_harness::rom; + +const TPF: u64 = rustyn64_core::MASTER_HZ / 60; + +struct Snap { + label: &'static str, + rdp: u64, + retired: u64, + rdram_nonzero: usize, + dims: (u32, u32), + /// retiring PC -> (count, first word seen) + top: Vec<(u64, u64, u32)>, + rsp_pcs: usize, + nmi: bool, + ai_status: u32, + ai_dacrate: u32, + ai_len: u32, +} + +/// Run `frames`, then cycle-step `probe_cycles` sampling the DC/WB latch — the +/// retiring position, not the fetch PC (ADR 0007). +fn run(label: &'static str, sys: &mut System, frames: u64, probe_cycles: u64) -> Snap { + let mut rsp_seen = std::collections::BTreeSet::new(); + // Per-frame AI_STATUS census. One instantaneous FULL=1 says nothing: the + // question is whether the bit EVER clears, so count both states and the + // transitions between them. + let mut full_set = 0u64; + let mut full_clear = 0u64; + let mut full_transitions = 0u64; + let mut prev_full: Option = None; + for _ in 0..frames { + let target = sys.master_ticks().saturating_add(TPF); + sys.run_until(target); + rsp_seen.insert(sys.bus.rsp.pc()); + let full = (sys.bus.audio.read_reg(3) >> 31) & 1 == 1; + if full { full_set += 1 } else { full_clear += 1 } + if prev_full.is_some_and(|p| p != full) { full_transitions += 1 } + prev_full = Some(full); + } + eprintln!( + " [{label}] AI FULL over {frames} frames: set={full_set} clear={full_clear} transitions={full_transitions}" + ); + + // Fine-grained retirement census: 2 master ticks = 1 CPU cycle. + let mut hist: BTreeMap = BTreeMap::new(); + for _ in 0..probe_cycles { + let t = sys.master_ticks().saturating_add(2); + sys.run_until(t); + let l = &sys.cpu.pipeline.dc_wb; + if l.occupied { + let e = hist.entry(l.pc & 0xFFFF_FFFF).or_insert((0, l.word)); + e.0 += 1; + } + } + let mut top: Vec<(u64, u64, u32)> = hist.iter().map(|(&p, &(c, w))| (c, p, w)).collect(); + top.sort_by(|a, b| b.0.cmp(&a.0)); + top.truncate(12); + + let mut frame = vec![0u8; 720 * 576 * 4]; + let dims = sys.bus.scanout_scaled(&mut frame); + + Snap { + label, + rdp: sys.bus.rdp.commands_processed, + retired: sys.cpu.retired, + rdram_nonzero: sys.bus.rdram.iter().filter(|&&b| b != 0).count(), + dims, + top, + rsp_pcs: rsp_seen.len(), + nmi: sys.bus.boot_nmi_halt(), + // AI register indices: 0 DRAM_ADDR, 1 LEN, 2 CONTROL, 3 STATUS, 4 DACRATE + ai_status: sys.bus.audio.read_reg(3), + ai_dacrate: sys.bus.audio.read_reg(4), + ai_len: sys.bus.audio.read_reg(1), + } +} + +fn report(s: &Snap) { + eprintln!( + "\n=== {} ===\n rdp={} retired={} rdram_nonzero={} scanout={}x{} rsp_pcs={} nmi={}", + s.label, s.rdp, s.retired, s.rdram_nonzero, s.dims.0, s.dims.1, s.rsp_pcs, s.nmi + ); + eprintln!( + " AI_STATUS={:#010x} FULL(b31)={} BUSY(b30)={} ENABLED(b25)={} AI_LEN={:#x} AI_DACRATE={:#x}", + s.ai_status, + (s.ai_status >> 31) & 1, + (s.ai_status >> 30) & 1, + (s.ai_status >> 25) & 1, + s.ai_len, + s.ai_dacrate, + ); + eprintln!(" top retiring PCs (of the fine-grained cycle census):"); + for (c, pc, w) in &s.top { + eprintln!(" {pc:#010x} word={w:#010x} n={c}"); + } +} + +#[test] +#[ignore = "scratch"] +fn wdc_boot_differential() { + let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/roms/external/commercial"); + let rom_path = base.join("controller-pak/World Driver Championship.z64"); + let Ok(image) = std::fs::read(&rom_path) else { + eprintln!("WDC not staged — skipped"); + return; + }; + let pif = std::fs::read(base.join("bios/pifdata.bin")).ok(); + + let frames: u64 = std::env::var("WDC_FRAMES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(420); + + let mut a = System::new(0); + rom::hle_boot(&mut a, &image).expect("hle boot"); + let sa = run("hle_boot", &mut a, frames, 4000); + report(&sa); + + if let Some(pif) = pif { + let mut b = System::new(0); + rom::real_pif_boot(&mut b, &image, &pif).expect("real-pif boot"); + let sb = run("real_pif_boot", &mut b, frames, 4000); + report(&sb); + + eprintln!( + "\n DELTA: rdp {} -> {} | rdram {} -> {} | scanout {}x{} -> {}x{}", + sa.rdp, sb.rdp, sa.rdram_nonzero, sb.rdram_nonzero, + sa.dims.0, sa.dims.1, sb.dims.0, sb.dims.1 + ); + } else { + eprintln!("no PIF ROM staged — real-PIF half skipped"); + } +} diff --git a/salvaged/scripts/codecheck.py b/salvaged/scripts/codecheck.py new file mode 100644 index 00000000..20dcbe76 --- /dev/null +++ b/salvaged/scripts/codecheck.py @@ -0,0 +1,22 @@ +import re, sys, pathlib +src = pathlib.Path(sys.argv[1]).read_text() +PAIRS = re.findall(r'\("([a-z-]+)", "([a-z-]+)"\)', src) +PROTECT = ["lightgrey", "`cancelled`"] +pat = re.compile("|".join(re.escape(g) for g, _ in PAIRS), re.IGNORECASE) +prot = re.compile("|".join(re.escape(p) for p in PROTECT)) +print(f"[{len(PAIRS)} pairs loaded]") +for name in sys.argv[2:]: + p = pathlib.Path(name) + if p.suffix not in (".rs", ".c", ".h", ".py", ".sh", ".toml", ".yml", ".yaml", ".s"): + continue + try: + lines = p.read_text(encoding="utf-8").splitlines() + except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError): + continue + for i, line in enumerate(lines, 1): + if not pat.search(line) or prot.search(line): + continue + s = line.strip() + if s.startswith(("//", "///", "//!", "#", "*", "/*")): + continue + print(f"{name}:{i}: {s[:120]}") diff --git a/salvaged/scripts/enus-applied.py b/salvaged/scripts/enus-applied.py new file mode 100644 index 00000000..cdf53b2a --- /dev/null +++ b/salvaged/scripts/enus-applied.py @@ -0,0 +1,84 @@ +import re, sys, pathlib + +# Stem-level pairs. Stems (not whole words) so every inflection is covered by one +# entry: "colour" also fixes colours/coloured/colourful. Every stem below was +# verified to occur in the tree; none is a substring of a word that is correct in +# en-US (checked explicitly for realis/normalis/serialis/materialis, and for the +# pairs where en-GB and en-US share a form: analysis, synthesis, peripheral, +# hypothesis, exercise, precise, premise, otherwise, likewise, bitwise). +PAIRS = [ + ("colour", "color"), ("behaviour", "behavior"), ("centre", "center"), + ("modelled", "modeled"), ("modelling", "modeling"), + ("licence", "license"), ("neighbour", "neighbor"), + ("rasteris", "rasteriz"), + ("signalling", "signaling"), ("signalled", "signaled"), + ("labelled", "labeled"), ("labelling", "labeling"), + ("travelled", "traveled"), + ("honour", "honor"), ("favour", "favor"), + ("catalogue", "catalog"), ("artefact", "artifact"), + ("analogue", "analog"), ("analyser", "analyzer"), + ("judgement", "judgment"), ("acknowledgement", "acknowledgment"), + ("grey", "gray"), + # -ise / -isation families + ("initialis", "initializ"), ("normalis", "normaliz"), ("serialis", "serializ"), + ("canonicalis", "canonicaliz"), ("capitalis", "capitaliz"), + ("characteris", "characteriz"), ("containeris", "containeriz"), + ("synchronis", "synchroniz"), ("finalis", "finaliz"), + ("generalis", "generaliz"), ("localis", "localiz"), + ("materialis", "materializ"), ("neutralis", "neutraliz"), + ("optimis", "optimiz"), ("organis", "organiz"), + ("parallelis", "paralleliz"), ("parameteris", "parameteriz"), + ("prioritis", "prioritiz"), ("privatis", "privatiz"), + ("quantis", "quantiz"), ("randomis", "randomiz"), + ("realis", "realiz"), ("recognis", "recogniz"), + ("specialis", "specializ"), ("stabilis", "stabiliz"), + ("summaris", "summariz"), ("theoris", "theoriz"), + ("synthesise", "synthesize"), # NOT "synthesis" -- correct in both + ("mis-analyses", "mis-analyzes"), # the verb; bare "analyses" can be a noun +] + +# Literals that must survive the sweep verbatim, with the reason. +PROTECT = [ + "lightgrey", # a shields.io badge COLOR PARAMETER in a URL, not prose + "`cancelled`", # GitHub Actions' own literal run status, quoted as a value +] + +def case_like(src: str, dst: str) -> str: + if src.isupper(): + return dst.upper() + if src[0].isupper(): + return dst[0].upper() + dst[1:] + return dst + +def convert(text: str): + for i, lit in enumerate(PROTECT): + text = text.replace(lit, f"\x00P{i}\x00") + n = 0 + for gb, us in PAIRS: + pat = re.compile(re.escape(gb), re.IGNORECASE) + def rep(m, us=us): + nonlocal n + n += 1 + return case_like(m.group(0), us) + text = pat.sub(rep, text) + for i, lit in enumerate(PROTECT): + text = text.replace(f"\x00P{i}\x00", lit) + return text, n + +apply = "--apply" in sys.argv +paths = [p for p in sys.argv[1:] if not p.startswith("--")] +total, touched = 0, 0 +for name in paths: + p = pathlib.Path(name) + try: + orig = p.read_text(encoding="utf-8") + except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError): + continue + new, n = convert(orig) + if new != orig: + touched += 1 + total += n + print(f"{n:5d} {name}") + if apply: + p.write_text(new, encoding="utf-8") +print(f"--- {total} replacements across {touched} files ({'APPLIED' if apply else 'dry run'})") diff --git a/salvaged/scripts/enus.py b/salvaged/scripts/enus.py new file mode 100644 index 00000000..243cd270 --- /dev/null +++ b/salvaged/scripts/enus.py @@ -0,0 +1,84 @@ +import re, sys, pathlib + +# Stem-level pairs. Stems (not whole words) so every inflection is covered by one +# entry: "colour" also fixes colours/coloured/colourful. Every stem below was +# verified to occur in the tree; none is a substring of a word that is correct in +# en-US (checked explicitly for realis/normalis/serialis/materialis, and for the +# pairs where en-GB and en-US share a form: analysis, synthesis, peripheral, +# hypothesis, exercise, precise, premise, otherwise, likewise, bitwise). +PAIRS = [ + ("colour", "color"), ("behaviour", "behavior"), ("centre", "center"), + ("modelled", "modeled"), ("modelling", "modeling"), + ("licence", "license"), ("neighbour", "neighbor"), + ("rasteris", "rasteriz"), + ("signalling", "signaling"), ("signalled", "signaled"), + ("labelled", "labeled"), ("labelling", "labeling"), + ("travelled", "traveled"), + ("honour", "honor"), ("favour", "favor"), + ("catalogue", "catalog"), ("artefact", "artifact"), + ("analogue", "analog"), ("analyser", "analyzer"), + ("judgement", "judgment"), ("acknowledgement", "acknowledgment"), + ("grey", "gray"), + # -ise / -isation families + ("initialis", "initializ"), ("normalis", "normaliz"), ("serialis", "serializ"), + ("canonicalis", "canonicaliz"), ("capitalis", "capitaliz"), + ("characteris", "characteriz"), ("containeris", "containeriz"), + ("synchronis", "synchroniz"), ("finalis", "finaliz"), + ("generalis", "generaliz"), ("localis", "localiz"), + ("materialis", "materializ"), ("neutralis", "neutraliz"), + ("optimis", "optimiz"), ("organis", "organiz"), + ("parallelis", "paralleliz"), ("parameteris", "parameteriz"), + ("prioritis", "prioritiz"), ("privatis", "privatiz"), + ("quantis", "quantiz"), ("randomis", "randomiz"), + ("realis", "realiz"), ("recognis", "recogniz"), + ("specialis", "specializ"), ("stabilis", "stabiliz"), + ("summaris", "summariz"), ("theoris", "theoriz"), + ("synthesise", "synthesize"), # NOT "synthesis" -- correct in both spell-exempt + ("mis-analyses", "mis-analyzes"), # the verb; bare "analyses" can be a noun +] + +# Literals that must survive the sweep verbatim, with the reason. +PROTECT = [ + "lightgrey", # a shields.io badge COLOR PARAMETER in a URL, not prose + "`cancelled`", # GitHub Actions' own literal run status, quoted as a value +] + +def case_like(src: str, dst: str) -> str: + if src.isupper(): + return dst.upper() + if src[0].isupper(): + return dst[0].upper() + dst[1:] + return dst + +def convert(text: str): + for i, lit in enumerate(PROTECT): + text = text.replace(lit, f"\x00P{i}\x00") + n = 0 + for gb, us in PAIRS: + pat = re.compile(re.escape(gb), re.IGNORECASE) + def rep(m, us=us): + nonlocal n + n += 1 + return case_like(m.group(0), us) + text = pat.sub(rep, text) + for i, lit in enumerate(PROTECT): + text = text.replace(f"\x00P{i}\x00", lit) + return text, n + +apply = "--apply" in sys.argv +paths = [p for p in sys.argv[1:] if not p.startswith("--")] +total, touched = 0, 0 +for name in paths: + p = pathlib.Path(name) + try: + orig = p.read_text(encoding="utf-8") + except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError): + continue + new, n = convert(orig) + if new != orig: + touched += 1 + total += n + print(f"{n:5d} {name}") + if apply: + p.write_text(new, encoding="utf-8") +print(f"--- {total} replacements across {touched} files ({'APPLIED' if apply else 'dry run'})") diff --git a/salvaged/scripts/wordaudit.py b/salvaged/scripts/wordaudit.py new file mode 100644 index 00000000..294a0a9a --- /dev/null +++ b/salvaged/scripts/wordaudit.py @@ -0,0 +1,34 @@ +"""Every distinct word the sweep produced, so a malformed output cannot hide. + +Reconstructs before/after per changed line and reports the set of word pairs +that actually differ. A stem substitution can produce a NON-word (centred -> spell-exempt +centerd) which no en-GB gate can catch, because the output is not en-GB either. spell-exempt +""" +import re, subprocess, collections + +out = subprocess.run(["git", "diff", "HEAD~1", "--unified=0", "-U0"], + capture_output=True, text=True).stdout.splitlines() +minus, plus = [], [] +pairs = collections.Counter() + +def flush(): + for a, b in zip(minus, plus): + wa = re.findall(r"[A-Za-z][A-Za-z'-]*", a) + wb = re.findall(r"[A-Za-z][A-Za-z'-]*", b) + if len(wa) != len(wb): + continue + for x, y in zip(wa, wb): + if x != y: + pairs[(x, y)] += 1 + minus.clear(); plus.clear() + +for line in out: + if line.startswith("@@") or line.startswith("+++") or line.startswith("---"): + flush(); continue + if line.startswith("-"): minus.append(line[1:]) + elif line.startswith("+"): plus.append(line[1:]) +flush() + +print(f"{len(pairs)} distinct word substitutions:\n") +for (a, b), n in sorted(pairs.items(), key=lambda kv: (-kv[1], kv[0])): + print(f" {n:5d} {a:28s} -> {b}") diff --git a/salvaged/scripts/zipcheck.sh b/salvaged/scripts/zipcheck.sh new file mode 100644 index 00000000..2bff21db --- /dev/null +++ b/salvaged/scripts/zipcheck.sh @@ -0,0 +1,9 @@ +set -e +for pair in "Super Mario 64 (USA).zip:eeprom-4k/Super Mario 64.z64" \ + "Star Wars - Rogue Squadron (USA).zip:eeprom-4k/Star Wars - Rogue Squadron.z64" \ + "Perfect Dark (USA).zip:eeprom-16k/Perfect Dark.z64"; do + zipf="${pair%%:*}"; z64="${pair##*:}" + a=$(unzip -p "$HOME/Emulation/roms/n64/$zipf" | sha256sum | cut -d' ' -f1) + b=$(sha256sum "tests/roms/external/commercial/$z64" | cut -d' ' -f1) + if [ "$a" = "$b" ]; then echo "MATCH $zipf"; else echo "DIFFER $zipf ($a vs $b)"; fi +done diff --git a/scripts/check_en_us.sh b/scripts/check_en_us.sh index be157e8a..3d33b2ff 100755 --- a/scripts/check_en_us.sh +++ b/scripts/check_en_us.sh @@ -15,6 +15,14 @@ # the point, and it is gitignored anyway # ref-proj/ study clones of other emulators (gitignored) # third_party/ vendored upstream source (libdragon); not our prose to edit +# salvaged/ development artifacts rescued verbatim from a volatile /tmp. +# Same reason as the trees above: they are a RECORD, not prose +# this project edits. Two of them are en-US conversion tools +# whose substitution TABLES are en-GB words as data (26 lines +# in one file), and one is a diff that must stay byte-exact to +# be worth keeping at all. Per-line markers would litter a data +# table and corrupt a patch. Nothing here is built, run in CI, +# or read as documentation. # # PER-LINE OPT-OUT: append the marker `spell-exempt` to a line that must keep an # en-GB form — a quoted external value, or prose that names the spelling itself. @@ -119,7 +127,7 @@ git ls-files -z --others --exclude-standard >>"$raw" # `grep -v` exits 1 when it selects nothing, which is not an error here, so the # filters are deliberately status-tolerant -- the emptiness check below is what # catches a broken listing. -grep -zvE '^(ref-docs|n64brew_wiki|ref-proj|third_party)/' <"$raw" | +grep -zvE '^(ref-docs|n64brew_wiki|ref-proj|third_party|salvaged)/' <"$raw" | grep -zvE '^scripts/check_en_us\.sh$' >"$list" || true mapfile -d '' -t files <"$list"