The init sequence

main() (FUN_80015E90) is called once by the entry stub FUN_80026C28 with $gp = 0x8007B318. Each stage below is bracketed by a dev-checkpoint print that is compiled in but inert on the shipped disc.

Init stageWhat happens
GPU / displaydisplay-mode reset, display mask off, full-frame clear, queue flush
CD + XACD driver init; the console probe result (gp+0x550) selects the heap size
Soundlibsnd init over the sound-driver work area (audio)
Heap0x134800 bytes retail, 0x200000 on an expanded-RAM dev console
Boot scene + namesdefault scene string opdeene; the dev/retail halfword gets its only write (debug flags); CDNAME primes the name table at 0x80088758
Display env + mode machineDISPENV / DRAWENV pair, boot mode-init, packet + ordering-table allocator, one priming frame, display on, first overlay pair streamed

Switching modes. When a handler writes a mode index different from the latched one, the loop stops XA / CD, flushes the GPU queue, re-reads the pad and clears the held-input and frame-state globals before latching the new index. A negative mode index exits the loop - a dev quit path retail never takes.

The full mode table

A 28-entry table at 0x8007078C, 24-byte stride: even rows are INIT handlers, odd rows per-frame handlers. It is static executable data, so legaia_game_tables::mode_table reads it straight off the disc (asset mode-table). The dev names in the table mislead; the third column is what each pair really is.

ModeDisc nameWhat it really isOverlay loadedInit handler
0/1CONFIGdev debug menu09710x80025C68
2/3MAINfield / town gameplay08970x80025B64
4/5MONSTER TESTdev; INIT bounces back to mode 0-0x8002611C
6/7TMD TESTdev-0x801CF730
8/9EFECT TESTdev09790x80025E68
10/11TESTdev-0x8002B97C
12/13MAPDSIPworld-map display0981, over 0897’s head0x80025DA0
14/15MAP TESTdev-0x8002B904
16/17READpublisher logos (init.pak)slot A already holds 08950x8002612C
18/19GAME OVERgame over09020x80025B30
20/21BATTLEbattle; setup is resident in the executablenone (0898 via the field path)0x800565D8
22/23CARDtitle screen and the menu - pause menu, save, options08990x8002574C
24/25OTHERminigames, one overlay per sub-id0972..0977, 09800x80025980
26/27STRFMV movies09700x80025FB4
  • 12 of 14 per-frame handlers are one shared tick (0x80025EEC), parameterised by the entry’s +0x14 word. Only world map (13) and menu (23) carry their own.
  • Mode 23 runs no frame driver. Its per-frame handler (FUN_80025F74 → FUN_80017978) calls an 18-instruction stub instead of the master frame driver FUN_80016444, so no actor, effect, move VM or animation advances while the pause menu is up.
  • World map is a head swap. Mode 12’s INIT saves the first 0x4000 bytes of the field overlay, loads PROT 0981 over them and restores them on exit; the world-map controller proper stays in 0897 (world map).
  • The mode word does not name the minigame. Modes 24/25 host all five warp minigames; the discriminator is the signed halfword _DAT_8007BA34, written by the field VM’s door-warp arm as op0 − 100 and left standing while the minigame runs. Key on the pair, never on 0x19 alone - and 0x18 is the one-frame INIT half. Full chain: asset loader.
  • Overlay index arithmetic. Both overlay loaders resolve param + 0x381 in the raw TOC, which is extraction entry param + 0x37F (the +2 shift). No call site can produce params 0 or 1, so extraction entries 0895 / 0896 are unreachable from any static loader call.

Entry layout and the bare INIT handlers

Each 24-byte entry is (name_ptr, …, next_mode: i16 at +0x0A, handler_ptr at +0x10, param at +0x14). next_mode is -1 (self-managed) or 0 (fall back to mode 0) - the only two retail values; the word at +0x08 reading 0xFFFF0000 is that -1 over a zero low half, not a sentinel.

Three INIT handlers skip the “reset, wait, load slot A, jump in” wrapper. Mode 4 writes mode 0 and returns. Mode 16 jals 0x801CE9C0 without loading anything, because slot A already holds what it jumps into: PROT 0895 (init.pak) at file +0x1A8, the routine that draws the publisher logos before writing mode 0x11. Mode 20’s FUN_80055B6C is the battle scene setup, resident in the executable.

