jevsnes.git / research / zbanks-alttp.md

zbanks/alttp on our console

zbanks/alttp is a bot, written in C, that plays A Link to the Past from SNES memory. Upstream it is linked into a patched Snes9x; here it is compiled from upstream's tree (third-party/c/zbanks-alttp, commit bcb2537, "Hammer/sword/lift obstacles in path in ap_follow_targets") plus the carried patches in third-party/c/patches/ and linked into our own host, packages/zbanks, which drives packages/console. This page is the contract between the two, every way our host differs from upstream's, and the evidence for each difference.

User ruling, 2026-09-21 12:34: "what you have build does NOT work. the repo i shared DOES work. i want the latter ported to rust, faithfully." Narrowed at 12:36: "it doesn't need ported to rust if you can wrap and run the C." So the bot's own code is not edited by the host; everything below is host, except the carried patches in third-party/c/patches/: two regressions in upstream's own last commits ("The stairs", "The Armos Knights"), the goal choice hook, and the $B8 room - each a change no host can make.

Citations are file:line in third-party/c/zbanks-alttp/ unless another repository is named. Reference clones: ~/src/github.com/zbanks/alttp, spannerisms/usdasm and spannerisms/jpdasm (the USA and Japanese disassemblies), sporchia/alttp_vt_randomizer (219fcaf) and KatDevsGames/z3randomizer (the Randomizer's patcher and its ASM).

The host contract

Upstream's host is snes9x.patch (unix/unix.cpp), and it gives the bot exactly this:

UpstreamWhat it isOurs (packages/zbanks/src/lib.rs)
struct ap_snes9x (alttp_public.h:10-15)Four fields: base, save, load, info_string_ptr.ApSnes9x, #[repr(C)], same order.
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.
save(name) / load(name) = S9xFreezeGame / S9xUnfreezeGameNamed 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.
info_string_ptr = &GFX.InfoStringSnes9x draws this string over the picture. The bot points it at ap_info_string every tick (alttp.c:15).A slot the panels read.
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.
joypad wordSnes9x 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).

What base() is asked for (every X(...) in AP_RAM_LIST, ap_snes.h:40-176, plus the three literal lookups in ap_map.c):

  • work RAM, $7E0000-$7FFFFF, and its mirror in bank $00 below $2000 ($000438, $00047E, ... $0006B0);
  • ROM tables in LoROM banks $01, $02, $06, $0F, $1B;
  • nothing else - no cartridge SRAM, no VRAM, no I/O. A request for anything else is counted (Report::stray_reads) and has always been 0.

assert_bp(x) (ap_macro.h:79) executes int3 when x is false - a breakpoint for the gdb upstream ran under. Outside a debugger that is SIGTRAP, which kills the process. shim/host.c installs a SIGTRAP handler that counts the trap, keeps its address and returns: exactly gdb's continue. Plain assert is left alone and still aborts.

The bot writes debug files (goals.txt, map, map.pbm, full_map.dot, screens.txt, ...) with bare relative names (ap_map.c:546-547, 653, 1639, 1645, 1732, 1761, 1769, 1877, 1884, 1899, ap_plan.c:37). The library is compiled with -Dfopen=zb_fopen -Drename=zb_rename, which sends them into the directory the host names (run/zbanks-c/<run>/ headless, run/zbanks-window/ in the app) instead of the repo root.

Build flags are upstream's Makefile's, with three changes a newer compiler forces (third-party/c/BUCK): -fcommon (ap_snes.h:32 defines bool ap_manual_mode; in a header; gcc < 10 merged it, clang 21 fails the link), no -Werror, and the two defines above.

Vanilla is not the Randomizer

Upstream played the Randomizer, "Open mode, Sword on Uncle, No Glitches, Defeat Ganon", cheating "infinite health, bombs, and arrows" (README). Our cartridge is USA 1.0, SHA-1 6d4f10a8…. Each difference that reaches the bot, and what the host does about it:

ROM layout: the Randomizer is built on the JAPANESE 1.0 ROM

The Randomizer requires "Zelda no Densetsu: Kamigami no Triforce (Japan) v1.0", CRC32 0x777AAC3F (alttpr.com/en/start; pyz3r). Work RAM is laid out the same in both, but ROM code and data moved, and the bot hardcodes Japanese ROM addresses. The first run on our ROM died at once on assert(n_chests == 0 || chest_index != 168) (ap_map.c:3201): it searched the chest table at $01E96C, which in the USA ROM starts two bytes later.

Every ROM table the bot reads was located in the USA ROM by taking its bytes from the Japanese disassembly (jpdasm) and searching our image, then checked byte-for-byte over the whole table:

JapaneseUSATableBot's name
$01E96C$01E96ERoomData_ChestItems, 168 × 3dungeon_chests
$02CCBD$02CF59EntranceData Yentrance_ys
$02CDC7$02D063EntranceData Xentrance_xs
$02EB29$02EDC5.bombable_door_locationover_overlay_map16s
$06F735..$06F7D56 bytes lowersprite hitbox tables, 6 × 0x20hitbox_*
$0FFD94$0E9459OverworldTileTypes, 0x200over_tattr2, and LOAD2(0xFFD94 + x) (ap_map.c:417; its own comment cites the USA DATA_0E9459)
$0F8000sameMap16DefinitionsLOAD2(0xF8000 + x) - the USA table has six more entries, but work RAM holds USA map16 numbers, so the USA table is the right one
$1BB800..$1BBB73, $1BF110sameoverworld entrance / hole tables, tile typesover_hle_*, over_ent_*, over_tattr

base() answers each Japanese address from the USA table (JP_TO_US).

Open mode: a save past the rain, and uncle who does not bring it back

Open mode is in the ROM and the save, not in the bot. The Randomizer's Rom::setOpenMode (alttp_vt_randomizer app/Rom.php:1554-1563) writes the initial save: game state $7EF3C5 = 2 (Zelda rescued, so no rain), progress flags $7EF3C6 |= 0x14, start at Link's house $7EF3C8 = 1, castle gate open (overworld $1B |= 0x20); every Randomizer file also starts with the Kakariko bomb hut and brewery open and ability flags 0x68 (InitialSram.php:24-31). zbanks::rando::preset_file puts those bytes into save file 1 and redoes its checksum (sum of words = 0x5A5A, usdasm bank_00.asm:1698-1718).

In vanilla, uncle spawns in the secret passage while $7EF3C6 bit 0 is clear, whatever the game state (SpritePrep_Uncle, usdasm bank_05.asm:16216-16240), so "Sword on Uncle" works on the preset save as it does in the Randomizer - but vanilla Uncle_GrantEquipment then stores 1 to the game state ($05DF65, bank_05.asm:17083-17084), which brings the rain back. The Randomizer NOPs those six bytes (z3randomizer hooks.asm:1560-1562, "Open Mode Fixes"); zbanks::rando::patch_rom does the same to our image (the first row of PATCHES). Vanilla uncle gives the fighter sword AND shield (ITEMGET 00); the Randomizer's gives one item.

Uncle also speaks first. Uncle_LyingInDefeat calls ShowMessageOnContact with message $0E and only moves on to Uncle_GrantEquipment when it returns carry (usdasm bank_05.asm:17042-17059). The Randomizer blanks that message: uncle_dying_sewer is in Text::removeUnwanted's list and becomes {NOTEXT} (alttp_vt_randomizer app/Text.php:1096, 1107). The bot relies on it: its TALK_NPC task ends the moment uncle is touched ("Return success early from the uncle", ap_plan.c:466-469), and the next task clears A, so a vanilla text box holds it for good - measured: frame 13,000 onward, module $0E, "Unnh... G, I didn't want you involved in this...". rando::PATCHES replaces that JSL ShowMessageOnContact ($05DF34) with the contact test the routine itself starts with, JSL CheckDamageToLink_same_layer_long ($06F129, bank_05.asm:17556): the same carry on touching him, no box.

Measured before this was done: from a vanilla rain-state start the bot walked out of the house and stood in the first hint soldier's text box for the rest of the run.

No item-get text

In vanilla, receiving an item shows a message looked up in Ancilla22_ItemReceipt .message ($08C2DD, usdasm bank_08.asm:13233-13310). The bot cannot close one: OPEN_CHEST finishes once the item is received (ap_plan.c:445-447), the next task follows targets, and ap_follow_targets clears A on every frame it is not lifting or cutting (ap_map.c:825), so the text holds Link still until the goal times out and is permanently failed. Measured: a heart piece's message held the bot from frame 12660 until it gave up at frame 17237 ("No goals available; falling back to manual mode", ap_plan.c:919).

Upstream's own 24-minute video (the media branch's alttp_20200104.mp4, 1463 s at 60 fps, ~45 item checks) was sampled 20 times a second for a text box - a white border 25+ pixels tall at both of the box's side columns, which matches our own text-box frames - and has none (4 hits, all four castle walls on inspection): its Randomizer build shows no item text. The Randomizer's own source says the same: "Item pickup text is skipped by a change we will keep" (alttp_vt_randomizer app/Text.php:958, in removeUnwanted, which also blanks the escort messages). zbanks::rando::PATCHES makes vanilla show none either, in all three places it would:

USA addressVanillaPatchedWhat
$08C2DDone message id per item, 0x4C wordsall $FFFF ("no message", bank_08.asm:13773-13775)Ancilla22_ItemReceipt .message (bank_08.asm:13233-13310)
$08C380$0155 $0156 $0157$FFFF ×3.heart_piece_message, a heart piece from a chest (bank_08.asm:13322-13326, 13754-13756)
$05F0BCJSL ShowMessageUnconditional4 × NOPa heart piece lying on the ground (Sprite_EB_HeartPiece, bank_05.asm:20413-20422)

The pendant message after all three pendants (.pendant_message, bank_08.asm:13722-13733) is left alone: the bot is nowhere near it yet.

The Kakariko informant: a box the bot walks into (not a Randomizer difference)

In Kakariko, while the game state is 2 (which open mode sets), two informants wander: the young and the old "snitch", sprites $34 and $3D (state-2 sprite list, usdasm bank_09.asm:15742-15755). Touching one runs Snitch_Meander's JSL ShowMessageOnContact with message $2F, "Here is [LINK], the wanted man! Soldiers! Anyone! Come quickly!" ($05E776, bank_05.asm:18772-18789; text.asm:1315-1318), then CallThePolice.

The Randomizer keeps it: kakariko_alert_guards is retexted ("Guards! Help! The creeper @ is here!", alttp_vt_randomizer app/Text.php:167) and is not in removeUnwanted's {NOTEXT} list (Text.php:931-1127); z3randomizer touches neither sprite. So upstream could meet the same box. Why its run never did: the bot treats both informants as obstacles ($34 BLKF, $3D BLKS, ap_snes.h:528, 537) and paths round where they stand; the box opens only if one walks INTO Link. Upstream's video is in Kakariko four times (1220-1232 s, 1268-1275 s, 1321-1333 s, 1351-1367 s) and at 1354 s leaves the chicken house one tile from the old informant and steps round her - never touched. Ours was walked into (run s1map, frame 22,907: the young informant moves west into Link on a GOTO_POINT). The bot does try to close dialog - ap_task_evaluate mashes A in module $0E (ap_plan.c:357-366) - but the movement task then calls ap_follow_targets, which clears A on the same frame (ap_map.c:825): 11,648 "Mashing dialog" lines, then manual mode.

Host-level fix, two rando::PATCHES rows: the JSL ShowMessageOnContact becomes JSL CheckDamageToLink_same_layer_long ($06F129), as for uncle - the same carry on contact, so he still calls the guards and runs - and the TYA : STA $0DE0,X after it goes, because the contact test leaves $0E40,X ($82) in Y where the message routine left a facing (bank_06.asm: 06F16F-06F172). This removes a text box, not a behaviour. Measured: run k2 (home remade from the patched ROM, iter2's map - which is what s1map imported, not iter3's as this page once said) matches s1map frame for frame through 22,000; at 22,907 the informant touches Link and a guard ($45) spawns in both runs; s1map sits in the box to manual mode, k2 walks on and is out of Kakariko by 25,000 (run/zbanks-c/k2/ frame-023000.png: informant, guard, no box).

Cheats

ap_tick itself writes, every frame, full health, 10 bombs, 10 arrows, the hammer and the lamp (alttp.c:20-26). They are the bot's code and run unchanged; the host's only part is carrying the writes back into the console. Vanilla has no "infinite" setting; these writes are it.

No map file

ap_tick imports map.19.txt on its first frame (alttp.c:31-38): a map of screens, nodes and tile attributes exported by an earlier run (ap_map_export, ap_map.c:3471-3499). It is not in the repository (upstream never committed it), and ap_map_import returns quietly when the file is absent (ap_map.c:3502-3503). Upstream's video starts with goals the bot could only know from that map (it heads straight for uncle's NPC goal on leaving the house); ours starts knowing nothing and explores. Nothing is substituted for it; as upstream did, a run's map_export.txt seeds the next (--import-map).

The starting state

ap_init loads the Snes9x savestate "home", then "hpegs" (ap_snes.c:66, 81). Neither exists here. The bot never presses anything outside the play modules - ap_tick zeroes the pad and returns for any other module (alttp.c:64-99) - so it cannot get itself from power-on to a game. apps/zbanks --make-home home makes the equivalent of upstream's "home": create file 1 from power-on the vanilla way, apply the open-mode preset to the save, boot again, start file 1 (choosing Link's house at the spawn select), and keep the machine once Link stands in his house and can move. "hpegs" stays absent, so that load fails as a missing file would in Snes9x.

The stairs: a regression in upstream's last commits (carried patch 0001)

Why ours never got the sword, found 2026-09-21 afternoon. The bot did reach uncle's room ($55, the secret passage, by its east-side stairs, entrance $32) - and then climbed its intra-room staircase and walked straight back down, for thousands of frames, until the goal was failed for good. Traced frame by frame (ZB_TRACE, and gdb on the static target list):

  • the bot pathfinds a target every 8 pixels up the column, straight across the stair tiles (attribute $3D, walkable, ap_snes.h:264);
  • stepping on the stairs starts Module07_10_SouthIntraRoomStairs, which carries Link ~40 pixels in ~60 frames and keeps its own step in $B0 (usdasm bank_02.asm:2926-3008);
  • ap_tick returns early whenever $B0 != 0 (alttp.c:78-82, "load in progress"), so the stairs case two lines below it - "Stairs; keep following targets" (alttp.c:85-89) - never runs;
  • at the top the next unconsumed target is back on the stairs (Link at y $0B8E, target $0BB0; ap_follow_targets looks only one target ahead, ap_map.c:750-763), so the bot presses DOWN, rides the stairs down, presses UP, and so on. The same loop is the one seen earlier in cave $10A.

