ADR 0058: Read collision from the live map buffer, and make a route an action
Status: accepted (James, 2026-07-25). The map-grid decoder, the enriched action
outcome, the adjacency observation, and the walk_to action are implemented and
covered by tests; the ROM-gated ones assert against collision that is first
established by hand. Narrowed by
ADR 0089: the warp-event list this ADR
deferred is decoded, the grid is rendered into the player's own view, and a
walk_to aimed at a decoded warp on blocking collision walks beside it and
presses in — routing through warps mid-route stays refused.
Current status (2026-08-19)
The live collision decoder and composite walk_to action remain current. The
external-harness shared-body measurement below is historical under
ADR 0129; Clankie's runtime and each private
GBA MCP runtime use the same contracts on independent bodies.
Context
An external harness played FireRed through the MCP surface (ADR 0053) and the cost of the first six tiles is measured: 14 tool calls — 8 actions and 6 observations — to cross a bedroom and take one staircase. Two of those actions are presses into a stair banister that returned looking exactly like successes.
Three properties of the surface caused that, and none of them are about the model's judgement:
- The action result omits the player's final position. Every move costs a second call to find out.
- Nothing distinguished a step from a bump.
ramStateSha256changes on a refused move too, because the bump animation is itself a state change, so it could not be used as a proxy. - Nothing described the map. The decoder exposed player coordinates and
facing only.
gba_emulator_observereturns the rendered screen for exactly this reason — ADR 0049 already recorded that a caller reading only RAM "discovers furniture by walking into it" — but a PNG makes a caller do tile arithmetic on pixels to guess what is solid.
The existing real-scenario driver worked around the third point by remembering "the set of directed transitions the emulator itself refused". That is honest about observed reality, but it is memory of walls already walked into. It cannot route around a wall that has not yet been hit.
Decision
Collision is read, not remembered
gBackupMapLayout — FireRed's live map buffer — is decoded from IWRAM
0x03005040 as { s32 width; s32 height; u16 *map; }, with each tile a u16
carrying metatile id in bits 0-9, collision in 10-11, and elevation in 12-15.
The player's coordinates are already in the buffer's border-inclusive space,
which is why they read as 13/13 in a room whose real origin is 0/0.
The address is derived the same way firered-ram-map.ts documents deriving the
others: boot the pinned savestate, scan RAM for a struct whose dimensions and
EWRAM-resident pointer are plausible, and keep only the candidate whose collision
bits reproduce tiles a walker proves open or blocked by bumping into
them. Exactly one candidate survived, and its grid tracked the 27x23 -> 28x24
change when the player takes the stairs down.
Reading beats remembering because the walls are already in RAM. The refused- transition memory stays useful for what collision genuinely does not cover.
The border is load-bearing. The buffer carries seven tiles of border filler on every side, and that filler decodes to collision 0. Treating the buffer as uniformly meaningful would report the void beyond a room's walls as open floor, so every query is clamped to the real map and fails closed outside it.
Collision is not reachability, and the code says so rather than implying otherwise. The bits model walls. They do not model ledges (one-way), water (needs Surf), elevation transitions, or NPCs — object events occupy tiles without appearing in collision at all. An open tile therefore means "not a wall", not "reachable on foot".
A route is a catalogued action
walk_to plans breadth-first over that grid and is a real entry in the action
catalogue, implemented in the adapter — not an MCP-only convenience. The MCP
README's rule that nothing there invents a capability is what forces this: a
pathing tool that exists only for the external harness would be a second
definition of what Clankie can do in a game, and his own free-play loop would not
have it. It maps to the existing emulator.gba.input capability, because walking
is a burst of presses the session is already allowed, and it draws from the same
input and frame budget such a burst draws from.
The route is verified every step, because the plan can stop being true while
it is walked. Collision has no NPCs in it, so a planned tile can be occupied.
Each step compares position before and after; if the player does not move, the
action stops and reports blockedAt rather than mashing a dead route. A warp
tile likewise ends the route, because the plan is made against a map that is no
longer loaded.
A tile that blocks a route is remembered, per map, and planned around.
Reporting blockedAt is not enough on its own: the next plan is drawn from the
same collision that never saw the obstruction, so it picks the same shortest
route and dies on the same tile. On 2026-08-15 four consecutive routes toward
Viridian's Pokémon Center are funnelled into one scripted old man — the fourth
planned 44 steps for a 4-tile trip to reach him — and four identical failures
read to the player as a wrong destination rather than a blocked route. He spent
forty turns searching buildings. The memory is bounded per map, and a target
reachable only through a remembered tile re-plans against the raw grid: it can
slow a route down, and can never make a reachable tile unreachable, because
whoever is standing there may well have moved on.
walk_to refuses an impassable or unreachable target instead of walking as close
as it can — answering a different question than the one asked would be worse than
refusing.
walk_to moves within a map, not between maps. Warp tiles carry blocking
collision and transport anyway: the stairs at (16,9) in players-house-2f
read as a wall, yet pressing left into them from (17,9) arrives on
players-house-1f. A route planned from collision alone can therefore never
step onto a door or a staircase. Crossing maps stays a directional press, and
moved reports it correctly because the map id changes. Routing through warps
would need the map header's warp-event list decoded as well, which is a separate
change; a test pins the current behaviour so that change is deliberate.
An action result describes the state it produced
button_press and walk_to return the resulting position, facing, moved,
turned, and the surrounding tiles. This is the change that removes the second
call per move, and moved is what makes a bump legible without one.
Consequences
- A caller can see a wall before walking into it, and crossing a room is one action instead of a probe-and-observe loop.
- The deterministic double implements the same seam from the bounds it already enforced, so CI covers adjacency and pathing with no ROM.
GbaCoreStategained optionalsurroundings/mapSize, derived in the double's accessor rather than stored —ramStateSha256hashes the held state, and every frozen scenario digest depends on its shape.- The map grid is a seam query rather than a state field, because the state view is cloned on every read and a whole grid per observation would make looking expensive to pay for something only pathing uses.
- Collision decoding is pinned to FireRed US v1.0 like the rest of the profile. Another ROM fails closed rather than reporting a confidently wrong grid.
