jevsnes.git / research / castle-inside.md

Inside Hyrule Castle: from uncle to Zelda's cell

Written 2026-09-21 from zelda3 (src/dungeon.c, src/player.c, src/sprite_main.c, assets/extract_resources.py), the Asar USA disassembly (AsarUSALTTPDisassembly/Bank04.asm) and usdasm, with print_room / get_exit_datas from zelda3's extractor run read-only against the USA ROM. Positions are pixels inside the room's 512x512 supertile unless marked world. It exists because Goal::RescueZelda had no route from the moment Link took the sword, and the reason was not where anyone had been looking.

Why RescueZelda had no route

Room $55 (uncle, the secret passage) has no door or stairs into the castle. Its only exits are the hole Link fell in by and a door of type $12 (exit to the overworld) in its south wall at (112, 464). The overworld exit table puts Link back outside at world (2248, 1736) in area $1B, inside the castle walls. So the goal's target, the front door (entrance $04, world (2040, 1784)), is right: what was missing was getting out of $55 first. plan::heading only answers for an outdoor target from outdoors, so indoors it said nothing. Fixed by plan::way_out (jev 8d087157). This probably also explains the "inner grounds unreachable" row in castle-passage.md: the inner courtyard is entered from this exit, not from the moat side. Inferred, not yet measured with walkcheck --spots.

Freeing Zelda does not set $7EF3C5 = 2. Her cell script sets follower_indicator ($7EF3CC) to 1 (sprite_main.c:6300-6305); progress 2 is set only at the Sanctuary (:6342). Goal::RescueZelda now reads the follower (alttp::Follower).

The route

StepHow
$55 (dark) → overworldSouth exit door (112, 464); walk from world (2248, 1736) to entrance $04
$61 (1F)Arrive at the south door, about (240, 440)
$61 → $60West door (16, 120) or (40, 248)
$60 → $50North door (368, 56)
$50 → $01East warp door (440, 120): header slot stairs3 = $01, not $51
$01 → $72 (B1)Spiral stairs down at (240, 80)
$72 → $82Opening in the bottom edge at (48, 448), room +16
$82 → $81West-edge openings at (0, 112) or (0, 384), room -1
$81 → $71North door (112, 56), room -16
$71 → $70 (B2)Spiral stairs down at (152, 56)
$70 → $80 (B3)Spiral stairs down at (72, 32), stair slot 1

The east wing mirrors it: $61 → $62 → $52, then $52's west warp door → $01. Floors are counted from $61 as 1F, not read from a floor table.

How each transition works (src/dungeon.c)

  • Edge. Room -1 at :2074, +1 at :7987, -16 at :2123, +16 at :2157, only when Link crosses the ROOM boundary (a quadrant boundary just scrolls). A $89 floor tile is a warp door: it goes to header slot stairs2 going left, stairs3 going right (:2067, :7980). An $8E tile on an up/down edge exits to the overworld (:2106, :2150).
  • Stairs. Dungeon_DetectStaircase (:4302-4357): $26/$38/$39/$5E/$5F at Link's feet with a $30-$37 tile one row below; the destination is header slot stairs[attr & 3] (:4338-4339). The engine numbers staircases $30 + running index, up stairs first then down from $34 (Dungeon_LoadObjectAttribute, :3865-3930). Stairs on layer 2 go in the second half of the collision grid (:1524), read only when $EE != 0 (:4310) - which applies to $01 and $71; the value of $EE there is unverified.
  • Holes. Header byte 9 (player.c:1545). None on this route.

Reading it from the ROM

  • Room header pointers at SNES $04F502 (PC $027502), a word per room into bank $04 (extract_resources.py:380; loader dungeon.c:3670-3719). Byte 0 bg2/collision/dark (bit 0); 1-6 palette, blockset, spriteset, effect, tag1, tag2; 7 hole and stairs0-2 planes; 8 stairs3 plane; 9 hole destination; 10-13 stairs0-3 destinations. Headers can stop short at 7 or 11 bytes - the rest is the next room's header - so a slot is read only when the room has a staircase or hole that uses it.
  • Door lists at $1F83C0 (usdasm rooms.asm:329), two bytes a door: position 0-11 in the high nibble of byte 0, direction N/S/W/E = 0-3 in its low two bits, type in byte 1 (dungeon.h:5-38; dungeon.c:83-86 maps position to tile). Types on this route: $1C small-key door, $44 shutter, $46 warp door, $12 exit to the overworld, $16 layer change.
  • Chests $01E96E; room sprites (including key carriers) $09D62E; overworld exits $02DD8A onward (extract_resources.py:32-45).
RoomHeader bytesBank04.asm
$5501 01 10 0D 00 00 00:7811
$61C0 00 00 04 00 00 00 08 00 00 51:8076
$60 = $62C0 00 00 04 00 00 00:8054
$50 = $52C0 00 00 04 00 00 00 00 00 00 00 00 01 01:7701, :7745
$01… 72 00 50 52:6193
$72C0 01 01 04 00 00 00 08 00 00 01:8386
$81 = $82C0 01 01 04 00 00 00:8653
$71C0 01 01 04 00 08 00 00 00 00 70:8364
$7000 01 01 04 00 00 00 08 00 00 71 80:8342
$8060 01 01 04 00 00 00 00 00 00 70:8631

What gates the route

  • $72: small-key door at (240, 288) between the landing and the way on; the key is carried by the soldier at (272, 96) in the same upper part.
  • $71: a kill-the-enemies tag on the south half with shutters at (112, 440) and (296, 376); the blue soldier at (416, 384) carries the key for the small-key door at (112, 288) in front of the stairs down.
  • $80: the ball-and-chain trooper at (416, 144) drops the big key; the cell lock at (352, 112) opens like a big-key chest (dungeon.c:1696-1704, :4041-4047). Zelda is at (352, 48). The chest at (328, 64) holds the lamp (whether it is inside the cell is unverified).
  • Dark rooms on the route: only $55.

What the harness still lacks for the indoor half

nav::dungeon has the graph and the search (edge / stairs / drop links, fewest-transition BFS). Not yet built: a room-header and door-list reader in alttp (same read_bytes/read_words helpers entrance.rs uses) that produces those links from the ROM; a Target::Room for goals inside a dungeon (RescueZelda's real target is room $80 once Link is through the front door); mapping a staircase exit in the scene to its header slot (attr & 3 of the $30-$37 tile under it); warp doors ($89) as their own exit sort, since today they read as a generic Door; and fighting the key carriers, which the fight candidate (Then::Strike) makes possible.