Skip to main content

rustysnes_cart/coproc/armv3/
board.rs

1//! The ST018 board — the ARMv3 (ARM6) coprocessor wired into a cartridge.
2//!
3//! The one confirmed commercial cart is *Hayazashi Nidan Morita Shogi 2* (SETA, 1995,
4//! Japan-only; internal title `NIDAN MORITASHOGI2`) — a LoROM cart (512 KiB ROM + 8 KiB
5//! battery-backed SRAM) using the chip's ARM core to strengthen its shogi AI. Confirmed against
6//! two independent sources: Mesen2's own header detection (`BaseCartridge::GetCoprocessorType`,
7//! `RomType` high nibble `$F` + `CartridgeType == 0x02`) and ares' heuristic detector
8//! (`mia/medium/super-famicom.cpp`, the identical `cartridgeTypeHi==0xf && cartridgeSubType==2`
9//! signature at the extended-header byte `$xFBF`) — see `docs/st018-arm-notes.md` for the full
10//! research trail, including the earlier (wrong) assumption that this chip was Star Ocean's
11//! (Star Ocean uses S-DD1 only; no ARM coprocessor). This project's own [`crate::header`]
12//! parser doesn't read `$xFBF` for the OTHER `$F`-nibble customs (CX4/SPC7110/S-RTC all resolve
13//! by title match instead, after an earlier investigation found that byte unreliable for THOSE
14//! chips against a real Mega Man X2 dump) — [`crate::header`]'s `coprocessor_from_chipset`
15//! mirrors that same title-match convention here rather than introducing a new header field this
16//! codebase has otherwise deliberately chosen not to trust.
17//!
18//! Board/bus protocol ported from Mesen2's `St018` (`Core/SNES/Coprocessors/ST018/St018.cpp`),
19//! architecturally an SA-1-style deterministic master-clock catch-up (`Run()`, called before
20//! every register access and at end-of-frame in the reference; here driven every single master
21//! tick by [`Board::coprocessor_tick`] instead — strictly more granular, functionally
22//! equivalent, and avoids needing any `rustysnes-core`-side plumbing since — unlike SA-1's
23//! second 65C816 — this ARM core is entirely self-contained within `rustysnes-cart` already).
24//! SNES-side register window `$00-3F,$80-BF:$3000-$3FFF` (the whole block, not just
25//! `$3800-38FF` as `boards.bml` implies at a glance): `$3800` (read: pull one byte the ARM
26//! placed for the SNES), `$3802` (write: push one byte to the ARM; read: clear the ack flag),
27//! `$3804` (status on read; a `1->0` write transition resets the ARM, preserving its own cycle
28//! counter). ARM-side 32-bit address space (top nibble selects region): `0x0` = 128 KiB PRG ROM,
29//! `0x4` = the same handshake registers mirrored in, `0xA` = 32 KiB data ROM, `0xE` = 16 KiB
30//! work RAM. A firmware dump is a single combined `0x28000`-byte file (PRG then data,
31//! `docs/st018-arm-notes.md`) — never bundled, user-supplied only (`docs/adr/0003`).
32
33// Chip-name jargon (ST018, ARMv3, ...) is not Rust code. The handshake state is a fixed set of
34// independent hardware flags (Mesen2 `St018State`), not a state-machine candidate for an enum.
35#![allow(clippy::doc_markdown, clippy::struct_excessive_bools)]
36
37use alloc::boxed::Box;
38use alloc::vec;
39
40use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
41
42use crate::board::{Board, Coprocessor, MappedAddr};
43use crate::coproc::armv3::{ArmBus, Cpu};
44
45/// ARM-side PRG ROM size (Mesen2 `St018::PrgRomSize`).
46const PRG_ROM_SIZE: usize = 0x2_0000;
47/// ARM-side data ROM size (Mesen2 `St018::DataRomSize`).
48const DATA_ROM_SIZE: usize = 0x8000;
49/// ARM-side work RAM size (Mesen2 `St018::WorkRamSize`).
50const WORK_RAM_SIZE: usize = 0x4000;
51/// The single combined firmware dump this board accepts (PRG ROM immediately followed by data
52/// ROM — Mesen2 `FirmwareHelper::LoadSt018Firmware` splits the same way).
53const FIRMWARE_SIZE: usize = PRG_ROM_SIZE + DATA_ROM_SIZE;
54
55/// Bridges [`Cpu::step`]'s [`ArmBus`] calls to the board's ROM/RAM/handshake state, charging one
56/// cycle per byte lane touched (Mesen2 `ProcessIdleCycle`/the implicit per-byte-lane cost inside
57/// `Read`/`Write` — `docs/st018-arm-notes.md`'s board-bus-protocol section). A short-lived
58/// borrow, not stored: [`St018Board::coprocessor_tick`] takes `self.cpu` out via
59/// `core::mem::take` before constructing this (mirroring `rustysnes-core`'s SA-1 catch-up's
60/// `Option::take`), so the adapter can freely borrow every OTHER field of the board.
61struct St018Bus<'a> {
62    board: &'a mut St018Board,
63}
64
65impl ArmBus for St018Bus<'_> {
66    fn read_code(&mut self, addr: u32) -> u32 {
67        self.board.arm_cycle_count += 4;
68        self.board.read_arm_word(addr)
69    }
70
71    fn read(&mut self, addr: u32, byte: bool) -> u32 {
72        if byte {
73            self.board.arm_cycle_count += 1;
74            u32::from(self.board.read_arm_byte(addr))
75        } else {
76            self.board.arm_cycle_count += 4;
77            self.board.read_arm_word(addr)
78        }
79    }
80
81    fn write(&mut self, addr: u32, value: u32, byte: bool) {
82        if byte {
83            self.board.arm_cycle_count += 1;
84            #[allow(clippy::cast_possible_truncation)]
85            self.board.write_arm_byte(addr, value as u8);
86        } else {
87            self.board.arm_cycle_count += 4;
88            self.board.write_arm_word(addr, value);
89        }
90    }
91
92    fn idle(&mut self) {
93        self.board.arm_cycle_count += 1;
94    }
95}
96
97/// Classify a 24-bit CPU address into the SNES-side handshake window (Mesen2 `St018::Read`/
98/// `Write`'s `addr & 0xFF06` dispatch over the registered `$3000-$3FFF` block).
99#[allow(clippy::cast_possible_truncation)] // `addr & 0xFF06` is always <= 0xFFFF.
100fn classify(addr24: u32) -> Option<u16> {
101    let bank = (addr24 >> 16) & 0xFF;
102    let addr = addr24 & 0xFFFF;
103    (matches!(bank, 0x00..=0x3F | 0x80..=0xBF) && (0x3000..=0x3FFF).contains(&addr))
104        .then_some((addr & 0xFF06) as u16)
105}
106
107/// A cartridge carrying an ST018 (Hayazashi Nidan Morita Shogi 2's ARMv3 coprocessor).
108pub struct St018Board {
109    inner: Box<dyn Board>,
110    cpu: Cpu,
111
112    prg_rom: Box<[u8]>,
113    data_rom: Box<[u8]>,
114    work_ram: Box<[u8]>,
115    firmware_loaded: bool,
116
117    // SNES<->ARM handshake (Mesen2 `St018State`).
118    has_data_for_snes: bool,
119    data_snes: u8,
120    ack: bool,
121    has_data_for_arm: bool,
122    data_arm: u8,
123    arm_reset: bool,
124
125    /// The ARM's own catch-up target, incremented by one on every [`Board::coprocessor_tick`]
126    /// call — mathematically equivalent to Mesen2's `_memoryManager->GetMasterClock()` (both
127    /// start at 0 and advance 1:1 with the master clock), just accumulated locally instead of
128    /// read from a shared clock each time.
129    target_cycle: u64,
130    /// The ARM's own elapsed-cycle counter (Mesen2 `ArmV3CpuState::CycleCount`), advanced by
131    /// [`St018Bus`] on every bus access/idle cycle.
132    arm_cycle_count: u64,
133}
134
135impl core::fmt::Debug for St018Board {
136    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
137        f.debug_struct("St018Board")
138            .field("inner", &self.inner.name())
139            .field("cpu", &self.cpu)
140            .field("firmware_loaded", &self.firmware_loaded)
141            .field("arm_reset", &self.arm_reset)
142            .finish_non_exhaustive()
143    }
144}
145
146impl St018Board {
147    /// Wrap a base board (`inner`, the cart's own LoROM ROM/SRAM decode) with an ST018. Inert
148    /// (the ARM never steps) until [`Board::load_firmware`] supplies the combined chip dump
149    /// (`docs/adr/0003`).
150    #[must_use]
151    pub fn new(inner: Box<dyn Board>) -> Self {
152        let mut board = Self {
153            inner,
154            cpu: Cpu::default(),
155            prg_rom: vec![0u8; PRG_ROM_SIZE].into_boxed_slice(),
156            data_rom: vec![0u8; DATA_ROM_SIZE].into_boxed_slice(),
157            work_ram: vec![0u8; WORK_RAM_SIZE].into_boxed_slice(),
158            firmware_loaded: false,
159            has_data_for_snes: false,
160            data_snes: 0,
161            ack: false,
162            has_data_for_arm: false,
163            data_arm: 0,
164            arm_reset: false,
165            target_cycle: 0,
166            arm_cycle_count: 0,
167        };
168        board.power_on_arm();
169        board
170    }
171
172    /// Power on the ARM core, re-priming its pipeline (Mesen2 `PowerOn(forReset=false)`, used at
173    /// construction and whenever firmware is (re)loaded). The priming reads' own bus-cycle cost
174    /// is charged to `arm_cycle_count` like any other access — there is no prior state to
175    /// preserve at these call sites (see [`Self::reset_arm`] for the one call site that does).
176    fn power_on_arm(&mut self) {
177        let mut cpu = core::mem::take(&mut self.cpu);
178        cpu.power_on(&mut St018Bus { board: self });
179        self.cpu = cpu;
180    }
181
182    /// A true ARM reset (Mesen2 `PowerOn(forReset=true)`, the SNES-side `$3804` 1->0 edge):
183    /// re-primes the pipeline exactly like [`Self::power_on_arm`], but saves `arm_cycle_count`
184    /// first and restores it afterward, discarding the priming reads' own cost — the reference
185    /// does the identical save-before/restore-after around its own `ProcessPipeline()` call.
186    fn reset_arm(&mut self) {
187        let saved = self.arm_cycle_count;
188        self.power_on_arm();
189        self.arm_cycle_count = saved;
190    }
191
192    /// `(HasDataForSnes<<0)|(Ack<<2)|(HasDataForArm<<3)|(!ArmReset<<7)` (Mesen2 `GetStatus`).
193    const fn status(&self) -> u8 {
194        (self.has_data_for_snes as u8)
195            | ((self.ack as u8) << 2)
196            | ((self.has_data_for_arm as u8) << 3)
197            | ((!self.arm_reset as u8) << 7)
198    }
199
200    /// SNES-side register read (Mesen2 `St018::Read`) — the ARM is already caught up by the time
201    /// this runs (every master tick already ran [`Board::coprocessor_tick`] first), so unlike
202    /// the reference there is no explicit `Run()` call needed here.
203    ///
204    /// Not marked `const fn`: `St018Board` carries a heap-allocated `inner: Box<dyn Board>`
205    /// field, so `const`-ness here is cosmetic and fragile against future field changes — the
206    /// same rationale `coproc::sharprtc`'s own dense register methods already document.
207    #[allow(clippy::missing_const_for_fn)]
208    fn read_register(&mut self, window: u16) -> u8 {
209        match window {
210            0x3800 => {
211                self.has_data_for_snes = false;
212                self.data_snes
213            }
214            0x3802 => {
215                self.ack = false;
216                0 // falls through to open bus in the reference; this port has no open-bus latch.
217            }
218            0x3804 => self.status(),
219            _ => 0,
220        }
221    }
222
223    /// SNES-side register write (Mesen2 `St018::Write`).
224    fn write_register(&mut self, window: u16, val: u8) {
225        match window {
226            0x3802 => {
227                self.data_arm = val;
228                self.has_data_for_arm = true;
229            }
230            0x3804 => {
231                let new_reset = val != 0;
232                if self.arm_reset && !new_reset {
233                    self.reset_arm();
234                }
235                self.arm_reset = new_reset;
236            }
237            _ => {}
238        }
239    }
240
241    /// ARM-side byte read (Mesen2 `St018::ReadCpuByte`) — the top nibble of the 32-bit ARM
242    /// address selects the region; anything unmapped reads 0 (matches the source's own
243    /// `default: return 0`).
244    fn read_arm_byte(&mut self, addr: u32) -> u8 {
245        match addr >> 28 {
246            0x0 => self.prg_rom[(addr & 0x1_FFFF) as usize],
247            0x4 => match addr & 0x3F {
248                0x10 => {
249                    self.has_data_for_arm = false;
250                    self.data_arm
251                }
252                0x20 => self.status(),
253                _ => 0,
254            },
255            0xA => self.data_rom[(addr & 0x7FFF) as usize],
256            0xE => self.work_ram[(addr & 0x3FFF) as usize],
257            _ => 0,
258        }
259    }
260
261    /// ARM-side byte write (Mesen2 `St018::WriteCpuByte`). PRG/data ROM are read-only from the
262    /// ARM side. The `$04:...20/24/28/2A` cases are unresolved even in the reference
263    /// implementation (commented `//??` there) — ported as no-ops rather than inventing
264    /// behavior, per `docs/st018-arm-notes.md`.
265    fn write_arm_byte(&mut self, addr: u32, val: u8) {
266        match addr >> 28 {
267            0x4 => match addr & 0x3F {
268                0x00 => {
269                    self.has_data_for_snes = true;
270                    self.data_snes = val;
271                }
272                0x10 => self.ack = true,
273                _ => {}
274            },
275            0xE => self.work_ram[(addr & 0x3FFF) as usize] = val,
276            _ => {}
277        }
278    }
279
280    /// Word access is 4 byte-lane accesses at `addr&!3 | {0,1,2,3}`, packed/unpacked
281    /// little-endian (Mesen2 `ReadCpu`, which just calls the byte version 4x) — no
282    /// misalignment-rotation is applied, confirmed against the real board implementation.
283    fn read_arm_word(&mut self, addr: u32) -> u32 {
284        let base = addr & !3;
285        u32::from(self.read_arm_byte(base))
286            | (u32::from(self.read_arm_byte(base | 1)) << 8)
287            | (u32::from(self.read_arm_byte(base | 2)) << 16)
288            | (u32::from(self.read_arm_byte(base | 3)) << 24)
289    }
290
291    /// The write-side mirror of [`Self::read_arm_word`] (Mesen2 `WriteCpu`).
292    #[allow(clippy::cast_possible_truncation)]
293    fn write_arm_word(&mut self, addr: u32, val: u32) {
294        let base = addr & !3;
295        self.write_arm_byte(base, val as u8);
296        self.write_arm_byte(base | 1, (val >> 8) as u8);
297        self.write_arm_byte(base | 2, (val >> 16) as u8);
298        self.write_arm_byte(base | 3, (val >> 24) as u8);
299    }
300}
301
302impl Board for St018Board {
303    fn name(&self) -> &'static str {
304        "LoROM+ST018"
305    }
306
307    fn coprocessor(&self) -> Coprocessor {
308        Coprocessor::St018
309    }
310
311    fn map(&self, addr24: u32) -> MappedAddr {
312        if classify(addr24).is_some() {
313            MappedAddr::Coprocessor
314        } else {
315            self.inner.map(addr24)
316        }
317    }
318
319    fn read24(&mut self, addr24: u32) -> u8 {
320        match classify(addr24) {
321            Some(w) => self.read_register(w),
322            None => self.inner.read24(addr24),
323        }
324    }
325
326    fn write24(&mut self, addr24: u32, val: u8) {
327        if let Some(w) = classify(addr24) {
328            self.write_register(w, val);
329        } else {
330            self.inner.write24(addr24, val);
331        }
332    }
333
334    fn rom(&self) -> &[u8] {
335        self.inner.rom()
336    }
337
338    fn sram(&self) -> &[u8] {
339        self.inner.sram()
340    }
341
342    fn sram_mut(&mut self) -> &mut [u8] {
343        self.inner.sram_mut()
344    }
345
346    /// Step the ARM core to catch up with the master clock by exactly one tick (Mesen2 `Run()`,
347    /// see the module doc for why this is driven every tick instead of lazily on access/EOF).
348    /// Inert (no-op) until firmware is loaded — the chip stays absent, never silently faked.
349    fn coprocessor_tick(&mut self) {
350        if !self.firmware_loaded {
351            return;
352        }
353        self.target_cycle += 1;
354        if self.arm_reset {
355            self.arm_cycle_count = self.target_cycle;
356            return;
357        }
358        let mut cpu = core::mem::take(&mut self.cpu);
359        while self.arm_cycle_count < self.target_cycle {
360            cpu.step(&mut St018Bus { board: self });
361        }
362        self.cpu = cpu;
363    }
364
365    fn load_firmware(&mut self, bytes: &[u8]) -> bool {
366        if bytes.len() != FIRMWARE_SIZE {
367            return false;
368        }
369        self.prg_rom.copy_from_slice(&bytes[..PRG_ROM_SIZE]);
370        self.data_rom.copy_from_slice(&bytes[PRG_ROM_SIZE..]);
371        self.firmware_loaded = true;
372        // The constructor already primed the pipeline once, against all-zero ROM (firmware
373        // hasn't loaded yet at that point) -- re-power-on now so the pipeline's `execute`/
374        // `decode` slots hold the REAL reset-vector opcodes instead of permanently-stale zero
375        // opcodes fetched before this firmware existed.
376        self.power_on_arm();
377        true
378    }
379
380    fn firmware_hint(&self) -> Option<&'static str> {
381        Some("st018.rom")
382    }
383
384    fn save_state(&self, w: &mut SaveWriter) {
385        w.section(*b"ST18", |s| {
386            self.cpu.save_state(s);
387            s.write_bytes(&self.work_ram);
388            s.write_bool(self.has_data_for_snes);
389            s.write_u8(self.data_snes);
390            s.write_bool(self.ack);
391            s.write_bool(self.has_data_for_arm);
392            s.write_u8(self.data_arm);
393            s.write_bool(self.arm_reset);
394            s.write_u64(self.target_cycle);
395            s.write_u64(self.arm_cycle_count);
396        });
397        self.inner.save_state(w);
398    }
399
400    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
401        let mut s = r.expect_section(*b"ST18")?;
402        self.cpu.load_state(&mut s)?;
403        self.work_ram.copy_from_slice(s.read_bytes(WORK_RAM_SIZE)?);
404        self.has_data_for_snes = s.read_bool()?;
405        self.data_snes = s.read_u8()?;
406        self.ack = s.read_bool()?;
407        self.has_data_for_arm = s.read_bool()?;
408        self.data_arm = s.read_u8()?;
409        self.arm_reset = s.read_bool()?;
410        self.target_cycle = s.read_u64()?;
411        self.arm_cycle_count = s.read_u64()?;
412        if s.remaining() != 0 {
413            return Err(SaveStateError::Invalid(alloc::format!(
414                "ST18 section has {} trailing byte(s)",
415                s.remaining()
416            )));
417        }
418        self.inner.load_state(r)
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::board::LoRom;
426
427    fn firmware() -> alloc::vec::Vec<u8> {
428        let mut fw = vec![0u8; FIRMWARE_SIZE];
429        // A single ARM opcode at the reset vector (address 0): `MOV r0, #1` = 0xE3A00001. Enough
430        // to prove the catch-up loop actually steps the core once firmware is present.
431        fw[0..4].copy_from_slice(&0xE3A0_0001u32.to_le_bytes());
432        fw
433    }
434
435    fn board() -> St018Board {
436        let inner = Box::new(LoRom::new(
437            vec![0u8; 0x8_0000].into_boxed_slice(),
438            vec![0u8; 0x2000].into_boxed_slice(),
439        ));
440        St018Board::new(inner)
441    }
442
443    #[test]
444    fn window_classify() {
445        assert_eq!(classify(0x00_3800), Some(0x3800));
446        assert_eq!(classify(0x3F_3802), Some(0x3802));
447        assert_eq!(classify(0x80_3804), Some(0x3804));
448        assert_eq!(classify(0xBF_3FFE), Some(0x3F06)); // top of the window still folds via &0xFF06
449        assert_eq!(classify(0x00_2FFF), None); // just below the window
450        assert_eq!(classify(0x40_3800), None); // outside 00-3f/80-bf
451    }
452
453    #[test]
454    fn inert_without_firmware() {
455        let mut b = board();
456        assert!(!b.firmware_loaded);
457        // The constructor already primes the pipeline once (against all-zero ROM) regardless of
458        // firmware state; the property under test is that ticking WITHOUT firmware never grows
459        // that baseline further -- the chip stays fully inert, never silently faked.
460        let baseline = b.arm_cycle_count;
461        for _ in 0..64 {
462            b.coprocessor_tick();
463        }
464        assert_eq!(b.arm_cycle_count, baseline);
465        assert_eq!(b.read24(0x00_3804), 0x80); // status: !ArmReset only
466    }
467
468    #[test]
469    fn rejects_a_wrong_sized_firmware_dump() {
470        let mut b = board();
471        assert!(!b.load_firmware(&[0u8; 0x100]));
472        assert!(!b.firmware_loaded);
473    }
474
475    #[test]
476    fn accepts_the_combined_dump_and_splits_prg_and_data() {
477        let mut fw = firmware();
478        fw[PRG_ROM_SIZE] = 0x42; // first data-ROM byte
479        let mut b = board();
480        assert!(b.load_firmware(&fw));
481        assert!(b.firmware_loaded);
482        assert_eq!(b.prg_rom[0..4], 0xE3A0_0001u32.to_le_bytes());
483        assert_eq!(b.data_rom[0], 0x42);
484    }
485
486    #[test]
487    fn coprocessor_tick_steps_the_arm_once_firmware_is_loaded() {
488        let mut b = board();
489        assert!(b.load_firmware(&firmware()));
490        // The constructor's power-on (against all-zero ROM) plus `load_firmware`'s re-power-on
491        // (against the real dump) each spend a fixed handful of housekeeping bus cycles priming
492        // the pipeline before any real instruction can reach the Execute stage; 64 ticks is
493        // comfortably past that baseline.
494        let before = b.arm_cycle_count;
495        for _ in 0..64 {
496            b.coprocessor_tick();
497        }
498        assert!(b.arm_cycle_count > before);
499        assert_eq!(b.cpu.regs.r[0], 1); // MOV r0,#1 executed
500    }
501
502    #[test]
503    fn snes_side_handshake_round_trip() {
504        let mut b = board();
505        b.write24(0x00_3802, 0x55); // push a byte to the ARM
506        assert!(b.has_data_for_arm);
507        assert_eq!(b.data_arm, 0x55);
508        b.data_snes = 0xAA;
509        b.has_data_for_snes = true;
510        assert_eq!(b.read24(0x00_3800), 0xAA); // pull it back
511        assert!(!b.has_data_for_snes); // read clears the flag
512    }
513
514    #[test]
515    fn a_reset_edge_reinitializes_the_arm_without_resetting_its_cycle_counter() {
516        let mut b = board();
517        assert!(b.load_firmware(&firmware()));
518        for _ in 0..64 {
519            b.coprocessor_tick();
520        }
521        let cycles_before_reset = b.arm_cycle_count;
522        b.write24(0x00_3804, 1); // assert reset
523        b.write24(0x00_3804, 0); // 1->0 edge: re-initializes the ARM
524        // `power_on_arm` never touches `arm_cycle_count` -- it stays board-owned across a reset,
525        // matching Mesen2's `PowerOn(forReset=true)` preserving the cycle counter.
526        assert_eq!(b.arm_cycle_count, cycles_before_reset);
527        assert_eq!(b.cpu.regs.r[0], 0); // registers ARE re-zeroed by the reset
528    }
529
530    #[test]
531    fn state_round_trips_through_save_state() {
532        let mut b = board();
533        assert!(b.load_firmware(&firmware()));
534        for _ in 0..64 {
535            b.coprocessor_tick();
536        }
537        b.write24(0x00_3802, 0x77);
538
539        let mut w = SaveWriter::new();
540        b.save_state(&mut w);
541        let bytes = w.into_bytes();
542
543        let mut fresh = board();
544        assert!(fresh.load_firmware(&firmware()));
545        let mut r = SaveReader::new(&bytes);
546        fresh.load_state(&mut r).unwrap();
547
548        assert_eq!(fresh.arm_cycle_count, b.arm_cycle_count);
549        assert_eq!(fresh.cpu.regs.r[0], b.cpu.regs.r[0]);
550        assert_eq!(fresh.data_arm, 0x77);
551        assert!(fresh.has_data_for_arm);
552    }
553
554    #[test]
555    fn rom_and_sram_delegate_to_inner_board() {
556        let mut b = board();
557        assert_eq!(b.rom().len(), 0x8_0000);
558        b.write24(0x70_0000, 0x99); // LoROM SRAM window
559        assert_eq!(b.read24(0x70_0000), 0x99);
560    }
561}