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

psx_settings/
lib.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2//! Shared, allocator-free game preferences.
3//!
4//! The record intentionally contains only settings that recur across PSoXide
5//! games. Games remain free to keep simulation or renderer-specific options in
6//! their own save data. The byte format is explicit and checksummed so a newer
7//! executable can reject a torn or incompatible card write and fall back to
8//! its shipped defaults.
9
10#![no_std]
11#![warn(missing_docs)]
12
13use psx_pad::ActionMap;
14
15/// On-card record magic.
16pub const MAGIC: [u8; 4] = *b"PSST";
17/// Current record format version.
18pub const VERSION: u8 = 1;
19/// Header bytes before action bindings and scores.
20const HEADER_LEN: usize = 16;
21/// Bytes occupied by one action binding.
22const BINDING_LEN: usize = 4;
23/// Bytes occupied by one high score.
24const SCORE_LEN: usize = 4;
25/// Largest settings record accepted by the card helpers.
26pub const MAX_RECORD_LEN: usize = 128;
27
28/// Settings flag: invert vertical look.
29pub const FLAG_INVERT_Y: u8 = 1 << 0;
30
31/// A game profile shared by menus, input and memory-card persistence.
32///
33/// `ACTIONS` is the number of game-defined logical actions. `SCORES` is the
34/// number of high-score slots (usually one per difficulty).
35#[derive(Copy, Clone, Debug, PartialEq, Eq)]
36pub struct Profile<const ACTIONS: usize, const SCORES: usize> {
37    /// Display brightness, 0..=100.
38    pub brightness: u8,
39    /// Sound-effect volume, 0..=100.
40    pub sfx_volume: u8,
41    /// Music volume, 0..=100.
42    pub music_volume: u8,
43    /// Left-stick deadzone in raw stick counts.
44    pub move_deadzone: u8,
45    /// Right-stick deadzone in raw stick counts.
46    pub look_deadzone: u8,
47    /// Look-speed percentage.
48    pub look_speed_percent: u8,
49    /// Game-selected difficulty index.
50    pub difficulty: u8,
51    /// Bit flags such as [`FLAG_INVERT_Y`].
52    pub flags: u8,
53    /// Logical action bindings.
54    pub actions: ActionMap<ACTIONS>,
55    /// Persistent high-score slots.
56    pub high_scores: [u32; SCORES],
57}
58
59impl<const ACTIONS: usize, const SCORES: usize> Profile<ACTIONS, SCORES> {
60    /// Construct a profile with common presentation/input defaults and the
61    /// caller's game-specific action map.
62    pub const fn new(actions: ActionMap<ACTIONS>) -> Self {
63        Self {
64            brightness: 75,
65            sfx_volume: 100,
66            music_volume: 100,
67            move_deadzone: 18,
68            look_deadzone: 12,
69            look_speed_percent: 100,
70            difficulty: 1,
71            flags: 0,
72            actions,
73            high_scores: [0; SCORES],
74        }
75    }
76
77    /// Exact encoded byte length for this profile shape.
78    pub const fn encoded_len() -> usize {
79        HEADER_LEN + ACTIONS * BINDING_LEN + SCORES * SCORE_LEN
80    }
81
82    /// Clamp user-controlled values to safe shared ranges.
83    pub fn sanitize(&mut self) {
84        self.brightness = self.brightness.min(100);
85        self.sfx_volume = self.sfx_volume.min(100);
86        self.music_volume = self.music_volume.min(100);
87        self.move_deadzone = self.move_deadzone.clamp(0, 64);
88        self.look_deadzone = self.look_deadzone.clamp(0, 64);
89        self.look_speed_percent = self.look_speed_percent.clamp(25, 200);
90        self.flags &= FLAG_INVERT_Y;
91    }
92
93    /// Whether vertical look is inverted.
94    pub const fn invert_y(&self) -> bool {
95        self.flags & FLAG_INVERT_Y != 0
96    }
97
98    /// Enable or disable inverted vertical look.
99    pub fn set_invert_y(&mut self, enabled: bool) {
100        if enabled {
101            self.flags |= FLAG_INVERT_Y;
102        } else {
103            self.flags &= !FLAG_INVERT_Y;
104        }
105    }
106
107    /// Raise one high-score slot when `score` beats it. Returns `true` if the
108    /// profile changed and should be persisted.
109    pub fn submit_score(&mut self, slot: usize, score: u32) -> bool {
110        let Some(best) = self.high_scores.get_mut(slot) else {
111            return false;
112        };
113        if score <= *best {
114            return false;
115        }
116        *best = score;
117        true
118    }
119
120    /// Encode into `out`, returning the bytes used.
121    pub fn encode(&self, out: &mut [u8]) -> Result<usize, CodecError> {
122        let len = Self::encoded_len();
123        if len > MAX_RECORD_LEN || out.len() < len {
124            return Err(CodecError::BufferTooSmall);
125        }
126        out[..len].fill(0);
127        out[..4].copy_from_slice(&MAGIC);
128        out[4] = VERSION;
129        out[5] = ACTIONS as u8;
130        out[6] = SCORES as u8;
131        out[7] = self.flags;
132        out[8] = self.brightness;
133        out[9] = self.sfx_volume;
134        out[10] = self.music_volume;
135        out[11] = self.move_deadzone;
136        out[12] = self.look_deadzone;
137        out[13] = self.look_speed_percent;
138        out[14] = self.difficulty;
139        let mut cursor = HEADER_LEN;
140        for binding in self.actions.bindings() {
141            put_u16(out, cursor, binding.primary);
142            put_u16(out, cursor + 2, binding.secondary);
143            cursor += BINDING_LEN;
144        }
145        for score in self.high_scores {
146            put_u32(out, cursor, score);
147            cursor += SCORE_LEN;
148        }
149        out[15] = checksum(&out[..15], &out[HEADER_LEN..len]);
150        Ok(len)
151    }
152
153    /// Decode a profile of this exact action/score shape.
154    pub fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
155        let len = Self::encoded_len();
156        if len > MAX_RECORD_LEN || bytes.len() < len {
157            return Err(CodecError::BufferTooSmall);
158        }
159        if bytes[..4] != MAGIC {
160            return Err(CodecError::BadMagic);
161        }
162        if bytes[4] != VERSION {
163            return Err(CodecError::UnsupportedVersion);
164        }
165        if bytes[5] as usize != ACTIONS || bytes[6] as usize != SCORES {
166            return Err(CodecError::WrongShape);
167        }
168        if bytes[15] != checksum(&bytes[..15], &bytes[HEADER_LEN..len]) {
169            return Err(CodecError::BadChecksum);
170        }
171        let mut cursor = HEADER_LEN;
172        let mut bindings = [psx_pad::ActionBinding::UNBOUND; ACTIONS];
173        for binding in &mut bindings {
174            binding.primary = get_u16(bytes, cursor);
175            binding.secondary = get_u16(bytes, cursor + 2);
176            cursor += BINDING_LEN;
177        }
178        let mut high_scores = [0; SCORES];
179        for score in &mut high_scores {
180            *score = get_u32(bytes, cursor);
181            cursor += SCORE_LEN;
182        }
183        let mut profile = Self {
184            brightness: bytes[8],
185            sfx_volume: bytes[9],
186            music_volume: bytes[10],
187            move_deadzone: bytes[11],
188            look_deadzone: bytes[12],
189            look_speed_percent: bytes[13],
190            difficulty: bytes[14],
191            flags: bytes[7],
192            actions: ActionMap::new(bindings),
193            high_scores,
194        };
195        profile.sanitize();
196        Ok(profile)
197    }
198}
199
200/// Settings codec failure.
201#[derive(Copy, Clone, Debug, PartialEq, Eq)]
202pub enum CodecError {
203    /// The supplied byte slice cannot contain this profile shape.
204    BufferTooSmall,
205    /// The record is not a PSoXide settings record.
206    BadMagic,
207    /// The record belongs to an unsupported format version.
208    UnsupportedVersion,
209    /// The record carries different action or score counts.
210    WrongShape,
211    /// The record was damaged or only partly written.
212    BadChecksum,
213}
214
215/// Persistence failure from the settings layer.
216#[cfg(feature = "card")]
217#[derive(Copy, Clone, Debug, PartialEq, Eq)]
218pub enum CardError {
219    /// Memory-card filesystem or transport failure.
220    Card(psx_mc::Error),
221    /// Settings codec failure.
222    Codec(CodecError),
223}
224
225/// Save one profile into a standard PS1 memory-card file.
226///
227/// A card without the `MC` header is not written to: this returns
228/// `CardError::Card(psx_mc::Error::NotFormatted)` and leaves the card as it
229/// was, so the game can ask the player before calling
230/// [`format_and_save`]. Formatting drops every save on the card from its
231/// directory.
232#[cfg(feature = "card")]
233pub fn save<B: psx_mc::Block, const ACTIONS: usize, const SCORES: usize>(
234    card: &mut psx_mc::Card<B>,
235    name: &str,
236    title: &str,
237    profile: &Profile<ACTIONS, SCORES>,
238) -> Result<(), CardError> {
239    if !card.is_formatted().map_err(CardError::Card)? {
240        return Err(CardError::Card(psx_mc::Error::NotFormatted));
241    }
242    let mut bytes = [0u8; MAX_RECORD_LEN];
243    let len = profile.encode(&mut bytes).map_err(CardError::Codec)?;
244    card.write(name, title, &bytes[..len])
245        .map_err(CardError::Card)
246}
247
248/// Format the card if it has no `MC` header, then save the profile. Call
249/// this only after the player agreed to format: it replaces the directory of
250/// an unformatted card, and every save the card held stops being listed. A
251/// formatted card is left alone and the profile is saved onto it.
252#[cfg(feature = "card")]
253pub fn format_and_save<B: psx_mc::Block, const ACTIONS: usize, const SCORES: usize>(
254    card: &mut psx_mc::Card<B>,
255    name: &str,
256    title: &str,
257    profile: &Profile<ACTIONS, SCORES>,
258) -> Result<(), CardError> {
259    if !card.is_formatted().map_err(CardError::Card)? {
260        card.format().map_err(CardError::Card)?;
261    }
262    save(card, name, title, profile)
263}
264
265/// Load one exact profile shape from a standard PS1 memory-card file.
266#[cfg(feature = "card")]
267pub fn load<B: psx_mc::Block, const ACTIONS: usize, const SCORES: usize>(
268    card: &mut psx_mc::Card<B>,
269    name: &str,
270) -> Result<Profile<ACTIONS, SCORES>, CardError> {
271    let mut bytes = [0u8; MAX_RECORD_LEN];
272    let len = card.read(name, &mut bytes).map_err(CardError::Card)?;
273    Profile::decode(&bytes[..len]).map_err(CardError::Codec)
274}
275
276/// Save a profile to the controller-1 memory-card slot, driving the port
277/// through `port`. Existing files with the same name are replaced. A card that
278/// is not formatted is refused with `CardError::Card(psx_mc::Error::NotFormatted)`
279/// and not touched; use [`format_and_save_to_slot_one`] once the player has
280/// agreed to format.
281#[cfg(feature = "card")]
282pub fn save_to_slot_one<const ACTIONS: usize, const SCORES: usize>(
283    port: &mut psx_mc::ControllerPort,
284    name: &str,
285    title: &str,
286    profile: &Profile<ACTIONS, SCORES>,
287) -> Result<(), CardError> {
288    let mut card = psx_mc::Card::new(psx_mc::HardwareCard::on_port(port, psx_mc::Slot::One));
289    save(&mut card, name, title, profile)
290}
291
292/// [`save_to_slot_one`] on a token the caller does not hold.
293#[cfg(feature = "card")]
294#[deprecated(note = "use `save_to_slot_one` with the `ControllerPort` token")]
295pub fn save_slot_one<const ACTIONS: usize, const SCORES: usize>(
296    name: &str,
297    title: &str,
298    profile: &Profile<ACTIONS, SCORES>,
299) -> Result<(), CardError> {
300    save_to_slot_one(&mut steal_port(), name, title, profile)
301}
302
303/// [`format_and_save`] on the controller-1 memory-card slot. This is the
304/// call that can erase a card's directory; reach it only from an explicit
305/// "format this card?" answer.
306#[cfg(feature = "card")]
307pub fn format_and_save_to_slot_one<const ACTIONS: usize, const SCORES: usize>(
308    port: &mut psx_mc::ControllerPort,
309    name: &str,
310    title: &str,
311    profile: &Profile<ACTIONS, SCORES>,
312) -> Result<(), CardError> {
313    let mut card = psx_mc::Card::new(psx_mc::HardwareCard::on_port(port, psx_mc::Slot::One));
314    format_and_save(&mut card, name, title, profile)
315}
316
317/// Load a profile from the controller-1 memory-card slot, driving the port
318/// through `port`.
319#[cfg(feature = "card")]
320pub fn load_from_slot_one<const ACTIONS: usize, const SCORES: usize>(
321    port: &mut psx_mc::ControllerPort,
322    name: &str,
323) -> Result<Profile<ACTIONS, SCORES>, CardError> {
324    let mut card = psx_mc::Card::new(psx_mc::HardwareCard::on_port(port, psx_mc::Slot::One));
325    load(&mut card, name)
326}
327
328/// [`load_from_slot_one`] on a token the caller does not hold.
329#[cfg(feature = "card")]
330#[deprecated(note = "use `load_from_slot_one` with the `ControllerPort` token")]
331pub fn load_slot_one<const ACTIONS: usize, const SCORES: usize>(
332    name: &str,
333) -> Result<Profile<ACTIONS, SCORES>, CardError> {
334    load_from_slot_one(&mut steal_port(), name)
335}
336
337/// The token for a deprecated forwarder that never took one.
338#[cfg(feature = "card")]
339fn steal_port() -> psx_mc::ControllerPort {
340    // SAFETY: a token is a logic guard, not a memory-safety one (see
341    // `psx_io::periph`), and the old functions never took one.
342    unsafe { psx_mc::ControllerPort::steal() }
343}
344
345#[inline]
346fn put_u16(out: &mut [u8], offset: usize, value: u16) {
347    out[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
348}
349
350#[inline]
351fn put_u32(out: &mut [u8], offset: usize, value: u32) {
352    out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
353}
354
355#[inline]
356fn get_u16(bytes: &[u8], offset: usize) -> u16 {
357    u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
358}
359
360#[inline]
361fn get_u32(bytes: &[u8], offset: usize) -> u32 {
362    u32::from_le_bytes([
363        bytes[offset],
364        bytes[offset + 1],
365        bytes[offset + 2],
366        bytes[offset + 3],
367    ])
368}
369
370fn checksum(header: &[u8], payload: &[u8]) -> u8 {
371    let mut value = 0xA7u8;
372    for byte in header.iter().chain(payload) {
373        value = value.rotate_left(1) ^ *byte;
374    }
375    value
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use psx_pad::{button, ActionBinding};
382
383    const ACTIONS: ActionMap<2> = ActionMap::new([
384        ActionBinding::new(button::CROSS, button::CIRCLE),
385        ActionBinding::new(button::START, 0),
386    ]);
387
388    #[test]
389    fn profile_round_trips_and_sanitizes() {
390        let mut original = Profile::<2, 3>::new(ACTIONS);
391        original.brightness = 140;
392        original.look_speed_percent = 220;
393        original.set_invert_y(true);
394        original.high_scores = [7, 42, 9];
395        let mut bytes = [0u8; MAX_RECORD_LEN];
396        let len = original.encode(&mut bytes).unwrap();
397        let decoded = Profile::<2, 3>::decode(&bytes[..len]).unwrap();
398        assert_eq!(decoded.brightness, 100);
399        assert_eq!(decoded.look_speed_percent, 200);
400        assert!(decoded.invert_y());
401        assert_eq!(decoded.actions, ACTIONS);
402        assert_eq!(decoded.high_scores, [7, 42, 9]);
403    }
404
405    #[test]
406    fn checksum_rejects_a_torn_record() {
407        let profile = Profile::<2, 1>::new(ACTIONS);
408        let mut bytes = [0u8; MAX_RECORD_LEN];
409        let len = profile.encode(&mut bytes).unwrap();
410        bytes[len - 1] ^= 0x80;
411        assert_eq!(
412            Profile::<2, 1>::decode(&bytes[..len]),
413            Err(CodecError::BadChecksum)
414        );
415    }
416
417    #[cfg(feature = "card")]
418    #[test]
419    fn memory_card_round_trip_uses_normal_filesystem() {
420        let mut card = psx_mc::Card::new(psx_mc::RamCard::new());
421        card.format().unwrap();
422        let mut profile = Profile::<2, 2>::new(ACTIONS);
423        profile.high_scores = [1234, 5678];
424        save(&mut card, "BESLES-00000SETTEST1", "SETTINGS TEST", &profile).unwrap();
425        let loaded = load::<_, 2, 2>(&mut card, "BESLES-00000SETTEST1").unwrap();
426        assert_eq!(loaded, profile);
427    }
428
429    /// A card holding another game's save whose `MC` header frame was lost.
430    #[cfg(feature = "card")]
431    fn card_without_header() -> psx_mc::Card<psx_mc::RamCard> {
432        let mut card = psx_mc::Card::new(psx_mc::RamCard::new());
433        card.format().unwrap();
434        card.write("BESLES-00000OTHER001", "OTHER", b"another save")
435            .unwrap();
436        let mut image = *card.into_inner().image();
437        image[..psx_mc::FRAME_SIZE].fill(0);
438        psx_mc::Card::new(psx_mc::RamCard::from_image(&image).unwrap())
439    }
440
441    #[cfg(feature = "card")]
442    #[test]
443    fn saving_to_an_unformatted_card_is_refused_and_touches_nothing() {
444        let profile = Profile::<2, 1>::new(ACTIONS);
445        let mut card = card_without_header();
446        let before = *card.device().image();
447        assert_eq!(
448            save(&mut card, "BESLES-00000SETTEST1", "SETTINGS TEST", &profile),
449            Err(CardError::Card(psx_mc::Error::NotFormatted))
450        );
451        assert_eq!(card.device().image(), &before);
452    }
453
454    #[cfg(feature = "card")]
455    #[test]
456    fn formatting_is_a_separate_explicit_call() {
457        let profile = Profile::<2, 1>::new(ACTIONS);
458        // A blank card is formatted and written.
459        let mut blank = psx_mc::Card::new(psx_mc::RamCard::new());
460        format_and_save(
461            &mut blank,
462            "BESLES-00000SETTEST1",
463            "SETTINGS TEST",
464            &profile,
465        )
466        .unwrap();
467        assert_eq!(
468            load::<_, 2, 1>(&mut blank, "BESLES-00000SETTEST1").unwrap(),
469            profile
470        );
471        // A formatted card keeps its other saves.
472        let mut card = psx_mc::Card::new(psx_mc::RamCard::new());
473        card.format().unwrap();
474        card.write("BESLES-00000OTHER001", "OTHER", b"another save")
475            .unwrap();
476        format_and_save(&mut card, "BESLES-00000SETTEST1", "SETTINGS TEST", &profile).unwrap();
477        let mut buf = [0u8; 32];
478        let n = card.read("BESLES-00000OTHER001", &mut buf).unwrap();
479        assert_eq!(&buf[..n], b"another save");
480    }
481}