Skip to main content

dotzuki_engine/save/
mod.rs

1//! Save system for JRPG engine.
2//!
3//! Provides a storage-agnostic save/load framework with CRC16 checksum
4//! validation. Game-specific data implements the [`SaveData`] trait, while
5//! platform backends implement [`SaveStorage`]. The [`SaveManager`] ties
6//! them together with multi-slot support.
7//!
8//! ## Wire format
9//!
10//! Each save slot stores: `[serialized_game_data][2-byte CRC16 checksum (LE)]`
11//!
12//! ## Example
13//!
14//! ```ignore
15//! #[derive(Debug, Clone)]
16//! struct MySave { name: String, level: u8 }
17//!
18//! impl SaveData for MySave {
19//!     fn serialize(&self) -> Vec<u8> { /* ... */ }
20//!     fn deserialize(data: &[u8]) -> Result<Self, SaveError> { /* ... */ }
21//!     fn save_size() -> usize { 64 * 1024 }
22//! }
23//!
24//! let storage = Box::new(InMemoryStorage::new());
25//! let manager = SaveManager::<MySave>::new(storage);
26//! manager.save(SaveSlot::Slot1, &my_data)?;
27//! let loaded = manager.load(SaveSlot::Slot1)?;
28//! ```
29
30use std::cell::RefCell;
31
32/// Errors that can occur during save/load operations.
33#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
34pub enum SaveError {
35    /// An I/O error occurred in the storage backend.
36    #[error("I/O error: {0}")]
37    IoError(String),
38
39    /// The stored checksum does not match the computed checksum.
40    #[error("invalid checksum — data may be corrupted")]
41    InvalidChecksum,
42
43    /// The deserialized data is structurally invalid.
44    #[error("invalid data")]
45    InvalidData,
46
47    /// The requested save slot is empty (no data stored).
48    #[error("slot is empty")]
49    SlotEmpty,
50
51    /// The requested save slot is already occupied.
52    #[error("slot is full")]
53    SlotFull,
54}
55
56/// Identifies a save slot.
57///
58/// Three save slots are supported, matching the classic JRPG convention.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum SaveSlot {
61    Slot1,
62    Slot2,
63    Slot3,
64}
65
66impl SaveSlot {
67    /// Returns the zero-based index of this slot (0, 1, or 2).
68    pub fn index(&self) -> usize {
69        match self {
70            SaveSlot::Slot1 => 0,
71            SaveSlot::Slot2 => 1,
72            SaveSlot::Slot3 => 2,
73        }
74    }
75
76    /// Returns all three save slots.
77    pub fn all() -> Vec<SaveSlot> {
78        vec![SaveSlot::Slot1, SaveSlot::Slot2, SaveSlot::Slot3]
79    }
80}
81
82/// Platform-specific storage backend for save data.
83///
84/// Implementations handle the actual reading and writing of bytes to the
85/// underlying medium (e.g. file system, browser localStorage, SRAM chip).
86/// Takes `&self` so implementations must use interior mutability.
87pub trait SaveStorage {
88    /// Write data to the given slot index (0, 1, or 2).
89    fn write(&self, slot: usize, data: &[u8]) -> Result<(), SaveError>;
90
91    /// Read data from the given slot index.
92    ///
93    /// Returns `Err(SaveError::SlotEmpty)` if the slot contains no data.
94    fn read(&self, slot: usize) -> Result<Vec<u8>, SaveError>;
95
96    /// Returns `true` if the slot contains data.
97    fn slot_exists(&self, slot: usize) -> bool;
98
99    /// Delete data from the given slot index.
100    fn delete_slot(&self, slot: usize) -> Result<(), SaveError>;
101}
102
103/// Game-specific save data that can be serialized, checksummed, and persisted.
104///
105/// Implementors define how their data is serialized to/from bytes and how
106/// large each save slot is. A default CRC16 checksum is provided.
107pub trait SaveData: Sized {
108    /// Serialize this game data to a byte vector.
109    ///
110    /// The returned data must NOT include the checksum — that is appended
111    /// automatically by [`SaveManager`].
112    fn serialize(&self) -> Vec<u8>;
113
114    /// Deserialize game data from a byte slice.
115    ///
116    /// The input does NOT include the checksum bytes — they are stripped
117    /// by [`SaveManager`] before calling this method.
118    fn deserialize(data: &[u8]) -> Result<Self, SaveError>;
119
120    /// Compute a CRC16 checksum over the given serialized game data.
121    ///
122    /// Default implementation uses CRC-16/XMODEM (polynomial `0x1021`,
123    /// initial value `0x0000`).
124    fn checksum(data: &[u8]) -> u16 {
125        crc16_xmodem(data)
126    }
127
128    /// Total size in bytes of a single save slot, including the 2-byte checksum.
129    ///
130    /// This must equal `serialize().len() + 2`.
131    fn save_size() -> usize;
132
133    /// Validate a complete save slot payload (game data + 2-byte checksum).
134    ///
135    /// Default implementation splits off the trailing 2-byte checksum and
136    /// compares it against [`checksum`](SaveData::checksum).
137    fn validate(data: &[u8]) -> bool {
138        if data.len() < 2 {
139            return false;
140        }
141        let (game_data, cksum_bytes) = data.split_at(data.len() - 2);
142        let stored_checksum = u16::from_le_bytes([cksum_bytes[0], cksum_bytes[1]]);
143        Self::checksum(game_data) == stored_checksum
144    }
145}
146
147/// Manages save/load operations across multiple slots.
148///
149/// `S` is the game-specific type implementing [`SaveData`].
150pub struct SaveManager<S: SaveData> {
151    storage: Box<dyn SaveStorage>,
152    _phantom: std::marker::PhantomData<S>,
153}
154
155impl<S: SaveData> SaveManager<S> {
156    /// Create a new save manager backed by the given storage implementation.
157    pub fn new(storage: Box<dyn SaveStorage>) -> Self {
158        Self {
159            storage,
160            _phantom: std::marker::PhantomData,
161        }
162    }
163
164    /// Save game data to the given slot.
165    ///
166    /// Serializes the data, appends a checksum, and writes to storage.
167    pub fn save(&self, slot: SaveSlot, data: &S) -> Result<(), SaveError> {
168        let raw = data.serialize();
169        let cksum = S::checksum(&raw);
170        let mut payload = raw;
171        payload.extend_from_slice(&cksum.to_le_bytes());
172        self.storage.write(slot.index(), &payload)
173    }
174
175    /// Load game data from the given slot.
176    ///
177    /// Reads from storage, validates the checksum, and deserializes.
178    pub fn load(&self, slot: SaveSlot) -> Result<S, SaveError> {
179        let payload = self.storage.read(slot.index())?;
180        if !S::validate(&payload) {
181            return Err(SaveError::InvalidChecksum);
182        }
183        let game_data = &payload[..payload.len() - 2];
184        S::deserialize(game_data)
185    }
186
187    /// List all slots and whether they contain data.
188    pub fn list_slots(&self) -> Vec<(SaveSlot, bool)> {
189        SaveSlot::all()
190            .into_iter()
191            .map(|slot| (slot, self.storage.slot_exists(slot.index())))
192            .collect()
193    }
194
195    /// Delete the save data in the given slot.
196    pub fn delete(&self, slot: SaveSlot) -> Result<(), SaveError> {
197        self.storage.delete_slot(slot.index())
198    }
199}
200
201// ---------------------------------------------------------------------------
202// CRC16 implementation
203// ---------------------------------------------------------------------------
204
205/// CRC-16/XMODEM: polynomial `0x1021`, initial value `0x0000`.
206pub fn crc16_xmodem(data: &[u8]) -> u16 {
207    let mut crc: u16 = 0;
208    for &byte in data {
209        crc ^= (byte as u16) << 8;
210        for _ in 0..8 {
211            if crc & 0x8000 != 0 {
212                crc = (crc << 1) ^ 0x1021;
213            } else {
214                crc <<= 1;
215            }
216        }
217    }
218    crc
219}
220
221// ---------------------------------------------------------------------------
222// In-memory storage (for testing)
223// ---------------------------------------------------------------------------
224
225/// An in-memory [`SaveStorage`] implementation backed by `Vec<Option<Vec<u8>>>`.
226///
227/// Intended for use in tests and as a reference implementation.
228pub struct InMemoryStorage {
229    slots: RefCell<Vec<Option<Vec<u8>>>>,
230}
231
232impl InMemoryStorage {
233    /// Create a new in-memory storage with 3 empty slots.
234    pub fn new() -> Self {
235        Self {
236            slots: RefCell::new(vec![None, None, None]),
237        }
238    }
239}
240
241impl Default for InMemoryStorage {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247impl SaveStorage for InMemoryStorage {
248    fn write(&self, slot: usize, data: &[u8]) -> Result<(), SaveError> {
249        let mut slots = self.slots.borrow_mut();
250        if slot >= slots.len() {
251            return Err(SaveError::IoError(format!(
252                "slot index {} out of range (max {})",
253                slot,
254                slots.len() - 1
255            )));
256        }
257        slots[slot] = Some(data.to_vec());
258        Ok(())
259    }
260
261    fn read(&self, slot: usize) -> Result<Vec<u8>, SaveError> {
262        let slots = self.slots.borrow();
263        if slot >= slots.len() {
264            return Err(SaveError::IoError(format!(
265                "slot index {} out of range (max {})",
266                slot,
267                slots.len() - 1
268            )));
269        }
270        slots[slot].clone().ok_or(SaveError::SlotEmpty)
271    }
272
273    fn slot_exists(&self, slot: usize) -> bool {
274        self.slots
275            .borrow()
276            .get(slot)
277            .map(|s| s.is_some())
278            .unwrap_or(false)
279    }
280
281    fn delete_slot(&self, slot: usize) -> Result<(), SaveError> {
282        let mut slots = self.slots.borrow_mut();
283        if slot >= slots.len() {
284            return Err(SaveError::IoError(format!(
285                "slot index {} out of range (max {})",
286                slot,
287                slots.len() - 1
288            )));
289        }
290        slots[slot] = None;
291        Ok(())
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Tests
297// ---------------------------------------------------------------------------
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    /// Mock save data: player name (up to 16 ASCII bytes), level, gold.
304    ///
305    /// Wire format: [16 bytes name (zero-padded)][1 byte level][4 bytes gold LE]
306    /// Total game data size: 21 bytes. Save slot size: 23 bytes (21 + 2 checksum).
307    #[derive(Debug, Clone, PartialEq, Eq)]
308    struct MockSave {
309        player_name: String,
310        level: u8,
311        gold: u32,
312    }
313
314    impl MockSave {
315        const NAME_LEN: usize = 16;
316    }
317
318    impl SaveData for MockSave {
319        fn serialize(&self) -> Vec<u8> {
320            let mut v = Vec::with_capacity(Self::NAME_LEN + 1 + 4);
321            let name_bytes = self.player_name.as_bytes();
322            let copy_len = name_bytes.len().min(Self::NAME_LEN);
323            v.extend_from_slice(&name_bytes[..copy_len]);
324            // Zero-pad to NAME_LEN
325            v.resize(Self::NAME_LEN, 0);
326            v.push(self.level);
327            v.extend_from_slice(&self.gold.to_le_bytes());
328            v
329        }
330
331        fn deserialize(data: &[u8]) -> Result<Self, SaveError> {
332            if data.len() < Self::NAME_LEN + 1 + 4 {
333                return Err(SaveError::InvalidData);
334            }
335            let name_bytes = &data[..Self::NAME_LEN];
336            // Find the first zero byte as terminator
337            let name_end = name_bytes
338                .iter()
339                .position(|&b| b == 0)
340                .unwrap_or(Self::NAME_LEN);
341            let player_name = String::from_utf8(name_bytes[..name_end].to_vec())
342                .map_err(|_| SaveError::InvalidData)?;
343            let level = data[Self::NAME_LEN];
344            let gold_start = Self::NAME_LEN + 1;
345            let gold = u32::from_le_bytes([
346                data[gold_start],
347                data[gold_start + 1],
348                data[gold_start + 2],
349                data[gold_start + 3],
350            ]);
351            Ok(MockSave {
352                player_name,
353                level,
354                gold,
355            })
356        }
357
358        fn save_size() -> usize {
359            // 21 bytes game data + 2 bytes checksum
360            Self::NAME_LEN + 1 + 4 + 2
361        }
362    }
363
364    // -----------------------------------------------------------------------
365    // CRC16 smoke test
366    // -----------------------------------------------------------------------
367
368    #[test]
369    fn test_crc16_known_value() {
370        // "123456789" → CRC-16/XMODEM = 0x31C3
371        let data = b"123456789";
372        assert_eq!(crc16_xmodem(data), 0x31C3);
373    }
374
375    #[test]
376    fn test_crc16_empty() {
377        assert_eq!(crc16_xmodem(b""), 0x0000);
378    }
379
380    // -----------------------------------------------------------------------
381    // Save / load round-trip
382    // -----------------------------------------------------------------------
383
384    #[test]
385    fn test_save_and_load_roundtrip() {
386        let storage = Box::new(InMemoryStorage::new());
387        let manager = SaveManager::<MockSave>::new(storage);
388
389        let original = MockSave {
390            player_name: "Ash".to_string(),
391            level: 42,
392            gold: 9999,
393        };
394
395        // Save to slot 1
396        manager
397            .save(SaveSlot::Slot1, &original)
398            .expect("save should succeed");
399
400        // Load from slot 1
401        let loaded = manager.load(SaveSlot::Slot1).expect("load should succeed");
402
403        assert_eq!(loaded, original, "loaded data should match original");
404    }
405
406    #[test]
407    fn test_save_and_load_multiple_slots() {
408        let storage = Box::new(InMemoryStorage::new());
409        let manager = SaveManager::<MockSave>::new(storage);
410
411        let save1 = MockSave {
412            player_name: "Red".to_string(),
413            level: 50,
414            gold: 5000,
415        };
416        let save2 = MockSave {
417            player_name: "Blue".to_string(),
418            level: 48,
419            gold: 4800,
420        };
421        let save3 = MockSave {
422            player_name: "Green".to_string(),
423            level: 55,
424            gold: 5500,
425        };
426
427        manager.save(SaveSlot::Slot1, &save1).unwrap();
428        manager.save(SaveSlot::Slot2, &save2).unwrap();
429        manager.save(SaveSlot::Slot3, &save3).unwrap();
430
431        assert_eq!(manager.load(SaveSlot::Slot1).unwrap(), save1);
432        assert_eq!(manager.load(SaveSlot::Slot2).unwrap(), save2);
433        assert_eq!(manager.load(SaveSlot::Slot3).unwrap(), save3);
434    }
435
436    // -----------------------------------------------------------------------
437    // Empty slot error
438    // -----------------------------------------------------------------------
439
440    #[test]
441    fn test_load_empty_slot_returns_error() {
442        let storage = Box::new(InMemoryStorage::new());
443        let manager = SaveManager::<MockSave>::new(storage);
444
445        let result = manager.load(SaveSlot::Slot1);
446        assert!(result.is_err());
447        assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
448    }
449
450    // -----------------------------------------------------------------------
451    // Corrupted checksum error
452    // -----------------------------------------------------------------------
453
454    #[test]
455    fn test_corrupted_data_returns_checksum_error() {
456        let storage = Box::new(InMemoryStorage::new());
457
458        // Manually write corrupted data (valid payload but wrong checksum)
459        let mock = MockSave {
460            player_name: "Ash".to_string(),
461            level: 10,
462            gold: 100,
463        };
464        let raw = mock.serialize();
465        let mut payload = raw.clone();
466        // Append a deliberately wrong checksum
467        let wrong_cksum: u16 = 0xFFFF;
468        payload.extend_from_slice(&wrong_cksum.to_le_bytes());
469        storage.write(0, &payload).unwrap();
470
471        let manager = SaveManager::<MockSave>::new(storage);
472        let result = manager.load(SaveSlot::Slot1);
473        assert!(result.is_err());
474        assert_eq!(result.unwrap_err(), SaveError::InvalidChecksum);
475    }
476
477    // -----------------------------------------------------------------------
478    // List slots
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn test_list_slots() {
483        let storage = Box::new(InMemoryStorage::new());
484        let manager = SaveManager::<MockSave>::new(storage);
485
486        let slots = manager.list_slots();
487        assert_eq!(slots.len(), 3);
488        for (_slot, has_data) in &slots {
489            assert!(!has_data, "all slots should be empty initially");
490        }
491
492        let mock = MockSave {
493            player_name: "Test".to_string(),
494            level: 1,
495            gold: 0,
496        };
497        manager.save(SaveSlot::Slot2, &mock).unwrap();
498
499        let slots = manager.list_slots();
500        assert!(!slots[0].1, "slot 1 should still be empty");
501        assert!(slots[1].1, "slot 2 should have data");
502        assert!(!slots[2].1, "slot 3 should still be empty");
503    }
504
505    // -----------------------------------------------------------------------
506    // Delete slot
507    // -----------------------------------------------------------------------
508
509    #[test]
510    fn test_delete_slot() {
511        let storage = Box::new(InMemoryStorage::new());
512        let manager = SaveManager::<MockSave>::new(storage);
513
514        let mock = MockSave {
515            player_name: "Del".to_string(),
516            level: 7,
517            gold: 77,
518        };
519        manager.save(SaveSlot::Slot1, &mock).unwrap();
520        assert!(manager.list_slots()[0].1, "slot 1 should have data");
521
522        manager.delete(SaveSlot::Slot1).unwrap();
523        assert!(
524            !manager.list_slots()[0].1,
525            "slot 1 should be empty after delete"
526        );
527
528        let result = manager.load(SaveSlot::Slot1);
529        assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
530    }
531
532    // -----------------------------------------------------------------------
533    // SaveSlot enum
534    // -----------------------------------------------------------------------
535
536    #[test]
537    fn test_save_slot_indices() {
538        assert_eq!(SaveSlot::Slot1.index(), 0);
539        assert_eq!(SaveSlot::Slot2.index(), 1);
540        assert_eq!(SaveSlot::Slot3.index(), 2);
541    }
542
543    #[test]
544    fn test_save_slot_all() {
545        let all = SaveSlot::all();
546        assert_eq!(all.len(), 3);
547        assert_eq!(all[0], SaveSlot::Slot1);
548        assert_eq!(all[1], SaveSlot::Slot2);
549        assert_eq!(all[2], SaveSlot::Slot3);
550    }
551
552    // -----------------------------------------------------------------------
553    // Save overwrite
554    // -----------------------------------------------------------------------
555
556    #[test]
557    fn test_overwrite_slot() {
558        let storage = Box::new(InMemoryStorage::new());
559        let manager = SaveManager::<MockSave>::new(storage);
560
561        let first = MockSave {
562            player_name: "First".to_string(),
563            level: 10,
564            gold: 100,
565        };
566        manager.save(SaveSlot::Slot1, &first).unwrap();
567
568        let second = MockSave {
569            player_name: "Second".to_string(),
570            level: 20,
571            gold: 200,
572        };
573        manager.save(SaveSlot::Slot1, &second).unwrap();
574
575        let loaded = manager.load(SaveSlot::Slot1).unwrap();
576        assert_eq!(loaded, second, "overwritten slot should return new data");
577        assert_ne!(loaded, first);
578    }
579}