Compare commits

..

No commits in common. "cfb3c1c506d74b30f04d092c5c8ca26419f50e15" and "351338038aa6e95d367d3d0412e65df3a2de7773" have entirely different histories.

14 changed files with 24 additions and 886 deletions

View file

@ -528,46 +528,9 @@ change, 648 bytes, checked on the WSL box.
| state | symbol | address | encoding | verified |
| --- | --- | ---: | --- | --- |
| the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so an item's position in this list is its cursor index in the buy menu **for the first three entries only** — see the correction below. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace |
| the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so **an item's position in this list is its cursor index** in the buy menu — which is what lets a purchase be navigated by reading the cursor rather than by counting presses. The terminator wins over the count, as it does for the bag, and the sixteen-byte buffer bounds both. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace |
| the tileset's counter tiles | `wTilesetTalkingOverTiles` | `$d532` | three tile ids from the tileset header (`data/tilesets/tileset_headers.asm`), `$ff` for a tileset with fewer. Mart and Pokecenter are both `$18 $19 $1e`; the overworld and an ordinary house have none. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch (`home/overworld.asm:1115`) walks exactly this list and doubles the talking range from `$10` to `$20` pixels — one tile to two — when the tile in front of the player is one of them. | ROM (the Viridian mart's column 1 and the centre's (3, 2) read as counters and the floor either side does not), trace |
### 7.1 Correction, 2026-09-22 (row 55): the list byte and the cursor index both say less than this
Two claims in the table above were surveyed again from the live checkpoint the stream looped in
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=shop`, in the Pewter mart), and both are narrower
than they were written.
**`wListMenuID` says the counter is open, not which of its screens is up.** Section 2's row for it
says it is "zeroed by `DisplayTextIDInit` at the start of every text display, so a stale value
cannot outlive one". That holds for text the overworld displays and not for the mart's own: the
clerk's "Here you are! Thank you!" is printed from inside `DisplayPokemartDialogue_`, which never
calls the routine that clears it. Measured: **every frame of a mart visit read `PRICEDITEMLISTMENU`
`$02`** — the counter menu and each of the clerk's text boxes included — and on the counter menu
`wTextBoxID` reads `MONEY_BOX` `$0d` rather than `BUY_SELL_QUIT_MENU` `$15`, because the money box
is the last template drawn. So `ShopScreen::BuySellQuit` and `Selling` were unreachable in a mart
that had ever drawn a buy list, and the frame the stream sat on — a dialogue box waiting for a
press, with a two-option box's leftover cursor bytes (`wTopMenuItemY` 8, `wTopMenuItemX` 15,
`wMaxMenuItem` 1, `wMenuWatchedKeys` `$03`) — read as "the priced buy list is open".
The screen is now read from the figure the game draws, the construction [`text_box`]'s `waiting`
test and [`yes_no_prompt`] already use: the full-width box drawn and waiting is the clerk
(`ShopScreen::Talking`, new), the item window drawn is the buy list, and the item window blank is
the counter menu. The item window's own figure is its first name cell, screen (6, 4), which held
`$7f` on the counter menu and `P` of `POKE BALL` on every frame the list was drawn.
**The buy list scrolls, so a position in `wItemList` is a cursor index only for the first three
entries.** Walked one DOWN pulse at a time on the live list: the cursor went `0, 1, 2` and then
**stopped moving while the window scrolled under it** — the fourth drawn row is a look-ahead the
cursor never occupies. The absolute position of the item under the cursor is that index plus
`wListScrollOffset`, which is **not** in the reviewed address list and cannot be pinned without the
disassembly `gen_symbols.py` reads (the same refusal `$cfc5` met in section 9). Viridian's
four-item counter hid it: POKE BALL is 0 and ANTIDOTE is 1, both in reach. Pewter's seven-item
counter did not: its ANTIDOTE is **3**.
So the macros carry `MART_CURSOR_ROWS = 3` and a purchase past it is not on the pad
(`infra/docs/macros-traps.md` row 55). Pinning `wListScrollOffset` would widen that, and it is a
residual rather than a guess.
**Why the second one is load-bearing.** A mart clerk is `object_event 0, 5, SPRITE_CLERK` behind a
counter running down column 1; a Pokémon Center nurse is `object_event 3, 1, SPRITE_NURSE` behind
the counter tile at (3, 2). **None of the four tiles around either of them is standable** — they are

View file

@ -1262,48 +1262,6 @@ finding out which half is the whole fix.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.19 A mart's counter is four screens, and one of them is the clerk talking (2026-09-22, row 55)
Minutes after v0.4.7 went live the watchdog flagged the narrowest loop yet: map `0x38`, scene
`shop`, `sequence: [BUY ANTIDOTE], period 1, repeats 747, distinctMacros 1` over ten brain
minutes, with `BUY ANTIDOTE start` then `BUY ANTIDOTE blocked` every 0.8 s and **nothing else
starting at all**. Surveyed from the live checkpoint with a new probe mode
(`FLY_PROBE_CATCH=shop` in `examples/scene_probe.rs`); the bytes are in
`docs/design/macros-wram.md` section 7.1.
- **`wListMenuID` says the counter is open, not which of its screens is up.** The mart prints its
own text from inside `DisplayPokemartDialogue_`, which never calls the routine that clears that
byte, so it holds `PRICEDITEMLISTMENU` for the **whole visit**. The frame the stream sat on was
the clerk's "Here you are! Thank you!" box waiting for a press, and the seam called it the buy
list; the cursor bytes on it belong to a two-option box (`max` 1). So which screen is up is now
read from the figure the game draws -- the construction the dialogue box's `waiting` test and the
YES/NO prompt already use. The clerk talking is `ShopScreen::Talking`, it reports **no listing at
all**, and no purchase starts on it: a scene whose menu is not open offers what actually opens it,
which here is `CONFIRM` and `LEAVE`, and both are already on the shop's pad.
- **A mart's buy list scrolls, so an item's position in the stock is its cursor index only for the
first three entries.** Walked one pulse at a time on the live list, the cursor went `0, 1, 2` and
then stopped moving while the window scrolled under it. The offset that names the scrolled
position is not a pinned address, so the fourth item of a counter and after have no index this
seam can aim at. Pewter's counter carries seven items and its ANTIDOTE is the **fourth**:
`BUY ANTIDOTE` aimed a cursor step at index 3 in a list reporting a max of 1 and returned
`Blocked` **on its own first frame, having pressed nothing** -- three of three attempts, zero
frames. A macro that cannot run is not on the pad, so the four purchases are bound on the rows
the cursor can reach, and Viridian's four-item counter is why this went unseen: its ANTIDOTE is
index 1.
- **A purchase has no target, so a blocked purchase records nothing.** Section 12.1's ledger is
keyed by what a walk set out for and a press sets out for nothing, so the button was dealt again
on the very next hold, for ever -- row 6's shape in a scene with no walk in it. The fix is the
precondition rather than a new ledger: the question the ledger would have answered is a fact about
the counter, and the pad can ask it before the fly presses.
The loop was also **the fly's own choice landing on the one button it could afford**: the wallet
read 104, so of Pewter's stock only the Antidote was under it, and `BUY ANTIDOTE` was the only
purchase bound. Nothing about the choice changes here. What changes is that the button is not
offered, and the two presses that leave a counter are.
Nothing here changes which button the fly presses. 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.")

View file

@ -784,11 +784,3 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
tiles up on both arms (296 -> 430 forest, 163 -> 184 Route 3), flagged windows again rise
because the fly is inside battles it is fighting (same judgement as v0.4.6). Next row: the
battle bag's list id outlives the bag the same way.
- 2026-09-22 (v0.5.2, loop review, auto): row 55, opened by the v0.4.7 errand change. A mart's
list id says the counter is open, not which screen is up, so the clerk's closing text box read
as an open buy list, and the buy list scrolls, so a stock position past the third row is never a
cursor index. BUY ANTIDOTE aimed at row 3 of a list reporting one row, blocked on frame 0 with
no button pressed, and was dealt again every hold. Fix: the shop screen is read from what the
game draws (a clerk's text box lists nothing, so a cursor step waits), and a purchase past the
three cursor rows is not on the pad. From the live checkpoint the fly leaves the mart in 0.19
brain minutes where it never left before; hunt tiles 1 -> 62, blocked starts 1,494 -> 82.

View file

@ -1552,6 +1552,7 @@ arm fought fifty thousand frames of battle *and* walked twenty thousand frames o
Raw reports: `hunt-before-20260922T0459.md` and `hunt-after-20260922T0459.md` in the coordination
state's `runs/` directory.
### Residuals, named rather than worked around
- **`NEXT` on a move list whose cursor the seam cannot place is now the largest source of it** --
@ -2058,7 +2059,6 @@ that covers more ground walks into more grass, and the hunt cannot tell a long f
| ---: | --- | --- | --- | --- |
| 54 | `GO FRONTIER`, `GO HEAL` and `GO ROUTE` cycle on five tiles: three walks that each end where they began | rung 10: brain minutes 1.0 to 8.5, `GO HEAL` **204** starts at a mean net of 0.0 tiles and a mean reach of 0.0, `GO ROUTE` 211 at a net of 0.2. Rung 11, the same cycle one town on: **14 distinct tiles in six brain minutes**, 17 of 17 windows flagged, `GO FRONTIER` 122 / `GO HEAL` 129 / `GO ROUTE` 126, **every one `done` at a mean net of 0.0**, printed as `GO ROUTE, GO FRONTIER, GO HEAL` x34 to x42 | `an_errand_does_not_settle_on_the_doormat_it_is_standing_on`, `an_errand_is_paid_by_a_building_this_run_has_already_been_inside`, `a_frame_mid_step_is_read_from_the_tile_the_screen_is_centred_on`, `a_decode_the_screen_disagrees_with_is_refused_mid_step_too`, `the_tile_a_step_is_landing_on_is_ground_the_run_has_covered`, `an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on`, and both ROM runs below | **fixed, and both readings were right.** Four facts, all measured: the coordinates change at the **end** of a step, so the grid was refused on every moving frame and every walk was planned over the ten-by-nine window; the tile a step is landing on was unrecorded for fifteen frames of every sixteen, so the fly's own next tile was a frontier it arrived at without moving; an errand's aim at a door the fly was standing on settled where it stood, and a completed errand walk writes the reached ledger, so the button came back every hold; and the errand ledger is session state, so a restore re-armed a town the run had already shopped and healed in. `docs/design/macros.md` section 12.17 |
| 54b | an **edge** the geography table has no row for is "somewhere new" for ever | Route 3: the cartridge reports its connections as **north and west** (`wCurMapConnections`; `warps: []`), the table carries west and **east**, so the seven walkable tiles of its north edge answered "leads somewhere this run has not stood on" on every hold, with `GO OBJECTIVE` off the pad beside them because nothing on that map leads to the objective | `an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on` | **fixed, narrowly.** A warp's destination is a byte the cartridge publishes, so `None` there is the `LAST_MAP` case row 2 already handles; an edge's comes only from `geography::connected`, so `None` there means the table cannot name it and never will. The only record left is the adapter's boundary ledger, and an edge the run has already stood on is not somewhere new. **The table row itself is not guessed at**: which map is north of Route 3 is a survey nobody has run, and it is a residual below |
| 55 | `BUY <item>` is dealt on a frame where no list is accepting input, and aimed at a stock index the counter's cursor cannot reach: it gives up on its own first frame, presses nothing, records nothing, and is dealt again next hold | the Pewter mart, ten brain minutes after v0.4.7: map `0x38`, scene `shop`, `loop.json` `sequence: [BUY ANTIDOTE], period 1, repeats 747, distinctMacros 1`, `BUY ANTIDOTE start` / `BUY ANTIDOTE blocked` every 0.8 s with **nothing else starting**; money 104, so the Antidote was the only purchase money allowed | `the_clerks_text_box_is_not_the_marts_buy_list`, `the_clerks_own_text_box_deals_no_purchase_and_reports_no_list`, `a_purchase_past_the_cursors_reach_is_not_on_the_pad`, `a_purchase_out_of_reach_by_the_time_it_starts_is_refused_without_a_press`, and the ROM run below | **fixed**, two facts, both surveyed with `FLY_PROBE_CATCH=shop`. `wListMenuID` says the **counter is open**, not which screen is up -- the mart prints its own text without clearing it, so every frame of a visit read "the priced buy list", including the clerk's "Here you are! Thank you!" box whose leftover cursor bytes are a two-option box's (`max` 1); the screen is now read from the figure the game draws and the clerk is `ShopScreen::Talking`, which reports no listing and deals no purchase. And the buy list **scrolls**: the cursor walks rows 0, 1, 2 and then the window moves under it, so only the first three of a counter's stock have an index this seam can aim at, and Pewter's ANTIDOTE is its fourth. `docs/design/macros.md` section 12.18, `docs/design/macros-wram.md` section 7.1 |
### Residuals, named rather than worked around
@ -2082,17 +2082,6 @@ that covers more ground walks into more grass, and the hunt cannot tell a long f
table has no row for it (row 54b). Naming it is a survey -- walk the fly off that edge with real
presses and read `wCurMap` back, the method of `docs/design/macros-wram.md` -- and nothing here
guesses at it. Until then that edge is walked once and then falls out of the first tier.
- **`wListScrollOffset` is not a pinned address** (row 55), so a mart's fourth item and after have
no cursor index this seam can name and their `BUY` buttons are off the pad -- Pewter's ANTIDOTE,
BURN HEAL, AWAKENING and PARLYZ HEAL among them. Pinning it is the same survey `$cfc5` is waiting
on in `docs/design/macros-wram.md` section 9: `gen_symbols.py` refuses a hand-written address and
the checkout it reads is not on this box.
- **A mart that has never drawn a buy list reads `Unknown`, not `Shop`** (measured beside row 55).
On a first visit `wListMenuID` is 0 and `wTextBoxID` on the counter menu is `MONEY_BOX` `$0d`
rather than `BUY_SELL_QUIT_MENU` `$15` -- the money box is the last template drawn -- so
`state::shop` answers `None` until the fly has opened the list once. Named rather than worked:
widening the test to `MONEY_BOX` would let the Game Corner's prize counter read as a mart, and
what that costs has not been surveyed.
## Row 54: the two arms, and the ROM runs (2026-09-22, v0.4.6)
@ -2290,78 +2279,3 @@ The blocked share is the assertion; the median is what it buys and the worst bat
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to this
branch's base. Decoder, reward catalog, adapter version and roles untouched.
## Row 55: the two arms, and the ROM run (2026-09-22, v0.4.7)
One checkpoint, the mart the stream was standing in. Same seed, same ground, `main` at `5512900`
against this branch, twenty brain minutes each.
| measure | before (`main`) | after |
| --- | ---: | ---: |
| distinct (map, tile) | **1** | **62** |
| windows flagged | 73 / 73 | 72 / 73 |
| macros started | 1,494 | 1,136 |
| macros **blocked** | **1,494 — every one of them** | 82 |
| `BUY ANTIDOTE` starts | **1,494, mean 0 frames, mean net 0.0 tiles** | **0** |
| frames in scene `shop` | **71,673 of 71,673** | **225** (longest run 171) |
| the repeated sequence | `BUY ANTIDOTE` x149 to x150, every window | `NEXT` x14 to x19 |
| the fly leaves the mart | **never** | **at 0.19 brain minutes** |
| frames in `battle` | 0 | 66,727 (longest run 17,722, from minute 0.58) |
| wall clock | 4,210 s | 2,384 s |
**The before arm is the live loop whole, with the real brain in it**: the fly started 1,494 macros
in twenty brain minutes, **every single one of them `BUY ANTIDOTE`, every single one `blocked`,
every one at a mean of nought frames** — so it pressed no button at all for the whole run and stood
on one tile. That is the sharpest reproduction any row here has had, and it is what a macro that
gives up before its first press looks like from outside.
**The after arm walks out and then fights.** The shop scene is 225 frames of 71,673 instead of all
of them, `CONFIRM` and `LEAVE` are the two macros that end it, and the fly is on the overworld at
0.19 brain minutes, takes an item six tiles away, walks three routes at a mean net of 9.0 tiles
(max 19) and is in a battle from 0.58. Sixty-two times the ground.
**The flagged-window count barely moves (73 → 72), and this says so rather than smoothing it.**
What the windows are flagging after the fix is **row 50**: 66,727 of the 71,673 frames are one long
battle, `MOVE 1`–`MOVE 4` are 74 of the 82 remaining `blocked` finishes at a mean of ~230 frames
each, and the repeated sequence is `NEXT`/`BACK` in a battle. The hunt's tile rule cannot tell a
long fight from a stall — `docs/design/macros.md` section 15's own measurement, and the third branch
in a row to run into it. The loop this row is about is gone; the one underneath it is somebody
else's brief.
**ROM-gated, from the same checkpoint**
(`services/flysim/crates/flysim/tests/rom_macros_mode.rs`, skipped cleanly without `FLY_ROM` and
the checkpoint):
- `the_fly_leaves_the_pewter_mart_counter_from_the_rung_eleven_checkpoint` — route `[56, 2, 14]`
over 20.1 brain minutes: out of the mart, across Pewter City and on to Route 3. `BUY ANTIDOTE`
**0** starts, `LEAVE` 1, `CONFIRM` 1, empty shop pads **0**, and the longest chain of one macro
reporting `blocked` in the shop with the map, the tile, the wallet and the scene unchanged is
**0**, against a bound of three. The same chain in the *battle* scene is **47** (`MOVE 2`),
printed beside it and asserted on by nobody here: it is row 50's.
### Gates
- `cargo test --workspace` with `FLY_ROM` and `FLY_DATASET` set: **58 suites green**, one failure --
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
which asserts the adapter version is `pokered-unique8-v5` while the catch-reward slice moved it to
**v6**. The assertion is unchanged on this branch's base and on `main`, so it is that slice's to
settle and not a macro change: the adapter version is out of the loop review's scope.
- `cargo clippy --all-targets`: **0 warnings**.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, de-PII guard included.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to this
branch's base. Decoder, reward catalog, adapter version and roles untouched.
**Re-run after the merge with row 50**, from the same checkpoint: the same ROM test walks route
`[56, 2, 14, 2, 14, ... 55, ... 54, ... 58, ...]` -- out of the mart, across Pewter City, on and off
Route 3 repeatedly, through the museum, the gym and the centre -- with `BUY` **0** starts, `LEAVE`
1, `CONFIRM` 1, **no `MOVE n` blocked at all** (row 50's own fix), the longest chain of one macro
blocked on an unchanged frame **1** in any scene, and the wallet up from **104 to 329**. The two
rows compose: row 55 gets the fly out of the counter and row 50 lets it win the fights it then
walks into. The trap-hunt arms above were measured against this branch's base, before row 50
landed, and are left as measured.
**Merged with `main` after row 50 landed** (`v0.5.1`). Three conflicts, all of them two reviews
appending in the same place: `docs/design/macros.md` (row 50 keeps 12.18, row 55 renumbered to
**12.19**), this file (both rows, both residual pairs and both arm sections kept) and
`examples/scene_probe.rs` (both survey modes kept, `accept` and `shop`). Every source file
auto-merged.

View file

@ -103,19 +103,6 @@ pub const CHEAPEST_PURCHASE: u32 = {
least
};
/// Rows of a mart's priced buy list the shared cursor can sit on.
///
/// Three, measured rather than derived (`infra/docs/macros-traps.md` row 55): on the live list in
/// the Pewter mart the cursor walked `0, 1, 2` under DOWN and then stopped moving while the
/// *window scrolled* under it, so the fourth drawn row is a look-ahead the cursor never occupies.
/// The absolute position of the item the cursor is on is that index plus `wListScrollOffset`,
/// which is not in the reviewed address list and cannot be pinned without the disassembly
/// `gen_symbols.py` reads -- so an item past the third of a counter's stock has no index this seam
/// can name, and a purchase aimed at one is a button whose script gives up before it presses
/// anything. [`super::palette::stock_index`] is where the rule is applied, once, for both the pad
/// and the plan.
pub const MART_CURSOR_ROWS: usize = 3;
/// Cursor indices of the battle menu, as `state::BattleMenu::Main` documents them: "0 FIGHT,
/// 1 PKMN, 2 ITEM, 3 RUN".
pub mod battle_entry {

View file

@ -34,7 +34,7 @@ use super::palette::{
facing_target, listing, move_index, move_list, nurse_prompt, objective_goals, party_rested,
potion_slot,
precondition,
shop_screen, stock_index, throw_slot, untalked_objects, untalked_people, ways,
shop_screen, throw_slot, untalked_objects, untalked_people, ways,
};
use super::path::{self, Route, Way};
use super::state::{Facing, Scene, ShopScreen};
@ -2098,11 +2098,10 @@ fn approach(state: &mut dyn MacroState, targets: &[(Tile, TalkTarget)]) -> Vec<G
///
/// One script over all four of section 13's purchases, which differ only in which item id they are
/// pointed at -- exactly as `GO NPC` and `GO ITEM` differ only in which half of the object data
/// they read. The item's position in the stock list is its cursor index in the buy list *while the
/// list is not scrolled* (`docs/design/macros-wram.md`, `wItemList`), so the navigation is a
/// cursor read and never a count of presses; an item the counter does not stock, or one past the
/// rows the cursor can reach, has no index and the script refuses before anything is pressed
/// ([`super::palette::stock_index`], row 55).
/// they read. The item's **position in the stock list is its cursor index** in the buy list
/// (`docs/design/macros-wram.md`, `wItemList`), so the navigation is a cursor read and never a
/// count of presses; an item the counter does not stock has no index and the script refuses before
/// anything is pressed.
///
/// Quantity one, always: the prompt opens on one and this macro presses A at it. "Buy ONE unit"
/// is section 13's own word, and a quantity the fly did not choose is not one this crate types.
@ -2110,7 +2109,8 @@ fn approach(state: &mut dyn MacroState, targets: &[(Tile, TalkTarget)]) -> Vec<G
/// Which screen the mart is on is read from agent A's `ShopScreen` rather than assumed, so the
/// script works both from a freshly opened counter and from the buy list.
fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option<Vec<Step>> {
let index = stock_index(state, want)?;
let index = state.shop_stock().iter().position(|stocked| *stocked == want)?;
let index = u8::try_from(index).ok()?;
let mut steps = Vec::new();
if shop_screen(state)? == ShopScreen::BuySellQuit {
// BUY is the counter menu's first entry.

View file

@ -9,7 +9,7 @@
//! B 5.
use super::cartridge::{
CHEAPEST_PURCHASE, FACINGS, MART_CURSOR_ROWS, opposite,
CHEAPEST_PURCHASE, FACINGS, opposite,
ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget,
TargetKey, Tile, item, outdoors,
};
@ -908,52 +908,19 @@ pub fn objective_place(state: &mut dyn MacroState) -> Option<Objective> {
errand_place(state).or_else(|| state.objective())
}
/// Whether the open mart stocks `item` within reach of its cursor, and the money on hand covers
/// it.
/// Whether the open mart stocks `item` and the money on hand covers it.
///
/// Three halves now, and each one has been the answer at a different counter.
///
/// - **Stock.** Viridian's counter sells POKE BALL, ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no
/// Potion at all** (`data/items/marts.asm` at the pinned commit), so `BUY POTION` is correctly
/// off the pad in the first mart the fly ever walks into. That is the precondition working, not
/// a gap.
/// - **Money**, which is section 13's "money allows at least one".
/// - **Reach**, which is row 55. A purchase is navigated by *reading the cursor*, and a mart's
/// buy list scrolls: the cursor sits on rows `0, 1, 2` and the window moves under it, so the
/// absolute position of the item the cursor is on is the index plus a scroll offset this seam
/// cannot read ([`super::cartridge::MART_CURSOR_ROWS`]). Pewter's counter carries seven items and
/// ANTIDOTE is its fourth, so `BUY ANTIDOTE` there is a button whose script gives up before it
/// presses anything -- 747 starts and 747 `blocked` in ten brain minutes, none of them pressing
/// a button and none of them changing a byte. A macro that cannot run is not on the pad, so the
/// first three of a counter's stock are what the four purchases are bound on, and the rest wait
/// for `wListScrollOffset` to be a pinned address.
///
/// The clerk's own text box is the fourth thing this refuses, and it refuses it through the seam
/// rather than here: [`ShopScreen::Talking`] is not one of the two screens a purchase can start
/// from (`docs/design/macros-wram.md` section 7, row 55).
/// Both halves, and the stock half is the one that surprises: Viridian's counter sells POKE BALL,
/// ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no Potion at all** (`data/items/marts.asm` at the
/// pinned commit), so `BUY POTION` is correctly off the pad in the first mart the fly ever walks
/// into. That is the precondition working, not a gap.
fn affordable(state: &mut dyn MacroState, item: u8, cost: u32) -> bool {
// Sell cursors index the bag, not the counter's stock; and while the clerk is talking there is
// no list up at all.
if !state
// Sell cursors index the bag, not the counter's stock
state
.shop()
.is_some_and(|shop| matches!(shop.screen, ShopScreen::BuySellQuit | ShopScreen::Buying))
{
return false;
}
state.money() >= cost && stock_index(state, item).is_some()
}
/// The cursor index of `item` in the open counter's stock, when the cursor can reach it.
///
/// One accessor rather than the same `position` in the precondition and in the script, so the
/// button and the plan cannot disagree about which items a mart can sell the fly (row 6's rule:
/// the cheap question and the real one have to be the same question when they are the same fact).
pub fn stock_index(state: &mut dyn MacroState, item: u8) -> Option<u8> {
let index = state.shop_stock().iter().position(|stocked| *stocked == item)?;
if index >= MART_CURSOR_ROWS {
return None;
}
u8::try_from(index).ok()
&& state.money() >= cost
&& state.shop_stock().contains(&item)
}
/// What `GO SHOP` or `GO HEAL` walks to, or empty when there is nothing to walk to.
@ -1965,15 +1932,6 @@ pub fn listing(state: &mut dyn MacroState) -> Option<Listing> {
});
}
if let Some(shop) = state.shop() {
// **The clerk talking is not a list.** Row 55: `wListMenuID` keeps `PRICEDITEMLISTMENU`
// across the mart's own text, and the cursor bytes left behind belong to a two-option box
// -- so a cursor step that read this would take its length and its direction from a menu
// that is not on screen, which is section 12.11's rule in the one scene it had not
// reached. Reporting nothing makes the step *wait*, pressing nothing, exactly as it waits
// for a list that has not drawn yet.
if shop.screen == ShopScreen::Talking {
return None;
}
return Some(Listing {
kind: ListKind::Shop,
current: shop.cursor.current,

View file

@ -309,16 +309,6 @@ pub enum ShopScreen {
Buying,
/// The bag list, for selling.
Selling,
/// The clerk is talking: the counter is open, and the thing on screen is a dialogue box
/// waiting for a press rather than a list waiting for a cursor.
///
/// Row 55 of `infra/docs/macros-traps.md`. `wListMenuID` is not cleared while the mart prints
/// its own text -- "Here you are! Thank you!" goes through `PrintText` inside
/// `DisplayPokemartDialogue_` and not through `DisplayTextIDInit` -- so the byte that says
/// "the priced buy list" outlives the list by the whole of the clerk's conversation, while the
/// cursor bytes hold a two-option box's leftovers. A purchase started on one of those frames
/// navigates a list that is not there.
Talking,
}
/// A mart, when one is open.

View file

@ -1308,18 +1308,14 @@ fn the_four_purchases_are_bound_by_the_counters_own_stock() {
"Viridian stocks no Potion and no Repel"
);
// Cerulean's, which stocks both of the other two -- and whose fourth entry is out of the
// cursor's reach, exactly as Pewter's Antidote is (row 55): the list scrolls, so only the
// first three of a counter's stock have an index this seam can aim at.
// Cerulean's, which stocks both of the other two.
world.stock = vec![item::POKE_BALL, item::POTION, item::REPEL, item::ANTIDOTE];
assert_eq!(
names(&Palette::for_scene(Scene::Shop, &mut world)),
["CONFIRM", "BUY POTION", "BUY BALL", "BUY REPEL", "LEAVE"]
["CONFIRM", "BUY POTION", "BUY BALL", "BUY ANTIDOTE", "BUY REPEL", "LEAVE"]
);
// Money is the other half, per item: 150 buys an Antidote and nothing else, at a counter
// whose Antidote the cursor can reach.
world.stock = vec![item::POKE_BALL, item::ANTIDOTE, 15, 12];
// Money is the other half, per item: 150 buys an Antidote and nothing else.
world.money = 150;
assert_eq!(
names(&Palette::for_scene(Scene::Shop, &mut world)),

View file

@ -41,92 +41,3 @@ fn a_purchase_dealt_before_entering_sell_is_refused_without_a_press() {
assert!(world.pulses.is_empty());
assert!(machine.running().is_none());
}
/// Row 55: a purchase the counter's cursor cannot reach is not on the pad.
///
/// **What was live** (2026-09-22 19:02 UTC, the Pewter mart, ten brain minutes): `BUY ANTIDOTE
/// start` / `BUY ANTIDOTE blocked` every 0.8 s, 747 repeats of a sequence of one, nothing else
/// starting. Surveyed on the cartridge: the counter stocks seven items and ANTIDOTE is its
/// **fourth**, while the buy list's cursor walks rows `0, 1, 2` and then scrolls the window under
/// itself -- so the absolute index the script aimed at was above the list's own max and the step
/// returned `Blocked` on its first frame, with no button pressed and no byte changed. The offset
/// that would name the scrolled position is not a pinned address, so the fourth item and after
/// have no index this seam can aim at, and a macro that cannot run is not on the pad.
#[test]
fn a_purchase_past_the_cursors_reach_is_not_on_the_pad() {
let mut world = World::room();
world.scene = Scene::Shop;
world.list = List::Shop(ShopScreen::Buying);
world.cursor_max = 2;
// Pewter's counter, in menu order, as `shop_stock` read it off the cartridge at the live
// checkpoint: POKE BALL, POTION, ESCAPE ROPE, ANTIDOTE, BURN HEAL, AWAKENING, PARLYZ HEAL.
// The four ids the palette has no constant for are the ones no button buys.
world.stock = vec![item::POKE_BALL, item::POTION, 29, item::ANTIDOTE, 12, 14, 15];
world.money = 9_999;
assert_eq!(
names(&Palette::for_scene(Scene::Shop, &mut world)),
["CONFIRM", "BUY POTION", "BUY BALL", "LEAVE"],
"the Antidote is Pewter's fourth item and the cursor cannot reach it"
);
// The live wallet, where the Antidote was the only thing money allowed: the pad is the two
// presses that get out of the counter, and it is not empty.
world.money = 104;
assert_eq!(
names(&Palette::for_scene(Scene::Shop, &mut world)),
["CONFIRM", "LEAVE"],
"nothing in reach is affordable, so nothing is dealt but the ways out"
);
assert!(world.pulses.is_empty());
}
/// Row 55, the other half: the clerk talking is not a list, so no purchase starts on it.
///
/// `wListMenuID` keeps `PRICEDITEMLISTMENU` across the mart's own text
/// (`docs/design/macros-wram.md` section 7), and the cursor bytes left behind belong to a
/// two-option box. A purchase dealt on such a frame navigates a menu that is not on screen, which
/// is section 12.11's rule in the one scene it had not reached -- so the seam names the screen and
/// the palette deals the presses that *do* advance it.
#[test]
fn the_clerks_own_text_box_deals_no_purchase_and_reports_no_list() {
let mut world = World::room();
world.scene = Scene::Shop;
world.list = List::Shop(ShopScreen::Talking);
world.cursor_max = 1;
world.stock = vec![item::POKE_BALL, item::POTION, item::ANTIDOTE];
world.money = 9_999;
for kind in [
MacroKind::BuyPotion,
MacroKind::BuyBall,
MacroKind::BuyAntidote,
MacroKind::BuyRepel,
] {
assert!(!precondition(kind, &mut world), "{kind:?} has no list to navigate");
}
assert_eq!(names(&Palette::for_scene(Scene::Shop, &mut world)), ["CONFIRM", "LEAVE"]);
assert!(listing(&mut world).is_none(), "nothing is accepting list input");
assert!(world.pulses.is_empty());
}
/// And a purchase that was dealt before the counter changed under it presses nothing.
///
/// The same shape as `a_purchase_dealt_before_entering_sell_is_refused_without_a_press`, for the
/// reach: the script asks [`stock_index`] again and refuses, rather than aiming a cursor step at
/// an index the list cannot hold and reporting `blocked` a frame later.
#[test]
fn a_purchase_out_of_reach_by_the_time_it_starts_is_refused_without_a_press() {
let mut world = World::room();
world.scene = Scene::Shop;
world.list = List::Shop(ShopScreen::Buying);
world.cursor_max = 2;
world.stock = vec![item::ANTIDOTE, item::POKE_BALL];
world.money = 9_999;
let (palette, slot) = pick(&mut world, MacroKind::BuyAntidote);
// The counter the fly is standing at turns out to be Pewter's, where the Antidote is fourth.
world.stock = vec![item::POKE_BALL, item::POTION, 29, item::ANTIDOTE];
let mut machine = MacroMachine::new(1);
assert!(machine.start(&palette, slot, &mut world).is_err());
assert!(world.pulses.is_empty());
assert!(machine.running().is_none());
}

