Boot path
Everything between the disc spinning up and the moment you press NEW GAME. The PlayStation logo fades, four publisher screens go by, the wordmark appears - and behind that the game reads its archive directory, wires up its asset router, streams its first code overlay and enters the top-level “what mode am I in” state machine that runs for the rest of the session. This page follows that ladder, then the title screen, then what NEW GAME launches.
At a glance
- Entry point
main()=FUN_80015E90, called once by the executable stub with$gp = 0x8007B318; it never returns in normal play- Archive directory
- First three sectors of
PROT.DAT(6 KB) land at0x801C70F0; every later load is a table lookup - Mode machine
- 28-entry table at
0x8007078C: 14 modes, each an INIT / per-frame pair; the mode word is_DAT_8007B83C - Overlay window
- Slot A at
0x801CE818holds one of field / menu / cutscene / minigame code at a time; slot B holds the summon-render pair - Title screen
- No row is named for it, but it is a mode - the
CARDpair (22/23), whose init streams PROT 0899 into slot A - NEW GAME
- Writes mode 2 (field), loads PROT 0897 and opens the prologue scene
opdeene; name entry comes later, intown01 - Port
engine-shell::BootSession(init + the seat),engine-core::mode::ModeSeat(the mode word and the table around it),engine-vm::title_overlay(the title tick)- Confidence
- Confirmed - mode table read off the disc; title tick and launch write pinned by live watchpoints
The ladder
init.pak fade in / hold / fade outlegaia_asset::init_pak
6Title overlaymode 22 CARD INIT streams PROT 0899 into slot A; the attract loop tickstitle tick
7Mode machineNEW GAME writes mode 2; the master loop dispatches the table from here onModeSeat
Almost every PlayStation game bundles its small files into one archive so the disc can stream them; Legaia’s is PROT.DAT, with a table of contents saying “entry N starts at sector S”. Reading that table is job one. Job two is the asset-type dispatcher: each entry’s first byte is a type tag, and one router hands the bytes to the matching parser (asset-type). Job three is the mode machine below.
Most gameplay code is not in the executable. The field VM, dialog renderer and battle logic live in RAM overlays streamed on demand into 0x801C0000+. “Zero callers in SCUS_942.54” never means dead code - it means the caller is in an overlay.
The main loop
After hardware init, main() spins forever: call the current mode’s handler; if the handler asked for a different mode, do the housekeeping and switch. A negative mode index exits - a dev quit path retail never takes.
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.
Details: the init sequence, stage by stage
| Init stage | What happens |
|---|---|
| GPU / display | display-mode reset, display mask off, full-frame clear, queue flush |
| CD + XA | CD driver init; the console probe result (gp+0x550) selects the heap size |
| Sound | libsnd init over the sound-driver work area (audio) |
| Heap | 0x134800 bytes retail, 0x200000 on an expanded-RAM dev console |
| Boot scene + names | default scene string opdeene; the dev/retail halfword gets its only write (debug flags); CDNAME primes the name table at 0x80088758 |
| Display env + mode machine | DISPENV / DRAWENV pair, boot mode-init, packet + ordering-table allocator, one priming frame, display on, first overlay pair streamed |
Each stage is bracketed by a dev-checkpoint print that is compiled in but inert on the shipped disc.
Game-mode state machine
Walking a town, fighting, browsing the pause menu, watching a movie - to the game these are modes, and exactly one is active. Even table rows are INIT handlers (one frame, usually loading an overlay); odd rows are per-frame handlers. The table is static executable data, so legaia_asset::mode_table reads it straight off the disc (asset mode-table). The dev names mislead; this is the ground truth:
| Mode | Disc name | What it really is | Overlay loaded | Init handler |
|---|---|---|---|---|
| 0/1 | CONFIG | dev debug menu | 0971 | 0x80025C68 |
| 2/3 | MAIN | field / town gameplay | 0897 | 0x80025B64 |
| 4/5 | MONSTER TEST | dev; INIT bounces back to mode 0 | - | 0x8002611C |
| 6/7 | TMD TEST | dev | - | 0x801CF730 |
| 8/9 | EFECT TEST | dev | 0979 | 0x80025E68 |
| 10/11 | TEST | dev | - | 0x8002B97C |
| 12/13 | MAPDSIP | world-map display | 0981, over 0897’s head | 0x80025DA0 |
| 14/15 | MAP TEST | dev | - | 0x8002B904 |
| 16/17 | READ | dev; jumps into slot A blind | - | 0x8002612C |
| 18/19 | GAME OVER | game over | 0902 | 0x80025B30 |
| 20/21 | BATTLE | battle; setup is resident in the executable | none (0898 via the field path) | 0x800565D8 |
| 22/23 | CARD | menu incl. the pause menu, save, options | 0899 | 0x8002574C |
| 24/25 | OTHER | minigames, one overlay per sub-id | 0972..0977, 0980 | 0x80025980 |
| 26/27 | STR | FMV movies | 0970 | 0x80025FB4 |
- 12 of 14 per-frame handlers are one shared tick (
0x80025EEC), parameterised by the entry’s+0x14word. Only world map (13) and menu (23) carry their own. - The pause menu runs under CARD (mode 23), not field mode 3. Every menu-open save state holds
game_mode = 0x17. The engine mirrors it:BootSessionholds the world inSceneMode::Menu. - Mode 23 runs no frame driver. Its per-frame handler calls an 18-instruction stub instead of the master frame driver, so retail advances no actor, effect, move VM or animation while the pause menu is up.
World::ticksuspends the same passes and keeps the frame-begin / frame-end passes running underneath. - World map is a head swap. Mode 12’s INIT saves the first
0x4000bytes 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 asop0 − 100and left standing while the minigame runs. Key on the pair, never on0x19alone - and0x18is the one-frame INIT half. Full chain: asset loader. - Overlay index arithmetic. Both overlay loaders resolve
param + 0x381in the raw TOC, which is extraction entryparam + 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.
The interpreters live in those overlays: the field / event VM in 0897, the effect VM cluster in the battle overlay 0898, and the window-widget VM in the menu overlay 0899. Slot-A overlays are mutually exclusive - see the residency figure on the subsystems index.
Details: next-mode field, bare INIT handlers, slot-B default
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. ModeEntry::next_mode decodes it and the disc-gated mode_table_reconcile test cross-checks the engine’s transcribed table field by field.
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, a clean addiu sp, sp, -0x230 prologue and the routine that draws the publisher logos before writing mode 0x11. The earlier reading - “an entry point in no image, so mode 16 is a retail-stripped dev path” - was VA aliasing at the shared slot-A base. Mode 20’s FUN_80055B6C is the battle scene setup, resident in the executable. Mirrored as engine_core::mode::mode_init_bare.
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, an asset::pack of TIMs); legaia_asset::boot_overlay ports the arithmetic and pins each by content.
History: off-by-2 loader arithmetic
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.
Archive directory and CD reads
The TOC is read exactly once. 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).
| Resolver | Input | Used by |
|---|---|---|
| index-based | PROT entry number → LBA | streaming loader, both overlay loaders |
| path-based | dev-style path → CDNAME index → the index resolver | most 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. The stack splits: the register / interrupt / transport tier is documented, not ported - the engine reads a disc image directly and has no drive to command - while the tier above it, the async loaders and the streaming queue’s enqueue arithmetic over the PROT TOC, is ported. 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.
Details: the CD-read API stack, bottom-up
| Function | Role |
|---|---|
FUN_8005D9A0 | CD-DMA-channel-3 synchronous read primitive; the DMA trigger is at 0x8005DA50 inside it |
FUN_8005C42C / FUN_8005C328 | BCD-MSF ↔ LBA, standard (min*60 + sec)*75 + frame − 150 |
FUN_8005DBB4 | ISO9660 directory lookup: filename → {msf[3], size, …} |
FUN_8005E788 / FUN_8005E574 | streaming-read starter + per-IRQ callback; cursor DAT_800796CC, sectors left DAT_800796D8, LBA DAT_800796E4 |
FUN_8005E9A4 | public streaming read (sector_count, dest, mode_flags); sector size 2048 / 2336 (XA) / 0x246 by mode_flags & 0x30 |
FUN_8005EA84 | completion sync; 0x4B0-vsync overall timeout, a 0x3C-vsync sector stall restarts the read. Port engine-core::cd_dma::stream_read_sync |
FUN_8005E4D4 / FUN_8003D3C4 | sync LBA reader; path-based ISO9660 loader for .STR / .XA files |
FUN_8003E4E8 | boot-time TOC loader |
FUN_8003E800 | async LBA loader; queues via gp+0x97C / gp+0x894, kicked by FUN_8003F128 |
FUN_8003DDA0 | index-based streaming enqueue: 8-byte (idx, byte_offset) descriptors at gp+0x1A8, cursor advanced by size << 11 |
FUN_8003DAA8 | load-kick / completion driver (in-progress flag _DAT_8007B876 & 1) |
FUN_8003EBE4 / FUN_8003EC70 | overlay loaders A / B; differ only in destination pointer and current-id tracker (0x8007BC3C / 0x8007BC4C) |
FUN_8005DAB0 / FUN_8005D5F8 / FUN_8005D648 | CD 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 screen
The title screen is a small program of its own: an attract countdown that eventually plays the opening movie, a two-row menu, a memory-card check, and the NEW GAME / CONTINUE launch. It runs under the CARD pair, modes 22/23: that mode’s init loads PROT 0899 and spawns an actor whose handler calls the tick every frame. What is empty is the name search - none of the 28 dev names says “title”, which is why the row looked missing.
| Piece | Where | Note |
|---|---|---|
| tick function | FUN_801DD35C, in the overlay | 3,026 instructions; the first ~250 fan out through a 25-entry sub-mode jump table |
| state struct | 0x801F0000 (+ a sibling block at 0x801EF014..0x801EF200) | sub-mode selector at +0x204; cursor grid at +0x1F4 / +0x1F8; linear cursor at +0x1FC |
| attract countdown | 0x801EF16C | starts 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-mode | 0x10 | the Press-Start wait; confirm fades through 0x18 into the two-row cursor state 0x14 (cursor _DAT_8007B820). A cold boot always arrives here, never at the alternative 0x02 menu: the boot init.pak raises the entry word _DAT_8007BB00 unconditionally at 0x801CEB84, which routes Init to 0x11 and then 0x10. |
| launch | sub-modes 0x16 fade → 0x06 init_game | writes mode word 2 at 0x801DFC00 and scene id opdeene into 0x8007050C; CONTINUE detours through the card-load path first |
All 25 sub-modes carry the role their handler body shows in legaia_engine_vm::title_overlay, and TitleTickState::step executes the graph - one arm per sub-mode plus the shared epilogue, driven by the 56 state[+0x204] stores. The whole sub-mode machine is the front-end menu, memory-card manager and launcher - not an opening narration or name-entry sequence. Master game mode 2 has two writers: LaunchGame (0x06) at 0x801DFC00 on the NEW GAME route and LaunchFade (0x16) at 0x801DFAFC on the load route.
Details: pad-mask layout the title tick reads
The per-frame button mask (_DAT_8007B850) and newly-pressed mask (_DAT_8007B874) are packed: the face / shoulder byte sits 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; engine-core::input::PadButton uses the raw layout.
| Bit | Button | Bit | Button |
|---|---|---|---|
| 0 | L2 | 8 | Select |
| 1 | R2 | 9 | L3 |
| 2 | L1 | 10 | R3 |
| 3 | R1 | 11 | Start |
| 4 | Triangle | 12 | Up |
| 5 | Circle | 13 | Right |
| 6 | Cross | 14 | Down |
| 7 | Square | 15 | Left |
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.
Details: the countdown instruction and the extended state struct
lui a0, 0x801f
lui v1, 0x1f80
lbu v1, 0x393(v1) ; per-frame scalar (_DAT_1F800393)
lw v0, -0xe94(a0) ; countdown (0x801EF16C)
subu v0, v0, v1
bgez v0, 0x801dfc3c ; still counting: normal attract loop
_sw v0, -0xe94(a0) ; pc 0x801DDCCC - the watchpoint hit
| Address | Offset | Use |
|---|---|---|
0x801EF14C | -0xEB4 | slider X; step = scalar × 8. Both arms clamp at 0x2C (floor slti 0x2c at 0x801DFC88, ceiling slti 0x2d at 0x801DFCB4), so it converges on 0x2C from either side - not a [0, 0x2C] range |
0x801EF160 | -0xEA0 | fade / sweep accumulator, clamp 0x1000 |
0x801EF170 | -0xE90 | frame counter, incremented every tick |
0x801EF190 / 194 / 1A0 | -0xE70.. | alpha A / B / C, clamp 0x1000 |
0x801F0204 | +0x204 | sub-mode dispatcher, jump table at 0x801CF244 |
0x801F0230 | +0x230 | top-of-tick early-out guard |
The tick draws through three executable-side helpers ported in legaia_engine_vm::title_prim: the ClearImage rect-fill queue, the MoveImage VRAM copy, and the sprite-descriptor dispatcher (SpriteDescriptor { tag, flags, rect, pixel_data_ptr }, alpha-OR pre-pass under flags & 8, width divisor from flags & 3). The overlay-side text and box drawers shared with the menu / save UI are a separate port.
Where the title overlay lives on disc
The title code is the tail of PROT entry 0899 - the same entry whose head is the options-menu bundle. It is streamed raw and uncompressed straight into the overlay window: a multi-sector read from the entry’s LBA (47227) covering its 74 sectors, in five DMA bursts that all land inside the entry.
Details: sector ranges, DMA bursts and the embedded TIMs
| PROT.DAT range | Sectors | Contents |
|---|---|---|
0x5C3D800..0x5C44800 | 47227..47241 | 0899 head: options / config-menu strings + small config TIMs |
0x5C44800..0x5C62800 | 47241..47301 | 0899 tail: title-overlay code + data, incl. two embedded UI TIMs |
0x5C62800.. | 47301.. | 0900, the slot-B summon-render pair |
| Burst | RAM | Entry offset |
|---|---|---|
| 1 | 0x801CF818 | +0x1000 |
| 2 | 0x801D4818 | +0x6000 |
| 3 | 0x801D9818 | +0xB000 |
| 4 | 0x801DD018 | +0xE800 |
| 5 | 0x801E4818 | +0x16000 |
The tick body sits at entry offset +0xEB44, byte-identical to the captured overlay_title.bin. Two TIMs are embedded in the data segment: 0x801E5120 (entry +0x16908, the save-menu UI atlas) and 0x801EE120 (+0x1F908, the 14-frame 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.
What NEW GAME launches
Press NEW GAME and you do not get a name-entry screen. You get a scripted opening - the creation-myth crawl, the Seru intro, the Mist story, a fly-in over Rim Elm - and only once you stand in Rim Elm does “Select your name” appear. Every leg is an ordinary field scene chained by script, not a movie, and a confirm press skips the whole thing.
(0x1D, 0x5B); the opening timeline pans, opens name entry, then plays Vahn’s walk-outmenu overlay
The fresh-state seed. The new-game data init sets gold to a hard-coded 500, zeroes a ~0x200-byte story-flag region, and expands the executable’s starting-party template into the live 0x414-byte records, default names included. The opening scene comes from the title launcher, not from this seed. Port: World::begin_new_game.
The intro skip. Once opdeene’s timeline sets scratchpad flag bit 26 (field-VM op 0x2E GFLAG_SET, operand 0x1A), a confirm press at any time fades out, sets the town01 entry coordinates, clears the bit so it fires once, and issues a name-based scene-change packet to town01. The target name is an overlay literal, which is why opdeene’s own data holds no town01 string. Port: World::take_prologue_handoff, armed by execution when the timeline runs.
Name entry runs in town01 under mode 3, opened by field-VM op 0x49 sub-op 3 in the opening timeline record (P2[3], body offset 0x02C6). The overlay draws a 6×17 ASCII grid 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 the Yes/No commit. The committed name lands at record +0x2A7. Port: engine-core::name_entry, rendered by name_entry_draws_for; a story flag on the record keeps a later visit from re-prompting.
Details: the skip block, the packet API, the name-entry grid
if (_DAT_8007b868 == 0 && (_DAT_1f800394 & 0x4000000) && (_DAT_8007b850 & 0x100)) {
FUN_801d58f0(2, 0, 0xffffff, 0, 0x3c, -1); // fade out
_DAT_80073ef4 = 0xec0; _DAT_80073ef8 = 0x2dc0; // town01 entry coords
_DAT_1f800394 &= 0xfbffffff; // clear bit 26 (fire-once)
func_0x8001fd44(s_town01_801ce82c, 3); // next scene = "town01"
}
- The packet API copies the target name into
0x8007050C, syncs it to the active buffer0x80084548and stages the load;s_ERR_CHANGE_PACKETguards re-entry. It is not the map-id door warp (op0x3E), which backs up the active scene name into0x8007BAE8- empty in thetown01opening saves. - The arm is the last record of
opdeene’s third partition (MAN offset0xA47, the2E 1Aat0xA5E). Disc-gatedopdeene_prologue_arm.rspins it and assertstown01carries no such arm. - Each leg’s opening record spawns via op
0x44SPAWN_RECORD (the three op scenes) or the walk-on tile trigger (map01,town01). Subtitle pages roll through the bottom-up crawl roller (FUN_80037174; engineCutsceneNarration) as a child context so camera cuts play under the scroll. - Name-entry pins: grid at
0x801F29F0, live buffer0x801F2A6C, cursor0x8007BB88(linear0..0x77, d-pad deltas ±0x11/ ±1, separators skipped, name width capped at 57 px). RendererFUN_801E6B34; state machineFUN_801F03F0with a 5-entry jump table at0x801CF71C(init → interactive → three confirm handlers). The parked op-0x49state slot_DAT_8007B450holds the op’s RAM address + 1 in thename_input_uisave state.
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.
Boot-time art
Three separate sources feed the boot screens: the publisher logos in init.pak, the title wordmark sheet in PROT 0890, and a bundle of system-UI textures that sits in no PROT entry at all.
Publisher logos - PROT 0895 init.pak
PROKION, Contrail, “SCEA Presents” and the epilepsy warning are four uncompressed TIMs in one bundle. Mode 16 READ INIT uploads all four to VRAM and then plays them from the overlay’s own 13-state sequencer, in the order SCEA, Contrail, PROKION - not the order they sit in the file. Each logo’s on-screen 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.
| Offset | Logo | Mode | Stored | Drawn as |
|---|---|---|---|---|
+0x021C4 | PROKION | 8 bpp | 176×256 | two 176×126 quads, (144, 165) and (320, 165) |
+0x0D3E4 | Contrail | 8 bpp | 184×256 | one 184×254 quad at (228, 105) |
+0x18E04 | SCEA Presents | 4 bpp | 256×128 | two 252×64 quads, (68, 192) and (320, 192) |
+0x1CE44 | WARNING | 4 bpp | 256×256 | uploaded, and drawn by nothing - see below |
The fade is not alpha: the quads are opaque, and the sequencer scales each vertex colour so the PSX texture blend texel × colour / 128 darkens them - level 0 is black and 0x80 leaves the texel alone. Per-logo pacing: SCEA 16 / 131 / 32 frames, Contrail 16 / 121 / 16, PROKION 16 / 91 then a 65-frame ramp to a white screen.
The epilepsy warning is never shown. Its sprite descriptor (record 1, tpage 0x000B / clut 0x7E80) is the one id none of the five emit calls passes, and a cold-boot capture carried through the logo chain, the title screen, the attract movie and the return to the title logs 857 quad emits with zero for that descriptor and finds no primitive anywhere in RAM carrying its CLUT - while the three logo CLUTs, swept in the same pass as a control, each land on their documented screen rect. Later in the same boot the menu overlay parks the memory-card kanji page on top of the warning’s VRAM rect, so its pixels do not survive to the title either.
Parser legaia_asset::init_pak; sequencer, quad table and pacing in engine_core::publisher_logos; shown by the asset viewer’s “Boot publisher logos” panel, by play-window --boot-ui and by the browser play page’s own boot chain, which opens the logo stage ahead of the title card off the same session and the same shared quad builder. The entry’s bat_back_dat filename label is the +2 shift, not a mislabel. There is no single title.pak entry: that dev-tree bundle is split between PROT 0890 (wordmark) and PROT 0899 (options bundle + title code).
Title wordmark - PROT 0890
One 256×256 8-bpp sheet at file offset 0x14228 (66,080 bytes with its CLUT) carries the orb, wordmark, prompt and copyright lines. Retail samples it in bands rather than blitting the quad; the engine (title_screen_atlas) emits one sprite per active band.
| Source rect | Content | Drawn when |
|---|---|---|
(0, 17, 256, 124) | orb + wordmark | every post-fade phase |
(96, 151, 64, 10) | demo-build label | never - a demo leftover the framebuffer omits |
(60, 178, 196, 16) | PRESS START BUTTON | press-start phase only |
(4, 195, 244, 14) | trademark line | every post-fade phase |
(8, 209, 234, 14) | copyright line | every post-fade phase |
(0, 226, 256, 11) | NEW GAME / CONTINUE footer | menu rows, sampled as two halves; selection is bright vs dim, no arrow cursor |
System-UI textures in the pre-entry gap
A 118-sector region sits between the TOC (ends at 0x1800) and the first indexed entry (0x3C800, sector 121). It holds the small-caps menu font, the boot cursor and a few sprite strips - all 4-bpp TIMs with palettes, all targeting the bottom-right corner of VRAM. No per-entry extractor visits it; ProtIndex::prot_dat_raw_bytes reads it directly.
Details: the gap’s seven TIMs and the menu-glyph atlas
| PROT.DAT offset | Dims | VRAM target | Purpose |
|---|---|---|---|
0x01858 | tiny | (896, 256) | boot cursor variant |
0x018E0 | 256×192 | (896, 256) | large UI sprite sheet |
0x07F40 | 256×256 | (896, 0) | dialog-font sheet |
0x10178 | 256×32 | (896, 448) | AP / status-icon strip |
0x11218 | 256×256 | (960, 256) | menu-glyph small-caps font |
0x19438 | 240×24 | (960, 400) | UI sprite strip |
0x1B80C | 256×256 | (640, 0) | system sprite sheet |
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 engine decodes once to a stencil (index 0 transparent, 1..15 opaque) and tints at draw time via SpriteDraw::color. Builders: crates/asset/src/menu_glyph_atlas.rs and crates/engine-core/src/menu_glyph_atlas.rs. The title’s own NEW GAME / CONTINUE rows do not use it - they are the footer band of the wordmark sheet.
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.
Debug flags
Two RAM values decide whether the game behaves like the shipped disc or a dev station. On real hardware both read “retail”; a cheat device can flip them, which is how the debug menu on the retail disc becomes reachable (docs/reference/builds.md lists the bindings).
| Address | Role | Retail value |
|---|---|---|
_DAT_8007B8C2 | dev / retail loader selector, read as a halfword at 40 sites; != 0 takes the PROT-index path, == 0 opens a host path that ends in break 0x103 | 1, written once by main() from a two-instruction leaf |
_DAT_8007B98C | debug-mode word tested by the input dispatcher and ~14 field-overlay gates; poking its top byte _DAT_8007B98F to 1 makes every != 0 gate read active, and SELECT+△ opens the debug menu | 0 |
History: why both flags were once read backwards
A sweep for absolute lui+offset references saw no writer for the loader flag, because the one write is gp-relative, and concluded it booted at 0; the executable’s PS-X EXE header carries b_size = 0, so no BSS is cleared either. The debug byte returned zero byte-granular references because consumers read the word. Both are under do-not-re-walk; the gp-relative blind spot is why the address reference scan checks all five forms.
How we know
The game shipped without symbols; functions are named FUN_<address> after their RAM location in the Ghidra trace.
| Function | Address | What it proves | Dump |
|---|---|---|---|
| main loop | FUN_80015E90 | init order, heap sizes, the mode-switch housekeeping, the loader flag’s only write at 0x80015F08 | funcs/80015e90.txt |
| entry stub | FUN_80026C28 | sets $gp = 0x8007B318, calls main once | - |
| TOC loader / span | FUN_8003E4E8 / FUN_8003E68C | 3 sectors to 0x801C70F0; size = gap to the next entry | - |
| resolvers | FUN_8003E8A8 / FUN_8003E6BC | index-based and path-based LBA lookup | - |
| CDNAME primer | FUN_8001D8FC | 16-byte name records at 0x80088758; unbounded copy | - |
| asset dispatcher | FUN_8001F05C | type byte in the high 8 bits of the size word; handler table 0x80010638 | - |
| mode table | 0x8007078C | 14 INIT / RUN pairs, 24-byte stride; read by legaia_asset::mode_table | disc-gated test |
| overlay loaders | FUN_8003EBE4 / FUN_8003EC70 | 16 static call sites; param + 0x381 raw index | - |
| menu per-frame | FUN_80025F74 → FUN_80017978 | skips the master frame driver FUN_80016444 | - |
| minigame init | FUN_80025980 | reads sub-id _DAT_8007BA34 twice, leaves it standing | - |
| title tick | FUN_801DD35C | countdown store at 0x801DDCCC (write watchpoint); launch write at 0x801DFC00 | funcs/overlay_title_801ddccc.txt |
| title overlay source | CD-DMA FUN_8005D9A0 | five bursts from LBA 47227, all inside entry 0899; uncompressed | autorun_title_overlay_writer_hunt.lua |
| field INIT / scene init | FUN_80025B64 / FUN_801D6704 | loads 0897, reads the scene id, writes mode 3 | - |
| new-game seed | FUN_80034A6C / FUN_800560B4 | gold 500, flags zeroed, template expanded | - |
| intro skip | FUN_801D1344 / FUN_8001FD44 | flag-and-pad-gated one-shot; name packet to town01 | - |
| opening chain | FUN_8003BDE0 | exec breakpoint: exactly five spawns, one per leg | live probe |
| name entry | FUN_801E6B34 / FUN_801F03F0 | grid renderer; substate jump table 0x801CF71C | - |
| pad builder | FUN_8001822C | ~((pad[2] << 8) | pad[3]) packed layout; debug-word gate | - |
| title TIM | RAM 0x80170DF8 | byte-matches PROT 0890 @0x14228; framebuffer omits the demo band | scan_tims_and_match_prot.py |
| menu-glyph atlas | RAM 0x80106478 | byte-matches PROT.DAT 0x11218 | - |