Upstream's video does not do this, and its own history says why: the video was recorded at commit 7add0e6 (2020-01-04), and the $B0 early return arrived in b9f7eee (2020-02-02, "Can almost clear aghanim's tower"; git log -S"sub_submodule_index != 0x00" -- alttp.c). The video's info line agrees: through the climb it reads "Plan: NPC via GOTO_POINT (517)", never the "main: 0x7; sub: 0x10; subsub: 0x1" the early return writes. So bcb2537 itself, on any host, walks back down; nothing in save, ROM or map can undo it. third-party/c/patches/0001-follow-targets-on-intra-room-stairs.patch exempts the two intra-room stair submodules ($08, $10, both stepping $B0: bank_02.asm:2851, 2916, 2947) from the early return, which is exactly what the stairs case after it was written for. Confirmed before patching by forcing one re-path at the top in gdb: the bot then walked to uncle.

Eastern Palace $B8: the big key room (carried patch 0003)

Run s2a stood in room $B8 from frame ~36,400 to its end at 50,000. Not a locked door: Link was pinned under the room's anti-fairy circle (s2a/frame-040000.png). What the room is (usdasm): the circle (Sprite_82_AntifairyCircle, bank_1E.asm:12749-12799, HP 255, bank_0D.asm:6184) orbits (0x180, 0x0D0), the very spot of the pot the floor switch is under (bank_01.asm:18537); the room's tag $27 is "switch makes the chest appear" (ap_snes.h:1029), and that chest holds the Eastern Palace big key (ITEMGET $32, bank_01.asm:19720). The circle breaks up into roaming anti-fairies (sprite $15) only once CheckIfRoomIsClear passes - the Eyegore and Stalfos dead. So vanilla's big key needs the room cleared first.

What upstream's bot does with it: $82 is a plain SPRITE_ATTR_ENMY (ap_snes.h:611), which the path planner only costs softly (ap_map.c: 1192-1201), so paths to the pot and to the east door run through the ring and Link is knocked back every attempt ("Link state: 0x2"; ~20 timeouts on L:8380,16f0; 83c0,1680); the tag $27 handler walks to the switch (ap_plan.c:298-316) and never kills anything; and SCRIPT_KILLALL targets the first active sprite (ap_plan.c:659-667), which the ring's sprites are - it would swing at them forever.

Why upstream never met it: it is the same room in the Randomizer, which moves the big key but not the room's sprites. Upstream's video (sampled at 1 fps, 700-1030 s) opens the Eastern Palace big chest at ~816 s without ever entering $B8: its seed's big key was elsewhere. Ours is here, and s2a had already met the big chest without it at 33,000.

Why a carried patch and not the host: there is no host difference to undo (same room, same sprites, same save), and the only thing a host could change is the room - deleting the circle or the enemies from the cartridge

  • which is changing the game rather than hosting it. The informant patch above removes a text box and keeps what he does; this would remove a puzzle. 0003-anti-fairy-circle.patch, in upstream's own idiom:
  1. $82 becomes ENMY | NVUL | BLKF (invulnerable, blocking), and $15, the anti-fairy it breaks into, ENMY | NVUL - the game does not count them when it decides a room is clear either.
  2. Kill-all skips NVUL sprites.
  3. A SCRIPT_KILLALL entry, "EP clear big key room", at (0x8380, 0x1730) below the circle, in upstream's per-room script table (ap_map.c:36-), beside its "HC kill guard for key".
  4. Kill-all no longer fails its goal on the first frame an enemy has no path to it (A* fails for a Stalfos beside a pot): it tries the others, then waits 8 frames and looks again, and fails after 32 such misses. Without it run b4 permafailed the new script within two frames (three "ap_pathfind_sprite failed" at 37,045-37,046).

Measured, Jev off, home remade from the patched ROM (states3), s1map's map - the same start as s2a: b1 (part 1 only) no longer pinned but the switch pot unreachable, so the chest goal stays unsatisfiable and the bot leaves; b3 (1-3) clears the Eyegore and Stalfos, then swings at the dispersed $15s; b5 (all four): script from 37,045, "Done killing" at 40,155, pot 0x72 lifted and the switch found at 40,571, the big key chest opened at 40,702, the big key door north out of $B8 at 41,241 and the one in the big chest room at 42,280 (b5/frame-041000.png: the chest open on the dais, anti-fairies roaming).

The Armos Knights: the bow shot cancelled (carried patch 0004)

Run j9 fought the Armos Knights (room $C8, upstream's own "EP Armos Knights Boss" kill-all, ap_map.c:152-158) from ~+16,500 to its end and killed one of six. Measured before changing anything, from j9's last frame as home (run/zbanks-c/states-armos), j9's map, Jev off: run a0, 12,000 frames, not one arrow in the log's ancilla dumps and five knights at HP 48 throughout; Link walked into them and was knocked back ("Link state: 0x2"). Not sword level, not the bow missing (equipped at the fight's start, "SET_INVENTORY start item: BOW"), not knockback as such:

  • The knights are SPRITE_ATTR_VBOW (ap_snes.h:561), so kill-all fights them with the bow: on each retarget frame it presses Y (JOYPAD_MASH(Y, 0x08), ap_plan.c) and suppresses the sword (no_sword), then follows targets toward the knight.
  • ap_follow_targets's last branch, when the next tile needs no lift, cut or hammer, is JOYPAD_CLEAR(A); JOYPAD_CLEAR(B); JOYPAD_CLEAR(Y); (ap_map.c:835-839). ap_tick zeroes the pad every frame (alttp.c:64), so the only Y it can ever clear is one the caller set just before: the bow shot, cancelled on the frame it was pressed.
  • That branch arrived in upstream's LAST commit, bcb2537 (2020-02-06, "Hammer/sword/lift obstacles in path"; git log -S'JOYPAD_CLEAR(Y);' -- ap_map.c). Upstream killed the Armos Knights at 8f13023 (2020-01-02, "Kill first boss: Armos Knights") and recorded its video at 7add0e6 (2020-01-04) - both with the same kill-all code and no such line. The same story as the stairs: a regression in the last commits, which no host can undo, because the Y is set and cleared inside one ap_tick.

third-party/c/patches/0004-bow-shot-survives-follow-targets.patch drops the JOYPAD_CLEAR(Y) and nothing else. Same start, patched (run a1): 62 arrow ancillae logged, all five knights dead and "Waiting for crystal to drop" at 3,000, the pendant held up at 3,500 (run/zbanks-c/a1/ frame-003500.png), then out of the boss room to the dungeon's remaining chests.

Goals given up too early come back (host)

Run b5 tried the Eastern Palace big chest four times at ~33,000 - before the big key - and never again: at 40,702 it had the key, and at 51,780 it gave up the game (manual mode) in front of the red Eyegore room, which needs the bow the chest holds. From source (bcb2537):

  • ap_goal_fail (ap_plan.c:917-926) counts an attempt and past three LL_EXTRACTs the goal - off the list for good, never freed. Nothing puts one back: not item pickup, not requirement changes, not map import (which only creates fresh goals, once per process). git log -S over upstream's whole history finds no reset of attempts ever.
  • The bot does not know a big chest needs the big key: chest_type = 1 is set for big chests (ap_map.c:3233-3238) and never read; no big-key requirement is put on GOAL_CHEST; ap_node_islocked checks the big key only for key blocks and big-key doors. So the chest looks open, OPEN_CHEST presses A, nothing arrives, the task times out, and after four the goal is gone.
  • Upstream's TODO still lists "Reset goal attempts count when re-visiting screens (maybe a bad idea?)" (added 488a6cf, 2019-12-01; "(maybe a bad idea?)" appended in ceb3ac0, 2019-12-31; never done). There are no commits after bcb2537 on any branch but the video commit. Its Randomizer run never met this because that seed's big key was not in $B8 (see "Eastern Palace $B8"); its only "reset" was restarting the process.

Host fix, not a patch. The shim watches the goal list after every tick (zb_watch_goals): a goal that has left it with attempts > 3 was permafailed (a completed one leaves with at most three). When Link's possessions change - items, keys, big keys, pendants, crystals, progress (zbanks::POSSESSIONS), never rupees, health or arrows - every given-up goal is put back through the bot's own ap_goal_add, fresh, and the bot's log says so and why. Re-visiting screens (upstream's idea) is the wrong trigger: a goal that fails for a reason a visit does not change would be retried forever. A change in what Link has is the thing that can make a failed goal succeed, and it bounds the retries: four attempts per change. --no-retry is upstream's behaviour.

Measured, Jev off, b5's exact start (states3 home, s1map's map), with this and carried patch 0004 (run r1, 70,000 frames): the big chest is permafailed at 33,075 exactly as in b5; the next small key ("$7EF36F 00->01") puts it back; big key 40,702; big chest (the bow) 42,146; "EP Igor Key" 44,133; "EP Red Igor Room" 48,095 (where b5 gave up); Armos Knights 54,080, the pendant on the floor at 54,000 (r1/frame-054000.png); two more chests; still playing at 70,000.

Starting mid-game: goals the save says are done (host)

Upstream always started from its "home", a fresh save, so every node its imported map recreated was still to do. We also start mid-game - the window's resume, a later headless run's home - with the map the bot built imported, and then an imported NPC can be done already. Run o1 (from r1's last frame, r1's map, Jev off) walked back to uncle's spot in the secret passage for the NPC goal the import recreated, and died at frame 21,758: uncle is gone once $7EF3C6 bit 0 is set (SpritePrep_Uncle, usdasm bank_05.asm:16216-16240); TALK_NPC takes a nonzero $02D8 - still the last item Link received - as uncle's gift (ap_plan.c:483-487); and the item tracker, which already has uncle's location down as the sword (ap_item.c:492-495), aborts on assert(item_loc->item->type == item_type) (ap_item.c:518). In the window that abort would close the window.

Chests and pots the bot re-checks against the save itself (sram_room_state, the tile under a pot); an NPC it cannot. ap_map.c, where every goal is made, is compiled with -Dap_goal_add=zb_goal_add (third-party/c/BUCK, the same mechanism as -Dfopen=zb_fopen), and the host's zb_goal_add declines an NPC goal for uncle when $7EF3C6 bit 0 is set, then calls the real ap_goal_add. Run o2, same start: "not adding goal for sprite 0x73.0x100: done in this save", no abort, 83,580 frames.

Sahasrahla: the green pendant is bit 0x04 (carried patch 0005)

o2 then played 83,580 frames after the pendant without once going to Sahasrahla, and gave up. His NPC goal requires REQUIREMENT_GREEN_PENDANT (ap_plan.c:133-136), which the bot sets from $7EF374 & 0x01 (ap_req.c:58). The Eastern Palace gave $7EF374 = 0x04 (o2/progress.tsv, pendants). The layout is "- - - - - g b r" (z3randomizer sram.asm:139; jpdasm symbols_sram.asm:779-781): 0x01 is red, 0x04 green. Vanilla Sahasrahla tests AND.b #$04 (usdasm bank_05.asm:20560-20562), and so does the Randomizer (dialog.asm:355) - upstream's 0x01 is wrong in both games, and its seed must simply have met it some other way. The only user of the requirement is that goal. No host fix: the bot reads the byte itself. 0005-green-pendant-is-bit-4.patch tests 0x04.

The boots: walking up to Sahasrahla, and talking until he gives (carried patches 0006-0008)

