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

Reconstruct the BOT's own state at a frame by re-running a fresh one over the logged history: exact, because the bot is a pure function of RAM, the pad, rand() and the goal-choice hook's picks - all fixed by the log (research/replay-timeline.md, "Re-running the bot over the log is exact"). This is what "run the bot from here" needs, since a zbanks::Bot's state is C globals that no snapshot can capture (packages/zbanks/CLAUDE.md: one bot per process) - crate::seek reconstructs the CONSOLE quickly for scrubbing, but the bot's own map, goals and item-location deductions only come back by actually ticking it through everything that happened.

Runs a bounded slice of frames per [Rebuilder::step] rather than all at once, so a caller with a UI can show progress ("rebuilding bot: 12,000/36,000 frames") instead of blocking.

Frame numbering here is LOCAL to the branch being rebuilt, and lines up with the fresh zbanks::Bot's own internal frame counter by construction: a recording always starts exactly when a bot does (crate::recorder's own CLAUDE.md note), so entry i was recorded on the tick where the ORIGINAL bot's frame counter was also i, and feeding entries 0.. to a fresh bot in order reproduces that exactly - no frame number needs to be threaded through the goal-choice hook itself.

24use std::cell::Cell;
25use std::rc::Rc;
27use console::Console;
28use zbanks::{Bot, GoalChooser, GoalOption, Situation, States};
29
30use crate::format::{FrameEntry, Pick};

Feeds back exactly the pick the ORIGINAL run's hook used, on the tick it used it - never asks Jev, never makes a fresh choice. [Rebuilder::step] sets pending immediately before each tick that has a logged decision.

35struct ReplayChooser {
36    pending: Rc<Cell<Option<Pick>>>,
37}
39impl GoalChooser for ReplayChooser {
40    fn choose(&mut self, _frame: u32, _here: &Situation, options: &[GoalOption]) -> Option<usize> {
41        match self.pending.take() {
42            Some(Pick::Index(i)) if (i as usize) < options.len() => Some(i as usize),
43            _ => None,
44        }
45    }
46}
47
48pub struct Progress {
49    pub done: u64,
50    pub total: u64,
51    pub finished: bool,
52}
53
54pub struct Rebuilder {
55    entries: Vec<FrameEntry>,
56    index: usize,
57    pending: Rc<Cell<Option<Pick>>>,
58}
59
60impl Rebuilder {

entries are the flattened, in-order [FrameEntry] values from a branch's own frame 1 up to (and stopping at) the target frame - the caller (walking the branch chain, as crate::seek does) has already picked out exactly the slice that matters. Returns the driver plus the [GoalChooser] to install on a freshly started Bot with Bot::set_goal_chooser BEFORE the first call to [Self::step].

67    pub fn new(entries: Vec<FrameEntry>) -> (Self, Box<dyn GoalChooser>) {
68        let pending = Rc::new(Cell::new(None));
69        let chooser = ReplayChooser { pending: pending.clone() };
70        (Self { entries, index: 0, pending }, Box::new(chooser))
71    }
73    pub fn total(&self) -> u64 {
74        self.entries.len() as u64
75    }

Run up to budget more logged frames through bot. states is the same [States] the bot was started with (it only ever asks for "home"/"hpegs" once, at Bot::start, but the trait object must stay alive for the type to name).

81    pub fn step(&mut self, bot: &mut Bot, console: &mut Console, states: &mut dyn States, budget: u32) -> Progress {
82        let mut ran = 0;
83        while self.index < self.entries.len() && ran < budget {
84            let entry = &self.entries[self.index];
85            self.pending.set(entry.decision);
86            let pad = bot.tick(console, states, entry.human_pad);
87            zbanks::apply_pad(console, pad);
88            console.run_frame().expect("running a logged frame during rebuild");
89            self.index += 1;
90            ran += 1;
91        }
92        Progress { done: self.index as u64, total: self.entries.len() as u64, finished: self.index >= self.entries.len() }
93    }
94}
96#[cfg(test)]
97mod tests {
98    // Rebuilder drives a real `zbanks::Bot` over a real `console::Console`,
99    // and `zbanks::Bot::start` panics on a second call in the same process
100    // (packages/zbanks/CLAUDE.md, "one bot per process") - exactly the
101    // constraint that makes a REBUILD a re-exec in `apps/native`, not an
102    // in-process operation. That rules out a from-scratch unit test here
103    // the same way `crate::seek`'s does; `apps/replay-probe` is where a
104    // real rebuild gets exercised end to end, once it grows a `--rebuild`
105    // mode.
106    use super::*;
107
108    #[test]
109    fn a_replay_chooser_reads_and_clears_its_pending_pick() {
110        let pending = Rc::new(Cell::new(None));
111        let mut chooser = ReplayChooser { pending: pending.clone() };
112        let options = [GoalOption {
113            kind: String::new(),
114            node: String::new(),
115            screen: String::new(),
116            screen_id: 0,
117            indoors: false,
118            direction: 0,
119            sprite_type: 0,
120            sprite_subtype: 0,
121            item: String::new(),
122            needs: String::new(),
123            score: 0,
124            distance: 0,
125            attempts: 0,
126            adjacent_known: false,
127            at: (0, 0),
128            screen_bounds: ((0, 0), (0, 0)),
129            via: String::new(),
130            via_direction: 0,
131            screens_on_path: 0,
132            on_way_to: 0,
133        }];
134        let here = Situation::default();
135
136        // Nothing pending: upstream's own pick.
137        assert_eq!(chooser.choose(0, &here, &options), None);
138
139        pending.set(Some(Pick::Index(0)));
140        assert_eq!(chooser.choose(0, &here, &options), Some(0));
141        // Consumed: asking again with nothing newly pending falls back.
142        assert_eq!(chooser.choose(0, &here, &options), None);
143
144        pending.set(Some(Pick::Upstream));
145        assert_eq!(chooser.choose(0, &here, &options), None);
146
147        // An index the options no longer cover (recorded against a longer
148        // list than this call offers) also falls back, rather than panicking.
149        pending.set(Some(Pick::Index(5)));
150        assert_eq!(chooser.choose(0, &here, &options), None);
151    }
152}