Slot B’s default (FUN_80025BA0) is the only runtime-decided load: it mirrors the summon-render flag DAT_8007B6A8, picks extraction 901 when set and 900 otherwise, and skips the load when that overlay is already resident. Two other constant loads: the effect-data init uses raw 0x3D5 (= extraction 979) and the CARD-mode init raw 0x37E (= 892, a container-formats::pack of TIMs); legaia_overlay_images::boot_overlay ports the arithmetic and pins each by content.

History: off-by-2 loader arithmetic, and mode 16 as a “stripped dev path”

Reading + 0x381 as an extraction index placed the mode-0 overlay at 973 (the 1-sector OTHER2 dev module), the mode-2 overlay at 899 (the menu) and the minigames at 0896. All three are the same +2 shift; see do-not-re-walk. Mode 16’s jump target was once read as “an entry point in no image, so a retail-stripped dev path”; that was VA aliasing at the shared slot-A base.

Archive directory and CD reads

The TOC is read exactly once: the first three sectors of PROT.DAT land at 0x801C70F0. After that, “give me entry N” is a table lookup, and “give me h:\PROT\FIELD\town01\…” is a CDNAME name lookup followed by the same table lookup. An entry’s size is the sector gap to the next entry, exactly what retail’s own span routine computes (PROT).

ResolverInputUsed by
index-basedPROT entry number → LBAstreaming loader, both overlay loaders
path-baseddev-style path → CDNAME index → the index resolvermost retail code paths (data\battle\efect.dat, scene paths)

Every load ends as “seek to a sector, DMA bytes into RAM” through Legaia’s own layered CD driver - hardware registers and callbacks, not the PsyQ libcd path. Every loader is dual-mode, keyed on the dev/retail flag: the retail branch takes the TOC-index path, the dev branch opens a host path through a break trap real hardware cannot service. The port reads a disc image directly, so the register / interrupt tier is documented rather than ported; the async loaders and the streaming queue above it are ported.

The CD-read API stack, bottom-up

FunctionRole
FUN_8005D9A0CD-DMA-channel-3 synchronous read primitive; the DMA trigger is at 0x8005DA50 inside it
FUN_8005C42C / FUN_8005C328BCD-MSF ↔ LBA, standard (min*60 + sec)*75 + frame − 150
FUN_8005DBB4ISO9660 directory lookup: filename → {msf[3], size, …}
FUN_8005E788 / FUN_8005E574streaming-read starter + per-IRQ callback; cursor DAT_800796CC, sectors left DAT_800796D8, LBA DAT_800796E4
FUN_8005E9A4public streaming read (sector_count, dest, mode_flags); sector size 2048 / 2336 (XA) / 0x246 by mode_flags & 0x30
FUN_8005EA84completion sync; 0x4B0-vsync overall timeout, a 0x3C-vsync sector stall restarts the read
FUN_8005E4D4 / FUN_8003D3C4sync LBA reader; path-based ISO9660 loader for .STR / .XA files
FUN_8003E4E8 / FUN_8003E68Cboot-time TOC loader; entry span = gap to the next entry
FUN_8003E8A8 / FUN_8003E6BCindex-based and path-based LBA resolvers
FUN_8003E800async LBA loader; queues via gp+0x97C / gp+0x894, kicked by FUN_8003F128
FUN_8003DDA0index-based streaming enqueue: 8-byte (idx, byte_offset) descriptors at gp+0x1A8, cursor advanced by size << 11
FUN_8003DAA8load-kick / completion driver (in-progress flag _DAT_8007B876 & 1)
FUN_8003EBE4 / FUN_8003EC70overlay loaders A / B; differ only in destination pointer and current-id tracker (0x8007BC3C / 0x8007BC4C); 16 static call sites
FUN_8005DAB0 / FUN_8005D5F8 / FUN_8005D648CD response dispatcher (cause 0x4 data-ready, 0x2 completion), re-arm, init / reset

The BIOS-trampoline stubs in this cluster (FUN_8005BD30 = B(0x07), FUN_8005DB9C = B(0x3F), the FUN_80056648 family) are syscall shims with no game logic.

The title tick

