12.13 the two states UNKNOWN holds and why one of them presses nothing; 12.14 a frontier no walk can reach, remembered per map rather than windowed; 12.15 what the ratchet's stall window cannot see; 12.16 what is still in the way of the badge, measured rather than fixed -- the town errands being session state, GO FRONTIER still being most of the run in Pewter City, and the whole-map grid being refused on every frame the fly is mid-step (118 of 120 standing still, and the disagreement is exactly one tile row in the direction of travel). 13.1's pad-empty table gains the row for the one empty pad that ends itself, and the ladder's recovery budgets carry the amendment to the 2026-09-17 progress rule.
10 KiB
Milestone ladder v2 for Pokémon Red
Planned 2026-09-15 (Fable). Replaces the 16-rung ladder (boot..Pokédex, then one rung per badge) with a 38-rung ladder that follows the game's real progression, so the ratchet archives every hour or two of progress instead of every badge, and the stream's rung panel moves visibly.
Built 2026-09-15. Three things came back different from the plan; the table below is corrected in place and the corrections are recorded under "Verified against the decomp".
Sources: pret/pokered at the pinned commit 0cd19d3b877b7dc66d12c7050bed9a7f38154d4b
(constants/event_constants.asm, constants/map_constants.asm, ram/wram.asm), the
generated symbol table in services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs, and the
existing rank logic in pokemon_red/mod.rs. Every flag name below has been verified against the
decomp at that commit.
Rules
- Rank is the maximum over all satisfied rung conditions, evaluated on every playable sample.
Conditions are sticky by construction: badge bits only grow, and "first visit to map X" is read
from the adapter's lifetime map ledger, not the current map. So rank never regresses, and rungs
may be earned out of order (a fly that stumbles into Viridian Forest before Viridian City still
gets the higher rung).
- Event flags, however, are not all sticky — the plan was wrong about this, see below — so
every flag rung is answered from the adapter's lifetime
seenledger rather than from the live bit. The ledger already records each flag under its pokered name the first sample it is seen set, survives a rollback and round-trips through the checkpoint, so this needed no new state.
- Event flags, however, are not all sticky — the plan was wrong about this, see below — so
every flag rung is answered from the adapter's lifetime
- Rungs based on a map use FIRST VISIT (the map id enters the lifetime ledger), never current position, and the safe-snapshot rules stay exactly as today (overworld, unscripted, three stable samples, no menu/battle/warp). The archive for a map rung is therefore taken a few seconds after arrival, standing still.
- Badges come from
wObtainedBadgesbit count as today. Elite Four rungs use theEVENT_BEAT_LORELEIS_ROOM_TRAINER_0-style flags, whose names were confirmed, and Champion isEVENT_BEAT_CHAMPION_RIVALseen once orwNumHoFTeams > 0: neither alone is enough, see below. - Adapter version bumps to
pokered-unique8-v4; the ratchet's rank bound becomes the ladder length; old checkpoints are rejected (pre-launch, acceptable). The feed header gainsmilestone.total(optional, non-breaking) so the page draws the right number of rungs.
The ladder
| rank | label | condition |
|---|---|---|
| 0 | Boot screen | not yet playable |
| 1 | Bedroom | first playable sample (map REDS_HOUSE_2F) |
| 2 | Downstairs | first visit REDS_HOUSE_1F |
| 3 | Pallet Town | first visit PALLET_TOWN |
| 4 | Oak's lab | first visit OAKS_LAB (or EVENT_FOLLOWED_OAK_INTO_LAB) |
| 5 | Got a starter | EVENT_GOT_STARTER |
| 6 | Oak's parcel | EVENT_OAK_GOT_PARCEL (the parcel delivered, not collected) |
| 7 | Pokédex | EVENT_GOT_POKEDEX |
| 8 | Viridian City | first visit VIRIDIAN_CITY |
| 9 | Viridian Forest | first visit VIRIDIAN_FOREST |
| 10 | Pewter City | first visit PEWTER_CITY |
| 11 | Boulder Badge | badges >= 1 |
| 12 | Mt. Moon | first visit MT_MOON_1F |
| 13 | Cerulean City | first visit CERULEAN_CITY |
| 14 | Cascade Badge | badges >= 2 |
| 15 | Nugget Bridge | EVENT_BEAT_CERULEAN_RIVAL (rival north of Cerulean) |
| 16 | Met Bill | EVENT_GOT_SS_TICKET (added to the generated symbols) |
| 17 | Vermilion City | first visit VERMILION_CITY |
| 18 | HM Cut | EVENT_GOT_HM01 |
| 19 | Thunder Badge | badges >= 3 |
| 20 | Rock Tunnel | first visit ROCK_TUNNEL_1F |
| 21 | Lavender Town | first visit LAVENDER_TOWN |
| 22 | Celadon City | first visit CELADON_CITY |
| 23 | Silph Scope | EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI — there is no EVENT_GOT_SILPH_SCOPE |
| 24 | Rainbow Badge | badges >= 4 |
| 25 | Poké Flute | EVENT_GOT_POKE_FLUTE (Mr. Fuji rescued) |
| 26 | Fuchsia City | first visit FUCHSIA_CITY |
| 27 | Soul Badge | badges >= 5 |
| 28 | Silph Co. freed | EVENT_BEAT_SILPH_CO_GIOVANNI |
| 29 | Marsh Badge | badges >= 6 |
| 30 | Cinnabar Island | first visit CINNABAR_ISLAND |
| 31 | Volcano Badge | badges >= 7 |
| 32 | Earth Badge | badges >= 8 |
| 33 | Indigo Plateau | first visit INDIGO_PLATEAU_LOBBY |
| 34 | Beat Lorelei | EVENT_BEAT_LORELEIS_ROOM_TRAINER_0, latched (the flag is reset) |
| 35 | Beat Bruno | EVENT_BEAT_BRUNOS_ROOM_TRAINER_0, latched |
| 36 | Beat Agatha | EVENT_BEAT_AGATHAS_ROOM_TRAINER_0, latched |
| 37 | Champion | EVENT_BEAT_CHAMPION_RIVAL latched, or wNumHoFTeams > 0 |
38 rungs, 0..37. Labels are what the rung panel shows; keep them short (they render in Press Start 2P at 33 px in a 1012 px rail).
Verified against the decomp
All 16 map constants and all but one flag name were confirmed unchanged at 0cd19d3. The exceptions, and one wrong assumption:
EVENT_GOT_SILPH_SCOPEdoes not exist (rung 23). The Silph Scope is a toggleable pick-up thatRocketHideoutB4FBeatGiovanniScriptreveals —data/maps/toggleable_objects.asmhasROCKETHIDEOUTB4F_SILPH_SCOPEstartingOFF. So the rung isEVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI, which is the plan's own parenthetical.EVENT_GOT_SS_TICKETwas missing from the generated symbols (rung 16), and from the prototype'ssymbols.tstoo, sogen_symbols.pygained an explicit allowlist resolved fromevent_constants.asm. Bit 1372.EVENT_MET_BILL(bit 1368) also exists; the ticket is the rung because it is the S.S. Anne gate, i.e. the progression.- Rung 6 is the delivery,
EVENT_OAK_GOT_PARCEL, notEVENT_GOT_OAKS_PARCEL. It is what unlocks the Pokédex, so it sits directly below rung 7. This is a change from v3.
The Elite Four and the Champion are not sticky
The plan's "event flags never clear in normal play" is false for exactly the top four rungs, and
these are the only two ResetEventRange calls in the game:
scripts/IndigoPlateauLobby.asm:14runsResetEventRange INDIGO_PLATEAU_EVENTS_START, EVENT_LANCES_ROOM_LOCK_DOORon every entry to the lobby onceBIT_STARTED_ELITE_4is set inwElite4Flags. That clears Lorelei's, Bruno's, Agatha's and Lance's beat flags — which is what happens after a blackout, the single most likely thing to happen to a fly in the Elite Four.scripts/HallOfFame.asm:45runsResetEventRange INDIGO_PLATEAU_EVENTS_START, INDIGO_PLATEAU_EVENTS_END, 1, which coversEVENT_BEAT_CHAMPION_RIVALas well: the flag for the ladder's last rung is destroyed by the event that earns it.
So wNumHoFTeams (0xd5a2, incremented by HallOfFamePC and saved) is the only durable
"has been Champion" signal on the cartridge — but it is set slightly after the rival is beaten,
and only the flag marks that moment. Hence both halves.
Rather than special-case four rungs, every flag rung reads the adapter's lifetime seen ledger.
The adapter already walks symbols::EVENTS on every playable sample, battles included, and records
each set flag under its pokered name whether or not it pays; that record survives
clear_transient and round-trips through the checkpoint. The flags are latched during the
thousands of frames between the battle ending and the reset firing, so the ledger is the sticky
condition the plan wanted, at the cost of no new state.
Recovery budgets
Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window unchanged (120 s without new exploration, 180 s since last recovery).
Amended 2026-09-22 (rung 10). "New exploration" is the adapter's lifetime tile ledger, so a map
entered for the first time resets the window -- a new map is a map's worth of tiles nobody has stood
on -- and a map re-entered does not. Two "Stuck" rollbacks fired inside half an hour on a fly that
was walking a town it had already covered toward the rung's own door, and both were this rule
working. The window now takes a second signal beside the tile count: the fly being nearer its
objective, in map hops, than this run has ever been (docs/design/macros.md section 12.15). The
ratchet treats it exactly as it treats new exploration -- it restarts the window and does nothing
else -- it can fire at most once per step of the road, and the ratchet itself knows no more about
what it means than it knows what a tile is. Nothing else about the budgets, the triggers or the
checkpointed state changes. The ratchet's rank bound is
now the running adapter's ladder length, passed in rather than a constant, because the bound belongs
to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16
(docs/design/platformer.md §3). Only the budgets are per-game, from the adapter's
RecoveryPolicy: the platformer asks for 48 lifetime recoveries, a 300 s stall window and a
game-over trigger with its own 60 s cooldown, and Pokémon takes the defaults above.
Verification
Unit tests with synthetic WRAM traces for: each map rung on first visit only; out-of-order rungs take the max; badge rungs; flag rungs; rank never decreases across a rollback; ladder length equals labels length equals 38; old adapter version rejected on import. The ROM-gated boot test asserts rank 1 in the bedroom and rank 3 on reaching Pallet Town as before.
All present, in pokemon_red/tests.rs, plus two the corrections above made necessary: the Elite
Four rungs surviving a simulated lobby reset, and both halves of the Champion rung. Also
ratchet.rs for the ladder-derived rank bound, simloop.rs for the next label at the top of the
ladder, packages/feed/tests/schema.test.ts for milestone.total, and two stage suites —
tests/unit/ladder.test.ts for the rung count and tests/e2e/ladder.spec.ts, which drives the real
build's spine to 38 rungs at 1080 and measures it (gap 1.25 px, segments 14.2 px, the current-rung
marker 20 px, which is 4.1 px at the phone downscale).
The ROM-gated boot test now walks out of the house on the real cartridge as well, and asserts the climb is exactly (2, DOWNSTAIRS) then (3, PALLET TOWN). It does so with a fixed-seed random walk: steering needs a collision map to be any good, and the player spawns at (3, 6) in the bedroom with (3, 5) blocked, so aimed steering stalls on the furniture.
No screenshot baseline changed. The committed .flyfeed fixtures predate milestone.total, so the
page's spine is still 16 rungs there, and the gap formula (48 / rungs, exactly 3 px at 16) and the
marker floor (never binding at 16) were both chosen to leave that rendering identical. Re-recording
the fixtures against the now-38-rung fake flysim would move every baseline and is a separate change.