Merge branch 'main' into feat/map-aware-walks
This commit is contained in:
commit
9479681b9f
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.
|
||||
|
||||
### 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
|
||||
## 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.")
|
||||
|
|
@ -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 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, 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, 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, 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
- 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
|
||||
ever deals nothing on the release box is the watchdog's gauge to answer, over a run longer than
|
||||
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
|
||||
// press exactly like a dialog. Section 13.1 adds `BACK`, because the bag list a battle's
|
||||
// ITEM entry opens is none of `BattleMenu`'s three and reads here.
|
||||
Scene::Battle { .. } => vec![Next, Back],
|
||||
// press exactly like a dialog. Section 13.1 added `BACK` here for the bag a battle's ITEM
|
||||
// entry opens, which is none of `BattleMenu`'s three and reads as nobody's turn -- and
|
||||
// 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.
|
||||
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
|
||||
|
|
@ -1600,6 +1612,29 @@ pub fn throw_slot(state: &mut dyn MacroState) -> Option<u8> {
|
|||
if state.party().mons.len() >= PARTY_CAPACITY {
|
||||
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
|
||||
.bag()
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ use super::path::{self, Exit, Way};
|
|||
use super::super::maps;
|
||||
use super::plan;
|
||||
use super::state::{
|
||||
Battle, BattleKind, BattleMenu, Connections, Cursor, Facing, GameState, MapSize, Mon, Move,
|
||||
Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox, Walkable,
|
||||
Warp,
|
||||
Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState, MapSize,
|
||||
Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox,
|
||||
Walkable, Warp,
|
||||
};
|
||||
|
||||
/// `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)>,
|
||||
mons: Vec<Mon>,
|
||||
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,
|
||||
bag: Vec<(u8, u8)>,
|
||||
|
|
@ -196,6 +201,7 @@ impl World {
|
|||
cursor_max: 0,
|
||||
grid: false,
|
||||
battle: None,
|
||||
enemy: None,
|
||||
mons: vec![mon(0, 20, 20, &[(33, 30)])],
|
||||
active: None,
|
||||
money: 0,
|
||||
|
|
@ -532,7 +538,7 @@ impl GameState for World {
|
|||
_ => BattleMenu::None,
|
||||
};
|
||||
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 {
|
||||
|
|
@ -1084,13 +1090,22 @@ fn a_forced_switch_binds_switch_five_times_and_nothing_on_b() {
|
|||
|
||||
#[test]
|
||||
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();
|
||||
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();
|
||||
// 2026-09-16 hotfix: battle text waits for a press like a dialog (live deadlock on Route 1).
|
||||
// Section 13.1 adds `BACK`: the bag list a battle's ITEM entry opens is none of
|
||||
// `BattleMenu`'s three, so it reads here, and `NEXT` alone could only press A at it.
|
||||
// Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry
|
||||
// opens, which reads as nobody's turn -- but on a frame of text there is no list to leave, and
|
||||
// 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);
|
||||
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"]);
|
||||
}
|
||||
|
||||
|
|
@ -3271,6 +3286,62 @@ fn each_battle_menu_deals_its_own_pad() {
|
|||
between.list = List::None;
|
||||
let pad = names(&plan::plan_for(between.scene(), &mut between));
|
||||
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]
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
|
||||
let mut world = World::battle();
|
||||
|
|
|
|||
|
|
@ -220,9 +220,42 @@ struct Trace {
|
|||
font_corners_no_border: u64,
|
||||
font_no_corners: 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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
struct Running {
|
||||
name: &'static str,
|
||||
|
|
@ -357,6 +390,9 @@ fn run(
|
|||
font_corners_no_border: 0,
|
||||
font_no_corners: 0,
|
||||
corners_no_font: 0,
|
||||
battle_frames: BTreeMap::new(),
|
||||
battle_starts: BTreeMap::new(),
|
||||
battle_pads: BTreeMap::new(),
|
||||
wall_seconds: 0.0,
|
||||
};
|
||||
|
||||
|
|
@ -413,6 +449,14 @@ fn run(
|
|||
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
|
||||
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
|
||||
.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() {
|
||||
let ledger = AdapterLedger(&adapter);
|
||||
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
|
||||
|
|
@ -426,6 +470,9 @@ fn run(
|
|||
if let Some(map) = dialog_map {
|
||||
*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 {
|
||||
name: event.name,
|
||||
from: location,
|
||||
|
|
@ -850,6 +897,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!("| --- | ---: | ---: | ---: |");
|
||||
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.
|
||||
const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32;
|
||||
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
|
||||
/// 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.
|
||||
move_button_on_battle_pad: bool,
|
||||
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 {
|
||||
|
|
@ -240,6 +255,9 @@ impl Run {
|
|||
pads: std::collections::BTreeMap::new(),
|
||||
move_button_on_battle_pad: false,
|
||||
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(),
|
||||
move_button_on_battle_pad: false,
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn in_battle(&mut self) -> u8 {
|
||||
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle)
|
||||
|
|
@ -415,6 +481,12 @@ impl Run {
|
|||
let dealt = bound.len();
|
||||
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.
|
||||
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
|
||||
if talk_bound && !self.talk_on_pad {
|
||||
|
|
@ -435,6 +507,10 @@ impl Run {
|
|||
(decision.mask, 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.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
|
||||
// is a pad of its own.
|
||||
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);
|
||||
*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.
|
||||
///
|
||||
/// **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