jevsnes.git / packages / replay / src / format.rs
1//! One recorded emulated frame: the pad the console held, the human's pad,
2//! the work-RAM bytes the bot's tick wrote this frame, and, on the frame a
3//! goal choice happened, which option was used.
4//!
5//! Per `research/replay-timeline.md`'s Findings (2026-09-21): the pad alone
6//! does not reproduce a run, because the bot's cheats (full health, bombs,
7//! arrows, hammer, lamp - `alttp.c:20-27`) write work RAM every frame, not
8//! only the pad. So a [`FrameEntry`] carries both.
9
10use bincode::{Decode, Encode};
11
12/// Which goal-choice option a decision used, on the frame the hook ran.
13/// Logging the PICK (not just what Jev answered) is deliberate: the pick
14/// also records the wall-clock rate breaker's own fallbacks, which a replay
15/// has no way to reproduce by asking again (`research/replay-timeline.md`,
16/// "Log the pick, not just Jev's answer").
17///
18/// This is goal-choice-specific on purpose, for now: a `packages/decisions`
19/// crate is landing that replaces `zbanks-jev` with one `Decision` trait and
20/// a `kind` on every decision record, covering more than the goal choice.
21/// Once it lands, `FrameEntry::decision` should generalize to `(kind,
22/// answer)` so replay can look any decision kind up by frame, not just this
23/// one - tracked here rather than acted on early, since the crate it
24/// depends on does not exist yet.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
26pub enum Pick {
27    /// The chooser returned `None`, or was never asked: upstream's own pick
28    /// (index 0, `packages/zbanks`' `choose_goal`).
29    Upstream,
30    /// The chooser returned this index into the options offered.
31    Index(u32),
32}
33
34/// One entry per emulated frame, in order, in a branch's log
35/// (`crate::log`). Bincode-encoded; `wram_diff` is empty on nearly every
36/// frame (`crate::keyframe`'s doc comment has the measured size).
37#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
38pub struct FrameEntry {
39    /// What the console held this frame - the bot's returned pad, Snes9x's
40    /// joypad layout (`zbanks::PAD_BITS`). This is what [`crate::seek`]
41    /// replays: it is the actual output, unlike `human_pad`, which upstream
42    /// itself may have overridden (`zbanks::apply_pad`'s doc comment).
43    pub console_pad: u16,
44    /// What the human held (keyboard plus any MCP `press`), before the bot
45    /// saw it. Not used to replay - the bot already turned it into
46    /// `console_pad` - kept so the recording says what a person actually did.
47    pub human_pad: u16,
48    /// `(offset from $7E0000, new byte)` for every work-RAM byte the bot's
49    /// tick changed this frame, in ascending offset order.
50    pub wram_diff: Vec<(u32, u8)>,
51    /// `Some` only on the frame a goal choice was made.
52    pub decision: Option<Pick>,
53}
54
55/// Every work-RAM byte that differs between `before` and `after`, as
56/// `(offset from $7E0000, new value)`, ascending. Pure and small enough to
57/// unit-test without a console: the caller (the recorder, or the
58/// determinism probe) supplies two work-RAM snapshots taken immediately
59/// before and immediately after one `zbanks::Bot::tick` - the window in
60/// which the bot's cheats run and nothing else touches RAM.
61pub fn diff_wram(before: &[u8], after: &[u8]) -> Vec<(u32, u8)> {
62    debug_assert_eq!(before.len(), after.len(), "diffing WRAM of two different sizes");
63    before
64        .iter()
65        .zip(after)
66        .enumerate()
67        .filter(|(_, (b, a))| b != a)
68        .map(|(i, (_, &a))| (i as u32, a))
69        .collect()
70}
71
72/// Apply a diff to a mutable WRAM buffer - the inverse of taking one. Used
73/// by [`crate::seek`] to reproduce a bot's cheat writes without the bot.
74pub fn apply_wram_diff(wram: &mut [u8], diff: &[(u32, u8)]) {
75    for &(offset, value) in diff {
76        wram[offset as usize] = value;
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn no_diff_when_nothing_changed() {
86        let ram = [1u8, 2, 3, 4, 5];
87        assert_eq!(diff_wram(&ram, &ram), Vec::<(u32, u8)>::new());
88    }
89
90    #[test]
91    fn diff_names_every_changed_offset_ascending() {
92        let before = [0u8, 1, 2, 3, 4, 5, 6];
93        let after = [0u8, 9, 2, 3, 8, 5, 7];
94        assert_eq!(diff_wram(&before, &after), vec![(1, 9), (4, 8), (6, 7)]);
95    }
96
97    #[test]
98    fn apply_is_the_inverse_of_diff() {
99        let before = vec![10u8; 64];
100        let mut after = before.clone();
101        after[3] = 99;
102        after[40] = 200;
103        let diff = diff_wram(&before, &after);
104        let mut reconstructed = before.clone();
105        apply_wram_diff(&mut reconstructed, &diff);
106        assert_eq!(reconstructed, after);
107    }
108
109    #[test]
110    fn a_frame_entry_round_trips_through_bincode() {
111        let entry = FrameEntry {
112            console_pad: 0x8421,
113            human_pad: 0x0040,
114            wram_diff: vec![(0x10, 5), (0xF340, 0x63)],
115            decision: Some(Pick::Index(3)),
116        };
117        let config = bincode::config::standard();
118        let bytes = bincode::encode_to_vec(&entry, config).expect("encode");
119        let (back, _): (FrameEntry, usize) = bincode::decode_from_slice(&bytes, config).expect("decode");
120        assert_eq!(back, entry);
121
122        let plain = FrameEntry { console_pad: 0, human_pad: 0, wram_diff: Vec::new(), decision: None };
123        let bytes = bincode::encode_to_vec(&plain, config).expect("encode");
124        let (back, _): (FrameEntry, usize) = bincode::decode_from_slice(&bytes, config).expect("decode");
125        assert_eq!(back, plain);
126
127        let bytes = bincode::encode_to_vec(&FrameEntry { decision: Some(Pick::Upstream), ..plain.clone() }, config).unwrap();
128        let (back, _): (FrameEntry, usize) = bincode::decode_from_slice(&bytes, config).unwrap();
129        assert_eq!(back.decision, Some(Pick::Upstream));
130    }
131}