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 attestation: None,
200 };
201 Ok((
202 movie,
203 LegacyMeta {
204 rerecord_count,
205 pal,
206 },
207 ))
208}
209
210fn decode_fcm_stream(
219 stream: &[u8],
220 frame_hint: usize,
221) -> Result<Vec<FrameInput>, LegacyMovieError> {
222 const FMT: &str = "fcm";
223 const HARD_CAP: usize = 1 << 24;
230 let cap = if frame_hint == 0 {
231 HARD_CAP
232 } else {
233 frame_hint.min(HARD_CAP)
234 };
235 let mut frames: Vec<FrameInput> = Vec::with_capacity(cap.min(4096));
236 let mut held = [Buttons::empty(); 2];
238 let mut i = 0usize;
239
240 let emit = |frames: &mut Vec<FrameInput>, held: &[Buttons; 2], n: usize| {
241 for _ in 0..n {
242 if frames.len() >= cap {
243 break;
244 }
245 frames.push(FrameInput::new(held[0], held[1]));
246 }
247 };
248
249 while i < stream.len() {
250 if frames.len() >= cap {
251 break;
252 }
253 let update = stream[i];
254 i += 1;
255 let delta_bytes = usize::from((update >> 5) & 0x3);
258 if i + delta_bytes > stream.len() {
259 return Err(LegacyMovieError::Malformed {
260 format: FMT,
261 reason: "delta bytes run past end of stream",
262 });
263 }
264 let mut advance: usize = 0;
265 for b in 0..delta_bytes {
266 advance |= usize::from(stream[i + b]) << (8 * b);
267 }
268 i += delta_bytes;
269 emit(&mut frames, &held, advance);
271
272 if update & 0x80 != 0 {
273 } else {
279 let player = ((update >> 3) & 0x3) as usize; let button_idx = update & 0x7;
284 if player >= 2 {
285 return Err(LegacyMovieError::Unsupported {
286 format: FMT,
287 reason: "four-score (>2 controllers) not supported",
288 });
289 }
290 let bit = Buttons::from_bits_truncate(1u8 << button_idx);
291 held[player] ^= bit; }
293 emit(&mut frames, &held, 1);
295 }
296
297 Ok(frames)
298}
299
300#[must_use]
306pub fn fmv_byte_to_buttons(byte: u8) -> Buttons {
307 let mut b = Buttons::empty();
308 if byte & 0x01 != 0 {
309 b |= Buttons::RIGHT;
310 }
311 if byte & 0x02 != 0 {
312 b |= Buttons::LEFT;
313 }
314 if byte & 0x04 != 0 {
315 b |= Buttons::UP;
316 }
317 if byte & 0x08 != 0 {
318 b |= Buttons::DOWN;
319 }
320 if byte & 0x10 != 0 {
321 b |= Buttons::B;
322 }
323 if byte & 0x20 != 0 {
324 b |= Buttons::A;
325 }
326 if byte & 0x40 != 0 {
327 b |= Buttons::SELECT;
328 }
329 if byte & 0x80 != 0 {
330 b |= Buttons::START;
331 }
332 b
333}
334
335pub fn import_fmv(
347 bytes: &[u8],
348 rom_sha256: [u8; 32],
349) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
350 const FMT: &str = "fmv";
351 if bytes.len() < FMV_HEADER_LEN {
352 return Err(LegacyMovieError::Truncated {
353 expected: FMV_HEADER_LEN,
354 got: bytes.len(),
355 });
356 }
357 if &bytes[0..4] != FMV_MAGIC {
358 return Err(LegacyMovieError::BadMagic { format: FMT });
359 }
360 let flags1 = bytes[0x04];
361 if flags1 & 0x04 != 0 {
363 return Err(LegacyMovieError::Unsupported {
364 format: FMT,
365 reason: "begins from a save-state (cross-emulator save states are not portable)",
366 });
367 }
368 let flags2 = bytes[0x05];
369 let has_fds = flags2 & 0x20 != 0;
370 let has_p2 = flags2 & 0x40 != 0;
371 let has_p1 = flags2 & 0x80 != 0;
372 let rerecord_count = u64::from(rd_u32_le(bytes, 0x0A).unwrap_or(0)).wrapping_add(1);
374
375 let bpf = usize::from(has_p1) + usize::from(has_p2) + usize::from(has_fds);
377 if bpf == 0 {
378 return Err(LegacyMovieError::Malformed {
379 format: FMT,
380 reason: "no active controller streams declared",
381 });
382 }
383
384 let body = &bytes[FMV_HEADER_LEN..];
385 let frame_count = body.len() / bpf;
386 let mut frames = Vec::with_capacity(frame_count);
387 for f in 0..frame_count {
388 let base = f * bpf;
389 let mut p1 = Buttons::empty();
390 let mut p2 = Buttons::empty();
391 let mut col = 0usize;
395 if has_p1 {
396 p1 = fmv_byte_to_buttons(body[base + col]);
397 col += 1;
398 }
399 if has_p2 {
400 p2 = fmv_byte_to_buttons(body[base + col]);
401 col += 1;
402 }
403 let _ = (col, has_fds);
406 frames.push(FrameInput::new(p1, p2));
407 }
408
409 let movie = Movie {
410 region: Region::Ntsc, rom_sha256,
412 start: StartPoint::PowerOn,
413 frames,
414 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
415 attestation: None,
418 };
419 Ok((
420 movie,
421 LegacyMeta {
422 rerecord_count,
423 pal: false,
424 },
425 ))
426}
427
428pub fn import_vmv(
447 bytes: &[u8],
448 rom_sha256: [u8; 32],
449) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
450 const FMT: &str = "vmv";
451 const MIN_HEADER: usize = 0x40;
452 if bytes.len() < MIN_HEADER {
453 return Err(LegacyMovieError::Truncated {
454 expected: MIN_HEADER,
455 got: bytes.len(),
456 });
457 }
458 if &bytes[0..12] != VMV_MAGIC {
459 return Err(LegacyMovieError::BadMagic { format: FMT });
460 }
461 let flags = rd_u32_le(bytes, 0x10).unwrap_or(0);
462 let reset_based = flags & (1 << 6) != 0;
465 if !reset_based {
466 return Err(LegacyMovieError::Unsupported {
467 format: FMT,
468 reason: "begins from a save-state (cross-emulator save states are not portable)",
469 });
470 }
471 let ctrl_count = (usize::from(flags & 0x1 != 0))
472 + usize::from(flags & 0x2 != 0)
473 + usize::from(flags & 0x4 != 0)
474 + usize::from(flags & 0x8 != 0);
475 if ctrl_count > 2 {
481 return Err(LegacyMovieError::Unsupported {
482 format: FMT,
483 reason: "four-score (>2 controllers) not supported",
484 });
485 }
486 let ctrl_count = ctrl_count.max(1);
489 let rerecord_count = u64::from(rd_u32_le(bytes, 0x1C).unwrap_or(0));
490 let pal = bytes[0x23] == 1;
492 let frame_count = rd_u32_le(bytes, 0x38).unwrap_or(0) as usize;
493 let data_off = rd_u32_le(bytes, 0x34).unwrap_or(0) as usize;
494 if data_off != 0 && data_off < MIN_HEADER {
498 return Err(LegacyMovieError::Malformed {
499 format: FMT,
500 reason: "movie-data offset overlaps the header",
501 });
502 }
503 let data_off = if data_off == 0 || data_off > bytes.len() {
504 MIN_HEADER
506 } else {
507 data_off
508 };
509
510 let body = &bytes[data_off..];
511 let derived = body.len() / ctrl_count;
513 let frame_count = if frame_count == 0 {
514 derived
515 } else {
516 frame_count.min(derived)
517 };
518 let mut frames = Vec::with_capacity(frame_count);
519 for f in 0..frame_count {
520 let base = f * ctrl_count;
521 let p1 = Buttons::from_bits_truncate(*body.get(base).unwrap_or(&0));
522 let p2 = if ctrl_count >= 2 {
523 Buttons::from_bits_truncate(*body.get(base + 1).unwrap_or(&0))
524 } else {
525 Buttons::empty()
526 };
527 frames.push(FrameInput::new(p1, p2));
528 }
529
530 let movie = Movie {
531 region: if pal { Region::Pal } else { Region::Ntsc },
532 rom_sha256,
533 start: StartPoint::PowerOn,
534 frames,
535 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
536 attestation: None,
539 };
540 Ok((
541 movie,
542 LegacyMeta {
543 rerecord_count,
544 pal,
545 },
546 ))
547}
548
549pub const fn import_mc2(
560 _bytes: &[u8],
561 _rom_sha256: [u8; 32],
562) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
563 Err(LegacyMovieError::Unsupported {
564 format: "mc2",
565 reason: "`.mc2` is a PC Engine (PCEjin/Mednafen) movie, not an NES movie",
566 })
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use alloc::vec;
573
574 const TEST_SHA: [u8; 32] = [0x33; 32];
575
576 fn synth_fcm(flags: u8, frame_count: u32, stream: &[u8]) -> Vec<u8> {
579 let mut b = vec![0u8; 0x34];
580 b[0..4].copy_from_slice(FCM_MAGIC);
581 b[0x04..0x08].copy_from_slice(&FCM_VERSION.to_le_bytes());
582 b[0x08] = flags;
583 b[0x0C..0x10].copy_from_slice(&frame_count.to_le_bytes());
584 b[0x10..0x14].copy_from_slice(&7u32.to_le_bytes()); let first_frame = u32::try_from(b.len()).unwrap();
586 b[0x1C..0x20].copy_from_slice(&first_frame.to_le_bytes());
587 b.extend_from_slice(stream);
588 b
589 }
590
591 #[test]
592 fn fcm_rejects_bad_magic_and_version() {
593 let mut b = synth_fcm(0x02, 0, &[]);
594 b[0] = b'X';
595 assert!(matches!(
596 import_fcm(&b, TEST_SHA),
597 Err(LegacyMovieError::BadMagic { .. })
598 ));
599 let mut b = synth_fcm(0x02, 0, &[]);
600 b[0x04] = 9; assert!(matches!(
602 import_fcm(&b, TEST_SHA),
603 Err(LegacyMovieError::BadVersion { .. })
604 ));
605 }
606
607 #[test]
608 fn fcm_rejects_savestate_start() {
609 let b = synth_fcm(0x00, 0, &[]);
611 assert!(matches!(
612 import_fcm(&b, TEST_SHA),
613 Err(LegacyMovieError::Unsupported { .. })
614 ));
615 }
616
617 #[test]
618 fn fcm_toggle_stream_decodes() {
619 let stream = [0x07u8, 0x07u8];
625 let b = synth_fcm(0x02, 2, &stream);
626 let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
627 assert_eq!(movie.region, Region::Ntsc);
628 assert_eq!(movie.frames.len(), 2);
629 assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
630 assert_eq!(movie.frames[1].p1, Buttons::empty());
631 assert_eq!(meta.rerecord_count, 7);
632 assert_eq!(movie.start, StartPoint::PowerOn);
633 }
634
635 #[test]
636 fn fcm_delta_advances_frames() {
637 let stream = [0xA0u8, 0x03u8];
642 let b = synth_fcm(0x02, 4, &stream);
643 let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
644 assert_eq!(movie.frames.len(), 4);
645 assert!(movie.frames.iter().all(|f| f.p1 == Buttons::empty()));
646 }
647
648 #[test]
649 fn fcm_pal_flag() {
650 let b = synth_fcm(0x02 | 0x04, 0, &[]);
651 let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
652 assert_eq!(movie.region, Region::Pal);
653 assert!(meta.pal);
654 }
655
656 fn synth_fmv(flags1: u8, flags2: u8, body: &[u8]) -> Vec<u8> {
658 let mut b = vec![0u8; FMV_HEADER_LEN];
659 b[0..4].copy_from_slice(FMV_MAGIC);
660 b[0x04] = flags1;
661 b[0x05] = flags2;
662 b[0x0A..0x0E].copy_from_slice(&4u32.to_le_bytes()); b.extend_from_slice(body);
664 b
665 }
666
667 #[test]
668 fn fmv_p1_only_full_dump() {
669 let body = [0x20u8, 0x01u8];
672 let b = synth_fmv(0x00, 0x80, &body);
673 let (movie, meta) = import_fmv(&b, TEST_SHA).expect("fmv import");
674 assert_eq!(movie.frames.len(), 2);
675 assert_eq!(movie.frames[0].p1, Buttons::A);
676 assert_eq!(movie.frames[1].p1, Buttons::RIGHT);
677 assert_eq!(movie.region, Region::Ntsc);
678 assert_eq!(meta.rerecord_count, 5);
679 }
680
681 #[test]
682 fn fmv_two_controllers_interleave() {
683 let body = [0x10u8, 0x80u8];
685 let b = synth_fmv(0x00, 0xC0, &body);
686 let (movie, _) = import_fmv(&b, TEST_SHA).expect("fmv import");
687 assert_eq!(movie.frames.len(), 1);
688 assert_eq!(movie.frames[0].p1, Buttons::B);
689 assert_eq!(movie.frames[0].p2, Buttons::START);
690 }
691
692 #[test]
693 fn fmv_rejects_savestate() {
694 let b = synth_fmv(0x04, 0x80, &[]);
695 assert!(matches!(
696 import_fmv(&b, TEST_SHA),
697 Err(LegacyMovieError::Unsupported { .. })
698 ));
699 }
700
701 #[test]
702 fn fmv_byte_permutation_is_correct() {
703 assert_eq!(fmv_byte_to_buttons(0x01), Buttons::RIGHT);
704 assert_eq!(fmv_byte_to_buttons(0x20), Buttons::A);
705 assert_eq!(fmv_byte_to_buttons(0x10), Buttons::B);
706 assert_eq!(fmv_byte_to_buttons(0x80), Buttons::START);
707 assert_eq!(
708 fmv_byte_to_buttons(0xFF),
709 Buttons::all(),
710 "all bits set -> all buttons"
711 );
712 }
713
714 fn synth_vmv(flags: u32, video_mode: u8, frame_count: u32, body: &[u8]) -> Vec<u8> {
717 let mut b = vec![0u8; 0x40];
718 b[0..12].copy_from_slice(VMV_MAGIC);
719 b[0x10..0x14].copy_from_slice(&flags.to_le_bytes());
720 b[0x1C..0x20].copy_from_slice(&11u32.to_le_bytes()); b[0x23] = video_mode;
722 let data_off = u32::try_from(b.len()).unwrap();
723 b[0x34..0x38].copy_from_slice(&data_off.to_le_bytes());
724 b[0x38..0x3C].copy_from_slice(&frame_count.to_le_bytes());
725 b.extend_from_slice(body);
726 b
727 }
728
729 #[test]
730 fn vmv_canonical_bit_order() {
731 let flags = (1u32 << 6) | 0x1;
734 let body = [Buttons::A.bits() | Buttons::RIGHT.bits()];
735 let b = synth_vmv(flags, 0, 1, &body);
736 let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
737 assert_eq!(movie.frames.len(), 1);
738 assert_eq!(movie.frames[0].p1, Buttons::A | Buttons::RIGHT);
739 assert_eq!(movie.region, Region::Ntsc);
740 assert_eq!(meta.rerecord_count, 11);
741 }
742
743 #[test]
744 fn vmv_pal_video_mode() {
745 let flags = (1u32 << 6) | 0x1;
746 let b = synth_vmv(flags, 1, 1, &[0u8]);
747 let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
748 assert_eq!(movie.region, Region::Pal);
749 assert!(meta.pal);
750 }
751
752 #[test]
753 fn vmv_rejects_savestate() {
754 let b = synth_vmv(0x1, 0, 1, &[0u8]);
756 assert!(matches!(
757 import_vmv(&b, TEST_SHA),
758 Err(LegacyMovieError::Unsupported { .. })
759 ));
760 }
761
762 #[test]
763 fn vmv_rejects_bad_magic() {
764 let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
765 b[0] = b'X';
766 assert!(matches!(
767 import_vmv(&b, TEST_SHA),
768 Err(LegacyMovieError::BadMagic { .. })
769 ));
770 }
771
772 #[test]
773 fn mc2_is_rejected_as_pce() {
774 assert!(matches!(
775 import_mc2(&[0u8; 16], TEST_SHA),
776 Err(LegacyMovieError::Unsupported { format: "mc2", .. })
777 ));
778 }
779
780 #[test]
781 fn fcm_rejects_overlapping_first_frame_offset() {
782 let mut b = synth_fcm(0x02, 0, &[]);
784 b[0x1C..0x20].copy_from_slice(&0x10u32.to_le_bytes());
785 assert!(matches!(
786 import_fcm(&b, TEST_SHA),
787 Err(LegacyMovieError::Malformed { .. })
788 ));
789 }
790
791 #[test]
792 fn fcm_oversized_advance_is_capped_not_unbounded() {
793 let stream = [0xE0u8, 0xFFu8, 0xFFu8, 0xFFu8];
799 let b = synth_fcm(0x02, 0, &stream);
800 let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
801 assert!(
802 movie.frames.len() <= (1 << 24),
803 "output must be capped at the hard limit, got {}",
804 movie.frames.len()
805 );
806 }
807
808 #[test]
809 fn vmv_rejects_overlapping_data_offset() {
810 let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
812 b[0x34..0x38].copy_from_slice(&0x20u32.to_le_bytes());
813 assert!(matches!(
814 import_vmv(&b, TEST_SHA),
815 Err(LegacyMovieError::Malformed { .. })
816 ));
817 }
818
819 #[test]
820 fn vmv_rejects_four_controllers() {
821 let flags = (1u32 << 6) | 0xF;
823 let b = synth_vmv(flags, 0, 1, &[0u8, 0u8, 0u8, 0u8]);
824 assert!(
825 matches!(
826 import_vmv(&b, TEST_SHA),
827 Err(LegacyMovieError::Unsupported { .. })
828 ),
829 "a 4-controller .vmv must be rejected rather than silently dropping P3/P4"
830 );
831 }
832
833 #[test]
834 fn vmv_two_controllers_round_trip() {
835 let flags = (1u32 << 6) | 0x3;
837 let body = [Buttons::A.bits(), Buttons::START.bits()];
838 let b = synth_vmv(flags, 0, 1, &body);
839 let (movie, _) = import_vmv(&b, TEST_SHA).expect("vmv import");
840 assert_eq!(movie.frames.len(), 1);
841 assert_eq!(movie.frames[0].p1, Buttons::A);
842 assert_eq!(movie.frames[0].p2, Buttons::START);
843 }
844
845 #[test]
846 fn truncated_inputs_never_panic() {
847 assert!(matches!(
848 import_fcm(&[0u8; 4], TEST_SHA),
849 Err(LegacyMovieError::Truncated { .. })
850 ));
851 assert!(matches!(
852 import_fmv(&[0u8; 4], TEST_SHA),
853 Err(LegacyMovieError::Truncated { .. })
854 ));
855 assert!(matches!(
856 import_vmv(&[0u8; 4], TEST_SHA),
857 Err(LegacyMovieError::Truncated { .. })
858 ));
859 }
860}