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