View file

@ -131,21 +131,6 @@ pub mod poke {
/// One count byte, then the ids, then `$ff`, so at most fourteen items can be both counted
/// and terminated inside the buffer. The real marts carry four to nine.
pub const MART_LIST_BYTES: u8 = 16;
/// Where the mart's priced item window writes its first item name, and the letter range a
/// name starts with -- the figure [`super::mart_item_window_drawn`] reads.
///
/// Screen coordinates rather than a symbol, for the reason [`YES_NO_BOX`] gives: what tells
/// the buy list from the counter menu is what is drawn, and the bytes that would name it are
/// not rewritten between the two. Surveyed in the Pewter mart from the live checkpoint
/// (`infra/docs/macros-traps.md` row 55): the counter menu left this cell at `HORIZONTAL`'s
/// neighbour `$7f`, and every frame the list was drawn held `P` of `POKE BALL` there, with the
/// shared cursor one column to its left.
pub const MART_NAME_ROW: u16 = 4;
pub const MART_NAME_COLUMN: u16 = 6;
/// Red's charmap: `$80`-`$99` are `A`-`Z`.
pub const CHAR_UPPER_A: u8 = 0x80;
pub const CHAR_UPPER_Z: u8 = 0x99;
/// `data/tilesets/tileset_headers.asm`: three counter tile ids per tileset, `-1` for none.
pub const COUNTER_TILES: u16 = 3;
/// The `-1` a tileset with fewer than three counter tiles pads its header with.
@ -736,23 +721,6 @@ pub fn submenu(memory: &mut dyn MemoryReader) -> bool {
/// is the only user of that template in the game; the buy list is `PRICEDITEMLISTMENU` and the
/// sell list is the bag's own `ITEMLISTMENU`, which is why selling is only recognised while the
/// mart's choice is still the last template drawn.
///
/// **`wListMenuID` says the counter is open, not which of its screens is up** (row 55 of
/// `infra/docs/macros-traps.md`, surveyed in the Pewter mart). The doc's claim that the byte is
/// "zeroed by `DisplayTextIDInit` at the start of every text display, so a stale value cannot
/// outlive one" holds for text the *overworld* displays and not for the mart's own: the clerk's
/// "Here you are! Thank you!" is printed from inside `DisplayPokemartDialogue_`, so `$cf94` keeps
/// `PRICEDITEMLISTMENU` across the whole visit. Measured on the cartridge from the live
/// checkpoint: every frame of a mart visit read `Buying`, the counter menu and the clerk's text
/// boxes included, and `wTextBoxID` on the counter menu reads `MONEY_BOX` rather than
/// `BUY_SELL_QUIT_MENU` because the money box is the last template drawn.
///
/// So which screen is up is read from the figure the game draws, the same construction
/// [`text_box`]'s `waiting` and [`yes_no_prompt`] already make:
///
/// - the full-width dialogue box drawn and waiting is the clerk, [`ShopScreen::Talking`];
/// - otherwise the item window drawn is the buy list and the item window blank is the counter
/// menu ([`mart_item_window_drawn`]).
pub fn shop(memory: &mut dyn MemoryReader) -> Option<Shop> {
if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 {
return None;
@ -760,15 +728,7 @@ pub fn shop(memory: &mut dyn MemoryReader) -> Option<Shop> {
let cursor = cursor(memory);
let list = read(memory, ram::wListMenuID);
if list == poke::PRICED_ITEM_LIST_MENU {
if text_box(memory).waiting {
return Some(Shop { screen: ShopScreen::Talking, cursor });
}
let screen = if mart_item_window_drawn(memory) {
ShopScreen::Buying
} else {
ShopScreen::BuySellQuit
};
return Some(Shop { screen, cursor });
return Some(Shop { screen: ShopScreen::Buying, cursor });
}
if read(memory, ram::wTextBoxID) != poke::BUY_SELL_QUIT_MENU {
return None;
@ -778,25 +738,6 @@ pub fn shop(memory: &mut dyn MemoryReader) -> Option<Shop> {
Some(Shop { screen, cursor })
}
/// Whether the mart's priced item window is the thing drawn over the counter menu.
///
/// `DisplayListMenuID` writes the item names down a fixed column of the window
/// ([`poke::MART_NAME_COLUMN`], from [`poke::MART_NAME_ROW`]) with the shared cursor in the column
/// to their left, and leaves that cell blank while only the BUY / SELL / QUIT box is up. Every
/// item a mart sells has a name that starts with a letter, so the test is "is there a letter
/// there": measured on the cartridge, the cell held `$7f` on the counter menu and the first
/// letter of the counter's first item on every frame the list was drawn.
///
/// A figure test rather than a byte, for the reason [`shop`] gives: the bytes that would name the
/// screen are not rewritten between the two, so they cannot tell them apart.
fn mart_item_window_drawn(memory: &mut dyn MemoryReader) -> bool {
let tile = read(
memory,
ram::wTileMap + poke::MART_NAME_ROW * poke::SCREEN_WIDTH + poke::MART_NAME_COLUMN,
);
(poke::CHAR_UPPER_A..=poke::CHAR_UPPER_Z).contains(&tile)
}
/// The PC, when one is open. `ActivatePC` sets `wMiscFlags`' generic-PC bit and `LogOff` clears
/// it, so it covers Bill's PC, the player's PC and Oak's alike.
pub fn pc(memory: &mut dyn MemoryReader) -> Option<Pc> {

View file

@ -460,9 +460,7 @@ fn a_mart_reports_which_of_its_screens_is_up() {
wram.set(ram::wListMenuID, poke::ITEM_LIST_MENU);
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Selling));
// The priced list byte with the item window drawn is the buy list.
wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU)
.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A);
wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU);
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Buying));
// The bag list on its own is the start menu's, not a mart's.
@ -472,45 +470,6 @@ fn a_mart_reports_which_of_its_screens_is_up() {
assert!(shop(&mut wram).is_none());
}
/// Row 55: the byte that says "the priced buy list" outlives the list, so which screen is up is
/// read from what is drawn.
///
/// Surveyed in the Pewter mart from the live checkpoint: `wListMenuID` held
/// `PRICEDITEMLISTMENU` on **every frame of the visit** -- the counter menu and the clerk's own
/// text boxes included -- because the mart prints its text from inside
/// `DisplayPokemartDialogue_` rather than through `DisplayTextIDInit`. The two things that do
/// change are the figure on screen: the full-width dialogue box for the clerk, and the item
/// window for the list.
#[test]
fn the_clerks_text_box_is_not_the_marts_buy_list() {
// The frame the stream looped on: the counter open, the priced-list byte stale, and the
// clerk's "Here you are! Thank you!" waiting for a press.
let mut wram = Wram::overworld();
wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU)
.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A)
.dialogue_box();
assert_eq!(
shop(&mut wram).map(|shop| shop.screen),
Some(ShopScreen::Talking),
"a dialogue box waiting for a press is the clerk, whatever the list byte says"
);
// The same counter with the box gone and the item window drawn: the buy list.
let mut wram = Wram::overworld();
wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)
.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU)
.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A);
assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Buying));
// And with the item window blank: the BUY / SELL / QUIT menu underneath it.
wram.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::frame::HORIZONTAL);
assert_eq!(
shop(&mut wram).map(|shop| shop.screen),
Some(ShopScreen::BuySellQuit),
"no item name in the window means the list is not the thing on screen"
);
}
#[test]
fn a_pc_is_the_generic_pc_bit() {
let mut wram = Wram::overworld();

View file

@ -931,277 +931,6 @@ fn accept_survey(
println!("```");
}
/// The screen as text, for a survey that has to read what the cartridge actually drew.
///
/// Red's charmap: `$80`-`$99` are `A`-`Z`, `$a0`-`$b9` are `a`-`z`, `$f6`-`$ff` are `0`-`9`, and
/// `$7f` is a space. Everything else prints as `.`, which is enough to tell a clerk's text box
/// from an item list.
fn screen_text(gb: &mut Emulator) -> Vec<String> {
(0..18u16)
.map(|y| {
(0..20u16)
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
0x7f => ' ',
byte @ 0x80..=0x99 => (b'A' + (byte - 0x80)) as char,
byte @ 0xa0..=0xb9 => (b'a' + (byte - 0xa0)) as char,
byte @ 0xf6..=0xff => (b'0' + (byte - 0xf6)) as char,
0xe7 => '!',
0xe8 => '?',
0xf3 => '$',
_ => '.',
})
.collect()
})
.collect()
}
/// Every byte the mart's reading rests on, as one line.
///
/// The raw half is read first, because the seam borrows the emulator: `wListMenuID` and
/// `wTextBoxID` are the two bytes [`state::shop`] decides on, and `wCurrentMenuItem` /
/// `wMaxMenuItem` are the cursor a purchase navigates by.
fn counter_line(gb: &mut Emulator, adapter: &PokemonRedReward) -> String {
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
let raw = format!(
"list={:#04x} textbox={:#04x} cur={} max={} font={:#04x} watched={:#04x} \
top=({},{}) joy={:#04x}",
gb.read8(ram::wListMenuID),
gb.read8(ram::wTextBoxID),
gb.read8(ram::wCurrentMenuItem),
gb.read8(ram::wMaxMenuItem),
gb.read8(ram::wFontLoaded),
gb.read8(ram::wMenuWatchedKeys),
gb.read8(ram::wTopMenuItemY),
gb.read8(ram::wTopMenuItemX),
gb.read8(ram::wJoyIgnore),
);
let text = flybrain_gb::pokemon_red::state::text_box(gb);
let yes_no = flybrain_gb::pokemon_red::state::yes_no_prompt(gb);
let ledger = AdapterLedger(adapter);
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
let state: &mut dyn MacroState = &mut poke;
format!(
"{:?} shop={:?} box(open={} waiting={}) yes_no={} money={} | {raw}",
state.scene(),
state.shop().map(|shop| (shop.screen, shop.cursor.current, shop.cursor.max)),
text.open,
text.waiting,
yes_no,
state.money()
)
}
/// Row 55's counter survey: what the mart's seam reads, what the pad offers, and what a real
/// `BUY …` does frame by frame.
///
/// `FLY_PROBE_CATCH=shop`. The live loop was `BUY ANTIDOTE start` / `BUY ANTIDOTE blocked` every
/// 0.8 s for ten brain minutes in the Pewter mart, with nothing else starting, so the three
/// questions are which reading puts the button on the pad, which step of the script gives up, and
/// what the counter's own cursor reports while it does. All three are printed rather than
/// reasoned about: the script navigates by reading the cursor, so the cursor is the evidence.
///
/// `FLY_PROBE_SHOP_ITEM` names the item by its `BUY …` button (`antidote` by default) so the same
/// survey can be pointed at whichever purchase the loop is on.
fn shop_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
use flybrain_gb::pokemon_red::macros::palette;
use flybrain_gb::pokemon_red::macros::{MacroKind, MacroMachine, Palette};
use flybrain_gb::pokemon_red::state::PokeState;
let want = std::env::var("FLY_PROBE_SHOP_ITEM").unwrap_or_else(|_| "antidote".to_string());
let wanted = match want.as_str() {
"potion" => MacroKind::BuyPotion,
"ball" => MacroKind::BuyBall,
"repel" => MacroKind::BuyRepel,
_ => MacroKind::BuyAntidote,
};
println!("\n## The counter as the seam reads it, sixty frames with nothing pressed\n");
let mut last = String::new();
for frame in 0..60 {
let line = counter_line(gb, adapter);
if line != last {
println!("- frame {frame:3}: {line}");
last = line;
}
gb.set_buttons(flybrain_gb::buttons::NONE);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
}
println!("\n## Every button the shop scene deals, and the halves of each precondition\n");
{
let ledger = AdapterLedger(adapter);
let mut poke = PokeState::with_ledger(gb, &ledger);
let state: &mut dyn MacroState = &mut poke;
let scene = state.scene();
let palette = Palette::for_scene(scene, state);
let names: Vec<&str> = palette.slots.iter().flatten().map(|spec| spec.name).collect();
println!("- scene `{scene:?}`, the pad: {names:?}");
println!("- `shop_screen` = {:?}", palette::shop_screen(state));
println!("- `listing` = {:?}", palette::listing(state));
println!("- `inside_mart` = {}", palette::inside_mart(state));
for kind in [
MacroKind::BuyPotion,
MacroKind::BuyBall,
MacroKind::BuyAntidote,
MacroKind::BuyRepel,
] {
let purchase = kind.purchase();
let stocked = purchase.map(|(id, _)| state.shop_stock().iter().position(|s| *s == id));
let rich = purchase.map(|(_, cost)| state.money() >= cost);
println!(
"- {:<12} item {:?}, its index in the stock {stocked:?}, money allows {rich:?}, \
precondition {}",
kind.name(),
purchase,
palette::precondition(kind, state),
);
}
}
println!("\n## `{}`, frame by frame, three times over\n", wanted.name());
for attempt in 1..=3 {
let slot = {
let ledger = AdapterLedger(adapter);
let mut poke = PokeState::with_ledger(gb, &ledger);
let state: &mut dyn MacroState = &mut poke;
let scene = state.scene();
let palette = Palette::for_scene(scene, state);
palette.slot(flybrain_gb::pokemon_red::macros::MacroId(wanted.slot())).map(|_| {
(palette, flybrain_gb::pokemon_red::macros::MacroId(wanted.slot()))
})
};
let Some((palette, slot)) = slot else {
println!("- attempt {attempt}: the button is not on the pad, so nothing is pressed.");
return;
};
let mut machine = MacroMachine::new(SEED);
let started = {
let ledger = AdapterLedger(adapter);
let mut poke = PokeState::with_ledger(gb, &ledger);
machine.start(&palette, slot, &mut poke)
};
println!("\n### Attempt {attempt}: `start` = {started:?}");
println!("- frame 0: {}", counter_line(gb, adapter));
let mut frame = 0u32;
loop {
let mask = {
let ledger = AdapterLedger(adapter);
let mut poke = PokeState::with_ledger(gb, &ledger);
machine.step(&mut poke)
};
let Some(mask) = mask else { break };
gb.set_buttons(mask);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
frame += 1;
let line = counter_line(gb, adapter);
if line != last || frame.is_multiple_of(20) {
println!("- frame {frame:3}: mask {mask:#06x} {line}");
last = line;
}
if frame > 700 {
println!("- (over seven hundred frames, which the cap forbids)");
break;
}
}
println!("- outcome after {frame} frames: {:?}", machine.outcome());
let mut entries = Vec::new();
while let Some(entry) = machine.take_blocked() {
entries.push(entry);
}
println!("- the blocked ledger entries it earned: {entries:?}");
}
println!("\n## What an A press at the counter really opens, pulsed by hand\n");
let pulse = |gb: &mut Emulator, adapter: &mut PokemonRedReward, mask: u8, ms: &mut f64| {
for phase in 0..16 {
gb.set_buttons(if phase < 8 { mask } else { 0 });
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
}
};
for step in 1..=40 {
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
println!("- A pulse {step:2}: {}", counter_line(gb, adapter));
}
println!("\n## And backing out of whatever that left, with B\n");
for step in 1..=10 {
pulse(gb, adapter, flybrain_gb::buttons::B, ms);
println!("- B pulse {step:2}: {}", counter_line(gb, adapter));
}
println!("\n## The live buy list: BUY chosen from the counter menu, then read\n");
// Get to the BUY / SELL / QUIT menu -- the one screen in the mart whose cursor is accepting
// input with no dialogue box drawn -- put the cursor on BUY, confirm, and read what opens.
// This is the list `shop_plan` navigates, and what `wMaxMenuItem` reports on it is the whole
// question row 55 turns on.
for _ in 0..40 {
let menu = {
let text = flybrain_gb::pokemon_red::state::text_box(gb);
!text.waiting && gb.read8(ram::wMaxMenuItem) == 2 && gb.read8(ram::wTopMenuItemY) == 4
};
if menu {
break;
}
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
}
println!("- at the counter menu: {}", counter_line(gb, adapter));
for row in screen_text(gb) {
println!(" |{row}|");
}
for _ in 0..4 {
if gb.read8(ram::wCurrentMenuItem) == 0 {
break;
}
pulse(gb, adapter, flybrain_gb::buttons::UP, ms);
}
println!("- cursor on BUY: {}", counter_line(gb, adapter));
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
let mut seen = String::new();
for frame in 0..90 {
let line = counter_line(gb, adapter);
if line != seen {
println!("- {frame:3} frames after confirming BUY: {line}");
for row in screen_text(gb) {
println!(" |{row}|");
}
seen = line;
}
gb.set_buttons(flybrain_gb::buttons::NONE);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
}
println!("\n### Walking that list down, one pulse at a time\n");
for step in 1..=9 {
pulse(gb, adapter, flybrain_gb::buttons::DOWN, ms);
println!("- DOWN {step}: {}", counter_line(gb, adapter));
for row in screen_text(gb) {
println!(" |{row}|");
}
}
println!("\n## The counter reopened from the overworld: every screen the mart draws\n");
// Out of the counter, face the clerk again and press A: the one sequence that shows what
// `wMaxMenuItem` really reports on the BUY / SELL / QUIT menu and on the *live* buy list,
// which is the question the fix turns on.
for step in 1..=30 {
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
let line = counter_line(gb, adapter);
println!("- A pulse {step:2}: {line}");
if step % 10 == 0 {
for down in 1..=3 {
pulse(gb, adapter, flybrain_gb::buttons::DOWN, ms);
println!(" - DOWN {down}: {}", counter_line(gb, adapter));
}
}
}
}
fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe.");
@ -1270,13 +999,6 @@ fn main() {
return;
}
// Row 55's counter survey: what the mart's seam reads while a `BUY ...` runs, and what an A
// press at the counter really opens.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "shop") {
shop_survey(&mut gb, &mut adapter, &mut ms);
return;
}
let budget = env_usize("FLY_PROBE_FRAMES", 200_000);
let stuck_after = env_usize("FLY_PROBE_STUCK", 600);
let mut next_burst = ms;

