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