1use alloc::string::{String, ToString};
41use alloc::vec::Vec;
42use core::fmt::Write as _;
43
44use crate::Region;
45use crate::controller::Buttons;
46use crate::movie::{FrameInput, Movie, StartPoint};
47use thiserror::Error;
48
49pub const FM2_VERSION: u32 = 3;
51
52const SI_GAMEPAD: u32 = 1;
54
55const MOVIECMD_RESET: u32 = 1;
58
59const PAD_COLUMNS: [Buttons; 8] = [
63 Buttons::RIGHT, Buttons::LEFT, Buttons::DOWN, Buttons::UP, Buttons::START, Buttons::SELECT, Buttons::B, Buttons::A, ];
72
73#[derive(Clone, Debug, Default, Eq, PartialEq)]
79pub struct Fm2Meta {
80 pub rerecord_count: u64,
82 pub author: Option<String>,
84 pub rom_filename: Option<String>,
86 pub rom_checksum_md5: Option<String>,
90 pub fourscore: bool,
94 pub pal: bool,
96}
97
98#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum Fm2Error {
102 #[error("fm2 missing required `version` header (must be the first key)")]
104 MissingVersion,
105
106 #[error("fm2 version {got} not supported (only version {} is)", FM2_VERSION)]
108 BadVersion {
109 got: u32,
111 },
112
113 #[error("fm2 header key `{key}` has an invalid integer value `{value}`")]
115 BadInteger {
116 key: String,
118 value: String,
120 },
121
122 #[error("fm2 malformed input-log line {line}: {reason}")]
126 Malformed {
127 line: usize,
129 reason: &'static str,
131 },
132
133 #[error("fm2 unsupported: {0}")]
136 Unsupported(&'static str),
137}
138
139#[derive(Clone, Debug, Default, Eq, PartialEq)]
142pub struct Fm2ExportOpts {
143 pub rerecord_count: u64,
145 pub author: Option<String>,
147 pub rom_filename: Option<String>,
149 pub rom_checksum_md5: Option<String>,
151 pub fourscore: bool,
155}
156
157pub fn import_fm2(text: &str, rom_sha256: [u8; 32]) -> Result<(Movie, Fm2Meta), Fm2Error> {
183 let mut meta = Fm2Meta::default();
184 let mut saw_version = false;
185 let mut port0_gamepad = true;
186 let mut port1_gamepad = true;
187 let mut frames: Vec<FrameInput> = Vec::new();
188 let mut input_line_no = 0usize;
189
190 for raw in text.lines() {
191 let line = raw.strip_suffix('\r').unwrap_or(raw);
194
195 if line.starts_with('|') {
196 input_line_no += 1;
198 let input = parse_input_line(line, input_line_no, meta.fourscore)?;
199 frames.push(input);
200 continue;
201 }
202
203 if line.trim().is_empty() {
205 continue;
206 }
207
208 let (key, value) = match line.split_once(' ') {
209 Some((k, v)) => (k, v),
210 None => (line, ""),
213 };
214
215 if !saw_version && key != "version" {
217 return Err(Fm2Error::MissingVersion);
218 }
219
220 match key {
221 "version" => {
222 let v = parse_int(key, value)?;
223 if v != FM2_VERSION {
224 return Err(Fm2Error::BadVersion { got: v });
225 }
226 saw_version = true;
227 }
228 "rerecordCount" => meta.rerecord_count = u64::from(parse_int(key, value)?),
229 "palFlag" => meta.pal = parse_int(key, value)? != 0,
230 "fourscore" => meta.fourscore = parse_int(key, value)? != 0,
231 "port0" => port0_gamepad = parse_int(key, value)? == SI_GAMEPAD,
232 "port1" => port1_gamepad = parse_int(key, value)? == SI_GAMEPAD,
233 "port2" => {
234 let _ = parse_int(key, value)?;
236 }
237 "romFilename" => meta.rom_filename = Some(value.to_string()),
238 "romChecksum" => meta.rom_checksum_md5 = Some(value.to_string()),
239 "savestate" => {
240 return Err(Fm2Error::Unsupported(
241 "savestate-anchored .fm2 (cross-emulator save states are not portable)",
242 ));
243 }
244 "comment" => {
245 if let Some(rest) = value.strip_prefix("author ") {
247 meta.author = Some(rest.to_string());
248 }
249 }
250 _ => {
251 }
254 }
255 }
256
257 if !saw_version {
258 return Err(Fm2Error::MissingVersion);
259 }
260 if !meta.fourscore && (!port0_gamepad || !port1_gamepad) {
264 return Err(Fm2Error::Unsupported(
265 "non-gamepad input device (only SI_GAMEPAD ports are supported)",
266 ));
267 }
268
269 let movie = Movie {
270 region: if meta.pal { Region::Pal } else { Region::Ntsc },
271 rom_sha256,
272 start: StartPoint::PowerOn,
273 frames,
274 rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
276 attestation: None,
279 };
280 Ok((movie, meta))
281}
282
283pub fn export_fm2(movie: &Movie, opts: &Fm2ExportOpts) -> Result<String, Fm2Error> {
300 if !matches!(movie.start, StartPoint::PowerOn) {
301 return Err(Fm2Error::Unsupported(
302 "save-state-anchored movie has no portable .fm2 representation",
303 ));
304 }
305
306 let pal = matches!(movie.region, Region::Pal | Region::Dendy);
307 let mut out = String::new();
308
309 out.push_str("version 3\n");
313 let _ = writeln!(out, "emuVersion {}", emu_version_tag());
314 let _ = writeln!(out, "rerecordCount {}", opts.rerecord_count);
315 let _ = writeln!(out, "palFlag {}", u8::from(pal));
316 let _ = writeln!(out, "fourscore {}", u8::from(opts.fourscore));
317 let _ = writeln!(out, "port0 {SI_GAMEPAD}");
318 let _ = writeln!(out, "port1 {SI_GAMEPAD}");
319 out.push_str("port2 0\n");
320 if let Some(name) = &opts.rom_filename {
321 let _ = writeln!(out, "romFilename {name}");
322 }
323 if let Some(sum) = &opts.rom_checksum_md5 {
324 let _ = writeln!(out, "romChecksum {sum}");
325 }
326 if let Some(author) = &opts.author {
327 let _ = writeln!(out, "comment author {author}");
328 }
329
330 let mut pad = [0u8; 8];
333 for frame in &movie.frames {
334 out.push_str("|0|");
335 write_pad(frame.p1, &mut pad);
336 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
337 out.push('|');
338 write_pad(frame.p2, &mut pad);
339 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
340 out.push('|');
341 if opts.fourscore {
342 write_pad(Buttons::empty(), &mut pad);
344 let empty = core::str::from_utf8(&pad).expect("pad bytes are ASCII");
345 out.push_str(empty);
346 out.push('|');
347 out.push_str(empty);
348 out.push('|');
349 }
350 out.push_str("|\n");
352 }
353
354 Ok(out)
355}
356
357fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
360 const LETTERS: [u8; 8] = [b'R', b'L', b'D', b'U', b'T', b'S', b'B', b'A'];
362 for i in 0..8 {
363 out[i] = if buttons.contains(PAD_COLUMNS[i]) {
364 LETTERS[i]
365 } else {
366 b'.'
367 };
368 }
369}
370
371fn parse_input_line(line: &str, line_no: usize, fourscore: bool) -> Result<FrameInput, Fm2Error> {
375 if !line.ends_with('|') {
376 return Err(Fm2Error::Malformed {
377 line: line_no,
378 reason: "input-log line must end with `|`",
379 });
380 }
381 let mut fields = line.split('|');
385 if fields.next() != Some("") {
387 return Err(Fm2Error::Malformed {
388 line: line_no,
389 reason: "input-log line must start with `|`",
390 });
391 }
392 let cmd_field = fields.next().ok_or(Fm2Error::Malformed {
394 line: line_no,
395 reason: "missing command field",
396 })?;
397 let _cmd = parse_command(cmd_field, line_no)?;
398
399 let pad_count = if fourscore { 4 } else { 2 };
400 let mut pads = [Buttons::empty(); 4];
401 for pad in pads.iter_mut().take(pad_count) {
402 let field = fields.next().ok_or(Fm2Error::Malformed {
403 line: line_no,
404 reason: "missing gamepad field",
405 })?;
406 *pad = parse_pad(field, line_no)?;
407 }
408
409 for field in fields {
413 if !field.is_empty() {
414 return Err(Fm2Error::Malformed {
415 line: line_no,
416 reason: "unexpected non-empty trailing field (only SIFC_NONE supported)",
417 });
418 }
419 }
420
421 Ok(FrameInput::new(pads[0], pads[1]))
423}
424
425fn parse_command(field: &str, line_no: usize) -> Result<bool, Fm2Error> {
428 let value: u32 = if field.is_empty() {
430 0
431 } else {
432 field.parse().map_err(|_| Fm2Error::Malformed {
433 line: line_no,
434 reason: "command field is not a decimal integer",
435 })?
436 };
437 Ok(value & MOVIECMD_RESET != 0)
438}
439
440fn parse_pad(field: &str, line_no: usize) -> Result<Buttons, Fm2Error> {
442 let bytes = field.as_bytes();
443 if bytes.len() != 8 {
444 return Err(Fm2Error::Malformed {
445 line: line_no,
446 reason: "gamepad field must be exactly 8 characters",
447 });
448 }
449 let mut buttons = Buttons::empty();
450 for (i, &b) in bytes.iter().enumerate() {
451 if b != b' ' && b != b'.' {
453 buttons |= PAD_COLUMNS[i];
454 }
455 }
456 Ok(buttons)
457}
458
459fn parse_int(key: &str, value: &str) -> Result<u32, Fm2Error> {
461 value
462 .trim()
463 .parse::<u32>()
464 .map_err(|_| Fm2Error::BadInteger {
465 key: key.to_string(),
466 value: value.to_string(),
467 })
468}
469
470const fn emu_version_tag() -> u32 {
474 20000
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use alloc::vec;
483
484 const TEST_SHA: [u8; 32] = [0x5A; 32];
485
486 fn varied_frames() -> Vec<FrameInput> {
488 vec![
489 FrameInput::new(Buttons::A, Buttons::B),
490 FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
491 FrameInput::new(
492 Buttons::UP | Buttons::DOWN | Buttons::SELECT,
493 Buttons::empty(),
494 ),
495 FrameInput::new(
496 Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
497 Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
498 ),
499 ]
500 }
501
502 #[test]
503 fn round_trip_power_on_ntsc() {
504 let movie = Movie {
505 region: Region::Ntsc,
506 rom_sha256: TEST_SHA,
507 start: StartPoint::PowerOn,
508 frames: varied_frames(),
509 rerecord_count: 0,
510 attestation: None,
511 };
512 let opts = Fm2ExportOpts {
513 rerecord_count: 42,
514 author: Some("tester".to_string()),
515 rom_filename: Some("game.nes".to_string()),
516 rom_checksum_md5: Some("base64:deadbeef".to_string()),
517 fourscore: false,
518 };
519 let text = export_fm2(&movie, &opts).expect("export");
520 let (back, meta) = import_fm2(&text, TEST_SHA).expect("import");
521
522 assert_eq!(back.frames, movie.frames, "frames survive round-trip");
523 assert_eq!(back.region, Region::Ntsc);
524 assert_eq!(back.start, StartPoint::PowerOn);
525 assert_eq!(back.rom_sha256, TEST_SHA);
526 assert!(!meta.fourscore);
527 assert!(!meta.pal);
528 assert_eq!(meta.rerecord_count, 42);
529 assert_eq!(meta.author.as_deref(), Some("tester"));
530 assert_eq!(meta.rom_filename.as_deref(), Some("game.nes"));
531 assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:deadbeef"));
532 }
533
534 #[test]
535 fn exact_bit_and_char_mapping() {
536 let movie = Movie {
538 region: Region::Ntsc,
539 rom_sha256: TEST_SHA,
540 start: StartPoint::PowerOn,
541 frames: vec![
542 FrameInput::new(Buttons::A, Buttons::empty()),
543 FrameInput::new(Buttons::RIGHT, Buttons::empty()),
544 ],
545 rerecord_count: 0,
546 attestation: None,
547 };
548 let text = export_fm2(&movie, &Fm2ExportOpts::default()).expect("export");
549 let lines: Vec<&str> = text.lines().filter(|l| l.starts_with('|')).collect();
551 assert_eq!(lines.len(), 2);
552
553 let p1_field_a = lines[0].split('|').nth(2).unwrap();
555 assert_eq!(p1_field_a.len(), 8);
556 for (i, c) in p1_field_a.chars().enumerate() {
557 if i == 7 {
558 assert_ne!(c, '.', "A button is char index 7 and must be pressed");
559 } else {
560 assert_eq!(c, '.', "non-A columns must be released");
561 }
562 }
563
564 let p1_field_right = lines[1].split('|').nth(2).unwrap();
565 for (i, c) in p1_field_right.chars().enumerate() {
566 if i == 0 {
567 assert_ne!(c, '.', "RIGHT button is char index 0 and must be pressed");
568 } else {
569 assert_eq!(c, '.', "non-RIGHT columns must be released");
570 }
571 }
572
573 let imported = "version 3\nport0 1\nport1 1\nport2 0\n\
576 |0|R.......|.......A||\n";
577 let (movie, _) = import_fm2(imported, TEST_SHA).expect("import");
578 assert_eq!(movie.frames.len(), 1);
579 assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
580 assert_eq!(movie.frames[0].p2, Buttons::A);
581 }
582
583 #[test]
584 fn pal_flag_maps_to_region() {
585 let text = "version 3\npalFlag 1\nport0 1\nport1 1\nport2 0\n|0|........|........||\n";
587 let (movie, meta) = import_fm2(text, TEST_SHA).expect("import");
588 assert_eq!(movie.region, Region::Pal);
589 assert!(meta.pal);
590
591 let pal_movie = Movie {
593 region: Region::Pal,
594 rom_sha256: TEST_SHA,
595 start: StartPoint::PowerOn,
596 frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
597 rerecord_count: 0,
598 attestation: None,
599 };
600 let out = export_fm2(&pal_movie, &Fm2ExportOpts::default()).expect("export");
601 assert!(
602 out.lines().any(|l| l == "palFlag 1"),
603 "Pal movie must export palFlag 1"
604 );
605
606 let ntsc_movie = Movie {
608 region: Region::Ntsc,
609 ..pal_movie
610 };
611 let out = export_fm2(&ntsc_movie, &Fm2ExportOpts::default()).expect("export");
612 assert!(out.lines().any(|l| l == "palFlag 0"));
613 }
614
615 #[test]
616 fn reset_command_parses_without_error() {
617 let text = "version 3\nport0 1\nport1 1\nport2 0\n|1|........|........||\n";
620 let (movie, _) = import_fm2(text, TEST_SHA).expect("reset command must parse");
621 assert_eq!(movie.frames.len(), 1);
622 assert_eq!(movie.frames[0].p1, Buttons::empty());
623 }
624
625 #[test]
626 fn fourscore_layout_parses_two_of_four_pads() {
627 let text = "version 3\nfourscore 1\nport0 1\nport1 1\nport2 0\n\
630 |0|.......A|......B.|R.......|.L......||\n";
631 let (movie, meta) = import_fm2(text, TEST_SHA).expect("fourscore import");
632 assert!(meta.fourscore);
633 assert_eq!(movie.frames.len(), 1);
634 assert_eq!(movie.frames[0].p1, Buttons::A);
635 assert_eq!(movie.frames[0].p2, Buttons::B);
636
637 let out = export_fm2(
639 &movie,
640 &Fm2ExportOpts {
641 fourscore: true,
642 ..Default::default()
643 },
644 )
645 .expect("export");
646 let log_line = out.lines().find(|l| l.starts_with('|')).unwrap();
647 let pad_count = log_line.split('|').filter(|p| p.len() == 8).count();
650 assert_eq!(pad_count, 4, "fourscore export must emit four pad fields");
651 }
652
653 #[test]
654 fn malformed_inputs_never_panic() {
655 assert!(matches!(
657 import_fm2("emuVersion 1\nport0 1\n", TEST_SHA),
658 Err(Fm2Error::MissingVersion)
659 ));
660
661 assert!(matches!(
663 import_fm2("palFlag 0\nversion 3\n", TEST_SHA),
664 Err(Fm2Error::MissingVersion)
665 ));
666
667 assert!(matches!(
669 import_fm2("version 2\nport0 1\nport1 1\nport2 0\n", TEST_SHA),
670 Err(Fm2Error::BadVersion { got: 2 })
671 ));
672
673 assert!(matches!(
675 import_fm2("version 3\npalFlag notanint\n", TEST_SHA),
676 Err(Fm2Error::BadInteger { .. })
677 ));
678
679 assert!(matches!(
681 import_fm2(
682 "version 3\nport0 1\nport1 1\nport2 0\n|0|........|........\n",
683 TEST_SHA
684 ),
685 Err(Fm2Error::Malformed { .. })
686 ));
687
688 assert!(matches!(
690 import_fm2(
691 "version 3\nport0 1\nport1 1\nport2 0\n|0|.......|........||\n",
692 TEST_SHA
693 ),
694 Err(Fm2Error::Malformed { .. })
695 ));
696
697 assert!(matches!(
699 import_fm2("version 3\nport0 2\nport1 1\nport2 0\n", TEST_SHA),
700 Err(Fm2Error::Unsupported(_))
701 ));
702
703 assert!(matches!(
705 import_fm2("version 3\nsavestate 0xDEAD\nport0 1\n", TEST_SHA),
706 Err(Fm2Error::Unsupported(_))
707 ));
708 }
709
710 #[test]
711 fn representative_header_parses() {
712 let text = "version 3\n\
713 emuVersion 22020\n\
714 rerecordCount 1234\n\
715 palFlag 0\n\
716 fourscore 0\n\
717 port0 1\n\
718 port1 1\n\
719 port2 0\n\
720 romFilename Super Demo.nes\n\
721 romChecksum base64:abc123==\n\
722 comment author Jane Doe\n\
723 comment subject A speedrun\n\
724 guid 452DE2C3-EF43-2FA9-77AC-0677FC51543B\n\
725 |0|........|........||\n\
726 |0|.......A|........||\n";
727 let (movie, meta) = import_fm2(text, TEST_SHA).expect("header parse");
728 assert_eq!(movie.frames.len(), 2);
729 assert_eq!(movie.region, Region::Ntsc);
730 assert_eq!(meta.rerecord_count, 1234);
731 assert!(!meta.fourscore);
732 assert!(!meta.pal);
733 assert_eq!(meta.author.as_deref(), Some("Jane Doe"));
734 assert_eq!(meta.rom_filename.as_deref(), Some("Super Demo.nes"));
735 assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:abc123=="));
736 assert_eq!(movie.frames[1].p1, Buttons::A);
738 }
739
740 #[test]
741 fn export_rejects_save_state_movie() {
742 let movie = Movie {
743 region: Region::Ntsc,
744 rom_sha256: TEST_SHA,
745 start: StartPoint::SaveState(vec![1, 2, 3]),
746 frames: vec![],
747 rerecord_count: 0,
748 attestation: None,
749 };
750 assert!(matches!(
751 export_fm2(&movie, &Fm2ExportOpts::default()),
752 Err(Fm2Error::Unsupported(_))
753 ));
754 }
755}