Skip to main content

rustynes_core/
bk2_interop.rs

1//! `BizHawk` `.bk2` movie interop.
2//!
3//! Import + export of the **text payload** of a `.bk2` archive (the
4//! `Header.txt` + `Input Log.txt` members) to and from the native [`Movie`]
5//! type. It mirrors the FCEUX [`crate::movie_interop`] `.fm2` design, with one
6//! structural difference: a `.bk2` is a **ZIP archive**, not a flat text file.
7//!
8//! # `no_std` and the ZIP split
9//!
10//! The `rustynes-core` chip stack is `#![no_std]` (`core` + `alloc` only), so it
11//! does **not** open or write ZIP containers — that needs `std` + a zip crate.
12//! Instead, the core handles the part that is `no_std`-clean and shared across
13//! every frontend: parsing / emitting the two text members. The frontend reads
14//! the two members out of the `.bk2` ZIP (and writes them back into one) and
15//! hands their string contents here. The split is the same reason `.fm2`'s text
16//! parse lives in core while file I/O lives in the frontend.
17//!
18//! # The `.bk2` text format (the subset we model)
19//!
20//! - **`Header.txt`** — `Key Value` lines (space-separated). The keys we read:
21//!   `Platform` (must be an NES family token), `rerecordCount`, `Author`,
22//!   `GameName`, `SHA1` (stored verbatim; the authoritative SHA-256 ROM identity
23//!   is supplied separately by the caller, exactly like `.fm2`'s MD5), and the
24//!   region flag `PAL`. A `StartsFromSavestate`/`StartsFromSaveRam` movie is
25//!   rejected (cross-emulator save blobs are not portable — same policy as
26//!   `.fm2`).
27//! - **`Input Log.txt`** — a `[Input]` ... `[/Input]` block. The first line is a
28//!   `LogKey:` declaration listing the `|`-separated controller column groups;
29//!   subsequent lines are per-frame input, each `|`-delimited, every button
30//!   rendered as its mnemonic letter (pressed) or `.` (released). The first
31//!   group is the console-buttons group (Reset / Power); then one group per
32//!   controller port.
33//!
34//! # The NES gamepad mnemonic order
35//!
36//! `BizHawk`'s NES standard-controller mnemonics are `U D L R S s B A`
37//! (Up, Down, Left, Right, Start, select, B, A — note the lower-case `s` for
38//! Select, distinct from the upper-case `S` for Start). Any non-`.`/non-space
39//! character in a column means that button is pressed; the column's *position*
40//! (not the specific letter) selects the button, so we tolerate either the
41//! canonical mnemonic letter or any other pressed marker.
42//!
43//! # Deliberate limitations (mirroring `.fm2`)
44//!
45//! - **Standard gamepads, players 1 and 2 only.** [`FrameInput`] models two
46//!   ports; extra controller groups are parsed but dropped (their presence is
47//!   not silently misleading — only P1/P2 are mapped). The console Reset bit is
48//!   parsed but not represented on [`FrameInput`] (it has no reset bit), exactly
49//!   as in `.fm2`.
50//! - **Power-on start only.** See above.
51//!
52//! This module is `no_std`-clean: it uses only `core` + `alloc`.
53
54use alloc::string::{String, ToString};
55use alloc::vec::Vec;
56use core::fmt::Write as _;
57
58use crate::Region;
59use crate::controller::Buttons;
60use crate::movie::{FrameInput, Movie, StartPoint};
61use thiserror::Error;
62
63/// The filename of the header member inside a `.bk2` ZIP.
64pub const BK2_HEADER_MEMBER: &str = "Header.txt";
65
66/// The filename of the input-log member inside a `.bk2` ZIP.
67pub const BK2_INPUT_LOG_MEMBER: &str = "Input Log.txt";
68
69/// The NES standard-controller mnemonic column order, paired with the
70/// [`Buttons`] flag each column drives. `BizHawk` order: `U D L R S s B A`.
71const PAD_COLUMNS: [(u8, Buttons); 8] = [
72    (b'U', Buttons::UP),
73    (b'D', Buttons::DOWN),
74    (b'L', Buttons::LEFT),
75    (b'R', Buttons::RIGHT),
76    (b'S', Buttons::START),  // upper-case S = Start
77    (b's', Buttons::SELECT), // lower-case s = Select
78    (b'B', Buttons::B),
79    (b'A', Buttons::A),
80];
81
82/// Header metadata parsed from a `.bk2` that has no home on [`Movie`].
83///
84/// Mirrors [`crate::movie_interop::Fm2Meta`]: [`Movie`] carries only what `.rnm`
85/// needs; the rest is surfaced here for the caller to display or persist.
86#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct Bk2Meta {
88    /// The `rerecordCount` header value (0 if absent).
89    pub rerecord_count: u64,
90    /// The movie author (`Author` header), if present.
91    pub author: Option<String>,
92    /// The `GameName` header value, if present.
93    pub game_name: Option<String>,
94    /// The `SHA1` header value, stored verbatim (a hex SHA-1). Not validated
95    /// against the ROM — the authoritative SHA-256 identity is supplied
96    /// separately by the caller.
97    pub sha1: Option<String>,
98    /// `true` if the header declared a PAL region (`PAL 1`).
99    pub pal: bool,
100}
101
102/// Options the caller supplies on export that the [`Movie`] does not carry.
103/// Mirrors the extra header fields surfaced by [`Bk2Meta`] on import.
104#[derive(Clone, Debug, Default, Eq, PartialEq)]
105pub struct Bk2ExportOpts {
106    /// Value to emit for the `rerecordCount` header.
107    pub rerecord_count: u64,
108    /// Author to emit as an `Author` line, if any.
109    pub author: Option<String>,
110    /// Value to emit for the `GameName` header, if any.
111    pub game_name: Option<String>,
112    /// Value to emit for the `SHA1` header, if any.
113    pub sha1: Option<String>,
114}
115
116/// The two text members of a `.bk2` ZIP, returned by [`export_bk2`] for the
117/// frontend to pack into the archive (and accepted by [`import_bk2`]).
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct Bk2Text {
120    /// The `Header.txt` contents.
121    pub header: String,
122    /// The `Input Log.txt` contents.
123    pub input_log: String,
124}
125
126/// Errors produced by `.bk2` text import / export.
127#[derive(Debug, Error)]
128#[non_exhaustive]
129pub enum Bk2Error {
130    /// The header declared a platform that is not an NES family.
131    #[error("bk2 platform `{0}` is not an NES family movie")]
132    WrongPlatform(String),
133
134    /// A header line declared an integer key whose value did not parse.
135    #[error("bk2 header key `{key}` has an invalid integer value `{value}`")]
136    BadInteger {
137        /// The offending key.
138        key: String,
139        /// The text we failed to parse as an integer.
140        value: String,
141    },
142
143    /// The input log had no `LogKey:` declaration line.
144    #[error("bk2 input log missing its `LogKey:` declaration")]
145    MissingLogKey,
146
147    /// A structural problem with an input-log line. `line` is the 1-based
148    /// input-frame line number.
149    #[error("bk2 malformed input-log line {line}: {reason}")]
150    Malformed {
151        /// 1-based index of the offending input-frame line.
152        line: usize,
153        /// Human-readable description of what was wrong.
154        reason: &'static str,
155    },
156
157    /// A feature of the `.bk2` (or of the [`Movie`] being exported) that this
158    /// module deliberately does not support.
159    #[error("bk2 unsupported: {0}")]
160    Unsupported(&'static str),
161}
162
163/// Parse the `Header.txt` + `Input Log.txt` text of a `.bk2` into a [`Movie`]
164/// plus the leftover header [`Bk2Meta`].
165///
166/// `rom_sha256` is the SHA-256 of the ROM the caller intends to replay against.
167/// `.bk2` carries only a SHA-1 (`SHA1` header), so the authoritative SHA-256
168/// identity must come from the loaded ROM; it is stored verbatim on the returned
169/// [`Movie`] and is *not* validated here.
170///
171/// The returned [`Movie`] always has [`StartPoint::PowerOn`] — `.bk2` movies
172/// start from power-on unless a `StartsFromSavestate`/`StartsFromSaveRam` flag is
173/// set, and such cross-emulator save blobs are not portable, so they are
174/// rejected. This reuses the **canonical movie-import power-on alignment** the
175/// `.fm2` path established (a deterministic zeroed-RAM cold boot via
176/// [`Movie::seek_to_start`]), so imports never desync.
177///
178/// # Errors
179///
180/// Returns [`Bk2Error`] for a non-NES platform, an unparseable integer header, a
181/// missing `LogKey:`, an unsupported save-anchored start, or a malformed
182/// input-log line. Never panics on malformed input.
183pub fn import_bk2(
184    header: &str,
185    input_log: &str,
186    rom_sha256: [u8; 32],
187) -> Result<(Movie, Bk2Meta), Bk2Error> {
188    let meta = parse_header(header)?;
189    let frames = parse_input_log(input_log)?;
190    let movie = Movie {
191        epoch: crate::EMULATION_EPOCH,
192        region: if meta.pal { Region::Pal } else { Region::Ntsc },
193        rom_sha256,
194        // v2.9.8 — the source format records no emulation options and no
195        // header, so the import states the stock NES explicitly and leaves the
196        // board unchecked (the ROM identity and region still are).
197        options: crate::HardwareOptions::default(),
198        board: None,
199        start: StartPoint::PowerOn,
200        frames,
201        // Carry the `.bk2` rerecordCount through (saturating into the `.rnm` u32).
202        rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
203        // An imported movie carries no attestation: the source format has no such
204        // field, and synthesizing one here would attest a run this build never
205        // performed. `Movie::verify` reports `NotAttested` for it, which is true.
206        attestation: None,
207    };
208    Ok((movie, meta))
209}
210
211/// Serialize a [`Movie`] into the two text members of a `.bk2` ZIP.
212///
213/// Emits a `Header.txt` (`MovieVersion`, `Platform NES`, region `PAL` flag,
214/// `rerecordCount`, optional `Author` / `GameName` / `SHA1`) and an
215/// `Input Log.txt` (`[Input]`, a `LogKey:` declaration, one `|`-delimited frame
216/// line per frame, `[/Input]`). The frontend writes both into the archive.
217///
218/// Only [`StartPoint::PowerOn`] movies export; a [`StartPoint::SaveState`] movie
219/// has no portable `.bk2` representation.
220///
221/// # Errors
222///
223/// Returns [`Bk2Error::Unsupported`] if `movie` is anchored to an embedded save
224/// state.
225pub fn export_bk2(movie: &Movie, opts: &Bk2ExportOpts) -> Result<Bk2Text, Bk2Error> {
226    if !matches!(movie.start, StartPoint::PowerOn) {
227        return Err(Bk2Error::Unsupported(
228            "save-state-anchored movie has no portable .bk2 representation",
229        ));
230    }
231
232    let pal = matches!(movie.region, Region::Pal | Region::Dendy);
233
234    // --- Header.txt ---
235    let mut header = String::new();
236    header.push_str("MovieVersion BizHawk v2.0\n");
237    header.push_str("Platform NES\n");
238    if pal {
239        header.push_str("PAL 1\n");
240    }
241    let _ = writeln!(header, "rerecordCount {}", opts.rerecord_count);
242    if let Some(name) = &opts.game_name {
243        let _ = writeln!(header, "GameName {name}");
244    }
245    if let Some(sha1) = &opts.sha1 {
246        let _ = writeln!(header, "SHA1 {sha1}");
247    }
248    if let Some(author) = &opts.author {
249        let _ = writeln!(header, "Author {author}");
250    }
251
252    // --- Input Log.txt ---
253    // The console-buttons group carries Reset / Power; RustyNES movies never
254    // record either, so it is always released (`..`). Two controller groups.
255    let mut input_log = String::new();
256    input_log.push_str("[Input]\n");
257    input_log.push_str("LogKey:#Reset|Power|#P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|P1 B|P1 A|#P2 Up|P2 Down|P2 Left|P2 Right|P2 Start|P2 Select|P2 B|P2 A|\n");
258    let mut pad = [0u8; 8];
259    for frame in &movie.frames {
260        // Console group: Reset + Power, both released.
261        input_log.push_str("|..|");
262        write_pad(frame.p1, &mut pad);
263        input_log.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
264        input_log.push('|');
265        write_pad(frame.p2, &mut pad);
266        input_log.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
267        input_log.push_str("|\n");
268    }
269    input_log.push_str("[/Input]\n");
270
271    Ok(Bk2Text { header, input_log })
272}
273
274/// Render `buttons` into an eight-byte `U D L R S s B A` pad field (mnemonic
275/// letter when pressed, `.` when released).
276fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
277    for (i, (letter, flag)) in PAD_COLUMNS.iter().enumerate() {
278        out[i] = if buttons.contains(*flag) {
279            *letter
280        } else {
281            b'.'
282        };
283    }
284}
285
286/// Parse the `Header.txt` member into a [`Bk2Meta`].
287fn parse_header(header: &str) -> Result<Bk2Meta, Bk2Error> {
288    let mut meta = Bk2Meta::default();
289    let mut saw_platform = false;
290    for raw in header.lines() {
291        let line = raw.strip_suffix('\r').unwrap_or(raw);
292        if line.trim().is_empty() {
293            continue;
294        }
295        let (key, value) = match line.split_once(' ') {
296            Some((k, v)) => (k, v.trim()),
297            None => (line, ""),
298        };
299        match key {
300            "Platform" => {
301                // Accept the NES family; reject anything else (a SNES/GB/etc.
302                // movie has the wrong controller model entirely).
303                let plat = value.to_ascii_uppercase();
304                if plat != "NES" && plat != "FAMICOM" && plat != "FDS" {
305                    return Err(Bk2Error::WrongPlatform(value.to_string()));
306                }
307                saw_platform = true;
308            }
309            "rerecordCount" => meta.rerecord_count = u64::from(parse_int(key, value)?),
310            "PAL" => meta.pal = parse_int(key, value)? != 0,
311            "Author" => meta.author = Some(value.to_string()),
312            "GameName" => meta.game_name = Some(value.to_string()),
313            "SHA1" => meta.sha1 = Some(value.to_string()),
314            "StartsFromSavestate" | "StartsFromSaveRam" if parse_int(key, value)? != 0 => {
315                return Err(Bk2Error::Unsupported(
316                    "save-anchored .bk2 (cross-emulator save blobs are not portable)",
317                ));
318            }
319            _ => {
320                // MovieVersion, Core, GUID, BoardName, FourScore, and any other
321                // header keys are ignored (forward-compatible).
322            }
323        }
324    }
325    // A `.bk2` without a Platform line is tolerated as NES (some minimal movies
326    // omit it); only an explicit non-NES platform is rejected above.
327    let _ = saw_platform;
328    Ok(meta)
329}
330
331/// The standard-controller column map (`U D L R S s B A`), used as the fallback
332/// when a `LogKey:` group is absent or unrecognized. Each slot maps an input-line
333/// character *position* to the [`Buttons`] flag it drives.
334fn default_pad_columns() -> Vec<Option<Buttons>> {
335    PAD_COLUMNS.iter().map(|(_, b)| Some(*b)).collect()
336}
337
338/// Map a `LogKey:` column *name* (e.g. `"P1 Up"`, `"Up"`, `"A"`, `"Select"`) to
339/// the NES standard-controller button it drives. The `"Pn "` port label (or any
340/// other prefix) is ignored — only the final word matters. Columns that are not
341/// standard-controller buttons (`"Reset"`, `"Power"`, `"FDS Insert Disk"`, mic,
342/// …) return `None`: they still occupy a character position in the input line but
343/// drive nothing `RustyNES` models.
344fn button_for_column(name: &str) -> Option<Buttons> {
345    match name.trim().rsplit(' ').next().unwrap_or("") {
346        "Up" | "U" => Some(Buttons::UP),
347        "Down" | "D" => Some(Buttons::DOWN),
348        "Left" | "L" => Some(Buttons::LEFT),
349        "Right" | "R" => Some(Buttons::RIGHT),
350        "Start" | "S" => Some(Buttons::START),
351        "Select" | "s" => Some(Buttons::SELECT),
352        "B" => Some(Buttons::B),
353        "A" => Some(Buttons::A),
354        _ => None,
355    }
356}
357
358/// Per-port `(P1, P2)` position→button column maps parsed from a `LogKey:`.
359type PadColumnMaps = (Vec<Option<Buttons>>, Vec<Option<Buttons>>);
360
361/// Parse the `LogKey:` declaration into per-port position→button column maps.
362///
363/// The `LogKey` is `#`-separated controller groups, each a `|`-separated column
364/// list: `LogKey:#Reset|Power|#P1 Up|P1 Down|…|P1 A|#P2 Up|…|`. Group 1 is the
365/// console (dropped), group 2 is P1, group 3 is P2. Reading the *declared* order
366/// (rather than assuming the fixed `U D L R S s B A`) is what lets a `.bk2`
367/// authored with a different column order or extra columns play back correctly
368/// (the NESdev-forum "`.bk2` did not play back" report). A group that yields no
369/// recognized buttons falls back to [`default_pad_columns`], so a truncated or
370/// exotic `LogKey` still maps a standard controller.
371fn parse_log_key(log_key: &str) -> PadColumnMaps {
372    let trimmed = log_key.trim();
373    let body = trimmed.strip_prefix("LogKey:").unwrap_or(trimmed);
374    // The body opens with a single `#` delimiter, then `#`-separated groups.
375    // Strip ONLY that leading delimiter and split without dropping empties: an
376    // empty console group (`##P1...`) must keep its slot so P1/P2 don't shift
377    // left into it. groups[0] = console, groups[1] = P1, groups[2] = P2.
378    let body = body.strip_prefix('#').unwrap_or(body);
379    // Read ONLY the three groups we consume (console, P1, P2) straight from the
380    // split iterator rather than collecting every `#`-group: a hostile `.bk2`
381    // padded with `#` delimiters would otherwise allocate one `&str` slot per
382    // empty group (~16 bytes each) and could exhaust memory on import. `split`
383    // still yields empty groups, so `next()` preserves the empty console slot
384    // (`##P1...`) and keeps P1/P2 from shifting left into it.
385    let mut groups = body.split('#');
386    let _console = groups.next(); // groups[0] = console (unused)
387    let cols = |g: Option<&str>| -> Vec<Option<Buttons>> {
388        let mapped: Vec<Option<Buttons>> = g.map_or_else(Vec::new, |grp| {
389            // Strip only the trailing `|` delimiter each group carries; keep
390            // interior empty columns (`P1 Up||P1 A`) so a button's column index
391            // stays aligned with the frame-value index (else `A` would map to the
392            // empty column's slot and a frame `U.A` would replay as `Up` alone).
393            grp.strip_suffix('|')
394                .unwrap_or(grp)
395                .split('|')
396                .map(button_for_column)
397                .collect()
398        });
399        // If nothing in this group is a recognized controller button, the LogKey
400        // was truncated/exotic — fall back to the fixed standard order.
401        if mapped.iter().any(Option::is_some) {
402            mapped
403        } else {
404            default_pad_columns()
405        }
406    };
407    // groups[1] = P1; groups[2] = P2, read in order from the same iterator.
408    (cols(groups.next()), cols(groups.next()))
409}
410
411/// Parse the `Input Log.txt` member into the per-frame [`FrameInput`] stream.
412///
413/// The first non-blank line inside `[Input]` must be a `LogKey:` declaration,
414/// which supplies the per-port column order. Every subsequent `|`-delimited line
415/// up to `[/Input]` is one frame; the first `|`-group is the console-buttons group
416/// (parsed but dropped), then one group per controller port. Only P1 and P2 are
417/// mapped.
418fn parse_input_log(input_log: &str) -> Result<Vec<FrameInput>, Bk2Error> {
419    let mut frames = Vec::new();
420    let mut columns: Option<PadColumnMaps> = None;
421    let mut frame_line_no = 0usize;
422    for raw in input_log.lines() {
423        let line = raw.strip_suffix('\r').unwrap_or(raw);
424        let trimmed = line.trim();
425        if trimmed.is_empty() || trimmed == "[Input]" || trimmed == "[/Input]" {
426            continue;
427        }
428        if trimmed.starts_with("LogKey:") {
429            columns = Some(parse_log_key(trimmed));
430            continue;
431        }
432        if line.starts_with('|') {
433            let cols = columns.as_ref().ok_or(Bk2Error::MissingLogKey)?;
434            frame_line_no += 1;
435            frames.push(parse_input_line(line, &cols.0, &cols.1, frame_line_no)?);
436        }
437        // Any other line (comments / unknown sections) is ignored.
438    }
439    if columns.is_none() {
440        return Err(Bk2Error::MissingLogKey);
441    }
442    Ok(frames)
443}
444
445/// Parse a single `|`-delimited input-log line into a [`FrameInput`] using the
446/// per-port column maps from the `LogKey`. The first group is the console-buttons
447/// group (dropped); groups 2 and 3 are P1 and P2.
448fn parse_input_line(
449    line: &str,
450    p1_cols: &[Option<Buttons>],
451    p2_cols: &[Option<Buttons>],
452    line_no: usize,
453) -> Result<FrameInput, Bk2Error> {
454    if !line.ends_with('|') {
455        return Err(Bk2Error::Malformed {
456            line: line_no,
457            reason: "input-log line must end with `|`",
458        });
459    }
460    // `|console|p1|p2|...|` splits (on `|`) to ["", console, p1, p2, ..., ""].
461    let mut groups = line.split('|');
462    // Leading empty field (before the first `|`).
463    if groups.next() != Some("") {
464        return Err(Bk2Error::Malformed {
465            line: line_no,
466            reason: "input-log line must start with `|`",
467        });
468    }
469    // Console-buttons group (Reset / Power / …); parsed-and-dropped — FrameInput
470    // has no reset bit, mirroring the `.fm2` path.
471    if groups.next().is_none() {
472        return Err(Bk2Error::Malformed {
473            line: line_no,
474            reason: "missing console-buttons group",
475        });
476    }
477    // P1 then P2 (extra controller groups, if any, are dropped).
478    let p1 = match groups.next() {
479        Some(g) => parse_pad(g, p1_cols, line_no)?,
480        None => {
481            return Err(Bk2Error::Malformed {
482                line: line_no,
483                reason: "missing player-1 controller group",
484            });
485        }
486    };
487    // P2 is optional (a 1-player movie); default to released when absent or an
488    // empty trailing field.
489    let p2 = match groups.next() {
490        Some(g) if !g.is_empty() => parse_pad(g, p2_cols, line_no)?,
491        _ => Buttons::empty(),
492    };
493    Ok(FrameInput::new(p1, p2))
494}
495
496/// Parse one gamepad group into [`Buttons`] using its port's `LogKey` column map.
497///
498/// Each character *position* is the column at that index of `columns`; a pressed
499/// marker (any char other than space or `.`) sets that column's button (columns
500/// that map to `None` — non-controller buttons — are consumed but ignored). The
501/// group may be *longer* than the map (extra trailing columns we don't model are
502/// tolerated) but not shorter (a truncated line is structurally malformed).
503fn parse_pad(
504    group: &str,
505    columns: &[Option<Buttons>],
506    line_no: usize,
507) -> Result<Buttons, Bk2Error> {
508    let bytes = group.as_bytes();
509    if bytes.len() < columns.len() {
510        return Err(Bk2Error::Malformed {
511            line: line_no,
512            reason: "gamepad group shorter than its LogKey column count",
513        });
514    }
515    let mut buttons = Buttons::empty();
516    for (i, col) in columns.iter().enumerate() {
517        if let Some(flag) = col {
518            let b = bytes[i];
519            if b != b' ' && b != b'.' {
520                buttons |= *flag;
521            }
522        }
523    }
524    Ok(buttons)
525}
526
527/// Parse an integer-typed header value, attaching the key for diagnostics.
528fn parse_int(key: &str, value: &str) -> Result<u32, Bk2Error> {
529    value
530        .trim()
531        .parse::<u32>()
532        .map_err(|_| Bk2Error::BadInteger {
533            key: key.to_string(),
534            value: value.to_string(),
535        })
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use alloc::vec;
542
543    const TEST_SHA: [u8; 32] = [0x7B; 32];
544
545    fn varied_frames() -> Vec<FrameInput> {
546        vec![
547            FrameInput::new(Buttons::A, Buttons::B),
548            FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
549            FrameInput::new(
550                Buttons::UP | Buttons::DOWN | Buttons::SELECT,
551                Buttons::empty(),
552            ),
553            FrameInput::new(
554                Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
555                Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
556            ),
557        ]
558    }
559
560    #[test]
561    fn round_trip_power_on_ntsc() {
562        let movie = Movie {
563            epoch: crate::EMULATION_EPOCH,
564            region: Region::Ntsc,
565            rom_sha256: TEST_SHA,
566            options: crate::HardwareOptions::default(),
567            board: None,
568            start: StartPoint::PowerOn,
569            frames: varied_frames(),
570            rerecord_count: 0,
571            attestation: None,
572        };
573        let opts = Bk2ExportOpts {
574            rerecord_count: 99,
575            author: Some("tester".to_string()),
576            game_name: Some("game".to_string()),
577            sha1: Some("abc123".to_string()),
578        };
579        let text = export_bk2(&movie, &opts).expect("export");
580        let (back, meta) = import_bk2(&text.header, &text.input_log, TEST_SHA).expect("import");
581
582        assert_eq!(back.frames, movie.frames, "frames survive round-trip");
583        assert_eq!(back.region, Region::Ntsc);
584        assert_eq!(back.start, StartPoint::PowerOn);
585        assert_eq!(back.rom_sha256, TEST_SHA);
586        assert!(!meta.pal);
587        assert_eq!(meta.rerecord_count, 99);
588        assert_eq!(meta.author.as_deref(), Some("tester"));
589        assert_eq!(meta.game_name.as_deref(), Some("game"));
590        assert_eq!(meta.sha1.as_deref(), Some("abc123"));
591    }
592
593    #[test]
594    fn exact_bit_and_char_mapping() {
595        // Only A set -> char index 7 pressed (the last column), others released.
596        let movie = Movie {
597            epoch: crate::EMULATION_EPOCH,
598            region: Region::Ntsc,
599            rom_sha256: TEST_SHA,
600            options: crate::HardwareOptions::default(),
601            board: None,
602            start: StartPoint::PowerOn,
603            frames: vec![
604                FrameInput::new(Buttons::A, Buttons::empty()),
605                FrameInput::new(Buttons::UP, Buttons::empty()),
606            ],
607            rerecord_count: 0,
608            attestation: None,
609        };
610        let text = export_bk2(&movie, &Bk2ExportOpts::default()).expect("export");
611        let lines: Vec<&str> = text
612            .input_log
613            .lines()
614            .filter(|l| l.starts_with("|.."))
615            .collect();
616        assert_eq!(lines.len(), 2);
617
618        // |..|<p1>|<p2>| -> p1 group is split index 2.
619        let p1_a = lines[0].split('|').nth(2).unwrap();
620        assert_eq!(p1_a.len(), 8);
621        for (i, c) in p1_a.chars().enumerate() {
622            if i == 7 {
623                assert_eq!(c, 'A', "A is the last column");
624            } else {
625                assert_eq!(c, '.', "non-A columns released");
626            }
627        }
628        let p1_up = lines[1].split('|').nth(2).unwrap();
629        for (i, c) in p1_up.chars().enumerate() {
630            if i == 0 {
631                assert_eq!(c, 'U', "Up is the first column");
632            } else {
633                assert_eq!(c, '.');
634            }
635        }
636
637        // Start (upper S) vs Select (lower s) are distinct columns 4 and 5.
638        let hand = "[Input]\nLogKey:#Reset|Power|...\n|..|....S...|.....s..|\n[/Input]\n";
639        let (m, _) = import_bk2("Platform NES\n", hand, TEST_SHA).expect("import");
640        assert_eq!(m.frames[0].p1, Buttons::START);
641        assert_eq!(m.frames[0].p2, Buttons::SELECT);
642    }
643
644    #[test]
645    fn log_key_column_order_is_honored() {
646        // v2.2.9 "Studio II": a `.bk2` whose P1 columns are declared in a
647        // NON-standard order must map by the `LogKey` order, not the fixed
648        // `U D L R S s B A` positions. Here column 0 = A and column 1 = B, so a
649        // press at character position 0 is A and at position 1 is B — the opposite
650        // of the standard layout. This is the fix for the "`.bk2` did not play
651        // back" report (a movie whose buttons all mapped to the wrong bits).
652        let log = "[Input]\n\
653            LogKey:#Reset|Power|#P1 A|P1 B|P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|\n\
654            |..|A.......|\n\
655            |..|.B......|\n\
656            [/Input]\n";
657        let (m, _) = import_bk2("Platform NES\n", log, TEST_SHA).expect("import");
658        assert_eq!(
659            m.frames[0].p1,
660            Buttons::A,
661            "position 0 = LogKey column 0 = A"
662        );
663        assert_eq!(
664            m.frames[1].p1,
665            Buttons::B,
666            "position 1 = LogKey column 1 = B"
667        );
668        // A pad group LONGER than the modeled columns (extra buttons like a mic)
669        // is tolerated: extra trailing chars are ignored, no malformed error.
670        let extra = "[Input]\n\
671            LogKey:#Reset|Power|#P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|P1 B|P1 A|P1 Mic|\n\
672            |..|.......AX|\n\
673            [/Input]\n";
674        let (m2, _) = import_bk2("Platform NES\n", extra, TEST_SHA).expect("import extra-col");
675        assert_eq!(
676            m2.frames[0].p1,
677            Buttons::A,
678            "column 7 = A pressed; the 9th (Mic) col is ignored"
679        );
680    }
681
682    #[test]
683    fn log_key_preserves_empty_columns_and_groups() {
684        // v2.2.9 fix: empty interior `LogKey` fields must KEEP their positions,
685        // or later columns/groups shift left and buttons re-map silently.
686        //
687        // Empty interior COLUMN (`P1 Up||P1 A`): the empty middle column is a real
688        // slot, so `A` stays at column index 2. A frame `U.A` must press Up (col 0)
689        // and A (col 2); the pre-fix filter dropped the empty column, mapping A to
690        // index 1 so `U.A` replayed as Up alone.
691        let empty_col = "[Input]\n\
692            LogKey:#Reset|Power|#P1 Up||P1 A|\n\
693            |..|U.A|\n\
694            [/Input]\n";
695        let (m, _) = import_bk2("Platform NES\n", empty_col, TEST_SHA).expect("import empty-col");
696        assert_eq!(
697            m.frames[0].p1,
698            Buttons::UP | Buttons::A,
699            "empty middle column keeps its slot: Up (col 0) + A (col 2) both press"
700        );
701
702        // Empty CONSOLE group (`##P1…`): must not shift P1's map into the dropped
703        // console slot. The pre-fix filter dropped the empty group, promoting P1
704        // into the console position and losing it entirely.
705        let empty_console = "[Input]\n\
706            LogKey:##P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|P1 B|P1 A|\n\
707            ||U.......|\n\
708            [/Input]\n";
709        let (m2, _) =
710            import_bk2("Platform NES\n", empty_console, TEST_SHA).expect("import empty-console");
711        assert_eq!(
712            m2.frames[0].p1,
713            Buttons::UP,
714            "empty console group keeps its slot; P1 col 0 = Up still maps to P1"
715        );
716    }
717
718    #[test]
719    fn pal_flag_maps_to_region() {
720        let text = "Platform NES\nPAL 1\n";
721        let log = "[Input]\nLogKey:x\n|..|........|........|\n[/Input]\n";
722        let (movie, meta) = import_bk2(text, log, TEST_SHA).expect("import");
723        assert_eq!(movie.region, Region::Pal);
724        assert!(meta.pal);
725
726        let pal_movie = Movie {
727            epoch: crate::EMULATION_EPOCH,
728            region: Region::Pal,
729            rom_sha256: TEST_SHA,
730            options: crate::HardwareOptions::default(),
731            board: None,
732            start: StartPoint::PowerOn,
733            frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
734            rerecord_count: 0,
735            attestation: None,
736        };
737        let out = export_bk2(&pal_movie, &Bk2ExportOpts::default()).expect("export");
738        assert!(out.header.lines().any(|l| l == "PAL 1"));
739
740        let ntsc_movie = Movie {
741            epoch: crate::EMULATION_EPOCH,
742            region: Region::Ntsc,
743            ..pal_movie
744        };
745        let out = export_bk2(&ntsc_movie, &Bk2ExportOpts::default()).expect("export");
746        assert!(!out.header.lines().any(|l| l == "PAL 1"));
747    }
748
749    #[test]
750    fn malformed_inputs_never_panic() {
751        // Non-NES platform.
752        assert!(matches!(
753            import_bk2("Platform SNES\n", "[Input]\nLogKey:x\n[/Input]\n", TEST_SHA),
754            Err(Bk2Error::WrongPlatform(_))
755        ));
756
757        // Missing LogKey.
758        assert!(matches!(
759            import_bk2(
760                "Platform NES\n",
761                "[Input]\n|..|........|........|\n",
762                TEST_SHA
763            ),
764            Err(Bk2Error::MissingLogKey)
765        ));
766
767        // Bad integer header.
768        assert!(matches!(
769            import_bk2("Platform NES\nrerecordCount nope\n", "LogKey:x\n", TEST_SHA),
770            Err(Bk2Error::BadInteger { .. })
771        ));
772
773        // Input line not ending with `|`.
774        assert!(matches!(
775            import_bk2(
776                "Platform NES\n",
777                "LogKey:x\n|..|........|........\n",
778                TEST_SHA
779            ),
780            Err(Bk2Error::Malformed { .. })
781        ));
782
783        // 7-char pad group.
784        assert!(matches!(
785            import_bk2(
786                "Platform NES\n",
787                "LogKey:x\n|..|.......|........|\n",
788                TEST_SHA
789            ),
790            Err(Bk2Error::Malformed { .. })
791        ));
792
793        // Save-anchored movie is unsupported.
794        assert!(matches!(
795            import_bk2(
796                "Platform NES\nStartsFromSavestate 1\n",
797                "LogKey:x\n",
798                TEST_SHA
799            ),
800            Err(Bk2Error::Unsupported(_))
801        ));
802    }
803
804    #[test]
805    fn log_key_bounded_against_pathological_group_padding() {
806        // Hardening regression (v2.2.9): `parse_log_key` reads only the console,
807        // P1, and P2 groups straight from the `split('#')` iterator instead of
808        // collecting every `#`-group, so a hostile `.bk2` padded with a large
809        // number of `#` delimiters cannot amplify into an unbounded `Vec<&str>`
810        // on import. The trailing empty groups must be ignored and P1/P2 must
811        // still map correctly.
812        let mut log = String::from("[Input]\nLogKey:#Reset|Power|#P1 Up|P1 A|#P2 Up|P2 A|");
813        log.push_str(&"#".repeat(100_000)); // pathological trailing delimiters
814        log.push_str("\n|..|U.|.A|\n[/Input]\n");
815        let (m, _) = import_bk2("Platform NES\n", &log, TEST_SHA).expect("import padded LogKey");
816        assert_eq!(
817            m.frames[0].p1,
818            Buttons::UP,
819            "P1 col 0 = Up maps despite trailing `#` padding"
820        );
821        assert_eq!(
822            m.frames[0].p2,
823            Buttons::A,
824            "P2 col 1 = A maps despite trailing `#` padding"
825        );
826    }
827
828    #[test]
829    fn one_player_movie_defaults_p2_released() {
830        // A line with only the console group + P1 (no P2 group).
831        let log = "[Input]\nLogKey:x\n|..|.......A|\n[/Input]\n";
832        let (movie, _) = import_bk2("Platform NES\n", log, TEST_SHA).expect("import");
833        assert_eq!(movie.frames.len(), 1);
834        assert_eq!(movie.frames[0].p1, Buttons::A);
835        assert_eq!(movie.frames[0].p2, Buttons::empty());
836    }
837
838    #[test]
839    fn export_rejects_save_state_movie() {
840        let movie = Movie {
841            epoch: crate::EMULATION_EPOCH,
842            region: Region::Ntsc,
843            rom_sha256: TEST_SHA,
844            options: crate::HardwareOptions::default(),
845            board: None,
846            start: StartPoint::SaveState(vec![1, 2, 3]),
847            frames: vec![],
848            rerecord_count: 0,
849            attestation: None,
850        };
851        assert!(matches!(
852            export_bk2(&movie, &Bk2ExportOpts::default()),
853            Err(Bk2Error::Unsupported(_))
854        ));
855    }
856}