Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions crates/rustyn64-core/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -108,6 +113,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(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.
system.cpu.regs.write(19, 0);
Expand Down Expand Up @@ -202,6 +220,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);
Expand Down
115 changes: 110 additions & 5 deletions crates/rustyn64-core/src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 oraclethey 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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2504,6 +2539,76 @@ 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"
);
}
}

/// **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.
#[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]
Expand Down
96 changes: 87 additions & 9 deletions crates/rustyn64-test-harness/tests/commercial_boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BootResult> {
const TICKS_PER_FRAME: u64 = rustyn64_core::MASTER_HZ / 60;

let image = std::fs::read(path).ok()?;
Expand All @@ -49,7 +48,45 @@ 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 {
// 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;
};
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)
}

/// 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
Expand All @@ -63,6 +100,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;
Expand All @@ -86,13 +126,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()),
Expand Down
Loading