rustyn64_cart/pif.rs
1//! The PIF RAM (64 bytes) + the joybus frame executor.
2//!
3//! The CPU fills PIF RAM with a joybus *frame* — a sequence of up to five
4//! per-channel handshakes `TX RX tt[..] rr[..]` (channels 0–3 = controller
5//! ports, channel 4 = the cartridge bus / EEPROM) — sets the command byte
6//! (`0x3F`) bit 0, and triggers an SI DMA. On the SI read the PIF *executes* the
7//! handshakes and writes the replies back into the `rr[..]` regions
8//! (`n64brew_wiki/markdown/PIF-NUS.md`, `Joybus protocol.md`).
9//!
10//! We model the common commands: `0x00`/`0xFF` info, `0x01` controller state,
11//! `0x02`/`0x03` Controller-Pak accessory access, and `0x04`/`0x05` EEPROM.
12//! Controller state comes from the Bus's four packed port words; the accessory
13//! and EEPROM backing is the cart's [`crate::save::SaveDevice`].
14#![allow(
15 clippy::doc_markdown,
16 reason = "joybus prose names PIF/EEPROM/CRC/DMA/RX/TX constantly"
17)]
18
19use alloc::boxed::Box;
20
21use serde::{Deserialize, Serialize};
22
23use crate::save::SaveDevice;
24
25/// PIF RAM size (bytes). The last byte (`0x3F`) is the command bitmask.
26pub const PIF_RAM_LEN: usize = 64;
27/// The command byte offset within PIF RAM.
28const CMD_BYTE: usize = 0x3F;
29
30/// PIF **boot** ROM size (bytes) — IPL1 + IPL2.
31///
32/// Memory-mapped at `0x1FC0_0000..0x1FC0_07C0` during boot only (`PIF-NUS.md`
33/// §Internal ROMs). The 64-byte PIF RAM (`PIF_RAM_LEN`) is the tail of the same
34/// 2 KiB PIF space and is modeled separately. Used only on the real-PIF boot
35/// path; `None` under HLE.
36pub const PIF_ROM_LEN: usize = 0x7C0;
37
38/// Frame control bytes (`PIF-NUS.md` §Joybus frame).
39const TX_END: u8 = 0xFE; // end of the command list
40const TX_SKIP: u8 = 0x00; // no command on this channel — advance to the next
41const TX_NOP: u8 = 0xFF; // padding byte — skip without advancing a channel
42
43/// The RX "device not present" flag (`PIF-NUS.md` §RX byte special flags): the
44/// PIF sets bit 7 of the RX byte when a channel times out (no device).
45const RX_NO_DEVICE: u8 = 0x80;
46
47/// Standard N64 controller identifier (`0x00`/`0xFF` info reply, 2 bytes).
48const ID_CONTROLLER: [u8; 2] = [0x05, 0x00];
49/// EEPROM identifiers (info reply): 4k = `0x0080`, 16k = `0x00C0`.
50const ID_EEPROM_4K: [u8; 2] = [0x00, 0x80];
51const ID_EEPROM_16K: [u8; 2] = [0x00, 0xC0];
52
53/// The PIF: its 64-byte RAM plus the per-channel accessory-change latch.
54#[derive(Clone, Debug, Serialize, Deserialize)]
55pub struct Pif {
56 #[serde(with = "serde_big_array::BigArray")]
57 ram: [u8; PIF_RAM_LEN],
58 /// Whether each controller port reports a Controller Pak present.
59 pak_present: [bool; 4],
60 /// Whether a device is plugged into each joybus channel.
61 ///
62 /// An empty channel does **not** simply stay silent: the PIF sets the RX
63 /// byte's **"no device" flag (bit 7, mask `0x80`)** so software can tell an
64 /// empty port from a connected one (`n64brew_wiki/markdown/PIF-NUS.md`
65 /// §*RX byte: special flags* — *"This bit is set if the handshake failed
66 /// because no device appears to be connected to the joybus channel."*).
67 ///
68 /// Without it every port answers as a connected controller, and
69 /// `osContInit` reports **four** controllers on a console with one plugged
70 /// in — which is not a cosmetic difference, since games size and index
71 /// their controller tables from that count.
72 ///
73 /// Defaults to controller 1 present and 2-4 empty, the ordinary single-pad
74 /// setup. Channel 4 is the cartridge/EEPROM channel and is handled by the
75 /// save device, not here.
76 connected: [bool; 4],
77 /// The boot ROM (IPL1/IPL2), present only on the real-PIF boot path. `None`
78 /// under HLE (the default), where boot skips IPL1/IPL2 entirely — so the
79 /// default machine allocates nothing here and the PIF-ROM window reads back 0.
80 #[serde(with = "rustyn64_snapshot::opt_boxed_bytes")]
81 boot_rom: Option<Box<[u8; PIF_ROM_LEN]>>,
82 /// The CIC's 6-byte IPL2 checksum, registered at real-PIF boot. The PIF
83 /// compares IPL2's computed checksum against it on the verify command; `None`
84 /// under HLE (nothing runs IPL2). See [`Pif::boot_command`].
85 boot_checksum: Option<[u8; 6]>,
86 /// The checksum IPL2 wrote to PIF RAM `0x32-0x37`, latched by the `0x20`
87 /// (acquire) command and compared on the `0x40` (run) command.
88 boot_acquired: Option<[u8; 6]>,
89 /// Set once IPL2 issues the `0x10` ROM-lockout command; the PIF-ROM window
90 /// then reads back 0 (the real PIF removes it from the serial bus).
91 rom_locked: bool,
92}
93
94impl Default for Pif {
95 fn default() -> Self {
96 Self::new()
97 }
98}
99
100impl Pif {
101 /// Power-on PIF (RAM cleared, no pak, no boot ROM).
102 #[must_use]
103 pub const fn new() -> Self {
104 Self {
105 ram: [0; PIF_RAM_LEN],
106 pak_present: [false; 4],
107 // One controller in port 1, the ordinary console setup.
108 connected: [true, false, false, false],
109 boot_rom: None,
110 boot_checksum: None,
111 boot_acquired: None,
112 rom_locked: false,
113 }
114 }
115
116 /// Set which joybus controller ports (0-3) have a device plugged in.
117 ///
118 /// Defaults to port 1 only. A frontend offering 2-4 player support sets the
119 /// extra ports here; without it every additional port reports "no device"
120 /// and `osContInit` sees a single pad, which is correct for the default
121 /// machine but wrong for a multiplayer session.
122 ///
123 /// Channel 4 (the cartridge/EEPROM bus) is not a controller port and is not
124 /// addressed by this — its presence follows the installed save device.
125 pub const fn set_controllers_connected(&mut self, connected: [bool; 4]) {
126 self.connected = connected;
127 }
128
129 /// Which controller ports currently report a device.
130 #[must_use]
131 pub const fn controllers_connected(&self) -> [bool; 4] {
132 self.connected
133 }
134
135 /// Install the PIF boot ROM (IPL1/IPL2) for the real-PIF boot path. Accepts
136 /// the raw dump; only the first [`PIF_ROM_LEN`] bytes (the ROM window) are
137 /// kept — a longer dump that also carries the 64-byte RAM tail is truncated.
138 /// Too-short input leaves the ROM absent (the real-PIF boot then cannot run).
139 pub fn load_boot_rom(&mut self, bytes: &[u8]) {
140 if bytes.len() < PIF_ROM_LEN {
141 return;
142 }
143 let mut rom = Box::new([0u8; PIF_ROM_LEN]);
144 rom.copy_from_slice(&bytes[..PIF_ROM_LEN]);
145 self.boot_rom = Some(rom);
146 }
147
148 /// Is a real PIF boot ROM installed?
149 #[must_use]
150 pub const fn has_boot_rom(&self) -> bool {
151 self.boot_rom.is_some()
152 }
153
154 /// Read a byte of the PIF boot ROM (`0x1FC0_0000 + off`, `off < PIF_ROM_LEN`).
155 /// Reads back 0 when no ROM is installed (HLE), once IPL2 has locked the ROM
156 /// (`0x10` command), or `off` is out of range — the same value the unmapped
157 /// PIF-ROM window returned before this path existed.
158 #[must_use]
159 pub fn boot_rom_read(&self, off: usize) -> u8 {
160 if self.rom_locked {
161 return 0;
162 }
163 self.boot_rom
164 .as_ref()
165 .and_then(|rom| rom.get(off))
166 .copied()
167 .unwrap_or(0)
168 }
169
170 /// Warm-reset the transient real-PIF boot state so a reset can re-run
171 /// IPL1→IPL2 (`PIF-NUS.md` §Console Reset: the PIF **unlocks the PIF ROM** on
172 /// the reset NMI). Clears the ROM lockout and the latched checksum; keeps the
173 /// installed boot ROM and the registered CIC checksum — the cartridge is still
174 /// inserted, so the CIC would re-hand-off the same values.
175 pub const fn reset_boot(&mut self) {
176 self.rom_locked = false;
177 self.boot_acquired = None;
178 }
179
180 /// Register the CIC's 6-byte IPL2 checksum for the real-PIF boot verify.
181 pub const fn set_boot_checksum(&mut self, checksum: [u8; 6]) {
182 self.boot_checksum = Some(checksum);
183 }
184
185 /// **Process a reset-mode PIF command-byte write** during the real-PIF boot,
186 /// returning `true` if the checksum verify FAILED and the caller must NMI-halt
187 /// the CPU (`PIF-NUS.md` §Console startup 8.7). No-op (returns `false`) unless
188 /// a boot ROM is installed, so the run-mode joybus path is unaffected.
189 ///
190 /// The command bits (`PIF-NUS.md` reset-mode table, cross-checked against the
191 /// SM5 firmware and the IPL2 stores at `bfc006xx`):
192 /// - `0x10` **ROM lockout** — remove the PIF-ROM from the bus (reads 0 after).
193 /// - `0x20` **acquire checksum** — latch the 6 bytes IPL2 wrote to `0x32-0x37`,
194 /// zero them in PIF RAM, and set bit `0x80` to acknowledge (IPL2 spins on it).
195 /// - `0x40` **run checksum** — compare the latched value against the CIC's; a
196 /// mismatch halts the CPU, a match lets IPL2's unconditional jump reach IPL3.
197 ///
198 /// Each handled bit is cleared after processing; bit `0x80` is PIF-owned.
199 pub fn boot_command(&mut self) -> bool {
200 if self.boot_rom.is_none() {
201 return false;
202 }
203 let cmd = self.ram[CMD_BYTE];
204
205 if cmd & 0x10 != 0 {
206 self.rom_locked = true;
207 self.ram[CMD_BYTE] &= !0x10;
208 }
209
210 if cmd & 0x20 != 0 {
211 let mut got = [0u8; 6];
212 got.copy_from_slice(&self.ram[0x32..0x38]);
213 self.boot_acquired = Some(got);
214 self.ram[0x32..0x38].fill(0); // PIF zeroes the checksum after reading
215 self.ram[CMD_BYTE] = (self.ram[CMD_BYTE] & !0x20) | 0x80; // ack
216 }
217
218 if cmd & 0x40 != 0 {
219 self.ram[CMD_BYTE] &= !0x40;
220 // Mismatch only when both are known and differ; absent data never
221 // spuriously halts a genuine boot.
222 if let (Some(got), Some(expected)) = (self.boot_acquired, self.boot_checksum) {
223 return got != expected;
224 }
225 }
226
227 false
228 }
229
230 /// Report a Controller Pak on port `channel` (0–3) — the info status byte
231 /// then advertises it and `0x02`/`0x03` route to the cart save.
232 pub const fn set_pak_present(&mut self, channel: usize, present: bool) {
233 if channel < 4 {
234 self.pak_present[channel] = present;
235 }
236 }
237
238 /// The 64-byte PIF RAM (for the SI DMA to copy to/from RDRAM).
239 #[must_use]
240 pub const fn ram(&self) -> &[u8; PIF_RAM_LEN] {
241 &self.ram
242 }
243
244 /// A byte of PIF RAM (CPU direct read at `0x1FC0_07C0 + off`).
245 #[must_use]
246 pub fn read(&self, off: usize) -> u8 {
247 self.ram.get(off).copied().unwrap_or(0)
248 }
249
250 /// Write a byte of PIF RAM (CPU direct write / SI DMA).
251 pub const fn write(&mut self, off: usize, val: u8) {
252 if off < PIF_RAM_LEN {
253 self.ram[off] = val;
254 }
255 }
256
257 /// Replace the whole PIF RAM (an SI 64-byte DMA write from RDRAM).
258 pub fn load(&mut self, bytes: &[u8; PIF_RAM_LEN]) {
259 self.ram = *bytes;
260 }
261
262 /// **Execute the joybus frame** if the command byte requests it (bit 0),
263 /// filling each handshake's reply region. Called on an SI read (the point at
264 /// which the PIF runs the handshakes), then the caller DMAs PIF RAM out.
265 pub fn execute(&mut self, controllers: &[u32; 4], save: &mut SaveDevice) {
266 if self.ram[CMD_BYTE] & 0x01 == 0 {
267 return;
268 }
269 let mut i = 0;
270 let mut channel = 0usize;
271 while i < CMD_BYTE && channel <= 4 {
272 match self.ram[i] {
273 TX_END => break,
274 TX_NOP => {
275 i += 1;
276 }
277 TX_SKIP => {
278 channel += 1;
279 i += 1;
280 }
281 tx => {
282 let tx_len = (tx & 0x3F) as usize;
283 if i + 1 >= CMD_BYTE {
284 break;
285 }
286 let rx_len = (self.ram[i + 1] & 0x3F) as usize;
287 let cmd = i + 2;
288 let resp = cmd + tx_len;
289 if resp + rx_len > PIF_RAM_LEN {
290 break; // malformed frame — do not run past the RAM
291 }
292 // An empty controller channel does not answer: the PIF sets
293 // the RX byte's "no device" flag (bit 7) and leaves the reply
294 // region untouched (`PIF-NUS.md` §RX byte: special flags).
295 // Channel 4 is the cartridge/EEPROM channel, whose presence
296 // the save device decides, so it is never gated here.
297 if channel < 4 && !self.connected[channel] {
298 self.mark_no_device(i + 1);
299 } else {
300 self.run_channel(
301 channel,
302 i + 1,
303 cmd,
304 tx_len,
305 resp,
306 rx_len,
307 controllers,
308 save,
309 );
310 }
311 i = resp + rx_len;
312 channel += 1;
313 }
314 }
315 }
316 // Parsing/execution consumed the request.
317 self.ram[CMD_BYTE] &= !0x01;
318 }
319
320 /// Run one channel's handshake. `cmd`/`resp` are PIF-RAM byte offsets.
321 #[allow(
322 clippy::too_many_arguments,
323 reason = "a joybus handshake is inherently many small operands"
324 )]
325 fn run_channel(
326 &mut self,
327 channel: usize,
328 rx_byte: usize,
329 cmd: usize,
330 tx_len: usize,
331 resp: usize,
332 rx_len: usize,
333 controllers: &[u32; 4],
334 save: &mut SaveDevice,
335 ) {
336 if tx_len == 0 {
337 return;
338 }
339 let command = self.ram[cmd];
340 match (channel, command) {
341 // Controllers on channels 0–3.
342 (0..=3, 0x00 | 0xFF) => {
343 if rx_len >= 3 {
344 self.ram[resp] = ID_CONTROLLER[0];
345 self.ram[resp + 1] = ID_CONTROLLER[1];
346 // Status: 1 = pak present, 2 = pak absent.
347 self.ram[resp + 2] = if self.pak_present[channel] {
348 0x01
349 } else {
350 0x02
351 };
352 }
353 }
354 (0..=3, 0x01) => {
355 let state = controllers[channel].to_be_bytes();
356 let n = rx_len.min(4);
357 self.ram[resp..resp + n].copy_from_slice(&state[..n]);
358 }
359 // Controller-Pak accessory read/write on channels 0–3.
360 (0..=3, 0x02) if tx_len >= 3 => {
361 let addr = u16::from_be_bytes([self.ram[cmd + 1], self.ram[cmd + 2]]) & 0xFFE0;
362 let mut block = [0u8; 32];
363 if self.pak_present[channel] {
364 save.cpak_read(addr, &mut block);
365 }
366 let n = rx_len.min(32);
367 self.ram[resp..resp + n].copy_from_slice(&block[..n]);
368 if rx_len >= 33 {
369 // A present pak returns the CRC; an absent one complements it.
370 let crc = data_crc(&block);
371 self.ram[resp + 32] = if self.pak_present[channel] { crc } else { !crc };
372 }
373 }
374 (0..=3, 0x03) if tx_len >= 35 => {
375 let addr = u16::from_be_bytes([self.ram[cmd + 1], self.ram[cmd + 2]]) & 0xFFE0;
376 let mut block = [0u8; 32];
377 block.copy_from_slice(&self.ram[cmd + 3..cmd + 35]);
378 if self.pak_present[channel] {
379 save.cpak_write(addr, &block);
380 }
381 if rx_len >= 1 {
382 let crc = data_crc(&block);
383 self.ram[resp] = if self.pak_present[channel] { crc } else { !crc };
384 }
385 }
386 // Cartridge bus (channel 4): EEPROM.
387 (4, 0x00 | 0xFF) => {
388 if let SaveDevice::Eeprom(store) = save {
389 if rx_len >= 3 {
390 let id = if store.len() > 512 {
391 ID_EEPROM_16K
392 } else {
393 ID_EEPROM_4K
394 };
395 self.ram[resp] = id[0];
396 self.ram[resp + 1] = id[1];
397 self.ram[resp + 2] = 0x00;
398 }
399 } else {
400 self.mark_no_device(rx_byte);
401 }
402 }
403 (4, 0x04) if tx_len >= 2 && matches!(save, SaveDevice::Eeprom(_)) => {
404 let mut block = [0u8; 8];
405 save.eeprom_read_block(self.ram[cmd + 1], &mut block);
406 let n = rx_len.min(8);
407 self.ram[resp..resp + n].copy_from_slice(&block[..n]);
408 }
409 (4, 0x05) if tx_len >= 10 && matches!(save, SaveDevice::Eeprom(_)) => {
410 let mut data = [0u8; 8];
411 data.copy_from_slice(&self.ram[cmd + 2..cmd + 10]);
412 save.eeprom_write_block(self.ram[cmd + 1], &data);
413 if rx_len >= 1 {
414 self.ram[resp] = 0x00; // status: write OK
415 }
416 }
417 // No device / unsupported command on this channel.
418 _ => self.mark_no_device(rx_byte),
419 }
420 }
421
422 /// Flag a channel's reply as "no device" (RX byte bit 7 set).
423 ///
424 /// The RX byte is the **second** byte of the handshake, at `i + 1` in the
425 /// frame — *not* `resp - 1`. A block is laid out `TX RX tt[tx_len]
426 /// rr[rx_len]` with `resp = i + 2 + tx_len`, so `resp - 1` is the **last TX
427 /// data byte**. This previously wrote there, and since [`Pif::run_channel`]
428 /// returns early on `tx_len == 0` the two offsets could never coincide — so
429 /// the flag was never once set on the byte software reads, while a comment
430 /// asserted the opposite. Nothing failed, because nothing tested it.
431 fn mark_no_device(&mut self, rx_byte: usize) {
432 if rx_byte < PIF_RAM_LEN {
433 self.ram[rx_byte] |= RX_NO_DEVICE;
434 }
435 }
436}
437
438/// The joybus accessory data CRC8 (seed 0x00, polynomial 0x85 —
439/// `Joybus protocol.md` §Data CRC).
440#[must_use]
441pub fn data_crc(data: &[u8; 32]) -> u8 {
442 let mut crc = 0u8;
443 for &byte in data {
444 crc ^= byte;
445 for _ in 0..8 {
446 let carry = crc & 0x80 != 0;
447 crc <<= 1;
448 if carry {
449 crc ^= 0x85;
450 }
451 }
452 }
453 crc
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::SaveType;
460
461 /// Build a single-channel frame in PIF RAM and set the run bit.
462 fn frame(pif: &mut Pif, channel_skips: usize, tx: &[u8], rx_len: u8) -> usize {
463 let mut i = 0;
464 for _ in 0..channel_skips {
465 pif.ram[i] = TX_SKIP;
466 i += 1;
467 }
468 pif.ram[i] = tx.len() as u8;
469 pif.ram[i + 1] = rx_len;
470 pif.ram[i + 2..i + 2 + tx.len()].copy_from_slice(tx);
471 let resp = i + 2 + tx.len();
472 pif.ram[CMD_BYTE] = 0x01;
473 resp
474 }
475
476 /// **An empty controller port reports "no device" — on the RX byte.**
477 ///
478 /// Two separate defects meet here, so both halves are asserted:
479 ///
480 /// 1. Empty ports used to answer as a connected controller, so `osContInit`
481 /// saw **four** pads on a one-pad console.
482 /// 2. [`Pif::mark_no_device`] wrote the flag to `resp - 1`, which for any
483 /// `tx_len >= 1` (and `run_channel` returns early on 0, so always) is the
484 /// **last TX data byte** — never the RX byte software reads.
485 ///
486 /// The offset assertion is the load-bearing one: writing `0x80` *somewhere*
487 /// satisfies a test that only checks "the reply is not a controller".
488 #[test]
489 fn an_empty_port_flags_no_device_on_the_rx_byte() {
490 const SKIPS: usize = 1;
491 const TX: [u8; 1] = [0x00];
492
493 let mut pif = Pif::new();
494 let mut save = SaveDevice::new(SaveType::None);
495 // Channel 0 is populated by default; channel 1 is not.
496 let resp = frame(&mut pif, SKIPS, &TX, 3);
497 // Frame layout is `TX RX tt[tx_len] rr[rx_len]` starting at `SKIPS`, so
498 // the RX byte is the second byte of the block. Derived from the layout
499 // rather than by counting backwards from `resp`.
500 let rx_byte = SKIPS + 1;
501 let tx_data_byte = SKIPS + 2 + TX.len() - 1;
502 let before = pif.ram[tx_data_byte];
503 pif.execute(&[0; 4], &mut save);
504
505 assert_ne!(
506 pif.ram[rx_byte] & RX_NO_DEVICE,
507 0,
508 "the RX byte at {rx_byte} must carry the no-device flag"
509 );
510 assert_eq!(
511 pif.ram[tx_data_byte], before,
512 "the last TX data byte must be untouched -- writing the flag there \
513 is the offset bug this pins"
514 );
515 assert_eq!(
516 pif.ram[resp], 0,
517 "an absent device writes no reply, so the identifier stays zero"
518 );
519 }
520
521 /// **[`Pif::mark_no_device`] writes the RX byte, not the last TX byte.**
522 ///
523 /// Reached through a *connected* channel given an **unsupported command**.
524 /// That is not the only route — a missing EEPROM on channel 4 also reaches
525 /// `mark_no_device` — but it is the one this test drives, and before the
526 /// empty-port guard was refactored to delegate, it was the only route any
527 /// test could take: the guard short-circuits before `run_channel`. The first
528 /// version of this pair missed exactly that, and a mutation reverting the
529 /// offset to `resp - 1` **passed**, because no test took this path at all.
530 ///
531 /// A two-byte TX is used deliberately so `resp - 1` and the RX byte are two
532 /// apart; with a one-byte TX they are adjacent and an off-by-one is
533 /// indistinguishable from a correct write.
534 #[test]
535 fn mark_no_device_flags_the_rx_byte_not_the_tx_payload() {
536 const SKIPS: usize = 0;
537 const TX: [u8; 2] = [0x99, 0x5A];
538
539 let mut pif = Pif::new();
540 let mut save = SaveDevice::new(SaveType::None);
541 // Channel 0 IS connected; command 0x99 is not one we model.
542 let resp = frame(&mut pif, SKIPS, &TX, 3);
543 let rx_byte = SKIPS + 1;
544 let last_tx = SKIPS + 2 + TX.len() - 1;
545 assert_eq!(resp, SKIPS + 2 + TX.len(), "frame layout as documented");
546 assert_eq!(pif.ram[last_tx], 0x5A, "the TX payload starts intact");
547
548 pif.execute(&[0; 4], &mut save);
549
550 assert_ne!(
551 pif.ram[rx_byte] & RX_NO_DEVICE,
552 0,
553 "the no-device flag belongs on the RX byte at {rx_byte}"
554 );
555 assert_eq!(
556 pif.ram[last_tx], 0x5A,
557 "the last TX data byte must be untouched -- `resp - 1` points HERE, \
558 which is where the flag used to land"
559 );
560 }
561
562 /// **The configured port mask actually reaches dispatch.**
563 ///
564 /// Without this, [`Pif::set_controllers_connected`] could be a no-op and
565 /// every existing test would still pass — they all run on the default mask.
566 /// Both directions are asserted from one setter call, so a setter that
567 /// wrote a constant could not satisfy it.
568 #[test]
569 fn the_configured_port_mask_reaches_the_joybus_dispatch() {
570 const TX: [u8; 1] = [0x00];
571
572 let mut pif = Pif::new();
573 let mut save = SaveDevice::new(SaveType::None);
574 // Invert the default: port 0 empty, port 1 populated.
575 pif.set_controllers_connected([false, true, false, false]);
576 assert_eq!(pif.controllers_connected(), [false, true, false, false]);
577
578 // Channel 0 — now empty, so flagged and silent.
579 let resp0 = frame(&mut pif, 0, &TX, 3);
580 pif.execute(&[0; 4], &mut save);
581 assert_ne!(
582 pif.ram[1] & RX_NO_DEVICE,
583 0,
584 "port 0 was configured empty and must be flagged"
585 );
586 assert_eq!(pif.ram[resp0], 0, "an empty port writes no identifier");
587
588 // Channel 1 — now populated, so it answers and is not flagged.
589 let mut pif = Pif::new();
590 pif.set_controllers_connected([false, true, false, false]);
591 let resp1 = frame(&mut pif, 1, &TX, 3);
592 pif.execute(&[0; 4], &mut save);
593 assert_eq!(
594 pif.ram[2] & RX_NO_DEVICE,
595 0,
596 "port 1 was configured present and must NOT be flagged"
597 );
598 assert_eq!(
599 [pif.ram[resp1], pif.ram[resp1 + 1]],
600 ID_CONTROLLER,
601 "a configured-present port replies with the controller identifier"
602 );
603 }
604
605 /// The populated port still answers normally — the guard must not silence
606 /// every channel, which is the obvious way to make the test above pass.
607 #[test]
608 fn a_populated_port_still_answers_and_is_not_flagged() {
609 const SKIPS: usize = 0;
610
611 let mut pif = Pif::new();
612 let mut save = SaveDevice::new(SaveType::None);
613 let resp = frame(&mut pif, SKIPS, &[0x00], 3);
614 let rx_byte = SKIPS + 1;
615 pif.execute(&[0; 4], &mut save);
616
617 assert_eq!(
618 pif.ram[rx_byte] & RX_NO_DEVICE,
619 0,
620 "a connected port must NOT be flagged"
621 );
622 assert_eq!(
623 [pif.ram[resp], pif.ram[resp + 1]],
624 ID_CONTROLLER,
625 "a connected port replies with the controller identifier"
626 );
627 }
628
629 #[test]
630 fn controller_state_is_returned_from_the_port_word() {
631 let mut pif = Pif::new();
632 let mut save = SaveDevice::None;
633 let resp = frame(&mut pif, 0, &[0x01], 4);
634 let controllers = [0x8000_1234, 0, 0, 0]; // A pressed, stick (0x12, 0x34)
635 pif.execute(&controllers, &mut save);
636 assert_eq!(&pif.ram[resp..resp + 4], &[0x80, 0x00, 0x12, 0x34]);
637 assert_eq!(pif.ram[CMD_BYTE] & 1, 0, "the run bit is cleared");
638 }
639
640 #[test]
641 fn info_reports_a_controller_and_pak_status() {
642 let mut pif = Pif::new();
643 pif.set_pak_present(0, true);
644 let mut save = SaveDevice::None;
645 let resp = frame(&mut pif, 0, &[0x00], 3);
646 pif.execute(&[0; 4], &mut save);
647 assert_eq!(
648 &pif.ram[resp..resp + 3],
649 &[0x05, 0x00, 0x01],
650 "controller + pak present"
651 );
652 }
653
654 #[test]
655 fn eeprom_write_then_read_round_trips_over_joybus() {
656 let mut save = SaveDevice::new(SaveType::Eeprom4k);
657 // Write block 2 = [1..8] on channel 4 (0x05 cmd, block, 8 data, rx 1).
658 let mut pif = Pif::new();
659 let tx = [0x05, 0x02, 1, 2, 3, 4, 5, 6, 7, 8];
660 frame(&mut pif, 4, &tx, 1);
661 pif.execute(&[0; 4], &mut save);
662 // Read it back (0x04 cmd, block, rx 8).
663 let mut pif = Pif::new();
664 let resp = frame(&mut pif, 4, &[0x04, 0x02], 8);
665 pif.execute(&[0; 4], &mut save);
666 assert_eq!(&pif.ram[resp..resp + 8], &[1, 2, 3, 4, 5, 6, 7, 8]);
667 }
668
669 #[test]
670 fn controller_pak_write_then_read_round_trips_with_crc() {
671 let mut save = SaveDevice::new(SaveType::ControllerPak);
672 let mut pif = Pif::new();
673 pif.set_pak_present(0, true);
674 // Write 32 bytes at address 0x0100 (aligned).
675 let mut tx = [0u8; 35];
676 tx[0] = 0x03;
677 tx[1] = 0x01;
678 tx[2] = 0x00;
679 for (k, b) in tx[3..].iter_mut().enumerate() {
680 *b = k as u8;
681 }
682 frame(&mut pif, 0, &tx, 1);
683 pif.execute(&[0; 4], &mut save);
684 // Read it back with the data CRC appended.
685 let mut pif = Pif::new();
686 pif.set_pak_present(0, true);
687 let resp = frame(&mut pif, 0, &[0x02, 0x01, 0x00], 33);
688 pif.execute(&[0; 4], &mut save);
689 let mut expected = [0u8; 32];
690 for (k, b) in expected.iter_mut().enumerate() {
691 *b = k as u8;
692 }
693 assert_eq!(&pif.ram[resp..resp + 32], &expected);
694 assert_eq!(
695 pif.ram[resp + 32],
696 data_crc(&expected),
697 "present pak returns the CRC"
698 );
699 }
700
701 #[test]
702 fn boot_rom_is_absent_by_default_and_reads_zero() {
703 let pif = Pif::new();
704 assert!(!pif.has_boot_rom(), "HLE default installs no boot ROM");
705 assert_eq!(pif.boot_rom_read(0), 0, "absent ROM reads 0");
706 assert_eq!(pif.boot_rom_read(PIF_ROM_LEN - 1), 0);
707 }
708
709 #[test]
710 fn boot_rom_loads_and_reads_back_the_window() {
711 let mut pif = Pif::new();
712 // A dump longer than the ROM window (carrying the 64-byte RAM tail) is
713 // truncated to the window; a byte pattern makes the mapping observable.
714 let mut dump = alloc::vec![0u8; PIF_ROM_LEN + PIF_RAM_LEN];
715 for (i, b) in dump.iter_mut().enumerate() {
716 *b = (i & 0xFF) as u8;
717 }
718 pif.load_boot_rom(&dump);
719 assert!(pif.has_boot_rom());
720 assert_eq!(pif.boot_rom_read(0), 0x00);
721 assert_eq!(pif.boot_rom_read(1), 0x01);
722 assert_eq!(pif.boot_rom_read(0x100), 0x00);
723 assert_eq!(
724 pif.boot_rom_read(PIF_ROM_LEN - 1),
725 ((PIF_ROM_LEN - 1) & 0xFF) as u8
726 );
727 // Out of range still reads 0, not the truncated tail.
728 assert_eq!(pif.boot_rom_read(PIF_ROM_LEN), 0);
729 }
730
731 #[test]
732 fn too_short_a_dump_leaves_the_boot_rom_absent() {
733 let mut pif = Pif::new();
734 pif.load_boot_rom(&[0xAB; 16]);
735 assert!(
736 !pif.has_boot_rom(),
737 "a short dump must not install a partial ROM"
738 );
739 }
740
741 /// The CIC 6102 IPL2 checksum (`PIF-NUS.md` §checksum table).
742 const SUM_6102: [u8; 6] = [0xA5, 0x36, 0xC0, 0xF1, 0xD8, 0x59];
743
744 /// Put the PIF in real-PIF boot mode with a registered CIC checksum.
745 fn booting_pif(checksum: [u8; 6]) -> Pif {
746 let mut pif = Pif::new();
747 pif.load_boot_rom(&[0u8; PIF_ROM_LEN]); // presence enables the boot path
748 pif.set_boot_checksum(checksum);
749 pif
750 }
751
752 #[test]
753 fn boot_acquire_latches_zeroes_and_acks_the_checksum() {
754 let mut pif = booting_pif(SUM_6102);
755 // IPL2 writes its computed checksum to 0x32-0x37, then sets 0x20.
756 pif.ram[0x32..0x38].copy_from_slice(&SUM_6102);
757 pif.ram[CMD_BYTE] = 0x20;
758 assert!(!pif.boot_command(), "acquire never halts");
759 assert_eq!(&pif.ram[0x32..0x38], &[0u8; 6], "PIF zeroes the checksum");
760 assert_eq!(
761 pif.ram[CMD_BYTE] & 0x80,
762 0x80,
763 "ack bit set (IPL2 spins on it)"
764 );
765 assert_eq!(pif.ram[CMD_BYTE] & 0x20, 0, "acquire bit cleared");
766 }
767
768 #[test]
769 fn boot_run_boots_on_a_matching_checksum() {
770 let mut pif = booting_pif(SUM_6102);
771 pif.ram[0x32..0x38].copy_from_slice(&SUM_6102);
772 pif.ram[CMD_BYTE] = 0x20;
773 assert!(!pif.boot_command());
774 pif.ram[CMD_BYTE] |= 0x40;
775 assert!(
776 !pif.boot_command(),
777 "a matching checksum lets IPL2 reach IPL3"
778 );
779 assert_eq!(pif.ram[CMD_BYTE] & 0x40, 0, "run bit cleared");
780 }
781
782 #[test]
783 fn boot_run_halts_on_a_wrong_checksum() {
784 // Mutation check: a checksum that differs from the CIC's must NMI-halt —
785 // if this passed with a matching one the guard would be vacuous.
786 let mut pif = booting_pif(SUM_6102);
787 pif.ram[0x32..0x38].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]);
788 pif.ram[CMD_BYTE] = 0x20;
789 assert!(!pif.boot_command());
790 pif.ram[CMD_BYTE] |= 0x40;
791 assert!(
792 pif.boot_command(),
793 "a wrong checksum freezes the CPU via NMI"
794 );
795 }
796
797 #[test]
798 fn boot_command_is_inert_without_a_boot_rom() {
799 // Under HLE (no boot ROM) the reset-mode path must not touch the command
800 // byte, so the run-mode joybus protocol is unaffected.
801 let mut pif = Pif::new();
802 pif.set_boot_checksum(SUM_6102);
803 pif.ram[CMD_BYTE] = 0x20 | 0x40;
804 assert!(!pif.boot_command(), "no boot ROM => inert");
805 assert_eq!(
806 pif.ram[CMD_BYTE],
807 0x20 | 0x40,
808 "command byte untouched under HLE"
809 );
810 }
811
812 #[test]
813 fn rom_lockout_makes_the_boot_rom_read_zero() {
814 let mut pif = Pif::new();
815 let mut dump = alloc::vec![0u8; PIF_ROM_LEN];
816 dump[0] = 0x3C; // a non-zero byte 0 so the change is observable
817 pif.load_boot_rom(&dump);
818 pif.set_boot_checksum(SUM_6102);
819 assert_eq!(pif.boot_rom_read(0), 0x3C, "readable before lockout");
820 pif.ram[CMD_BYTE] = 0x10; // IPL2's ROM-lockout command
821 assert!(!pif.boot_command());
822 assert_eq!(pif.ram[CMD_BYTE] & 0x10, 0, "lockout bit cleared");
823 assert_eq!(
824 pif.boot_rom_read(0),
825 0,
826 "the PIF ROM is off the bus after lockout"
827 );
828 }
829
830 #[test]
831 fn a_warm_reset_unlocks_the_rom_so_ipl1_can_re_run() {
832 let mut pif = Pif::new();
833 let mut dump = alloc::vec![0u8; PIF_ROM_LEN];
834 dump[0] = 0x3C;
835 pif.load_boot_rom(&dump);
836 pif.set_boot_checksum(SUM_6102);
837 // Boot once: lock the ROM and latch a checksum.
838 pif.ram[0x32..0x38].copy_from_slice(&SUM_6102);
839 pif.ram[CMD_BYTE] = 0x10 | 0x20;
840 assert!(!pif.boot_command());
841 assert_eq!(pif.boot_rom_read(0), 0, "ROM locked after the first boot");
842 // Warm reset: the PIF unlocks the ROM and drops the latch, so IPL1 reads
843 // again — but keeps the installed ROM and the registered CIC checksum.
844 pif.reset_boot();
845 assert_eq!(pif.boot_rom_read(0), 0x3C, "ROM readable again after reset");
846 assert!(
847 pif.has_boot_rom(),
848 "the installed boot ROM survives a warm reset"
849 );
850 // The latch is gone, so a fresh acquire compares the newly-written value.
851 pif.ram[0x32..0x38].copy_from_slice(&SUM_6102);
852 pif.ram[CMD_BYTE] = 0x20;
853 assert!(!pif.boot_command());
854 pif.ram[CMD_BYTE] |= 0x40;
855 assert!(
856 !pif.boot_command(),
857 "the retained CIC checksum still matches"
858 );
859 }
860}