//! Pokémon Red behind the sim loop's macro seam (`crate::macros`). //! //! Agent B's [`MacroMachine`] reads the game through [`MacroState`] and agent A's [`PokeState`] //! implements it over live WRAM; this file is the twenty lines that put the two together and hand //! the result to the loop as a [`MacroPalette`]. It is the only place where the executor, the //! scene detector and a `MemoryReader` meet. //! //! What it adds beyond the wiring: the palette the last `observe` bound is *remembered*, because //! `MacroMachine::start` takes the palette it is starting a slot from and the loop should not have //! to carry one. The remembered palette is one frame old at most — `observe` runs after every //! frame and `start` before the next one, which read the same WRAM — and `start` checks the scene //! against it anyway (`Refusal::WrongScene`), so a stale palette refuses rather than acting. use crate::adapter::MemoryReader; use crate::macros::{ MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started, }; use super::super::mapgrid::MapGrids; use super::super::state::PokeState; use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::geography; use super::executor::{MacroAbort, MacroMachine, Refusal}; use super::palette::{self, MacroId, Palette}; use super::plan; use super::state::{GameState, Scene}; /// The macro palette over Pokémon Red. #[derive(Debug, Clone)] pub struct PokemonPalette { machine: MacroMachine, /// Whether a slot is a channel or a rank in the scene's plan /// ([`PaletteMode`], `docs/design/macros.md` sections 3 and 9). /// /// The only thing this changes is which of two functions deals the slots. Everything after /// that — the executor, the refusals, the outcomes, the feed — cannot tell the two modes /// apart, which is the point: a plan *is* a palette whose slots are ranks. mode: PaletteMode, /// The palette the last [`MacroPalette::observe`] bound, and the scene it was bound for. palette: Option, /// What this session has talked to (`docs/design/macros.md` section 12). /// /// Owned here because this is the one type that both builds the state the macros read and /// steps the machine that writes to it: [`MacroMachine::take_talked`] hands over a finished /// `TALK`'s target and this records it, one frame later than the press and before the next /// `observe` asks about it. Session state, not run state -- it does not reach the checkpoint /// and a restored run offers every person once more. talked: Talked, /// Which targets are excluded and which have been reached /// (`docs/design/macros.md` section 12, the Viridian stall). Owned here for the same reason /// the talked ledger is: this is the one type that both builds the state the macros read and /// steps the machine that writes to it. Session state -- not checkpointed. targets: Targets, /// The ground this session has watched the fly stand on (`docs/design/macros.md` section /// 12.7). Owned here for the same reason the two ledgers above are: this is the one type that /// both builds the state the macros read and sees every frame the fly stands on. Session /// state -- not checkpointed. stood: Stood, /// Which of each area's errands this run has discharged (`docs/design/macros.md` section 13). /// /// Owned here for the same reason the three ledgers above are: this is the one type that both /// builds the state the macros read and sees every frame the fly is standing somewhere. The /// entry is written on *arrival* -- the frame the fly is observed standing on a mart's or a /// centre's own map -- which is the moment the errand is discharged. A purchase or a heal marks /// nothing: what the errand asked for was the visit. Session state, not checkpointed. areas: Areas, /// Maps whose frontier a `GO FRONTIER` has proved unreachable (`docs/design/macros.md` /// section 12.14). /// /// Owned here beside the other session ledgers and written from the same two places they are: /// the machine's refusal on one side and the frame the fly is standing on new ground on the /// other. Like [`Pushed`] it has no window -- ground the map has fenced off is still fenced /// off ten brain minutes later -- and unlike it, it is *cleared*, by the only event that can /// change the answer. frontiers: Frontiers, /// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37). /// /// Owned here beside the other session ledgers and for the same reason. Unlike the target /// ledgers this one has no window: Viridian City's (19, 9) prints "This is private property!" /// and walks the fly down on every frame it stands there until the Pokédex exists, and a map /// does not stop being like that ten brain minutes later. pushed: Pushed, /// The decoded walkability of the map the fly is on (`docs/design/macros.md` section 15). /// /// Owned here beside the session ledgers because it is the same shape of thing: built from /// the cartridge, valid for as long as the map is loaded, dropped on arrival somewhere else, /// and never checkpointed -- a restored run decodes the map again on its first overworld /// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the /// cartridge's own tables say about the ground. grids: MapGrids, /// The fewest map hops between the fly and its objective this run has managed, and which /// objective that was (`docs/design/macros.md` section 12.15). /// /// Session state beside the ledgers and never checkpointed, and unlike them it is not a fact /// about the map at all: it is the one reading the *sim loop* takes from the macro layer, for /// the ratchet's stall window. The objective is carried with the number because the ladder's /// next rung changes as the run climbs, and "nearer" means nothing across two different /// places. nearest: Option<(u8, u32)>, /// Whether the last `observe` was the frame that number fell on. nearer: bool, /// 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 /// than a frame count of its own: a macro's frames and the brain's milliseconds are not the /// same unit and the exclusion is quoted in brain minutes. now_ms: f64, } impl PokemonPalette { pub fn new(seed: u32) -> Self { Self::with_mode(seed, PaletteMode::Palette) } pub fn with_mode(seed: u32, mode: PaletteMode) -> Self { Self { machine: MacroMachine::new(seed), mode, palette: None, talked: Talked::default(), targets: Targets::new(), stood: Stood::default(), areas: Areas::default(), frontiers: Frontiers::default(), pushed: Pushed::default(), grids: MapGrids::default(), nearest: None, nearer: false, now_ms: 0.0, } } /// Frames the running macro has spent, for a log line. pub fn frames(&self) -> u32 { self.machine.frames() } /// How many things this session has talked to, for a log line and the tests. pub fn talked(&self) -> usize { self.talked.len() } /// How many targets are excluded and how many reached, for a log line and the tests. pub fn targets(&self) -> (usize, usize) { self.targets.len() } /// How many tiles this session has watched the fly stand on, for a log line and the tests. pub fn stood(&self) -> usize { self.stood.len() } /// How many of the run's errands have been discharged, for a log line and the tests. pub fn errands(&self) -> usize { self.areas.len() } /// How many tiles the cartridge has pushed the fly off, for a log line and the tests. pub fn pushed(&self) -> usize { self.pushed.len() } /// How many maps have proved their frontier unreachable, for a log line and the tests. pub fn exhausted(&self) -> usize { self.frontiers.len() } /// Take whatever the machine's last finished macro earned into the session's ledgers. fn record_talk(&mut self) { if let Some((map, target)) = self.machine.take_talked() { self.talked.record(map, target); } // Several at once, for a refusal that found no route to any of its goals. while let Some((map, target)) = self.machine.take_blocked() { self.targets.record_blocked(map, target); } if let Some((map, target)) = self.machine.take_reached() { self.targets.record_reached(map, target); } // 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() { self.pushed.record(map, tile); } // A frontier the walk could not reach any of: a fact about this map's ground, with no // window on it (section 12.14). if let Some(map) = self.machine.take_exhausted() { self.frontiers.record(map); } if let Some((map, target, closer)) = self.machine.take_timeout() { self.targets.record_timeout(map, target, closer); } } } impl MacroPalette for PokemonPalette { fn clock(&mut self, ms: f64) { self.now_ms = ms; self.targets.clock(ms); } fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { let (scene, bindings, standing, approach) = { let Self { machine, mode, palette: cached, talked, targets, stood, areas, frontiers, pushed, grids, .. } = self; let mut state = PokeState::with_ledgers( memory, ledger, talked, targets, &*stood, &*areas, &*pushed, ) .caching_grid(grids) .with_frontiers(&*frontiers); // `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(); // 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). machine.observe_frame(&mut state); let palette = match mode { PaletteMode::Palette => Palette::for_scene(scene, &mut state), PaletteMode::Plan => plan::plan_for(scene, &mut state), }; let bindings = bindings(&palette); // Where the fly is standing, for the ledger below. Only on a frame the fly is its own // 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(); // How far the objective is, over the same map graph `GO OBJECTIVE` walks (section // 12.15). Read from the same frame and the same state everything else is, and only // where the fly is its own master, for the same reason the ground is. let approach = standing.and_then(|player| { let objective = palette::objective_place(&mut state)?; let hops = geography::hops(geography::region_at(player.map, player.y), objective.map)?; Some((objective.map, hops)) }); *cached = Some(palette); (scene, bindings, standing, approach) }; // Section 12.7: the macro layer's own answer to "has the run stood here", because the // adapter's reward ledger cannot record a doormat. if let Some(player) = standing { // New ground under the fly is the one thing that can change which tiles of this map // it can reach, so it is what clears the map's frontier mark (section 12.14). A tile // the ledger already had changes nothing and clears nothing. if self.stood.record(player.map, Tile::new(player.x, player.y)) { self.frontiers.clear(player.map); } // Section 13's `areaVisited(kind, area)`: the errand is paid on *entering*, so the // ledger is written from the same frame that records the ground. Standing on the // building's own map is the whole test -- the fly is inside it -- and it is written // only on a frame the fly is its own master, for the same reason the ground is: while // the cartridge is walking it through a warp the coordinates and the loaded map header // are from different frames. if let Some(kind) = geography::amenity_at(player.map) && let Some(area) = geography::area_of(player.map) { self.areas.record(kind, area); } } // Section 12.15: nearer the objective than this run has ever been, which is the other // thing that is plainly progress and which the ratchet's stall window cannot see in the // exploration ledger. A level, true on the frame the number falls and false after, so // there is nothing to checkpoint and nothing to drift. A different objective starts the // measurement again: the ladder's next rung moves as the run climbs and "nearer" means // nothing across two different places. self.nearer = match (self.nearest, approach) { (_, None) => false, (None, Some(_)) => false, (Some((was, best)), Some((map, hops))) => map == was && hops < best, }; self.nearest = match (self.nearest, approach) { (_, None) => self.nearest, (Some((was, best)), Some((map, hops))) if map == was => Some((map, best.min(hops))), (_, Some(now)) => Some(now), }; // The talked entry `observe_frame` may just have earned, into the ledger the next frame // reads. self.record_talk(); Observed { scene: scene_id(scene), bindings } } fn start( &mut self, slot: u8, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger, ) -> Started { let Some(palette) = self.palette else { // Nothing has been observed yet, so there is no palette to start a slot from. return Started::Refused { name: None, reason: "unobserved" }; }; let slot = MacroId(slot); let name = palette.slot(slot).map(|spec| spec.name); let begun = { let mut state = PokeState::with_ledgers( memory, ledger, &self.talked, &self.targets, &self.stood, &self.areas, &self.pushed, ) .caching_grid(&mut self.grids) .with_frontiers(&self.frontiers); self.machine.start(&palette, slot, &mut state) }; let started = match begun { // A started macro always has a name: the machine refuses an unbound slot before it // can start one. If that ever stopped being true, abandoning the macro is better // than publishing a nameless one and inventing its outcome later. Ok(()) => match name { Some(name) => Started::Running(name), None => { self.machine.cancel(); let _ = self.machine.take_outcome(); Started::Refused { name: None, reason: "unbound" } } }, Err(refused) => Started::Refused { // An unbound slot is "no action", not a failed macro, so it carries no name and // the loop reports no outcome for it. name: if refused.reason == Refusal::Unbound { None } else { name }, reason: refused.reason.label(), }, }; // A `no route` refusal earns blocked-ledger entries and presses nothing, so nothing else // would ever collect them: the sim loop calls `step` only for a macro that started. Taken // here, before the next decision, so the exclusion is in the ledger the next `observe` // reads rather than one hold late. self.record_talk(); started } fn step(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Option { let mask = { let mut state = PokeState::with_ledgers( memory, ledger, &self.talked, &self.targets, &self.stood, &self.areas, &self.pushed, ) .caching_grid(&mut self.grids) .with_frontiers(&self.frontiers); self.machine.step(&mut state) }; // A macro that just finished may have been the press that talked to something; the ledger // is written after the step so the state the step read is the one the fly chose in. self.record_talk(); mask } fn running(&self) -> Option<&'static str> { self.machine.running() } fn take_finished(&mut self) -> Option<(&'static str, Outcome)> { let (name, abort) = self.machine.take_outcome()?; // An unbound slot records a nameless refusal, because the machine cannot name a macro // that does not exist in this scene. That is "no action" rather than an outcome: it is // taken (so it cannot linger into the next frame) and reported as nothing. if name.is_empty() { return None; } Some((name, outcome(abort))) } fn nearer_the_objective(&self) -> bool { self.nearer } fn cancel(&mut self) { self.machine.cancel(); // A cancelled macro talked to nothing, and a stale entry would silence a person for the // rest of the session. let _ = self.machine.take_talked(); // Nor did it fail at its target: a rollback is the loop's doing and not the map's, so // neither ledger is written. `cancel` reports `Blocked` to the feed, which is the honest // outcome for the macro, and excluding a target for it would punish the fly for a // rollback it did not cause. while self.machine.take_blocked().is_some() {} let _ = self.machine.take_timeout(); 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(); let _ = self.machine.take_exhausted(); // The cached palette was dealt for a frame that is being thrown away. Dropping it makes // the next `start` before the next `observe` a nameless refusal, which presses nothing // and reports nothing, rather than a named refusal against a scene that no longer exists. self.palette = None; } } /// Every bound slot of `palette`, in slot order, with its gloss. fn bindings(palette: &Palette) -> Vec { (0..SLOTS) .filter_map(|slot| { let spec = palette.slot(MacroId(slot))?; Some(SlotBinding { slot, name: spec.name, gloss: spec.kind.gloss(), channel: spec.kind.channel(), tag: spec.kind.channel_tag(), }) }) .collect() } /// `Scene` onto the feed's closed set. A forced switch is its own name because it is the one /// battle state with a different palette; every other battle frame is `battle`. fn scene_id(scene: Scene) -> SceneId { match scene { Scene::Title => SceneId::Title, Scene::Overworld => SceneId::Overworld, Scene::Dialog => SceneId::Dialog, Scene::Menu => SceneId::Menu, Scene::Battle { forced_switch: true, .. } => SceneId::BattleSwitch, Scene::Battle { .. } => SceneId::Battle, Scene::Shop => SceneId::Shop, Scene::Pc => SceneId::Pc, Scene::Unknown => SceneId::Unknown, } } const fn outcome(abort: MacroAbort) -> Outcome { match abort { MacroAbort::Done => Outcome::Done, MacroAbort::Blocked => Outcome::Blocked, MacroAbort::Timeout => Outcome::Timeout, MacroAbort::Refused => Outcome::Refused, } } #[cfg(test)] mod tests { use super::*; use crate::adapter::{MapEdge, MapExit}; use crate::macros::NoLedger; use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram}; use crate::pokemon_red::macros::geography::Amenity; use crate::pokemon_red::maps; use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState}; /// A ledger with one exit in it, for the wiring test below. struct OneVisited(MapExit); impl RunLedger for OneVisited { fn exit_visited(&self, exit: MapExit) -> bool { exit == self.0 } } #[test] fn a_fresh_cartridge_reads_as_the_title_and_binds_nothing() { // All-zero WRAM: the game-timer bit is clear, which is the title screen. let mut wram = Wram::new(); let mut palette = PokemonPalette::new(7); let observed = palette.observe(&mut wram, &NoLedger); assert_eq!(observed.scene, SceneId::Title); assert!(observed.bindings.is_empty(), "{:?}", observed.bindings); assert_eq!(palette.running(), None); // A slot of an empty palette presses nothing and reports no outcome. assert_eq!( palette.start(0, &mut wram, &NoLedger), Started::Refused { name: None, reason: "unbound" } ); assert_eq!(palette.take_finished(), None); } /// A ledger whose objective is one map, for the approach reading below. struct Bound(u8); impl RunLedger for Bound { fn exit_visited(&self, _exit: MapExit) -> bool { false } fn objective(&self) -> Option { Some(crate::adapter::MapPlace { map: self.0, tile: None, warp: None, edge: None, target: None, }) } } #[test] fn the_objective_getting_nearer_is_read_once_per_step_of_the_road() { // Section 12.15, the rung-10 stall. The ratchet's stall window is reset by ground never // stood on, and a fly walking a road it has already covered earns none -- so this is the // other reading, and what it has to be is a *level* that is true on the frame the hop // count falls and false on every frame after it. Pewter's own museum is the road: the // upper floor is three hops from the gym, the ground floor two, the town one. let mut wram = Wram::new(); wram.started().map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6).facing(0).house_collision(); let mut palette = PokemonPalette::new(7); // Both of Pewter's errands discharged, so the objective is the rung's own place for the // whole test: section 13 puts an unvisited mart or centre *ahead* of it, and that is a // different place to be near. palette.areas.record(Amenity::Mart, maps::PEWTER_CITY); palette.areas.record(Amenity::Center, maps::PEWTER_CITY); let ledger = Bound(maps::PEWTER_GYM); palette.observe(&mut wram, &ledger); assert!(!palette.nearer_the_objective(), "the first reading is a measurement, not a step"); palette.observe(&mut wram, &ledger); assert!(!palette.nearer_the_objective(), "standing still is not nearer"); wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6); palette.observe(&mut wram, &ledger); assert!(palette.nearer_the_objective(), "down the stairs is one hop nearer"); palette.observe(&mut wram, &ledger); assert!(!palette.nearer_the_objective(), "and it is read once, not held"); // Back upstairs is not progress, and it does not undo the number either: the measurement // is the best this run has managed, so walking the road twice pays once. wram.map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6); palette.observe(&mut wram, &ledger); assert!(!palette.nearer_the_objective()); wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6); palette.observe(&mut wram, &ledger); assert!(!palette.nearer_the_objective(), "ground already gained is not gained again"); wram.map(maps::PEWTER_CITY, 4, 4, 3, 6); palette.observe(&mut wram, &ledger); assert!(palette.nearer_the_objective(), "out of the front door is nearer still"); } #[test] fn standing_on_new_ground_is_what_clears_a_maps_frontier_mark() { // Section 12.14's other half, wired: the mark is written by a `GO FRONTIER` that could // reach none of its goals and cleared by the fly standing somewhere on that map it had // not stood on before -- the only event that can change which tiles it can reach. A tile // the stood ledger already has changes nothing, which is what keeps the mark from being // cleared by the fly pacing the ground it has covered. let mut wram = Wram::overworld(); let mut palette = PokemonPalette::new(7); palette.frontiers.record(REDS_HOUSE_1F); assert_eq!(palette.exhausted(), 1); palette.observe(&mut wram, &NoLedger); assert_eq!(palette.exhausted(), 0, "the first frame is new ground, so it clears"); palette.frontiers.record(REDS_HOUSE_1F); palette.observe(&mut wram, &NoLedger); assert_eq!( palette.exhausted(), 1, "standing on the same tile again is not new ground and clears nothing" ); wram.map(REDS_HOUSE_1F, 4, 4, 3, 5); palette.observe(&mut wram, &NoLedger); assert_eq!(palette.exhausted(), 0, "a tile the run had not stood on clears it"); } #[test] fn every_bound_slot_carries_a_name_and_a_gloss() { let mut wram = Wram::overworld(); let mut palette = PokemonPalette::new(7); let observed = palette.observe(&mut wram, &NoLedger); assert_eq!(observed.scene, SceneId::Overworld); assert!(!observed.bindings.is_empty()); for binding in &observed.bindings { assert!(binding.slot < SLOTS); assert!(!binding.name.is_empty() && binding.name.len() <= 14, "{binding:?}"); assert!(!binding.gloss.is_empty(), "{binding:?}"); assert!( binding.gloss.split_whitespace().count() <= 3, "a gloss is two or three words: {binding:?}" ); } let slots: Vec = observed.bindings.iter().map(|binding| binding.slot).collect(); let mut sorted = slots.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(slots, sorted, "slots are reported once each, in order"); } /// The ledger reaches the palette: a map whose only warp is already in it has no second exit /// for `GO OUT` to prefer, and one that is not has one. /// /// This is the whole of the wiring under test — `AdapterLedger` -> `PokeState::with_ledger` -> /// `MacroState::exit_visited` -> `palette::ways` — and the fallback is what makes it /// observable: with every exit visited the slot is still bound (a room whose doors are all in /// the ledger still has to be left), so what changes is *which* exit the walk aims at, which /// is why this test asks the ledger directly as well. #[test] fn the_exploration_ledger_reaches_the_palette() { // Red's ground floor: the staircase at (7, 1) and a doormat at (2, 3) on the bottom row. let mut wram = Wram::overworld(); wram.warps(&[(7, 1, 2, 0x26), (2, 3, 1, 0xff)]); let mut palette = PokemonPalette::new(7); let fresh = palette.observe(&mut wram, &NoLedger); assert_eq!(fresh.scene, SceneId::Overworld); assert!( fresh.bindings.iter().any(|binding| binding.name == "GO OUT"), "a map with two warps binds a way out: {:?}", fresh.bindings ); // The staircase, by its tile on this map, which is how the adapter's ledger names it. let stairs = OneVisited(MapExit::Warp { map: REDS_HOUSE_1F, x: 7, y: 1 }); let mut state = PokeState::with_ledger(&mut wram, &stairs); assert!( state.exit_visited(ExitId::Warp(0)), "the ledger's answer crosses the seam for the warp the test put in it" ); assert!(!state.exit_visited(ExitId::Warp(1)), "and only for that one"); assert!(!state.exit_visited(ExitId::Warp(9)), "a warp index past the table is not an exit"); assert!( !state.exit_visited(ExitId::Edge(Edge::North)), "an edge of this map is a different key" ); // And an edge asked for as an edge. let north = OneVisited(MapExit::Edge { map: REDS_HOUSE_1F, edge: MapEdge::North }); let mut state = PokeState::with_ledger(&mut wram, &north); assert!(state.exit_visited(ExitId::Edge(Edge::North))); assert!(!state.exit_visited(ExitId::Edge(Edge::South))); } /// The tile the fly is standing on is visited, whatever the run ledger can record. /// /// `infra/docs/macros-traps.md` row 32: the adapter's `exploration` ledger is a *reward* /// ledger and its payout gate rejects every frame with `wMovementFlags`' door and warp bits /// set, so a doormat — walkable, standable ground — can never be recorded in it. The macro /// layer keeps its own answer, written once per frame by `observe`, and `NoLedger` here is /// exactly what the adapter looks like for such a tile: it records nothing at all. #[test] fn the_tile_the_fly_stands_on_is_visited_although_the_run_ledger_records_nothing() { // Red's ground floor, the fly at (3, 6). let mut wram = Wram::overworld(); let mut palette = PokemonPalette::new(7); { let mut state = PokeState::with_ledger(&mut wram, &NoLedger); assert!(!state.tile_visited(3, 6), "an empty run ledger holds no ground"); } assert_eq!(palette.stood(), 0, "nothing observed yet"); palette.observe(&mut wram, &NoLedger); assert_eq!(palette.stood(), 1, "one frame on one tile is one tile of ground"); { // The same seam every macro reads the ledgers through. let mut state = PokeState::with_ledgers( &mut wram, &NoLedger, &palette.talked, &palette.targets, &palette.stood, &palette.areas, &palette.pushed, ); assert!(state.tile_visited(3, 6), "and the answer crosses the seam"); assert!(!state.tile_visited(3, 5), "only the tile it stood on"); } // A second frame on the same tile adds nothing; a step adds one. palette.observe(&mut wram, &NoLedger); assert_eq!(palette.stood(), 1); wram.set(crate::pokemon_red::symbols::ram::wYCoord, 5); palette.observe(&mut wram, &NoLedger); assert_eq!(palette.stood(), 2); // A frame the cartridge is driving records nothing: the coordinates and the loaded map // header are from different frames then, so the pair is a tile of nowhere. wram.set(crate::pokemon_red::symbols::ram::wJoyIgnore, 1); wram.set(crate::pokemon_red::symbols::ram::wYCoord, 4); palette.observe(&mut wram, &NoLedger); assert_eq!(palette.stood(), 2, "nothing is recorded while the fly is not its own master"); } }