Effect VM (battle effect cluster)
The runtime that drives battle-spawn effects - spell casts, item-use animations, hit sparks. Implemented as a per-slot state machine rather than a clean bytecode dispatcher: there's no central switch on a per-slot opcode byte, and state transitions are inlined throughout 600+ instructions of the per-frame 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): 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 - the port itself is straightforward; the only question is whether to label the format an "opcode table" or a "state machine".
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 that, an effect terminated on its first work tick and never persisted 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 (the earlier shape) froze 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.
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 |
Format not yet decoded, and the PROT entry these dev paths map to is unpinned: the earlier "summon.dat = PROT 0x37F / readef.dat = PROT 0x380" reading is falsified (895/896 are the boot init pak and an unidentified code+data blob - the old “mode-24 overlay” reading of 896 is refuted; the 0879..=0890 band is all VABp sound banks). The real entry is resolved at runtime by the path builder feeding 0x801F17F8.
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).