1use alloc::vec::Vec;
42
43use crate::Region;
44use crate::controller::Buttons;
45use crate::movie::{FrameInput, Movie, StartPoint};
46use thiserror::Error;
47
48const FCM_MAGIC: &[u8; 4] = b"FCM\x1A";
50const FCM_VERSION: u32 = 2;
52const FMV_MAGIC: &[u8; 4] = b"FMV\x1A";
54const FMV_HEADER_LEN: usize = 144;
56const VMV_MAGIC: &[u8; 12] = b"VirtuaNES MV";
58
59#[derive(Debug, Error)]
61#[non_exhaustive]
62pub enum LegacyMovieError {
63 #[error("legacy movie truncated: need at least {expected} bytes, got {got}")]
65 Truncated {
66 expected: usize,
68 got: usize,
70 },
71
72 #[error("legacy movie magic mismatch (not a {format} movie)")]
74 BadMagic {
75 format: &'static str,
77 },
78
79 #[error("legacy movie {format} version {got} not supported")]
81 BadVersion {
82 format: &'static str,
84 got: u32,
86 },
87
88 #[error("legacy movie {format} malformed: {reason}")]
91 Malformed {
92 format: &'static str,
94 reason: &'static str,
96 },
97
98 #[error("legacy movie {format} unsupported: {reason}")]
101 Unsupported {
102 format: &'static str,
104 reason: &'static str,
106 },
107}
108
109#[derive(Clone, Debug, Default, Eq, PartialEq)]
111pub struct LegacyMeta {
112 pub rerecord_count: u64,
114 pub pal: bool,
116}
117
118fn rd_u32_le(bytes: &[u8], off: usize) -> Option<u32> {
120 let b = bytes.get(off..off + 4)?;
121 Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
122}
123
124pub fn import_fcm(
141 bytes: &[u8],
142 rom_sha256: [u8; 32],
143) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
144 const FMT: &str = "fcm";
145 const MIN_HEADER: usize = 0x34;
148 if bytes.len() < MIN_HEADER {
149 return Err(LegacyMovieError::Truncated {
150 expected: MIN_HEADER,
151 got: bytes.len(),
152 });
153 }
154 if &bytes[0..4] != FCM_MAGIC {
155 return Err(LegacyMovieError::BadMagic { format: FMT });
156 }
157 let version = rd_u32_le(bytes, 0x04).unwrap_or(0);
158 if version != FCM_VERSION {
159 return Err(LegacyMovieError::BadVersion {
160 format: FMT,
161 got: version,
162 });
163 }
164 let flags = bytes[0x08];
165 let reset_based = flags & 0x02 != 0;
167 if !reset_based {
168 return Err(LegacyMovieError::Unsupported {
169 format: FMT,
170 reason: "begins from a save-state (cross-emulator save states are not portable)",
171 });
172 }
173 let pal = flags & 0x04 != 0;
175 let frame_count = rd_u32_le(bytes, 0x0C).unwrap_or(0) as usize;
176 let rerecord_count = u64::from(rd_u32_le(bytes, 0x10).unwrap_or(0));
177 let first_frame = rd_u32_le(bytes, 0x1C).unwrap_or(0) as usize;
180 if first_frame < MIN_HEADER || first_frame > bytes.len() {
183 return Err(LegacyMovieError::Malformed {
184 format: FMT,
185 reason: "input-data offset is out of range",
186 });
187 }
188
189 let stream = &bytes[first_frame..];
190 let frames = decode_fcm_stream(stream, frame_count)?;
191 let movie = Movie {
192 region: if pal { Region::Pal } else { Region::Ntsc },
193 rom_sha256,
194 start: StartPoint::PowerOn,
195 frames,
196 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
197 };
198 Ok((
199 movie,
200 LegacyMeta {
201 rerecord_count,
202 pal,
203 },
204 ))
205}
206
207fn decode_fcm_stream(
216 stream: &[u8],
217 frame_hint: usize,
218) -> Result<Vec<FrameInput>, LegacyMovieError> {
219 const FMT: &str = "fcm";
220 const HARD_CAP: usize = 1 << 24;
227 let cap = if frame_hint == 0 {
228 HARD_CAP
229 } else {
230 frame_hint.min(HARD_CAP)
231 };
232 let mut frames: Vec<FrameInput> = Vec::with_capacity(cap.min(4096));
233 let mut held = [Buttons::empty(); 2];
235 let mut i = 0usize;
236
237 let emit = |frames: &mut Vec<FrameInput>, held: &[Buttons; 2], n: usize| {
238 for _ in 0..n {
239 if frames.len() >= cap {
240 break;
241 }
242 frames.push(FrameInput::new(held[0], held[1]));
243 }
244 };
245
246 while i < stream.len() {
247 if frames.len() >= cap {
248 break;
249 }
250 let update = stream[i];
251 i += 1;
252 let delta_bytes = usize::from((update >> 5) & 0x3);
255 if i + delta_bytes > stream.len() {
256 return Err(LegacyMovieError::Malformed {
257 format: FMT,
258 reason: "delta bytes run past end of stream",
259 });
260 }
261 let mut advance: usize = 0;
262 for b in 0..delta_bytes {
263 advance |= usize::from(stream[i + b]) << (8 * b);
264 }
265 i += delta_bytes;
266 emit(&mut frames, &held, advance);
268
269 if update & 0x80 != 0 {
270 } else {
276 let player = ((update >> 3) & 0x3) as usize; let button_idx = update & 0x7;
281 if player >= 2 {
282 return Err(LegacyMovieError::Unsupported {
283 format: FMT,
284 reason: "four-score (>2 controllers) not supported",
285 });
286 }
287 let bit = Buttons::from_bits_truncate(1u8 << button_idx);
288 held[player] ^= bit; }
290 emit(&mut frames, &held, 1);
292 }
293
294 Ok(frames)
295}
296
297#[must_use]
303pub fn fmv_byte_to_buttons(byte: u8) -> Buttons {
304 let mut b = Buttons::empty();
305 if byte & 0x01 != 0 {
306 b |= Buttons::RIGHT;
307 }
308 if byte & 0x02 != 0 {
309 b |= Buttons::LEFT;
310 }
311 if byte & 0x04 != 0 {
312 b |= Buttons::UP;
313 }
314 if byte & 0x08 != 0 {
315 b |= Buttons::DOWN;
316 }
317 if byte & 0x10 != 0 {
318 b |= Buttons::B;
319 }
320 if byte & 0x20 != 0 {
321 b |= Buttons::A;
322 }
323 if byte & 0x40 != 0 {
324 b |= Buttons::SELECT;
325 }
326 if byte & 0x80 != 0 {
327 b |= Buttons::START;
328 }
329 b
330}
331
332pub fn import_fmv(
344 bytes: &[u8],
345 rom_sha256: [u8; 32],
346) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
347 const FMT: &str = "fmv";
348 if bytes.len() < FMV_HEADER_LEN {
349 return Err(LegacyMovieError::Truncated {
350 expected: FMV_HEADER_LEN,
351 got: bytes.len(),
352 });
353 }
354 if &bytes[0..4] != FMV_MAGIC {
355 return Err(LegacyMovieError::BadMagic { format: FMT });
356 }
357 let flags1 = bytes[0x04];
358 if flags1 & 0x04 != 0 {
360 return Err(LegacyMovieError::Unsupported {
361 format: FMT,
362 reason: "begins from a save-state (cross-emulator save states are not portable)",
363 });
364 }
365 let flags2 = bytes[0x05];
366 let has_fds = flags2 & 0x20 != 0;
367 let has_p2 = flags2 & 0x40 != 0;
368 let has_p1 = flags2 & 0x80 != 0;
369 let rerecord_count = u64::from(rd_u32_le(bytes, 0x0A).unwrap_or(0)).wrapping_add(1);
371
372 let bpf = usize::from(has_p1) + usize::from(has_p2) + usize::from(has_fds);
374 if bpf == 0 {
375 return Err(LegacyMovieError::Malformed {
376 format: FMT,
377 reason: "no active controller streams declared",
378 });
379 }
380
381 let body = &bytes[FMV_HEADER_LEN..];
382 let frame_count = body.len() / bpf;
383 let mut frames = Vec::with_capacity(frame_count);
384 for f in 0..frame_count {
385 let base = f * bpf;
386 let mut p1 = Buttons::empty();
387 let mut p2 = Buttons::empty();
388 let mut col = 0usize;
392 if has_p1 {
393 p1 = fmv_byte_to_buttons(body[base + col]);
394 col += 1;
395 }
396 if has_p2 {
397 p2 = fmv_byte_to_buttons(body[base + col]);
398 col += 1;
399 }
400 let _ = (col, has_fds);
403 frames.push(FrameInput::new(p1, p2));
404 }
405
406 let movie = Movie {
407 region: Region::Ntsc, rom_sha256,
409 start: StartPoint::PowerOn,
410 frames,
411 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
412 };
413 Ok((
414 movie,
415 LegacyMeta {
416 rerecord_count,
417 pal: false,
418 },
419 ))
420}
421
422pub fn import_vmv(
441 bytes: &[u8],
442 rom_sha256: [u8; 32],
443) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
444 const FMT: &str = "vmv";
445 const MIN_HEADER: usize = 0x40;
446 if bytes.len() < MIN_HEADER {
447 return Err(LegacyMovieError::Truncated {
448 expected: MIN_HEADER,
449 got: bytes.len(),
450 });
451 }
452 if &bytes[0..12] != VMV_MAGIC {
453 return Err(LegacyMovieError::BadMagic { format: FMT });
454 }
455 let flags = rd_u32_le(bytes, 0x10).unwrap_or(0);
456 let reset_based = flags & (1 << 6) != 0;
459 if !reset_based {
460 return Err(LegacyMovieError::Unsupported {
461 format: FMT,
462 reason: "begins from a save-state (cross-emulator save states are not portable)",
463 });
464 }
465 let ctrl_count = (usize::from(flags & 0x1 != 0))
466 + usize::from(flags & 0x2 != 0)
467 + usize::from(flags & 0x4 != 0)
468 + usize::from(flags & 0x8 != 0);
469 if ctrl_count > 2 {
475 return Err(LegacyMovieError::Unsupported {
476 format: FMT,
477 reason: "four-score (>2 controllers) not supported",
478 });
479 }
480 let ctrl_count = ctrl_count.max(1);
483 let rerecord_count = u64::from(rd_u32_le(bytes, 0x1C).unwrap_or(0));
484 let pal = bytes[0x23] == 1;
486 let frame_count = rd_u32_le(bytes, 0x38).unwrap_or(0) as usize;
487 let data_off = rd_u32_le(bytes, 0x34).unwrap_or(0) as usize;
488 if data_off != 0 && data_off < MIN_HEADER {
492 return Err(LegacyMovieError::Malformed {
493 format: FMT,
494 reason: "movie-data offset overlaps the header",
495 });
496 }
497 let data_off = if data_off == 0 || data_off > bytes.len() {
498 MIN_HEADER
500 } else {
501 data_off
502 };
503
504 let body = &bytes[data_off..];
505 let derived = body.len() / ctrl_count;
507 let frame_count = if frame_count == 0 {
508 derived
509 } else {
510 frame_count.min(derived)
511 };
512 let mut frames = Vec::with_capacity(frame_count);
513 for f in 0..frame_count {
514 let base = f * ctrl_count;
515 let p1 = Buttons::from_bits_truncate(*body.get(base).unwrap_or(&0));
516 let p2 = if ctrl_count >= 2 {
517 Buttons::from_bits_truncate(*body.get(base + 1).unwrap_or(&0))
518 } else {
519 Buttons::empty()
520 };
521 frames.push(FrameInput::new(p1, p2));
522 }
523
524 let movie = Movie {
525 region: if pal { Region::Pal } else { Region::Ntsc },
526 rom_sha256,
527 start: StartPoint::PowerOn,
528 frames,
529 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
530 };
531 Ok((
532 movie,
533 LegacyMeta {
534 rerecord_count,
535 pal,
536 },
537 ))
538}
539
540pub const fn import_mc2(
551 _bytes: &[u8],
552 _rom_sha256: [u8; 32],
553) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
554 Err(LegacyMovieError::Unsupported {
555 format: "mc2",
556 reason: "`.mc2` is a PC Engine (PCEjin/Mednafen) movie, not an NES movie",
557 })
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use alloc::vec;
564
565 const TEST_SHA: [u8; 32] = [0x33; 32];
566
567 fn synth_fcm(flags: u8, frame_count: u32, stream: &[u8]) -> Vec<u8> {
570 let mut b = vec![0u8; 0x34];
571 b[0..4].copy_from_slice(FCM_MAGIC);
572 b[0x04..0x08].copy_from_slice(&FCM_VERSION.to_le_bytes());
573 b[0x08] = flags;
574 b[0x0C..0x10].copy_from_slice(&frame_count.to_le_bytes());
575 b[0x10..0x14].copy_from_slice(&7u32.to_le_bytes()); let first_frame = u32::try_from(b.len()).unwrap();
577 b[0x1C..0x20].copy_from_slice(&first_frame.to_le_bytes());
578 b.extend_from_slice(stream);
579 b
580 }
581
582 #[test]
583 fn fcm_rejects_bad_magic_and_version() {
584 let mut b = synth_fcm(0x02, 0, &[]);
585 b[0] = b'X';
586 assert!(matches!(
587 import_fcm(&b, TEST_SHA),
588 Err(LegacyMovieError::BadMagic { .. })
589 ));
590 let mut b = synth_fcm(0x02, 0, &[]);
591 b[0x04] = 9; assert!(matches!(
593 import_fcm(&b, TEST_SHA),
594 Err(LegacyMovieError::BadVersion { .. })
595 ));
596 }
597
598 #[test]
599 fn fcm_rejects_savestate_start() {
600 let b = synth_fcm(0x00, 0, &[]);
602 assert!(matches!(
603 import_fcm(&b, TEST_SHA),
604 Err(LegacyMovieError::Unsupported { .. })
605 ));
606 }
607
608 #[test]
609 fn fcm_toggle_stream_decodes() {
610 let stream = [0x07u8, 0x07u8];
616 let b = synth_fcm(0x02, 2, &stream);
617 let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
618 assert_eq!(movie.region, Region::Ntsc);
619 assert_eq!(movie.frames.len(), 2);
620 assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
621 assert_eq!(movie.frames[1].p1, Buttons::empty());
622 assert_eq!(meta.rerecord_count, 7);
623 assert_eq!(movie.start, StartPoint::PowerOn);
624 }
625
626 #[test]
627 fn fcm_delta_advances_frames() {
628 let stream = [0xA0u8, 0x03u8];
633 let b = synth_fcm(0x02, 4, &stream);
634 let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
635 assert_eq!(movie.frames.len(), 4);
636 assert!(movie.frames.iter().all(|f| f.p1 == Buttons::empty()));
637 }
638
639 #[test]
640 fn fcm_pal_flag() {
641 let b = synth_fcm(0x02 | 0x04, 0, &[]);
642 let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
643 assert_eq!(movie.region, Region::Pal);
644 assert!(meta.pal);
645 }
646
647 fn synth_fmv(flags1: u8, flags2: u8, body: &[u8]) -> Vec<u8> {
649 let mut b = vec![0u8; FMV_HEADER_LEN];
650 b[0..4].copy_from_slice(FMV_MAGIC);
651 b[0x04] = flags1;
652 b[0x05] = flags2;
653 b[0x0A..0x0E].copy_from_slice(&4u32.to_le_bytes()); b.extend_from_slice(body);
655 b
656 }
657
658 #[test]
659 fn fmv_p1_only_full_dump() {
660 let body = [0x20u8, 0x01u8];
663 let b = synth_fmv(0x00, 0x80, &body);
664 let (movie, meta) = import_fmv(&b, TEST_SHA).expect("fmv import");
665 assert_eq!(movie.frames.len(), 2);
666 assert_eq!(movie.frames[0].p1, Buttons::A);
667 assert_eq!(movie.frames[1].p1, Buttons::RIGHT);
668 assert_eq!(movie.region, Region::Ntsc);
669 assert_eq!(meta.rerecord_count, 5);
670 }
671
672 #[test]
673 fn fmv_two_controllers_interleave() {
674 let body = [0x10u8, 0x80u8];
676 let b = synth_fmv(0x00, 0xC0, &body);
677 let (movie, _) = import_fmv(&b, TEST_SHA).expect("fmv import");
678 assert_eq!(movie.frames.len(), 1);
679 assert_eq!(movie.frames[0].p1, Buttons::B);
680 assert_eq!(movie.frames[0].p2, Buttons::START);
681 }
682
683 #[test]
684 fn fmv_rejects_savestate() {
685 let b = synth_fmv(0x04, 0x80, &[]);
686 assert!(matches!(
687 import_fmv(&b, TEST_SHA),
688 Err(LegacyMovieError::Unsupported { .. })
689 ));
690 }
691
692 #[test]
693 fn fmv_byte_permutation_is_correct() {
694 assert_eq!(fmv_byte_to_buttons(0x01), Buttons::RIGHT);
695 assert_eq!(fmv_byte_to_buttons(0x20), Buttons::A);
696 assert_eq!(fmv_byte_to_buttons(0x10), Buttons::B);
697 assert_eq!(fmv_byte_to_buttons(0x80), Buttons::START);
698 assert_eq!(
699 fmv_byte_to_buttons(0xFF),
700 Buttons::all(),
701 "all bits set -> all buttons"
702 );
703 }
704
705 fn synth_vmv(flags: u32, video_mode: u8, frame_count: u32, body: &[u8]) -> Vec<u8> {
708 let mut b = vec![0u8; 0x40];
709 b[0..12].copy_from_slice(VMV_MAGIC);
710 b[0x10..0x14].copy_from_slice(&flags.to_le_bytes());
711 b[0x1C..0x20].copy_from_slice(&11u32.to_le_bytes()); b[0x23] = video_mode;
713 let data_off = u32::try_from(b.len()).unwrap();
714 b[0x34..0x38].copy_from_slice(&data_off.to_le_bytes());
715 b[0x38..0x3C].copy_from_slice(&frame_count.to_le_bytes());
716 b.extend_from_slice(body);
717 b
718 }
719
720 #[test]
721 fn vmv_canonical_bit_order() {
722 let flags = (1u32 << 6) | 0x1;
725 let body = [Buttons::A.bits() | Buttons::RIGHT.bits()];
726 let b = synth_vmv(flags, 0, 1, &body);
727 let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
728 assert_eq!(movie.frames.len(), 1);
729 assert_eq!(movie.frames[0].p1, Buttons::A | Buttons::RIGHT);
730 assert_eq!(movie.region, Region::Ntsc);
731 assert_eq!(meta.rerecord_count, 11);
732 }
733
734 #[test]
735 fn vmv_pal_video_mode() {
736 let flags = (1u32 << 6) | 0x1;
737 let b = synth_vmv(flags, 1, 1, &[0u8]);
738 let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
739 assert_eq!(movie.region, Region::Pal);
740 assert!(meta.pal);
741 }
742
743 #[test]
744 fn vmv_rejects_savestate() {
745 let b = synth_vmv(0x1, 0, 1, &[0u8]);
747 assert!(matches!(
748 import_vmv(&b, TEST_SHA),
749 Err(LegacyMovieError::Unsupported { .. })
750 ));
751 }
752
753 #[test]
754 fn vmv_rejects_bad_magic() {
755 let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
756 b[0] = b'X';
757 assert!(matches!(
758 import_vmv(&b, TEST_SHA),
759 Err(LegacyMovieError::BadMagic { .. })
760 ));
761 }
762
763 #[test]
764 fn mc2_is_rejected_as_pce() {
765 assert!(matches!(
766 import_mc2(&[0u8; 16], TEST_SHA),
767 Err(LegacyMovieError::Unsupported { format: "mc2", .. })
768 ));
769 }
770
771 #[test]
772 fn fcm_rejects_overlapping_first_frame_offset() {
773 let mut b = synth_fcm(0x02, 0, &[]);
775 b[0x1C..0x20].copy_from_slice(&0x10u32.to_le_bytes());
776 assert!(matches!(
777 import_fcm(&b, TEST_SHA),
778 Err(LegacyMovieError::Malformed { .. })
779 ));
780 }
781
782 #[test]
783 fn fcm_oversized_advance_is_capped_not_unbounded() {
784 let stream = [0xE0u8, 0xFFu8, 0xFFu8, 0xFFu8];
790 let b = synth_fcm(0x02, 0, &stream);
791 let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
792 assert!(
793 movie.frames.len() <= (1 << 24),
794 "output must be capped at the hard limit, got {}",
795 movie.frames.len()
796 );
797 }
798
799 #[test]
800 fn vmv_rejects_overlapping_data_offset() {
801 let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
803 b[0x34..0x38].copy_from_slice(&0x20u32.to_le_bytes());
804 assert!(matches!(
805 import_vmv(&b, TEST_SHA),
806 Err(LegacyMovieError::Malformed { .. })
807 ));
808 }
809
810 #[test]
811 fn vmv_rejects_four_controllers() {
812 let flags = (1u32 << 6) | 0xF;
814 let b = synth_vmv(flags, 0, 1, &[0u8, 0u8, 0u8, 0u8]);
815 assert!(
816 matches!(
817 import_vmv(&b, TEST_SHA),
818 Err(LegacyMovieError::Unsupported { .. })
819 ),
820 "a 4-controller .vmv must be rejected rather than silently dropping P3/P4"
821 );
822 }
823
824 #[test]
825 fn vmv_two_controllers_round_trip() {
826 let flags = (1u32 << 6) | 0x3;
828 let body = [Buttons::A.bits(), Buttons::START.bits()];
829 let b = synth_vmv(flags, 0, 1, &body);
830 let (movie, _) = import_vmv(&b, TEST_SHA).expect("vmv import");
831 assert_eq!(movie.frames.len(), 1);
832 assert_eq!(movie.frames[0].p1, Buttons::A);
833 assert_eq!(movie.frames[0].p2, Buttons::START);
834 }
835
836 #[test]
837 fn truncated_inputs_never_panic() {
838 assert!(matches!(
839 import_fcm(&[0u8; 4], TEST_SHA),
840 Err(LegacyMovieError::Truncated { .. })
841 ));
842 assert!(matches!(
843 import_fmv(&[0u8; 4], TEST_SHA),
844 Err(LegacyMovieError::Truncated { .. })
845 ));
846 assert!(matches!(
847 import_vmv(&[0u8; 4], TEST_SHA),
848 Err(LegacyMovieError::Truncated { .. })
849 ));
850 }
851}