PieceWhereNote
tick functionFUN_801DD35C, in the overlay3,026 instructions; the first ~250 fan out through a 25-entry sub-mode jump table at 0x801CF244
state struct0x801F0000 (+ a sibling block at 0x801EF014..0x801EF200)sub-mode selector at +0x204; cursor grid at +0x1F4 / +0x1F8; linear cursor at +0x1FC
attract countdown0x801EF16Cstarts at 0x8000 (a disc byte, not computed), minus the per-frame scalar each tick; underflow writes mode 0x1A and FMV id 0 → MV1.STR (cutscene)
menu sub-mode0x10the Press-Start wait; confirm fades through 0x18 into the two-row cursor state 0x14 (cursor _DAT_8007B820). A cold boot always arrives here: init.pak raises the entry word _DAT_8007BB00 at 0x801CEB84, which routes Init to 0x11 and then 0x10
launchsub-modes 0x16 fade → 0x06 init_gamewrites mode word 2 at 0x801DFC00 and scene id opdeene into 0x8007050C; CONTINUE detours through the card-load path, and its 0x16 writes mode 2 at 0x801DFAFC

The countdown store is sw v0, -0xe94(a0) at 0x801DDCCC, in the delay slot of a bgez that keeps the attract loop running while the counter is non-negative; the per-frame scalar it subtracts is the byte at _DAT_1F800393. That store is where a write watchpoint on the countdown fires.

The extended state block

AddressOffsetUse
0x801EF14C-0xEB4slider X; step = scalar × 8. Both arms clamp at 0x2C (floor at 0x801DFC88, ceiling at 0x801DFCB4), so it converges on 0x2C from either side
0x801EF160-0xEA0fade / sweep accumulator, clamp 0x1000
0x801EF170-0xE90frame counter, incremented every tick
0x801EF190 / 194 / 1A0-0xE70..alpha A / B / C, clamp 0x1000
0x801F0204+0x204sub-mode dispatcher index
0x801F0230+0x230top-of-tick early-out guard

The tick draws through three executable-side helpers: the ClearImage rect-fill queue, the MoveImage VRAM copy, and the sprite-descriptor dispatcher ({ tag, flags, rect, pixel_data_ptr }, alpha-OR pre-pass under flags & 8, width divisor from flags & 3).

The packed pad mask

The per-frame button mask (_DAT_8007B850) and newly-pressed mask (_DAT_8007B874) are packed by the pad builder FUN_8001822C as ~((pad[2] << 8) | pad[3]): the face / shoulder byte in bits 0..7 and the d-pad / system byte in bits 8..15 - not the raw PSX pad word. Anything that ingests retail RAM-side input must translate.

BitButtonBitButton
0L28Select
1R29L3
2L110R3
3R111Start
4Triangle12Up
5Circle13Right
6Cross14Down
7Square15Left

Masks the title tick tests: 0x44 = L1|Cross (confirm), 0x21 = L2|Circle (cancel), 0x844 = Start|L1|Cross (press start), 0xF5 = any face button + L1 + L2.

The title overlay on disc

PROT 0899 is streamed raw and uncompressed from its LBA (47227) across its 74 sectors, in five DMA bursts that all land inside the entry.

PROT.DAT rangeSectorsContents
0x5C3D800..0x5C4480047227..472410899 head: options / config-menu strings + small config TIMs
0x5C44800..0x5C6280047241..473010899 tail: title-overlay code + data, incl. two embedded UI TIMs
0x5C62800..47301..0900, the slot-B summon-render pair
BurstRAMEntry offset
10x801CF818+0x1000
20x801D4818+0x6000
30x801D9818+0xB000
40x801DD018+0xE800
50x801E4818+0x16000

The tick body sits at entry offset +0xEB44, byte-identical to the captured title overlay. Two TIMs are embedded in the data segment: 0x801E5120 (entry +0x16908, the save-menu UI atlas) and 0x801EE120 (+0x1F908, the memory-card icon strip - save screen).

History: the “unindexed 60-sector gap”

A superseded entry-size expression gave 0899 only 14 sectors, and the missing 60 became a “gap the TOC misses”. The real size is the gap to the next entry, 74 sectors. The same artifact produced every “hidden overlay after entry N” reading; see PROT.

The opening chain in detail

The intro skip

