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}