1use alloc::string::{String, ToString};
46use alloc::vec::Vec;
47use core::fmt::Write as _;
48
49use crate::Region;
50use crate::controller::Buttons;
51use crate::movie::{FrameInput, Movie, StartPoint};
52use thiserror::Error;
53
54pub const FM2_VERSION: u32 = 3;
56
57const SI_GAMEPAD: u32 = 1;
59
60const MOVIECMD_RESET: u32 = 1;
63
64const PAD_COLUMNS: [Buttons; 8] = [
68 Buttons::RIGHT, Buttons::LEFT, Buttons::DOWN, Buttons::UP, Buttons::START, Buttons::SELECT, Buttons::B, Buttons::A, ];
77
78#[derive(Clone, Debug, Default, Eq, PartialEq)]
84pub struct Fm2Meta {
85 pub rerecord_count: u64,
87 pub author: Option<String>,
89 pub rom_filename: Option<String>,
91 pub rom_checksum_md5: Option<String>,
95 pub fourscore: bool,
99 pub pal: bool,
101}
102
103#[derive(Debug, Error)]
105#[non_exhaustive]
106pub enum Fm2Error {
107 #[error("fm2 missing required `version` header (must be the first key)")]
109 MissingVersion,
110
111 #[error("fm2 version {got} not supported (only version {} is)", FM2_VERSION)]
113 BadVersion {
114 got: u32,
116 },
117
118 #[error("fm2 header key `{key}` has an invalid integer value `{value}`")]
120 BadInteger {
121 key: String,
123 value: String,
125 },
126
127 #[error("fm2 malformed input-log line {line}: {reason}")]
131 Malformed {
132 line: usize,
134 reason: &'static str,
136 },
137
138 #[error("fm2 unsupported: {0}")]
141 Unsupported(&'static str),
142}
143
144#[derive(Clone, Debug, Default, Eq, PartialEq)]
147pub struct Fm2ExportOpts {
148 pub rerecord_count: u64,
150 pub author: Option<String>,
152 pub rom_filename: Option<String>,
154 pub rom_checksum_md5: Option<String>,
156 pub fourscore: bool,
161}
162
163pub fn import_fm2(text: &str, rom_sha256: [u8; 32]) -> Result<(Movie, Fm2Meta), Fm2Error> {
189 let mut meta = Fm2Meta::default();
190 let mut saw_version = false;
191 let mut port0_gamepad = true;
192 let mut port1_gamepad = true;
193 let mut frames: Vec<FrameInput> = Vec::new();
194 let mut input_line_no = 0usize;
195
196 for raw in text.lines() {
197 let line = raw.strip_suffix('\r').unwrap_or(raw);
200
201 if line.starts_with('|') {
202 input_line_no += 1;
204 let input = parse_input_line(line, input_line_no, meta.fourscore)?;
205 frames.push(input);
206 continue;
207 }
208
209 if line.trim().is_empty() {
211 continue;
212 }
213
214 let (key, value) = match line.split_once(' ') {
215 Some((k, v)) => (k, v),
216 None => (line, ""),
219 };
220
221 if !saw_version && key != "version" {
223 return Err(Fm2Error::MissingVersion);
224 }
225
226 match key {
227 "version" => {
228 let v = parse_int(key, value)?;
229 if v != FM2_VERSION {
230 return Err(Fm2Error::BadVersion { got: v });
231 }
232 saw_version = true;
233 }
234 "rerecordCount" => meta.rerecord_count = u64::from(parse_int(key, value)?),
235 "palFlag" => meta.pal = parse_int(key, value)? != 0,
236 "fourscore" => meta.fourscore = parse_int(key, value)? != 0,
237 "port0" => port0_gamepad = parse_int(key, value)? == SI_GAMEPAD,
238 "port1" => port1_gamepad = parse_int(key, value)? == SI_GAMEPAD,
239 "port2" => {
240 let _ = parse_int(key, value)?;
242 }
243 "romFilename" => meta.rom_filename = Some(value.to_string()),
244 "romChecksum" => meta.rom_checksum_md5 = Some(value.to_string()),
245 "savestate" => {
246 return Err(Fm2Error::Unsupported(
247 "savestate-anchored .fm2 (cross-emulator save states are not portable)",
248 ));
249 }
250 "comment" => {
251 if let Some(rest) = value.strip_prefix("author ") {
253 meta.author = Some(rest.to_string());
254 }
255 }
256 _ => {
257 }
260 }
261 }
262
263 if !saw_version {
264 return Err(Fm2Error::MissingVersion);
265 }
266 if !meta.fourscore && (!port0_gamepad || !port1_gamepad) {
270 return Err(Fm2Error::Unsupported(
271 "non-gamepad input device (only SI_GAMEPAD ports are supported)",
272 ));
273 }
274
275 let movie = Movie {
276 epoch: crate::EMULATION_EPOCH,
277 region: if meta.pal { Region::Pal } else { Region::Ntsc },
278 rom_sha256,
279 options: crate::HardwareOptions {
284 four_score: meta.fourscore,
285 ..crate::HardwareOptions::default()
286 },
287 board: None,
288 start: StartPoint::PowerOn,
289 frames,
290 rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
292 attestation: None,
295 };
296 Ok((movie, meta))
297}
298
299pub fn export_fm2(movie: &Movie, opts: &Fm2ExportOpts) -> Result<String, Fm2Error> {
316 if !matches!(movie.start, StartPoint::PowerOn) {
317 return Err(Fm2Error::Unsupported(
318 "save-state-anchored movie has no portable .fm2 representation",
319 ));
320 }
321
322 let pal = matches!(movie.region, Region::Pal | Region::Dendy);
323 let fourscore = opts.fourscore || movie.options.four_score;
324 let mut out = String::new();
325
326 out.push_str("version 3\n");
330 let _ = writeln!(out, "emuVersion {}", emu_version_tag());
331 let _ = writeln!(out, "rerecordCount {}", opts.rerecord_count);
332 let _ = writeln!(out, "palFlag {}", u8::from(pal));
333 let _ = writeln!(out, "fourscore {}", u8::from(fourscore));
334 let _ = writeln!(out, "port0 {SI_GAMEPAD}");
335 let _ = writeln!(out, "port1 {SI_GAMEPAD}");
336 out.push_str("port2 0\n");
337 if let Some(name) = &opts.rom_filename {
338 let _ = writeln!(out, "romFilename {name}");
339 }
340 if let Some(sum) = &opts.rom_checksum_md5 {
341 let _ = writeln!(out, "romChecksum {sum}");
342 }
343 if let Some(author) = &opts.author {
344 let _ = writeln!(out, "comment author {author}");
345 }
346
347 let mut pad = [0u8; 8];
350 for frame in &movie.frames {
351 out.push_str("|0|");
352 write_pad(frame.p1, &mut pad);
353 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
354 out.push('|');
355 write_pad(frame.p2, &mut pad);
356 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
357 out.push('|');
358 if fourscore {
359 for buttons in [frame.p3, frame.p4] {
360 write_pad(buttons, &mut pad);
361 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
362 out.push('|');
363 }
364 }
365 out.push_str("|\n");
367 }
368
369 Ok(out)
370}
371
372fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
375 const LETTERS: [u8; 8] = *b"RLDUTSBA";
377 for i in 0..8 {
378 out[i] = if buttons.contains(PAD_COLUMNS[i]) {
379 LETTERS[i]
380 } else {
381 b'.'
382 };
383 }
384}
385
386fn parse_input_line(line: &str, line_no: usize, fourscore: bool) -> Result<FrameInput, Fm2Error> {
390 if !line.ends_with('|') {
391 return Err(Fm2Error::Malformed {
392 line: line_no,
393 reason: "input-log line must end with `|`",
394 });
395 }
396 let mut fields = line.split('|');
400 if fields.next() != Some("") {
402 return Err(Fm2Error::Malformed {
403 line: line_no,
404 reason: "input-log line must start with `|`",
405 });
406 }
407 let cmd_field = fields.next().ok_or(Fm2Error::Malformed {
409 line: line_no,
410 reason: "missing command field",
411 })?;
412 let _cmd = parse_command(cmd_field, line_no)?;
413
414 let pad_count = if fourscore { 4 } else { 2 };
415 let mut pads = [Buttons::empty(); 4];
416 for pad in pads.iter_mut().take(pad_count) {
417 let field = fields.next().ok_or(Fm2Error::Malformed {
418 line: line_no,
419 reason: "missing gamepad field",
420 })?;
421 *pad = parse_pad(field, line_no)?;
422 }
423
424 for field in fields {
428 if !field.is_empty() {
429 return Err(Fm2Error::Malformed {
430 line: line_no,
431 reason: "unexpected non-empty trailing field (only SIFC_NONE supported)",
432 });
433 }
434 }
435
436 Ok(FrameInput::four_players(pads[0], pads[1], pads[2], pads[3]))
438}
439
440fn parse_command(field: &str, line_no: usize) -> Result<bool, Fm2Error> {
443 let value: u32 = if field.is_empty() {
445 0
446 } else {
447 field.parse().map_err(|_| Fm2Error::Malformed {
448 line: line_no,
449 reason: "command field is not a decimal integer",
450 })?
451 };
452 Ok(value & MOVIECMD_RESET != 0)
453}
454
455fn parse_pad(field: &str, line_no: usize) -> Result<Buttons, Fm2Error> {
457 let bytes = field.as_bytes();
458 if bytes.len() != 8 {
459 return Err(Fm2Error::Malformed {
460 line: line_no,
461 reason: "gamepad field must be exactly 8 characters",
462 });
463 }
464 let mut buttons = Buttons::empty();
465 for (i, &b) in bytes.iter().enumerate() {
466 if b != b' ' && b != b'.' {
468 buttons |= PAD_COLUMNS[i];
469 }
470 }
471 Ok(buttons)
472}
473
474fn parse_int(key: &str, value: &str) -> Result<u32, Fm2Error> {
476 value
477 .trim()
478 .parse::<u32>()
479 .map_err(|_| Fm2Error::BadInteger {
480 key: key.to_string(),
481 value: value.to_string(),
482 })
483}
484
485const fn emu_version_tag() -> u32 {
489 20000
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use alloc::vec;
498
499 const TEST_SHA: [u8; 32] = [0x5A; 32];
500
501 fn varied_frames() -> Vec<FrameInput> {
503 vec![
504 FrameInput::new(Buttons::A, Buttons::B),
505 FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
506 FrameInput::new(
507 Buttons::UP | Buttons::DOWN | Buttons::SELECT,
508 Buttons::empty(),
509 ),
510 FrameInput::new(
511 Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
512 Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
513 ),
514 ]
515 }
516
517 #[test]
518 fn round_trip_power_on_ntsc() {
519 let movie = Movie {
520 epoch: crate::EMULATION_EPOCH,
521 region: Region::Ntsc,
522 rom_sha256: TEST_SHA,
523 options: crate::HardwareOptions::default(),
524 board: None,
525 start: StartPoint::PowerOn,
526 frames: varied_frames(),
527 rerecord_count: 0,
528 attestation: None,
529 };
530 let opts = Fm2ExportOpts {
531 rerecord_count: 42,
532 author: Some("tester".to_string()),
533 rom_filename: Some("game.nes".to_string()),
534 rom_checksum_md5: Some("base64:deadbeef".to_string()),
535 fourscore: false,
536 };
537 let text = export_fm2(&movie, &opts).expect("export");
538 let (back, meta) = import_fm2(&text, TEST_SHA).expect("import");
539
540 assert_eq!(back.frames, movie.frames, "frames survive round-trip");
541 assert_eq!(back.region, Region::Ntsc);
542 assert_eq!(back.start, StartPoint::PowerOn);
543 assert_eq!(back.rom_sha256, TEST_SHA);
544 assert!(!meta.fourscore);
545 assert!(!meta.pal);
546 assert_eq!(meta.rerecord_count, 42);
547 assert_eq!(meta.author.as_deref(), Some("tester"));
548 assert_eq!(meta.rom_filename.as_deref(), Some("game.nes"));
549 assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:deadbeef"));
550 }
551
552 #[test]
553 fn exact_bit_and_char_mapping() {
554 let movie = Movie {
556 epoch: crate::EMULATION_EPOCH,
557 region: Region::Ntsc,
558 rom_sha256: TEST_SHA,
559 options: crate::HardwareOptions::default(),
560 board: None,
561 start: StartPoint::PowerOn,
562 frames: vec![
563 FrameInput::new(Buttons::A, Buttons::empty()),
564 FrameInput::new(Buttons::RIGHT, Buttons::empty()),
565 ],
566 rerecord_count: 0,
567 attestation: None,
568 };
569 let text = export_fm2(&movie, &Fm2ExportOpts::default()).expect("export");
570 let lines: Vec<&str> = text.lines().filter(|l| l.starts_with('|')).collect();
572 assert_eq!(lines.len(), 2);
573
574 let p1_field_a = lines[0].split('|').nth(2).unwrap();
576 assert_eq!(p1_field_a.len(), 8);
577 for (i, c) in p1_field_a.chars().enumerate() {
578 if i == 7 {
579 assert_ne!(c, '.', "A button is char index 7 and must be pressed");
580 } else {
581 assert_eq!(c, '.', "non-A columns must be released");
582 }
583 }
584
585 let p1_field_right = lines[1].split('|').nth(2).unwrap();
586 for (i, c) in p1_field_right.chars().enumerate() {
587 if i == 0 {
588 assert_ne!(c, '.', "RIGHT button is char index 0 and must be pressed");
589 } else {
590 assert_eq!(c, '.', "non-RIGHT columns must be released");
591 }
592 }
593
594 let imported = "version 3\nport0 1\nport1 1\nport2 0\n\
597 |0|R.......|.......A||\n";
598 let (movie, _) = import_fm2(imported, TEST_SHA).expect("import");
599 assert_eq!(movie.frames.len(), 1);
600 assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
601 assert_eq!(movie.frames[0].p2, Buttons::A);
602 }
603
604 #[test]
605 fn pal_flag_maps_to_region() {
606 let text = "version 3\npalFlag 1\nport0 1\nport1 1\nport2 0\n|0|........|........||\n";
608 let (movie, meta) = import_fm2(text, TEST_SHA).expect("import");
609 assert_eq!(movie.region, Region::Pal);
610 assert!(meta.pal);
611
612 let pal_movie = Movie {
614 epoch: crate::EMULATION_EPOCH,
615 region: Region::Pal,
616 rom_sha256: TEST_SHA,
617 options: crate::HardwareOptions::default(),
618 board: None,
619 start: StartPoint::PowerOn,
620 frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
621 rerecord_count: 0,
622 attestation: None,
623 };
624 let out = export_fm2(&pal_movie, &Fm2ExportOpts::default()).expect("export");
625 assert!(
626 out.lines().any(|l| l == "palFlag 1"),
627 "Pal movie must export palFlag 1"
628 );
629
630 let ntsc_movie = Movie {
632 epoch: crate::EMULATION_EPOCH,
633 region: Region::Ntsc,
634 ..pal_movie
635 };
636 let out = export_fm2(&ntsc_movie, &Fm2ExportOpts::default()).expect("export");
637 assert!(out.lines().any(|l| l == "palFlag 0"));
638 }
639
640 #[test]
641 fn reset_command_parses_without_error() {
642 let text = "version 3\nport0 1\nport1 1\nport2 0\n|1|........|........||\n";
645 let (movie, _) = import_fm2(text, TEST_SHA).expect("reset command must parse");
646 assert_eq!(movie.frames.len(), 1);
647 assert_eq!(movie.frames[0].p1, Buttons::empty());
648 }
649
650 #[test]
651 fn fourscore_layout_parses_all_four_pads() {
652 let text = "version 3\nfourscore 1\nport0 1\nport1 1\nport2 0\n\
655 |0|.......A|......B.|R.......|.L......||\n";
656 let (movie, meta) = import_fm2(text, TEST_SHA).expect("fourscore import");
657 assert!(meta.fourscore);
658 assert_eq!(movie.frames.len(), 1);
659 assert_eq!(movie.frames[0].p1, Buttons::A);
660 assert_eq!(movie.frames[0].p2, Buttons::B);
661 assert_eq!(movie.frames[0].p3, Buttons::RIGHT);
662 assert_eq!(movie.frames[0].p4, Buttons::LEFT);
663 assert!(movie.options.four_score);
664
665 let out = export_fm2(
667 &movie,
668 &Fm2ExportOpts {
669 fourscore: true,
670 ..Default::default()
671 },
672 )
673 .expect("export");
674 let log_line = out.lines().find(|l| l.starts_with('|')).unwrap();
675 let pad_count = log_line.split('|').filter(|p| p.len() == 8).count();
678 assert_eq!(pad_count, 4, "fourscore export must emit four pad fields");
679 }
680
681 #[test]
685 fn fourscore_import_keeps_players_three_and_four() {
686 let text = "version 3\nfourscore 1\nport0 1\nport1 1\nport2 0\n\
687 |0|.......A|......B.|R.......|.L......||\n\
688 |0|........|........|...U....|....T...||\n";
689 let (movie, meta) = import_fm2(text, TEST_SHA).expect("fourscore import");
690 assert!(meta.fourscore);
691 let out = export_fm2(
692 &movie,
693 &Fm2ExportOpts {
694 fourscore: true,
695 ..Default::default()
696 },
697 )
698 .expect("export");
699 let logs: Vec<&str> = out.lines().filter(|l| l.starts_with('|')).collect();
700 assert_eq!(
701 logs,
702 [
703 "|0|.......A|......B.|R.......|.L......||",
704 "|0|........|........|...U....|....T...||",
705 ],
706 "players 3 and 4 must round-trip"
707 );
708
709 let rom = {
711 let mut b = vec![b'N', b'E', b'S', 0x1A, 1, 1, 0, 0];
712 b.extend_from_slice(&[0u8; 8]);
713 let mut prg = vec![0u8; 16 * 1024];
714 prg[..3].copy_from_slice(&[0x4C, 0x00, 0xC0]);
715 let len = prg.len();
716 prg[len - 6..].copy_from_slice(&[0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0]);
717 b.extend_from_slice(&prg);
718 b.extend_from_slice(&[0u8; 8 * 1024]);
719 b
720 };
721 let mut nes = crate::Nes::from_rom(&rom).unwrap();
722 let (movie, _) = import_fm2(text, *nes.rom_sha256()).unwrap();
723 movie.seek_to_start(&mut nes).expect("seek");
724 assert!(nes.four_score(), "a fourscore import plugs the adapter in");
725 let mut player = crate::MoviePlayer::new(&movie);
726 assert!(player.apply_next(&mut nes));
727 assert_eq!(nes.buttons(2), Buttons::RIGHT);
728 assert_eq!(nes.buttons(3), Buttons::LEFT);
729 nes.run_frame();
730 assert!(player.apply_next(&mut nes));
731 assert_eq!(nes.buttons(2), Buttons::UP);
732 assert_eq!(nes.buttons(3), Buttons::START);
733 }
734
735 #[test]
736 fn malformed_inputs_never_panic() {
737 assert!(matches!(
739 import_fm2("emuVersion 1\nport0 1\n", TEST_SHA),
740 Err(Fm2Error::MissingVersion)
741 ));
742
743 assert!(matches!(
745 import_fm2("palFlag 0\nversion 3\n", TEST_SHA),
746 Err(Fm2Error::MissingVersion)
747 ));
748
749 assert!(matches!(
751 import_fm2("version 2\nport0 1\nport1 1\nport2 0\n", TEST_SHA),
752 Err(Fm2Error::BadVersion { got: 2 })
753 ));
754
755 assert!(matches!(
757 import_fm2("version 3\npalFlag notanint\n", TEST_SHA),
758 Err(Fm2Error::BadInteger { .. })
759 ));
760
761 assert!(matches!(
763 import_fm2(
764 "version 3\nport0 1\nport1 1\nport2 0\n|0|........|........\n",
765 TEST_SHA
766 ),
767 Err(Fm2Error::Malformed { .. })
768 ));
769
770 assert!(matches!(
772 import_fm2(
773 "version 3\nport0 1\nport1 1\nport2 0\n|0|.......|........||\n",
774 TEST_SHA
775 ),
776 Err(Fm2Error::Malformed { .. })
777 ));
778
779 assert!(matches!(
781 import_fm2("version 3\nport0 2\nport1 1\nport2 0\n", TEST_SHA),
782 Err(Fm2Error::Unsupported(_))
783 ));
784
785 assert!(matches!(
787 import_fm2("version 3\nsavestate 0xDEAD\nport0 1\n", TEST_SHA),
788 Err(Fm2Error::Unsupported(_))
789 ));
790 }
791
792 #[test]
793 fn representative_header_parses() {
794 let text = "version 3\n\
795 emuVersion 22020\n\
796 rerecordCount 1234\n\
797 palFlag 0\n\
798 fourscore 0\n\
799 port0 1\n\
800 port1 1\n\
801 port2 0\n\
802 romFilename Super Demo.nes\n\
803 romChecksum base64:abc123==\n\
804 comment author Jane Doe\n\
805 comment subject A speedrun\n\
806 guid 452DE2C3-EF43-2FA9-77AC-0677FC51543B\n\
807 |0|........|........||\n\
808 |0|.......A|........||\n";
809 let (movie, meta) = import_fm2(text, TEST_SHA).expect("header parse");
810 assert_eq!(movie.frames.len(), 2);
811 assert_eq!(movie.region, Region::Ntsc);
812 assert_eq!(meta.rerecord_count, 1234);
813 assert!(!meta.fourscore);
814 assert!(!meta.pal);
815 assert_eq!(meta.author.as_deref(), Some("Jane Doe"));
816 assert_eq!(meta.rom_filename.as_deref(), Some("Super Demo.nes"));
817 assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:abc123=="));
818 assert_eq!(movie.frames[1].p1, Buttons::A);
820 }
821
822 #[test]
823 fn export_rejects_save_state_movie() {
824 let movie = Movie {
825 epoch: crate::EMULATION_EPOCH,
826 region: Region::Ntsc,
827 rom_sha256: TEST_SHA,
828 options: crate::HardwareOptions::default(),
829 board: None,
830 start: StartPoint::SaveState(vec![1, 2, 3]),
831 frames: vec![],
832 rerecord_count: 0,
833 attestation: None,
834 };
835 assert!(matches!(
836 export_fm2(&movie, &Fm2ExportOpts::default()),
837 Err(Fm2Error::Unsupported(_))
838 ));
839 }
840}