The skip lives in FUN_801D1344. Each frame it tests three things: no dialog is up (_DAT_8007B868 == 0), scratchpad flag bit 26 is set (_DAT_1F800394 & 0x4000000), and confirm is held (_DAT_8007B850 & 0x100). When all three hold it starts a 60-frame fade-out, writes the town01 entry coordinates (0xEC0, 0x2DC0) into _DAT_80073EF4 / _DAT_80073EF8, clears bit 26 so the skip fires once, and calls the name-based scene change FUN_8001FD44 with the overlay literal town01 at 0x801CE82C.

  • The scene-change packet copies the target name into 0x8007050C, syncs it to the active buffer 0x80084548 and stages the load; s_ERR_CHANGE_PACKET guards re-entry. It is not the map-id door warp (op 0x3E), which backs up the active scene name into 0x8007BAE8 - empty in the town01 opening saves.
  • The arm is the last record of opdeene’s third partition (MAN offset 0xA47, the 2E 1A at 0xA5E); town01 carries no such arm.
  • Each leg’s opening record spawns via op 0x44 SPAWN_RECORD (the three op scenes) or the walk-on tile trigger (map01, town01). An exec breakpoint on the spawner FUN_8003BDE0 sees exactly five spawns, one per leg. Subtitle pages roll through the bottom-up crawl roller (FUN_80037174) as a child context, so camera cuts play under the scroll.
History: superseded readings of the skip

The skip was first read as the required hand-off from the prologue into the field; the chain advances by itself and the gate is a skip. A guess that a 0x4C MenuCtrl sub-op in record 0 armed the bit is falsified - record 0 has no 0x2E / 0x2F byte at all.

Name entry

Opened in town01 by field-VM op 0x49 sub-op 3 in the opening timeline record (P2[3], body offset 0x02C6). The grid is 6×17 ASCII cells in three column groups (upper / lower / digits); the cursor walks a 7×17 space whose last row is Backspace / Space / End, and End on a non-empty name opens a Yes/No commit. The committed name lands at record +0x2A7.

PieceWhere
Grid / live buffer0x801F29F0 / 0x801F2A6C
Cursor0x8007BB88, linear 0..0x77; d-pad deltas ±0x11 / ±1, separators skipped; name width capped at 57 px
RendererFUN_801E6B34
State machineFUN_801F03F0, 5-entry jump table at 0x801CF71C (init → interactive → three confirm handlers)
Parked op state_DAT_8007B450 holds the op’s RAM address + 1 while the prompt is up

The fresh-state seed (FUN_80034A6C / FUN_800560B4) sets gold to 500, zeroes a ~0x200-byte story-flag region and expands the starting-party template into the live records.

Boot-screen textures

Publisher logos - PROT 0895 init.pak

OffsetLogoModeStoredDrawn as
+0x021C4PROKION8 bpp176×256two 176×126 quads, (144, 165) and (320, 165)
+0x0D3E4Contrail8 bpp184×256one 184×254 quad at (228, 105)
+0x18E04SCEA Presents4 bpp256×128two 252×64 quads, (68, 192) and (320, 192)
+0x1CE44WARNING4 bpp256×256uploaded, drawn by nothing

Each logo’s quads come from a six-record sprite-descriptor table that follows the fourth TIM, so PROKION and SCEA are vertically packed: the top half and the bottom half draw side by side, meeting on the 640×480 stage’s centre. Pacing: SCEA 16 / 131 / 32 frames (fade in / hold / fade out), Contrail 16 / 121 / 16, PROKION 16 / 91 then a 65-frame ramp to a white screen. The warning’s descriptor (record 1, tpage 0x000B / clut 0x7E80) is the one id none of the five emit calls passes; a cold-boot capture through the logos, the title, the attract movie and back logs 857 quad emits and none for it, and the menu overlay later parks the memory-card kanji page on top of its VRAM rect.

Title wordmark - PROT 0890

One 256×256 8-bpp sheet at file offset 0x14228 (66,080 bytes with its CLUT; byte-matches RAM 0x80170DF8) carries the orb, wordmark, prompt and copyright lines. Retail samples it in bands rather than blitting the quad.

Source rectContentDrawn when
(0, 17, 256, 124)orb + wordmarkevery post-fade phase
(96, 151, 64, 10)demo-build labelnever - a demo leftover the framebuffer omits
(60, 178, 196, 16)PRESS START BUTTONpress-start phase only
(4, 195, 244, 14)trademark lineevery post-fade phase
(8, 209, 234, 14)copyright lineevery post-fade phase
(0, 226, 256, 11)NEW GAME / CONTINUE footermenu rows, sampled as two halves; selection is bright vs dim, no arrow cursor
History: “three multi-bank duplicates” of the title TIM

