Effect bundles Confirmed
The data behind Legaia's battle visual effects - the hit sparks, spell flames, and summon flourishes. An “effect” here is a small script that spawns child sprites (each a rectangle cut from a shared texture atlas) and animates them frame by frame. Two distinct formats share the “effect” name: an on-disc bundle (magic 0x02018B0C) found in exactly one PROT entry (an entry of PROT.DAT, the disc's single big archive), and the runtime 2-pack wrapper used by data\battle\efect.dat - the latter is what battle code actually consumes, and where most of this page lives.
On-disc effect bundle (magic 0x02018B0C)
A bulk scan over the PROT corpus finds this format in exactly one entry: 0000_init_data (engine bootstrap data).
Header (12 bytes)
+0 u32 LE = 0x02018B0C ; magic
+4 u32 LE = 0x0000001D ; HEADER_A - TMD slot count (1 master + 28 sub = 29)
+8 u32 LE = 0x0000001E ; HEADER_B - HEADER_A + 1 (sentinel/terminator)
Both header words are constant within the format. HEADER_A = 29 = the maximum TMD count the format reserves space for - 1 master TMD plus 0..28 sub-effect TMDs.
Offset table (112 bytes after the header)
28 strictly-ascending u32 LE values:
0x17F4, 0x1832, 0x198F, 0x1B9B, 0x1D75, 0x1EFD, 0x20BB, 0x224B,
0x2438, 0x260B, 0x26DD, 0x27C3, 0x2982, 0x2AA1, 0x2C44, 0x2D9F,
0x2F77, 0x30E6, 0x3300, 0x34BE, 0x36BE, 0x3805, 0x39B6, 0x3AB4,
0x3C4A, 0x3E23, 0x3F78, 0x404D
Slot sizes derive from offset[i+1] - offset[i]. The 28th slot's size depends on the post-table asset-region layout.
Asset region (after the table)
The asset region begins with a master Legaia TMD at assets_start. The single observed master carries 1 object, 382 verts, 760 normals, 760 primitives. The 28 schema offsets do not byte-align with sub-TMD file positions; they likely index into an abstract runtime buffer whose semantics live in a consumer that hasn't been reached.
API
use legaia_asset::effect_bundle;
if let Some(eb) = effect_bundle::detect(&buf) {
println!("magic @ 0x{:X}, asset region 0x{:X}..0x{:X}",
eb.magic_offset, eb.assets_start, eb.file_size);
for (i, slot) in eb.slots.iter().enumerate() {
let size = slot.size.map(|s| format!("{}", s)).unwrap_or("?".into());
println!(" slot[{}] off=0x{:X} size={}", i, slot.offset, size);
}
}
Implementation: crates/asset/src/effect_bundle.rs.
Runtime effect format - 2-pack wrapper
The format data\battle\efect.dat (PROT entry 873) actually uses at runtime: two offset-table “packs” behind a small header - pack0 holds sprite-frame animation batches, pack1 holds the effect scripts that spawn them. Cross-validated against a live battle save state - the post-init buffer at RAM _DAT_8007BD5C = 0x800E425C is byte-identical to PROT.DAT bytes at sector 0x9086.
Buffer layout
+0 u32 pack0_offset ← fixed up to absolute pointer on first init
+4 u32 pack1_offset ← fixed up to absolute pointer on first init
+8 [N × 8-byte sprite atlas entries]
... [pack0] u32 count, u32 entry_offsets[count] ← packs of frame-batched anim entries
... [pack1] u32 count, u32 entry_offsets[count] ← packs of effect scripts
Each pack0 entry is a frame-batch animation record:
+0 u8 frame_count ← number of 6-byte frames
+1 u8 flags
+2 [N × 6-byte frame records]
each frame:
+0 u8 sprite_atlas_index (indexes the inline 8-byte atlas at buffer+8)
+1..+5 (timing / dir bits)
Each pack1 entry is an effect-ID script:
+0 u8 child_count ← N - number of child sprites to spawn
+1 u8 flags (bit 0 = use random child distribution)
+2 u16 spread ← half-range modulo for random child position (signed 8.8 fixed)
+4 [N × 14-byte child sprite descriptors]
each descriptor (retail offsets from the per-frame walker):
+0x00 u16 sprite_id ← indexes pack0; copied to master slot on spawn
+0x02 i16 width ← half-width of random X distribution (8.8 fixed)
+0x04 u16 anim_flags ← animation / shading flags read by the per-frame walker
+0x06 i16 depth ← half-width of random Z distribution (8.8 fixed)
+0x08 u8[6] tail ← animation curves / sound-id / timing (per-frame walker only)
The retail random-distribution loop (FUN_801E0088 pass 1) reads only +0x02 (width) and +0x06 (depth) per child - those two govern where a child sprite spawns relative to the effect origin. anim_flags and tail are consumed later by the per-frame walker when advancing a live child slot's animation state.
A live 0873_befect_data sample carries 14 entries in pack0 and 33 entries in pack1. The pack0/pack1 offset tables hold absolute file offsets (not the word*4 offsets of asset::pack). Parser: legaia_engine_vm::effect_vm::EffectCatalog::from_efect_dat_bytes.
Inline sprite atlas entries (between buffer+8 and pack0) are 8 bytes each. The layout is pinned from the consumer (FUN_801E0088 pass 2, the sprite-emit block ~0x801E0840), which reads them byte-wise to build each child's GPU sprite primitive:
+0 u8 u ; source texel U within the texture page
+1 u8 v ; source texel V
+2 u8 w ; sprite width in texels
+3 u8 h ; sprite height
+4 u16 clut ; CLUT (CBA) id -> primitive CLUT field (POLY_FT4 word3 high)
+6 u8 tpage ; texture-page descriptor byte -> primitive tpage (word5 high)
+7 u8 ? ; unknown / reserved
Field order note: the emit at ~0x801E0980 copies the u16 at +4 into the primitive CLUT field and the byte at +6 into the tpage field - the reverse of an earlier reading. So the oft-cited 0x7680 is the CLUT, not the tpage: as a CBA it decodes to fb (0, 474), an effect-CLUT row (PROT 870 TIM0's CLUT), not page (0,0). The real tpage is the single byte at +6 (e.g. 0x25 = page (320,0), 4bpp).
The texel rectangle is (u, v)..(u+w-1, v+h-1). Effect sprites therefore sample the loaded effect-texture pages (PROT 870 / etim, fb_x≥320) with effect-band CLUTs - confirmed against a melee hit-spark battle capture (the live impact quads sample pages (320,0)/(448,0) with CLUT rows 473..480). The pixels live in VRAM, blitted at battle load; the atlas carries only the VRAM coordinates.
Battle effect cluster (befect_data)
efect.dat is one of four dev-named files in the retail befect_data CDNAME block (defines 872..875 → extraction entries 0870..0873 under the −2 numbering correction). The battle scene loader FUN_800520F0 pulls them as a sequential state machine (sub-state byte at gp+0xa59); each state loads one file through the dual-mode loader - retail opens the dev-path string (a trap stub on this build), so the load resolves through the raw TOC index (FUN_8003e8a8; raw = extraction + 2). The full case → index → entry map, read off the decomp and byte-checked per entry below:
| Loader state / case | Dev-path string | Raw index | Extraction entry | Content |
|---|---|---|---|---|
case 0x8 | h:\prot\battle\etim.dat | 0x368 | 0870 | Effect texture pages (3-TIM pack). |
case 0xb | h:\prot\battle\etmd.dat | 0x369 | 0871 | Effect 3D-model library (30-TMD asset::pack; registered via FUN_80026b4c). |
case 0xb | h:\prot\battle\vdf.dat | 0x36A | 0872 | VDF buffer (32-entry offset pack; asset type 0x07, appended via FUN_8001fbcc). |
case 0xc | data\battle\efect.dat | 0x36B | 0873 | The 2-pack above; initialised by FUN_801DE914 (offset fixup only). |
case 0x4 | - (battle-type-conditional) | 0x367 / 0x36D | 0869 / 0875 | Streaming files [type-0 VAB chunk][type-3 chunk]; 0x36D when DAT_8007bd11 == 4. Outside the befect block (retail sound_data / sound_data2). |
So the dev names map 1:1 onto the four retail befect_data slots. Earlier readings that applied the raw indices as extraction indices ("etim = entry 872", "etmd loads at 0x367", "PROT 870 = index 0x366, blitted by a separate unpinned site") are superseded - extraction 0870 is the case-0x8 etim.dat load. The extraction filename labels for 0870/0871 say sound_data (the +2 label shift), and extraction 0874 - which an earlier cluster analysis read as "etmd/vdf/etim LZS sections" - is the retail player_data file (player.lzs): its three LZS sections are the field character mesh pack / auxiliary models / field-character textures (see character-mesh).
Entry contents (byte-checked)
| Extraction entry | Dev name | Structure (verified against the extracted bytes) |
|---|---|---|
| 0870 | etim.dat | 16-byte pack header [u32 3][u32 word_offsets 0x4, 0x208C, 0x4114]; three 64×256 4bpp TIMs targeting VRAM (320,0) / (384,0) / (448,0), CLUTs (0,474) / (0,475) / (0,476). The first TIM's flags word is 0x00010008 (a bit-16 quirk; strict TIM parsers reject it). |
| 0871 | etmd.dat | asset::pack, word[0] = 30, 30 Legaia-TMD magics at the declared word offsets. The body spans the entry's extended footprint (the indexed view truncates it mid-table). |
| 0872 | vdf.dat | Offset pack, 32 strictly-ascending entries (~96 B records). |
| 0873 | efect.dat | The 2-pack: 144 atlas entries, pack0@0x488 (14 anim batches), pack1@0x900 (33 scripts). Byte-identical to the live post-init buffer at _DAT_8007BD5C (PROT.DAT sector 0x9086 = this entry's start). |
The per-entry PROT extractor over-reads here (neighbouring entries' extended footprints overlap), so naive .BIN files bleed into their neighbours. asset befect-cluster PROT.DAT --cdname CDNAME.TXT [--out DIR] footprint-bounds and classifies a window of entries; note it resolves the CDNAME symbol in define-number space, so its "befect" window is extraction 872..875 - retail vdf.dat, efect.dat, player.lzs (= player_data), and a sound_data2 VAB stream. Its LZS-section expansion of entry 874 is the player.lzs split, not the battle effect files.
Effect texels in VRAM - pixel-verified
FUN_800198e0 is the general packed-image → VRAM uploader the loader uses (loader state 9 walks a pack and calls it per entry): it reads a per-chunk tag/flag word, builds a PSX RECT, and calls FUN_800583c8 = LoadImage to DMA pixels into VRAM, maintaining a CLUT cache at 0x8007BEC0. (Same routine the title / menu / save overlays and the type-0x01 CLUT walker FUN_8001fe70 use.)
Two texture pools serve battle effects (an earlier reading conflated them under one "etim" label):
etim.dat= extraction 0870 (the 3-TIM pack above). Battle-only: byte-verified pixel-exact in VRAM against every stable Rim Elm battle capture (command-menu / submenu / pre- and post-Seru-capture frames match 100%; a still-loading frame matches partially - the mid-DMA snapshot). Its pages sit atfb_y=0in the same VRAM columns the field uses for town stage textures, so the town01 field captures hold unrelated texels there. The engine uploads it on battle entry (engine-core::scene::upload_flame_atlas_into_vram, into a throwaway VRAM copy that battle exit discards, so field VRAM is never clobbered).- The
player_data§2 band (extraction 0874 §2) - eight TIMs atfb_y=256+: the three field-character atlases at(832,256)/(852,256)/(872,256), two shared 256-colour pages at(320,256)/(384,256), two 16×64 extension tiles at(880,384)/(880,448), CLUTs in rows 473/475/478 (full table: character-mesh § textures, byte-exact vs a live field VRAM dump). These were previously mislabeled "etim" here. They are field-resident (uploaded at field entry, kept through battle), which is why mid-cast battle captures byte-match them: during a Gimard summon cast (thebattle_gimard_tail_fire_acapture), five of the blocks match VRAM at their rect-header targets, and the(320,256)/(384,256)pages match atown01field capture 256 rows byte-exact. The engine uploads them at scene entry (scene::upload_effect_textures_into_vram); the field VRAM-parity oracle applies the same upload image-pages-only (upload_clut = false). They are invisible to the per-entrytim_scan(the overlapping windows mis-slice them);befect_cluster::scan_timsresolves all eight with correctfb_x/fb_y/CLUT. - 2D sprite billboards. The
efect.datsprite atlas (entry 873) drives the per-frame billboard emit inFUN_801E0088pass 2, sampling the residentetimpages (the atlas+4/+6fields are CLUT/tpage - see the field-order note above). Confirmed from a melee hit-spark battle capture: the live GPU prim pool has no prim sampling page (0,0), 8bpp, ortpage 0x7680; the hit-spark draws as textured quads (POLY_FT4/POLY_GT4) on the extraction-0870etim.datpages(320,0)/(448,0)(effect-band CLUTs rows 473..480), pages present only in the impact frame.
Consumer cluster
The runtime consumer lives in the battle overlay (0898_xxx_dat):
| Function | Span | Role |
|---|---|---|
0x801DE914 |
0x138 | Init / pack-fixup. Called by FUN_800520F0 case 0xE with (id=0x1000, param=0xA00). Zeros the 5008-byte runtime pool at _DAT_8007BD30, treats _DAT_8007BD5C as the 2-pack wrapper, walks both packs converting offsets→pointers (fixup gated by byte[3] == 0). Stores post-fixup state in the table-head 16-byte record (u16 id, u16 param, u32 buf+8, u32 pack0_data+4, u32 pack1_data+4). Sets the init flag _DAT_8007BD58 = 1. |
0x801DFDF8 |
0x290 | Public spawn-effect API. Signature: (byte effect_id, short* world_pos, ushort angle). Reads pack1[effect_id] from the head record to find the effect's script. Allocates the first free slot in the 32-entry × 28-byte master pool, writes pos/angle, copies script header bytes, sets script cursor to entry + 4. Special-cases effect_id = 4 → 0x801F5D90 and effect_id = 0x13 → 0x801F5CF8. |
0x801E0088 |
0x970 | Per-frame walker (update + render). Two passes. Pass 1 (32 master slots): for each active slot, decrement state byte; if zero, fetch next 14-byte script instruction (byte 0 indexes pack0 → an anim batch; the rest provides angle/dir/lifetime). Spawns into the 128 child slots; applies sin/cos via lookup tables at _DAT_8007B7F8 and _DAT_8007B81C. Pass 2 (128 child slots): builds a PSX GPU sprite primitive (0x9000000 opcode + 0x2E000000 RGB), pulls UV/size/page from the inline sprite atlas via the child's anim cursor, submits via func_0x8003D2C4. |
Decompiled output: ghidra/scripts/funcs/overlay_battle_*.txt.
How a move reaches this 2D pool - the bit-7 multiplex
A battle move's effect-id lists (move-power record +0x12/+0x16) are the main producers of FUN_801DFDF8 spawns. Each list byte multiplexes two id spaces by bit 7 in the dispatch loop FUN_801e09f8: bit-7-clear (0x01..=0x63) is the 3D move-FX path (prototype 0x801F6324[id] via FUN_80050ED4 → FUN_80021B04, not this pool; 0x64 = screen-flash), while bit-7-set (0x80..=0xFE) reaches this 2D pool via FUN_801DFDF0(id & 0x7F) → pack1[id & 0x7F] (the two special-cased ids 0x04/0x13 apply here). So only the bit-7-set half of a move's list reaches this pool; the engine models the split at EffectListEntry (Spawn vs AltEffect).
Runtime pool layout (_DAT_8007BD30, 5008 bytes total)
+0x000 16 bytes table-head record set by init
+0x010 4096 bytes 128 × 32-byte child slots - per-sprite render state
+0x1010 896 bytes 32 × 28-byte master slots - per-effect-instance state
+0x1390 1968 bytes (unused / future expansion)
32 max simultaneous effects × ~4 sprites avg = 128-child sprite pool.
Side-band streaming-effect handler
0x801F17F8, called from FUN_800520F0 case 0xFF, streams two specific runtime-only files via FUN_800558FC:
data\battle\summon.dat- selected when_DAT_8007BD24[0x26B] & 0x80 != 0.data\battle\readef.dat- opposite branch.
Both entries are pinned and the format is decoded - see summon-readef. In retail FUN_800558FC ignores the path string (the ISO9660 open is a trap stub) and consumes its fourth argument as a retail TOC index directly: summon.dat = 0x37F, readef.DAT = 0x380. The retail index space includes the PROT.DAT 8-byte header in the in-RAM TOC copy, so those map to extraction entries 893 / 894 (retail index − 2) - exactly 103 / 78 slots of 0x10800 bytes. Byte-verified in the battle_gimard_tail_fire_a save state (stream buffer ↔ disc slot, slot-0 CLUT + texture page ↔ VRAM (0,488) / (512,0)). The slots carry per-special-attack CLUT rows + 4bpp texture pages and summon-creature actor records (TMD + texture pool installed via FUN_80055468); parser legaia_asset::summon_readef. The earlier reading that placed 0x37F/0x380 at extraction entries 895 / 896 (init pak / 0896 blob) failed because it compared against the extraction numbering - the two index spaces differ by 2.
Open questions
- Effect-ID → human effect name. Effect IDs are anonymous and there is no string table mapping id → "fireball / thunder / heal" - the ids are pure disc data. The call sites are traced (the move-power
+0x12/+0x16effect lists' bit-7-set entries route here viaFUN_801e09f8→FUN_801DFDF0; see the bit-7 multiplex above), so the only "name" available is an id → triggering-move join off the move-power table. - The low-band
readef.DATaux-slot consumer - see summon-readef.
Field-pack format (magic 0x01059B84)
A handful of PROT entries lead with magic 0x01059B84 followed by a 97-entry strict schema and a byte-identical shared template block; the per-scene payload is the preamble before it. See field-pack. Detector + dispatch live in crates/asset/src/field_pack.rs.