Skip to main content

rustynes_core/
movie_interop.rs

1//! FCEUX `.fm2` movie interop: import + export of FCEUX's plain-text TAS
2//! movie format to and from the native [`Movie`] type.
3//!
4//! `.fm2` is ASCII text (see `fceux/documentation/fm2.txt`): a block
5//! of `key value` header lines (the first of which must be `version 3`),
6//! followed by an input-log section whose every line begins and ends with a
7//! `|` (pipe). The movie length is implicit -- it is the number of input-log
8//! lines (FCEUX `.fm2` note A).
9//!
10//! # Scope and deliberate limitations
11//!
12//! - **Standard gamepads only.** `RustyNES`'s input model here maps FCEUX
13//!   `SI_GAMEPAD` ports onto [`FrameInput`]. A `port0`/`port1` declaring a
14//!   zapper (`SI_ZAPPER = 2`) is rejected with [`Fm2Error::Unsupported`]
15//!   rather than silently mis-mapped.
16//! - **Power-on start only.** A `savestate`-anchored `.fm2` is rejected on
17//!   import (cross-emulator save-state blobs are not portable), and a
18//!   [`StartPoint::SaveState`] [`Movie`] is rejected on export. Both surface
19//!   [`Fm2Error::Unsupported`].
20//! - **Two controllers stored.** [`FrameInput`] models players 1 and 2 only.
21//!   A `fourscore` `.fm2` (four pads) is imported by keeping pads 1 and 2 and
22//!   dropping pads 3 and 4; the fourscore flag is preserved in [`Fm2Meta`] so
23//!   the caller is not silently misled. (TODO: carry P3/P4 once `FrameInput`
24//!   grows beyond two ports.)
25//! - **Soft reset has no home on [`FrameInput`].** The per-frame command
26//!   field's `MOVIECMD_RESET` bit (value 1) is parsed without error but is
27//!   *not* applied to any frame today; see [`import_fm2`].
28//!
29//! # The `RLDUTSBA` pad order (a classic footgun)
30//!
31//! Each gamepad field is exactly eight characters. Per FCEUX, the column
32//! order is the deliberately-reversed `RLDUTSBA` = Right, Left, Down, Up,
33//! sTart, Select, B, A (kept for back-compat with FCEUX's first release). So
34//! character index 0 is the Right button and index 7 is the A button. A
35//! character of `' '` (space) or `'.'` means released; any other character
36//! (conventionally the button's own mnemonic letter) means pressed.
37//!
38//! This module is `no_std`-clean: it uses only `core` + `alloc`.
39
40use 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
49/// The only FCEUX `.fm2` format version this module understands.
50pub const FM2_VERSION: u32 = 3;
51
52/// FCEUX `port0`/`port1` value for a standard gamepad (`SI_GAMEPAD`).
53const SI_GAMEPAD: u32 = 1;
54
55/// FCEUX per-frame command bit: a soft reset occurred at the start of the
56/// frame (`MOVIECMD_RESET`).
57const MOVIECMD_RESET: u32 = 1;
58
59/// The eight-character gamepad column order used by `.fm2`, paired with the
60/// [`Buttons`] flag each column drives. Index 0 is the first character of a
61/// pad field. Order is FCEUX's reversed `RLDUTSBA`.
62const PAD_COLUMNS: [Buttons; 8] = [
63    Buttons::RIGHT,  // index 0: R
64    Buttons::LEFT,   // index 1: L
65    Buttons::DOWN,   // index 2: D
66    Buttons::UP,     // index 3: U
67    Buttons::START,  // index 4: T (sTart)
68    Buttons::SELECT, // index 5: S
69    Buttons::B,      // index 6: B
70    Buttons::A,      // index 7: A
71];
72
73/// Header metadata parsed from an `.fm2` that has no home on [`Movie`] yet.
74///
75/// [`Movie`] carries only the data the native `.rnm` format needs (region,
76/// ROM hash, start point, frames). The remaining `.fm2` header fields are
77/// returned here so the caller can surface or persist them.
78#[derive(Clone, Debug, Default, Eq, PartialEq)]
79pub struct Fm2Meta {
80    /// The `rerecordCount` header value (0 if absent).
81    pub rerecord_count: u64,
82    /// The movie author, taken from a `comment author <name>` line if present.
83    pub author: Option<String>,
84    /// The `romFilename` header value, if present.
85    pub rom_filename: Option<String>,
86    /// The `romChecksum` header value, stored verbatim (an MD5, `base64:`- or
87    /// hex-encoded). Not validated against the ROM -- the SHA-256 identity is
88    /// supplied separately by the caller.
89    pub rom_checksum_md5: Option<String>,
90    /// `true` if the movie declared `fourscore 1` (four controllers). When
91    /// set, only pads 1 and 2 made it into the [`Movie`]; pads 3 and 4 were
92    /// dropped (see the module docs).
93    pub fourscore: bool,
94    /// `true` if the movie declared `palFlag 1`.
95    pub pal: bool,
96}
97
98/// Errors produced by `.fm2` import / export.
99#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum Fm2Error {
102    /// The header had no `version` line, or it was not the first key.
103    #[error("fm2 missing required `version` header (must be the first key)")]
104    MissingVersion,
105
106    /// The `version` value was not [`FM2_VERSION`].
107    #[error("fm2 version {got} not supported (only version {} is)", FM2_VERSION)]
108    BadVersion {
109        /// The version value we read.
110        got: u32,
111    },
112
113    /// A header line declared an integer key whose value did not parse.
114    #[error("fm2 header key `{key}` has an invalid integer value `{value}`")]
115    BadInteger {
116        /// The offending key.
117        key: String,
118        /// The text we failed to parse as an integer.
119        value: String,
120    },
121
122    /// A structural problem with an input-log line (missing pipes, wrong
123    /// field count, or a pad field of the wrong length). `line` is the
124    /// 1-based input-log line number.
125    #[error("fm2 malformed input-log line {line}: {reason}")]
126    Malformed {
127        /// 1-based index of the offending input-log line.
128        line: usize,
129        /// Human-readable description of what was wrong.
130        reason: &'static str,
131    },
132
133    /// A feature of the `.fm2` (or of the [`Movie`] being exported) that this
134    /// module deliberately does not support.
135    #[error("fm2 unsupported: {0}")]
136    Unsupported(&'static str),
137}
138
139/// Options the caller supplies on export that the [`Movie`] itself does not
140/// carry. Mirrors the extra header fields surfaced by [`Fm2Meta`] on import.
141#[derive(Clone, Debug, Default, Eq, PartialEq)]
142pub struct Fm2ExportOpts {
143    /// Value to emit for the `rerecordCount` header.
144    pub rerecord_count: u64,
145    /// Author to emit as a `comment author <name>` line, if any.
146    pub author: Option<String>,
147    /// Value to emit for the `romFilename` header, if any.
148    pub rom_filename: Option<String>,
149    /// Value to emit for the `romChecksum` header, if any.
150    pub rom_checksum_md5: Option<String>,
151    /// Emit `fourscore 1` and four pad columns per line when `true`. The
152    /// extra pads (3 and 4) are always released, since [`FrameInput`] models
153    /// only two controllers.
154    pub fourscore: bool,
155}
156
157/// Parse `.fm2` text into a [`Movie`] plus the leftover header [`Fm2Meta`].
158///
159/// `rom_sha256` is the SHA-256 of the ROM the caller intends to replay the
160/// movie against. The `.fm2` format carries only an MD5 (`romChecksum`), so
161/// the authoritative SHA-256 ROM identity must come from the loaded ROM; it is
162/// stored verbatim on the returned [`Movie`] and is *not* validated here.
163///
164/// The returned [`Movie`] always has [`StartPoint::PowerOn`] -- FCEUX note B
165/// says movies start from power-on unless a `savestate` key is present, and
166/// such cross-emulator save-state blobs are not portable, so a `savestate`
167/// header is rejected.
168///
169/// # Soft reset handling
170///
171/// The per-frame command field's `MOVIECMD_RESET` bit (value 1) is parsed (so
172/// such lines do not error) but is **not** represented anywhere on the
173/// resulting [`Movie`], because [`FrameInput`] has no reset bit. A reset
174/// command therefore affects neither the frame count nor playback today.
175///
176/// # Errors
177///
178/// Returns [`Fm2Error`] for a missing/wrong `version`, an unparseable integer
179/// header, an unsupported device or `savestate` start point, or a malformed
180/// input-log line (bad pipes, wrong field count, wrong pad length). Never
181/// panics on malformed input.
182pub 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        // Trim a trailing '\r' so CRLF and LF both work; leave interior
192        // content alone.
193        let line = raw.strip_suffix('\r').unwrap_or(raw);
194
195        if line.starts_with('|') {
196            // Input-log line.
197            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        // Header line. Blank lines in the header are tolerated.
204        if line.trim().is_empty() {
205            continue;
206        }
207
208        let (key, value) = match line.split_once(' ') {
209            Some((k, v)) => (k, v),
210            // A bare key with no value (e.g. an empty string field): treat the
211            // value as empty.
212            None => (line, ""),
213        };
214
215        // `version` must be the very first header key.
216        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                // SIFC_NONE = 0 is the only expansion-port device we model.
235                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                // By convention `comment author <name>` carries the author.
246                if let Some(rest) = value.strip_prefix("author ") {
247                    meta.author = Some(rest.to_string());
248                }
249            }
250            _ => {
251                // `emuVersion`, `guid`, and any unknown header keys are
252                // ignored (forward-compatible).
253            }
254        }
255    }
256
257    if !saw_version {
258        return Err(Fm2Error::MissingVersion);
259    }
260    // Reject non-gamepad standard ports only after we know `version` was OK,
261    // so the error reflects the real obstacle. Fourscore implies all-gamepad
262    // (FCEUX note C), so the port checks only matter when not fourscore.
263    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        // Carry the `.fm2` rerecordCount through (saturating into the `.rnm` u32).
275        rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
276        // Imported: no attestation (the source format has no such field, and
277        // synthesizing one would attest a run this build never performed).
278        attestation: None,
279    };
280    Ok((movie, meta))
281}
282
283/// Serialize a [`Movie`] to `.fm2` text.
284///
285/// Emits a `version 3` header, then `emuVersion`, `rerecordCount`, `palFlag`
286/// (from [`Movie::region`] -- both [`Region::Pal`] and [`Region::Dendy`] are
287/// PAL-timed, so both export `palFlag 1`), `fourscore`, `port0`/`port1`/`port2`
288/// (all gamepad / none), the optional `romFilename` / `romChecksum`, an
289/// optional `comment author` line, then the `|c|RLDUTSBA|RLDUTSBA||` input log
290/// (one line per frame, with a trailing empty `port2` field per the spec).
291///
292/// Only [`StartPoint::PowerOn`] movies export; a [`StartPoint::SaveState`]
293/// movie has no portable `.fm2` representation.
294///
295/// # Errors
296///
297/// Returns [`Fm2Error::Unsupported`] if `movie` is anchored to an embedded
298/// save state.
299pub fn export_fm2(movie: &Movie, opts: &Fm2ExportOpts) -> Result<String, Fm2Error> {
300    if !matches!(movie.start, StartPoint::PowerOn) {
301        return Err(Fm2Error::Unsupported(
302            "save-state-anchored movie has no portable .fm2 representation",
303        ));
304    }
305
306    let pal = matches!(movie.region, Region::Pal | Region::Dendy);
307    let mut out = String::new();
308
309    // Header. `version` must be first. Writing into a `String` via the
310    // `core::fmt::Write` impl is infallible, so the `write!` results are
311    // discarded.
312    out.push_str("version 3\n");
313    let _ = writeln!(out, "emuVersion {}", emu_version_tag());
314    let _ = writeln!(out, "rerecordCount {}", opts.rerecord_count);
315    let _ = writeln!(out, "palFlag {}", u8::from(pal));
316    let _ = writeln!(out, "fourscore {}", u8::from(opts.fourscore));
317    let _ = writeln!(out, "port0 {SI_GAMEPAD}");
318    let _ = writeln!(out, "port1 {SI_GAMEPAD}");
319    out.push_str("port2 0\n");
320    if let Some(name) = &opts.rom_filename {
321        let _ = writeln!(out, "romFilename {name}");
322    }
323    if let Some(sum) = &opts.rom_checksum_md5 {
324        let _ = writeln!(out, "romChecksum {sum}");
325    }
326    if let Some(author) = &opts.author {
327        let _ = writeln!(out, "comment author {author}");
328    }
329
330    // Input log: one line per frame. RustyNES movies never carry a per-frame
331    // reset command, so field `c` is always 0.
332    let mut pad = [0u8; 8];
333    for frame in &movie.frames {
334        out.push_str("|0|");
335        write_pad(frame.p1, &mut pad);
336        out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
337        out.push('|');
338        write_pad(frame.p2, &mut pad);
339        out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
340        out.push('|');
341        if opts.fourscore {
342            // Players 3 and 4 are always released (FrameInput has no P3/P4).
343            write_pad(Buttons::empty(), &mut pad);
344            let empty = core::str::from_utf8(&pad).expect("pad bytes are ASCII");
345            out.push_str(empty);
346            out.push('|');
347            out.push_str(empty);
348            out.push('|');
349        }
350        // Trailing empty `port2` field (SIFC_NONE is always empty).
351        out.push_str("|\n");
352    }
353
354    Ok(out)
355}
356
357/// Render `buttons` into an eight-byte `RLDUTSBA` pad field. A pressed button
358/// is written as its mnemonic letter; a released one as `'.'`.
359fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
360    // Mnemonic letters in column order, matching `PAD_COLUMNS`.
361    const LETTERS: [u8; 8] = [b'R', b'L', b'D', b'U', b'T', b'S', b'B', b'A'];
362    for i in 0..8 {
363        out[i] = if buttons.contains(PAD_COLUMNS[i]) {
364            LETTERS[i]
365        } else {
366            b'.'
367        };
368    }
369}
370
371/// Parse a single input-log line (already known to start with `|`) into a
372/// [`FrameInput`]. `line_no` is the 1-based input-log line number used in
373/// errors; `fourscore` selects the 4-pad layout.
374fn parse_input_line(line: &str, line_no: usize, fourscore: bool) -> Result<FrameInput, Fm2Error> {
375    if !line.ends_with('|') {
376        return Err(Fm2Error::Malformed {
377            line: line_no,
378            reason: "input-log line must end with `|`",
379        });
380    }
381    // `|c|p0|p1|port2|` splits (on `|`) to ["", c, p0, p1, port2, ""]; the
382    // fourscore form has p2/p3 between p1 and port2. Both leading and trailing
383    // empty strings are expected.
384    let mut fields = line.split('|');
385    // Leading empty field (before the first `|`).
386    if fields.next() != Some("") {
387        return Err(Fm2Error::Malformed {
388            line: line_no,
389            reason: "input-log line must start with `|`",
390        });
391    }
392    // Command field.
393    let cmd_field = fields.next().ok_or(Fm2Error::Malformed {
394        line: line_no,
395        reason: "missing command field",
396    })?;
397    let _cmd = parse_command(cmd_field, line_no)?;
398
399    let pad_count = if fourscore { 4 } else { 2 };
400    let mut pads = [Buttons::empty(); 4];
401    for pad in pads.iter_mut().take(pad_count) {
402        let field = fields.next().ok_or(Fm2Error::Malformed {
403            line: line_no,
404            reason: "missing gamepad field",
405        })?;
406        *pad = parse_pad(field, line_no)?;
407    }
408
409    // Remaining fields: the `port2` field then the trailing empty string. We
410    // tolerate the `port2` field being present-and-empty (SIFC_NONE) or
411    // omitted entirely, but anything non-empty there is unsupported.
412    for field in fields {
413        if !field.is_empty() {
414            return Err(Fm2Error::Malformed {
415                line: line_no,
416                reason: "unexpected non-empty trailing field (only SIFC_NONE supported)",
417            });
418        }
419    }
420
421    // pads[0] = P1, pads[1] = P2 (pads 2/3 dropped for fourscore).
422    Ok(FrameInput::new(pads[0], pads[1]))
423}
424
425/// Parse the variable-length decimal command bitfield. Returns whether the
426/// reset bit was set (currently informational only).
427fn parse_command(field: &str, line_no: usize) -> Result<bool, Fm2Error> {
428    // The command field is conventionally empty or a small decimal integer.
429    let value: u32 = if field.is_empty() {
430        0
431    } else {
432        field.parse().map_err(|_| Fm2Error::Malformed {
433            line: line_no,
434            reason: "command field is not a decimal integer",
435        })?
436    };
437    Ok(value & MOVIECMD_RESET != 0)
438}
439
440/// Parse one eight-character `RLDUTSBA` gamepad field into [`Buttons`].
441fn parse_pad(field: &str, line_no: usize) -> Result<Buttons, Fm2Error> {
442    let bytes = field.as_bytes();
443    if bytes.len() != 8 {
444        return Err(Fm2Error::Malformed {
445            line: line_no,
446            reason: "gamepad field must be exactly 8 characters",
447        });
448    }
449    let mut buttons = Buttons::empty();
450    for (i, &b) in bytes.iter().enumerate() {
451        // Space or '.' = released; anything else = pressed.
452        if b != b' ' && b != b'.' {
453            buttons |= PAD_COLUMNS[i];
454        }
455    }
456    Ok(buttons)
457}
458
459/// Parse an integer-typed header value, attaching the key for diagnostics.
460fn parse_int(key: &str, value: &str) -> Result<u32, Fm2Error> {
461    value
462        .trim()
463        .parse::<u32>()
464        .map_err(|_| Fm2Error::BadInteger {
465            key: key.to_string(),
466            value: value.to_string(),
467        })
468}
469
470/// The `emuVersion` tag emitted on export. An FCEUX-style numeric emulator
471/// version is not meaningful for a different emulator, so we emit a stable
472/// sentinel that round-trips harmlessly (the importer ignores `emuVersion`).
473const fn emu_version_tag() -> u32 {
474    // RustyNES is not FCEUX; a fixed sentinel keeps export deterministic and
475    // the field is ignored on import.
476    20000
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use alloc::vec;
483
484    const TEST_SHA: [u8; 32] = [0x5A; 32];
485
486    /// A fixed, varied input sequence touching every button.
487    fn varied_frames() -> Vec<FrameInput> {
488        vec![
489            FrameInput::new(Buttons::A, Buttons::B),
490            FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
491            FrameInput::new(
492                Buttons::UP | Buttons::DOWN | Buttons::SELECT,
493                Buttons::empty(),
494            ),
495            FrameInput::new(
496                Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
497                Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
498            ),
499        ]
500    }
501
502    #[test]
503    fn round_trip_power_on_ntsc() {
504        let movie = Movie {
505            region: Region::Ntsc,
506            rom_sha256: TEST_SHA,
507            start: StartPoint::PowerOn,
508            frames: varied_frames(),
509            rerecord_count: 0,
510            attestation: None,
511        };
512        let opts = Fm2ExportOpts {
513            rerecord_count: 42,
514            author: Some("tester".to_string()),
515            rom_filename: Some("game.nes".to_string()),
516            rom_checksum_md5: Some("base64:deadbeef".to_string()),
517            fourscore: false,
518        };
519        let text = export_fm2(&movie, &opts).expect("export");
520        let (back, meta) = import_fm2(&text, TEST_SHA).expect("import");
521
522        assert_eq!(back.frames, movie.frames, "frames survive round-trip");
523        assert_eq!(back.region, Region::Ntsc);
524        assert_eq!(back.start, StartPoint::PowerOn);
525        assert_eq!(back.rom_sha256, TEST_SHA);
526        assert!(!meta.fourscore);
527        assert!(!meta.pal);
528        assert_eq!(meta.rerecord_count, 42);
529        assert_eq!(meta.author.as_deref(), Some("tester"));
530        assert_eq!(meta.rom_filename.as_deref(), Some("game.nes"));
531        assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:deadbeef"));
532    }
533
534    #[test]
535    fn exact_bit_and_char_mapping() {
536        // Only A set -> char index 7 pressed, others released.
537        let movie = Movie {
538            region: Region::Ntsc,
539            rom_sha256: TEST_SHA,
540            start: StartPoint::PowerOn,
541            frames: vec![
542                FrameInput::new(Buttons::A, Buttons::empty()),
543                FrameInput::new(Buttons::RIGHT, Buttons::empty()),
544            ],
545            rerecord_count: 0,
546            attestation: None,
547        };
548        let text = export_fm2(&movie, &Fm2ExportOpts::default()).expect("export");
549        // Pull the two input-log lines.
550        let lines: Vec<&str> = text.lines().filter(|l| l.starts_with('|')).collect();
551        assert_eq!(lines.len(), 2);
552
553        // |0|<pad p1>|<pad p2>||  -> the first pad field is between pipe 2 & 3.
554        let p1_field_a = lines[0].split('|').nth(2).unwrap();
555        assert_eq!(p1_field_a.len(), 8);
556        for (i, c) in p1_field_a.chars().enumerate() {
557            if i == 7 {
558                assert_ne!(c, '.', "A button is char index 7 and must be pressed");
559            } else {
560                assert_eq!(c, '.', "non-A columns must be released");
561            }
562        }
563
564        let p1_field_right = lines[1].split('|').nth(2).unwrap();
565        for (i, c) in p1_field_right.chars().enumerate() {
566            if i == 0 {
567                assert_ne!(c, '.', "RIGHT button is char index 0 and must be pressed");
568            } else {
569                assert_eq!(c, '.', "non-RIGHT columns must be released");
570            }
571        }
572
573        // Import the reverse: a hand-built log with only index 0 (RIGHT) and
574        // only index 7 (A) set, assert the right Buttons come back.
575        let imported = "version 3\nport0 1\nport1 1\nport2 0\n\
576                        |0|R.......|.......A||\n";
577        let (movie, _) = import_fm2(imported, TEST_SHA).expect("import");
578        assert_eq!(movie.frames.len(), 1);
579        assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
580        assert_eq!(movie.frames[0].p2, Buttons::A);
581    }
582
583    #[test]
584    fn pal_flag_maps_to_region() {
585        // Import: palFlag 1 -> Region::Pal.
586        let text = "version 3\npalFlag 1\nport0 1\nport1 1\nport2 0\n|0|........|........||\n";
587        let (movie, meta) = import_fm2(text, TEST_SHA).expect("import");
588        assert_eq!(movie.region, Region::Pal);
589        assert!(meta.pal);
590
591        // Export of a Pal movie emits palFlag 1.
592        let pal_movie = Movie {
593            region: Region::Pal,
594            rom_sha256: TEST_SHA,
595            start: StartPoint::PowerOn,
596            frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
597            rerecord_count: 0,
598            attestation: None,
599        };
600        let out = export_fm2(&pal_movie, &Fm2ExportOpts::default()).expect("export");
601        assert!(
602            out.lines().any(|l| l == "palFlag 1"),
603            "Pal movie must export palFlag 1"
604        );
605
606        // Ntsc exports palFlag 0.
607        let ntsc_movie = Movie {
608            region: Region::Ntsc,
609            ..pal_movie
610        };
611        let out = export_fm2(&ntsc_movie, &Fm2ExportOpts::default()).expect("export");
612        assert!(out.lines().any(|l| l == "palFlag 0"));
613    }
614
615    #[test]
616    fn reset_command_parses_without_error() {
617        // c = 1 means MOVIECMD_RESET. We parse it (don't crash) but it is not
618        // represented on FrameInput, so the frame is otherwise a normal frame.
619        let text = "version 3\nport0 1\nport1 1\nport2 0\n|1|........|........||\n";
620        let (movie, _) = import_fm2(text, TEST_SHA).expect("reset command must parse");
621        assert_eq!(movie.frames.len(), 1);
622        assert_eq!(movie.frames[0].p1, Buttons::empty());
623    }
624
625    #[test]
626    fn fourscore_layout_parses_two_of_four_pads() {
627        // Four pad fields; only P1/P2 are retained. P1 = A, P2 = B, P3/P4 set
628        // (and dropped). fourscore must survive in meta.
629        let text = "version 3\nfourscore 1\nport0 1\nport1 1\nport2 0\n\
630                    |0|.......A|......B.|R.......|.L......||\n";
631        let (movie, meta) = import_fm2(text, TEST_SHA).expect("fourscore import");
632        assert!(meta.fourscore);
633        assert_eq!(movie.frames.len(), 1);
634        assert_eq!(movie.frames[0].p1, Buttons::A);
635        assert_eq!(movie.frames[0].p2, Buttons::B);
636
637        // Export with fourscore emits four pad fields.
638        let out = export_fm2(
639            &movie,
640            &Fm2ExportOpts {
641                fourscore: true,
642                ..Default::default()
643            },
644        )
645        .expect("export");
646        let log_line = out.lines().find(|l| l.starts_with('|')).unwrap();
647        // |0|p1|p2|p3|p4||  -> split has ["",0,p1,p2,p3,p4,"",""]; four of the
648        // fields are 8-char pads.
649        let pad_count = log_line.split('|').filter(|p| p.len() == 8).count();
650        assert_eq!(pad_count, 4, "fourscore export must emit four pad fields");
651    }
652
653    #[test]
654    fn malformed_inputs_never_panic() {
655        // Missing version line entirely.
656        assert!(matches!(
657            import_fm2("emuVersion 1\nport0 1\n", TEST_SHA),
658            Err(Fm2Error::MissingVersion)
659        ));
660
661        // First key is not version.
662        assert!(matches!(
663            import_fm2("palFlag 0\nversion 3\n", TEST_SHA),
664            Err(Fm2Error::MissingVersion)
665        ));
666
667        // Wrong version.
668        assert!(matches!(
669            import_fm2("version 2\nport0 1\nport1 1\nport2 0\n", TEST_SHA),
670            Err(Fm2Error::BadVersion { got: 2 })
671        ));
672
673        // A bad integer header value.
674        assert!(matches!(
675            import_fm2("version 3\npalFlag notanint\n", TEST_SHA),
676            Err(Fm2Error::BadInteger { .. })
677        ));
678
679        // An input line that starts with `|` but does not end with one.
680        assert!(matches!(
681            import_fm2(
682                "version 3\nport0 1\nport1 1\nport2 0\n|0|........|........\n",
683                TEST_SHA
684            ),
685            Err(Fm2Error::Malformed { .. })
686        ));
687
688        // A truncated pad field (7 chars).
689        assert!(matches!(
690            import_fm2(
691                "version 3\nport0 1\nport1 1\nport2 0\n|0|.......|........||\n",
692                TEST_SHA
693            ),
694            Err(Fm2Error::Malformed { .. })
695        ));
696
697        // A zapper port is unsupported.
698        assert!(matches!(
699            import_fm2("version 3\nport0 2\nport1 1\nport2 0\n", TEST_SHA),
700            Err(Fm2Error::Unsupported(_))
701        ));
702
703        // A savestate-anchored movie is unsupported.
704        assert!(matches!(
705            import_fm2("version 3\nsavestate 0xDEAD\nport0 1\n", TEST_SHA),
706            Err(Fm2Error::Unsupported(_))
707        ));
708    }
709
710    #[test]
711    fn representative_header_parses() {
712        let text = "version 3\n\
713            emuVersion 22020\n\
714            rerecordCount 1234\n\
715            palFlag 0\n\
716            fourscore 0\n\
717            port0 1\n\
718            port1 1\n\
719            port2 0\n\
720            romFilename Super Demo.nes\n\
721            romChecksum base64:abc123==\n\
722            comment author Jane Doe\n\
723            comment subject A speedrun\n\
724            guid 452DE2C3-EF43-2FA9-77AC-0677FC51543B\n\
725            |0|........|........||\n\
726            |0|.......A|........||\n";
727        let (movie, meta) = import_fm2(text, TEST_SHA).expect("header parse");
728        assert_eq!(movie.frames.len(), 2);
729        assert_eq!(movie.region, Region::Ntsc);
730        assert_eq!(meta.rerecord_count, 1234);
731        assert!(!meta.fourscore);
732        assert!(!meta.pal);
733        assert_eq!(meta.author.as_deref(), Some("Jane Doe"));
734        assert_eq!(meta.rom_filename.as_deref(), Some("Super Demo.nes"));
735        assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:abc123=="));
736        // Frame 1 had A on P1.
737        assert_eq!(movie.frames[1].p1, Buttons::A);
738    }
739
740    #[test]
741    fn export_rejects_save_state_movie() {
742        let movie = Movie {
743            region: Region::Ntsc,
744            rom_sha256: TEST_SHA,
745            start: StartPoint::SaveState(vec![1, 2, 3]),
746            frames: vec![],
747            rerecord_count: 0,
748            attestation: None,
749        };
750        assert!(matches!(
751            export_fm2(&movie, &Fm2ExportOpts::default()),
752            Err(Fm2Error::Unsupported(_))
753        ));
754    }
755}