Compare commits
6 commits
1a3cfb714a
...
592c2640a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592c2640a3 | ||
|
|
e2082e3699 | ||
|
|
18e16819ca | ||
|
|
7d5c788876 | ||
|
|
ce02a89922 | ||
|
|
097a11196b |
7 changed files with 630 additions and 13 deletions
|
|
@ -806,6 +806,59 @@ list, whose only bound button with one Pokémon is `BACK`. Six hundred thousand
|
||||||
|
|
||||||
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
||||||
|
|
||||||
|
### 12.9 `BACK` belongs to a list, and a ball is not thrown at what the party already has (2026-09-22, rung 9, sixty-nine hours)
|
||||||
|
|
||||||
|
Rank 9 (VIRIDIAN FOREST, next PEWTER CITY), sixty-nine hours on the rung, the ratchet's three
|
||||||
|
attempts spent, the fly on Route 2 and in the forest and mostly in wild battles. Since the restart
|
||||||
|
the macro starts were `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 and `GO ROUTE` 5, and the
|
||||||
|
event log repeated `RUN blocked, BACK start, BACK done`. `infra/docs/macros-traps.md` has the
|
||||||
|
reproduction and the numbers; two macros, and section 12.2's one rule between them.
|
||||||
|
|
||||||
|
- **`BACK` is a button only where there is a list to leave.** The top-level battle menu never had
|
||||||
|
it and does not now: FIGHT, PKMN, ITEM and RUN are the four answers to it and B is not a fifth.
|
||||||
|
What dealt it was the *between-turns* row, which section 13.1 gave `NEXT, BACK` for the sake of
|
||||||
|
the battle bag -- the bag reads as nobody's turn (12.6), so it lands there -- and which is also
|
||||||
|
every frame of battle text, every animation and every turn resolving. On those frames there is
|
||||||
|
nothing open, so the B press changes nothing the `NEXT` beside it does not, the macro completes
|
||||||
|
in a handful of frames on the tile it started on, and half the pad is a button that cannot move
|
||||||
|
the game on. That is exactly **a macro that completes without moving because its precondition is
|
||||||
|
already satisfied where the fly stands** (12.2). The row is now the sub-state's, like the own
|
||||||
|
turn's: `BACK` with the bag open, `NEXT` alone otherwise, and the move list and the party list
|
||||||
|
keep it as before.
|
||||||
|
- **`RUN blocked` was the script, not the cartridge.** Not "can't escape": `RUN` is one cursor
|
||||||
|
navigation, and a cursor macro whose list is not accepting input *waits* rather than pressing
|
||||||
|
blind (section 4), for `CURSOR_WAIT` = 180 frames before it reports `Blocked`. So a `RUN` that
|
||||||
|
won a hold as the menu closed spent three brain seconds pressing nothing and then gave up --
|
||||||
|
row 19 of the audit, bounded and left, and the reason it was visible at all is the `BACK` above
|
||||||
|
filling the frames on either side of it. `RUN`'s own precondition is unchanged (13.1: only a
|
||||||
|
wild battle the fly is losing).
|
||||||
|
- **A ball is not thrown at a species the party already holds.** `THROW BALL`'s precondition was
|
||||||
|
three facts -- a wild battle, a ball in the bag, room in the party -- and it now has a fourth:
|
||||||
|
the Pokémon on the other side is not one the party already has. Viridian Forest holds five
|
||||||
|
species and the fly had caught its own, so every throw spent a ball on a Caterpie or a Weedle it
|
||||||
|
was carrying, and a catch opens the nickname screen, which reads `Unknown`, needs the START the
|
||||||
|
pad has no button for (row 14), and is the one screen in the game neither `NEXT` nor `BACK`
|
||||||
|
leaves. The party is the caught set here and it is the honest one: it is the cartridge's own
|
||||||
|
lifetime record, it survives the restart the session ledgers do not, and it is in the same
|
||||||
|
numbering the enemy is read in -- the internal species index. **`wPokedexOwned` is not asked**,
|
||||||
|
because that bitset is by Pokédex *number* and the table converting an internal index into one
|
||||||
|
lives in a ROM bank this crate cannot read: `docs/design/ladder.md`'s rule is that an unverified
|
||||||
|
number does not go in. It costs nothing measurable -- the button is already off the pad while
|
||||||
|
the party is full and nothing in the vocabulary deposits into a box (row 17), so every species
|
||||||
|
this run has caught is in the party this reads. An enemy the seam cannot place leaves the button
|
||||||
|
where it was: a precondition this crate cannot observe is not a precondition (13.1).
|
||||||
|
- **The ratchet is not the backstop, and on this rung it cannot be.** `budget_spent` precedes both
|
||||||
|
triggers and three attempts on rank 9 were spent sixty-nine hours ago, so no recovery can fire
|
||||||
|
again until the rank *improves* -- row 22, contract, and the reason the trap hunt runs on the dev
|
||||||
|
box before a release. What has to carry the run is the road: rung 9's place stays
|
||||||
|
`VIRIDIAN_FOREST`, the objective is the lowest **unearned** rung (12.4), which is rung 10's
|
||||||
|
`PEWTER_CITY`, and `geography::next_hop` from the forest answers `VIRIDIAN_FOREST_NORTH_GATE`
|
||||||
|
(47), then Route 2's north piece, then Pewter (2) -- already asserted hop by hop in
|
||||||
|
`macros::geography`'s own tests since 12.7. `GO OBJECTIVE` is on the overworld pad there, which
|
||||||
|
the ROM-gated run from this checkpoint holds.
|
||||||
|
|
||||||
|
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
||||||
|
|
||||||
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
## 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
|
## 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.")
|
## for the Pokécenter. heal should be a macro.")
|
||||||
|
|
@ -883,12 +936,12 @@ observe is not a precondition, it is a guess.
|
||||||
| 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. 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 bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
|
| 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 |
|
| 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, NEXT | four move buttons for `ATTACK` (section 14); THROW BALL added; **RUN gated**, below |
|
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT | 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 |
|
||||||
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
|
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
|
||||||
| Battle, own turn, party list | SWITCH, BACK | unchanged |
|
| Battle, own turn, party list | SWITCH, BACK | unchanged |
|
||||||
| Battle, the bag | NEXT, **BACK** | **BACK added**, and the list now reports a *cursor* (`macros-wram.md` 7.1) — before this `ITEM` could open the bag and had nothing to read |
|
| Battle, the bag | NEXT, **BACK** | **BACK added**, and the list now reports a *cursor* (`macros-wram.md` 7.1) — before this `ITEM` could open the bag and had nothing to read |
|
||||||
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8) |
|
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8) |
|
||||||
| Battle, between turns | NEXT, **BACK** | **BACK added** |
|
| Battle, between turns | NEXT | `BACK` was added here for the bag (the row above reads as nobody’s turn) and **taken back out by section 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2 |
|
||||||
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
|
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
|
||||||
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
|
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
|
||||||
| Title | nothing | unchanged, by contract: the readout's boot variant applies |
|
| Title | nothing | unchanged, by contract: the readout's boot variant applies |
|
||||||
|
|
|
||||||
|
|
@ -610,3 +610,8 @@ then purge; then the stale-doc pass.
|
||||||
- 2026-09-22 02:22 UTC (v0.4.1): first release cut from the public repository (fresh history at
|
- 2026-09-22 02:22 UTC (v0.4.1): first release cut from the public repository (fresh history at
|
||||||
v0.4.0); the DESCRIBE card carries the repository URL. Deployed to the release container with a
|
v0.4.0); the DESCRIBE card carries the repository URL. Deployed to the release container with a
|
||||||
flysim and flystage restart, checkpoint carried over at rank 9, lag 0, encoder output clean.
|
flysim and flystage restart, checkpoint carried over at rank 9, lag 0, encoder output clean.
|
||||||
|
- 2026-09-22 (v0.4.2, loop review, auto): rung 9 for 69 h. The between-turns battle row dealt
|
||||||
|
BACK with no list open (175 of 959 starts, instant, net nothing) and THROW BALL threw at species
|
||||||
|
the party already held. Fixed: BACK only where a list is open; THROW BALL skips held species.
|
||||||
|
Trap hunt: tiles 216 -> 286, listless BACK 175 -> 0, dialog frames 23,548 -> 0. Ethos check
|
||||||
|
held. Row 41 named: a nurse box answered YES 474 times on one tile.
|
||||||
|
|
|
||||||
|
|
@ -1230,3 +1230,132 @@ somewhere else, and the script fires on any frame the fly stands there.
|
||||||
so the only thing said about it here is that it exists and is exported. Whether a playable scene
|
so the only thing said about it here is that it exists and is exported. Whether a playable scene
|
||||||
ever deals nothing on the release box is the watchdog's gauge to answer, over a run longer than
|
ever deals nothing on the release box is the watchdog's gauge to answer, over a run longer than
|
||||||
twenty brain minutes.
|
twenty brain minutes.
|
||||||
|
|
||||||
|
## 2026-09-22, rows 38 and 39: `BACK` between turns, and a ball at what the party already has
|
||||||
|
|
||||||
|
Rank 9 (VIRIDIAN FOREST, next PEWTER CITY), **sixty-nine hours** on the rung, ratchet attempts 3 of
|
||||||
|
3 spent, the fly on Route 2 and in the forest and mostly in wild battles. Since the last restart the
|
||||||
|
macro starts were `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 and `GO ROUTE` 5, and the event
|
||||||
|
log repeated:
|
||||||
|
|
||||||
|
```
|
||||||
|
RUN blocked, BACK start, BACK done
|
||||||
|
```
|
||||||
|
|
||||||
|
`docs/design/macros.md` section 12.9 is the contract this closed against. Both traps are section
|
||||||
|
12.2's one rule — *a macro that completes without moving because its precondition is already
|
||||||
|
satisfied where the fly stands* — and the reproduction is the hunt from the release container's own
|
||||||
|
checkpoint, twenty brain minutes, the real brain as the readout.
|
||||||
|
|
||||||
|
### The mechanism
|
||||||
|
|
||||||
|
Three facts, and the first one is the whole of it.
|
||||||
|
|
||||||
|
1. **`BACK` was half of the pad the fly spends a wild battle looking at, and it could not change
|
||||||
|
anything.** The top-level battle menu never dealt it: `scene_set`'s own-turn arm is
|
||||||
|
`MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT` and B is not a fifth answer to a four-entry
|
||||||
|
menu. What dealt it was the **between-turns** row, `Scene::Battle { own_turn: false, .. }`, which
|
||||||
|
section 13.1 gave `NEXT, BACK` for the sake of the battle bag — the bag reads as nobody's turn
|
||||||
|
(12.6), so it lands on that arm — and which is *also* every frame of battle text, every
|
||||||
|
animation and every turn resolving. On those frames nothing is open, so `BACK`'s single B press
|
||||||
|
changes nothing the `NEXT` beside it does not: measured at **mean 16 frames, 1.0 tiles, net 0**,
|
||||||
|
163 starts between turns plus 12 more on a move list whose cursor the seam cannot place, out of
|
||||||
|
959 macros in twenty brain minutes. Half a pad of two, once per hold, while the turn did not
|
||||||
|
move.
|
||||||
|
2. **`RUN blocked` was the script, not the cartridge.** Not "can't escape": `RUN` is one cursor
|
||||||
|
navigation, and a cursor macro whose list is not accepting input **waits** rather than pressing
|
||||||
|
blind (section 4), for `CURSOR_WAIT` = 180 frames before it reports `Blocked`. Measured: ten
|
||||||
|
`RUN` starts, ten `Blocked`, **mean 184 frames**, every one of them started on the top-level
|
||||||
|
menu. So a `RUN` that won a hold as the menu closed spent three brain seconds pressing nothing
|
||||||
|
and gave up. That is **row 19**, bounded and left, and the only reason it was legible in the
|
||||||
|
event log is the `BACK` above filling the frames on either side of it. `RUN`'s precondition is
|
||||||
|
unchanged (13.1: only a wild battle the fly is losing).
|
||||||
|
3. **`THROW BALL` had no reading of what it was throwing at.** Its three facts were a wild battle,
|
||||||
|
a ball in the bag and room in the party, and none of them is "this is not one I already have".
|
||||||
|
Viridian Forest holds five species; a throw at one the party carries spends a ball for nothing,
|
||||||
|
and a catch opens the nickname screen, which reads `Unknown`, needs the START the pad has no
|
||||||
|
button for, and is the one screen neither `NEXT` nor `BACK` leaves (**row 14**). So the trap is
|
||||||
|
paid for twice.
|
||||||
|
|
||||||
|
| # | trap | trigger | test | fix, or why it is left |
|
||||||
|
| ---: | --- | --- | --- | --- |
|
||||||
|
| 38 | `BACK` on a battle pad with no list open completes without changing anything, once per hold | every frame of battle text, every animation, every turn resolving — which is most of a wild battle | `back_is_on_a_battle_pad_only_where_a_list_is_open`, `a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated) | **fixed**: the between-turns row is the sub-state's, like the own turn's — `NEXT, BACK` with the bag open, `NEXT` alone otherwise. The move list and the party list keep `BACK`, because backing out of a list is one of exactly two answers to one, and the top-level menu never had it |
|
||||||
|
| 39 | `THROW BALL` throws at a species the party already holds | any wild battle after the first catch of that species | `throw_ball_refuses_a_species_the_party_already_holds`, and the ROM-gated run above asserts it never happens on the cartridge | **fixed**: a fourth fact in the precondition. The **party** is the caught set — the cartridge's own lifetime record, in the same internal species numbering the enemy is read in. `wPokedexOwned` is *not* asked: that bitset is by Pokédex number and the table converting an internal index into one lives in a ROM bank this crate cannot read, so it would be an unverified number (`docs/design/ladder.md`). It costs nothing measurable, because the button is already off the pad while the party is full and nothing in the vocabulary deposits into a box (row 17). An enemy the seam cannot place leaves the button where it was |
|
||||||
|
| 40 | the ratchet cannot help on a rung sixty-nine hours old | `budget_spent()` precedes both triggers and three attempts on rank 9 were spent | `ratchet.rs`'s own tests ("three attempts per rank") | **left, contract**, and this is row 22 measured again: no recovery can fire until the rank *improves*. What carries the run is the road, and the road is checked rather than assumed — the objective is the lowest **unearned** rung (12.4), which is rung 10's `PEWTER_CITY`; `geography::next_hop` from the forest answers `VIRIDIAN_FOREST_NORTH_GATE` (47), then Route 2's north piece, then Pewter (2), asserted hop by hop since 12.7; and the ROM-gated run reads `GO OBJECTIVE` on the overworld pad of maps 13, 50 and 51. **The run does not reach map 47** in 67 brain minutes of the game-blind rotation, so that is reported and not asserted |
|
||||||
|
| 41 | the nurse's box answered `YES` four hundred and seventy-four times | measured in the **before** arm only: from 11.5 brain minutes the fly stood on one tile of a Pokémon Center and the windows read `YES` x21 to the end of the run | — | **named, not worked**: 23,548 of the before arm's 71,673 frames were a text box on map `0x29` at (3, 3), 474 of them answered `YES`, and the run ended there. The after arm never enters it — `dialog` frames go **23,548 → 0** — so it is neither reproduced nor fixed by this branch, and it is *the next thing to measure*. Rows 1, 2b, 23 and 24 were each found this way: a loop behind the loop in front of it |
|
||||||
|
|
||||||
|
### The trap hunt, before and after
|
||||||
|
|
||||||
|
Twenty brain minutes, seed 20260917, 4 sweep threads, same connectome and same cartridge, from the
|
||||||
|
release container's rung-9 checkpoint. **Driven by the brain rather than by `FLY_TRAP_STUB`**, which
|
||||||
|
is legitimate here and was not for sections 13 and 14: this branch adds no macro type, so
|
||||||
|
`tools/build_flywire.py` re-deals nothing, the thirty-one `macro_<type>` populations are the same
|
||||||
|
function in both arms, and `--print-compatibility` is byte-identical at 648 bytes. The two arms
|
||||||
|
differ in the macro code and in nothing else.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FLY_ROM=".../pokemon-red.gb" FLY_MACRO_BRAIN=data/fafb-v783 \
|
||||||
|
FLY_TRAP_CHECKPOINT=.local/checkpoints/<the rung-9 forest checkpoint> \
|
||||||
|
FLY_TRAP_MINUTES=20 cargo run --release -p flysim --example trap_hunt
|
||||||
|
```
|
||||||
|
|
||||||
|
| measure | before (v0.4.1) | after (`fix/loop-20260922T0254`) |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| distinct (map, tile) over 20 brain minutes | 216 | **286** |
|
||||||
|
| windows flagged | 70 of 73 | **61 of 73** |
|
||||||
|
| windows under 4 distinct tiles | 21 | **5** |
|
||||||
|
| macros started | 959 | 830 |
|
||||||
|
| done / blocked / timeout | 873 / 80 / 5 | 726 / 97 / 6 |
|
||||||
|
| frames in `battle` | 27,525 | 48,756 |
|
||||||
|
| frames in `dialog` | 23,548 | **0** |
|
||||||
|
| `MOVE n` starts | 36 | **68** |
|
||||||
|
| `BACK` starts with **no list open** | **175** | **0** |
|
||||||
|
| `THROW BALL` starts | 47 | 46 |
|
||||||
|
|
||||||
|
**Both of `docs/loop-review.md`'s two criteria improve**: a third more ground (216 → 286 tiles) and
|
||||||
|
nine fewer flagged windows, with the windows that hold fewer than four tiles down from 21 to 5. The
|
||||||
|
pad tables the hunt gained for this work are the direct reading:
|
||||||
|
|
||||||
|
| battle sub-state | pad before | pad after |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| main menu | `move_1..4 next run throw_ball` | unchanged |
|
||||||
|
| move list | `back move_1..4` | unchanged |
|
||||||
|
| move list, no cursor | `back next` | **`next`** |
|
||||||
|
| party list | `back` | unchanged |
|
||||||
|
| between turns | `back next` | **`next`** |
|
||||||
|
|
||||||
|
Four things about it that are not improvements, recorded rather than buried.
|
||||||
|
|
||||||
|
- **`BACK` is still 359 starts of 830**, all of them over an open list: 306 on the move list and 46
|
||||||
|
on the party list, which with a party of one is `BACK` alone by design (row 34 — "what changed is
|
||||||
|
where `BACK` leads: a menu with `ATTACK` on it"). The trap was the *listless* `BACK`, and that is
|
||||||
|
the row that went to zero.
|
||||||
|
- **A `NEXT, BACK` two-cycle closes the run**, x75 over the last four brain minutes inside one
|
||||||
|
battle: the move list opens before its cursor can be placed, `NEXT` advances, the cursor appears,
|
||||||
|
`BACK` closes the list, and round again. It is bounded — the battle is real, `MOVE n` wins holds
|
||||||
|
in it, and the turn does move — and it replaces a `BACK` x13 cycle in the same windows of the
|
||||||
|
before arm. Worth watching; the honest fix is a move list whose cursor the seam can place on its
|
||||||
|
opening frames, which is a WRAM reading and not a pad change.
|
||||||
|
- **A window spent in a battle is flagged by the tile rule whatever happens in it.** A battle does
|
||||||
|
not move the fly, so `tiles = 1` is what a long fight looks like from outside, and the after arm
|
||||||
|
spends 48,756 of 71,673 frames in one. That is the known false-positive shape of the *tile* rule,
|
||||||
|
the mirror of the sequence rule's legitimately-repeating explorer, and it is why the pad and
|
||||||
|
sub-state tables above are what this row is proved on.
|
||||||
|
- **The `THROW BALL` precondition is inert in this run and is still right.** The save carries a
|
||||||
|
party of **one** — the starter — so no forest species is ever in it and the new fact never fires;
|
||||||
|
the 46 starts are all `Blocked` at mean 67 frames, which is row 19's cursor wait again. What the
|
||||||
|
ROM-gated test holds is the invariant (`threw_at_a_held_species` is never set), not a drop in the
|
||||||
|
count. The live 28 starts were the same shape.
|
||||||
|
|
||||||
|
### 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.1** on this box with the same assertion (`/status` reports
|
||||||
|
`total: 1` with the service still on `BOOT` 54 s after start, where the test wants the 38-rung
|
||||||
|
ladder). A debug build of the service does not finish booting inside the test's window here.
|
||||||
|
Pre-existing and unrelated to the macro layer; measured on both sides rather than assumed.
|
||||||
|
- `cargo clippy --all-targets`: clean.
|
||||||
|
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||||
|
- `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter
|
||||||
|
version / roles untouched.
|
||||||
|
|
|
||||||
|
|
@ -518,9 +518,21 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a
|
// 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a
|
||||||
// press exactly like a dialog. Section 13.1 adds `BACK`, because the bag list a battle's
|
// press exactly like a dialog. Section 13.1 added `BACK` here for the bag a battle's ITEM
|
||||||
// ITEM entry opens is none of `BattleMenu`'s three and reads here.
|
// entry opens, which is none of `BattleMenu`'s three and reads as nobody's turn -- and
|
||||||
Scene::Battle { .. } => vec![Next, Back],
|
// section 12.9 narrows it back to that list.
|
||||||
|
//
|
||||||
|
// **`BACK` is a button only where there is a list to leave.** Live on rung 9, 69 hours in
|
||||||
|
// Viridian Forest: `BACK` was 135 of 183 macro starts since the restart, `RUN blocked,
|
||||||
|
// BACK start, BACK done` over and over. Between turns there is nothing open to back out
|
||||||
|
// of, so the B press changes nothing the `NEXT` beside it does not, the macro completes on
|
||||||
|
// the tile it started on in a handful of frames, and the roll lands on it most holds while
|
||||||
|
// the turn does not move. That is section 12.2's trap exactly: a macro that completes
|
||||||
|
// without moving because its precondition is already satisfied where the fly stands.
|
||||||
|
Scene::Battle { .. } => match battle_menu(state) {
|
||||||
|
BattleMenu::Bag { .. } => vec![Next, Back],
|
||||||
|
_ => vec![Next],
|
||||||
|
},
|
||||||
// Section 13: the shop's buttons are the four purchases, plus the two answers any list has.
|
// Section 13: the shop's buttons are the four purchases, plus the two answers any list has.
|
||||||
Scene::Shop => vec![BuyPotion, BuyBall, BuyAntidote, BuyRepel, Confirm, Leave],
|
Scene::Shop => vec![BuyPotion, BuyBall, BuyAntidote, BuyRepel, Confirm, Leave],
|
||||||
// Section 13.1: `CONFIRM` as well as `LEAVE`, so a PC the fly opened is a list it can
|
// Section 13.1: `CONFIRM` as well as `LEAVE`, so a PC the fly opened is a list it can
|
||||||
|
|
@ -1600,6 +1612,29 @@ pub fn throw_slot(state: &mut dyn MacroState) -> Option<u8> {
|
||||||
if state.party().mons.len() >= PARTY_CAPACITY {
|
if state.party().mons.len() >= PARTY_CAPACITY {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// **Not a species this run already has** (section 12.9). Live on rung 9, 69 hours in Viridian
|
||||||
|
// Forest: `THROW BALL` was 28 of 183 macro starts, spending balls on the Caterpie and Weedle
|
||||||
|
// already in the party -- and a catch opens the nickname screen, which reads `Unknown` and
|
||||||
|
// needs the START the pad has no button for (row 14 of `infra/docs/macros-traps.md`), so the
|
||||||
|
// throw costs a ball and then a stall.
|
||||||
|
//
|
||||||
|
// The *party* is the caught set here, and it is the honest one: it is the cartridge's own
|
||||||
|
// lifetime record, it survives a restart the session ledgers do not, and it is in the same
|
||||||
|
// numbering the enemy is read in -- the internal species index
|
||||||
|
// (`docs/design/macros-wram.md`). `wPokedexOwned` is not usable for this: that bitset is by
|
||||||
|
// Pokédex *number*, the table that converts an internal index to one is in a ROM bank this
|
||||||
|
// crate cannot read, and `docs/design/ladder.md`'s rule is that an unverified number does not
|
||||||
|
// go in. It costs nothing measurable: the button is already off the pad while the party is
|
||||||
|
// full, nothing in the macro vocabulary deposits into a box (row 17), so every species this
|
||||||
|
// run has caught is in the party this reads.
|
||||||
|
//
|
||||||
|
// An enemy species the seam could not place leaves the button where it was -- a precondition
|
||||||
|
// this crate cannot observe is not a precondition, it is a guess (section 13.1).
|
||||||
|
if let Some(species) = battle.enemy.map(|enemy| enemy.species).filter(|species| *species != 0)
|
||||||
|
&& state.party().mons.iter().any(|mon| mon.species == species)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let index = state
|
let index = state
|
||||||
.bag()
|
.bag()
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,9 @@ use super::path::{self, Exit, Way};
|
||||||
use super::super::maps;
|
use super::super::maps;
|
||||||
use super::plan;
|
use super::plan;
|
||||||
use super::state::{
|
use super::state::{
|
||||||
Battle, BattleKind, BattleMenu, Connections, Cursor, Facing, GameState, MapSize, Mon, Move,
|
Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState, MapSize,
|
||||||
Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox, Walkable,
|
Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox,
|
||||||
Warp,
|
Walkable, Warp,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `SPRITE_POKE_BALL`, the first still sprite: the picture id a fake gives an object rather than a
|
/// `SPRITE_POKE_BALL`, the first still sprite: the picture id a fake gives an object rather than a
|
||||||
|
|
@ -99,6 +99,11 @@ struct World {
|
||||||
battle: Option<(BattleKind, bool, bool)>,
|
battle: Option<(BattleKind, bool, bool)>,
|
||||||
mons: Vec<Mon>,
|
mons: Vec<Mon>,
|
||||||
active: Option<u8>,
|
active: Option<u8>,
|
||||||
|
/// The Pokémon on the other side, when a test is about which one it is.
|
||||||
|
///
|
||||||
|
/// `None` is the seam answering nothing, which is what most of these fixtures want: the
|
||||||
|
/// species only matters to `THROW BALL`'s precondition (section 12.9).
|
||||||
|
enemy: Option<EnemyMon>,
|
||||||
|
|
||||||
money: u32,
|
money: u32,
|
||||||
bag: Vec<(u8, u8)>,
|
bag: Vec<(u8, u8)>,
|
||||||
|
|
@ -196,6 +201,7 @@ impl World {
|
||||||
cursor_max: 0,
|
cursor_max: 0,
|
||||||
grid: false,
|
grid: false,
|
||||||
battle: None,
|
battle: None,
|
||||||
|
enemy: None,
|
||||||
mons: vec![mon(0, 20, 20, &[(33, 30)])],
|
mons: vec![mon(0, 20, 20, &[(33, 30)])],
|
||||||
active: None,
|
active: None,
|
||||||
money: 0,
|
money: 0,
|
||||||
|
|
@ -532,7 +538,7 @@ impl GameState for World {
|
||||||
_ => BattleMenu::None,
|
_ => BattleMenu::None,
|
||||||
};
|
};
|
||||||
let own = self.active.and_then(|slot| self.mons.iter().find(|mon| mon.slot == slot)).copied();
|
let own = self.active.and_then(|slot| self.mons.iter().find(|mon| mon.slot == slot)).copied();
|
||||||
Some(Battle { kind, own_turn, forced_switch, menu, own, enemy: None })
|
Some(Battle { kind, own_turn, forced_switch, menu, own, enemy: self.enemy })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_box(&mut self) -> TextBox {
|
fn text_box(&mut self) -> TextBox {
|
||||||
|
|
@ -1084,13 +1090,22 @@ fn a_forced_switch_binds_switch_five_times_and_nothing_on_b() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text() {
|
fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text() {
|
||||||
|
// 2026-09-16 hotfix: battle text waits for a press like a dialog (live deadlock on Route 1).
|
||||||
let mut world = World::battle();
|
let mut world = World::battle();
|
||||||
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
|
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
|
||||||
|
world.battle = Some((BattleKind::Wild, false, false));
|
||||||
|
world.list = List::None;
|
||||||
let scene = world.scene();
|
let scene = world.scene();
|
||||||
// 2026-09-16 hotfix: battle text waits for a press like a dialog (live deadlock on Route 1).
|
// Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry
|
||||||
// Section 13.1 adds `BACK`: the bag list a battle's ITEM entry opens is none of
|
// opens, which reads as nobody's turn -- but on a frame of text there is no list to leave, and
|
||||||
// `BattleMenu`'s three, so it reads here, and `NEXT` alone could only press A at it.
|
// a `BACK` that changes nothing is the trap of section 12.2 (live, rung 9: 135 of 183 macro
|
||||||
|
// starts).
|
||||||
let palette = Palette::for_scene(scene, &mut world);
|
let palette = Palette::for_scene(scene, &mut world);
|
||||||
|
assert_eq!(names(&palette), ["NEXT"]);
|
||||||
|
|
||||||
|
// The bag is the one sub-state on this arm with a list open in it, and it keeps `BACK`.
|
||||||
|
world.list = List::BattleBag;
|
||||||
|
let palette = Palette::for_scene(world.scene(), &mut world);
|
||||||
assert_eq!(names(&palette), ["NEXT", "BACK"]);
|
assert_eq!(names(&palette), ["NEXT", "BACK"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3271,6 +3286,62 @@ fn each_battle_menu_deals_its_own_pad() {
|
||||||
between.list = List::None;
|
between.list = List::None;
|
||||||
let pad = names(&plan::plan_for(between.scene(), &mut between));
|
let pad = names(&plan::plan_for(between.scene(), &mut between));
|
||||||
assert_eq!(pad.iter().filter(|name| **name == "NEXT").count(), 1, "{pad:?}");
|
assert_eq!(pad.iter().filter(|name| **name == "NEXT").count(), 1, "{pad:?}");
|
||||||
|
assert!(!pad.contains(&"BACK"), "nothing is open to back out of: {pad:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section 12.9: `BACK` is on a battle's pad only where a list is open.
|
||||||
|
///
|
||||||
|
/// **Live on rung 9**, 69 hours in Viridian Forest: `BACK` was 135 of 183 macro starts since the
|
||||||
|
/// restart and the event log repeated `RUN blocked, BACK start, BACK done`. Between turns there is
|
||||||
|
/// no list to leave, so the B press changes nothing the `NEXT` beside it does not and the macro
|
||||||
|
/// completes on the tile it started on -- section 12.2's trap, on the pad the fly spends most of a
|
||||||
|
/// wild battle looking at.
|
||||||
|
///
|
||||||
|
/// The three lists keep it, because backing out of a list is one of exactly two answers to one.
|
||||||
|
#[test]
|
||||||
|
fn back_is_on_a_battle_pad_only_where_a_list_is_open() {
|
||||||
|
let with_back = |world: &mut World| {
|
||||||
|
let pad = names(&plan::plan_for(world.scene(), world));
|
||||||
|
assert!(pad.contains(&"BACK"), "a list can be left: {pad:?}");
|
||||||
|
};
|
||||||
|
let without = |world: &mut World| {
|
||||||
|
let pad = names(&plan::plan_for(world.scene(), world));
|
||||||
|
assert!(!pad.contains(&"BACK"), "nothing to back out of: {pad:?}");
|
||||||
|
assert!(plan::plan_for(world.scene(), world).bound() > 0, "and never an empty pad");
|
||||||
|
};
|
||||||
|
|
||||||
|
// The top-level menu: FIGHT, PKMN, ITEM and RUN are the four answers and B is not a fifth.
|
||||||
|
let mut main = World::battle();
|
||||||
|
without(&mut main);
|
||||||
|
|
||||||
|
// The three lists.
|
||||||
|
let mut moves = World::battle();
|
||||||
|
moves.list = List::Moves(3);
|
||||||
|
with_back(&mut moves);
|
||||||
|
let mut party = World::battle();
|
||||||
|
party.list = List::BattleParty;
|
||||||
|
with_back(&mut party);
|
||||||
|
// The bag reads as nobody's turn (`state::battle`), so it lands on the between-turns arm --
|
||||||
|
// and it is the one sub-state there with a list open in it.
|
||||||
|
let mut bag = World::battle();
|
||||||
|
bag.scene = Scene::Battle { own_turn: false, forced_switch: false };
|
||||||
|
bag.battle = Some((BattleKind::Wild, false, false));
|
||||||
|
bag.list = List::BattleBag;
|
||||||
|
with_back(&mut bag);
|
||||||
|
|
||||||
|
// Text, an animation, a turn resolving: no list, no `BACK`.
|
||||||
|
let mut between = World::battle();
|
||||||
|
between.scene = Scene::Battle { own_turn: false, forced_switch: false };
|
||||||
|
between.battle = Some((BattleKind::Wild, false, false));
|
||||||
|
between.list = List::None;
|
||||||
|
without(&mut between);
|
||||||
|
|
||||||
|
// And a forced switch, which could never be backed out of anyway (row 8).
|
||||||
|
let mut forced = World::battle();
|
||||||
|
forced.scene = Scene::Battle { own_turn: false, forced_switch: true };
|
||||||
|
forced.battle = Some((BattleKind::Wild, false, true));
|
||||||
|
forced.list = List::BattleParty;
|
||||||
|
without(&mut forced);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3872,6 +3943,51 @@ fn throw_ball_needs_a_wild_battle_a_ball_and_room_in_the_party() {
|
||||||
assert!(!on_the_pad(&mut room, MacroKind::ThrowBall));
|
assert!(!on_the_pad(&mut room, MacroKind::ThrowBall));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Section 12.9: a ball is not thrown at a species the party already holds.
|
||||||
|
///
|
||||||
|
/// **Live on rung 9**, 69 hours in Viridian Forest: `THROW BALL` was 28 of 183 macro starts, and
|
||||||
|
/// the forest holds Caterpie, Weedle, Metapod, Kakuna and Pidgey -- the fly had caught its own and
|
||||||
|
/// went on throwing at them. Every throw spends a ball, and a catch opens the nickname screen,
|
||||||
|
/// which reads `Unknown` and needs a START the pad has no button for (row 14).
|
||||||
|
///
|
||||||
|
/// The party is the caught set: the cartridge's own lifetime record, in the same internal species
|
||||||
|
/// numbering the enemy is read in. `wPokedexOwned` is by Pokédex number and the conversion is in a
|
||||||
|
/// ROM bank this crate cannot read, so it is not asked.
|
||||||
|
#[test]
|
||||||
|
fn throw_ball_refuses_a_species_the_party_already_holds() {
|
||||||
|
// Two distinct internal species indices -- the forest's Weedle and Caterpie, whose exact
|
||||||
|
// numbers nothing below depends on.
|
||||||
|
let weedle = 0x70;
|
||||||
|
let caterpie = 0x7b;
|
||||||
|
let mut world = World::battle();
|
||||||
|
world.mons.truncate(1);
|
||||||
|
world.mons[0].species = caterpie;
|
||||||
|
world.bag = vec![(item::POKE_BALL, 5)];
|
||||||
|
|
||||||
|
// A species the party does not hold: the button is on the pad, as before.
|
||||||
|
world.enemy = Some(EnemyMon { species: weedle, level: 6, hp: 20, max_hp: 20 });
|
||||||
|
assert_eq!(throw_slot(&mut world), Some(0));
|
||||||
|
assert!(on_the_pad(&mut world, MacroKind::ThrowBall));
|
||||||
|
|
||||||
|
// The one that is already in the party: off the pad, whatever the bag holds.
|
||||||
|
world.enemy = Some(EnemyMon { species: caterpie, level: 6, hp: 20, max_hp: 20 });
|
||||||
|
assert_eq!(throw_slot(&mut world), None, "a Caterpie is already in the party");
|
||||||
|
assert!(!on_the_pad(&mut world, MacroKind::ThrowBall));
|
||||||
|
|
||||||
|
// Any party slot counts, not only the one that is out.
|
||||||
|
world.mons.push(mon(1, 20, 20, &[(33, 30)]));
|
||||||
|
world.mons[1].species = weedle;
|
||||||
|
world.enemy = Some(EnemyMon { species: weedle, level: 6, hp: 20, max_hp: 20 });
|
||||||
|
assert_eq!(throw_slot(&mut world), None, "a Weedle is on the bench");
|
||||||
|
|
||||||
|
// A species the seam could not place leaves the button where it was: an unobservable
|
||||||
|
// precondition is a guess, and this crate does not guess (section 13.1).
|
||||||
|
world.enemy = None;
|
||||||
|
assert_eq!(throw_slot(&mut world), Some(0), "no reading is not a refusal");
|
||||||
|
world.enemy = Some(EnemyMon { species: 0, level: 0, hp: 0, max_hp: 0 });
|
||||||
|
assert_eq!(throw_slot(&mut world), Some(0), "species 0 is not a species");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
|
fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
|
||||||
let mut world = World::battle();
|
let mut world = World::battle();
|
||||||
|
|
|
||||||
|
|
@ -214,9 +214,42 @@ struct Trace {
|
||||||
font_corners_no_border: u64,
|
font_corners_no_border: u64,
|
||||||
font_no_corners: u64,
|
font_no_corners: u64,
|
||||||
corners_no_font: u64,
|
corners_no_font: u64,
|
||||||
|
/// Frames spent in each battle sub-state, by [`battle_sub_state`]'s name.
|
||||||
|
///
|
||||||
|
/// The scene histogram says "battle" and a battle has five sub-states with five different
|
||||||
|
/// pads (`docs/design/macros.md` section 12.6, 13.1), so a loop inside one of them is
|
||||||
|
/// invisible above. This is what names it.
|
||||||
|
battle_frames: BTreeMap<&'static str, u64>,
|
||||||
|
/// Macro starts by battle sub-state: which button the fly pressed on which of the five pads.
|
||||||
|
battle_starts: BTreeMap<(&'static str, &'static str), u64>,
|
||||||
|
/// Every macro channel that was bound in each battle sub-state, over the whole run.
|
||||||
|
///
|
||||||
|
/// The pad's composition, measured rather than read off the table: "which sub-state offers
|
||||||
|
/// `BACK`" is a question about the build under test and not about the document.
|
||||||
|
battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
|
||||||
wall_seconds: f64,
|
wall_seconds: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which sub-state of a battle this frame is, or `None` when no battle is running.
|
||||||
|
///
|
||||||
|
/// The five the pad is dealt by: the top-level menu, the move list, the party list, the bag, the
|
||||||
|
/// forced switch, and the frames between turns where no list is accepting input.
|
||||||
|
fn battle_sub_state(emulator: &mut flybrain_gb::Emulator) -> Option<&'static str> {
|
||||||
|
use flybrain_gb::pokemon_red::macros::state::BattleMenu;
|
||||||
|
let battle = flybrain_gb::pokemon_red::state::battle(emulator)?;
|
||||||
|
if battle.forced_switch {
|
||||||
|
return Some("forced switch");
|
||||||
|
}
|
||||||
|
Some(match battle.menu {
|
||||||
|
BattleMenu::Main { .. } => "main menu",
|
||||||
|
BattleMenu::Moves { cursor: Some(_), .. } => "move list",
|
||||||
|
BattleMenu::Moves { cursor: None, .. } => "move list, no cursor",
|
||||||
|
BattleMenu::Party { .. } => "party list",
|
||||||
|
BattleMenu::Bag { .. } => "bag",
|
||||||
|
BattleMenu::None => "between turns",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The macro that owns the buttons, with where it began and the ground it has covered since.
|
/// The macro that owns the buttons, with where it began and the ground it has covered since.
|
||||||
struct Running {
|
struct Running {
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
|
|
@ -350,6 +383,9 @@ fn run(
|
||||||
font_corners_no_border: 0,
|
font_corners_no_border: 0,
|
||||||
font_no_corners: 0,
|
font_no_corners: 0,
|
||||||
corners_no_font: 0,
|
corners_no_font: 0,
|
||||||
|
battle_frames: BTreeMap::new(),
|
||||||
|
battle_starts: BTreeMap::new(),
|
||||||
|
battle_pads: BTreeMap::new(),
|
||||||
wall_seconds: 0.0,
|
wall_seconds: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -406,6 +442,14 @@ fn run(
|
||||||
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
|
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
|
||||||
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
|
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
|
||||||
.flatten();
|
.flatten();
|
||||||
|
let battle_sub = battle_sub_state(&mut emulator);
|
||||||
|
if let Some(sub) = battle_sub {
|
||||||
|
*trace.battle_frames.entry(sub).or_insert(0) += 1;
|
||||||
|
let pad = trace.battle_pads.entry(sub).or_default();
|
||||||
|
for channel in bound.as_deref().unwrap_or_default() {
|
||||||
|
pad.insert(channel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(layer) = macros.as_mut() {
|
if let Some(layer) = macros.as_mut() {
|
||||||
let ledger = AdapterLedger(&adapter);
|
let ledger = AdapterLedger(&adapter);
|
||||||
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
|
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
|
||||||
|
|
@ -419,6 +463,9 @@ fn run(
|
||||||
if let Some(map) = dialog_map {
|
if let Some(map) = dialog_map {
|
||||||
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
|
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
if let Some(sub) = battle_sub {
|
||||||
|
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
running = Some(Running {
|
running = Some(Running {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
from: location,
|
from: location,
|
||||||
|
|
@ -801,6 +848,31 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !trace.battle_frames.is_empty() {
|
||||||
|
println!("\n| battle sub-state | frames | pad |");
|
||||||
|
println!("| --- | ---: | --- |");
|
||||||
|
for (sub, frames) in &trace.battle_frames {
|
||||||
|
let pad = trace
|
||||||
|
.battle_pads
|
||||||
|
.get(sub)
|
||||||
|
.map(|set| {
|
||||||
|
set.iter()
|
||||||
|
.map(|c| c.strip_prefix("macro_").unwrap_or(c).to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("| {sub} | {frames} | {pad} |");
|
||||||
|
}
|
||||||
|
println!("\n| macro start | battle sub-state | n |");
|
||||||
|
println!("| --- | --- | ---: |");
|
||||||
|
let mut rows: Vec<((&str, &str), u64)> =
|
||||||
|
trace.battle_starts.iter().map(|(key, n)| (*key, *n)).collect();
|
||||||
|
rows.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
|
||||||
|
for ((name, sub), n) in rows {
|
||||||
|
println!("| {name} | {sub} | {n} |");
|
||||||
|
}
|
||||||
|
}
|
||||||
println!("\n| scene | frames | longest run | run began (brain min) |");
|
println!("\n| scene | frames | longest run | run began (brain min) |");
|
||||||
println!("| --- | ---: | ---: | ---: |");
|
println!("| --- | ---: | ---: | ---: |");
|
||||||
for (scene, frames) in &trace.scenes {
|
for (scene, frames) in &trace.scenes {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ const VIRIDIAN_MART: u32 = 0x2a;
|
||||||
/// Route 2's southern forest gate and the forest north of it, which is rung 9's own road.
|
/// 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_SOUTH_GATE: u32 = 0x32;
|
||||||
const VIRIDIAN_FOREST: u32 = 0x33;
|
const VIRIDIAN_FOREST: u32 = 0x33;
|
||||||
|
/// 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;
|
||||||
|
|
||||||
/// `constants/event_constants.asm`, by bit index: the parcel picked up at the mart, and the parcel
|
/// `constants/event_constants.asm`, by bit index: the parcel picked up at the mart, and the parcel
|
||||||
/// delivered to Oak. The save that stalled has the first and not the second.
|
/// delivered to Oak. The save that stalled has the first and not the second.
|
||||||
|
|
@ -170,6 +173,18 @@ struct Run {
|
||||||
/// and the party list it fell into dealt one button.
|
/// and the party list it fell into dealt one button.
|
||||||
move_button_on_battle_pad: bool,
|
move_button_on_battle_pad: bool,
|
||||||
battle_pad: Option<usize>,
|
battle_pad: Option<usize>,
|
||||||
|
/// Whether `BACK` was ever on the pad in a battle with **no list open** (section 12.9).
|
||||||
|
///
|
||||||
|
/// The rung-9 trap: between turns there is nothing to back out of, so the press completes
|
||||||
|
/// where the fly stands and the turn does not move. `false` is the assertion.
|
||||||
|
back_without_a_list: bool,
|
||||||
|
/// Whether `THROW BALL` ever started at a species the party already held (section 12.9).
|
||||||
|
threw_at_a_held_species: bool,
|
||||||
|
/// Maps on whose *overworld* pad `GO OBJECTIVE` was ever bound.
|
||||||
|
///
|
||||||
|
/// 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Run {
|
impl Run {
|
||||||
|
|
@ -240,6 +255,9 @@ impl Run {
|
||||||
pads: std::collections::BTreeMap::new(),
|
pads: std::collections::BTreeMap::new(),
|
||||||
move_button_on_battle_pad: false,
|
move_button_on_battle_pad: false,
|
||||||
battle_pad: None,
|
battle_pad: None,
|
||||||
|
back_without_a_list: false,
|
||||||
|
threw_at_a_held_species: false,
|
||||||
|
objective_on_pad: std::collections::BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,6 +331,9 @@ impl Run {
|
||||||
pads: std::collections::BTreeMap::new(),
|
pads: std::collections::BTreeMap::new(),
|
||||||
move_button_on_battle_pad: false,
|
move_button_on_battle_pad: false,
|
||||||
battle_pad: None,
|
battle_pad: None,
|
||||||
|
back_without_a_list: false,
|
||||||
|
threw_at_a_held_species: false,
|
||||||
|
objective_on_pad: std::collections::BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,6 +361,51 @@ impl Run {
|
||||||
state.battle().is_some_and(|battle| battle.own_turn && !battle.forced_switch)
|
state.battle().is_some_and(|battle| battle.own_turn && !battle.forced_switch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a battle list is open and accepting input: the move list, the party list or the bag.
|
||||||
|
///
|
||||||
|
/// Section 12.9's question, through the same seam the macros read: `BACK` is a button where
|
||||||
|
/// there is a list to leave and nowhere else.
|
||||||
|
fn battle_list_open(&mut self) -> bool {
|
||||||
|
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
|
||||||
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
|
let mut state =
|
||||||
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
||||||
|
state.battle().is_some_and(|battle| {
|
||||||
|
matches!(
|
||||||
|
battle.menu,
|
||||||
|
BattleMenu::Moves { cursor: Some(_), .. }
|
||||||
|
| BattleMenu::Party { .. }
|
||||||
|
| BattleMenu::Bag { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the Pokémon on the other side is a species the party already holds.
|
||||||
|
///
|
||||||
|
/// The internal species index on both sides, which is the one numbering they share
|
||||||
|
/// (`docs/design/macros-wram.md`).
|
||||||
|
fn enemy_species_in_party(&mut self) -> bool {
|
||||||
|
use flybrain_gb::pokemon_red::macros::state::GameState;
|
||||||
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
|
let mut state =
|
||||||
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
||||||
|
let Some(species) =
|
||||||
|
state.battle().and_then(|battle| battle.enemy).map(|enemy| enemy.species)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
species != 0 && state.party().mons.iter().any(|mon| mon.species == species)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rung the macros are walking toward, as the objective reads it.
|
||||||
|
fn objective_map(&mut self) -> Option<u8> {
|
||||||
|
use flybrain_gb::pokemon_red::macros::MacroState;
|
||||||
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
|
let mut state =
|
||||||
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
||||||
|
state.objective().map(|objective| objective.map)
|
||||||
|
}
|
||||||
|
|
||||||
/// `wIsInBattle`: 0 out of battle, 1 wild, 2 a trainer.
|
/// `wIsInBattle`: 0 out of battle, 1 wild, 2 a trainer.
|
||||||
fn in_battle(&mut self) -> u8 {
|
fn in_battle(&mut self) -> u8 {
|
||||||
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle)
|
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle)
|
||||||
|
|
@ -415,6 +481,12 @@ impl Run {
|
||||||
let dealt = bound.len();
|
let dealt = bound.len();
|
||||||
self.battle_pad = Some(self.battle_pad.map_or(dealt, |seen| seen.min(dealt)));
|
self.battle_pad = Some(self.battle_pad.map_or(dealt, |seen| seen.min(dealt)));
|
||||||
}
|
}
|
||||||
|
// Section 12.9, on the cartridge: `BACK` belongs to a list. A battle frame with no list
|
||||||
|
// accepting input and `BACK` on the pad is the rung-9 trap itself.
|
||||||
|
if self.in_battle() != 0 && !self.battle_list_open() {
|
||||||
|
self.back_without_a_list |=
|
||||||
|
bound.iter().any(|channel| channel.as_str() == "macro_back");
|
||||||
|
}
|
||||||
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
|
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
|
||||||
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
|
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
|
||||||
if talk_bound && !self.talk_on_pad {
|
if talk_bound && !self.talk_on_pad {
|
||||||
|
|
@ -435,6 +507,10 @@ impl Run {
|
||||||
(decision.mask, started)
|
(decision.mask, started)
|
||||||
};
|
};
|
||||||
for name in started {
|
for name in started {
|
||||||
|
// Section 12.9's other half: a ball is never thrown at a species the party holds.
|
||||||
|
if name == "THROW BALL" && self.enemy_species_in_party() {
|
||||||
|
self.threw_at_a_held_species = true;
|
||||||
|
}
|
||||||
*self.started.entry(name).or_insert(0) += 1;
|
*self.started.entry(name).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
self.gb.set_buttons(mask as u8);
|
self.gb.set_buttons(mask as u8);
|
||||||
|
|
@ -454,7 +530,11 @@ impl Run {
|
||||||
// dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box
|
// dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box
|
||||||
// is a pad of its own.
|
// is a pad of its own.
|
||||||
if map != u32::MAX && self.layer.scene_name() == "overworld" {
|
if map != u32::MAX && self.layer.scene_name() == "overworld" {
|
||||||
let dealt = self.layer.bound_channels().len();
|
let dealt = self.layer.bound_channels();
|
||||||
|
if dealt.iter().any(|channel| channel.as_str() == "macro_go_objective") {
|
||||||
|
self.objective_on_pad.insert(map);
|
||||||
|
}
|
||||||
|
let dealt = dealt.len();
|
||||||
let seen = self.pads.entry(map).or_insert(dealt);
|
let seen = self.pads.entry(map).or_insert(dealt);
|
||||||
*seen = (*seen).min(dealt);
|
*seen = (*seen).min(dealt);
|
||||||
}
|
}
|
||||||
|
|
@ -652,6 +732,133 @@ fn macros_mode_completes_its_walks_from_the_stalled_checkpoint() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rung-9 forest checkpoint, or `None` to skip.
|
||||||
|
///
|
||||||
|
/// Its own variable rather than `FLY_TRAP_CHECKPOINT`, because the two checkpoint tests above
|
||||||
|
/// assert the map their checkpoint is on: one envelope cannot be both.
|
||||||
|
fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||||
|
let path = std::env::var_os("FLY_FOREST_CHECKPOINT")?;
|
||||||
|
Some(
|
||||||
|
flysim::store::load(std::path::Path::new(&path))
|
||||||
|
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From the rung-9 forest checkpoint: the turns advance, and `BACK` is never a battle's whole pad.
|
||||||
|
///
|
||||||
|
/// **What was live** (2026-09-22, `infra/docs/macros-traps.md`): rank 9, VIRIDIAN FOREST, 69 hours
|
||||||
|
/// on the rung, the ratchet's three attempts spent, and since the restart the macro starts were
|
||||||
|
/// `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 — the event log repeating `RUN blocked, BACK
|
||||||
|
/// start, BACK done`. `BACK` on a battle frame with no list open completes in a handful of frames
|
||||||
|
/// without changing anything, so the roll landed on it most holds and the turn did not move; and
|
||||||
|
/// `THROW BALL` spent balls on the species already in the party, each catch opening a nickname
|
||||||
|
/// screen the pad cannot leave.
|
||||||
|
///
|
||||||
|
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that the
|
||||||
|
/// fly's own presses are what ends it, and that neither of the two traps is on the pad any more.
|
||||||
|
/// Which move it picks and whether it wins are the fly's.
|
||||||
|
///
|
||||||
|
/// ```sh
|
||||||
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
||||||
|
/// FLY_FOREST_CHECKPOINT=.local/checkpoints/release-forest-rung9.checkpoint \
|
||||||
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
||||||
|
/// ```
|
||||||
|
#[test]
|
||||||
|
fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
||||||
|
let rom = skip_without_rom!();
|
||||||
|
let Some(checkpoint) = forest_checkpoint() else {
|
||||||
|
eprintln!("skipped: no FLY_FOREST_CHECKPOINT");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
||||||
|
let from = run.map();
|
||||||
|
assert!(
|
||||||
|
from == VIRIDIAN_FOREST || from == ROUTE_2 || from == VIRIDIAN_FOREST_SOUTH_GATE,
|
||||||
|
"the checkpoint is the one the stream stalled on, got map {from:#04x}"
|
||||||
|
);
|
||||||
|
// Rung 9 is stood on, so the objective is rung 10 — Pewter City — and the road there is north
|
||||||
|
// through the forest (`macros::geography`, asserted as hops in that module's own tests).
|
||||||
|
assert_eq!(
|
||||||
|
run.objective_map(),
|
||||||
|
Some(0x02),
|
||||||
|
"the objective is Pewter City, whatever errand is in front of it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every battle this run passes through, and how it left.
|
||||||
|
let (mut battles, mut ended, mut in_battle) = (0u32, 0u32, run.in_battle() != 0);
|
||||||
|
let mut own_turns = 0u32;
|
||||||
|
for _ in 0..240_000 {
|
||||||
|
run.frame();
|
||||||
|
let now = run.in_battle() != 0;
|
||||||
|
match (in_battle, now) {
|
||||||
|
(false, true) => battles += 1,
|
||||||
|
(true, false) => ended += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
in_battle = now;
|
||||||
|
if now && run.own_turn() {
|
||||||
|
own_turns += 1;
|
||||||
|
}
|
||||||
|
if run.route.contains(&VIRIDIAN_FOREST_NORTH_GATE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let counts = run.layer.counts();
|
||||||
|
eprintln!(
|
||||||
|
"from map {from:#04x} in {:.1} brain minutes: route {:?}, battles {battles} ({ended} ended), own-turn frames {own_turns}, macros {counts:?}, by name {:?}, objective on the pad on {:?}",
|
||||||
|
run.ms / 60_000.0,
|
||||||
|
run.route,
|
||||||
|
run.started,
|
||||||
|
run.objective_on_pad
|
||||||
|
);
|
||||||
|
|
||||||
|
// The two traps, as assertions on the cartridge.
|
||||||
|
assert!(!run.back_without_a_list, "`BACK` was on a battle pad with no list open");
|
||||||
|
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
|
||||||
|
|
||||||
|
// The turn moves: the fly's own battle presses happen, and a battle this run entered or
|
||||||
|
// resumed finishes.
|
||||||
|
assert!(own_turns > 0, "the fly never got a turn");
|
||||||
|
let battle_presses: u32 = run
|
||||||
|
.started
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| name.starts_with("MOVE ") || **name == "THROW BALL")
|
||||||
|
.map(|(_, n)| *n)
|
||||||
|
.sum();
|
||||||
|
assert!(battle_presses > 0, "no move and no ball: {:?}", run.started);
|
||||||
|
assert!(ended > 0, "no battle ever ended: {battles} entered");
|
||||||
|
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
|
||||||
|
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
|
||||||
|
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this
|
||||||
|
// harness's game-blind rotation it is a fact about the rotation and not about the macros,
|
||||||
|
// because every bound channel wins about equally often.
|
||||||
|
let backs = run.started.get("BACK").copied().unwrap_or(0);
|
||||||
|
eprintln!(
|
||||||
|
"`BACK` was {backs} of {} macro starts, none of them with no list open",
|
||||||
|
counts.started
|
||||||
|
);
|
||||||
|
|
||||||
|
// North is the road, and the gate is the first hop. Asserted when the run reaches it and
|
||||||
|
// reported when it does not: which way the fly walks is its own, and the objective being on
|
||||||
|
// the pad is what this harness can hold it to.
|
||||||
|
if run.route.contains(&VIRIDIAN_FOREST_NORTH_GATE) {
|
||||||
|
eprintln!("the fly left the forest north through the gate at 0x2f");
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"the forest's north gate was not reached in this run; route {:?}",
|
||||||
|
run.route
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
run.objective_on_pad.contains(&VIRIDIAN_FOREST)
|
||||||
|
|| run.objective_on_pad.contains(&ROUTE_2)
|
||||||
|
|| run.objective_on_pad.contains(&VIRIDIAN_FOREST_NORTH_GATE),
|
||||||
|
"GO OBJECTIVE was on no overworld pad on the road north: {:?}",
|
||||||
|
run.objective_on_pad
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// From the stalled checkpoint, nothing the map draws is read as a text box.
|
/// From the stalled checkpoint, nothing the map draws is read as a text box.
|
||||||
///
|
///
|
||||||
/// **What this was written to settle** (2026-09-17, `infra/docs/macros-traps.md`): with the walk
|
/// **What this was written to settle** (2026-09-17, `infra/docs/macros-traps.md`): with the walk
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue