Skip to main content

rustysnes_cart/coproc/
sharprtc.rs

1//! The Sharp S-RTC standalone real-time clock — the only commercial cart is Daikaijuu
2//! Monogatari II (an ExHiROM title; ares board `EXHIROM-RAM-SHARPRTC`).
3//!
4//! Clean-room port of ares' `SharpRTC` (ISC, `sfc/coprocessor/sharprtc/`): a 2-register (`$2800`
5//! data, `$2801` unused/pass-through) handshake that walks a 13-slot clock register file
6//! (second/minute/hour/day/month/year, each as one or two decimal digits, plus an
7//! auto-computed weekday) through a `Ready -> Command -> Write` or `-> Read` state machine
8//! driven entirely by magic values written to `$2800` (`$0D`=enter read mode, `$0E`=enter
9//! command mode, then `$00`=write / `$04`=reset-to-epoch as the command byte). This is a
10//! DIFFERENT chip/protocol from [`crate::coproc::epsonrtc::EpsonRtc`] (the SPC7110-paired Epson
11//! RTC-4513) despite the similar name — distinct register windows, distinct handshake, distinct
12//! state machine, per ares treating them as two unrelated components.
13//!
14//! Like [`EpsonRtc`](crate::coproc::epsonrtc::EpsonRtc), this port seeds a fixed epoch and never
15//! advances the clock other than via explicit register writes (real wall-clock ticking would
16//! break this project's determinism contract, `docs/adr/0004`); no released game logic depends
17//! on the clock's absolute value, only on the read/write handshake completing.
18//!
19//! No commercial Daikaijuu Monogatari II dump exists in this project's local corpus, so this
20//! board has unit-test-level coverage only, not golden-framebuffer validation — the same honesty
21//! gap already carried openly for ExLoROM/PAL auto-detect (`docs/adr/0003`). The chipset-byte
22//! detection in [`crate::header`] is title-matched (best-effort, no cartridge database), mirroring
23//! the existing CX4/SPC7110 `$F`-nibble disambiguation.
24//!
25//! Bus window (ares board `EXHIROM-RAM-SHARPRTC`): `$00-3F,$80-BF:$2800-$2801` (the 2-byte
26//! handshake); ROM/SRAM otherwise delegate to the wrapped ExHiROM base board.
27
28// Chip-name jargon (RTC-4513, ...) is not Rust code.
29#![allow(clippy::doc_markdown)]
30
31use alloc::boxed::Box;
32
33use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
34
35use crate::board::{Board, Coprocessor, MappedAddr};
36
37/// Days in each month (non-leap), ares `SharpRTC::daysInMonth` — used only by
38/// [`calculate_weekday`]'s per-month accumulation (the deterministic port never ticks the clock,
39/// so [`SharpRtcBoard`]'s own `tick*` family from ares is intentionally not ported).
40const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
41
42/// The S-RTC's protocol state machine (ares `SharpRTC::State`).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44enum State {
45    #[default]
46    Ready,
47    Command,
48    Read,
49    Write,
50}
51
52/// Day-of-week for `year`/`month`/`day` (`0`=Sunday .. `6`=Saturday), ares
53/// `SharpRTC::calculateWeekday`: the SharpRTC epoch is `1000-01-01` (a Wednesday), so this walks
54/// whole years then whole months accumulating day counts (clamping `year`/`month`/`day` to the
55/// chip's valid range first, exactly as ares does) rather than using a general Gregorian formula.
56fn calculate_weekday(year: u32, month: u32, day: u32) -> u32 {
57    let year = year.max(1000);
58    let month = month.clamp(1, 12);
59    let day = day.clamp(1, 31);
60
61    let is_leap = |y: u32| y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400));
62
63    let mut sum = 0u32;
64    let mut y = 1000u32;
65    while y < year {
66        sum += 365 + u32::from(is_leap(y));
67        y += 1;
68    }
69
70    let mut m = 1u32;
71    while m < month {
72        let days = DAYS_IN_MONTH[usize::try_from((m - 1) % 12).unwrap_or(0)];
73        let leap_month = days == 28 && is_leap(y);
74        sum += days + u32::from(leap_month);
75        m += 1;
76    }
77
78    sum += day - 1;
79    (sum + 3) % 7 // 1000-01-01 was a Wednesday
80}
81
82/// An ExHiROM cartridge carrying a standalone Sharp RTC.
83pub struct SharpRtcBoard {
84    inner: Box<dyn Board>,
85
86    second: u32,
87    minute: u32,
88    hour: u32,
89    day: u32,
90    month: u32,
91    year: u32,
92    weekday: u32,
93
94    state: State,
95    /// `-1` is the handshake's own "not yet primed" sentinel (ares `index = -1`), distinct from
96    /// every real slot `0..=12`.
97    index: i32,
98}
99
100impl core::fmt::Debug for SharpRtcBoard {
101    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
102        f.debug_struct("SharpRtcBoard")
103            .field("inner", &self.inner.name())
104            .field("second", &self.second)
105            .field("minute", &self.minute)
106            .field("hour", &self.hour)
107            .field("day", &self.day)
108            .field("month", &self.month)
109            .field("year", &self.year)
110            .field("weekday", &self.weekday)
111            .field("state", &self.state)
112            .field("index", &self.index)
113            .finish()
114    }
115}
116
117/// Classify a 24-bit CPU address into the RTC's `$2800`/`$2801` window, returning the register
118/// index (`0` or `1`) if it lands there.
119fn classify(addr24: u32) -> Option<u8> {
120    let bank = (addr24 >> 16) & 0xFF;
121    let addr = addr24 & 0xFFFF;
122    (matches!(bank, 0x00..=0x3F | 0x80..=0xBF) && matches!(addr, 0x2800 | 0x2801))
123        .then_some((addr & 1) as u8)
124}
125
126impl SharpRtcBoard {
127    /// Wrap a base board (`inner`, the cart's ExHiROM ROM/SRAM decode) with a standalone Sharp
128    /// RTC, seeded to the chip's own epoch (ares `SharpRTC::power`: `state = Read, index = -1`)
129    /// with an all-zero clock rather than the host's wall-clock time (see the module doc).
130    #[must_use]
131    pub fn new(inner: Box<dyn Board>) -> Self {
132        Self {
133            inner,
134            second: 0,
135            minute: 0,
136            hour: 0,
137            day: 0,
138            month: 0,
139            year: 0,
140            weekday: 0,
141            state: State::Read,
142            index: -1,
143        }
144    }
145
146    /// Read one BCD-ish decimal digit of the clock file (ares `SharpRTC::rtcRead`, `address` is
147    /// the 0-12 slot). Every arm is `< 100`, well within `u8` range.
148    ///
149    /// Not marked `const fn`: `SharpRtcBoard` carries a heap-allocated `inner: Box<dyn Board>`
150    /// field, so `const`-ness here is cosmetic and fragile against future field changes — the
151    /// same posture `coproc::hg51b` already documents for its own dense register-port methods.
152    #[allow(clippy::missing_const_for_fn, clippy::cast_possible_truncation)]
153    fn rtc_read(&self, address: u8) -> u8 {
154        let v = match address {
155            0 => self.second % 10,
156            1 => self.second / 10,
157            2 => self.minute % 10,
158            3 => self.minute / 10,
159            4 => self.hour % 10,
160            5 => self.hour / 10,
161            6 => self.day % 10,
162            7 => self.day / 10,
163            8 => self.month,
164            9 => self.year % 10,
165            10 => (self.year / 10) % 10,
166            11 => self.year / 100,
167            12 => self.weekday,
168            _ => 0,
169        };
170        v as u8
171    }
172
173    /// Write one decimal digit of the clock file (ares `SharpRTC::rtcWrite`).
174    ///
175    /// Not marked `const fn`: same rationale as [`Self::rtc_read`] (the enclosing struct carries
176    /// a heap-allocated `Box<dyn Board>`).
177    #[allow(clippy::missing_const_for_fn)]
178    fn rtc_write(&mut self, address: u8, data: u32) {
179        match address {
180            0 => self.second = self.second / 10 * 10 + data,
181            1 => self.second = data * 10 + self.second % 10,
182            2 => self.minute = self.minute / 10 * 10 + data,
183            3 => self.minute = data * 10 + self.minute % 10,
184            4 => self.hour = self.hour / 10 * 10 + data,
185            5 => self.hour = data * 10 + self.hour % 10,
186            6 => self.day = self.day / 10 * 10 + data,
187            7 => self.day = data * 10 + self.day % 10,
188            8 => self.month = data,
189            9 => self.year = self.year / 10 * 10 + data,
190            10 => self.year = self.year / 100 * 100 + data * 10 + self.year % 10,
191            11 => self.year = data * 100 + self.year % 100,
192            12 => self.weekday = data,
193            _ => {}
194        }
195    }
196
197    /// `$2800`/`$2801` register read (ares `SharpRTC::read`, `address & 1`). `$2801` is not a
198    /// real RTC register (ares passes through the bus's open-bus fallback); this port returns 0,
199    /// matching [`crate::coproc::epsonrtc::EpsonRtc`]'s existing convention for unhandled slots.
200    fn read_register(&mut self, address: u8) -> u8 {
201        if address != 0 || self.state != State::Read {
202            return 0;
203        }
204        if self.index < 0 {
205            self.index += 1;
206            return 15;
207        }
208        if self.index > 12 {
209            self.index = -1;
210            return 15;
211        }
212        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
213        let slot = self.index as u8;
214        self.index += 1;
215        self.rtc_read(slot)
216    }
217
218    /// `$2800`/`$2801` register write (ares `SharpRTC::write`, `address & 1`, `data & 15`).
219    fn write_register(&mut self, address: u8, data: u8) {
220        if address != 0 {
221            return;
222        }
223        let data = data & 0xF;
224        if data == 0x0D {
225            self.state = State::Read;
226            self.index = -1;
227            return;
228        }
229        if data == 0x0E {
230            self.state = State::Command;
231            return;
232        }
233        if data == 0x0F {
234            return; // unknown behavior (ares comment)
235        }
236
237        match self.state {
238            State::Command => {
239                if data == 0 {
240                    self.state = State::Write;
241                    self.index = 0;
242                } else if data == 4 {
243                    self.state = State::Ready;
244                    self.index = -1;
245                    self.second = 0;
246                    self.minute = 0;
247                    self.hour = 0;
248                    self.day = 0;
249                    self.month = 0;
250                    self.year = 0;
251                    self.weekday = 0;
252                } else {
253                    self.state = State::Ready; // unknown behavior (ares comment)
254                }
255            }
256            State::Write => {
257                if (0..12).contains(&self.index) {
258                    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
259                    let slot = self.index as u8;
260                    self.rtc_write(slot, u32::from(data));
261                    self.index += 1;
262                    if self.index == 12 {
263                        self.weekday = calculate_weekday(1000 + self.year, self.month, self.day);
264                    }
265                }
266            }
267            State::Ready | State::Read => {}
268        }
269    }
270}
271
272impl Board for SharpRtcBoard {
273    fn name(&self) -> &'static str {
274        "ExHiROM+S-RTC"
275    }
276
277    fn coprocessor(&self) -> Coprocessor {
278        Coprocessor::Srtc
279    }
280
281    fn map(&self, addr24: u32) -> MappedAddr {
282        if classify(addr24).is_some() {
283            MappedAddr::Coprocessor
284        } else {
285            self.inner.map(addr24)
286        }
287    }
288
289    fn read24(&mut self, addr24: u32) -> u8 {
290        match classify(addr24) {
291            Some(a) => self.read_register(a),
292            None => self.inner.read24(addr24),
293        }
294    }
295
296    fn write24(&mut self, addr24: u32, val: u8) {
297        if let Some(a) = classify(addr24) {
298            self.write_register(a, val);
299        } else {
300            self.inner.write24(addr24, val);
301        }
302    }
303
304    fn rom(&self) -> &[u8] {
305        self.inner.rom()
306    }
307
308    fn sram(&self) -> &[u8] {
309        self.inner.sram()
310    }
311
312    fn sram_mut(&mut self) -> &mut [u8] {
313        self.inner.sram_mut()
314    }
315
316    fn save_state(&self, w: &mut SaveWriter) {
317        w.section(*b"SRTC", |s| {
318            s.write_u32(self.second);
319            s.write_u32(self.minute);
320            s.write_u32(self.hour);
321            s.write_u32(self.day);
322            s.write_u32(self.month);
323            s.write_u32(self.year);
324            s.write_u32(self.weekday);
325            s.write_u8(match self.state {
326                State::Ready => 0,
327                State::Command => 1,
328                State::Read => 2,
329                State::Write => 3,
330            });
331            // `index` ranges -1..=13 (ares' `-1` sentinel plus slots 0-12); store as `index + 1`
332            // (0..=14) since the format has no signed-integer primitive.
333            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
334            s.write_u8((self.index + 1) as u8);
335        });
336        self.inner.save_state(w);
337    }
338
339    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
340        let mut s = r.expect_section(*b"SRTC")?;
341        // Unlike `Obc1Board`'s cursor (an out-of-range `shift` panics the next `$1FF4` access
342        // via a `<<`-overflow), NONE of the clock digits below can panic on any `u32` value:
343        // `rtc_read`/`rtc_write` only ever `%`/`/` them, which never panics. Accepting them as-is
344        // (rather than inventing bit-width bounds these decimal-digit fields don't actually
345        // have — each is built from two masked-but-not-decimal-clamped 4-bit register writes, so
346        // the real reachable range isn't a clean power-of-two mask either) matches this
347        // project's "reject only what would otherwise panic or corrupt an enum" posture.
348        let second = s.read_u32()?;
349        let minute = s.read_u32()?;
350        let hour = s.read_u32()?;
351        let day = s.read_u32()?;
352        let month = s.read_u32()?;
353        let year = s.read_u32()?;
354        let weekday = s.read_u32()?;
355        let state_byte = s.read_u8()?;
356        // `index_byte` is `index + 1` (see `save_state`); clamp to the encoding's valid 0..=14
357        // range (decodes to the real `-1..=13` sentinel-plus-slots range) rather than rejecting —
358        // `read_register`/`write_register` already re-check `index` against `0..12`/`>12` on
359        // every access, so an over-clamped value self-corrects on the very next register access.
360        let index_byte = s.read_u8()?.min(14);
361        let index = i32::from(index_byte) - 1;
362        let state = match state_byte {
363            0 => State::Ready,
364            1 => State::Command,
365            2 => State::Read,
366            3 => State::Write,
367            _ => {
368                return Err(SaveStateError::Invalid(alloc::format!(
369                    "SharpRtcBoard state discriminant {state_byte} is not a valid State variant \
370                     (0-3)"
371                )));
372            }
373        };
374        if s.remaining() != 0 {
375            return Err(SaveStateError::Invalid(alloc::format!(
376                "SRTC section has {} trailing byte(s)",
377                s.remaining()
378            )));
379        }
380        self.second = second;
381        self.minute = minute;
382        self.hour = hour;
383        self.day = day;
384        self.month = month;
385        self.year = year;
386        self.weekday = weekday;
387        self.state = state;
388        self.index = index;
389        self.inner.load_state(r)
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::board::ExHiRom;
397    use alloc::vec;
398
399    fn board() -> SharpRtcBoard {
400        let inner = Box::new(ExHiRom::new(
401            vec![0u8; 0x40_0000].into_boxed_slice(),
402            vec![0u8; 0x2000].into_boxed_slice(),
403        ));
404        SharpRtcBoard::new(inner)
405    }
406
407    #[test]
408    fn window_classify() {
409        assert_eq!(classify(0x00_2800), Some(0));
410        assert_eq!(classify(0x3F_2801), Some(1));
411        assert_eq!(classify(0x80_2800), Some(0));
412        assert_eq!(classify(0xBF_2801), Some(1));
413        assert_eq!(classify(0x00_2802), None);
414        assert_eq!(classify(0x40_2800), None); // outside 00-3f/80-bf
415    }
416
417    #[test]
418    fn power_on_state_is_read_with_sentinel_index() {
419        let b = board();
420        assert_eq!(b.state, State::Read);
421        assert_eq!(b.index, -1);
422    }
423
424    #[test]
425    fn write_then_read_clock_roundtrip() {
426        let mut b = board();
427        b.write24(0x00_2800, 0x0E); // enter Command
428        b.write24(0x00_2800, 0x00); // command: Write, index=0
429        // second=45, minute=30, hour=12, day=15, month=6, year=1024 (=> stored 24, epoch+1000=1024)
430        for digit in [5, 4, 0, 3, 2, 1, 5, 1, 6, 4, 2, 0] {
431            b.write24(0x00_2800, digit);
432        }
433        b.write24(0x00_2800, 0x0D); // enter Read
434        assert_eq!(b.read24(0x00_2800), 15); // priming read (ares: index<0 => 15, index=0)
435        let mut got = [0u8; 13];
436        for slot in &mut got {
437            *slot = b.read24(0x00_2800);
438        }
439        assert_eq!(&got[0..12], &[5, 4, 0, 3, 2, 1, 5, 1, 6, 4, 2, 0]);
440        // weekday auto-computed after the 12th write; just confirm it's a valid day index.
441        assert!(got[12] < 7);
442    }
443
444    #[test]
445    fn command_reset_zeroes_the_clock() {
446        let mut b = board();
447        b.write24(0x00_2800, 0x0E); // Command
448        b.write24(0x00_2800, 0x00); // Write, index=0
449        b.write24(0x00_2800, 9); // second lo digit = 9
450        b.write24(0x00_2800, 0x0E); // Command again
451        b.write24(0x00_2800, 0x04); // reset-to-epoch
452        assert_eq!(b.second, 0);
453        assert_eq!(b.state, State::Ready);
454    }
455
456    #[test]
457    fn rom_and_sram_delegate_to_inner_board() {
458        let mut b = board();
459        assert_eq!(b.rom().len(), 0x40_0000);
460        // ExHiROM SRAM window: banks 20-3f/a0-bf, $6000-7fff.
461        b.write24(0x20_6000, 0x42);
462        assert_eq!(b.read24(0x20_6000), 0x42);
463    }
464
465    #[test]
466    fn clock_and_handshake_state_round_trips_through_save_state() {
467        let mut b = board();
468        b.write24(0x00_2800, 0x0E);
469        b.write24(0x00_2800, 0x00);
470        b.write24(0x00_2800, 7); // second lo digit = 7
471
472        let mut w = SaveWriter::new();
473        b.save_state(&mut w);
474        let bytes = w.into_bytes();
475
476        let mut fresh = board();
477        let mut r = SaveReader::new(&bytes);
478        fresh.load_state(&mut r).unwrap();
479
480        assert_eq!(fresh.second, 7);
481        assert_eq!(fresh.state, State::Write);
482        assert_eq!(fresh.index, 1);
483        assert_eq!(r.remaining(), 0);
484    }
485
486    #[test]
487    fn out_of_range_state_discriminant_is_rejected_not_panicked_on() {
488        let b = board();
489        let mut w = SaveWriter::new();
490        b.save_state(&mut w);
491        let mut bytes = w.into_bytes();
492        // The state discriminant follows the 7 clock u32 fields — corrupt it.
493        let offset = 8 + (4 * 7); // section header (tag+len, see SaveWriter::section) + 7 u32s
494        bytes[offset] = 99;
495        let mut fresh = board();
496        let mut r = SaveReader::new(&bytes);
497        assert!(matches!(
498            fresh.load_state(&mut r),
499            Err(SaveStateError::Invalid(_))
500        ));
501    }
502
503    #[test]
504    fn weekday_epoch_is_a_wednesday() {
505        assert_eq!(calculate_weekday(1000, 1, 1), 3);
506    }
507}