jevsnes.git / research / castle-passage.md

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:

DirectionLine
Reading the ROMassets/extract_resources.py:137 — pos = get_word(0x9BB800 + i * 2) + 0x400
Writing it backassets/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 $08 deep water (1,943 cells) and the bridge is ordinary $00 ground between $01/$02 railings, 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:

EntranceRoomAtReached
$0396(1832, 1576)no
$04 — the front door97(2040, 1784)no
$0598(2248, 1576)no
$24224(2040, 1624)no
$32 — walk-in secret entrance85(2248, 1752)no
$7D — the hole85(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:

ByteVerticalHorizontal
$28 northhop, either way, landing searched northward (:4479-4487, 4675-4706)blocked (:5079)
$29 southhop (:4465-4476)blocked
$2A/$2Bblocked (:4459)hop, left or right by walking direction (:5098-5122)
$2C/$2Ehop (:4508-4517)also hop (:5141-5151)
$2D/$2Fhop, 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.