1#![no_std]
11#![warn(missing_docs)]
12
13use psx_pad::ActionMap;
14
15pub const MAGIC: [u8; 4] = *b"PSST";
17pub const VERSION: u8 = 1;
19const HEADER_LEN: usize = 16;
21const BINDING_LEN: usize = 4;
23const SCORE_LEN: usize = 4;
25pub const MAX_RECORD_LEN: usize = 128;
27
28pub const FLAG_INVERT_Y: u8 = 1 << 0;
30
31#[derive(Copy, Clone, Debug, PartialEq, Eq)]
36pub struct Profile<const ACTIONS: usize, const SCORES: usize> {
37 pub brightness: u8,
39 pub sfx_volume: u8,
41 pub music_volume: u8,
43 pub move_deadzone: u8,
45 pub look_deadzone: u8,
47 pub look_speed_percent: u8,
49 pub difficulty: u8,
51 pub flags: u8,
53 pub actions: ActionMap<ACTIONS>,
55 pub high_scores: [u32; SCORES],
57}
58
59impl<const ACTIONS: usize, const SCORES: usize> Profile<ACTIONS, SCORES> {
60 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 pub const fn encoded_len() -> usize {
79 HEADER_LEN + ACTIONS * BINDING_LEN + SCORES * SCORE_LEN
80 }
81
82 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 pub const fn invert_y(&self) -> bool {
95 self.flags & FLAG_INVERT_Y != 0
96 }
97
98 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 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 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 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#[derive(Copy, Clone, Debug, PartialEq, Eq)]
202pub enum CodecError {
203 BufferTooSmall,
205 BadMagic,
207 UnsupportedVersion,
209 WrongShape,
211 BadChecksum,
213}
214
215#[cfg(feature = "card")]
217#[derive(Copy, Clone, Debug, PartialEq, Eq)]
218pub enum CardError {
219 Card(psx_mc::Error),
221 Codec(CodecError),
223}
224
225#[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#[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#[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#[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#[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#[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#[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#[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#[cfg(feature = "card")]
339fn steal_port() -> psx_mc::ControllerPort {
340 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 #[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 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 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}