Skip to main content

rustysnes_cart/coproc/
spc7110.rs

1//! The SPC7110 board — Hudson's decompression + memory-mapping ASIC (Far East of Eden Zero /
2//! Tengai Makyou Zero).
3//!
4//! Clean-room port of ares' `SPC7110` component (ISC, `sfc/coprocessor/spc7110/`): a decompression
5//! unit (DCU, an adaptive binary range coder over 1/2/4bpp tile planes — `decompressor`), a data
6//! port unit (a seekable data-ROM cursor with auto-increment), an arithmetic-logic unit (16x16
7//! multiply, 32/16 divide), and a memory-control unit (four independently-bankable 1 MiB data-ROM
8//! windows). There is no chip-ROM dump for the DCU/data-port/ALU — the algorithm runs entirely
9//! against the cart's own PROM/DROM (`docs/adr/0003`).
10//!
11//! SPC7110 owns its ROM/RAM mapping directly (like S-DD1/Super FX/SA-1): its `$00-3F,$80-BF:8000-
12//! FFFF` + `$C0-FF:0000-FFFF` window folds to a UNIFIED linear data-ROM address (`(bank & 0x3F) <<
13//! 16 | offset`, ares `mcuromRead`'s doc comment) that a plain LoROM/HiROM board's fold can't
14//! express, and the register window additionally mirrors onto whole banks `$50`/`$58`.
15//!
16//! Cartridge geometry: unlike every other coprocessor here, SPC7110 carts physically carry TWO
17//! ROM chips — a small plain "PROM" (CPU-executable program code, LoROM-style banks `00-0F`) and a
18//! much larger "DROM" (compressed/plain data, addressed only through this board's registers). A
19//! combined `.sfc` dump concatenates PROM then DROM. There is no header field or generic formula
20//! that recovers the split for every SPC7110 title (this project has exactly one SPC7110 ROM to
21//! validate against, mirroring the single-title validation basis already used for CX4/DSP-4/ST010/
22//! OBC1): [`select`] uses Far East of Eden Zero's known real physical chip sizes (1 MiB PROM + 4
23//! MiB DROM, per ares' own board database — see `select`'s own doc for the discrepancy this fixed
24//! against this project's staged dump), else treats the whole image as DROM (the "no PROM"
25//! SPC7110 layout used by titles like Momotarou Dentetsu VII).
26//!
27//! Bus window (bank:addr):
28//!
29//! | Region | Target |
30//! |---|---|
31//! | `$00-3F,$80-BF:$4800-$483F`, whole bank `$50`, whole bank `$58` | registers (DCU/data-port/ALU/memory-control) |
32//! | `$00-3F,$80-BF:$8000-FFFF`, `$C0-FF:$0000-FFFF` | PROM (if present) or DROM, banked via `$4830-$4833` + `$4834`. `$40-$7D` is genuinely UNMAPPED on this board (`MappedAddr::Open`) — ares' real board database (`board: SHVC-LDH3C-01`, the exact board this title uses) maps the `mcu` ROM window only at these two ranges, NOT `$40-$7D`; an earlier session's claim that boot code executes from `$40-$7D` was never checked against this database, and a watchpoint+disassembler trace found the opposite (code jumping there decodes as incoherent garbage) |
33//! | `$00-3F,$80-BF:$6000-$7FFF` | battery SRAM (folds to banks `00-07`), gated on `$4830` bit 7 |
34
35// Chip-name jargon (SPC7110, DCU, MCU, ...) is not Rust code; the register file is naturally dense
36// with small bitfields ported verbatim from ares.
37#![allow(
38    clippy::doc_markdown,
39    clippy::similar_names,
40    clippy::cast_possible_truncation
41)]
42
43use alloc::boxed::Box;
44use alloc::vec;
45
46use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
47
48use crate::board::{Board, Coprocessor, MappedAddr};
49use crate::coproc::epsonrtc::EpsonRtc;
50use crate::header::MapMode;
51
52mod decompressor;
53use decompressor::Decompressor;
54
55/// A cartridge carrying an SPC7110 (owns its ROM/RAM mapping directly — see the module doc).
56pub struct Spc7110Board {
57    prom: Box<[u8]>,
58    drom: Box<[u8]>,
59    ram: Box<[u8]>,
60
61    // decompression unit
62    r4801: u8,
63    r4802: u8,
64    r4803: u8,
65    r4804: u8,
66    r4805: u8,
67    r4806: u8,
68    r4807: u8,
69    r4809: u8,
70    r480a: u8,
71    r480b: u8,
72    r480c: u8,
73    dcu_mode: u8,
74    dcu_address: u32,
75    dcu_offset: u32,
76    dcu_tile: [u8; 32],
77    decompressor: Decompressor,
78    /// Set by a `$4806` write, consumed on the next [`Board::coprocessor_tick`] call — ares'
79    /// SPC7110 runs as its own cothread (`Thread::create(21'477'272, ...)`, the master-clock
80    /// rate) whose `main()` defers `dcuBeginTransfer()` by exactly this one tick (`sfc/
81    /// coprocessor/spc7110/spc7110.cpp`/`dcu.cpp`), not a synchronous same-write completion. Not
82    /// persisted in `save_state`/`load_state`: it is `true` for at most one master-clock tick
83    /// (cleared on the very next `coprocessor_tick`, called every master clock from
84    /// `Bus::advance_master`), and a save-state can only be taken at a real frame/user-action
85    /// boundary, many master clocks after any register write — so it is always `false` at any
86    /// boundary a save-state could actually observe.
87    dcu_pending: bool,
88    /// Same deferral, for a `$4825` write (`aluMultiply`) — see `dcu_pending`'s doc.
89    mul_pending: bool,
90    /// Same deferral, for a `$4827` write (`aluDivide`) — see `dcu_pending`'s doc.
91    div_pending: bool,
92
93    // data port unit
94    r4810: u8,
95    r4811: u8,
96    r4812: u8,
97    r4813: u8,
98    r4814: u8,
99    r4815: u8,
100    r4816: u8,
101    r4817: u8,
102    r4818: u8,
103
104    // arithmetic logic unit
105    r4820: u8,
106    r4821: u8,
107    r4822: u8,
108    r4823: u8,
109    r4824: u8,
110    r4825: u8,
111    r4826: u8,
112    r4827: u8,
113    r4828: u8,
114    r4829: u8,
115    r482a: u8,
116    r482b: u8,
117    r482c: u8,
118    r482d: u8,
119    r482e: u8,
120    r482f: u8,
121
122    // memory control unit
123    r4830: u8,
124    r4831: u8,
125    r4832: u8,
126    r4833: u8,
127    r4834: u8,
128
129    /// The Epson RTC-4513 fitted alongside SPC7110 on exactly one commercial cart (Far East of
130    /// Eden Zero — see `coproc::epsonrtc`'s module doc). Present unconditionally: titles without
131    /// an RTC simply never address `$4840-$4842`, so it stays inert and harmless.
132    rtc: EpsonRtc,
133}
134
135impl core::fmt::Debug for Spc7110Board {
136    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
137        f.debug_struct("Spc7110Board")
138            .field("prom_len", &self.prom.len())
139            .field("drom_len", &self.drom.len())
140            .finish_non_exhaustive()
141    }
142}
143
144impl Spc7110Board {
145    /// Build an SPC7110 board directly from the cart's split PROM/DROM + header SRAM size.
146    #[must_use]
147    pub fn new(prom: Box<[u8]>, drom: Box<[u8]>, sram_size: usize) -> Self {
148        Self {
149            prom,
150            drom,
151            ram: vec![0u8; sram_size].into_boxed_slice(),
152            r4801: 0,
153            r4802: 0,
154            r4803: 0,
155            r4804: 0,
156            r4805: 0,
157            r4806: 0,
158            r4807: 0,
159            r4809: 0,
160            r480a: 0,
161            r480b: 0,
162            r480c: 0,
163            dcu_mode: 0,
164            dcu_address: 0,
165            dcu_offset: 0,
166            dcu_tile: [0; 32],
167            decompressor: Decompressor::new(),
168            dcu_pending: false,
169            mul_pending: false,
170            div_pending: false,
171            r4810: 0,
172            r4811: 0,
173            r4812: 0,
174            r4813: 0,
175            r4814: 0,
176            r4815: 0,
177            r4816: 0,
178            r4817: 0,
179            r4818: 0,
180            r4820: 0,
181            r4821: 0,
182            r4822: 0,
183            r4823: 0,
184            r4824: 0,
185            r4825: 0,
186            r4826: 0,
187            r4827: 0,
188            r4828: 0,
189            r4829: 0,
190            r482a: 0,
191            r482b: 0,
192            r482c: 0,
193            r482d: 0,
194            r482e: 0,
195            r482f: 0,
196            r4830: 0,
197            r4831: 0,
198            r4832: 0x01,
199            r4833: 0x02,
200            r4834: 0,
201            rtc: EpsonRtc::new(),
202        }
203    }
204
205    // ============================
206    // data ROM (ares data.cpp)
207    // ============================
208
209    fn datarom_read(&self, address: u32) -> u8 {
210        let size = 1u32 << (self.r4834 & 3); // in MiB
211        let mask = 0x0010_0000u32.wrapping_mul(size).wrapping_sub(1);
212        let offset = address & mask;
213        if (self.r4834 & 3) != 3 && (address & 0x40_0000) != 0 {
214            return 0;
215        }
216        self.drom
217            .get(bus_mirror(offset, self.drom.len() as u32) as usize)
218            .copied()
219            .unwrap_or(0)
220    }
221
222    fn data_offset(&self) -> u32 {
223        u32::from(self.r4811) | (u32::from(self.r4812) << 8) | (u32::from(self.r4813) << 16)
224    }
225
226    fn data_adjust(&self) -> u16 {
227        u16::from(self.r4814) | (u16::from(self.r4815) << 8)
228    }
229
230    fn data_stride(&self) -> u16 {
231        u16::from(self.r4816) | (u16::from(self.r4817) << 8)
232    }
233
234    const fn set_data_offset(&mut self, address: u32) {
235        self.r4811 = address as u8;
236        self.r4812 = (address >> 8) as u8;
237        self.r4813 = (address >> 16) as u8;
238    }
239
240    const fn set_data_adjust(&mut self, address: u16) {
241        self.r4814 = address as u8;
242        self.r4815 = (address >> 8) as u8;
243    }
244
245    fn data_port_read(&mut self) {
246        let offset = self.data_offset();
247        let mut adjust = if self.r4818 & 2 != 0 {
248            u32::from(self.data_adjust())
249        } else {
250            0
251        };
252        if self.r4818 & 8 != 0 {
253            adjust = sign_extend16(adjust as u16);
254        }
255        self.r4810 = self.datarom_read(offset.wrapping_add(adjust));
256    }
257
258    fn data_port_increment_4810(&mut self) {
259        let offset = self.data_offset();
260        let mut stride = if self.r4818 & 1 != 0 {
261            u32::from(self.data_stride())
262        } else {
263            1
264        };
265        let mut adjust = u32::from(self.data_adjust());
266        if self.r4818 & 4 != 0 {
267            stride = sign_extend16(stride as u16);
268        }
269        if self.r4818 & 8 != 0 {
270            adjust = sign_extend16(adjust as u16);
271        }
272        if self.r4818 & 16 == 0 {
273            self.set_data_offset(offset.wrapping_add(stride));
274        } else {
275            self.set_data_adjust(adjust.wrapping_add(stride) as u16);
276        }
277        self.data_port_read();
278    }
279
280    fn data_port_increment_seek(&mut self, select: u8) {
281        if self.r4818 >> 5 != select {
282            return;
283        }
284        let offset = self.data_offset();
285        let mut adjust = u32::from(self.data_adjust());
286        if self.r4818 & 8 != 0 {
287            adjust = sign_extend16(adjust as u16);
288        }
289        self.set_data_offset(offset.wrapping_add(adjust));
290        self.data_port_read();
291    }
292
293    // ============================
294    // decompression unit (ares dcu.cpp)
295    // ============================
296
297    fn dcu_load_address(&mut self) {
298        let table =
299            u32::from(self.r4801) | (u32::from(self.r4802) << 8) | (u32::from(self.r4803) << 16);
300        let index = u32::from(self.r4804) << 2;
301        let address = table.wrapping_add(index);
302        self.dcu_mode = self.datarom_read(address);
303        self.dcu_address = (u32::from(self.datarom_read(address.wrapping_add(1))) << 16)
304            | (u32::from(self.datarom_read(address.wrapping_add(2))) << 8)
305            | u32::from(self.datarom_read(address.wrapping_add(3)));
306    }
307
308    fn dcu_begin_transfer(&mut self) {
309        if self.dcu_mode == 3 {
310            return; // invalid mode
311        }
312        let origin = self.dcu_address;
313        self.decompressor
314            .initialize(u32::from(self.dcu_mode), origin, |o| {
315                Self::datarom_read_static(&self.drom, self.r4834, o)
316            });
317        self.decompress_one();
318        let seek: u16 = if self.r480b & 2 != 0 {
319            u16::from(self.r4805) | (u16::from(self.r4806) << 8)
320        } else {
321            0
322        };
323        for _ in 0..seek {
324            self.decompress_one();
325        }
326        self.r480c |= 0x80;
327        self.dcu_offset = 0;
328    }
329
330    fn decompress_one(&mut self) {
331        let drom = &self.drom;
332        let r4834 = self.r4834;
333        self.decompressor
334            .decode(|o| Self::datarom_read_static(drom, r4834, o));
335    }
336
337    /// [`Self::datarom_read`] without a `&self` borrow, for use inside closures that also need to
338    /// mutate `self.decompressor` (see [`Self::dcu_begin_transfer`]/[`Self::decompress_one`]).
339    fn datarom_read_static(drom: &[u8], r4834: u8, address: u32) -> u8 {
340        let size = 1u32 << (r4834 & 3);
341        let mask = 0x0010_0000u32.wrapping_mul(size).wrapping_sub(1);
342        let offset = address & mask;
343        if (r4834 & 3) != 3 && (address & 0x40_0000) != 0 {
344            return 0;
345        }
346        drom.get(bus_mirror(offset, drom.len() as u32) as usize)
347            .copied()
348            .unwrap_or(0)
349    }
350
351    fn dcu_read(&mut self) -> u8 {
352        if self.r480c & 0x80 == 0 {
353            return 0;
354        }
355
356        if self.dcu_offset == 0 {
357            for row in 0..8usize {
358                match self.decompressor.bpp {
359                    1 => self.dcu_tile[row] = self.decompressor.result as u8,
360                    2 => {
361                        self.dcu_tile[row * 2] = self.decompressor.result as u8;
362                        self.dcu_tile[row * 2 + 1] = (self.decompressor.result >> 8) as u8;
363                    }
364                    4 => {
365                        self.dcu_tile[row * 2] = self.decompressor.result as u8;
366                        self.dcu_tile[row * 2 + 1] = (self.decompressor.result >> 8) as u8;
367                        self.dcu_tile[row * 2 + 16] = (self.decompressor.result >> 16) as u8;
368                        self.dcu_tile[row * 2 + 17] = (self.decompressor.result >> 24) as u8;
369                    }
370                    _ => {}
371                }
372                let seek = if self.r480b & 1 != 0 { self.r4807 } else { 1 };
373                for _ in 0..seek {
374                    self.decompress_one();
375                }
376            }
377        }
378
379        let data = self.dcu_tile[self.dcu_offset as usize];
380        self.dcu_offset += 1;
381        self.dcu_offset &= 8 * self.decompressor.bpp - 1;
382        data
383    }
384
385    // ============================
386    // arithmetic logic unit (ares alu.cpp)
387    // ============================
388
389    fn alu_multiply(&mut self) {
390        let result: u32 = if self.r482e & 1 != 0 {
391            let r0 = i32::from(i16::from_le_bytes([self.r4824, self.r4825]));
392            let r1 = i32::from(i16::from_le_bytes([self.r4820, self.r4821]));
393            r0.wrapping_mul(r1).cast_unsigned()
394        } else {
395            let r0 = u32::from(u16::from_le_bytes([self.r4824, self.r4825]));
396            let r1 = u32::from(u16::from_le_bytes([self.r4820, self.r4821]));
397            r0.wrapping_mul(r1)
398        };
399        self.r4828 = result as u8;
400        self.r4829 = (result >> 8) as u8;
401        self.r482a = (result >> 16) as u8;
402        self.r482b = (result >> 24) as u8;
403        self.r482f &= 0x7f;
404    }
405
406    fn alu_divide(&mut self) {
407        let (quotient, remainder): (u32, u16) = if self.r482e & 1 != 0 {
408            let dividend = i32::from_le_bytes([self.r4820, self.r4821, self.r4822, self.r4823]);
409            let divisor = i16::from_le_bytes([self.r4826, self.r4827]);
410            if divisor == 0 {
411                (0, dividend.cast_unsigned() as u16)
412            } else {
413                (
414                    dividend.wrapping_div(i32::from(divisor)).cast_unsigned(),
415                    dividend.wrapping_rem(i32::from(divisor)).cast_unsigned() as u16,
416                )
417            }
418        } else {
419            let dividend = u32::from_le_bytes([self.r4820, self.r4821, self.r4822, self.r4823]);
420            let divisor = u16::from_le_bytes([self.r4826, self.r4827]);
421            if divisor == 0 {
422                (0, dividend as u16)
423            } else {
424                (
425                    dividend / u32::from(divisor),
426                    (dividend % u32::from(divisor)) as u16,
427                )
428            }
429        };
430        self.r4828 = quotient as u8;
431        self.r4829 = (quotient >> 8) as u8;
432        self.r482a = (quotient >> 16) as u8;
433        self.r482b = (quotient >> 24) as u8;
434        self.r482c = remainder as u8;
435        self.r482d = (remainder >> 8) as u8;
436        self.r482f &= 0x7f;
437    }
438
439    // ============================
440    // memory control unit (ares spc7110.cpp mcuromRead/mcuramRead)
441    // ============================
442
443    /// The unified linear data-ROM address ares' `mcuromRead` doc comment describes: banks
444    /// `$00-3F,$80-BF` at `$8000-FFFF` and banks `$C0-FF` at `$0000-FFFF` both fold to
445    /// `(bank & 0x3F) << 16 | offset`.
446    const fn mcurom_linear(bank: u32, offset: u32) -> u32 {
447        ((bank & 0x3F) << 16) | offset
448    }
449
450    fn mcurom_read(&self, linear: u32) -> u8 {
451        if linear < 0x10_0000 {
452            let a = linear & 0x0f_ffff;
453            if !self.prom.is_empty() {
454                return self.prom[bus_mirror(a, self.prom.len() as u32) as usize];
455            }
456            return self.datarom_read(a | (0x10_0000 * u32::from(self.r4830 & 7)));
457        }
458        if linear < 0x20_0000 {
459            let a = linear & 0x0f_ffff;
460            if self.r4834 & 4 != 0 && !self.prom.is_empty() {
461                return self.prom[bus_mirror(0x10_0000 + a, self.prom.len() as u32) as usize];
462            }
463            return self.datarom_read(a | (0x10_0000 * u32::from(self.r4831 & 7)));
464        }
465        if linear < 0x30_0000 {
466            let a = linear & 0x0f_ffff;
467            return self.datarom_read(a | (0x10_0000 * u32::from(self.r4832 & 7)));
468        }
469        if linear < 0x40_0000 {
470            let a = linear & 0x0f_ffff;
471            return self.datarom_read(a | (0x10_0000 * u32::from(self.r4833 & 7)));
472        }
473        0
474    }
475
476    fn mcuram_addr(&self, bank: u32, offset_in_window: u32) -> usize {
477        let a = ((bank & 0x07) << 13) | (offset_in_window & 0x1FFF);
478        bus_mirror(a, self.ram.len() as u32) as usize
479    }
480
481    // ============================
482    // registers (ares spc7110.cpp read/write)
483    // ============================
484
485    /// Normalize a raw 24-bit CPU address to the `$4800-$483F` register space, folding the
486    /// bank-`$50`/`$58` full-bank mirrors (ares `read`/`write`'s shared prologue).
487    const fn reg_addr(addr24: u32) -> u16 {
488        let bank = (addr24 >> 16) & 0xff;
489        if bank == 0x50 {
490            return 0x4800;
491        }
492        if bank == 0x58 {
493            return 0x4808;
494        }
495        (0x4800 | (addr24 & 0x3f)) as u16
496    }
497
498    fn read_reg(&mut self, addr: u16) -> u8 {
499        match addr {
500            0x4800 => {
501                let counter = u16::from(self.r4809) | (u16::from(self.r480a) << 8);
502                let counter = counter.wrapping_sub(1);
503                self.r4809 = counter as u8;
504                self.r480a = (counter >> 8) as u8;
505                self.dcu_read()
506            }
507            0x4801 => self.r4801,
508            0x4802 => self.r4802,
509            0x4803 => self.r4803,
510            0x4804 => self.r4804,
511            0x4805 => self.r4805,
512            0x4806 => self.r4806,
513            0x4807 => self.r4807,
514            // $4808 always reads 0 (ares `case 0x4808: return 0x00;`) — same as the wildcard.
515            0x4809 => self.r4809,
516            0x480a => self.r480a,
517            0x480b => self.r480b,
518            0x480c => self.r480c,
519            0x4810 => {
520                let data = self.r4810;
521                self.data_port_increment_4810();
522                data
523            }
524            0x4811 => self.r4811,
525            0x4812 => self.r4812,
526            0x4813 => self.r4813,
527            0x4814 => self.r4814,
528            0x4815 => self.r4815,
529            0x4816 => self.r4816,
530            0x4817 => self.r4817,
531            0x4818 => self.r4818,
532            0x481a => {
533                self.data_port_increment_seek(3);
534                0
535            }
536            0x4820 => self.r4820,
537            0x4821 => self.r4821,
538            0x4822 => self.r4822,
539            0x4823 => self.r4823,
540            0x4824 => self.r4824,
541            0x4825 => self.r4825,
542            0x4826 => self.r4826,
543            0x4827 => self.r4827,
544            0x4828 => self.r4828,
545            0x4829 => self.r4829,
546            0x482a => self.r482a,
547            0x482b => self.r482b,
548            0x482c => self.r482c,
549            0x482d => self.r482d,
550            0x482e => self.r482e,
551            0x482f => self.r482f,
552            0x4830 => self.r4830,
553            0x4831 => self.r4831,
554            0x4832 => self.r4832,
555            0x4833 => self.r4833,
556            0x4834 => self.r4834,
557            _ => 0,
558        }
559    }
560
561    fn write_reg(&mut self, addr: u16, data: u8) {
562        match addr {
563            0x4801 => self.r4801 = data,
564            0x4802 => self.r4802 = data,
565            0x4803 => self.r4803 = data & 0x7f,
566            0x4804 => {
567                self.r4804 = data;
568                self.dcu_load_address();
569            }
570            0x4805 => self.r4805 = data,
571            0x4806 => {
572                self.r4806 = data;
573                self.r480c &= 0x7f;
574                // Deferred to the next `coprocessor_tick` — see `dcu_pending`'s doc for why a
575                // same-write synchronous `dcu_begin_transfer()` call here is the wrong timing.
576                self.dcu_pending = true;
577            }
578            0x4807 => self.r4807 = data,
579            0x4809 => self.r4809 = data,
580            0x480a => self.r480a = data,
581            0x480b => self.r480b = data & 0x03,
582            0x4811 => self.r4811 = data,
583            0x4812 => self.r4812 = data,
584            0x4813 => {
585                self.r4813 = data & 0x7f;
586                self.data_port_read();
587            }
588            0x4814 => {
589                self.r4814 = data;
590                self.data_port_increment_seek(1);
591            }
592            0x4815 => {
593                self.r4815 = data;
594                if self.r4818 & 2 != 0 {
595                    self.data_port_read();
596                }
597                self.data_port_increment_seek(2);
598            }
599            0x4816 => self.r4816 = data,
600            0x4817 => self.r4817 = data,
601            0x4818 => {
602                self.r4818 = data & 0x7f;
603                self.data_port_read();
604            }
605            0x4820 => self.r4820 = data,
606            0x4821 => self.r4821 = data,
607            0x4822 => self.r4822 = data,
608            0x4823 => self.r4823 = data,
609            0x4824 => self.r4824 = data,
610            0x4825 => {
611                self.r4825 = data;
612                self.r482f |= 0x81;
613                // Deferred to the next `coprocessor_tick` — see `dcu_pending`'s doc.
614                self.mul_pending = true;
615            }
616            0x4826 => self.r4826 = data,
617            0x4827 => {
618                self.r4827 = data;
619                self.r482f |= 0x80;
620                // Deferred to the next `coprocessor_tick` — see `dcu_pending`'s doc.
621                self.div_pending = true;
622            }
623            0x482e => self.r482e = data & 0x01,
624            0x4830 => self.r4830 = data & 0x87,
625            0x4831 => self.r4831 = data & 0x07,
626            0x4832 => self.r4832 = data & 0x07,
627            0x4833 => self.r4833 = data & 0x07,
628            0x4834 => self.r4834 = data & 0x07,
629            _ => {}
630        }
631    }
632}
633
634impl Spc7110Board {
635    /// Write every register across the DCU/data-port/ALU/memory-control units, the DCU's
636    /// `dcu_tile` scratch buffer, the decompressor's mid-stream state, and the paired RTC into an
637    /// `"SP70"` section. PROM/DROM/battery-SRAM are never written (`System::save_state` captures
638    /// SRAM separately via `Board::sram`; ROM is never embedded, `docs/adr/0003`).
639    pub fn save_state(&self, w: &mut SaveWriter) {
640        w.section(*b"SP70", |s| {
641            s.write_u8(self.r4801);
642            s.write_u8(self.r4802);
643            s.write_u8(self.r4803);
644            s.write_u8(self.r4804);
645            s.write_u8(self.r4805);
646            s.write_u8(self.r4806);
647            s.write_u8(self.r4807);
648            s.write_u8(self.r4809);
649            s.write_u8(self.r480a);
650            s.write_u8(self.r480b);
651            s.write_u8(self.r480c);
652            s.write_u8(self.dcu_mode);
653            s.write_u32(self.dcu_address);
654            s.write_u32(self.dcu_offset);
655            s.write_bytes(&self.dcu_tile);
656            s.write_u8(self.r4810);
657            s.write_u8(self.r4811);
658            s.write_u8(self.r4812);
659            s.write_u8(self.r4813);
660            s.write_u8(self.r4814);
661            s.write_u8(self.r4815);
662            s.write_u8(self.r4816);
663            s.write_u8(self.r4817);
664            s.write_u8(self.r4818);
665            s.write_u8(self.r4820);
666            s.write_u8(self.r4821);
667            s.write_u8(self.r4822);
668            s.write_u8(self.r4823);
669            s.write_u8(self.r4824);
670            s.write_u8(self.r4825);
671            s.write_u8(self.r4826);
672            s.write_u8(self.r4827);
673            s.write_u8(self.r4828);
674            s.write_u8(self.r4829);
675            s.write_u8(self.r482a);
676            s.write_u8(self.r482b);
677            s.write_u8(self.r482c);
678            s.write_u8(self.r482d);
679            s.write_u8(self.r482e);
680            s.write_u8(self.r482f);
681            s.write_u8(self.r4830);
682            s.write_u8(self.r4831);
683            s.write_u8(self.r4832);
684            s.write_u8(self.r4833);
685            s.write_u8(self.r4834);
686        });
687        self.decompressor.save_state(w);
688        self.rtc.save_state(w);
689    }
690
691    /// The inverse of [`Self::save_state`].
692    ///
693    /// # Errors
694    /// [`SaveStateError`] on truncated/corrupt input, a section with unconsumed trailing bytes,
695    /// or whatever `Decompressor::load_state`/`EpsonRtc::load_state` themselves reject.
696    /// `dcu_offset` is masked to `& 31`: it indexes the fixed 32-byte `dcu_tile` directly
697    /// (`dcu_tile[self.dcu_offset as usize]`), and every normal-operation mutator already masks
698    /// it to `8 * bpp - 1` (at most 31) before storing, so this applies the engine's own
699    /// existing invariant rather than new validation policy. Every register masked/write-limited
700    /// on a normal `write_reg` call (`r4803`/`r480b`/`r4813`/`r4818`/`r482e`/`r4830`-`r4834`) is
701    /// masked identically on load. `dcu_mode` is restored verbatim: it's a raw data-ROM byte with
702    /// no enforced range even during normal execution (an out-of-range value there is a
703    /// pre-existing, save-state-independent hazard tracked separately, not something this format
704    /// can or should paper over).
705    pub fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
706        let mut s = r.expect_section(*b"SP70")?;
707        self.r4801 = s.read_u8()?;
708        self.r4802 = s.read_u8()?;
709        self.r4803 = s.read_u8()? & 0x7F; // write_reg $4803: data & 0x7f
710        self.r4804 = s.read_u8()?;
711        self.r4805 = s.read_u8()?;
712        self.r4806 = s.read_u8()?;
713        self.r4807 = s.read_u8()?;
714        self.r4809 = s.read_u8()?;
715        self.r480a = s.read_u8()?;
716        self.r480b = s.read_u8()? & 0x03; // write_reg $480b: data & 0x03
717        self.r480c = s.read_u8()?;
718        self.dcu_mode = s.read_u8()?;
719        self.dcu_address = s.read_u32()?;
720        self.dcu_offset = s.read_u32()? & 31;
721        self.dcu_tile.copy_from_slice(s.read_bytes(32)?);
722        self.r4810 = s.read_u8()?;
723        self.r4811 = s.read_u8()?;
724        self.r4812 = s.read_u8()?;
725        self.r4813 = s.read_u8()? & 0x7F; // write_reg $4813: data & 0x7f
726        self.r4814 = s.read_u8()?;
727        self.r4815 = s.read_u8()?;
728        self.r4816 = s.read_u8()?;
729        self.r4817 = s.read_u8()?;
730        self.r4818 = s.read_u8()? & 0x7F; // write_reg $4818: data & 0x7f
731        self.r4820 = s.read_u8()?;
732        self.r4821 = s.read_u8()?;
733        self.r4822 = s.read_u8()?;
734        self.r4823 = s.read_u8()?;
735        self.r4824 = s.read_u8()?;
736        self.r4825 = s.read_u8()?;
737        self.r4826 = s.read_u8()?;
738        self.r4827 = s.read_u8()?;
739        self.r4828 = s.read_u8()?;
740        self.r4829 = s.read_u8()?;
741        self.r482a = s.read_u8()?;
742        self.r482b = s.read_u8()?;
743        self.r482c = s.read_u8()?;
744        self.r482d = s.read_u8()?;
745        self.r482e = s.read_u8()? & 0x01; // write_reg $482e: data & 0x01
746        self.r482f = s.read_u8()?;
747        self.r4830 = s.read_u8()? & 0x87; // write_reg $4830: data & 0x87
748        self.r4831 = s.read_u8()? & 0x07; // write_reg $4831: data & 0x07
749        self.r4832 = s.read_u8()? & 0x07; // write_reg $4832: data & 0x07
750        self.r4833 = s.read_u8()? & 0x07; // write_reg $4833: data & 0x07
751        self.r4834 = s.read_u8()? & 0x07; // write_reg $4834: data & 0x07
752        if s.remaining() != 0 {
753            return Err(SaveStateError::Invalid(alloc::format!(
754                "SP70 section has {} trailing byte(s)",
755                s.remaining()
756            )));
757        }
758        self.decompressor.load_state(r)?;
759        self.rtc.load_state(r)
760    }
761}
762
763/// Sign-extend a 16-bit value into a `u32` (ares `(i16)adjust`/`(i16)stride` idiom).
764const fn sign_extend16(v: u16) -> u32 {
765    (v.cast_signed() as i32).cast_unsigned()
766}
767
768/// ares' `Bus::mirror` (`sfc/memory/inline.hpp`): folds `address` into a buffer of `size` bytes
769/// by repeatedly stripping the largest power-of-two block that keeps `address` in range, NOT a
770/// plain `address % size`. The two agree only when `size` is itself a power of two.
771///
772/// This matters here specifically because SPC7110 DROM chips are **not** power-of-two sized —
773/// Far East of Eden Zero's is 6 MiB (7 MiB image minus the 1 MiB PROM split, `select`) — while
774/// register-selected read windows (`r4830`-`r4833`, `dataromRead`'s `r4834`-sized mask) can
775/// address up to 8 MiB. A plain modulo silently returns the WRONG byte for any offset past the
776/// physical chip size but inside the addressable window, corrupting whatever data-ROM-resident
777/// table the game reads through it — the root cause of the SPC7110 boot crash this replaces
778/// (`docs/cart.md` §SPC7110).
779const fn bus_mirror(address: u32, size: u32) -> u32 {
780    if size == 0 {
781        return 0;
782    }
783    let mut address = address;
784    let mut size = size;
785    let mut base = 0u32;
786    let mut mask = 1u32 << 23;
787    while address >= size {
788        while address & mask == 0 {
789            mask >>= 1;
790        }
791        address -= mask;
792        if size > mask {
793            size -= mask;
794            base += mask;
795        }
796        mask >>= 1;
797    }
798    base + address
799}
800
801impl Board for Spc7110Board {
802    fn name(&self) -> &'static str {
803        "HiROM+SPC7110"
804    }
805
806    fn coprocessor(&self) -> Coprocessor {
807        Coprocessor::Spc7110
808    }
809
810    /// Ares' `SPC7110::main()`, called once per master clock (`Bus::advance_master`, matching the
811    /// SPC7110's own real cothread rate — `Thread::create(21'477'272, ...)`, exactly the master
812    /// clock frequency): consume a pending DCU-begin/multiply/divide trigger from the register
813    /// write that armed it one tick ago. See `dcu_pending`'s doc for why this one-tick deferral
814    /// (not a same-write synchronous completion) is the hardware-accurate timing.
815    fn coprocessor_tick(&mut self) {
816        if self.dcu_pending {
817            self.dcu_pending = false;
818            self.dcu_begin_transfer();
819        }
820        if self.mul_pending {
821            self.mul_pending = false;
822            self.alu_multiply();
823        }
824        if self.div_pending {
825            self.div_pending = false;
826            self.alu_divide();
827        }
828    }
829
830    fn map(&self, addr24: u32) -> MappedAddr {
831        let a = addr24 & 0xff_ffff;
832        let bank = (a >> 16) & 0xff;
833        let off = a & 0xffff;
834        if bank == 0x50 || bank == 0x58 {
835            return MappedAddr::Coprocessor;
836        }
837        if matches!(bank, 0x00..=0x3f | 0x80..=0xbf) {
838            if (0x4800..=0x483f).contains(&off) || (0x4840..=0x4842).contains(&off) {
839                return MappedAddr::Coprocessor;
840            }
841            if (0x6000..=0x7fff).contains(&off) {
842                return MappedAddr::Sram(0);
843            }
844            if off >= 0x8000 {
845                return MappedAddr::Rom(a);
846            }
847        }
848        // `v0.8.0`: NOT `0x40..=0x7d` — ares' real board database
849        // (`mia/Database/Super Famicom Boards.bml`, `board: SHVC-LDH3C-01`, the exact board Far
850        // East of Eden Zero uses) maps the `mcu` ROM window ONLY at `00-3f,80-bf:8000-ffff` and
851        // `c0-ff:0000-ffff` — `40-7d` is genuinely unmapped on this board, not a `c0-ff` mirror.
852        // An earlier session's claim that boot code "does execute" from `40-7d` was never
853        // actually checked against this database; a watchpoint+disassembler trace
854        // (`docs/audit/spc7110-boot-crash-2026-07-08.md`) found the opposite — code that jumps
855        // there decodes as incoherent garbage, consistent with reading unmapped/open-bus space.
856        if matches!(bank, 0xc0..=0xff) {
857            return MappedAddr::Rom(a);
858        }
859        MappedAddr::Open
860    }
861
862    fn read24(&mut self, addr24: u32) -> u8 {
863        let a = addr24 & 0xff_ffff;
864        let bank = (a >> 16) & 0xff;
865        let off = a & 0xffff;
866
867        if bank == 0x50 || bank == 0x58 {
868            return self.read_reg(Self::reg_addr(a));
869        }
870        if matches!(bank, 0x00..=0x3f | 0x80..=0xbf) {
871            if (0x4800..=0x483f).contains(&off) {
872                return self.read_reg(Self::reg_addr(a));
873            }
874            if (0x4840..=0x4842).contains(&off) {
875                return self.rtc.read(off - 0x4840);
876            }
877            if (0x6000..=0x7fff).contains(&off) {
878                if self.r4830 & 0x80 == 0 {
879                    return 0;
880                }
881                let idx = self.mcuram_addr(bank, off - 0x6000);
882                return self.ram[idx];
883            }
884            if off >= 0x8000 {
885                return self.mcurom_read(Self::mcurom_linear(bank, off));
886            }
887        }
888        // See `map`'s doc a few lines up: `40-7d` is genuinely unmapped on the real
889        // `SHVC-LDH3C-01` board, not a `c0-ff` mirror.
890        if matches!(bank, 0xc0..=0xff) {
891            return self.mcurom_read(Self::mcurom_linear(bank, off));
892        }
893        0
894    }
895
896    fn write24(&mut self, addr24: u32, val: u8) {
897        let a = addr24 & 0xff_ffff;
898        let bank = (a >> 16) & 0xff;
899        let off = a & 0xffff;
900
901        if bank == 0x50 || bank == 0x58 {
902            self.write_reg(Self::reg_addr(a), val);
903            return;
904        }
905        if matches!(bank, 0x00..=0x3f | 0x80..=0xbf) {
906            if (0x4800..=0x483f).contains(&off) {
907                self.write_reg(Self::reg_addr(a), val);
908                return;
909            }
910            if (0x4840..=0x4842).contains(&off) {
911                self.rtc.write(off - 0x4840, val);
912                return;
913            }
914            if (0x6000..=0x7fff).contains(&off) {
915                if self.r4830 & 0x80 == 0 {
916                    return;
917                }
918                let idx = self.mcuram_addr(bank, off - 0x6000);
919                self.ram[idx] = val;
920            }
921            // ROM is read-only (ares `mcuromWrite` is a no-op).
922        }
923    }
924
925    fn rom(&self) -> &[u8] {
926        &self.drom
927    }
928
929    fn sram(&self) -> &[u8] {
930        &self.ram
931    }
932
933    fn sram_mut(&mut self) -> &mut [u8] {
934        &mut self.ram
935    }
936
937    fn save_state(&self, w: &mut SaveWriter) {
938        Self::save_state(self, w);
939    }
940
941    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
942        Self::load_state(self, r)
943    }
944}
945
946/// Build a [`Spc7110Board`] for a cart detected as SPC7110 (`board::select`).
947///
948/// Splits `rom` into PROM + DROM using Far East of Eden Zero's known physical geometry: 1 MiB
949/// PROM + 4 MiB DROM — the real, physical chip sizes per ares' own board database
950/// (`mia/Database/Super Famicom.bml`, `board: SHVC-LDH3C-01`: `memory type=ROM content=Program
951/// size=0x100000` + `memory type=ROM content=Data size=0x400000`), not "however many bytes
952/// happen to be left in the file after the first `PROM_SIZE`" as an earlier version of this
953/// function assumed. That assumption was silently wrong for this project's own staged dump: it
954/// is 7 MiB (`0x700000`), 2 MiB (`0x200000`) larger than the real 5 MiB (`0x500000`) of physical
955/// ROM content, so treating "everything past 1 MiB" as DROM fed `Spc7110Board::datarom_read`'s
956/// `bus_mirror` fold a 6 MiB length instead of the real 4 MiB — silently wrong-sized mirroring
957/// for any register-selected DROM offset in the (nonexistent) upper 2 MiB. Any bytes in `rom`
958/// past `PROM_SIZE + DROM_SIZE` (padding/junk some dump tools append) are dropped. Other SPC7110
959/// titles' exact split is not derivable from a raw dump without this same kind of external
960/// database lookup — this project has exactly one SPC7110 ROM to validate against
961/// (`docs/rom-test-corpus.md`).
962#[must_use]
963pub fn select(_map_mode: MapMode, rom: Box<[u8]>, sram_size: usize) -> Spc7110Board {
964    const PROM_SIZE: usize = 0x10_0000;
965    const DROM_SIZE: usize = 0x40_0000;
966    if rom.len() > PROM_SIZE {
967        let (prom, rest) = rom.split_at(PROM_SIZE);
968        let drom_len = rest.len().min(DROM_SIZE);
969        Spc7110Board::new(
970            prom.to_vec().into_boxed_slice(),
971            rest[..drom_len].to_vec().into_boxed_slice(),
972            sram_size,
973        )
974    } else {
975        Spc7110Board::new(Box::new([]), rom, sram_size)
976    }
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    fn board() -> Spc7110Board {
984        Spc7110Board::new(
985            vec![0u8; 0x10_0000].into_boxed_slice(),
986            vec![0u8; 0x60_0000].into_boxed_slice(),
987            0x2000,
988        )
989    }
990
991    #[test]
992    fn control_register_roundtrip() {
993        let mut b = board();
994        b.write24(0x00_4801, 0xAB);
995        assert_eq!(b.read24(0x00_4801), 0xAB);
996        b.write24(0x00_4830, 0xFF); // masked to 0x87
997        assert_eq!(b.read24(0x00_4830), 0x87);
998    }
999
1000    #[test]
1001    fn bank_50_and_58_mirror_the_data_port_and_dummy_register() {
1002        let mut b = board();
1003        // Bank $58 is always a dummy 0 read regardless of offset.
1004        assert_eq!(b.read24(0x58_1234), 0x00);
1005        // Bank $50 mirrors the decompression data-read port ($4800); with no active stream
1006        // (r480c bit7 clear) it returns 0 rather than panicking.
1007        assert_eq!(b.read24(0x50_ABCD), 0x00);
1008    }
1009
1010    #[test]
1011    fn alu_multiply_unsigned() {
1012        let mut b = board();
1013        b.write24(0x00_4820, 5); // multiplicand low
1014        b.write24(0x00_4821, 0);
1015        b.write24(0x00_4824, 3); // multiplier low
1016        b.write24(0x00_4825, 0); // arms the multiply (deferred one master-clock tick, see
1017        // `dcu_pending`'s doc)
1018        assert_ne!(
1019            b.read24(0x00_482f) & 0x80,
1020            0,
1021            "the busy bit must be set immediately on the triggering write, before the tick"
1022        );
1023        b.coprocessor_tick();
1024        assert_eq!(
1025            b.read24(0x00_482f) & 0x80,
1026            0,
1027            "the busy bit clears once the deferred multiply actually runs"
1028        );
1029        assert_eq!(b.read24(0x00_4828), 15);
1030        assert_eq!(b.read24(0x00_4829), 0);
1031    }
1032
1033    #[test]
1034    fn alu_divide_unsigned() {
1035        let mut b = board();
1036        b.write24(0x00_4820, 10);
1037        b.write24(0x00_4821, 0);
1038        b.write24(0x00_4822, 0);
1039        b.write24(0x00_4823, 0);
1040        b.write24(0x00_4826, 3);
1041        b.write24(0x00_4827, 0); // arms the divide (deferred one master-clock tick)
1042        b.coprocessor_tick();
1043        assert_eq!(b.read24(0x00_4828), 3); // quotient
1044        assert_eq!(b.read24(0x00_482c), 1); // remainder
1045    }
1046
1047    /// `v0.8.0`, T-81-001b's sibling fix: a DCU-begin trigger (`$4806`) is likewise deferred one
1048    /// master-clock tick, matching ares' `SPC7110::main()`'s `dcuPending` handling exactly — the
1049    /// synchronous same-write completion an earlier draft had is the root cause narrowed (not yet
1050    /// fixed) in `docs/audit/spc7110-boot-crash-2026-07-08.md`.
1051    #[test]
1052    fn dcu_begin_transfer_is_deferred_one_tick() {
1053        let mut b = board();
1054        // Program a trivial 1-byte DCU table entry (mode 0, address 0) so `dcu_begin_transfer`
1055        // has something well-defined to decode, then arm it via `$4806`.
1056        b.write24(0x00_4801, 0);
1057        b.write24(0x00_4802, 0);
1058        b.write24(0x00_4803, 0);
1059        b.write24(0x00_4804, 0); // loads mode=drom[0], address=drom[1..4] (all zero in a fresh board)
1060        b.write24(0x00_4806, 0); // arms the DCU transfer
1061        assert_eq!(
1062            b.read24(0x00_480c) & 0x80,
1063            0,
1064            "the ready bit must be clear immediately after the triggering write, before the tick"
1065        );
1066        b.coprocessor_tick();
1067        assert_ne!(
1068            b.read24(0x00_480c) & 0x80,
1069            0,
1070            "the ready bit sets once the deferred transfer actually runs"
1071        );
1072    }
1073
1074    #[test]
1075    fn mcurom_linear_folds_both_windows_to_the_same_space() {
1076        // bank 00 offset 8000-ffff and bank c0 offset 0000-ffff both land under 0x100000.
1077        assert!(Spc7110Board::mcurom_linear(0x00, 0x8000) < 0x10_0000);
1078        assert!(Spc7110Board::mcurom_linear(0xc0, 0x0000) < 0x10_0000);
1079    }
1080
1081    #[test]
1082    fn register_and_rtc_state_round_trips_through_save_state() {
1083        let mut b = board();
1084        b.write24(0x00_4801, 0xAB);
1085        b.write24(0x00_4830, 0xFF); // masked to 0x87
1086        b.rtc.write(0, 1); // RTC chip select
1087        b.rtc.write(1, 0x03); // mode: write
1088        b.rtc.write(1, 0x00); // seek to offset 0
1089        b.rtc.write(1, 0x07); // write secondlo = 7
1090
1091        let mut w = SaveWriter::new();
1092        b.save_state(&mut w);
1093        let bytes = w.into_bytes();
1094
1095        let mut fresh = board();
1096        let mut r = SaveReader::new(&bytes);
1097        fresh.load_state(&mut r).unwrap();
1098
1099        assert_eq!(fresh.read24(0x00_4801), 0xAB);
1100        assert_eq!(fresh.read24(0x00_4830), 0x87);
1101        // Read the restored RTC clock field back out through its own public read/seek protocol
1102        // (mirrors epsonrtc.rs's own round-trip test — `secondlo` is private to that module).
1103        // Deselect then reselect first: the restored state is already mid-Write with chipselect
1104        // already 1, and a same-value chipselect write does NOT reset the state machine (matches
1105        // real hardware — see epsonrtc.rs's own round-trip test for the identical deselect step).
1106        fresh.rtc.write(0, 0);
1107        fresh.rtc.write(0, 1);
1108        fresh.rtc.write(1, 0x0c); // mode: read
1109        fresh.rtc.write(1, 0x00); // seek to offset 0
1110        assert_eq!(fresh.rtc.read(1), 7);
1111        assert_eq!(r.remaining(), 0);
1112    }
1113}