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 };
277 Ok((movie, meta))
278}
279
280pub fn export_fm2(movie: &Movie, opts: &Fm2ExportOpts) -> Result<String, Fm2Error> {
297 if !matches!(movie.start, StartPoint::PowerOn) {
298 return Err(Fm2Error::Unsupported(
299 "save-state-anchored movie has no portable .fm2 representation",
300 ));
301 }
302
303 let pal = matches!(movie.region, Region::Pal | Region::Dendy);
304 let mut out = String::new();
305
306 out.push_str("version 3\n");
310 let _ = writeln!(out, "emuVersion {}", emu_version_tag());
311 let _ = writeln!(out, "rerecordCount {}", opts.rerecord_count);
312 let _ = writeln!(out, "palFlag {}", u8::from(pal));
313 let _ = writeln!(out, "fourscore {}", u8::from(opts.fourscore));
314 let _ = writeln!(out, "port0 {SI_GAMEPAD}");
315 let _ = writeln!(out, "port1 {SI_GAMEPAD}");
316 out.push_str("port2 0\n");
317 if let Some(name) = &opts.rom_filename {
318 let _ = writeln!(out, "romFilename {name}");
319 }
320 if let Some(sum) = &opts.rom_checksum_md5 {
321 let _ = writeln!(out, "romChecksum {sum}");
322 }
323 if let Some(author) = &opts.author {
324 let _ = writeln!(out, "comment author {author}");
325 }
326
327 let mut pad = [0u8; 8];
330 for frame in &movie.frames {
331 out.push_str("|0|");
332 write_pad(frame.p1, &mut pad);
333 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
334 out.push('|');
335 write_pad(frame.p2, &mut pad);
336 out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
337 out.push('|');
338 if opts.fourscore {
339 write_pad(Buttons::empty(), &mut pad);
341 let empty = core::str::from_utf8(&pad).expect("pad bytes are ASCII");
342 out.push_str(empty);
343 out.push('|');
344 out.push_str(empty);
345 out.push('|');
346 }
347 out.push_str("|\n");
349 }
350
351 Ok(out)
352}
353
354fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
357 const LETTERS: [u8; 8] = [b'R', b'L', b'D', b'U', b'T', b'S', b'B', b'A'];
359 for i in 0..8 {
360 out[i] = if buttons.contains(PAD_COLUMNS[i]) {
361 LETTERS[i]
362 } else {
363 b'.'
364 };
365 }
366}
367
368fn parse_input_line(line: &str, line_no: usize, fourscore: bool) -> Result<FrameInput, Fm2Error> {
372 if !line.ends_with('|') {
373 return Err(Fm2Error::Malformed {
374 line: line_no,
375 reason: "input-log line must end with `|`",
376 });
377 }
378 let mut fields = line.split('|');
382 if fields.next() != Some("") {
384 return Err(Fm2Error::Malformed {
385 line: line_no,
386 reason: "input-log line must start with `|`",
387 });
388 }
389 let cmd_field = fields.next().ok_or(Fm2Error::Malformed {
391 line: line_no,
392 reason: "missing command field",
393 })?;
394 let _cmd = parse_command(cmd_field, line_no)?;
395
396 let pad_count = if fourscore { 4 } else { 2 };
397 let mut pads = [Buttons::empty(); 4];
398 for pad in pads.iter_mut().take(pad_count) {
399 let field = fields.next().ok_or(Fm2Error::Malformed {
400 line: line_no,
401 reason: "missing gamepad field",
402 })?;
403 *pad = parse_pad(field, line_no)?;
404 }
405
406 for field in fields {
410 if !field.is_empty() {
411 return Err(Fm2Error::Malformed {
412 line: line_no,
413 reason: "unexpected non-empty trailing field (only SIFC_NONE supported)",
414 });
415 }
416 }
417
418 Ok(FrameInput::new(pads[0], pads[1]))
420}
421
422fn parse_command(field: &str, line_no: usize) -> Result<bool, Fm2Error> {
425 let value: u32 = if field.is_empty() {
427 0
428 } else {
429 field.parse().map_err(|_| Fm2Error::Malformed {
430 line: line_no,
431 reason: "command field is not a decimal integer",
432 })?
433 };
434 Ok(value & MOVIECMD_RESET != 0)
435}
436
437fn parse_pad(field: &str, line_no: usize) -> Result<Buttons, Fm2Error> {
439 let bytes = field.as_bytes();
440 if bytes.len() != 8 {
441 return Err(Fm2Error::Malformed {
442 line: line_no,
443 reason: "gamepad field must be exactly 8 characters",
444 });
445 }
446 let mut buttons = Buttons::empty();
447 for (i, &b) in bytes.iter().enumerate() {
448 if b != b' ' && b != b'.' {
450 buttons |= PAD_COLUMNS[i];
451 }
452 }
453 Ok(buttons)
454}
455
456fn parse_int(key: &str, value: &str) -> Result<u32, Fm2Error> {
458 value
459 .trim()
460 .parse::<u32>()
461 .map_err(|_| Fm2Error::BadInteger {
462 key: key.to_string(),
463 value: value.to_string(),
464 })
465}
466
467const fn emu_version_tag() -> u32 {
471 20000
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use alloc::vec;
480
481 const TEST_SHA: [u8; 32] = [0x5A; 32];
482
483 fn varied_frames() -> Vec<FrameInput> {
485 vec![
486 FrameInput::new(Buttons::A, Buttons::B),
487 FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
488 FrameInput::new(
489 Buttons::UP | Buttons::DOWN | Buttons::SELECT,
490 Buttons::empty(),
491 ),
492 FrameInput::new(
493 Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
494 Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
495 ),
496 ]
497 }
498
499 #[test]
500 fn round_trip_power_on_ntsc() {
501 let movie = Movie {
502 region: Region::Ntsc,
503 rom_sha256: TEST_SHA,
504 start: StartPoint::PowerOn,
505 frames: varied_frames(),
506 rerecord_count: 0,
507 };
508 let opts = Fm2ExportOpts {
509 rerecord_count: 42,
510 author: Some("tester".to_string()),
511 rom_filename: Some("game.nes".to_string()),
512 rom_checksum_md5: Some("base64:deadbeef".to_string()),
513 fourscore: false,
514 };
515 let text = export_fm2(&movie, &opts).expect("export");
516 let (back, meta) = import_fm2(&text, TEST_SHA).expect("import");
517
518 assert_eq!(back.frames, movie.frames, "frames survive round-trip");
519 assert_eq!(back.region, Region::Ntsc);
520 assert_eq!(back.start, StartPoint::PowerOn);
521 assert_eq!(back.rom_sha256, TEST_SHA);
522 assert!(!meta.fourscore);
523 assert!(!meta.pal);
524 assert_eq!(meta.rerecord_count, 42);
525 assert_eq!(meta.author.as_deref(), Some("tester"));
526 assert_eq!(meta.rom_filename.as_deref(), Some("game.nes"));
527 assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:deadbeef"));
528 }
529
530 #[test]
531 fn exact_bit_and_char_mapping() {
532 let movie = Movie {
534 region: Region::Ntsc,
535 rom_sha256: TEST_SHA,
536 start: StartPoint::PowerOn,
537 frames: vec![
538 FrameInput::new(Buttons::A, Buttons::empty()),
539 FrameInput::new(Buttons::RIGHT, Buttons::empty()),
540 ],
541 rerecord_count: 0,
542 };
543 let text = export_fm2(&movie, &Fm2ExportOpts::default()).expect("export");
544 let lines: Vec<&str> = text.lines().filter(|l| l.starts_with('|')).collect();
546 assert_eq!(lines.len(), 2);
547
548 let p1_field_a = lines[0].split('|').nth(2).unwrap();
550 assert_eq!(p1_field_a.len(), 8);
551 for (i, c) in p1_field_a.chars().enumerate() {
552 if i == 7 {
553 assert_ne!(c, '.', "A button is char index 7 and must be pressed");
554 } else {
555 assert_eq!(c, '.', "non-A columns must be released");
556 }
557 }
558
559 let p1_field_right = lines[1].split('|').nth(2).unwrap();
560 for (i, c) in p1_field_right.chars().enumerate() {
561 if i == 0 {
562 assert_ne!(c, '.', "RIGHT button is char index 0 and must be pressed");
563 } else {
564 assert_eq!(c, '.', "non-RIGHT columns must be released");
565 }
566 }
567
568 let imported = "version 3\nport0 1\nport1 1\nport2 0\n\
571 |0|R.......|.......A||\n";
572 let (movie, _) = import_fm2(imported, TEST_SHA).expect("import");
573 assert_eq!(movie.frames.len(), 1);
574 assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
575 assert_eq!(movie.frames[0].p2, Buttons::A);
576 }
577
578 #[test]
579 fn pal_flag_maps_to_region() {
580 let text = "version 3\npalFlag 1\nport0 1\nport1 1\nport2 0\n|0|........|........||\n";
582 let (movie, meta) = import_fm2(text, TEST_SHA).expect("import");
583 assert_eq!(movie.region, Region::Pal);
584 assert!(meta.pal);
585
586 let pal_movie = Movie {
588 region: Region::Pal,
589 rom_sha256: TEST_SHA,
590 start: StartPoint::PowerOn,
591 frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
592 rerecord_count: 0,
593 };
594 let out = export_fm2(&pal_movie, &Fm2ExportOpts::default()).expect("export");
595 assert!(
596 out.lines().any(|l| l == "palFlag 1"),
597 "Pal movie must export palFlag 1"
598 );
599
600 let ntsc_movie = Movie {
602 region: Region::Ntsc,
603 ..pal_movie
604 };
605 let out = export_fm2(&ntsc_movie, &Fm2ExportOpts::default()).expect("export");
606 assert!(out.lines().any(|l| l == "palFlag 0"));
607 }
608
609 #[test]
610 fn reset_command_parses_without_error() {
611 let text = "version 3\nport0 1\nport1 1\nport2 0\n|1|........|........||\n";
614 let (movie, _) = import_fm2(text, TEST_SHA).expect("reset command must parse");
615 assert_eq!(movie.frames.len(), 1);
616 assert_eq!(movie.frames[0].p1, Buttons::empty());
617 }
618
619 #[test]
620 fn fourscore_layout_parses_two_of_four_pads() {
621 let text = "version 3\nfourscore 1\nport0 1\nport1 1\nport2 0\n\
624 |0|.......A|......B.|R.......|.L......||\n";
625 let (movie, meta) = import_fm2(text, TEST_SHA).expect("fourscore import");
626 assert!(meta.fourscore);
627 assert_eq!(movie.frames.len(), 1);
628 assert_eq!(movie.frames[0].p1, Buttons::A);
629 assert_eq!(movie.frames[0].p2, Buttons::B);
630
631 let out = export_fm2(
633 &movie,
634 &Fm2ExportOpts {
635 fourscore: true,
636 ..Default::default()
637 },
638 )
639 .expect("export");
640 let log_line = out.lines().find(|l| l.starts_with('|')).unwrap();
641 let pad_count = log_line.split('|').filter(|p| p.len() == 8).count();
644 assert_eq!(pad_count, 4, "fourscore export must emit four pad fields");
645 }
646
647 #[test]
648 fn malformed_inputs_never_panic() {
649 assert!(matches!(
651 import_fm2("emuVersion 1\nport0 1\n", TEST_SHA),
652 Err(Fm2Error::MissingVersion)
653 ));
654
655 assert!(matches!(
657 import_fm2("palFlag 0\nversion 3\n", TEST_SHA),
658 Err(Fm2Error::MissingVersion)
659 ));
660
661 assert!(matches!(
663 import_fm2("version 2\nport0 1\nport1 1\nport2 0\n", TEST_SHA),
664 Err(Fm2Error::BadVersion { got: 2 })
665 ));
666
667 assert!(matches!(
669 import_fm2("version 3\npalFlag notanint\n", TEST_SHA),
670 Err(Fm2Error::BadInteger { .. })
671 ));
672
673 assert!(matches!(
675 import_fm2(
676 "version 3\nport0 1\nport1 1\nport2 0\n|0|........|........\n",
677 TEST_SHA
678 ),
679 Err(Fm2Error::Malformed { .. })
680 ));
681
682 assert!(matches!(
684 import_fm2(
685 "version 3\nport0 1\nport1 1\nport2 0\n|0|.......|........||\n",
686 TEST_SHA
687 ),
688 Err(Fm2Error::Malformed { .. })
689 ));
690
691 assert!(matches!(
693 import_fm2("version 3\nport0 2\nport1 1\nport2 0\n", TEST_SHA),
694 Err(Fm2Error::Unsupported(_))
695 ));
696
697 assert!(matches!(
699 import_fm2("version 3\nsavestate 0xDEAD\nport0 1\n", TEST_SHA),
700 Err(Fm2Error::Unsupported(_))
701 ));
702 }
703
704 #[test]
705 fn representative_header_parses() {
706 let text = "version 3\n\
707 emuVersion 22020\n\
708 rerecordCount 1234\n\
709 palFlag 0\n\
710 fourscore 0\n\
711 port0 1\n\
712 port1 1\n\
713 port2 0\n\
714 romFilename Super Demo.nes\n\
715 romChecksum base64:abc123==\n\
716 comment author Jane Doe\n\
717 comment subject A speedrun\n\
718 guid 452DE2C3-EF43-2FA9-77AC-0677FC51543B\n\
719 |0|........|........||\n\
720 |0|.......A|........||\n";
721 let (movie, meta) = import_fm2(text, TEST_SHA).expect("header parse");
722 assert_eq!(movie.frames.len(), 2);
723 assert_eq!(movie.region, Region::Ntsc);
724 assert_eq!(meta.rerecord_count, 1234);
725 assert!(!meta.fourscore);
726 assert!(!meta.pal);
727 assert_eq!(meta.author.as_deref(), Some("Jane Doe"));
728 assert_eq!(meta.rom_filename.as_deref(), Some("Super Demo.nes"));
729 assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:abc123=="));
730 assert_eq!(movie.frames[1].p1, Buttons::A);
732 }
733
734 #[test]
735 fn export_rejects_save_state_movie() {
736 let movie = Movie {
737 region: Region::Ntsc,
738 rom_sha256: TEST_SHA,
739 start: StartPoint::SaveState(vec![1, 2, 3]),
740 frames: vec![],
741 rerecord_count: 0,
742 };
743 assert!(matches!(
744 export_fm2(&movie, &Fm2ExportOpts::default()),
745 Err(Fm2Error::Unsupported(_))
746 ));
747 }
748}