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 at 0x801C70F0; 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 0x801CE818 holds 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 CARD pair (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, in town01
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

main loop FUN_80015E90 mode table 14 INIT / RUN pairs INIT handler one frame per-frame handler every frame overlay slot A 0x801CE818 master frame driver actors, VMs, draw handler writes a new mode word mode word streams a PROT entry skipped by pause menu Title screen: modes 22/23 (CARD), as a spawned actor tick
One loop, one table: a mode’s INIT handler streams code into the overlay window, its per-frame handler runs the game, and writing the mode word is the only way to switch.
1Hardware initGPU, CD, sound and heap; a console probe picks the heap sizemain() 2Archive directorythe PROT.DAT table of contents is read once into RAMTOC loader 3Name mapCDNAME.TXT fills the scene-name table so “town01” resolves to an entry indexCDNAME primer 4Asset routerthe type-byte dispatcher’s buffer pointers become validasset dispatcher 5Publisher logosfour textures in 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 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

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:

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/17READdev; jumps into slot A blind-0x8002612C
18/19GAME OVERgame over09020x80025B30
20/21BATTLEbattle; setup is resident in the executablenone (0898 via the field path)0x800565D8
22/23CARDmenu incl. the 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.
  • 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: BootSession holds the world in SceneMode::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::tick suspends 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 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.

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).

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. 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
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. Port engine-core::cd_dma::stream_read_sync
FUN_8005E4D4 / FUN_8003D3C4sync LBA reader; path-based ISO9660 loader for .STR / .XA files
FUN_8003E4E8boot-time TOC loader
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)
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 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.

PieceWhereNote
tick functionFUN_801DD35C, in the overlay3,026 instructions; the first ~250 fan out through a 25-entry sub-mode jump table
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, 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.
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 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.

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.

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
AddressOffsetUse
0x801EF14C-0xEB4slider 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-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, jump table at 0x801CF244
0x801F0230+0x230top-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 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 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.

1Mode 2the field INIT handler streams the field overlay (PROT 0897) and calls the per-scene initializermode table 2Scene initreads the resident scene id, loads geometry + the MAN (the scene’s script-and-data bundle), camera, fog and BGM, then writes mode 3field overlay 3opdeenecreation-myth crawl; its timeline arms the skip flag near its top and ends with a scene changefield VM 4opstati → opurud → map01Seru intro, Mist story, world-map fly-in with the title cardfield VM 5town01arrival at tile (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 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). Disc-gated opdeene_prologue_arm.rs pins it and asserts 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). Subtitle pages roll through the bottom-up crawl roller (FUN_80037174; engine CutsceneNarration) as a child context so camera cuts play under the scroll.
  • Name-entry pins: grid at 0x801F29F0, live buffer 0x801F2A6C, cursor 0x8007BB88 (linear 0..0x77, d-pad deltas ±0x11 / ±1, separators skipped, name width capped at 57 px). Renderer FUN_801E6B34; state machine FUN_801F03F0 with a 5-entry jump table at 0x801CF71C (init → interactive → three confirm handlers). The parked op-0x49 state slot _DAT_8007B450 holds the op’s RAM address + 1 in the name_input_ui save 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.

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, 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 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

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 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)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).

AddressRoleRetail value
_DAT_8007B8C2dev / 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 0x1031, written once by main() from a two-instruction leaf
_DAT_8007B98Cdebug-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 menu0
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.

FunctionAddressWhat it provesDump
main loopFUN_80015E90init order, heap sizes, the mode-switch housekeeping, the loader flag’s only write at 0x80015F08funcs/80015e90.txt
entry stubFUN_80026C28sets $gp = 0x8007B318, calls main once-
TOC loader / spanFUN_8003E4E8 / FUN_8003E68C3 sectors to 0x801C70F0; size = gap to the next entry-
resolversFUN_8003E8A8 / FUN_8003E6BCindex-based and path-based LBA lookup-
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 by legaia_asset::mode_tabledisc-gated test
overlay loadersFUN_8003EBE4 / FUN_8003EC7016 static call sites; param + 0x381 raw index-
menu per-frameFUN_80025F74 → FUN_80017978skips the master frame driver FUN_80016444-
minigame initFUN_80025980reads sub-id _DAT_8007BA34 twice, leaves it standing-
title tickFUN_801DD35Ccountdown store at 0x801DDCCC (write watchpoint); launch write at 0x801DFC00funcs/overlay_title_801ddccc.txt
title overlay sourceCD-DMA FUN_8005D9A0five bursts from LBA 47227, all inside entry 0899; uncompressedautorun_title_overlay_writer_hunt.lua
field INIT / scene initFUN_80025B64 / FUN_801D6704loads 0897, reads the scene id, writes mode 3-
new-game seedFUN_80034A6C / FUN_800560B4gold 500, flags zeroed, template expanded-
intro skipFUN_801D1344 / FUN_8001FD44flag-and-pad-gated one-shot; name packet to town01-
opening chainFUN_8003BDE0exec breakpoint: exactly five spawns, one per leglive probe
name entryFUN_801E6B34 / FUN_801F03F0grid renderer; substate jump table 0x801CF71C-
pad builderFUN_8001822C~((pad[2] << 8) | pad[3]) packed layout; debug-word gate-
title TIMRAM 0x80170DF8byte-matches PROT 0890 @0x14228; framebuffer omits the demo bandscan_tims_and_match_prot.py
menu-glyph atlasRAM 0x80106478byte-matches PROT.DAT 0x11218-

See also