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. 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, CDNAME 872)

efect.dat is one of four logical files in the befect_data cluster the battle scene loader FUN_800520F0 pulls in. FUN_800520F0 is 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, debug uses a PROT index (FUN_8003e8a8):

Loader state / caseDev-path stringRole
case 0x8h:\prot\battle\etim.datEffect TIM images (textures).
case 0xbh:\prot\battle\etmd.datEffect 3D models (Legaia TMDs; registered via FUN_80026b4c, which asserts magic 0x80000002).
case 0xbh:\prot\battle\vdf.datVDF buffer (asset type 0x07, appended via FUN_8001fbcc).
case 0xcdata\battle\efect.datThe 2-pack above; initialised by FUN_801DE914 (offset fixup only).

On-disc layout + the cluster-aware extractor

The per-entry PROT extractor does not cleanly separate these files: the four cluster entries (872..875) overlap on disc (each starts only a few sectors into the previous entry's extended footprint), so the naive per-entry .BIN files bleed into their neighbours - e.g. 0873_befect_data.BIN at offset 0x2000 is byte-identical to the start of 0874_befect_data.BIN. The true per-file size is the footprint (next_lba - this_lba), which the indexed/extended TOC formula over-reads here.

asset befect-cluster PROT.DAT --cdname CDNAME.TXT [--out DIR] (in legaia-asset) does the cluster-aware extraction: footprint-bound each entry, expand the one LZS-container entry into its sections, and classify every part. It resolves to:

PartFootprint / sectionClassificationNotes
entry 8720x4800offset pack, 32 entriesEffect billboard geometry (small per-entry records, ~96 B each).
entry 8730x2000efect.dat 2-pack144 atlas entries, 14 anim batches, 33 scripts (pack0@0x488, pack1@0x900).
entry 874 §0LZS, 46236 BTMD pack, 5 modelsLabelled etmd.datflagged for re-verification (see below).
entry 874 §1LZS, 16864 Boffset pack, 23 entriesLabelled vdf.datflagged for re-verification.
entry 874 §2LZS, 120100 B8 effect-texture TIMsLabelled etim.dat. 4bpp, CLUTs in high VRAM rows 473..478, pixels at fb_x≥320.
entry 8750x20000rawA 256×256-halfword page blob.

Texel source — etim, 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.)

There are two independent effect-texel systems here:

  1. 3D effect models. The etim texture set (entry 874 §2) holds 4bpp TIMs (pages at fb_x≥320, fb_y=256+, CLUTs in rows 473..478) whose CLUT rows are referenced exactly by the 3D effect-model primitives. This is confirmed pixel-exact against a live battle VRAM dump captured mid-cast (Gimard's Tail Fire, a 3D flame mesh): five of the seven etim TIM pixel blocks byte-match VRAM at their stated targets, and the CLUT rows match. The whole etim section is also field-resident: its two 64×256 pages at fb(320,256)/fb(384,256) match a town01 field VRAM capture 256 rows byte-exact (correcting an earlier "those pages are battle-loaded" reading). The engine uploads etim into the scene VRAM at scene entry (scene::upload_effect_textures_into_vram in engine-core), making those texels resident for effect-model rendering; the field VRAM-parity oracle applies the same upload image-pages-only (upload_clut = false), since retail keeps the effect pixels field-resident but uploads their CLUTs (rows 473..478) at battle entry. These TIMs evade the per-entry tim_scan/clut-finder (which mis-slices the overlapping befect_data cluster); the cluster-aware befect_cluster::scan_tims resolves all eight.
  2. 2D sprite billboards. The efect.dat sprite atlas (entry 873) drives the per-frame billboard emit in FUN_801E0088 pass 2. The "billboards sample page (0,0), 8bpp" reading was a field-order misread: the atlas entry's +4/+6 fields are CLUT (u16) / tpage (byte), not tpage/CLUT (the emit writes atlas[4..5] to the primitive CLUT field and atlas[6] to its tpage). So the oft-cited 0x7680 is the CLUT (CBA fb (0,474)), and the real tpage is the byte at +6 (e.g. 0x25 = page (320,0) 4bpp). Confirmed from a melee hit-spark battle capture: the live GPU prim pool has no prim sampling page (0,0), 8bpp, or tpage 0x7680; the hit-spark draws as textured quads (POLY_FT4/POLY_GT4) on the PROT 870 flame atlas at (320,0)/(448,0) (effect-band CLUTs rows 473..480), pages present only in the impact frame. The engine reads the atlas in the correct order now, so the billboards sample the resident PROT 870 / etim texels.

PROT 870 — the flame-texture atlas (load path + VRAM target pinned)

PROT 870 (CDNAME label sound_data, like its sibling PROT 871) is three back-to-back 64×256 4bpp PSX TIMs behind a 16-byte prefix, whose own headers target VRAM (320,0), (384,0), (448,0) with CLUTs in rows 474, 475, 476 (the effect-CLUT band). It is battle-loaded, 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 only partially, the mid-DMA snapshot). Unlike etim.dat (entry 874 §2, pages at fb_y=256), these pages sit at fb_y=0 in the same VRAM columns the field uses for town stage textures, so PROT 870 is a battle-only upload (the town01 field captures hold unrelated town texels there). It is not pulled by the FUN_800520F0 etmd/befect path (indices 0x367..0x36d); PROT 870 = index 0x366 is blitted by a separate, not-yet-pinned site. 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 the field VRAM is never clobbered.

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.

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 (PROT 0x37F) - selected when _DAT_8007BD24[0x26B] & 0x80 != 0.
  • data\battle\readef.dat (PROT 0x380) - opposite branch.

Buffer size per slot: 0x10800 = 67584 bytes. Format unverified; may share the 2-pack layout but not yet confirmed.

Open questions

  • Effect-ID → human effect name. Effect IDs are anonymous; no string table maps id → "fireball / thunder / heal". Reachable by tracing call sites of FUN_801DFDF8 in damage / battle-action code.
  • 2D billboard texel source — RESOLVED. The "page (0,0)" reading 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. The billboards sample the loaded effect pages (PROT 870 flame atlas (320,0)/(448,0)), confirmed against a melee hit-spark capture. Engine SpriteAtlasEntry fixed; see the "2D sprite billboards" item above.
  • 874 §0 / §1 labels. The standalone extractor's etmd/vdf reading of entry 874's first two LZS sections needs re-verification against the loader's case→index map (the real battle model library is PROT 871, not 874 §0).
  • summon.dat / readef.dat formats. Not yet decoded.

Field-pack format (magic 0x01059B84)

A small number of PROT entries lead with magic 0x01059B84 followed by a 97-entry strict schema preceding packed TIMs/TMDs. The preamble→slot mapping is unknown - likely runtime-reconstructed from the schema's offset hints. Detector + dispatch live in crates/asset/src/field_pack.rs.

See also