From 24a7d9707870fb0f42b194504b8ad20cc0ad5f72 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 29 Jul 2026 00:18:39 -0400 Subject: [PATCH 1/2] fix(core): seed the stack pointer IPL3 inherits, and decode the RI block (R-18) Retail games never booted. hle_boot skips IPL1/IPL2 and seeds the state IPL3 expects, but it never seeded sp. IPL1 sets it before handing off - N64brew IPL2 section, IPL1 listing at 0xBFC000D0: `ORI sp, sp, 0x1FF0 # sp = 0xA4001FF0 (this prepares sp for use in IPL2)` - and IPL2 leaves it alone, so IPL3 runs on it, using the top of RSP IMEM as its stack while executing from DMEM. With sp = 0, IPL3's opening prologue - ADDIU sp, sp, -24 then SW s3, 0(sp) - stored to 0xFFFF_FFE8. That is KSEG3: TLB-mapped, and no entries exist, so the store raised a TLB-refill exception, vectored to 0x8000_0000 in empty RDRAM, and executed a NOP sled to the end of memory. Every retail title did this. It hid behind the capstone's own metric. A machine sledding through zeroed RDRAM still retires ~180 million instructions, so "boots and executes real code" passed. The tell was that retired was IDENTICAL across four different games - the same-value-whatever-the-input signature - and it was found by tracing the instruction stream rather than inspecting state, which is what the engineering lessons prescribe for exactly this. Also decode the RI register block (0x0470_0000..0x0470_0020) as storage. IPL3's very first act is to read RI_SELECT and branch on whether RDRAM is already up; an undecoded block returned 0 unconditionally. Storage is the honest model here: n64-systemtest has no RI group, and the wiki marks several read behaviours TOVERIFY, so inventing them would be fabrication. Ledgered as R-22. Results, measured over 120 frames each: Super Mario 64 reaches pc=0x80246ddc with 928 KiB of RDRAM populated; Star Fox 64 submits 122 RDP commands; World Driver Championship 45; Star Wars Rogue Squadron scans out 68,527 lit pixels. Every title uploads its graphics microcode into IMEM. n64-systemtest is unchanged at 90 suite-wide with Phase 1 categories still Failed: 0 - it loads through the ELF path, not IPL3. The local capstone now asserts what was silently false: RDRAM is populated, and the PC is inside cached RDRAM rather than sledding past it. Retired count alone could never have caught this. Two things stay open and are ledgered rather than papered over. R-18's remaining half: no title starts the RSP, so those RDP commands come from the CPU driving the DPC directly, not from microcode - which is also what still blocks T-71-003. And R-23: CIC-6105 titles need real_pif_boot, because their IPL3 self-descrambles with an XOR loop over t1/t3 that only the real IPL2 leaves set. The capstone detects 6105 from the cartridge header and reports it by name instead of passing quietly; the real-PIF capstone boots those titles and asserts on them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 29 ++++++ crates/rustyn64-core/src/boot.rs | 44 ++++++++++ crates/rustyn64-core/src/bus.rs | 82 +++++++++++++++-- .../tests/commercial_boot.rs | 88 +++++++++++++++++-- docs/accuracy-ledger.md | 4 +- 5 files changed, 232 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153893d4..2839df72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ All notable changes to RustyN64 are documented here. The format is based on Work toward `v0.8.0 "Breadth"` — the accuracy battery (Phase 7). +### Fixed — retail games now boot into their own code (ledger R-18) + +- **`hle_boot` never seeded the stack pointer.** IPL1 sets `sp = 0xA4001FF0` + before handing off (N64brew *IPL2* §IPL1, `0xBFC000D0`), and `hle_boot` skips + IPL1/IPL2 without standing in for it. With `sp = 0`, IPL3's opening + `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue stored to `0xFFFF_FFE8` — KSEG3, + TLB-mapped with no entries — took a TLB-refill exception to `0x8000_0000` in + empty RDRAM, and executed a **NOP sled to the end of memory**. Every retail + title did this. +- It hid behind the capstone's own metric: a sledding machine still retires ~180 + million instructions, so "boots and executes" passed. The tell was that + `retired` was **identical across four different games** — the "same value + whatever the input" signature — found by tracing the instruction stream rather + than inspecting state. +- **The RI register block (`0x0470_0000`) is now decoded** as storage, so IPL3's + opening `RI_SELECT` read is coherent instead of always reading 0. +- Result: titles boot into their own code. Super Mario 64 reaches `pc=0x80246ddc` + with 928 KiB of RDRAM populated; Star Fox 64 submits **122** RDP commands, + World Driver Championship **45**; Star Wars Rogue Squadron scans out **68,527 + lit pixels**. Every title uploads its graphics microcode into IMEM. +- The local capstone now asserts what was silently false — that RDRAM is + populated and the PC is inside cached RDRAM — instead of only counting retired + instructions. +- **Still open (R-18):** no title starts the RSP, so those RDP commands come from + the CPU driving the DPC directly, not from microcode. **New R-23:** CIC-6105 + titles still need `real_pif_boot`; their IPL3 self-descrambles using registers + only the real IPL2 leaves set. The capstone detects and reports them rather + than passing quietly. n64-systemtest is unchanged at 90. + ### Fixed — `Fill Rectangle` respects the cycle type (ledger R-21) - **A non-FILL `Fill Rectangle` now goes through the combiner.** It previously diff --git a/crates/rustyn64-core/src/boot.rs b/crates/rustyn64-core/src/boot.rs index 85d27722..0de9d1b5 100644 --- a/crates/rustyn64-core/src/boot.rs +++ b/crates/rustyn64-core/src/boot.rs @@ -108,6 +108,19 @@ pub fn hle_boot(system: &mut System, rom: &[u8]) -> Result<(), BootError> { .cop0 .set_hardware(reg::CONFIG, 0x7006_E463); + // **The stack pointer IPL3 inherits.** IPL1 sets it before handing off — + // N64brew *IPL2* §IPL1 listing, `0xBFC000D0`: + // `ORI sp, sp, 0x1FF0 # sp = 0xA4001FF0 (this prepares sp for use in IPL2)` + // — and IPL2 leaves it alone, so IPL3 runs on it. It points at the top of RSP + // IMEM, which is where IPL3's stack lives while IPL3 itself executes from DMEM. + // + // Skipping this is NOT harmless. `sp` would be 0, IPL3's opening + // `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue would store to `0xFFFF_FFE8` + // (KSEG3 — TLB-mapped, no entries), and the resulting TLB-refill exception + // vectors to `0x8000_0000` in empty RDRAM. Every retail title then executed a + // NOP sled to the end of memory instead of booting (ledger R-18). + system.cpu.regs.write(29, 0xFFFF_FFFF_A400_1FF0); + // s3–s7 the OS/IPL3 rely on: rom_type=0 (cart), tv_type=1 (NTSC), // reset_type=0 (cold), s6 = the CIC seed byte, s7 = 0. system.cpu.regs.write(19, 0); @@ -202,6 +215,37 @@ mod tests { use super::*; use crate::cart::Cic; + /// **`hle_boot` seeds the stack pointer IPL3 inherits** (`0xA400_1FF0`, + /// sign-extended). IPL1 sets it before handing off — N64brew *IPL2* §IPL1 + /// listing, `0xBFC000D0` — and `hle_boot` skips IPL1/IPL2, so it must stand in. + /// + /// This is asserted on its own because leaving `sp` at 0 does **not** crash or + /// panic: IPL3's opening `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue faults to + /// `0xFFFF_FFE8` (KSEG3, unmapped), takes a TLB-refill exception to + /// `0x8000_0000`, and executes a NOP sled through empty RDRAM to the end of + /// memory. Every retail title did exactly that, quietly, while still retiring + /// hundreds of millions of instructions — so an instruction-count or + /// does-not-panic check cannot detect it. Ledger R-18. + #[test] + fn hle_boot_seeds_the_stack_pointer_ipl3_inherits() { + let mut rom = [0u8; 0x1000]; + rom[0..4].copy_from_slice(&[0x80, 0x37, 0x12, 0x40]); // .z64 magic + let mut sys = System::new(0); + hle_boot(&mut sys, &rom).expect("boot"); + assert_eq!( + sys.cpu.regs.read(29), + 0xFFFF_FFFF_A400_1FF0, + "sp must be the top of RSP IMEM, sign-extended" + ); + // And it must be a *valid* address to store through: the whole failure was + // that `sp - 24` landed outside any mapped segment. + let prologue = sys.cpu.regs.read(29).wrapping_sub(24) & 0xFFFF_FFFF; + assert!( + (0xA400_0000..0xA400_2000).contains(&prologue), + "sp-24 must stay inside SP DMEM/IMEM, got {prologue:#010x}" + ); + } + #[test] fn too_small_a_rom_is_rejected_before_any_slice() { let mut sys = System::new(0); diff --git a/crates/rustyn64-core/src/bus.rs b/crates/rustyn64-core/src/bus.rs index 9d4007aa..7542c0e2 100644 --- a/crates/rustyn64-core/src/bus.rs +++ b/crates/rustyn64-core/src/bus.rs @@ -198,12 +198,27 @@ pub struct RcpRegs { pub mi_mask: MiInterrupt, /// `MI_MODE`'s storage bits (the repeat count and flags). pub mi_mode: u32, - // The SP, DP (DPC), VI, AI, PI, SI, and MI register blocks are all decoded + /// The RI (RDRAM controller) register file, `0x0470_0000..0x0470_0020`: + /// `RI_MODE`, `RI_CONFIG`, `RI_CURRENT_LOAD`, `RI_SELECT`, `RI_REFRESH`, + /// `RI_LATENCY`, `RI_ERROR`, `RI_BANK_STATUS` (N64brew *RDRAM Interface* + /// §Registers). + /// + /// Plain storage — writes stick and reads return them. That is enough for the + /// one thing that actually depends on it today: the cartridge's IPL3 reads + /// `RI_SELECT` and branches on whether RDRAM has already been brought up + /// (ledger R-18). The documented read *oddities* are deliberately NOT modelled + /// (see R-22): `RI_CURRENT_LOAD` is write-only on hardware and its read + /// returns a collection of bits from other registers, and `RI_ERROR` / + /// `RI_BANK_STATUS` reflect controller state rather than the last write. + /// Nothing exercises those yet and there is no oracle for them — the suite has + /// no RI group — so they stay honest storage rather than invented behaviour. + pub ri: [u32; 8], + // The SP, DP (DPC), VI, AI, PI, SI, MI, and RI register blocks are all decoded // (see the `is_*_register` methods + the read/write dispatch). Still undecoded: - // the RI RDRAM-controller block (`0x0470_0000`) and the RDRAM-config registers - // (`0x03F0_0000`). n64-systemtest has no RI/RDRAM-register group, so these have - // no suite oracle — they are validated by commercial-boot progress (ledger R-18) - // rather than pinned here, and land with that work. + // the RDRAM-config registers (`0x03F0_0000`) — the per-chip Rambus device + // registers, distinct from the RI controller block above. n64-systemtest has no + // RI/RDRAM-register group, so neither has a suite oracle; they are validated by + // commercial-boot progress (ledger R-18) instead. } /// Everything mutable lives here — the single owner. @@ -566,6 +581,16 @@ impl Bus { } } + /// Base of the RI (RDRAM controller) register block (`0x0470_0000`). + pub const RI_BASE: u32 = 0x0470_0000; + + /// Is this address in the RI register block? Eight registers span + /// `0x0470_0000..0x0470_0020`; the rest of the `0x047x_xxxx` window mirrors + /// them via the three-bit decode `(addr >> 2) & 7`, as the other RCP blocks do. + const fn is_ri_register(addr: u32) -> bool { + addr >= Self::RI_BASE && addr < Self::SI_BASE + } + /// Base of the SI register block (`0x0480_0000`). pub const SI_BASE: u32 = 0x0480_0000; @@ -1423,6 +1448,9 @@ impl CpuBus for Bus { if Self::is_ai_register(addr) { return (self.audio.read_reg((addr >> 2) & 7) >> (8 * (3 - (addr & 3)))) as u8; } + if Self::is_ri_register(addr) { + return (self.rcp.ri[((addr >> 2) & 7) as usize] >> (8 * (3 - (addr & 3)))) as u8; + } if Self::is_si_register(addr) { return (self.si_read(addr) >> (8 * (3 - (addr & 3)))) as u8; } @@ -1509,6 +1537,9 @@ impl CpuBus for Bus { if Self::is_ai_register(addr) { return self.audio.read_reg((addr >> 2) & 7); } + if Self::is_ri_register(addr) { + return self.rcp.ri[((addr >> 2) & 7) as usize]; + } if Self::is_si_register(addr) { return self.si_read(addr); } @@ -1703,6 +1734,10 @@ impl CpuBus for Bus { self.ai_write(addr, val); return; } + if Self::is_ri_register(addr) { + self.rcp.ri[((addr >> 2) & 7) as usize] = val; + return; + } if Self::is_si_register(addr) { self.si_write(addr, val); return; @@ -2504,6 +2539,43 @@ mod pi_tests { assert_eq!(bus.isviewer_output().len(), Bus::ISVIEWER_LEN); } + /// **The RI register block round-trips.** Eight registers at + /// `0x0470_0000..0x0470_0020` (N64brew *RDRAM Interface* §Registers). Before + /// this block was decoded every RI address read back `0`, which is not inert: + /// the cartridge's IPL3 opens by reading `RI_SELECT` (`0x0470_000C`) to decide + /// whether RDRAM has already been brought up, so an undecoded block silently + /// forced the cold-init path on every boot (ledger R-18). + /// + /// Each register is given a *distinct* value so a decode that collapses them + /// onto one another — or drops the low address bits — fails rather than + /// passing on a shared zero. + #[test] + fn the_ri_register_block_round_trips() { + let mut bus = Bus::new(); + for i in 0..8u32 { + CpuBus::write_u32(&mut bus, Bus::RI_BASE + i * 4, 0x1234_0000 + i); + } + for i in 0..8u32 { + assert_eq!( + CpuBus::read_u32(&mut bus, Bus::RI_BASE + i * 4), + 0x1234_0000 + i, + "RI register {i} must read back what was written" + ); + } + } + + /// **`RI_SELECT` specifically reads back**, since it is the one RI register a + /// real boot depends on: IPL3 branches on it. Asserted separately from the + /// round-trip above so the intent survives if that test is ever narrowed. + #[test] + fn ri_select_reads_back_what_ipl3_writes() { + let mut bus = Bus::new(); + // The value IPL3 configures: TSEL = 0b0001, RSEL = 0b0100 (N64brew + // *RDRAM Interface* §RI_SELECT, "Extra Details"). + CpuBus::write_u32(&mut bus, 0x0470_000C, 0x14); + assert_eq!(CpuBus::read_u32(&mut bus, 0x0470_000C), 0x14); + } + /// **The RSP powers up halted.** Reading `SP_STATUS` as zero claims a /// running RSP, which is false; n64-systemtest's `StartupTest` reads `0x1`. #[test] diff --git a/crates/rustyn64-test-harness/tests/commercial_boot.rs b/crates/rustyn64-test-harness/tests/commercial_boot.rs index 9b13d142..37eda646 100644 --- a/crates/rustyn64-test-harness/tests/commercial_boot.rs +++ b/crates/rustyn64-test-harness/tests/commercial_boot.rs @@ -27,10 +27,9 @@ use std::path::Path; use rustyn64_core::System; use rustyn64_test_harness::rom; -/// Boot `path` and run it for `frames` ~60 Hz frames, returning the retired -/// instruction count and the number of non-black scanned-out pixels on the final -/// frame (0 = never produced video). -fn boot_and_run(path: &Path, frames: u64) -> Option<(u64, usize)> { +/// Boot `path` and run it for `frames` ~60 Hz frames, returning what the run +/// observed (see [`BootResult`]) — or `None` if the ROM could not be read or booted. +fn boot_and_run(path: &Path, frames: u64) -> Option { const TICKS_PER_FRAME: u64 = rustyn64_core::MASTER_HZ / 60; let image = std::fs::read(path).ok()?; @@ -49,7 +48,37 @@ fn boot_and_run(path: &Path, frames: u64) -> Option<(u64, usize)> { .take((w * h) as usize) .filter(|px| px[0] != 0 || px[1] != 0 || px[2] != 0) .count(); - Some((sys.cpu.retired, lit)) + // How much of RDRAM the boot chain actually populated, and where the CPU + // ended up. Both are needed because a *huge* retired count proves nothing on + // its own: a machine sledding through zeroed RDRAM retires hundreds of + // millions of NOPs and looks identical to a booted game by that measure. + let rdram_nonzero = sys.bus.rdram.iter().filter(|b| **b != 0).count(); + let pc = (sys.cpu.pc & 0xFFFF_FFFF) as u32; + Some(BootResult { + retired: sys.cpu.retired, + lit, + rdram_nonzero, + pc, + }) +} + +/// Is this ROM's bootcode CIC-6105? Read from the cartridge header the same way +/// the boot does, so the classification cannot drift from what actually runs. +fn is_cic_6105(path: &Path) -> bool { + let Ok(image) = std::fs::read(path) else { + return false; + }; + rustyn64_core::cart::Cart::load(&image) + .is_ok_and(|c| c.header().cic == rustyn64_core::cart::Cic::Cic6105) +} + +/// What [`boot_and_run`] observed. Named fields rather than a tuple because the +/// assertions below distinguish "executed a lot" from "executed the *game*". +struct BootResult { + retired: u64, + lit: usize, + rdram_nonzero: usize, + pc: u32, } /// **A commercial ROM boots and executes** (local capstone). Runs the first @@ -63,6 +92,9 @@ fn a_commercial_rom_boots_and_executes() { /// A booted retail ROM retires far more than this within a few frames; a /// stalled or mis-booted machine retires near zero. const MIN_RETIRED: u64 = 1_000_000; + /// A booted title has ~0.8-1.2 MiB of game code and data in RDRAM (IPL3 copies + /// 1 MiB); a machine that faulted out of IPL3 leaves it entirely zero. + const MIN_RDRAM_NONZERO: usize = 256 * 1024; let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/roms/external/commercial"); let mut any = false; @@ -86,13 +118,51 @@ fn a_commercial_rom_boots_and_executes() { }; any = true; let name = rom_path.file_name().unwrap().to_string_lossy().into_owned(); + // **CIC-6105 is a documented HLE scope limit, not a silent skip.** Its + // IPL3 is a different program: it opens with a self-descrambling XOR loop + // (`LW t0, -0xFF0(t1)` / `LW t2, 0x44(t3)` / `XOR` / `SW`) over registers + // that only the real IPL2 leaves set. `hle_boot` seeds `sp` and `s3`-`s7` + // but not `t1`/`t3`, so the first load faults and the machine sleds + // (ledger R-23). The real-PIF capstone below boots these titles correctly, + // which is why this is scoped rather than fixed here. + if is_cic_6105(&rom_path) { + eprintln!( + "[{folder}] {name}: SKIPPED — CIC-6105 IPL3 is not HLE-bootable \ + (ledger R-23); the real-PIF capstone covers it" + ); + continue; + } match boot_and_run(&rom_path, 120) { - Some((retired, lit)) => { - eprintln!("[{folder}] {name}: retired={retired}, lit pixels={lit}"); + Some(r) => { + eprintln!( + "[{folder}] {name}: retired={}, pc={:#010x}, rdram_nonzero={}, lit pixels={}", + r.retired, r.pc, r.rdram_nonzero, r.lit + ); assert!( - retired >= MIN_RETIRED, - "[{folder}] {name} retired only {retired} instructions \ + r.retired >= MIN_RETIRED, + "[{folder}] {name} retired only {} instructions \ (< {MIN_RETIRED}) — it did not boot and execute", + r.retired, + ); + // **IPL3 loaded the game.** Retired count alone cannot show this: + // for a long time every title took a TLB-refill exception out of + // IPL3's prologue and executed a NOP sled through *empty* RDRAM, + // retiring ~180 million instructions while loading nothing at all + // (ledger R-18). A populated RDRAM is what separates the two. + assert!( + r.rdram_nonzero >= MIN_RDRAM_NONZERO, + "[{folder}] {name} left only {} non-zero RDRAM bytes \ + (< {MIN_RDRAM_NONZERO}) — IPL3 never copied the game in", + r.rdram_nonzero, + ); + // **And the CPU is running that game code**, in cached RDRAM + // (KSEG0 below the installed 8 MiB) — not still in IPL3's DMEM, and + // not sledding past the end of memory. + assert!( + (0x8000_0000..0x8080_0000).contains(&r.pc), + "[{folder}] {name} ended at pc={:#010x}, outside cached RDRAM \ + — it is not executing the game", + r.pc, ); } None => panic!("[{folder}] could not read/boot {}", rom_path.display()), diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 464843d4..dedb803c 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -410,7 +410,9 @@ labelled as one until something reads the register on hardware. | R-21 | `Fill Rectangle` (0x36) always writes the **`SET_FILL_COLOR` register**, whatever the cycle type. On hardware only FILL mode does that — in 1-/2-cycle mode the rectangle is rasterised through the **combiner/blender** like any other primitive | `fill_rectangle` calls `fill_pixel` unconditionally; the cycle type is never consulted. Every committed conformance vector that exercises `Fill Rectangle` sets FILL mode, so the non-FILL path has no coverage in either direction and the gap was invisible | absolute — a coverage boundary, not a fitted constant | **Open (found 2026-07-28, not yet oracled).** Surfaced *accidentally* while building the microcode end-to-end test: that test's queue initially mis-packed `SET_OTHER_MODES` (the cycle type went into word1 instead of word0 bits 21:20, so the RDP never entered FILL mode) and **the picture was still correct**, which is only possible because `Fill Rectangle` ignores the cycle type. A reviewer flagged the mis-packing; chasing it found this. The queue is fixed and the test now asserts the *emitted* `SET_OTHER_MODES` carries FILL, so the two are no longer confounded. **RESOLVED 2026-07-28 (oracle-confirmed).** Vector `fill_rect_1cycle_16` settles it: a 1-cycle rectangle with prim `(0x22,0x44,0x66)`, a combine selecting prim for RGB and alpha, and a *deliberately different* green fill register `0x07C1` renders **`0x2219` in all 64 pixels** — the prim colour in RGBA5551, not the fill register. So hardware routes a non-FILL rectangle through the combiner and never reads the fill register. `fill_rectangle` now takes the combiner path (with alpha-compare and dither, as the triangle path does) unless the cycle type is `CYCLE_TYPE_COPY` or `CYCLE_TYPE_FILL`. Mutation-checked: reverting the guard reproduces `07 C1` against golden `22 19` at pixel (0,0), and *only* that vector regresses. A second vector `fill_rect_2cycle_16` pins the **2-cycle** branch, which the 1-cycle vector leaves untested because `combine()` takes a distinct path there (cycle 0 runs first and feeds cycle 1 as the `COMBINED` input): its combine makes cycle 0 emit the env colour and cycle 1 select `COMBINED`, so env `0x8D73` means the chain ran, black means cycle 0 was skipped, and green `0x07C1` means the cycle type was ignored. Angrylion renders **`0x8D73`** and we already match. **Fallout worth recording:** six existing tests — the five `fill_rectangle_*` unit tests and the `golden_frame` end-to-end test — were named for FILL-mode behaviour but **never selected FILL mode**, so they were passing on this bug. They now emit a `Set Other Modes` with `cycle_type = FILL` and test what their names claim. **Still open:** the same question for a *flat* `Fill Triangle` (0x08) with no shade/texture block, which likewise takes the fill register unconditionally (the presence of a shade/texture block selects the combiner there, not the cycle type); no vector exercises it yet. **n64-systemtest impact: not measured** — the suite has no RDP render-path coverage | | R-17 | The AI DMA models the sample **rate** exactly (`MASTER_HZ / (video_clock / (DACRATE + 1))` per sample-pair) but charges **no DMA setup/arbitration latency and no RDRAM bank-state cost**: the transfer begins, and the start-interrupt fires, at the derived sample boundary rather than after the real DMA-engine delay. The underrun behaviour is a defined **hold-and-decay** (integer `× 63/64` per sample) rather than the analog decay curve | The AI DMA is "directly connected to the DAC" and "progresses as samples are physically put through the DAC" (wiki §DMA), so the per-sample rate is the dominant timing term and is exact; the fixed setup latency `M` and the RDRAM bank costs are the same unmeasured constants flagged for the CPU/PI (they belong in this ledger with provenance when measured, never tuned). The decay shape is deterministic and no-`std`-friendly; ares uses `exp(-1/(freq·0.003))` | absolute — an unmeasured latency, not a differential re-phasing | **Open.** The rate and the FIFO/interrupt sequencing are unit- and integration-tested; the setup latency stays unmeasured (measure, don't guess) and the decay is defined-but-unpinned. No AI-timing oracle exists in the committed suites, so nothing gates it yet — to be validated against the project64 `DoubleShot` PCM ROM (Sprint 2) and any AI-timing capture that surfaces. **n64-systemtest impact: not measured** — no AI test drives the DMA-timing path, so the oracle count is **unchanged at 93** *(as-at — see the note above this table)* | | R-15 | The **scissor** lower-right bound in FILL mode is **asymmetric**: the **X** bound is **inclusive** of its boundary pixel while the **Y** bound is **exclusive**, and a rectangle lying entirely at or past the scissor's right edge draws nothing (`allover`). `fill_rectangle` previously clipped both bounds exclusively (`(coord + 3) >> 2`) | Isolated cleanly against the Angrylion oracle by a scissor-clip fuzz batch (rectangles extending past the scissor on each edge). The X clip keeps the pixel containing `scissor.xl` (a rect spanning past `xl = 8.0` fills column 8), but the Y clip drops row `scissor.yl >> 2` (a scissor `yl = 5.0` fills up to row 4). The asymmetry is `edgewalker_for_prims`: the rectangle's `yl` is `\| 3`'d (FILL/COPY) so its own last scanline fills, but the scissor's raw `clip.yl` makes `invaly = k >= yllimit` drop that boundary row; the horizontal clip (`curover = xlsc >= clip.xl << 1`, `allover` ⇒ `!validline`) keeps the boundary column unless the whole span is over it. Read from the oracle's output, not computed | absolute — a rasterisation geometry rule, oracle-confirmed | **Closed for the integer-coordinate FILL scissor (oracle-validated).** `fill_rectangle` now clips X inclusive with the `allover` guard (`rect_xh >= scissor.xl` ⇒ nothing) and Y exclusive, plus a hard width clamp. Pinned by a 48-vector scissor-clip fuzz family (`tests/vectors/fuzz/fz_scis_*`, all byte-exact) and the reconciled `fill_rectangle_is_clipped_to_the_scissor` unit test (which previously asserted an unverified exclusive X edge). Sub-pixel (fractional-coordinate) scissor edges remain unexercised. No n64-systemtest driver (count 93 *(as-at — see the note above this table)*) | -| R-18 | A **commercial ROM boots and executes real code but does not reach video** (Phase 5 capstone). Through the retail HLE boot (`rom::hle_boot`) the game's own IPL3 runs, the CPU fetches the cartridge's instruction stream, and the PC advances through hundreds of millions of retired instructions across varied routines — but no frame is scanned out: over ~10 s of emulated time `VI_CTRL` stays 0, `VI_ORIGIN` is never set, and **no interrupt of any kind fires** (SM64 witnessed at `retired ≈ 9.4×10⁸`, all MI interrupt lines clear) | The retail OS-boot runtime the game waits on is not yet modelled. A commercial title's boot is interrupt-driven: after its OS initialises, its main loop blocks on the **VI vblank interrupt**, which the emulator only raises once the game programs `VI_CTRL`/`VI_V_INTR` — and the game does not reach that programming, indicating an earlier dependency (the **RI/RDRAM interface** registers used for RDRAM sizing, and/or the OS thread/interrupt setup). This is a cross-subsystem gap spanning the VI vblank loop, the RI registers, and the F3DEX graphics microcode — all **outside the Phase 5 cart/boot/saves boundary** (ADR 0003; the cart phase delivers PI/SI/PIF/CIC + saves, not the OS runtime) | absolute — a coverage boundary across subsystems, not a fitted constant or a timing interval | **Open — characterised, not a regression.** The committable Phase 5 gate (n64-systemtest cart/PIF/SI, save round-trips, homebrew boot) is met; the commercial capstone is asserted at its honest achievable level — `a_commercial_rom_boots_and_executes` (local, `#[ignore]`d) proves the ROM boots and retires ≥ 10⁶ real instructions without panicking, and *reports* the lit-pixel count (0) rather than asserting it. Reaching a title frame is deferred to the VI/RI/F3DEX work of a later phase and validated then. This gap was surfaced by the capstone exactly as the plan's escalation gate intended: **ship v0.6.0 on the committable gates + an honest "boots and executes" capstone, not a faked pass or an unbounded chase.** n64-systemtest impact: none — the boot/video path has no systemtest driver; the suite-wide count is **90** (see C-32) | +| R-18 | A **commercial ROM boots and executes real code but does not reach video** (Phase 5 capstone). Through the retail HLE boot (`rom::hle_boot`) the game's own IPL3 runs, the CPU fetches the cartridge's instruction stream, and the PC advances through hundreds of millions of retired instructions across varied routines — but no frame is scanned out: over ~10 s of emulated time `VI_CTRL` stays 0, `VI_ORIGIN` is never set, and **no interrupt of any kind fires** (SM64 witnessed at `retired ≈ 9.4×10⁸`, all MI interrupt lines clear) | The retail OS-boot runtime the game waits on is not yet modelled. A commercial title's boot is interrupt-driven: after its OS initialises, its main loop blocks on the **VI vblank interrupt**, which the emulator only raises once the game programs `VI_CTRL`/`VI_V_INTR` — and the game does not reach that programming, indicating an earlier dependency (the **RI/RDRAM interface** registers used for RDRAM sizing, and/or the OS thread/interrupt setup). This is a cross-subsystem gap spanning the VI vblank loop, the RI registers, and the F3DEX graphics microcode — all **outside the Phase 5 cart/boot/saves boundary** (ADR 0003; the cart phase delivers PI/SI/PIF/CIC + saves, not the OS runtime) | absolute — a coverage boundary across subsystems, not a fitted constant or a timing interval | **Open — characterised, not a regression.** The committable Phase 5 gate (n64-systemtest cart/PIF/SI, save round-trips, homebrew boot) is met; the commercial capstone is asserted at its honest achievable level — `a_commercial_rom_boots_and_executes` (local, `#[ignore]`d) proves the ROM boots and retires ≥ 10⁶ real instructions without panicking, and *reports* the lit-pixel count (0) rather than asserting it. Reaching a title frame is deferred to the VI/RI/F3DEX work of a later phase and validated then. This gap was surfaced by the capstone exactly as the plan's escalation gate intended: **ship v0.6.0 on the committable gates + an honest "boots and executes" capstone, not a faked pass or an unbounded chase.** n64-systemtest impact: none — the boot/video path has no systemtest driver; the suite-wide count is **90** (see C-32) **SUBSTANTIALLY RESOLVED 2026-07-29 — the root cause was NOT the theory above.** It was `hle_boot` never seeding **`sp`**. IPL1 sets it before handing off (N64brew *IPL2* §IPL1 listing, `0xBFC000D0`: `ORI sp, sp, 0x1FF0 # sp = 0xA4001FF0`), and `hle_boot` skips IPL1/IPL2 without standing in for it. With `sp = 0`, IPL3's opening `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue stored to `0xFFFF_FFE8` — KSEG3, TLB-mapped, no entries — taking a TLB-refill exception to `0x8000_0000` in empty RDRAM and executing a **NOP sled to the end of memory**. That is why the game never programmed `VI_CTRL`: it never ran at all. The symptom hid perfectly behind the capstone's own metric, because a sledding machine still retires ~180 million instructions. Found by tracing the instruction stream rather than the state (`docs/engineering-lessons.md`), and by noticing `retired` was *identical across four different games* — the 'same value regardless of input' signature. With `sp` seeded (and the **RI register block** decoded so IPL3's `RI_SELECT` read is coherent), retail titles now boot into their own code: Super Mario 64 reaches `pc=0x80246ddc` with 928 KiB of RDRAM populated, Star Fox 64 submits **122** RDP commands, World Driver Championship **45**, and Star Wars Rogue Squadron scans out **68 527 lit pixels**. Every title uploads its graphics microcode into IMEM. **Still open:** the RSP is never started — no title unhalts it, so those RDP commands come from the CPU driving the DPC directly, not from microcode. That is the remaining half of R-18 and the blocker for T-71-003. n64-systemtest is unchanged at 90 (it uses the ELF load path, not IPL3). | +| R-22 | The **RI register block** (`0x0470_0000..0x0470_0020`) is modelled as **plain storage**: writes stick, reads return them. Hardware read behaviour differs for at least three of the eight — N64brew *RDRAM Interface* documents `RI_CURRENT_LOAD` as intended write-only, its read returning "a collection of bits from other registers" (`RI_ERROR` Ack, `RI_MODE` STOP_R, `RI_SELECT` TSEL[0], and two bits marked TOVERIFY), while `RI_ERROR` and `RI_BANK_STATUS` reflect controller state rather than the last write | Storage is enough for the only consumer today: IPL3 reads `RI_SELECT` and branches on it. Modelling the readback quirks would mean inventing the parts the wiki itself marks TOVERIFY | absolute — a coverage boundary | **Open, deliberately.** n64-systemtest has **no RI group**, so there is no oracle in the vendored set; per measure-don't-tune these stay honest storage rather than fabricated behaviour. Decoding the block at all is what R-18 needed. The separate **RDRAM device registers** (`0x03F0_0000`) remain undecoded | +| 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 | **Open, scoped explicitly.** The HLE capstone `a_commercial_rom_boots_and_executes` 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 hangs on the n64-systemtest test `TLB: Execute mapped branch with a non-mapped delay slot`** — a mapped branch whose delay slot lies in a page not currently in the TLB. Both the committed **base** ROM and the `--features timing` ROM stop dead there: `started = 917`, `emux_exited = false`, no test after it ever starts, at an 8×10⁹-tick budget (~2× a normal base completion). It is a genuine loop, not slowness. | **The committed `systemtest` gate masks it**: `tests/systemtest.rs` asserts Phase-1 *category* `Failed: 0` (those results are captured before test 917) and witnesses `started > 0`, but never requires the ROM to run to `xioctl(EXIT)` — so a mid-suite hang is invisible (the failure mode engineering-lessons §2.2 warns about, one level up). **Fully traced 2026-07-24 — every architectural field is CORRECT, so the defect is NOT in the delivered exception state.** The loop oscillates between `pc = 0x1234_5000` (the non-mapped delay-slot fetch) and `pc = 0x8000_0180` (the general vector), sustaining `EXL = 1`, with: `BadVAddr = 0x1234_5000` (✓ the delay slot), `EPC = 0x1234_4FFC` (✓ the branch, `pc − 4`), `Cause.BD = 1` (✓), `Cause.ExcCode = 2` = `TLBL` (✓), `Context`/`XContext` `BadVPN2 = 0x0_91A2` (✓ `= BadVAddr >> 13`), `EntryHi` VPN2 `= 0x1234_4000` + ASID (✓), 32-bit mode (`Status.KX/SX/UX = 0`). The general vector is correct *given* `EXL = 1` (a refill with `EXL` set uses `0x180`, S-3). `ERET` clears `EXL` correctly (tested). **So `EPC`, `BD`, `Cause`, `BadVAddr`, `Context`, `XContext`, `EntryHi`, the vector, and `ERET` are all right** — the earlier "vector/EPC/EXL is off" guess is disproven. The remaining suspects are in the finer *sequencing* the trace hasn't yet caught: (a) the **`EXL = 0` first fault** (does it reach the refill vector `0x8000_0000` and n64-systemtest's *test* handler, or does the refill handler's own page-table load fault nested straight to the general/"unexpected-exception spin" handler?); and (b) whether n64-systemtest's handler **maps the page and ERETs** expecting the fetch to now hit — in which case a stale **micro-ITLB** (not refilled from the JTLB after the map) would keep the fetch missing. | absolute — a hang is a coverage boundary, not a fitted constant | **Open — fully characterised, not yet root-caused. Blocks the `timing` suite from completing (so it blocks the clean `M` measurement, C-1).** **The test + handler are now understood** (`tlb/exceptions.rs:388` + `exception_handler.rs:247`): the JALR is the last instruction of the mapped page, its delay slot is the first of the next (unmapped) page; the test runs it under `expect_exception(TLBL, -4, …)`, which sets `EXCEPTION_SKIP = -4`, so the handler resumes at `return_to = exceptpc + skip*4 = EPC − 16` — back inside the mapped block, expecting the block's own code there to escape back to the `0x80…` test. n64-systemtest asserts `exceptpc == fault_address − 4` (line 436), and **our `EPC = 0x1234_4FFC` matches that exactly** — a third confirmation the exception state is right. So the loop is not a wrong `EPC`/vector; it is that after the skip-return our CPU re-reaches the JALR and re-faults instead of escaping. **RESOLVED 2026-07-24 (root-caused by a full pipeline-latch trace, not by reasoning).** The defect was NOT in the exception state (all correct, as characterised) but in the **branch-redirect vs. exception-vector race** in `ex_stage`. Sequence: the delay-slot fetch (`0x1234_5000`) faults and the exception is dispatched at the end of the cycle, setting `next_pc = 0x8000_0180` — but the JALR is still sitting **unexecuted** in `rf_ex`. On the next active cycle the JALR reaches EX and unconditionally applied its redirect (`*next_pc = r.target`), and this JALR's target is its **own address** (`v1 = 0x1234_4FFC`), so it clobbered the vector, re-fetched itself, re-faulted its delay slot, and looped forever — exactly the two-state oscillation the latch trace showed (JALR + aborted delay slot circulating, never retiring). Fix (`pipeline.rs::resolve_branch_control`): a branch whose delay slot has aborted (its `ic_rf` latch carries `in_delay_slot` + an abort) **still writes its link** — from the architectural `pc + 8`, since `next_pc` now holds the vector — but its **redirect is suppressed**, so the exception PC wins. This is hardware-accurate: the older branch retires and links (n64-systemtest asserts `RA == fault_address + 4`) while the precise exception on the younger delay slot takes over control flow. With the fix the delay-slot test passes and **the full suite runs to `xioctl(EXIT)` for the first time (950 tests, ~30 s), so `emux_exited` is now `true`.** No regression: golden-log 0-diff, determinism, residue-invariant, and all workspace tests stay green. Completing the run unmasked a distinct pre-existing cluster the hang had hidden — see **R-20**. Discovered + traced + fixed 2026-07-24 during the Stage-C/D timing work | | 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 | From 6eaa1f42c11af38f5744c5766331a7de6485f2ba Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 29 Jul 2026 00:28:00 -0400 Subject: [PATCH 2/2] test(core): pin the size-blind RI store path; name the sp register index Review follow-up on the R-18 fix. Rejecting the sub-word-write concern, but with a test rather than an assertion. The worry was that is_ri_register was added to read_u8/read_u32/write_u32 but not to write_u8, so narrow stores to RI would fall through or be dropped. The premise does not hold: narrow CPU stores reach the bus through write_sized, which shifts the register into its byte lane and funnels to write_u32(addr & !3, word) - the RCP's documented size-blind path, where the whole word latches and the access size is ignored. None of MI/DP/VI/AI/SI appear in write_u8 either, so adding RI there would be inconsistent AND would bypass that semantics. a_narrow_store_to_ri_latches_the_whole_word now pins it: a byte store of 0x12 into lane 3 leaves 0x0000_0012, and a halfword store of 0xABCD into the upper lane leaves 0xABCD_0000. Read only the 0x1000-byte boot header in is_cic_6105 instead of the whole image. The CIC is resolved from the header plus IPL3, both inside it, so this drops a duplicate 8-64 MiB read per ROM while leaving the classification identical - still parsed via Cart::load, so it cannot drift from what actually boots. Name the stack-pointer register index GPR_SP rather than writing a bare 29, matching how the COP0 seeds read as reg::STATUS / reg::CONFIG. Co-Authored-By: Claude Opus 5 (1M context) --- crates/rustyn64-core/src/boot.rs | 7 +++- crates/rustyn64-core/src/bus.rs | 33 +++++++++++++++++++ .../tests/commercial_boot.rs | 12 +++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/rustyn64-core/src/boot.rs b/crates/rustyn64-core/src/boot.rs index 0de9d1b5..cb6b44af 100644 --- a/crates/rustyn64-core/src/boot.rs +++ b/crates/rustyn64-core/src/boot.rs @@ -64,6 +64,11 @@ const fn apply_cartridge_region(system: &mut System) { system.bus.audio.set_region(region); } +/// The MIPS general-purpose register index of the stack pointer (`$29`/`$sp`). +/// Named so the seed below reads as intent rather than as a magic index, matching +/// how the COP0 writes use `reg::STATUS` / `reg::CONFIG`. +const GPR_SP: u8 = 29; + /// **HLE-boot a retail ROM.** /// /// Seed the state IPL3 expects, copy the cart's *real* IPL3 (ROM `0x40..0x1000`) @@ -119,7 +124,7 @@ pub fn hle_boot(system: &mut System, rom: &[u8]) -> Result<(), BootError> { // (KSEG3 — TLB-mapped, no entries), and the resulting TLB-refill exception // vectors to `0x8000_0000` in empty RDRAM. Every retail title then executed a // NOP sled to the end of memory instead of booting (ledger R-18). - system.cpu.regs.write(29, 0xFFFF_FFFF_A400_1FF0); + system.cpu.regs.write(GPR_SP, 0xFFFF_FFFF_A400_1FF0); // s3–s7 the OS/IPL3 rely on: rom_type=0 (cart), tv_type=1 (NTSC), // reset_type=0 (cold), s6 = the CIC seed byte, s7 = 0. diff --git a/crates/rustyn64-core/src/bus.rs b/crates/rustyn64-core/src/bus.rs index 7542c0e2..57cecff0 100644 --- a/crates/rustyn64-core/src/bus.rs +++ b/crates/rustyn64-core/src/bus.rs @@ -2564,6 +2564,39 @@ mod pi_tests { } } + /// **A narrow store to an RI register takes the size-blind RCP path**, like + /// every other RCP block — it is NOT dropped, and it does not need a + /// per-block arm in [`Bus::write_u8`]. + /// + /// Pinned because "sub-word writes to RI fall through or are silently + /// dropped" is a reasonable-sounding worry that is wrong here, and only a test + /// settles it. Narrow CPU stores reach the bus through `write_sized`, which + /// funnels them to `write_u32(addr & !3, word)` after shifting the register + /// into its byte lane — the RCP latches the whole word and ignores the access + /// size (N64brew *Memory map* §Physical Memory Map accesses). So a byte store + /// of `0x12` at `RI_SELECT + 3` must leave `0x0000_0012`, not `0x12` merged + /// into a previous value and not nothing at all. + #[test] + fn a_narrow_store_to_ri_latches_the_whole_word() { + let mut bus = Bus::new(); + CpuBus::write_u32(&mut bus, 0x0470_000C, 0xFFFF_FFFF); + // Byte lane 3 (the low byte of the word). + bus.write_sized(0x0470_000F, 1, 0x12); + assert_eq!( + CpuBus::read_u32(&mut bus, 0x0470_000C), + 0x0000_0012, + "the RCP latches the whole shifted word, zeroing the untouched bytes" + ); + // ... and a halfword store into the upper lane behaves the same way. + CpuBus::write_u32(&mut bus, 0x0470_000C, 0xFFFF_FFFF); + bus.write_sized(0x0470_000C, 2, 0xABCD); + assert_eq!( + CpuBus::read_u32(&mut bus, 0x0470_000C), + 0xABCD_0000, + "a halfword store shifts into its lane and zero-fills the rest" + ); + } + /// **`RI_SELECT` specifically reads back**, since it is the one RI register a /// real boot depends on: IPL3 branches on it. Asserted separately from the /// round-trip above so the intent survives if that test is ever narrowed. diff --git a/crates/rustyn64-test-harness/tests/commercial_boot.rs b/crates/rustyn64-test-harness/tests/commercial_boot.rs index 37eda646..b742b6cd 100644 --- a/crates/rustyn64-test-harness/tests/commercial_boot.rs +++ b/crates/rustyn64-test-harness/tests/commercial_boot.rs @@ -65,10 +65,18 @@ fn boot_and_run(path: &Path, frames: u64) -> Option { /// Is this ROM's bootcode CIC-6105? Read from the cartridge header the same way /// the boot does, so the classification cannot drift from what actually runs. fn is_cic_6105(path: &Path) -> bool { - let Ok(image) = std::fs::read(path) else { + // Read only the 0x1000-byte boot header, not the whole 8-64 MiB image: + // `Cart::load` resolves the CIC from the header + IPL3, both of which live + // inside it, and `boot_and_run` reads the full image separately anyway. + use std::io::Read as _; + let Ok(mut f) = std::fs::File::open(path) else { return false; }; - rustyn64_core::cart::Cart::load(&image) + let mut header = [0u8; 0x1000]; + if f.read_exact(&mut header).is_err() { + return false; + } + rustyn64_core::cart::Cart::load(&header) .is_ok_and(|c| c.header().cic == rustyn64_core::cart::Cic::Cic6105) }