jevsnes.git / packages / zbanks / src / stall.rs
stall.rsannotatedstall.rssource344 lines · 14.7 KB · raw
1//! Whether the bot has stopped playing, in a way nothing else in this app
2//! says out loud: the window looks the same whether the bot is deep in a
3//! boss fight or has been standing still for two hours, because the frame
4//! counter moves either way and the picture keeps rendering. This turns
5//! three things the host already reads every tick - the planner's own
6//! give-up flag, its task queue, and where Link actually is - into one
7//! fact a caller cannot miss.
8//!
9//! Incident (2026-09-21): the window sat idle for about two hours (frame
10//! 514908, last goal chosen at 79548, every goal `unsatisfiable`,
11//! `ap_manual_mode` latched, Link standing still) with nothing in the app
12//! saying so.
13//!
14//! [`STALL_FRAMES`] is not a guess: `ap_manual_mode` (`third-party/c/zbanks-alttp/ap_plan.c:917`,
15//! the only place it is ever assigned - grepped, not assumed) is a one-way
16//! latch IN THE C, with no code path there that clears it, so a goal-gap or
17//! stillness threshold only has to sit above the largest gap the bot has
18//! ever recovered from, not guess at "normal". Two independent measurements
19//! agree on where that line is: this run's own `run/native.log` (the
20//! `zbanks_jev` decision log, every "jev goal choice" line carries a
21//! frame) shows a largest recovered gap of 16,413 frames between two real
22//! choices before this incident's run finally gave up; `research/zbanks-alttp.md`
23//! ("Jev at the goal choice", run `j3`) independently reports "kept picking
24//! a goal in cave `$123` for 17,000 frames until the bot gave up" as its
25//! own worst case. 18,000 frames (five minutes at the SNES's ~60.0988 Hz)
26//! sits just above both.
27//!
28//! **The HOST can now clear it** (`crate::recovery`, added after a goal that
29//! had given up was put back through `zb_goal_add` while `ap_manual_mode`
30//! was set - jev `c37bf1ea`). So `manual_mode` in [`Signal`] is no longer a
31//! value that only ever goes one way, and [`Detector`] follows suit: a
32//! `false` reading clears `ManualMode` immediately rather than being treated
33//! as impossible.
34
35use alttp::Place;
36
37/// Five minutes at ~60.0988 fps - see the module doc for the evidence.
38pub const STALL_FRAMES: u32 = 18_000;
39
40/// One frame's evidence, built from what a caller already has: a
41/// `zbanks::Report` and an `alttp::State`. Nothing here is computed by this
42/// module.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct Signal {
45    /// `Report::frame` / `State`'s frame - the emulated frame this evidence
46    /// is from.
47    pub frame: u32,
48    /// `Report::manual_mode` - `ap_plan.c`'s own "every goal was
49    /// unsatisfiable, stop for good" flag. A one-way latch in the C (see the
50    /// module doc), but the host's own recovery can clear it, so a `false`
51    /// after a `true` is real and this detector believes it.
52    pub manual_mode: bool,
53    /// `Report::tasks.is_empty()` - nothing queued or running.
54    pub no_task: bool,
55    /// Whether the bot could have acted at all this frame: `State::control`
56    /// (the engine's own "the pad moves Link" gate - false during a text
57    /// box, a menu, a load or a cutscene) and `Mode::is_play()` (excludes
58    /// the title screen and the attract demo outright), and NOT the human
59    /// holding X (upstream's manual override, `alttp.c:57`). A frame where
60    /// this is false proves nothing either way, so it neither starts nor
61    /// grows a stall - it also does not reset one already building: a human
62    /// tapping X for a few frames must not hide a near-threshold stall.
63    pub eligible: bool,
64    /// Where Link is, coarse: his room/area plus pixel position.
65    pub position: (Place, u16, u16),
66}
67
68/// Why [`Detector::update`] says the bot is stalled. Checked in this order:
69/// `ManualMode` is both the most specific and the most permanent, so it
70/// always wins when it applies.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum Reason {
73    /// The planner has no goals left. Ordinarily permanent for the life of
74    /// this process (the C's own one-way latch, or a `restart` - a fresh
75    /// `zbanks::Bot`, fresh C globals); the host's own recovery
76    /// (`crate::recovery`) can also clear it by putting a goal back while it
77    /// is set, and this reason clears the moment that happens.
78    ManualMode,
79    /// No task has been queued or running for [`STALL_FRAMES`] eligible
80    /// frames.
81    NoTask,
82    /// Link's room and pixel position have not changed for [`STALL_FRAMES`]
83    /// eligible frames while the bot should have been driving him.
84    NotMoving,
85}
86
87impl Reason {
88    /// 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}
97
98/// 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}
104
105/// Tracks the three conditions across ticks. One per `zbanks::Bot`: a
106/// `restart` makes a fresh bot (its state is C globals, `packages/zbanks`'s
107/// own doc comment) and should make a fresh `Detector` alongside it, so a
108/// 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}
118
119impl Detector {
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Feed one frame's evidence, in frame order, and read back whether the
125    /// 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}
178
179/// "T minutes" for the panel line and the log, from a frame span and the
180/// 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}
184
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);
191
192    /// An ordinary frame: has a task, eligible, and - unlike [`HERE`]/[`THERE`]
193    /// - actually walking, so tests about `no_task`/`eligible` that run for
194    /// tens of thousands of frames don't trip `NotMoving` by accident. The
195    /// period (97) is short enough that consecutive frames are never at the
196    /// 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    }
201
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}