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}