With patch 0005 the goal was chosen (run o3, 55,233) and failed, and each fix exposed the next step of the same errand. Every one was isolated by replaying o3 exactly (Jev off replays frame for frame) to Sahasrahla's door at 59,300 (--save-at) with the change gated on ap_frame >= 59300 - a throwaway build (runs o3c), since an ungated pathing change moves the whole run from its first frame.

  1. No path to him. "A* failed, min heuristic: 2" with Sahasrahla standing there (sprite 0, 0x16.0 BLKF|TALK|NODE). ap_pathfind_local makes a blocking sprite's hitbox plus one 8-pixel cell impassable (ap_map.c:1204- 1231); a sprite node's box is the sprite's own box (ap_map_add_sprite_nodes_to_screen); the goal test needs a cell inside it; the margin rings it. Unblocking only the box's cells was not enough (the hitbox sticks out of the box). Fix: the sprite being walked to does not block that search.
  2. Never "there". XYLINKIN wants Link's whole 16x16 box inside the node box, and his body is solid: Link stopped at (6a78,2190), 8 pixels short, "Stuck Link Detected", timeout. Fix: at a talking sprite, within 8 pixels is arrived - TALK_NPC presses up and A from there, as upstream wrote it. (Upstream's own earlier answer, a node placed below the sprite, is commented out at ap_map.c, from 072b195.)
  3. A false gift. TALK_NPC counted any nonzero $02D8 as the NPC's item. $02D8 keeps the last item Link got (0x17 here), so the talk "succeeded" in one frame and left the text box it had opened with nothing pressing A - the same stale read that made o1 abort at uncle. And the real gift is received while Link holds it up, when ap_tick does not evaluate tasks at all (alttp.c:126-127); by the next task frame $02D8 is 0. Fix: a gift is a change in what Link has ($7EF340-$7EF35F, less the bomb count) since the talk began.
  4. The talk timed out mid-text. Sahasrahla's text outlasts TALK_NPC's 64 frames, and a goal failed mid-text left the box open. Fix: while a text box is up, the talk does not time out.
  5. Walled in afterwards. Link ends pressed against him, inside his margin, and every search from there failed: every goal unsatisfiable, manual mode at once. Fix: a sprite whose margin Link already stands in blocks only its own cells.
  6. Pushing along his body. The first step of each new path was a 4-pixel sidestep into his solid body (69 "Stuck Link"). Link can walk away (checked with ZB_HUMAN=100-160:0x0440, X+down: y 0x2188 to 0x21d8 in 60 frames). Fix: when the talk is done, step down off the NPC for 20 frames.
  7. A dash in a text box. With the boots, the A that closes his last text box starts a dash ($5D = 0x11, kPlayerState_StartDash, zelda3 player.h:21; the bot's enum calls 0x11 FALLING_LEDGE), ap_tick only plans - and so only mashes dialog - on the ground, and the box stayed open for good (o3c/frame-065000.png). Fix: an open text box gets A in any state.

1, 2 and 5 are 0006-walk-up-to-a-blocking-npc.patch; 3, 4 and 6 0007-talk-to-an-npc-until-it-gives.patch; 7 0008-text-box-gets-a-in-any-state.patch. With all of them, gated (last o3c): the boots at ~60,500, out of the hut, exploring for 40,000 frames (areas $2C, $34, $18, $1B, the Eastern Palace again) until manual mode at 100,620.

What stops it next: the Book of Mudora. It sits on the library's bookshelf and falls only to a dash into the shelf. The bot has no dash: nothing in it holds A to run; the boots appear only as a requirement on TILE_ATTR_BONK explore goals (ap_plan.c:142-143). The Desert Palace entrance needs the book (door 0x09, ap_plan.c:152-155), so the bot's goal system has nothing past this point but what it has already done.

The castle passage door: a second stairs-shaped regression (fixed 2026-09-21, patch 0006)

Found 2026-09-21 night: every fresh apps/zbanks run from the canonical home/hpegs states froze at exactly link_x,y = 0x0500,0x0277 in room $0012, the small connecting room right past the Hyrule Castle secret passage's door (door D 0x8e, screen 0x0250). GOTO_POINT/TRANSITION toward that door repeated forever, position frozen, until the goal permafailed and the run gave up without the sword - the same shape as "The stairs" above: a place the bot reaches every time and never crosses.

Bisected, not reasoned about, by rebuilding at each commit between the last known-good run (j9, 15:52-15:55, HEAD then was 8a32e63d) and HEAD, and replaying the identical canonical home/hpegs (made once at 15:55 and untouched since) headless for 45,000 frames each time:

CommitResult
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).
ee2cf60a (patch 0005 + the mid-game-uncle host fix)Same as above, frame 36,212. Not the regression.
33246459 (patches 0006-0008, Sahasrahla's boots)Frozen at 0x0500,0x0277, room $0012, gives up at frame 32,359.
4ea2b00b (HEAD)Same freeze, same frame, 32,359.

33246459 is a direct child of ee2cf60a - one commit, three patch files. Rebuilding with only 0006-walk-up-to-a-blocking-npc.patch present (0007 and 0008 removed) reproduced the exact same freeze; 0007/0008 are not involved.

The cause: patch 0006's ap_map.c hunk is scoped to a coordinate overlap, not to the sprite it was written for. The hunk changes the loop in ap_pathfind_local that marks every BLKF/BLKS sprite's hitbox impassable - generic code, called for every pathfind toward every kind of destination (a door, a chest, a sprite). Its destination/inside guards were meant to stop a TALK sprite's own margin from blocking the search for itself (Sahasrahla, 0x16), by comparing that sprite's hitbox against dst_tl/dst_br, the CURRENT call's destination region - but with no check that the sprite being tested is the thing the search is actually walking up to. Room $0012 holds a BLKF sprite (type 0x73, the same template type uncle uses in room $55, but not TALK-attributed here - ap_snes.h's per-type override at .type = 0x73, .subtype = 0x0100, .only_dungeon_room = 0x55 only adds SPRITE_ATTR_TALK inside uncle's own room) that happens to overlap the door's destination box for an ordinary door D 0x8e pathfind. Patch 0006 made that sprite transparent to the SEARCH for that pathfind; it stayed solid to the real engine, so the computed path walked Link into it and the real collision held him there forever - exactly what a search that no longer treats an obstacle as an obstacle produces.

Fix: gate both destination and inside on SPRITE_ATTR_TALK (ap_sprites[i].attrs & SPRITE_ATTR_TALK), so the exemption only ever applies to a TALK sprite - the one case 0006 was written for. Sahasrahla (0x16) has SPRITE_ATTR_TALK unconditionally, so this is a no-op for the scenario 0006 fixes; room $0012's non-TALK 0x73 (and any other non-talking blocking sprite that happens to sit near a destination) stays solid to the search, as the real engine has it.

Measured with the fix, headless, canonical home/hpegs, s1map's imported map (Jev off), 90,000 frames: no freeze at 0x0500,0x0277 (0 occurrences, against hundreds before the fix); through the passage and past room $0012 by frame 3,000 with the sword; bow (the Eastern Palace big chest) at frame 42,600; green pendant ($7EF374 = 0x04) at frame 54,600; Sahasrahla's boots at frame 58,200 - all within the range of the best pre-regression runs (j10: sword 2,719, bow 43,714, pendant ~59,000, boots 62,499) - and still playing, sword+bow+pendant+boots all held, no manual mode, at frame 90,000 (fighting through a SCRIPT_KILLALL and a hole drop in dungeon room $0123), further than any pre-regression run got (j10/v1 both gave up between 83,280 and 86,760).

The window's stall: not the Armos Knights, a real physical wedge in room $0012 (2026-09-21 night)

The live window sat in manual mode for over an hour, reported as "the Eastern Palace Armos Knights room" from its own bot MCP tool's task list ending in SCRIPT_KILLALL ... EP Armos Knights Boss. That task list is a STACK, not the current position: read_wram and state over MCP showed the true, live position - link_x,y = 0x0500,0x0277, room $0012, the small room just past the Hyrule Castle secret passage's own door (door D 0x8e, screen 0x0250) - the Armos task was queued, unreached, twenty-some steps down a plan that never got past its first one. $7EF374 = 0x04 (the green pendant, read live) proved Eastern Palace was already cleared long before this stall; the boss-room framing was a misreading of the task stack, not the ground truth.

Reproduced headless from a copy of the window's own resume.state (made its own home in a scratch states directory): the SAME freeze, same position, at both main (patch 0006 fixed) and ee2cf60a (before 0006 ever existed) - ruling out patch 0006 as the cause here, unlike the castle-passage regression above.

Proven a real physical dead end, not a search bug, with ZB_HUMAN (needs apps/zbanks built from a commit that has it, >= 33246459 - testing this at ee2cf60a first gave a false "nothing moves" result because that commit's main.rs does not read ZB_HUMAN at all and passes 0 to every tick regardless, apps/zbanks/src/main.rs:151 on main vs. the hardcoded 0 at the same call on ee2cf60a - always rebuild from the commit under test): holding UP, DOWN, LEFT, RIGHT and A alone for 200 frames each, raw pad, no bot in the loop, moved Link zero pixels in every case. A frame-by-frame trace (ZB_TRACE) during the A-alone test showed why: with the Pegasus Boots already owned ($7EF355 = 1), A with nothing to interact with starts an automatic dash charge (link_state = 0x11, kPlayerState_StartDash - see "The Book of Mudora" above), and the charge itself never resolves either - it sits in module $0E sub $02 for 800+ more frames. Two independent room-relative sprite dumps of $0012 (this session's and an earlier one from before any of tonight's patches) agree: Sprite 0 (type=0x73, subtype=0x200, BLKS) and Sprite 1 (type=0x76, subtype=0x200, BLKF|FLLW - Princess Zelda's own sprite type, ap_snes.h:598) sit within single-digit pixels of Link, and upstream's own ap_snes.h has a ROOM-SPECIFIC override baked in for this exact room - { .type = 0x73, .subtype = 0x0000/0x0100, .only_dungeon_room = 0x12, .attrs = SPRITE_ATTR_BLKF } (ap_snes.h:748-749) - meaning upstream's own authors already knew room $12 puts blocking sprites where a generic type 0x73 elsewhere would not be one. A live screenshot (frame MCP tool) shows why: a small vestibule, a carved idol centred at the top, and Link boxed between two flanking figures with no visible gap on either side.

Not a resume/goal-state bug and not fixable in the C bot's own logic: a fresh, continuous walkthrough of this same room (this session's own verify-0006-fix/verify-main-fix runs, 20,000-90,000 frames) crosses it without incident every time - the wedge is a property of the EXACT pixel Link was standing on when something (a periodic resume save, or genuine natural play) left him there, not a property of the room itself. Since raw, un-bot-mediated input in every direction fails identically, no pathfinding or goal-retry change can reach a cell that plain movement cannot leave - this is a real ALTTP engine dead end, the kind a human player escapes by power-cycling the console.

Recovery: power_cycle (dev-mode MCP tool; packages/console's hard_reset, which reloads the cartridge's own SRAM, so items are not at risk) escapes the position but lands at the title screen, and this specific attempt could not navigate back through it - Module14_Attract (~/src/github.com/snesrev/zelda3/src/attract.c:372-386) only accepts Start/B during particular attract_state values (not 0, 2 or 6) and while INIDISP_copy is set, and mashing Start on a fixed cadence for several thousand frames never landed in a window where the demo yielded to input on this run - not chased further, since load_state("home") (a name already kept beside the ROM, and confirmed compatible with the current patched ROM CRC where two older named checkpoints, has-sword/outside, were not - "snapshot is of cartridge 777AAC2F, this is 173FFEC8", made before a later ROM-patch change) reaches an ordinary, controllable, playable state directly with no menu navigation at all. Cost: the session's sword/pendant/boots progress, traded for an unstuck, playing window - the alternative was leaving it in either the original wedge or a half-navigated demo loop.

A second, smaller bug found and fixed along the way: apps/native's own stall detector (apps/native/src/stall.rs) is correctly a one-way latch per its own doc comment ("a restart makes a fresh bot ... and should make a fresh Detector alongside it, so a stall from the last process never survives into the next one's frame 0") - but nothing enforced the other half of that sentence. check_stall only writes or removes stalled.json on a transition (app.stalled == was is the whole guard); a fresh process whose own Detector starts at None and stays there all its life never transitions, so a stalled.json left on disk by a PREVIOUS process (this session's own restarts included) is never touched and reads as a live stall indefinitely. Fixed (apps/native/src/app.rs, right after bot_dir is created, before zbanks::Bot::start): remove any stalled.json unconditionally on every startup, since a fresh Detector's ground truth is always "not stalled" at frame 0 and the file should say so immediately rather than carry forward a description of a process that no longer exists.

The Book of Mudora (carried patch 0009, mechanism only - not yet wired)

Confirmed there is no dash anywhere to steal: git log -S dash|Mudora|bookshelf over upstream's whole history (8192fb7..bcb2537) finds BookOfMudora, BONK/TILE_ATTR_BONK and the dash comment in ap_snes.h unchanged since they were first written, and no code that ever presses A to run. Sprite $3B ("BonkItem", which the book, a hidden key and a fake tree all share via a sub-jump on $0DC0,X) has attrs = 0 in ap_snes.h:535 - invisible to the map/goal system entirely, not merely ungated.

The game's own mechanism, from usdasm and zelda3 (snesrev's C port of the disassembly, src/player.c): pressing A with the boots equipped and nothing else to interact with always attempts a dash (Link_HandleLiftables, kAbilityBitmasks[2] = 4, the boots bit) - not a location-specific move. Link_PerformDash sets kPlayerState_StartDash; LinkState_Dashing then requires A held on every frame of a ~29-frame stationary charge (if (!(joypad1L_last & kJoypadL_A)) { ...cancel... }, checked while link_countdown_for_dash counts down) - a single dropped frame cancels it. Once the charge ends Link moves under his own power at dash speed and no longer needs A held; only a NEW, different direction stops him (want_stop_dash), which is why 0009's dash-hold script characters keep holding A anyway for the whole approach - harmless, and it removes any timing window at the charge/travel boundary. BookOfMudora_WaitForBonk (bank_05.asm:22825) then needs Link's position within a small anticipatory box of the sprite AND nonzero velocity ($011A/$011C) - i.e. genuinely dashing, not walking - before the shelf's own state machine (WaitForBonk → KnockedDown → Land → GrantLiterature, which calls CancelDash_long) runs; walking collision stops Link outside contact range of the item's own hitbox, so a plain walk can never even trigger it.

Why the C bot could never do this from outside: ap_follow_targets's own per-tile logic (ap_map.c, the branch that lifts pots and swings the hammer) ends in an unconditional else { JOYPAD_CLEAR(A); ...} whenever the tile ahead needs no lift/sword/hammer - which is every frame of a dash across open floor - so even a host that set A itself would have it erased the same tick. 0009-pegasus-boots-dash.patch adds four SCRIPT_SEQUENCE characters (8 2 4 6, numpad directions, chosen not to collide with the existing <>^vABYUD letters) that hold A together with a direction in ONE target (repeat the character to cover more distance; each target only pops once Link's real position reaches it, so it keeps re-asserting both bits for however many frames that takes - the charge, then the travel), and narrows the JOYPAD_CLEAR(A) to skip only when the current target holds A AND a direction together - true only for these new characters, never for the existing 'A'/'B'/'Y' taps (which set only their own button), so no existing script's behaviour changes. Built, applies, and a 5,000-frame replay from states3+s1map (sword by 3,000, well/uncle/HC by 5,000) is unchanged from the pre-0009 shape.

Wired (carried patch 0010-book-of-mudora-goal.patch), coordinate DERIVED from ROM data, not yet live-walked. An ap_scripts[] entry now exists (ap_map.c) and ap_goal_add (ap_plan.c) requires REQUIREMENT_BOOTS for it by name, the same pattern as Sahasrahla's pendant check. Proven headless (run/zbanks-c/ scratch runs, not kept - reproduce with --import-map from any map that has tagged the Library, e.g. j10's map_export.txt): the script's node attaches to the right screen (ap_map.c:ap_screen_add_raw_node logs "attached node Script: Library Book of Mudora") and goals.txt shows it unsat Needs: [BOOTS] from a boots-less home, and limit (in range, not unsatisfiable - i.e. the requirement is satisfied and it is only a search-cost cutoff) from a boots-having save. The requirement gate is real; the IN-ROOM coordinates it dashes to are not yet proven against the real room.

  • The screen carrying the front door is already named in the bot's own ap_screen_infos[] (ap_map.c): { .id = 0x0a02, .name = "Library Yard", .add_explore_goals = true } outdoors, { .id = 0x2178, .name = "Library" } for the interior (ap_map.c's screen ids are (y>>8)<<8 | (x>>8), 256px quadrants). Entrance table entry 0x49 (~/src/github.com/JaredBrian/AsarUSALTTPDisassembly Bank02.asm:11975 Dungeon_LoadEntrance, indexed at Bank02.asm:11525 .rooms/Bank02.asm:11712 .playerY/Bank02.asm:11731 .playerX, pinned e41fef7) gives room $0107, playerY=$21D8, playerX=$0E78 - stored straight into $20/$22 by that routine, matching SpritePrep_DashItem's own hardcoded library check (room == 0x07, sub == 0x01, same repo Sprites/sprite_prep.asm:1394,1398, mirrored at walkingeyerobot/alttp-disassembly sprite_prep.asm:1394).
  • The indoor address space widens X but not Y. ap_snes.c:ap_sprites_update applies x = ((x & ~0x1FF) << 2) | (x & 0x1FF); x += 0x4000 to a sprite's hitbox X when *ap_ram.in_building, and leaves Y untouched. Applied to the entrance's raw playerX=$0E78: (0x0E78 & 0xFE00)<<2 | (0x0E78 & 0x1FF) = 0x3878, +0x4000 = 0x7878 - which lands inside the screen's own measured tags (Screen tags: … Library v 7800,2100 x 78ff,21ff, logged by ap_map_add_constants_to_screen in every run that ever referenced the door, e.g. run/zbanks-c/b3/bot.log:4718). playerY=$21D8 needs no transform and is already inside the same tags' Y range. This is the cross-check the earlier attempt at this (see history below) lacked.
  • The sprite's own room-relative bytes, decoded with a control group. RoomData_Sprites_Room0107 (Bank09.asm:8050): db $15, $03, $3B for the book, and two decorative $6Ds at db $1B, $17, $6D / db $1B, $18, $6D. The disassembler's own comments read xy: { 0x030, 0x150 } for the book and { 0x170, 0x1B0 } / { 0x180, 0x1B0 } for the decorations - and byte1*16 ($03*16=0x030, $17*16=0x170, $18*16=0x180) matches the FIRST ("x") number both times, byte0*16 ($15*16=0x150, $1B*16=0x1B0 twice) matches the second ("y") both times. So the book's room-relative position is X=0x030 (48), Y=0x150 (336) - the two decorations share a Y (0x1B0) and sit 16px apart in X (0x170/0x180), which is exactly what two objects side by side on the same wall would look like, and is why a single sprite's own bytes were not trusted alone.
  • Room origin, and the book's absolute position. Local offsets under 0x200 pass through the X-widening formula unchanged (it preserves x & 0x1FF verbatim), so an offset from the room's local origin maps 1:1 onto an offset from the room's WIDE origin. Taking $7800,$2100 (the clean quadrant boundary the screen tags name) as that origin: book absolute = (0x7800+0x030, 0x2100+0x150) = (0x7830, 0x2250). The entrance spawn (0x7878,0x21D8) is 0x48 (72px) EAST and 0x78 (120px) NORTH of the book - not the "shelf on the wall opposite the door" a reflex north-dash-from-the-doorway would assume. start_tl is set to the entrance's own Y but the book's X (0x7830,0x21D8), which ordinary GOTO_POINT pathing (no dash) can reach from the door before the script's ten 2 (dash-south) characters take the ~120px remaining, with margin for the ~29-frame charge and the fact the box WaitForBonk checks is small and anticipatory rather than pixel-exact.