View file

@ -246,26 +246,6 @@ struct Run {
blocked: std::collections::BTreeMap<&'static str, u32>,
/// `NAME@scene/sub-state` for every macro that reported `blocked`: *where* it gave up.
blocked_where: std::collections::BTreeSet<String>,
/// The longest run of `blocked` finishes of one macro on a frame nothing changed, and the
/// macro it was.
///
/// Row 55's own shape (`infra/docs/macros-traps.md`): `BUY ANTIDOTE start` / `BUY ANTIDOTE
/// blocked` every 0.8 s for ten brain minutes in the Pewter mart, **747 repeats** of a
/// sequence of one -- and the survey found the macro giving up on its *first frame*, with no
/// button pressed and no byte changed, because the step's target was above the cursor's max.
/// A purchase has no walk target, so nothing reached the blocked ledger either.
///
/// "Nothing changed" is the tuple a loop is measured by: the map, the tile, the money and the
/// scene. One or two repeats are a rotation landing on the same button twice; a chain of them
/// with the world identical is the trap, whatever the macro.
/// Kept per scene as well as overall, because the two are different rows: the battle scene's
/// own chain is row 50 (`MOVE n` reports `blocked` with the move list drawn but not accepting
/// input) and belongs to that review, not to this one.
worst_blocked_streak: u32,
worst_blocked_streak_name: Option<&'static str>,
worst_blocked_streak_by_scene: std::collections::BTreeMap<&'static str, (u32, &'static str)>,
blocked_streak: u32,
blocked_streak_key: Option<(&'static str, u32, u8, u8, u32, &'static str)>,
/// Macros started while the fly was still on the map it resumed on.
macros_on_the_first_map: u32,
/// How many times each macro started while the fly was still on that map, by name.
@ -428,11 +408,6 @@ impl Run {
empty_overworld_pads: std::collections::BTreeSet::new(),
blocked: std::collections::BTreeMap::new(),
blocked_where: std::collections::BTreeSet::new(),
worst_blocked_streak: 0,
worst_blocked_streak_name: None,
worst_blocked_streak_by_scene: std::collections::BTreeMap::new(),
blocked_streak: 0,
blocked_streak_key: None,
macros_on_the_first_map: 0,
started_on_the_first_map: std::collections::BTreeMap::new(),
longest_menu_back_alternation: 0,
@ -545,11 +520,6 @@ impl Run {
empty_overworld_pads: std::collections::BTreeSet::new(),
blocked: std::collections::BTreeMap::new(),
blocked_where: std::collections::BTreeSet::new(),
worst_blocked_streak: 0,
worst_blocked_streak_name: None,
worst_blocked_streak_by_scene: std::collections::BTreeMap::new(),
blocked_streak: 0,
blocked_streak_key: None,
macros_on_the_first_map: 0,
started_on_the_first_map: std::collections::BTreeMap::new(),
longest_menu_back_alternation: 0,
@ -854,25 +824,6 @@ impl Run {
"{name}@{}/{sub}/list={list:#04x}",
self.layer.scene_name()
));
// Row 55: a macro that gives up on a frame nothing changed will give up again on the
// next hold, for ever, because nothing about the refusal is recorded anywhere.
let (x, y) = self.tile();
let key = (name, self.map(), x, y, self.money(), self.layer.scene_name());
if self.blocked_streak_key == Some(key) {
self.blocked_streak += 1;
} else {
self.blocked_streak = 1;
self.blocked_streak_key = Some(key);
}
if self.blocked_streak > self.worst_blocked_streak {
self.worst_blocked_streak = self.blocked_streak;
self.worst_blocked_streak_name = Some(name);
}
let scene = self.layer.scene_name();
let worst = self.worst_blocked_streak_by_scene.entry(scene).or_insert((0, name));
if self.blocked_streak > worst.0 {
*worst = (self.blocked_streak, name);
}
}
let in_battle_now = self.in_battle() != 0;
let on_a_battle_pad = self.battle_pad();
@ -2424,110 +2375,6 @@ fn pewter_checkpoint() -> Option<flysim::store::Checkpoint> {
})
}
/// The rung-11 mart checkpoint, or `None` to skip.
///
/// Its own variable, like every other checkpoint test here: one envelope cannot be on two maps.
fn mart_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_MART_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From inside the Pewter mart: the fly gets out of the shop scene, and no macro gives up over and
/// over on a frame nothing changed.
///
/// **What was live** (2026-09-22 19:02 UTC, minutes after v0.4.7): map `0x38`, scene `shop`, and
/// the watchdog's `loop.json` reading `sequence: [BUY ANTIDOTE], period 1, repeats 747,
/// distinctMacros 1` over ten brain minutes -- `BUY ANTIDOTE start` then `BUY ANTIDOTE blocked`
/// every 0.8 s, and nothing else starting at all.
///
/// **The mechanism, surveyed on the cartridge from this checkpoint** (`examples/scene_probe.rs`
/// `FLY_PROBE_CATCH=shop`, `infra/docs/macros-traps.md` row 55):
///
/// - the screen is the clerk's **"Here you are! Thank you!"** box, waiting for a press, and
/// `wListMenuID` still reads `PRICEDITEMLISTMENU` because the mart prints its own text without
/// going through `DisplayTextIDInit` -- so `state::shop` called it the buy list;
/// - the cursor bytes left on that frame are a **two-option box's**: `max` 1;
/// - the counter stocks seven items and ANTIDOTE is its **fourth**, so the purchase script's
/// first step aimed the cursor at index 3 in a list reporting a max of 1 and returned `Blocked`
/// **on its own first frame, with no button pressed** -- three of three attempts, zero frames;
/// - a purchase has no walk target, so the blocked ledger earned **nothing** and the button was
/// dealt again on the next hold, for ever;
/// - and the money was **104**, which is why `BUY ANTIDOTE` was the only purchase bound at all.
///
/// The claims, none of them about which button the fly presses:
///
/// - the fly **leaves the shop scene** -- a purchase that completes or a way out of the counter --
/// inside twenty brain minutes;
/// - **no macro reports `blocked` more than three times in a row on a frame nothing changed**
/// (the same map, tile, wallet and scene), against 747 live;
/// - the shop pad is never empty while the fly is in the mart, because a scene with nothing
/// sensible waits and this one has `CONFIRM` and `LEAVE`.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_MART_CHECKPOINT=.local/checkpoints/release-rank11-mart.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
/// ```
#[test]
fn the_fly_leaves_the_pewter_mart_counter_from_the_rung_eleven_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = mart_checkpoint() else {
eprintln!("skipped: no FLY_MART_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), PEWTER_MART, "the checkpoint is the mart the stream looped in");
assert_eq!(run.layer.scene_name(), "shop", "and the counter is open on it");
let mut left_the_counter = None;
let mut empty_shop_pads = 0u32;
for frame in 0..72_000u32 {
run.frame();
if run.layer.scene_name() == "shop" && run.layer.bound_channels().is_empty() {
empty_shop_pads += 1;
}
if left_the_counter.is_none() && run.layer.scene_name() != "shop" {
left_the_counter = Some(frame);
}
}
let money = run.money();
eprintln!(
"from the mart counter in {:.1} brain minutes: route {:?}, macros {:?}, blocked {:?}, \
the longest chain of one macro blocked on an unchanged frame {} ({:?}), money {money}",
run.ms / 60_000.0,
run.route,
run.started,
run.blocked,
run.worst_blocked_streak,
run.worst_blocked_streak_name,
);
eprintln!("the longest such chain per scene: {:?}", run.worst_blocked_streak_by_scene);
assert!(
left_the_counter.is_some(),
"the fly never left the shop scene (pad {:?}, macros {:?}, blocked {:?})",
run.layer.bound_channels(),
run.started,
run.blocked
);
// Scoped to the shop, because the *battle* scene has a chain of its own and it is row 50's
// (`MOVE n` blocked with the move list drawn but not accepting input), reviewed on its own
// branch. Reported rather than smoothed: the number is in the line above.
let (shop_streak, shop_macro) =
run.worst_blocked_streak_by_scene.get("shop").copied().unwrap_or((0, "none"));
assert!(
shop_streak <= 3,
"{shop_streak} finishes of `{shop_macro}` in a row reported blocked in the shop with the \
map, the tile, the wallet and the scene all unchanged: {:?}",
run.blocked_where
);
assert_eq!(
empty_shop_pads, 0,
"the shop dealt an empty pad on {empty_shop_pads} frames"
);
}
/// The rung-11 Pewter checkpoint, or `None` to skip.
fn rank_eleven_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_RANK11_CHECKPOINT").map(|path| {