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}