Skip to main content

rustyn64_core/
boot.rs

1//! Retail cartridge boot — the console boot a real N64 performs, moved here from
2//! the test harness so the frontend can boot a game too (both consume it).
3//!
4//! Two paths, both modeling real hardware (not a test shortcut):
5//!
6//! - [`hle_boot`] — the copyright-clean default. It seeds the state IPL3 expects,
7//!   copies the cart's real IPL3 into RSP DMEM, and jumps to it, skipping only the
8//!   PIF ROM (IPL1/IPL2) and the CIC challenge — which the seed injection stands
9//!   in for. Deterministic; the seeds are cited constants (`docs/accuracy-ledger.md`
10//!   C-32).
11//! - [`real_pif_boot`] — the faithful path (off by default, local-only, never
12//!   CI-gated: it needs the copyrighted PIF ROM). It runs the console's real
13//!   IPL1/IPL2 from the PIF ROM at the reset vector and CIC-verifies the IPL2
14//!   checksum (ledger C-33).
15//!
16//! The ELF direct-load (`seed_ipl3_handoff`, for the n64-systemtest ELF payload)
17//! is a genuine *test* facility and stays in `rustyn64-test-harness` — the core
18//! must not acquire a test load-path dependency.
19
20use crate::System;
21
22/// Why a retail boot could not start.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum BootError {
25    /// The image is shorter than the `0x1000` boot header + IPL3, so there is
26    /// nothing to boot. (Also covers a PIF ROM shorter than its window.)
27    TooSmall,
28    /// The cartridge image could not be parsed (unrecognized byte order or a
29    /// truncated header) — the underlying [`crate::cart::CartError`].
30    Cart(crate::cart::CartError),
31}
32
33/// The CIC seed word IPL2 leaves in PIF RAM `0x24` (from which the HLE boot reads
34/// the `s3`–`s7` GPRs). Values from cen64 `si/cic.c`.
35#[must_use]
36pub const fn cic_seed(cic: crate::cart::Cic) -> u32 {
37    use crate::cart::Cic;
38    match cic {
39        Cic::Cic6101 => 0x0004_3F3F,
40        Cic::Cic6102 => 0x0000_3F3F,
41        Cic::Cic6103 => 0x0000_783F,
42        Cic::Cic6105 => 0x0000_913F,
43        Cic::Cic6106 => 0x0000_853F,
44    }
45}
46
47/// Select the AI region from the inserted cartridge's **destination code** (ROM
48/// header byte `0x3E`, the last character of the 4-byte game code).
49///
50/// The AI's video clock — and so its sample rate for a given `AI_DACRATE` —
51/// differs between NTSC and PAL consoles. The machinery existed but nothing drove
52/// it, so a PAL cartridge played at the NTSC rate; this is the selector
53/// (`T-71-005`). An unrecognized code maps to NTSC, so the default is
54/// behavior-preserving.
55///
56/// The classification and its provenance limits live on
57/// [`rustyn64_audio::Region::from_destination_code`].
58///
59/// `const` is not decorative: the workspace runs clippy's `missing_const_for_fn`
60/// under `-D warnings`, which rejects this function without it.
61const fn apply_cartridge_region(system: &mut System) {
62    let code = system.bus.cart.header().game_code[3];
63    let region = rustyn64_audio::Region::from_destination_code(code);
64    system.bus.audio.set_region(region);
65}
66
67/// **HLE-boot a retail ROM.**
68///
69/// Seed the state IPL3 expects, copy the cart's *real* IPL3 (ROM `0x40..0x1000`)
70/// into RSP DMEM, and jump to it at `0xA400_0040`. IPL3 then copies the game to
71/// RDRAM and jumps to the header entry — running the cart's own bootcode rather
72/// than a reimplementation of it. This skips only IPL1/IPL2 (the PIF ROM) and the
73/// CIC challenge, which the seed injection stands in for.
74///
75/// This is the pure retail path: an ELF-payload ROM (n64-systemtest) is handled
76/// separately by the harness's `seed_ipl3_handoff`, not here.
77///
78/// # Errors
79/// [`BootError::TooSmall`] if the image is shorter than a `0x1000` boot header.
80pub fn hle_boot(system: &mut System, rom: &[u8]) -> Result<(), BootError> {
81    use crate::cpu::cop0::reg;
82    use crate::cpu::regs::gpr;
83
84    if rom.len() < 0x1000 {
85        return Err(BootError::TooSmall);
86    }
87
88    // Insert the cartridge; PI reads and IPL3's DMA see the ROM through it.
89    let cart = crate::cart::Cart::load(rom).map_err(BootError::Cart)?;
90    let cic = cart.header().cic;
91    system.bus.cart = cart;
92    apply_cartridge_region(system);
93
94    // Inject the CIC seed into PIF RAM 0x24..0x28 (the boot reads it for s3–s7).
95    let seed = cic_seed(cic);
96    for (i, b) in seed.to_be_bytes().into_iter().enumerate() {
97        system.bus.cart.pif_write(0x24 + i, b);
98    }
99
100    // COP0: Status = CU1|CU0|FR, Config = the IPL3-left value (K0=3 cached).
101    system
102        .cpu
103        .pipeline
104        .cop0
105        .set_hardware(reg::STATUS, 0x3400_0000);
106    system
107        .cpu
108        .pipeline
109        .cop0
110        .set_hardware(reg::CONFIG, 0x7006_E463);
111
112    // **The stack pointer IPL3 inherits.** IPL1 sets it before handing off —
113    // N64brew *IPL2* §IPL1 listing, `0xBFC000D0`:
114    // `ORI sp, sp, 0x1FF0  # sp = 0xA4001FF0 (this prepares sp for use in IPL2)`
115    // — and IPL2 leaves it alone, so IPL3 runs on it. It points at the top of RSP
116    // IMEM, which is where IPL3's stack lives while IPL3 itself executes from DMEM.
117    //
118    // Skipping this is NOT harmless. `sp` would be 0, IPL3's opening
119    // `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue would store to `0xFFFF_FFE8`
120    // (KSEG3 — TLB-mapped, no entries), and the resulting TLB-refill exception
121    // vectors to `0x8000_0000` in empty RDRAM. Every retail title then executed a
122    // NOP sled to the end of memory instead of booting (ledger R-18).
123    system.cpu.regs.write(gpr::SP, 0xFFFF_FFFF_A400_1FF0);
124
125    // **The rest of the IPL2 exit state IPL3 inherits — MEASURED, not invented.**
126    //
127    // Captured at IPL3's entry (`0xA400_0040`) by running the console's real
128    // IPL1/IPL2 out of a PIF ROM dump via [`real_pif_boot`], then keeping only the
129    // registers that are **identical across ROMs of different CIC variants**
130    // (compared Banjo-Tooie / CIC-6105 against Super Mario 64 / CIC-6102).
131    //
132    // The excluded registers are excluded on purpose: `v0`, `v1`, `a0`, `a1` and
133    // `t4`-`t9` differ per ROM because they carry IPL2's running checksum of that
134    // cartridge's IPL3, and `s6` is the CIC seed, already set per-CIC above.
135    // Freezing any of those would be fabricating a value the boot computes.
136    //
137    // `s4` is absent here on purpose: the `s3`-`s7` block below already writes it
138    // (as `tv_type = 1`), and IPL2 leaves the same value, so seeding it twice would
139    // be two writes to one register with no second source of truth.
140    //
141    // `t3 = 0xA400_0000` is the one that matters most: **CIC-6105's IPL3 is a
142    // different program** that opens with a self-descrambling XOR loop reading
143    // `0x44(t3)` — DMEM + 0x40, its own image. With `t3 = 0` that read goes to
144    // low RDRAM and the descramble produces garbage (ledger R-23).
145    system.cpu.regs.write(gpr::AT, 1);
146    system.cpu.regs.write(gpr::A2, 0xFFFF_FFFF_A400_1F0C);
147    system.cpu.regs.write(gpr::A3, 0xFFFF_FFFF_A400_1F08);
148    system.cpu.regs.write(gpr::T0, 0xC0);
149    system.cpu.regs.write(gpr::T2, 0x40);
150    system.cpu.regs.write(gpr::T3, 0xFFFF_FFFF_A400_0000);
151    system.cpu.regs.write(gpr::RA, 0xFFFF_FFFF_A400_1550);
152
153    // s3–s7 the OS/IPL3 rely on: rom_type=0 (cart), tv_type=1 (NTSC),
154    // reset_type=0 (cold), s6 = the CIC seed byte, s7 = 0.
155    system.cpu.regs.write(gpr::S3, 0);
156    system.cpu.regs.write(gpr::S4, 1);
157    system.cpu.regs.write(gpr::S5, 0);
158    system
159        .cpu
160        .regs
161        .write(gpr::S6, u64::from((seed >> 8) & 0xFF));
162    system.cpu.regs.write(gpr::S7, 0);
163
164    // PI DOM1 bus timing from the ROM header's first word (as IPL2 does).
165    let cfg = u32::from_be_bytes([rom[0], rom[1], rom[2], rom[3]]);
166    system
167        .bus
168        .pi
169        .write(crate::cart::pi::PI_BSD_DOM1_LAT, cfg & 0xFF);
170    system
171        .bus
172        .pi
173        .write(crate::cart::pi::PI_BSD_DOM1_PWD, (cfg >> 8) & 0xFF);
174    system
175        .bus
176        .pi
177        .write(crate::cart::pi::PI_BSD_DOM1_PGS, (cfg >> 16) & 0x0F);
178    system
179        .bus
180        .pi
181        .write(crate::cart::pi::PI_BSD_DOM1_RLS, (cfg >> 20) & 0x03);
182
183    // Copy the real IPL3 into DMEM (`0x40..0x1000`) and jump to it.
184    let ipl3 = &rom[0x40..0x1000];
185    system.bus.rsp.dmem[0x40..0x40 + ipl3.len()].copy_from_slice(ipl3);
186    system.cpu.set_pc(0xFFFF_FFFF_A400_0040);
187    Ok(())
188}
189
190/// **Real-PIF boot a retail ROM** — the faithful path (off by default, local).
191///
192/// Where [`hle_boot`] *seeds* the post-IPL3 state and jumps straight into the
193/// cartridge's IPL3, this runs the console's **real IPL1 and IPL2** from the
194/// supplied PIF boot ROM: it installs that ROM at `0x1FC0_0000`, models the
195/// PIF-SM5's power-on hand-off (writes the CIC seed word the CIC would have
196/// relayed into PIF RAM `0x24`, and registers the CIC's IPL2 checksum so the PIF
197/// can adjudicate IPL2's verify command), and leaves the CPU at its reset vector
198/// `0xBFC0_0000`. The CPU then fetches IPL1 → copies IPL2 to IMEM → runs IPL2 →
199/// verifies the checksum → jumps into the cart's own IPL3, exactly as hardware
200/// does (`n64brew_wiki/markdown/PIF-NUS.md` §Console startup).
201///
202/// The PIF boot ROM is copyrighted and is **never committed**; a caller supplies
203/// a local dump. On a genuine cartridge the checksum matches and boot proceeds;
204/// a mismatch makes the PIF freeze the CPU via NMI ([`crate::Bus::boot_nmi_halt`]).
205///
206/// # Errors
207/// [`BootError::TooSmall`] if `rom` is shorter than a `0x1000` boot header or
208/// `pif_rom` is shorter than the PIF-ROM window.
209pub fn real_pif_boot(system: &mut System, rom: &[u8], pif_rom: &[u8]) -> Result<(), BootError> {
210    if rom.len() < 0x1000 || pif_rom.len() < crate::cart::pif::PIF_ROM_LEN {
211        return Err(BootError::TooSmall);
212    }
213
214    let cart = crate::cart::Cart::load(rom).map_err(BootError::Cart)?;
215    let cic = cart.header().cic;
216    system.bus.cart = cart;
217    apply_cartridge_region(system);
218
219    // Install the real IPL1/IPL2 so the CPU fetches them from the reset vector.
220    system.bus.cart.pif_load_boot_rom(pif_rom);
221
222    // Model the PIF-SM5 power-on hand-off: after the CIC exchange, the PIF writes
223    // the boot-info word to PIF RAM 0x24-0x27, which IPL2 reads — byte 0x27 = the
224    // IPL2 seed (IPL2 feeds it to its checksum), byte 0x26 = the IPL3 seed. These
225    // come from `CicBootSecrets`, NOT the legacy `cic_seed` word: cen64's seed
226    // packs 0x3F into the IPL2-seed byte for every CIC (harmless when the checksum
227    // is HLE'd, wrong when the real IPL2 consumes it), which N64brew corrects. The
228    // upper bits (region / reset-type / 64DD) stay 0 for a cold NTSC cart boot.
229    let secrets = cic.boot_secrets();
230    system.bus.cart.pif_write(0x24, 0x00);
231    system.bus.cart.pif_write(0x25, 0x00);
232    system.bus.cart.pif_write(0x26, secrets.ipl3_seed);
233    system.bus.cart.pif_write(0x27, secrets.ipl2_seed);
234    // Command byte 0x3F bit 0x80 is the PIF's "busy" gate IPL2 spins on; a zeroed
235    // PIF RAM already has it clear, so IPL2's startup sync passes immediately.
236
237    // Register the CIC's IPL2 checksum so the PIF adjudicates IPL2's verify.
238    system.bus.cart.pif_set_boot_checksum(secrets.ipl2_checksum);
239
240    // The CPU is already at 0xBFC0_0000 (System::new's reset vector); do NOT set
241    // the PC — that is the whole point of running the real boot ROM.
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::cart::Cic;
249
250    /// **`hle_boot` seeds the stack pointer IPL3 inherits** (`0xA400_1FF0`,
251    /// sign-extended). IPL1 sets it before handing off — N64brew *IPL2* §IPL1
252    /// listing, `0xBFC000D0` — and `hle_boot` skips IPL1/IPL2, so it must stand in.
253    ///
254    /// This is asserted on its own because leaving `sp` at 0 does **not** crash or
255    /// panic: IPL3's opening `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue faults to
256    /// `0xFFFF_FFE8` (KSEG3, unmapped), takes a TLB-refill exception to
257    /// `0x8000_0000`, and executes a NOP sled through empty RDRAM to the end of
258    /// memory. Every retail title did exactly that, quietly, while still retiring
259    /// hundreds of millions of instructions — so an instruction-count or
260    /// does-not-panic check cannot detect it. Ledger R-18.
261    #[test]
262    fn hle_boot_seeds_the_stack_pointer_ipl3_inherits() {
263        let mut rom = [0u8; 0x1000];
264        rom[0..4].copy_from_slice(&[0x80, 0x37, 0x12, 0x40]); // .z64 magic
265        let mut sys = System::new(0);
266        hle_boot(&mut sys, &rom).expect("boot");
267        assert_eq!(
268            sys.cpu.regs.read(29),
269            0xFFFF_FFFF_A400_1FF0,
270            "sp must be the top of RSP IMEM, sign-extended"
271        );
272        // And it must be a *valid* address to store through: the whole failure was
273        // that `sp - 24` landed outside any mapped segment.
274        let prologue = sys.cpu.regs.read(29).wrapping_sub(24) & 0xFFFF_FFFF;
275        assert!(
276            (0xA400_0000..0xA400_2000).contains(&prologue),
277            "sp-24 must stay inside SP DMEM/IMEM, got {prologue:#010x}"
278        );
279    }
280
281    /// **`hle_boot` seeds the ROM-independent part of the IPL2 exit state**
282    /// (ledger R-23), measured by running the real IPL1/IPL2 from a PIF ROM dump.
283    ///
284    /// `t3` is asserted with its own message because it is the one that decides
285    /// whether CIC-6105 titles boot at all: their IPL3 self-descrambles by reading
286    /// `0x44(t3)` = DMEM + 0x40, its own image. Removing just this register sends
287    /// Banjo-Tooie back to a NOP sled with RDRAM left entirely empty.
288    ///
289    /// The checksum-derived registers are asserted **absent**: `v0`/`v1`/`a0`/`a1`
290    /// and `t4`-`t9` differ per ROM because they carry IPL2's running checksum of
291    /// that cartridge's IPL3, so seeding them would freeze a computed value.
292    #[test]
293    fn hle_boot_seeds_the_rom_independent_ipl2_exit_state() {
294        let mut rom = [0u8; 0x1000];
295        rom[0..4].copy_from_slice(&[0x80, 0x37, 0x12, 0x40]);
296        let mut sys = System::new(0);
297        hle_boot(&mut sys, &rom).expect("boot");
298
299        assert_eq!(
300            sys.cpu.regs.read(11),
301            0xFFFF_FFFF_A400_0000,
302            "t3 must be the DMEM base — CIC-6105's IPL3 descrambles through it"
303        );
304        for (reg, want, name) in [
305            (1u8, 1u64, "at"),
306            (6, 0xFFFF_FFFF_A400_1F0C, "a2"),
307            (7, 0xFFFF_FFFF_A400_1F08, "a3"),
308            (8, 0xC0, "t0"),
309            (10, 0x40, "t2"),
310            (20, 1, "s4"),
311            (31, 0xFFFF_FFFF_A400_1550, "ra"),
312        ] {
313            assert_eq!(sys.cpu.regs.read(reg), want, "{name} (r{reg})");
314        }
315        // Checksum-derived registers must stay unset — **every** documented
316        // exclusion, not a sample. `t4`-`t9` is r12-r15 plus r24-r25; listing only
317        // the endpoints would let a seed slip into the middle of the range.
318        for (reg, name) in [
319            (2u8, "v0"),
320            (3, "v1"),
321            (4, "a0"),
322            (5, "a1"),
323            (12, "t4"),
324            (13, "t5"),
325            (14, "t6"),
326            (15, "t7"),
327            (24, "t8"),
328            (25, "t9"),
329        ] {
330            assert_eq!(
331                sys.cpu.regs.read(reg),
332                0,
333                "{name} (r{reg}) is IPL2's per-ROM checksum state and must NOT be seeded"
334            );
335        }
336    }
337
338    #[test]
339    fn too_small_a_rom_is_rejected_before_any_slice() {
340        let mut sys = System::new(0);
341        // Shorter than the 0x1000 header + IPL3 — must error, not panic on a slice.
342        assert_eq!(hle_boot(&mut sys, &[0u8; 0x40]), Err(BootError::TooSmall));
343        assert_eq!(hle_boot(&mut sys, &[]), Err(BootError::TooSmall));
344    }
345
346    #[test]
347    fn real_pif_boot_rejects_a_short_pif_rom() {
348        let mut sys = System::new(0);
349        let rom = [0u8; 0x1000];
350        assert_eq!(
351            real_pif_boot(&mut sys, &rom, &[0u8; 16]),
352            Err(BootError::TooSmall),
353            "a PIF ROM shorter than its window is rejected"
354        );
355    }
356
357    /// **Booting a PAL cartridge retunes the AI (T-71-005).** The region selector
358    /// must actually run during boot, not merely exist: this boots two ROMs that
359    /// differ ONLY in the destination code (header byte 0x3E) and asserts the AI
360    /// sample rate differs. Removing the `apply_cartridge_region` call makes both
361    /// rates identical and fails here.
362    #[test]
363    fn a_pal_destination_code_retunes_the_ai_at_boot() {
364        // AI_DACRATE must be programmed for a rate to exist at all; the boot path
365        // does not set it, so drive it directly after booting.
366        let rate_for = |dest: u8| {
367            let mut rom = [0u8; 0x1000];
368            rom[0..4].copy_from_slice(&[0x80, 0x37, 0x12, 0x40]); // .z64 big-endian magic
369            rom[0x3E] = dest;
370            let mut sys = System::new(0);
371            hle_boot(&mut sys, &rom).expect("boot");
372            sys.bus.audio.write_reg(4, 1103); // AI_DACRATE
373            sys.bus.audio.sample_rate()
374        };
375        let ntsc = rate_for(b'E'); // North America
376        let pal = rate_for(b'P'); // Europe
377        assert_ne!(
378            ntsc, pal,
379            "a PAL cartridge must boot with a different AI rate than an NTSC one"
380        );
381        // And an unknown code keeps the NTSC default (behavior-preserving).
382        assert_eq!(
383            rate_for(b'?'),
384            ntsc,
385            "an unknown destination code stays NTSC"
386        );
387    }
388
389    /// **The real-PIF boot path selects the region too.** `apply_cartridge_region`
390    /// has two call sites; the HLE test above covers only one, so a regression in
391    /// the `real_pif_boot` call would go undetected. Same assertion, other path.
392    #[test]
393    fn the_real_pif_boot_path_also_selects_the_region() {
394        let rate_for = |dest: u8| {
395            let mut rom = [0u8; 0x1000];
396            rom[0..4].copy_from_slice(&[0x80, 0x37, 0x12, 0x40]); // .z64 magic
397            rom[0x3E] = dest;
398            // A blank PIF ROM of the right size: this test asserts the region
399            // selection, not the IPL1/IPL2 execution a real ROM would drive.
400            let pif = [0u8; crate::cart::pif::PIF_ROM_LEN];
401            let mut sys = System::new(0);
402            real_pif_boot(&mut sys, &rom, &pif).expect("boot");
403            sys.bus.audio.write_reg(4, 1103); // AI_DACRATE
404            sys.bus.audio.sample_rate()
405        };
406        assert_ne!(
407            rate_for(b'E'),
408            rate_for(b'P'),
409            "real_pif_boot must select the region as hle_boot does"
410        );
411    }
412
413    #[test]
414    fn cic_seed_covers_every_variant() {
415        // The low byte is the IPL2 seed the boot leaves in PIF RAM; bits 8-15 the
416        // IPL3 seed (cen64 si/cic.c). Pin all five arms so a typo fails here.
417        assert_eq!(cic_seed(Cic::Cic6101), 0x0004_3F3F);
418        assert_eq!(cic_seed(Cic::Cic6102), 0x0000_3F3F);
419        assert_eq!(cic_seed(Cic::Cic6103), 0x0000_783F);
420        assert_eq!(cic_seed(Cic::Cic6105), 0x0000_913F);
421        assert_eq!(cic_seed(Cic::Cic6106), 0x0000_853F);
422    }
423}