From 552428a7be20bb87567628ffd7655b6520cf6da2 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 09:46:04 +0000 Subject: [PATCH 01/10] survey: the route probe reads the room on the fly's Nth arrival on a map FLY_PROBE_CATCH_MAP and FLY_PROBE_CATCH_ENTRIES stop the route survey forty frames after the fly's Nth arrival on a map (the first frames on a new map byte still carry the old map's warps), and the dump lists every person the macros can see with its talked, blocked and reached entries. Row 58's pad was one door in and one door out, and what it needed read was the room on the far side of the door. --- .../crates/flysim/examples/scene_probe.rs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 5f83667..717001a 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -1379,6 +1379,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) let mut outcomes: BTreeMap = BTreeMap::new(); let mut single_refusals = 0u32; let mut caught_at: Option = None; + // `FLY_PROBE_CATCH_MAP=54` with `FLY_PROBE_CATCH_ENTRIES=4` reads the frame the fly is given + // the buttons back on its fourth arrival on map 54 (row 58: the pad in and out of one door). + let catch_map: Option = + std::env::var("FLY_PROBE_CATCH_MAP").ok().and_then(|value| value.parse().ok()); + let catch_entries = env_usize("FLY_PROBE_CATCH_ENTRIES", 4); + let mut entries = 0usize; + let mut arrived_at = 0usize; + let mut last_map: Option = None; // `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is // every eight frames: what the cartridge does with a press, before any macro is asked. @@ -1502,6 +1510,25 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) caught_at = Some(frame); break; } + if let (Some(want), Some(player)) = (catch_map, player) { + if player.map == want && last_map != Some(want) { + entries += 1; + arrived_at = frame; + } + last_map = Some(player.map); + // Forty frames in: the first frames on a new map byte still carry the old map's + // warps (the tear 12.16 names), and a reading there says nothing about the room. + if player.map == want + && entries >= catch_entries + && frame >= arrived_at + 40 + && !running + && matches!(observed.scene, flybrain_gb::SceneId::Overworld) + && !observed.bindings.is_empty() + { + caught_at = Some(frame); + break; + } + } } println!("```\n"); println!("- refusals: {refusals:?}"); @@ -1512,8 +1539,13 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) return; }; println!( - "\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\n", - frame as f64 * MS_PER_FRAME / 60_000.0 + "\n## Caught on frame {frame} ({:.1} brain minutes): {}\n", + frame as f64 * MS_PER_FRAME / 60_000.0, + if single_refusals >= catch_after { + "one button, refused twenty holds running".to_string() + } else { + format!("arrival {entries} on map {catch_map:?}") + } ); let (pushed, frontiers) = macros.fences(); println!("- pushed tiles (no window): {pushed:?}"); @@ -1525,6 +1557,16 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) println!("- objective: {:?}", state.objective()); println!("- `objective_goals`: {:?}", palette::objective_goals(state)); println!("- `objective_targets`: {:?}", palette::objective_targets(state)); + for (tile, target) in path::person_targets(state) { + println!( + " - person {target:?} at ({:2},{:2}): talked {}, blocked {}, reached {}", + tile.x, + tile.y, + state.talked(target), + state.blocked(TargetKey::Thing(target)), + state.reached(TargetKey::Thing(target)) + ); + } println!("- `untalked_people`: {:?}", palette::untalked_people(state)); println!("- `untalked_objects`: {:?}", palette::untalked_objects(state)); println!("- `facing_untalked`: {}", palette::facing_untalked(state)); From 484cc075cc95aad94e4e1cbbf14d1bd305fcd4e3 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:34:21 +0000 Subject: [PATCH 02/10] the rung's people are in the room when the screen does not show them CheckSpriteAvailability writes $ff into the image index of a sprite outside its window, and state::npcs reports what is drawn. From the Pewter Gym's doormat that is the guide alone, already talked to, so the rung's list was empty: GO OBJECTIVE had nothing to aim at and GO OUT, withheld only while the rung's person is in the room, was the pad. Outside, GO OBJECTIVE walked back in. BROCK was twelve rows up. state::offscreen_npcs reports the sprites the cartridge hides only for being outside the window, read from bytes the seam already has, and objective_targets reads them for a person. Nothing else does: a sprite out of the window may be a toggleable object switched off, and GO NPC, TALK and objects keep what is drawn. Facing any of the rung's people is the arrival: with three in a gym, leaving out only the one ahead walked GO OBJECTIVE between the leader and the trainer. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 18 ++++ .../src/pokemon_red/macros/palette.rs | 26 +++++- .../src/pokemon_red/macros/path.rs | 22 +++++ .../src/pokemon_red/macros/state.rs | 9 ++ .../flybrain-gb/src/pokemon_red/state.rs | 89 +++++++++++++++++++ .../src/pokemon_red/state/tests.rs | 27 ++++++ 6 files changed, 188 insertions(+), 3 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index cd4338f..b7b47c9 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -365,6 +365,24 @@ impl Wram { self.set(ram::wNumSprites, count) } + /// One sprite the cartridge is not drawing: `$ff` in its image index, which is what + /// `CheckSpriteAvailability` writes for a sprite off the screen or switched off, and the + /// movement byte that decides whether the window test applies to it (row 58). + pub fn npc_undrawn( + &mut self, + slot: u8, + picture: u8, + x: u8, + y: u8, + movement: u8, + ) -> &mut Self { + self.npc(slot, picture, x, y, 0x00); + let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES; + let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES; + self.set(data1 + poke::SPRITE_IMAGE_INDEX, poke::SPRITE_NOT_DRAWN) + .set(data2 + poke::SPRITE_MOVEMENT_BYTE, movement) + } + /// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id /// each. pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index ea88b10..c0bdae3 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -1687,9 +1687,18 @@ pub fn objective_goals(state: &mut dyn MacroState) -> Vec { if objective.target.is_some() { let ahead = Tile::new(player.x, player.y).step(player.facing); let here_tile = Tile::new(player.x, player.y); - let mut ranked: Vec<(u32, Tile, TalkTarget)> = objective_targets(state) + let targets = objective_targets(state); + // **Facing any of them is the arrival** (row 58). With one target this was already + // true -- the thing ahead is left out and nothing else is left -- but a gym has three + // people the ladder names, and standing in front of the leader left the Jr. Trainer + // to walk to: `GO OBJECTIVE` walked to him, then back to the leader, and `TALK` was + // the one press it never made room for. A fly facing a person the rung is waiting on + // has nothing left for a walk to do. + if targets.iter().any(|(tile, _)| Some(*tile) == ahead) { + return Vec::new(); + } + let mut ranked: Vec<(u32, Tile, TalkTarget)> = targets .into_iter() - .filter(|(tile, _)| Some(*tile) != ahead) .map(|(tile, target)| (tile.distance(here_tile), tile, target)) .collect(); ranked.sort_unstable(); @@ -1795,7 +1804,18 @@ pub fn objective_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> return Vec::new(); } let targets = match kind { - PlaceKind::Person => path::person_targets(state), + // Row 58: the room's people, drawn or not. From the Pewter Gym's doormat the only person + // on screen is the guide, and with him talked to this list was empty -- so `GO OUT` was a + // candidate and `GO OBJECTIVE` had nothing to aim at, while BROCK stood twelve tiles up the + // room, outside the window the cartridge draws. The whole map's grid is what the walk + // plans over (section 15), so a person off the screen is somewhere a walk can go. + PlaceKind::Person => { + let mut all = path::person_targets(state); + all.extend(path::offscreen_person_targets(state)); + all + } + // Not objects: every item ball in the game is a toggleable object, so a ball the run has + // picked up and one out of sight read alike from outside the window. PlaceKind::Object => path::interactable_targets(state), }; targets diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs index 29d19e0..ef751e8 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs @@ -348,6 +348,28 @@ pub fn person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> { out } +/// The people of this map the cartridge is not drawing only because they are off the screen, +/// keyed as [`person_targets`] keys the drawn ones (row 58). +/// +/// Kept apart from [`person_targets`] on purpose: that list is what `GO NPC`, `TALK` and the +/// talked ledger's facing test read, and a sprite outside the window may also be a toggleable +/// object the cartridge has switched off ([`GameState::offscreen_npcs`]). The one reader is the +/// ladder's own target list, which has to know the leader is in the room before the fly can see +/// him. +/// +/// [`GameState::offscreen_npcs`]: super::state::GameState::offscreen_npcs +pub fn offscreen_person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> { + let mut out: Vec<(Tile, TalkTarget)> = state + .offscreen_npcs() + .iter() + .filter(|npc| npc.person()) + .map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot))) + .collect(); + out.sort_unstable(); + out.dedup(); + out +} + /// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground. /// /// People first, because a person standing on a sign's tile is what the press would reach. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs index 867a1b8..0b40cab 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs @@ -655,6 +655,15 @@ pub trait GameState { /// the same sixteen slots. [`Npc::person`] is the test that separates the two. fn npcs(&mut self) -> Vec; + /// Sprites of the current map the cartridge is not drawing only because they are off the + /// screen (row 58, `pokemon_red::state::offscreen_npcs`). + /// + /// Defaulted to none, which narrows: a seam that cannot answer knows the drawn sprites and + /// nothing more, which is what every reader had before row 58. + fn offscreen_npcs(&mut self) -> Vec { + Vec::new() + } + /// The current map's signs, i.e. its `bg_event` text tiles. /// /// Empty on a map with none. Required rather than defaulted like the rest of this trait: an diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 836780c..d7ceff1 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -184,6 +184,19 @@ pub mod poke { pub const SPRITE_BYTES: u16 = 16; /// `MACRO object_event` stores map coordinates plus four. pub const SPRITE_COORD_BIAS: u8 = 4; + /// `constants/map_object_constants.asm`: `SPRITESTATEDATA1_IMAGEINDEX`, and the `$ff` that + /// `CheckSpriteAvailability` writes there for a sprite it will not draw. + pub const SPRITE_IMAGE_INDEX: u16 = 2; + pub const SPRITE_NOT_DRAWN: u8 = 0xff; + /// `SPRITESTATEDATA2_MOVEMENTBYTE1`, and `WALK` (`$fe`): a movement byte below it is a + /// scripted mover, which `CheckSpriteAvailability` never hides for being off the screen. + pub const SPRITE_MOVEMENT_BYTE: u16 = 6; + pub const MOVEMENT_WALK: u8 = 0xfe; + /// `CheckSpriteAvailability`'s window, in map tiles past the player's own coordinate: + /// `SCREEN_HEIGHT / 2 - 1` rows and `SCREEN_WIDTH / 2 - 1` columns, compared against the + /// sprite's *biased* coordinate. + pub const DRAWN_ROWS: u8 = 8; + pub const DRAWN_COLUMNS: u8 = 9; /// `constants/map_data_constants.asm`: `wCurMapConnections` bits. pub const CONNECTION_EAST: u8 = 1; @@ -1045,6 +1058,78 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec { npcs } +/// The people and objects of the current map the cartridge is not drawing **only because they are +/// off the screen** (row 58). +/// +/// [`npcs`] reports what is drawn, and the Pewter Gym showed what that costs: from the gym's +/// doormat at (4, 13) BROCK at (4, 1) and the Jr. Trainer at (3, 6) are both outside the window, so +/// the macros saw one person in the room -- the guide, already talked to -- and concluded the +/// room held nothing the ladder wanted. +/// +/// `CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into a sprite's image +/// index for three reasons: it is a toggleable object switched off, it is outside the window, or the +/// tile under it is a text box's (a tile id past the map tileset). The window is a pure function of +/// bytes this crate already reads -- `wYCoord`, `wXCoord` and the sprite's own biased `MAPY` / +/// `MAPX` -- so a sprite the cartridge hides and whose coordinates lie **outside** that window is +/// one it would hide for that reason whatever else were true, and its coordinates are still the +/// map's: a sprite the cartridge is not updating does not move. A sprite hidden **inside** the +/// window is hidden for another reason and is not reported. A scripted mover (movement byte below +/// `WALK`) skips the window test altogether, so its `$ff` is never the screen's and it is never +/// reported either. +/// +/// What this cannot tell is the first reason from the second for a sprite outside the window: a +/// toggleable object that is off reads the same as one that is merely far away. That is named, not +/// guessed: [`crate::pokemon_red::macros::palette::objective_targets`] is the one reader, and the +/// ladder's places that name a person are Oak's lab and the gyms, of which only the lab and Viridian +/// Gym carry toggleable people (`data/maps/toggleable_objects.asm`). +pub fn offscreen_npcs(memory: &mut dyn MemoryReader) -> Vec { + let Some(size) = map_size(memory) else { return Vec::new() }; + let player_y = read(memory, ram::wYCoord); + let player_x = read(memory, ram::wXCoord); + if player_x >= size.width || player_y >= size.height { + return Vec::new(); + } + // `CheckSpriteAvailability`, one axis: `cp b / jr z, skip / jr nc, invisible / add n / cp b / + // jr c, invisible` against the biased coordinate `b`. + let drawn = |own: u8, sprite: u8, reach: u8| { + sprite == own || (own < sprite && u16::from(sprite) <= u16::from(own) + u16::from(reach)) + }; + let count = read(memory, ram::wNumSprites).min(poke::SPRITE_SLOTS - 1); + let mut out = Vec::new(); + for slot in 1..=count { + let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES; + let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES; + let picture = read(memory, data1); + if picture == 0 || read(memory, data1 + poke::SPRITE_IMAGE_INDEX) != poke::SPRITE_NOT_DRAWN + { + continue; + } + if read(memory, data2 + poke::SPRITE_MOVEMENT_BYTE) < poke::MOVEMENT_WALK { + continue; + } + let y = read(memory, data2 + 4); + let x = read(memory, data2 + 5); + if y < poke::SPRITE_COORD_BIAS || x < poke::SPRITE_COORD_BIAS { + continue; + } + let (map_x, map_y) = (x - poke::SPRITE_COORD_BIAS, y - poke::SPRITE_COORD_BIAS); + if map_x >= size.width || map_y >= size.height { + continue; + } + if drawn(player_y, y, poke::DRAWN_ROWS) && drawn(player_x, x, poke::DRAWN_COLUMNS) { + continue; + } + out.push(Npc { + slot, + picture, + x: map_x, + y: map_y, + facing: facing_from(read(memory, data1 + 9)), + }); + } + out +} + /// The current tileset's list of passable tile ids, terminator included. /// /// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the @@ -1625,6 +1710,10 @@ impl GameState for PokeState<'_> { npcs(self.memory) } + fn offscreen_npcs(&mut self) -> Vec { + offscreen_npcs(self.memory) + } + fn signs(&mut self) -> Vec { signs(self.memory) } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index a877825..f5a8bdd 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -1040,3 +1040,30 @@ fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_non // Which is the frame the window predicate is for. assert_eq!(state.walkable(3, 6), Walkable::No); } + +#[test] +fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() { + // Row 58, the Pewter Gym from its doormat at (4, 13). The cartridge draws the guide; BROCK at + // (4, 1) and the Jr. Trainer at (3, 6) are outside `CheckSpriteAvailability`'s window, so it + // writes `$ff` into their image index and `npcs` -- which reports what is drawn -- skips them. + const STAY: u8 = 0xff; + let mut wram = Wram::overworld(); + wram.map(0x36, 5, 7, 4, 13) + .npc(3, 0x2b, 7, 10, 0x00) + .npc_undrawn(1, 0x1f, 4, 1, STAY) + .npc_undrawn(2, 0x0e, 3, 6, STAY) + // Undrawn *inside* the window: switched off, or under a text box -- not the screen's doing. + .npc_undrawn(4, 0x05, 5, 11, STAY) + // Undrawn outside it, but a scripted mover, which the window test never hides. + .npc_undrawn(5, 0x05, 8, 1, 0x00); + let drawn: Vec = npcs(&mut wram).iter().map(|npc| npc.slot).collect(); + assert_eq!(drawn, vec![3]); + let off: Vec<(u8, u8, u8)> = + offscreen_npcs(&mut wram).iter().map(|npc| (npc.slot, npc.x, npc.y)).collect(); + assert_eq!(off, vec![(1, 4, 1), (2, 3, 6)], "the leader and the trainer, where they stand"); + + // Walk up the room and the trainer is inside the window: a `$ff` there is not the screen's. + wram.map(0x36, 5, 7, 4, 8); + let off: Vec = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect(); + assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)"); +} From 5fd16536dbd89f94dda84f3c5923439f4dc22ed6 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:34:21 +0000 Subject: [PATCH 03/10] a battle decided is the cartridge's Between a trainer's challenge closing and the battle screen the transition runs 219 frames with every joypad and script bit clear, so the scene read overworld and a pad was dealt: a walk toward the leader pressed into the animation, gave up after three refused steps and put him in the blocked ledger, and the trainer's conversation read as over. wCurOpponent is set when a battle is decided and cleared by EndOfBattle with wIsInBattle. It is not in the generated table; it is the byte between wIsInBattle's flag byte and wBattleType, both neighbours checked against the table, and controllable() reads it. --- .../flybrain-gb/src/pokemon_red/scene.rs | 3 ++- .../src/pokemon_red/scene/tests.rs | 15 +++++++++++ .../flybrain-gb/src/pokemon_red/state.rs | 27 +++++++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs index e09347a..2dc18f2 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs @@ -105,7 +105,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String { format!( "started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \ list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \ - flags6={:#04x} move={:#04x} \ + flags6={:#04x} move={:#04x} opp={:#04x} \ corners=({:#04x},{:#04x},{:#04x},{:#04x})", state::started(memory), state::map_size(memory).map(|size| (size.width, size.height)), @@ -126,6 +126,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String { memory.read8(ram::wStatusFlags5), memory.read8(ram::wStatusFlags6), memory.read8(ram::wMovementFlags), + memory.read8(state::poke::CUR_OPPONENT), box_corners[0], box_corners[1], box_corners[2], diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene/tests.rs index ddfb249..90dd9f0 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene/tests.rs @@ -410,3 +410,18 @@ fn the_start_menus_box_is_read_the_same_way() { .cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START); assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu"); } + +#[test] +fn a_battle_decided_and_not_yet_begun_is_the_cartridges() { + // Row 58. Between a trainer's challenge closing and the battle screen the transition runs for + // 219 frames with every joypad and script bit clear; `wCurOpponent` is what says a battle has + // been decided. The byte is derived, not generated: it sits between two generated ones. + assert_eq!(poke::CUR_OPPONENT, ram::wIsInBattle + 2, "after wIsInBattle and one flag byte"); + assert_eq!(poke::CUR_OPPONENT, ram::wTrainerNo - 4, "and four before wTrainerNo"); + let mut wram = Wram::overworld(); + assert_eq!(detect(&mut wram), Scene::Overworld); + wram.set(poke::CUR_OPPONENT, 0xcd); + assert_eq!(detect(&mut wram), Scene::Unknown, "OPP_JR_TRAINER_M, decided"); + wram.set(poke::CUR_OPPONENT, 0x00); + assert_eq!(detect(&mut wram), Scene::Overworld, "and `EndOfBattle` clears it"); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index d7ceff1..13e7582 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -132,6 +132,19 @@ pub mod poke { /// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the /// one `docs/design/room-escape.md` cares most about. pub const SCRIPTED_MOVEMENT: u8 = 0xc0; + /// `wCurOpponent` (row 58): the species of a wild opponent or `OPP_ID_OFFSET` plus a + /// trainer's class, written when a battle is *decided* -- `home/trainers.asm` for a trainer, + /// the encounter check for a wild one -- and cleared by `EndOfBattle` together with + /// `wIsInBattle`. Not in the generated table, so it is derived rather than pinned: + /// `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`, + /// `wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`, + /// `wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db` in + /// that order, and the table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and + /// `wTrainerNo` (`$d05d`) sit exactly where that layout puts them, so the byte between is + /// `wBattleType - 1` with both neighbours checked. Measured on the cartridge in the Pewter Gym: + /// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of + /// the battle, including the 219 frames of the battle transition in between. + pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1; /// `constants/battle_constants.asm`: the non-volatile status byte. pub const SLP_MASK: u8 = 0b111; @@ -293,12 +306,22 @@ pub fn started(memory: &mut dyn MemoryReader) -> bool { } /// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no -/// scripted movement, no warp in flight, not mid-ledge-hop. +/// scripted movement, no warp in flight, not mid-ledge-hop, no battle decided and not yet begun. /// /// The masks are the reward adapter's own scripted gate, minus the door bits — see /// [`poke::SCRIPTED_MOVEMENT`]. +/// +/// **A battle decided is the cartridge's** (row 58). Between a trainer's challenge closing and the +/// battle screen, the battle transition runs for 219 frames with every joypad and script bit +/// clear, so the seam read an overworld the fly could walk in: the pad was dealt, a walk toward +/// the gym leader pressed into an animation, gave up after three refused steps, and put the +/// leader into the blocked ledger for ten brain minutes -- and the Jr. Trainer's conversation read +/// as over, so the trainer the fly was about to lose to went into the talked ledger for the +/// session. [`poke::CUR_OPPONENT`] is set on the frame the battle is decided and cleared with the +/// battle's own end. pub fn controllable(memory: &mut dyn MemoryReader) -> bool { - read(memory, ram::wJoyIgnore) == 0 + read(memory, poke::CUR_OPPONENT) == 0 + && read(memory, ram::wJoyIgnore) == 0 && read(memory, ram::wSimulatedJoypadStatesIndex) == 0 && read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0 && read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0 From 3eb82d7144e7d23375845ec8037921bf781ad881 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:34:33 +0000 Subject: [PATCH 04/10] a warp's tear deals no pad wCurMap names the new map thirty-two frames before the header, the coordinates and the warp table follow it, while the screen fades, and nothing sets the joypad bits until the fade ends. The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld, dealt a pad, and a walk started there planned over the wrong map; what it aimed at and the tile it left went into the ledgers under the new map's id. Live, GO OUT started and finished in 0.05 s. The driver reads a tear as the map byte having changed while the fly still stands on a warp of the loaded table that leads to the map the byte names (a doormat's LAST_MAP under a town's id included), deals it as Unknown with an empty pad, and records no ground from it. Teleport pads do not change the map byte, so they are never a tear; a tear is bounded at TEAR_FRAMES all the same. --- .../src/pokemon_red/macros/driver.rs | 120 +++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 4132f63..f6091ea 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -18,13 +18,20 @@ use crate::macros::{ use super::super::mapgrid::MapGrids; use super::super::state::PokeState; -use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile}; +use super::cartridge::{ + Areas, Frontiers, LAST_MAP, MacroState, Pushed, Stood, Talked, Targets, Tile, outdoors, +}; use super::geography; use super::executor::{MacroAbort, MacroMachine, Refusal}; use super::palette::{self, MacroId, Palette}; use super::plan; use super::state::{GameState, Scene}; +/// The longest a warp's tear is honoured (row 58): the thirty-two frames measured at the Pewter Gym +/// door, with room for a slower fade, and short enough that a false reading costs a second and a +/// half of an empty pad rather than a stall. +pub const TEAR_FRAMES: u16 = 90; + /// The macro palette over Pokémon Red. #[derive(Debug, Clone)] pub struct PokemonPalette { @@ -99,6 +106,19 @@ pub struct PokemonPalette { nearest: Option<(u8, u32)>, /// Whether the last `observe` was the frame that number fell on. nearer: bool, + /// The map of the last frame that was not a warp's tear, and how many tear frames have run + /// since (row 58, [`PokemonPalette::tear`], [`TEAR_FRAMES`]). + /// + /// Measured on the cartridge at the Pewter Gym's door: `wCurMap` changes to the new map + /// **thirty-two frames** before the map header, the coordinates and the warp table follow it, + /// while the screen fades. On those frames every reading in the seam describes the map the fly + /// just left under the new map's id -- the player "on map 54 at (16, 17)", which is Pewter + /// City's doormat -- and nothing sets the joypad bits `controllable` reads until the fade is + /// over, so the scene read `Overworld` and a pad was dealt. A walk started there plans over + /// the wrong map, ends when the cartridge takes the joypad at the end of the fade, and the + /// ledgers wrote what it had been aiming at against the new map's id. + settled: Option, + tear_frames: u16, /// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather @@ -126,10 +146,46 @@ impl PokemonPalette { grids: MapGrids::default(), nearest: None, nearer: false, + settled: None, + tear_frames: 0, now_ms: 0.0, } } + /// Whether this frame is a warp's tear (row 58): the map byte has changed since the last + /// settled frame, and the fly still stands on a warp of the *loaded* table that leads to the + /// map the byte now names. Updates the settled map on every frame that is not one. + /// + /// On a tear the warp table is still the map the fly just left, so the tile underfoot is the + /// door it walked through: Pewter City's (16, 17), whose destination is the gym, under the + /// gym's id; or a building's doormat, whose destination is `LAST_MAP`, under the id of the + /// town outside. Once the header loads, the table is the new map's and the tile underfoot is + /// the arrival warp, which leads back where the fly came from -- so the reading ends by itself. + /// Measured: thirty-two frames at the gym door each way. + /// + /// The map byte having changed is what keeps the teleport pads of Saffron Gym and Silph Co. -- + /// the three maps in Red with a warp to themselves -- from reading as a tear: a pad moves the + /// fly without changing the map. Bounded by [`TEAR_FRAMES`] all the same, because an empty pad + /// that did not end would be a fly that waits for ever. + fn tear(&mut self, state: &mut dyn MacroState) -> bool { + let Some(player) = state.player() else { return false }; + let changed = self.settled.is_some_and(|was| was != player.map); + let torn = changed + && self.tear_frames < TEAR_FRAMES + && state.warps().iter().any(|warp| { + (warp.x, warp.y) == (player.x, player.y) + && (warp.destination_map == player.map + || (warp.destination_map == LAST_MAP && outdoors(player.map))) + }); + if torn { + self.tear_frames += 1; + } else { + self.tear_frames = 0; + self.settled = Some(player.map); + } + torn + } + /// Frames the running macro has spent, for a log line. pub fn frames(&self) -> u32 { self.machine.frames() @@ -243,6 +299,10 @@ impl MacroPalette for PokemonPalette { } fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { + let torn = { + let mut state = PokeState::new(memory); + self.tear(&mut state) + }; let (scene, bindings, standing, stepping, approach) = { let Self { machine, @@ -265,7 +325,10 @@ impl MacroPalette for PokemonPalette { // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // palette and the scene the feed reports cannot disagree about which frame they are // for. - let scene = state.scene(); + // A warp's tear is a warp in flight: the cartridge is driving and the seam's readings + // are the last map's under the new map's id, which is section 12.13's `Unknown` with + // nothing on screen -- an empty pad the fly waits out, for thirty-two frames (row 58). + let scene = if torn { Scene::Unknown } else { state.scene() }; // Whether a conversation has ended, and how, is a question about the frames *after* // the `TALK` gave the buttons back, so the machine is given every frame rather than // only the ones it owns (`docs/design/macros.md` section 12.4). @@ -279,7 +342,7 @@ impl MacroPalette for PokemonPalette { // master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script // -- the coordinates and the loaded map header are from different frames, and a tile // recorded from that pair is a tile of nowhere. - let standing = (!state.scripted()).then(|| state.player()).flatten(); + let standing = (!state.scripted() && !torn).then(|| state.player()).flatten(); // And the tile the step in flight is landing on (row 54). Read from the same frame and // behind the same "the fly is its own master" gate as the ground itself. let stepping = standing.and_then(|_| state.stepping_onto()); @@ -525,6 +588,57 @@ mod tests { } } + #[test] + fn a_warps_tear_deals_no_pad() { + // Row 58, measured at the Pewter Gym's door: `wCurMap` names the gym for thirty-two frames + // while the header, the coordinates and the warp table are still Pewter City's -- "map 54 + // at (16, 17)", which is the town's doormat. A pad dealt there started a walk over the + // wrong map, and what it was aiming at went into the ledgers under the gym's id. + let mut wram = Wram::overworld(); + wram.map(maps::PEWTER_CITY, 20, 18, 16, 18) + .warps(&[(16, 17, 0, maps::PEWTER_GYM), (29, 13, 0, maps::PEWTER_MUSEUM_1F)]); + let mut palette = PokemonPalette::new(7); + let settled = palette.observe(&mut wram, &NoLedger); + assert_eq!(settled.scene, SceneId::Overworld); + + // The step onto the door lands and the map byte changes; nothing else has loaded. + wram.map(maps::PEWTER_GYM, 20, 18, 16, 17); + let torn = palette.observe(&mut wram, &NoLedger); + assert_eq!(torn.scene, SceneId::Unknown, "a warp in flight"); + assert!(torn.bindings.is_empty(), "and nothing to press: {:?}", torn.bindings); + assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown); + + // The header loads: the gym's own size, its doormat, its own table. + wram.map(maps::PEWTER_GYM, 5, 7, 4, 13).warps(&[(4, 13, 2, 0xff), (5, 13, 2, 0xff)]); + assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld); + + // And out again: the doormat's `LAST_MAP` under the town's id is the same tear. + wram.map(maps::PEWTER_CITY, 5, 7, 4, 13); + assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown); + } + + #[test] + fn a_teleport_pad_is_not_a_tear() { + // Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose + // destination is the map the fly is on is an ordinary frame there, and an empty pad on it + // would be a fly that waits for ever: the map byte did not change, so it is not a tear. + let mut wram = Wram::overworld(); + wram.map(0xb2, 10, 9, 1, 1).warps(&[(1, 1, 3, 0xb2), (5, 5, 0, 0xb2)]); + let mut palette = PokemonPalette::new(7); + for _ in 0..3 { + assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld); + } + // And a tear that does not end is still bounded. + wram.map(0x02, 10, 9, 1, 1).warps(&[(1, 1, 0, 0x02)]); + let mut torn = 0; + for _ in 0..(TEAR_FRAMES + 10) { + if palette.observe(&mut wram, &NoLedger).scene == SceneId::Unknown { + torn += 1; + } + } + assert_eq!(torn, u32::from(TEAR_FRAMES), "at most {TEAR_FRAMES} frames"); + } + #[test] fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() { // Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step From ddf07433953b5e2478e5ade5686020ca158893bc Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:34:33 +0000 Subject: [PATCH 05/10] a trainer's challenge is not the cartridge refusing a step Section 12.4 and row 37 read a macro the cartridge ended by taking the joypad as a refusal and wrote the target blocked and the tile pushed on the spot. A trainer who sees the fly takes the joypad the same way. In the Pewter Gym the walk toward the leader crossed the Jr. Trainer's sight line, and BROCK went into the blocked ledger for ten brain minutes while the fly lost, blacked out and walked back to a room whose way out was the pad again. The entries now wait for the cartridge to give the joypad back: back in the overworld is a refusal, written as before; a battle is a battle and teaches the ledgers nothing. Tests for the rung's people off the screen, facing one of them, and the challenge; the two push-back tests now hand the joypad back before they read the ledgers. --- .../src/pokemon_red/macros/driver.rs | 4 +- .../src/pokemon_red/macros/executor.rs | 89 ++++++++- .../src/pokemon_red/macros/tests.rs | 178 +++++++++++++++++- 3 files changed, 250 insertions(+), 21 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index f6091ea..eddd01f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -273,7 +273,7 @@ impl PokemonPalette { } // A tile the cartridge drove the fly off: no window, because the map is like that until // the event that unlocks it, and nothing here knows which event that is (row 37). - if let Some((map, tile)) = self.machine.take_pushed() { + while let Some((map, tile)) = self.machine.take_pushed() { self.pushed.record(map, tile); } // A refusal from where the fly is standing: that button is not dealt again from this tile @@ -518,7 +518,7 @@ impl MacroPalette for PokemonPalette { let _ = self.machine.take_reached(); // A rollback is not the map pushing the fly anywhere, nor its frontier going out of // reach: the fly is about to be standing somewhere else. - let _ = self.machine.take_pushed(); + while self.machine.take_pushed().is_some() {} let _ = self.machine.take_exhausted(); let _ = self.machine.take_refused(); // The cached palette was dealt for a frame that is being thrown away. Dropping it makes diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs index 1c3667f..aaf306a 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs @@ -541,6 +541,16 @@ struct PendingTalk { at: Tile, } +/// What a macro the cartridge ended by taking the joypad earned, held until the cartridge gives +/// the joypad back ([`MacroMachine::pending_push`], row 58). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingPush { + /// The tile the fly was driven off, for the pushed ledger (row 37). + tile: Option<(u8, Tile)>, + /// The target the macro was aimed at, for the blocked ledger (section 12.4). + target: Option<(u8, TargetKey)>, +} + /// A walk the frame cap cut short, as the next start needs it. /// /// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the @@ -589,7 +599,7 @@ pub struct MacroMachine { /// and the blocked ledger could only say it about the target the walk was aimed at. Recorded /// from the frame the push is seen, because by the time the next macro starts the fly has been /// walked somewhere else. - pushed_tile: Option<(u8, Tile)>, + pushed_tile: Vec<(u8, Tile)>, /// A refusal the route search or the precondition made, and where the fly stood for it -- /// `(map, slot, tile)`, waiting to be taken into the session's ledger /// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`). @@ -637,6 +647,21 @@ pub struct MacroMachine { /// the next press will not undo. One hold of frames is the window, because that is how long /// the fly has to choose again; anything later and something else happened in between. pending_answer: Option, + /// A macro the cartridge ended by taking the joypad, whose ledger entries wait for the + /// cartridge to give it back (row 58). + /// + /// Section 12.4 and row 37 read "the cartridge took the joypad" as the cartridge *refusing* + /// the step -- the Viridian gate's "This is private property!" and the walk back -- and wrote + /// the target into the blocked ledger and the tile into the pushed one on the spot. A trainer + /// who sees the fly takes the joypad the same way: the "!", the walk up, the challenge. In the + /// Pewter Gym that cost BROCK: the fly walked toward him past the Jr. Trainer's line of sight, + /// the trainer's walk ended the macro, BROCK went into the blocked ledger for ten brain + /// minutes and the tile the walk set out from into the pushed one for the session -- and a fly + /// that lost the battle and walked back found the leader excluded and the way out on the pad. + /// What the cartridge does when it gives the joypad back is what says which it was: back in + /// the overworld is a refusal and is written as one; a battle is a battle, and nothing about + /// the target or the ground is learned from it. + pending_push: Vec, /// A finished `TALK`'s target, waiting to be taken into the session's talked ledger. /// /// The machine records rather than keeps: the ledger is the driver's @@ -664,12 +689,13 @@ impl MacroMachine { blocked: Vec::new(), reached: None, exhausted: None, - pushed_tile: None, + pushed_tile: Vec::new(), refused_at: None, timed_out: None, resume: VecDeque::new(), pending_talk: None, pending_answer: None, + pending_push: Vec::new(), talked: None, rng: if seed == 0 { 1 } else { seed }, } @@ -903,8 +929,11 @@ impl MacroMachine { } /// The tile a scripted push-back earned, taken rather than read (row 37). + /// + /// Call it until it answers `None`: the entries a script held back are written together when + /// it gives the joypad back (row 58). pub fn take_pushed(&mut self) -> Option<(u8, Tile)> { - self.pushed_tile.take() + if self.pushed_tile.is_empty() { None } else { Some(self.pushed_tile.remove(0)) } } /// Where the last `no route` or `precondition` refusal happened, taken rather than read @@ -936,7 +965,7 @@ impl MacroMachine { self.reached = None; // A rollback is not the map pushing the fly anywhere, and it is not the frontier being // out of reach either: the fly is about to be somewhere else entirely. - self.pushed_tile = None; + self.pushed_tile.clear(); self.exhausted = None; self.refused_at = None; self.timed_out = None; @@ -948,6 +977,8 @@ impl MacroMachine { self.pending_talk = None; // Nor is it a prompt reopening: the frames the answer was made in are being thrown away. self.pending_answer = None; + // Nor the cartridge refusing a step: the frames it happened in are being thrown away too. + self.pending_push.clear(); } /// Whether the fly is standing somewhere other than where the running macro began. @@ -976,6 +1007,7 @@ impl MacroMachine { /// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`]. pub fn observe_frame(&mut self, state: &mut dyn MacroState) { self.observe_answer(state); + self.observe_push(state); let Some(pending) = self.pending_talk else { return }; if state.scripted() { self.pending_talk = None; @@ -1002,6 +1034,34 @@ impl MacroMachine { } } + /// One frame after the cartridge took the joypad from a macro: decide what it was (row 58). + /// + /// Back in the overworld with the buttons the fly's again: a refusal, written exactly as + /// section 12.4 and row 37 always wrote it. A battle: a trainer's challenge, and it teaches the + /// ledgers nothing. Anything else -- the text, the walk, the frames between -- is still the + /// cartridge's, and the decision waits. + fn observe_push(&mut self, state: &mut dyn MacroState) { + if self.pending_push.is_empty() { + return; + } + match class(state.scene()) { + Class::Battle | Class::ForcedSwitch => self.pending_push.clear(), + Class::Overworld => { + // Every macro the script ended while it held the joypad -- the walk it interrupted + // and any press made into its text -- in the order they ended. + for pending in std::mem::take(&mut self.pending_push) { + if let Some(tile) = pending.tile { + self.pushed_tile.push(tile); + } + if let Some(target) = pending.target { + self.blocked.push(target); + } + } + } + _ => {} + } + } + /// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer /// rather than a conversation walked through (section 12.20). fn declined_out_of(&self, map: u8) -> bool { @@ -1127,14 +1187,18 @@ impl MacroMachine { // entrance -- with no window, in the middle of the town. A dozen of those fenced // the fly into a pocket no walk could leave. The tile a walk last stood the fly on // is its own record of where the cartridge took over. - if let Some(player) = at { + // + // Held until the cartridge gives the joypad back, which is what says whether this + // was a refusal or a trainer walking up (row 58, [`MacroMachine::observe_push`]). + let tile = at.map(|player| { let current = Tile::new(player.x, player.y); let tile = match active.plan.front() { Some(Step::Walk(walk)) => walk.expect.unwrap_or(current), _ => active.from.unwrap_or(current), }; - self.pushed_tile = Some((player.map, tile)); - } + (player.map, tile) + }); + self.pending_push.push(PendingPush { tile, target: None }); } let (closer, stalled) = walk_flags(&active); // A walk the cap cut short keeps its route for the next hold; any other ending means @@ -1181,8 +1245,15 @@ impl MacroMachine { // target's own fact, not the world's, so it is excluded for the window like // any other refusal. Without it the gate was walked into once per hold for // ever, because every macro that hit it ended `Done`. - if pushed && let Some(entry) = active.target { - self.blocked.push(entry); + // + // Held with the tile above, and for the same reason: a trainer's walk up to the + // fly takes the joypad exactly as the gate's walk back does, and only what the + // cartridge does next tells them apart (row 58). + if pushed + && let Some(entry) = active.target + && let Some(pending) = self.pending_push.last_mut() + { + pending.target = Some(entry); } // A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`, // because facing it is what the arrival promises, and excluded, because the diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index 5aee4e0..097ba80 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -18,7 +18,7 @@ use crate::adapter::PlaceKind; use crate::emulator::buttons; use super::cartridge::{ - BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, MacroState, Objective, + BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, LAST_MAP, MacroState, Objective, TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price, }; use super::geography::Amenity; @@ -89,6 +89,8 @@ struct World { warps: Vec, connections: Connections, npcs: Vec, + /// Sprites the cartridge is not drawing only because they are off the screen (row 58). + offscreen: Vec, signs: Vec, list: List, @@ -223,6 +225,7 @@ impl World { warps: Vec::new(), connections: Connections::default(), npcs: Vec::new(), + offscreen: Vec::new(), signs: Vec::new(), list: List::None, cursor: 0, @@ -636,6 +639,10 @@ impl GameState for World { self.npcs.clone() } + fn offscreen_npcs(&mut self) -> Vec { + self.offscreen.clone() + } + fn signs(&mut self) -> Vec { self.signs.clone() } @@ -811,6 +818,13 @@ fn drive( // The loop's own bookkeeping, so a test sees what the next decision would see: whatever the // finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job // in the sim loop and this line's here. + settle(machine, world); + Ok(machine.outcome().expect("a finished macro has an outcome").1) +} + +/// `PokemonPalette::record_talk`, over the fixture: whatever the machine has earned goes into the +/// session's ledgers. +fn settle(machine: &mut MacroMachine, world: &mut World) { while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } @@ -823,11 +837,25 @@ fn drive( if let Some((map, target)) = machine.take_reached() { world.targets.record_reached(map, target); } + while let Some((map, tile)) = machine.take_pushed() { + assert_eq!(map, world.map); + world.pushes.insert(tile); + } if let Some((map, target)) = machine.take_talked() { assert_eq!(map, world.map); world.talked.insert(target); } - Ok(machine.outcome().expect("a finished macro has an outcome").1) +} + +/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever +/// it decided taken into the ledgers (row 58). +fn hand_back(machine: &mut MacroMachine, world: &mut World) { + world.scene = Scene::Overworld; + world.scripted = false; + world.scripted_at = None; + world.switch = None; + machine.observe_frame(world); + settle(machine, world); } /// A palette of exactly one button, for a script whose macro no scene binds any more. @@ -3510,7 +3538,11 @@ fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() { let north = TargetKey::Exit(ExitId::Edge(Edge::North)); assert!(on_the_pad(&mut world, MacroKind::GoRoute)); - assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); + let mut machine = MacroMachine::new(0x1234_5678); + assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); + // The gate's text is still up: the cartridge has not given the joypad back (row 58). + assert!(!world.targets.blocked(world.map, north), "nothing decided inside the script"); + hand_back(&mut machine, &mut world); assert!( world.targets.blocked(world.map, north), "the road the cartridge refused is excluded for the window" @@ -5023,10 +5055,13 @@ fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() { let mut machine = MacroMachine::new(1); let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute); - let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back"); - assert_eq!(map, maps::PEWTER_CITY); - assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from"); - assert_eq!(tile, world.player, "the tile the walk had reached when the script took over"); + // Row 58: written when the cartridge gives the joypad back in the overworld. + let reached = world.player; + hand_back(&mut machine, &mut world); + let pushed: Vec = world.pushes.iter().copied().collect(); + assert_eq!(pushed.len(), 1, "the script moved the fly: a push-back"); + assert_ne!(pushed[0], Tile::new(3, 6), "not the tile the walk set out from"); + assert_eq!(pushed[0], reached, "the tile the walk had reached when the script took over"); } /// The push-back writes the ledger, and it writes the *tile* rather than the target. @@ -5042,10 +5077,13 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { let mut machine = MacroMachine::new(1); let _ = run_with(&mut machine, &mut world, MacroKind::Talk); - let pushed = machine.take_pushed(); + assert!(world.pushes.is_empty(), "nothing is decided while the cartridge holds the joypad"); + // Row 58: the ledger is written when the cartridge gives the joypad back in the overworld, + // which is what tells the gate's walk back from a trainer's walk up. + hand_back(&mut machine, &mut world); assert_eq!( - pushed, - Some((world.map, Tile::new(3, 3))), + world.pushes.iter().copied().collect::>(), + vec![Tile::new(3, 3)], "the tile the macro was standing on, not the person it was facing" ); } @@ -5198,3 +5236,123 @@ fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() { assert_eq!(center.player, Tile::new(3, 3)); assert!(!precondition(MacroKind::Talk, &mut center)); } + +// --------------------------------------------------------------------------------------------- +// Row 58: the gym door, in and out +// --------------------------------------------------------------------------------------------- + +/// The Pewter Gym as the fly finds it on its doormat: the guide on screen and talked to, the +/// leader and the Jr. Trainer up the room and not drawn. +fn pewter_gym_doormat() -> World { + let mut world = World::room().at(4, 13); + world.map = maps::PEWTER_GYM; + world.size = MapSize { width: 10, height: 14 }; + world.facing = Facing::Up; + world.warps = vec![ + Warp { x: 4, y: 13, destination_warp: 3, destination_map: LAST_MAP }, + Warp { x: 5, y: 13, destination_warp: 3, destination_map: LAST_MAP }, + ]; + world.npcs = vec![Npc { slot: 3, picture: 1, x: 7, y: 10, facing: Facing::Down }]; + world.offscreen = vec![ + Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }, + Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }, + ]; + world.talked.insert(TalkTarget::Sprite(3)); + // Pewter's errands are paid, as they were live: the objective is the rung's own place. + world.areas.insert((Amenity::Mart, maps::PEWTER_CITY)); + world.areas.insert((Amenity::Center, maps::PEWTER_CITY)); + world.objective = Some(Objective { + map: world.map, + tile: None, + warp: None, + edge: None, + target: Some(PlaceKind::Person), + }); + world +} + +#[test] +fn the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them() { + // Row 58, live for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight + // back out, ~200 macro starts per ten brain minutes and no reward at all. From the doormat the + // cartridge draws only the guide, who had been talked to, so the rung's own list was empty: + // `GO OBJECTIVE` had nothing to aim at and `GO OUT` -- whose candidates 12.5 withholds only + // while the rung's person is in the room -- was the pad. BROCK was twelve rows up. + let mut world = pewter_gym_doormat(); + let targets = super::palette::objective_targets(&mut world); + assert!( + targets.contains(&(Tile::new(4, 1), TalkTarget::Sprite(1))), + "the leader is one of the rung's people: {targets:?}" + ); + assert!(on_the_pad(&mut world, MacroKind::GoObjective), "there is someone to walk to"); + assert!(!on_the_pad(&mut world, MacroKind::GoOut), "and the room is not left while he is in it"); + + // What the base saw, for the record: the drawn sprites alone leave nothing. + world.offscreen.clear(); + assert!(super::palette::objective_targets(&mut world).is_empty()); + assert!(!on_the_pad(&mut world, MacroKind::GoObjective)); + assert!(on_the_pad(&mut world, MacroKind::GoOut), "the undo pair's inside half"); +} + +#[test] +fn only_the_rung_reads_people_off_the_screen() { + // A sprite outside the window may be a toggleable object the cartridge has switched off, and + // the two read alike from here (`state::offscreen_npcs`). The rung's list is the one reader: + // `GO NPC`, `TALK` and the objects are what they were. + let mut world = pewter_gym_doormat(); + world.objective = None; + assert!(super::palette::untalked_people(&mut world).is_empty(), "`GO NPC` sees what is drawn"); + world.objective = Some(Objective { + map: world.map, + tile: None, + warp: None, + edge: None, + target: Some(PlaceKind::Object), + }); + assert!( + super::palette::objective_targets(&mut world).is_empty(), + "an item ball out of sight and one picked up read alike, so objects are not guessed at" + ); +} + +#[test] +fn facing_one_of_the_rungs_people_is_the_arrival() { + // A gym names three people and 12.5's "leave out the one ahead" was written for one: in front + // of the leader, `GO OBJECTIVE` still had the Jr. Trainer to walk to, and at the trainer it had + // the leader. The walk is done when any of them is ahead, and `TALK` is the press. + let mut world = pewter_gym_doormat().at(4, 2); + world.facing = Facing::Up; + world.npcs = vec![Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }]; + world.offscreen = vec![Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }]; + assert!(on_the_pad(&mut world, MacroKind::Talk)); + assert!(!on_the_pad(&mut world, MacroKind::GoObjective), "no walk left while facing him"); + + world.facing = Facing::Left; + assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back"); +} + +#[test] +fn a_trainer_walking_up_teaches_the_ledgers_nothing() { + // The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight; + // the trainer's "!" and walk up took the joypad, which 12.4 reads as the cartridge refusing + // the step, so BROCK went into the blocked ledger for ten brain minutes and the tile into the + // pushed one for the session. What the cartridge does when it gives the joypad back is what + // tells a refusal from a challenge. + let mut world = World::room().at(3, 3); + world.map = 0x00; + world.connections = Connections { north: true, south: false, east: false, west: false }; + world.switch = Some((4, Scene::Dialog)); + world.scripted_at = Some(4); + let north = TargetKey::Exit(ExitId::Edge(Edge::North)); + let mut machine = MacroMachine::new(0x1234_5678); + assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); + + // The challenge closes into a battle. + world.scene = Scene::Battle { own_turn: false, forced_switch: false }; + machine.observe_frame(&mut world); + settle(&mut machine, &mut world); + // And the battle ends back in the overworld: nothing was refused. + hand_back(&mut machine, &mut world); + assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing"); + assert!(world.pushes.is_empty(), "and the ground is as walkable as it was"); +} From 28bd980e653560194e88c2c4148a08bbfddbb168 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:34:33 +0000 Subject: [PATCH 06/10] survey: the route probe prints the room whole, its people, and the rung at the end The catch dump prints a small map whole with its people drawn and off the screen, each person's ledger entries and whether a route reaches them; outcomes are keyed by the map they finished on, the trace line carries the seam's bytes (wCurOpponent included), and the drive ends with the rank. How row 58's mechanism was read. --- .../crates/flysim/examples/scene_probe.rs | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 717001a..28a3d33 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -1355,9 +1355,9 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64 /// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { use flybrain_gb::MacroPalette; - use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey}; + use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey}; use flybrain_gb::pokemon_red::macros::path::Way; - use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path}; + use flybrain_gb::pokemon_red::macros::{PokemonPalette, Tile, palette, path}; let budget = env_usize("FLY_PROBE_FRAMES", 240_000); let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1; @@ -1484,7 +1484,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) } } if let Some((name, outcome)) = macros.take_finished() { - *outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1; + *outcomes + .entry(format!( + "{name} {outcome:?} on {:?}", + state::player(gb).map(|p| p.map) + )) + .or_default() += 1; if !matches!(outcome, flybrain_gb::Outcome::Done) { println!( "f{frame:<6} {:?} {name} {outcome:?}", @@ -1495,11 +1500,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) since_decision += 1; if frame < trace_frames { println!( - " t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {}", + " t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {} | {}", state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)), macros.running(), macros.fences().1, - macros.stood() + macros.stood(), + scene::why_unknown(gb) ); } gb.set_buttons(mask); @@ -1531,6 +1537,11 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) } } println!("```\n"); + let progress = adapter.progress(); + println!( + "- at the end: rank {} ({}), badges {}, unique tiles {}", + progress.rank, progress.rank_label, progress.counter, progress.unique_locations + ); println!("- refusals: {refusals:?}"); println!("- outcomes: {outcomes:?}"); let Some(frame) = caught_at else { @@ -1557,11 +1568,13 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) println!("- objective: {:?}", state.objective()); println!("- `objective_goals`: {:?}", palette::objective_goals(state)); println!("- `objective_targets`: {:?}", palette::objective_targets(state)); - for (tile, target) in path::person_targets(state) { + let drawn = path::person_targets(state); + for (tile, target) in drawn.iter().copied().chain(path::offscreen_person_targets(state)) { println!( - " - person {target:?} at ({:2},{:2}): talked {}, blocked {}, reached {}", + " - person {target:?} at ({:2},{:2}) {}: talked {}, blocked {}, reached {}", tile.x, tile.y, + if drawn.contains(&(tile, target)) { "drawn" } else { "off the screen" }, state.talked(target), state.blocked(TargetKey::Thing(target)), state.reached(TargetKey::Thing(target)) @@ -1591,16 +1604,44 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) reach ); } - println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```"); - for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) { - let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6)) + // A room small enough to print whole is printed whole, with its people on it (row 58: + // the gym's leader is twelve rows from the door). + let size = state.map_size().expect("a loaded map"); + let whole = size.width <= 24 && size.height <= 24; + let people: Vec<(Tile, TalkTarget)> = path::person_targets(state) + .into_iter() + .chain(path::offscreen_person_targets(state)) + .collect(); + let (rows, columns) = if whole { + (0..=size.height - 1, 0..=size.width - 1) + } else { + ( + player.y.saturating_sub(3)..=player.y.saturating_add(3), + player.x.saturating_sub(6)..=player.x.saturating_add(6), + ) + }; + let grid = state.map_grid(); + println!( + "\n### The fly's {} (pushed = `P`, player = `@`, a person = `N`; grid {})\n\n```", + if whole { "whole map" } else { "own neighbourhood" }, + grid.is_some() + ); + for y in rows { + let row: String = columns + .clone() .map(|x| { + let walk = match grid.as_deref() { + Some(grid) => grid.walkable(x, y), + None => state.walkable(x, y), + }; if x == player.x && y == player.y { '@' + } else if people.iter().any(|(tile, _)| *tile == Tile::new(x, y)) { + 'N' } else if state.pushed_tile(x, y) { 'P' } else { - match state.walkable(x, y) { + match walk { flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.', flybrain_gb::pokemon_red::macros::state::Walkable::No => '#', flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?', @@ -1608,9 +1649,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) } }) .collect(); - println!("y{y:2} x{:2}.. {row}", player.x.saturating_sub(6)); + println!("y{y:2} x{:2}.. {row}", if whole { 0 } else { player.x.saturating_sub(6) }); } println!("```"); + for (tile, target) in &people { + let aims: Vec = FACINGS.iter().filter_map(|facing| tile.step(*facing)).collect(); + let reach = path::route(state, &aims).map(|route| route.goal); + println!("- a route to {target:?} at ({:2},{:2}): {reach:?}", tile.x, tile.y); + } }); } From 2b1a6c0dea45c003ff2df125803f470cde207f5b Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:39:42 +0000 Subject: [PATCH 07/10] watchdog: check 10 reads the reward events, and flags a window busy going nowhere Row 58's pad was GO OBJECTIVE into the Pewter Gym and GO OUT straight back out for 25 minutes, diluted by eight other names, every macro done: ten distinct names, so the four-name sequence rule could not fire, no macro near 95%, nothing refused or blocked, and the exploration count flat. What the window did not have was a reward event. The stream carries the reward events beside the macros, and one more rule reads them behind the same no-new-ground gate: WD_LOOP_BUSY_MIN (100) decisions and no reward in the window, on two probes running, is 'unrewarded'. fly_loop_rewards is exported and loop.json carries window.rewards. Run against the live row-58 log it flags (211 decisions, 0 rewards, 10 names) where the rules before it did not. Still never acts: the fixture's two new cases restart nothing. --- infra/bin/fly-watchdog | 63 ++++++++++++++++++++++++++++++++++-------- infra/docs/runbook.md | 5 +++- infra/tests/lint.sh | 43 +++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/infra/bin/fly-watchdog b/infra/bin/fly-watchdog index 038ed88..12ec3e8 100755 --- a/infra/bin/fly-watchdog +++ b/infra/bin/fly-watchdog @@ -68,6 +68,7 @@ log_info() { : "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window : "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10) : "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57) +: "${WD_LOOP_BUSY_MIN:=100}" # this many decisions with no reward and no new ground, two probes running, is busy going nowhere (row 58) : "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}" mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR" @@ -212,6 +213,11 @@ write_textfile_metrics() { echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)." echo "# TYPE fly_loop_done gauge" echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)" + # Row 58: an undo pair diluted by other macros completes everything and + # earns nothing; the reward events in the window are what say so. + echo "# HELP fly_loop_rewards Reward events in the last check-10 window (-1 before the first probe)." + echo "# TYPE fly_loop_rewards gauge" + echo "fly_loop_rewards $(cat "${WD_RUN_DIR}/loop.rewards" 2>/dev/null || echo -1)" echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against." echo "# TYPE fly_places_delta gauge" echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)" @@ -766,6 +772,16 @@ check_capture_freeze() { # this probe AND the previous one (two probes, so a single # unlucky window never flags). # +# Row 58: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight back out, for +# 25 minutes, with GO ITEM / GO FRONTIER / YES / NO mixed in — ten distinct +# names, every macro `done`, so neither the sequence rule (four names at +# most) nor zero-progress could fire, and the exploration count sat still. +# What the window did not have was a single reward event. So the stream also +# carries the `reward` events, and one more rule reads them, behind the same +# gate: +# unrewarded — WD_LOOP_BUSY_MIN+ decisions and no reward event in the +# window, on this probe AND the previous one. +# # The tile rule is what separates a loop from a legitimately repeating # explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer # than the frame cap, not a trap), and the watchdog has no tile counter. The @@ -816,7 +832,7 @@ loop_status_fields() { loop_macro_stream() { [ -f "$FLY_EVENT_LOG" ] || return 0 tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \ - | jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \ + | jq -r 'fromjson? // empty | select(.kind == "macro" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \ || true } @@ -824,23 +840,28 @@ loop_macro_stream() { # # total US distinct US topCount US period US repeats US windowFrom US # windowTo US topName US block US tail US decisions US refused US blocked US -# timeout US done +# timeout US done US rewards # # `total` counts start events inside the window, `decisions` starts plus # refusals (the sequence, `distinct` and `topCount` are over decisions), and -# the last four count each outcome in the window. `period`/`repeats` describe +# the next four count each outcome in the window, and `rewards` the reward +# events in it (row 58). `period`/`repeats` describe # the shortest repeating block at the END of the sequence (0/0 when nothing # repeats), `block` is that block comma-joined, and `tail` is the last 12 # names for context. The window ends at the newest macro event's own brain # clock, not at `now`: brain time is the only clock the event log carries. loop_analyze() { awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" ' - { ms[NR] = $1 + 0; lbl[NR] = $2; n = NR } + # Reward lines are counted and nothing else: the window still ends at the + # newest macro event, as it always has. + $3 == "reward" { rms[++nr] = $1 + 0; next } + { ms[++n] = $1 + 0; lbl[n] = $2 } END { - if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\n"; exit } + if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\0370\n"; exit } to = ms[n]; from = to - win k = 0; distinct = 0; topn = 0; top = "" - starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0 + starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0; rewards = 0 + for (q = 1; q <= nr; q++) if (rms[q] >= from) rewards++ for (i = 1; i <= n; i++) { if (ms[i] < from) continue if (lbl[i] ~ / blocked$/) { blocked++; continue } @@ -873,9 +894,9 @@ loop_analyze() { tailstr = "" start = k - 11; if (start < 1) start = 1 for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j] - printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\n", \ + printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\037%d\n", \ starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \ - k, refused, blocked, timeout, done + k, refused, blocked, timeout, done, rewards }' } @@ -933,20 +954,21 @@ check_loop() { echo "$delta" > "${WD_RUN_DIR}/loop.places_delta" local analysis total distinct topn period repeats win_from win_to top block tailstr - local decisions refused blocked timeouts completed + local decisions refused blocked timeouts completed rewards analysis="$(loop_macro_stream | loop_analyze)" IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \ - decisions refused blocked timeouts completed <<< "$analysis" + decisions refused blocked timeouts completed rewards <<< "$analysis" total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}" period="${period:-0}"; repeats="${repeats:-0}" decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}" - timeouts="${timeouts:-0}"; completed="${completed:-0}" + timeouts="${timeouts:-0}"; completed="${completed:-0}"; rewards="${rewards:-0}" echo "$distinct" > "${WD_RUN_DIR}/loop.distinct" echo "$period" > "${WD_RUN_DIR}/loop.period" echo "$repeats" > "${WD_RUN_DIR}/loop.repeats" echo "$refused" > "${WD_RUN_DIR}/loop.refused" echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked" echo "$completed" > "${WD_RUN_DIR}/loop.done" + echo "$rewards" > "${WD_RUN_DIR}/loop.rewards" local prev_suspected=0 [ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" @@ -967,6 +989,14 @@ check_loop() { [ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1 echo "$idle" > "$idle_file" + # Busy going nowhere (row 58), judged over two probes like zero progress: + # many decisions, not one reward event, no new ground. + local busy_file="${WD_RUN_DIR}/loop.prev_unrewarded" prev_busy=0 busy=0 + [ -f "$busy_file" ] && prev_busy="$(cat "$busy_file" 2>/dev/null || echo 0)" + case "${prev_busy:-}" in ''|*[!0-9]*) prev_busy=0 ;; esac + [ "$decisions" -ge "$WD_LOOP_BUSY_MIN" ] && [ "$rewards" -eq 0 ] && [ "$grown" -eq 0 ] && busy=1 + echo "$busy" > "$busy_file" + local failed=$(( refused + blocked + timeouts )) local suspected=0 reason="" if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then @@ -991,6 +1021,11 @@ check_loop() { # which is silence (above), not a loop. suspected=1 reason="dominant" + elif [ "$busy" -eq 1 ] && [ "$prev_busy" -eq 1 ]; then + # Row 58: an undo pair diluted by other macros. Every one of them + # completes, none of them earns anything, and the ground stays put. + suspected=1 + reason="unrewarded" fi fi echo "$suspected" > "${WD_RUN_DIR}/loop.suspected" @@ -1022,6 +1057,7 @@ check_loop() { --argjson blocked "$blocked" \ --argjson timeouts "$timeouts" \ --argjson completed "$completed" \ + --argjson rewards "$rewards" \ --argjson windowFrom "${win_from:-0}" \ --argjson windowTo "${win_to:-0}" \ --argjson windowMs "$WD_LOOP_WINDOW_MS" \ @@ -1050,6 +1086,7 @@ check_loop() { macroStarts: $total, decisions: $decisions, outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused }, + rewards: $rewards, tail: (if $tail == "" then [] else ($tail | split(", ")) end) }, dominant: { name: (if $dominant == "" then null else $dominant end), @@ -1073,7 +1110,9 @@ check_loop() { local mins=$(( WD_LOOP_WINDOW_MS / 60000 )) if [ "$suspected" -eq 1 ]; then - if [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then + if [ "$reason" = "unrewarded" ]; then + log_err "loop suspected (unrewarded): ${decisions} decisions and no reward event in the last ${mins} brain minutes, two probes running — ${completed} done, top [${top}] x${topn}, ${distinct} distinct name(s), tail [${tailstr}], exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — macros that complete and earn nothing are an undo pair or a ring. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." + elif [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." elif [ "$reason" = "sequence" ]; then log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." diff --git a/infra/docs/runbook.md b/infra/docs/runbook.md index d4081ef..a1a1cc6 100644 --- a/infra/docs/runbook.md +++ b/infra/docs/runbook.md @@ -602,11 +602,14 @@ pct exec -- cat /run/fly/wd/loop.json | jq . | `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run | | `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window | | `fly_loop_done` | macros that ended `done` in the window | +| `fly_loop_rewards` | reward events in the window | The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+ times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused, blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row -(`zero-progress`) — **and** no growth in the exploration count. A decision is a `start` or a +(`zero-progress`), or 100+ decisions with no reward event among them on two probes in a row +(`unrewarded`, row 58: `GO OBJECTIVE` in and `GO OUT` out of one door, diluted by eight other +names, every macro `done`) — **and** no growth in the exploration count. A decision is a `start` or a `refused`: a refused press starts nothing, which is why counting starts alone read row 57's pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and one name. A diff --git a/infra/tests/lint.sh b/infra/tests/lint.sh index 0a45f9a..6f4b82a 100755 --- a/infra/tests/lint.sh +++ b/infra/tests/lint.sh @@ -1065,10 +1065,51 @@ LPCAT fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")" fi + # (7) Row 58: the gym door, in and out. GO OBJECTIVE / GO OUT diluted by + # eight other names, every macro `done`, no reward event, no new ground -- + # neither the four-name sequence rule, dominance nor zero-progress fires. + # Two probes of it flag; the same window with one reward in it does not. + lp_reset + lp_cycle 20 "GO OBJECTIVE" "GO OUT" "GO OUT" "GO ITEM" "GO OBJECTIVE" "GO OUT" \ + "GO FRONTIER" "YES" "NO" "GO ROUTE" "NEXT" "TALK" "GO SHOP" \ + | lp_outcomes "$lp_fixture/events.jsonl" "done" + lp_status "$lp_fixture/status.json" 1892 + lp_pass + lp_pass + lp_first="$(lp_metric fly_loop_suspected)" + lp_pass + if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \ + && [ "$(lp_metric fly_loop_rewards)" = "0" ] \ + && [ "$(lp_metric fly_loop_distinct_macros)" = "10" ] \ + && grep -q 'loop suspected (unrewarded): 260 decisions and no reward event' "$lp_fixture/journal.log"; then + pass "check 10: an undo pair diluted by eight other names, all done, no reward over two probes flags as unrewarded" + else + fail "check 10: the row-58 log gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards) distinct=$(lp_metric fly_loop_distinct_macros), journal: $(cat "$lp_fixture/journal.log")" + fi + if lp_report="$(jq -e -r '[.reason, (.window.decisions|tostring), (.window.rewards|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \ + && [ "$lp_report" = "unrewarded 260 0 none" ]; then + pass "check 10: loop.json carries the reward events in the window" + else + fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'unrewarded 260 0 none'" + fi + lp_reset + { cat "$lp_fixture/events.jsonl" + printf '{"id":999998,"wallMs":1758000999998,"brainMs":150000,"kind":"reward","label":"WILD KO 54:1:1","value":0.1,"rewardKind":"wildwin"}\n'; } \ + > "$lp_fixture/events-rewarded.jsonl" + mv -f "$lp_fixture/events-rewarded.jsonl" "$lp_fixture/events.jsonl" + lp_pass + lp_pass + lp_pass + if [ "$(lp_metric fly_loop_suspected)" = "0" ] && [ "$(lp_metric fly_loop_rewards)" = "1" ]; then + pass "check 10: the same busy window with one reward in it does not flag" + else + fail "check 10: a rewarded busy window gave suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards)" + fi + # The ethos, asserted rather than reviewed: over every case above, check 10 # restarted nothing. It reports; a human or a review agent decides. if [ ! -s "$lp_fixture/systemctl.log" ]; then - pass "check 10: never acts — no unit was restarted across any of the six cases" + pass "check 10: never acts — no unit was restarted across any of the eight cases" else fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")" fi From 04657d33242f13dbbec8f66d133404e5847763ad Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:42:57 +0000 Subject: [PATCH 08/10] docs: macros.md 12.22, macros-wram.md section 12 --- docs/design/macros-wram.md | 38 ++++++++++++++++++++++++ docs/design/macros.md | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 2b97299..70bd517 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -893,3 +893,41 @@ point of reading the figure; a border drawn somewhere the cursor is not parked i box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this accessor's. + +## 12. The people off the screen, and a battle decided (2026-09-23, `docs/design/macros.md` 12.22) + +Two readings row 58 added, both out of bytes the seam already had or a byte bracketed by two it had. + +### `state::offscreen_npcs` + +`CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into +`SPRITESTATEDATA1_IMAGEINDEX` (offset 2) for a sprite that is a toggleable object switched off, that +is outside its window, or that stands on a text box's tiles. The window, for a sprite whose +`SPRITESTATEDATA2_MOVEMENTBYTE1` (offset 6) is `WALK` (`$fe`) or `STAY` (`$ff`), compares the +sprite's biased `MAPY` / `MAPX` (offsets 4 and 5) with `wYCoord` / `wXCoord`: drawn when equal, or +when the sprite's is greater by at most `SCREEN_HEIGHT / 2 - 1` (8) rows and +`SCREEN_WIDTH / 2 - 1` (9) columns. A scripted mover (movement byte below `WALK`) skips the test. + +So a `$ff` sprite of the loaded map whose coordinates fall **outside** the window, and whose +movement byte is `WALK` or above, is reported with those coordinates: the cartridge would hide it +for being off the screen whatever else were true, and a sprite it is not updating does not move. +Everything else `$ff` is not reported. Measured from the row-58 checkpoint at the Pewter Gym's +doormat: BROCK at (4, 1) and the Jr. Trainer at (3, 6) reported, the guide at (7, 10) drawn. + +What it cannot tell is a toggleable object switched off from one out of sight, because both are +`$ff` outside the window. Its one reader is the rung's own list of people; the ladder's person +places are Oak's lab and the gyms, and only the lab and Viridian Gym carry toggleable people +(`data/maps/toggleable_objects.asm`). + +### `poke::CUR_OPPONENT`, `wCurOpponent` + +Written when a battle is decided (`home/trainers.asm` for a trainer, the encounter check for a wild +one), cleared by `EndOfBattle` in the same block that clears `wIsInBattle`. Not in the generated +table: `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`, +`wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`, +`wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db`, and the +table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and `wTrainerNo` (`$d05d`) are exactly +where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserted against both +neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the +overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the +219-frame transition and the battle. `controllable` reads it as zero. diff --git a/docs/design/macros.md b/docs/design/macros.md index 8cabcee..00fc5a8 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1445,6 +1445,66 @@ Nothing presses for the fly and nothing is ranked: one button leaves a pad it co one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter version, the roles and the compatibility string are untouched. +### 12.22 The rung's people are in the room when the screen does not show them (2026-09-23, row 58) + +Live on v0.5.3, rank 10, for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` +straight back out, with `GO ITEM`, `GO FRONTIER`, `YES` and `NO` mixed in. Per ten brain minutes +about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts in all, every one `done`, **no reward event of any +kind**, the exploration count frozen at 1,892. Check 10 saw ten distinct names and said nothing. +Surveyed from the live checkpoint with the route probe (`FLY_PROBE_CATCH=route`, +`FLY_PROBE_CATCH_MAP=54`), which reads the room on the fly's Nth arrival. + +- **The objective saw the room through the screen.** `objective_targets` read `npcs`, which is + what the cartridge *draws*, and `CheckSpriteAvailability` writes `$ff` into the image index of + every sprite outside a window of the player's coordinate. From the doormat at (4, 13) that window + holds the guide at (7, 10) and nobody else: BROCK at (4, 1) and the Jr. Trainer at (3, 6) are not + drawn. With the guide talked to (12.20), the rung's list was empty, so `GO OBJECTIVE` had nothing + to aim at inside and 12.5's rule -- the ways out are withheld while the rung's person is in the + room -- let `GO OUT` onto the pad. Outside, `GO OBJECTIVE` aimed at the gym's door. The pair + undoes itself in about a second, and nothing on either side of the door earns anything. +- **So the rung reads the people the cartridge hides only for being off the screen.** The window + is a function of `wYCoord`, `wXCoord` and the sprite's own biased coordinates, all already read, + so a sprite whose `$ff` falls outside it is one the cartridge would hide for that reason whatever + else were true, and a sprite the cartridge is not updating does not move + (`state::offscreen_npcs`). A `$ff` *inside* the window, or on a scripted mover, is not the + screen's and is not reported. Only the rung reads the list: a sprite outside the window may also + be a toggleable object switched off, which reads the same, so `GO NPC`, `TALK` and objects keep + what is drawn. +- **Facing any of the rung's people is the arrival.** 12.5 left out only the one ahead, which was + enough for one target; a gym names three, and in front of BROCK `GO OBJECTIVE` still had the + trainer to walk to. A fly facing a person the rung is waiting on has nothing left for a walk to + do, and `TALK` is the press. + +Three frames the seam read as the fly's own were the cartridge's, and each wrote a ledger entry that +emptied the room again once the first fix let the fly into it: + +- **A warp's tear.** `wCurMap` changes thirty-two frames before the header, the coordinates and the + warp table follow it, while the screen fades, and no joypad bit is set until the fade is over. + The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld + and dealt it a pad; a walk started there planned over the wrong map, and what it aimed at went + into the blocked ledger under the gym's id (live: `GO OUT` started and finished in 0.05 s). The + driver now reads a tear as the map byte having changed while the fly still stands on a warp of + the loaded table that leads to the map the byte names, deals it as `Unknown` with an empty pad, + and records no ground from it. Teleport pads -- Saffron Gym, two Silph Co. floors -- do not change + the map byte, so they are never a tear; a tear is bounded at ninety frames all the same. +- **A battle's transition.** Between a trainer's challenge closing and the battle screen there are + 219 frames with every joypad and script bit clear. The pad was dealt, a walk toward BROCK pressed + into the animation and gave up after three refused steps -- BROCK blocked for ten brain minutes -- + and the Jr. Trainer's conversation read as over, so the trainer the fly then lost to was + "talked to" for the session. `wCurOpponent` is set when a battle is decided and cleared by + `EndOfBattle` with `wIsInBattle`; it is not in the generated table and is derived as the byte + between two that are, both neighbours checked in a test (`macros-wram.md` section 12), and + `controllable` reads it. +- **A trainer walking up.** 12.4 reads a macro the cartridge ended by taking the joypad as a + refusal and wrote the target blocked and the tile pushed at once. A trainer who sees the fly + takes the joypad the same way. The entries now wait until the cartridge gives the joypad back: + in the overworld it was a refusal and is written as before; a battle teaches the ledgers nothing. + +Nothing is ranked, nothing presses for the fly, and no button is added to any pad: `GO OBJECTIVE` +has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own rule, and three +frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter +version, the roles and the compatibility string are untouched. + ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## priority to visit the shop at least once per area; make shop macros item purchases. same ## for the Pokécenter. heal should be a macro.") From ae3b15ab70b0bd4b05ef386e6259e626ea8de91b Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:53:31 +0000 Subject: [PATCH 09/10] tests: the ROM proof from the gym-door checkpoint From the row-58 checkpoint, 30 brain minutes on the stub rotation: at most three gym arrivals end in the fly walking straight back out inside ten seconds, and the fly goes up the room to row 6 or above, where the Jr. Trainer stands. Base: one arrival, back out in 309 frames, highest row 11 -- fails. Branch: 4 arrivals, 1 back out, 30,879 frames in the gym, highest row 2, beside the leader. Rung 11 is printed, not asserted. --- .../crates/flysim/tests/rom_macros_mode.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 2fb329e..b128344 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -3182,3 +3182,111 @@ fn the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint() { // ten brain minutes in. assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse"); } + +/// The row-58 checkpoint (Pewter City, outside the gym, taken during the loop), or `None` to skip. +fn door_checkpoint() -> Option { + std::env::var_os("FLY_DOOR_CHECKPOINT").map(|path| { + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope") + }) +} + +/// From Pewter City, the checkpoint taken while the fly was walking in and out of the gym's door. +/// +/// **What was live** (2026-09-23, rank 10 PEWTER CITY, v0.5.3): for twenty-five minutes +/// `GO OBJECTIVE` into the Pewter Gym and `GO OUT` straight back out, with `GO ITEM`, +/// `GO FRONTIER`, `YES` and `NO` mixed in -- about 93 `GO OUT` and 47 `GO OBJECTIVE` per ten brain +/// minutes, every one `done`, and not one reward event. The watchdog saw ten distinct names. +/// +/// **What the survey found** (`infra/docs/macros-traps.md` row 58): from the gym's doormat the +/// cartridge draws only the guide, already talked to, and hides BROCK and the Jr. Trainer for being +/// off the screen -- so the rung's list of people was empty, `GO OBJECTIVE` had nothing to aim at +/// inside and `GO OUT` was the pad; outside, `GO OBJECTIVE` aimed at the door. And three frames +/// the seam read as the fly's own were the cartridge's: a warp's tear, a battle's transition, and +/// a trainer walking up -- each of which wrote an entry that kept the room empty. +/// +/// The claims, none of them about which button the fly presses: +/// +/// - **the gym is not a door in and a door out**: at most three arrivals end in the fly walking +/// straight back out inside ten seconds, against one every few seconds on the base; +/// - **the fly goes up the room**: it stands at row 6 or above on map 54, where the Jr. Trainer +/// is, which it never does on the base. +/// +/// Rung 11 is printed and not asserted: which button the fly presses at the leader is the fly's. +/// +/// ```sh +/// FLY_ROM=/path/to/pokemon-red.gb \ +/// FLY_DOOR_CHECKPOINT=.local/checkpoints/release-rank10-row58.checkpoint \ +/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_gym +/// ``` +#[test] +fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() { + let rom = skip_without_rom!(); + let Some(checkpoint) = door_checkpoint() else { + eprintln!("skipped: no FLY_DOOR_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint); + assert_eq!(run.map(), PEWTER_CITY, "the checkpoint is the town outside the gym's door"); + + let mut arrivals = 0u32; + let mut bounces = 0u32; + let mut arrived_at: Option = None; + let mut highest_row: Option = None; + let mut frames_in_gym = 0u32; + let mut badge = None; + let mut previous = run.map(); + for frame in 0..108_000u32 { + run.frame(); + let map = run.map(); + if map != previous { + if map == PEWTER_GYM { + arrivals += 1; + arrived_at = Some(frame); + } else if previous == PEWTER_GYM { + if arrived_at.is_some_and(|at| frame - at < 600) { + bounces += 1; + } + arrived_at = None; + } + previous = map; + } + if map == PEWTER_GYM { + frames_in_gym += 1; + if let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb) + && u32::from(player.map) == PEWTER_GYM + { + highest_row = Some(highest_row.map_or(player.y, |row| row.min(player.y))); + } + } + if badge.is_none() && run.adapter.progress().rank >= 11 { + badge = Some(frame); + } + } + let progress = run.adapter.progress(); + eprintln!( + "{:.1} brain minutes: gym arrivals {arrivals}, straight back out {bounces}, frames in the \ + gym {frames_in_gym}, highest row reached {highest_row:?}, macros {:?}, rank {} ({})", + run.ms / 60_000.0, + run.started, + progress.rank, + progress.rank_label + ); + match badge { + Some(frame) => eprintln!( + "rung 11 at frame {frame} ({:.2} brain minutes)", + f64::from(frame) * MS_PER_FRAME / 60_000.0 + ), + None => eprintln!("rung 11 not reached inside the budget"), + } + assert!(arrivals > 0, "the fly never went through the gym's door: {:?}", run.route); + assert!( + bounces <= 3, + "{bounces} of {arrivals} arrivals walked straight back out: {:?}", + run.started + ); + assert!( + highest_row.is_some_and(|row| row <= 6), + "the fly never went up the room past the doormat rows: highest row {highest_row:?}" + ); +} From 21701579a372edfa4a5a1d174f390df438415401 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 13:05:02 +0000 Subject: [PATCH 10/10] docs: the row 58 audit --- infra/docs/macros-traps.md | 104 +++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index f4fb3dd..f711144 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -2655,3 +2655,107 @@ the fly is no longer held in the pocket. - `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included. - `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched. + +## 2026-09-23, row 58: the gym's door, in and out + +### What was live + +Map 2 (Pewter City) and map 54 (Pewter Gym), rank 10, v0.5.3, for twenty-five minutes: `GO OBJECTIVE` +`done` into the gym, `GO OUT` `done` straight back out, `GO ITEM` / `GO FRONTIER` / `YES` / `NO` +mixed in. Per ten brain minutes about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts, 0-5 refused or +blocked, **no reward event of any kind**, `uniqueLocations` frozen at 1,892. `GO OUT` often started +and finished inside 0.05-0.17 s. Check 10 saw ten distinct names and never flagged. + +### The survey: the room on the Nth arrival + +`FLY_PROBE_CATCH=route` drives the real palette from the checkpoint; `FLY_PROBE_CATCH_MAP=54` +stops it forty frames into the fly's Nth arrival on the gym and dumps the room with every person's +ledger entries. From the bare checkpoint, preferring `GO OBJECTIVE`, the base walks the pair +itself inside seven brain minutes: **`GO OUT` 1,118, `GO OBJECTIVE` 583 in 33 brain minutes**, the +gym pad `["GO OUT"]`, and on the doormat: + +- `objective_targets` empty, `person_targets` = the guide at (7, 10), talked; +- BROCK at (4, 1) and the Jr. Trainer at (3, 6) **absent**: `CheckSpriteAvailability` had written + `$ff` into their image index because they are outside the window of (4, 13); +- pushed tiles `(54, (16, 17))` and `(2, (5, 13))` -- Pewter's gym door and the gym's doormat, + recorded under the *other* map's id. + +### Why nothing inside the gym was offered, and why the door was + +| # | trap | trigger | test | fix, or why it is left | +| --- | --- | --- | --- | --- | +| 58 | the rung's list of people is read from the sprites the cartridge draws, so a person off the screen is not in the room; with the one drawn person talked to, `GO OBJECTIVE` has nothing inside, 12.5 lets `GO OUT` onto the pad, and outside `GO OBJECTIVE` aims at the door | any rung earned by a person who is more than four rows or five columns from where the fly arrives; the Pewter Gym from its doormat | `the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them`, `only_the_rung_reads_people_off_the_screen`, `a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map`, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint` (ROM) | **fixed**: `state::offscreen_npcs` reports a `$ff` sprite whose coordinates fall outside `CheckSpriteAvailability`'s own window (movement byte `WALK` or above), and only the rung's list reads it. `docs/design/macros.md` 12.22, `macros-wram.md` section 12 | +| 58b | in front of one of the rung's people, `GO OBJECTIVE` still walks to another | a room with more than one of them: the gym's leader and trainer | `facing_one_of_the_rungs_people_is_the_arrival` | **fixed**: facing any of them is the arrival, and `TALK` is the press | +| 58c | a warp's tear -- `wCurMap` changed, header, coordinates and warp table not yet -- reads as a controllable overworld for thirty-two frames; a pad is dealt, a walk plans over the wrong map, and its target and tile go into the ledgers under the new map's id | every warp; live, `GO OUT` started and finished in 0.05 s | `a_warps_tear_deals_no_pad`, `a_teleport_pad_is_not_a_tear` | **fixed** in the driver: the map byte changed and the fly still stands on a loaded warp into the map the byte names (a doormat's `LAST_MAP` under a town's id included) is `Unknown` with an empty pad, bounded at ninety frames. Teleport pads never change the map byte | +| 58d | the 219 frames of a battle transition read as a controllable overworld: a walk toward the leader presses into the animation and blocks him for ten brain minutes, and the trainer's conversation reads as over, so a trainer the fly then loses to is "talked to" for the session | every trainer battle, and every wild one | `a_battle_decided_and_not_yet_begun_is_the_cartridges` | **fixed**: `controllable` reads `wCurOpponent`, set when a battle is decided and cleared by `EndOfBattle`; derived as `wBattleType - 1`, both neighbours asserted | +| 58e | a trainer walking up to the fly is read as the cartridge refusing the step (12.4): the target is blocked and the tile pushed on the spot | every trainer's line of sight a walk crosses | `a_trainer_walking_up_teaches_the_ledgers_nothing`, `a_scripted_push_back_records_the_tile_it_happened_on`, `a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to` | **fixed**: the entries wait for the cartridge to give the joypad back; the overworld is a refusal, written as before, and a battle writes nothing | +| 58f | check 10 cannot see an undo pair diluted by other names | ten distinct names, every macro `done`, 211 decisions in ten brain minutes | `lint.sh` check 10 cases 7 and 8 | **fixed**: `unrewarded`, 100+ decisions and no reward event on two probes with no new ground. Against the live row-58 log it flags; the rules before it did not | + +### Before and after + +The route survey, same checkpoint, 120,000 frames (33 brain minutes), base `main` at `174dc7e` +(row 57 merged) against this branch: + +| measure | base | branch, `GO OBJECTIVE` preferred | branch, `TALK` preferred | +| --- | ---: | ---: | ---: | +| `GO OUT` done | **1,118** | **0** | **0** | +| `GO OBJECTIVE` done on the gym | **583** (all maps) | **4** | **4** | +| rung at the end | 10 | **11, BOULDER BADGE** | **11, BOULDER BADGE** | +| pushed tiles under the wrong map's id | `(54, (16, 17))`, `(2, (5, 13))` and four more doormats | none | none | + +The ROM-gated run, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint`, 30.1 +brain minutes on the stub rotation: the base goes through the door once, is back out in 309 +frames and never above row 11 (fails); the branch goes through four times, walks straight back out +once, spends 30,879 frames in the gym and stands on row 2 beside BROCK (passes). Row 56's +`the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint` now reaches rung 11 at +8.45 brain minutes, which it never did; row 57's pocket test passes, and its part one no longer +walls `(54, (16, 17))`, the tear's tile. + +The trap hunt, 30 brain minutes each from the same checkpoint and seed, the stub rotation +(`FLY_TRAP_STUB=1`; the brain is stepped and the readout replaced), base `174dc7e` against this +branch: + +| measure | base | branch | +| --- | ---: | ---: | +| distinct (map, tile) | 329 | **437** | +| windows flagged | 17 | 23 | +| macros started / done | 139 / 137 | 131 / 129 | +| `GO OUT` done | 3 | 0 | +| frames between battle turns | 51,328 | 53,336 | +| rung reached | 10 | 10 | + +**The stub does not walk the ring on either arm** -- it spends half of both runs in battles and +goes through the gym's door once -- so the hunt says little about this row either way, and the +flagged-window count rises (17 -> 23) on battle time, the same judgement as rows 50 and 56. The +route survey above is the reproduction; the hunt is reported, not smoothed. + +### Residuals, named rather than worked around + +- **After the badge, a new pair at the Pewter/Route 3 edge.** With the rung earned, the objective + is Mt. Moon (map 59), and on Route 3 `GO OBJECTIVE` has nothing to aim at: `geography` carries + Route 3's neighbour as Route 4 to the **east** and a Mt. Moon door **on Route 3**, while the + disassembly (`data/maps/headers/Route3.asm`, `objects/Route4.asm`) and the cartridge + (`wCurMapConnections` north and west, row 54b) say Route 4 is **north** and Mt. Moon's doors + are **on Route 4**, whose ground is in two pieces like Route 2's. The survey that prefers + `GO OBJECTIVE` walks `GO OBJECTIVE` east into Route 3 and `GO ROUTE` back west 538 times from + 21 brain minutes. It is row 54b's residual and it needs a `SPLIT` row for Route 4; the next + brief. Check 10's new `unrewarded` rule sees it. +- **A toggleable object switched off reads as present from outside the window.** Only the rung's + own list reads the off-screen people; among the ladder's person places, only Oak's lab (behind + this run) and Viridian Gym carry toggleable people. +- **The tear is read stateful and bounded**: an arrival onto a warp into the map it arrived on, + under a map byte that changed, is a tear for at most ninety frames. +- **The real-brain hunt was not run to the end on this branch**: two 30-minute arms were started + and stopped at a quarter done when row 57 merged and the branch was rebased; the box was loaded + at twelve. The stub arms above are the hunt. + +### Gates + +- `cargo test --workspace` with `FLY_ROM` and `FLY_DATASET`: 1,267 passed, 1 failed -- + `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`, + the known boot-time failure, identical on the base. +- `cargo clippy --all-targets`: **0 warnings**. +- `npm test` 663 passed; `npm run typecheck` clean. +- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included. +- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the + base. Decoder, reward catalog, adapter version and roles untouched.