How to use this page

Use this page to find what's worth digging into next. The detailed write-ups, captures, and decompiler dumps live in the per-topic memory files (~/.claude/projects/.../memory/project_<slug>.md) and in the linked docs.

Status conventions:

  • open - active hunt; concrete next step exists.
  • partial - main result pinned; a residual sub-question remains.
  • falsified - hypothesis disproved; row kept so the path isn't re-walked.

World map / kingdom bundles

ThreadStatusWhat would close itMemory
Kingdom slot 4 - per-record semantic resolved The consumer is fully decoded: FUN_80043390 walks an 8-byte-header command stream (kind = bits 17–31, count = bits 0–15), tail-calling per-kind GTE primitive emitters (kinds 8–19 across 4 banks via the 0x8007657C table; each reads packed vertex indices & 0x7FF8 into a vertex pool and emits a POLY_F3/G3/G4/GT3/GT4 packet). The handlers read the slot-4 RAM payload IN PLACE - there is no record → working-buffer transcode. A Drake warp capture shows the cluster-A GTE prim path (lw …,0x10(a1); andi …,0x7FF8, the exact packed-vertex-index extraction) holding slot-4 pointers under return addresses 0x801F78D4 (the world-map top-view overlay renderer) and 0x8001BC8C (SCUS render); the slot-4 sub-body payloads are the command stream + vertex pool, walked directly. The earlier “FUN_8001E54C distributes the records into a working buffer the handlers walk” reading is falsified (it fired only twice, on a non-slot-4 buffer). Cross-kingdom: the slot-4 resident base is byte-pinned per kingdom (Drake 0x8011A624, Sebucus 0x80119CE4, Karisto 0x80108D84). Per-record semantic: each 8-byte record is a GTE vertex - the handler FUN_80044c14 loads its two words into the GTE vertex registers (VXY = x | y<<16, VZ = z) and RTPT-transforms them, so x/y/z are model-space coordinates and attr (the 4th i16) is not a coordinate. A full sweep of the cluster-A handler family confirms attr is render-unused (reserved/authoring data or a non-render consumer; nothing in the render path reads it). kind (1/2/4) is a body class/scope tag: kind 1 bodies (the three leading bodies) are byte-identical across all three kingdoms (a shared universal mesh set); kind 2 = full-3D kingdom objects; kind 4 = widest-extent meshes. A Read-watchpoint on body 0's header during the Drake warp catches the cluster-A handler chain (ra = 0x801F78D4, the world-map renderer) reading count/kind in place - there is no separate command-stream / mesh builder; each slot-4 body is a self-contained render packet (header + indexed vertex records) consumed in place by the cluster-A handler chain. So slot 4 is a per-kingdom assembly from a shared mesh library plus kingdom-specific bodies. project_slot4_is_wireframe_not_terrain.md
Slot-4 → cluster-A converter site open Find the function that walks the slot-4 outer pack and feeds cluster A. The converter does not run as a direct overlay read of _DAT_8007B888; the populating site is either an SCUS function-pointer table or an unscanned overlay. project_open_work_slot4_cluster_a.md
DAT_8007C018[45..53] mid-load vertex-pool pointers open Single Lua write-watchpoint capture on 0x8007C018 + 45*4 during scene load to disambiguate stale-pointer vs. live-data. Steady-state model says reads past DAT_8007BB38 are stale and never consumed; the mid-load snapshot deserves direct confirmation. project_dat_8007c018_global_tmd_table.md
PROT 0874 section-0 outer producer partial Find the dispatch site that funnels PROT 0874 section 0 through the 3-section parse_player_lzs shape into FUN_80020224FUN_8001F05C case 2 → FUN_80026B4C. Inner dispatch is pinned; outer producer is not. Likely lives in an overlay-resident scene loader (e.g. FUN_801D6704 family). project_global_tmd_pool_source.md + project_next_session_backlog.md § D
Drake uncapped cluster-A totals open Re-run autorun_slot4_dispatcher_args.lua with LEGAIA_PC_CAP=50000 and a timeout --kill-after=30s 1500s wrapper. Drake saturated 7 of 9 PCs at PC_CAP=5000; raising the cap closes the cross-kingdom delta table. project_open_work_slot4_cluster_a.md
Slot-4 freeze flag _DAT_8007B824 open Write-breakpoint probe on _DAT_8007B824 during retail play. Either an undumped overlay sets the freeze flag, or the BSS-init zero holds through retail and the "persistent slots" semantic is vestigial. project_open_work_slot4_cluster_a.md
World-map outline / coastline reading falsified Visual inspection plus the slot-4 record-semantic work refuted the "world-map overlay outlines / coastline wireframe" interpretation. Bodies are most likely small object-local 3D meshes; treat any future "kingdom border lines" claim with suspicion. project_slot4_is_wireframe_not_terrain.md

Battle / arts / level-up

