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.
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,
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.
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.
87impl Reason {
One clause, for the panel line and the log.
The bot is stalled, and has been since since.
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.
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).
An ordinary frame: has a task, eligible, and - unlike [HERE]/[THERE]
- actually walking, so tests about
no_task/eligiblethat run for tens of thousands of frames don't tripNotMovingby accident. The period (97) is short enough that consecutive frames are never at the same pixel.
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}