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):

FunctionSpanRole
0x801DE9140x138Init / pack-fixup. Called from FUN_800520F0 case 0xE with (id=0x1000, param=0xA00).
0x801DFDF80x290Public spawn-effect API: (byte effect_id, short* world_pos, ushort angle).
0x801E00880x970Per-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 coarse EffectMarker per 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. Mirrors FUN_801E0088 pass 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_800520F0 case 0xFF
Buffer size per slot
0x10800 = 67584 bytes

Streams two specific runtime-only files via FUN_800558FC:

FileTrigger
data\battle\summon.datSelected when _DAT_8007BD24[0x26B] & 0x80 != 0
data\battle\readef.datOpposite 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).

See also