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:
| Upstream | What it is | Ours (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 / S9xUnfreezeGame | Named savestates in the working directory; nonzero on success. ap_init calls load("home") then load("hpegs") (ap_snes.c:66-81) and ignores the result. | Console snapshots, <name>.state in a states directory the host chooses. |
info_string_ptr = &GFX.InfoString | Snes9x draws this string over the picture. The bot points it at ap_info_string every tick (alttp.c:15). | A slot the panels read. |
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 word | Snes9x layout: bit 15 B, 14 Y, 13 Select, 12 Start, 11 Up, 10 Down, 9 Left, 8 Right, 7 A, 6 X, 5 L, 4 R (ap_snes.h:6-17). | PAD_BITS. Opposing directions are dropped by both emulators (Snes9x UpAndDown off by default; jgenesis allow_opposing_joypad_directions: false, snes-core api.rs:63). |
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$00below$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:
| Japanese | USA | Table | Bot's name |
|---|---|---|---|
$01E96C | $01E96E | RoomData_ChestItems, 168 × 3 | dungeon_chests |
$02CCBD | $02CF59 | EntranceData Y | entrance_ys |
$02CDC7 | $02D063 | EntranceData X | entrance_xs |
$02EB29 | $02EDC5 | .bombable_door_location | over_overlay_map16s |
$06F735..$06F7D5 | 6 bytes lower | sprite hitbox tables, 6 × 0x20 | hitbox_* |
$0FFD94 | $0E9459 | OverworldTileTypes, 0x200 | over_tattr2, and LOAD2(0xFFD94 + x) (ap_map.c:417; its own comment cites the USA DATA_0E9459) |
$0F8000 | same | Map16Definitions | LOAD2(0xF8000 + x) - the USA table has six more entries, but work RAM holds USA map16 numbers, so the USA table is the right one |
$1BB800..$1BBB73, $1BF110 | same | overworld entrance / hole tables, tile types | over_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 address | Vanilla | Patched | What |
|---|---|---|---|
$08C2DD | one message id per item, 0x4C words | all $FFFF ("no message", bank_08.asm:13773-13775) | Ancilla22_ItemReceipt .message (bank_08.asm:13233-13310) |
$08C380 | $0155 $0156 $0157 | $FFFF ×3 | .heart_piece_message, a heart piece from a chest (bank_08.asm:13322-13326, 13754-13756) |
$05F0BC | JSL ShowMessageUnconditional | 4 × NOP | a 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_tickreturns 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_targetslooks 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:
$82becomesENMY | 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.- Kill-all skips
NVULsprites. - A
SCRIPT_KILLALLentry, "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". - 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
b4permafailed 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, isJOYPAD_CLEAR(A); JOYPAD_CLEAR(B); JOYPAD_CLEAR(Y);(ap_map.c:835-839).ap_tickzeroes 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 oneap_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 threeLL_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 -Sover upstream's whole history finds no reset ofattemptsever.- The bot does not know a big chest needs the big key:
chest_type = 1is set for big chests (ap_map.c:3233-3238) and never read; no big-key requirement is put onGOAL_CHEST;ap_node_islockedchecks the big key only for key blocks and big-key doors. So the chest looks open,OPEN_CHESTpresses 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.
- No path to him. "A* failed, min heuristic: 2" with Sahasrahla standing
there (sprite 0,
0x16.0 BLKF|TALK|NODE).ap_pathfind_localmakes 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. - Never "there".
XYLINKINwants 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_NPCpresses 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.) - A false gift.
TALK_NPCcounted any nonzero$02D8as the NPC's item.$02D8keeps the last item Link got (0x17here), so the talk "succeeded" in one frame and left the text box it had opened with nothing pressing A - the same stale read that madeo1abort at uncle. And the real gift is received while Link holds it up, whenap_tickdoes not evaluate tasks at all (alttp.c:126-127); by the next task frame$02D8is 0. Fix: a gift is a change in what Link has ($7EF340-$7EF35F, less the bomb count) since the talk began. - 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. - 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.
- 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. - 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 calls0x11FALLING_LEDGE),ap_tickonly 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:
| Commit | Result |
|---|---|
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 entry0x49(~/src/github.com/JaredBrian/AsarUSALTTPDisassemblyBank02.asm:11975Dungeon_LoadEntrance, indexed atBank02.asm:11525.rooms/Bank02.asm:11712.playerY/Bank02.asm:11731.playerX, pinnede41fef7) gives room$0107,playerY=$21D8,playerX=$0E78- stored straight into$20/$22by that routine, matchingSpritePrep_DashItem's own hardcoded library check (room == 0x07, sub == 0x01, same repoSprites/sprite_prep.asm:1394,1398, mirrored atwalkingeyerobot/alttp-disassemblysprite_prep.asm:1394). - The indoor address space widens X but not Y.
ap_snes.c:ap_sprites_updateappliesx = ((x & ~0x1FF) << 2) | (x & 0x1FF); x += 0x4000to a sprite's hitbox X when*ap_ram.in_building, and leaves Y untouched. Applied to the entrance's rawplayerX=$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 byap_map_add_constants_to_screenin every run that ever referenced the door, e.g.run/zbanks-c/b3/bot.log:4718).playerY=$21D8needs 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, $3Bfor the book, and two decorative$6Ds atdb $1B, $17, $6D/db $1B, $18, $6D. The disassembler's own comments readxy: { 0x030, 0x150 }for the book and{ 0x170, 0x1B0 }/{ 0x180, 0x1B0 }for the decorations - andbyte1*16($03*16=0x030,$17*16=0x170,$18*16=0x180) matches the FIRST ("x") number both times,byte0*16($15*16=0x150,$1B*16=0x1B0twice) 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
0x200pass through the X-widening formula unchanged (it preservesx & 0x1FFverbatim), 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) is0x48(72px) EAST and0x78(120px) NORTH of the book - not the "shelf on the wall opposite the door" a reflex north-dash-from-the-doorway would assume.start_tlis set to the entrance's own Y but the book's X (0x7830,0x21D8), which ordinaryGOTO_POINTpathing (no dash) can reach from the door before the script's ten2(dash-south) characters take the ~120px remaining, with margin for the ~29-frame charge and the fact the boxWaitForBonkchecks 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):
| Run | Host as of | Frames | What happened |
|---|---|---|---|
bed | no ROM remap | 0 | Died on the chest-table assert, ap_map.c:3201 - the Japanese-address finding. |
r1 | remap, vanilla rain start | 30,000 | Out of the house, then stood in a hint soldier's text box for the rest of the run. |
r2/r3 | + open mode | 30,000 | 12 places; the Dam's block-puzzle script solved; a heart piece's text held it from frame 12,660 until it gave up (manual mode) at 17,237. |
r4 | + no item text | 30,000 | 24 places, reaching the castle yard and graveyard; still exploring at the end. |
iter1 | same | 44,580 | 36 places, 11/19 chests, 9 pots, 3/3 scripts, a heart container; fell into a hole into room $2F and gave up at 40,980. |
iter2-iter4 | + each imports the last one's map | up to 60,000 | The map grows run on run (explore goals known 155 → 200 → 287 → 367) and each run heads somewhere new (Kakariko, where the informant's "Here is G, the wanted man!" text held it - see "The Kakariko informant" above). |
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):
| Run | Map imported | Frames | What happened |
|---|---|---|---|
s1map | iter2's | 40,000 | Sword from uncle by frame 12,000 (room $55); then Kakariko, where the informant's text holds it (manual mode). |
s1fresh | none | 40,000 | Explores caves and houses; never near the castle in this run. |
s2a | s1map's | 50,000 | Sword by frame 5,000; the castle's key guard killed (SCRIPT_KILLALL, 10,526, 11,183); Eastern Palace at 27,000; the Stalfos room ($A8) cleared - "Done killing" at 31,448, its trap door taken at 31,774 (run/zbanks-c/s2a/frame-031000.png, frame-032000.png); then pinned under the anti-fairy circle in room $B8 to the end (see "Eastern Palace $B8"). |
s2b | iter4's | 50,000 | Sword 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):
| Run | Jev | Map | Frames | What happened |
|---|---|---|---|---|
b5 | off | s1map's | 70,000 | As s2a to 36,400, then through $B8: big key 40,702, the big key doors 41,241 and 42,280, the Eyegore key room ("EP Igor Key") 44,092, on to the red Eyegore room ($D8) - which needs the bow, and the big chest holding it had been permafailed at 33,075, tried four times BEFORE the big key. Manual mode 51,780. |
j4 | on | iter2's | 40,000 | The informant touches Link at 18,583 (a guard spawns), no box; the Kakariko well at 20,422, Blind's house puzzle 24,568. 22 questions, $0.000505. |
j9 | on | p1's | 36,000 | Started as home = p1's frame 34,000 (b5's road, Jev off, in the Eastern Palace). $B8 cleared at +5,156, big key +5,633, big chest (the bow) +6,890 - its goal fresh from the imported map, not yet permafailed - Eyegore key +9,687, the Armos Knights ($C8) from ~+16,500 to the end: one of the six killed ("EP Armos Knights Boss" is upstream's own kill-all script), the rest at full HP 48. No pendant. 25 questions, $0.000608. |
j2, j7 | on | s2a's, s1map's | 108,000, 72,000 | Jev's picks send the bot round the overworld and Kakariko's houses first; neither reaches $B8. |
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:
| Run | Jev | What happened |
|---|---|---|
v1 | off | Bow 45,000 (the retried big chest), pendant by 59,000, boots by 63,000; manual mode 86,760 - the book is next and needs a dash the bot does not have. |
j10 | on, run limit $0.05 | Sword 2,719; big chest (bow) 43,714; Armos Knights 58,209; pendant by 59,000; Sahasrahla's boots 62,499; manual mode 83,280. 124 choices, 70 questions, 48 reused, 6 not asked, 0 throttled, 0 fallbacks; 577 input tokens a question; $0.001696 over 24.13 game-minutes: 2.90 questions per game-minute, $0.000070 per game-minute. |
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:
| Run | Jev | Frames | Result |
|---|---|---|---|
off1 | off (hook compiled in, NULL) | 12,000 | progress.tsv identical, line for line, to s2a (built before the patch). |
jev1 | on, margin 512, run limit $0.10 | 36,000 (10 game-minutes) | 31 choices, 31 questions, 0 reused, 0 fallbacks, 9 picks differing from upstream's; $0.000707 in all: 3.1 questions per game-minute, $0.000071 per game-minute; ~150 ms a question (111-306). Still progresses: sword by 5,000, castle key guard by 11,000, Eastern Palace by 29,000, the Stalfos room's SCRIPT_KILLALL at 32,000. |
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) | Pad | Movement in 200 frames |
|---|---|---|
| Up | 0x0840 | 1 px |
| Down | 0x0440 | 1 px |
| Left | 0x0240 | 19 px (west, toward/along the sprite's own left edge, 5a88→~5a75) |
| Right | 0x0140 | 4 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 whenattrs & (SPRITE_ATTR_BLKF | SPRITE_ATTR_BLKS)(ap_map.c, the loop patch0006also 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 rawZB_HUMANinput confirmed Down blocked at the very first pixel while the A* grid (dumped fromlocal_cost.pgm, decoded to ASCII) showed no blocked cell anywhere near Link's position at all. Patch0006's owndestination/insideexemptions 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:
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 arestart(dev mode on →restart→ off) gives it a freshRecovery/Bot; this is expected maintenance, not a defect, and is now the second time this exact remedy has been needed.- After a
load_state("home")reset, the window's own auto-imported map (run/zbanks-window/map_export.txt, copied tomap.19.txton everyrestartperapps/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 goalunsatisfiable(no known route) within ~600 frames, then genuinely exhaustedRecovery's cap with 0 goals ever restored (nothing to retry - the graph was just disconnected from here). Renaming bothmap.19.txtandmap_export.txtaside (kept, not deleted, as*.bak-2026-09-22-0105) before the nextrestart+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
0012disabled, 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-triggeredap_jam_notefor 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_cheststays 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:
- 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) showedstart_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_localfails first. Any static same-dungeon_roompre-link (mirroring the stairs/ledge merges) would be a no-op for this bug. - 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 rawZB_HUMANmovement (no bot in the loop) proved this ISN'T a real wall: holding LEFT/RIGHT alone walks Link'sxto exactly each door's owntl.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. - A mis-detected door direction. Every door's
adjacent_directionis guessed from which edge of its own bookkeeping screen it sits nearest (ap_map.c'sd_t/d_l/d_b/d_rcomparison), then asserted (not corrected) against the ROM's own door-direction table (ap_ram.dungeon_door_dirs) - a mismatch is a silently-continuedassert_bp(a SIGTRAP), never fixed up. Suspected here, since a wrong guess would explain both the offset and a failedTASK_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$00a9is simply this same room's lower floor ($0089 + 0x20,XYFLIPBG's own x match at0x88b0) was also checked against the real landing position and rejected: Link'syafter 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:
- 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 inrun/zbanks-window/jev.jsonlthe new rule would have skipped. - 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 - checkgit log/git statusbefore starting; another agent may already be on this. - 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 inFacts(attempts, how each ended, frames since last try). Narrowzb_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. - 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_SEQUENCEtask 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 == 0x010Broom 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.
- 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
- Item 2 -
failure_recovery, theap_goal_failhook, and a room-exit task, proven on Dam Chest0x010B. - The frame-57040 stall - see "Paused mid-investigation" below.
- 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
homeand record its milestone frames here (s1mapis gone). - 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. - 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.