Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_mc/
fs.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2//! The standard PlayStation 1 memory-card filesystem, on top of any [`Block`].
3//!
4//! Layout (matching the BIOS / PCSX-Redux formatted image, so saves are visible
5//! in the console's card manager):
6//!
7//! ```text
8//! block 0  = directory
9//!   frame 0        "MC" header + XOR checksum
10//!   frames 1..=15  one directory entry per data block 1..=15
11//!   frames 16..=35 broken-sector list (0xFF)
12//! blocks 1..=15 = 8 KiB data blocks
13//!   a saved file's first block:
14//!     frame 0      "SC" title header + 16-colour icon CLUT
15//!     frame 1      16x16 4bpp icon
16//!     frames 2..63 payload
17//!   continuation blocks: 64 payload frames
18//! ```
19//!
20//! Directory entry (128 bytes):
21//! `[0]` alloc state, `[4..8]` size LE, `[8..10]` next-block link LE,
22//! `[10..30]` NUL-terminated name, `[127]` XOR checksum of `[0..127)`.
23
24use crate::{
25    Block, Card, Entry, Error, Result, SaveIcon, CONTAINER_LEN, CONTAINER_MAGIC, DATA_BLOCKS,
26    FLAG_COMPRESSED, FRAMES_PER_BLOCK, FRAME_SIZE, MAX_NAME_LEN,
27};
28
29// Directory entry field offsets.
30const E_STATE: usize = 0;
31const E_SIZE: usize = 4;
32const E_LINK: usize = 8;
33const E_NAME: usize = 10;
34const E_CHK: usize = 127;
35
36// Allocation states.
37const ST_FIRST: u8 = 0x51;
38const ST_MIDDLE: u8 = 0x52;
39const ST_LAST: u8 = 0x53;
40const ST_FREE: u8 = 0xA0;
41/// A block is free if its state's high nibble is `0xA` (formatted or deleted).
42fn is_free(state: u8) -> bool {
43    state & 0xF0 == 0xA0
44}
45
46// Title (`SC`) header offsets within a file's first frame.
47const T_ICON_FLAG: usize = 2;
48const T_BLOCKS: usize = 3;
49const T_TITLE: usize = 4;
50const T_TITLE_LEN: usize = 64;
51const T_CLUT: usize = 0x60;
52
53const LINK_NONE: u16 = 0xFFFF;
54/// Payload frames in a file's first block (title + icon consume 2 frames).
55const FIRST_BLOCK_FRAMES: usize = FRAMES_PER_BLOCK - 2;
56/// Payload bytes in a file's first block.
57const FIRST_BLOCK_CAP: usize = FIRST_BLOCK_FRAMES * FRAME_SIZE; // 7936
58/// Payload bytes in a continuation block.
59const BLOCK_CAP: usize = FRAMES_PER_BLOCK * FRAME_SIZE; // 8192
60
61/// XOR of a frame's first 127 bytes (the directory checksum).
62fn checksum(frame: &[u8; FRAME_SIZE]) -> u8 {
63    frame[..E_CHK].iter().fold(0u8, |a, &b| a ^ b)
64}
65
66/// Blocks needed to hold `total` payload bytes (including the container header).
67fn blocks_for(total: usize) -> usize {
68    if total <= FIRST_BLOCK_CAP {
69        1
70    } else {
71        1 + (total - FIRST_BLOCK_CAP).div_ceil(BLOCK_CAP)
72    }
73}
74
75fn write_u32(buf: &mut [u8], v: u32) {
76    buf[0..4].copy_from_slice(&v.to_le_bytes());
77}
78fn read_u32(buf: &[u8]) -> u32 {
79    u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])
80}
81
82// --------------------------------------------------------------------------
83// A file's block chain: physical block numbers (1..=15), in order.
84// --------------------------------------------------------------------------
85
86struct Chain {
87    blocks: [u8; DATA_BLOCKS],
88    len: usize,
89}
90
91/// The directory entries of the files a write replaces, one bit per entry.
92struct Replaced {
93    first_entries: u16,
94    all_entries: u16,
95}
96
97// --------------------------------------------------------------------------
98// Sequential reader over a file's payload region (skips title+icon frames).
99// --------------------------------------------------------------------------
100
101struct DataCursor<'a> {
102    blocks: &'a [u8],
103    li: usize,  // index into `blocks`
104    fib: usize, // frame-in-block (starts at 2 for the first block)
105    off: usize, // byte within the current frame
106    frame: [u8; FRAME_SIZE],
107    loaded: bool,
108}
109
110impl<'a> DataCursor<'a> {
111    fn new(blocks: &'a [u8]) -> Self {
112        DataCursor {
113            blocks,
114            li: 0,
115            fib: 2, // skip title + icon frames of the first block
116            off: 0,
117            frame: [0; FRAME_SIZE],
118            loaded: false,
119        }
120    }
121
122    fn next_byte<B: Block>(&mut self, dev: &mut B) -> Result<u8> {
123        if self.li >= self.blocks.len() {
124            return Err(Error::Corrupt);
125        }
126        if !self.loaded {
127            let phys = self.blocks[self.li] as u16 * FRAMES_PER_BLOCK as u16 + self.fib as u16;
128            dev.read_frame(phys, &mut self.frame)?;
129            self.loaded = true;
130        }
131        let b = self.frame[self.off];
132        self.off += 1;
133        if self.off == FRAME_SIZE {
134            self.off = 0;
135            self.loaded = false;
136            self.fib += 1;
137            if self.fib == FRAMES_PER_BLOCK {
138                self.fib = 0;
139                self.li += 1;
140            }
141        }
142        Ok(b)
143    }
144
145    fn read_exact<B: Block>(&mut self, dev: &mut B, out: &mut [u8]) -> Result<()> {
146        for b in out.iter_mut() {
147            *b = self.next_byte(dev)?;
148        }
149        Ok(())
150    }
151}
152
153// --------------------------------------------------------------------------
154// Sequential writer over a file's payload region.
155// --------------------------------------------------------------------------
156
157struct DataWriter<'a> {
158    blocks: &'a [u8],
159    li: usize,
160    fib: usize,
161    off: usize,
162    frame: [u8; FRAME_SIZE],
163}
164
165impl<'a> DataWriter<'a> {
166    fn new(blocks: &'a [u8]) -> Self {
167        DataWriter {
168            blocks,
169            li: 0,
170            fib: 2,
171            off: 0,
172            frame: [0; FRAME_SIZE],
173        }
174    }
175
176    fn push<B: Block>(&mut self, dev: &mut B, byte: u8) -> Result<()> {
177        self.frame[self.off] = byte;
178        self.off += 1;
179        if self.off == FRAME_SIZE {
180            self.flush_frame(dev)?;
181        }
182        Ok(())
183    }
184
185    fn flush_frame<B: Block>(&mut self, dev: &mut B) -> Result<()> {
186        if self.li >= self.blocks.len() {
187            return Err(Error::NoSpace);
188        }
189        let phys = self.blocks[self.li] as u16 * FRAMES_PER_BLOCK as u16 + self.fib as u16;
190        dev.write_frame(phys, &self.frame)?;
191        self.off = 0;
192        self.frame = [0; FRAME_SIZE];
193        self.fib += 1;
194        if self.fib == FRAMES_PER_BLOCK {
195            self.fib = 0;
196            self.li += 1;
197        }
198        Ok(())
199    }
200
201    /// Flush a final partial frame (zero-padded).
202    fn finish<B: Block>(&mut self, dev: &mut B) -> Result<()> {
203        if self.off > 0 {
204            self.flush_frame(dev)?;
205        }
206        Ok(())
207    }
208}
209
210// --------------------------------------------------------------------------
211// Filesystem operations on Card.
212// --------------------------------------------------------------------------
213
214impl<B: Block> Card<B> {
215    /// Read directory entry `index`, refusing one whose XOR checksum does not
216    /// match. Every operation walks these entries, so a flipped link or state
217    /// byte must stop it before it frees or reuses another save's blocks.
218    fn read_dir(&mut self, index: usize) -> Result<[u8; FRAME_SIZE]> {
219        let mut f = [0u8; FRAME_SIZE];
220        self.dev.read_frame(1 + index as u16, &mut f)?;
221        if checksum(&f) != f[E_CHK] {
222            return Err(Error::Corrupt);
223        }
224        Ok(f)
225    }
226
227    fn write_dir(&mut self, index: usize, frame: &mut [u8; FRAME_SIZE]) -> Result<()> {
228        frame[E_CHK] = checksum(frame);
229        self.dev.write_frame(1 + index as u16, frame)
230    }
231
232    /// True if the card carries the `MC` directory header.
233    pub fn is_formatted(&mut self) -> Result<bool> {
234        let mut f = [0u8; FRAME_SIZE];
235        self.dev.read_frame(0, &mut f)?;
236        Ok(f[0] == b'M' && f[1] == b'C')
237    }
238
239    /// Validate the directory structure shared by strict validation and the
240    /// narrowly-scoped legacy-size repair path.
241    fn validate_structure(&mut self) -> Result<()> {
242        let mut header = [0u8; FRAME_SIZE];
243        self.dev.read_frame(0, &mut header)?;
244        if header[0] != b'M' || header[1] != b'C' {
245            return Err(Error::NotFormatted);
246        }
247        if checksum(&header) != header[E_CHK] {
248            return Err(Error::Corrupt);
249        }
250
251        let mut states = [0u8; DATA_BLOCKS];
252        let mut links = [LINK_NONE; DATA_BLOCKS];
253        for i in 0..DATA_BLOCKS {
254            let entry = self.read_dir(i)?;
255            let state = entry[E_STATE];
256            if !is_free(state) && !matches!(state, ST_FIRST | ST_MIDDLE | ST_LAST) {
257                return Err(Error::Corrupt);
258            }
259            states[i] = state;
260            links[i] = u16::from_le_bytes([entry[E_LINK], entry[E_LINK + 1]]);
261        }
262
263        let mut visited = 0u16;
264        for first in 0..DATA_BLOCKS {
265            if states[first] != ST_FIRST {
266                continue;
267            }
268            let mut current = first;
269            let mut first_node = true;
270            loop {
271                let bit = 1u16 << current;
272                if visited & bit != 0 {
273                    return Err(Error::Corrupt);
274                }
275                visited |= bit;
276
277                let state = states[current];
278                if first_node {
279                    if state != ST_FIRST {
280                        return Err(Error::Corrupt);
281                    }
282                    first_node = false;
283                } else if !matches!(state, ST_MIDDLE | ST_LAST) {
284                    return Err(Error::Corrupt);
285                }
286
287                let link = links[current];
288                if state == ST_LAST || (state == ST_FIRST && link == LINK_NONE) {
289                    if link != LINK_NONE {
290                        return Err(Error::Corrupt);
291                    }
292                    break;
293                }
294                if link == LINK_NONE || link as usize >= DATA_BLOCKS {
295                    return Err(Error::Corrupt);
296                }
297                current = link as usize;
298            }
299        }
300
301        // `i` is both a slot index and a bit position in `visited`, so the
302        // iterator form would still need the counter.
303        #[allow(clippy::needless_range_loop)]
304        for i in 0..DATA_BLOCKS {
305            if matches!(states[i], ST_MIDDLE | ST_LAST) && visited & (1u16 << i) == 0 {
306                return Err(Error::Corrupt);
307            }
308        }
309        Ok(())
310    }
311
312    /// Validate the directory before a safety-critical write.
313    ///
314    /// This checks the `MC` header, every directory-sector checksum, every
315    /// allocation state and link, rejects loops/cross-links and orphan
316    /// continuation blocks, and requires each first entry's file size to
317    /// equal its allocated chain in 8 KiB units as required by the Sony BIOS.
318    /// It performs no writes.
319    pub fn validate_filesystem(&mut self) -> Result<()> {
320        self.validate_structure()?;
321        for first in 0..DATA_BLOCKS {
322            let entry = self.read_dir(first)?;
323            if entry[E_STATE] != ST_FIRST {
324                continue;
325            }
326            let chain = self.chain(first)?;
327            let expected = (chain.len * BLOCK_CAP) as u32;
328            if read_u32(&entry[E_SIZE..]) != expected {
329                return Err(Error::Corrupt);
330            }
331        }
332        Ok(())
333    }
334
335    /// Lay down a fresh, empty directory (byte-identical to a BIOS format's
336    /// system area). Data blocks are left as-is; the directory marks them free.
337    pub fn format(&mut self) -> Result<()> {
338        // Frame 0: "MC" header.
339        let mut hdr = [0u8; FRAME_SIZE];
340        hdr[0] = b'M';
341        hdr[1] = b'C';
342        hdr[E_CHK] = checksum(&hdr);
343        self.dev.write_frame(0, &hdr)?;
344
345        // Frames 1..=15: free directory entries.
346        for i in 0..DATA_BLOCKS {
347            let mut e = [0u8; FRAME_SIZE];
348            e[E_STATE] = ST_FREE;
349            e[E_LINK] = 0xFF;
350            e[E_LINK + 1] = 0xFF;
351            self.write_dir(i, &mut e)?;
352        }
353
354        // Frames 16..=35: broken-sector list (all "no broken sector").
355        for frame in 16..36u16 {
356            let mut f = [0u8; FRAME_SIZE];
357            f[0] = 0xFF;
358            f[1] = 0xFF;
359            f[2] = 0xFF;
360            f[3] = 0xFF;
361            f[8] = 0xFF;
362            f[9] = 0xFF;
363            self.dev.write_frame(frame, &f)?;
364        }
365        // Remaining system frames 36..=63: cleared.
366        let zero = [0u8; FRAME_SIZE];
367        for frame in 36..FRAMES_PER_BLOCK as u16 {
368            self.dev.write_frame(frame, &zero)?;
369        }
370        Ok(())
371    }
372
373    /// Number of free 8 KiB blocks.
374    pub fn free_blocks(&mut self) -> Result<usize> {
375        let mut n = 0;
376        for i in 0..DATA_BLOCKS {
377            if is_free(self.read_dir(i)?[E_STATE]) {
378                n += 1;
379            }
380        }
381        Ok(n)
382    }
383
384    /// Find the first-block directory index of `name`, if present.
385    fn find(&mut self, name: &[u8]) -> Result<Option<usize>> {
386        for i in 0..DATA_BLOCKS {
387            let e = self.read_dir(i)?;
388            if e[E_STATE] == ST_FIRST && entry_name_eq(&e, name) {
389                return Ok(Some(i));
390            }
391        }
392        Ok(None)
393    }
394
395    /// Follow the link chain from a first-block entry.
396    fn chain(&mut self, first: usize) -> Result<Chain> {
397        let mut blocks = [0u8; DATA_BLOCKS];
398        let mut len = 0;
399        let mut idx = first;
400        loop {
401            if len >= DATA_BLOCKS {
402                return Err(Error::Corrupt); // loop / overrun
403            }
404            let e = self.read_dir(idx)?;
405            blocks[len] = (idx + 1) as u8; // physical block = dir index + 1
406            len += 1;
407            let link = u16::from_le_bytes([e[E_LINK], e[E_LINK + 1]]);
408            match e[E_STATE] {
409                ST_LAST => break,
410                ST_FIRST | ST_MIDDLE => {
411                    if link == LINK_NONE {
412                        break; // single-block file (first with no link)
413                    }
414                    if link as usize >= DATA_BLOCKS {
415                        return Err(Error::Corrupt);
416                    }
417                    idx = link as usize;
418                }
419                _ => return Err(Error::Corrupt),
420            }
421        }
422        Ok(Chain { blocks, len })
423    }
424
425    /// List all files. Returns the number written to `out` (capped at its len).
426    pub fn list(&mut self, out: &mut [Entry]) -> Result<usize> {
427        let mut n = 0;
428        for i in 0..DATA_BLOCKS {
429            let e = self.read_dir(i)?;
430            if e[E_STATE] != ST_FIRST {
431                continue;
432            }
433            if n >= out.len() {
434                break;
435            }
436            let mut name = [0u8; MAX_NAME_LEN + 1];
437            let name_len = copy_entry_name(&e, &mut name);
438            let blocks = self.chain(i)?.len as u8;
439            out[n] = Entry {
440                name,
441                name_len,
442                blocks,
443            };
444            n += 1;
445        }
446        Ok(n)
447    }
448
449    /// Delete `name`, freeing its blocks. `Ok(())` even if it did not exist.
450    pub fn delete(&mut self, name: &str) -> Result<()> {
451        let name = name.as_bytes();
452        let Some(first) = self.find(name)? else {
453            return Ok(());
454        };
455        let chain = self.chain(first)?;
456        for k in 0..chain.len {
457            let index = chain.blocks[k] as usize - 1;
458            let mut e = [0u8; FRAME_SIZE];
459            e[E_STATE] = ST_FREE;
460            e[E_LINK] = 0xFF;
461            e[E_LINK + 1] = 0xFF;
462            self.write_dir(index, &mut e)?;
463        }
464        Ok(())
465    }
466
467    /// Write a save (uncompressed), replacing any existing file of the same
468    /// name. The old save stays on the card until the new one is complete, so a
469    /// failed or interrupted write leaves it readable; the price is that the
470    /// card needs room for both copies at once, and a write that does not fit
471    /// returns [`Error::NoSpace`] without touching the card. `name` is the BIOS file name (product code + label, <= 20 ASCII);
472    /// `title` is the human-readable label shown by the card manager (<= 32
473    /// ASCII).
474    pub fn write(&mut self, name: &str, title: &str, data: &[u8]) -> Result<()> {
475        self.write_inner(name, title, data, false, data.len() as u32, None)
476    }
477
478    /// Write an uncompressed save with a caller-supplied native 16×16 icon.
479    pub fn write_with_icon(
480        &mut self,
481        name: &str,
482        title: &str,
483        data: &[u8],
484        icon: &SaveIcon,
485    ) -> Result<()> {
486        self.write_inner(name, title, data, false, data.len() as u32, Some(icon))
487    }
488
489    /// Write a save, compressing the payload with LZSS when it helps. `scratch`
490    /// receives the compressor output and must be at least `data.len()` bytes;
491    /// if compression does not shrink the data, it is stored as-is.
492    #[cfg(feature = "compress")]
493    pub fn write_compressed(
494        &mut self,
495        name: &str,
496        title: &str,
497        data: &[u8],
498        scratch: &mut [u8],
499    ) -> Result<()> {
500        match crate::compress::compress(data, scratch) {
501            Some(clen) if clen < data.len() => {
502                self.write_inner(name, title, &scratch[..clen], true, data.len() as u32, None)
503            }
504            _ => self.write_inner(name, title, data, false, data.len() as u32, None),
505        }
506    }
507
508    fn write_inner(
509        &mut self,
510        name: &str,
511        title: &str,
512        stored: &[u8],
513        compressed: bool,
514        raw_len: u32,
515        icon: Option<&SaveIcon>,
516    ) -> Result<()> {
517        let name = name.as_bytes();
518        validate_name(name)?;
519        validate_title(title)?;
520
521        let total = CONTAINER_LEN + stored.len();
522        let need = blocks_for(total);
523        if need > DATA_BLOCKS {
524            return Err(Error::NoSpace);
525        }
526
527        // An existing same-name file stays on the card, untouched, until the
528        // new one is complete. Remember its blocks so they can be released last.
529        let old = self.same_name_blocks(name)?;
530
531        // Allocate `need` directory indices from blocks that are free now. The
532        // old file's blocks are not free, so a replacement needs room for both
533        // copies, and one that does not fit is refused before any write.
534        let mut alloc = [0u8; DATA_BLOCKS];
535        let mut got = 0;
536        for i in 0..DATA_BLOCKS {
537            if got == need {
538                break;
539            }
540            if is_free(self.read_dir(i)?[E_STATE]) {
541                alloc[got] = i as u8;
542                got += 1;
543            }
544        }
545        if got < need {
546            return Err(Error::NoSpace);
547        }
548
549        // Physical block numbers (dir index + 1) for the data cursor.
550        let mut phys = [0u8; DATA_BLOCKS];
551        for k in 0..need {
552            phys[k] = alloc[k] + 1;
553        }
554
555        // Title + icon frames in the first block.
556        self.write_title(phys[0], title, need as u8, icon)?;
557
558        // Container header + payload across the chain.
559        let mut hdr = [0u8; CONTAINER_LEN];
560        hdr[0..4].copy_from_slice(&CONTAINER_MAGIC);
561        hdr[4] = if compressed { FLAG_COMPRESSED } else { 0 };
562        write_u32(&mut hdr[8..], raw_len);
563        write_u32(&mut hdr[12..], stored.len() as u32);
564
565        let mut w = DataWriter::new(&phys[..need]);
566        for &b in hdr.iter() {
567            w.push(&mut self.dev, b)?;
568        }
569        for &b in stored.iter() {
570            w.push(&mut self.dev, b)?;
571        }
572        w.finish(&mut self.dev)?;
573
574        // Directory entries with the link chain, name + size in the first. The
575        // first entry goes last: a file appears only once its whole chain is
576        // in place, so a card pulled before then still holds the old save.
577        for k in (0..need).rev() {
578            let mut e = [0u8; FRAME_SIZE];
579            e[E_STATE] = if k == 0 {
580                ST_FIRST // a single-block file is FIRST with a terminal link
581            } else if k == need - 1 {
582                ST_LAST
583            } else {
584                ST_MIDDLE
585            };
586            // Link points at the NEXT block's directory index.
587            let link = if k + 1 < need {
588                alloc[k + 1] as u16
589            } else {
590                LINK_NONE
591            };
592            e[E_LINK] = link as u8;
593            e[E_LINK + 1] = (link >> 8) as u8;
594            if k == 0 {
595                // Sony's directory field is allocated file size, not payload
596                // length: one 0x2000-byte unit for every linked card block.
597                write_u32(&mut e[E_SIZE..], (need * BLOCK_CAP) as u32);
598                let n = name.len().min(MAX_NAME_LEN);
599                e[E_NAME..E_NAME + n].copy_from_slice(&name[..n]);
600            }
601            self.write_dir(alloc[k] as usize, &mut e)?;
602        }
603
604        // The new save is whole. Release the old one, first entry first so it
605        // disappears in one frame write.
606        self.free_blocks_in(old.first_entries)?;
607        self.free_blocks_in(old.all_entries & !old.first_entries)
608    }
609
610    /// Directory indices of every file called `name`, as bit masks: the first
611    /// entries and every entry of the chains they own. Normally one file, but a
612    /// card pulled during a replacement can leave the old and new copies both.
613    fn same_name_blocks(&mut self, name: &[u8]) -> Result<Replaced> {
614        let mut found = Replaced {
615            first_entries: 0,
616            all_entries: 0,
617        };
618        for i in 0..DATA_BLOCKS {
619            let e = self.read_dir(i)?;
620            if e[E_STATE] != ST_FIRST || !entry_name_eq(&e, name) {
621                continue;
622            }
623            found.first_entries |= 1 << i;
624            let chain = self.chain(i)?;
625            for &block in &chain.blocks[..chain.len] {
626                found.all_entries |= 1 << (block - 1);
627            }
628        }
629        Ok(found)
630    }
631
632    /// Mark every directory entry in `mask` free, lowest index first.
633    fn free_blocks_in(&mut self, mask: u16) -> Result<()> {
634        for i in 0..DATA_BLOCKS {
635            if mask & (1 << i) != 0 {
636                let mut e = [0u8; FRAME_SIZE];
637                e[E_STATE] = ST_FREE;
638                e[E_LINK] = 0xFF;
639                e[E_LINK + 1] = 0xFF;
640                self.write_dir(i, &mut e)?;
641            }
642        }
643        Ok(())
644    }
645
646    /// Write the `SC` title header + default icon into a file's first block.
647    fn write_title(
648        &mut self,
649        phys_block: u8,
650        title: &str,
651        blocks: u8,
652        icon: Option<&SaveIcon>,
653    ) -> Result<()> {
654        let mut hdr = [0u8; FRAME_SIZE];
655        hdr[0] = b'S';
656        hdr[1] = b'C';
657        hdr[T_ICON_FLAG] = 0x11; // one icon frame
658        hdr[T_BLOCKS] = blocks;
659        // Title as Shift-JIS: ASCII is a single-byte subset, copied verbatim.
660        let tb = title.as_bytes();
661        let n = tb.len().min(T_TITLE_LEN);
662        hdr[T_TITLE..T_TITLE + n].copy_from_slice(&tb[..n]);
663        // Icon palette.
664        let clut = icon.map(|icon| icon.palette).unwrap_or_else(default_clut);
665        for (i, c) in clut.iter().enumerate() {
666            hdr[T_CLUT + i * 2] = *c as u8;
667            hdr[T_CLUT + i * 2 + 1] = (*c >> 8) as u8;
668        }
669        let base = phys_block as u16 * FRAMES_PER_BLOCK as u16;
670        self.dev.write_frame(base, &hdr)?;
671
672        // Icon pixels in the next frame.
673        let mut pixels = [0u8; FRAME_SIZE];
674        match icon {
675            Some(icon) => pixels.copy_from_slice(&icon.pixels),
676            None => default_icon(&mut pixels),
677        }
678        self.dev.write_frame(base + 1, &pixels)?;
679        Ok(())
680    }
681
682    /// Read a save into `buf`. Returns the payload length. Transparently
683    /// decompresses if the save was written with [`Card::write_compressed`]
684    /// (requires the `compress` feature).
685    pub fn read(&mut self, name: &str, buf: &mut [u8]) -> Result<usize> {
686        let name = name.as_bytes();
687        let first = self.find(name)?.ok_or(Error::NotFound)?;
688        let chain = self.chain(first)?;
689        let blocks = &chain.blocks[..chain.len];
690
691        let mut cur = DataCursor::new(blocks);
692        let mut hdr = [0u8; CONTAINER_LEN];
693        cur.read_exact(&mut self.dev, &mut hdr)?;
694        if hdr[0..4] != CONTAINER_MAGIC {
695            return Err(Error::BadContainer);
696        }
697        let flags = hdr[4];
698        let raw_len = read_u32(&hdr[8..]) as usize;
699        let stored_len = read_u32(&hdr[12..]) as usize;
700        if buf.len() < raw_len {
701            return Err(Error::BufferTooSmall);
702        }
703
704        if flags & FLAG_COMPRESSED == 0 {
705            // Writer invariant: uncompressed saves store the payload verbatim,
706            // so the two lengths must agree. A mismatch means a corrupt header;
707            // reject it instead of trusting stored_len (which was never checked
708            // against buf and could slice out of bounds).
709            if stored_len != raw_len {
710                return Err(Error::BadContainer);
711            }
712            cur.read_exact(&mut self.dev, &mut buf[..stored_len])?;
713            return Ok(stored_len);
714        }
715
716        // Compressed: stream the compressed bytes straight into the LZSS decoder,
717        // which uses `buf` itself as the history window (no scratch needed).
718        #[cfg(feature = "compress")]
719        {
720            let mut remaining = stored_len;
721            // A transport error ends the input the way a short stream does, so
722            // keep it: a card pulled mid-load is not a codec fault.
723            let mut transport_error = None;
724            let produced = crate::compress::decompress_from(
725                || {
726                    if remaining == 0 {
727                        return None;
728                    }
729                    remaining -= 1;
730                    match cur.next_byte(&mut self.dev) {
731                        Ok(byte) => Some(byte),
732                        Err(e) => {
733                            transport_error = Some(e);
734                            None
735                        }
736                    }
737                },
738                &mut buf[..raw_len],
739            );
740            if let Some(e) = transport_error {
741                return Err(e);
742            }
743            let produced = produced.ok_or(Error::Compression)?;
744            if produced != raw_len {
745                return Err(Error::Compression);
746            }
747            Ok(raw_len)
748        }
749        #[cfg(not(feature = "compress"))]
750        {
751            let _ = stored_len;
752            Err(Error::Compression)
753        }
754    }
755}
756
757// --------------------------------------------------------------------------
758// Name / title helpers.
759// --------------------------------------------------------------------------
760
761fn validate_name(name: &[u8]) -> Result<()> {
762    if name.is_empty() || name.len() > MAX_NAME_LEN {
763        return Err(Error::BadName);
764    }
765    if name.iter().any(|&b| !(0x20..=0x7E).contains(&b)) {
766        return Err(Error::BadName);
767    }
768    Ok(())
769}
770
771fn validate_title(title: &str) -> Result<()> {
772    if title.is_empty() || title.len() > 32 {
773        return Err(Error::BadName);
774    }
775    Ok(())
776}
777
778fn entry_name_eq(entry: &[u8; FRAME_SIZE], name: &[u8]) -> bool {
779    let mut buf = [0u8; MAX_NAME_LEN + 1];
780    let len = copy_entry_name(entry, &mut buf) as usize;
781    &buf[..len] == name
782}
783
784fn copy_entry_name(entry: &[u8; FRAME_SIZE], out: &mut [u8; MAX_NAME_LEN + 1]) -> u8 {
785    let mut len = 0;
786    while len < MAX_NAME_LEN {
787        let b = entry[E_NAME + len];
788        if b == 0 {
789            break;
790        }
791        out[len] = b;
792        len += 1;
793    }
794    len as u8
795}
796
797// --------------------------------------------------------------------------
798// Default icon (a simple framed disk so saves are visible in the manager).
799// --------------------------------------------------------------------------
800
801/// 16-entry BGR555 palette: transparent, frame, fill, accent.
802fn default_clut() -> [u16; 16] {
803    let mut c = [0u16; 16];
804    c[0] = 0x0000; // background
805    c[1] = 0x7FFF; // white frame
806    c[2] = 0x7C00; // blue fill (BGR555: blue in high bits)
807    c[3] = 0x03FF; // yellow accent
808    c
809}
810
811/// Draw a 16x16 4bpp icon: a bordered blue square with an accent corner.
812fn default_icon(out: &mut [u8; FRAME_SIZE]) {
813    for y in 0..16usize {
814        for x in 0..16usize {
815            let idx: u8 = if x == 0 || x == 15 || y == 0 || y == 15 {
816                1 // frame
817            } else if (2..=5).contains(&x) && (2..=3).contains(&y) {
818                3 // accent label strip
819            } else {
820                2 // fill
821            };
822            let byte = (y * 16 + x) / 2;
823            if x & 1 == 0 {
824                out[byte] = (out[byte] & 0xF0) | idx; // low nibble = left pixel
825            } else {
826                out[byte] = (out[byte] & 0x0F) | (idx << 4); // high nibble = right
827            }
828        }
829    }
830}