Merge fix/loop-20260922T0735: MENU off every pad, stranded rooms keep their way out, battle menu columns read right
This commit is contained in:
commit
335a3b534b
8 changed files with 1130 additions and 125 deletions
|
|
@ -914,6 +914,95 @@ mechanism -- `BACK` **739 starts on the move list** and `NEXT` **739 on the top-
|
|||
|
||||
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
||||
|
||||
### 12.11 `MENU` opens a screen its own `BACK` closes, and Red's battle menu is two columns (2026-09-22, rung 10, thirty-one minutes after v0.4.3)
|
||||
|
||||
Rank 10 (PEWTER CITY, next the BOULDER BADGE), the fly inside a Pewter building, and since the
|
||||
restart the macro starts were `MENU` **82**, `BACK` **82**, `GO FRONTIER` 8 -- the event log
|
||||
alternating `MENU start/done, BACK start/done` on **map 0x35**. Reproduced from the live checkpoint
|
||||
with the real cartridge: the map is the **upper floor of the Pewter museum**, fourteen blocks by
|
||||
eight, 81 walkable tiles all reachable and four never stood on, one warp at (7, 7) down to the
|
||||
museum's ground floor (0x34), two signs and three exhibits.
|
||||
`infra/docs/macros-traps.md` has the probe whole.
|
||||
|
||||
- **`MENU` is a trap by section 12.2's own definition, and it was the *only* button left.** Opening
|
||||
the start menu changes nothing in the world, so the macro completes on the tile it started on --
|
||||
and the scene it opens deals `CLOSE`, `CONFIRM` and `BACK`, of which `BACK` presses B and closes
|
||||
it again. Two buttons that undo each other with nothing else changing: section 12.10's rule, one
|
||||
scene wider than a battle. **`MENU` is on no pad now.** Not narrowed -- there is nothing behind
|
||||
it to press it for, because no macro in the vocabulary uses the start menu except as a scene to
|
||||
leave, and "MENU to save the game" is not a macro that exists. It stays a type, a population, a
|
||||
tag and a script, so the thirty-one channels, the roles and `--print-compatibility` do not move,
|
||||
and the start menu is still the fly's to open with the **raw** START button, which reaches the
|
||||
cartridge in macros mode.
|
||||
- **Why that map's pad was `MENU` and `GO FRONTIER` and nothing else.** Every candidate list on the
|
||||
museum's upper floor empties: `geography` has no row for the museum, so `next_hop` answers `None`
|
||||
and `GO OBJECTIVE` has nothing to aim at -- the objective itself is right, map 0x36 with a
|
||||
*person* on it, which is rung 11's gym leader; the three exhibits and two signs are *reached* by
|
||||
`GO NPC` and `GO ITEM`, which retires them for the session (12.1); the four unstood tiles are
|
||||
walked or excluded and `GO FRONTIER` empties for the window; and the one way out is classified a
|
||||
**passage** and not an exit -- it is a staircase -- so `unexcluded_exits` drops it while the
|
||||
blocked ledger rests it, and `ways`' tier 3 for a passage is built out of the same list.
|
||||
- **`MENU` was also what made an empty overworld pad impossible, so that guarantee moves to the way
|
||||
out.** `ways` gains, for a *room*, the last resort `GO ROUTE` has had outdoors since 13.1: with
|
||||
nothing else on this map worth walking to (`palette::stranded`), the exits of this kind come back
|
||||
**ignoring the blocked window**, the one toward the objective preferred. A target the ledger is
|
||||
resting is still the only place to go, and a door the run has been through is a better answer
|
||||
than a pad that cannot move. It is a last resort and not a tier: one unstood tile and it goes
|
||||
away again, because "the nearest door, once per hold" is row 2's own two hours seventeen.
|
||||
- **A map with no way out at all is now a genuinely empty pad, and that is said rather than
|
||||
papered over.** No map in Red is that -- an interior has its front door or its staircase, an
|
||||
outdoor map has its connections -- it is asserted as a named residual in the pad-empty sweep, and
|
||||
`game.padEmptyMs` is what reports it if one ever appears.
|
||||
- **A cursor step waits for the list it was built for.** `THROW BALL` was **63 starts and 63
|
||||
`blocked`** on v0.4.3, mean sixty-nine frames, which is the cursor to ITEM, the A that confirms
|
||||
it, the twenty settle frames, and a refusal on the next frame. Red keeps one cursor for every
|
||||
menu in the game, so "where is the cursor" was half a question: the step that should have walked
|
||||
the bag list read the battle *menu*'s four entries instead. A `Listing` now says which list it is
|
||||
and a cursor step says which list its target indexes into; a step whose list is not up waits, as
|
||||
it already waited for a list reporting no cursor at all (section 4), and takes its press order
|
||||
and its budget from that list on the first frame it accepts input. A confirming press that has
|
||||
begun still finishes, because the press is what answers the list.
|
||||
- **And the reason `THROW BALL` never reached the bag: Red's battle menu is two columns, so its
|
||||
order is FIGHT, ITEM, PKMN, RUN.** The screen reads `FIGHT PKMN` over `ITEM RUN` and the game's
|
||||
index does not: `wCurrentMenuItem` is the row inside the column the cursor is in, and selection
|
||||
adds two for the right column. `battle_entry` had `PKMN` 1 and `ITEM` 2 -- the row-major reading
|
||||
of the picture -- so **every macro that meant to open the bag opened the party list and every
|
||||
macro that meant to open the party list opened the bag**, for as long as the four constants have
|
||||
existed. Surveyed on the cartridge: A at `wTopMenuItemX` 15 with `wCurrentMenuItem` 0 opens the
|
||||
party list (`wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02`) and the game writes
|
||||
`wCurrentMenuItem` 2 on the frame after. `THROW BALL` and `ITEM` and `SWITCH` have never once
|
||||
completed on the release box; they do now. The fake had the same mistake in its own two-by-two
|
||||
geometry, which is why no unit test could have caught it, and it is column-major now.
|
||||
- **`BACK` on the move list only where the moves can be read.** The other v0.4.3 residual: `BACK`
|
||||
was 263 of 797 macro starts and every one was over an open move list. A move list whose battler
|
||||
the seam cannot place binds no `MOVE n` at all, so its pad was `BACK` alone -- and closing the
|
||||
list is exactly undoing the `MOVE 1` on the menu underneath that opened it, which is 12.10's pair
|
||||
with `MOVE 1` in `NEXT`'s place. With nothing readable the pad is `MOVE 1` alone, and its script
|
||||
confirms wherever the cursor stands, which is the press that ends a turn.
|
||||
|
||||
**What the harness holds.** No scene's set and no scene's pad contains `MENU`, in any state; a room
|
||||
whose one door the ledger rests still offers it and the pad is that walk; the move list's pad is
|
||||
`MOVE n` plus `BACK` only with a readable battler and `MOVE 1` alone otherwise; a cursor step waits
|
||||
rather than reading the list it has already answered; and `battle_entry`'s four numbers are pinned
|
||||
against the survey. ROM-gated from the live checkpoint: the fly leaves the museum's upper floor on
|
||||
**frame 182**, and over thirty-three brain minutes across seven maps `MENU` is on no pad, no
|
||||
overworld pad is empty, and `MENU`/`BACK` never alternate -- where v0.4.3 deals `MENU` on all seven
|
||||
with 147 starts. ROM-gated from the rung-9 forest checkpoint: `THROW BALL` 15 starts and **0**
|
||||
blocked and `SWITCH` 17 and **0**, against 6 of 6 and 15 of 17 before.
|
||||
|
||||
**Two things measured and not fixed here.** The trap hunt from this checkpoint does not reproduce
|
||||
this loop at all, and it cannot: the reached and blocked ledgers are session state that a restore
|
||||
clears (12.1), so a restored fly walks straight out of the museum, and the live loop needed thirty
|
||||
minutes of ledger to build. What the hunt reproduces instead is **row 41**, the Pokémon Center
|
||||
nurse's box -- 62,804 of 71,673 frames on one tile of map 0x3a with `YES` 1,278 of 1,295 macro
|
||||
starts -- and that is the next trap. Inside a battle the largest residual is `MOVE n`: 890 of 1,431
|
||||
macros report `blocked`, every one with the move list drawn and its cursor placeable but not
|
||||
accepting input, so no press moves it and the step spends its budget. That is row 30b's unplaceable
|
||||
cursor inverted and it needs a WRAM reading rather than a pad change.
|
||||
|
||||
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.")
|
||||
|
|
@ -981,18 +1070,18 @@ observe is not a precondition, it is a guess.
|
|||
|
||||
| scene / sub-state | pad | change |
|
||||
| --- | --- | --- |
|
||||
| Overworld, outdoors | GO OBJECTIVE, GO ROUTE, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU | **MENU added** (row 18); errands added |
|
||||
| Overworld, indoors | GO OBJECTIVE, GO OUT, GO WARP, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU | **MENU added** (row 18); `GO NPC` was off section 3's fixed indoor row and on the plan's, and with one dealer it is on both |
|
||||
| Overworld, outdoors | GO OBJECTIVE, GO ROUTE, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK | MENU was added by row 18 and **taken back off by 12.11**: it opens a screen whose own `BACK` closes it again, and nothing in the vocabulary uses the start menu. Errands added |
|
||||
| Overworld, indoors | GO OBJECTIVE, GO OUT, GO WARP, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK | **MENU off, by 12.11**, as above; `GO NPC` was off section 3's fixed indoor row and on the plan's, and with one dealer it is on both |
|
||||
| Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire |
|
||||
| Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below |
|
||||
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter, MENU | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
|
||||
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
|
||||
| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing |
|
||||
| Dialog | NEXT, YES, NO | unchanged. There is no "a choice is open" flag (`macros-wram.md`), and A and B both advance a plain box, so all three are dealt for every box — what it buys is the fly being able to answer *no* |
|
||||
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged. The cursor is the fly's to move with the **raw** D-pad, which still reaches the cartridge in macros mode; a SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for |
|
||||
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it |
|
||||
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
|
||||
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
|
||||
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
|
||||
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
|
||||
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
|
||||
| Battle, own turn, move list | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands |
|
||||
| Battle, own turn, party list | SWITCH, BACK | unchanged |
|
||||
| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds |
|
||||
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it |
|
||||
|
|
@ -1031,7 +1120,9 @@ measured where it cannot.
|
|||
|
||||
| cause | closed by |
|
||||
| --- | --- |
|
||||
| a scene that binds nothing at all | the table above: every playable scene has at least one unconditional button (`MENU` on the overworld, `NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC) |
|
||||
| a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below |
|
||||
| an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it |
|
||||
| an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it |
|
||||
| an overworld where the talked, reached and blocked ledgers exclude every person, object and frontier tile, every route leads somewhere visited, and there is no objective hop | **fixed**: `ways`' last resort. With *nothing else on this map worth walking to* (`palette::stranded`), `GO ROUTE` offers the exit toward the objective and, failing that, all of them — **ignoring the blocked window**, because a target the ledger is resting is still the only place to go. It is a last resort and not a tier: with anything else on the pad it stays off, because "all of them, nearest" once per hold is row 2's own loop |
|
||||
| the frontier is empty because `path::frontier` answers about the ten-by-nine walkable window, while most of the map is unstood | **fixed**: `frontier_aims` falls back to the nearest tile of the *whole* map the stood ledger has no entry for. A fallback and not the rule, because the windowed answer is the correct one whenever it has anything in it |
|
||||
| a running macro aborts and the pad is not re-dealt until the next frame | not a cause: `observe` runs after every frame and the decoder is handed the bound channels again on the very next one |
|
||||
|
|
|
|||
|
|
@ -1589,3 +1589,181 @@ state's `runs/` directory.
|
|||
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1 and
|
||||
v0.4.2. Decoder, reward catalog, adapter version and roles untouched.
|
||||
|
||||
## 2026-09-22, rows 44 to 47: the screen the pad opens and closes again, and a menu read row-major
|
||||
|
||||
Thirty-one minutes after v0.4.3 deployed, the release watchdog flagged the next rung. Rank 10
|
||||
(PEWTER CITY, next the BOULDER BADGE), the fly inside a Pewter building, and since the restart the
|
||||
macro starts were `MENU` **82**, `BACK` **82** and `GO FRONTIER` 8, with the event log alternating
|
||||
|
||||
```
|
||||
MENU start, MENU done, BACK start, BACK done
|
||||
```
|
||||
|
||||
on map `0x35`. It is section 12.10's pair again -- two buttons that undo each other with nothing
|
||||
else changing -- with the difference that the two are on **different scenes**, so no per-pad rule
|
||||
could see it: `MENU` is on the overworld and `BACK` is on the start menu that `MENU` opens.
|
||||
`docs/design/macros.md` section 12.11 is the contract this closed against.
|
||||
|
||||
### Which building, and why its pad was two buttons
|
||||
|
||||
Reproduced from the release container's own checkpoint with the real cartridge,
|
||||
`examples/scene_probe.rs`:
|
||||
|
||||
```
|
||||
- rank 10 (PEWTER CITY), badges 0, unique tiles 2132
|
||||
- player: Player { map: 53, x: 3, y: 3, facing: Left }
|
||||
- objective: Objective { map: 54, target: Some(Person) }
|
||||
- map size: MapSize { width: 14, height: 8 }
|
||||
- warps: [Warp { x: 7, y: 7, destination_warp: 4, destination_map: 52 }]
|
||||
- signs: [Sign { x: 11, y: 2 }, Sign { x: 2, y: 5 }]
|
||||
- people and objects: slot 1 picture 0x04 at (2, 7), slot 2 picture 0x25 at (0, 5),
|
||||
slot 3 picture 0x20 at (7, 5)
|
||||
- the pad: ["GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
|
||||
- the map grid: 14x8 walkable 81 reachable 81 unstood 4 unknown 0
|
||||
- `next_hop(Region { map: 53, part: 0 }, 0x36)` = None, neighbours []
|
||||
- `ways(Exit)` = [], `ways(Passage)` = [Warp(0)], `ways(Route)` = []
|
||||
- `objective_goals` = [], `objective_targets` = []
|
||||
```
|
||||
|
||||
Map `0x35` is **the upper floor of the Pewter museum** -- `0x34` is its ground floor, `0x36` the
|
||||
gym, `0x38` the mart, `0x3a` the centre -- fourteen blocks by eight, one staircase down at (7, 7),
|
||||
two exhibit signs and three people. Every candidate list on it empties, which is why the live pad
|
||||
was `MENU` and an occasional `GO FRONTIER`:
|
||||
|
||||
| button | why it was not there |
|
||||
| --- | --- |
|
||||
| `GO OBJECTIVE` | the objective is right -- map `0x36` with a **person** on it, which is rung 11's gym leader -- but `geography` has no row for the museum, so `next_hop` from map 53 answers `None` with no neighbours, and `objective_goals` is empty |
|
||||
| `GO OUT` | the museum's upper floor has **no exit-class warp at all**: its one way out is a staircase, which is a `Passage` |
|
||||
| `GO WARP` | on the pad at a fresh restore, and off it live: `ways(Passage)`' tier 3 is `unexcluded_exits`, which the blocked window empties for ten brain minutes after a refused walk |
|
||||
| `GO ITEM`, `GO NPC` | the two signs and three exhibits are **reached**, and a reached target is retired for the session (12.1) |
|
||||
| `TALK` | the fly at (3, 3) facing Left has nothing in front of it |
|
||||
| `GO SHOP`, `GO HEAL` | `geography::area_of` has no row for the museum, so the area's errands cannot be aimed at from inside it |
|
||||
| `GO FRONTIER` | on the pad while any of the four unstood tiles is outside the blocked window, which is the 8 starts in thirty minutes |
|
||||
|
||||
### The survey that named the second trap
|
||||
|
||||
`THROW BALL` was **63 starts and 63 `blocked`** in v0.4.3's own after-arm, mean sixty-nine frames,
|
||||
and the first reading of that -- the bag had not finished drawing when the step read the cursor --
|
||||
is true and is not the whole of it. Instrumenting *where* a macro reports `blocked` from the rung-9
|
||||
checkpoint answered `battle/party` and `battle/between-turns`, never `battle/bag`, and a frame dump
|
||||
of the menu bytes across one `THROW BALL` says why:
|
||||
|
||||
```
|
||||
DUMP 21 topy=14 topx=9 cur=1 max=1 watch=0x11 sub=main (the cursor walked DOWN)
|
||||
DUMP 35 topy=14 topx=15 cur=1 max=1 watch=0x21 sub=main (then RIGHT)
|
||||
DUMP 52 topy=14 topx=15 cur=0 max=1 watch=0x21 sub=main (then UP: the step's target)
|
||||
DUMP 55 topy=14 topx=15 cur=2 max=1 watch=0x21 sub=main (A: the game adds 2 for the column)
|
||||
DUMP 60 topy=1 topx=0 cur=0 max=0 watch=0x03 sub=party list=0x02
|
||||
```
|
||||
|
||||
The A press at the right column's first row opens the **party list**. Red's battle menu is two
|
||||
*columns* -- the screen reads `FIGHT PKMN` over `ITEM RUN` -- and `wCurrentMenuItem` is the row
|
||||
inside the column the cursor is in, with two added for the right column on selection. So the
|
||||
game's order is **FIGHT, ITEM, PKMN, RUN**, and `macros::cartridge::battle_entry` had `PKMN` 1 and
|
||||
`ITEM` 2: the row-major reading of the picture. Every macro that meant to open the bag opened the
|
||||
party list and every macro that meant to open the party list opened the bag, for as long as the
|
||||
four constants have existed. The fake's own two-by-two moved row-major too, which is why no unit
|
||||
test could have caught it.
|
||||
|
||||
### What changed, all of it inside the macros
|
||||
|
||||
- **`MENU` is on no pad.** Not narrowed: nothing in the vocabulary uses the start menu except as a
|
||||
scene to leave, so there is nothing behind the button. It stays a type, a population, a tag and a
|
||||
script -- thirty-one channels, the roles and `--print-compatibility` do not move -- and the start
|
||||
menu is still the fly's to open with the raw START button.
|
||||
- **`ways` gains a room's last resort**, the one `GO ROUTE` has had outdoors since 13.1: with
|
||||
nothing else on this map worth walking to (`palette::stranded`), the exits of that kind come back
|
||||
ignoring the blocked window, the one toward the objective preferred.
|
||||
- **A cursor step waits for the list it was built for.** A `Listing` says which list it is and a
|
||||
step says which list its target indexes into; a step whose list is not up waits rather than
|
||||
pressing at the list it has already answered, and takes its order and budget from that list on
|
||||
the first frame it accepts input.
|
||||
- **`battle_entry`'s `ITEM` and `PKMN` swap**, and the fake's geometry becomes column-major.
|
||||
- **`BACK` on the move list only where the battler reads**, else `MOVE 1` alone.
|
||||
|
||||
| # | trap | trigger | test | fix, or why it is left |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 44 | `MENU` opens the start menu and that scene's `BACK` closes it again: a pair split across two scenes, and on a map where every other list has emptied it is the whole pad | thirty brain minutes on map `0x35`, `MENU` 82 starts and `BACK` 82; on v0.4.3 `MENU` is dealt on every overworld map | `menu_is_on_no_scenes_pad`, `the_overworld_plan_never_truncates_the_frontier_away`, `the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint` (ROM-gated: it **fails on v0.4.3** with `MENU` on the pad of maps 2, 52, 53, 54, 55, 57, 58 and 147 starts) | **fixed**: `MENU` is off every scene's set. A macro whose precondition holds wherever the fly stands and whose effect a neighbouring scene undoes is section 12.2's trap, and the start menu has nothing in it for the fly |
|
||||
| 45 | the overworld's never-empty guarantee *was* `MENU`, so taking it off could strand a room -- and the museum's one way out is a passage the blocked window rests | the same thirty minutes: `ways(Passage)` empty, `ways(Exit)` empty because there is no exit-class warp at all | `a_room_whose_only_way_out_the_ledger_rests_still_offers_it`, `an_overworld_pad_is_never_one_button_that_undoes_itself`, `no_playable_scene_and_no_sub_state_deals_an_empty_pad` | **fixed**: `ways`' last resort covers a room as well as a route. A map with **no way out at all** is a genuinely empty pad, is asserted as such, and is reported by `game.padEmptyMs` -- no map in Red is that shape |
|
||||
| 46 | Red's battle menu is two columns, so `battle_entry`'s row-major `PKMN` 1 / `ITEM` 2 sent `ITEM` and `THROW BALL` to the party list and `SWITCH` to the bag | every `THROW BALL`, `ITEM` and `SWITCH` ever started: 63 starts and 63 `blocked` in v0.4.3's after-arm, `SWITCH` 15 of them | `the_battle_menus_two_columns_put_item_under_fight_and_pkmn_beside_it`, `throw_ball_waits_for_the_bag_rather_than_reading_the_menu_it_came_from`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated, now asserting `THROW BALL` never blocks) | **fixed**: the order is FIGHT, ITEM, PKMN, RUN, surveyed byte by byte. ROM-gated from the rung-9 checkpoint: `THROW BALL` 15 starts and 0 blocked, `SWITCH` 17 and 0, against 6 of 6 and 15 before |
|
||||
| 47 | a move list whose battler the seam cannot read binds no `MOVE n`, so its pad is `BACK` alone -- which closes the list `MOVE 1` underneath had just opened | `BACK` was 263 of 797 macro starts in v0.4.3's after-arm, every one over an open move list | `the_move_list_deals_back_only_where_the_moves_can_be_read` | **fixed**: `BACK` is dealt on the move list only while `wBattleMon*` reads; otherwise `MOVE 1` alone, whose script confirms where the cursor stands, which is the press that ends a turn |
|
||||
|
||||
### The ROM-gated runs, before and after
|
||||
|
||||
From the rung-10 checkpoint, thirty-three brain minutes of the game-blind rotation, v0.4.3
|
||||
(`928d66b`) against this branch:
|
||||
|
||||
| measure | before (v0.4.3) | after |
|
||||
| --- | ---: | ---: |
|
||||
| maps whose overworld pad dealt `MENU` | **7** (2, 52, 53, 54, 55, 57, 58) | **0** |
|
||||
| `MENU` starts | **147** | **0** |
|
||||
| overworld pads that were empty | 0 | 0 |
|
||||
| frame the fly left map `0x35` | (it left, then came back) | **182** |
|
||||
|
||||
From the rung-9 forest checkpoint, sixty-seven brain minutes, the same two arms:
|
||||
|
||||
| measure | before (v0.4.3) | after |
|
||||
| --- | ---: | ---: |
|
||||
| `THROW BALL` starts / blocked | 6 / **6** | 15 / **0** |
|
||||
| `SWITCH` starts / blocked | 17 / **15** | 17 / **0** |
|
||||
| `RUN` blocked | 6 | 0 |
|
||||
| battles entered / ended | 8 / 8 | 4 / 3 (one still running at the budget) |
|
||||
| worst battle, in macros | 275 | 450 |
|
||||
|
||||
The worst battle grows because a fly whose `SWITCH` and `ITEM` reach their own lists spends turns
|
||||
switching and healing instead of only attacking. The claim the assertion carries is unchanged and
|
||||
still holds: a battle **ends**, on a bounded number of macros.
|
||||
|
||||
### The trap hunt, before and after -- and why it says nothing about this trap
|
||||
|
||||
Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge,
|
||||
from the release container's own rung-10 checkpoint, driven by the brain.
|
||||
|
||||
| measure | before (v0.4.3) | after |
|
||||
| --- | ---: | ---: |
|
||||
| distinct (map, tile) | 175 | 175 |
|
||||
| windows flagged | 73/73 | 73/73 |
|
||||
| macros started | 1413 | 1413 |
|
||||
| `MENU` starts | **0** | **0** |
|
||||
| `THROW BALL` blocked | 0 (never started) | 0 (never started) |
|
||||
| frames in `dialog` | 63,668 | 63,668 |
|
||||
|
||||
**The two arms are identical, and that is the honest result rather than a null one.** The reached
|
||||
and blocked ledgers are session state that a restore clears (12.1), so a restored fly is not in the
|
||||
state the loop needed: it walks out of the museum's upper floor in one `GO WARP` and never presses
|
||||
`MENU` at all, so removing a channel the decoder never picked changes nothing downstream. The trap
|
||||
hunt cannot reproduce a ledger-built loop from a checkpoint, and this is the first review where
|
||||
that has mattered; the proof for rows 44 and 45 is the pad rules and the ROM-gated run above.
|
||||
|
||||
What the hunt *does* reproduce, from this checkpoint, is **row 41** -- the Pokémon Center nurse's
|
||||
box -- at full scale: 62,804 of 71,673 frames are one text box on **one tile** of map `0x3a` at
|
||||
(3, 3), with `YES` **1,278** of 1,295 macro starts and the run ending there. That is the next trap
|
||||
and it now has a checkpoint of its own.
|
||||
|
||||
### Residuals, named rather than worked around
|
||||
|
||||
- **Row 41 is reproduced and is the next brief.** 1,295 of 1,413 macro starts in both arms are
|
||||
`YES` at the nurse's counter, on one tile, for the last eighteen brain minutes of the run.
|
||||
- **`MOVE n` reports `blocked` 890 times in 1,431 macros** in the forest ROM run, every one of them
|
||||
with the move list drawn and its cursor *placeable* but not accepting input: no press moves the
|
||||
cursor and the step spends its budget (`reason=budget target=2 max=3 kind=BattleMoves here=1`,
|
||||
535 of them). That is row 30b's unplaceable cursor inverted, it is unchanged from v0.4.3 (747
|
||||
blocked in 1,260), and the honest fix is a WRAM reading rather than a pad change.
|
||||
- **The museum is not in `geography`**, which is why `GO OBJECTIVE`, `GO SHOP` and `GO HEAL` are
|
||||
all off the pad inside it. Adding the row would give the fly the road to the gym from indoors;
|
||||
this branch did not, because the map graph is a data change with its own survey (12.7) and the
|
||||
way out now stands on its own.
|
||||
- **A map with no way out at all deals an empty pad.** No map in Red is that shape; it is asserted
|
||||
and reported rather than covered.
|
||||
|
||||
### Gates
|
||||
|
||||
- `cargo test --workspace` with `FLY_ROM` set: green except
|
||||
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
||||
which fails identically on v0.4.3 on this box (a debug build of the service does not finish
|
||||
booting inside the test's window here). Pre-existing and unrelated to the macro layer.
|
||||
- `cargo clippy --all-targets`: clean.
|
||||
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1,
|
||||
v0.4.2 and v0.4.3. Decoder, reward catalog, adapter version and roles untouched.
|
||||
|
|
|
|||
|
|
@ -107,8 +107,24 @@ pub const CHEAPEST_PURCHASE: u32 = {
|
|||
/// 1 PKMN, 2 ITEM, 3 RUN".
|
||||
pub mod battle_entry {
|
||||
pub const FIGHT: u8 = 0;
|
||||
pub const PKMN: u8 = 1;
|
||||
pub const ITEM: u8 = 2;
|
||||
/// `$01`. **The left column's second row, not the right column's first.**
|
||||
///
|
||||
/// Red draws the battle menu as `FIGHT PKMN` over `ITEM RUN`, which reads as two rows -- and
|
||||
/// the game's own index is two *columns*: `wCurrentMenuItem` is the row inside the column the
|
||||
/// cursor is in and selection adds two for the right one. So the order is FIGHT, `ITEM`,
|
||||
/// `PKMN`, RUN, and this pair was the other way round for as long as the four constants have
|
||||
/// existed.
|
||||
///
|
||||
/// **Measured on the cartridge** (2026-09-22, `infra/docs/macros-traps.md`): a `THROW BALL`
|
||||
/// aiming at 2 walked the cursor to `wTopMenuItemX` 15, `wCurrentMenuItem` 0, pressed A, and
|
||||
/// the **party list** opened -- `wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02`.
|
||||
/// The frame after the press the game wrote `wCurrentMenuItem` 2, which is the right column's
|
||||
/// first row plus two, and the right column's first row is PKMN. So `THROW BALL` and `ITEM`
|
||||
/// opened the party list and `SWITCH` opened the bag, every single time: `THROW BALL` was 63
|
||||
/// starts and 63 `blocked` on v0.4.3, and `SWITCH` 15 of them.
|
||||
pub const ITEM: u8 = 1;
|
||||
/// `$02`. The right column's first row: see [`ITEM`].
|
||||
pub const PKMN: u8 = 2;
|
||||
pub const RUN: u8 = 3;
|
||||
}
|
||||
|
||||
|
|
@ -272,12 +288,44 @@ pub enum TargetKey {
|
|||
Tile(Tile),
|
||||
}
|
||||
|
||||
/// A cursor the current scene's macros navigate: where it is, and how far it can go.
|
||||
/// Which list the shared cursor belongs to right now.
|
||||
///
|
||||
/// Red keeps one cursor for every menu in the game (`wCurrentMenuItem`), so "where is the cursor"
|
||||
/// is only half a question: a script that opens the bag from the battle menu and then navigates to
|
||||
/// a bag index has to know that the index it is aiming at belongs to the *bag* and not to the four
|
||||
/// entries it was reading a moment ago. Measured on the cartridge 2026-09-22: `THROW BALL` was
|
||||
/// **63 starts and 63 `blocked`**, mean sixty-nine frames, because the bag takes longer than the
|
||||
/// twenty settle frames to draw -- so the step that should have walked the bag list read the
|
||||
/// battle menu's `max` of 3, found the ball's bag index above it, and gave up at once
|
||||
/// (`docs/design/macros.md` section 12.11).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ListKind {
|
||||
/// FIGHT / PKMN / ITEM / RUN.
|
||||
BattleMain,
|
||||
/// The move list.
|
||||
BattleMoves,
|
||||
/// The party list, inside a battle.
|
||||
BattleParty,
|
||||
/// The bag, inside a battle.
|
||||
BattleBag,
|
||||
/// The start menu.
|
||||
StartMenu,
|
||||
/// A mart's counter, on whichever of its screens is up.
|
||||
Shop,
|
||||
/// A PC.
|
||||
Pc,
|
||||
}
|
||||
|
||||
/// A cursor the current scene's macros navigate: which list it is, where it is, and how far it can
|
||||
/// go.
|
||||
///
|
||||
/// Derived from whichever of agent A's menus is up, so a script asks "where is the cursor" once
|
||||
/// and does not care whether it is in a battle, a mart or the start menu.
|
||||
/// and does not care whether it is in a battle, a mart or the start menu -- but it can ask *which*
|
||||
/// list answered, which is what a script that crosses from one list into another needs
|
||||
/// ([`ListKind`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Listing {
|
||||
pub kind: ListKind,
|
||||
pub current: u8,
|
||||
pub max: u8,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ use crate::adapter::MemoryReader;
|
|||
use crate::emulator::buttons;
|
||||
|
||||
use super::cartridge::{
|
||||
FACINGS, MacroState, TalkTarget, TargetKey, Tile, battle_entry, button, item, opposite,
|
||||
FACINGS, ListKind, MacroState, TalkTarget, TargetKey, Tile, battle_entry, button, item,
|
||||
opposite,
|
||||
};
|
||||
use super::geography::Amenity;
|
||||
use super::palette::{
|
||||
|
|
@ -411,14 +412,37 @@ struct Walk {
|
|||
struct Cursor {
|
||||
target: u8,
|
||||
confirm: bool,
|
||||
/// The directions to try, in order, aimed at the target by the first comparison.
|
||||
order: [Facing; 4],
|
||||
/// Which list `target` indexes into, when the script knows -- and it always does when it has
|
||||
/// just pressed A to open one.
|
||||
///
|
||||
/// `None` navigates whatever list is up, which is what a step that does not cross a boundary
|
||||
/// wants: the shop's own screens are one [`ListKind`] and the step after a purchase's A press
|
||||
/// is still the counter's.
|
||||
///
|
||||
/// While the list up is a *different* one, the step waits rather than pressing at it, exactly
|
||||
/// as it waits for a list that reports no cursor at all (section 4: "never by counting
|
||||
/// presses"). Twenty settle frames is not always enough for the cartridge to draw the next
|
||||
/// list, and a step that reads the list it has already answered is pressing blind: it takes
|
||||
/// its length and its direction from four entries that are not the ones it is walking.
|
||||
///
|
||||
/// Section 12.11 is where this came from, and it is also what *found* the reason `THROW BALL`
|
||||
/// was 63 starts and 63 `blocked`: the step began reporting which list it had been left
|
||||
/// looking at, and the answer was never the bag -- it was the party list, because
|
||||
/// `battle_entry`'s `ITEM` and `PKMN` were the other way round. That is fixed at the
|
||||
/// constants; this rule stands on its own.
|
||||
want: Option<ListKind>,
|
||||
/// The directions to try, in order, aimed at the target from where the cursor actually is.
|
||||
///
|
||||
/// `None` until the list this step is for is accepting input, because "which way is the
|
||||
/// target" is a fact about that list and not about whatever was up when the script was built.
|
||||
order: Option<[Facing; 4]>,
|
||||
/// Which of them is being tried now.
|
||||
at: u8,
|
||||
/// The cursor value the current pulse started from.
|
||||
before: u8,
|
||||
/// Presses left in the budget.
|
||||
left: u8,
|
||||
/// Presses left in the budget, `None` until the list is up: twice the list plus slack, and
|
||||
/// the list whose length that is has to be the one being walked.
|
||||
left: Option<u8>,
|
||||
phase: u32,
|
||||
/// Frames spent waiting for the list to accept input.
|
||||
waited: u32,
|
||||
|
|
@ -1370,9 +1394,19 @@ fn goal_tile(walk: &Walk) -> Tile {
|
|||
/// which is what section 4 means by "never by counting presses": a list that is a column moves
|
||||
/// under DOWN and UP, Red's two-by-two battle menu also needs RIGHT and LEFT, and this finds out
|
||||
/// which by watching rather than by knowing. A list that is not accepting input yet is *waited*
|
||||
/// for, never pressed at.
|
||||
/// for, never pressed at -- and since section 12.11 so is a list that is up but is **not the one
|
||||
/// this step is walking**, because Red keeps one cursor for every menu in the game and reading the
|
||||
/// wrong one is not reading.
|
||||
fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
|
||||
let Some(list) = listing(state) else {
|
||||
// A confirming press that has begun finishes, and it is read before the list: the press is
|
||||
// what *answers* the list, so on the very frames it is being issued the cartridge is already
|
||||
// drawing the next one, and waiting for the list this step walked would wait for a list the
|
||||
// step has just left.
|
||||
if cursor.confirmed {
|
||||
return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP);
|
||||
}
|
||||
let list = listing(state).filter(|list| cursor.want.is_none_or(|want| want == list.kind));
|
||||
let Some(list) = list else {
|
||||
cursor.waited += 1;
|
||||
return if cursor.waited > CURSOR_WAIT {
|
||||
Progress::Blocked
|
||||
|
|
@ -1381,9 +1415,6 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
|
|||
};
|
||||
};
|
||||
let here = list.current;
|
||||
if cursor.confirmed {
|
||||
return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP);
|
||||
}
|
||||
if here == cursor.target {
|
||||
if !cursor.confirm {
|
||||
return Progress::Next;
|
||||
|
|
@ -1392,18 +1423,25 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
|
|||
cursor.phase = 0;
|
||||
return pulse(buttons::A, &mut cursor.phase, PRESS_HOLD, PRESS_GAP);
|
||||
}
|
||||
if cursor.left == 0 || cursor.target > list.max {
|
||||
// The first frame the list this step is for is accepting input is where the navigation is
|
||||
// aimed from and budgeted by. Both are facts about *this* list, and a script that opened it
|
||||
// could not have known either when it was built.
|
||||
let aimed = aim_order(cursor.target, here);
|
||||
let order = *cursor.order.get_or_insert(aimed);
|
||||
let sized = list.max.saturating_add(1).saturating_mul(2).saturating_add(CURSOR_SLACK);
|
||||
let budget = *cursor.left.get_or_insert(sized);
|
||||
if budget == 0 || cursor.target > list.max {
|
||||
return Progress::Blocked;
|
||||
}
|
||||
if cursor.phase == 0 {
|
||||
cursor.before = here;
|
||||
}
|
||||
let facing = cursor.order[usize::from(cursor.at) % cursor.order.len()];
|
||||
let facing = order[usize::from(cursor.at) % order.len()];
|
||||
match pulse(button(facing), &mut cursor.phase, PRESS_HOLD, PRESS_GAP) {
|
||||
Progress::Next => {
|
||||
let closer = here.abs_diff(cursor.target) < cursor.before.abs_diff(cursor.target);
|
||||
cursor.at = if closer { 0 } else { (cursor.at + 1) % 4 };
|
||||
cursor.left -= 1;
|
||||
cursor.left = Some(budget.saturating_sub(1));
|
||||
cursor.phase = 0;
|
||||
Progress::Hold(buttons::NONE)
|
||||
}
|
||||
|
|
@ -1411,6 +1449,19 @@ fn cursor_frame(cursor: &mut Cursor, state: &mut dyn MacroState) -> Progress {
|
|||
}
|
||||
}
|
||||
|
||||
/// The directions a cursor tries, in order, to get from `here` to `target`.
|
||||
///
|
||||
/// A column moves under DOWN and UP; Red's two-by-two battle menu needs RIGHT and LEFT as well,
|
||||
/// and which of the four works is found by watching the cursor rather than by knowing the
|
||||
/// geometry, so this only decides which to try *first*.
|
||||
const fn aim_order(target: u8, here: u8) -> [Facing; 4] {
|
||||
if target > here {
|
||||
[Facing::Down, Facing::Right, Facing::Up, Facing::Left]
|
||||
} else {
|
||||
[Facing::Up, Facing::Left, Facing::Down, Facing::Right]
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the script for one macro, or `None` when there is nothing to walk to.
|
||||
///
|
||||
/// Every target is computed here, once, from the state the macro started in — which move, which
|
||||
|
|
@ -1520,7 +1571,7 @@ fn script(
|
|||
} else {
|
||||
cursor_at.filter(|at| *at < count)?
|
||||
};
|
||||
steps.push(cursor(state, target, true));
|
||||
steps.push(cursor_on(target, true, Some(ListKind::BattleMoves)));
|
||||
return Some((steps.into(), aimed));
|
||||
}
|
||||
// From the menu above, FIGHT has to be chosen first, whether or not anything has PP:
|
||||
|
|
@ -1529,7 +1580,7 @@ fn script(
|
|||
if !in_main_menu(state) {
|
||||
return None;
|
||||
}
|
||||
steps.push(cursor(state, battle_entry::FIGHT, true));
|
||||
steps.push(cursor_on(battle_entry::FIGHT, true, Some(ListKind::BattleMain)));
|
||||
steps.push(settle());
|
||||
// What happens after FIGHT is the cartridge's own answer and it is measured rather
|
||||
// than assumed (row 34): with a move that has PP the list opens and this slot is
|
||||
|
|
@ -1538,7 +1589,7 @@ fn script(
|
|||
// second cursor step would press A at text. `MOVE 1` is the button that reaches that
|
||||
// state, because it is the only one bound there.
|
||||
if slot_has_pp(state, slot) {
|
||||
steps.push(cursor(state, slot, true));
|
||||
steps.push(cursor_on(slot, true, Some(ListKind::BattleMoves)));
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
|
@ -1550,10 +1601,13 @@ fn script(
|
|||
let bag_slot = throw_slot(state)?;
|
||||
let mut steps = Vec::new();
|
||||
if in_main_menu(state) {
|
||||
steps.push(cursor(state, battle_entry::ITEM, true));
|
||||
steps.push(cursor_on(battle_entry::ITEM, true, Some(ListKind::BattleMain)));
|
||||
steps.push(settle());
|
||||
}
|
||||
steps.push(cursor(state, bag_slot, true));
|
||||
// The bag, and it has to *be* the bag: this is the step that reported `blocked` 63
|
||||
// times out of 63 on the cartridge while it was allowed to read the battle menu's
|
||||
// cursor instead (section 12.11).
|
||||
steps.push(cursor_on(bag_slot, true, Some(ListKind::BattleBag)));
|
||||
steps
|
||||
}
|
||||
MacroKind::Switch => {
|
||||
|
|
@ -1562,10 +1616,10 @@ fn script(
|
|||
// A forced switch is already looking at the party list; a chosen switch has to get
|
||||
// there through the battle menu's PKMN entry first.
|
||||
if in_main_menu(state) {
|
||||
steps.push(cursor(state, battle_entry::PKMN, true));
|
||||
steps.push(cursor_on(battle_entry::PKMN, true, Some(ListKind::BattleMain)));
|
||||
steps.push(settle());
|
||||
}
|
||||
steps.push(cursor(state, slot, true));
|
||||
steps.push(cursor_on(slot, true, Some(ListKind::BattleParty)));
|
||||
steps.push(settle());
|
||||
// The party entry's action list opens on SWITCH.
|
||||
steps.push(press(buttons::A));
|
||||
|
|
@ -1576,17 +1630,19 @@ fn script(
|
|||
let active = state.battle().and_then(|battle| battle.own).map(|mon| mon.slot)?;
|
||||
let mut steps = Vec::new();
|
||||
if in_main_menu(state) {
|
||||
steps.push(cursor(state, battle_entry::ITEM, true));
|
||||
steps.push(cursor_on(battle_entry::ITEM, true, Some(ListKind::BattleMain)));
|
||||
steps.push(settle());
|
||||
}
|
||||
steps.push(cursor(state, bag_slot, true));
|
||||
steps.push(cursor_on(bag_slot, true, Some(ListKind::BattleBag)));
|
||||
steps.push(settle());
|
||||
// Which Pokémon to heal: the one that is out, because that is the HP the precondition
|
||||
// measured.
|
||||
steps.push(cursor(state, active, true));
|
||||
steps.push(cursor_on(active, true, Some(ListKind::BattleParty)));
|
||||
steps
|
||||
}
|
||||
MacroKind::Run => vec![cursor(state, battle_entry::RUN, true)],
|
||||
MacroKind::Run => {
|
||||
vec![cursor_on(battle_entry::RUN, true, Some(ListKind::BattleMain))]
|
||||
}
|
||||
MacroKind::BuyPotion => shop_plan(state, item::POTION)?,
|
||||
MacroKind::BuyBall => shop_plan(state, item::POKE_BALL)?,
|
||||
MacroKind::BuyAntidote => shop_plan(state, item::ANTIDOTE)?,
|
||||
|
|
@ -1675,24 +1731,31 @@ fn settle() -> Step {
|
|||
Step::Settle { phase: 0 }
|
||||
}
|
||||
|
||||
/// A cursor step aimed at `target`, with its press order and budget taken from the list that is
|
||||
/// up right now.
|
||||
fn cursor(state: &mut dyn MacroState, target: u8, confirm: bool) -> Step {
|
||||
let list = listing(state);
|
||||
let from = list.map_or(0, |list| list.current);
|
||||
let max = list.map_or(target, |list| list.max);
|
||||
/// A cursor step aimed at `target` in whichever list is accepting input when it runs.
|
||||
///
|
||||
/// For a step that does not cross from one list into another: the shop's own screens, and the
|
||||
/// first step of any script, which reads the list its macro was dealt on.
|
||||
fn cursor(target: u8, confirm: bool) -> Step {
|
||||
cursor_on(target, confirm, None)
|
||||
}
|
||||
|
||||
/// A cursor step aimed at `target` in `want`, waiting for that list rather than pressing at
|
||||
/// whichever one happens to be up.
|
||||
///
|
||||
/// The press order and the budget are taken from the list on the first frame it accepts input
|
||||
/// ([`cursor_frame`]) and not from here, because a script that has just pressed A to open a list
|
||||
/// is still reading the list it pressed A *in*: twenty settle frames is not always enough for the
|
||||
/// cartridge to draw the next one, and the one it has is the wrong length and points the wrong
|
||||
/// way (section 12.11).
|
||||
fn cursor_on(target: u8, confirm: bool, want: Option<ListKind>) -> Step {
|
||||
Step::Cursor(Cursor {
|
||||
target,
|
||||
confirm,
|
||||
order: if target > from {
|
||||
[Facing::Down, Facing::Right, Facing::Up, Facing::Left]
|
||||
} else {
|
||||
[Facing::Up, Facing::Left, Facing::Down, Facing::Right]
|
||||
},
|
||||
want,
|
||||
order: None,
|
||||
at: 0,
|
||||
before: from,
|
||||
// Twice the list, plus slack for a press the game swallows while a menu draws.
|
||||
left: max.saturating_add(1).saturating_mul(2).saturating_add(CURSOR_SLACK),
|
||||
before: target,
|
||||
left: None,
|
||||
phase: 0,
|
||||
waited: 0,
|
||||
confirmed: false,
|
||||
|
|
@ -1902,10 +1965,10 @@ fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option<Vec<Step>> {
|
|||
let mut steps = Vec::new();
|
||||
if shop_screen(state)? == ShopScreen::BuySellQuit {
|
||||
// BUY is the counter menu's first entry.
|
||||
steps.push(cursor(state, 0, true));
|
||||
steps.push(cursor(0, true));
|
||||
steps.push(settle());
|
||||
}
|
||||
steps.push(cursor(state, index, true));
|
||||
steps.push(cursor(index, true));
|
||||
steps.push(settle());
|
||||
// The quantity prompt opens on one, and the price confirmation opens on YES.
|
||||
steps.push(press(buttons::A));
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
|
||||
use super::cartridge::{
|
||||
CHEAPEST_PURCHASE, FACINGS, opposite,
|
||||
ExitId, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget, TargetKey,
|
||||
Tile, item, outdoors,
|
||||
ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget,
|
||||
TargetKey, Tile, item, outdoors,
|
||||
};
|
||||
use crate::adapter::PlaceKind;
|
||||
|
||||
|
|
@ -484,8 +484,18 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
|||
Scene::Menu => vec![Close, Confirm, Back],
|
||||
// Section 9.1's split, plus section 13's errands and centre. Indoors the ways out of a
|
||||
// room are the building's door and its passages; outdoors there is no building to leave.
|
||||
// `MENU` is unconditional and last, which is what makes an empty overworld pad impossible
|
||||
// (section 13.1, row 18).
|
||||
//
|
||||
// **`MENU` is on no pad** (section 12.11). It was here as the unconditional button that
|
||||
// made an empty overworld pad impossible, and that is exactly what made it a trap: opening
|
||||
// the start menu changes nothing in the world, so the macro completes where the fly stands
|
||||
// -- section 12.2's rule -- and the scene it opens deals `CLOSE` and `BACK`, which close it
|
||||
// again. Live on rung 10, thirty minutes inside the Pewter museum's upper floor: `MENU` 82
|
||||
// starts, `BACK` 82, `GO FRONTIER` 8, the log alternating `MENU start/done, BACK
|
||||
// start/done`. Nothing in the macro vocabulary uses the start menu for anything -- there is
|
||||
// no SAVE macro and no POKéDEX macro -- so there is nothing behind the button worth
|
||||
// pressing it for. What keeps this pad from being empty instead is the way out, which
|
||||
// [`ways`] offers regardless of the ledgers when nothing else on the map is worth walking
|
||||
// to.
|
||||
Scene::Overworld => {
|
||||
let mut set = vec![GoObjective];
|
||||
if outdoors_now(state) {
|
||||
|
|
@ -497,7 +507,7 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
|||
if inside_center(state) {
|
||||
set.push(Heal);
|
||||
}
|
||||
set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk, Menu]);
|
||||
set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk]);
|
||||
set
|
||||
}
|
||||
// Section 12: "Forced switch: SWITCH" -- plus the press that advances a battle, because
|
||||
|
|
@ -516,7 +526,20 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
|||
// between them. Two buttons that undo each other with nothing else changing are section
|
||||
// 12.2's trap spread over two sub-states of one turn.
|
||||
Scene::Battle { own_turn: true, .. } => match battle_menu(state) {
|
||||
BattleMenu::Moves { .. } => vec![Move1, Move2, Move3, Move4, Back],
|
||||
// The move list. `BACK` is a button here because there is a list to leave (12.9) --
|
||||
// but only while the moves can be *read*: a battler the seam cannot place leaves all
|
||||
// four `MOVE n` unbound, and a pad of `BACK` alone closes the list that `MOVE 1` on
|
||||
// the menu underneath had just opened. That is 12.10's pair again, with `MOVE 1` in
|
||||
// `NEXT`'s place. With nothing readable the pad is `MOVE 1` alone and its script
|
||||
// confirms wherever the cursor stands, which is the press that ends the turn
|
||||
// (section 12.11).
|
||||
BattleMenu::Moves { .. } => {
|
||||
if state.battle().and_then(|battle| battle.own).is_some() {
|
||||
vec![Move1, Move2, Move3, Move4, Back]
|
||||
} else {
|
||||
vec![Move1]
|
||||
}
|
||||
}
|
||||
BattleMenu::Party { .. } => vec![Switch, Back],
|
||||
// The bag, which is the fly's turn since 12.10. Its three answers: use the thing the
|
||||
// cursor is on (`ITEM`), throw the ball (`THROW BALL`), or leave the list (`BACK`).
|
||||
|
|
@ -579,7 +602,15 @@ pub fn precondition(kind: MacroKind, state: &mut dyn MacroState) -> bool {
|
|||
// (only when facing something untalked)"). A tile ahead with nothing on it is not a
|
||||
// reason to press A, and a shelf that has been read is not a reason to read it again.
|
||||
MacroKind::Talk => facing_untalked(state),
|
||||
// Unconditional: opening the start menu is always available.
|
||||
// The start menu opens from anywhere, and that is exactly why `MENU` is on no pad:
|
||||
// "the precondition is satisfied wherever the fly stands" is section 12.2's trap, and a
|
||||
// macro whose whole effect is a screen its own scene's `BACK` closes again is 12.10's
|
||||
// pair one scene wider (section 12.11). The refusal belongs at the **dealer** and not
|
||||
// here, because this arm is a true fact about the macro and [`scene_set`] is where the
|
||||
// reason lives: nothing in the vocabulary uses the start menu, so there is nothing behind
|
||||
// the button to press it for -- and the day a SAVE macro exists, one row changes.
|
||||
// `MENU` stays a type, a population, a tag and a script, so the roles, the channel order
|
||||
// and `--print-compatibility` are untouched.
|
||||
MacroKind::Menu => true,
|
||||
// Advancing text, answering a prompt and backing out never need anything.
|
||||
MacroKind::Next
|
||||
|
|
@ -985,7 +1016,17 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
|
|||
let all = unexcluded_exits(state, way);
|
||||
// Tier 3, and only where a map would otherwise be impossible to leave: see the doc comment.
|
||||
match way {
|
||||
Way::Exit => all,
|
||||
// A room still has to be leavable, and since section 12.11 that holds even while the
|
||||
// ledgers are resting its one door: `unexcluded_exits` is emptied by the blocked window,
|
||||
// by the rung's own target being on this map (row 29) and by an unfaced counter, and with
|
||||
// `MENU` off the pad an emptied way out is an overworld with nothing on it at all.
|
||||
Way::Exit => {
|
||||
if all.is_empty() {
|
||||
last_resort(state, way)
|
||||
} else {
|
||||
all
|
||||
}
|
||||
}
|
||||
// A staircase the run has already been up is exploration already done, and the same bounce
|
||||
// `GO ROUTE` had: up, straight back down, up again, once per hold. The exception is the map
|
||||
// whose only way anywhere *is* a passage -- Red's bedroom, the upper floor of any house --
|
||||
|
|
@ -993,6 +1034,8 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
|
|||
Way::Passage => {
|
||||
if path::exits(state).iter().any(|exit| exit.way == Way::Exit) {
|
||||
Vec::new()
|
||||
} else if all.is_empty() {
|
||||
last_resort(state, way)
|
||||
} else {
|
||||
all
|
||||
}
|
||||
|
|
@ -1010,19 +1053,31 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
|
|||
//
|
||||
// It also ignores the blocked window, which nothing else does: a target the ledger is
|
||||
// resting is still the only place to go.
|
||||
Way::Route => {
|
||||
if stranded(state) {
|
||||
let every: Vec<Exit> =
|
||||
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
|
||||
let toward = toward_objective(state, &every);
|
||||
if toward.is_empty() { every } else { toward }
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
Way::Route => last_resort(state, way),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way out of this kind, ignoring the ledgers, when the map offers nothing else at all.
|
||||
///
|
||||
/// Section 13.1's never-empty rule, and since section 12.11 it is what the rule *rests* on: the
|
||||
/// overworld has no unconditional button any more, so the pad of a map with every ledger against it
|
||||
/// is this list or nothing. Guarded by [`stranded`], which is built out of [`exit_tiers`] rather
|
||||
/// than [`ways`] so that asking "is the fly stranded" cannot recurse into the answer it decides.
|
||||
///
|
||||
/// It ignores the blocked window, which nothing else does: a target the ledger is resting is still
|
||||
/// the only place to go. And it is a last resort rather than a tier -- with anything else on the
|
||||
/// pad it stays off, because "all of them, nearest" once per hold is row 2's own loop, measured as
|
||||
/// two hours seventeen in and out of one house door.
|
||||
fn last_resort(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
|
||||
if !stranded(state) {
|
||||
return Vec::new();
|
||||
}
|
||||
let every: Vec<Exit> =
|
||||
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
|
||||
let toward = toward_objective(state, &every);
|
||||
if toward.is_empty() { every } else { toward }
|
||||
}
|
||||
|
||||
/// The exits of the current map of one kind that no ledger excludes.
|
||||
fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
|
||||
if !objective_targets(state).is_empty() {
|
||||
|
|
@ -1053,8 +1108,10 @@ fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
|
|||
/// Section 13.1's pad-empty audit. Deliberately built out of [`exit_tiers`] rather than [`ways`],
|
||||
/// so that asking "is the fly stranded" cannot recurse into the fallback the answer decides.
|
||||
///
|
||||
/// `MENU` is unconditional, so an overworld pad is never literally empty; what this measures is
|
||||
/// the state the operator saw on stream, where the pad has a button that cannot move the fly anywhere.
|
||||
/// What this measures is the state the operator saw on stream: a map where nothing the pad offers
|
||||
/// can move the fly anywhere. It used to be the weaker claim -- `MENU` was unconditional, so the
|
||||
/// pad was never *literally* empty, only useless -- and since section 12.11 took `MENU` off the
|
||||
/// overworld it is the literal one, which is why [`last_resort`] is what answers it.
|
||||
pub fn stranded(state: &mut dyn MacroState) -> bool {
|
||||
objective_goals(state).is_empty()
|
||||
&& amenity_goals(state, Amenity::Mart).is_empty()
|
||||
|
|
@ -1599,7 +1656,14 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
|
|||
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) {
|
||||
return true;
|
||||
}
|
||||
let Some(own) = battle.own else { return false };
|
||||
// And the same backstop over an **open move list** whose battler the seam cannot read
|
||||
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
|
||||
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
|
||||
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
|
||||
// it reads no move either, and confirming a move is what ends a turn.
|
||||
let Some(own) = battle.own else {
|
||||
return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. });
|
||||
};
|
||||
let holds = |slot: usize| -> Option<&Move> {
|
||||
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
|
||||
};
|
||||
|
|
@ -1671,28 +1735,42 @@ pub fn throw_slot(state: &mut dyn MacroState) -> Option<u8> {
|
|||
pub fn listing(state: &mut dyn MacroState) -> Option<Listing> {
|
||||
if let Some(battle) = state.battle() {
|
||||
return match battle.menu {
|
||||
BattleMenu::Main { cursor } => Some(Listing { current: cursor, max: 3 }),
|
||||
BattleMenu::Moves { cursor: Some(cursor), count } => {
|
||||
Some(Listing { current: cursor, max: count.saturating_sub(1) })
|
||||
BattleMenu::Main { cursor } => {
|
||||
Some(Listing { kind: ListKind::BattleMain, current: cursor, max: 3 })
|
||||
}
|
||||
BattleMenu::Moves { cursor: Some(cursor), count } => Some(Listing {
|
||||
kind: ListKind::BattleMoves,
|
||||
current: cursor,
|
||||
max: count.saturating_sub(1),
|
||||
}),
|
||||
BattleMenu::Moves { cursor: None, .. } | BattleMenu::None => None,
|
||||
BattleMenu::Bag { cursor, count } => {
|
||||
(count > 0).then(|| Listing { current: cursor, max: count.saturating_sub(1) })
|
||||
}
|
||||
BattleMenu::Bag { cursor, count } => (count > 0).then(|| Listing {
|
||||
kind: ListKind::BattleBag,
|
||||
current: cursor,
|
||||
max: count.saturating_sub(1),
|
||||
}),
|
||||
BattleMenu::Party { cursor } => {
|
||||
let max = u8::try_from(state.party().mons.len().saturating_sub(1)).unwrap_or(0);
|
||||
Some(Listing { current: cursor, max })
|
||||
Some(Listing { kind: ListKind::BattleParty, current: cursor, max })
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(menu) = state.start_menu() {
|
||||
return Some(Listing { current: menu.cursor.current, max: menu.cursor.max });
|
||||
return Some(Listing {
|
||||
kind: ListKind::StartMenu,
|
||||
current: menu.cursor.current,
|
||||
max: menu.cursor.max,
|
||||
});
|
||||
}
|
||||
if let Some(shop) = state.shop() {
|
||||
return Some(Listing { current: shop.cursor.current, max: shop.cursor.max });
|
||||
return Some(Listing {
|
||||
kind: ListKind::Shop,
|
||||
current: shop.cursor.current,
|
||||
max: shop.cursor.max,
|
||||
});
|
||||
}
|
||||
if let Some(pc) = state.pc() {
|
||||
return Some(Listing { current: pc.cursor.current, max: pc.cursor.max });
|
||||
return Some(Listing { kind: ListKind::Pc, current: pc.cursor.current, max: pc.cursor.max });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,15 @@ struct World {
|
|||
start_to_open: u8,
|
||||
/// What the next A presses open.
|
||||
opens: VecDeque<Opens>,
|
||||
/// Frames the cartridge spends *drawing* a list an A press opened, before it accepts input.
|
||||
///
|
||||
/// Zero everywhere but the one test this is for. On the cartridge it is not zero and it is not
|
||||
/// bounded by the twenty frames a script's `settle` waits: measured 2026-09-22, `THROW BALL`
|
||||
/// pressed ITEM, settled, and then read the battle *menu*'s cursor because the bag had not
|
||||
/// drawn yet -- 63 starts, 63 `blocked` (section 12.11).
|
||||
opens_draw_in: u32,
|
||||
/// The list an A press opened and the frame it starts accepting input on.
|
||||
pending: Option<(u32, Opens)>,
|
||||
/// A scene the world switches to at this frame, for the abort rule.
|
||||
switch: Option<(u32, Scene)>,
|
||||
/// A frame at which the cartridge heals the party, which is what a Pokémon Center does while
|
||||
|
|
@ -223,6 +232,8 @@ impl World {
|
|||
b_to_close: 1,
|
||||
start_to_open: 1,
|
||||
opens: VecDeque::new(),
|
||||
opens_draw_in: 0,
|
||||
pending: None,
|
||||
switch: None,
|
||||
heal_at: None,
|
||||
scripted: false,
|
||||
|
|
@ -342,6 +353,15 @@ impl World {
|
|||
|
||||
fn frame(&mut self, mask: u8) {
|
||||
self.frames += 1;
|
||||
if let Some((at, next)) = self.pending
|
||||
&& self.frames >= at
|
||||
{
|
||||
self.list = next.list;
|
||||
self.cursor = next.cursor;
|
||||
self.cursor_max = next.max;
|
||||
self.grid = next.grid;
|
||||
self.pending = None;
|
||||
}
|
||||
if let Some(at) = self.scripted_at
|
||||
&& self.frames >= at
|
||||
{
|
||||
|
|
@ -411,10 +431,14 @@ impl World {
|
|||
if mask == buttons::A
|
||||
&& let Some(next) = self.opens.pop_front()
|
||||
{
|
||||
self.list = next.list;
|
||||
self.cursor = next.cursor;
|
||||
self.cursor_max = next.max;
|
||||
self.grid = next.grid;
|
||||
if self.opens_draw_in > 0 {
|
||||
self.pending = Some((self.frames + self.opens_draw_in, next));
|
||||
} else {
|
||||
self.list = next.list;
|
||||
self.cursor = next.cursor;
|
||||
self.cursor_max = next.max;
|
||||
self.grid = next.grid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -424,12 +448,16 @@ impl World {
|
|||
}
|
||||
let here = i16::from(self.cursor);
|
||||
let target = if self.grid {
|
||||
// Two rows of two: up and down change row, left and right change column.
|
||||
// Red's battle menu: two **columns** of two, indexed by column. `wCurrentMenuItem` is
|
||||
// the row inside the column the cursor is in and selection adds two for the right
|
||||
// column, so the order is FIGHT, ITEM, PKMN, RUN -- up and down move one inside a
|
||||
// column, left and right move two across (surveyed 2026-09-22; the fake had it
|
||||
// row-major, which is the same mistake `battle_entry` had).
|
||||
match facing {
|
||||
Facing::Down => here + 2,
|
||||
Facing::Up => here - 2,
|
||||
Facing::Right if here % 2 == 0 => here + 1,
|
||||
Facing::Left if here % 2 == 1 => here - 1,
|
||||
Facing::Down if here % 2 == 0 => here + 1,
|
||||
Facing::Up if here % 2 == 1 => here - 1,
|
||||
Facing::Right => here + 2,
|
||||
Facing::Left => here - 2,
|
||||
_ => here,
|
||||
}
|
||||
} else {
|
||||
|
|
@ -685,7 +713,19 @@ fn run_with(
|
|||
kind: MacroKind,
|
||||
) -> Result<MacroAbort, MacroRefused> {
|
||||
let (palette, slot) = pick(world, kind);
|
||||
if let Err(refused) = machine.start(&palette, slot, world) {
|
||||
drive(machine, &palette, slot, world)
|
||||
}
|
||||
|
||||
/// [`run_with`], on a palette the caller dealt: start the slot, drive to the outcome, and do the
|
||||
/// driver's own bookkeeping after it.
|
||||
fn drive(
|
||||
machine: &mut MacroMachine,
|
||||
palette: &Palette,
|
||||
slot: MacroId,
|
||||
world: &mut World,
|
||||
) -> Result<MacroAbort, MacroRefused> {
|
||||
let kind = palette.slot(slot).expect("the caller dealt this slot").kind;
|
||||
if let Err(refused) = machine.start(palette, slot, world) {
|
||||
// The driver drains the ledgers after every `start`, refusal included: a `no route`
|
||||
// refusal earns blocked entries and presses nothing, so nothing else would collect them
|
||||
// (`PokemonPalette::start`).
|
||||
|
|
@ -726,6 +766,19 @@ fn run_with(
|
|||
Ok(machine.outcome().expect("a finished macro has an outcome").1)
|
||||
}
|
||||
|
||||
/// A palette of exactly one button, for a script whose macro no scene binds any more.
|
||||
///
|
||||
/// `MENU` is the only one (section 12.11): it is still a type, a population, a tag and a script --
|
||||
/// the roles and `--print-compatibility` depend on the type list -- and it is on no pad, so
|
||||
/// [`pick`] cannot find it. Its script is exercised here rather than deleted, because what took it
|
||||
/// off the pad is that the start menu has nothing in it for the fly and not that pressing START is
|
||||
/// wrong.
|
||||
fn forced(kind: MacroKind, world: &mut World) -> (Palette, MacroId) {
|
||||
let mut slots = [None; super::palette::SLOTS];
|
||||
slots[usize::from(kind.slot())] = Some(super::palette::MacroSpec::of(kind));
|
||||
(Palette { scene: world.scene(), slots }, MacroId(kind.slot()))
|
||||
}
|
||||
|
||||
fn pick(world: &mut World, kind: MacroKind) -> (Palette, MacroId) {
|
||||
let scene = world.scene();
|
||||
// The shipping mode is `PaletteMode::Plan` (`MacroMode::Macros` in `flysim::snapshot`), which
|
||||
|
|
@ -781,7 +834,8 @@ fn the_indoor_overworld_row_is_section_nine_ones_row() {
|
|||
let palette = Palette::for_scene(Scene::Overworld, &mut world);
|
||||
assert_eq!(
|
||||
names(&palette),
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK", "MENU"]
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK"],
|
||||
"and no `MENU`, which section 12.11 took off this row"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +848,7 @@ fn the_outdoor_overworld_row_has_the_route_and_no_passage_in_it() {
|
|||
world.connections.south = true;
|
||||
world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }];
|
||||
let palette = Palette::for_scene(Scene::Overworld, &mut world);
|
||||
assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER", "MENU"]);
|
||||
assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -802,7 +856,7 @@ fn an_overworld_with_no_way_out_leaves_the_leaving_buttons_unbound() {
|
|||
let mut world = World::room();
|
||||
let palette = Palette::for_scene(Scene::Overworld, &mut world);
|
||||
assert_eq!(palette.slot(MacroId(MacroKind::GoOut.slot())), None, "a sealed room binds no way out");
|
||||
assert_eq!(names(&palette), ["GO FRONTIER", "MENU"], "and there is always ground to cover");
|
||||
assert_eq!(names(&palette), ["GO FRONTIER"], "and there is always ground to cover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1016,8 +1070,12 @@ fn a_move_button_confirms_its_own_slot_over_an_open_list() {
|
|||
assert_eq!(world.cursor, 2, "the third slot, by watching where the cursor went");
|
||||
assert_eq!(world.pulses.last(), Some(&buttons::A));
|
||||
|
||||
// And from the menu above, FIGHT first and then the slot.
|
||||
// And from the menu above, FIGHT first and then the slot. The list has to actually open:
|
||||
// since section 12.11 the step that walks it *waits* for the move list rather than reading
|
||||
// whatever cursor is up, so a fixture where FIGHT opens nothing waits out `CURSOR_WAIT` --
|
||||
// which is the right answer, and is what the cartridge was doing to `THROW BALL` in reverse.
|
||||
let mut world = World::battle();
|
||||
world.opens.push_back(Opens { list: List::Moves(3), cursor: 0, max: 2, grid: false });
|
||||
assert_eq!(run(&mut world, MacroKind::Move2).unwrap(), MacroAbort::Done);
|
||||
assert!(
|
||||
world.pulses.contains(&buttons::A),
|
||||
|
|
@ -1715,7 +1773,11 @@ fn go_frontier_is_unbound_once_every_reachable_tile_has_been_stood_on() {
|
|||
fn menu_holds_start_until_the_start_menu_opens() {
|
||||
let mut world = World::room();
|
||||
world.start_to_open = 2;
|
||||
assert_eq!(run(&mut world, MacroKind::Menu).unwrap(), MacroAbort::Done);
|
||||
// Off every pad since section 12.11, so the palette is built by hand: the script is what is
|
||||
// under test here and no scene offers the button any more.
|
||||
let mut machine = MacroMachine::new(0x1234_5678);
|
||||
let (palette, slot) = forced(MacroKind::Menu, &mut world);
|
||||
assert_eq!(drive(&mut machine, &palette, slot, &mut world).unwrap(), MacroAbort::Done);
|
||||
assert_eq!(world.pulses, vec![buttons::START, buttons::START]);
|
||||
assert_eq!(world.scene, Scene::Menu);
|
||||
}
|
||||
|
|
@ -1724,7 +1786,9 @@ fn menu_holds_start_until_the_start_menu_opens() {
|
|||
fn menu_reports_blocked_when_the_start_menu_never_opens() {
|
||||
let mut world = World::room();
|
||||
world.start_to_open = u8::MAX;
|
||||
assert_eq!(run(&mut world, MacroKind::Menu).unwrap(), MacroAbort::Blocked);
|
||||
let mut machine = MacroMachine::new(0x1234_5678);
|
||||
let (palette, slot) = forced(MacroKind::Menu, &mut world);
|
||||
assert_eq!(drive(&mut machine, &palette, slot, &mut world).unwrap(), MacroAbort::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2110,6 +2174,10 @@ fn item_reaches_the_potion_by_reading_the_bags_cursor() {
|
|||
let mut world = World::battle();
|
||||
world.bag = vec![(item::POKE_BALL, 3), (item::POTION, 2)];
|
||||
world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 1, grid: false });
|
||||
// And confirming the potion opens the party list, which is where the script says which
|
||||
// Pokémon to heal. Section 12.11: that step waits for the *party* list, so the fixture has to
|
||||
// open it -- the cartridge does.
|
||||
world.opens.push_back(Opens { list: List::BattleParty, cursor: 0, max: 2, grid: false });
|
||||
assert_eq!(run(&mut world, MacroKind::Item).unwrap(), MacroAbort::Done);
|
||||
assert!(
|
||||
world.pulses.iter().filter(|mask| **mask == buttons::A).count() >= 2,
|
||||
|
|
@ -2268,7 +2336,7 @@ fn the_indoor_pad_is_section_nine_ones_set() {
|
|||
let mut world = populated();
|
||||
assert_eq!(
|
||||
plan(&mut world),
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"],
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"],
|
||||
"no objective is known, so `GO OBJECTIVE` is not on the pad"
|
||||
);
|
||||
}
|
||||
|
|
@ -2277,7 +2345,7 @@ fn the_indoor_pad_is_section_nine_ones_set() {
|
|||
fn the_outdoor_pad_has_the_route_and_no_passage_in_it() {
|
||||
let mut world = populated();
|
||||
world.map = 0x00;
|
||||
assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]);
|
||||
assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2304,7 +2372,7 @@ fn a_rung_on_another_floor_of_this_building_is_reached_through_the_passage() {
|
|||
assert!(plan::passage_to_objective(&mut world));
|
||||
assert_eq!(
|
||||
plan(&mut world),
|
||||
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
|
||||
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
|
||||
);
|
||||
// And the objective's own goal is the staircase, because that is the warp that names it.
|
||||
let goals: Vec<Tile> = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect();
|
||||
|
|
@ -2324,14 +2392,14 @@ fn an_objective_on_this_map_with_no_finer_place_falls_through() {
|
|||
// *ledger* half of that fix (`ways`' tiers), and it is unchanged.
|
||||
assert_eq!(
|
||||
plan(&mut world),
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
|
||||
);
|
||||
|
||||
// A rung the catalog does know a tile for is walked to instead.
|
||||
world.objective = Some(Objective { map: world.map, tile: Some(Tile::new(6, 3)), warp: None, edge: None, target: None });
|
||||
assert_eq!(
|
||||
plan(&mut world),
|
||||
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]
|
||||
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
|
||||
);
|
||||
let aims = objective_goals(&mut world);
|
||||
assert_eq!(aims.iter().map(|aim| (aim.tile, aim.press)).collect::<Vec<_>>(), vec![
|
||||
|
|
@ -2351,14 +2419,14 @@ fn a_thing_this_session_has_talked_to_leaves_the_plan() {
|
|||
world.talked.insert(TalkTarget::Sprite(1));
|
||||
assert_eq!(
|
||||
plan(&mut world),
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER", "MENU"],
|
||||
["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER"],
|
||||
"the person drops out and the object stays"
|
||||
);
|
||||
assert!(!precondition(MacroKind::GoNpc, &mut world), "and the button is gone");
|
||||
|
||||
// The object is the other half, keyed by its own sprite slot.
|
||||
world.talked.insert(TalkTarget::Sprite(2));
|
||||
assert_eq!(plan(&mut world), ["GO OUT", "GO WARP", "GO FRONTIER", "MENU"]);
|
||||
assert_eq!(plan(&mut world), ["GO OUT", "GO WARP", "GO FRONTIER"]);
|
||||
|
||||
// Palette mode reads the same ledger: an unbound slot is dim rather than aimed at a villager
|
||||
// the fly has already heard out.
|
||||
|
|
@ -2953,24 +3021,67 @@ fn a_no_route_refusal_records_what_it_could_not_reach() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_overworld_plan_never_truncates_the_frontier_or_the_menu_away() {
|
||||
// Eleven buttons and six rows: the entries that used to be cut were the last ones, which are
|
||||
// the fallback that always has somewhere to go while any ground is unexplored and -- since
|
||||
// section 13.1 -- the unconditional `MENU` that makes an empty pad impossible.
|
||||
fn the_overworld_plan_never_truncates_the_frontier_away() {
|
||||
// Eleven buttons and six rows: the entry that used to be cut was the last one, which is the
|
||||
// fallback that always has somewhere to go while any ground is unexplored. `MENU` was the
|
||||
// other, and section 12.11 took it off the row rather than rescuing it again.
|
||||
let mut world = populated();
|
||||
world.objective = Some(Objective { map: 0x00, tile: None, warp: None, edge: None, target: None });
|
||||
world.signs = vec![Sign { x: 6, y: 6, text_id: 3 }];
|
||||
let rows = plan(&mut world);
|
||||
assert!(rows.len() >= 7, "a full overworld pad is more than six buttons: {rows:?}");
|
||||
// Section 14: nothing is truncated at all any more, so the two that used to have to be
|
||||
// rescued from the cut are simply there, in their own places at the end of the type order.
|
||||
assert!(rows.len() >= 6, "a full overworld pad is more than five buttons: {rows:?}");
|
||||
assert_eq!(
|
||||
&rows[rows.len() - 2..],
|
||||
["GO FRONTIER", "MENU"],
|
||||
"the explorer and the start menu are both dealt: {rows:?}"
|
||||
rows.last(),
|
||||
Some(&"GO FRONTIER"),
|
||||
"the explorer is dealt last and is never cut: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Section 12.11: `MENU` is on no scene's pad, in any state of any scene.
|
||||
///
|
||||
/// **What was live** (2026-09-22, rung 10, thirty minutes inside the Pewter museum's upper floor):
|
||||
/// macro starts `MENU` 82, `BACK` 82, `GO FRONTIER` 8, the event log alternating `MENU
|
||||
/// start/done, BACK start/done`. `MENU` pressed START, the start menu opened, its pad is `CLOSE`,
|
||||
/// `CONFIRM` and `BACK`, and `BACK` pressed B and closed it again -- two buttons that undo each
|
||||
/// other with nothing else changing, which is section 12.10's rule one scene wider than a battle.
|
||||
///
|
||||
/// `MENU` was there as the overworld's *unconditional* button, the one that made an empty pad
|
||||
/// impossible. It is also section 12.2's trap by definition -- a precondition satisfied wherever
|
||||
/// the fly stands, and a macro that completes without moving -- and nothing in the vocabulary uses
|
||||
/// the start menu for anything, so there is nothing behind it worth pressing it for. What keeps the
|
||||
/// pad from being empty instead is the never-empty way out of [`ways`](super::palette::ways).
|
||||
#[test]
|
||||
fn menu_is_on_no_scenes_pad() {
|
||||
let mut world = populated();
|
||||
for scene in [
|
||||
Scene::Overworld,
|
||||
Scene::Dialog,
|
||||
Scene::Unknown,
|
||||
Scene::Menu,
|
||||
Scene::Shop,
|
||||
Scene::Pc,
|
||||
Scene::Title,
|
||||
Scene::Battle { own_turn: true, forced_switch: false },
|
||||
Scene::Battle { own_turn: false, forced_switch: false },
|
||||
Scene::Battle { own_turn: false, forced_switch: true },
|
||||
] {
|
||||
world.scene = scene;
|
||||
world.battle = matches!(scene, Scene::Battle { .. })
|
||||
.then_some((BattleKind::Wild, true, false));
|
||||
assert!(
|
||||
!super::palette::scene_set(scene, &mut world).contains(&MacroKind::Menu),
|
||||
"{} deals MENU",
|
||||
scene.label()
|
||||
);
|
||||
assert!(!plan(&mut world).contains(&"MENU"), "{} binds MENU", scene.label());
|
||||
}
|
||||
// The start menu is still reachable and still has its own pad: the fly's raw START reaches the
|
||||
// cartridge in macros mode (section 13.1), and what is on that pad is what leaves it.
|
||||
world.scene = Scene::Menu;
|
||||
world.list = List::Start;
|
||||
assert_eq!(pad_of(&mut world), ["CLOSE", "CONFIRM", "BACK"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_playable_scene_deals_an_empty_pad() {
|
||||
// The battle-text deadlock of v0.2.4 was one scene with nothing on the pad. This is the sweep
|
||||
|
|
@ -3891,6 +4002,13 @@ fn every_macro_type_has_a_population_a_tag_and_a_gloss() {
|
|||
///
|
||||
/// The sweep that catches an empty pad, which is the one state the doctrine cannot recover from on
|
||||
/// its own: nothing presses for the fly, so a scene with no buttons waits for ever.
|
||||
///
|
||||
/// **What the overworld's half rests on since section 12.11** is the way out and no longer the
|
||||
/// unconditional `MENU`: every map in the game has one -- an interior's front door or its
|
||||
/// staircase, an outdoor map's connection -- and `ways`' last resort offers it regardless of the
|
||||
/// ledgers when the map holds nothing else worth walking to. So the fixtures below have their
|
||||
/// doors, where the old ones did not need them, and the map with *no way out at all* is the named
|
||||
/// residual at the end of this test rather than a case the rule covers.
|
||||
#[test]
|
||||
fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
|
||||
let worst = |world: &mut World| {
|
||||
|
|
@ -3902,12 +4020,17 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
|
|||
);
|
||||
};
|
||||
|
||||
// The overworld, in all three of its rows, on a map with nothing on it and nowhere to go.
|
||||
let mut bare = World::room();
|
||||
// The overworld, in all three of its rows, on a map with nothing on it but its own door.
|
||||
let mut bare = World::ground_floor();
|
||||
bare.seen_maps.insert(0x00);
|
||||
bare.seen_maps.insert(0x26);
|
||||
bare.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
|
||||
assert!(super::palette::stranded(&mut bare), "nothing on this floor to walk to");
|
||||
worst(&mut bare);
|
||||
let mut outdoors = viridian();
|
||||
outdoors.warps.clear();
|
||||
outdoors.connections = Connections { north: true, south: false, east: false, west: false };
|
||||
outdoors.seen_maps.insert(maps::ROUTE_2);
|
||||
outdoors.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY));
|
||||
outdoors.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY));
|
||||
outdoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
|
||||
|
|
@ -3946,6 +4069,16 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
|
|||
cornered.list = list;
|
||||
worst(&mut cornered);
|
||||
}
|
||||
|
||||
// And the one overworld that still deals nothing, said out loud rather than papered over: a
|
||||
// map with **no way out at all**, every tile stood on and nothing on it. No map in Red is
|
||||
// that -- an interior has its front door or its staircase and an outdoor map has its
|
||||
// connections -- so this is a shape the cartridge does not hold, and `game.padEmptyMs` is what
|
||||
// would report it if one ever did (section 13.1).
|
||||
let mut sealed = World::room();
|
||||
sealed.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
|
||||
assert!(path::exits(&mut sealed).is_empty(), "the fixture really has no way out");
|
||||
assert_eq!(plan::plan_for(Scene::Overworld, &mut sealed).bound(), 0);
|
||||
}
|
||||
|
||||
/// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk.
|
||||
|
|
@ -4132,6 +4265,203 @@ fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
|
|||
assert_eq!(world.pulses.last(), Some(&buttons::A));
|
||||
}
|
||||
|
||||
/// Section 12.11: a room whose one way out every ledger is resting still offers it.
|
||||
///
|
||||
/// **What was live** (2026-09-22, rung 10, the release container's own checkpoint): the fly on
|
||||
/// **map 0x35, the Pewter museum's upper floor** -- fourteen blocks by eight, one warp at (7, 7)
|
||||
/// down to the floor below (0x34), two signs and three exhibits. The reproduction is in
|
||||
/// `infra/docs/macros-traps.md`; the shape of it is that every candidate list on that map empties:
|
||||
///
|
||||
/// - `geography` has no row for the museum, so `next_hop` from it answers `None` and
|
||||
/// `GO OBJECTIVE` has nothing to aim at -- the objective itself is fine (map 0x36, the gym
|
||||
/// leader, rung 11's BOULDER BADGE);
|
||||
/// - the three exhibits and two signs are *reached* by `GO NPC` and `GO ITEM`, which retires them
|
||||
/// for the session;
|
||||
/// - the four unstood tiles are walked or excluded, and `GO FRONTIER` empties for the window;
|
||||
/// - the one warp out is classified a **passage** and not an exit -- it is a staircase -- and
|
||||
/// `unexcluded_exits` drops it while the blocked ledger rests it.
|
||||
///
|
||||
/// That left `MENU` and nothing else, and `MENU` opened the start menu whose `BACK` closed it
|
||||
/// again: 82 starts each in thirty minutes. With `MENU` gone the same state has to deal a walk, and
|
||||
/// the walk is the staircase **although the ledger is resting it** -- a target the ledger has
|
||||
/// parked is still the only place to go.
|
||||
#[test]
|
||||
fn a_room_whose_only_way_out_the_ledger_rests_still_offers_it() {
|
||||
let mut world = World::room();
|
||||
// One staircase, no front door: the museum's upper floor, and Red's bedroom, and every other
|
||||
// map whose only way anywhere is a passage.
|
||||
world.warps = vec![Warp { x: 7, y: 1, destination_warp: 2, destination_map: 0x26 }];
|
||||
world.seen_maps.insert(0x26);
|
||||
world.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
|
||||
assert_eq!(
|
||||
path::exits(&mut world).iter().map(|exit| exit.way).collect::<Vec<_>>(),
|
||||
vec![Way::Passage],
|
||||
"a staircase and no front door"
|
||||
);
|
||||
// With the staircase unexcluded the pad is the ordinary indoor one.
|
||||
assert!(on_the_pad(&mut world, MacroKind::GoWarp));
|
||||
|
||||
// Now rest it, which is what a refused walk does for ten brain minutes.
|
||||
world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Warp(0)));
|
||||
assert!(super::palette::stranded(&mut world), "nothing else on this floor to walk to");
|
||||
assert!(
|
||||
on_the_pad(&mut world, MacroKind::GoWarp),
|
||||
"the resting staircase comes back as the last resort"
|
||||
);
|
||||
let pad = pad_of(&mut world);
|
||||
assert_eq!(pad, ["GO WARP"], "and it is the whole pad: {pad:?}");
|
||||
|
||||
// And it is *only* a last resort: one unstood tile and the resting staircase goes away again,
|
||||
// because "the nearest door, once per hold" is row 2's own loop.
|
||||
world.stood.remove(&Tile::new(0, 0));
|
||||
assert!(!super::palette::stranded(&mut world));
|
||||
assert!(!on_the_pad(&mut world, MacroKind::GoWarp));
|
||||
assert!(on_the_pad(&mut world, MacroKind::GoFrontier));
|
||||
}
|
||||
|
||||
/// Section 12.11, stated at the pad: no overworld pad is one button that undoes itself.
|
||||
///
|
||||
/// The test the museum loop would have failed. `MENU` on the overworld and `BACK` on the start
|
||||
/// menu are a pair across two scenes, so a per-pad rule cannot see it -- what can is that `MENU`
|
||||
/// is on no pad at all, and that what is left when every ledger is against the map is a *walk*.
|
||||
#[test]
|
||||
fn an_overworld_pad_is_never_one_button_that_undoes_itself() {
|
||||
// The museum shape, indoors, and the town shape, outdoors: both stranded, both dealt a walk.
|
||||
let mut indoors = World::ground_floor();
|
||||
indoors.seen_maps.insert(0x00);
|
||||
indoors.seen_maps.insert(0x26);
|
||||
indoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
|
||||
for exit in path::exits(&mut indoors) {
|
||||
indoors.targets.record_blocked(indoors.map, TargetKey::Exit(exit.id));
|
||||
}
|
||||
let mut outdoors = viridian();
|
||||
outdoors.warps.clear();
|
||||
outdoors.connections = Connections { north: true, south: false, east: false, west: false };
|
||||
outdoors.seen_maps.insert(maps::ROUTE_2);
|
||||
outdoors.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY));
|
||||
outdoors.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY));
|
||||
outdoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
|
||||
outdoors.targets.record_blocked(outdoors.map, TargetKey::Exit(ExitId::Edge(Edge::North)));
|
||||
|
||||
for world in [&mut indoors, &mut outdoors] {
|
||||
let pad = pad_of(world);
|
||||
assert!(!pad.is_empty(), "an overworld with a way out deals something");
|
||||
assert!(!pad.contains(&"MENU"), "MENU is on no pad: {pad:?}");
|
||||
assert!(
|
||||
pad.iter().any(|name| name.starts_with("GO ")),
|
||||
"what is left is a walk rather than a screen to open and close: {pad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 12.11: the move list deals `BACK` only where the moves can be read.
|
||||
///
|
||||
/// The v0.4.3 residual (`infra/docs/macros-traps.md`): `BACK` was 263 of 797 macro starts, every
|
||||
/// one over an open move list, and 142 of the run's `NEXT` starts were on a move list whose cursor
|
||||
/// the seam could not place. A move list the seam cannot read the battler for binds no `MOVE n` at
|
||||
/// all, so its pad was `BACK` alone -- and the only thing that button does is close the list that
|
||||
/// `MOVE 1` on the menu underneath had just opened. That is 12.10's pair with `MOVE 1` in `NEXT`'s
|
||||
/// place. `MOVE 1` alone confirms wherever the cursor stands, which is the press that ends a turn.
|
||||
#[test]
|
||||
fn the_move_list_deals_back_only_where_the_moves_can_be_read() {
|
||||
let mut world = World::battle();
|
||||
world.list = List::Moves(3);
|
||||
world.grid = false;
|
||||
world.cursor_max = 2;
|
||||
assert_eq!(pad_of(&mut world), ["BACK", "MOVE 1", "MOVE 2", "MOVE 3"]);
|
||||
|
||||
// The battler the seam cannot place: no active slot, so `battle.own` is `None`.
|
||||
world.active = None;
|
||||
let pad = pad_of(&mut world);
|
||||
assert_eq!(pad, ["MOVE 1"], "one button, and it ends the turn: {pad:?}");
|
||||
assert!(move_slot_bound(&mut world, MacroKind::Move1));
|
||||
assert!(!move_slot_bound(&mut world, MacroKind::Move2));
|
||||
// And it really presses: the cursor is confirmed where it stands, which is Struggle's own
|
||||
// path (row 30a) and the only reading available here.
|
||||
assert_eq!(run(&mut world, MacroKind::Move1).unwrap(), MacroAbort::Done);
|
||||
assert_eq!(world.pulses.last(), Some(&buttons::A));
|
||||
}
|
||||
|
||||
/// Section 12.11: Red's battle menu is two columns, so its order is FIGHT, ITEM, PKMN, RUN.
|
||||
///
|
||||
/// The screen reads `FIGHT PKMN` over `ITEM RUN` and the game's own index does not: it is the row
|
||||
/// inside the column the cursor is in, plus two for the right column. `battle_entry` had `PKMN` 1
|
||||
/// and `ITEM` 2, which is the row-major reading of the picture, so **every macro that opened the
|
||||
/// bag opened the party list and every macro that opened the party list opened the bag**.
|
||||
///
|
||||
/// **Surveyed on the cartridge** (`infra/docs/macros-traps.md`): a `THROW BALL` aiming at 2 walked
|
||||
/// the cursor to `wTopMenuItemX` 15 / `wCurrentMenuItem` 0, pressed A, and the party list opened --
|
||||
/// `wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02` -- with the game writing
|
||||
/// `wCurrentMenuItem` 2 on the frame after the press. On v0.4.3 `THROW BALL` was 63 starts and 63
|
||||
/// `blocked`, and `SWITCH` 15 of them: never the macro's own list, always the other one.
|
||||
#[test]
|
||||
fn the_battle_menus_two_columns_put_item_under_fight_and_pkmn_beside_it() {
|
||||
use super::cartridge::battle_entry;
|
||||
assert_eq!(
|
||||
[battle_entry::FIGHT, battle_entry::ITEM, battle_entry::PKMN, battle_entry::RUN],
|
||||
[0, 1, 2, 3],
|
||||
"the left column is FIGHT then ITEM, the right is PKMN then RUN"
|
||||
);
|
||||
// And the seam reads the same order back off the fake's own geometry: DOWN moves one inside a
|
||||
// column, RIGHT moves two across.
|
||||
let mut world = World::battle();
|
||||
let menu = |world: &mut World| super::palette::battle_menu(world);
|
||||
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::FIGHT });
|
||||
world.on_pulse(buttons::DOWN);
|
||||
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::ITEM });
|
||||
world.on_pulse(buttons::UP);
|
||||
world.on_pulse(buttons::RIGHT);
|
||||
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::PKMN });
|
||||
world.on_pulse(buttons::DOWN);
|
||||
assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::RUN });
|
||||
}
|
||||
|
||||
/// Section 12.11: a cursor step waits for the list it was built for.
|
||||
///
|
||||
/// **Measured on the cartridge, v0.4.3** (`infra/docs/macros-traps.md`): `THROW BALL` was **63
|
||||
/// starts and 63 `blocked`**, mean sixty-nine frames -- which is the cursor to ITEM, the A that
|
||||
/// confirms it, the twenty settle frames, and then a refusal on the very next frame. The bag had
|
||||
/// not drawn yet, so `listing` still answered for the battle *menu*: four entries, `max` 3. The
|
||||
/// ball's own bag index was above that, so the step read "off the end of the list" and gave up at
|
||||
/// once -- and where the index was inside it, the step pressed UP and LEFT at the battle menu
|
||||
/// instead, which is the blind pressing section 4 forbids.
|
||||
#[test]
|
||||
fn throw_ball_waits_for_the_bag_rather_than_reading_the_menu_it_came_from() {
|
||||
let mut world = World::battle();
|
||||
world.mons.truncate(1);
|
||||
// Five items with the ball last, so its bag index is 4 -- above the battle menu's `max` of 3,
|
||||
// which is the number the old step compared it against.
|
||||
world.bag = vec![
|
||||
(item::POTION, 1),
|
||||
(item::ANTIDOTE, 1),
|
||||
(item::REPEL, 1),
|
||||
(item::POTION, 1),
|
||||
(item::POKE_BALL, 5),
|
||||
];
|
||||
assert_eq!(throw_slot(&mut world), Some(4));
|
||||
// The bag takes longer to draw than the script's twenty settle frames, which is the cartridge's
|
||||
// own timing and the whole of the trap.
|
||||
world.opens_draw_in = 40;
|
||||
world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 4, grid: false });
|
||||
assert_eq!(run(&mut world, MacroKind::ThrowBall).unwrap(), MacroAbort::Done);
|
||||
assert_eq!(world.cursor, 4, "the ball's own bag index, by reading the bag's cursor");
|
||||
assert_eq!(world.pulses.last(), Some(&buttons::A));
|
||||
// Nothing was pressed at the menu it came from while the bag was drawing: the presses are the
|
||||
// one that chose ITEM and then the bag's own.
|
||||
assert_eq!(
|
||||
world.pulses.iter().filter(|mask| **mask == buttons::UP).count(),
|
||||
0,
|
||||
"no blind press at the list it had already answered: {:?}",
|
||||
world.pulses
|
||||
);
|
||||
|
||||
// And a list that never opens is `blocked` rather than pressed at blind.
|
||||
let mut never = World::battle();
|
||||
never.mons.truncate(1);
|
||||
never.bag = vec![(item::POKE_BALL, 5)];
|
||||
assert_eq!(run(&mut never, MacroKind::ThrowBall).unwrap(), MacroAbort::Blocked);
|
||||
}
|
||||
|
||||
/// Row 37 of `infra/docs/macros-traps.md`: a tile the cartridge pushes the fly off is not a tile
|
||||
/// to stand on, and the ground beside a villager is not the villager's fault.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -406,8 +406,14 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
|
|||
&& (left || right);
|
||||
|
||||
let menu = if main {
|
||||
// FIGHT and PKMN are the left column, ITEM and RUN the right; the cursor index the game
|
||||
// keeps is within the column, and `.rightColumn` adds two to it on selection.
|
||||
// **FIGHT and ITEM are the left column, PKMN and RUN the right.** The screen reads
|
||||
// `FIGHT PKMN` over `ITEM RUN` and the game's index is by column: `wCurrentMenuItem` is
|
||||
// the row inside the column the cursor is in, and `.rightColumn` adds two to it on
|
||||
// selection -- so the order is FIGHT, ITEM, PKMN, RUN. Surveyed on the cartridge
|
||||
// 2026-09-22 (`infra/docs/macros-traps.md`): A at `wTopMenuItemX` 15 with
|
||||
// `wCurrentMenuItem` 0 opens the **party** list and the game then writes
|
||||
// `wCurrentMenuItem` 2. `macros::cartridge::battle_entry` had this pair the other way
|
||||
// round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag.
|
||||
let column = if right { 2 } else { 0 };
|
||||
BattleMenu::Main { cursor: column + cursor.current.min(1) }
|
||||
} else if cursor.top_y == 12 && cursor.top_x == 5 {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const VIRIDIAN_MART: u32 = 0x2a;
|
|||
/// Route 2's southern forest gate and the forest north of it, which is rung 9's own road.
|
||||
const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32;
|
||||
const VIRIDIAN_FOREST: u32 = 0x33;
|
||||
/// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
|
||||
const MUSEUM_2F: u32 = 0x35;
|
||||
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter
|
||||
/// (`macros::geography`, and rung 10's own road).
|
||||
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
|
||||
|
|
@ -217,6 +219,28 @@ struct Run {
|
|||
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
|
||||
/// north is whatever `GO FRONTIER` stumbles into.
|
||||
objective_on_pad: std::collections::BTreeSet<u32>,
|
||||
/// Maps on whose *overworld* pad `MENU` was ever bound (section 12.11).
|
||||
///
|
||||
/// The rung-10 trap: `MENU` opened the start menu and that scene's `BACK` closed it again, 82
|
||||
/// starts each in thirty brain minutes inside one building. `MENU` is on no pad at all now, so
|
||||
/// this set is empty -- and on v0.4.3 it holds every overworld map the run stood on.
|
||||
menu_on_pad: std::collections::BTreeSet<u32>,
|
||||
/// Overworld pads that bound nothing at all, by map: section 13.1's never-empty rule, which
|
||||
/// since 12.11 rests on the way out rather than on an unconditional `MENU`.
|
||||
empty_overworld_pads: std::collections::BTreeSet<u32>,
|
||||
/// How many times each macro finished `blocked`, for the `THROW BALL` residual.
|
||||
///
|
||||
/// v0.4.3 on the cartridge: `THROW BALL` 63 starts, 63 `blocked` -- the bag had not drawn and
|
||||
/// the step read the battle menu's cursor instead (section 12.11).
|
||||
blocked: std::collections::BTreeMap<&'static str, u32>,
|
||||
/// `NAME@scene/sub-state` for every macro that reported `blocked`: *where* it gave up.
|
||||
blocked_where: std::collections::BTreeSet<String>,
|
||||
/// Macros started while the fly was still on the map it resumed on.
|
||||
macros_on_the_first_map: u32,
|
||||
/// The longest chain of macro starts that alternated `MENU`, `BACK`, `MENU`, `BACK`.
|
||||
longest_menu_back_alternation: u32,
|
||||
menu_alternation: u32,
|
||||
last_start: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
|
|
@ -302,6 +326,14 @@ impl Run {
|
|||
battles_ended: 0,
|
||||
was_in_battle: false,
|
||||
objective_on_pad: std::collections::BTreeSet::new(),
|
||||
menu_on_pad: std::collections::BTreeSet::new(),
|
||||
empty_overworld_pads: std::collections::BTreeSet::new(),
|
||||
blocked: std::collections::BTreeMap::new(),
|
||||
blocked_where: std::collections::BTreeSet::new(),
|
||||
macros_on_the_first_map: 0,
|
||||
longest_menu_back_alternation: 0,
|
||||
menu_alternation: 0,
|
||||
last_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,6 +422,14 @@ impl Run {
|
|||
battles_ended: 0,
|
||||
was_in_battle: false,
|
||||
objective_on_pad: std::collections::BTreeSet::new(),
|
||||
menu_on_pad: std::collections::BTreeSet::new(),
|
||||
empty_overworld_pads: std::collections::BTreeSet::new(),
|
||||
blocked: std::collections::BTreeMap::new(),
|
||||
blocked_where: std::collections::BTreeSet::new(),
|
||||
macros_on_the_first_map: 0,
|
||||
longest_menu_back_alternation: 0,
|
||||
menu_alternation: 0,
|
||||
last_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -603,7 +643,7 @@ impl Run {
|
|||
self.talk_on_pad = talk_bound;
|
||||
let active =
|
||||
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
|
||||
let (mask, started) = {
|
||||
let (mask, started, blocked) = {
|
||||
let ledger = AdapterLedger(&self.adapter);
|
||||
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
|
||||
let started: Vec<&'static str> = decision
|
||||
|
|
@ -612,8 +652,25 @@ impl Run {
|
|||
.filter(|event| event.outcome.is_none())
|
||||
.map(|event| event.name)
|
||||
.collect();
|
||||
(decision.mask, started)
|
||||
let blocked: Vec<&'static str> = decision
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.outcome.is_some_and(|outcome| outcome.as_str() == "blocked")
|
||||
})
|
||||
.map(|event| event.name)
|
||||
.collect();
|
||||
(decision.mask, started, blocked)
|
||||
};
|
||||
for name in blocked {
|
||||
*self.blocked.entry(name).or_insert(0) += 1;
|
||||
let sub = self.battle_sub_state();
|
||||
let list = self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wListMenuID);
|
||||
self.blocked_where.insert(format!(
|
||||
"{name}@{}/{sub}/list={list:#04x}",
|
||||
self.layer.scene_name()
|
||||
));
|
||||
}
|
||||
let in_battle_now = self.in_battle() != 0;
|
||||
let on_a_battle_pad = self.battle_pad();
|
||||
for name in started {
|
||||
|
|
@ -640,6 +697,24 @@ impl Run {
|
|||
self.longest_next_back_alternation.max(self.alternation);
|
||||
self.last_battle_start = Some(name);
|
||||
}
|
||||
// Section 12.11's own signature, as the live event log printed it: `MENU
|
||||
// start/done, BACK start/done`, every hold, for thirty brain minutes inside one
|
||||
// building. The pair is split across two *scenes*, so this is measured over every
|
||||
// start rather than over a battle's.
|
||||
let two = name == "MENU" || name == "BACK";
|
||||
self.menu_alternation = match self.last_start {
|
||||
Some(last) if two && (last == "MENU" || last == "BACK") && last != name => {
|
||||
self.menu_alternation.max(1) + 1
|
||||
}
|
||||
_ if two => 1,
|
||||
_ => 0,
|
||||
};
|
||||
self.longest_menu_back_alternation =
|
||||
self.longest_menu_back_alternation.max(self.menu_alternation);
|
||||
self.last_start = Some(name);
|
||||
if self.route.len() == 1 {
|
||||
self.macros_on_the_first_map += 1;
|
||||
}
|
||||
*self.started.entry(name).or_insert(0) += 1;
|
||||
}
|
||||
self.gb.set_buttons(mask as u8);
|
||||
|
|
@ -681,6 +756,12 @@ impl Run {
|
|||
if dealt.iter().any(|channel| channel.as_str() == "macro_go_objective") {
|
||||
self.objective_on_pad.insert(map);
|
||||
}
|
||||
if dealt.iter().any(|channel| channel.as_str() == "macro_menu") {
|
||||
self.menu_on_pad.insert(map);
|
||||
}
|
||||
if dealt.is_empty() {
|
||||
self.empty_overworld_pads.insert(map);
|
||||
}
|
||||
let dealt = dealt.len();
|
||||
let seen = self.pads.entry(map).or_insert(dealt);
|
||||
*seen = (*seen).min(dealt);
|
||||
|
|
@ -981,6 +1062,16 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
|||
run.battle_back_where
|
||||
);
|
||||
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
|
||||
eprintln!("blocked where: {:?}", run.blocked_where);
|
||||
// Section 12.11, the other half of `THROW BALL`: it reaches the bag. On v0.4.3 every start of
|
||||
// it was `blocked` -- 63 of 63, mean sixty-nine frames -- because the step that walks the bag
|
||||
// list was allowed to read the battle menu's cursor while the bag was still drawing.
|
||||
assert_eq!(
|
||||
run.blocked.get("THROW BALL"),
|
||||
None,
|
||||
"`THROW BALL` reported blocked: {:?}",
|
||||
run.blocked
|
||||
);
|
||||
// Section 12.10, the two halves of it.
|
||||
assert!(
|
||||
!run.next_and_back_on_one_pad,
|
||||
|
|
@ -1015,9 +1106,15 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
|||
// battle never leaves the fly's own turn at all, so the count grows with the *run*. On v0.4.2
|
||||
// from this same checkpoint the trap hunt spent all twenty of its brain minutes -- 71,673
|
||||
// frames, 1,489 macros, 73 of 73 windows flagged -- inside **one** battle that never ended,
|
||||
// with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu. Four hundred
|
||||
// is generous against the 275 this run's worst battle measured and far under an unbounded
|
||||
// cycle.
|
||||
// with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu.
|
||||
//
|
||||
// The bound is **seven hundred** and was four; the number it is generous against is **450**
|
||||
// and was 275, re-measured after section 12.11 put `battle_entry`'s `ITEM` and `PKMN` the
|
||||
// right way round. Before that `SWITCH` opened the bag and `ITEM` and `THROW BALL` opened the
|
||||
// party list, so neither could ever finish: every turn was an attack or nothing. A fly that
|
||||
// can switch and heal fights longer, which is a longer battle and not a stalled one -- what
|
||||
// the assertion is for is the difference between a battle that ends and a cycle that does
|
||||
// not, and 3 of 3 ended here.
|
||||
assert!(
|
||||
run.battles_ended + 1 >= run.battles_entered,
|
||||
"{} battles entered and only {} left: a battle was entered and never got out",
|
||||
|
|
@ -1025,7 +1122,7 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
|||
run.battles_ended
|
||||
);
|
||||
assert!(
|
||||
run.worst_battle_macros > 0 && run.worst_battle_macros < 400,
|
||||
run.worst_battle_macros > 0 && run.worst_battle_macros < 700,
|
||||
"the worst battle cost {} macros over {} that ended",
|
||||
run.worst_battle_macros,
|
||||
run.battles_ended
|
||||
|
|
@ -1806,3 +1903,117 @@ fn go_heal_enters_the_centre_and_heal_restores_the_party() {
|
|||
run.force_hot = None;
|
||||
assert!(run.party_rested(), "the nurse healed the party");
|
||||
}
|
||||
|
||||
/// The rung-10 Pewter checkpoint, or `None` to skip.
|
||||
///
|
||||
/// Its own variable, like `FLY_FOREST_CHECKPOINT`: the tests above assert the map their envelope
|
||||
/// is on and one envelope cannot be two maps.
|
||||
fn building_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||
std::env::var_os("FLY_BUILDING_CHECKPOINT").map(|path| {
|
||||
flysim::store::load(std::path::Path::new(&path))
|
||||
.expect("the checkpoint should be a FLYSIM01 envelope")
|
||||
})
|
||||
}
|
||||
|
||||
/// From the rung-10 checkpoint: the fly leaves the building, and no pad is `MENU` and a way back.
|
||||
///
|
||||
/// **What was live** (2026-09-22, thirty-one minutes after v0.4.3 deployed): rank 10, PEWTER CITY,
|
||||
/// the fly on **map 0x35 -- the upper floor of the Pewter museum**, fourteen blocks by eight, one
|
||||
/// warp at (7, 7) down to the floor below, two signs and three exhibits. For thirty brain minutes
|
||||
/// the macro starts were `MENU` 82, `BACK` 82 and `GO FRONTIER` 8, the event log alternating `MENU
|
||||
/// start/done, BACK start/done`.
|
||||
///
|
||||
/// Every candidate list on that map empties: `geography` has no row for the museum, so `next_hop`
|
||||
/// answers `None` and `GO OBJECTIVE` has nothing to aim at (the objective itself is map 0x36 --
|
||||
/// the gym leader, rung 11's BOULDER BADGE); the three exhibits and two signs are *reached* and
|
||||
/// retired for the session; the four unstood tiles are walked or excluded; and the one way out is
|
||||
/// a **passage** whose blocked window `unexcluded_exits` respects. That left `MENU`, which opens a
|
||||
/// scene whose pad is `CLOSE`, `CONFIRM` and `BACK` -- and `BACK` closes it again.
|
||||
///
|
||||
/// The claims here are about the pad and about *leaving*, not about where the fly goes next:
|
||||
///
|
||||
/// - `MENU` is on **no** overworld pad, on any map the run stands on (section 12.11). On v0.4.3
|
||||
/// this set holds every one of them, which is what makes this the regression test.
|
||||
/// - no overworld pad is empty, which since 12.11 rests on the way out rather than on `MENU`.
|
||||
/// - the fly **leaves map 0x35** on a bounded number of macros.
|
||||
/// - `MENU`/`BACK` never alternate, and `THROW BALL` never reports `blocked` -- the two residuals
|
||||
/// v0.4.3 left (63 starts, 63 blocked, mean sixty-nine frames).
|
||||
///
|
||||
/// ```sh
|
||||
/// FLY_ROM=/path/to/pokemon-red.gb \
|
||||
/// FLY_BUILDING_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \
|
||||
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
||||
/// ```
|
||||
#[test]
|
||||
fn the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint() {
|
||||
let rom = skip_without_rom!();
|
||||
let Some(checkpoint) = building_checkpoint() else {
|
||||
eprintln!("skipped: no FLY_BUILDING_CHECKPOINT");
|
||||
return;
|
||||
};
|
||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
||||
let from = run.map();
|
||||
assert_eq!(from, MUSEUM_2F, "the checkpoint is the building the stream stalled in");
|
||||
// Rung 10 is stood on, so the objective is rung 11 -- the BOULDER BADGE, which is the gym
|
||||
// leader and so a *person* on the gym's map.
|
||||
assert_eq!(
|
||||
run.objective_map(),
|
||||
Some(0x36),
|
||||
"the objective is the Pewter gym, where the badge is"
|
||||
);
|
||||
|
||||
// Driven the whole budget rather than stopped at the door: leaving is one claim and "`MENU`
|
||||
// is on no pad" is a claim about every pad the run deals, so the run keeps going and keeps
|
||||
// recording. Thirty-three brain minutes, against the thirty the live run spent not leaving.
|
||||
let mut left = None;
|
||||
for frame in 0..120_000u32 {
|
||||
run.frame();
|
||||
if left.is_none() && run.map() != from {
|
||||
left = Some(frame);
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, MENU on the pad \
|
||||
of {:?}, empty pads {:?}, blocked {:?}",
|
||||
run.ms / 60_000.0,
|
||||
run.route,
|
||||
run.started,
|
||||
run.menu_on_pad,
|
||||
run.empty_overworld_pads,
|
||||
run.blocked
|
||||
);
|
||||
|
||||
assert!(
|
||||
run.menu_on_pad.is_empty(),
|
||||
"`MENU` was on an overworld pad: {:?}",
|
||||
run.menu_on_pad
|
||||
);
|
||||
assert_eq!(run.started.get("MENU"), None, "`MENU` cannot start if it is on no pad");
|
||||
// One is a lone `BACK`, which is an ordinary press in a list; two is the pair, and the live
|
||||
// run did it eighty-two times each for thirty brain minutes.
|
||||
assert!(
|
||||
run.longest_menu_back_alternation < 2,
|
||||
"`MENU`/`BACK` alternated {} times in a row",
|
||||
run.longest_menu_back_alternation
|
||||
);
|
||||
assert!(
|
||||
run.empty_overworld_pads.is_empty(),
|
||||
"an overworld pad was empty on {:?}",
|
||||
run.empty_overworld_pads
|
||||
);
|
||||
let Some(left) = left else { panic!("the fly never left map {from:#04x}") };
|
||||
eprintln!("it left map {from:#04x} on frame {left}");
|
||||
assert!(
|
||||
run.macros_on_the_first_map < 400,
|
||||
"leaving the building cost {} macros",
|
||||
run.macros_on_the_first_map
|
||||
);
|
||||
// The v0.4.3 residual, on the cartridge: every `THROW BALL` was blocked because the step read
|
||||
// the battle menu's cursor while the bag was still drawing.
|
||||
assert_eq!(
|
||||
run.blocked.get("THROW BALL"),
|
||||
None,
|
||||
"`THROW BALL` reported blocked: {:?}",
|
||||
run.blocked
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue