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)"); +}