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.borrow().get(slot).map(|s| s.is_some()).unwrap_or(false)
275    }
276
277    fn delete_slot(&self, slot: usize) -> Result<(), SaveError> {
278        let mut slots = self.slots.borrow_mut();
279        if slot >= slots.len() {
280            return Err(SaveError::IoError(format!(
281                "slot index {} out of range (max {})",
282                slot,
283                slots.len() - 1
284            )));
285        }
286        slots[slot] = None;
287        Ok(())
288    }
289}
290
291// ---------------------------------------------------------------------------
292// Tests
293// ---------------------------------------------------------------------------
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    /// Mock save data: player name (up to 16 ASCII bytes), level, gold.
300    ///
301    /// Wire format: [16 bytes name (zero-padded)][1 byte level][4 bytes gold LE]
302    /// Total game data size: 21 bytes. Save slot size: 23 bytes (21 + 2 checksum).
303    #[derive(Debug, Clone, PartialEq, Eq)]
304    struct MockSave {
305        player_name: String,
306        level: u8,
307        gold: u32,
308    }
309
310    impl MockSave {
311        const NAME_LEN: usize = 16;
312    }
313
314    impl SaveData for MockSave {
315        fn serialize(&self) -> Vec<u8> {
316            let mut v = Vec::with_capacity(Self::NAME_LEN + 1 + 4);
317            let name_bytes = self.player_name.as_bytes();
318            let copy_len = name_bytes.len().min(Self::NAME_LEN);
319            v.extend_from_slice(&name_bytes[..copy_len]);
320            // Zero-pad to NAME_LEN
321            v.resize(Self::NAME_LEN, 0);
322            v.push(self.level);
323            v.extend_from_slice(&self.gold.to_le_bytes());
324            v
325        }
326
327        fn deserialize(data: &[u8]) -> Result<Self, SaveError> {
328            if data.len() < Self::NAME_LEN + 1 + 4 {
329                return Err(SaveError::InvalidData);
330            }
331            let name_bytes = &data[..Self::NAME_LEN];
332            // Find the first zero byte as terminator
333            let name_end = name_bytes.iter().position(|&b| b == 0).unwrap_or(Self::NAME_LEN);
334            let player_name = String::from_utf8(name_bytes[..name_end].to_vec())
335                .map_err(|_| SaveError::InvalidData)?;
336            let level = data[Self::NAME_LEN];
337            let gold_start = Self::NAME_LEN + 1;
338            let gold = u32::from_le_bytes([
339                data[gold_start],
340                data[gold_start + 1],
341                data[gold_start + 2],
342                data[gold_start + 3],
343            ]);
344            Ok(MockSave { player_name, level, gold })
345        }
346
347        fn save_size() -> usize {
348            // 21 bytes game data + 2 bytes checksum
349            Self::NAME_LEN + 1 + 4 + 2
350        }
351    }
352
353    // -----------------------------------------------------------------------
354    // CRC16 smoke test
355    // -----------------------------------------------------------------------
356
357    #[test]
358    fn test_crc16_known_value() {
359        // "123456789" → CRC-16/XMODEM = 0x31C3
360        let data = b"123456789";
361        assert_eq!(crc16_xmodem(data), 0x31C3);
362    }
363
364    #[test]
365    fn test_crc16_empty() {
366        assert_eq!(crc16_xmodem(b""), 0x0000);
367    }
368
369    // -----------------------------------------------------------------------
370    // Save / load round-trip
371    // -----------------------------------------------------------------------
372
373    #[test]
374    fn test_save_and_load_roundtrip() {
375        let storage = Box::new(InMemoryStorage::new());
376        let manager = SaveManager::<MockSave>::new(storage);
377
378        let original = MockSave {
379            player_name: "Ash".to_string(),
380            level: 42,
381            gold: 9999,
382        };
383
384        // Save to slot 1
385        manager.save(SaveSlot::Slot1, &original).expect("save should succeed");
386
387        // Load from slot 1
388        let loaded = manager.load(SaveSlot::Slot1).expect("load should succeed");
389
390        assert_eq!(loaded, original, "loaded data should match original");
391    }
392
393    #[test]
394    fn test_save_and_load_multiple_slots() {
395        let storage = Box::new(InMemoryStorage::new());
396        let manager = SaveManager::<MockSave>::new(storage);
397
398        let save1 = MockSave { player_name: "Red".to_string(), level: 50, gold: 5000 };
399        let save2 = MockSave { player_name: "Blue".to_string(), level: 48, gold: 4800 };
400        let save3 = MockSave { player_name: "Green".to_string(), level: 55, gold: 5500 };
401
402        manager.save(SaveSlot::Slot1, &save1).unwrap();
403        manager.save(SaveSlot::Slot2, &save2).unwrap();
404        manager.save(SaveSlot::Slot3, &save3).unwrap();
405
406        assert_eq!(manager.load(SaveSlot::Slot1).unwrap(), save1);
407        assert_eq!(manager.load(SaveSlot::Slot2).unwrap(), save2);
408        assert_eq!(manager.load(SaveSlot::Slot3).unwrap(), save3);
409    }
410
411    // -----------------------------------------------------------------------
412    // Empty slot error
413    // -----------------------------------------------------------------------
414
415    #[test]
416    fn test_load_empty_slot_returns_error() {
417        let storage = Box::new(InMemoryStorage::new());
418        let manager = SaveManager::<MockSave>::new(storage);
419
420        let result = manager.load(SaveSlot::Slot1);
421        assert!(result.is_err());
422        assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
423    }
424
425    // -----------------------------------------------------------------------
426    // Corrupted checksum error
427    // -----------------------------------------------------------------------
428
429    #[test]
430    fn test_corrupted_data_returns_checksum_error() {
431        let storage = Box::new(InMemoryStorage::new());
432
433        // Manually write corrupted data (valid payload but wrong checksum)
434        let mock = MockSave { player_name: "Ash".to_string(), level: 10, gold: 100 };
435        let raw = mock.serialize();
436        let mut payload = raw.clone();
437        // Append a deliberately wrong checksum
438        let wrong_cksum: u16 = 0xFFFF;
439        payload.extend_from_slice(&wrong_cksum.to_le_bytes());
440        storage.write(0, &payload).unwrap();
441
442        let manager = SaveManager::<MockSave>::new(storage);
443        let result = manager.load(SaveSlot::Slot1);
444        assert!(result.is_err());
445        assert_eq!(result.unwrap_err(), SaveError::InvalidChecksum);
446    }
447
448    // -----------------------------------------------------------------------
449    // List slots
450    // -----------------------------------------------------------------------
451
452    #[test]
453    fn test_list_slots() {
454        let storage = Box::new(InMemoryStorage::new());
455        let manager = SaveManager::<MockSave>::new(storage);
456
457        let slots = manager.list_slots();
458        assert_eq!(slots.len(), 3);
459        for (_slot, has_data) in &slots {
460            assert!(!has_data, "all slots should be empty initially");
461        }
462
463        let mock = MockSave { player_name: "Test".to_string(), level: 1, gold: 0 };
464        manager.save(SaveSlot::Slot2, &mock).unwrap();
465
466        let slots = manager.list_slots();
467        assert!(!slots[0].1, "slot 1 should still be empty");
468        assert!(slots[1].1, "slot 2 should have data");
469        assert!(!slots[2].1, "slot 3 should still be empty");
470    }
471
472    // -----------------------------------------------------------------------
473    // Delete slot
474    // -----------------------------------------------------------------------
475
476    #[test]
477    fn test_delete_slot() {
478        let storage = Box::new(InMemoryStorage::new());
479        let manager = SaveManager::<MockSave>::new(storage);
480
481        let mock = MockSave { player_name: "Del".to_string(), level: 7, gold: 77 };
482        manager.save(SaveSlot::Slot1, &mock).unwrap();
483        assert!(manager.list_slots()[0].1, "slot 1 should have data");
484
485        manager.delete(SaveSlot::Slot1).unwrap();
486        assert!(!manager.list_slots()[0].1, "slot 1 should be empty after delete");
487
488        let result = manager.load(SaveSlot::Slot1);
489        assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
490    }
491
492    // -----------------------------------------------------------------------
493    // SaveSlot enum
494    // -----------------------------------------------------------------------
495
496    #[test]
497    fn test_save_slot_indices() {
498        assert_eq!(SaveSlot::Slot1.index(), 0);
499        assert_eq!(SaveSlot::Slot2.index(), 1);
500        assert_eq!(SaveSlot::Slot3.index(), 2);
501    }
502
503    #[test]
504    fn test_save_slot_all() {
505        let all = SaveSlot::all();
506        assert_eq!(all.len(), 3);
507        assert_eq!(all[0], SaveSlot::Slot1);
508        assert_eq!(all[1], SaveSlot::Slot2);
509        assert_eq!(all[2], SaveSlot::Slot3);
510    }
511
512    // -----------------------------------------------------------------------
513    // Save overwrite
514    // -----------------------------------------------------------------------
515
516    #[test]
517    fn test_overwrite_slot() {
518        let storage = Box::new(InMemoryStorage::new());
519        let manager = SaveManager::<MockSave>::new(storage);
520
521        let first = MockSave { player_name: "First".to_string(), level: 10, gold: 100 };
522        manager.save(SaveSlot::Slot1, &first).unwrap();
523
524        let second = MockSave { player_name: "Second".to_string(), level: 20, gold: 200 };
525        manager.save(SaveSlot::Slot1, &second).unwrap();
526
527        let loaded = manager.load(SaveSlot::Slot1).unwrap();
528        assert_eq!(loaded, second, "overwritten slot should return new data");
529        assert_ne!(loaded, first);
530    }
531}