docs: row 55, and the two claims about a mart the survey narrowed

`macros.md` section 12.18 is the review; `macros-wram.md` section 7.1 corrects
the address table, where `wListMenuID` was said to be cleared by every text
display and an item's position in the stock was said to be its cursor index;
`macros-traps.md` carries row 55, its two arms and its ROM run, and two new
residuals -- `wListScrollOffset` is not a pinned address, and a mart that has
never drawn a buy list reads `Unknown` rather than `Shop`.
This commit is contained in:
acamilo 2026-09-22 22:29:07 +00:00
parent 01c199fe18
commit 549df6c323
3 changed files with 148 additions and 1 deletions

View file

@ -528,9 +528,46 @@ 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 — 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 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 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

@ -1217,6 +1217,48 @@ walk. Both were measured from the same rung-10 checkpoint, with
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.18 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

@ -1552,9 +1552,22 @@ 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.
| 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
- **`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.
- **`NEXT` on a move list whose cursor the seam cannot place is now the largest source of it** --
142 of the after arm's 248 `NEXT` starts, over 8,687 frames. That frame is *correctly* not the
fly's turn (row 30b: `MoveSelectionMenu`'s coordinates appear before the engine has copied the
@ -2156,3 +2169,58 @@ skipped cleanly without `FLY_ROM` and the checkpoint):
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1
through v0.4.5. 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.
- `cargo clippy --all-targets`.
- `infra/tests/lint.sh`, de-PII guard included.
- `flysim --print-compatibility`, byte-identical to this branch's base.