jevsnes.git / research / zbanks-alttp.md
1# zbanks/alttp on our console
2
3[zbanks/alttp](https://github.com/zbanks/alttp) is a bot, written in C, that
4plays *A Link to the Past* from SNES memory. Upstream it is linked into a
5patched Snes9x; here it is compiled from upstream's tree
6(`third-party/c/zbanks-alttp`, commit bcb2537, "Hammer/sword/lift obstacles
7in path in ap_follow_targets") plus the carried patches in
8`third-party/c/patches/`
9and linked into our own host, `packages/zbanks`, which drives
10`packages/console`. This page is the contract between the two, every way our
11host differs from upstream's, and the evidence for each difference.
12
13User ruling, 2026-09-21 12:34: *"what you have build does NOT work. the repo i
14shared DOES work. i want the latter ported to rust, faithfully."* Narrowed at
1512:36: *"it doesn't need ported to rust if you can wrap and run the C."* So
16the bot's own code is not edited by the host; everything below is host,
17except the carried patches in `third-party/c/patches/`: two regressions in
18upstream's own last commits ("The stairs", "The Armos Knights"), the goal
19choice hook, and the `$B8` room - each a change no host can make.
20
21Citations are `file:line` in `third-party/c/zbanks-alttp/` unless another
22repository is named. Reference clones: `~/src/github.com/zbanks/alttp`,
23`spannerisms/usdasm` and `spannerisms/jpdasm` (the USA and Japanese
24disassemblies), `sporchia/alttp_vt_randomizer` (219fcaf) and
25`KatDevsGames/z3randomizer` (the Randomizer's patcher and its ASM).
26
27## The host contract
28
29Upstream's host is `snes9x.patch` (unix/unix.cpp), and it gives the bot
30exactly this:
31
32| Upstream | What it is | Ours (`packages/zbanks/src/lib.rs`) |
33| --- | --- | --- |
34| `struct ap_snes9x` (`alttp_public.h:10-15`) | Four fields: `base`, `save`, `load`, `info_string_ptr`. | `ApSnes9x`, `#[repr(C)]`, same order. |
35| `base(addr)` = `S9xGetMemPointer(addr)` | A pointer to the byte at a SNES bus address. `ap_init` calls it once per RAM variable and caches the pointer forever (`ap_snes.c:60-61`); `ap_map_attr_from_ram` calls it per lookup (`ap_map.c:364-365`). The bot READS and WRITES through it. | Pointers into a WRAM mirror and a ROM copy that never move (leaked allocations). WRAM is copied in from the console before every tick and back after it, which is how the bot's writes reach the game. |
36| `save(name)` / `load(name)` = `S9xFreezeGame` / `S9xUnfreezeGame` | Named savestates in the working directory; nonzero on success. `ap_init` calls `load("home")` then `load("hpegs")` (`ap_snes.c:66-81`) and ignores the result. | Console snapshots, `<name>.state` in a states directory the host chooses. |
37| `info_string_ptr` = `&GFX.InfoString` | Snes9x draws this string over the picture. The bot points it at `ap_info_string` every tick (`alttp.c:15`). | A slot the panels read. |
38| `ap_tick(IPPU.TotalEmulatedFrames, &jpb)` then `S9xMainLoop()` | Once per frame, BEFORE the frame runs, with the frame count and the human's pad (`MovieGetJoypad(0)`); the pad it leaves is the pad for that frame, and the human's is put back afterwards. | `Bot::tick(console, states, human)` returns the pad; the host holds it and runs one frame. Frame numbers start at 0 and run on across loads, as `TotalEmulatedFrames` does. |
39| joypad word | Snes9x layout: bit 15 B, 14 Y, 13 Select, 12 Start, 11 Up, 10 Down, 9 Left, 8 Right, 7 A, 6 X, 5 L, 4 R (`ap_snes.h:6-17`). | `PAD_BITS`. Opposing directions are dropped by both emulators (Snes9x `UpAndDown` off by default; jgenesis `allow_opposing_joypad_directions: false`, snes-core api.rs:63). |
40
41What `base()` is asked for (every `X(...)` in `AP_RAM_LIST`,
42`ap_snes.h:40-176`, plus the three literal lookups in `ap_map.c`):
43
44- work RAM, `$7E0000-$7FFFFF`, and its mirror in bank `$00` below `$2000`
45  (`$000438`, `$00047E`, ... `$0006B0`);
46- ROM tables in LoROM banks `$01`, `$02`, `$06`, `$0F`, `$1B`;
47- nothing else - no cartridge SRAM, no VRAM, no I/O. A request for anything
48  else is counted (`Report::stray_reads`) and has always been 0.
49
50`assert_bp(x)` (`ap_macro.h:79`) executes `int3` when `x` is false - a
51breakpoint for the gdb upstream ran under. Outside a debugger that is SIGTRAP,
52which kills the process. `shim/host.c` installs a SIGTRAP handler that counts
53the trap, keeps its address and returns: exactly gdb's `continue`. Plain
54`assert` is left alone and still aborts.
55
56The bot writes debug files (`goals.txt`, `map`, `map.pbm`, `full_map.dot`,
57`screens.txt`, ...) with bare relative names (`ap_map.c:546-547, 653, 1639,
581645, 1732, 1761, 1769, 1877, 1884, 1899`, `ap_plan.c:37`). The library is
59compiled with `-Dfopen=zb_fopen -Drename=zb_rename`, which sends them into
60the directory the host names (`run/zbanks-c/<run>/` headless,
61`run/zbanks-window/` in the app) instead of the repo root.
62
63Build flags are upstream's Makefile's, with three changes a newer compiler
64forces (`third-party/c/BUCK`): `-fcommon` (`ap_snes.h:32` defines
65`bool ap_manual_mode;` in a header; gcc < 10 merged it, clang 21 fails the
66link), no `-Werror`, and the two defines above.
67
68## Vanilla is not the Randomizer
69
70Upstream played the Randomizer, "Open mode, Sword on Uncle, No Glitches,
71Defeat Ganon", cheating "infinite health, bombs, and arrows" (README). Our
72cartridge is USA 1.0, SHA-1 `6d4f10a8…`. Each difference that reaches the
73bot, and what the host does about it:
74
75### ROM layout: the Randomizer is built on the JAPANESE 1.0 ROM
76
77The Randomizer requires "Zelda no Densetsu: Kamigami no Triforce (Japan)
78v1.0", CRC32 0x777AAC3F (alttpr.com/en/start; pyz3r). Work RAM is laid out
79the same in both, but ROM code and data moved, and the bot hardcodes
80Japanese ROM addresses. The first run on our ROM died at once on
81`assert(n_chests == 0 || chest_index != 168)` (`ap_map.c:3201`): it searched
82the chest table at `$01E96C`, which in the USA ROM starts two bytes later.
83
84Every ROM table the bot reads was located in the USA ROM by taking its bytes
85from the Japanese disassembly (`jpdasm`) and searching our image, then
86checked byte-for-byte over the whole table:
87
88| Japanese | USA | Table | Bot's name |
89| --- | --- | --- | --- |
90| `$01E96C` | `$01E96E` | `RoomData_ChestItems`, 168 × 3 | `dungeon_chests` |
91| `$02CCBD` | `$02CF59` | `EntranceData` Y | `entrance_ys` |
92| `$02CDC7` | `$02D063` | `EntranceData` X | `entrance_xs` |
93| `$02EB29` | `$02EDC5` | `.bombable_door_location` | `over_overlay_map16s` |
94| `$06F735`..`$06F7D5` | 6 bytes lower | sprite hitbox tables, 6 × 0x20 | `hitbox_*` |
95| `$0FFD94` | `$0E9459` | `OverworldTileTypes`, 0x200 | `over_tattr2`, and `LOAD2(0xFFD94 + x)` (`ap_map.c:417`; its own comment cites the USA `DATA_0E9459`) |
96| `$0F8000` | same | `Map16Definitions` | `LOAD2(0xF8000 + x)` - the USA table has six more entries, but work RAM holds USA map16 numbers, so the USA table is the right one |
97| `$1BB800`..`$1BBB73`, `$1BF110` | same | overworld entrance / hole tables, tile types | `over_hle_*`, `over_ent_*`, `over_tattr` |
98
99`base()` answers each Japanese address from the USA table (`JP_TO_US`).
100
101### Open mode: a save past the rain, and uncle who does not bring it back
102
103Open mode is in the ROM and the save, not in the bot. The Randomizer's
104`Rom::setOpenMode` (alttp_vt_randomizer app/Rom.php:1554-1563) writes the
105initial save: game state `$7EF3C5 = 2` (Zelda rescued, so no rain), progress
106flags `$7EF3C6 |= 0x14`, start at Link's house `$7EF3C8 = 1`, castle gate
107open (overworld `$1B` |= 0x20); every Randomizer file also starts with the
108Kakariko bomb hut and brewery open and ability flags `0x68`
109(InitialSram.php:24-31). `zbanks::rando::preset_file` puts those bytes into
110save file 1 and redoes its checksum (sum of words = 0x5A5A, usdasm
111bank_00.asm:1698-1718).
112
113In vanilla, uncle spawns in the secret passage while `$7EF3C6` bit 0 is
114clear, whatever the game state (`SpritePrep_Uncle`, usdasm
115bank_05.asm:16216-16240), so "Sword on Uncle" works on the preset save as it
116does in the Randomizer - but vanilla `Uncle_GrantEquipment` then stores 1 to
117the game state (`$05DF65`, bank_05.asm:17083-17084), which brings the rain
118back. The Randomizer NOPs those six bytes (z3randomizer hooks.asm:1560-1562,
119"Open Mode Fixes"); `zbanks::rando::patch_rom` does the same to our image (the first row of `PATCHES`).
120Vanilla uncle gives the fighter sword AND shield (ITEMGET 00); the
121Randomizer's gives one item.
122
123Uncle also speaks first. `Uncle_LyingInDefeat` calls `ShowMessageOnContact`
124with message `$0E` and only moves on to `Uncle_GrantEquipment` when it
125returns carry (usdasm bank_05.asm:17042-17059). The Randomizer blanks that
126message: `uncle_dying_sewer` is in `Text::removeUnwanted`'s list and becomes
127`{NOTEXT}` (alttp_vt_randomizer app/Text.php:1096, 1107). The bot relies on
128it: its `TALK_NPC` task ends the moment uncle is touched ("Return success
129early from the uncle", ap_plan.c:466-469), and the next task clears A, so a
130vanilla text box holds it for good - measured: frame 13,000 onward, module
131`$0E`, "Unnh... G, I didn't want you involved in this...". `rando::PATCHES`
132replaces that `JSL ShowMessageOnContact` (`$05DF34`) with the contact test
133the routine itself starts with, `JSL CheckDamageToLink_same_layer_long`
134(`$06F129`, bank_05.asm:17556): the same carry on touching him, no box.
135
136Measured before this was done: from a vanilla rain-state start the bot
137walked out of the house and stood in the first hint soldier's text box for
138the rest of the run.
139
140### No item-get text
141
142In vanilla, receiving an item shows a message looked up in
143`Ancilla22_ItemReceipt .message` (`$08C2DD`, usdasm bank_08.asm:13233-13310).
144The bot cannot close one: `OPEN_CHEST` finishes once the item is received
145(`ap_plan.c:445-447`), the next task follows targets, and
146`ap_follow_targets` clears A on every frame it is not lifting or cutting
147(`ap_map.c:825`), so the text holds Link still until the goal times out and
148is permanently failed. Measured: a heart piece's message held the bot from
149frame 12660 until it gave up at frame 17237 ("No goals available; falling
150back to manual mode", `ap_plan.c:919`).
151
152Upstream's own 24-minute video (the `media` branch's `alttp_20200104.mp4`,
1531463 s at 60 fps, ~45 item checks) was sampled 20 times a second for a text
154box - a white border 25+ pixels tall at both of the box's side columns,
155which matches our own text-box frames - and has none (4 hits, all four
156castle walls on inspection): its Randomizer build shows no item text. The
157Randomizer's own source says the same: "Item pickup text is skipped by a
158change we will keep" (alttp_vt_randomizer app/Text.php:958, in
159`removeUnwanted`, which also blanks the escort messages).
160`zbanks::rando::PATCHES` makes vanilla show none either, in all three places
161it would:
162
163| USA address | Vanilla | Patched | What |
164| --- | --- | --- | --- |
165| `$08C2DD` | one message id per item, 0x4C words | all `$FFFF` ("no message", bank_08.asm:13773-13775) | `Ancilla22_ItemReceipt .message` (bank_08.asm:13233-13310) |
166| `$08C380` | `$0155 $0156 $0157` | `$FFFF` ×3 | `.heart_piece_message`, a heart piece from a chest (bank_08.asm:13322-13326, 13754-13756) |
167| `$05F0BC` | `JSL ShowMessageUnconditional` | 4 × `NOP` | a heart piece lying on the ground (`Sprite_EB_HeartPiece`, bank_05.asm:20413-20422) |
168
169The pendant message after all three pendants (`.pendant_message`,
170bank_08.asm:13722-13733) is left alone: the bot is nowhere near it yet.
171
172### The Kakariko informant: a box the bot walks into (not a Randomizer difference)
173
174In Kakariko, while the game state is 2 (which open mode sets), two
175informants wander: the young and the old "snitch", sprites `$34` and `$3D`
176(state-2 sprite list, usdasm bank_09.asm:15742-15755). Touching one runs
177`Snitch_Meander`'s `JSL ShowMessageOnContact` with message `$2F`, "Here is
178[LINK], the wanted man! Soldiers! Anyone! Come quickly!" (`$05E776`,
179bank_05.asm:18772-18789; text.asm:1315-1318), then `CallThePolice`.
180
181The Randomizer keeps it: `kakariko_alert_guards` is retexted ("Guards!
182Help! The creeper @ is here!", alttp_vt_randomizer app/Text.php:167) and is
183not in `removeUnwanted`'s `{NOTEXT}` list (Text.php:931-1127); z3randomizer
184touches neither sprite. So upstream could meet the same box. Why its run
185never did: the bot treats both informants as obstacles (`$34` BLKF, `$3D`
186BLKS, ap_snes.h:528, 537) and paths round where they stand; the box opens
187only if one walks INTO Link. Upstream's video is in Kakariko four times
188(1220-1232 s, 1268-1275 s, 1321-1333 s, 1351-1367 s) and at 1354 s leaves
189the chicken house one tile from the old informant and steps round her -
190never touched. Ours was walked into (run `s1map`, frame 22,907: the young
191informant moves west into Link on a `GOTO_POINT`). The bot does try to close
192dialog - `ap_task_evaluate` mashes A in module `$0E` (ap_plan.c:357-366) -
193but the movement task then calls `ap_follow_targets`, which clears A on the
194same frame (ap_map.c:825): 11,648 "Mashing dialog" lines, then manual mode.
195
196Host-level fix, two `rando::PATCHES` rows: the `JSL ShowMessageOnContact`
197becomes `JSL CheckDamageToLink_same_layer_long` (`$06F129`), as for uncle -
198the same carry on contact, so he still calls the guards and runs - and the
199`TYA : STA $0DE0,X` after it goes, because the contact test leaves `$0E40,X`
200(`$82`) in Y where the message routine left a facing (bank_06.asm:
20106F16F-06F172). This removes a text box, not a behaviour. Measured: run
202`k2` (home remade from the patched ROM, `iter2`'s map - which is what
203`s1map` imported, not `iter3`'s as this page once said) matches `s1map`
204frame for frame through 22,000; at 22,907 the informant touches Link and a
205guard (`$45`) spawns in both runs; `s1map` sits in the box to manual mode,
206`k2` walks on and is out of Kakariko by 25,000 (`run/zbanks-c/k2/
207frame-023000.png`: informant, guard, no box).
208
209### Cheats
210
211`ap_tick` itself writes, every frame, full health, 10 bombs, 10 arrows, the
212hammer and the lamp (`alttp.c:20-26`). They are the bot's code and run
213unchanged; the host's only part is carrying the writes back into the
214console. Vanilla has no "infinite" setting; these writes are it.
215
216### No map file
217
218`ap_tick` imports `map.19.txt` on its first frame (`alttp.c:31-38`): a map
219of screens, nodes and tile attributes exported by an earlier run
220(`ap_map_export`, `ap_map.c:3471-3499`). It is not in the repository
221(upstream never committed it), and `ap_map_import` returns quietly when the
222file is absent (`ap_map.c:3502-3503`). Upstream's video starts with goals
223the bot could only know from that map (it heads straight for uncle's `NPC`
224goal on leaving the house); ours starts knowing nothing and explores.
225Nothing is substituted for it; as upstream did, a run's `map_export.txt`
226seeds the next (`--import-map`).
227
228### The starting state
229
230`ap_init` loads the Snes9x savestate "home", then "hpegs" (`ap_snes.c:66,
23181`). Neither exists here. The bot never presses anything outside the play
232modules - `ap_tick` zeroes the pad and returns for any other module
233(`alttp.c:64-99`) - so it cannot get itself from power-on to a game.
234`apps/zbanks --make-home home` makes the equivalent of upstream's "home":
235create file 1 from power-on the vanilla way, apply the open-mode preset to
236the save, boot again, start file 1 (choosing Link's house at the spawn
237select), and keep the machine once Link stands in his house and can move.
238"hpegs" stays absent, so that load fails as a missing file would in Snes9x.
239
240## The stairs: a regression in upstream's last commits (carried patch 0001)
241
242Why ours never got the sword, found 2026-09-21 afternoon. The bot did reach
243uncle's room (`$55`, the secret passage, by its east-side stairs, entrance
244`$32`) - and then climbed its intra-room staircase and walked straight back
245down, for thousands of frames, until the goal was failed for good. Traced
246frame by frame (`ZB_TRACE`, and gdb on the static target list):
247
248- the bot pathfinds a target every 8 pixels up the column, straight across
249  the stair tiles (attribute `$3D`, walkable, `ap_snes.h:264`);
250- stepping on the stairs starts `Module07_10_SouthIntraRoomStairs`, which
251  carries Link ~40 pixels in ~60 frames and keeps its own step in `$B0`
252  (usdasm bank_02.asm:2926-3008);
253- `ap_tick` returns early whenever `$B0 != 0` (alttp.c:78-82, "load in
254  progress"), so the stairs case two lines below it - "Stairs; keep
255  following targets" (alttp.c:85-89) - never runs;
256- at the top the next unconsumed target is back on the stairs (Link at
257  y `$0B8E`, target `$0BB0`; `ap_follow_targets` looks only one target
258  ahead, ap_map.c:750-763), so the bot presses DOWN, rides the stairs down,
259  presses UP, and so on. The same loop is the one seen earlier in cave
260  `$10A`.
261
262Upstream's video does not do this, and its own history says why: the video
263was recorded at commit 7add0e6 (2020-01-04), and the `$B0` early return
264arrived in b9f7eee (2020-02-02, "Can almost clear aghanim's tower"; `git log
265-S"sub_submodule_index != 0x00" -- alttp.c`). The video's info line agrees:
266through the climb it reads "Plan: NPC via GOTO_POINT (517)", never the
267"main: 0x7; sub: 0x10; subsub: 0x1" the early return writes. So bcb2537
268itself, on any host, walks back down; nothing in save, ROM or map can undo
269it. `third-party/c/patches/0001-follow-targets-on-intra-room-stairs.patch`
270exempts the two intra-room stair submodules (`$08`, `$10`, both stepping
271`$B0`: bank_02.asm:2851, 2916, 2947) from the early return, which is exactly
272what the stairs case after it was written for. Confirmed before patching by
273forcing one re-path at the top in gdb: the bot then walked to uncle.
274
275## Eastern Palace `$B8`: the big key room (carried patch 0003)
276
277Run `s2a` stood in room `$B8` from frame ~36,400 to its end at 50,000. Not a
278locked door: Link was pinned under the room's anti-fairy circle
279(`s2a/frame-040000.png`). What the room is (usdasm): the circle
280(`Sprite_82_AntifairyCircle`, bank_1E.asm:12749-12799, HP 255,
281bank_0D.asm:6184) orbits (0x180, 0x0D0), the very spot of the pot the floor
282switch is under (bank_01.asm:18537); the room's tag `$27` is "switch makes
283the chest appear" (ap_snes.h:1029), and that chest holds the Eastern
284Palace big key (ITEMGET `$32`, bank_01.asm:19720). The circle breaks up into
285roaming anti-fairies (sprite `$15`) only once `CheckIfRoomIsClear` passes -
286the Eyegore and Stalfos dead. So vanilla's big key needs the room cleared
287first.
288
289What upstream's bot does with it: `$82` is a plain `SPRITE_ATTR_ENMY`
290(ap_snes.h:611), which the path planner only costs softly (ap_map.c:
2911192-1201), so paths to the pot and to the east door run through the ring
292and Link is knocked back every attempt ("Link state: 0x2"; ~20 timeouts on
293`L:8380,16f0; 83c0,1680`); the tag `$27` handler walks to the switch
294(ap_plan.c:298-316) and never kills anything; and `SCRIPT_KILLALL` targets
295the first active sprite (ap_plan.c:659-667), which the ring's sprites are -
296it would swing at them forever.
297
298Why upstream never met it: it is the same room in the Randomizer, which
299moves the big key but not the room's sprites. Upstream's video (sampled at
3001 fps, 700-1030 s) opens the Eastern Palace big chest at ~816 s without
301ever entering `$B8`: its seed's big key was elsewhere. Ours is here, and
302`s2a` had already met the big chest without it at 33,000.
303
304Why a carried patch and not the host: there is no host difference to undo
305(same room, same sprites, same save), and the only thing a host could
306change is the room - deleting the circle or the enemies from the cartridge
307- which is changing the game rather than hosting it. The informant patch
308above removes a text box and keeps what he does; this would remove a
309puzzle. `0003-anti-fairy-circle.patch`, in upstream's own idiom:
310
3111. `$82` becomes `ENMY | NVUL | BLKF` (invulnerable, blocking), and `$15`,
312   the anti-fairy it breaks into, `ENMY | NVUL` - the game does not count
313   them when it decides a room is clear either.
3142. Kill-all skips `NVUL` sprites.
3153. A `SCRIPT_KILLALL` entry, "EP clear big key room", at (0x8380, 0x1730)
316   below the circle, in upstream's per-room script table (ap_map.c:36-),
317   beside its "HC kill guard for key".
3184. Kill-all no longer fails its goal on the first frame an enemy has no
319   path to it (A* fails for a Stalfos beside a pot): it tries the others,
320   then waits 8 frames and looks again, and fails after 32 such misses.
321   Without it run `b4` permafailed the new script within two frames
322   (three "ap_pathfind_sprite failed" at 37,045-37,046).
323
324Measured, Jev off, home remade from the patched ROM (`states3`), `s1map`'s
325map - the same start as `s2a`: `b1` (part 1 only) no longer pinned but the
326switch pot unreachable, so the chest goal stays unsatisfiable and the bot
327leaves; `b3` (1-3) clears the Eyegore and Stalfos, then swings at the
328dispersed `$15`s; `b5` (all four): script from 37,045, "Done killing" at
32940,155, pot `0x72` lifted and the switch found at 40,571, the big key chest
330opened at 40,702, the big key door north out of `$B8` at 41,241 and the
331one in the big chest room at 42,280 (`b5/frame-041000.png`: the chest open
332on the dais, anti-fairies roaming).
333
334## The Armos Knights: the bow shot cancelled (carried patch 0004)
335
336Run `j9` fought the Armos Knights (room `$C8`, upstream's own "EP Armos
337Knights Boss" kill-all, ap_map.c:152-158) from ~+16,500 to its end and
338killed one of six. Measured before changing anything, from `j9`'s last
339frame as `home` (`run/zbanks-c/states-armos`), `j9`'s map, Jev off: run
340`a0`, 12,000 frames, **not one arrow** in the log's ancilla dumps and five
341knights at HP 48 throughout; Link walked into them and was knocked back
342("Link state: 0x2"). Not sword level, not the bow missing (equipped at the
343fight's start, "SET_INVENTORY start item: BOW"), not knockback as such:
344
345- The knights are `SPRITE_ATTR_VBOW` (ap_snes.h:561), so kill-all fights
346  them with the bow: on each retarget frame it presses Y
347  (`JOYPAD_MASH(Y, 0x08)`, ap_plan.c) and suppresses the sword
348  (`no_sword`), then follows targets toward the knight.
349- `ap_follow_targets`'s last branch, when the next tile needs no lift, cut
350  or hammer, is `JOYPAD_CLEAR(A); JOYPAD_CLEAR(B); JOYPAD_CLEAR(Y);`
351  (ap_map.c:835-839). `ap_tick` zeroes the pad every frame (alttp.c:64), so
352  the only Y it can ever clear is one the caller set just before: the bow
353  shot, cancelled on the frame it was pressed.
354- That branch arrived in upstream's LAST commit, bcb2537 (2020-02-06,
355  "Hammer/sword/lift obstacles in path"; `git log -S'JOYPAD_CLEAR(Y);' --
356  ap_map.c`). Upstream killed the Armos Knights at 8f13023 (2020-01-02,
357  "Kill first boss: Armos Knights") and recorded its video at 7add0e6
358  (2020-01-04) - both with the same kill-all code and no such line. The same
359  story as the stairs: a regression in the last commits, which no host can
360  undo, because the Y is set and cleared inside one `ap_tick`.
361
362`third-party/c/patches/0004-bow-shot-survives-follow-targets.patch` drops
363the `JOYPAD_CLEAR(Y)` and nothing else. Same start, patched (run `a1`): 62
364arrow ancillae logged, all five knights dead and "Waiting for crystal to
365drop" at 3,000, the pendant held up at 3,500 (`run/zbanks-c/a1/
366frame-003500.png`), then out of the boss room to the dungeon's remaining
367chests.
368
369## Goals given up too early come back (host)
370
371Run `b5` tried the Eastern Palace big chest four times at ~33,000 - before
372the big key - and never again: at 40,702 it had the key, and at 51,780 it
373gave up the game (manual mode) in front of the red Eyegore room, which needs
374the bow the chest holds. From source (bcb2537):
375
376- `ap_goal_fail` (ap_plan.c:917-926) counts an attempt and past three
377  `LL_EXTRACT`s the goal - off the list for good, never freed. Nothing puts
378  one back: not item pickup, not requirement changes, not map import (which
379  only creates fresh goals, once per process). `git log -S` over upstream's
380  whole history finds no reset of `attempts` ever.
381- The bot does not know a big chest needs the big key: `chest_type = 1` is
382  set for big chests (ap_map.c:3233-3238) and never read; no big-key
383  requirement is put on `GOAL_CHEST`; `ap_node_islocked` checks the big key
384  only for key blocks and big-key doors. So the chest looks open, `OPEN_CHEST`
385  presses A, nothing arrives, the task times out, and after four the goal
386  is gone.
387- Upstream's TODO still lists "Reset goal attempts count when re-visiting
388  screens (maybe a bad idea?)" (added 488a6cf, 2019-12-01; "(maybe a bad
389  idea?)" appended in ceb3ac0, 2019-12-31; never done). There are no commits
390  after bcb2537 on any branch but the video commit. Its Randomizer run never
391  met this because that seed's big key was not in `$B8` (see "Eastern
392  Palace `$B8`"); its only "reset" was restarting the process.
393
394**Host fix, not a patch.** The shim watches the goal list after every tick
395(`zb_watch_goals`): a goal that has left it with `attempts > 3` was
396permafailed (a completed one leaves with at most three). When Link's
397possessions change - items, keys, big keys, pendants, crystals, progress
398(`zbanks::POSSESSIONS`), never rupees, health or arrows - every given-up goal
399is put back through the bot's own `ap_goal_add`, fresh, and the bot's log
400says so and why. Re-visiting screens (upstream's idea) is the wrong trigger:
401a goal that fails for a reason a visit does not change would be retried
402forever. A change in what Link has is the thing that can make a failed goal
403succeed, and it bounds the retries: four attempts per change. `--no-retry`
404is upstream's behaviour.
405
406Measured, Jev off, `b5`'s exact start (`states3` home, `s1map`'s map), with
407this and carried patch 0004 (run `r1`, 70,000 frames): the big chest is
408permafailed at 33,075 exactly as in `b5`; the next small key
409("`$7EF36F 00->01`") puts it back; big key 40,702; **big chest (the bow)
41042,146**; "EP Igor Key" 44,133; "EP Red Igor Room" 48,095 (where `b5` gave
411up); **Armos Knights 54,080, the pendant on the floor at 54,000**
412(`r1/frame-054000.png`); two more chests; still playing at 70,000.
413
414## Starting mid-game: goals the save says are done (host)
415
416Upstream always started from its "home", a fresh save, so every node its
417imported map recreated was still to do. We also start mid-game - the
418window's `resume`, a later headless run's `home` - with the map the bot built
419imported, and then an imported NPC can be done already. Run `o1` (from
420`r1`'s last frame, `r1`'s map, Jev off) walked back to uncle's spot in the
421secret passage for the NPC goal the import recreated, and died at frame
42221,758: uncle is gone once `$7EF3C6` bit 0 is set (`SpritePrep_Uncle`,
423usdasm bank_05.asm:16216-16240); `TALK_NPC` takes a nonzero `$02D8` - still
424the last item Link received - as uncle's gift (ap_plan.c:483-487); and the
425item tracker, which already has uncle's location down as the sword
426(ap_item.c:492-495), aborts on `assert(item_loc->item->type == item_type)`
427(ap_item.c:518). In the window that abort would close the window.
428
429Chests and pots the bot re-checks against the save itself (`sram_room_state`,
430the tile under a pot); an NPC it cannot. `ap_map.c`, where every goal is
431made, is compiled with `-Dap_goal_add=zb_goal_add` (`third-party/c/BUCK`,
432the same mechanism as `-Dfopen=zb_fopen`), and the host's `zb_goal_add`
433declines an NPC goal for uncle when `$7EF3C6` bit 0 is set, then calls the
434real `ap_goal_add`. Run `o2`, same start: "not adding goal for sprite
4350x73.0x100: done in this save", no abort, 83,580 frames.
436
437## Sahasrahla: the green pendant is bit `0x04` (carried patch 0005)
438
439`o2` then played 83,580 frames after the pendant without once going to
440Sahasrahla, and gave up. His NPC goal requires `REQUIREMENT_GREEN_PENDANT`
441(ap_plan.c:133-136), which the bot sets from `$7EF374 & 0x01`
442(ap_req.c:58). The Eastern Palace gave `$7EF374 = 0x04` (`o2/progress.tsv`,
443`pendants`). The layout is "- - - - - g b r" (z3randomizer sram.asm:139;
444jpdasm symbols_sram.asm:779-781): `0x01` is red, `0x04` green. Vanilla
445Sahasrahla tests `AND.b #$04` (usdasm bank_05.asm:20560-20562), and so does
446the Randomizer (dialog.asm:355) - upstream's `0x01` is wrong in both games,
447and its seed must simply have met it some other way. The only user of the
448requirement is that goal. No host fix: the bot reads the byte itself.
449`0005-green-pendant-is-bit-4.patch` tests `0x04`.
450
451## The boots: walking up to Sahasrahla, and talking until he gives (carried patches 0006-0008)
452
453With patch 0005 the goal was chosen (run `o3`, 55,233) and failed, and each
454fix exposed the next step of the same errand. Every one was isolated by
455replaying `o3` exactly (Jev off replays frame for frame) to Sahasrahla's
456door at 59,300 (`--save-at`) with the change gated on `ap_frame >= 59300` -
457a throwaway build (runs `o3c`), since an ungated pathing change moves the
458whole run from its first frame.
459
4601. **No path to him.** "A* failed, min heuristic: 2" with Sahasrahla standing
461   there (sprite 0, `0x16.0 BLKF|TALK|NODE`). `ap_pathfind_local` makes a
462   blocking sprite's hitbox plus one 8-pixel cell impassable (ap_map.c:1204-
463   1231); a sprite node's box is the sprite's own box
464   (`ap_map_add_sprite_nodes_to_screen`); the goal test needs a cell inside
465   it; the margin rings it. Unblocking only the box's cells was not enough
466   (the hitbox sticks out of the box). Fix: the sprite being walked to does
467   not block that search.
4682. **Never "there".** `XYLINKIN` wants Link's whole 16x16 box inside the
469   node box, and his body is solid: Link stopped at (6a78,2190), 8 pixels
470   short, "Stuck Link Detected", timeout. Fix: at a talking sprite, within 8
471   pixels is arrived - `TALK_NPC` presses up and A from there, as upstream
472   wrote it. (Upstream's own earlier answer, a node placed below the sprite,
473   is commented out at ap_map.c, from 072b195.)
4743. **A false gift.** `TALK_NPC` counted any nonzero `$02D8` as the NPC's
475   item. `$02D8` keeps the last item Link got (`0x17` here), so the talk
476   "succeeded" in one frame and left the text box it had opened with nothing
477   pressing A - the same stale read that made `o1` abort at uncle. And the
478   real gift is received while Link holds it up, when `ap_tick` does not
479   evaluate tasks at all (alttp.c:126-127); by the next
480   task frame `$02D8` is 0. Fix: a gift is a change in what Link has
481   (`$7EF340-$7EF35F`, less the bomb count) since the talk began.
4824. **The talk timed out mid-text.** Sahasrahla's text outlasts `TALK_NPC`'s
483   64 frames, and a goal failed mid-text left the box open. Fix: while a
484   text box is up, the talk does not time out.
4855. **Walled in afterwards.** Link ends pressed against him, inside his
486   margin, and every search from there failed: every goal unsatisfiable,
487   manual mode at once. Fix: a sprite whose margin Link already stands in
488   blocks only its own cells.
4896. **Pushing along his body.** The first step of each new path was a
490   4-pixel sidestep into his solid body (69 "Stuck Link"). Link can walk
491   away (checked with `ZB_HUMAN=100-160:0x0440`, X+down: y 0x2188 to 0x21d8
492   in 60 frames). Fix: when the talk is done, step down off the NPC for 20
493   frames.
4947. **A dash in a text box.** With the boots, the A that closes his last
495   text box starts a dash (`$5D = 0x11`, `kPlayerState_StartDash`, zelda3
496   player.h:21; the bot's enum calls `0x11` `FALLING_LEDGE`), `ap_tick` only
497   plans - and so only mashes dialog - on the ground, and the box stayed
498   open for good (`o3c/frame-065000.png`). Fix: an open text box gets A in
499   any state.
500
5011, 2 and 5 are `0006-walk-up-to-a-blocking-npc.patch`; 3, 4 and 6
502`0007-talk-to-an-npc-until-it-gives.patch`; 7
503`0008-text-box-gets-a-in-any-state.patch`. With all of them, gated (last
504`o3c`): the boots at ~60,500, out of the hut, exploring for 40,000 frames
505(areas `$2C`, `$34`, `$18`, `$1B`, the Eastern Palace again) until manual
506mode at 100,620.
507
508**What stops it next: the Book of Mudora.** It sits on the library's
509bookshelf and falls only to a dash into the shelf. The bot has no dash:
510nothing in it holds A to run; the boots appear only as a requirement on
511`TILE_ATTR_BONK` explore goals (ap_plan.c:142-143). The Desert Palace
512entrance needs the book (`door 0x09`, ap_plan.c:152-155), so the bot's goal
513system has nothing past this point but what it has already done.
514
515## The castle passage door: a second stairs-shaped regression (fixed 2026-09-21, patch 0006)
516
517Found 2026-09-21 night: every fresh `apps/zbanks` run from the canonical
518`home`/`hpegs` states froze at exactly `link_x,y = 0x0500,0x0277` in room
519`$0012`, the small connecting room right past the Hyrule Castle secret
520passage's door (`door D 0x8e`, screen `0x0250`). `GOTO_POINT`/`TRANSITION`
521toward that door repeated forever, position frozen, until the goal
522permafailed and the run gave up without the sword - the same shape as "The
523stairs" above: a place the bot reaches every time and never crosses.
524
525**Bisected, not reasoned about**, by rebuilding at each commit between the
526last known-good run (`j9`, 15:52-15:55, HEAD then was `8a32e63d`) and HEAD,
527and replaying the identical canonical `home`/`hpegs` (made once at 15:55 and
528untouched since) headless for 45,000 frames each time:
529
530| Commit | Result |
531| --- | --- |
532| `8a32e63d` (j9's HEAD) | No freeze; explores to Kakariko, gives up on an unrelated pot puzzle at frame 34,883 (pre-existing, not this bug). |
533| `ee2cf60a` (patch 0005 + the mid-game-uncle host fix) | Same as above, frame 36,212. Not the regression. |
534| `33246459` (patches 0006-0008, Sahasrahla's boots) | Frozen at `0x0500,0x0277`, room `$0012`, gives up at frame 32,359. |
535| `4ea2b00b` (HEAD) | Same freeze, same frame, 32,359. |
536
537`33246459` is a direct child of `ee2cf60a` - one commit, three patch files.
538Rebuilding with only `0006-walk-up-to-a-blocking-npc.patch` present (0007
539and 0008 removed) reproduced the exact same freeze; 0007/0008 are not
540involved.
541
542**The cause: patch 0006's `ap_map.c` hunk is scoped to a coordinate overlap,
543not to the sprite it was written for.** The hunk changes the loop in
544`ap_pathfind_local` that marks every `BLKF`/`BLKS` sprite's hitbox
545impassable - generic code, called for every pathfind toward every kind of
546destination (a door, a chest, a sprite). Its `destination`/`inside` guards
547were meant to stop a TALK sprite's own margin from blocking the search for
548*itself* (Sahasrahla, `0x16`), by comparing that sprite's hitbox against
549`dst_tl`/`dst_br`, the CURRENT call's destination region - but with no check
550that the sprite being tested is the thing the search is actually walking up
551to. Room `$0012` holds a `BLKF` sprite (type `0x73`, the same template type
552uncle uses in room `$55`, but not `TALK`-attributed here -
553`ap_snes.h`'s per-type override at `.type = 0x73, .subtype = 0x0100,
554.only_dungeon_room = 0x55` only adds `SPRITE_ATTR_TALK` inside uncle's own
555room) that happens to overlap the door's destination box for an ordinary
556`door D 0x8e` pathfind. Patch 0006 made that sprite transparent to the
557SEARCH for that pathfind; it stayed solid to the real engine, so the
558computed path walked Link into it and the real collision held him there
559forever - exactly what a search that no longer treats an obstacle as an
560obstacle produces.
561
562**Fix**: gate both `destination` and `inside` on `SPRITE_ATTR_TALK`
563(`ap_sprites[i].attrs & SPRITE_ATTR_TALK`), so the exemption only ever
564applies to a TALK sprite - the one case 0006 was written for. Sahasrahla
565(`0x16`) has `SPRITE_ATTR_TALK` unconditionally, so this is a no-op for the
566scenario 0006 fixes; room `$0012`'s non-TALK `0x73` (and any other
567non-talking blocking sprite that happens to sit near a destination) stays
568solid to the search, as the real engine has it.
569
570**Measured with the fix**, headless, canonical `home`/`hpegs`, `s1map`'s
571imported map (Jev off), 90,000 frames: no freeze at `0x0500,0x0277` (0
572occurrences, against hundreds before the fix); through the passage and past
573room `$0012` by frame 3,000 with the **sword**; **bow** (the Eastern Palace
574big chest) at frame 42,600; **green pendant** (`$7EF374 = 0x04`) at frame
57554,600; **Sahasrahla's boots** at frame 58,200 - all within the range of the
576best pre-regression runs (`j10`: sword 2,719, bow 43,714, pendant ~59,000,
577boots 62,499) - and still playing, sword+bow+pendant+boots all held, no
578manual mode, at frame 90,000 (fighting through a `SCRIPT_KILLALL` and a
579hole drop in dungeon room `$0123`), further than any pre-regression run got
580(`j10`/`v1` both gave up between 83,280 and 86,760).
581
582## The window's stall: not the Armos Knights, a real physical wedge in room `$0012` (2026-09-21 night)
583
584The live window sat in manual mode for over an hour, reported as "the
585Eastern Palace Armos Knights room" from its own `bot` MCP tool's task list
586ending in `SCRIPT_KILLALL ... EP Armos Knights Boss`. That task list is a
587STACK, not the current position: `read_wram` and `state` over MCP showed the
588true, live position - `link_x,y = 0x0500,0x0277`, room `$0012`, the small
589room just past the Hyrule Castle secret passage's own door (`door D 0x8e`,
590screen `0x0250`) - the Armos task was queued, unreached, twenty-some steps
591down a plan that never got past its first one. `$7EF374 = 0x04` (the green
592pendant, read live) proved Eastern Palace was already cleared long before
593this stall; the boss-room framing was a misreading of the task stack, not
594the ground truth.
595
596**Reproduced headless** from a copy of the window's own `resume.state`
597(made its own `home` in a scratch states directory): the SAME freeze,
598same position, at both `main` (patch 0006 fixed) and `ee2cf60a` (before
5990006 ever existed) - ruling out patch 0006 as the cause here, unlike the
600castle-passage regression above.
601
602**Proven a real physical dead end, not a search bug**, with `ZB_HUMAN`
603(needs `apps/zbanks` built from a commit that has it, `>= 33246459` -
604testing this at `ee2cf60a` first gave a false "nothing moves" result
605because that commit's `main.rs` does not read `ZB_HUMAN` at all and passes
606`0` to every tick regardless, `apps/zbanks/src/main.rs:151` on `main`
607vs. the hardcoded `0` at the same call on `ee2cf60a` - always rebuild from
608the commit under test): holding UP, DOWN, LEFT, RIGHT and A alone for 200
609frames each, raw pad, no bot in the loop, moved Link **zero pixels** in
610every case. A frame-by-frame trace (`ZB_TRACE`) during the A-alone test
611showed why: with the Pegasus Boots already owned (`$7EF355 = 1`), A with
612nothing to interact with starts an automatic dash charge
613(`link_state = 0x11`, `kPlayerState_StartDash` - see "The Book of Mudora"
614above), and the charge itself never resolves either - it sits in
615module `$0E` sub `$02` for 800+ more frames. Two independent room-relative
616sprite dumps of `$0012` (this session's and an earlier one from before any
617of tonight's patches) agree: Sprite 0 (`type=0x73, subtype=0x200, BLKS`)
618and Sprite 1 (`type=0x76, subtype=0x200, BLKF|FLLW` - Princess Zelda's own
619sprite type, `ap_snes.h:598`) sit within single-digit pixels of Link, and
620upstream's own `ap_snes.h` has a ROOM-SPECIFIC override baked in for this
621exact room - `{ .type = 0x73, .subtype = 0x0000/0x0100, .only_dungeon_room
622= 0x12, .attrs = SPRITE_ATTR_BLKF }` (`ap_snes.h:748-749`) - meaning
623upstream's own authors already knew room `$12` puts blocking sprites where
624a generic type `0x73` elsewhere would not be one. A live screenshot
625(`frame` MCP tool) shows why: a small vestibule, a carved idol centred at
626the top, and Link boxed between two flanking figures with no visible gap
627on either side.
628
629**Not a resume/goal-state bug and not fixable in the C bot's own logic**: a
630fresh, continuous walkthrough of this same room (this session's own
631`verify-0006-fix`/`verify-main-fix` runs, 20,000-90,000 frames) crosses it
632without incident every time - the wedge is a property of the EXACT pixel
633Link was standing on when something (a periodic `resume` save, or genuine
634natural play) left him there, not a property of the room itself. Since raw,
635un-bot-mediated input in every direction fails identically, no pathfinding
636or goal-retry change can reach a cell that plain movement cannot leave -
637this is a real ALTTP engine dead end, the kind a human player escapes by
638power-cycling the console.
639
640**Recovery**: `power_cycle` (dev-mode MCP tool; `packages/console`'s
641`hard_reset`, which reloads the cartridge's own SRAM, so items are not at
642risk) escapes the position but lands at the title screen, and this specific
643attempt could not navigate back through it - `Module14_Attract`
644(`~/src/github.com/snesrev/zelda3/src/attract.c:372-386`) only accepts
645Start/B during particular `attract_state` values (not 0, 2 or 6) and while
646`INIDISP_copy` is set, and mashing Start on a fixed cadence for several
647thousand frames never landed in a window where the demo yielded to input on
648this run - not chased further, since `load_state("home")` (a name already
649kept beside the ROM, and confirmed compatible with the current patched ROM
650CRC where two older named checkpoints, `has-sword`/`outside`, were not -
651"snapshot is of cartridge 777AAC2F, this is 173FFEC8", made before a later
652ROM-patch change) reaches an ordinary, controllable, playable state directly
653with no menu navigation at all. Cost: the session's sword/pendant/boots
654progress, traded for an unstuck, playing window - the alternative was
655leaving it in either the original wedge or a half-navigated demo loop.
656
657**A second, smaller bug found and fixed along the way**: `apps/native`'s own
658stall detector (`apps/native/src/stall.rs`) is correctly a one-way latch per
659its own doc comment ("a `restart` makes a fresh bot ... and should make a
660fresh `Detector` alongside it, so a stall from the last process never
661survives into the next one's frame 0") - but nothing enforced the other
662half of that sentence. `check_stall` only writes or removes `stalled.json`
663**on a transition** (`app.stalled == was` is the whole guard); a fresh
664process whose own `Detector` starts at `None` and stays there all its life
665never transitions, so a `stalled.json` left on disk by a PREVIOUS process
666(this session's own restarts included) is never touched and reads as a live
667stall indefinitely. Fixed (`apps/native/src/app.rs`, right after
668`bot_dir` is created, before `zbanks::Bot::start`): remove any
669`stalled.json` unconditionally on every startup, since a fresh Detector's
670ground truth is always "not stalled" at frame 0 and the file should say so
671immediately rather than carry forward a description of a process that no
672longer exists.
673
674## The Book of Mudora (carried patch 0009, mechanism only - not yet wired)
675
676Confirmed there is no dash anywhere to steal: `git log -S dash|Mudora|bookshelf`
677over upstream's whole history (`8192fb7`..`bcb2537`) finds `BookOfMudora`,
678`BONK`/`TILE_ATTR_BONK` and the dash comment in `ap_snes.h` unchanged since
679they were first written, and no code that ever presses A to run. Sprite `$3B`
680("BonkItem", which the book, a hidden key and a fake tree all share via a
681sub-jump on `$0DC0,X`) has `attrs = 0` in `ap_snes.h:535` - invisible to the
682map/goal system entirely, not merely ungated.
683
684The game's own mechanism, from usdasm and zelda3 (snesrev's C port of the
685disassembly, `src/player.c`): pressing A with the boots equipped and nothing
686else to interact with always attempts a dash (`Link_HandleLiftables`,
687`kAbilityBitmasks[2] = 4`, the boots bit) - not a location-specific move.
688`Link_PerformDash` sets `kPlayerState_StartDash`; `LinkState_Dashing` then
689requires **A held on every frame of a ~29-frame stationary charge**
690(`if (!(joypad1L_last & kJoypadL_A)) { ...cancel... }`, checked while
691`link_countdown_for_dash` counts down) - a single dropped frame cancels it.
692Once the charge ends Link moves under his own power at dash speed and no
693longer needs A held; only a NEW, different direction stops him
694(`want_stop_dash`), which is why `0009`'s dash-hold script characters keep
695holding A anyway for the whole approach - harmless, and it removes any timing
696window at the charge/travel boundary. `BookOfMudora_WaitForBonk`
697(bank_05.asm:22825) then needs Link's position within a small anticipatory
698box of the sprite AND nonzero velocity (`$011A`/`$011C`) - i.e. genuinely
699dashing, not walking - before the shelf's own state machine (`WaitForBonk` →
700`KnockedDown` → `Land` → `GrantLiterature`, which calls `CancelDash_long`)
701runs; walking collision stops Link outside contact range of the item's own
702hitbox, so a plain walk can never even trigger it.
703
704Why the C bot could never do this from outside: `ap_follow_targets`'s own
705per-tile logic (`ap_map.c`, the branch that lifts pots and swings the hammer)
706ends in an unconditional `else { JOYPAD_CLEAR(A); ...}` whenever the tile
707ahead needs no lift/sword/hammer - which is every frame of a dash across open
708floor - so even a host that set A itself would have it erased the same tick.
709`0009-pegasus-boots-dash.patch` adds four `SCRIPT_SEQUENCE` characters (`8`
710`2` `4` `6`, numpad directions, chosen not to collide with the existing
711`<>^vABYUD` letters) that hold A together with a direction in ONE target
712(repeat the character to cover more distance; each target only pops once
713Link's real position reaches it, so it keeps re-asserting both bits for
714however many frames that takes - the charge, then the travel), and narrows
715the `JOYPAD_CLEAR(A)` to skip only when the *current* target holds A AND a
716direction together - true only for these new characters, never for the
717existing `'A'`/`'B'`/`'Y'` taps (which set only their own button), so no
718existing script's behaviour changes. Built, applies, and a 5,000-frame
719replay from `states3`+`s1map` (sword by 3,000, well/uncle/HC by 5,000) is
720unchanged from the pre-0009 shape.
721
722**Wired (carried patch `0010-book-of-mudora-goal.patch`), coordinate DERIVED
723from ROM data, not yet live-walked.** An `ap_scripts[]` entry now exists
724(`ap_map.c`) and `ap_goal_add` (`ap_plan.c`) requires `REQUIREMENT_BOOTS` for
725it by name, the same pattern as Sahasrahla's pendant check. Proven headless
726(`run/zbanks-c/` scratch runs, not kept - reproduce with `--import-map` from
727any map that has tagged the Library, e.g. `j10`'s `map_export.txt`): the
728script's node attaches to the right screen (`ap_map.c:ap_screen_add_raw_node`
729logs "attached node Script: Library Book of Mudora") and `goals.txt` shows it
730`unsat Needs: [BOOTS]` from a boots-less `home`, and `limit` (in range, not
731unsatisfiable - i.e. the requirement is satisfied and it is only a search-cost
732cutoff) from a boots-having save. The requirement gate is real; the
733IN-ROOM coordinates it dashes to are not yet proven against the real room.
734
735- The screen carrying the front door is already named in the bot's own
736  `ap_screen_infos[]` (`ap_map.c`): `{ .id = 0x0a02, .name = "Library Yard",
737  .add_explore_goals = true }` outdoors, `{ .id = 0x2178, .name = "Library"
738  }` for the interior (`ap_map.c`'s screen ids are `(y>>8)<<8 | (x>>8)`,
739  256px quadrants). Entrance table entry `0x49` (`~/src/github.com/JaredBrian/AsarUSALTTPDisassembly`
740  `Bank02.asm:11975` `Dungeon_LoadEntrance`, indexed at `Bank02.asm:11525`
741  `.rooms`/`Bank02.asm:11712` `.playerY`/`Bank02.asm:11731` `.playerX`, pinned
742  `e41fef7`) gives room `$0107`, `playerY=$21D8`, `playerX=$0E78` - stored
743  straight into `$20`/`$22` by that routine, matching `SpritePrep_DashItem`'s
744  own hardcoded library check (`room == 0x07, sub == 0x01`, same repo
745  `Sprites/sprite_prep.asm:1394,1398`, mirrored at
746  `walkingeyerobot/alttp-disassembly` `sprite_prep.asm:1394`).
747- **The indoor address space widens X but not Y.** `ap_snes.c:ap_sprites_update`
748  applies `x = ((x & ~0x1FF) << 2) | (x & 0x1FF); x += 0x4000` to a sprite's
749  hitbox X when `*ap_ram.in_building`, and leaves Y untouched. Applied to the
750  entrance's raw `playerX=$0E78`: `(0x0E78 & 0xFE00)<<2 | (0x0E78 & 0x1FF) =
751  0x3878`, `+0x4000 = 0x7878` - which lands inside the screen's own measured
752  tags (`Screen tags: … Library v 7800,2100 x 78ff,21ff`, logged by
753  `ap_map_add_constants_to_screen` in every run that ever referenced the
754  door, e.g. `run/zbanks-c/b3/bot.log:4718`). `playerY=$21D8` needs no
755  transform and is already inside the same tags' Y range. This is the
756  cross-check the earlier attempt at this (see history below) lacked.
757- **The sprite's own room-relative bytes, decoded with a control group.**
758  `RoomData_Sprites_Room0107` (`Bank09.asm:8050`): `db $15, $03, $3B` for the
759  book, and two decorative `$6D`s at `db $1B, $17, $6D` / `db $1B, $18, $6D`.
760  The disassembler's own comments read `xy: { 0x030, 0x150 }` for the book
761  and `{ 0x170, 0x1B0 }` / `{ 0x180, 0x1B0 }` for the decorations - and
762  `byte1*16` (`$03*16=0x030`, `$17*16=0x170`, `$18*16=0x180`) matches the
763  FIRST ("x") number both times, `byte0*16` (`$15*16=0x150`, `$1B*16=0x1B0`
764  twice) matches the second ("y") both times. So the book's room-relative
765  position is **X=`0x030` (48), Y=`0x150` (336)** - the two decorations
766  share a Y (`0x1B0`) and sit 16px apart in X (`0x170`/`0x180`), which is
767  exactly what two objects side by side on the same wall would look like,
768  and is why a single sprite's own bytes were not trusted alone.
769- **Room origin, and the book's absolute position.** Local offsets under
770  `0x200` pass through the X-widening formula unchanged (it preserves
771  `x & 0x1FF` verbatim), so an offset from the room's local origin maps
772  1:1 onto an offset from the room's WIDE origin. Taking `$7800,$2100` (the
773  clean quadrant boundary the screen tags name) as that origin: book
774  absolute = `(0x7800+0x030, 0x2100+0x150)` = **`(0x7830, 0x2250)`**. The
775  entrance spawn (`0x7878,0x21D8`) is `0x48` (72px) EAST and `0x78` (120px)
776  NORTH of the book - not the "shelf on the wall opposite the door" a
777  reflex north-dash-from-the-doorway would assume. `start_tl` is set to the
778  entrance's own Y but the book's X (`0x7830,0x21D8`), which ordinary
779  `GOTO_POINT` pathing (no dash) can reach from the door before the script's
780  ten `2` (dash-south) characters take the ~120px remaining, with margin for
781  the ~29-frame charge and the fact the box `WaitForBonk` checks is small
782  and anticipatory rather than pixel-exact.
783
784**Why this was not live-walked this session, and what actually blocked it.**
785Getting Link physically into the room needs boots (for the gate to matter at
786all) and a walk there, and every fresh `apps/zbanks --states
787"roms/….states"` run tried this session (no Jev, canonical `home`/`hpegs`,
788several independent attempts) **never got the sword**: it explores the
789reachable light-world screens that need no items (70+ EXPLORE goals
790completed, per `goals.txt`'s own `Stats:` line) and then gives up, because
791the one NPC goal ever created in any of these runs is not uncle's - the
792route down through the Hyrule Castle secret passage (`door D 0x8e`, screen
793`0x0250`, landing in room `$0012`) is walked to (position frozen exactly
794there, `link_x,y = 0x0500,0x0277`) but the transition never completes,
795across three-figure frame counts and multiple independent fresh attempts.
796`run/zbanks-c/j9` (this same day, **15:52-15:55**, i.e. before carried patch
797`0009` at **19:09**) got the sword and reached the Armos Knights from a
798`home`/`hpegs` that has not changed since (`home.state`'s own mtime,
79915:55:43, matches `home-before-informant-patch.state` exactly). The
800correlation with `0009`'s timing does not hold up under reading the code,
801though: `ap_follow_targets`'s new guard only changes behaviour when the
802CURRENT target's `joypad_mask` has both `A` and a direction bit set
803together, and every ordinary `GOTO_POINT`/`TRANSITION` target (as opposed to
804a script's) is built with `joypad_mask = 0` (`ap_map.c`, e.g. line 681,
805734, 1442) - so `0009` is provably a no-op on this path, and what actually
806regressed (if anything did, rather than this always being an occasional
807snag on this one transition) is not identified. The `ap_goal_fail`
808`assert_bp` (`ap_plan.c:968`, `ap_tick`'s own anchor + offset `0x147cc`,
809confirmed with `addr2line`) that both this stall and the LIVE WINDOW's
810separate, later Armos-fight stall share is the GENERIC one-per-codebase
811"a goal failed" tracepoint, not evidence the two are the same bug.
812**Next step for whoever picks this up:** `apps/zbanks` from a fresh `home`
813with `ZB_TRACE` bracketing the approach to `door D 0x8e` (screen `0x0250`,
814room `$0012`) - a frame-by-frame trace the way carried patches `0001`/`0004`
815were each found, since "reaches the same standing spot every time and then
816never crosses it" is exactly their shape. Once past it (or from any
817boots-having save that already is), a plain headless run with the map above
818imported should walk to `start_tl` on its own; whether the ten-`2` sequence
819actually lands the item is the one thing left to prove, by reading
820`$7EF34E` (the book byte) after.
821
822## How far it plays (measured 2026-09-21)
823
824Headless, `apps/zbanks`, from the open-mode `home` state (runs under
825`run/zbanks-c/`, gitignored):
826
827| Run | Host as of | Frames | What happened |
828| --- | --- | --- | --- |
829| `bed` | no ROM remap | 0 | Died on the chest-table assert, `ap_map.c:3201` - the Japanese-address finding. |
830| `r1` | remap, vanilla rain start | 30,000 | Out of the house, then stood in a hint soldier's text box for the rest of the run. |
831| `r2`/`r3` | + open mode | 30,000 | 12 places; the Dam's block-puzzle script solved; a heart piece's text held it from frame 12,660 until it gave up (manual mode) at 17,237. |
832| `r4` | + no item text | 30,000 | 24 places, reaching the castle yard and graveyard; still exploring at the end. |
833| `iter1` | same | 44,580 | 36 places, 11/19 chests, 9 pots, 3/3 scripts, a heart container; fell into a hole into room `$2F` and gave up at 40,980. |
834| `iter2`-`iter4` | + each imports the last one's map | up to 60,000 | The map grows run on run (explore goals known 155 → 200 → 287 → 367) and each run heads somewhere new (Kakariko, where the informant's "Here is G, the wanted man!" text held it - see "The Kakariko informant" above). |
835
836The window (`apps/native`), started from `home` with `iter3`'s map as its
837`map.19.txt`, walked to the Eastern Palace and in (room `$C9`) within about
838three and a half minutes: `run/window/zbanks-1.png` (the ruins, frame 8,648),
839`run/window/zbanks-2.png` (inside, frame 12,886), `run/window/zbanks-3.png`
840(the Stalfos room, running its `SCRIPT_KILLALL` with no sword, frame 17,372).
841
842What it never did in these runs: reach uncle for the sword - see "The
843stairs" above. With carried patch 0001 and the uncle-message patch
844(home remade from the re-patched ROM, `run/zbanks-c/states2`):
845
846| Run | Map imported | Frames | What happened |
847| --- | --- | --- | --- |
848| `s1map` | `iter2`'s | 40,000 | Sword from uncle by frame 12,000 (room `$55`); then Kakariko, where the informant's text holds it (manual mode). |
849| `s1fresh` | none | 40,000 | Explores caves and houses; never near the castle in this run. |
850| `s2a` | `s1map`'s | 50,000 | Sword by frame 5,000; the castle's key guard killed (`SCRIPT_KILLALL`, 10,526, 11,183); Eastern Palace at 27,000; the Stalfos room (`$A8`) cleared - "Done killing" at 31,448, its trap door taken at 31,774 (`run/zbanks-c/s2a/frame-031000.png`, `frame-032000.png`); then pinned under the anti-fairy circle in room `$B8` to the end (see "Eastern Palace `$B8`"). |
851| `s2b` | `iter4`'s | 50,000 | Sword by 14,000; Dam puzzle and castle key guard; into Eastern Palace by 41,000. |
852
853The bot's own behaviour, run unchanged, that these runs meet: a text box it
854did not open holds it until its goals time out (`ap_follow_targets` clears
855A); it can loop on a pair of intra-room stairs for thousands of frames (cave
856`$10A`, frames ~5,700-12,000: one frame of `DOWN` at the top re-enters the
857stairs, one frame of `UP` at the bottom does the same); `assert_bp` fires
858tens of times a run, always at the same site (`ap_tick` + 0x146bc); and when
859every goal is unsatisfiable it sets `ap_manual_mode` and stops for good
860(`ap_plan.c:915-918`). Upstream's TODO lists stairs, and its video ran from a
861map built over many runs.
862
863### With both blockers gone (measured 2026-09-21, evening)
864
865All from home remade from the patched ROM (`run/zbanks-c/states3`):
866
867| Run | Jev | Map | Frames | What happened |
868| --- | --- | --- | --- | --- |
869| `b5` | off | `s1map`'s | 70,000 | As `s2a` to 36,400, then through `$B8`: big key 40,702, the big key doors 41,241 and 42,280, the Eyegore key room ("EP Igor Key") 44,092, on to the red Eyegore room (`$D8`) - which needs the bow, and the big chest holding it had been permafailed at 33,075, tried four times BEFORE the big key. Manual mode 51,780. |
870| `j4` | on | `iter2`'s | 40,000 | The informant touches Link at 18,583 (a guard spawns), no box; the Kakariko well at 20,422, Blind's house puzzle 24,568. 22 questions, $0.000505. |
871| `j9` | on | `p1`'s | 36,000 | Started as `home` = `p1`'s frame 34,000 (`b5`'s road, Jev off, in the Eastern Palace). `$B8` cleared at +5,156, big key +5,633, **big chest (the bow) +6,890** - its goal fresh from the imported map, not yet permafailed - Eyegore key +9,687, the Armos Knights (`$C8`) from ~+16,500 to the end: one of the six killed ("EP Armos Knights Boss" is upstream's own kill-all script), the rest at full HP 48. No pendant. 25 questions, $0.000608. |
872| `j2`, `j7` | on | `s2a`'s, `s1map`'s | 108,000, 72,000 | Jev's picks send the bot round the overworld and Kakariko's houses first; neither reaches `$B8`. |
873
874### With patches 0004-0008 and the host's retries (measured 2026-09-21, night)
875
876Same start as `b5` (`states3` home, `s1map`'s map), 200,000 frames:
877
878| Run | Jev | What happened |
879| --- | --- | --- |
880| `v1` | off | Bow 45,000 (the retried big chest), pendant by 59,000, boots by 63,000; manual mode 86,760 - the book is next and needs a dash the bot does not have. |
881| `j10` | on, run limit $0.05 | Sword 2,719; big chest (bow) 43,714; Armos Knights 58,209; pendant by 59,000; Sahasrahla's boots 62,499; manual mode 83,280. 124 choices, 70 questions, 48 reused, 6 not asked, 0 throttled, 0 fallbacks; 577 input tokens a question; $0.001696 over 24.13 game-minutes: **2.90 questions per game-minute, $0.000070 per game-minute**. |
882
883From the window's own last state (its `resume` of 18:22, the old build having
884given up at frame 80,157 with no goal since 79,548), run `w3` on this build:
885goals chosen again from frame 0 (the big chest, four times - no big key in
886that save - then pots and "EP Igor Key"), out of room `$A9` by ~1,100, then
887a ledge drop into a shutter room with two Stalfos and every goal
888unsatisfiable at 1,431. That save's route is its own trap; the window was
889relaunched from `home` instead (see the register).
890
891Two things these runs found that are not blockers of the game but of the
892harness: the ledger's 30-questions-a-minute breaker is wall-clock, and two
893headless runs at ~5x real time plus the window tripped it (15:32:05; every
894later choice fell back until `paused.json` was cleared) - run one Jev
895headless run at a time; and in `j3` Jev kept picking a goal in cave `$123`
896for 17,000 frames until the bot gave up.
897
898## Jev at the goal choice (carried patch 0002, `packages/zbanks-jev`)
899
900The bot's one real decision is `ap_goal_evaluate` (ap_plan.c:887-925):
901score every goal (`ap_goal_score`, ap_plan.c:735-841 - path cost, +100 per
902failed attempt, +10,000 for anything but an NPC while swordless) and pursue
903the lowest. `third-party/c/patches/0002-goal-choice-hook.patch` adds
904`ap_goal_choose_hook`, called with upstream's pick, its score and
905`ap_goal_score`; NULL, the default, is upstream exactly. It cannot be done
906from the shim: the choice sits between two statements of a `static`
907function. The shim's hook (`zb_set_goal_chooser`) rescores the satisfiable
908goals with the limit `min + margin`, keeps at most six (upstream's pick
909first, then cheapest), and hands them to the host only when there are at
910least two. `zbanks-jev` says each as a sentence, asks one Choice, samples
911the pick per the digest's `_sample_goal` (floor 0.05, T 2.0), reuses a
912remembered answer for the same option set within 1,800 frames, and on any
913failure returns upstream's pick.
914
915Measured 2026-09-21, headless, home from `states2`, map from `s1map`:
916
917| Run | Jev | Frames | Result |
918| --- | --- | --- | --- |
919| `off1` | off (hook compiled in, NULL) | 12,000 | `progress.tsv` identical, line for line, to `s2a` (built before the patch). |
920| `jev1` | on, margin 512, run limit $0.10 | 36,000 (10 game-minutes) | 31 choices, 31 questions, 0 reused, 0 fallbacks, 9 picks differing from upstream's; $0.000707 in all: **3.1 questions per game-minute, $0.000071 per game-minute**; ~150 ms a question (111-306). Still progresses: sword by 5,000, castle key guard by 11,000, Eastern Palace by 29,000, the Stalfos room's `SCRIPT_KILLALL` at 32,000. |
921
922Jev's answers mostly agree with upstream's nearest-first (median top
923probability ~0.9 on the first option), and it prefers the unvisited: at
924the castle key room it put 0.29 on "go through the south door ... to
925somewhere never visited" against 0.50 for the scripted key guard, and the
926flattened sample took the door. Log: `run/zbanks-c/jev1/jev.jsonl`.
927
928### Options Jev can tell apart (2026-09-21, evening)
929
930The window asked (frame 8,912, `run/window/jev-5.png`): "Lift the pot right
931here on this screen to see what is under it. It is about 62 steps away." and
932the same sentence with "(another one, number 2)" - 8 of the window's 73
933choices had such a pair. The user had already objected to that ("use door
934use door use door"). Options now come from more of the bot's own records
935(`zb_goal_option` in the shim: the node's centre, its screen's bounds, and
936the path `ap_goal_score` just found, read from `pgsearch.from` straight
937after each score call): which third of its screen; on Link's screen, how
938many steps east/west and north/south of him; elsewhere, the compass point,
939the exit the bot's path leaves Link's screen by and the screens it crosses;
940whether it lies on another option's path; the exit's kind from the bot's
941node name (dungeon door, entrance, screen edge, stairs, hole, ledge); the
942item lying (small key, big key). Goals that read alike on the same screen at
943a similar distance (within 8 steps or a fifth) are ONE option
944(`words::offers`); if that leaves one, nothing is asked. That pot pair, at
945the same spot in run `w2` (frame 8,805): "Lift the pot in the middle of this
946screen to see what is under it, 6 steps east and 8 steps south of Link. It
947is about 62 steps away." - one option, not asked.
948
949Input tokens per question: 534 in the window before (55 questions, from
950their cost at $0.042/MTok), 543 in `jev1`; after, 544 in `w2` (8 questions)
951and 549 in `j2` (47 questions, 501-708). Per question the longer sentences
952and the collapsed options about cancel; per choice fewer are asked (`j2`:
95373 choices, 47 asked, 18 reused, 8 not asked as one choice; 0 questions
954with two equal options).
955
956## The door `D 0x80` stall in room `$0123`: pinned against a TALK sprite (2026-09-21 night, characterized; fixed 2026-09-22, carried patch 0011)
957
958The live window's real current blocker (RESUME HERE's item 1, brain
959`jev-plays-snes.md`): `GOTO_POINT` to door `D 0x80`, screen `0x2458`, failed
960five times, ~127 frames apart, and the planner gave up. Reproduced headless
961from the window's own `resume.state` (copied to a scratch `home.state`, its
962own `map_export.txt` as `--import-map`, Jev off): the SAME position, `link
9630x5a88,0x2447` in the bot's coordinates (raw WRAM `0x0688,0x243f`), frozen
964from frame ~409 (the plan is set) through the end of a 6,000-frame run - not
965one pixel of movement, `given up: 9 waiting` (nine *different* goals burned
966through their own four attempts in that time, all funnelled through this
967same door, since a fresh headless process rebuilds every goal from the
968imported map with `attempts` reset to 0 - it does not inherit the live
969process's own exhausted goal list, so reaching true global `ap_manual_mode`
970this way would take far longer than reproducing the STALL itself).
971
972**The mechanism, not just the symptom.** `ZB_TRACE=400-450` on this same
973reproduction: the pad is `0x0400` (Down) on every single frame, and
974`link_state` stays `0x00` (standing, not even a walking animation) while
975`x,y` never change at all - the engine is refusing the move outright, not
976merely running out of time to complete it. Geometrically: sprite 4 in the
977room is `type=0xbb subtype=0x200 state=0x9 attrs=TALK|NODE`, hitbox
978`(5a78,2448) x (5a98,2470)` (`ap_snes.h`'s own per-room override making
979`0xbb`, normally a shop/vendor type, a plain `NODE|TALK` only in room
980`$123`). Link's frozen position, `(5a88,2447)`, sits one pixel NORTH of the
981hitbox's own top edge (`2447` vs `2448`) and inside its X range - pressed
982flush against it. The door's own node box, `(5a78,24c0) x (5a87,24e7)`, is
983128 pixels SOUTH of the sprite's bottom edge (`24c0` vs `2470`): to reach it
984Link must go around the sprite, not through it, and `ap_follow_targets`'s
985"Stuck Link Detected! 5a88,2447 en route to 5a78,24c0" names that far
986target directly, not an intermediate waypoint that would step him around
987it first.
988
989**Not a full physical wedge, `ZB_HUMAN` confirms - but it is not a free
990room either.** From the same reproduction, four 200-frame raw-input probes
991(`X` held throughout so the bot steps aside, `alttp.c:57`), each restarted
992fresh from the identical `home.state`:
993
994| Direction (+X) | Pad | Movement in 200 frames |
995| --- | --- | --- |
996| Up | `0x0840` | 1 px |
997| Down | `0x0440` | 1 px |
998| Left | `0x0240` | 19 px (west, toward/along the sprite's own left edge, `5a88`→`~5a75`) |
999| Right | `0x0140` | 4 px |
1000
1001South (the door's own direction) is blocked at the first pixel, matching the
1002bot's own trace; west is the only direction with any real headroom, and even
1003that stops well short of open ground - consistent with a small interior
1004(the sprite's own room is a shop-shaped space, per its `NODE|TALK` shop-type
1005sprite) where the walkable margin around the sprite is narrow rather than
1006absent.
1007
1008**Why this is not the castle-passage-door regression again, and not a quick
1009third fix in the same family.** `$BB` here genuinely IS `SPRITE_ATTR_TALK`
1010(unlike room `$0012`'s non-TALK `0x73`), so carried patch `0006`'s TALK gate
1011is not misfiring in the way it was there - this is a different failure
1012inside the CORRECT branch of that patch, not a case wrongly taking it.
1013Patch `0006`'s own `inside` exemption (`third-party/c/patches/0006-...patch`,
1014`ap_map.c`) only ever unblocks the ONE-CELL MARGIN ring immediately around a
1015sprite's raw hitbox when Link already stands in it - it shrinks the blocked
1016region from `[hb_tl, hb_br]` (hitbox + margin) to the raw hitbox alone, a
1017change of at most a few pixels at the sprite's own edge. The door is 128
1018pixels further south than that ring reaches, so the exemption cannot be
1019what is routing (or failing to route) Link there; A* found SOME 18-waypoint
1020path at all (unlike Sahasrahla's original "A* failed" case, `0006`'s reason
1021for existing), so the search can start from inside the margin - the failure
1022is in what that path asks `ap_follow_targets` to do afterward, not in
1023whether a path exists in principle.
1024
1025**Left unfixed, and why:** turning this into a general "route around a
1026solid sprite mid-path, not just avoid it as a destination or an origin"
1027fix touches the same pathfinding code that has already produced one
1028regression this session (the castle passage door, `0006`'s own `destination`
1029exemption catching an unrelated sprite) from a narrower, better-understood
1030change than this would need. Confirming a fix here would mean tracing
1031`ap_pathfind_local`'s actual grid construction for this specific room
1032against the disassembly's tile-attribute data, at the same rigor as patches
1033`0001`/`0003`/`0004`/`0006` each took - not an afternoon next to the
1034recovery mechanism this session was mainly about. Recovery itself does not
1035depend on this being fixed: retried unconditionally, this exact goal will
1036keep failing exactly this way (it is a deterministic replay from a fixed
1037position with Jev off), which is precisely what the recovery cap is for -
1038see `apps/native/src/app.rs`, `packages/zbanks/src/recovery.rs`.
1039
1040**Next step for whoever picks this up:** read `ap_pathfind_local`'s handling
1041of the cells between the sprite's margin and the door's own node box in room
1042`$123`'s tile-attribute grid (`ap_map.c`, the same table `alttp-ram-map.md`
1043documents the format of), specifically whether a WEST-then-SOUTH route
1044exists in the grid at all before touching how `ap_follow_targets` executes
1045whatever path is found - the geometry above says a route around the west
1046side is where to look first.
1047
1048### Fixed (2026-09-22): the sprite was never blocking to A* at all
1049
1050The "next step" above assumed A*'s obstacle model of sprite `$BB` was
1051correct and the bug was in how `ap_follow_targets` executed the path (or in
1052the grid's own construction). Neither was true. **Sprite `$BB`'s per-room
1053override in room `$0123` never marked it `SPRITE_ATTR_BLKF` or `BLKS` in the
1054first place** (`ap_snes.h`: `{ .type = 0xBB, .subtype = 0x0200,
1055.only_dungeon_room = 0x123, .attrs = SPRITE_ATTR_NODE | SPRITE_ATTR_TALK }`)
1056- only `NODE | TALK`. `ap_pathfind_local`'s obstacle-cost loop only marks a
1057sprite's hitbox impassable when `attrs & (SPRITE_ATTR_BLKF | SPRITE_ATTR_BLKS)`
1058(`ap_map.c`, the loop patch `0006` also touches); without either flag, this
1059sprite is invisible to that loop entirely, so A* never saw an obstacle here
1060and happily built an 18-waypoint path straight through the sprite's real
1061hitbox - a path the game's own collision then refused outright, which is
1062exactly why raw `ZB_HUMAN` input confirmed Down blocked at the very first
1063pixel while the A* grid (dumped from `local_cost.pgm`, decoded to ASCII) showed
1064no blocked cell anywhere near Link's position at all. Patch `0006`'s own
1065`destination`/`inside` exemptions were correctly scoped all along (as the
1066characterization above found) - they were simply never reached, because the
1067sprite they gate never entered the blocking code path to begin with.
1068
1069The general type entry for `0xBB` (`ap_snes.h`, above the per-room table)
1070DOES carry `SPRITE_ATTR_BLKF` alongside `SUBT` and `TALK`. Every other
1071per-room `TALK|NODE` override in the same table keeps or drops BLKF
1072correctly for what the sprite actually is: Sahasrahla's (`0x16`) keeps it (he
1073is solid); uncle's (`0x73`/`0x55`) correctly has none (he lies on the
1074ground, not solid). Room `$0123`'s override for `0xBB` is the one entry that
1075adds `NODE` (so the bot can build a TALK node for this NPC, the "Mini
1076Moldorm Cave Guy") while silently dropping the BLKF the general entry has -
1077an upstream authoring slip in a static table, not a Randomizer difference
1078and not anything a host can see around. `third-party/c/patches/0011-shopkeeper-blocks-in-room-0123.patch`
1079restores it.
1080
1081**Proof.** Reproduced headless exactly as before (the live window's own
1082`resume.state` as `home`, its `map_export.txt` imported, Jev off): before the
1083patch, frozen at `link 0x5a88,0x2447` pressing Down every frame, replanning
1084an identical 18-waypoint path every ~127-133 frames forever (`run/zbanks-c`
1085scratch, not kept). With the patch, the same start reaches the door and keeps
1086playing - by frame 600 Link has already left room `$0123` through door `D
10870x8e`; by frame 12,000 (in a longer run from the same start) he is back at
1088the shopkeeper doing `TALK_NPC` normally, no freeze (that specific NPC
1089cannot actually be satisfied - see "The shopkeeper cannot be paid" below,
1090a separate, newly-exposed limitation, not a regression from this patch).
1091
1092**Regression, a full 90,000-frame run from a freshly-made `home`** (current
1093patched ROM, `--make-home`, since the ROM's cumulative patches make any
1094older saved `home` untrustworthy - checked by cartridge-CRC refusal as the
1095safeguard), Jev off, `run/zbanks-c/s1map`'s own historical map imported (the
1096same map earlier regression proofs on this page use, and one that does not
1097yet know about the newly-reachable shopkeeper, so it does not spend time
1098proving that NPC unfixable before reaching the main objectives - see below
1099for what happens with a map that DOES already know about it): **sword by
1100frame 3,000** (`$0055`, Well Uncle), **bow by frame 42,600** (matching
1101`j10`: 43,714, and the pre-regression best), **green pendant (`$7EF374 =
11020x04`) by frame 54,600** (`j10`: ~59,000), **Sahasrahla's boots by frame
110358,200** (`j10`: 62,499) - all within the pre-regression range, `manual
1104mode: false` for the ENTIRE run (no stall of any kind, headless summary
1105confirms zero rows with `manual=true`), and still playing at the full
110690,000-frame budget with no early stop. Sahasrahla is confirmed reachable
1107(boots obtained). Nothing in the fix touches Sahasrahla's own path (patch
1108`0006`'s Sahasrahla-specific behaviour is unchanged - `0011` only adds a
1109flag to an unrelated sprite type/room pair).
1110
1111A SEPARATE run using the live window's own, much more fully-explored
1112`map_export.txt` instead of `s1map`'s reaches the newly-reachable shopkeeper
1113NPC early (its map already tags the NPC as a cheap, nearby EXPLORE-adjacent
1114option) and spends ~9,000 frames proving it unfixable (see "The shopkeeper
1115cannot be paid" below) before `apps/zbanks`'s own "stop a minute after
1116recovery gives up" behaviour ends that run around frame 34,800 - a property
1117of which map was imported and what it already believes is worth trying, not
1118evidence against the door fix itself, which is what the `s1map` run above
1119isolates.
1120
1121**Live**, restarted onto this build via MCP (`dev_mode on` -> `restart` ->
1122`dev_mode off`): the window had been pinned at this exact pixel for
1123multiple real hours (RECOVERY firing every 16,558 frames without moving
1124Link at all - see the recovery section below); after the restart it left
1125room `$0123` within the first few seconds of play. It was seen back in that
1126room later, at a nearby but different position (the shopkeeper NPC's own
1127approach point, not the original door-pin pixel) - the door-pin bug itself
1128never recurred at any point in continuous real-time observation, but the
1129window did stick again for several minutes on the SEPARATE shopkeeper
1130problem described below, surfaced only because the door fix made that NPC
1131reachable for the first time. `Recovery` was seen escalating correctly
1132(`attempt: 4` of `5` via the `bot` MCP tool) before something else on the
1133map's own goal list resolved and the bot moved on by itself, confirmed by
1134`state` showing `mode: "overworld"`, `control: true` and a real, changing
1135position across successive polls (world areas 60, 27, 52, then 24) minutes
1136later. (An MCP `press` probe used to test raw movement at the stuck spot
1137briefly and harmlessly toggled the world-map overlay open via `X` - closed
1138again the same way before the bot resumed; no directional input from that
1139probe reached the game, so the overworld movement seen afterward is the
1140bot's own.)
1141
1142## Recovery's cap never engaged: progress was scored too broadly (fixed 2026-09-22)
1143
1144Live, before the `$0123` fix above, `packages/zbanks/src/recovery.rs`'s cap
1145(5 attempts with no progress before giving up for good) never engaged
1146although the SAME stall recurred over and over: `run/native.log` shows
1147`RECOVERY attempt 1 of 5` at frames 16567, 33125, 49683, 66241, 82799, 99357
1148and 115915 - **exactly 16,558 frames apart, every single time** - never
1149`attempt 2`. Each restored the same 33 given-up goals (all of them, per the
1150window's own MCP `state`, routed through the same physically-pinned door -
1151see the section above), and Link's OWN position (`read_wram`/`state`, not
1152the task-list stack) never moved from `0x5a88,0x2447` across any of them.
1153
1154**Cause.** `Recovery::consider`'s progress signal was `possessions(wram)`
1155OR `bot.completed_count()` changing since the previous check - both GLOBAL:
1156a change anywhere on the whole known map counts, not only something that
1157would let the STUCK goal succeed. `zb_completed_count` (`packages/zbanks/shim/host.c`)
1158increments for any goal that leaves the bot's list with `attempts <= 3` -
1159which includes a goal `retry_all_given_up` just re-added from scratch
1160(attempts reset to 0) immediately re-evaluating as already satisfied against
1161the save (a chest already opened, a door already unlocked) and leaving the
1162list "complete" before Link takes a single step. With 33 goals restored
1163every cycle, at least one such trivial re-completion happened deterministically
1164within the same ~16,558-frame window each time, resetting `Schedule`'s
1165attempt counter to 0 although the one goal that actually mattered - the door
1166- kept failing for the exact same unfixed reason.
1167
1168**First fix (necessary, not sufficient on its own), `packages/zbanks/src/recovery.rs`:**
1169a completed goal now counts as progress only together with Link's own
1170position (`Place` and pixel coordinates, the same reading the caller
1171already takes for `stall::Signal`, passed into `Recovery::consider` rather
1172than re-read) having changed since the last check. A possession change
1173(`$7EF340`-range: item, key, pendant, crystal, big key, progress) still
1174counts alone - Link cannot gain one without being at its location, so a
1175global read of it is trustworthy on its own; a completed goal is not, since
1176the mechanism above can fire it without Link moving at all.
1177
1178**This alone did not fix the live incident.** A second run of the exact
1179same shape (see below - a different NPC, same room) reproduced "attempt 1"
1180forever even with this fix built in, because of a SECOND, larger bug in
1181`Schedule::poll` (the pure backoff/cap decision, deliberately kept apart
1182from `Recovery` so it is testable without a real bot/console): the
1183`!stalled` branch reset `attempts` and `next_at` to zero on ANY tick where
1184the caller reports "not stalled" - not just when real progress happened.
1185`retry_all_given_up`'s own `zb_goal_add` clears `ap_manual_mode` as a side
1186effect of adding a goal back (`c37bf1ea`, above), so `stalled` reads `false`
1187for a tick (or many) immediately after EVERY recovery attempt, whether or
1188not the goal it just revived can actually succeed. Under the old rule that
1189momentary "not stalled" reading was read as "the stall cleared, so whatever
1190was wrong is fixed" and wiped the count - meaning a stall that recurs
1191because the SAME goal keeps failing could never escalate past attempt 1, no
1192matter what `progressed` said, because the `!stalled` branch reset it first
1193on the very next tick regardless.
1194
1195**Second fix, `Schedule::poll`:** only `progressed==true` forgets the count
1196now; a bare `stalled==false` reading returns early without touching
1197`attempts`/`next_at`, so a recurrence of the same unfixed stall resumes
1198escalating from where it left off. Unit-tested (`packages/zbanks/src/recovery.rs`,
1199`buck2 test //packages/zbanks:test`, 26 tests green): a schedule fed
1200`true, false` / `false, false` / `true, false` in a loop (exactly the
1201toggle recovery's own side effect produces) escalates 1..CAP and gives up,
1202where the old code returned `Some(1)` forever.
1203
1204**Both fixes were needed, and both were proven against a SECOND live
1205incident, not just replayed in a test.** With the `$0123` door fix above
1206landed, the door stall itself cannot recur (there is no unfixed goal left
1207to keep restoring through it) - so proving the recovery fix end-to-end
1208needed a live reproduction of a DIFFERENT stall with the same shape. One
1209appeared immediately: see "The shopkeeper cannot be paid" below. Headless,
1210against that exact case (`/tmp` scratch run, not kept - reproduce with the
1211live window's own `resume.state`/`map_export.txt`, `--import-map`, Jev off):
1212`RECOVERY attempt 1 of 5` at frame 22011, then `attempt 2` at 22611,
1213`attempt 3` at 23811, `attempt 4` at 26211, and `RECOVERY gave up after 5
1214recoveries with no progress` at frame 31011 - escalating on every single
1215occurrence, matching `Schedule`'s own unit test exactly, and giving up loudly
1216rather than looping for the rest of the run. Live in the window (frame
1217counters reset by an intervening restart, so not directly comparable to the
1218headless numbers): `recovery.last.attempt` was seen at 4 of 5 via the `bot`
1219MCP tool before the underlying goal picture changed enough (see below) that
1220the stall cleared on its own.
1221
1222## The shopkeeper cannot be paid: a new, separate limitation the door fix exposed (found 2026-09-22, not fixed - out of scope for this pass)
1223
1224Fixing the `$0123` door made sprite `$BB` ("Mini Moldorm Cave Guy",
1225`NODE|TALK|BLKF`) reachable for the first time - and reachable is also
1226walkable-TO, so the bot's planner now offers a `TALK_NPC` goal for it. That
1227goal cannot succeed: headless (Jev off, the live window's own `resume.state`
1228and `map_export.txt`), the bot walked up to it, opened its text box
1229repeatedly, and the goal failed and was retried in a tight ~400-600-frame
1230cycle (`STALLED`/`RECOVERY`/`stall cleared` every cycle) until `Recovery`
1231correctly gave up for good at frame 31011 (see above). `TALK_NPC`'s own
1232gift detection (patch 0007) is "a change in `$7EF340-$7EF35F` since the talk
1233began" - the general mechanism that works for Sahasrahla and uncle. This
1234NPC's own base type (`ap_snes.h`'s general `0xBB` comment: "Salesman /
1235chestgame guy / 300 rupee giver guy / Chest game thief / Item for sale")
1236is a family of NPCs that, in vanilla, gate their gift behind a CHOICE or a
1237COST the bot has no model for at all - a "will you pay 100 rupees?" prompt,
1238a chest game, or (matching the room's own name) a "kill 4 mini-moldorms
1239first" precondition never satisfied by anything in the bot's own goal
1240system. No amount of retrying changes any of that, so `Recovery`'s cap
1241giving up on it is the CORRECT terminal behaviour, not a defect - this is
1242squarely the "no goal exists for the actual precondition" class of gap the
1243Book of Mudora section above already describes for a different NPC.
1244
1245**Once `Recovery` gave up on this one goal, in the LIVE window the bot kept
1246playing.** It has 249+ other goals on a heavily-explored map, and
1247`ap_manual_mode` (the C's own one-way latch) only ever applies to ALL goals
1248at once, not this one specifically - so once something ELSE unrelated
1249finished (a chest, an unrelated key), `zb_goal_add` cleared the latch as it
1250always does, and the bot moved on. Confirmed live: after `recovery.last`
1251showed `attempt: 4` (via the `bot` MCP tool) with the window's own
1252`stalled.json` reading "Link has not moved" for several minutes at this
1253exact NPC, `state` moments later showed `mode: "overworld"`, `control:
1254true`, and a real, changing position across successive reads (area 60, then
125527, then 52, then 24) - the bot had moved on to other goals entirely, on its
1256own, with no intervention beyond what `Recovery`'s own bounded mechanism
1257and the bot's own huge goal backlog already provide.
1258
1259**In a headless run with a MAP THAT DOES NOT YET KNOW ABOUT THIS NPC**
1260(`s1map`'s own map, from before the door fix made it reachable), the
126190,000-frame regression proof below never encounters it at all in that
1262run's own exploration order, and reaches every milestone cleanly. A run
1263seeded with a map that already tags it as a "cheap, nearby" option (the
1264live window's own heavily-explored `map_export.txt`) discovers it early and
1265spends the ~9,000 frames above proving it unfixable before moving on - cost,
1266not correctness: the same run still recovers and keeps playing afterward,
1267it is simply the fully-explored map's own EXPLORE-goal ordering that visits
1268this dead end sooner. Left unfixed on purpose, per the task that found
1269it: teaching the bot to navigate a shop's payment prompt (or add a
1270`SCRIPT_KILLALL`-style precondition script for whatever this specific room
1271actually requires) is new scope, not a regression from anything done here.
1272
1273## The shopkeeper goal declined (host), and proving the Book of Mudora (2026-09-22)
1274
1275**Confirming the requirement gate, again.** A 400,000-frame headless run
1276(`run/zbanks-c/task1-book`, `home`, `s1map`'s map, Jev off) reached boots at
1277frame 60,000 and, from then on, `goals.txt` shows the Library script
1278flipping between `unsat Needs: [BOOTS]` and `limit` (in-range, cost over the
1279search cutoff, not unreachable) depending on Link's current position and
1280search budget - the same behaviour item 4 above already found, now seen
1281continuously for tens of thousands more frames. It was never `Active goal`
1282even once. This is not new; what stopped the run from getting the chance to
1283prove it further is.
1284
1285**The shopkeeper goal (previous section) was not merely a cost - it
1286permanently derailed a long run.** At frame 91,483 the same `task1-book` run
1287walked into room `$0123`'s shopkeeper, and from there `Recovery` escalated
12881→2→3→4→5 exactly as designed and gave up for good at frame 100,797;
1289`apps/zbanks`'s own "stop a minute after recovery gives up" then ended the
1290run at 104,397 - a quarter of its budget, with the Library goal sitting the
1291whole time as a correctly satisfiable, in-range option the greedy scorer
1292never got to reach because nothing after that point ever ran again.
1293
1294**Fix: decline the goal outright (`packages/zbanks/shim/host.c`,
1295`zb_npc_cannot_be_satisfied`).** The same mechanism that already declines
1296uncle's done-in-this-save NPC goal now also declines any `GOAL_NPC` for
1297sprite `0xBB`/subtype `0x0200` in dungeon room `$0123` - the bot never
1298creates a task for it at all, so it never approaches, never opens its text
1299box, and never spends a `Recovery` attempt on it. Proven
1300(`run/zbanks-c/task1-fix`, identical start): the bot leaves room `$0123`
1301within 5,000 frames of arriving (vs. never, before) and keeps playing to at
1302least frame 190,000 - a run that used to die at 104,397 now goes 82% further
1303before hitting anything else.
1304
1305**A second bug this exposed: `apps/zbanks`'s own stop condition fired on a
1306transient stall, not just a permanent one.** `task1-fix` still hit
1307`RECOVERY gave up after 5 recoveries` at frame 186,826 (an unrelated,
1308ordinary stall - not the shopkeeper, which is gone) - and the very next
1309log line is `stall cleared at frame 186827`, through `zb_goal_add`'s own
1310"a goal was added while in manual mode; resuming the plan", which has
1311nothing to do with `Recovery`. The old rule stopped the run unconditionally
13123,600 frames after `Recovery` ever gave up, discarding the run at frame
1313190,426 while the bot had in fact been un-stalled and exploring normally the
1314entire time since 186,827. Fixed (`apps/zbanks/src/main.rs`): the
13153,600-frame grace timer now tracks "stalled AND recovery exhausted"
1316together and resets the moment either stops being true, so a transient
1317stall Recovery happened to be exhausted for no longer ends the run, while a
1318genuinely permanent one still does.
1319
1320**Proven both ways, same run (`run/zbanks-c/task1-fix2`, identical start,
1321both fixes in place):** past frame 190,000 (where the old rule would have
1322stopped it) still playing normally; `RECOVERY gave up` fires again at the
1323same frame, 186,826 (a deterministic replay), and this time the run
1324correctly continues; it finally stops for good at frame 197,331 on a
1325*different*, genuinely permanent stall (below) - `stopping at frame 197331:
1326recovery gave up and the bot has stayed stalled for a minute`, this time
1327truly warranted. Net effect of both fixes together: the run's healthy
1328lifetime went from 104,397 → 197,331 frames, roughly double, entirely by
1329removing waste, before meeting a wall that needs its own investigation.
1330
1331**The next blocker: a door at the Dam, same shape as the room `$0123` pin,
1332not yet characterized or fixed.** `ap_follow_targets` logs `Stuck Link
1333Detected! 9b08,2190 en route to 9af8,2140` repeatedly from frame 179,651
1334onward, and the task that never completes is `TRANSITION ... [node=door U
13350x80 DOOR|NODE NORM screen=Dam Chest ^ 9a00,2100 x 9bff,21ff]` - a fixed
1336pixel, a fixed unreachable target, exactly the shape carried patches
1337`0006` and `0011` each turned out to be (a real obstacle A* does not see,
1338or a route that requires going around one). Not investigated this session:
1339whoever picks this up should start the same way those were - a `ZB_TRACE`
1340bracket on the approach to this door and a dump of `ap_pathfind_local`'s
1341grid for this room, the same method that found `0006`'s regression and
1342`0011`'s missing `BLKF`.
1343
1344**The live window separately needed two operational recoveries this
1345session, neither a code bug:**
1346
13471. `Recovery`'s cap (`packages/zbanks/src/recovery.rs`) is a one-way latch
1348   for the life of the process by design - once spent, it never retries
1349   again, whatever the reason it was spent on. The window hit this
1350   legitimately (the shopkeeper, pre-fix) and sat permanently stalled with
1351   nothing able to clear it. Only a `restart` (dev mode on → `restart` →
1352   off) gives it a fresh `Recovery`/`Bot`; this is expected maintenance, not
1353   a defect, and is now the second time this exact remedy has been needed.
13542. After a `load_state("home")` reset, the window's own auto-imported map
1355   (`run/zbanks-window/map_export.txt`, copied to `map.19.txt` on every
1356   `restart` per `apps/native/src/app.rs`) still reflected a much
1357   later-game topology than Link's new position, and a fresh Bot built from
1358   it found every reachable goal `unsatisfiable` (no known route) within
1359   ~600 frames, then genuinely exhausted `Recovery`'s cap with 0 goals ever
1360   restored (nothing to retry - the graph was just disconnected from here).
1361   Renaming both `map.19.txt` and `map_export.txt` aside (kept, not
1362   deleted, as `*.bak-2026-09-22-0105`) before the next `restart` +
1363   `load_state("home")` let the bot build a fresh, internally-consistent
1364   graph from its actual position, and it got the sword and shield within
1365   1,500 frames and stayed healthy (no stall, `recovery.gave_up: false`)
1366   for a confirmed 10+ minutes of continuous real-time play afterward.
1367
1368## The Dam Chest push block: a hammer peg that was really a wall (fixed 2026-09-22, carried patch 0012)
1369
1370The next blocker after the shopkeeper fix (previous section): `ap_follow_targets`
1371logged `Stuck Link Detected! 9b08,2190 en route to 9af8,2140` repeatedly, and the
1372live window's own user, watching the window, gave the ground truth a trace alone
1373would not have: at the Dam Chest room, Link stands beside the grey push blocks and
1374repeatedly swings the **hammer** at one, gets nowhere, and eventually wanders off
1375to an unrelated goal - the same shape as the shopkeeper, but with a different tool
1376mistaken for the right one.
1377
1378**Reproduced fast and cheaply**, without touching the live window: the live
1379window's own `resume.state` and `map_export.txt`, copied (not read live - the
1380window was later closed) into a scratch states directory and used as `home` for
1381`apps/zbanks`, Jev off. A fresh process rebuilds its goal list from the imported
1382map with `attempts` reset to 0, so it re-picks the same door goal from scratch in
1383under 10,000 frames rather than needing the live process's own already-mostly-
1384exhausted state - the same trick used for every stall reproduced this way in this
1385document.
1386
1387**The mechanism, read directly out of the running C** (a temporary `LOG` line in
1388`ap_follow_targets`, added, proved, and reverted - never committed, per this
1389file's own "Proving a patch that changes pathing" note): at the exact stuck
1390position, `next_xy = target.tl = (0x9af8, 0x2178)`, one grid cell north of Link -
1391an ordinary, close waypoint, not a stale far-away target. `ap_map_attr(next_xy)`
1392there is raw tile `0x27`, and `ap_tile_attrs[0x27] = TILE_ATTR_HMMR` - `ap_snes.h`'s
1393own comment on that entry already flags the ambiguity: `// hammer peg (was also
1394fence; remapped to 0x01)`. Dungeon room `$010B` (the Dam's block-puzzle room)
1395reuses the same tile id for its push blocks, a THIRD meaning the same comment
1396never anticipated. With `TILE_ATTR_HMMR` set and the hammer owned,
1397`ap_follow_targets`'s branch order (`ap_map.c`) presses Y at it every other frame
1398(`JOYPAD_MASH(Y, 0x1)` - the trace's alternating `0x4800`/`0x0800` pad values,
1399step 2 apart, are exactly this) - forever, since a push block does not respond to
1400a hammer swing.
1401
1402**Not merely a wrong tool - a wrong PASSABILITY judgment too.** `ap_pathfind_local`
1403computes its own `lift_mask` *including* `TILE_ATTR_HMMR` whenever the hammer is
1404held, and scores an HMMR tile `cost = 20` (a normal, low-cost, "interact and pass"
1405tile) rather than the `1 << 20` a wall gets - so A* built an 18-ish-waypoint path
1406straight onto this tile in the first place, the same shape as patches `0003` and
1407`0011`. Confirmed live: `push_timer` ($7E0371) was actively counting down frame by
1408frame throughout the stall (`0x1b` → `0x19` → `0x17` → `0x13` over ~150 frames)
1409while Link's own x,y never moved a single pixel - the real engine's push mechanic
1410genuinely engaging (UP is held unconditionally whenever the target is north,
1411regardless of any tile-attribute branch), consistent with the block already having
1412been shoved as far as it can go and Link now pushing against something immovable,
1413matching what the user watched happen: "he pushes the first block up, and it is
1414now sitting between him and the chest."
1415
1416**Fix (`0012-dam-push-block-not-a-hammer-peg.patch`, `ap_map.c`).** A new
1417`ap_tile_attrs_here(raw_tile)` returns `0` for tile `0x27` specifically when
1418`*ap_ram.dungeon_room == 0x010B`, and `ap_tile_attrs[raw_tile]` unchanged
1419everywhere else - wired into both of `ap_follow_targets`'s attribute checks and
1420`ap_pathfind_local`'s per-tile costing loop. With attrs forced to 0 in this one
1421room, the tile is no longer "liftable" (no more A-mash) or `TILE_ATTR_HMMR` (no
1422more Y-mash), and `ap_pathfind_local`'s costing falls through to its own `else`
1423branch - `cost = (1 << 20)`, a real wall - so A* treats it exactly like any other
1424solid obstacle it cannot cross. No host fix: it is the exact same
1425static-table-lacks-room-context bug as `0003`/`0011`, just for a tile id instead
1426of a sprite type.
1427
1428**Proof.** Reproduced headless from the (now-closed) live window's own
1429`resume.state`/`map_export.txt`, Jev off: unpatched, `given_up` climbs to 1 within
1430~9,600 frames (the door goal fails its three attempts and is abandoned) and the
1431task list shows the identical `Stuck Link Detected!`/hammer-mash pattern seen
1432live. Patched, the SAME reproduction (`run/zbanks-c/live6`): `given_up` stays `0`
1433for the entire 20,000-frame run - the bot leaves the Dam Chest room cleanly via a
1434different door (`door D 0x8e`) within the same few thousand frames, and the door
1435`U 0x80` goal itself now scores `limit` (in range, over the search-cost cutoff -
1436the same harmless, ordinary "book of Mudora" shape) rather than ever being
1437attempted and failing. No `Stuck Link Detected` anywhere in the patched run's log.
1438
1439## The hammer swings at bare floor (fixed 2026-09-22, carried patch 0030, movement/combat item 1)
1440
1441The user, watching the live window: at frame 88684, in Sahasrahla's hut, the
1442active task was `LIFT_POT [node=pot 0x71 NODE|LFT0]` and the pad showed
1443Y+Right with the hammer equipped - Link swung the hammer at bare floor
1444instead of walking to the pot and pressing A. `0012` (previous section)
1445had already fixed the identical shape once, but only for dungeon room
1446`$010B`: `ap_tile_attrs_here` returned `0` for raw tile `0x27` in that one
1447room and left every other room's `0x27` going through the unmodified flat
1448`ap_tile_attrs[0x27] = TILE_ATTR_HMMR`.
1449
1450**Why raw `0x27` is never a real hammer peg indoors, from the disassembly.**
1451zelda3's `HandleItemTileAction_Dungeon` (`src/dungeon.c:81dabb`) is the real
1452game's own version of "is the tile in front of Link interactable, and how":
1453it only ever recognizes an interactive dungeon object through the
1454"replacement object" family - raw BG attribute `0x70-0x7F`, whose low
1455nibble indexes `dung_replacement_tile_state[]` (`$7E0500`, 16 `uint16_t`
1456slots). `RoomDraw_HammerPegSingle` (`dungeon.c:81b493`) writes `0x4040`
1457into a slot for a real peg; a pot's slot is `0x1010`
1458(`kDungeon_QueryIfTileLiftable_rv`, matching this brain's own
1459`alttp-ram-map.md`, "Liftables, named and gated" - dungeon liftables/pegs
1460are `$70-$7F`, translated back through this same table); a plain pushable
1461block (`DrawObjects_PushableBlock`, `dungeon.c:81b4d6`) writes `0`, neither
1462pattern. Raw `0x27` is a completely different attribute value, outside
1463this family - it is `TileBehavior_Hookshottables` in the OUTDOOR
1464tile-detection switch (`src/tile_detect.c:342`), and `ap_snes.h`'s own
1465comment on its `0x27` entry ("was also fence") already shows it is reused
1466for unrelated static scenery per-tileset. Indoors, `ap_map_attr_from_ram`
1467reads straight from the game's own live `dngn_bg1_tattr`/`dngn_bg2_tattr`
1468buffers (`$7F3000`/`$7F2000`, matching `dung_bg1_attr_table`/
1469`dung_bg2_attr_table` in zelda3's `variables.h`) - so raw `0x27` indoors is
1470whatever that tileset's designer used the id for: a push block's floor in
1471`$010B`, apparently a plain floor tile in Sahasrahla's hut. Nothing in the
1472real game's own hammer-peg check ever consults it.
1473
1474**Fix (`0030-hammer-peg-indoors-general.patch`, `ap_map.c`), no room
1475number anywhere.** `ap_tile_attrs_here` now strips `TILE_ATTR_HMMR`
1476whenever `*ap_ram.in_building` is set, unconditionally, before returning
1477the flat table's value - `ap_snes.h`'s table sets that bit for exactly one
1478raw id (`0x27`), so this is equivalent to "indoors, `0x27` is never treated
1479as a hammer peg," full stop. For dungeon room `$010B` specifically this
1480produces the identical result `0012` did (`0x27`'s only bit is HMMR, so
1481stripping it zeroes the same way `0012`'s hardcoded `return 0` did), so
1482`0012`'s own fix is now subsumed rather than duplicated - both patches stay
1483carried (in application order `0012` then `0030`) since `0030` composes on
1484top of `0012`'s introduction of `ap_tile_attrs_here` rather than
1485replacing it. Outdoors is untouched: `ap_map_attr_from_ram`'s existing
1486fence carve-out (`tile != 0x021B`) already covers the one known outdoor
1487ambiguity, and real outdoor hammer pegs keep working exactly as before.
1488
1489**Proof.** A fresh 100,000-frame headless run (`--make-home`, canonical
1490open-mode home, Jev off, `--no-retry`) reaches the same healthy shape prior
1491runs in this document show before hitting manual mode: `PICKUP 16/25,
1492CHEST 13/20, EXPLORE 99/155, ITEM 0/0, NPC 0/0, SCRIPT 3/3`. Only two
1493`Stuck Link Detected` events in the whole run, both at the same cell in
1494Blind's Basement (`ab10,23b0` en route to `ab10,23a0`) - a pre-existing,
1495unrelated jam (`ap_jam_note` fired for it, the general `0015` mechanism
1496already handling it), not a hammer mash. No regression: the run never held
1497the hammer at all in this playthrough (too early), so the fix's own branch
1498(`*ap_ram.inventory_hammer != 0` still gates the Y-press) never even
1499engaged, and the general A*/pot-lift pathing this patch touches is
1500unaffected by it either way. The exact historical frame (88684, Sahasrahla's
1501hut) was not re-reproduced pixel-for-pixel - the live window that produced
1502it is closed and its save was not kept - but the fix is a direct,
1503disassembly-grounded removal of the only code path that could have pressed
1504Y there, and is provably behaviour-preserving for the one case (`$010B`)
1505that was previously proven live.
1506
1507## The Eastern Palace big chest: no big-key gate at all (fixed 2026-09-22, carried patch 0013)
1508
1509While the Dam fix was being proven, the live window's own user reported a SECOND
1510failing goal, and that the bot was oscillating between it and the Dam: at Eastern
1511Palace's big chest, "Eh? It's locked! If you had the Big Key…" - repeatedly.
1512
1513**Root cause, read from the code, not guessed.** `ap_goal_score`'s `GOAL_CHEST`
1514case (`ap_plan.c`) only ever checked `sram_room_state` for "already opened";
1515nothing checks whether the chest CAN be opened. `ap_node_islocked` (`ap_map.c`)
1516does have a correct, working big-key check - `DOOR_ATTR_BKEY`, `*ap_ram.sram_
1517dungeon_bigkeys & (1 << (15 - dungeon_id))` - but that branch only ever fires
1518for `node->type == NODE_TRANSITION` (a locked DOOR). Its own `NODE_CHEST` branch,
1519a few lines further down, asks only "is this chest tile still closed" (`lock_attr
1520== 0x27` there means "opened chest" - yet another, unrelated meaning for the same
1521raw tile id 0x27 the Dam fix above deals with, in a different context/plane -
1522confirming these ids really are heavily overloaded throughout this codebase) and
1523never consults `sram_dungeon_bigkeys` at all. So a big chest was always
1524"reachable" to the pathfinder and always score-eligible to the goal system,
1525however keyless the run, and the bot walked up and got told no every single time.
1526
1527**This one failing goal was not isolated - it fed a ping-pong.** With ~200,000
1528frames of exploration behind it and 60 goals already given-up-and-restored, the
1529window's own `Recovery` kept retrying every given-up goal on a stall
1530(`zb_retry_given_up`, `packages/zbanks`), the big chest included; each retry
1531walked most of the map to reach it, failed identically, and the resulting stall
1532retried everything given-up again - including whatever OTHER far-away goal (the
1533Dam door, before its own fix landed) was also failing, so the two traded places
1534indefinitely, burning real wall-clock time on a network of unfixable and
1535soon-to-be-fixed goals rather than ever reaching new content.
1536
1537**Fix (`0013-big-chest-needs-the-big-key.patch`, `ap_plan.c`).** `GOAL_CHEST`'s
1538own scoring now also checks, when `goal->node->chest_type == 1` (upstream's own
1539ROM-derived "this is THE big chest" flag, set from the dungeon's chest table -
1540`ap_map.c`, `chest_id & 0x8000`): `*ap_ram.sram_dungeon_bigkeys` for
1541`goal->node->screen->dungeon_id`'s bit, returning `GOAL_SCORE_UNSATISFIABLE`
1542if it is not set - the exact bit formula `ap_node_islocked`'s own `NODE_KEYBLOCK`
1543branch already uses, and deliberately the per-NODE `screen->dungeon_id` rather
1544than the global "current dungeon" `ap_req`'s generic requirement system would
1545have used (that system's own `REQUIREMENT_KEY` already carries a `// XXX how to
1546handle this across dungeons` admission of the same imprecision - this fix does
1547not repeat it, since a chest's own dungeon is always known statically from its
1548node, not from wherever Link currently stands).
1549
1550**Why this alone stops the ping-pong, without a separate retry-suppression
1551mechanism.** `GOAL_SCORE_UNSATISFIABLE` goals are skipped in `ap_goal_evaluate`'s
1552own scoring loop before a goal is ever chosen `min_goal` - they never become the
1553active goal, never generate a task, never physically fail, and therefore never
1554reach `ap_goal_fail`/the given-up list `zb_retry_given_up` walks. An unsatisfiable
1555big chest just sits in `ap_goal_list` forever, silently, exactly like the Book of
1556Mudora's own `unsat`/`limit` goal before boots - the SAME already-working
1557mechanism that makes a correctly-gated goal harmless, now covering a chest for
1558the first time. Combined with the Dam fix (which stops that goal from ever
1559failing either), neither side of the reported ping-pong can occur any more: no
1560new mechanism was needed once both root causes were actually fixed.
1561
1562## Never push a block into a wall (general rule, fixed 2026-09-22, carried patch 0015)
1563
1564The Dam Chest fix (`0012`, previous section) closed one instance of a shape that
1565recurs by construction, not by bad luck: `ap_snes.h`'s own comment on raw tile
1566`0x27` already lists three unrelated meanings for the same id (fence, hammer peg,
1567push block), and `0012`'s fix only teaches the host the THIRD one, and only in
1568dungeon room `$010B`. Every other room that reuses `0x27` (or any other tile id)
1569for a push block the static `ap_tile_attrs[]` table cannot see room-context for
1570will produce the identical stall - Link stands next to an already-jammed block
1571mashing the wrong tool forever - and would need its own hand-found room check,
1572forever, exactly the pattern `0011`/`0012`/`0014` were already three instances of
1573before `0014` itself got generalized (previous sections).
1574
1575**Why no static table generalizes this.** The SNES's own tile system reuses
1576numeric ids per room BY DESIGN (each room selects a graphics/tileset bank that
1577reinterprets the same indices) - there is no bit anywhere in a raw tile id that
1578says "this one is a push block here." A lookup table keyed by id, however large,
1579is the wrong shape for a per-room reinterpretation; every future occurrence would
1580be undiscovered until a user watched the window stall and reported it by hand.
1581
1582**The general signal is not the tile at all - it is what the GAME does.**
1583`push_timer` ($7E0371) engages whenever Link genuinely starts a push/pull/lift
1584interaction, real regardless of room or tile id (confirmed directly for the Dam
1585case in `0012`'s own section: `push_timer` counted down frame by frame while
1586Link's x,y did not move a single pixel). `ap_follow_targets` already runs a
1587generic stuck detector (`stationary_link_count >= 128`, pre-existing, unrelated
1588to any of this) that fires today for stalls of every kind and merely resets the
1589target timeout, letting `ap_pathfind_local` immediately re-select the identical
1590blocked path next time. The fix teaches that existing, already-generic detector
1591to REMEMBER a jam instead of only forgetting the timeout.
1592
1593**Fix (`0015-never-push-a-block-into-a-wall.patch`, `ap_map.c`).** A small
1594per-screen memo - `ap_jam_screen`/`ap_jam_cells[8]`/`ap_jam_count`, all file-scope
1595statics, no change to `struct ap_screen` itself. `ap_jam_sync(screen)` clears the
1596memo the instant the active screen differs from the one it was last touched for -
1597correct, not merely conservative, because this codebase's own understanding of
1598ALTTP (`Room-reset-before-fail`, next section) already establishes that a room's
1599pushed-block state resets ONLY on a real screen transition, so "the active screen
1600changed" is exactly the event that must invalidate previously-learned jams, no
1601more and no less. `ap_follow_targets`'s stuck-detector calls `ap_jam_note(screen,
1602target)` when it fires AND `*ap_ram.push_timer != 0` (an interaction was actually
1603engaged, as against every OTHER reason `ap_follow_targets` can get stuck, which
1604this patch leaves untouched). `ap_pathfind_local`'s per-cell costing loop
1605consults `ap_jam_is(screen, mapxy)` as its FIRST branch, ahead of even the
1606destination-bbox and `0x1C` special cases, forcing `cost = (1 << 20)` (an
1607ordinary wall) - a cell proven immovable cannot be reclassified as free just
1608because a goal's own bbox happens to cover it.
1609
1610**Proof, organic, not synthetic.** `0012`'s own fix was disabled for one
1611diagnostic build only (its room/tile guard changed from `dungeon_room == 0x010B`
1612to an unreachable `0xFFFF`, `git checkout --` after - the same temporary-and-
1613reverted method this file's own "Proving a patch that changes pathing" note
1614already establishes), isolating `0015` as the only defense against the ORIGINAL
1615bug. A fresh `--make-home` boot, no map, Jev off (`run/zbanks-c/jam-proof/
1616isolation`) reached the Dam at frame 3000 on its own and reproduced the exact
1617historical position from `0012`'s own section (`9af8,2140`, one grid cell from
1618the documented `9af8,2178`/`9af8,2140` pair) at frame 3408:
1619
1620```
1621[3408 ap_map.c:ap_follow_targets] Stuck Link Detected! 9af8,2180 en route to 9af8,2140
1622[3408 ap_map.c:ap_jam_note] Jammed cell noted: 9af8,2140 in screen Dam Chest ... - treating it as a wall until the room resets
1623[3408 ap_plan.c:ap_goal_fail] Failed goal: EXPLORE [node=door U 0x80 ... screen=Dam Chest ...]
1624```
1625De-duplication held (the same cell was not re-logged on the goal's two further
1626retries at frames 3535/3662), and the goal was correctly declared unsatisfiable
1627(`Permafailing goal`, frame 3789) once no path around the now-known wall existed
1628- the right call with `0012` disabled, since the underlying misclassification
1629that would let Link approach correctly is still gone in this diagnostic build.
1630Leaving and later re-entering the SAME room (frame ~10129, a fresh transition)
1631re-triggered `ap_jam_note` for the SAME cell independently - direct proof the
1632per-screen memo is cleared and correctly re-learned across a real room reset,
1633not merely never cleared.
1634
1635With the real `0012` restored (`git checkout --` undid the diagnostic edit,
1636verified byte-for-byte against the pre-edit copy) and `0015` still present, the
1637same fresh-home reproduction crosses the Dam room on both of its two visits
1638(frames 3000-4800 and 5400-9600) with zero "Stuck Link Detected"/"Jammed cell
1639noted" lines - `0012`'s preventive, room-scoped fix still does its job on the
1640first attempt, and the general mechanism stays fully dormant, proving the two
1641are compatible rather than one masking the other. Separately, the Eastern
1642Palace door-offset reproduction (`run/zbanks-c/jam-proof/ep-repro`, `0014` +
1643`0015` together, 20,000 frames from the same fixture used to prove `0014`)
1644finished with `given_up` at `0` for the entire run and zero jam events - no
1645regression from adding the general mechanism alongside the door-offset one.
1646
1647## A one-time NPC's reward already held is never offered again (fixed 2026-09-22)
1648
1649Live, right after a restart rebuilt the goal list from nothing (frame 3806):
1650the bot walked straight back to Sahasrahla's hut and talked to him again,
1651although Link already held the Pegasus Boots. Unlike a chest (`GOAL_CHEST`
1652reads `sram_room_state`), a talk-for-item NPC's goal had no such re-check at
1653all - once created, it stayed offerable forever regardless of the save.
1654
1655**Where, and why not in the C.** `packages/zbanks/CLAUDE.md`'s own rule:
1656"never edit the bot" - a behaviour the host needs is a change in
1657`shim/host.c`, not a carried patch, unless no host can make it from outside.
1658This one can: `ap_map.c`'s `ap_goal_add` calls are compiled to go through
1659the host's `zb_goal_add` (`-Dap_goal_add=zb_goal_add`,
1660`third-party/c/BUCK`), and that function already had exactly this shape -
1661`zb_done_in_this_save` declines uncle's own goal once his SpritePrep bit
1662fires, to avoid a stale-memory assert. The Sahasrahla case is the same
1663INTERCEPT POINT, a different REASON (a wasted trip, not a crash).
1664
1665**Fix, table-driven.** A new `zb_npc_reward_already_held` in
1666`shim/host.c`, checked in `zb_goal_add` alongside the existing two declines:
1667one row per known one-time-reward NPC (`ap_snes.h`'s room-scoped
1668`ap_sprite_attrs_for_type` table has exactly three `TALK|NODE` overrides -
1669Sahasrahla, uncle, and room `$0123`'s shopkeeper, which is handled
1670separately since its own problem is "can never succeed", not "already
1671succeeded"). Sahasrahla ($16/$0000): Pegasus Boots, `$7EF355`
1672(`inventory_base` `$7EF33F` + `BOOTS`'s own index `0x16` - cross-checked
1673against `research/alttp-ram-map.md`'s own citation, zelda3
1674`variables.h:1085`, "link_item_boots"). Uncle ($73/$0100): the sword,
1675`$7EF359` - a second, independent check alongside
1676`zb_done_in_this_save`'s own crash-avoidance one, since the sword can be
1677held before uncle's own SpritePrep bit is set. The next one-time NPC is a
1678row, not a new function.
1679
1680**Proof.** Headless from the live window's own `resume.state` (boots
1681already held) with its current map imported, 3,000 frames: frame 0 logs
1682`zbanks host: not adding goal for sprite 0x16.0 BLKF|TALK|NODE: reward
1683already held (boots already held)` and Sahasrahla's goal never appears
1684again for the rest of the run - the bot spends the whole run on unrelated
1685EXPLORE/transition tasks instead.
1686
1687**Not yet built: the NPC completion check is goal-CREATION-time only**,
1688matching the existing `zb_done_in_this_save` pattern exactly (both check at
1689`zb_goal_add`, not via a re-scored `ap_goal_score` branch, since scoring has
1690no host-replaceable hook the way goal creation does). This is sufficient for
1691the reported bug (a restart rebuilds the whole goal list from nothing, so
1692the check runs fresh for every node), but an NPC goal created BEFORE this
1693fix existed, still sitting in a long-lived process's goal list, would not
1694retroactively decline until that process restarts.
1695
1696## A routine already done, or already claimed, is never offered again (fixed 2026-09-22, carried patch 0016)
1697
1698Two related complaints from the user, watching the live window.
1699
1700**Hyrule Castle.** Jev picked "Carry out the known routine 'HC kill guard for
1701key'" at 70% - Link walked back to the castle, went in, turned around and
1702left. `ap_scripts[]` (`ap_map.c`) has two live Hyrule Castle routines, "HC
1703kill guard for key" and "HC kill miniboss free Zelda" (`SCRIPT_KILLDROPS`, no
1704`start_item`, no requirement) - both exist only to progress the vanilla
1705rescue sequence: kill a guard for its key, then the miniboss guarding
1706Zelda's cell. `zbanks::rando::PRESET` (`packages/zbanks/src/lib.rs`) sets
1707`$7EF3C6` (`sram_progress2`) to `0x14` from file creation - bit `0x10`
1708(`uncle_left_house`) and bit `0x04` (`zelda_at_sanctuary`) both already set,
1709matching `packages/alttp`'s own `Progress::read` (what the `state` tool's
1710`"progress":"zelda_rescued"` reflects). Both routines' entire purpose is
1711already accomplished before the bot's first frame, in every game this
1712project starts - not conditionally, structurally, since open mode IS this
1713preset.
1714
1715**Blind's House.** Separately, Jev picked "Blind's House Block Puzzle" at
171679% (frame 41857) with the room's own chests already open - the routine had
1717nothing left to solve either, a different cause (a claimed reward, not a
1718skipped storyline beat) with the same symptom.
1719
1720**Fix (`0016-hc-routines-already-done-in-open-mode.patch`, `ap_plan.c`), two
1721checks in `ap_goal_score`'s `GOAL_SCRIPT` case.** (1) `GOAL_SCORE_UNSATISFIABLE`
1722for any script node whose name starts `"HC "` once `*ap_ram.sram_progress2 &
17230x04` is set - the same "read the live flag, gate the goal" shape as `0010`'s
1724boots requirement and `0013`'s big-key gate. The two commented-out HC entries
1725share the prefix, so re-enabling either inherits the gate for free. (2) A
1726GENERAL check, for every routine rather than only the castle's: walk the
1727script's own screen's node list: if it has at least one `NODE_CHEST` and
1728every one already shows its `sram_room_state` bit set (the identical read
1729`GOAL_CHEST`'s own scoring already does), the routine is `GOAL_SCORE_COMPLETE`
1730- nothing left to solve. A routine whose screen has no chest at all (AgTower's
1731statues/curtain, the Kak well jump) is untouched, since `has_chest` stays
1732false.
1733
1734**Proof.** The HC gate: a fresh headless run from `home` with the live
1735window's own current map imported (`run/zbanks-c/hc-gate/final`, since a
1736truly fresh, mapless run had not discovered Hyrule Castle within 15,000
1737frames tried) shows, from the first evaluation after the script node is
1738registered, `unsat` for the whole run:
1739```
1740attached node Script: HC kill guard for key
1741* unsat SCRIPT [node=Script: HC kill guard for key screen=HC First Key ^ 5200,e00 x 53ff,eff] on HC First Key ^ 5200,e00 x 53ff,eff Needs: [Any]
1742```
1743The chest-completion check is code-reviewed, not yet live-fire proven: it
1744reuses `GOAL_CHEST`'s own already-proven bit formula verbatim over a node
1745traversal, but no fixture on disk has Dam's or Blind's chest ALREADY open at
1746the moment its script goal is (re-)scored - in every headless repro tried,
1747the script's own task-completion fires (wrongly - see below) before the
1748chest opens, so the new branch is never exercised end to end. It becomes
1749testable once the next fix lands.
1750
1751## A finished routine that did not reach its goal is a failure, not a success
1752
1753The Dam Chest script itself is the reason the check above could not be
1754live-fire proven: `ap_goal_complete` fired for "Dam Chest Block Puzzle" at
1755frame 2528, BEFORE the chest opened at frame 2591 - the goal is marked
1756complete when its `SCRIPT_SEQUENCE` task finishes running its hardcoded
1757movement string, not when the thing the script exists to do (open the
1758chest) actually happens. A script whose steps are wrong for this cartridge
1759(the user's own "D" report: two blocks pushed beside the chest, one below,
1760chest still shut, frame 26507) still reports success and the bot wanders off
1761to unrelated exploration - the exact "worthless, looks-fine failure" this
1762section exists to name. Fix and general Dam Chest push-block solver: see
1763this file's own upcoming section (this session's item D).
1764
1765## Jev is not told a routine already failed here (open, item E)
1766
1767A third complaint from the user watching live: at frame 23806, Jev's options
1768were the Dam Chest routine (already failed on earlier visits) against an
1769unexplored overworld edge, with nothing in the request telling Jev the Dam
1770option was a repeat of a known failure - `state`/the goal-choice `Facts`
1771carried no attempt history at all, so Jev could not weigh "try again" against
1772"explore something new". Fix: a per-goal outcome log kept by the host (the
1773C side already has `goal->attempts`; the host sees every fail/retry) surfaced
1774in `Facts`, and narrowing when `zb_retry_given_up`/`Recovery` put a failed
1775goal back to only when something that bears on ITS OWN failure changed - a
1776requirement now held, the room reset, or its routine fixed - rather than any
1777possession change at all. Not yet built; this section is the record of why.
1778
1779## Never ask Jev to break a tie the scorer already settles (fixed 2026-09-22, item B)
1780
1781At frame 14674 (and, in the log, frame 8739's own instance of the identical
1782shape) Jev's options were "Lift the pot ... 27 steps away" against "Lift the
1783pot ... 45 steps away" - two pots in the same room, differing only in
1784distance. `goal_choice/words.rs`'s `offers()` already collapsed same-kind,
1785same-place goals into one offer, but only when their distances were
1786`similar` (within 8 steps or a fifth of the longer) - a threshold meant to
1787stop two sentences reading IDENTICALLY at a glance, not to decide whether
1788Jev should be asked. The planner's own scoring already weighs distance, so
1789asking Jev to pick the nearer of two otherwise-identical goals spends a
1790question and teaches nothing, at any distance gap.
1791
1792**Fix.** `offers()`'s merge condition drops `similar(g.steps, steps)`
1793entirely - same description and same screen now always collapse to one
1794offer, however far apart the distances are. `similar()` itself is deleted
1795(its only caller). The group's representative (`goals[0]`, the goal actually
1796pursued if the offer is picked) is kept as the NEAREST of the merged set as
1797each candidate streams in, rather than whichever happened to be seen first -
1798"not asked: same kind, nearest taken" is the resulting behaviour, not just
1799the log line's wording.
1800
1801**Proof.** `packages/decisions/src/goal_choice/words.rs`'s own test
1802`same_place_far_apart_in_distance_is_not_collapsed` (asserting 2 offers for
1803a 200-vs-900-distance pair) is rewritten as
1804`same_place_far_apart_in_distance_is_still_collapsed_and_the_nearer_is_taken`
1805(asserting 1 offer, with the nearer goal - index 1, the 200-distance one -
1806as the representative even though it was listed second). Full
1807`packages/decisions:test` suite green (20/20): the three tests that rely on
1808DIFFERENT descriptions staying apart (`pots_in_different_places_are_told_apart`,
1809`pots_close_together_are_placed_by_steps`, `sentences_say_what_where_and_the_way`)
1810are unaffected, since they never shared a description to begin with.
1811
1812**How many of the logged questions this would have skipped.** `jev.jsonl`
1813only records each option's RENDERED SENTENCE, not the original
1814`GoalOption` structs `offers()` groups on, so this is a text-level
1815reconstruction (strip each option's trailing distance clause and count
1816groups that collapse to one), not a byte-exact re-run of the real function -
1817stated as what it is, not overclaimed. Of 776 lines in
1818`run/zbanks-window/jev.jsonl` where `how` shows a real `asked` question (not
1819reused, one-choice, throttled or a fallback), **20 (2.6%) would have been
1820skipped entirely** under the new rule - every one is exactly this shape (two
1821or three same-kind, same-room options differing only by a distance clause):
1822frame 92 (two pots, 2 vs 9 steps), frame 3388 (two pots, 19 vs 23), frame
18238739 (two pots, 27 vs 45 - the cited example, one frame count off from how
1824it was first reported live), frame 23504 (three chests, 2/7/9), frame 23577
1825(two chests, 6 vs 8), and 15 more of the same shape.
1826
1827## Room-reset-before-fail: designed, not built
1828
1829Separately asked for, generically: when a goal fails inside a room whose state
1830the bot changed (a pushed block, and so on), retry by first leaving through the
1831nearest door and re-entering (which resets ALTTP's own per-room puzzle state) for
1832up to `K` tries, before counting it as a real failure against the goal's attempt
1833limit and `Recovery`'s cap. Not built this session, for a specific, structural
1834reason rather than lack of time to spare: this project's only outward hooks after
1835a goal is chosen are (a) `ap_goal_choose_hook` (patch `0002`), which is offered
1836only the goals `ap_goal_evaluate`'s OWN scoring loop has already decided are
1837`GOAL_SCORE_COMPLETE`-eligible or better - and a door already known to lead
1838somewhere mapped scores `GOAL_SCORE_COMPLETE` for a plain `EXPLORE` goal without
1839ever generating a walk-there task, so the obvious "make the chooser pick the
1840nearest door goal twice" does not actually make Link walk anywhere; and
1841(b) `ap_goal_fail`, which is `static` to `ap_plan.c` and has no host hook at all
1842yet (unlike `ap_goal_add`, it cannot be redirected with a `-D` rename trick from
1843another file, since every caller is inside the same translation unit).
1844
1845A real implementation needs, at minimum: a new `ap_goal_fail_hook` carried patch
1846(mirroring `0002` exactly - a NULL-by-default function pointer called from
1847`ap_goal_fail` before its own `attempts++`/permafail logic, proven a no-op the
1848same way `0002` was, by a byte-identical run with and without it), plus a genuine
1849host-driven "walk to the nearest door and back" maneuver that does NOT go through
1850`ap_goal_add`/`ap_goal_score` at all - since as established above, a known door
1851cannot be forced to generate a task through the ordinary goal system. The
1852nearest-existing analogue is `ap_target_scripted`/`ap_scripts[]` (`SCRIPT_SEQUENCE`,
1853patches `0009`/`0010`), which drives Link through a fixed waypoint list outside
1854goal-scoring - but those are hand-authored per room at compile time, not
1855something a host can construct at runtime for an arbitrary room's arbitrary
1856nearest door. That gap - a host-constructible, non-scored "go here, then there"
1857task - is the piece a future session needs to add before this feature can exist;
1858everything else (the per-node failure-count bookkeeping, the K-retries-then-real-
1859failure threshold) is ordinary host-side (Rust) work once that primitive exists.
1860Not attempted half-built: a partial version that LOOKS like it resets the room
1861without actually forcing the walk would be worse than not having it, per this
1862project's own standard for what counts as done.
1863
1864## A door with no vestibule ($0089), and the resume stall it caused (2026-09-22)
1865
1866A window resumed mid-game (`resume`, indoors room `$0089`) stalled in manual
1867mode from frame 0, forever: `bot` (MCP) showed exactly 2 `EXPLORE` goals, both
1868scoring `unsatisfiable`, both targeting the room's own two north doors
1869(`door U 0x4b`). This was first suspected to be the already-known stale
1870imported map bug (`map.19.txt` disconnected from Link's real position,
18712026-09-21), and renaming the map aside was tried live - it changed the
1872symptom (`goal_count` dropped from 232 to 2, an honestly-scanned room instead
1873of a stale one) but not the stall, proving the two are different bugs.
1874
1875**Reproduced headless, deterministically**, by `save_state`-ing the live
1876stalled window and using it as a fresh `home` for `apps/zbanks`
1877(`run/zbanks-c/ep-repro/`, `--states` pointed at a directory holding only that
1878one snapshot as `home.state`): the same two doors, same `unsatisfiable`, same
1879manual mode from frame 0, every time.
1880
1881**Ruled out, with evidence, in order:**
1882
18831. *Missing cross-screen adjacency (a sibling multi-cell quadrant never
1884   linked).* A temporary, never-committed instrumentation pass on
1885   `ap_pathfind_global`/`ap_pathfind_node` (printf per call, gated on
1886   nothing so it always fires) showed `start_screen == destination->screen`
1887   (`same=1`) for both goals - the destination is on LINK'S OWN screen. The
1888   Dijkstra cross-screen graph is never even reached; `ap_pathfind_local`
1889   fails first. Any static same-`dungeon_room` pre-link (mirroring the
1890   stairs/ledge merges) would be a no-op for this bug.
18912. *A genuine local-pathfinding misclassification (a real tile wrongly
1892   scored as a wall).* A second instrumentation pass dumped
1893   `ap_pathfind_local`'s own cost grid (`raw_tile`/cost per cell) around
1894   both Link's position and the registered destination, and a flood-fill
1895   over the parsed grid confirmed the destination box is disconnected from
1896   every cell Link's own position can reach - matching the search's own
1897   verdict. But raw `ZB_HUMAN` movement (no bot in the loop) proved this
1898   ISN'T a real wall: holding LEFT/RIGHT alone walks Link's `x` to exactly
1899   each door's own `tl.x`; holding DOWN from north of the door crosses its
1900   tile and lands in a DIFFERENT ROOM (`$00a9`) within 20 frames. The grid
1901   is disconnected because the registered DESTINATION - not the path to
1902   it - is unreachable: it sits 24px past the door, on the far side of an
1903   instant, no-vestibule transition.
19043. *A mis-detected door direction.* Every door's `adjacent_direction` is
1905   guessed from which edge of its own bookkeeping screen it sits nearest
1906   (`ap_map.c`'s `d_t`/`d_l`/`d_b`/`d_r` comparison), then asserted (not
1907   corrected) against the ROM's own door-direction table
1908   (`ap_ram.dungeon_door_dirs`) - a mismatch is a silently-continued
1909   `assert_bp` (a SIGTRAP), never fixed up. Suspected here, since a wrong
1910   guess would explain both the offset and a failed `TASK_TRANSITION`.
1911   Disproven: making the fix "trust the ROM's own direction when it
1912   disagrees" produced ZERO mismatches for this door (traps: 0) - the
1913   heuristic's "U" already agrees with the ROM. The theory that `$00a9` is
1914   simply this same room's lower floor (`$0089 + 0x20`, `XYFLIPBG`'s own
1915   x match at `0x88b0`) was also checked against the real landing position
1916   and rejected: Link's `y` after the transition (`~0x147b`) is roughly
1917   1024px past where a same-quadrant floor-below would land (`~0x1068`) -
1918   this is a real, separate, distant room, not an adjacent floor.
1919
1920**Root cause and fix: every door's registered approach point is offset 24px
1921past its own tile, in its own direction, assuming a few pixels of floor
1922stand between "in front of the door" and the transition** - correct for
1923every other door this bot has ever crossed (Eastern Palace's own clear run
1924depends on it), wrong for this one, which has no vestibule at all: touching
1925its tile transitions instantly. The 24px offset therefore places the goal on
1926the far side of a line Link's own room can never reach by walking, and
1927`ap_pathfind_local` correctly, permanently, reports it unsatisfiable.
1928Patch `0014` (`ap_map.c`, room `$0089`, `tile_attr 0x4b` only): use a 0px
1929offset instead - the door's own tile, which `ap_pathfind_local` already
1930treats as passable within 3 grid-cells of Link's start (the `XYL1DIST(...)
1931<= 3` exemption every EXPLORE-a-door goal already relies on to be reachable
1932at all once Link is close), with no assumption about which side has
1933clearance.
1934
1935**Proven, headless, from the exact reproduced stall (`run/zbanks-c/ep-repro/`,
193620,000 frames, deterministic across repeated runs):** both goals go from
1937permanently `unsatisfiable` to active and pursued within the first few
1938hundred frames (`Plan: EXPLORE via GOTO_POINT`), the bot walks through the
1939door and the room changes - `$0089` (start) → `$00a8`/`$00a9` (by frame
19402,000-3,000) → `$00aa` → `$00b9` → `$00c9` → `$0105` → `$0112` → `$010b`
1941("Dam Chest", by frame 20,000) - recognizably Eastern Palace's own room
1942names appearing in the goal list (`EP Catwalk`, `EP Dodgeball`, `EP 5 pots`,
1943`EP Chest&Ledge`) along the way. `manual mode: false` at the end: the bot is
1944genuinely playing, not idling in a shorter recoverable cycle.
1945
1946## Outstanding, as of 2026-09-22 (queue after the movement/combat split)
1947
1948Recorded so the queue survives a session boundary. The coordinator split
1949this engagement's queue: a SEPARATE agent now owns movement and combat
1950(hammer-on-floor / 0012 generalized, dash as a path action, glove gating,
1951dash-when-faster, stopping a dash, melee/bow/vulnerability, projectiles -
1952its patches number from 0030) - none of that is this file's concern from
1953here. This agent kept the goal-system/planning half, patches numbered
19540016-0029.
1955
1956**Done, committed:** item 7 (bot.log real rotation via a pipe and a
1957dedicated thread, `1ddd5b0d`); item A (routines with nothing left to do -
1958the HC gate and the general chest-open check, patch `0016`, `2daf5bf4`);
1959patch `0015` (general push-block jam detection, `d34f01b1`).
1960
1961**Queued, in the order last given:**
19621. **B - Jev asked a worthless question**: frame 14674, two pots differing
1963   only in distance (27 vs 45 steps). Only ask Jev when offers differ in
1964   KIND (screen/area, goal type, item/requirement) - collapse same-kind
1965   options in the same room to "not asked: same kind, nearest taken",
1966   extending the existing equivalence merge in `goal_choice/words.rs`.
1967   Report how many of the 728 questions in `run/zbanks-window/jev.jsonl`
1968   the new rule would have skipped.
19692. **F - argmax vs. sample threshold**: frame 68593 sampled a 10% option
1970   against a clear favourite (wasted trip); frame 77784 sampled a 19%
1971   option. Decode rule: top probability >= 0.70 takes the argmax; below
1972   that, keep sampling (close calls still explore). Record which rule fired
1973   ("took favourite" / "sampled, close call") in the Record and show it in
1974   the panel badge. Unit-test both branches. NOTE: `packages/jev-http`
1975   (`ledger.rs`/`lib.rs`/`README.md`) showed uncommitted changes mid-session,
1976   not made by this agent - check `git log`/`git status` before starting;
1977   another agent may already be on this.
19783. **E - goal outcome history + narrower retry**: Jev's options at frame
1979   23806 didn't say the Dam routine had already failed here. Keep a per-goal
1980   outcome log in the host (C already has `goal->attempts`; host sees every
1981   fail/retry) and surface it in `Facts` (attempts, how each ended, frames
1982   since last try). Narrow `zb_retry_given_up`/`Recovery`'s put-back rule:
1983   only when something bearing on THIS goal's own failure changed (a
1984   requirement now held, the room reset, its routine fixed) - not on any
1985   possession change. Prove headless: no return trip to a failed goal
1986   without a relevant change.
19874. **D - general push-block puzzle solver** (do not hand-fix the Dam
1988   script): BFS over block configurations from the room's live tile
1989   attributes and block sprite positions - state is (Link region, block
1990   positions), a move is a legal push (target tile floor, not wall/another
1991   block, reusing 0015's knowledge) - search until the goal tile (chest/door
1992   approach) is reachable, then execute via existing push/GOTO machinery.
1993   Use for every push-block room; Dam Chest's routine becomes a caller.
1994   Prove headless on Dam Chest (chest's SRAM bit set) and one other
1995   push-block room. Also folds in the already-identified bug: a
1996   `SCRIPT_SEQUENCE` task finishing must not count as its goal being reached
1997   - the Dam script finished at frame 2528 with the chest still shut and the
1998   goal wrongly marked complete; a finished routine that did not reach its
1999   goal must count as a FAILURE (see this file's own section on this,
2000   above). NOTE: generalizing 0012 itself (removing its `dungeon_room ==
2001   0x010B` room check) moved to the movement/combat agent's list - this
2002   agent's own general push-block MECHANISM (0015) already stands
2003   independently of whether 0012 stays room-scoped.
20045. **Item 2** - `failure_recovery`, the `ap_goal_fail` hook, and a room-exit
2005   task, proven on Dam Chest `0x010B`.
20066. **The frame-57040 stall** - see "Paused mid-investigation" below.
20077. **Fresh-game sword with no map, plus a new canonical baseline** - see
2008   this file's own bisect finding (next paragraph): make a fresh game
2009   without a map actually get the sword, then rebuild the 90k baseline from
2010   `home` and record its milestone frames here (`s1map` is gone).
20118. **The generalized-0014 verdict** - land or drop the graduated door-offset
2012   generalization (`run/saved-patches/0014-generalized-graduated-offset.patch`)
2013   against the new baseline from item 7, and say which.
20149. **Item 5** - the sourced, web-researched location table for
2015   `goal_choice::Facts`.
2016
2017**Item 7's own finding (already recorded, still load-bearing for item 7
2018above):** the "fresh run from `home` never gets the sword" symptom is NOT a
2019code regression - a headless run at `1e493ef2` itself, with the coordinator's
2020own exact bisect protocol (fresh `--make-home`, no map, Jev off), also never
2021gets the sword out to 30,000 frames. The historical "sword ~3,000 frames"
2022figure required the missing `s1map` import giving the bot pre-existing
2023location knowledge; blind EXPLORE alone does not converge on it at any
2024commit tested. No commit needs bisecting; what is missing is either a
2025rebuilt map fixture (tracked in git this time, not gitignored `run/`, so it
2026cannot go missing again) or a real "seed goals from the current screen on a
2027mapless start" mechanism - not yet designed.
2028
2029**Paused mid-investigation, evidence preserved:** the frame-57040 EP
2030Bigchest stall (`second_stall_frame57040` save state, `roms/*.states/`).
2031Findings so far, from headless repro (`run/zbanks-c/frame57040/`) with
2032temporary, reverted debug instrumentation: (1) `ap_update_map_screen(false)`
2033resolved "current screen" as `0x04b8` while Link's own Y position
2034(`0x0538`=1336) falls inside the ADJACENT screen `0x05b8`'s range
2035(1280-1535) - an off-by-one-quadrant misresolution, seen specifically with
2036`link_lower_level` set (a multi-level room) - this alone makes
2037`GOAL_PICKUP`'s `goal->node->screen != screen` check wrongly reject pots
2038Link stands next to. (2) Even when screen resolution IS correct (observed
2039once, frame 157), `ap_pathfind_local` still returns -1 for a pot on that
2040SAME, correctly-matched screen - a genuine local A* bug independent of (1).
2041(3) The GLOBAL Dijkstra fallback ALSO fails for every goal on that screen
2042(search exhausts with no route). Root cause not yet isolated to a single
2043fix; likely candidates given `link_lower_level`'s involvement: the room's
2044BG1/BG2 layer selection or Y-origin computation for the lower level of a
2045multi-quadrant room. Next step: read `ap_update_map_screen`
2046(`ap_map.c:2363`) and the `link_lower_level` branch (`ap_map.c:273`)
2047against how the mismatched screens' own bounds were registered.