Effect VM (battle effect cluster)
The system that plays battle effects - the fireball flying at an enemy, the flash of an item use, the spark when a punch lands. Each effect is a small on-disc script, and this is the
runtime that executes them. It lives in the battle overlay (0898_xxx_dat - an overlay is a chunk of code the game loads into RAM on demand; the battle overlay is resident only during
fights), and the per-frame walker is FUN_801E0088 (Ghidra's name for the traced function at that RAM address). It is the odd one out of Legaia's five
runtime VMs: the other four are bytecode dispatchers (read an instruction byte, jump to its handler), while this one is a per-slot state machine - there's no central switch on a
per-slot opcode byte, and state transitions are inlined throughout 600+ instructions of the walker.
How it works
When you cast a fireball in battle, three things happen visually: the spell-cast pose plays on the caster, a sequence of sprites animates between caster and target (the "fireball flying" frames), and a hit explosion plays on the target. Internally that's one effect script doing all three - one entry in efect.dat describing the whole thing as a sequence of frame batches.
The effect VM is what runs that script. It owns a fixed pool of "master slots" (32 of them, one per simultaneous effect) and "child slots" (128 of them, one per active sprite within an effect). Every frame, the per-frame walker iterates the master slots and pushes each one through its state machine: spawn the next batch of children, update positions, decrement timers, retire finished sprites, advance to the next state.
Unlike the field VM or the move VM, this isn't a clean bytecode dispatcher with a switch table. It's a state machine where the "next state" tokens live inside the on-disc effect script, and the walker reads them inline. Producing a clean opcode table from it would mean extracting the per-state transition logic by hand (~10–20 cases). The cleaner port path is to model the walker as a state-machine class and accept its decompile shape rather than insisting on an opcode table.
The on-disc input is the runtime 2-pack wrapper at PROT entry 873 (data\battle\efect.dat; PROT.DAT is the disc's single big archive, and 873 is one of its numbered entries): pack0 holds 14 sprite-anim records, pack1 holds 33 effect-ID scripts indexed by the public effect ID.
Where it lives
All three functions are in the battle overlay (0898_xxx_dat):
| Function | Span | Role |
|---|---|---|
0x801DE914 | 0x138 | Init / pack-fixup. Called from FUN_800520F0 case 0xE with (id=0x1000, param=0xA00). |
0x801DFDF8 | 0x290 | Public spawn-effect API: (byte effect_id, short* world_pos, ushort angle). |
0x801E0088 | 0x970 | Per-frame walker (update + render). 600+ instructions of inlined state transitions. |
How it dispatches
Each 28-byte master slot carries:
(state, counter, ?, sub_state, pos_x, pos_y, pos_z, data_ptr)
The walker reads *data_ptr as the next-state token, but state transitions are inlined throughout FUN_801E0088. To produce a clean-room opcode table you'd need to extract per-state-byte transition logic by hand. The 32-master / 128-child slot pool, the spawn API, and the per-frame walker are all well-understood.
Lifetime + render bridge (engine port)
The retail per-state token algebra (FUN_801E0088 pass 1) is inlined and not yet extracted, so the port's EffectHost::advance_state models the lifecycle as a fixed-frame countdown: each work tick increments master.field_14, and the slot retires once it reaches effect_vm::DEFAULT_EFFECT_LIFETIME_FRAMES. Without the countdown, an effect would terminate on its first work tick and never persist long enough to draw.
The walker splits into two host hooks because retail runs two passes at different cadences. advance_state is the state == 0 script work and is gated on the state byte. accumulate_child_motion is the per-child position integration (child+0xc/+0x10/+0x14 += velocity × accel × frame_delta) and runs every frame for every active slot regardless of state - FUN_801E0088 performs that accumulation in both its work loop and its wait-countdown branch, so a billboard keeps drifting during a wait. Pool::tick therefore calls accumulate_child_motion before the state gate; gating it behind advance_state freezes waiting effects.
Catalog load
The runtime effect catalog (PROT 0873 efect.dat) loads at scene entry via EffectCatalog::from_efect_dat_bytes (the 2-pack parser - see formats / effect), staying resident on World::effect_catalog across field/battle transitions. So the action SM's ui_element spawns (FUN_801D8DE8 → FUN_801DFDF8, ported as World::try_spawn_effect) resolve to real effect scripts. The catalog carries the pack1 effect scripts + per-child descriptors, the pack0 animation batches, and the inline sprite atlas.
Render snapshots
Two render-agnostic seams expose the live pool:
World::active_effect_markers- one coarseEffectMarkerper effect (origin + age). For hosts/tests that only need effect positions.World::active_effect_sprites- the faithful per-child billboard view (the textured-quad path). For each active effect it resolves the effect's children through the catalog, walks each child's pack0 animation to the current frame, and reads that frame's sprite-atlas entry for size + VRAM(u, v)/tpage/clut. MirrorsFUN_801E0088pass 2 (one GPU sprite primitive per child).
The native host (play-window) draws each EffectSprite two ways: a camera-facing textured quad through the VRAM-mesh pipeline (upload_vram_mesh, sampling the scene VRAM at the sprite's atlas page/clut/uv as a SceneDraw), plus a tinted outline through the UploadedLines pipeline so the billboard is visible regardless of VRAM contents, faded by age. World::spawn_debug_effect seats a synthetic effect by hand (the E key in play-window); it is not a retail path.
Effect texels - two pools
The texels effects sample come from two distinct pools, both pixel-verified:
etim.dat(extraction 0870) - three 64×256 4bpp TIMs targeting VRAM(320,0)/(384,0)/(448,0), CLUT rows 474..476. Battle-only: those columns hold town stage textures during a field scene, so the engine uploads it on battle entry (engine-core::scene::upload_flame_atlas_into_vram) into a throwaway VRAM copy that battle exit discards. Melee hit sparks draw as textured quads sampling this atlas.- The
player_data§2 band (extraction 0874 §2) - eight TIMs atfb_y = 256+whose pages are field-resident through battle. The Gimard summon flame samples this band (page(832, 256), CLUT row 478), not the 0870 atlas. The engine uploads it at scene entry (scene::upload_effect_textures_into_vram).
3D effect models + the summon render path
The effect-model library is extraction 0871 (etmd.dat, raw index 0x369): an uncompressed 30-entry TMD pack that retail registers into DAT_8007C018[3..=32] at battle init. The engine seeds the same window at scene entry (engine-core::scene::seed_effect_model_library_from_etmd → World::global_tmd_pool[3..=32]); Gimard's flame is index 26 (GIMARD_TAIL_FIRE_MODEL_INDEX). World::active_effect_models snapshots each live effect that has a model assigned (EffectModel = global-TMD-pool index + world position + age), and the native host builds a textured VRAM mesh for it through the standard mesh pipeline.
The player summon (e.g. Gimard's Burning Attack) is not spawned by this effect VM and is not in befect_data: a live PCSX-Redux trace of a mid-cast pins the render path as the ordinary battle per-actor draw FUN_80048A08 (35-64×/frame) → the per-object rigid-TRS keyframe decoder FUN_8004998C - the summon is posed exactly like an enemy monster body, and its flame animation is geometric, not palette. The per-summon stager overlays (extraction PROT 903..913, dispatched by the battle SM - see battle action · summon overlay) carry real move-VM part records (legaia_asset::summon_overlay), which the engine drives as a stand-in scene-graph (summon::SummonScene); the trace shows that graph is not the player summon's per-frame render path. Scope: the trace covers the player Burning Attack; the enemy Gimard Fire Tail boss move is untraced.
Superseded readings
- The
efect.datatlas value0x7680was read as a page-(0,0) 8bpp tpage - a field-order misread. The atlas entry's+4/+6fields are CLUT (u16) / tpage (byte);0x7680is the CLUT (CBA fb(0,474)). Confirmed from a melee hit-spark capture; the engine'sSpriteAtlasEntryreads the fields in the correct order. - “The flame flicker is per-frame CLUT cycling” - falsified: two animation-distinct frames have a byte-identical CLUT band (VRAM rows 470..499) while the framebuffer differs ~21%. The motion is geometric.
- “PROT 905 has zero
jal 0x80023070→ no move VM in the stagers” - a wrong-link-base artifact; the part records recover under the corrected base0x801F69D8, and thejallives in the SCUS stagerFUN_80021B04, not the overlay. FUN_801F7088(the world-map top-view tile renderer, aliasing the same0x801Fxxxxband) fired 0× in the summon trace - it is not part of the summon path.- “
summon.dat= PROT0x37Fis falsified” - itself superseded: the loader consumes a raw-TOC index, so0x37F/0x380resolve to extraction entries 893 / 894 (see the streaming section below).
Pool layout
One contiguous 5008-byte block at _DAT_8007BD30:
+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
- Function
0x801F17F8- Called from
FUN_800520F0case0xFF- Buffer size per slot
0x10800= 67584 bytes
Streams two specific runtime-only files via FUN_800558FC:
| File | Trigger |
|---|---|
data\battle\summon.dat | Selected when _DAT_8007BD24[0x26B] & 0x80 != 0 |
data\battle\readef.dat | Opposite branch |
Confirmed In retail, FUN_800558FC ignores the dev path string and consumes its fourth argument as a raw-TOC index: summon.dat = 0x37F, readef.DAT = 0x380, which are extraction entries 893 / 894 (the retail in-RAM TOC keeps the PROT.DAT 8-byte header, so raw index = extraction index + 2). Each file is an exact array of 0x10800-byte slots (103 / 78) carrying per-special-attack CLUT rows + 4bpp texture pages, summon-creature actor records (TMD + texture pool), and the player art-anim "ME" stream archives. Byte-verified RAM↔disc and VRAM↔disc in a mid-cast save state. Full format: summon-readef; parser legaia_asset::summon_readef.
Effect-ID → human effect name mapping
Effect IDs are anonymous; no string table maps id → "fireball / thunder / heal". To name effects, trace call sites of FUN_801DFDF8 in damage / battle-action code (in the town/level-up overlays). Each caller passes a literal byte for effect_id; correlate with the action that triggered it (a Tactical Arts move, an item use, a spell cast).