A Link to the Past, read out of SNES work RAM.
Every address here is an offset into the 128 KiB of work RAM, which the
SNES maps at $7E0000: $7E0010 is offset 0x10, $7EF36D is 0xF36D.
The addresses and their meanings come from research/alttp-ram-map.md,
which cites the disassemblies they were read from.
8use std::fmt;
10use serde::Serialize;
A direction on the pad, which is also a direction on the map: up is north.
22impl Direction {
$7E002F, link_direction_facing. Two independent places in zelda3
pin the values: kDashTab2[] = {8, 4, 2, 1} indexed by
link_direction_facing >> 1 as the joypad bits that continue a dash
(src/player.c:1184,1212), where 8/4/2/1 are Up/Down/Left/Right
(src/zelda_rtl.h:89-92); and src/misc.c:706-708, which sets
link_direction = 8 (up) and link_direction_facing = 0 together.
$7E0010: which of the game's top-level modules is running. The value is
the index into the game's own dispatch table, and the names follow it.
Light world and dark world alike; which one is [State::world].
The areas off the overworld grid: the Master Sword grove, Zora's domain.
58 SpecialOverworld,
A text box, a map, a menu, a potion being drunk: see [Interface].
0x0C and 0x0D are empty slots in the dispatch table, and nothing
above 0x1B is in it at all.
79impl Mode { 80 pub fn read(byte: u8) -> Self { 81 match byte { 82 0x00 => Self::Title, 83 0x01 => Self::FileSelect, 84 0x02 => Self::CopyFile, 85 0x03 => Self::EraseFile, 86 0x04 => Self::NameFile, 87 0x05 => Self::LoadFile, 88 0x06 => Self::EnteringDungeon, 89 0x07 => Self::Dungeon, 90 0x08 => Self::LoadingOverworld, 91 0x09 => Self::Overworld, 92 0x0A => Self::LoadingSpecialOverworld, 93 0x0B => Self::SpecialOverworld, 94 0x0E => Self::Interface, 95 0x0F => Self::SpotlightClosing, 96 0x10 => Self::SpotlightOpening, 97 0x11 => Self::FallingIntoDungeon, 98 0x12 => Self::GameOver, 99 0x13 => Self::PendantBossVictory, 100 0x14 => Self::AttractDemo, 101 0x15 => Self::MirrorWarpFromAgahnim, 102 0x16 => Self::CrystalBossVictory, 103 0x17 => Self::SaveAndQuit, 104 0x18 => Self::GanonEmerges, 105 0x19 => Self::TriforceRoom, 106 0x1A => Self::Credits, 107 0x1B => Self::SpawnSelect, 108 other => Self::Unrecognised(other), 109 } 110 }
The modes in which Link walks about.
What kind of thing this mode is, for a caller that has to do something about every one of them.
Exhaustive on purpose, with no wildcard arm. A mode added to the
enum is a compile error here until somebody says which kind it is,
which is the whole point: the app once sat on the full-screen world map
pressing A at it - a button that screen does not answer to - because
"not a play mode" was a single else and nothing had to name the case.
125 pub fn kind(self) -> Kind { 126 match self { 127 Self::Dungeon | Self::Overworld | Self::SpecialOverworld => Kind::Play, 128 // A screen laid over the game. Which one is the `Interface` step. 129 Self::Interface => Kind::Overlay, 130 Self::Title | Self::AttractDemo => Kind::Title, 131 Self::FileSelect => Kind::FileSelect, 132 Self::NameFile => Kind::NameFile, 133 // Flows we never want to be in. Backing out is the answer. 134 Self::CopyFile | Self::EraseFile => Kind::Unwanted, 135 Self::GameOver => Kind::GameOver, 136 Self::SpawnSelect => Kind::SpawnSelect, 137 // Loads, transitions and cutscenes: they end by themselves, and 138 // a button pressed into one is at best ignored. 139 Self::LoadFile 140 | Self::EnteringDungeon 141 | Self::LoadingOverworld 142 | Self::LoadingSpecialOverworld 143 | Self::SpotlightClosing 144 | Self::SpotlightOpening 145 | Self::FallingIntoDungeon 146 | Self::PendantBossVictory 147 | Self::MirrorWarpFromAgahnim 148 | Self::CrystalBossVictory 149 | Self::SaveAndQuit 150 | Self::GanonEmerges 151 | Self::TriforceRoom 152 | Self::Credits => Kind::Passing, 153 Self::Unrecognised(_) => Kind::Unrecognised, 154 } 155 } 156}
What a [Mode] is, to something that must handle every one of them.
Link walks about and the pad is his. The only kind worth a question.
163 Play,
A screen over the game: a text box, a map, a menu, a potion being
drunk. [Mode::Interface] alone; the [Interface] step says which.
Copying or erasing a file: flows there is never a reason to be in.
A load, a transition or a cutscene. It ends by itself.
175 Passing,
180impl fmt::Display for Mode { 181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 182 f.write_str(match self { 183 Self::Title => "title", 184 Self::FileSelect => "file select", 185 Self::CopyFile => "copy file", 186 Self::EraseFile => "erase file", 187 Self::NameFile => "name file", 188 Self::LoadFile => "load file", 189 Self::EnteringDungeon => "entering dungeon", 190 Self::Dungeon => "dungeon", 191 Self::LoadingOverworld => "loading overworld", 192 Self::Overworld => "overworld", 193 Self::LoadingSpecialOverworld => "loading special overworld", 194 Self::SpecialOverworld => "special overworld", 195 Self::Interface => "interface", 196 Self::SpotlightClosing => "spotlight closing", 197 Self::SpotlightOpening => "spotlight opening", 198 Self::FallingIntoDungeon => "falling into dungeon", 199 Self::GameOver => "game over", 200 Self::PendantBossVictory => "pendant boss victory", 201 Self::AttractDemo => "attract demo", 202 Self::MirrorWarpFromAgahnim => "mirror warp from Agahnim", 203 Self::CrystalBossVictory => "crystal boss victory", 204 Self::SaveAndQuit => "save and quit", 205 Self::GanonEmerges => "Ganon emerges", 206 Self::TriforceRoom => "Triforce room", 207 Self::Credits => "credits", 208 Self::SpawnSelect => "spawn select", 209 Self::Unrecognised(byte) => return write!(f, "unrecognised mode {byte}"), 210 }) 211 } 212}
A mode's steps, as its dispatch table in the game names them. Several bytes share a name where the table points them at the same routine.
216macro_rules! steps { 217 ($(#[$doc:meta])* $name:ident { $($variant:ident $text:literal = $($byte:literal)|+,)+ }) => { 218 $(#[$doc])* 219 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 220 #[serde(rename_all = "snake_case")] 221 pub enum $name { $($variant,)+ } 222 223 impl $name { 224 fn read(byte: u8) -> Option<Self> { 225 match byte { 226 $($($byte)|+ => Some(Self::$variant),)+ 227 _ => None, 228 } 229 } 230 } 231 232 impl fmt::Display for $name { 233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 234 f.write_str(match self { $(Self::$variant => $text,)+ }) 235 } 236 } 237 }; 238}
240steps! {
Module00_Intro, zelda3 src/ending.c:513-525.
242 TitleStep { 243 SettingUp "setting up" = 0 | 1, 244 PreparingTriforce "preparing the triforce" = 2 | 10, 245 TriforceSpinning "triforce spinning in" = 3 | 4 | 9 | 11, 246 LogoFadingIn "logo fading in" = 5, 247 SwordComingDown "sword coming down" = 6, 248 BackgroundFadingIn "background fading in" = 7, 249 WaitingForStart "waiting for start" = 8, 250 } 251}
253steps! {
kDungeonSubmodules, zelda3 src/dungeon.c:401-429. Step 0 is
[Step::PlayerControl].
256 DungeonStep { 257 ScrollingWithinRoom "scrolling within the room" = 1, 258 ScrollingToNextRoom "scrolling to the next room" = 2, 259 OverlayChanging "overlay changing" = 3, 260 UnlockingDoor "unlocking a door" = 4, 261 ShuttersMoving "shutter doors moving" = 5, 262 WideStairsBetweenRooms "taking wide stairs between rooms" = 6, 263 FallingToRoomBelow "falling to the room below" = 7, 264 StairsNorthWithinRoom "taking stairs north within the room" = 8, 265 OpeningCrackedDoor "opening a cracked door" = 9, 266 BrightnessChanging "brightness changing" = 10, 267 SwampPoolDraining "swamp pool draining" = 11, 268 SwampWaterFlooding "swamp water flooding" = 12, 269 DamFlooding "dam flooding" = 13, 270 SpiralStairs "taking spiral stairs" = 14, 271 LandingFromFall "landing from a fall" = 15, 272 StairsSouthWithinRoom "taking stairs south within the room" = 16, 273 StraightStairsBetweenRooms "taking straight stairs between rooms" = 17 | 18 | 19, 274 RecoveringFromFall "recovering from a fall" = 20, 275 WarpPad "on a warp pad" = 21, 276 PegsMoving "crystal pegs moving" = 22, 277 PressurePlate "pressure plate pressed" = 23, 278 MaidenRescued "maiden rescued" = 24, 279 MirrorFading "mirror fading" = 25, 280 TriforceDoorOpening "triforce door opening" = 26, 281 } 282}
284steps! {
kOverworldSubmodules, zelda3 src/overworld.c:191-240, which
[Mode::SpecialOverworld] runs too. Step 0 is [Step::PlayerControl];
the entries the source leaves as Overworld_FuncNN stay numbered.
288 OverworldStep { 289 LoadingGraphics "loading graphics" = 1 | 15 | 26 | 38, 290 FinishingGraphics "finishing graphics" = 2 | 16 | 27 | 39, 291 LoadingMap "loading the next map" = 3 | 17, 292 LoadingSprites "loading sprites" = 4 | 18, 293 ScrollStarting "scroll starting" = 5 | 19, 294 Scrolling "scrolling to the next screen" = 6 | 20, 295 ScrollEasingOff "scroll easing off" = 7 | 21, 296 ArrivingOnScreen "arriving on the screen" = 8, 297 BigDoorOpeningFromInside "big door opening from inside" = 9, 298 WalkingOutFacingDown "walking out, facing down" = 10, 299 WalkingOutFacingUp "walking out, facing up" = 11, 300 BigDoorOpening "big door opening" = 12, 301 MosaicStarting "mosaic fade starting" = 13 | 23 | 36, 302 LoadingOverlays "loading overlays" = 14 | 32 | 33 | 37, 303 MosaicFadingBackIn "fading back in from mosaic" = 22 | 41, 304 MirrorWarp "mirror warp" = 35 | 44, 305 BuildingScreen "building the screen" = 40, 306 RecoveringFromDrowning "recovering from drowning" = 42, 307 WeathervaneExploding "weathervane exploding" = 45, 308 Whirlpool "in a whirlpool" = 46, 309 } 310}
What [Mode::Interface] is showing.
328impl fmt::Display for Interface { 329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 330 f.write_str(match self { 331 Self::Hud => "HUD update", 332 Self::TextBox => "text box", 333 Self::DungeonMap => "dungeon map", 334 Self::RedPotion => "drinking red potion", 335 Self::DesertPrayer => "desert prayer", 336 Self::WorldMap => "world map", 337 Self::GreenPotion => "drinking green potion", 338 Self::BluePotion => "drinking blue potion", 339 Self::FluteMenu => "flute menu", 340 Self::SaveMenu => "save menu", 341 }) 342 } 343}
$7E0011: the step within the mode. The same number means a different
thing in each mode, so it is only read together with one.
Step 0 of a mode Link walks about in: the routine that reads the pad. Necessary for the player to have control, and not sufficient — the game can still hold Link in place from here.
A step this mode's dispatch table has no name for, or a mode whose table has not been read.
363impl Step { 364 pub fn read(mode: Mode, byte: u8) -> Self { 365 match (mode, byte) { 366 (mode, 0) if mode.is_play() => Self::PlayerControl, 367 (Mode::Interface, 1) => Self::Interface(Interface::Hud), 368 (Mode::Interface, 2) => Self::Interface(Interface::TextBox), 369 (Mode::Interface, 3) => Self::Interface(Interface::DungeonMap), 370 (Mode::Interface, 4) => Self::Interface(Interface::RedPotion), 371 (Mode::Interface, 5) => Self::Interface(Interface::DesertPrayer), 372 (Mode::Interface, 7) => Self::Interface(Interface::WorldMap), 373 (Mode::Interface, 8) => Self::Interface(Interface::GreenPotion), 374 (Mode::Interface, 9) => Self::Interface(Interface::BluePotion), 375 (Mode::Interface, 10) => Self::Interface(Interface::FluteMenu), 376 (Mode::Interface, 11) => Self::Interface(Interface::SaveMenu), 377 (Mode::Title, byte) => TitleStep::read(byte).map_or(Self::Numbered(byte), Self::Title), 378 (Mode::Dungeon, byte) => { 379 DungeonStep::read(byte).map_or(Self::Numbered(byte), Self::Dungeon) 380 } 381 (Mode::Overworld | Mode::SpecialOverworld, byte) => { 382 OverworldStep::read(byte).map_or(Self::Numbered(byte), Self::Overworld) 383 } 384 (_, other) => Self::Numbered(other), 385 } 386 } 387} 388 389impl fmt::Display for Step { 390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 391 match self { 392 Self::PlayerControl => f.write_str("player control"), 393 Self::Interface(interface) => interface.fmt(f), 394 Self::Title(step) => step.fmt(f), 395 Self::Dungeon(step) => step.fmt(f), 396 Self::Overworld(step) => step.fmt(f), 397 Self::Numbered(byte) => write!(f, "step {byte}"), 398 } 399 } 400}
$7EF3CA.
$7EF3C5, the save file's own account of how far the story has got. Four
values, enumerated in jpdasm as GAMESTATE (research/alttp-ram-map.md
§3) - the signal to drive intro automation from, because it needs no
room-id table.
The very start; progress cannot even be saved yet.
435impl Progress { 436 pub fn read(byte: u8) -> Self { 437 match byte { 438 0 => Self::Beginning, 439 1 => Self::UncleReached, 440 2 => Self::ZeldaRescued, 441 3 => Self::AgahnimDefeated, 442 other => Self::Unrecognised(other), 443 } 444 } 445} 446 447impl fmt::Display for Progress { 448 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 449 f.write_str(match self { 450 Self::Beginning => "the very beginning", 451 Self::UncleReached => "uncle reached", 452 Self::ZeldaRescued => "Zelda rescued", 453 Self::AgahnimDefeated => "Agahnim defeated", 454 Self::Unrecognised(byte) => return write!(f, "progress {byte}"), 455 }) 456 } 457}
$7EF3C6, the opening's one-time events. jpdasm names the bits
(PROGLITE, research/alttp-ram-map.md §3); these three are the cleanest
checkpoints the whole game exposes for the first half hour.
Uncle has left the house, so Link is free to follow.
465 pub uncle_left_house: bool,
Uncle has been found in the secret passage - the sword and shield.
467 pub uncle_found: bool,
$7E005D, Link's own state machine, as far as it has names
(research/alttp-ram-map.md §2, from zelda3 src/player.h:7-34).
502impl Player { 503 pub fn read(byte: u8) -> Self { 504 match byte { 505 0 => Self::Ground, 506 1 => Self::FallingIntoHole, 507 2 | 6 => Self::Recoiling, 508 3 | 30 => Self::SpinAttacking, 509 4 => Self::Swimming, 510 17 | 18 => Self::Dashing, 511 19 => Self::Hookshot, 512 20 => Self::UsingMirror, 513 21 => Self::HoldingSomethingUp, 514 22 => Self::AsleepInBed, 515 23 | 28 => Self::Bunny, 516 other => Self::Numbered(other), 517 } 518 } 519} 520 521impl fmt::Display for Player { 522 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 523 f.write_str(match self { 524 Self::Ground => "on his feet", 525 Self::FallingIntoHole => "falling into a hole", 526 Self::Recoiling => "recoiling", 527 Self::SpinAttacking => "spin attacking", 528 Self::Swimming => "swimming", 529 Self::Dashing => "dashing", 530 Self::Hookshot => "on the hookshot", 531 Self::UsingMirror => "using the mirror", 532 Self::HoldingSomethingUp => "holding something up", 533 Self::AsleepInBed => "asleep in bed", 534 Self::Bunny => "a bunny", 535 Self::Numbered(byte) => return write!(f, "player state {byte}"), 536 }) 537 } 538}
Where Link is, coarsely: one number, and which kind of number it is.
$7E008A, the overworld area.
545 Outdoors(u8),
What the game is doing and where Link is, as of one frame.
$7E0022, pixels.
566 pub link_x: u16,
$7E0020, pixels.
568 pub link_y: u16,
$7EF36D, in eighths of a heart.
570 pub health: u8,
$7EF36C, in eighths of a heart.
572 pub health_capacity: u8,
$7EF362.
574 pub rupees: u16,
Whether the player's own buttons move Link this frame. The exact rule
the engine itself gates on, not a guess: see [State::has_control].
$7E00EE: which of a two-storey room's layers Link stands on, and
which half of the tile-attribute grid therefore describes him.
581 pub lower_level: bool,
$7E002F. None for a byte outside the four-value convention.
$7EF359: 0 is no sword at all, which is how the game starts.
588 pub sword: u8,
$7EF35A.
590 pub shield: u8,
$7EF3CC, follower_indicator (zelda3 src/variables.h:1121): who is
walking behind Link.
This, and not $7EF3C5, is what freeing Zelda sets. Her cell script
writes follower_indicator = 1 the moment she joins Link (zelda3
src/sprite_main.c:6300-6305); the progress byte only becomes 2 when she
reaches the Sanctuary (:6342). Reading "Zelda rescued" off the progress
byte made the rescue and the sanctuary one and the same event.
Read the state out of work RAM. Takes the whole of it, so that no offset above can be out of range.
625 pub fn read(wram: &[u8; 0x20000]) -> Self { 626 let word = |at: usize| u16::from_le_bytes([wram[at], wram[at + 1]]); 627 let mode = Mode::read(wram[0x10]); 628 let indoors = wram[0x1B] != 0; 629 Self { 630 mode, 631 step: Step::read(mode, wram[0x11]), 632 world: if wram[0xF3CA] == 0 { World::Light } else { World::Dark }, 633 link_x: word(0x22), 634 link_y: word(0x20), 635 health: wram[0xF36D], 636 health_capacity: wram[0xF36C], 637 rupees: word(0xF362), 638 control: Self::has_control(wram, mode), 639 place: if indoors { Place::Indoors(word(0xA0)) } else { Place::Outdoors(wram[0x8A]) }, 640 lower_level: wram[0xEE] != 0, 641 facing: Direction::facing(wram[0x2F]), 642 player: Player::read(wram[0x5D]), 643 progress: Progress::read(wram[0xF3C5]), 644 opening: Opening::read(wram[0xF3C6]), 645 sword: wram[0xF359], 646 follower: Follower::read(wram[0xF3CC]), 647 shield: wram[0xF35A], 648 } 649 }
Whether the pad moves Link this frame.
The engine's own gate, copied out of the two functions that implement
it - Module09_00_PlayerControl and Module07_00_PlayerControl guard
identically (research/alttp-ram-map.md §1, citing zelda3
src/overworld.c:750-758 and src/dungeon.c:6594-6602). Being in a
walking mode on step zero is necessary and NOT sufficient: four flags
can still hold Link where he stands.
659 fn has_control(wram: &[u8; 0x20000], mode: Mode) -> bool { 660 mode.is_play() 661 && wram[0x11] == 0 662 // flag_custom_spell_anim_active, flag_is_link_immobilized, 663 // flag_block_link_menu. 664 && wram[0x112] == 0 665 && wram[0x2E4] == 0 666 && wram[0xFFC] == 0 667 // trigger_special_entrance, which gates the overworld only. 668 && (mode == Mode::Dungeon || wram[0x4C6] == 0) 669 }
Hearts, as the HUD draws them: work RAM keeps eighths.
677#[cfg(test)] 678mod tests { 679 use super::*; 680 681 fn wram() -> Box<[u8; 0x20000]> { 682 vec![0u8; 0x20000].into_boxed_slice().try_into().expect("the right size") 683 } 684 685 #[test] 686 fn control_needs_all_of_the_gate_and_not_just_the_mode() { 687 let mut ram = wram(); 688 ram[0x10] = 0x09; 689 assert!(State::read(&ram).control, "overworld, step zero, no flags set"); 690 for flag in [0x112usize, 0x2E4, 0xFFC, 0x4C6] { 691 ram[flag] = 1; 692 assert!(!State::read(&ram).control, "flag ${flag:X} must take control away"); 693 ram[flag] = 0; 694 } 695 ram[0x11] = 1; 696 assert!(!State::read(&ram).control, "a nonzero step is a cutscene or a scroll"); 697 } 698 699 #[test] 700 fn a_dungeon_ignores_the_overworld_only_flag() { 701 let mut ram = wram(); 702 ram[0x10] = 0x07; 703 ram[0x4C6] = 1; 704 assert!(State::read(&ram).control, "trigger_special_entrance gates the overworld only"); 705 } 706 707 #[test] 708 fn a_text_box_is_never_control() { 709 let mut ram = wram(); 710 ram[0x10] = 0x0E; 711 ram[0x11] = 2; 712 let state = State::read(&ram); 713 assert!(!state.control); 714 assert_eq!(state.step, Step::Interface(Interface::TextBox)); 715 } 716 717 #[test] 718 fn the_opening_flags_are_the_bits_jpdasm_names() { 719 let opening = Opening::read(0x15); 720 assert!(opening.uncle_left_house && opening.uncle_found && opening.zelda_at_sanctuary); 721 assert_eq!(Opening::read(0x00).uncle_found, false); 722 } 723}