jevsnes.git / packages / replay / src / format.rs

One recorded emulated frame: the pad the console held, the human's pad, the work-RAM bytes the bot's tick wrote this frame, and, on the frame a goal choice happened, which option was used.

Per research/replay-timeline.md's Findings (2026-09-21): the pad alone does not reproduce a run, because the bot's cheats (full health, bombs, arrows, hammer, lamp - alttp.c:20-27) write work RAM every frame, not only the pad. So a [FrameEntry] carries both.

10use bincode::{Decode, Encode};

Which goal-choice option a decision used, on the frame the hook ran. Logging the PICK (not just what Jev answered) is deliberate: the pick also records the wall-clock rate breaker's own fallbacks, which a replay has no way to reproduce by asking again (research/replay-timeline.md, "Log the pick, not just Jev's answer").

This is goal-choice-specific on purpose, for now: a packages/decisions crate is landing that replaces zbanks-jev with one Decision trait and a kind on every decision record, covering more than the goal choice. Once it lands, FrameEntry::decision should generalize to (kind, answer) so replay can look any decision kind up by frame, not just this one - tracked here rather than acted on early, since the crate it depends on does not exist yet.

25#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
26pub enum Pick {

The chooser returned None, or was never asked: upstream's own pick (index 0, packages/zbanks' choose_goal).

29    Upstream,

The chooser returned this index into the options offered.

31    Index(u32),
32}

One entry per emulated frame, in order, in a branch's log (crate::log). Bincode-encoded; wram_diff is empty on nearly every frame (crate::keyframe's doc comment has the measured size).

37#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
38pub struct FrameEntry {

What the console held this frame - the bot's returned pad, Snes9x's joypad layout (zbanks::PAD_BITS). This is what [crate::seek] replays: it is the actual output, unlike human_pad, which upstream itself may have overridden (zbanks::apply_pad's doc comment).

43    pub console_pad: u16,

What the human held (keyboard plus any MCP press), before the bot saw it. Not used to replay - the bot already turned it into console_pad - kept so the recording says what a person actually did.

47    pub human_pad: u16,

(offset from $7E0000, new byte) for every work-RAM byte the bot's tick changed this frame, in ascending offset order.

50    pub wram_diff: Vec<(u32, u8)>,

Some only on the frame a goal choice was made.

52    pub decision: Option<Pick>,
53}

Every work-RAM byte that differs between before and after, as (offset from $7E0000, new value), ascending. Pure and small enough to unit-test without a console: the caller (the recorder, or the determinism probe) supplies two work-RAM snapshots taken immediately before and immediately after one zbanks::Bot::tick - the window in 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}

Apply a diff to a mutable WRAM buffer - the inverse of taking one. Used 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}
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}