Three sources (0888 @ 0x1AA28, 0889 @ 0x19A28, 0890 @ 0x14228) all land on the same absolute PROT.DAT offset; they looked like three entries only under the pre-correction entry size. A header-signature scan across the archive returns one hit, inside 0890.

System-UI textures: raw TOC entries 0 and 1

A 118-sector region between the TOC (ends at 0x1800) and extraction entry 0 (0x3C800, sector 121) is skipped by the extraction index, but not by the TOC: it is raw TOC entries 0 and 1, which are exactly CDNAME's two head names, init_data and gameover_data. They have different loaders.

Raw indexCDNAMEDev-tree fileLoaded by
0 (sectors 3..55)init_datainit\init.pakthe boot image (PROT 0895), state machine FUN_801CECD0 arm 2; arm 3 uploads every TIM
1 (sectors 55..121)gameover_datagameover\gameover.pakthe GAME OVER overlay (PROT 0902) only
2 (extraction 0000)-title\title.pakthe boot image, arm 6

Each dev-tree name is the operand of the developer-build branch of a load whose retail branch reads the raw index beside it, so the pairing is read straight off the instructions. Raw entry 0 holds the small-caps menu font, the boot cursor and a few sprite strips - 4-bpp TIMs targeting the bottom-right corner of VRAM. Raw entry 1's one page (0x1B80C) is the GAME OVER screen's, and no retail field, battle or menu save state holds its palette; the port's scene upload leaves it out.

PROT.DAT offsetDimsVRAM targetPurpose
0x01858tiny(896, 256)boot cursor variant
0x018E0256×192(896, 256)large UI sprite sheet
0x07F40256×256(896, 0)dialog-font sheet
0x10178256×32(896, 448)AP / status-icon strip
0x11218256×256(960, 256)menu-glyph small-caps font
0x19438240×24(960, 400)UI sprite strip
0x1B80C256×256(640, 0)raw entry 1: the GAME OVER page

The atlas at 0x11218 (33,312 bytes) is the font the shop / inventory / status panels sample; its in-RAM copy at 0x80106478 is byte-equal modulo CLUT relocation. Alphabet row at y = 224..238, 26 cells 8 px wide from x = 8; digits at y = 209..220. Retail switches CLUT rows per context (white / gold / dim). The title’s own NEW GAME / CONTINUE rows do not use it - they are the footer band of the wordmark sheet.

How we know

FunctionAddressWhat it proves
main loopFUN_80015E90init order, heap sizes, the mode-switch housekeeping, the loader flag’s only write at 0x80015F08
entry stubFUN_80026C28sets $gp = 0x8007B318, calls main once
CDNAME primerFUN_8001D8FC16-byte name records at 0x80088758; unbounded copy
asset dispatcherFUN_8001F05Ctype byte in the high 8 bits of the size word; handler table 0x80010638
mode table0x8007078C14 INIT / RUN pairs, 24-byte stride, read straight off the disc
boot mode chainsix sh stores to _DAT_8007B83C0x8001D5B8, 0x801CEC94, 0x801CF4D4, 0x80025974, 0x801DFC00, 0x80025E50 - one per hand-off
minigame initFUN_80025980reads sub-id _DAT_8007BA34 twice, leaves it standing
title tickFUN_801DD35Ccountdown store at 0x801DDCCC (write watchpoint); launch write at 0x801DFC00
title overlay sourceCD-DMA FUN_8005D9A0five bursts from LBA 47227, all inside entry 0899; uncompressed
field INIT / scene initFUN_80025B64 / FUN_801D6704loads 0897, reads the scene id, writes mode 3
intro skipFUN_801D1344 / FUN_8001FD44flag-and-pad-gated one-shot; name packet to town01
menu-glyph atlasRAM 0x80106478byte-matches PROT.DAT 0x11218
system-UI loaderFUN_801CECD0 (PROT 0895)FUN_8003EB98(0, buf, 1) at 0x801CEDA8 / 0x801CEEF8, then a TIM_LIST walk into FUN_800198E0

Full write-up: docs/subsystems/boot.md.