From 484cc075cc95aad94e4e1cbbf14d1bd305fcd4e3 Mon Sep 17 00:00:00 2001 From: acamilo Date: Wed, 23 Sep 2026 10:34:21 +0000 Subject: [PATCH] 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)"); +}