Hyrule Castle's secret passage, and why every run stopped short of it
Written 2026-09-20 from primary sources (zelda3 src/, assets/; usdasm
bank_*.asm) and from measurements taken with apps/walkcheck --map against
the real USA ROM. It exists because a whole session was spent believing the
harness could not cross Hyrule Castle's moat, and the moat was innocent.
The one fact that mattered
kFallHole_Pos is not the position of the hole. It is 128 pixels - eight
map16 rows - above it.
Overworld_GetPitDestination (zelda3 src/overworld.c:3194-3215) builds its
lookup key from link_x_coord/link_y_coord, but it is called from
Link_HandleFall after that function has already moved link_y_coord up
by the camera offset:
// src/player.c:1531,1547,1552
uint8 y = (uint8)(link_y_coord - BG2VOFS_copy2);
...
link_y_coord = link_y_coord - y - 0x10;
Overworld_GetPitDestination();
So the table is built against a key that is not where the hole is, and zelda3's own tooling says so in one line each way:
| Direction | Line |
|---|---|
| Reading the ROM | assets/extract_resources.py:137 — pos = get_word(0x9BB800 + i * 2) + 0x400 |
| Writing it back | assets/compile_resources.py:323 — x << 1 | ((y - 8) & 0x3f) << 7 |
$0400 is eight rows of 128 pos units. packages/alttp/src/entrance.rs
applies it to holes and not to doorways, which carry no such fudge.
What it cost
Entrance $7D decoded to world (2432, 1568): a Wall tile in the deep water
at the top of area $1B, with no land near it. Every run therefore reported
"the goal is unreachable", from anywhere, forever — and because the scene says
cannot get there whether the map does not join up or the target is in the
sea, it read as a collision defect for a whole session. Corrected, the hole is
at (2432, 1696), and the flood reaches it in 148 steps from south of the
moat.
Cross-check, from a table with nothing to do with the first
OverworldData_HiddenItems_Screen_1B (usdasm bank_1B.asm:2359):
|1BC4E5| dw $0570 : db $80 ; Hole xy:{ 0x380, 0x0A0 }
$380 and $0A0 are tiles 56 and 10, which is (2432, 1696). Two tables, one
answer.
The hole is under a bush, and the bush grows back
Type $80 is a secret: Overworld_RevealSecret (src/overworld.c:3556-3595)
returns kTileBelow[0] = map16 $0DCC, whose four quadrants are map8 $024
and $035, both behaviour $20, a pit (usdasm bank_0F.asm:3538,
bank_0E.asm). Before it is revealed the tile is map16 $0036, behaviour
$50 — a green bush, solid and liftable.
Lifting is what reveals it. Overworld_LiftingSmallObj (:3393-3411) calls
Overworld_RevealSecret(pos) and, when it returns non-zero, writes that map16
straight into the $7E2000 buffer (:3405) — the same buffer
alttp::Outside reads every frame. So the hole appearing is something the
harness simply sees; nothing has to be predicted.
It is not persistent. Overworld_Memorize_Map16_Change (:2727-2735)
fills a RAM list that every area load zeroes (:934, 1782, 1868, 1931, 1980, 2024), and no save_ow_event_info bit is set for a $80 secret. The bush is
back every time Link re-enters the area, so "walk there, lift, step in" is a
plan and not a one-off. That is brain::Then::LiftAndEnter.
The map of area $1B, measured
walkcheck --state castle --map, 2026-09-20. Area $1B is 1024x1024 at
(1536, 1536); the four screens $1B/$1C/$23/$24 share it.
- No unnamed behaviour bytes at all. The area uses 31 distinct values and every one has a name. Tile classification was never the blocker here.
$27(1,065 cells) is the castle's iron fence —TileBehavior_Hookshottables,R14 |= bits(src/tile_detect.c:342-344), genuinely solid.- The moat is
$08deep water (1,943 cells) and the bridge is ordinary$00ground between$01/$02railings, at x 1992-2088, running from y 2424 up into the courtyard. There is no "bridge" tile behaviour in the engine and no mention of a castle moat anywhere in zelda3 or the three disassemblies; bridges in this game are drawn tiles over plain ground. - The flood from south of the moat crosses the bridge, spreads through the courtyard band at y 2208-2264, and walks both the west (x 1624-1712) and east (x 2384-2496) perimeter corridors all the way north.
What is still unreachable, and is a real gap
The castle's inner grounds are not reached by the flood, so the four
doorways filed under the area are all no route:
| Entrance | Room | At | Reached |
|---|---|---|---|
$03 | 96 | (1832, 1576) | no |
$04 — the front door | 97 | (2040, 1784) | no |
$05 | 98 | (2248, 1576) | no |
$24 | 224 | (2040, 1624) | no |
$32 — walk-in secret entrance | 85 | (2248, 1752) | no |
$7D — the hole | 85 | (2432, 1696) | 148 steps |
The what-if pass (nav::Ground::treating_as_open, one class at a time) says
all five open at once if Wall is opened and none of them open for any other
class. So the barrier is something we read as $01/$02/$26/$43/$42
and the engine does not, OR the inner grounds are genuinely gated in a way
this has not found — a gate sprite, a $70-$7F manipulable tile, or a
one-way ledge taken in a direction alttp::Hop does not model. It has not
been measured against the engine, and the way to do that is to put Link on
the frontier and run walkcheck --spots there.
It matters for Goal::RescueZelda, whose target is entrance $04. It does
not matter for Goal::FindUncle, which is the hole.
Ledge directions, not yet acted on
alttp::Hop::ways is narrower than the engine. From src/player.c:
| Byte | Vertical | Horizontal |
|---|---|---|
$28 north | hop, either way, landing searched northward (:4479-4487, 4675-4706) | blocked (:5079) |
$29 south | hop (:4465-4476) | blocked |
$2A/$2B | blocked (:4459) | hop, left or right by walking direction (:5098-5122) |
$2C/$2E | hop (:4508-4517) | also hop (:5141-5151) |
$2D/$2F | hop, with a sideways component (:4490-4505) | also hop (:5154-5168) |
We model the diagonals as vertical-only. Widening them would open routes; it
would also open wrong ones, so it wants a walkcheck --spots measurement
before and after rather than a patch.
Every ledge also goes through RunLedgeHopTimer (:4572-4588), which
restores Link's coordinates for about 19 frames — so for a third of a second a
ledge behaves exactly like a wall. Anything that decides "Link did not move,
therefore that direction is blocked" on a shorter window than that will
blacklist every ledge in the game.
Bytes that can occur outdoors at all
The overworld's behaviour byte comes only from kMap8DataToTileAttr, a
512-byte ROM table at $8E9459 (usdasm bank_0E.asm:205-268,
OverworldTileTypes). Its whole distinct value set is
00 01 02 08 09 10 12 18 1A 20 22 27 28 29 2A 2B 2C 2D 2E 2F
40 42 43 44 46 48 4B 4E 4F 50 51 52 53 54 55 56 57
plus 11 13 19 1B from the horizontal-flip OR (src/tile_detect.c:25-27).
Everything else is indoors-only in vanilla. alttp::Tile::outdoors still
names all 256, because a classifier with a hole in it reads an unknown wall as
open.