Merge fix/loop-row59: the map graph as the cartridge's headers and warps draw it, pieces and all

This commit is contained in:
acamilo 2026-09-23 23:08:09 +00:00
commit f32352bd5e
11 changed files with 1139 additions and 179 deletions

View file

@ -1539,6 +1539,75 @@ Nothing is ranked, weighted or pressed for the fly: a button leaves the pad whil
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
catalog, the adapter version, the roles and the compatibility string are untouched.
### 12.24 The map graph is the disassembly's, piece by piece (2026-09-23, row 59)
Opened pre-emptively: the row-58 review carried the route survey past the Boulder Badge and the
fly walked Pewter City (39, 17) to Route 3 (0, 9) and back from about frame 68,000, `GO OBJECTIVE`
done on Route 3 538 times and `GO ROUTE` done on Pewter 537. Reproduced from a rank-11 checkpoint
the survey writes (`FLY_PROBE_SAVE_RANK=11`): 509 and 508, never on Route 4. The live fly got
through Route 3 anyway and met the next half on v0.6.0 at 22:20 UTC: rank 12, MT. MOON, the
objective Cerulean, on Route 4 per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103,
two new tiles, in and out of the Pokécenter and the cave mouth. Route 4 was one node with Cerulean
off its east edge, which the mountain cuts off from the cave mouth's side. From the live
checkpoint the route survey on `main` walks it 930 times in 72,000 frames.
**The geography table disagreed with the headers.** Checked row by row against
`data/maps/headers/*.asm` and `data/maps/objects/*.asm` at the pinned commit:
- Route 4 is **north** of Route 3, not east (`Route3.asm`: `connection north, Route4`); Route 3's
top edge is the road to Mt. Moon's Pokécenter. Mt. Moon's doors are both **on Route 4**:
(18, 5) into the first floor and (24, 5) into B1F, whose (27, 3) is the way out. Route 3 has no
warps. So Route 3's north edge named no map, and nothing on Route 3 was the way to the rung.
- Route 14 / 15 and Route 24 / 25 had the right neighbour in the wrong column (west and east,
not south and north). Route 24's east edge is Nugget Bridge's far end, rung 16's road.
**Four maps are pieces the player cannot walk between** (12.7's rule, measured by flooding every
tile of every map on the graph from the blocks, blockset, collision list, tile-pair walls and
ledges): Route 2 as before; **Route 4**, cut by the mountain into the cave mouth's side and
Cerulean's side; **Mt. Moon B1F**, four chambers of two ladders each; **B2F**, one large piece and
two small ones. The one road through is 1F (5, 5), B1F (21, 17), B2F (5, 7), B1F (27, 3); the
other two ladders on 1F lead to dead ends.
A split row is now any number of pieces, each with its doors (the warp index and tile) and what is
one step from it, a whole map or another map's piece. Three rules keep it honest:
- **Which piece the fly is in** is what its walk can reach on the decoded grid (section 15): the
piece whose doors it reaches, when exactly one piece's are. The grid has no ledges, so where it
reaches none (Route 4 below the ledges) the nearest door answers. Flooded over every tile of
the four maps' ground in the disassembly, the rule names the right piece for all of them.
- **Which piece a door lands in** is the cartridge's own answer: a warp names the destination
warp it arrives at (`wWarpEntries` byte 2), and each piece lists its warps. An edge lands in
the piece that lists the map it is stepped off.
- **The hop is a piece**, and an exit is toward the objective only if it lands in that piece. On
1F three ladders go down to B1F and one of them is the road.
**A connection nobody can walk across is not a road.** Four header connections have no tile where
both sides are land: Pallet Town / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea) and Route 22 /
23 (the League's fence). They keep their name and offer no exit and no hop. Without this the road
from Pallet Town to Cerulean was by sea, and a fly that whited out in Mt. Moon, which the survey's
did, walked into Pallet's shore every two seconds.
**One seam frame, found on the way.** Route 3's first trainer closes his challenge onto five frames
of plain overworld before `StartTrainerBattle` decides the battle (`home/trainers.asm`: it runs
after `DisplayTextID`'s close-down). Row 58's pending push-back was decided on the first of them
and walled (11, 6), the one gap between Route 3's west end and the rest of the road, for the
session. A push-back is now a refusal only once the overworld has been the fly's for thirty frames
running; a battle inside them drops it.
From the badge, the route survey reaches Route 4 at frame 70,356 and Mt. Moon at 70,707 (rung 12),
no pushed tile; after whiting out in the cave it walks the land road back from Pallet Town. The
ROM test on the stub rotation reaches Mt. Moon in 56.7 brain minutes with 4 Pewter / Route 3
crossings; the base makes 3,391 in 80.4 and never stands on Route 4. From the live 2026-09-22
rank-11 checkpoint the branch reaches Mt. Moon too, where the base ends fenced on Route 3. From
the live Route 4 checkpoint the branch is in the cave on frame 279 and stays on the road; on the
stub rotation Route 4's west doors are crossed 13 times in twenty brain minutes (whiteouts and
walks back included) against 56 on `main`.
Nothing is ranked and nothing presses for the fly: a table of maps says what the headers say, a
split map has the pieces its ground has, and a frame that was the cartridge's is not read as the
fly's. 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.")

View file

@ -2851,3 +2851,119 @@ of the brain, which is the deviation.
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
base. Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 59: the road to Mt. Moon, and Route 4 in two
### What was coming, and what came
Opened pre-emptively. The row-58 review carried the route survey past the Boulder Badge (v0.5.5's fix):
from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE` done
on Route 3 538 times, `GO ROUTE` done on Pewter 537. Route 3's pad was `GO ROUTE` alone, because
`GO OBJECTIVE` had nothing on Route 3 to aim at. The live fly was due there with the badge.
It got through Route 3 and met the other half on v0.6.0 at 22:20 UTC: rank 12 (MT. MOON), map 15,
per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct macros, two new
tiles; the coordinator restarted flysim. The live checkpoint from inside the ring is v7. From it the
route survey on `main` (`4d82f7d`, 72,000 frames) walks between Route 4 and the Pokécenter (map
68): 930 map changes, `GO OBJECTIVE` done on Route 4 465 and `GO ROUTE` done on the Pokécenter
462 with `GO OBJECTIVE` preferred; with a uniform choice 706 changes, `GO ROUTE` 350,
`GO OBJECTIVE` 183, `GO OUT` 170, the live shape. That is 59c.
### Reproduction
`examples/scene_probe.rs` gains `FLY_PROBE_SAVE_RANK`: the route survey writes the first safe
overworld frame at a rung as a checkpoint. From the row-58 checkpoint, `FLY_PROBE_CATCH=route
FLY_PROBE_PREFER="GO OBJECTIVE,TALK" FLY_PROBE_FRAMES=120000`, the survey is byte-identical to the
review's and writes rank 11 on frame 19,620, in the gym beside BROCK
(`.local/checkpoints/survey-rank11-row59.checkpoint`, untracked). From that checkpoint, with empty
ledgers, the base walks the ring again: `GO OBJECTIVE` done on Route 3 509, `GO ROUTE` done on
Pewter 508, never on Route 4.
### The audit: every row of the map graph against the disassembly
`geography.rs` against `data/maps/headers/*.asm` and `data/maps/objects/*.asm` at `0cd19d3`, all
outdoor rows and every `LINKS` pair; then every map on the graph flooded tile by tile (blocks,
blockset, collision list, `TilePairCollisionsLand`, `LedgeTiles`, lower-left quadrant as section
15 reads it) for pieces, and every connection for a strip that lands on land.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 59 | Route 3 / Route 4 in the wrong columns (`[-,-,PEWTER,ROUTE_4]` / `[-,-,ROUTE_3,CERULEAN]`; the headers say Route 4 is **north** of Route 3), and Mt. Moon 1F linked to **Route 3**, which has no warps; B1F's exit to Route 4 missing | any objective past Pewter: Route 3's north edge named no map, so it was nobody's hop | `the_rows_the_disassembly_corrected_say_what_its_headers_say`, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint` (ROM) | **fixed**: rows as `Route3.asm` / `Route4.asm`; `(MT_MOON_1F, ROUTE_4)`, `(MT_MOON_B1F, ROUTE_4)` per `Route4.asm`'s warps (18, 5) and (24, 5) and B1F's (27, 3) |
| 59b | Route 14 / 15 and Route 24 / 25 in the wrong columns (south/north for west/east) | Nugget Bridge's far end (rung 16) named no map; Route 14's west edge likewise | the same unit test | **fixed**: `Route14.asm`, `Route15.asm`, `Route24.asm`, `Route25.asm` |
| 59c | Route 4 is one node, though Mt. Moon cuts it in two: the cave mouth's side (Pokécenter (11, 5), 1F (18, 5), Route 3) and Cerulean's side (B1F's exit (24, 5), Cerulean) | every hop across Route 4 | `route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot`, `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` (ROM) | **fixed**: a `SPLIT` row |
| 59d | Mt. Moon B1F and B2F are one node each, though B1F is four chambers of two ladders and B2F three pieces; 1F has three ladders to B1F and only (5, 5) is the road, so a map-level hop sends the fly up dead ends and back | rung 13 (Cerulean) from the first floor | `the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time`, `a_door_or_an_edge_lands_in_the_piece_it_opens_onto` | **fixed**: `SPLIT` rows; a piece lists its warps by index, a warp lands in the piece its destination warp is in (`wWarpEntries` byte 2), and the hop is a piece. The fly's own piece is what its walk reaches on the decoded grid, the nearest door where the grid (no ledges) reaches none; flooded over all four maps' ground, exact for every tile |
| 59e | four header connections have no tile where both sides are land -- Pallet / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea), Route 22 / 23 (the League's fence) -- and the graph routed along them: from Pallet the road to Cerulean was by sea | whiting out in Mt. Moon, which the survey's fly did: `GO ROUTE` walked into Pallet's shore every 108 frames | `a_connection_nobody_can_walk_across_is_named_and_is_not_a_road` | **fixed**: `NO_CROSSING`: named, no exit, no hop. Route 22 / 23 go back into the table from the same list |
| 59f | a trainer's challenge closes onto five frames of plain overworld before `StartTrainerBattle` decides the battle; row 58's pending push-back was written on the first of them | Route 3's first trainer: (11, 6), the one gap between the road's west end and the rest of it, walled for the session | `a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge` | **fixed**: a push-back is a refusal only after thirty frames running of the fly's overworld; a battle inside them drops it |
Measured and not the same kind, so left: Cerulean's south and east (Route 5, Route 9, the trashed
house's back door) are reached from the town only through the trashed house, which is off the
graph, and back over a ledge -- rung 17's road, with the Saffron gates and the Underground Path
behind it. Routes 5-8, 10-12, 15, 16, 18 and 23 are pieces joined by gate buildings off the graph,
and Rock Tunnel 1F is four pieces (rung 20). And many edges have walkable tiles past the strip
that lands on land: a walk aimed at
one is blocked and rests the whole edge for the window. On the road through rung 16 these are
Route 3's (62, 0) and (63, 0), Route 4's (6, 17) and Cerulean's (0, 13) and (25, 0), all beside the
landing tiles a walk from the road reaches first.
### Before and after
Route survey from the rank-11 checkpoint, 120,000 frames, `GO OBJECTIVE` and `TALK` preferred:
| measure | base `7784a9d` | branch |
| --- | ---: | ---: |
| `GO OBJECTIVE` done on Route 3 | **509** | 9 |
| `GO ROUTE` done on Pewter | **508** | 0 |
| Route 4 / Mt. Moon first | never / never | frame 70,356 / 70,707 |
| rung at the end | 11 | **12, MT. MOON** |
| pushed tiles at the end | (11, 6), (14, 6), (14, 9) on Route 3 | none |
From the live Route 4 checkpoint, 72,000 frames, rebased on `main` `4d82f7d` (v7):
| measure | base `4d82f7d` | branch |
| --- | ---: | ---: |
| map changes, `GO OBJECTIVE` preferred / uniform | **930 / 706** | 29 / 50 |
| `GO OBJECTIVE` done on Route 4, preferred / uniform | **465 / 183** | 3 / 2 |
| `GO ROUTE` done on the Pokécenter, preferred / uniform | **462 / 350** | 0 / 0 |
| into Mt. Moon, preferred / uniform | frame 541 / 826, and back out | frame 279 / 1,910 |
| rung at the end | 12 | 12 |
`the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint`, twenty brain minutes on the stub
rotation: the base crosses Route 4's west doors 56 times, 18 through the Pokécenter's and 38
through the cave's (fails); the branch 13, over three whiteouts and walks back (passes), in the
cave on frame 278.
The same survey from the live rank-11 checkpoint of 2026-09-22 (Pewter, the badge won):
the base stays on Route 3 with five pushed tiles fencing it, rank 11; the branch reaches Route 4 at
frame 118,621 and Mt. Moon at 118,972, rank 12, nothing pushed.
The ROM-gated run, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint`,
on the stub rotation: the base crosses between Pewter City and Route 3 **3,391** times in 80.4 brain
minutes and never stands on Route 4 (fails); the branch crosses 4 times, whites out twice on the
way, and is on Route 4 at frame 202,734 and at Mt. Moon's door at 203,232, 56.7 brain minutes
(passes). `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` reads Route 4's
warp table off the cartridge -- (11, 5) to `$44`, (18, 5) to `$3b`, (24, 5) to `$3c`, the table's
doors in its order -- and the decoded grid puts the fly, arrived from Route 3 at (9, 17), on the cave
mouth's side.
**No trap hunt numbers.** Two 30-brain-minute stub arms were started from the badge checkpoint on
v0.5.5 with the box at load 35-40, had written nothing after two hours, and were stopped when the
live fly reached Route 4 and this row became the live priority; FND-01 has since changed the
hunt's frame, so hunts across the rebase would not compare (`9301e39`). The proof is the route
survey and the three ROM tests, which is a deviation from the ethos check's letter, recorded.
### Gates
- `cargo test --workspace --no-fail-fast` in release with `FLY_ROM`, `FLY_DATASET` and
`FLY_BADGE_CHECKPOINT`: 1,285 passed, 1 failed --
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known load-sensitive test (feed at 11.28 Hz with the box at load 35-40); the base fails it
too at the same load (the boot-time `total` assertion).
- `cargo clippy --workspace --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the de-PII guard included.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` on
`4d82f7d` (v7). Decoder, reward catalog, adapter version and roles untouched.

View file

@ -349,10 +349,9 @@ impl MacroPalette for PokemonPalette {
// 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 approach = standing.and_then(|_| {
let objective = palette::objective_place(&mut state)?;
let hops =
geography::hops(geography::region_at(player.map, player.y), objective.map)?;
let hops = geography::hops(palette::region_here(&mut state)?, objective.map)?;
Some((objective.map, hops))
});
*cached = Some(palette);

View file

@ -149,6 +149,12 @@ const ANSWER_REOPEN_FRAMES: u32 = 24;
/// animation is about three.
const HEAL_WAIT_FRAMES: u32 = 360;
/// Frames running the overworld has to be the fly's before a push-back is written as a refusal
/// ([`MacroMachine::observe_push`], row 59). A trainer's challenge text closes onto five frames of
/// overworld before the battle is decided; six times that is still half a second, and a refusal
/// the cartridge really made loses nothing by being written half a second late.
pub const PUSH_SETTLE_FRAMES: u32 = 30;
/// How a macro ended, i.e. the `outcome` field of the `macro` feed event (section 5: "outcome =
/// done/blocked/timeout/refused").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -662,6 +668,9 @@ pub struct MacroMachine {
/// 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<PendingPush>,
/// How many frames running the overworld has been the fly's while [`Self::pending_push`]
/// waits: the push-back is decided at [`PUSH_SETTLE_FRAMES`] (row 59).
pending_push_calm: u32,
/// 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
@ -696,6 +705,7 @@ impl MacroMachine {
pending_talk: None,
pending_answer: None,
pending_push: Vec::new(),
pending_push_calm: 0,
talked: None,
rng: if seed == 0 { 1 } else { seed },
}
@ -979,6 +989,7 @@ impl MacroMachine {
self.pending_answer = None;
// Nor the cartridge refusing a step: the frames it happened in are being thrown away too.
self.pending_push.clear();
self.pending_push_calm = 0;
}
/// Whether the fly is standing somewhere other than where the running macro began.
@ -1036,17 +1047,32 @@ 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.
/// Back in the overworld with the buttons the fly's again, for [`PUSH_SETTLE_FRAMES`] running:
/// 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.
///
/// The window is row 59's. A trainer's challenge text closes onto five frames of an ordinary
/// overworld -- no text, no script, no joypad bit, `wCurOpponent` still clear -- before
/// `StartTrainerBattle` runs (`home/trainers.asm`: it follows `DisplayTextID`, whose
/// close-down redraws the map first). Decided on the first of them, Route 3's first trainer
/// walled (11, 6), the one gap between the road's west end and the rest of it, for the
/// session, and the fly walked between Pewter City and that end for hours.
fn observe_push(&mut self, state: &mut dyn MacroState) {
if self.pending_push.is_empty() {
self.pending_push_calm = 0;
return;
}
match class(state.scene()) {
Class::Battle | Class::ForcedSwitch => self.pending_push.clear(),
Class::Battle | Class::ForcedSwitch => {
self.pending_push.clear();
self.pending_push_calm = 0;
}
Class::Overworld if self.pending_push_calm + 1 < PUSH_SETTLE_FRAMES => {
self.pending_push_calm += 1;
}
Class::Overworld => {
self.pending_push_calm = 0;
// 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) {
@ -1058,7 +1084,7 @@ impl MacroMachine {
}
}
}
_ => {}
_ => self.pending_push_calm = 0,
}
}

View file

@ -28,6 +28,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use super::super::maps;
use super::cartridge::Edge;
use super::state::MapGrid;
/// A column with no connection on it.
const NONE: u8 = 0xff;
@ -44,10 +45,13 @@ const EAST: usize = 3;
/// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is
/// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions.
///
/// Two rows are worth a note. `ROUTE_3` and `ROUTE_4` are connected along the east-west axis even
/// though Mt. Moon stands between them, so the walkable path is the cave and not the edge; that
/// costs nothing, because an edge whose tiles are not walkable produces no exit at all
/// ([`super::path::exits`] filters on the walkable predicate) and the cave is in [`LINKS`].
/// Every row is the header's own, checked line by line against the disassembly at the pinned
/// commit (row 59). Four pairs had the right neighbour in the wrong column, and a wrong column is
/// a wrong map on the other side of an edge: `ROUTE_3` / `ROUTE_4` (Route 4 is north of Route 3,
/// not east, and Route 3's top edge is the road to Mt. Moon's Pokécenter), `ROUTE_14` /
/// `ROUTE_15` and `ROUTE_24` / `ROUTE_25` (west and east, not south and north), and `ROUTE_22` /
/// `ROUTE_23`, which the table had left out. A connection nobody can walk across is still the
/// header's, and [`NO_CROSSING`] says which.
const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]),
(maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]),
@ -62,8 +66,8 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]),
(maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]),
(maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]),
(maps::ROUTE_3, [NONE, NONE, maps::PEWTER_CITY, maps::ROUTE_4]),
(maps::ROUTE_4, [NONE, NONE, maps::ROUTE_3, maps::CERULEAN_CITY]),
(maps::ROUTE_3, [maps::ROUTE_4, NONE, maps::PEWTER_CITY, NONE]),
(maps::ROUTE_4, [NONE, maps::ROUTE_3, NONE, maps::CERULEAN_CITY]),
(maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]),
(maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]),
(maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]),
@ -73,28 +77,54 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]),
(maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]),
(maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]),
(maps::ROUTE_14, [NONE, maps::ROUTE_15, NONE, maps::ROUTE_13]),
(maps::ROUTE_15, [maps::ROUTE_14, NONE, maps::FUCHSIA_CITY, NONE]),
(maps::ROUTE_14, [NONE, NONE, maps::ROUTE_15, maps::ROUTE_13]),
(maps::ROUTE_15, [NONE, NONE, maps::FUCHSIA_CITY, maps::ROUTE_14]),
(maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]),
(maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]),
(maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]),
(maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]),
(maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]),
(maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]),
// Route 22 ends at the League gate, which is a building rather than an edge, and this
// table has no id for it: Indigo Plateau is on the graph but not reachable from the south.
(maps::ROUTE_22, [NONE, NONE, NONE, maps::VIRIDIAN_CITY]),
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, NONE, NONE, NONE]),
(maps::ROUTE_24, [maps::ROUTE_25, maps::CERULEAN_CITY, NONE, NONE]),
(maps::ROUTE_25, [NONE, maps::ROUTE_24, NONE, NONE]),
(maps::ROUTE_22, [maps::ROUTE_23, NONE, NONE, maps::VIRIDIAN_CITY]),
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, maps::ROUTE_22, NONE, NONE]),
(maps::ROUTE_24, [NONE, maps::CERULEAN_CITY, NONE, maps::ROUTE_25]),
(maps::ROUTE_25, [NONE, NONE, maps::ROUTE_24, NONE]),
];
/// Connections in the headers that no step on foot crosses, from both sides.
///
/// Row 59, measured from the disassembly: for every connection, the tiles of this map's edge that
/// are walkable *and* land on a walkable tile of the other map's strip (the header's offset, the
/// other map's blocks and collision list). These eight have none. Pallet Town's south edge has
/// two walkable tiles and Route 21 is water under both; Cinnabar's east edge and Route 20's two
/// ends are sea; Route 22's north edge is the League's fence, and the road is its gate, a building
/// the graph has no row for. The map on the other side is still named ([`connected`]), and a
/// surfer's road is for a later row; on foot none of them is a way out
/// ([`super::path::exits`] offers no exit on them) or a road ([`neighbours`] leaves them out).
/// Without this the road from Pallet Town to Cerulean was by sea, and a fly that whited out in
/// Mt. Moon walked into Pallet's shore once every two seconds.
const NO_CROSSING: &[(u8, Edge)] = &[
(maps::PALLET_TOWN, Edge::South),
(maps::ROUTE_21, Edge::North),
(maps::CINNABAR_ISLAND, Edge::East),
(maps::ROUTE_20, Edge::West),
(maps::ROUTE_20, Edge::East),
(maps::ROUTE_19, Edge::West),
(maps::ROUTE_22, Edge::North),
(maps::ROUTE_23, Edge::South),
];
/// Whether a step off `map`'s `edge` can land on the other map on foot ([`NO_CROSSING`]).
pub fn crossable(map: u8, edge: Edge) -> bool {
!NO_CROSSING.contains(&(map, edge))
}
/// Doors and floor changes, as undirected pairs of maps.
///
/// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an
/// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim
/// at from another map and changes nothing else. A cave with two mouths appears twice, which is
/// what makes Mt. Moon a way from Route 3 to Route 4.
/// at from another map and changes nothing else. Every pair is two warp tables that name each
/// other (a `LAST_MAP` door resolved to the one outdoor map whose warps lead in).
const LINKS: &[(u8, u8)] = &[
(maps::REDS_HOUSE_1F, maps::PALLET_TOWN),
(maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F),
@ -122,10 +152,14 @@ const LINKS: &[(u8, u8)] = &[
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
(maps::PEWTER_MART, maps::PEWTER_CITY),
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
(maps::MT_MOON_1F, maps::ROUTE_3),
// Mt. Moon has two mouths, and both are on Route 4 (`data/maps/objects/Route4.asm`): (18, 5)
// into the first floor, and (24, 5) into B1F, whose (27, 3) is the way back out on the far
// side of the mountain. Route 3 has no warps at all. Which chamber of B1F and B2F each ladder
// opens onto is [`SPLIT`]'s business.
(maps::MT_MOON_1F, maps::ROUTE_4),
(maps::MT_MOON_1F, maps::MT_MOON_B1F),
(maps::MT_MOON_B1F, maps::MT_MOON_B2F),
(maps::MT_MOON_B1F, maps::ROUTE_4),
(maps::CERULEAN_GYM, maps::CERULEAN_CITY),
(maps::CERULEAN_MART, maps::CERULEAN_CITY),
(maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY),
@ -141,11 +175,12 @@ const LINKS: &[(u8, u8)] = &[
/// the forest's south gate; the north half touches Pewter City and the forest's north gate; the
/// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two
/// hops from the south gate, south" — which is a road that does not exist — and sent the fly back
/// out of the gate it had just walked into, once per hold, for four hours.
/// out of the gate it had just walked into, once per hold, for four hours. Row 59 found three more
/// on the road to Cerulean: Route 4, and Mt. Moon's two lower floors ([`SPLIT`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Region {
pub map: u8,
/// Which piece, for a map [`SPLIT`] has a row for; 0 everywhere else.
/// Which piece, for a map [`SPLIT`] has a row for: its index in that row. 0 everywhere else.
pub part: u8,
}
@ -154,108 +189,268 @@ impl Region {
pub const fn whole(map: u8) -> Self {
Self { map, part: 0 }
}
/// Piece `part` of a map [`SPLIT`] has a row for.
pub const fn piece(map: u8, part: u8) -> Self {
Self { map, part }
}
}
/// [`SPLIT`]'s two piece numbers.
const NORTH_PIECE: u8 = 0;
const SOUTH_PIECE: u8 = 1;
/// One piece of a split map.
struct Piece {
/// The map's own warps that stand on this piece's ground: the index into its warp table (as
/// `wWarpEntries` holds it, and as a warp elsewhere names it for its destination, 0-based) and
/// the tile. A warp that lands on one of these lands in this piece; the tiles are what the
/// fly's own piece is told apart by ([`region_on`]).
doors: &'static [(u8, u8, u8)],
/// Everything one step from this piece: a whole map, or a piece of another split map. Their
/// maps together are exactly [`neighbours`]'s answer for the map, and every step is listed
/// back from the other side; [`tests::a_split_maps_pieces_add_up_and_answer_each_other`] pins
/// both.
next: &'static [Region],
}
/// A map whose walkable ground is in two pieces, and which of its neighbours each piece touches.
/// A map whose walkable ground is in pieces the player cannot walk between.
struct Split {
map: u8,
/// The tile rows each piece's own doorway is on, measured from the cartridge: a tile belongs
/// to the piece whose row it is nearer to. Anchoring on the doorways rather than on a row in
/// the middle means the number comes from the warp table rather than from a claim about where
/// the trees are, and a tile in the impassable belt between them — ground the fly cannot
/// stand on — is the only place the answer could be wrong.
north_door: u8,
south_door: u8,
/// The neighbours reachable from each piece. Together they are exactly [`neighbours`]'s answer
/// for the map, which [`tests::a_split_maps_pieces_divide_its_neighbours_between_them`] pins.
north: &'static [u8],
south: &'static [u8],
pieces: &'static [Piece],
}
/// Every map whose ground is in two pieces. One row, and it took four hours of stream to find.
/// Route 2's two halves, in [`SPLIT`]'s order.
#[cfg(test)]
const NORTH_PIECE: u8 = 0;
#[cfg(test)]
const SOUTH_PIECE: u8 = 1;
/// Route 4's two sides of the mountain.
#[cfg(test)]
const WEST_SIDE: u8 = 0;
const EAST_SIDE: u8 = 1;
/// Mt. Moon B1F's four chambers, named by what they hold.
const B1F_EXIT: u8 = 0;
const B1F_WEST: u8 = 1;
const B1F_MIDDLE: u8 = 2;
const B1F_SOUTH: u8 = 3;
/// Mt. Moon B2F's three pieces.
const B2F_MAIN: u8 = 0;
const B2F_NORTH: u8 = 1;
const B2F_SOUTH: u8 = 2;
/// Every map whose ground is in pieces, with each piece's doors and neighbours.
///
/// `ROUTE_2`, surveyed from the cartridge on 2026-09-17 (`docs/design/macros-wram.md`'s method,
/// the run recorded in `infra/docs/macros-traps.md` row 33). The map is 20 by 72 and its warp
/// table reads:
/// Every row is measured from the disassembly at the pinned commit: the map's blocks, its
/// tileset's blockset and collision list, the tile-pair walls and the ledges, flooded tile by tile
/// (`infra/docs/macros-traps.md` row 59 has the method). A map is listed here only when two of its
/// ways out are on different pieces, and the audit ran over every map on the graph.
///
/// | warp | tile | into |
/// | ---: | --- | --- |
/// | 0 | (12, 9) | `DIGLETTS_CAVE_ROUTE_2` (46) |
/// | 1 | (3, 11) | `VIRIDIAN_FOREST_NORTH_GATE` (47) |
/// | 2 | (15, 19) | `ROUTE_2_TRADE_HOUSE` (48) |
/// | 3 | (16, 35) | `ROUTE_2_GATE` (49) |
/// | 4 | (15, 39) | `ROUTE_2_GATE` (49) |
/// | 5 | (3, 43) | `VIRIDIAN_FOREST_SOUTH_GATE` (50) |
///
/// with `north: true` and `south: true` in `wCurMapConnections` — Pewter off the top row, Viridian
/// off the bottom one. The two forest gates at rows 11 and 43 are the doorways this splits on.
///
/// Maps 46, 48 and 49 are deliberately *not* on the graph, which is this module's standing rule
/// for a building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2`
/// itself, so it is a shortcut within one map rather than a way between two, and 46 and 48 are
/// ends of the line. A route the table does not carry is simply not offered; nothing is guessed.
const SPLIT: &[Split] = &[Split {
/// - **`ROUTE_2`** (row 33): the forest's north gate at (3, 11) and Pewter's edge; the south gate
/// at (3, 43) and Viridian's. Maps 46, 48 and 49 stay off the graph, the module's standing rule
/// for a building no rung place needs a route through: 49's two doors are both Route 2's own.
/// - **`ROUTE_4`** (row 59): Mt. Moon stands across it. The west side holds the Pokécenter at
/// (11, 5), the cave mouth at (18, 5) and the road down to Route 3; the east side holds B1F's
/// exit at (24, 5) and the ledges down to Cerulean. From the Pewter side the only way east is
/// through the mountain.
/// - **`MT_MOON_B1F`** (row 59): four chambers, each two ladders and nothing between them. The
/// one road through is 1F (5, 5) to the west chamber, (21, 17) down to B2F, B2F (5, 7) up to the
/// exit chamber, (27, 3) out onto Route 4's east side. The middle and south chambers are ladders
/// to dead ends on B2F.
/// - **`MT_MOON_B2F`** (row 59): the fossil floor, one large piece with the two ladders the road
/// uses, and two small pieces under the dead-end ladders.
const SPLIT: &[Split] = &[
Split {
map: maps::ROUTE_2,
north_door: 11,
south_door: 43,
north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE],
south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE],
}];
pieces: &[
Piece {
doors: &[(1, 3, 11)],
next: &[
Region::whole(maps::PEWTER_CITY),
Region::whole(maps::VIRIDIAN_FOREST_NORTH_GATE),
],
},
Piece {
doors: &[(5, 3, 43)],
next: &[
Region::whole(maps::VIRIDIAN_CITY),
Region::whole(maps::VIRIDIAN_FOREST_SOUTH_GATE),
],
},
],
},
Split {
map: maps::ROUTE_4,
pieces: &[
Piece {
doors: &[(0, 11, 5), (1, 18, 5)],
next: &[Region::whole(maps::ROUTE_3), Region::whole(maps::MT_MOON_1F)],
},
Piece {
doors: &[(2, 24, 5)],
next: &[
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::whole(maps::CERULEAN_CITY),
],
},
],
},
Split {
map: maps::MT_MOON_B1F,
pieces: &[
Piece {
doors: &[(6, 23, 3), (7, 27, 3)],
next: &[
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
Region::piece(maps::ROUTE_4, EAST_SIDE),
],
},
Piece {
doors: &[(0, 5, 5), (4, 21, 17)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
],
},
Piece {
doors: &[(1, 17, 11), (2, 25, 9)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_NORTH),
],
},
Piece {
doors: &[(3, 25, 15), (5, 13, 27)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH),
],
},
],
},
Split {
map: maps::MT_MOON_B2F,
pieces: &[
Piece {
doors: &[(1, 21, 17), (3, 5, 7)],
next: &[
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
],
},
Piece {
doors: &[(0, 25, 9)],
next: &[Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)],
},
Piece {
doors: &[(2, 15, 27)],
next: &[Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)],
},
],
},
];
fn split_of(map: u8) -> Option<&'static Split> {
SPLIT.iter().find(|split| split.map == map)
}
/// The piece of `map` a tile on row `y` is in.
///
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. Callers pass the player's own
/// row, which is the only thing that can tell the two halves of `ROUTE_2` apart.
pub fn region_at(map: u8, y: u8) -> Region {
match split_of(map) {
None => Region::whole(map),
Some(split) => {
let north = y.abs_diff(split.north_door);
let south = y.abs_diff(split.south_door);
Region { map, part: if north <= south { NORTH_PIECE } else { SOUTH_PIECE } }
}
}
/// The pieces of a split map with their numbers, which are their [`Region::part`]s.
fn pieces(split: &'static Split) -> impl Iterator<Item = (u8, &'static Piece)> {
split.pieces.iter().enumerate().filter_map(|(part, piece)| Some((u8::try_from(part).ok()?, piece)))
}
/// The piece of `map` that `from` opens onto, or `None` when no piece of it touches `from`.
/// The piece of `map` the tile `(x, y)` is in, by the doors alone.
///
/// This is the reverse of [`region_at`] and it needs no tile: a door or an edge is listed under
/// exactly one piece, so "which half of Route 2 does the north gate open onto" is a table lookup.
/// `None` is an edge the graph does not have -- the south half of Route 2 is not reachable from
/// Pewter City, whatever the map ids alone would suggest.
fn region_toward(map: u8, from: u8) -> Option<Region> {
match split_of(map) {
None => Some(Region::whole(map)),
Some(split) => {
if split.north.contains(&from) {
Some(Region { map, part: NORTH_PIECE })
} else if split.south.contains(&from) {
Some(Region { map, part: SOUTH_PIECE })
} else {
None
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. On a split map it is the piece
/// with the nearest door, counting tiles across and down, the first piece on a tie. That is exact
/// for every tile of Route 2's and Route 4's ground, which is where the grid cannot answer
/// ([`region_on`]: a ledge is a one-way step the grid does not model), and it is only the fallback
/// on Mt. Moon's floors, whose chambers wrap round each other.
pub fn region_at(map: u8, x: u8, y: u8) -> Region {
let Some(split) = split_of(map) else { return Region::whole(map) };
let distance = |piece: &Piece| {
piece
.doors
.iter()
.map(|(_, dx, dy)| u16::from(x.abs_diff(*dx)) + u16::from(y.abs_diff(*dy)))
.min()
.unwrap_or(u16::MAX)
};
let part = pieces(split).min_by_key(|(part, piece)| (distance(piece), *part)).map_or(0, |(part, _)| part);
Region { map, part }
}
/// The piece of `map` the fly standing on `(x, y)` is in.
///
/// The ground decides: with the decoded map grid (section 15) the piece is the one whose doors a
/// walk from here can reach, when exactly one piece's can. The grid has no ledges -- a ledge is a
/// step one way only, and the grid reads it as a wall -- so on the part of Route 4 below the
/// ledges no door is reachable and [`region_at`] answers from the doors. Row 59 flooded every tile
/// of every piece's ground in the disassembly under this rule and it names the right piece for
/// all of them.
pub fn region_on(map: u8, x: u8, y: u8, grid: Option<&MapGrid>) -> Region {
let Some(split) = split_of(map) else { return Region::whole(map) };
if let Some(grid) = grid.filter(|grid| grid.map() == map) {
let walk = grid.reachable(x, y);
// A door tile the collision list refuses is still stepped onto from beside it.
let reached = |dx: u8, dy: u8| {
walk.contains(dx, dy)
|| [(0i16, 1i16), (0, -1), (1, 0), (-1, 0)].iter().any(|(ox, oy)| {
match (u8::try_from(i16::from(dx) + ox), u8::try_from(i16::from(dy) + oy)) {
(Ok(nx), Ok(ny)) => walk.contains(nx, ny),
_ => false,
}
})
};
let mut hit =
pieces(split).filter(|(_, piece)| piece.doors.iter().any(|(_, dx, dy)| reached(*dx, *dy)));
if let (Some((part, _)), None) = (hit.next(), hit.next()) {
return Region { map, part };
}
}
region_at(map, x, y)
}
/// The piece of `map` a warp lands in when it names `map`'s warp `index` as its destination.
///
/// `None` only for a split map whose table does not list that warp, which is not guessed at.
pub fn arrival_by_warp(map: u8, index: u8) -> Option<Region> {
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
pieces(split)
.find(|(_, piece)| piece.doors.iter().any(|(door, _, _)| *door == index))
.map(|(part, _)| Region { map, part })
}
/// The piece of `map` that stepping off an edge of `from` lands in.
///
/// An edge is listed under exactly one piece of a split map: Pewter's south edge opens onto Route
/// 2's north half, Route 3's north edge onto Route 4's west side and Cerulean's west edge onto its
/// east side. `None` is an edge the graph does not have.
pub fn arrival_by_edge(map: u8, from: u8) -> Option<Region> {
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
pieces(split)
.find(|(_, piece)| piece.next.iter().any(|next| next.map == from))
.map(|(part, _)| Region { map, part })
}
/// Every piece of ground one step from `region`.
fn region_neighbours(region: Region) -> Vec<Region> {
let of = |map: u8| region_toward(map, region.map);
match split_of(region.map) {
None => neighbours(region.map).into_iter().filter_map(of).collect(),
Some(split) => {
let own = if region.part == NORTH_PIECE { split.north } else { split.south };
own.iter().copied().filter_map(of).collect()
if let Some(split) = split_of(region.map) {
return split.pieces.get(usize::from(region.part)).map_or_else(Vec::new, |piece| piece.next.to_vec());
}
// A whole map steps onto every piece of a split neighbour that lists it back: Mt. Moon's
// first floor has a ladder into three of B1F's four chambers.
let mut out = Vec::new();
for map in neighbours(region.map) {
match split_of(map) {
None => out.push(Region::whole(map)),
Some(split) => out.extend(
pieces(split)
.filter(|(_, piece)| piece.next.contains(&region))
.map(|(part, _)| Region { map, part }),
),
}
}
out
}
/// The map on the other side of `map`'s `edge`, or `None` where the table does not know.
@ -280,11 +475,20 @@ pub fn outdoor_of(interior: u8) -> Option<u8> {
neighbours(interior).into_iter().find(|map| super::cartridge::outdoors(*map))
}
/// Every map one step from `map`, doors and edges together, deduplicated and in id order.
/// Every map one step on foot from `map`, doors and edges together, deduplicated and in id order.
///
/// A connection with no crossing ([`NO_CROSSING`]) is not a step on foot and is left out.
pub fn neighbours(map: u8) -> Vec<u8> {
const EDGES: [Edge; 4] = [Edge::North, Edge::South, Edge::West, Edge::East];
let mut out: Vec<u8> = Vec::new();
if let Some(row) = CONNECTIONS.iter().find(|(id, _)| *id == map) {
out.extend(row.1.iter().copied().filter(|id| *id != NONE));
out.extend(
row.1
.iter()
.zip(EDGES)
.filter(|(id, edge)| **id != NONE && crossable(map, *edge))
.map(|(id, _)| *id),
);
}
for (a, b) in LINKS {
if *a == map {
@ -296,7 +500,7 @@ pub fn neighbours(map: u8) -> Vec<u8> {
}
// Symmetric closure: a row that names a neighbour is a connection whichever side lists it.
for (id, row) in CONNECTIONS {
if row.contains(&map) {
if row.iter().zip(EDGES).any(|(other, edge)| *other == map && crossable(*id, edge)) {
out.push(*id);
}
}
@ -313,16 +517,23 @@ pub fn neighbours(map: u8) -> Vec<u8> {
/// an unreachable one, and for `from == to` -- there is no hop to take when the fly is already
/// there, and `GO OBJECTIVE` has its own answer for that case.
pub fn next_hop(from: Region, to: u8) -> Option<u8> {
next_step(from, to).map(|hop| hop.map)
}
/// [`next_hop`] with the piece it lands in, which is what an exit has to match on a map with two
/// doors into one split map: three of Mt. Moon's first-floor ladders go down to B1F, and only
/// one of them reaches the way out ([`arrival_by_warp`] names where each one lands).
pub fn next_step(from: Region, to: u8) -> Option<Region> {
if from.map == to {
return None;
}
let mut seen: HashSet<Region> = HashSet::from([from]);
// piece -> the first hop out of `from` that reaches it
let mut first: HashMap<Region, u8> = HashMap::new();
let mut first: HashMap<Region, Region> = HashMap::new();
let mut queue: VecDeque<Region> = VecDeque::new();
for hop in region_neighbours(from) {
if seen.insert(hop) {
first.insert(hop, hop.map);
first.insert(hop, hop);
queue.push_back(hop);
}
}
@ -495,6 +706,62 @@ mod tests {
}
}
#[test]
fn the_rows_the_disassembly_corrected_say_what_its_headers_say() {
// Row 59, `data/maps/headers/*.asm` at the pinned commit. Route 4 is north of Route 3:
// Route 3's top edge is the road to Mt. Moon's Pokécenter, and Route 3 has no east exit.
assert_eq!(connected(maps::ROUTE_3, Edge::North), Some(maps::ROUTE_4));
assert_eq!(connected(maps::ROUTE_3, Edge::East), None);
assert_eq!(connected(maps::ROUTE_4, Edge::South), Some(maps::ROUTE_3));
assert_eq!(connected(maps::ROUTE_4, Edge::West), None);
assert_eq!(connected(maps::ROUTE_4, Edge::East), Some(maps::CERULEAN_CITY));
// Nugget Bridge's far end is Route 24's east edge, not its north one.
assert_eq!(connected(maps::ROUTE_24, Edge::East), Some(maps::ROUTE_25));
assert_eq!(connected(maps::ROUTE_24, Edge::North), None);
assert_eq!(connected(maps::ROUTE_25, Edge::West), Some(maps::ROUTE_24));
assert_eq!(connected(maps::ROUTE_25, Edge::South), None);
assert_eq!(connected(maps::ROUTE_14, Edge::West), Some(maps::ROUTE_15));
assert_eq!(connected(maps::ROUTE_15, Edge::East), Some(maps::ROUTE_14));
// Mt. Moon's two mouths are both on Route 4, and Route 3 has no warps: a cave door that
// is not there is a road the fly walks up and down for ever (row 59's Pewter ring).
assert!(!neighbours(maps::ROUTE_3).contains(&maps::MT_MOON_1F));
assert_eq!(outdoor_of(maps::MT_MOON_1F), Some(maps::ROUTE_4));
assert_eq!(outdoor_of(maps::MT_MOON_B1F), Some(maps::ROUTE_4));
assert_eq!(
neighbours(maps::ROUTE_3),
vec![maps::PEWTER_CITY, maps::ROUTE_4],
"Route 3 is a road between two maps and nothing else"
);
}
#[test]
fn a_connection_nobody_can_walk_across_is_named_and_is_not_a_road() {
// Row 59: Pallet Town's shore. The header connects it to Route 21, which is water.
assert_eq!(connected(maps::PALLET_TOWN, Edge::South), Some(maps::ROUTE_21));
assert!(!crossable(maps::PALLET_TOWN, Edge::South));
assert!(!neighbours(maps::PALLET_TOWN).contains(&maps::ROUTE_21));
assert!(!neighbours(maps::ROUTE_21).contains(&maps::PALLET_TOWN));
// So the road from Pallet Town to Cerulean is the long one on land: north, through the
// forest, Pewter and Mt. Moon, and not by sea through Cinnabar and Fuchsia.
assert_eq!(next_hop(Region::whole(maps::PALLET_TOWN), maps::CERULEAN_CITY), Some(maps::ROUTE_1));
assert_eq!(hops(Region::whole(maps::PALLET_TOWN), maps::CERULEAN_CITY), Some(16));
// Every entry is one of the header's own connections, and both sides are listed.
for (map, edge) in NO_CROSSING {
let other = connected(*map, *edge).expect("a no-crossing entry is a header connection");
let back = match edge {
Edge::North => Edge::South,
Edge::South => Edge::North,
Edge::West => Edge::East,
Edge::East => Edge::West,
};
assert!(!crossable(other, back), "{other:#04x} lists {map:#04x} as crossable");
}
// Indigo Plateau is on the graph and, until a row gives the League gate, not on foot
// from the south.
assert_eq!(connected(maps::ROUTE_22, Edge::North), Some(maps::ROUTE_23));
assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::INDIGO_PLATEAU), None);
}
#[test]
fn a_front_door_resolves_to_the_town_outside_it() {
assert_eq!(outdoor_of(maps::OAKS_LAB), Some(maps::PALLET_TOWN));
@ -520,7 +787,7 @@ mod tests {
assert_eq!(next_hop(at(maps::OAKS_LAB), maps::VIRIDIAN_MART), Some(maps::PALLET_TOWN));
// Upstairs is two hops from the town, through the ground floor.
assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::REDS_HOUSE_2F), Some(maps::REDS_HOUSE_1F));
// Through the cave, because Route 3 and Route 4 are the same two maps either way round.
// Out along Route 3, whose only other end is the road up to Mt. Moon.
assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::CERULEAN_GYM), Some(maps::ROUTE_3));
// Nowhere to go, and nowhere known.
assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::PALLET_TOWN), None);
@ -528,37 +795,169 @@ mod tests {
}
#[test]
fn a_split_maps_pieces_divide_its_neighbours_between_them() {
// The invariant that keeps [`SPLIT`] honest: a piece's own list is a real subset of the
// map's neighbours, the two pieces together are all of them, and neither claims the same
// neighbour twice. A typo here is a road that does not exist.
fn a_split_maps_pieces_add_up_and_answer_each_other() {
// The invariants that keep [`SPLIT`] honest. A typo in any of them is a road that does
// not exist, or a door that leads nowhere.
for split in SPLIT {
let mut both: Vec<u8> =
split.north.iter().chain(split.south.iter()).copied().collect();
both.sort_unstable();
let mut once = both.clone();
once.dedup();
assert_eq!(both, once, "{:#04x} lists a neighbour under both pieces", split.map);
// The pieces' neighbours together are exactly the map's.
let mut maps_of: Vec<u8> =
split.pieces.iter().flat_map(|piece| piece.next.iter().map(|r| r.map)).collect();
maps_of.sort_unstable();
maps_of.dedup();
assert_eq!(
both,
maps_of,
neighbours(split.map),
"{:#04x}'s pieces do not add up to its neighbours",
split.map
);
assert_ne!(split.north_door, split.south_door);
let mut doors: Vec<u8> =
split.pieces.iter().flat_map(|piece| piece.doors.iter().map(|d| d.0)).collect();
doors.sort_unstable();
let count = doors.len();
doors.dedup();
assert_eq!(doors.len(), count, "{:#04x} lists one warp under two pieces", split.map);
for (part, piece) in pieces(split) {
let here = Region { map: split.map, part };
assert!(!piece.doors.is_empty(), "{here:?} has no door to be told apart by");
// A door's own tile is in its own piece.
for (_, x, y) in piece.doors {
assert_eq!(region_at(split.map, *x, *y), here, "door ({x}, {y})");
}
// Every step is listed back from the other side, so a route is reversible.
for next in piece.next {
assert!(
region_neighbours(*next).contains(&here),
"{next:?} does not step back onto {here:?}"
);
if let Some(other) = split_of(next.map) {
assert!(usize::from(next.part) < other.pieces.len(), "{next:?}");
}
}
}
}
}
#[test]
fn the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time() {
// Row 59. Every step of the road, as the pieces the fly stands in, measured from the
// disassembly: Route 3's top edge, Route 4's west side, the cave mouth at (18, 5), 1F's
// ladder at (5, 5), B1F's west chamber, its ladder at (21, 17), B2F, its ladder at (5, 7),
// B1F's exit chamber, (27, 3), Route 4's east side, Cerulean.
let road = [
Region::whole(maps::PEWTER_CITY),
Region::whole(maps::ROUTE_3),
Region::piece(maps::ROUTE_4, WEST_SIDE),
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::piece(maps::ROUTE_4, EAST_SIDE),
Region::whole(maps::CERULEAN_CITY),
];
for pair in road.windows(2) {
assert_eq!(next_step(pair[0], maps::CERULEAN_CITY), Some(pair[1]), "from {:?}", pair[0]);
}
assert_eq!(hops(road[0], maps::CERULEAN_CITY), Some(8));
// Mt. Moon's rung is the first floor, one hop from the cave mouth's side of Route 4 --
// which is where the Pewter ring said the fly could never get to.
assert_eq!(next_hop(Region::whole(maps::PEWTER_CITY), maps::MT_MOON_1F), Some(maps::ROUTE_3));
assert_eq!(next_hop(Region::whole(maps::ROUTE_3), maps::MT_MOON_1F), Some(maps::ROUTE_4));
assert_eq!(
next_hop(Region::piece(maps::ROUTE_4, WEST_SIDE), maps::MT_MOON_1F),
Some(maps::MT_MOON_1F)
);
// The dead ends lead back the way they came.
assert_eq!(
next_step(Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE), maps::CERULEAN_CITY),
Some(Region::whole(maps::MT_MOON_1F))
);
assert_eq!(
next_step(Region::piece(maps::MT_MOON_B2F, B2F_SOUTH), maps::CERULEAN_CITY),
Some(Region::piece(maps::MT_MOON_B1F, B1F_SOUTH))
);
// From Cerulean's side of the mountain the way back to Pewter is the cave, not Route 4's
// south edge, which is on the other side.
assert_eq!(
next_step(Region::piece(maps::ROUTE_4, EAST_SIDE), maps::PEWTER_CITY),
Some(Region::piece(maps::MT_MOON_B1F, B1F_EXIT))
);
assert_eq!(
next_step(Region::whole(maps::CERULEAN_CITY), maps::ROUTE_24),
Some(Region::whole(maps::ROUTE_24))
);
assert_eq!(next_hop(Region::whole(maps::ROUTE_24), maps::ROUTE_25), Some(maps::ROUTE_25));
}
#[test]
fn a_door_or_an_edge_lands_in_the_piece_it_opens_onto() {
// Which warp of the destination a warp names is the cartridge's own answer (`wWarpEntries`
// byte 2, 0-based): 1F's ladders are B1F's warps 0, 2 and 3.
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 0), Some(Region::piece(maps::MT_MOON_B1F, B1F_WEST)));
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 2), Some(Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)));
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 3), Some(Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)));
// B1F's exit is `LAST_MAP` warp 2: Route 4's (24, 5), the far side of the mountain.
assert_eq!(arrival_by_warp(maps::ROUTE_4, 2), Some(Region::piece(maps::ROUTE_4, EAST_SIDE)));
// 1F's doormat is `LAST_MAP` warp 1: the cave mouth, the Pewter side.
assert_eq!(arrival_by_warp(maps::ROUTE_4, 1), Some(Region::piece(maps::ROUTE_4, WEST_SIDE)));
// The forest gates' doormats onto Route 2 are its warps 1 and 5.
assert_eq!(arrival_by_warp(maps::ROUTE_2, 1), Some(Region::piece(maps::ROUTE_2, NORTH_PIECE)));
assert_eq!(arrival_by_warp(maps::ROUTE_2, 5), Some(Region::piece(maps::ROUTE_2, SOUTH_PIECE)));
// A warp the table does not list is not guessed at; a whole map is always whole.
assert_eq!(arrival_by_warp(maps::ROUTE_2, 0), None);
assert_eq!(arrival_by_warp(maps::PEWTER_GYM, 0), Some(Region::whole(maps::PEWTER_GYM)));
// Edges.
assert_eq!(arrival_by_edge(maps::ROUTE_4, maps::ROUTE_3), Some(Region::piece(maps::ROUTE_4, WEST_SIDE)));
assert_eq!(
arrival_by_edge(maps::ROUTE_4, maps::CERULEAN_CITY),
Some(Region::piece(maps::ROUTE_4, EAST_SIDE))
);
assert_eq!(arrival_by_edge(maps::ROUTE_2, maps::PEWTER_CITY), Some(Region::piece(maps::ROUTE_2, NORTH_PIECE)));
assert_eq!(arrival_by_edge(maps::ROUTE_3, maps::ROUTE_4), Some(Region::whole(maps::ROUTE_3)));
}
#[test]
fn route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot() {
// With no grid, the nearest door: the cave mouth's side reaches down to Route 3's road at
// (7..11, 17), and Cerulean's side is everything east of the mountain.
assert_eq!(region_at(maps::ROUTE_4, 9, 17), Region::piece(maps::ROUTE_4, WEST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 18, 6), Region::piece(maps::ROUTE_4, WEST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 24, 6), Region::piece(maps::ROUTE_4, EAST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 89, 10), Region::piece(maps::ROUTE_4, EAST_SIDE));
assert_eq!(region_at(maps::ROUTE_3, 60, 0), Region::whole(maps::ROUTE_3));
// B2F's chambers wrap round each other, so there the grid decides.
let mut grid = MapGrid::new(maps::MT_MOON_B2F, 40, 36);
// A corridor from B2F's (5, 7) ladder east along row 7 and down column 33 to (33, 31):
// nearer the (15, 27) ladder than either of its own, and on the main piece.
for x in 4..=33 {
grid.set(x, 7, 0, super::super::state::Walkable::Yes);
}
for y in 7..=31 {
grid.set(33, y, 0, super::super::state::Walkable::Yes);
}
assert_eq!(region_at(maps::MT_MOON_B2F, 33, 31), Region::piece(maps::MT_MOON_B2F, B2F_SOUTH));
assert_eq!(
region_on(maps::MT_MOON_B2F, 33, 31, Some(&grid)),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN)
);
// A grid of another map says nothing, and neither does none.
let other = MapGrid::new(maps::ROUTE_4, 90, 18);
assert_eq!(
region_on(maps::MT_MOON_B2F, 33, 31, Some(&other)),
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH)
);
assert_eq!(region_on(maps::MT_MOON_B2F, 33, 31, None), Region::piece(maps::MT_MOON_B2F, B2F_SOUTH));
}
#[test]
fn route_2s_halves_are_told_apart_by_the_row_the_fly_is_standing_on() {
// The two doorways are rows 11 and 43, measured from the cartridge's own warp table.
assert_eq!(region_at(maps::ROUTE_2, 11).part, NORTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 43).part, SOUTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 0).part, NORTH_PIECE, "Pewter's end");
assert_eq!(region_at(maps::ROUTE_2, 71).part, SOUTH_PIECE, "Viridian's end");
assert_eq!(region_at(maps::ROUTE_2, 3, 11).part, NORTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 3, 43).part, SOUTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 8, 0).part, NORTH_PIECE, "Pewter's end");
assert_eq!(region_at(maps::ROUTE_2, 8, 71).part, SOUTH_PIECE, "Viridian's end");
// Every other map is one piece, whatever row is asked about.
assert_eq!(region_at(maps::ROUTE_1, 30), Region::whole(maps::ROUTE_1));
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 7).part, 0);
assert_eq!(region_at(maps::ROUTE_1, 10, 30), Region::whole(maps::ROUTE_1));
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 4, 7).part, 0);
}
#[test]
@ -578,16 +977,16 @@ mod tests {
Some(maps::ROUTE_2)
);
// Route 2's north half steps off its own top edge; its south half walks to the gate.
assert_eq!(next_hop(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
assert_eq!(next_hop(region_at(maps::ROUTE_2, 3, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
assert_eq!(
next_hop(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY),
next_hop(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY),
Some(maps::VIRIDIAN_FOREST_SOUTH_GATE)
);
// From Viridian City the first hop is still Route 2, which is the road the fly takes north.
assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::PEWTER_CITY), Some(maps::ROUTE_2));
// And the way back south from the north half is the forest, not Route 2's own bottom edge.
assert_eq!(
next_hop(region_at(maps::ROUTE_2, 11), maps::VIRIDIAN_CITY),
next_hop(region_at(maps::ROUTE_2, 3, 11), maps::VIRIDIAN_CITY),
Some(maps::VIRIDIAN_FOREST_NORTH_GATE)
);
}
@ -645,11 +1044,11 @@ mod tests {
assert_eq!(here.map, maps::PEWTER_GYM);
assert_eq!(steps, 3);
// A split map is measured from the piece the fly is standing in, exactly as `next_hop` is.
assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1));
assert_eq!(hops(region_at(maps::ROUTE_2, 3, 11), maps::PEWTER_CITY), Some(1));
// Five from the south half, because the belt of trees between the halves needs CUT and
// the road is the forest: the south gate, the forest, the north gate, Route 2's north
// half, Pewter.
assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5));
assert_eq!(hops(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY), Some(5));
// And nothing is guessed.
assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None);
}

View file

@ -1422,14 +1422,47 @@ fn exit_tiers(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
/// was in. Empty when there is no objective and when it is on this map.
pub fn toward_objective(state: &mut dyn MacroState, candidates: &[Exit]) -> Vec<Exit> {
let Some(objective) = objective_place(state) else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() };
let here = player.map;
let Some(from) = region_here(state) else { return Vec::new() };
let here = from.map;
if here == objective.map {
return Vec::new();
}
let hop = geography::next_hop(geography::region_at(here, player.y), objective.map);
let aim = hop.unwrap_or(objective.map);
candidates.iter().copied().filter(|exit| exit.destination(here) == Some(aim)).collect()
match geography::next_step(from, objective.map) {
Some(hop) => {
candidates.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect()
}
None => candidates
.iter()
.copied()
.filter(|exit| exit.destination(here) == Some(objective.map))
.collect(),
}
}
/// The piece of ground the fly is standing in: its map, and on a map whose ground is in pieces
/// the piece its walk can reach the doors of (`docs/design/macros.md` sections 12.7 and 12.24).
pub fn region_here(state: &mut dyn MacroState) -> Option<geography::Region> {
let player = state.player()?;
let grid = state.map_grid();
Some(geography::region_on(player.map, player.x, player.y, grid.as_deref()))
}
/// Whether `exit` takes the fly onto `hop`: the map on the other side, and on a map whose ground
/// is in pieces, the piece it lands in. A warp names the destination's warp it arrives at, which
/// is what tells Mt. Moon's three ladders down to B1F apart (section 12.24); an edge lands in the
/// piece that lists the map it is stepped off. A landing the table cannot name is not a match.
fn leads_to(state: &mut dyn MacroState, exit: &Exit, here: u8, hop: geography::Region) -> bool {
if exit.destination(here) != Some(hop.map) {
return false;
}
let landing = match exit.id {
ExitId::Warp(index) => state
.warps()
.get(usize::from(index))
.and_then(|warp| geography::arrival_by_warp(hop.map, warp.destination_warp)),
ExitId::Edge(_) => geography::arrival_by_edge(hop.map, here),
};
landing == Some(hop)
}
/// The people on this map still worth walking to, each with the key the ledgers name it by.
@ -1768,9 +1801,10 @@ pub fn goals_toward(state: &mut dyn MacroState, target: u8) -> Vec<Aim> {
})
.collect()
};
if let Some(hop) = geography::next_hop(geography::region_at(here, player.y), target) {
let hop = region_here(state).and_then(|from| geography::next_step(from, target));
if let Some(hop) = hop {
let toward: Vec<Exit> =
exits.iter().copied().filter(|exit| exit.destination(here) == Some(hop)).collect();
exits.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect();
if !toward.is_empty() {
return of(toward);
}

View file

@ -430,7 +430,9 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
Edge::East => connections.east,
Edge::West => connections.west,
};
if !connected {
// A connection the headers name and no step on foot crosses -- Pallet Town's shore --
// is not a way out (`geography::NO_CROSSING`, row 59).
if !connected || !geography::crossable(player.map, edge) {
continue;
}
// A step off the edge of an outdoor map is the next area; off an interior one -- which

View file

@ -536,18 +536,22 @@ impl MapGrid {
/// reachable ones is fenced in, and no amount of re-planning is going to help it
/// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`).
pub fn reachable_from(&self, x: u8, y: u8) -> usize {
if self.index(x, y).is_none() {
return 0;
self.reachable(x, y).iter().filter(|seen| **seen).count()
}
/// Whether `(tx, ty)` is among the tiles [`MapGrid::reachable_from`] counts from `(x, y)`.
///
/// The whole flood at once, row-major like the grid itself, so a caller asking about several
/// tiles pays for one walk. Off the map is never reachable.
pub fn reachable(&self, x: u8, y: u8) -> Reachable {
let mut seen = vec![false; self.tiles.len()];
let Some(start) = self.index(x, y) else {
return Reachable { width: self.width, seen };
};
seen[start] = true;
let mut queue = std::collections::VecDeque::new();
if let Some(index) = self.index(x, y) {
seen[index] = true;
}
queue.push_back((x, y));
let mut count = 0;
while let Some((tx, ty)) = queue.pop_front() {
count += 1;
for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] {
if self.walled(tx, ty, facing) {
continue;
@ -563,7 +567,30 @@ impl MapGrid {
queue.push_back((nx, ny));
}
}
count
Reachable { width: self.width, seen }
}
}
/// The tiles a walk from one tile of a [`MapGrid`] could reach ([`MapGrid::reachable`]).
#[derive(Debug, Clone)]
pub struct Reachable {
width: u8,
seen: Vec<bool>,
}
impl Reachable {
/// Whether the walk reaches `(x, y)`.
pub fn contains(&self, x: u8, y: u8) -> bool {
x < self.width
&& self
.seen
.get(usize::from(y) * usize::from(self.width) + usize::from(x))
.copied()
.unwrap_or(false)
}
fn iter(&self) -> impl Iterator<Item = &bool> {
self.seen.iter()
}
}

View file

@ -854,14 +854,16 @@ fn settle(machine: &mut MacroMachine, world: &mut World) {
}
}
/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever
/// it decided taken into the ledgers (row 58).
/// The cartridge gives the joypad back in the overworld: the settle window of it, observed, and
/// whatever it decided taken into the ledgers (rows 58 and 59).
fn hand_back(machine: &mut MacroMachine, world: &mut World) {
world.scene = Scene::Overworld;
world.scripted = false;
world.scripted_at = None;
world.switch = None;
for _ in 0..super::executor::PUSH_SETTLE_FRAMES {
machine.observe_frame(world);
}
settle(machine, world);
}
@ -4160,18 +4162,20 @@ fn go_shop_and_go_heal_are_on_the_pad_while_their_errand_stands() {
#[test]
fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() {
// The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route
// 3's connections as north and west; `geography`'s row carries west and east, so the north
// edge's destination is unnameable -- and an unnameable destination counted as *unvisited*,
// which made those tiles first-tier for `GO ROUTE` on every hold for ever, with
// `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective.
// The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reported Route
// 3's connections as north and west while `geography`'s row carried west and east, so the
// north edge's destination was unnameable -- and an unnameable destination counted as
// *unvisited*, which made those tiles first-tier for `GO ROUTE` on every hold for ever. Row 59
// corrected the row itself, and since then every header connection has a row; the rule is
// pinned on `$0B`, the one outdoor id with no header of its own (`UNUSED_MAP_0B`), whose
// edges the table cannot name.
let mut world = World::room();
world.map = maps::ROUTE_3;
world.map = 0x0b;
world.size = MapSize { width: 8, height: 8 };
world.player = Tile::new(4, 4);
world.connections = Connections { north: true, south: false, east: false, west: true };
// West is Pewter City, which the table does name and the run has stood on.
world.seen_maps.insert(maps::PEWTER_CITY);
// West the run has already stood on.
world.visited.insert(ExitId::Edge(Edge::West));
let north: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect();
assert!(
@ -5427,6 +5431,37 @@ fn facing_one_of_the_rungs_people_is_the_arrival() {
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back");
}
#[test]
fn a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge() {
// Row 59, Route 3's first trainer. The challenge text closes, and for five frames the screen
// is an ordinary overworld with nothing set -- no text, no script, no joypad bit, the battle
// not yet decided -- before `StartTrainerBattle` runs. Decided on the first of them, the walk
// the trainer interrupted walled (11, 6) for the session: the one gap east on Route 3.
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 text closes onto five frames of overworld, and then the battle is decided.
world.scene = Scene::Overworld;
world.scripted = false;
world.scripted_at = None;
world.switch = None;
for _ in 0..5 {
machine.observe_frame(&mut world);
}
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
machine.observe_frame(&mut world);
settle(&mut machine, &mut world);
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 gap it was walking through is still ground");
}
#[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;

View file

@ -189,11 +189,12 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
println!("- scene `{scene:?}`, player {player:?}, map {}x{}", size.width, size.height);
println!("- objective: {:?}", state.objective());
if let Some(objective) = state.objective() {
let from = palette::region_here(state)
.unwrap_or(geography::Region::whole(player.map));
println!(
"- `next_hop({:?}, {:#04x})` = {:?}, neighbours {:?}",
geography::region_at(player.map, player.y),
"- `next_step({from:?}, {:#04x})` = {:?}, neighbours {:?}",
objective.map,
geography::next_hop(geography::region_at(player.map, player.y), objective.map),
geography::next_step(from, objective.map),
geography::neighbours(player.map)
);
}
@ -1346,7 +1347,12 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
/// (comma-separated names) presses those buttons whenever they are dealt.
///
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
fn route_survey(
gb: &mut Emulator,
adapter: &mut PokemonRedReward,
ms: &mut f64,
checkpoint: &flysim::store::Checkpoint,
) {
use flybrain_gb::MacroPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey};
use flybrain_gb::pokemon_red::macros::path::Way;
@ -1380,6 +1386,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let mut entries = 0usize;
let mut arrived_at = 0usize;
let mut last_map: Option<u8> = None;
let save_rank: Option<u32> =
std::env::var("FLY_PROBE_SAVE_RANK").ok().and_then(|value| value.parse().ok());
let mut saved = false;
// `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.
@ -1508,6 +1517,28 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
// `FLY_PROBE_SAVE_RANK=11` with `FLY_PROBE_SAVE` writes the first frame at or past that
// rung where the fly has the buttons in the overworld and no macro is running: a state the
// stream could be restored into next (row 59, after the badge), carried forward by the
// survey from the source checkpoint. The session ledgers are not in it, as they are in no
// checkpoint.
if let Some(want) = save_rank
&& !saved
&& adapter.progress().rank >= want
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& state::controllable(gb)
&& let Some(path) = std::env::var_os("FLY_PROBE_SAVE")
{
saved = true;
let bytes = save_state(checkpoint, gb, adapter, frame, &path);
println!(
"f{frame:<6} {:?} saved rank {} to `{}` ({bytes} bytes)",
state::player(gb).map(|p| (p.map, p.x, p.y)),
adapter.progress().rank,
std::path::Path::new(&path).display()
);
}
if single_refusals >= catch_after {
caught_at = Some(frame);
break;
@ -1673,6 +1704,28 @@ fn separator_table(classes: &BTreeMap<(bool, bool, bool), u64>) -> String {
out
}
/// This state as a `FLYSIM01` checkpoint: the agent half is the source checkpoint's, unchanged --
/// the release box's own run carried forward by the stub, not a synthesised save -- and `.local/`
/// is not tracked, exactly as every other checkpoint in this workspace. Returns the size written.
fn save_state(
checkpoint: &flysim::store::Checkpoint,
gb: &mut Emulator,
adapter: &PokemonRedReward,
frame: usize,
path: &std::ffi::OsStr,
) -> usize {
let mut runtime = checkpoint.runtime.clone();
runtime.emulator = gb.export_state().expect("the emulator should export");
runtime.reward = adapter.export_state();
runtime.framebuffer = gb.framebuffer().to_vec();
runtime.emulator_frame = frame as u64;
let bytes =
flysim::store::encode(&checkpoint.agent, &runtime).expect("the envelope should encode");
flysim::store::write_atomic(std::path::Path::new(path), &bytes)
.expect("the checkpoint should be writable");
bytes.len()
}
fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe.");
@ -1764,7 +1817,7 @@ fn main() {
// Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette,
// and read the frame the pad comes down to one refusing button on.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") {
route_survey(&mut gb, &mut adapter, &mut ms);
route_survey(&mut gb, &mut adapter, &mut ms, &checkpoint);
return;
}
@ -1877,19 +1930,10 @@ fn main() {
// release box's own run carried forward by the stub, not a synthesised save -- and
// `.local/` is not tracked, exactly as every other checkpoint in this workspace.
if let Some(save) = std::env::var_os("FLY_PROBE_SAVE") {
let mut runtime = checkpoint.runtime.clone();
runtime.emulator = gb.export_state().expect("the emulator should export");
runtime.reward = adapter.export_state();
runtime.framebuffer = gb.framebuffer().to_vec();
runtime.emulator_frame = frame as u64;
let bytes = flysim::store::encode(&checkpoint.agent, &runtime)
.expect("the envelope should encode");
flysim::store::write_atomic(std::path::Path::new(&save), &bytes)
.expect("the checkpoint should be writable");
let bytes = save_state(&checkpoint, &mut gb, &adapter, frame, &save);
println!(
"\nWrote this state to `{}` ({} bytes).",
std::path::Path::new(&save).display(),
bytes.len()
"\nWrote this state to `{}` ({bytes} bytes).",
std::path::Path::new(&save).display()
);
}
// The survey method on the one question the fix turns on: **what does the

View file

@ -3452,3 +3452,212 @@ fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() {
);
assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}");
}
const ROUTE_4: u32 = 0x0f;
const MT_MOON_1F: u32 = 0x3b;
/// A rung-11 checkpoint just after the Boulder Badge, or `None` to skip.
///
/// Row 59's is the route survey's own: `examples/scene_probe.rs` driven from the row-58 checkpoint
/// with `FLY_PROBE_CATCH=route FLY_PROBE_PREFER="GO OBJECTIVE,TALK"` until rung 11, written by
/// `FLY_PROBE_SAVE_RANK=11` on the first safe overworld frame -- in the gym, beside BROCK.
fn badge_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_BADGE_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the badge: the fly takes the road to Mt. Moon instead of walking the Pewter end of Route 3.
///
/// **What the row-58 review measured** (the route survey carried past the badge, 2026-09-23):
/// from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE`
/// done on Route 3 538 times and `GO ROUTE` done on Pewter 537. The live fly was due to reach
/// the same state as soon as it earned the badge.
///
/// **What was wrong** (`infra/docs/macros-traps.md` row 59), two things stacked:
///
/// - the map graph had Route 4 east of Route 3 and a Mt. Moon door on Route 3. The headers put
/// Route 4 north of Route 3 and both of Mt. Moon's doors on Route 4, so Route 3's north edge,
/// the one road to the mountain, named no map and was nobody's first hop; and Route 4's two
/// sides, which the mountain cuts apart, were one node;
/// - Route 3's first trainer closed his challenge onto five frames of plain overworld before the
/// battle was decided, and the walk he interrupted was written as a refusal on the first of
/// them: (11, 6), the one gap between the road's west end and the rest of it, walled for the
/// session.
///
/// The claims, none of them about which button the fly presses:
///
/// - the fly is on **Route 4** and at **Mt. Moon's door** (map `0x3b`) inside the budget;
/// - Pewter City and Route 3 are **not a ring**: under forty crossings between them, against
/// over a thousand on the base.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_BADGE_CHECKPOINT=.local/checkpoints/survey-rank11-row59.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_road_to_mt_moon
/// ```
#[test]
fn the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = badge_checkpoint() else {
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert!(run.adapter.progress().rank >= 11, "the checkpoint is past the badge");
let mut crossings = 0u32;
let mut on_route_4: Option<u32> = None;
let mut at_mt_moon: Option<u32> = None;
let mut previous = run.map();
for frame in 0..288_000u32 {
run.frame();
let map = run.map();
if map != previous {
if (previous == PEWTER_CITY && map == ROUTE_3) || (previous == ROUTE_3 && map == PEWTER_CITY) {
crossings += 1;
}
previous = map;
}
if on_route_4.is_none() && map == ROUTE_4 {
on_route_4 = Some(frame);
}
if at_mt_moon.is_none() && map == MT_MOON_1F {
at_mt_moon = Some(frame);
break;
}
}
let progress = run.adapter.progress();
eprintln!(
"{:.1} brain minutes: Route 4 at {on_route_4:?}, Mt. Moon at {at_mt_moon:?}, Pewter / Route 3 \
crossings {crossings}, rank {} ({}), route {:?}, macros {:?}",
run.ms / 60_000.0,
progress.rank,
progress.rank_label,
run.route,
run.started
);
assert!(crossings < 40, "{crossings} crossings between Pewter City and Route 3: {:?}", run.started);
assert!(on_route_4.is_some(), "the fly never reached Route 4: {:?}", run.route);
assert!(at_mt_moon.is_some(), "the fly never reached Mt. Moon's door: {:?}", run.route);
}
/// Route 4's two sides, read off the cartridge: the warp table the geography's pieces are keyed
/// by, and the piece the fly is standing in on arrival from Route 3.
///
/// The table's door indices and tiles are the disassembly's (`data/maps/objects/Route4.asm`); this
/// is the same three warps read from `wWarpEntries` on the loaded map, and the piece named by the
/// decoded grid ([`geography::region_on`]) rather than by the doors.
#[test]
fn route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint() {
use flybrain_gb::pokemon_red::macros::geography::{self, Region};
let rom = skip_without_rom!();
let Some(checkpoint) = badge_checkpoint() else {
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
let mut read = None;
for _ in 0..216_000u32 {
run.frame();
if run.map() != ROUTE_4 {
continue;
}
let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb) else { continue };
let Ok(grid) = flybrain_gb::pokemon_red::state::map_grid(&mut run.gb) else { continue };
if u32::from(player.map) != ROUTE_4 || grid.map() != player.map {
continue;
}
let warps = flybrain_gb::pokemon_red::state::warps(&mut run.gb);
read = Some((player, warps, geography::region_on(player.map, player.x, player.y, Some(&grid))));
break;
}
let Some((player, warps, region)) = read else {
panic!("the fly never stood on Route 4 with its grid decoded: {:?}", run.route);
};
eprintln!("on Route 4 at ({}, {}), piece {region:?}, warps {warps:?}", player.x, player.y);
let doors: Vec<(u8, u8, u8)> =
warps.iter().map(|warp| (warp.x, warp.y, warp.destination_map)).collect();
assert_eq!(
doors,
vec![(11, 5, 0x44), (18, 5, 0x3b), (24, 5, 0x3c)],
"the Pokécenter, the cave mouth and B1F's exit, in the table's order"
);
assert_eq!(region, Region::piece(0x0f, 0), "arrived from Route 3, on the cave mouth's side");
}
const MT_MOON_POKECENTER: u32 = 0x44;
/// The live checkpoint from inside row 59's ring (Route 4, rank 12 MT. MOON), or `None` to skip.
fn mt_moon_live_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_MT_MOON_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the live checkpoint taken inside the ring: the fly goes into Mt. Moon instead of in and
/// out of the Pokécenter beside it.
///
/// **What was live** (2026-09-23 22:20 UTC, v0.6.0, rank 12 MT. MOON, the objective Cerulean City):
/// on Route 4, per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct
/// macros and two new tiles. Route 4 was one node on the map graph with Cerulean off its east
/// edge, which Mt. Moon cuts off from the cave mouth's side, so the objective aimed at ground no
/// walk could reach and the Pokécenter door was the way out that was left. The route survey from
/// this checkpoint on `main` walks Route 4 and the Pokécenter 930 times in 72,000 frames.
///
/// The claims, none of them about which button the fly presses, over twenty brain minutes on the
/// stub rotation: the fly is **inside Mt. Moon** (map `0x3b`), and Route 4's west side and its two
/// doors, the Pokécenter and the cave mouth, are **not a ring**: under twenty-five crossings in all.
/// The base makes 56 (18 through the Pokécenter's door, 38 through the cave's); the rotation walks
/// into the cave on the base too, and back out, and in, because from 1F the graph's Cerulean was
/// Route 4's east edge beside it.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_MT_MOON_CHECKPOINT=.local/checkpoints/release-rank12-route4.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_fly_goes_into_mt_moon
/// ```
#[test]
fn the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = mt_moon_live_checkpoint() else {
eprintln!("skipped: no FLY_MT_MOON_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert!(run.adapter.progress().rank >= 12, "the checkpoint is the rung the ring was on");
let mut crossings = 0u32;
let mut in_mt_moon: Option<u32> = None;
let mut on_route_4 = 0u32;
let mut previous = run.map();
for frame in 0..72_000u32 {
run.frame();
let map = run.map();
if map != previous {
let door = |other: u32| other == MT_MOON_POKECENTER || other == MT_MOON_1F;
if (previous == ROUTE_4 && door(map)) || (door(previous) && map == ROUTE_4) {
crossings += 1;
}
previous = map;
}
if in_mt_moon.is_none() && map == MT_MOON_1F {
in_mt_moon = Some(frame);
}
if map == ROUTE_4 {
on_route_4 += 1;
}
}
eprintln!(
"{:.1} brain minutes: Mt. Moon at {in_mt_moon:?}, crossings of Route 4's west doors {crossings}, \
frames on Route 4 {on_route_4}, rank {}, route {:?}, macros {:?}",
run.ms / 60_000.0,
run.adapter.progress().rank,
run.route,
run.started
);
assert!(crossings < 25, "{crossings} crossings of Route 4's west doors: {:?}", run.started);
assert!(in_mt_moon.is_some(), "the fly never went into Mt. Moon: {:?}", run.route);
}