Why this was not live-walked this session, and what actually blocked it. Getting Link physically into the room needs boots (for the gate to matter at all) and a walk there, and every fresh apps/zbanks --states "roms/….states" run tried this session (no Jev, canonical home/hpegs, several independent attempts) never got the sword: it explores the reachable light-world screens that need no items (70+ EXPLORE goals completed, per goals.txt's own Stats: line) and then gives up, because the one NPC goal ever created in any of these runs is not uncle's - the route down through the Hyrule Castle secret passage (door D 0x8e, screen 0x0250, landing in room $0012) is walked to (position frozen exactly there, link_x,y = 0x0500,0x0277) but the transition never completes, across three-figure frame counts and multiple independent fresh attempts. run/zbanks-c/j9 (this same day, 15:52-15:55, i.e. before carried patch 0009 at 19:09) got the sword and reached the Armos Knights from a home/hpegs that has not changed since (home.state's own mtime, 15:55:43, matches home-before-informant-patch.state exactly). The correlation with 0009's timing does not hold up under reading the code, though: ap_follow_targets's new guard only changes behaviour when the CURRENT target's joypad_mask has both A and a direction bit set together, and every ordinary GOTO_POINT/TRANSITION target (as opposed to a script's) is built with joypad_mask = 0 (ap_map.c, e.g. line 681, 734, 1442) - so 0009 is provably a no-op on this path, and what actually regressed (if anything did, rather than this always being an occasional snag on this one transition) is not identified. The ap_goal_fail assert_bp (ap_plan.c:968, ap_tick's own anchor + offset 0x147cc, confirmed with addr2line) that both this stall and the LIVE WINDOW's separate, later Armos-fight stall share is the GENERIC one-per-codebase "a goal failed" tracepoint, not evidence the two are the same bug. Next step for whoever picks this up: apps/zbanks from a fresh home with ZB_TRACE bracketing the approach to door D 0x8e (screen 0x0250, room $0012) - a frame-by-frame trace the way carried patches 0001/0004 were each found, since "reaches the same standing spot every time and then never crosses it" is exactly their shape. Once past it (or from any boots-having save that already is), a plain headless run with the map above imported should walk to start_tl on its own; whether the ten-2 sequence actually lands the item is the one thing left to prove, by reading $7EF34E (the book byte) after.

How far it plays (measured 2026-09-21)

Headless, apps/zbanks, from the open-mode home state (runs under run/zbanks-c/, gitignored):

RunHost as ofFramesWhat happened
bedno ROM remap0Died on the chest-table assert, ap_map.c:3201 - the Japanese-address finding.
r1remap, vanilla rain start30,000Out of the house, then stood in a hint soldier's text box for the rest of the run.
r2/r3+ open mode30,00012 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.
r4+ no item text30,00024 places, reaching the castle yard and graveyard; still exploring at the end.
iter1same44,58036 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.
iter2-iter4+ each imports the last one's mapup to 60,000The 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).

The window (apps/native), started from home with iter3's map as its map.19.txt, walked to the Eastern Palace and in (room $C9) within about three and a half minutes: run/window/zbanks-1.png (the ruins, frame 8,648), run/window/zbanks-2.png (inside, frame 12,886), run/window/zbanks-3.png (the Stalfos room, running its SCRIPT_KILLALL with no sword, frame 17,372).

What it never did in these runs: reach uncle for the sword - see "The stairs" above. With carried patch 0001 and the uncle-message patch (home remade from the re-patched ROM, run/zbanks-c/states2):

RunMap importedFramesWhat happened
s1mapiter2's40,000Sword from uncle by frame 12,000 (room $55); then Kakariko, where the informant's text holds it (manual mode).
s1freshnone40,000Explores caves and houses; never near the castle in this run.
s2as1map's50,000Sword 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").
s2biter4's50,000Sword by 14,000; Dam puzzle and castle key guard; into Eastern Palace by 41,000.

The bot's own behaviour, run unchanged, that these runs meet: a text box it did not open holds it until its goals time out (ap_follow_targets clears A); it can loop on a pair of intra-room stairs for thousands of frames (cave $10A, frames ~5,700-12,000: one frame of DOWN at the top re-enters the stairs, one frame of UP at the bottom does the same); assert_bp fires tens of times a run, always at the same site (ap_tick + 0x146bc); and when every goal is unsatisfiable it sets ap_manual_mode and stops for good (ap_plan.c:915-918). Upstream's TODO lists stairs, and its video ran from a map built over many runs.

With both blockers gone (measured 2026-09-21, evening)

All from home remade from the patched ROM (run/zbanks-c/states3):

RunJevMapFramesWhat happened
b5offs1map's70,000As 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.
j4oniter2's40,000The 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.
j9onp1's36,000Started 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.
j2, j7ons2a's, s1map's108,000, 72,000Jev's picks send the bot round the overworld and Kakariko's houses first; neither reaches $B8.

With patches 0004-0008 and the host's retries (measured 2026-09-21, night)

Same start as b5 (states3 home, s1map's map), 200,000 frames:

RunJevWhat happened
v1offBow 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.
j10on, run limit $0.05Sword 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.

From the window's own last state (its resume of 18:22, the old build having given up at frame 80,157 with no goal since 79,548), run w3 on this build: goals chosen again from frame 0 (the big chest, four times - no big key in that save - then pots and "EP Igor Key"), out of room $A9 by ~1,100, then a ledge drop into a shutter room with two Stalfos and every goal unsatisfiable at 1,431. That save's route is its own trap; the window was relaunched from home instead (see the register).

Two things these runs found that are not blockers of the game but of the harness: the ledger's 30-questions-a-minute breaker is wall-clock, and two headless runs at ~5x real time plus the window tripped it (15:32:05; every later choice fell back until paused.json was cleared) - run one Jev headless run at a time; and in j3 Jev kept picking a goal in cave $123 for 17,000 frames until the bot gave up.

Jev at the goal choice (carried patch 0002, packages/zbanks-jev)

The bot's one real decision is ap_goal_evaluate (ap_plan.c:887-925): score every goal (ap_goal_score, ap_plan.c:735-841 - path cost, +100 per failed attempt, +10,000 for anything but an NPC while swordless) and pursue the lowest. third-party/c/patches/0002-goal-choice-hook.patch adds ap_goal_choose_hook, called with upstream's pick, its score and ap_goal_score; NULL, the default, is upstream exactly. It cannot be done from the shim: the choice sits between two statements of a static function. The shim's hook (zb_set_goal_chooser) rescores the satisfiable goals with the limit min + margin, keeps at most six (upstream's pick first, then cheapest), and hands them to the host only when there are at least two. zbanks-jev says each as a sentence, asks one Choice, samples the pick per the digest's _sample_goal (floor 0.05, T 2.0), reuses a remembered answer for the same option set within 1,800 frames, and on any failure returns upstream's pick.

Measured 2026-09-21, headless, home from states2, map from s1map:

RunJevFramesResult
off1off (hook compiled in, NULL)12,000progress.tsv identical, line for line, to s2a (built before the patch).
jev1on, margin 512, run limit $0.1036,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.

Jev's answers mostly agree with upstream's nearest-first (median top probability ~0.9 on the first option), and it prefers the unvisited: at the castle key room it put 0.29 on "go through the south door ... to somewhere never visited" against 0.50 for the scripted key guard, and the flattened sample took the door. Log: run/zbanks-c/jev1/jev.jsonl.

Options Jev can tell apart (2026-09-21, evening)

The window asked (frame 8,912, run/window/jev-5.png): "Lift the pot right here on this screen to see what is under it. It is about 62 steps away." and the same sentence with "(another one, number 2)" - 8 of the window's 73 choices had such a pair. The user had already objected to that ("use door use door use door"). Options now come from more of the bot's own records (zb_goal_option in the shim: the node's centre, its screen's bounds, and the path ap_goal_score just found, read from pgsearch.from straight after each score call): which third of its screen; on Link's screen, how many steps east/west and north/south of him; elsewhere, the compass point, the exit the bot's path leaves Link's screen by and the screens it crosses; whether it lies on another option's path; the exit's kind from the bot's node name (dungeon door, entrance, screen edge, stairs, hole, ledge); the item lying (small key, big key). Goals that read alike on the same screen at a similar distance (within 8 steps or a fifth) are ONE option (words::offers); if that leaves one, nothing is asked. That pot pair, at the same spot in run w2 (frame 8,805): "Lift the pot in the middle of this screen to see what is under it, 6 steps east and 8 steps south of Link. It is about 62 steps away." - one option, not asked.

Input tokens per question: 534 in the window before (55 questions, from their cost at $0.042/MTok), 543 in jev1; after, 544 in w2 (8 questions) and 549 in j2 (47 questions, 501-708). Per question the longer sentences and the collapsed options about cancel; per choice fewer are asked (j2: 73 choices, 47 asked, 18 reused, 8 not asked as one choice; 0 questions with two equal options).

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)

The live window's real current blocker (RESUME HERE's item 1, brain jev-plays-snes.md): GOTO_POINT to door D 0x80, screen 0x2458, failed five times, ~127 frames apart, and the planner gave up. Reproduced headless from the window's own resume.state (copied to a scratch home.state, its own map_export.txt as --import-map, Jev off): the SAME position, link 0x5a88,0x2447 in the bot's coordinates (raw WRAM 0x0688,0x243f), frozen from frame ~409 (the plan is set) through the end of a 6,000-frame run - not one pixel of movement, given up: 9 waiting (nine different goals burned through their own four attempts in that time, all funnelled through this same door, since a fresh headless process rebuilds every goal from the imported map with attempts reset to 0 - it does not inherit the live process's own exhausted goal list, so reaching true global ap_manual_mode this way would take far longer than reproducing the STALL itself).

The mechanism, not just the symptom. ZB_TRACE=400-450 on this same reproduction: the pad is 0x0400 (Down) on every single frame, and link_state stays 0x00 (standing, not even a walking animation) while x,y never change at all - the engine is refusing the move outright, not merely running out of time to complete it. Geometrically: sprite 4 in the room is type=0xbb subtype=0x200 state=0x9 attrs=TALK|NODE, hitbox (5a78,2448) x (5a98,2470) (ap_snes.h's own per-room override making 0xbb, normally a shop/vendor type, a plain NODE|TALK only in room $123). Link's frozen position, (5a88,2447), sits one pixel NORTH of the hitbox's own top edge (2447 vs 2448) and inside its X range - pressed flush against it. The door's own node box, (5a78,24c0) x (5a87,24e7), is 128 pixels SOUTH of the sprite's bottom edge (24c0 vs 2470): to reach it Link must go around the sprite, not through it, and ap_follow_targets's "Stuck Link Detected! 5a88,2447 en route to 5a78,24c0" names that far target directly, not an intermediate waypoint that would step him around it first.

Not a full physical wedge, ZB_HUMAN confirms - but it is not a free room either. From the same reproduction, four 200-frame raw-input probes (X held throughout so the bot steps aside, alttp.c:57), each restarted fresh from the identical home.state:

Direction (+X)PadMovement in 200 frames
Up0x08401 px
Down0x04401 px
Left0x024019 px (west, toward/along the sprite's own left edge, 5a88→~5a75)
Right0x01404 px

South (the door's own direction) is blocked at the first pixel, matching the bot's own trace; west is the only direction with any real headroom, and even that stops well short of open ground - consistent with a small interior (the sprite's own room is a shop-shaped space, per its NODE|TALK shop-type sprite) where the walkable margin around the sprite is narrow rather than absent.

Why this is not the castle-passage-door regression again, and not a quick third fix in the same family. $BB here genuinely IS SPRITE_ATTR_TALK (unlike room $0012's non-TALK 0x73), so carried patch 0006's TALK gate is not misfiring in the way it was there - this is a different failure inside the CORRECT branch of that patch, not a case wrongly taking it. Patch 0006's own inside exemption (third-party/c/patches/0006-...patch, ap_map.c) only ever unblocks the ONE-CELL MARGIN ring immediately around a sprite's raw hitbox when Link already stands in it - it shrinks the blocked region from [hb_tl, hb_br] (hitbox + margin) to the raw hitbox alone, a change of at most a few pixels at the sprite's own edge. The door is 128 pixels further south than that ring reaches, so the exemption cannot be what is routing (or failing to route) Link there; A* found SOME 18-waypoint path at all (unlike Sahasrahla's original "A* failed" case, 0006's reason for existing), so the search can start from inside the margin - the failure is in what that path asks ap_follow_targets to do afterward, not in whether a path exists in principle.

Left unfixed, and why: turning this into a general "route around a solid sprite mid-path, not just avoid it as a destination or an origin" fix touches the same pathfinding code that has already produced one regression this session (the castle passage door, 0006's own destination exemption catching an unrelated sprite) from a narrower, better-understood change than this would need. Confirming a fix here would mean tracing ap_pathfind_local's actual grid construction for this specific room against the disassembly's tile-attribute data, at the same rigor as patches 0001/0003/0004/0006 each took - not an afternoon next to the recovery mechanism this session was mainly about. Recovery itself does not depend on this being fixed: retried unconditionally, this exact goal will keep failing exactly this way (it is a deterministic replay from a fixed position with Jev off), which is precisely what the recovery cap is for - see apps/native/src/app.rs, packages/zbanks/src/recovery.rs.

Next step for whoever picks this up: read ap_pathfind_local's handling of the cells between the sprite's margin and the door's own node box in room $123's tile-attribute grid (ap_map.c, the same table alttp-ram-map.md documents the format of), specifically whether a WEST-then-SOUTH route exists in the grid at all before touching how ap_follow_targets executes whatever path is found - the geometry above says a route around the west side is where to look first.

Fixed (2026-09-22): the sprite was never blocking to A* at all

The "next step" above assumed A*'s obstacle model of sprite $BB was correct and the bug was in how ap_follow_targets executed the path (or in the grid's own construction). Neither was true. Sprite $BB's per-room override in room $0123 never marked it SPRITE_ATTR_BLKF or BLKS in the first place (ap_snes.h: { .type = 0xBB, .subtype = 0x0200, .only_dungeon_room = 0x123, .attrs = SPRITE_ATTR_NODE | SPRITE_ATTR_TALK })

  • only NODE | TALK. ap_pathfind_local's obstacle-cost loop only marks a sprite's hitbox impassable when attrs & (SPRITE_ATTR_BLKF | SPRITE_ATTR_BLKS) (ap_map.c, the loop patch 0006 also touches); without either flag, this sprite is invisible to that loop entirely, so A* never saw an obstacle here and happily built an 18-waypoint path straight through the sprite's real hitbox - a path the game's own collision then refused outright, which is exactly why raw ZB_HUMAN input confirmed Down blocked at the very first pixel while the A* grid (dumped from local_cost.pgm, decoded to ASCII) showed no blocked cell anywhere near Link's position at all. Patch 0006's own destination/inside exemptions were correctly scoped all along (as the characterization above found) - they were simply never reached, because the sprite they gate never entered the blocking code path to begin with.

The general type entry for 0xBB (ap_snes.h, above the per-room table) DOES carry SPRITE_ATTR_BLKF alongside SUBT and TALK. Every other per-room TALK|NODE override in the same table keeps or drops BLKF correctly for what the sprite actually is: Sahasrahla's (0x16) keeps it (he is solid); uncle's (0x73/0x55) correctly has none (he lies on the ground, not solid). Room $0123's override for 0xBB is the one entry that adds NODE (so the bot can build a TALK node for this NPC, the "Mini Moldorm Cave Guy") while silently dropping the BLKF the general entry has - an upstream authoring slip in a static table, not a Randomizer difference and not anything a host can see around. third-party/c/patches/0011-shopkeeper-blocks-in-room-0123.patch restores it.

Proof. Reproduced headless exactly as before (the live window's own resume.state as home, its map_export.txt imported, Jev off): before the patch, frozen at link 0x5a88,0x2447 pressing Down every frame, replanning an identical 18-waypoint path every ~127-133 frames forever (run/zbanks-c scratch, not kept). With the patch, the same start reaches the door and keeps playing - by frame 600 Link has already left room $0123 through door D 0x8e; by frame 12,000 (in a longer run from the same start) he is back at the shopkeeper doing TALK_NPC normally, no freeze (that specific NPC cannot actually be satisfied - see "The shopkeeper cannot be paid" below, a separate, newly-exposed limitation, not a regression from this patch).

Regression, a full 90,000-frame run from a freshly-made home (current patched ROM, --make-home, since the ROM's cumulative patches make any older saved home untrustworthy - checked by cartridge-CRC refusal as the safeguard), Jev off, run/zbanks-c/s1map's own historical map imported (the same map earlier regression proofs on this page use, and one that does not yet know about the newly-reachable shopkeeper, so it does not spend time proving that NPC unfixable before reaching the main objectives - see below for what happens with a map that DOES already know about it): sword by frame 3,000 ($0055, Well Uncle), bow by frame 42,600 (matching j10: 43,714, and the pre-regression best), green pendant ($7EF374 = 0x04) by frame 54,600 (j10: ~59,000), Sahasrahla's boots by frame 58,200 (j10: 62,499) - all within the pre-regression range, manual mode: false for the ENTIRE run (no stall of any kind, headless summary confirms zero rows with manual=true), and still playing at the full 90,000-frame budget with no early stop. Sahasrahla is confirmed reachable (boots obtained). Nothing in the fix touches Sahasrahla's own path (patch 0006's Sahasrahla-specific behaviour is unchanged - 0011 only adds a flag to an unrelated sprite type/room pair).

A SEPARATE run using the live window's own, much more fully-explored map_export.txt instead of s1map's reaches the newly-reachable shopkeeper NPC early (its map already tags the NPC as a cheap, nearby EXPLORE-adjacent option) and spends ~9,000 frames proving it unfixable (see "The shopkeeper cannot be paid" below) before apps/zbanks's own "stop a minute after recovery gives up" behaviour ends that run around frame 34,800 - a property of which map was imported and what it already believes is worth trying, not evidence against the door fix itself, which is what the s1map run above isolates.

Live, restarted onto this build via MCP (dev_mode on -> restart -> dev_mode off): the window had been pinned at this exact pixel for multiple real hours (RECOVERY firing every 16,558 frames without moving Link at all - see the recovery section below); after the restart it left room $0123 within the first few seconds of play. It was seen back in that room later, at a nearby but different position (the shopkeeper NPC's own approach point, not the original door-pin pixel) - the door-pin bug itself never recurred at any point in continuous real-time observation, but the window did stick again for several minutes on the SEPARATE shopkeeper problem described below, surfaced only because the door fix made that NPC reachable for the first time. Recovery was seen escalating correctly (attempt: 4 of 5 via the bot MCP tool) before something else on the map's own goal list resolved and the bot moved on by itself, confirmed by state showing mode: "overworld", control: true and a real, changing position across successive polls (world areas 60, 27, 52, then 24) minutes later. (An MCP press probe used to test raw movement at the stuck spot briefly and harmlessly toggled the world-map overlay open via X - closed again the same way before the bot resumed; no directional input from that probe reached the game, so the overworld movement seen afterward is the bot's own.)

Recovery's cap never engaged: progress was scored too broadly (fixed 2026-09-22)

Live, before the $0123 fix above, packages/zbanks/src/recovery.rs's cap (5 attempts with no progress before giving up for good) never engaged although the SAME stall recurred over and over: run/native.log shows RECOVERY attempt 1 of 5 at frames 16567, 33125, 49683, 66241, 82799, 99357 and 115915 - exactly 16,558 frames apart, every single time - never attempt 2. Each restored the same 33 given-up goals (all of them, per the window's own MCP state, routed through the same physically-pinned door - see the section above), and Link's OWN position (read_wram/state, not the task-list stack) never moved from 0x5a88,0x2447 across any of them.

Cause. Recovery::consider's progress signal was possessions(wram) OR bot.completed_count() changing since the previous check - both GLOBAL: a change anywhere on the whole known map counts, not only something that would let the STUCK goal succeed. zb_completed_count (packages/zbanks/shim/host.c) increments for any goal that leaves the bot's list with attempts <= 3 - which includes a goal retry_all_given_up just re-added from scratch (attempts reset to 0) immediately re-evaluating as already satisfied against the save (a chest already opened, a door already unlocked) and leaving the list "complete" before Link takes a single step. With 33 goals restored every cycle, at least one such trivial re-completion happened deterministically within the same ~16,558-frame window each time, resetting Schedule's attempt counter to 0 although the one goal that actually mattered - the door

  • kept failing for the exact same unfixed reason.

First fix (necessary, not sufficient on its own), packages/zbanks/src/recovery.rs: a completed goal now counts as progress only together with Link's own position (Place and pixel coordinates, the same reading the caller already takes for stall::Signal, passed into Recovery::consider rather than re-read) having changed since the last check. A possession change ($7EF340-range: item, key, pendant, crystal, big key, progress) still counts alone - Link cannot gain one without being at its location, so a global read of it is trustworthy on its own; a completed goal is not, since the mechanism above can fire it without Link moving at all.

This alone did not fix the live incident. A second run of the exact same shape (see below - a different NPC, same room) reproduced "attempt 1" forever even with this fix built in, because of a SECOND, larger bug in Schedule::poll (the pure backoff/cap decision, deliberately kept apart from Recovery so it is testable without a real bot/console): the !stalled branch reset attempts and next_at to zero on ANY tick where the caller reports "not stalled" - not just when real progress happened. retry_all_given_up's own zb_goal_add clears ap_manual_mode as a side effect of adding a goal back (c37bf1ea, above), so stalled reads false for a tick (or many) immediately after EVERY recovery attempt, whether or not the goal it just revived can actually succeed. Under the old rule that momentary "not stalled" reading was read as "the stall cleared, so whatever was wrong is fixed" and wiped the count - meaning a stall that recurs because the SAME goal keeps failing could never escalate past attempt 1, no matter what progressed said, because the !stalled branch reset it first on the very next tick regardless.

Second fix, Schedule::poll: only progressed==true forgets the count now; a bare stalled==false reading returns early without touching attempts/next_at, so a recurrence of the same unfixed stall resumes escalating from where it left off. Unit-tested (packages/zbanks/src/recovery.rs, buck2 test //packages/zbanks:test, 26 tests green): a schedule fed true, false / false, false / true, false in a loop (exactly the toggle recovery's own side effect produces) escalates 1..CAP and gives up, where the old code returned Some(1) forever.

Both fixes were needed, and both were proven against a SECOND live incident, not just replayed in a test. With the $0123 door fix above landed, the door stall itself cannot recur (there is no unfixed goal left to keep restoring through it) - so proving the recovery fix end-to-end needed a live reproduction of a DIFFERENT stall with the same shape. One appeared immediately: see "The shopkeeper cannot be paid" below. Headless, against that exact case (/tmp scratch run, not kept - reproduce with the live window's own resume.state/map_export.txt, --import-map, Jev off): RECOVERY attempt 1 of 5 at frame 22011, then attempt 2 at 22611, attempt 3 at 23811, attempt 4 at 26211, and RECOVERY gave up after 5 recoveries with no progress at frame 31011 - escalating on every single occurrence, matching Schedule's own unit test exactly, and giving up loudly rather than looping for the rest of the run. Live in the window (frame counters reset by an intervening restart, so not directly comparable to the headless numbers): recovery.last.attempt was seen at 4 of 5 via the bot MCP tool before the underlying goal picture changed enough (see below) that the stall cleared on its own.

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)

Fixing the $0123 door made sprite $BB ("Mini Moldorm Cave Guy", NODE|TALK|BLKF) reachable for the first time - and reachable is also walkable-TO, so the bot's planner now offers a TALK_NPC goal for it. That goal cannot succeed: headless (Jev off, the live window's own resume.state and map_export.txt), the bot walked up to it, opened its text box repeatedly, and the goal failed and was retried in a tight ~400-600-frame cycle (STALLED/RECOVERY/stall cleared every cycle) until Recovery correctly gave up for good at frame 31011 (see above). TALK_NPC's own gift detection (patch 0007) is "a change in $7EF340-$7EF35F since the talk began" - the general mechanism that works for Sahasrahla and uncle. This NPC's own base type (ap_snes.h's general 0xBB comment: "Salesman / chestgame guy / 300 rupee giver guy / Chest game thief / Item for sale") is a family of NPCs that, in vanilla, gate their gift behind a CHOICE or a COST the bot has no model for at all - a "will you pay 100 rupees?" prompt, a chest game, or (matching the room's own name) a "kill 4 mini-moldorms first" precondition never satisfied by anything in the bot's own goal system. No amount of retrying changes any of that, so Recovery's cap giving up on it is the CORRECT terminal behaviour, not a defect - this is squarely the "no goal exists for the actual precondition" class of gap the Book of Mudora section above already describes for a different NPC.

Once Recovery gave up on this one goal, in the LIVE window the bot kept playing. It has 249+ other goals on a heavily-explored map, and ap_manual_mode (the C's own one-way latch) only ever applies to ALL goals at once, not this one specifically - so once something ELSE unrelated finished (a chest, an unrelated key), zb_goal_add cleared the latch as it always does, and the bot moved on. Confirmed live: after recovery.last showed attempt: 4 (via the bot MCP tool) with the window's own stalled.json reading "Link has not moved" for several minutes at this exact NPC, state moments later showed mode: "overworld", control: true, and a real, changing position across successive reads (area 60, then 27, then 52, then 24) - the bot had moved on to other goals entirely, on its own, with no intervention beyond what Recovery's own bounded mechanism and the bot's own huge goal backlog already provide.

In a headless run with a MAP THAT DOES NOT YET KNOW ABOUT THIS NPC (s1map's own map, from before the door fix made it reachable), the 90,000-frame regression proof below never encounters it at all in that run's own exploration order, and reaches every milestone cleanly. A run seeded with a map that already tags it as a "cheap, nearby" option (the live window's own heavily-explored map_export.txt) discovers it early and spends the ~9,000 frames above proving it unfixable before moving on - cost, not correctness: the same run still recovers and keeps playing afterward, it is simply the fully-explored map's own EXPLORE-goal ordering that visits this dead end sooner. Left unfixed on purpose, per the task that found it: teaching the bot to navigate a shop's payment prompt (or add a SCRIPT_KILLALL-style precondition script for whatever this specific room actually requires) is new scope, not a regression from anything done here.

The shopkeeper goal declined (host), and proving the Book of Mudora (2026-09-22)

Confirming the requirement gate, again. A 400,000-frame headless run (run/zbanks-c/task1-book, home, s1map's map, Jev off) reached boots at frame 60,000 and, from then on, goals.txt shows the Library script flipping between unsat Needs: [BOOTS] and limit (in-range, cost over the search cutoff, not unreachable) depending on Link's current position and search budget - the same behaviour item 4 above already found, now seen continuously for tens of thousands more frames. It was never Active goal even once. This is not new; what stopped the run from getting the chance to prove it further is.

The shopkeeper goal (previous section) was not merely a cost - it permanently derailed a long run. At frame 91,483 the same task1-book run walked into room $0123's shopkeeper, and from there Recovery escalated 1→2→3→4→5 exactly as designed and gave up for good at frame 100,797; apps/zbanks's own "stop a minute after recovery gives up" then ended the run at 104,397 - a quarter of its budget, with the Library goal sitting the whole time as a correctly satisfiable, in-range option the greedy scorer never got to reach because nothing after that point ever ran again.

Fix: decline the goal outright (packages/zbanks/shim/host.c, zb_npc_cannot_be_satisfied). The same mechanism that already declines uncle's done-in-this-save NPC goal now also declines any GOAL_NPC for sprite 0xBB/subtype 0x0200 in dungeon room $0123 - the bot never creates a task for it at all, so it never approaches, never opens its text box, and never spends a Recovery attempt on it. Proven (run/zbanks-c/task1-fix, identical start): the bot leaves room $0123 within 5,000 frames of arriving (vs. never, before) and keeps playing to at least frame 190,000 - a run that used to die at 104,397 now goes 82% further before hitting anything else.

A second bug this exposed: apps/zbanks's own stop condition fired on a transient stall, not just a permanent one. task1-fix still hit RECOVERY gave up after 5 recoveries at frame 186,826 (an unrelated, ordinary stall - not the shopkeeper, which is gone) - and the very next log line is stall cleared at frame 186827, through zb_goal_add's own "a goal was added while in manual mode; resuming the plan", which has nothing to do with Recovery. The old rule stopped the run unconditionally 3,600 frames after Recovery ever gave up, discarding the run at frame 190,426 while the bot had in fact been un-stalled and exploring normally the entire time since 186,827. Fixed (apps/zbanks/src/main.rs): the 3,600-frame grace timer now tracks "stalled AND recovery exhausted" together and resets the moment either stops being true, so a transient stall Recovery happened to be exhausted for no longer ends the run, while a genuinely permanent one still does.

Proven both ways, same run (run/zbanks-c/task1-fix2, identical start, both fixes in place): past frame 190,000 (where the old rule would have stopped it) still playing normally; RECOVERY gave up fires again at the same frame, 186,826 (a deterministic replay), and this time the run correctly continues; it finally stops for good at frame 197,331 on a different, genuinely permanent stall (below) - stopping at frame 197331: recovery gave up and the bot has stayed stalled for a minute, this time truly warranted. Net effect of both fixes together: the run's healthy lifetime went from 104,397 → 197,331 frames, roughly double, entirely by removing waste, before meeting a wall that needs its own investigation.

The next blocker: a door at the Dam, same shape as the room $0123 pin, not yet characterized or fixed. ap_follow_targets logs Stuck Link Detected! 9b08,2190 en route to 9af8,2140 repeatedly from frame 179,651 onward, and the task that never completes is TRANSITION ... [node=door U 0x80 DOOR|NODE NORM screen=Dam Chest ^ 9a00,2100 x 9bff,21ff] - a fixed pixel, a fixed unreachable target, exactly the shape carried patches 0006 and 0011 each turned out to be (a real obstacle A* does not see, or a route that requires going around one). Not investigated this session: whoever picks this up should start the same way those were - a ZB_TRACE bracket on the approach to this door and a dump of ap_pathfind_local's grid for this room, the same method that found 0006's regression and 0011's missing BLKF.

The live window separately needed two operational recoveries this session, neither a code bug:

  1. Recovery's cap (packages/zbanks/src/recovery.rs) is a one-way latch for the life of the process by design - once spent, it never retries again, whatever the reason it was spent on. The window hit this legitimately (the shopkeeper, pre-fix) and sat permanently stalled with nothing able to clear it. Only a restart (dev mode on → restart → off) gives it a fresh Recovery/Bot; this is expected maintenance, not a defect, and is now the second time this exact remedy has been needed.
  2. After a load_state("home") reset, the window's own auto-imported map (run/zbanks-window/map_export.txt, copied to map.19.txt on every restart per apps/native/src/app.rs) still reflected a much later-game topology than Link's new position, and a fresh Bot built from it found every reachable goal unsatisfiable (no known route) within ~600 frames, then genuinely exhausted Recovery's cap with 0 goals ever restored (nothing to retry - the graph was just disconnected from here). Renaming both map.19.txt and map_export.txt aside (kept, not deleted, as *.bak-2026-09-22-0105) before the next restart + load_state("home") let the bot build a fresh, internally-consistent graph from its actual position, and it got the sword and shield within 1,500 frames and stayed healthy (no stall, recovery.gave_up: false) for a confirmed 10+ minutes of continuous real-time play afterward.

The Dam Chest push block: a hammer peg that was really a wall (fixed 2026-09-22, carried patch 0012)

The next blocker after the shopkeeper fix (previous section): ap_follow_targets logged Stuck Link Detected! 9b08,2190 en route to 9af8,2140 repeatedly, and the live window's own user, watching the window, gave the ground truth a trace alone would not have: at the Dam Chest room, Link stands beside the grey push blocks and repeatedly swings the hammer at one, gets nowhere, and eventually wanders off to an unrelated goal - the same shape as the shopkeeper, but with a different tool mistaken for the right one.

Reproduced fast and cheaply, without touching the live window: the live window's own resume.state and map_export.txt, copied (not read live - the window was later closed) into a scratch states directory and used as home for apps/zbanks, Jev off. A fresh process rebuilds its goal list from the imported map with attempts reset to 0, so it re-picks the same door goal from scratch in under 10,000 frames rather than needing the live process's own already-mostly- exhausted state - the same trick used for every stall reproduced this way in this document.

The mechanism, read directly out of the running C (a temporary LOG line in ap_follow_targets, added, proved, and reverted - never committed, per this file's own "Proving a patch that changes pathing" note): at the exact stuck position, next_xy = target.tl = (0x9af8, 0x2178), one grid cell north of Link - an ordinary, close waypoint, not a stale far-away target. ap_map_attr(next_xy) there is raw tile 0x27, and ap_tile_attrs[0x27] = TILE_ATTR_HMMR - ap_snes.h's own comment on that entry already flags the ambiguity: // hammer peg (was also fence; remapped to 0x01). Dungeon room $010B (the Dam's block-puzzle room) reuses the same tile id for its push blocks, a THIRD meaning the same comment never anticipated. With TILE_ATTR_HMMR set and the hammer owned, ap_follow_targets's branch order (ap_map.c) presses Y at it every other frame (JOYPAD_MASH(Y, 0x1) - the trace's alternating 0x4800/0x0800 pad values, step 2 apart, are exactly this) - forever, since a push block does not respond to a hammer swing.

Not merely a wrong tool - a wrong PASSABILITY judgment too. ap_pathfind_local computes its own lift_mask including TILE_ATTR_HMMR whenever the hammer is held, and scores an HMMR tile cost = 20 (a normal, low-cost, "interact and pass" tile) rather than the 1 << 20 a wall gets - so A* built an 18-ish-waypoint path straight onto this tile in the first place, the same shape as patches 0003 and 0011. Confirmed live: push_timer ($7E0371) was actively counting down frame by frame throughout the stall (0x1b → 0x19 → 0x17 → 0x13 over ~150 frames) while Link's own x,y never moved a single pixel - the real engine's push mechanic genuinely engaging (UP is held unconditionally whenever the target is north, regardless of any tile-attribute branch), consistent with the block already having been shoved as far as it can go and Link now pushing against something immovable, matching what the user watched happen: "he pushes the first block up, and it is now sitting between him and the chest."

Fix (0012-dam-push-block-not-a-hammer-peg.patch, ap_map.c). A new ap_tile_attrs_here(raw_tile) returns 0 for tile 0x27 specifically when *ap_ram.dungeon_room == 0x010B, and ap_tile_attrs[raw_tile] unchanged everywhere else - wired into both of ap_follow_targets's attribute checks and ap_pathfind_local's per-tile costing loop. With attrs forced to 0 in this one room, the tile is no longer "liftable" (no more A-mash) or TILE_ATTR_HMMR (no more Y-mash), and ap_pathfind_local's costing falls through to its own else branch - cost = (1 << 20), a real wall - so A* treats it exactly like any other solid obstacle it cannot cross. No host fix: it is the exact same static-table-lacks-room-context bug as 0003/0011, just for a tile id instead of a sprite type.

Proof. Reproduced headless from the (now-closed) live window's own resume.state/map_export.txt, Jev off: unpatched, given_up climbs to 1 within ~9,600 frames (the door goal fails its three attempts and is abandoned) and the task list shows the identical Stuck Link Detected!/hammer-mash pattern seen live. Patched, the SAME reproduction (run/zbanks-c/live6): given_up stays 0 for the entire 20,000-frame run - the bot leaves the Dam Chest room cleanly via a different door (door D 0x8e) within the same few thousand frames, and the door U 0x80 goal itself now scores limit (in range, over the search-cost cutoff - the same harmless, ordinary "book of Mudora" shape) rather than ever being attempted and failing. No Stuck Link Detected anywhere in the patched run's log.

The hammer swings at bare floor (fixed 2026-09-22, carried patch 0030, movement/combat item 1)

The user, watching the live window: at frame 88684, in Sahasrahla's hut, the active task was LIFT_POT [node=pot 0x71 NODE|LFT0] and the pad showed Y+Right with the hammer equipped - Link swung the hammer at bare floor instead of walking to the pot and pressing A. 0012 (previous section) had already fixed the identical shape once, but only for dungeon room $010B: ap_tile_attrs_here returned 0 for raw tile 0x27 in that one room and left every other room's 0x27 going through the unmodified flat ap_tile_attrs[0x27] = TILE_ATTR_HMMR.

Why raw 0x27 is never a real hammer peg indoors, from the disassembly. zelda3's HandleItemTileAction_Dungeon (src/dungeon.c:81dabb) is the real game's own version of "is the tile in front of Link interactable, and how": it only ever recognizes an interactive dungeon object through the "replacement object" family - raw BG attribute 0x70-0x7F, whose low nibble indexes dung_replacement_tile_state[] ($7E0500, 16 uint16_t slots). RoomDraw_HammerPegSingle (dungeon.c:81b493) writes 0x4040 into a slot for a real peg; a pot's slot is 0x1010 (kDungeon_QueryIfTileLiftable_rv, matching this brain's own alttp-ram-map.md, "Liftables, named and gated" - dungeon liftables/pegs are $70-$7F, translated back through this same table); a plain pushable block (DrawObjects_PushableBlock, dungeon.c:81b4d6) writes 0, neither pattern. Raw 0x27 is a completely different attribute value, outside this family - it is TileBehavior_Hookshottables in the OUTDOOR tile-detection switch (src/tile_detect.c:342), and ap_snes.h's own comment on its 0x27 entry ("was also fence") already shows it is reused for unrelated static scenery per-tileset. Indoors, ap_map_attr_from_ram reads straight from the game's own live dngn_bg1_tattr/dngn_bg2_tattr buffers ($7F3000/$7F2000, matching dung_bg1_attr_table/ dung_bg2_attr_table in zelda3's variables.h) - so raw 0x27 indoors is whatever that tileset's designer used the id for: a push block's floor in $010B, apparently a plain floor tile in Sahasrahla's hut. Nothing in the real game's own hammer-peg check ever consults it.

Fix (0030-hammer-peg-indoors-general.patch, ap_map.c), no room number anywhere. ap_tile_attrs_here now strips TILE_ATTR_HMMR whenever *ap_ram.in_building is set, unconditionally, before returning the flat table's value - ap_snes.h's table sets that bit for exactly one raw id (0x27), so this is equivalent to "indoors, 0x27 is never treated as a hammer peg," full stop. For dungeon room $010B specifically this produces the identical result 0012 did (0x27's only bit is HMMR, so stripping it zeroes the same way 0012's hardcoded return 0 did), so 0012's own fix is now subsumed rather than duplicated - both patches stay carried (in application order 0012 then 0030) since 0030 composes on top of 0012's introduction of ap_tile_attrs_here rather than replacing it. Outdoors is untouched: ap_map_attr_from_ram's existing fence carve-out (tile != 0x021B) already covers the one known outdoor ambiguity, and real outdoor hammer pegs keep working exactly as before.

Proof. A fresh 100,000-frame headless run (--make-home, canonical open-mode home, Jev off, --no-retry) reaches the same healthy shape prior runs in this document show before hitting manual mode: PICKUP 16/25, CHEST 13/20, EXPLORE 99/155, ITEM 0/0, NPC 0/0, SCRIPT 3/3. Only two Stuck Link Detected events in the whole run, both at the same cell in Blind's Basement (ab10,23b0 en route to ab10,23a0) - a pre-existing, unrelated jam (ap_jam_note fired for it, the general 0015 mechanism already handling it), not a hammer mash. No regression: the run never held the hammer at all in this playthrough (too early), so the fix's own branch (*ap_ram.inventory_hammer != 0 still gates the Y-press) never even engaged, and the general A*/pot-lift pathing this patch touches is unaffected by it either way. The exact historical frame (88684, Sahasrahla's hut) was not re-reproduced pixel-for-pixel - the live window that produced it is closed and its save was not kept - but the fix is a direct, disassembly-grounded removal of the only code path that could have pressed Y there, and is provably behaviour-preserving for the one case ($010B) that was previously proven live.

The Eastern Palace big chest: no big-key gate at all (fixed 2026-09-22, carried patch 0013)

While the Dam fix was being proven, the live window's own user reported a SECOND failing goal, and that the bot was oscillating between it and the Dam: at Eastern Palace's big chest, "Eh? It's locked! If you had the Big Key…" - repeatedly.

Root cause, read from the code, not guessed. ap_goal_score's GOAL_CHEST case (ap_plan.c) only ever checked sram_room_state for "already opened"; nothing checks whether the chest CAN be opened. ap_node_islocked (ap_map.c) does have a correct, working big-key check - DOOR_ATTR_BKEY, *ap_ram.sram_ dungeon_bigkeys & (1 << (15 - dungeon_id)) - but that branch only ever fires for node->type == NODE_TRANSITION (a locked DOOR). Its own NODE_CHEST branch, a few lines further down, asks only "is this chest tile still closed" (lock_attr == 0x27 there means "opened chest" - yet another, unrelated meaning for the same raw tile id 0x27 the Dam fix above deals with, in a different context/plane - confirming these ids really are heavily overloaded throughout this codebase) and never consults sram_dungeon_bigkeys at all. So a big chest was always "reachable" to the pathfinder and always score-eligible to the goal system, however keyless the run, and the bot walked up and got told no every single time.

This one failing goal was not isolated - it fed a ping-pong. With ~200,000 frames of exploration behind it and 60 goals already given-up-and-restored, the window's own Recovery kept retrying every given-up goal on a stall (zb_retry_given_up, packages/zbanks), the big chest included; each retry walked most of the map to reach it, failed identically, and the resulting stall retried everything given-up again - including whatever OTHER far-away goal (the Dam door, before its own fix landed) was also failing, so the two traded places indefinitely, burning real wall-clock time on a network of unfixable and soon-to-be-fixed goals rather than ever reaching new content.

Fix (0013-big-chest-needs-the-big-key.patch, ap_plan.c). GOAL_CHEST's own scoring now also checks, when goal->node->chest_type == 1 (upstream's own ROM-derived "this is THE big chest" flag, set from the dungeon's chest table - ap_map.c, chest_id & 0x8000): *ap_ram.sram_dungeon_bigkeys for goal->node->screen->dungeon_id's bit, returning GOAL_SCORE_UNSATISFIABLE if it is not set - the exact bit formula ap_node_islocked's own NODE_KEYBLOCK branch already uses, and deliberately the per-NODE screen->dungeon_id rather than the global "current dungeon" ap_req's generic requirement system would have used (that system's own REQUIREMENT_KEY already carries a // XXX how to handle this across dungeons admission of the same imprecision - this fix does not repeat it, since a chest's own dungeon is always known statically from its node, not from wherever Link currently stands).

Why this alone stops the ping-pong, without a separate retry-suppression mechanism. GOAL_SCORE_UNSATISFIABLE goals are skipped in ap_goal_evaluate's own scoring loop before a goal is ever chosen min_goal - they never become the active goal, never generate a task, never physically fail, and therefore never reach ap_goal_fail/the given-up list zb_retry_given_up walks. An unsatisfiable big chest just sits in ap_goal_list forever, silently, exactly like the Book of Mudora's own unsat/limit goal before boots - the SAME already-working mechanism that makes a correctly-gated goal harmless, now covering a chest for the first time. Combined with the Dam fix (which stops that goal from ever failing either), neither side of the reported ping-pong can occur any more: no new mechanism was needed once both root causes were actually fixed.

Never push a block into a wall (general rule, fixed 2026-09-22, carried patch 0015)

The Dam Chest fix (0012, previous section) closed one instance of a shape that recurs by construction, not by bad luck: ap_snes.h's own comment on raw tile 0x27 already lists three unrelated meanings for the same id (fence, hammer peg, push block), and 0012's fix only teaches the host the THIRD one, and only in dungeon room $010B. Every other room that reuses 0x27 (or any other tile id) for a push block the static ap_tile_attrs[] table cannot see room-context for will produce the identical stall - Link stands next to an already-jammed block mashing the wrong tool forever - and would need its own hand-found room check, forever, exactly the pattern 0011/0012/0014 were already three instances of before 0014 itself got generalized (previous sections).

Why no static table generalizes this. The SNES's own tile system reuses numeric ids per room BY DESIGN (each room selects a graphics/tileset bank that reinterprets the same indices) - there is no bit anywhere in a raw tile id that says "this one is a push block here." A lookup table keyed by id, however large, is the wrong shape for a per-room reinterpretation; every future occurrence would be undiscovered until a user watched the window stall and reported it by hand.

The general signal is not the tile at all - it is what the GAME does. push_timer ($7E0371) engages whenever Link genuinely starts a push/pull/lift interaction, real regardless of room or tile id (confirmed directly for the Dam case in 0012's own section: push_timer counted down frame by frame while Link's x,y did not move a single pixel). ap_follow_targets already runs a generic stuck detector (stationary_link_count >= 128, pre-existing, unrelated to any of this) that fires today for stalls of every kind and merely resets the target timeout, letting ap_pathfind_local immediately re-select the identical blocked path next time. The fix teaches that existing, already-generic detector to REMEMBER a jam instead of only forgetting the timeout.

Fix (0015-never-push-a-block-into-a-wall.patch, ap_map.c). A small per-screen memo - ap_jam_screen/ap_jam_cells[8]/ap_jam_count, all file-scope statics, no change to struct ap_screen itself. ap_jam_sync(screen) clears the memo the instant the active screen differs from the one it was last touched for - correct, not merely conservative, because this codebase's own understanding of ALTTP (Room-reset-before-fail, next section) already establishes that a room's pushed-block state resets ONLY on a real screen transition, so "the active screen changed" is exactly the event that must invalidate previously-learned jams, no more and no less. ap_follow_targets's stuck-detector calls ap_jam_note(screen, target) when it fires AND *ap_ram.push_timer != 0 (an interaction was actually engaged, as against every OTHER reason ap_follow_targets can get stuck, which this patch leaves untouched). ap_pathfind_local's per-cell costing loop consults ap_jam_is(screen, mapxy) as its FIRST branch, ahead of even the destination-bbox and 0x1C special cases, forcing cost = (1 << 20) (an ordinary wall) - a cell proven immovable cannot be reclassified as free just because a goal's own bbox happens to cover it.

Proof, organic, not synthetic. 0012's own fix was disabled for one diagnostic build only (its room/tile guard changed from dungeon_room == 0x010B to an unreachable 0xFFFF, git checkout -- after - the same temporary-and- reverted method this file's own "Proving a patch that changes pathing" note already establishes), isolating 0015 as the only defense against the ORIGINAL bug. A fresh --make-home boot, no map, Jev off (run/zbanks-c/jam-proof/ isolation) reached the Dam at frame 3000 on its own and reproduced the exact historical position from 0012's own section (9af8,2140, one grid cell from the documented 9af8,2178/9af8,2140 pair) at frame 3408:

[3408 ap_map.c:ap_follow_targets] Stuck Link Detected! 9af8,2180 en route to 9af8,2140
[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
[3408 ap_plan.c:ap_goal_fail] Failed goal: EXPLORE [node=door U 0x80 ... screen=Dam Chest ...]

De-duplication held (the same cell was not re-logged on the goal's two further retries at frames 3535/3662), and the goal was correctly declared unsatisfiable (Permafailing goal, frame 3789) once no path around the now-known wall existed

  • the right call with 0012 disabled, since the underlying misclassification that would let Link approach correctly is still gone in this diagnostic build. Leaving and later re-entering the SAME room (frame ~10129, a fresh transition) re-triggered ap_jam_note for the SAME cell independently - direct proof the per-screen memo is cleared and correctly re-learned across a real room reset, not merely never cleared.

With the real 0012 restored (git checkout -- undid the diagnostic edit, verified byte-for-byte against the pre-edit copy) and 0015 still present, the same fresh-home reproduction crosses the Dam room on both of its two visits (frames 3000-4800 and 5400-9600) with zero "Stuck Link Detected"/"Jammed cell noted" lines - 0012's preventive, room-scoped fix still does its job on the first attempt, and the general mechanism stays fully dormant, proving the two are compatible rather than one masking the other. Separately, the Eastern Palace door-offset reproduction (run/zbanks-c/jam-proof/ep-repro, 0014 + 0015 together, 20,000 frames from the same fixture used to prove 0014) finished with given_up at 0 for the entire run and zero jam events - no regression from adding the general mechanism alongside the door-offset one.

A one-time NPC's reward already held is never offered again (fixed 2026-09-22)

Live, right after a restart rebuilt the goal list from nothing (frame 3806): the bot walked straight back to Sahasrahla's hut and talked to him again, although Link already held the Pegasus Boots. Unlike a chest (GOAL_CHEST reads sram_room_state), a talk-for-item NPC's goal had no such re-check at all - once created, it stayed offerable forever regardless of the save.

Where, and why not in the C. packages/zbanks/CLAUDE.md's own rule: "never edit the bot" - a behaviour the host needs is a change in shim/host.c, not a carried patch, unless no host can make it from outside. This one can: ap_map.c's ap_goal_add calls are compiled to go through the host's zb_goal_add (-Dap_goal_add=zb_goal_add, third-party/c/BUCK), and that function already had exactly this shape - zb_done_in_this_save declines uncle's own goal once his SpritePrep bit fires, to avoid a stale-memory assert. The Sahasrahla case is the same INTERCEPT POINT, a different REASON (a wasted trip, not a crash).

Fix, table-driven. A new zb_npc_reward_already_held in shim/host.c, checked in zb_goal_add alongside the existing two declines: one row per known one-time-reward NPC (ap_snes.h's room-scoped ap_sprite_attrs_for_type table has exactly three TALK|NODE overrides - Sahasrahla, uncle, and room $0123's shopkeeper, which is handled separately since its own problem is "can never succeed", not "already succeeded"). Sahasrahla ($16/$0000): Pegasus Boots, $7EF355 (inventory_base $7EF33F + BOOTS's own index 0x16 - cross-checked against research/alttp-ram-map.md's own citation, zelda3 variables.h:1085, "link_item_boots"). Uncle ($73/$0100): the sword, $7EF359 - a second, independent check alongside zb_done_in_this_save's own crash-avoidance one, since the sword can be held before uncle's own SpritePrep bit is set. The next one-time NPC is a row, not a new function.

Proof. Headless from the live window's own resume.state (boots already held) with its current map imported, 3,000 frames: frame 0 logs zbanks host: not adding goal for sprite 0x16.0 BLKF|TALK|NODE: reward already held (boots already held) and Sahasrahla's goal never appears again for the rest of the run - the bot spends the whole run on unrelated EXPLORE/transition tasks instead.

Not yet built: the NPC completion check is goal-CREATION-time only, matching the existing zb_done_in_this_save pattern exactly (both check at zb_goal_add, not via a re-scored ap_goal_score branch, since scoring has no host-replaceable hook the way goal creation does). This is sufficient for the reported bug (a restart rebuilds the whole goal list from nothing, so the check runs fresh for every node), but an NPC goal created BEFORE this fix existed, still sitting in a long-lived process's goal list, would not retroactively decline until that process restarts.

A routine already done, or already claimed, is never offered again (fixed 2026-09-22, carried patch 0016)

Two related complaints from the user, watching the live window.

Hyrule Castle. Jev picked "Carry out the known routine 'HC kill guard for key'" at 70% - Link walked back to the castle, went in, turned around and left. ap_scripts[] (ap_map.c) has two live Hyrule Castle routines, "HC kill guard for key" and "HC kill miniboss free Zelda" (SCRIPT_KILLDROPS, no start_item, no requirement) - both exist only to progress the vanilla rescue sequence: kill a guard for its key, then the miniboss guarding Zelda's cell. zbanks::rando::PRESET (packages/zbanks/src/lib.rs) sets $7EF3C6 (sram_progress2) to 0x14 from file creation - bit 0x10 (uncle_left_house) and bit 0x04 (zelda_at_sanctuary) both already set, matching packages/alttp's own Progress::read (what the state tool's "progress":"zelda_rescued" reflects). Both routines' entire purpose is already accomplished before the bot's first frame, in every game this project starts - not conditionally, structurally, since open mode IS this preset.

Blind's House. Separately, Jev picked "Blind's House Block Puzzle" at 79% (frame 41857) with the room's own chests already open - the routine had nothing left to solve either, a different cause (a claimed reward, not a skipped storyline beat) with the same symptom.

Fix (0016-hc-routines-already-done-in-open-mode.patch, ap_plan.c), two checks in ap_goal_score's GOAL_SCRIPT case. (1) GOAL_SCORE_UNSATISFIABLE for any script node whose name starts "HC " once *ap_ram.sram_progress2 & 0x04 is set - the same "read the live flag, gate the goal" shape as 0010's boots requirement and 0013's big-key gate. The two commented-out HC entries share the prefix, so re-enabling either inherits the gate for free. (2) A GENERAL check, for every routine rather than only the castle's: walk the script's own screen's node list: if it has at least one NODE_CHEST and every one already shows its sram_room_state bit set (the identical read GOAL_CHEST's own scoring already does), the routine is GOAL_SCORE_COMPLETE

  • nothing left to solve. A routine whose screen has no chest at all (AgTower's statues/curtain, the Kak well jump) is untouched, since has_chest stays false.

Proof. The HC gate: a fresh headless run from home with the live window's own current map imported (run/zbanks-c/hc-gate/final, since a truly fresh, mapless run had not discovered Hyrule Castle within 15,000 frames tried) shows, from the first evaluation after the script node is registered, unsat for the whole run:

attached node Script: HC kill guard for key
* 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]

The chest-completion check is code-reviewed, not yet live-fire proven: it reuses GOAL_CHEST's own already-proven bit formula verbatim over a node traversal, but no fixture on disk has Dam's or Blind's chest ALREADY open at the moment its script goal is (re-)scored - in every headless repro tried, the script's own task-completion fires (wrongly - see below) before the chest opens, so the new branch is never exercised end to end. It becomes testable once the next fix lands.

A finished routine that did not reach its goal is a failure, not a success

The Dam Chest script itself is the reason the check above could not be live-fire proven: ap_goal_complete fired for "Dam Chest Block Puzzle" at frame 2528, BEFORE the chest opened at frame 2591 - the goal is marked complete when its SCRIPT_SEQUENCE task finishes running its hardcoded movement string, not when the thing the script exists to do (open the chest) actually happens. A script whose steps are wrong for this cartridge (the user's own "D" report: two blocks pushed beside the chest, one below, chest still shut, frame 26507) still reports success and the bot wanders off to unrelated exploration - the exact "worthless, looks-fine failure" this section exists to name. Fix and general Dam Chest push-block solver: see this file's own upcoming section (this session's item D).

Jev is not told a routine already failed here (open, item E)

A third complaint from the user watching live: at frame 23806, Jev's options were the Dam Chest routine (already failed on earlier visits) against an unexplored overworld edge, with nothing in the request telling Jev the Dam option was a repeat of a known failure - state/the goal-choice Facts carried no attempt history at all, so Jev could not weigh "try again" against "explore something new". Fix: a per-goal outcome log kept by the host (the C side already has goal->attempts; the host sees every fail/retry) surfaced in Facts, and narrowing when zb_retry_given_up/Recovery put a failed goal back to only when something that bears on ITS OWN failure changed - a requirement now held, the room reset, or its routine fixed - rather than any possession change at all. Not yet built; this section is the record of why.

Never ask Jev to break a tie the scorer already settles (fixed 2026-09-22, item B)

At frame 14674 (and, in the log, frame 8739's own instance of the identical shape) Jev's options were "Lift the pot ... 27 steps away" against "Lift the pot ... 45 steps away" - two pots in the same room, differing only in distance. goal_choice/words.rs's offers() already collapsed same-kind, same-place goals into one offer, but only when their distances were similar (within 8 steps or a fifth of the longer) - a threshold meant to stop two sentences reading IDENTICALLY at a glance, not to decide whether Jev should be asked. The planner's own scoring already weighs distance, so asking Jev to pick the nearer of two otherwise-identical goals spends a question and teaches nothing, at any distance gap.

Fix. offers()'s merge condition drops similar(g.steps, steps) entirely - same description and same screen now always collapse to one offer, however far apart the distances are. similar() itself is deleted (its only caller). The group's representative (goals[0], the goal actually pursued if the offer is picked) is kept as the NEAREST of the merged set as each candidate streams in, rather than whichever happened to be seen first - "not asked: same kind, nearest taken" is the resulting behaviour, not just the log line's wording.

Proof. packages/decisions/src/goal_choice/words.rs's own test same_place_far_apart_in_distance_is_not_collapsed (asserting 2 offers for a 200-vs-900-distance pair) is rewritten as same_place_far_apart_in_distance_is_still_collapsed_and_the_nearer_is_taken (asserting 1 offer, with the nearer goal - index 1, the 200-distance one - as the representative even though it was listed second). Full packages/decisions:test suite green (20/20): the three tests that rely on DIFFERENT descriptions staying apart (pots_in_different_places_are_told_apart, pots_close_together_are_placed_by_steps, sentences_say_what_where_and_the_way) are unaffected, since they never shared a description to begin with.

How many of the logged questions this would have skipped. jev.jsonl only records each option's RENDERED SENTENCE, not the original GoalOption structs offers() groups on, so this is a text-level reconstruction (strip each option's trailing distance clause and count groups that collapse to one), not a byte-exact re-run of the real function - stated as what it is, not overclaimed. Of 776 lines in run/zbanks-window/jev.jsonl where how shows a real asked question (not reused, one-choice, throttled or a fallback), 20 (2.6%) would have been skipped entirely under the new rule - every one is exactly this shape (two or three same-kind, same-room options differing only by a distance clause): frame 92 (two pots, 2 vs 9 steps), frame 3388 (two pots, 19 vs 23), frame 8739 (two pots, 27 vs 45 - the cited example, one frame count off from how it was first reported live), frame 23504 (three chests, 2/7/9), frame 23577 (two chests, 6 vs 8), and 15 more of the same shape.

Room-reset-before-fail: designed, not built

Separately asked for, generically: when a goal fails inside a room whose state the bot changed (a pushed block, and so on), retry by first leaving through the nearest door and re-entering (which resets ALTTP's own per-room puzzle state) for up to K tries, before counting it as a real failure against the goal's attempt limit and Recovery's cap. Not built this session, for a specific, structural reason rather than lack of time to spare: this project's only outward hooks after a goal is chosen are (a) ap_goal_choose_hook (patch 0002), which is offered only the goals ap_goal_evaluate's OWN scoring loop has already decided are GOAL_SCORE_COMPLETE-eligible or better - and a door already known to lead somewhere mapped scores GOAL_SCORE_COMPLETE for a plain EXPLORE goal without ever generating a walk-there task, so the obvious "make the chooser pick the nearest door goal twice" does not actually make Link walk anywhere; and (b) ap_goal_fail, which is static to ap_plan.c and has no host hook at all yet (unlike ap_goal_add, it cannot be redirected with a -D rename trick from another file, since every caller is inside the same translation unit).

A real implementation needs, at minimum: a new ap_goal_fail_hook carried patch (mirroring 0002 exactly - a NULL-by-default function pointer called from ap_goal_fail before its own attempts++/permafail logic, proven a no-op the same way 0002 was, by a byte-identical run with and without it), plus a genuine host-driven "walk to the nearest door and back" maneuver that does NOT go through ap_goal_add/ap_goal_score at all - since as established above, a known door cannot be forced to generate a task through the ordinary goal system. The nearest-existing analogue is ap_target_scripted/ap_scripts[] (SCRIPT_SEQUENCE, patches 0009/0010), which drives Link through a fixed waypoint list outside goal-scoring - but those are hand-authored per room at compile time, not something a host can construct at runtime for an arbitrary room's arbitrary nearest door. That gap - a host-constructible, non-scored "go here, then there" task - is the piece a future session needs to add before this feature can exist; everything else (the per-node failure-count bookkeeping, the K-retries-then-real- failure threshold) is ordinary host-side (Rust) work once that primitive exists. Not attempted half-built: a partial version that LOOKS like it resets the room without actually forcing the walk would be worse than not having it, per this project's own standard for what counts as done.

A door with no vestibule ($0089), and the resume stall it caused (2026-09-22)

A window resumed mid-game (resume, indoors room $0089) stalled in manual mode from frame 0, forever: bot (MCP) showed exactly 2 EXPLORE goals, both scoring unsatisfiable, both targeting the room's own two north doors (door U 0x4b). This was first suspected to be the already-known stale imported map bug (map.19.txt disconnected from Link's real position, 2026-09-21), and renaming the map aside was tried live - it changed the symptom (goal_count dropped from 232 to 2, an honestly-scanned room instead of a stale one) but not the stall, proving the two are different bugs.

Reproduced headless, deterministically, by save_state-ing the live stalled window and using it as a fresh home for apps/zbanks (run/zbanks-c/ep-repro/, --states pointed at a directory holding only that one snapshot as home.state): the same two doors, same unsatisfiable, same manual mode from frame 0, every time.

Ruled out, with evidence, in order:

  1. Missing cross-screen adjacency (a sibling multi-cell quadrant never linked). A temporary, never-committed instrumentation pass on ap_pathfind_global/ap_pathfind_node (printf per call, gated on nothing so it always fires) showed start_screen == destination->screen (same=1) for both goals - the destination is on LINK'S OWN screen. The Dijkstra cross-screen graph is never even reached; ap_pathfind_local fails first. Any static same-dungeon_room pre-link (mirroring the stairs/ledge merges) would be a no-op for this bug.
  2. A genuine local-pathfinding misclassification (a real tile wrongly scored as a wall). A second instrumentation pass dumped ap_pathfind_local's own cost grid (raw_tile/cost per cell) around both Link's position and the registered destination, and a flood-fill over the parsed grid confirmed the destination box is disconnected from every cell Link's own position can reach - matching the search's own verdict. But raw ZB_HUMAN movement (no bot in the loop) proved this ISN'T a real wall: holding LEFT/RIGHT alone walks Link's x to exactly each door's own tl.x; holding DOWN from north of the door crosses its tile and lands in a DIFFERENT ROOM ($00a9) within 20 frames. The grid is disconnected because the registered DESTINATION - not the path to it - is unreachable: it sits 24px past the door, on the far side of an instant, no-vestibule transition.
  3. A mis-detected door direction. Every door's adjacent_direction is guessed from which edge of its own bookkeeping screen it sits nearest (ap_map.c's d_t/d_l/d_b/d_r comparison), then asserted (not corrected) against the ROM's own door-direction table (ap_ram.dungeon_door_dirs) - a mismatch is a silently-continued assert_bp (a SIGTRAP), never fixed up. Suspected here, since a wrong guess would explain both the offset and a failed TASK_TRANSITION. Disproven: making the fix "trust the ROM's own direction when it disagrees" produced ZERO mismatches for this door (traps: 0) - the heuristic's "U" already agrees with the ROM. The theory that $00a9 is simply this same room's lower floor ($0089 + 0x20, XYFLIPBG's own x match at 0x88b0) was also checked against the real landing position and rejected: Link's y after the transition (~0x147b) is roughly 1024px past where a same-quadrant floor-below would land (~0x1068) - this is a real, separate, distant room, not an adjacent floor.

Root cause and fix: every door's registered approach point is offset 24px past its own tile, in its own direction, assuming a few pixels of floor stand between "in front of the door" and the transition - correct for every other door this bot has ever crossed (Eastern Palace's own clear run depends on it), wrong for this one, which has no vestibule at all: touching its tile transitions instantly. The 24px offset therefore places the goal on the far side of a line Link's own room can never reach by walking, and ap_pathfind_local correctly, permanently, reports it unsatisfiable. Patch 0014 (ap_map.c, room $0089, tile_attr 0x4b only): use a 0px offset instead - the door's own tile, which ap_pathfind_local already treats as passable within 3 grid-cells of Link's start (the XYL1DIST(...) <= 3 exemption every EXPLORE-a-door goal already relies on to be reachable at all once Link is close), with no assumption about which side has clearance.

Proven, headless, from the exact reproduced stall (run/zbanks-c/ep-repro/, 20,000 frames, deterministic across repeated runs): both goals go from permanently unsatisfiable to active and pursued within the first few hundred frames (Plan: EXPLORE via GOTO_POINT), the bot walks through the door and the room changes - $0089 (start) → $00a8/$00a9 (by frame 2,000-3,000) → $00aa → $00b9 → $00c9 → $0105 → $0112 → $010b ("Dam Chest", by frame 20,000) - recognizably Eastern Palace's own room names appearing in the goal list (EP Catwalk, EP Dodgeball, EP 5 pots, EP Chest&Ledge) along the way. manual mode: false at the end: the bot is genuinely playing, not idling in a shorter recoverable cycle.

Outstanding, as of 2026-09-22 (queue after the movement/combat split)

Recorded so the queue survives a session boundary. The coordinator split this engagement's queue: a SEPARATE agent now owns movement and combat (hammer-on-floor / 0012 generalized, dash as a path action, glove gating, dash-when-faster, stopping a dash, melee/bow/vulnerability, projectiles - its patches number from 0030) - none of that is this file's concern from here. This agent kept the goal-system/planning half, patches numbered 0016-0029.

Done, committed: item 7 (bot.log real rotation via a pipe and a dedicated thread, 1ddd5b0d); item A (routines with nothing left to do - the HC gate and the general chest-open check, patch 0016, 2daf5bf4); patch 0015 (general push-block jam detection, d34f01b1).

Queued, in the order last given:

  1. B - Jev asked a worthless question: frame 14674, two pots differing only in distance (27 vs 45 steps). Only ask Jev when offers differ in KIND (screen/area, goal type, item/requirement) - collapse same-kind options in the same room to "not asked: same kind, nearest taken", extending the existing equivalence merge in goal_choice/words.rs. Report how many of the 728 questions in run/zbanks-window/jev.jsonl the new rule would have skipped.
  2. F - argmax vs. sample threshold: frame 68593 sampled a 10% option against a clear favourite (wasted trip); frame 77784 sampled a 19% option. Decode rule: top probability >= 0.70 takes the argmax; below that, keep sampling (close calls still explore). Record which rule fired ("took favourite" / "sampled, close call") in the Record and show it in the panel badge. Unit-test both branches. NOTE: packages/jev-http (ledger.rs/lib.rs/README.md) showed uncommitted changes mid-session, not made by this agent - check git log/git status before starting; another agent may already be on this.
  3. E - goal outcome history + narrower retry: Jev's options at frame 23806 didn't say the Dam routine had already failed here. Keep a per-goal outcome log in the host (C already has goal->attempts; host sees every fail/retry) and surface it in Facts (attempts, how each ended, frames since last try). Narrow zb_retry_given_up/Recovery's put-back rule: only when something bearing on THIS goal's own failure changed (a requirement now held, the room reset, its routine fixed) - not on any possession change. Prove headless: no return trip to a failed goal without a relevant change.
  4. D - general push-block puzzle solver (do not hand-fix the Dam script): BFS over block configurations from the room's live tile attributes and block sprite positions - state is (Link region, block positions), a move is a legal push (target tile floor, not wall/another block, reusing 0015's knowledge) - search until the goal tile (chest/door approach) is reachable, then execute via existing push/GOTO machinery. Use for every push-block room; Dam Chest's routine becomes a caller. Prove headless on Dam Chest (chest's SRAM bit set) and one other push-block room. Also folds in the already-identified bug: a SCRIPT_SEQUENCE task finishing must not count as its goal being reached
    • the Dam script finished at frame 2528 with the chest still shut and the goal wrongly marked complete; a finished routine that did not reach its goal must count as a FAILURE (see this file's own section on this, above). NOTE: generalizing 0012 itself (removing its dungeon_room == 0x010B room check) moved to the movement/combat agent's list - this agent's own general push-block MECHANISM (0015) already stands independently of whether 0012 stays room-scoped.
  5. Item 2 - failure_recovery, the ap_goal_fail hook, and a room-exit task, proven on Dam Chest 0x010B.
  6. The frame-57040 stall - see "Paused mid-investigation" below.
  7. Fresh-game sword with no map, plus a new canonical baseline - see this file's own bisect finding (next paragraph): make a fresh game without a map actually get the sword, then rebuild the 90k baseline from home and record its milestone frames here (s1map is gone).
  8. The generalized-0014 verdict - land or drop the graduated door-offset generalization (run/saved-patches/0014-generalized-graduated-offset.patch) against the new baseline from item 7, and say which.
  9. Item 5 - the sourced, web-researched location table for goal_choice::Facts.

Item 7's own finding (already recorded, still load-bearing for item 7 above): the "fresh run from home never gets the sword" symptom is NOT a code regression - a headless run at 1e493ef2 itself, with the coordinator's own exact bisect protocol (fresh --make-home, no map, Jev off), also never gets the sword out to 30,000 frames. The historical "sword ~3,000 frames" figure required the missing s1map import giving the bot pre-existing location knowledge; blind EXPLORE alone does not converge on it at any commit tested. No commit needs bisecting; what is missing is either a rebuilt map fixture (tracked in git this time, not gitignored run/, so it cannot go missing again) or a real "seed goals from the current screen on a mapless start" mechanism - not yet designed.

Paused mid-investigation, evidence preserved: the frame-57040 EP Bigchest stall (second_stall_frame57040 save state, roms/*.states/). Findings so far, from headless repro (run/zbanks-c/frame57040/) with temporary, reverted debug instrumentation: (1) ap_update_map_screen(false) resolved "current screen" as 0x04b8 while Link's own Y position (0x0538=1336) falls inside the ADJACENT screen 0x05b8's range (1280-1535) - an off-by-one-quadrant misresolution, seen specifically with link_lower_level set (a multi-level room) - this alone makes GOAL_PICKUP's goal->node->screen != screen check wrongly reject pots Link stands next to. (2) Even when screen resolution IS correct (observed once, frame 157), ap_pathfind_local still returns -1 for a pot on that SAME, correctly-matched screen - a genuine local A* bug independent of (1). (3) The GLOBAL Dijkstra fallback ALSO fails for every goal on that screen (search exhausts with no route). Root cause not yet isolated to a single fix; likely candidates given link_lower_level's involvement: the room's BG1/BG2 layer selection or Y-origin computation for the lower level of a multi-quadrant room. Next step: read ap_update_map_screen (ap_map.c:2363) and the link_lower_level branch (ap_map.c:273) against how the mismatched screens' own bounds were registered.