ThreadStatusWhat would close itMemory
MP-cost ability-bit priority (half vs quarter) resolved (dump-confirmed) Reading the state-0x28 block of FUN_801E295C (0x801E3D0C; the same block recurs in state 0x3C at 0x801E4568) settles both open points. (1) Priority - MP-half (0x20) wins. The code is andi 0x20; bne <half> then andi 0x10; beq <none>, i.e. if (bits & 0x20) {half} else if (bits & 0x10) {quarter} - the 0x20 test short-circuits 0x10. This matches the docs / MpCostModifier::from_ability_flags; the engine SM port + live cast path that applied quarter first were a guess and are now flipped. (2) Formula - it subtracts a right-shifted copy, not a floor-divide. Half = cost - (cost>>1) (rounds up on odd costs); "MP-quarter" = cost - (cost>>2) = pay 3/4 (shave 25%), NOT cost/4. The engine's base/2 / base/4 were both corrected (battle_formulas::mp_cost_after_ability_bits); all three cast paths route through the shared helper. MP cost consumes no RNG, so determinism oracles are unaffected. project_re_and_engine_batch_day_branch.md
Encounter record carrier resolved (no array) Decompile of FUN_801DE840 shows install handlers all use pbVar43 = param_1 + param_2 (the current opcode pointer in the field-VM script bytecode); each scripted encounter is its own dispatcher-op site (cases 0x37/0x41, 0x38, 0x43, 0x47, 0x4C), with monster count/ids inline as the trailing operand bytes. There is no separate encounter-record table on disc. See the encounter format writer table. project_encounter_record_format.md
Random-encounter trigger path resolved FUN_801D9E1C is the per-step roll function (rate counter _DAT_8007B5FC, scaled by config _DAT_8007B5F8). On counter underflow it picks a formation from the matching region's RNG range and installs actor[+0x94] = formation_table_base + 1 + id * stride, raising bit 0x80000. The per-scene control block at _DAT_801C6EA4 + 0x20/0x24/0x28 is populated by FUN_8003A110 ("Mesworks set encount group table") from the MAN asset (type 0x03) buffered at _DAT_8007B898. project_random_encounter_trigger_path.md
Encounter MAN sub-section layout resolved FUN_8003AEB0 is fully decoded: it walks the MAN multi-section header (sections at MAN offsets +0x22, +0x24, +0x26, +0x28, signed 16-bit LE) and legaia_engine_core::encounter_man::scene_encounter_from_man reads the encounter section straight from disc bytes, wiring per-scene EncounterTables for the standalone towns + kingdom-bundle scenes. The region-table section is the per-scene control block _DAT_801c6ea4 + 0x4 count-prefixed array of 18-byte records: byte[0] kind selector, bytes[1..4] tile-space bounding box [minX, minZ, maxX, maxZ] queried by FUN_801dba20(tileX, tileZ) (tile = (player_pos - 0x40) >> 7), bytes[5..17] payload (sub-split still open), consumed by the field camera arrival handler FUN_801dbec4 + camera-config FUN_801dbc20. Residual: the world-overview actor-placement section FUN_8003A1E4 consumes is decoded separately. project_man_section_decoded.md
Super / Miracle Arts trigger logic open Port the find/replace trigger matcher into engine-vm battle action. Tables and constants are in legaia-art; the trigger SM driving "find string in queue → replace" is not yet ported. project_arts_system.md
Seru-magic summon visual (e.g. Tail Fire) dispatch + overlay structure + flame-mesh index + PROT 870 atlas resolved; position fully ported (FUN_801F811C per-frame lerp); library + atlas loaded in engine; PLAYER summon render path resolved by live trace (battle draw FUN_80048A08, per-object TRS keyframes - NOT FUN_801F7088); model-library base gp[0x754] RESOLVED (party_count + 2, not per-summon); enemy "Fire Tail" path RESOLVED (a move-VM part-actor on the SCUS render-tail FUN_80021DF4 over a record in the battle overlay 0898; the PROT 0900 screen-widget path is dormant) The 3D summon visual is a per-summon code overlay, not an opcode and not befect_data. Battle SM FUN_801E295C state 0x29: for a spell id actor[+0x1df] in 0x81..0x8b it sets _DAT_8007ba2c = PTR_801f6734[id-0x81] and calls FUN_8003EC70(id-0x79) (overlay loader B → raw TOC id-0x79+0x381 = extraction id-0x79+0x37F), i.e. extraction PROT 903..913 (Gimard Burning Attack 0x81 → PROT 903; the deep-dived stager file analysed below is extraction 0905 - the spell-0x83 slot under the corrected loader math; the historical “0905 = Gimard” was the +0x381 off-by-2). The overlay embeds no TMD geometry; decompiled (PROT 905 imported raw at base 0x801F0000, ghidra/scripts/dump_summon_overlay.py) it spawns part-actors via the SCUS part-stager FUN_80021B04 (param_1 = world pos, param_3 = a part record; model_sel < 0 / 0x4000 / 0x4001 = transform node else mesh DAT_8007C018[model_sel + gp[0x754]], allocated from effect pool DAT_8007062c). Three staging functions drive it: FUN_801F16A0 (phase 0 = a do { stage } while(< 8) loop spawning 8 flame parts with rand()-seeded actor params +0x84/+0xb4=rng%15+16/+0xb6=rng%255+512; phase 1 = 1 more), FUN_801F36A0, FUN_801F4DD0. CORRECTED (do not re-walk): (a) the parts ARE move-VM bytecode - PROT 905 has zero direct jal 0x80023070 only because the move VM runs inside the SCUS spawn helper FUN_80021B04 (which seats the move-buffer at record+4 and ticks FUN_80023070); the earlier “no move VM here / not move-VM bytecode” reading was a wrong-link-base artifact (and the live trace below separately shows the player summon renders via the battle TRS-keyframe draw, not this scene-graph). (b) CORRECTED: an earlier note called the 0x180C records “coincidentally record-shaped” and “beyond the 0x5800 file” - that was the wrong link base (0x801F0000). The shared summon-overlay buffer (*DAT_80010390) links at 0x801F69D8 (pinned by byte-matching the resident overlay in the battle_gimard_tail_fire_a/_b captures: RAM 0x801F8000 == file 0x1628), so the runtime record pointers (lui 0x8020 / addiu) resolve to PROT 0905 file 0x180C..0x1E00 - in-file. Also CORRECTED: two overlays timeshare that buffer - PROT 0905 is the spawn stager (38 FUN_80021B04 calls), PROT 0900 is a resident transform/GTE-render overlay; 0900 is the one byte-resident in the mid-cast captures (after 0905 ran), which is why a 905-head RAM search finds nothing. Parser legaia_asset::summon_overlay (CLI asset summon-overlay, disc-gated summon_overlay_real): 38 sites → 23 part records (17 transform nodes, model_sel == -1), each [i16 model_sel][u16 flags][move-VM bytecode @+4]. Generalizes across the whole player-summon block: every overlay in extraction PROT 0903..=0913 (spell_id 0x81..=0x8b, summon_overlay::PLAYER_SUMMON_STAGER_PROT) recovers a move-VM scene-graph (disc-gated summon_overlay_block sweep - 20..73 spawn sites, 10..43 contiguous in-file records each). 0905 reads cleanest (transform-node-dominated + small library indices); the larger stagers carry many SummonPartKind::Sentinel first-words - node-mode 0x1000/0x4000/0x8000-class markers, not library indices - so the CLI labels those sentinel 0xNNNN. The model-library additive base (gp[0x754], global 0x8007BA6C) is RESOLVED: it is not per-summon and not a constant 3 - it is party_count + 2 in battle (3 for the 1-member training party, 5 for the 3-member party), read across the save corpus; there is one per-battle value and model_sel is library-relative. Still open across the block: the precise sentinel semantics. The parts ARE move-VM-driven - FUN_80021B04 sets the actor’s move-buffer to record+4 and ticks FUN_80023070; PROT 0905 has no direct jal 0x80023070 because the move VM runs inside the spawn helper. The meshes are the separately-loaded DAT_8007C018 library; the flame draws as POLY_GT3/GT4 from the etim page (832,256) 4bpp, row 478 (cols 0/16/32 used simultaneously across parts - a static multi-shade look, not a temporal cycle). The fire mesh-set is the 10 etim-baking models DAT_8007C018[23..32]; the active flame at the captured frame is DAT_8007C018[26]. The TMD library is extraction PROT 871 (etmd.dat, a 30-TMD asset::pack; the sound_data filename label is the +2 shift), loaded at battle init by FUN_800520F0 - the real etmd.dat is 871, not 874 §0. The flame animation is geometric (PROT 905 move-VM part transforms), NOT CLUT cycling - falsified by the battle_gimard_tail_fire_a/_b capture pair (whole CLUT band byte-identical across two animation-distinct frames). Engine: PROT 871 is now loaded - engine-core::scene::seed_effect_model_library_from_etmd walks the uncompressed 30-TMD pack (from the entry's extended footprint) into World::global_tmd_pool[3..=32] at scene entry, and the effect-model render path / F-key dev spawn draw GIMARD_TAIL_FIRE_MODEL_INDEX = 26 from it (the 874 §0 mesh is now only a fallback). PROT 870 flame-texture atlas resolved: three 64×256 4bpp TIMs → battle VRAM (320,0)/(384,0)/(448,0), CLUTs rows 474..476 - byte-verified pixel-exact in every stable Rim Elm battle capture, battle-only (the field uses those columns for town textures), now uploaded on battle entry by engine-core::scene::upload_flame_atlas_into_vram. It is NOT the 2D-billboard page-(0,0) source (it's at fb_x≥320). The flame-atlas loader site is now pinned: FUN_80020050 (SCUS 0x80020050) uploads PROT entry 0x366 into VRAM twice via FUN_8001fc00 (→ FUN_8003e8a8, the PROT-index loader), with the VRAM region set up by FUN_80017888 / FUN_8001e54c (param 0xf000); it is gated on _DAT_8007b868 == 0 and is independent of the FUN_800520F0 battle-bundle path (which pulls 0x367..0x36d). 2D-billboard texel source resolved (page-(0,0) was an atlas field-order misread): the entry's +4/+6 are CLUT (u16) / tpage (byte), so 0x7680 is the CLUT (CBA fb (0,474)) and the real tpage is the byte at +6 (e.g. 0x25 = page (320,0) 4bpp). A melee hit-spark capture confirms it: no prim samples page (0,0)/8bpp, the spark draws as textured quads on the PROT 870 flame atlas (320,0)/(448,0). Engine SpriteAtlasEntry fixed (CLUT u16 +4, tpage byte +6); active_effect_sprites now samples the resident PROT 870 / etim texels. Animation driver LANDED. engine_core::summon::SummonScene seeds one move-VM ActorState per parsed part (PC=2 → record+4, mirroring FUN_80021B04) and ticks every part through the already-ported move VM each frame (World::spawn_summon / tick_summon / active_summon_part_draws; play-window G debug-spawns the Gimard summon and draws one textured TMD per mesh part). The per-part animation computation is faithful (every Gimard part runs the move VM without an unimplemented opcode; disc-gated summon_scene_real). Production cast-band trigger WIRED. A player Seru-magic cast (spell_id in 0x81..=0x8b) requests the summon at the cast point in both engine cast paths - the action-SM spell_anim_trigger (World::fold_battle_event on SpellAnimTrigger) and the live-loop cast_spell_on_slots - via World::request_summon_spawn; the host drains take_pending_summon_spawn, maps the id to its overlay PROT entry (engine mirror 0x81..=0x8b → 905..=915 - still the raw +0x381 arithmetic; the corrected extraction range is 903..=913; retail FUN_8003EC70(id-0x79)), loads + parses it, and seats the scene-graph. A real Gimard Burning Attack cast spawns the animated summon, no debug key. PROT 0900 transform partially decoded. The resident render overlay (base 0x801F69D8) composes each part's transform. Translation pinned + ported: phase A at 0x801F82A0 - when the keyframe gate *(i16)(actor+0x9C) == *(i16)(actor+0x9E) holds, the world position is overwritten by the move-VM anim-bank slots (anim_3c/3e/40, op 0x00, v<<3) and +0x9E cleared; the anim banks are summon-local so the engine adds the cast origin (summon::apply_translation_update in SummonScene::tick) - this is why a part animates with no WORLD_ADD op. Rotation: the overlay builds a per-part render node (rot at +0x8/0xa/0xc, mesh +0x10, flags +0x12), applies the camera angles _DAT_8007B790/2/4 gated by flags +0x12 bits 0x80/0x100/0x200, then the local rotation - composed via RotMatrixX/Y/Z. Two distinct PROT 0900 render paths, separated (correcting earlier mis-attributions). (1) Position - FUN_801F811C (keyframe interpolation, decoded): advances the part's world pos toward the anim-bank target - when actor+0x9E (duration) is non-zero it adds the per-frame delta _DAT_1F800393 to actor+0x9C (time, clamped), interpolates world (+0x14/16/18) toward anim banks (+0x3c/3e/40) via the FUN_801DE4C8 mode-1 fixed-point lerp + FUN_801DE648 store, and latches world = anim banks exactly on completion. This whole per-frame update is now ported in full as summon::apply_translation_update - the latch is its terminal case, not the whole behaviour. It also emits 2D GP0 sprite packets linked to the OT by FUN_8003D2C4 = the PSX OT-linker addPrim (not a mesh renderer); those packet fields +0x8/0xa/0xc are GP0 params, not Euler rotation - an earlier reading of them as a rotation "render node" was a mis-attribution of this 2D layer. (2) 3D mesh rotation - resolved by live trace for the PLAYER summon: NOT FUN_801F7088. A PCSX-Redux capture of a player Gimard Burning Attack cast (Vahn solo; states gimard_summon_start / _visible / _burning_attack) shows, across all three phases: FUN_801F7088 = 0 calls, move VM FUN_80023070 = 2-3 (noise, not a per-part driver), part-stager FUN_80021B04 = 1, and the battle per-actor draw FUN_80048A08 = 35-64×/frame. The summon is an ordinary battle actor (mesh-table at +0x44, monster-anim archive at *(actor+0x4C)+0x88) drawn by FUN_80048A08 → the per-object rigid-TRS keyframe decoder FUN_8004998C → cluster-A FUN_80043390, each object's Euler composed by RotMatrixX/Y/Z - posed exactly like an enemy monster body, with the orientation coming from the per-object TRS keyframes, not FUN_801F7088 (whose only static dumps are the world-map top-view tile renderer aliasing the same 0x801Fxxxx band) and not a move-VM scene-graph. So the move VM stages the part-actors and the battle draw renders + orients them; only the position lerp (1) rides the summon overlay. NAMING: the PLAYER Gimard summon move is Burning Attack; the ENEMY Gimard boss move is Fire Tail - distinct moves with distinct animations. SCOPE: the capture is the PLAYER move only. The ENEMY Fire Tail is now resolved separately (disc + library gated firetail_movefx_liveness): its mid-cast holds PROT 0900 resident but the screen-widget family is dormant (zero live widgets), and the live effect is a single move-VM part-actor ticked by the SCUS render-tail FUN_80021DF4 over a [model_sel][flags][bytecode] record in the battle overlay (0898) data - not a 0900 record - so Fire Tail does not drive the widget path. Those enemy-special move-FX records are the effect-prototype table 0x801F6324 entries (61 pointers → 54 unique records, all validated through the move VM by move_fx_records_vm_exec_disc). Probes autorun_summon_rotation.lua + autorun_summon_path_reconcile.lua. There is no faithful CLUT cycle to wire (falsified above); the engine renders the flame with the correct row-478 CLUT. Animated battle-actor rendering is now WIRED. Enemy monsters animate in play-window: monster_archive::idle_animation (action 0, the +0x8c 9-byte TRS stream) → engine_core::battle_anim::MonsterAnimPlayer (8.8 fixed-point loop cursor → legaia_anm::PoseFrame, the same per-object (translation, rotation) shape the field player produces) → the rigid tmd_to_vram_mesh_posed_rot deform (R·v + T, Rz·Ry·Rx, the validated monsters.html _assemble math). enter_battle_render attaches the clip per actor, World::tick_battle_animations advances it each battle frame, the posed-override path deforms the mesh (field translation-only path unchanged). Proven on real disc data by battle_anim_real (monster 1 = 28 frames × 15 parts). Player summon source - RESOLVED: the summon reuses the namesake battle_data enemy creature. The actor 0x8008350C earlier called "the summon" is actually a Gobu Gobu monster (its 13×18 idle byte-matches battle_data id 4). The fix was fingerprint discipline: the state6 RAM dump is advanced N frames, so analysing the fingerprint-verified frame-0 RAM of gimard_summon_visible (8aa0…, sha256-matched to catalog + live slot) instead, the actor table DAT_801C9370 shows Vahn casting 0x81, a Gobu Gobu enemy, and a distinct 11-part / 2-action entity whose idle (0x800BBB20, 11×40) byte-matches battle_data id 10 = "Gimard". So the player Gimard summon spawns the namesake "Gimard" creature (id 10), reusing its monster-archive mesh + TRS animation - exactly the now-wired enemy pipeline's format. Disc-verified spell→creature map (by name): Gimard→10, Theeder→25, Vera→28, Gizam→55, Nighto→49, Zenoir→64, Viguro→74, Swordie→86, Orb→83, Freed→92, Nova→95 (summon::summon_creature_id, disc-gated summon_creature_map_real). The map is now extended through the evolved-Seru block 0x8C..=0x95 by mesh identity, not name: each summon.dat group's actor-record Legaia TMD is byte-identical (8–17 KB) to its battle_data creature mesh, recovering all of 0x81..=0x95 - including the two evolved legs no capture state covered, Kemaro 0x90→144 and Spoon 0x91→147 (also Gola Gola 0x8c→98, Mushura 0x8d→101, Aluru 0x8e→80, Barra 0x8f→141, Slippery 0x92→150, Iota 0x93→153, Puera 0x94→156, Gilium 0x95→159). Map legaia_asset::summon_creatures, byte-validated by disc-gated summon_creature_tmd_map_real; the engine resolves + spawns evolved summons through it. The high block 0x99..=0xA0 (Juggernaut / Palma / Mule / Horn / Jedo / Meta / Terra / Ozma) does not byte-match any archive record - those summons carry a bespoke mesh in the group's raw part-pool slot, not a reused enemy body. This supersedes the move-VM SummonScene model for the visual: the faithful render is the battle creature drawn through battle_render_mesh + MonsterAnimPlayer + posed_rot (PROT 867), not the stager scene-graph. (PROT 905 remains the magnitude/effect stager.) project_effect_pool_draw_bridge.md
Per-spell magic power / multiplier roll ported Mechanism RESOLVED by static dump. The jump-table reads (jr *(0x801F69D8 + state*4)) resolve to PROT 0900 file offset 0 - the render overlay. Those five entries are staggered entry points into one per-frame routine that lerps move-VM anim banks and emits GP0 display-list packets into scratchpad 0x1F800314: zero mult/div, zero actor+0x14c write, no power read → the “magnitude is in this jump table” hypothesis is falsified (it is animation/GPU only). The magnitude is applied by the paired stager overlay (PROT 0903..0915, the file with the jal FUN_80021B04 part-spawns), in the same function that spawns the body parts - each has exactly one actor+0x14c writer: damage summons (0904/0912/0914 + 0915’s 2nd arm; subu) call the shared battle kernel FUN_801dd0ac (a0 = a per-summon move-type const 0x10..0x12, a1=7, a2 = target), clamp to current HP, then HP -= amount; heal summons (0903/0905/0910/0911/0913 + 0915’s 1st arm; addu) apply (power_byte<<5)+0xe0 inline (power_byte from a 0x80084140-based table searched by the cast spell-id actor+0x1df: ids at +0x705, powers at +0x729). FUN_801dd0ac’s summon path (attacker_slot == 7): roll = rand % (INT@+0x168 + 1) + HP@+0x14c + DAT_801C9370[ctx+0x13]_INT * 2, returns roll − defender_mitigation - so summon “power” is caster/summon battle-state-derived, not a static per-spell scalar (hence the zero SCUS +5..+8 and no gamedata power column). Its non-summon branch reads a real 26-byte-stride per-move power table at 0x801F4F5C (the arts/physical path, not magic) - now located + parsed + labeled off the disc (legaia_asset::move_power, static battle-overlay data in PROT 0898 file 0x26744). param_1 is a mapped index: a 128-byte id→index map at 0x801F4E63 gives param_1 = map[actor[+0x1df]] (setup FUN_801DEA50 caches the record at ctx+0x1014; the per-frame tick FUN_801E09F8 reads the residual fields off the held pointer). The full 26-byte record is now decoded (see move-power): +0 power, +0x02 strike-Y offset, +0x04/+0x06 move/phase counters, +0x08/+0x09 homing speed + tracking flag, +0x0a impact-effect selector, +0x0b trail texpage, +0x0d sound cue, +0x0e list-mode flag, +0x12/+0x16 on-contact / launch effect-id lists; +0x0c is an unused C/E/G designer tag with no runtime reader. Joining the move-id space to the spell table labels every record: ids 0x25..0x74 = the named monster special-attacks (Tail Fire 0x27, …), ids 0x04..0x1f = the unnamed internal enemy-attack tiers (disc-gated move_power_real pins the boundary, no Sony strings). The scale stage FUN_801dd864 (8×8 element-affinity matrix 0x801F53E8 + status bits + the summon magic-power tail) and the finisher FUN_801ddb30 (resistance bits, rand%9+8 floor, 9999 cap, spirit gauge, MP drain, stat debuffs) are now fully traced. PORTED: the closed-form roll + scale arithmetic is now pure kernels in legaia_engine_vm::battle_formulas (summon_attacker_roll / summon_defender_roll / summon_predamage / the apply_* helpers / heal_summon_amount), hand-tested against the disassembly. Residual: the arts/physical kernel is now wired into the live loop for monster special-attacks - the move-power table loads onto World::move_power (PROT 0898) and cast_spell_on_slots overrides a damaging monster cast's magnitude via World::enemy_move_predamagearts_physical_predamage seeded by the move's +0 power (INT from battle_accuracy, defense from battle_defense_split, five rand() in retail order; gated on the table being installed so disc-free battles keep the placeholder + RNG stream). The element-affinity scale is now engine-side too: the 8×8 matrix 0x801F53E8 + per-character element table 0x801F5480 parse off the disc (legaia_asset::element_affinity), the enemy element source is pinned from the FUN_801dd864 disasm - read record-direct (lbu …,0x1d(record) where record = 0x801C9348[slot-3], the per-enemy record-pointer table, not a copied live-actor field), so it is MonsterRecord::element (+0x1D) consumed exactly as parsed (the same record the spoils path reads +0x44/+0x46/+0x48 from); the curated-element correlation now only corroborates the id labelling, and a monster special attack now scales by matrix[enemy_element][party_member_element] (applied inside the roll, before the conditional bonus-arm threshold - matching retail's scale→bonus order, so a non-neutral value can shift the lazy bonus draw). The player→enemy direction is now wired too: a player Seru-magic cast scales by matrix[summon-creature element][target element] (World::cast_affinity_pct in cast_spell_on_slots) - the attacker element resolved off the summon creature (its namesake battle_data record, the engine-side slot-7 +0x1d), the defender by slot; this multiply is post-roll on the deterministic cast output. Both are gated so an uninstalled / neutral table reproduces the no-affinity baseline bit-identically (magnitude + RNG stream), keeping disc-free battles deterministic. The record's auxiliary tables are now parsed too: the +0x12/+0x16 effect-id lists' 0x801F6324 prototype-pointer + 0x801F6418 SFX tables (EffectAuxTables) and the +0x0a 0x801F53D4 config words (parse_impact_effect_table - packed u32 config words, correcting an earlier “pointer table” mislabel). The 0x801F4F5C table is special-attack-only: its id→index map covers 44 ids (internal tiers 0x04..07/0x12..1F + named attacks 0x25..74); the basic-attack / art bands 0x08..11 and 0x16..18 are unmapped - pinned by a live capture (a party member's Tactical Art carries an unmapped id, e.g. Vahn's Somersault 0x0F, so it would roll against the zero-power record 0). So a party member's arts do not use this table; they take their damage from the per-strike art-record power byte (the engine's art_strike.rs already does this faithfully), leaving only apply_basic_attack's flat art_strike_damage_default (no-art generic hit) as a stand-in. Still open: the player-driven summon roll (needs the slot-7 summon-body battle-actor context). The FUN_801ddb30 finisher's closed-form finalisation arithmetic is now ported (battle_formulas::damage_finish - equipment elemental-resistance halving / guard halve / rand%9+8 floor / summon power-% scale / 9999 cap - plus spirit_gauge_fill, both unit-tested); only its state-mutating tail (damage-popup accumulator, AI revenge table, MP drain, per-element stat-debuff switch) stays in the live battle context. Dumps: overlay_battle_action_801dd0ac.txt / _801dd864.txt / _801ddb30.txt. project_spell_table_pinned.md
Arts command sequence - independent source resolved The SCUS arts-name table (DAT_80075EC4) glyph string is byte-exact ground truth for every art's directional command; legaia_art::ArtsOracle exposes it, and disc-gated contract tests validate both the best-effort PROT 0x05C4 parse_record command-decode and the curated gamedata directions/ap columns against it (one documented walkthrough error: Hyper Elbow). project_arts_name_table_pinned.md
Stat growth-rate source resolved (port pending) The per-character per-level stat-grant source is static SCUS_942.54 tables read by the level-up applier FUN_801E9504: per-stat 98-entry curves at DAT_800769CC (stride 0x62, indexed by level) + a per-stat parameter block at DAT_80076918. The “Seru struct +0x74” reading stays falsified (a 0x80808080 battle-state flag). Earlier searches scanned the magic_level_up display overlay, not the victory-path applier. Remaining: an engine port extracting the tables at runtime. See level-up. project_shop_ui_and_levelup.md
Monster stat-record archive source resolved The monster archive is PROT entry 0867_battle_data (extended footprint; the 15.9 MB archive lives in the entry's trailing-gap sectors). FUN_800542C8 streams per-monster 0x14000 LZS slots at (id-1)*0x14000, each [u32 dec_size][LZS] decoding to a block whose head is the FUN_80054CB0 stat record (name @0x00, HP @0x0C, MP @0x10, stat u16s @0x0E/0x12/0x14/0x16/0x18/0x1A, magic count @0x4A, spell-ptr array @0x4C). Pinned by a live-battle PCSX-Redux watchpoint (autorun_monster_record_source.lua) - relative seek (id-1)*40 sectors + disc_read CdlLOC → PROT.DAT 0x38AF000 = entry 867; three records match live actor stats byte-for-byte. The monster_data label (PROT 869) is a stub. Parser legaia_asset::monster_archive; bridge catalog_from_monster_archive wired into enter_field_scene. The record is now fully decoded: all six stats are named (ATK/DEF↑/DEF↓/INT/SPD/AGL), rewards are inline at +0x44..0x49, and +0x04 is the monster's battle-model TMD offset (not XP/drop - see the mesh thread below). project_monster_stat_archive.md
Monster mesh + texture pool resolved The monster's 3D battle model is a Legaia TMD embedded in each PROT 867 archive block at the offset in stat record +0x04 (installed at battle-actor +0x230; the 0x1C-stride records FUN_80049858/FUN_800495C8 walk are its object table). 186/194 slots parse cleanly. The texture/CLUT pool at record +0x08 is decoded from the battle loader FUN_80055468: a 0x1E0-byte region of fifteen 16-colour CLUTs followed by a 4bpp page (always 256 rows tall, 128 or 256 texels wide; palette = cba & 0x3F). Byte-exact vs pool sizes; renders to recognizable atlases. The on-disc CBA/TSB are nominal defaults the loader relocates per slot, so the raw pool does not appear verbatim in a battle VRAM dump - the loader layout is the ground truth. Parser legaia_asset::monster_archive::{mesh, MonsterMesh::texture}; CLI --obj + --texture-png; WASM accessors drive the enemy-table page's per-row WebGL viewer (textured + directional-lit). project_monster_mesh_source.md
Terra slot-3 / story-flag overlap resolved The header-size constant drifted: RETAIL_CHAR_RECORD_HEADER_SIZE was 0x66F (the name field) but the true record base is game+0x3C8 (live RAM 0x80084708), with the display name at internal offset +0x2A7. Confirmed across six in-game RAM captures: mid-game stats at record+0x104/+0x11C read back the expected per-character HP/MP for all four slots. The four-slot array runs into the global region, so slot 3 (Terra)'s tail (record offset ≥ +0x2BC = game+0x12C0) aliases the story-flag bitmap and inventory; Terra's meaningful fields (name, live stats, RecordStats) sit before that boundary. There is no special case - Terra is the New Game template's fourth roster entry (HP 400) but never a savable battle-party member, so the tail aliasing is benign. The constant is now 0x3C8, legaia_save::CharacterRecord gains a name()/set_name() accessor at NAME_OFFSET (+0x2A7), and the off-by-0x2A7 that made Party::from_retail_sc_block read stats from the wrong fields on a populated save is fixed. project_char_record_name_offset.md
Navmesh / per-scene navigation data falsified 0x80108EA4..0x80109550 is per-scene GPU primitive scratch, not a 24-byte stride navmesh. Pointer hunts find zero RAM cells pointing into the window. Real per-scene region / collision / event-trigger data lives in field-pack schema slots; the encounter-record path lives at actor[+0x94]. project_navmesh_negative_finding.md
Scripted Tetsu encounter → Battle (v0.1 oracle Battle leg) mostly The v0.1 oracle now reaches Battle from a NEW GAME cold boot: BootSession::begin_new_game seeds the opening party (Vahn, 180 HP) - the Tetsu fight is the game's first battle, so the new-game state is retail's pre-fight story state (there is no earlier save to seed from, and you cannot save before the tutorial fight) - the cold boot installs town01's sparring carrier from its MAN, and the field-VM dialogue-accept engages it (v0_1_playthrough.rs::v0_1_battle_leg_reaches_battle_from_new_game, converging with the cataloged retail Field/Battle anchors). The earlier framing below assumed a save-seed was required; it is not, for the opening fight. The install seam exists: the field VM's bare arm-encounter op (0x37/0x41) forwards the record window overlaying the opcode to the host via FieldHost::install_scripted_encounter when is_scripted_encounter_armed is true; World::arm_scripted_encounter + install_scripted_encounter parse it, register the formation, and arm the next field-step roll (fire-once: a successful install disarms), emitting FieldEvent::ScriptedEncounter. The formation is now pinned: the training fight is a lone monster, archive id 0x4F ("Tetsu"), built clean-room by EncounterRecord::rim_elm_training() and verified reachable end-to-end (a disc-gated test cold-boots town01, installs it via the arm API, and reaches Battle with id 0x4F). The arm is also confirmed dialogue-driven, not scene-entry-driven: the global formation cell 0x8007BD0C is empty in the pre-battle field capture and only carries 4F from battle-load onward. The install/launch mechanism is now pinned (corpus RAM + FUN_801DA51C decomp): the carrier reaches SM state 1, which copies its entity[+0x94] formation into the cell and - in the same tick, via the case 2/3 fall-through - writes _DAT_8007B83C = 8 (the battle game-mode handoff). The carrier is a dedicated field entity, not the player context (_DAT_8007C364 = corpus-stable 0x80083794, which carries no clean +0x8A/+0x94 SM); the entity-update loop reaches it via per-entity function-pointer dispatch (no direct jal), so it is one of the scene's MAN-placed entities. Three threads remain: (1) the carrier identity + scripted state-0→1 advance - which MAN-placed town01 entity it is and what bytecode advances its FUN_801DA51C SM (no mid-armed +0x94 capture exists: the pre-battle save has the cell clear and battle ctx unset, every battle save has the cell 4F but +0x94 already cleared); (2) seeding mc7's RAM (story flags + field record) into the clean-room World so a cold boot reaches the dialogue at all; (3) the engine interaction gap - town01's real MAN scene-entry script does load (it is a SceneAssetTable bundle, PROT entry 4; its MAN entry script is at MAN offset 3075, pc0 = 11, and enter_field_scene runs it, overriding the record-0 fallback - the earlier "standalone scene / unpinned _DAT_8007B898 / halting record 0" framing was wrong), but the Tetsu arm is not a script-borne inline literal. An opcode-aware walk of town01's MAN partition-1 field-VM scripts (man_field_scripts::walk_partition1_scripts, driven by the field_disasm linear walker) lands on every 0x37/0x41 yield byte and decodes the trailing [count][ids] window at each: across 53 records and 71 yield sites, zero carry the [1][0x4F] Tetsu signature - every decode is a count=0 artifact from the walker stepping into embedded MES dialog text (the earlier P1[32] 0x41 "candidate" is the ASCII of a dialogue line, not an arm). This corroborates the indexed formation-table path from a second, independent region (the earlier byte-scan of the event-scripts prescript at PROT entry 3 reached the same negative): the lone-0x4F formation is town01 MAN formation index 4, installed by the carrier pointing actor[+0x94] at that row, not by an inline operand. So auto-arming the Battle leg does not need a script arm op. The field-resident carrier SM tick now exists: World ticks the ported FUN_801DA51C SM in SceneMode::Field (tick_field_carriers), install_field_carriers places a scripted-encounter carrier, and the engage is now driven by the field-VM dialogue-accept rather than a manual API: a field-interact op (0x3E, op0 < 100) on the carrier's placement arms the engage (World::field_carrier_slotspending_carrier_engage) and accepting its prompt (the 0x4C n5 sub-4 dialog dismiss) engages it Idle → Activating, so the next tick runs the state-1 formation copy + case 2/3 battle handoff, resolving MAN formation index 4 and flipping Field → Battle. training_battle.rs drives this end-to-end on disc data, reaching Battle with Tetsu without engage_field_carrier (alongside the direct-API and by-index paths). The residual gap is the trigger wiring, not the mechanism: (a) the carrier placement is now pinned to a specific MAN actor - town01 partition-1's single placement at tile (76, 65) / model 0x6A, the sparring partner, identified by its long multi-page inline dialog block (RIM_ELM_SPARRING_CARRIER_TILE/_MODEL, rim_elm_sparring_carrier.rs); the field-carrier config derives from that MAN actor (man_field_scripts::derive_field_carriers + World::install_field_carriers_from_man); (b) the field-VM dialogue-accept now drives the engage (above) and the v0.1 oracle reaches Battle from a new-game cold boot. The interaction probe is now ported faithfully: World::tick_field_interaction_probe (clean-room FUN_801cf9f4) runs retail's DAT_801f2254 facing probe - a radius-64 compass point ahead of the player's facing, box-tested at ±72 against the talkable NPCs' placement positions (World::field_npc_positions) - and on the action button talks to the matched NPC and turns the player toward it, so facing the sparring partner and pressing X starts the fight with no script injection. This relies on the runtime actor frame == MAN placement frame finding: FUN_8003A1E4 spawns at tile*128 + 0x40 via FUN_80024C88 with no anchor, and the player cold-spawn 0xA40 is tile 20*128 + 0x40 in that same frame (the apparent mismatch in the mc6 capture was a patrolling NPC that had walked from its spawn tile). Auto-navigation now closes the emergent path: World::nav_step_toward drives the player along a BFS route over the real collision grid, so the v0.1 oracle's emergent Battle leg walks the player from the cold-boot spawn to the partner, talks via the probe, and accepts → Battle, with no teleport. Carrier-reposition finding: the carrier's MAN placement tile (76,65) is its post-tutorial village spot, in a town01 sub-area NOT walk-reachable from spawn (town01's MAN spans several door-connected sub-areas); the opening repositions the partner next to Vahn for the tutorial (RIM_ELM_SPARRING_CARRIER_TUTORIAL_POS ≈ tile (21,14), a reachable hop, pinned from the capture whose actor[+0x90] resolves to the (76,65)/0x6A record - same carrier). The cold boot skips that reposition, so the emergent test places the carrier at its tutorial position first. What remains: deriving that opening reposition from the opening sequence itself (vs the pinned constant); and the dialogue box's Yes/No selection logic, still undecoded (the engine treats accept as dismiss - faithful for the forced tutorial, which has no decline path). Disc-gated regression: town01_p1_arm_sites.rs, training_battle.rs, v0_1_playthrough.rs; survey CLI: legaia-engine man-scripts. project_v0_1_oracle_phase1.md

Field / locomotion

ThreadStatusWhat would close itMemory
Town/field free-movement locomotion resolved The player free-movement controller is FUN_801d01b0 (field overlay 0897), pinned by a runtime write-watchpoint on *(0x8007c364) + 0x14/0x18 (autorun_player_pos_watch.lua). It camera-remaps the held pad (func_0x800467e8 + FUN_80046494 → direction bits & 0xf000), computes a per-frame speed (base_step * player[+0x72] >> 12 * DAT_1f800393, with terrain-slow + diagonal modifiers), then steps the player position 2 units at a time with per-axis collision via FUN_801cfe4c. Sets facing player[+0x26]. Full write-up in field locomotion. The 801db81c..801dbf9c cluster previously suspected here is the field camera system, not movement. project_field_locomotion_integrator.md
Field collision-map source resolved The collision grid at *(_DAT_1f8003ec) + 0x4000 (1 byte/128-unit tile, high nibble = 4 sub-cell wall bits) is painted by the field-VM 0x4C opcode, outer-nibble 7 (op00x70..0x7F, handler 0x801e1c64): a rectangular wall-paint with inline operands [4C, 0x7s, col0, row0, col1, row1, mask], sub-op = clear-walkable / block-all / clear-mask / set-mask. So collision walls are authored in the scene event script, not a separate disc blob - same inline-operand pattern as encounters / tile-board. The +0x4000 byte's low nibble is a floor-elevation tier - a 4-bit index into a 16-entry short height LUT at scratchpad 0x1f80035c, filled at scene entry by FUN_8003aeb0 from the MAN header and consumed by the object spawn iterator FUN_8003a55c to offset each placed object's Y. The +0x8000 region is not a terrain-flag grid (corrected) - it is a per-tile u16 object/attribute map (low 9 bits = object-record index; bit 0x400 = footprint flag). See field locomotion. Residual: the +0x4000 zero-init site (likely a wholesale memset by the scene-boot allocator). project_field_locomotion_integrator.md
Field .MAP PROT resolution - define − 2, universal resolved A scene's field .MAP PROT entry is the one two slots before its CDNAME block start (define − 2), identified by its 0x12000 extended footprint - for every field scene, not just the kingdom walk views where the rule was first pinned. The scene PROT clusters overlap by two entries, so the per-entry extractor attributes each scene's .MAP to the previous block's tail; the first 0x12000 entry inside a block is the next scene's map. Pinned by a save-library census (field_grid_census example): the live keikoku field buffer matches PROT 0109 (define 111 − 2) with zero collision-grid diffs (in-block 0118 differs by 3855 bytes); koin3 matches 0559 exactly (in-block 0568: 531). The object-index grid (+0x8000, the Scene::field_object_placements source) is live-validated the same way: residuals of 0..96 bytes against the resolved entry across town01 / town0c / keikoku / koin3 sessions (story-conditional cell mutations), thousands against every other candidate (regression-guarded by field_map_object_grid_live). Consequences: Scene::field_map_index resolves define − 2 (it previously picked the in-block entry - the next scene's map - masked only on town01 where the Rim Elm variants byte-copy); the town0c “cold .MAP” question dissolves (town0c's .MAP = PROT 0019, byte-identical to town01's; PROT 0028 is izumi's). project_field_locomotion_integrator.md
Tile-board grid mode resolved (re-scoped) The _DAT_8007b450/DAT_801f35c0/801ef2b0 tile-grid walk is a puzzle / board minigame (procedural rand-filled board, per-cell drawn tiles), not town locomotion. Documented in tile-board. Open sub-questions: which minigames use it; whether any board is fixed (inline-script cells) vs. always procedural; the inline cell-array offset. project_tile_board_grid.md
game_mode 0x03 = field/town gameplay resolved (mapping); model open _DAT_8007B83C = 0x03 is the in-town / on-field gameplay mode. Pinned empirically by two independent retail captures (the v0_1_pre_battle_tetsu save and the runtime-pinned free-movement controller on map03), both at 0x03. engine_core::mode::GameMode::scene_mode() maps MainMode (3) → SceneMode::Field accordingly, and the mode_trace_e3 + v0_1_playthrough oracles drive the engine into the field. Handler map RECOVERED: the index → handler/param/name map is now read off the disc by legaia_asset::mode_table (asset mode-table; disc-gated mode_table_real), confirming the saves - field/town is modes 2/3 MAIN, and MAPDSIP (12/13) is the world-map display mode, not the field (corrected MapdispMode → SceneMode::WorldMap). 12 of the 14 per-frame modes share the generic handler 0x80025EEC; only Mode 13 (world-map) and Mode 23 (memory card) carry their own. Residual (code): the engine_core::mode model still runs MainMode via TitleHandler / labels index 3 "options menu"; reconciling the engine's ModeHandler wiring to this map is the remaining work. project_mode_table_structure.md
Engine VRAM byte-exactness for town01 resolved (major source); minor residue Single-snapshot byte-exact is physically unachievable: a save state's VRAM is a live snapshot and ~40% of the texpage band is dynamic/residual state (proven by diffing two town01 captures pre/post battle - they disagree on ~40% of the band). The pre-pass also only uploaded the render-targeted TIM subset (17/181). Fix: the parity pre-pass now does field-mode DMA-every-TIM (BuildOptions.upload_all_tims), lifting town01 oracle coverage 4%→38% with engine-only (wrong) texels dropping 11.5k→~250. vram_oracle_e1 was reframed to the static mask (words stable across same-scene captures), excluding the runtime-managed NPC/character CLUT band; town01 now passes byte-exact on every static pixel the engine uploads. Source resolved - etim.dat effect textures. The dominant missing block (texpage columns x=320..448 and most of x=832..896, y=256..512, ~27k texels) is the befect_data (PROT 0874) section-2 effect-texture TIMs (etim.dat): 4bpp PSX TIMs whose headers target fb(320,256)/fb(384,256) (64×256) etc., matched 256 rows byte-exact against a town01 field VRAM rect. This falsifies the earlier "those effect pages are battle-loaded, not static" reading - they are field-resident from field through battle. They evade asset clut-finder because the per-entry tim_scan mis-slices the overlapping befect_data cluster; the cluster-aware befect_cluster::scan_tims resolves all eight. The live engine already uploads them at field entry (scene::upload_effect_textures_into_vram); the gap was an oracle artifact - the lightweight pre-pass skipped that step. Fixed: the oracle pre-pass now applies the same upload image-pages-only (upload_clut = false; retail uploads the effect CLUTs, rows 473..478, at battle entry, so writing them in the field oracle is a wrong static upload), dropping the town01 static gap ~45k→~18k. Minor residue (open): x=896..1024, y=256 (~12k) is the character/party-texture region (fb_x≥864, the battle/character CLUT pass the field pre-pass excludes by design = CLUT-scattering thread), plus ~2.5k small framebuffer/UI residue in x=0..320. Per-scene mask premise refined (map01 false red resolved): two capture-pinned failure modes of “stable across same-scene captures = static” - (1) the befect_data band is global, history-dependent state (a few pixels boot with a variant that differs from the disc copy until a battle re-uploads the disc bytes; pinned at (853,271)), so the oracle now demands cross-scene staticity inside scene::effect_texture_image_rects; (2) the world-map walk view palette-cycles the kingdom terrain CLUT rows 506/508/509 in place, excluded via vram_oracle::WORLD_MAP_CLUT_CYCLE_ROWS (see the two threads below). project_town01_targeted_upload_fix.md
World-map CLUT cycling beyond the ocean head - rows 508/509 + generated row-506 tail writer located (script-driven CLUT-cell effects); cadence open The row-506 head (entries 0..15) is the documented 13-frame ocean CLUT animation (legaia_asset::ocean, engine-implemented); a capture holds an arbitrary phase, never the disc base CLUT. Capture evidence (map01 overworld vs field-menu states) shows the runtime rewrites more than that head: rows 508 and 509 each cycle a few entries in place (shoreline shimmer inside the mountain/forest terrain palettes), and row 508's entries 32..47 mirror the live frame of its own 0..15 head. Row 506's tail (entries ~40..47) additionally holds a runtime-generated palette - pure-channel BGR555 combos (B/G/BG/R/BR/GR at intensity 0x11) present in no disc bundle (all 7 kingdom-bundle slots of PROT 0085 + the PROT 0093 overview pack swept). The writer is located (from an existing capture's RAM alone): the cells are rewritten by the field overlay's script-driven CLUT-cell effects - FUN_801E4C58 (field-VM 0x4C n6 sub-0x61, one-shot 16×1 MoveImage cell copy / flat fill, coordinates = script operands) and FUN_801E4794 (multi-frame cross-fade SM) - sourcing 13-frame strips at VRAM rows 498/501..505; the lockstep coupling is sibling ops sharing the frame counter, not one wider rect. Remaining open: the exact retail cadence (the scratchpad frame-delta byte feeds the fade SM); play-window animates the row-506 ocean head only; map02/map03 unverified (no resident capture). -
befect_data boot-variant pixels - who uploads the 0xFFFF row? open A freshly booted game holds a variant of the befect_data effect-texture band whose row-271 pixels (fb_x 852 page) read 0xFFFF where the disc TIM (PROT 0874 §2) carries 0x3333; the first battle re-uploads the disc bytes and the disc value then persists (town01 pre- vs post-battle captures discriminate; town0c post-battle-lineage captures all hold the disc value). The disc TIM's row 273 holds the F-variant of the same row, so the boot-time source may be a sibling copy or a one-row blit. Open: which boot/new-game path uploads the F-variant (a different disc copy? an effect rendering into the page?). -
Field/town environment-geometry placement resolved (renders) The town's environment meshes (terrain + buildings + props) are Legaia TMDs packed inside LZS streams of the scene_asset_table PROT entry (town01 = entry 4: 114 meshes, ≈8041 verts), object-local (each centred near origin), so each needs a world transform. Placement source (FUN_8003a55c): the field map file's object-index grid at +0x8000 (0x80 x 0x80 u16, cell & 0x1FF = object id) selects a 0x20-byte record in the +0x0000 object-record table; placed tiles (record +0x12 bit 0x4) give world_x = col*128 + x_off + 0x40, world_z = row*128 - (z_off - 0x40), world_y = floorHeightLUT[nibble] + y_off. Mesh source (byte-verified): every object draws from the scene_asset_table pack - pack_index = obj_idx - 5 for the field-actor band 93..=118, else the record's +0x10 u16 (ids 1/2/3 are protagonist/NPC meshes from the shared pool). anim_id (from the MAN script via func_0x801d5630) only animates. Validated against a live town01 save: 46 placed objects, Vahn's house id 137(4864,_,3208) → mesh 36; windmill id 96 → mesh 91. Parser legaia_asset::field_objects; Scene::field_object_placements; disc-gated field_object_placement_disc.rs. Per-tile world Y = -floorHeightLUT[tile_nibble] + y_off (16 s16 at MAN header +0x02; Scene::field_floor_height_lut; Vahn's-house nibble 6, lut[6]=192 → Y -192). play-window now renders the town (resolve_field_placement_draws, 40/46 placements draw). This corrects the prior "NPC/event spawns, not building transforms, buildings are not actors" reading. Open (minor): the historical 8 of 46 non-drawing placements are pinned by cause (disc-gated town01_dropped_placements_split_untextured_vs_missing_clut) - correcting the prior "all 8 are untextured props" reading. They split across 3 distinct meshes: 2 placements (pack 31/obj 315, pack 109/obj 114) are untextured (per-vertex-RGB) props, and 6 placements (one mesh, pack 74/obj 347) are textured but dropped for MissingClut - the field VRAM pre-pass didn't upload their CLUT row. They need different fixes: (a) now done - the untextured props render through the engine's vertex-colour path (tmd_to_color_mesh + upload_color_mesh + Scene::color_draws; the 2 props are the +2 that lifts the count to 40/46, live-window confirmed); (b) still open - uploading the missing CLUT row (a VRAM-coverage question; a shading fallback would render it wrong, so it stays dropped). Known limit: a mixed textured+untextured mesh renders only its textured half (the colour path fires only when the textured build is empty). project_town_geometry_render_gap.md

Text / fonts / dialog

ThreadStatusWhat would close itMemory
Dialog font extraction done - kept for reference Earlier "blocked on runtime trace" framing was wrong; tile-page lives at VRAM (896, 0)..(960, 256), extracted by legaia-font::font-extract from any in-game save state. Listed here only so the older "open" framing doesn't get re-opened. project_dialog_font_hunt.md
Inline dialog-box format (0x1F-lead segments) resolved - prologue (decode + execution), pager dispatch, option-list inner format, and multi-segment box packing all pinned Placement-NPC / event dialogue text is inline in the field-VM interaction record, not the scene MES - the opcode-decoded text_id is a box-config id that never resolves through SceneMes::message_offset (0/13 town01 placement-NPC ids resolve). The text is a run of 0x1F-lead / 0x00-terminated segments of MES glyph bytecode. It is recovered structurally, not from the 0x3F op's len field: a text-heavy field interaction record desyncs under linear disassembly (a literal > is 0x3E, the warp/interact opcode; ASCII punctuation hits the 0x37/0x41 yield bytes), so the decoded 0x3F op and its len are unreliable on field scenes and the byte-len capture returned empty for every town01 NPC. man_field_scripts::first_inline_dialog_offset finds the first printable 0x1F segment (printable-ratio gated), classify_placement carries the record bytes from there as PlacementKind::Npc::dialog_inline, and OwnedDialogPanel::from_inline_dialog types the prompt segment; the native play-window renders the box. With this, 36 town01 placements recover renderable dialogue (the sparring partner, Meta the dog, villagers, leftover "dummy" dev placeholders, and the 0x1F-segment developer story-flag toggle menu at placement P1[1]). Segment-pool structure pinned: the segments are not "prompt + option labels" of one box; each record holds the NPC's entire dialogue line set - every line across every story-state branch, with "Yes"/"No" labels interspersed (e.g. the Village Elder decodes to 80 segments, Val to 59). So a 0x1F segment is one line; multi-page speech is multiple segments. The "box-geometry header" framing is falsified: the bytes between the placement's script_pc0 and the first 0x1F are normal field-VM bytecode - CFlag / SysFlag.Test / JmpRel / Nop / 0x4C 0x51 NPC-move-to-tile / 0x4C 0x52 menu-activation poll - that the field-VM dispatcher runs as the NPC's interaction prologue (face the player, set conversation flags, walk to the talk position, branch on story flags). The retail SM FUN_80039B7C state 0 calls FUN_801DE840 directly on this stream and transitions into the pager only when the dispatcher leaves the actor's PC on a byte where & 0x7F < 0x20 (a 0x1F lead or 0x21 terminator); the "select which segment to start at" mechanism is the prologue's own story-flag-gated SysFlag.Test branches - the script JmpRels past unwanted segments to the desired one. Pager-side dispatch now decoded: the box geometry is fixed at _DAT_801F2740 = 3 lines per box at both init arms (case 6 / case 9), and the post-page state 0x19 reads the next control byte past the box to pick the follow-on state - 0x25 → end, 0x24 → next-line same-box, 0x48 → new box, 0x4C 0xFF → terminate, 0x2A → resize, 0x27 → 2-option picker (state 0x130x12), 0x28 → 3-option picker (0x150x14), 0x29 → 4-option picker (0x170x16). High-bit raw bytes in the bytecode (0xA7 / 0xA8 / 0xA9). Each picker arm sets the box dimensions from a per-N table and clamps the choice cursor at *(DAT_801c6ea4 + 0xc); on confirm it reads the continuation byte at pbVar14[N*2 + 1] (same dispatch table) and advances. Captured in formats/mes.md § Dialog window pager. FUN_8001ebec is NOT the renderer - disassembly shows it's a per-character TMD-pose copier (party slots 0..2, indexed by the slot-4 freeze flag _DAT_8007B824); the real per-actor dialog SM is FUN_80039b7c, pager is FUN_801D84D0. Option-list inner format RESOLVED: the 2-byte per-option entries are not (label_index, post_action) and are not the labels - they are a signed i16 LE relative-jump table the inline-script handler FUN_80038050 applies on confirm (new_pc = (open + 1 + index*2) + i16_LE(entry[index])); the on-screen labels are standard 0x1F-lead glyph segments after the continuation byte. Parser legaia_mes::picker; disc-gated field_dialog_pickers_disc. Prologue now EXECUTES (opt-in path): the field-VM dialogue runner (World::use_vm_dialogue) runs the interaction prologue before the first segment, so its SysFlag.Test/JmpRel chain selects which segment the box opens at per story state; the engine stores the untruncated record alongside the truncated buffer (field_npc_dialog_prologue, started via InlineDialogue::with_prologue) and falls back to the first segment if the prologue can't reach one. engine_core::inline_dialogue / World::step_inline_dialogue drive the whole script through the real field VM so a chosen option's branch handler runs its flag-sets / scene-changes before the reply. Multi-segment box packing RESOLVED: the SM packs consecutive 0x1F lines into one window of _DAT_801F2740 = 3 rows (a line's 0x00 terminator immediately followed by another 0x1F = "same box, next row"), ending after at most three rows at the post-page control byte. FUN_80039B7C's state-0x2 advance masks (byte & 0xF0) == 0xC0 and consumes the escape's data byte, so a 0xC? escape whose argument lands in 0x00..=0x1E (e.g. 0xC1 0x00) doesn't terminate the line early. Decoded by legaia_mes::dialog_box (pack_box / pack_boxes, LINES_PER_BOX = 3, Dispatch for the terminating control byte); disc-gated field_dialog_boxpack_disc pins it on real town01 bytes (561 packed boxes all ≤ 3 lines; the Tetsu sparring opening packs as three 0x24-chained 3-row pages → a 4-option Picker; the Mist appeared, .., but line survives its 0xC1 0x00). The contiguous box run stops where the pool hands control back to the field VM (a non-pager byte → Dispatch::Unknown), which the faithful path runs as bytecode. Nothing further open on this thread. -

Audio

ThreadStatusWhat would close itMemory
SPU reverb live routing resolved + wired (Studio C, global) A pure-Rust read of the save-state corpus (no live probe) settled it. legaia_mednafen::PsxSpu reads the SPU register shadow - reverb_master_enabled (SPUCNT bit 7), reverb_registers (the 32 reverb registers at 0x1F801DC0..), and voice_reverb_mask (the per-voice EON enable; CLI mednafen-state spu). Across all captured states (field / town / battle / summon / title / minigames): reverb is master-enabled everywhere, the 32-register block is byte-identical and is the Studio C preset (dAPF1=0x00E3, work area 0x6FE0; engine_audio::ReverbMode::identify resolves it), and the EON mask routes most voices by default (BGM + SFX alike). So there is no per-cue reverb-enable source - reverb is a fixed global. Wired: the live mixer calls Spu::set_retail_reverb() (Studio C + every voice routed) at SPU init; the PCM oracle's retail-side reverb is also fixed (it had mis-read the EON mask as a mode byte and run Off). Falsifies the earlier “Spirit-Arts / echo cues opt in, everything else dry” reading. project_reverb_studio_c_global.md

Title / boot / overlays

ThreadStatusWhat would close itMemory
title.pak PROT entry resolved There is no single title.pak bundle entry - the dev-tree title.pak content is split across two PROT entries, both confirmed by the init.pak fingerprint method now that a title-phase RAM snapshot exists (title_screen_new_game save state): the title wordmark TIM is PROT 888/890 (sound_data2; already parsed by legaia_asset::title_pak, the big-logo RAM TIM at 0x80170DF8 fingerprint-matches it), and the options/config-menu bundle is PROT 899 (xxx_dat) - its indexed payload opens with the config-menu string pool ("Display Off / Gradual / Immediate / Field HP Display / Encounters / Vibration / Dual Shock / Voices / Battle Camera / Monaural / Stereo …") followed by the small config TIMs (CLUTs byte-matched at 899 offsets 0x169DC / 0x1F91C+), with the title-overlay code in the trailing unindexed gap after entry 899. (The 0895_bat_back_dat filename label is the +2 numbering shift, not dev mislabeling.) project_prot_0895_init_pak.md
Title screen mode-table PROT resolved (no such entry) The premise is wrong: there is no title-screen entry in the 28-entry mode table at 0x8007078C. Per boot.md the title overlay is loaded by a pre-mode-dispatch boot routine ahead of the mode table being consulted at all - its tick FUN_801DD35C lives in the unindexed 60-sector PROT.DAT gap between TOC entries 899 and 900. legaia_asset::title_pak reads the wordmark TIM out of PROT 888/890; PROT 899 carries the options-menu config bundle. NEW GAME is how control crosses from the title overlay into the mode table at mode 2. Row kept so the “title entry is unresolved” framing isn’t re-opened. project_mode_table_structure.md
Load-screen panel 9-slice geometry resolved (engine renders byte-perfect) Pinned in save-screen.md: retail composes the 81×29 panel at dst (6, 4) from 14 textured-sprite primitives (GP0 cmd 0x64) sampling the system-UI sheet with CLUT (32, 511). The exact per-tile rects are exported as legaia_asset::title_pak::OVERLAY_SYSTEM_UI_PANEL_* and emitted by legaia_engine_render::save_select_chrome_draws_for (covered by save_select_chrome_emits_9slice_panel_and_pills test). No interior fill sprite is drawn - the “marbled blue” look is the dimmed title art bleeding through the empty middle of the frame. project_load_screen_panel_source_pinned.md
Debug flags 0x8007B8C2 / 0x8007B98F partial - 0x8007B98F live-confirmed; consumer in uncaptured overlay Both addresses are in the SBSS/BSS region (zero-initialised at boot). The corpus sweep (2661 files) returns zero writes to _DAT_8007B8C2 and zero references (read or write) to _DAT_8007B98F. _DAT_8007B8C2 is confirmed read-only at runtime: FUN_8003E360's dual-mode loader routes through ISO9660 when the byte is zero (retail) and through PROT-index when non-zero (dev); same pattern at FUN_8001FA88 / FUN_8001FC00 (sound) and FUN_8001F7C0 (field-asset loader). _DAT_8007B98F is live-confirmed active: a RAM-poke probe (autorun_debug_bit_poke.lua) writing 0x8007B98F = 1 in a stable field scene brings up the debug menu on SELECT+Δ in the NA retail build - the consumer is overlay-resident and outside the 2661-file captured corpus. The earlier “stripped at link time / inert” conclusion is falsified. _DAT_8007B8C2 is fully resolved; _DAT_8007B98F remains partial (consumer not located). project_debug_flags.md
Key-item area consumers (0x800859E8–0x80085A40) open - read-BP scan pending The full-bag add helper (FUN_800421D4, id store at 0x800422BC) writes an item id byte to the key-item area (0x800859E8, then 0x800859EA, …) when the 72-slot consumable window is full. Reachability is live-confirmed via two independent caller paths: casino prize-exchange CROSS (id=0x9C) and equip-unequip via START menu (id=0xD0). The question is whether any native code subsequently reads those bytes and uses the value as an index or pointer without a bound check - which would turn the OOB write primitive into a usable chain. Probe: scripts/pcsx-redux/autorun_key_item_consumer_hunt.lua arms Read BPs on the first 24 bytes of the key-item area and logs every hit (pc, ra, mode); a heartbeat prints unique-PC summaries. Run pending on a koin1 or stable-field save state. See project_ace_oob_confirmed.md. project_ace_oob_confirmed.md
XP-table source + reader resolved + ported The retail XP curve is the static-SCUS per-level delta table DAT_80076AF4 (u16), read by the level-up applier FUN_801E9504 (overlay; called from the reward resolver FUN_8004E568 at 0x8004F34C): the running sum to the current level is scaled (sum × 9999999) / 0x140FE for level < 0x11 (else sum × 0x79) and compared ≤ record cumulative XP in a multi-level do…while loop. The earlier 0x8007123C / 0x80070A3C framing was doubly wrong (an off-by-0x800 confusion, then a sin-LUT slice). The engine now extracts the table at boot (legaia_asset::level_up_tables::xp_thresholds_from_scusBootSession), byte-validated L2 = 365 / L3 = 730 against a captured retail level-up; the retail_xp_table() slice is the disc-less fallback. See level-up. project_xp_split_static_negative.md
Opening-prologue tail (opdeene) partial The opdeenetown01 hand-off arm is now data-driven: enter_field_scene walks the cutscene-timeline partition (partition 2) for the real GFLAG_SET 26 write and arms only when present (World::arm_prologue_handoff_from_man; the write is partition-2 record-18 offset 0xA5E, also confirmed via man-scripts --gflag-partition). The intro cutscene's content is partly mapped: partition-2 record 18 is the closing timeline script, interleaving op 0x45 Camera Configure (23-byte payload), op 0x46 RenderCfg, op 0x23 MoveTo, op 0x34 Effect, the GFLAG_SET 26 arm, and inline narration text - 0x1F/0x00-framed ASCII pages introduced by a 0x4C op whose operand declares the page count (two blocks, 14 + 8 pages; parser legaia_asset::cutscene_text). The decoded subtitle pages now play in-engine: entering opdeene installs them on the world (World::open_cutscene_narration), the CutsceneNarration presenter auto-advances them on a per-page timer (skippable with confirm), the host renders the active page, and the narration gates the Rim Elm hand-off (take_prologue_handoff is suppressed until it finishes). Threads remaining: (1) the field-VM op that auto-opens the name-entry overlay during the town01 opening is now pinned: op 0x49 STATE_RESUME sub-op 3 at town01 partition-2 record 3 (P2[3]) body offset 0x02c6 (49 03 00). Pinned by executing P2[3] through the engine's authoritative field VM (crates/engine-core/tests/town01_opening_timeline_trace.rs: install the record as a CutsceneTimeline, linear-decode via legaia_engine_vm::field::step - the only complete decoder; the field_disasm linear decoder bails on unported 0x4C sub-ops and a naive disassembler mis-strides the variable-width 0x4C) and correlating against the name_input_ui save oracle: while name entry is open, _DAT_8007B450 (the op-0x49 state slot) holds 0x800EB297, and the record loads with body 0x02b0 at RAM 0x800EB280 (byte-identical), so 0x800EB297 = (the 0x49 op's RAM address) + 1 - the field script is parked exactly at this STATE_RESUME while name entry is up. The op's op49_invoke_setup hook (func_0x80020de0(0x8007065c, _DAT_8007c34c)) is the suspend-and-hand-off to the name-entry overlay. The sequence around it: 4A 03 00 WAIT → 45 13 7B … camera move → 49 03 00 STATE_RESUME (name entry)45 13 7B …4C CD script-alloc. (P2[3] carries two further STATE_RESUMEs deeper for later beats; the 0x02c6 one is the name-entry handoff, fixed by the save correlation.) Ruled out earlier: op 0x4C nibble-E sub-8 (4C E8) is camera-zoom; the active state also shows _DAT_8007BB88 = grid cursor + the name prompt in the dialog buffer at 0x800740EC. WIRED: the engine runs P2[3] in-engine on the new-game hand-off (World::install_town01_opening_timeline, gated on entering_town01_opening set by take_prologue_handoff). step_cutscene_timeline steps past the conditional-wait parks the engine doesn't model (0x4C nibble-C script_alloc/globals-gate, 0x2D/0x30 flag-tests - handshakes a spawned sub-context would satisfy) while keeping 0x4A WAIT (timed, plays out) and 0x49 parking; the establishing camera + Vahn walk-out play over ~490 frames, then op 0x49 opens name entry via the op-49 host hooks (op49_invoke_setupopen_name_entry(0); op49_state Armed while open, Done after commit) and the timeline freezes until the player names the lead, then resumes and drops (reverting the cutscene camera). The opdeene hand-off arming is scoped to that timeline (CutsceneTimeline::arms_prologue_handoff) so town01's opening never falsely arms a scene change. Disc-gated town01_opening_name_entry_wiring.rs + synthetic cutscene_timeline_synthetic.rs. (2) opdeene's partition-2 cutscene timeline now executes in-engine as a second spawned field-VM context (cutscene_timeline::CutsceneTimeline): World::load_cutscene_timeline_from_man resolves the partition-2 named record ([name_len][SJIS name][3 story-flag gates]<script>, entry PC 0x10 for record 18; dispatched by FUN_8003BDE0, ext-target 0xF8 = the player/camera actor _DAT_8007C364) and World::step_cutscene_timeline runs it through the same field VM each frame, so the camera Configure (0x45) + actor MoveTo (0x23) ops emit camera/move FieldEvents and the closing GFLAG_SET 26 fires the hand-off by execution (static arm kept as fallback; a frame-cap safety net guarantees the prologue can't stall). The inline narration spans are NOP-filled in the timeline's bytecode copy (cutscene_text::NarrationBlock::byte_span) so the single shared VM walks past them while the CutsceneNarration presenter shows the text. The op-0x45 param→global map is now fully pinned (FUN_801DE084): params 6/7/8 = the camera focus stored as the negated GTE translation (-X, +Y, -Z; world focus (-param6, param7, -param8), confirmed by the follow-cam FUN_801DBE9C and the culling test FUN_80021DF4), param 1 = yaw, param 9 = the GTE H projection register (FOV/zoom). The native play-window renders the cutscene with window::cutscene_camera_mvp, framing that focus rotated by the yaw with the FOV from H. One approximation left: retail has no explicit eye-distance param (the camera is parametrized as focus + yaw + param 0 "rot/zoom" + H), so the engine uses a scene-sized orbit radius rather than the (still-undecoded) mode-keyed GTE rotation/translation build, and the shot snaps between Camera Configure beats rather than interpolating; param 0 + the _DAT_800840b8/bc/c0 shake/offset trio and the commit's snap-vs-interpolate timing are also still open. project_cold_boot_prologue.md

When to add a row

A thread belongs here when:

  1. There is something specific that would close it - a probe to run, a dump to read, a function to port. "Generally understand X better" is not closable; skip.
  2. The next step is non-obvious from the code or git log. If grep would surface it, no row needed.
  3. The detail lives elsewhere (a memory entry, a docs page, a Ghidra dump). The row is the pointer, not the analysis.

When the thread closes, rewrite the row to a falsified or done - kept for reference line if the path was instructive enough to warrant a "do not re-walk" marker; otherwise delete the row. Rotating the page is part of using it.

See also