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.
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.
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].
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}