jevsnes.git / packages / zbanks / src / stall.rs
stall.rsannotatedstall.rssource344 lines · 14.7 KB · raw

Whether the bot has stopped playing, in a way nothing else in this app says out loud: the window looks the same whether the bot is deep in a boss fight or has been standing still for two hours, because the frame counter moves either way and the picture keeps rendering. This turns three things the host already reads every tick - the planner's own give-up flag, its task queue, and where Link actually is - into one fact a caller cannot miss.

Incident (2026-09-21): the window sat idle for about two hours (frame 514908, last goal chosen at 79548, every goal unsatisfiable, ap_manual_mode latched, Link standing still) with nothing in the app saying so.

[STALL_FRAMES] is not a guess: ap_manual_mode (third-party/c/zbanks-alttp/ap_plan.c:917, the only place it is ever assigned - grepped, not assumed) is a one-way latch IN THE C, with no code path there that clears it, so a goal-gap or stillness threshold only has to sit above the largest gap the bot has ever recovered from, not guess at "normal". Two independent measurements agree on where that line is: this run's own run/native.log (the zbanks_jev decision log, every "jev goal choice" line carries a frame) shows a largest recovered gap of 16,413 frames between two real choices before this incident's run finally gave up; research/zbanks-alttp.md ("Jev at the goal choice", run j3) independently reports "kept picking a goal in cave $123 for 17,000 frames until the bot gave up" as its own worst case. 18,000 frames (five minutes at the SNES's ~60.0988 Hz) sits just above both.

The HOST can now clear it (crate::recovery, added after a goal that had given up was put back through zb_goal_add while ap_manual_mode was set - jev c37bf1ea). So manual_mode in [Signal] is no longer a value that only ever goes one way, and [Detector] follows suit: a false reading clears ManualMode immediately rather than being treated as impossible.

35use alttp::Place;

Five minutes at ~60.0988 fps - see the module doc for the evidence.

38pub const STALL_FRAMES: u32 = 18_000;

One frame's evidence, built from what a caller already has: a zbanks::Report and an alttp::State. Nothing here is computed by this module.

43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct Signal {

Report::frame / State's frame - the emulated frame this evidence is from.

47    pub frame: u32,

Report::manual_mode - ap_plan.c's own "every goal was unsatisfiable, stop for good" flag. A one-way latch in the C (see the module doc), but the host's own recovery can clear it, so a false after a true is real and this detector believes it.

52    pub manual_mode: bool,

Report::tasks.is_empty() - nothing queued or running.

54    pub no_task: bool,

Whether the bot could have acted at all this frame: State::control (the engine's own "the pad moves Link" gate - false during a text box, a menu, a load or a cutscene) and Mode::is_play() (excludes the title screen and the attract demo outright), and NOT the human holding X (upstream's manual override, alttp.c:57). A frame where this is false proves nothing either way, so it neither starts nor grows a stall - it also does not reset one already building: a human tapping X for a few frames must not hide a near-threshold stall.

63    pub eligible: bool,

Where Link is, coarse: his room/area plus pixel position.

65    pub position: (Place, u16, u16),
66}

Why [Detector::update] says the bot is stalled. Checked in this order: ManualMode is both the most specific and the most permanent, so it always wins when it applies.

71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum Reason {

The planner has no goals left. Ordinarily permanent for the life of this process (the C's own one-way latch, or a restart - a fresh zbanks::Bot, fresh C globals); the host's own recovery (crate::recovery) can also clear it by putting a goal back while it is set, and this reason clears the moment that happens.

78    ManualMode,

No task has been queued or running for [STALL_FRAMES] eligible frames.

81    NoTask,

Link's room and pixel position have not changed for [STALL_FRAMES] eligible frames while the bot should have been driving him.

84    NotMoving,
85}
87impl Reason {

One clause, for the panel line and the log.

89    pub fn words(self) -> &'static str {
90        match self {
91            Self::ManualMode => "the planner has no goals left; the bot fell back to manual mode",
92            Self::NoTask => "no task has been queued or running",
93            Self::NotMoving => "Link has not moved",
94        }
95    }
96}

The bot is stalled, and has been since since.

99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct Stall {
101    pub since: u32,
102    pub reason: Reason,
103}

Tracks the three conditions across ticks. One per zbanks::Bot: a restart makes a fresh bot (its state is C globals, packages/zbanks's own doc comment) and should make a fresh Detector alongside it, so a stall from the last process never survives into the next one's frame 0.

109#[derive(Default)]
110pub struct Detector {
111    manual_mode_since: Option<u32>,
112    no_task_since: Option<u32>,
113    no_task_eligible: u32,
114    last_position: Option<(Place, u16, u16)>,
115    position_since: Option<u32>,
116    position_eligible: u32,
117}
119impl Detector {
120    pub fn new() -> Self {
121        Self::default()
122    }

Feed one frame's evidence, in frame order, and read back whether the bot is currently stalled. Cheap and pure: no I/O, no allocation.

126    pub fn update(&mut self, s: Signal) -> Option<Stall> {
127        if s.manual_mode {
128            if self.manual_mode_since.is_none() {
129                self.manual_mode_since = Some(s.frame);
130            }
131        } else {
132            // The C's own latch cannot do this, but the host's recovery can
133            // (crate::recovery) - see the module doc.
134            self.manual_mode_since = None;
135        }
136
137        if s.no_task {
138            if self.no_task_since.is_none() {
139                self.no_task_since = Some(s.frame);
140                self.no_task_eligible = 0;
141            }
142            if s.eligible {
143                self.no_task_eligible += 1;
144            }
145        } else {
146            self.no_task_since = None;
147            self.no_task_eligible = 0;
148        }
149
150        if self.last_position == Some(s.position) {
151            if s.eligible {
152                self.position_eligible += 1;
153            }
154        } else {
155            self.last_position = Some(s.position);
156            self.position_since = Some(s.frame);
157            self.position_eligible = 0;
158        }
159
160        if let Some(since) = self.manual_mode_since {
161            return Some(Stall { since, reason: Reason::ManualMode });
162        }
163        if self.no_task_eligible >= STALL_FRAMES {
164            return Some(Stall {
165                since: self.no_task_since.expect("no_task_eligible only grows while no_task_since is set"),
166                reason: Reason::NoTask,
167            });
168        }
169        if self.position_eligible >= STALL_FRAMES {
170            return Some(Stall {
171                since: self.position_since.expect("position_eligible only grows while position_since is set"),
172                reason: Reason::NotMoving,
173            });
174        }
175        None
176    }
177}

"T minutes" for the panel line and the log, from a frame span and the console's own target_fps() (~60.0988, not a bare 60).

181pub fn minutes(frames_elapsed: u32, fps: f64) -> f64 {
182    f64::from(frames_elapsed) / fps / 60.0
183}
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const HERE: (Place, u16, u16) = (Place::Outdoors(0), 100, 100);
190    const THERE: (Place, u16, u16) = (Place::Outdoors(0), 200, 100);

An ordinary frame: has a task, eligible, and - unlike [HERE]/[THERE]

  • actually walking, so tests about no_task/eligible that run for tens of thousands of frames don't trip NotMoving by accident. The period (97) is short enough that consecutive frames are never at the same pixel.
197    fn playing(frame: u32) -> Signal {
198        let x = 100 + frame % 97;
199        Signal { frame, manual_mode: false, no_task: false, eligible: true, position: (Place::Outdoors(0), x as u16, 100) }
200    }
202    #[test]
203    fn quiet_when_everything_is_normal() {
204        let mut d = Detector::new();
205        for frame in 0..STALL_FRAMES * 2 {
206            assert_eq!(d.update(playing(frame)), None);
207        }
208    }
209
210    #[test]
211    fn manual_mode_flags_immediately_not_after_a_threshold() {
212        let mut d = Detector::new();
213        assert_eq!(d.update(playing(0)), None);
214        let stall = d.update(Signal { manual_mode: true, ..playing(1) }).expect("manual mode is immediate");
215        assert_eq!(stall, Stall { since: 1, reason: Reason::ManualMode });
216    }
217
218    #[test]
219    fn manual_mode_since_never_moves_once_set() {
220        let mut d = Detector::new();
221        d.update(Signal { manual_mode: true, ..playing(1000) });
222        for frame in [1001, 5000, 500_000] {
223            let stall = d.update(Signal { manual_mode: true, ..playing(frame) }).expect("stays set");
224            assert_eq!(stall.since, 1000, "the flag never clears in the C, so neither does its since-frame");
225        }
226    }
227
228    #[test]
229    fn manual_mode_clears_the_moment_the_host_reports_it_cleared() {
230        // Unlike the C's own latch, the host's recovery (crate::recovery)
231        // can put a goal back while manual_mode is set and clear it - a
232        // `false` reading after a `true` one is real, not noise, and must
233        // clear the stall at once rather than being remembered forever.
234        let mut d = Detector::new();
235        let stall = d.update(Signal { manual_mode: true, ..playing(1000) }).expect("manual mode is immediate");
236        assert_eq!(stall.reason, Reason::ManualMode);
237        assert_eq!(d.update(Signal { manual_mode: false, ..playing(1001) }), None);
238    }
239
240    #[test]
241    fn manual_mode_outranks_the_others_even_if_they_would_also_fire() {
242        let mut d = Detector::new();
243        for frame in 0..STALL_FRAMES {
244            // Both no_task and manual_mode would independently qualify.
245            d.update(Signal { manual_mode: frame >= 10, no_task: true, ..playing(frame) });
246        }
247        let stall = d.update(Signal { manual_mode: true, no_task: true, ..playing(STALL_FRAMES) }).unwrap();
248        assert_eq!(stall.reason, Reason::ManualMode);
249    }
250
251    #[test]
252    fn no_task_needs_the_full_threshold() {
253        let mut d = Detector::new();
254        for frame in 0..STALL_FRAMES - 1 {
255            assert_eq!(d.update(Signal { no_task: true, ..playing(frame) }), None, "frame {frame}");
256        }
257        let stall = d.update(Signal { no_task: true, ..playing(STALL_FRAMES - 1) }).expect("threshold crossed");
258        assert_eq!(stall, Stall { since: 0, reason: Reason::NoTask });
259    }
260
261    #[test]
262    fn no_task_resets_the_moment_a_task_reappears() {
263        let mut d = Detector::new();
264        for frame in 0..STALL_FRAMES - 1 {
265            d.update(Signal { no_task: true, ..playing(frame) });
266        }
267        // A task shows up just short of the threshold: no stall reported,
268        // and the clock starts over.
269        assert_eq!(d.update(playing(STALL_FRAMES - 1)), None);
270        for frame in STALL_FRAMES..(2 * STALL_FRAMES - 1) {
271            assert_eq!(d.update(Signal { no_task: true, ..playing(frame) }), None, "frame {frame}");
272        }
273        let stall = d.update(Signal { no_task: true, ..playing(2 * STALL_FRAMES - 1) }).expect("threshold crossed again");
274        assert_eq!(stall.since, STALL_FRAMES, "restarted from the frame the task list emptied again");
275    }
276
277    #[test]
278    fn ineligible_frames_do_not_count_but_do_not_reset_either() {
279        let mut d = Detector::new();
280        // The task list empties at frame 0, and the human holds X (or the
281        // bot is in an overlay) from that same frame for far longer than the
282        // threshold: none of that time should count toward the stall.
283        for frame in 0..(STALL_FRAMES * 3) {
284            let s = d.update(Signal { no_task: true, eligible: false, ..playing(frame) });
285            assert_eq!(s, None, "ineligible time must not accumulate toward the stall, frame {frame}");
286        }
287        // Eligibility returns; the ORIGINAL since-frame (0) is preserved,
288        // and it still takes a full threshold of eligible frames from here.
289        for frame in (STALL_FRAMES * 3)..(STALL_FRAMES * 4 - 1) {
290            assert_eq!(d.update(Signal { no_task: true, ..playing(frame) }), None);
291        }
292        let stall = d.update(Signal { no_task: true, ..playing(STALL_FRAMES * 4 - 1) }).expect("threshold now crossed");
293        assert_eq!(stall.since, 0, "the task list has been empty since frame 0 regardless of eligibility");
294    }
295
296    #[test]
297    fn not_moving_needs_the_full_threshold_and_resets_on_a_move() {
298        let mut d = Detector::new();
299        for frame in 0..STALL_FRAMES {
300            assert_eq!(d.update(Signal { position: HERE, ..playing(frame) }), None, "frame {frame}");
301        }
302        let stall = d.update(Signal { position: HERE, ..playing(STALL_FRAMES) }).expect("threshold crossed");
303        assert_eq!(stall, Stall { since: 0, reason: Reason::NotMoving });
304
305        // Link takes one step: the clock restarts from here.
306        d.update(Signal { position: THERE, ..playing(STALL_FRAMES + 1) });
307        for frame in (STALL_FRAMES + 2)..(2 * STALL_FRAMES) {
308            assert_eq!(
309                d.update(Signal { position: THERE, ..playing(frame) }),
310                None,
311                "frame {frame}"
312            );
313        }
314    }
315
316    #[test]
317    fn a_fight_that_never_moves_is_not_flagged_as_no_task() {
318        // A long single task (a boss fight) with a non-empty task list and
319        // a position that changes every frame must never trip NoTask or
320        // NotMoving, however long it runs.
321        let mut d = Detector::new();
322        for frame in 0..(STALL_FRAMES * 3) {
323            let position = (Place::Indoors(0xC8), 100 + (frame % 7) as u16, 100);
324            assert_eq!(d.update(Signal { position, ..playing(frame) }), None, "frame {frame}");
325        }
326    }
327
328    #[test]
329    fn paused_by_x_is_not_a_stall_however_long_it_lasts() {
330        let mut d = Detector::new();
331        for frame in 0..(STALL_FRAMES * 5) {
332            let s = d.update(Signal { no_task: true, eligible: false, ..playing(frame) });
333            assert_eq!(s, None, "frame {frame}");
334        }
335    }
336
337    #[test]
338    fn minutes_matches_the_incident() {
339        // The reported incident: last goal at 79548, found stalled at
340        // 514908, at the SNES's own rate.
341        let m = minutes(514908 - 79548, 60.0988);
342        assert!((m - 120.7).abs() < 0.5, "expected about two hours, got {m}");
343    }
344}