flybrain/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs
acamilo 895fb52cda macros: the tile a step is landing on is ground the run has covered
The other half of the same measurement. `wXCoord` and `wYCoord` are the tile the step began on
until the frame it ends, so the stood ledger recorded ground the fly had already left and the tile
under it stayed unstood: `path::frontier` kept offering it, `GO FRONTIER` was dealt aiming one tile
away, and `Arrival::Step` reported `done` the instant the step it did not make landed. A macro that
completes without changing anything, which is section 12.2's trap.

A step that has begun always finishes -- the cartridge owns the animation and no press stops it --
and the screen has already centred on the tile, so it is ground this run has covered. It clears the
map's frontier mark on the same rule the coordinates do: only ground never stood on before.
2026-09-22 14:57:34 +00:00

734 lines
34 KiB
Rust

//! 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<Palette>,
/// 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, stepping, 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();
// 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());
// 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, stepping, 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);
}
// The tile a step in flight is landing on is ground this run has covered: the
// cartridge owns the animation and no press stops it, and the screen has already
// centred on it. Without this the fly's own next tile is a frontier for the fifteen
// frames it takes to get there, which `GO FRONTIER` arrives at without moving
// (`infra/docs/macros-traps.md` row 54).
if let Some(onto) = stepping
&& self.stood.record(player.map, onto)
{
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<u8> {
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<SlotBinding> {
(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::{self, REDS_HOUSE_1F, WALL_TILE, 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 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
// began on until the frame it ends, so without this the ground under the fly is unrecorded
// for fifteen frames of every sixteen: `path::frontier` keeps offering the tile the fly is
// already halfway onto, `GO FRONTIER` is dealt aiming at it, and `Arrival::Step` reports
// `done` the instant the step it did not make lands -- a macro that completes without
// changing anything, which is section 12.2's trap.
let (mut wram, blocks, blockset) = Wram::town();
let mut palette = PokemonPalette::new(7);
palette.observe(&mut wram, &NoLedger);
assert_eq!(palette.stood(), 1, "standing still, the tile under the fly and nothing else");
// Mid-step west: the coordinates still read (3, 4), the screen is already centred on
// (2, 4).
wram.mid_step(-1, 0, &blocks, &blockset);
palette.observe(&mut wram, &NoLedger);
assert_eq!(palette.stood(), 2, "and the tile the step is landing on");
// The step lands. The ledger had it already, so nothing new is recorded and the frontier
// mark is not cleared a second time.
wram.map(fake_wram::PALLET_TOWN, 10, 9, 2, 4)
.fill_screen(WALL_TILE)
.screen_from_blocks(&blocks, &blockset);
palette.observe(&mut wram, &NoLedger);
assert_eq!(palette.stood(), 2, "the tile it landed on was already ground it had covered");
}
#[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<crate::adapter::MapPlace> {
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<u8> = 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");
}
}