From f0a8a1b0de2192a13363af1eca548ce61211ff09 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 25 Jul 2026 02:28:58 -0400 Subject: [PATCH 1/2] feat(harness): author I-cache-fill hardware timing ROM Companion to the D-cache M(RDRAM) timing ROM (tools/mrdram-timing-rom/): a bare-metal N64 ROM that measures the VR4300 I-cache line-fill cost on real hardware. It runs a straight-line block of N=8192 addiu instructions (32 KiB, larger than the 16 KiB I-cache) so every 32-byte fetch line misses, times it with COP0 Count, and reports fill = (delta*2 - N)/(N/8) after subtracting the verified 1-PClock-per-instruction base. Results go to fixed RDRAM words (phys 0x10000, past the code block) and the ISViewer text channel for a flashcart to read -- the same output path as the D-cache ROM. This makes the eventual hardware measurement of M_ICACHE_FILL one console-run away, replacing the value currently FITTED from ares/cen64 (ledger C-1). - tools/mrdram-timing-rom/icache_timing.asm + .z64 (bass, ARM9 fork; blank IPL3, no Nintendo code; MIT OR Apache-2.0). - build.sh now assembles both ROMs; README documents the I-cache variant. - crates/rustyn64-test-harness/tests/icache_timing_rom.rs boots the ROM through load_direct and asserts it reads back the charged M_ICACHE_FILL (measures 46.09 in-emulator, the charged 46) -- proof the measurement path is correct end-to-end and a guard tying the ROM to the constant. - .gitignore re-includes icache_timing.z64 by exact filename; ledger C-1 and CHANGELOG note both authored hardware ROMs. Gates: cargo fmt --check, clippy -D warnings (test-harness), both ROM runners green, markdownlint on the touched docs, check_no_roms clean. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 +- CHANGELOG.md | 11 +- .../tests/icache_timing_rom.rs | 66 ++++++++ docs/accuracy-ledger.md | 11 +- tools/mrdram-timing-rom/README.md | 59 +++++-- tools/mrdram-timing-rom/build.sh | 5 +- tools/mrdram-timing-rom/icache_timing.asm | 154 ++++++++++++++++++ tools/mrdram-timing-rom/icache_timing.z64 | Bin 0 -> 98304 bytes 8 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 crates/rustyn64-test-harness/tests/icache_timing_rom.rs create mode 100644 tools/mrdram-timing-rom/icache_timing.asm create mode 100644 tools/mrdram-timing-rom/icache_timing.z64 diff --git a/.gitignore b/.gitignore index 633d36c3..aaa63677 100644 --- a/.gitignore +++ b/.gitignore @@ -107,9 +107,10 @@ __pycache__/ !/tests/roms/peterlemon-timing/CPUTIMINGNTSC.z64 !/tests/roms/peterlemon-timing/CP1TIMINGNTSC.z64 -# First-party homebrew timing ROM (authored here, MIT OR Apache-2.0; 32 KiB, no -# Nintendo code). Re-included by exact filename. See tools/mrdram-timing-rom/. +# First-party homebrew timing ROMs (authored here, MIT OR Apache-2.0; no Nintendo +# code). Re-included by exact filename. See tools/mrdram-timing-rom/. !/tools/mrdram-timing-rom/mrdram_timing.z64 +!/tools/mrdram-timing-rom/icache_timing.z64 # ...then hard-exclude the external tier again, so no negation above can ever # accidentally re-include a commercial dump. Order matters: this comes last. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3644a932..dfac0938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,14 @@ Work toward `v0.8.0 "Breadth"` — the accuracy battery (Phase 7). The I-cache stall is charged behind a `#[cfg(not(test))]` seam — active in real execution and integration tests, skipped in the CPU crate's own pipeline units (an every-fetch stall would confound their fixed-cycle interlock assertions). -- **A hardware timing ROM** (`tools/mrdram-timing-rom/`) that measures the real - D-cache fill cost on a console via a COP0-`Count` differential, so the fitted - values can be replaced with a measurement when hardware is available. +- **Two hardware timing ROMs** (`tools/mrdram-timing-rom/`) that measure the real + cache fill costs on a console, so the fitted values can be replaced with a + measurement when hardware is available: `mrdram_timing.z64` (D-cache fill, a + COP0-`Count` differential of cached loads that miss vs. hit) and + `icache_timing.z64` (I-cache fill, a straight-line instruction block larger than + the 16 KiB I-cache, base-subtracted). Both emit their result over ISViewer for a + flashcart to read, and each has an emulator runner asserting it reads back the + charged constant (`icache_timing_rom.rs` measures 46.09, the charged 46). - `M(RDRAM)` as a true measurement and the RDRAM bank-state model (C-4) remain open. ### Added — the first CPU-timing differential measurement (gap-analysis Stage D) diff --git a/crates/rustyn64-test-harness/tests/icache_timing_rom.rs b/crates/rustyn64-test-harness/tests/icache_timing_rom.rs new file mode 100644 index 00000000..f7c2b284 --- /dev/null +++ b/crates/rustyn64-test-harness/tests/icache_timing_rom.rs @@ -0,0 +1,66 @@ +//! Runner for the authored **I-cache-fill timing ROM** +//! (`tools/mrdram-timing-rom/icache_timing.asm`). +//! +//! The companion to `mrdram_timing_rom.rs` (which measures the D-cache fill). +//! That ROM measures the VR4300 **I-cache** line-fill cost on real hardware: it +//! runs a straight-line block of N one-PClock instructions LARGER than the +//! 16 KiB instruction cache, so every 32-byte fetch line misses, and times it +//! with COP0 `Count`. Run on an N64, the number it emits is the *real* fill cost +//! — the measurement that would replace the value currently FITTED from +//! ares/cen64 (accuracy ledger C-1). +//! +//! In the emulator it necessarily reads back **our** charged I-cache fill +//! (`M_ICACHE_FILL` = 46), so this test doubles as (a) proof the ROM's +//! measurement logic is correct end-to-end through a real machine boot, and +//! (b) a regression guard tying the ROM to the charged constant. On hardware the +//! same ROM yields the true number. + +use rustyn64_core::System; +use rustyn64_test_harness::rom; + +const ROM: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tools/mrdram-timing-rom/icache_timing.z64" +); + +/// Result words the ROM writes to uncached RDRAM (phys `0x10000`). +fn word(sys: &System, phys: usize) -> u32 { + let r = &sys.bus.rdram; + u32::from_be_bytes([r[phys], r[phys + 1], r[phys + 2], r[phys + 3]]) +} + +#[test] +fn the_timing_rom_measures_the_charged_icache_fill() { + let image = std::fs::read(ROM).expect("assembled timing ROM (see tools/mrdram-timing-rom)"); + let entry = rom::entry_point(&image).expect("ROM header entry point"); + let mut sys = System::new(0); + rom::load_direct(&mut sys, &image, entry).expect("load the ROM"); + + // Run until the ROM writes its sentinel (the N word at phys 0x10004): the + // straight-line block runs, then it stores delta + N and spins. The cap is a + // generous backstop against a ROM that never writes. + let mut steps = 0u64; + while word(&sys, 0x10004) == 0 && steps < 20_000_000 { + sys.step_to_next_edge(); + steps += 1; + } + + let delta = word(&sys, 0x10000); + let n = word(&sys, 0x10004); + assert_eq!(n, 8192, "the ROM ran its block and wrote its sentinel N"); + + // The block is N one-PClock `addiu`s; every 8-instruction (32 B) fetch line + // misses. Subtract the 1-PClock-per-instruction base, divide by the number + // of line fills (N/8). COP0 `Count` ticks once per 2 PClocks, so *2 gives + // PClocks: fill = (delta * 2 - N) / (N / 8). + let fill = (f64::from(delta) * 2.0 - f64::from(n)) / (f64::from(n) / 8.0); + println!( + "timing ROM: delta={delta} N={n} \ + -> I-cache fill = {fill:.2} PClocks (our charged M_ICACHE_FILL)" + ); + assert!( + (fill - 46.0).abs() < 1.0, + "the ROM measured {fill:.2} PClocks; in-emulator it must read the charged \ + I-cache fill (46). If M_ICACHE_FILL changed, update this and ledger C-1." + ); +} diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index faf05968..626195c6 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -210,7 +210,16 @@ block twice the 16 KiB I-cache (every line misses) and, subtracting the verified measures **46.05 PClocks/fill**; the systemtest still completes (Phase-1 `Failed: 0`, 90 suite-wide, `xioctl(EXIT)`, ~33 s vs ~31 s) and golden-log 0-diff / residue / determinism hold. The D-cache fill, by contrast, fires only on a rare cached load and is charged unconditionally (two units -absorbed it). **`M(RDRAM)` as a true measurement, and the RDRAM bank-state model (C-4), remain +absorbed it). To make the eventual hardware measurement one console-run away, two bare-metal +timing ROMs are authored in `tools/mrdram-timing-rom/` (MIT OR Apache-2.0, blank IPL3): +`mrdram_timing.z64` measures the D-cache fill via a COP0-`Count` miss-vs-hit differential, and +`icache_timing.z64` measures the I-cache fill by timing a straight-line block larger than the +16 KiB I-cache and subtracting the verified 1-PClock base. Both emit their raw numbers over +ISViewer for a flashcart to read, and each has an emulator runner +(`mrdram_timing_rom.rs`, `icache_timing_rom.rs`) that boots the ROM through `load_direct` and +asserts it reads back the charged constant (the I-cache ROM measures 46.09 in-emulator, the +charged 46) — proof the measurement path is correct end-to-end, and a guard tying each ROM to +its constant. **`M(RDRAM)` as a true measurement, and the RDRAM bank-state model (C-4), remain open.** No regression from the D-cache charge: golden-log 0-diff (it keys on retired instructions, not stalls), the residue invariant, determinism, and the 950-test functional suite (Phase-1 `Failed: 0`, still 90 suite-wide, `Random` timing tests pass, runs to `xioctl(EXIT)`) are all diff --git a/tools/mrdram-timing-rom/README.md b/tools/mrdram-timing-rom/README.md index 010dcb0e..e16d04b9 100644 --- a/tools/mrdram-timing-rom/README.md +++ b/tools/mrdram-timing-rom/README.md @@ -1,12 +1,20 @@ -# M(RDRAM) cached-load timing ROM +# VR4300 cache-fill timing ROMs -A bare-metal N64 ROM that **measures the VR4300 D-cache line-fill cost on real -hardware** — the memory latency `M(RDRAM)` that accuracy-ledger **C-1** currently -carries as a value *fitted* from ares/cen64 (no hardware oracle exists in the -emulation community's test corpus). Run this on a console and it yields the real -number. +Two bare-metal N64 ROMs that **measure the VR4300 cache line-fill costs on real +hardware** — the memory latencies that accuracy-ledger **C-1** currently carries +as values *fitted* from ares/cen64 (no hardware oracle exists in the emulation +community's test corpus). Run them on a console and they yield the real numbers. -## What it measures, and how +- **`mrdram_timing.z64`** — the **D-cache** line-fill cost (a differential of + cached loads that miss vs. hit). +- **`icache_timing.z64`** — the **I-cache** line-fill cost (a straight-line + instruction block larger than the 16 KiB I-cache, so every fetch line misses). + +The D-cache ROM is described in full below; the I-cache ROM is its companion and +shares the build, header convention, and ISViewer output — see +[I-cache variant](#i-cache-variant) at the end. + +## What the D-cache ROM measures, and how The D-cache miss cost is `8..=9 + M(RDRAM)` PClocks (VR4300 User's Manual Table 11-1). This ROM isolates it with a **differential**, timed by the COP0 @@ -36,10 +44,10 @@ either way. `fill_cost = word[2] / word[3] × 2` PClocks. architecture-table placement. Then: ```sh -BASS=/path/to/bass sh build.sh # -> mrdram_timing.z64 (32 KiB) +BASS=/path/to/bass sh build.sh # -> mrdram_timing.z64 (32 KiB) + icache_timing.z64 (96 KiB) ``` -The assembled `mrdram_timing.z64` is committed for convenience. +Both assembled ROMs are committed for convenience. ## Verify in the emulator @@ -68,8 +76,37 @@ cargo test -p rustyn64-test-harness --release --test mrdram_timing_rom -- --noca `0x2000`), compute `word[2] / word[3] × 2`, and that is the real `M(RDRAM)`-inclusive D-cache fill in PClocks. Drop it into ledger C-1 and the emulator's `Pipeline::M_DCACHE_FILL`, and the fitted value becomes a measured - one. (An I-cache variant — a straight-line block larger than the 16 KiB - I-cache — is the obvious follow-up for `M_ICACHE_FILL`.) + one. + +## I-cache variant + +`icache_timing.asm` → `icache_timing.z64` measures the **I-cache** line-fill cost +(`M_ICACHE_FILL`, ledger C-1, currently fitted at 46 PClocks). Rather than a +load differential it runs a **straight-line block of `N = 8192` `addiu` +instructions** (32 KiB, larger than the 16 KiB I-cache), so every 32-byte fetch +line (8 instructions) misses. Each `addiu` has no interlock, so its execute cost +is exactly one PClock — the base that is subtracted: + +```text +fill_PClocks = (delta * 2 - N) / (N / 8) +``` + +(`delta` is the COP0-`Count` span of the block; `Count` ticks once per 2 +PClocks; there are `N/8` line fills.) It writes `delta` and `N` to uncached +RDRAM at phys `0x10000` / `0x10004` (past the 32 KiB code block) and prints both +via ISViewer, exactly like the D-cache ROM. Header convention, blank IPL3, and +hardware-run steps are identical — just read `delta` and `N` and apply the +formula. Its emulator runner is +`crates/rustyn64-test-harness/tests/icache_timing_rom.rs`: + +```sh +cargo test -p rustyn64-test-harness --release --test icache_timing_rom -- --nocapture +# -> I-cache fill = 46.09 PClocks (our charged M_ICACHE_FILL) +``` + +The residual 0.09 over the charged 46 is fixed jal/jr/pipeline-fill overhead not +captured by the `N × 1` base, diluted across 1024 fills; on hardware the block +dominates identically, so the measured number is the real fill cost. ## Licence diff --git a/tools/mrdram-timing-rom/build.sh b/tools/mrdram-timing-rom/build.sh index e3817bc2..45d3662b 100755 --- a/tools/mrdram-timing-rom/build.sh +++ b/tools/mrdram-timing-rom/build.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Assemble the M(RDRAM) cached-load timing ROM with bass (ARM9 fork). +# Assemble both timing ROMs with bass (ARM9 fork): the M(RDRAM) cached-load ROM +# (D-cache fill) and the I-cache-fill ROM. # # bass is not vendored. Fetch + build it once (it needs one modern-g++ fix), then # point BASS at the binary. The n64 architecture tables must sit next to the @@ -20,4 +21,6 @@ cd "$(dirname "$0")" # run from this directory regardless of caller : "${BASS:=bass}" "$BASS" mrdram_timing.asm echo "built mrdram_timing.z64 ($(wc -c < mrdram_timing.z64) bytes)" +"$BASS" icache_timing.asm +echo "built icache_timing.z64 ($(wc -c < icache_timing.z64) bytes)" echo "For hardware: fix the header CRC (e.g. chksum64) and load via your flashcart." diff --git a/tools/mrdram-timing-rom/icache_timing.asm b/tools/mrdram-timing-rom/icache_timing.asm new file mode 100644 index 00000000..97f47ebd --- /dev/null +++ b/tools/mrdram-timing-rom/icache_timing.asm @@ -0,0 +1,154 @@ +// RustyN64 -- I-cache-fill timing ROM (bass, ARM9 fork syntax). +// +// The companion to mrdram_timing.asm (which measures the D-cache fill). This one +// measures the VR4300 **I-cache** line-fill cost on real hardware: it executes a +// straight-line block of N one-PClock instructions that is LARGER than the 16 KiB +// instruction cache, so every 32-byte fetch line (8 instructions) misses. Timed +// with COP0 Count: +// fill_PClocks = (delta * 2 - N) / (N / 8) +// -- subtract the verified 1-PClock-per-instruction base, divide by the number of +// line fills. (Count ticks once per 2 PClocks; a 32-byte I-cache line is 8 * 4 B.) +// +// Results go to fixed RDRAM words (phys 0x10000, past the code) and the ISViewer +// text channel, as in the D-cache ROM. See README.md; build with build.sh. + +arch n64.cpu +endian msb +output "icache_timing.z64", create +fill 0x18000 // 96 KiB: header + blank IPL3 + setup + the 32 KiB block + +// MIPS register aliases. +constant r0 = 0 +constant at = 1 +constant t0 = 8 +constant t1 = 9 +constant t2 = 10 +constant t3 = 11 +constant t7 = 15 +constant t8 = 24 +constant t9 = 25 +constant sp = 29 +constant a0 = 4 +constant a1 = 5 +constant a2 = 6 +constant a3 = 7 +constant ra = 31 + +constant COUNT = 9 +constant N = 8192 // instructions in the straight block (32 KiB > 16 KiB I-cache) +// Result/scratch addresses must sit PAST the loaded code: the 32 KiB block runs +// from RDRAM 0x1000 to ~0x9100, so use phys 0x10000 (64 KiB) and up. +constant RESULTS = 0xA0010000 // uncached result words (phys 0x10000) +constant ISVLEN = 0xA0010100 // our running text length (uncached) +constant ISVIEWER_WLEN = 0xB3FF0014 +constant ISVIEWER_BUF = 0xB3FF0020 + +// ---- ROM header ---- +origin 0x00000000 +base 0x80000000 + dw 0x80371240 + dw 0x0000000F + dw 0x80001000 // entry + dw 0x00001444 + dw 0x00000000 // CRC1 (fix with chksum64 for hardware) + dw 0x00000000 + dw 0x00000000 + dw 0x00000000 + db "RUSTYN64 ICACHE " + db "TIME" + dw 0x00000000 + dw 0x0000004E + +origin 0x00000040 + fill 0xFC0, 0x00 + +// ---- Code ---- +origin 0x00001000 +base 0x80001000 +Start: + lui sp, 0x8020 + + // zero the ISViewer length (RDRAM is not pre-zeroed on hardware) + lui t8, ISVLEN >> 16 + ori t8, t8, ISVLEN & 0xFFFF + sw r0, 0(t8) + + ori t0, r0, 0 // $t0 accumulates (the block increments it) + mtc0 r0, COUNT // Count = 0 + nop + jal StraightBlock // run the cold straight-line block + nop + mfc0 t3, COUNT // t3 = delta (Count units) + nop + + // results: delta and N (the runner / reader computes the fill) + lui t1, RESULTS >> 16 + ori t1, t1, RESULTS & 0xFFFF + sw t3, 0(t1) // [0] = delta + ori t2, r0, N + sw t2, 4(t1) // [4] = N + + ori a0, t3, 0 + jal PrintHex + nop + ori a0, t2, 0 + jal PrintHex + nop + jal IsvFlush + nop +Spin: + j Spin + nop + +// ---- the straight-line block: N one-PClock instructions, all fetch-miss ---- +// Emitted at assemble time. `addiu t0,t0,1` has no interlock (its result is not +// read by the next), so each is exactly 1 PClock -- the base the runner subtracts. +StraightBlock: +variable ii = 0 +while ii < N { + addiu t0, t0, 1 + ii = ii + 1 +} + jr ra + nop + +// ---- ISViewer helpers (same as mrdram_timing.asm) ---- +PrintHex: + lui t7, ISVIEWER_BUF >> 16 + ori t7, t7, ISVIEWER_BUF & 0xFFFF + lui t8, ISVLEN >> 16 + ori t8, t8, ISVLEN & 0xFFFF + lw t9, 0(t8) + ori a1, r0, 8 +PhLoop: + srl a2, a0, 28 + andi a2, a2, 0xF + sltiu a3, a2, 10 + bne a3, r0, PhDigit + addiu a2, a2, 0x30 + addiu a2, a2, 7 +PhDigit: + addu at, t7, t9 + sb a2, 0(at) + addiu t9, t9, 1 + sll a0, a0, 4 + addiu a1, a1, -1 + bne a1, r0, PhLoop + nop + addu at, t7, t9 + ori a2, r0, 0x20 + sb a2, 0(at) + addiu t9, t9, 1 + sw t9, 0(t8) + jr ra + nop + +IsvFlush: + lui t8, ISVLEN >> 16 + ori t8, t8, ISVLEN & 0xFFFF + lw t9, 0(t8) + lui t7, ISVIEWER_WLEN >> 16 + ori t7, t7, ISVIEWER_WLEN & 0xFFFF + sw t9, 0(t7) + jr ra + nop diff --git a/tools/mrdram-timing-rom/icache_timing.z64 b/tools/mrdram-timing-rom/icache_timing.z64 new file mode 100644 index 0000000000000000000000000000000000000000..1492c019279f9ddc14073c2acd01e91e718cb0b6 GIT binary patch literal 98304 zcmeI$y-wRu7=Yo=)CKb+1)&V4Dw2^>s!|4qgb2w)DvAU{844Y`_d;?9y#+5FBL?mP zZi72uVPOi#{1rCFN_{Lj$Idx9wjRAx#>16SM0CcnoQCe#^zq{O{^R)M=h05TI^6rR zcd%cb9Ddt>Sa9@Wp&J4O2oNAZfB*pk1PBlyK;WMhsMp3-T{hXU%;Ge?Wxt3RjSueL zcPa9B?Zs&E{=S}Xvcac_$>+3gp^C|7LK-=?{IIJ12oovn*l z=w8N5?>x5CIhWlO>&-^Q$KA*xUyt6O>9pHSuiL%ax&CZakMpN#uczlc+yCL2-O0`0 z`S$k?0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U QAV7cs0RjXF5O}@7Eqeg*{Qv*} literal 0 HcmV?d00001 From 86e5236711c47927512ff370d59d7c974f56f8a2 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 25 Jul 2026 02:33:12 -0400 Subject: [PATCH 2/2] test(harness): assert the I-cache timing ROM did not hit the step cap Adopt Antigravity review suggestion #2: on a hung ROM or wrong entry point the sentinel-poll loop exits on the step cap, after which assert_eq!(n, 8192) fails with an opaque mismatch. An explicit non-timeout assert makes that failure mode self-explanatory. Co-Authored-By: Claude Opus 4.8 --- crates/rustyn64-test-harness/tests/icache_timing_rom.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/rustyn64-test-harness/tests/icache_timing_rom.rs b/crates/rustyn64-test-harness/tests/icache_timing_rom.rs index f7c2b284..1d6a380d 100644 --- a/crates/rustyn64-test-harness/tests/icache_timing_rom.rs +++ b/crates/rustyn64-test-harness/tests/icache_timing_rom.rs @@ -44,6 +44,11 @@ fn the_timing_rom_measures_the_charged_icache_fill() { sys.step_to_next_edge(); steps += 1; } + assert!( + steps < 20_000_000, + "ROM never wrote its sentinel within the step cap — it hung or the entry \ + point is wrong; results below would be garbage" + ); let delta = word(&sys, 0x10000); let n = word(&sys, 0x10004);