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 epoch: crate::EMULATION_EPOCH,
193 region: if pal { Region::Pal } else { Region::Ntsc },
194 rom_sha256,
195 options: crate::HardwareOptions::default(),
196 board: None,
197 start: StartPoint::PowerOn,
198 frames,
199 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
200 attestation: None,
203 };
204 Ok((
205 movie,
206 LegacyMeta {
207 rerecord_count,
208 pal,
209 },
210 ))
211}
212
213fn decode_fcm_stream(
222 stream: &[u8],
223 frame_hint: usize,
224) -> Result<Vec<FrameInput>, LegacyMovieError> {
225 const FMT: &str = "fcm";
226 const HARD_CAP: usize = 1 << 24;
233 let cap = if frame_hint == 0 {
234 HARD_CAP
235 } else {
236 frame_hint.min(HARD_CAP)
237 };
238 let mut frames: Vec<FrameInput> = Vec::with_capacity(cap.min(4096));
239 let mut held = [Buttons::empty(); 2];
241 let mut i = 0usize;
242
243 let emit = |frames: &mut Vec<FrameInput>, held: &[Buttons; 2], n: usize| {
244 for _ in 0..n {
245 if frames.len() >= cap {
246 break;
247 }
248 frames.push(FrameInput::new(held[0], held[1]));
249 }
250 };
251
252 while i < stream.len() {
253 if frames.len() >= cap {
254 break;
255 }
256 let update = stream[i];
257 i += 1;
258 let delta_bytes = usize::from((update >> 5) & 0x3);
261 if i + delta_bytes > stream.len() {
262 return Err(LegacyMovieError::Malformed {
263 format: FMT,
264 reason: "delta bytes run past end of stream",
265 });
266 }
267 let mut advance: usize = 0;
268 for b in 0..delta_bytes {
269 advance |= usize::from(stream[i + b]) << (8 * b);
270 }
271 i += delta_bytes;
272 emit(&mut frames, &held, advance);
274
275 if update & 0x80 != 0 {
276 } else {
282 let player = ((update >> 3) & 0x3) as usize; let button_idx = update & 0x7;
287 if player >= 2 {
288 return Err(LegacyMovieError::Unsupported {
289 format: FMT,
290 reason: "four-score (>2 controllers) not supported",
291 });
292 }
293 let bit = Buttons::from_bits_truncate(1u8 << button_idx);
294 held[player] ^= bit; }
296 emit(&mut frames, &held, 1);
298 }
299
300 Ok(frames)
301}
302
303#[must_use]
309pub fn fmv_byte_to_buttons(byte: u8) -> Buttons {
310 let mut b = Buttons::empty();
311 if byte & 0x01 != 0 {
312 b |= Buttons::RIGHT;
313 }
314 if byte & 0x02 != 0 {
315 b |= Buttons::LEFT;
316 }
317 if byte & 0x04 != 0 {
318 b |= Buttons::UP;
319 }
320 if byte & 0x08 != 0 {
321 b |= Buttons::DOWN;
322 }
323 if byte & 0x10 != 0 {
324 b |= Buttons::B;
325 }
326 if byte & 0x20 != 0 {
327 b |= Buttons::A;
328 }
329 if byte & 0x40 != 0 {
330 b |= Buttons::SELECT;
331 }
332 if byte & 0x80 != 0 {
333 b |= Buttons::START;
334 }
335 b
336}
337
338pub fn import_fmv(
350 bytes: &[u8],
351 rom_sha256: [u8; 32],
352) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
353 const FMT: &str = "fmv";
354 if bytes.len() < FMV_HEADER_LEN {
355 return Err(LegacyMovieError::Truncated {
356 expected: FMV_HEADER_LEN,
357 got: bytes.len(),
358 });
359 }
360 if &bytes[0..4] != FMV_MAGIC {
361 return Err(LegacyMovieError::BadMagic { format: FMT });
362 }
363 let flags1 = bytes[0x04];
364 if flags1 & 0x04 != 0 {
366 return Err(LegacyMovieError::Unsupported {
367 format: FMT,
368 reason: "begins from a save-state (cross-emulator save states are not portable)",
369 });
370 }
371 let flags2 = bytes[0x05];
372 let has_fds = flags2 & 0x20 != 0;
373 let has_p2 = flags2 & 0x40 != 0;
374 let has_p1 = flags2 & 0x80 != 0;
375 let rerecord_count = u64::from(rd_u32_le(bytes, 0x0A).unwrap_or(0)).wrapping_add(1);
377
378 let bpf = usize::from(has_p1) + usize::from(has_p2) + usize::from(has_fds);
380 if bpf == 0 {
381 return Err(LegacyMovieError::Malformed {
382 format: FMT,
383 reason: "no active controller streams declared",
384 });
385 }
386
387 let body = &bytes[FMV_HEADER_LEN..];
388 let frame_count = body.len() / bpf;
389 let mut frames = Vec::with_capacity(frame_count);
390 for f in 0..frame_count {
391 let base = f * bpf;
392 let mut p1 = Buttons::empty();
393 let mut p2 = Buttons::empty();
394 let mut col = 0usize;
398 if has_p1 {
399 p1 = fmv_byte_to_buttons(body[base + col]);
400 col += 1;
401 }
402 if has_p2 {
403 p2 = fmv_byte_to_buttons(body[base + col]);
404 col += 1;
405 }
406 let _ = (col, has_fds);
409 frames.push(FrameInput::new(p1, p2));
410 }
411
412 let movie = Movie {
413 epoch: crate::EMULATION_EPOCH,
414 region: Region::Ntsc, rom_sha256,
416 options: crate::HardwareOptions::default(),
417 board: None,
418 start: StartPoint::PowerOn,
419 frames,
420 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
421 attestation: None,
424 };
425 Ok((
426 movie,
427 LegacyMeta {
428 rerecord_count,
429 pal: false,
430 },
431 ))
432}
433
434pub fn import_vmv(
453 bytes: &[u8],
454 rom_sha256: [u8; 32],
455) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
456 const FMT: &str = "vmv";
457 const MIN_HEADER: usize = 0x40;
458 if bytes.len() < MIN_HEADER {
459 return Err(LegacyMovieError::Truncated {
460 expected: MIN_HEADER,
461 got: bytes.len(),
462 });
463 }
464 if &bytes[0..12] != VMV_MAGIC {
465 return Err(LegacyMovieError::BadMagic { format: FMT });
466 }
467 let flags = rd_u32_le(bytes, 0x10).unwrap_or(0);
468 let reset_based = flags & (1 << 6) != 0;
471 if !reset_based {
472 return Err(LegacyMovieError::Unsupported {
473 format: FMT,
474 reason: "begins from a save-state (cross-emulator save states are not portable)",
475 });
476 }
477 let ctrl_count = (usize::from(flags & 0x1 != 0))
478 + usize::from(flags & 0x2 != 0)
479 + usize::from(flags & 0x4 != 0)
480 + usize::from(flags & 0x8 != 0);
481 if ctrl_count > 2 {
487 return Err(LegacyMovieError::Unsupported {
488 format: FMT,
489 reason: "four-score (>2 controllers) not supported",
490 });
491 }
492 let ctrl_count = ctrl_count.max(1);
495 let rerecord_count = u64::from(rd_u32_le(bytes, 0x1C).unwrap_or(0));
496 let pal = bytes[0x23] == 1;
498 let frame_count = rd_u32_le(bytes, 0x38).unwrap_or(0) as usize;
499 let data_off = rd_u32_le(bytes, 0x34).unwrap_or(0) as usize;
500 if data_off != 0 && data_off < MIN_HEADER {
504 return Err(LegacyMovieError::Malformed {
505 format: FMT,
506 reason: "movie-data offset overlaps the header",
507 });
508 }
509 let data_off = if data_off == 0 || data_off > bytes.len() {
510 MIN_HEADER
512 } else {
513 data_off
514 };
515
516 let body = &bytes[data_off..];
517 let derived = body.len() / ctrl_count;
519 let frame_count = if frame_count == 0 {
520 derived
521 } else {
522 frame_count.min(derived)
523 };
524 let mut frames = Vec::with_capacity(frame_count);
525 for f in 0..frame_count {
526 let base = f * ctrl_count;
527 let p1 = Buttons::from_bits_truncate(*body.get(base).unwrap_or(&0));
528 let p2 = if ctrl_count >= 2 {
529 Buttons::from_bits_truncate(*body.get(base + 1).unwrap_or(&0))
530 } else {
531 Buttons::empty()
532 };
533 frames.push(FrameInput::new(p1, p2));
534 }
535
536 let movie = Movie {
537 epoch: crate::EMULATION_EPOCH,
538 region: if pal { Region::Pal } else { Region::Ntsc },
539 rom_sha256,
540 options: crate::HardwareOptions::default(),
541 board: None,
542 start: StartPoint::PowerOn,
543 frames,
544 rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
545 attestation: None,
548 };
549 Ok((
550 movie,
551 LegacyMeta {
552 rerecord_count,
553 pal,
554 },
555 ))
556}
557
558pub const fn import_mc2(
569 _bytes: &[u8],
570 _rom_sha256: [u8; 32],
571) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
572 Err(LegacyMovieError::Unsupported {
573 format: "mc2",
574 reason: "`.mc2` is a PC Engine (PCEjin/Mednafen) movie, not an NES movie",
575 })
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use alloc::vec;
582
583 const TEST_SHA: [u8; 32] = [0x33; 32];
584
585 fn synth_fcm(flags: u8, frame_count: u32, stream: &[u8]) -> Vec<u8> {
588 let mut b = vec![0u8; 0x34];
589 b[0..4].copy_from_slice(FCM_MAGIC);
590 b[0x04..0x08].copy_from_slice(&FCM_VERSION.to_le_bytes());
591 b[0x08] = flags;
592 b[0x0C..0x10].copy_from_slice(&frame_count.to_le_bytes());
593 b[0x10..0x14].copy_from_slice(&7u32.to_le_bytes()); let first_frame = u32::try_from(b.len()).unwrap();
595 b[0x1C..0x20].copy_from_slice(&first_frame.to_le_bytes());
596 b.extend_from_slice(stream);
597 b
598 }
599
600 #[test]
601 fn fcm_rejects_bad_magic_and_version() {
602 let mut b = synth_fcm(0x02, 0, &[]);
603 b[0] = b'X';
604 assert!(matches!(
605 import_fcm(&b, TEST_SHA),
606 Err(LegacyMovieError::BadMagic { .. })
607 ));
608 let mut b = synth_fcm(0x02, 0, &[]);
609 b[0x04] = 9; assert!(matches!(
611 import_fcm(&b, TEST_SHA),
612 Err(LegacyMovieError::BadVersion { .. })
613 ));
614 }
615
616 #[test]
617 fn fcm_rejects_savestate_start() {
618 let b = synth_fcm(0x00, 0, &[]);
620 assert!(matches!(
621 import_fcm(&b, TEST_SHA),
622 Err(LegacyMovieError::Unsupported { .. })
623 ));
624 }
625
626 #[test]
627 fn fcm_toggle_stream_decodes() {
628 let stream = [0x07u8, 0x07u8];
634 let b = synth_fcm(0x02, 2, &stream);
635 let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
636 assert_eq!(movie.region, Region::Ntsc);
637 assert_eq!(movie.frames.len(), 2);
638 assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
639 assert_eq!(movie.frames[1].p1, Buttons::empty());
640 assert_eq!(meta.rerecord_count, 7);
641 assert_eq!(movie.start, StartPoint::PowerOn);
642 }
643
644 #[test]
645 fn fcm_delta_advances_frames() {
646 let stream = [0xA0u8, 0x03u8];
651 let b = synth_fcm(0x02, 4, &stream);
652 let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
653 assert_eq!(movie.frames.len(), 4);
654 assert!(movie.frames.iter().all(|f| f.p1 == Buttons::empty()));
655 }
656
657 #[test]
658 fn fcm_pal_flag() {
659 let b = synth_fcm(0x02 | 0x04, 0, &[]);
660 let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
661 assert_eq!(movie.region, Region::Pal);
662 assert!(meta.pal);
663 }
664
665 fn synth_fmv(flags1: u8, flags2: u8, body: &[u8]) -> Vec<u8> {
667 let mut b = vec![0u8; FMV_HEADER_LEN];
668 b[0..4].copy_from_slice(FMV_MAGIC);
669 b[0x04] = flags1;
670 b[0x05] = flags2;
671 b[0x0A..0x0E].copy_from_slice(&4u32.to_le_bytes()); b.extend_from_slice(body);
673 b
674 }
675
676 #[test]
677 fn fmv_p1_only_full_dump() {
678 let body = [0x20u8, 0x01u8];
681 let b = synth_fmv(0x00, 0x80, &body);
682 let (movie, meta) = import_fmv(&b, TEST_SHA).expect("fmv import");
683 assert_eq!(movie.frames.len(), 2);
684 assert_eq!(movie.frames[0].p1, Buttons::A);
685 assert_eq!(movie.frames[1].p1, Buttons::RIGHT);
686 assert_eq!(movie.region, Region::Ntsc);
687 assert_eq!(meta.rerecord_count, 5);
688 }
689
690 #[test]
691 fn fmv_two_controllers_interleave() {
692 let body = [0x10u8, 0x80u8];
694 let b = synth_fmv(0x00, 0xC0, &body);
695 let (movie, _) = import_fmv(&b, TEST_SHA).expect("fmv import");
696 assert_eq!(movie.frames.len(), 1);
697 assert_eq!(movie.frames[0].p1, Buttons::B);
698 assert_eq!(movie.frames[0].p2, Buttons::START);
699 }
700
701 #[test]
702 fn fmv_rejects_savestate() {
703 let b = synth_fmv(0x04, 0x80, &[]);
704 assert!(matches!(
705 import_fmv(&b, TEST_SHA),
706 Err(LegacyMovieError::Unsupported { .. })
707 ));
708 }
709
710 #[test]
711 fn fmv_byte_permutation_is_correct() {
712 assert_eq!(fmv_byte_to_buttons(0x01), Buttons::RIGHT);
713 assert_eq!(fmv_byte_to_buttons(0x20), Buttons::A);
714 assert_eq!(fmv_byte_to_buttons(0x10), Buttons::B);
715 assert_eq!(fmv_byte_to_buttons(0x80), Buttons::START);
716 assert_eq!(
717 fmv_byte_to_buttons(0xFF),
718 Buttons::all(),
719 "all bits set -> all buttons"
720 );
721 }
722
723 fn synth_vmv(flags: u32, video_mode: u8, frame_count: u32, body: &[u8]) -> Vec<u8> {
726 let mut b = vec![0u8; 0x40];
727 b[0..12].copy_from_slice(VMV_MAGIC);
728 b[0x10..0x14].copy_from_slice(&flags.to_le_bytes());
729 b[0x1C..0x20].copy_from_slice(&11u32.to_le_bytes()); b[0x23] = video_mode;
731 let data_off = u32::try_from(b.len()).unwrap();
732 b[0x34..0x38].copy_from_slice(&data_off.to_le_bytes());
733 b[0x38..0x3C].copy_from_slice(&frame_count.to_le_bytes());
734 b.extend_from_slice(body);
735 b
736 }
737
738 #[test]
739 fn vmv_canonical_bit_order() {
740 let flags = (1u32 << 6) | 0x1;
743 let body = [Buttons::A.bits() | Buttons::RIGHT.bits()];
744 let b = synth_vmv(flags, 0, 1, &body);
745 let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
746 assert_eq!(movie.frames.len(), 1);
747 assert_eq!(movie.frames[0].p1, Buttons::A | Buttons::RIGHT);
748 assert_eq!(movie.region, Region::Ntsc);
749 assert_eq!(meta.rerecord_count, 11);
750 }
751
752 #[test]
753 fn vmv_pal_video_mode() {
754 let flags = (1u32 << 6) | 0x1;
755 let b = synth_vmv(flags, 1, 1, &[0u8]);
756 let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
757 assert_eq!(movie.region, Region::Pal);
758 assert!(meta.pal);
759 }
760
761 #[test]
762 fn vmv_rejects_savestate() {
763 let b = synth_vmv(0x1, 0, 1, &[0u8]);
765 assert!(matches!(
766 import_vmv(&b, TEST_SHA),
767 Err(LegacyMovieError::Unsupported { .. })
768 ));
769 }
770
771 #[test]
772 fn vmv_rejects_bad_magic() {
773 let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
774 b[0] = b'X';
775 assert!(matches!(
776 import_vmv(&b, TEST_SHA),
777 Err(LegacyMovieError::BadMagic { .. })
778 ));
779 }
780
781 #[test]
782 fn mc2_is_rejected_as_pce() {
783 assert!(matches!(
784 import_mc2(&[0u8; 16], TEST_SHA),
785 Err(LegacyMovieError::Unsupported { format: "mc2", .. })
786 ));
787 }
788
789 #[test]
790 fn fcm_rejects_overlapping_first_frame_offset() {
791 let mut b = synth_fcm(0x02, 0, &[]);
793 b[0x1C..0x20].copy_from_slice(&0x10u32.to_le_bytes());
794 assert!(matches!(
795 import_fcm(&b, TEST_SHA),
796 Err(LegacyMovieError::Malformed { .. })
797 ));
798 }
799
800 #[test]
801 fn fcm_oversized_advance_is_capped_not_unbounded() {
802 let stream = [0xE0u8, 0xFFu8, 0xFFu8, 0xFFu8];
808 let b = synth_fcm(0x02, 0, &stream);
809 let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
810 assert!(
811 movie.frames.len() <= (1 << 24),
812 "output must be capped at the hard limit, got {}",
813 movie.frames.len()
814 );
815 }
816
817 #[test]
818 fn vmv_rejects_overlapping_data_offset() {
819 let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
821 b[0x34..0x38].copy_from_slice(&0x20u32.to_le_bytes());
822 assert!(matches!(
823 import_vmv(&b, TEST_SHA),
824 Err(LegacyMovieError::Malformed { .. })
825 ));
826 }
827
828 #[test]
829 fn vmv_rejects_four_controllers() {
830 let flags = (1u32 << 6) | 0xF;
832 let b = synth_vmv(flags, 0, 1, &[0u8, 0u8, 0u8, 0u8]);
833 assert!(
834 matches!(
835 import_vmv(&b, TEST_SHA),
836 Err(LegacyMovieError::Unsupported { .. })
837 ),
838 "a 4-controller .vmv must be rejected rather than silently dropping P3/P4"
839 );
840 }
841
842 #[test]
843 fn vmv_two_controllers_round_trip() {
844 let flags = (1u32 << 6) | 0x3;
846 let body = [Buttons::A.bits(), Buttons::START.bits()];
847 let b = synth_vmv(flags, 0, 1, &body);
848 let (movie, _) = import_vmv(&b, TEST_SHA).expect("vmv import");
849 assert_eq!(movie.frames.len(), 1);
850 assert_eq!(movie.frames[0].p1, Buttons::A);
851 assert_eq!(movie.frames[0].p2, Buttons::START);
852 }
853
854 #[test]
855 fn truncated_inputs_never_panic() {
856 assert!(matches!(
857 import_fcm(&[0u8; 4], TEST_SHA),
858 Err(LegacyMovieError::Truncated { .. })
859 ));
860 assert!(matches!(
861 import_fmv(&[0u8; 4], TEST_SHA),
862 Err(LegacyMovieError::Truncated { .. })
863 ));
864 assert!(matches!(
865 import_vmv(&[0u8; 4], TEST_SHA),
866 Err(LegacyMovieError::Truncated { .. })
867 ));
868 }
869}