Effect VM - battle sprite effects
Every spark, puff and spell flash in a fight is a short on-disc script that spawns a burst of sprites, animates each one for a few frames and lets it fade. This is the runtime that plays those scripts. It is called a “VM” for symmetry with its siblings, but it has no opcode table at all: it is a pool of slots driven by countdown timers, and the only data it reads are spawn records and animation frames.
At a glance
- Where
- Battle overlay (PROT 0898). Spawn API
0x801DFDF0, per-frame walker0x801E0080(called once per battle frame by the draw tick), pool at_DAT_8007BD30, 5008 bytes - Input
efect.dat(extraction 0873) - a 2-pack of 14 sprite-animation records and 33 effect scripts; see effect format- Capacity
- 32 effects (master slots, 28 B each) × 128 sprites (child slots, 32 B each)
- Textures
- Effect atlas
etim.dat(0870, battle-only) and the field-resident character texture band (0874 §2) - In the port
engine-vm::effect_vm(Pool::tick_retail,Pool::child_billboards), driven byWorld::tick_effects- the engine's only per-frame effect path; the render-mode-4 emitters areengine-effects(effect_ribbon,effect_sprite_arm,effect_default_arm)- Confidence
- Confirmed - walker and spawn traced instruction-for-instruction
- Used by
- The animation effect arm, the per-action effect-script walk, the move-power effect lists and the per-move effect-list spawner (spawn sources)
Two addresses per entry
Both the spawn API and the walker begin two words ahead of their stack prologue, loading the pool-ready byte 0x8007BD58 first. Every jal on the disc names 0x801DFDF0 and 0x801E0080; dumps and pages citing 0x801DFDF8 / 0x801E0088 name the prologue word of the same routines.
How it works
Cast a spell and three things happen on screen: the caster poses, a stream of sprites flies to the target, and a hit flash plays. Internally that is one effect script: a list of spawn records, each saying “start sprite animation K at this offset, then wait N frames”. A master slot walks the record list; each record it reaches claims a child slot, which walks its own animation frames and drifts along a velocity until its last frame retires it.
What the slots read
The "state" bytes are wait counters. Every wait in the system is 5.3 fixed-point: a frame count stored << 3 and decremented by 8 per frame. The only data consumed are two record kinds.
Spawn record (effect script, 14 bytes each)
The script's header is [child_count][flags][spread:i16]. The two planar legs (offsets and velocities) are rotated into world space by the master's spawn angle, so one script aims wherever the caster faces. With flag bit 0 set, the spawn API rewrites every record's two offsets in place with a random value in ±spread - the resident efect.dat buffer mutates on every randomised spawn.
Animation frame (6 bytes each)
- Bursts. Zero-delay records spawn as one burst and zero-delay frames advance as one burst: both loops repeat while the freshly loaded wait is zero.
- Graceful exhaustion. On a full pool the record is still consumed with no child - effects degrade rather than stall.
- Delays wrap. The wait is stored in a byte after the
<< 3, so a delay of 32 frames or more wraps modulo 32. - Motion. Each step is
pos += vel × frame.speedwith the retail pool scale; each child also gets two random UV-mirror bits for variety. - Idle early-out. A sweep that finds no active master and no active child skips the remaining catch-up iterations.
- Retirement overrun. Retiring a child zeroes its active flag and its wait, but the frame-advance loop tests only the wait, so retail keeps reading 6-byte strides past the batch end on the already-dead slot until it meets a non-zero delay byte. Only the dead slot is touched (the next seed rewrites it), so the port stops at retirement instead.
- Summon side call. Spawn ids
4and0x13first call the move-VM part spawner0x80050ED4, then take the ordinary spawn path.
How a child is drawn
- Brightness envelope. With
n = frame_count >> 3, the colour ramps in over the first eighth of the animation and back out over the rest, clamped at0x80(neutral) and written asr = g = b. - Size. Atlas
w/h × sprite_scale >> 8(retail scale0xA00, ten times the texel size) is the half-extent, applied in view space after the centre is transformed - so the battle camera's 4× world scale never touches it. The scale is pool state, not per-effect: Viguro’s and Theeder’s Seru modules lower it for their casts (0x600,0x400,0x800on Theeder’s hits) and their closing arms restore0xA00, so the sparks those creatures shed draw smaller than a melee hit’s. The port applies the same stores to the pool head, and the battle teardown re-inits the pool as the battle load does. - UVs. From the 8-byte atlas entry, corner order swapped by the child's mirror bits; CLUT from atlas
+4, texture page from atlas+6. - Battle centres are Y-flipped. The pool integrates in raw PSX units, Y down. The battle view-projection both hosts share carries a trailing
scale(1, -1, 1)that cancels the per-model Y-flip every mesh draw carries; a billboard has no model matrix, so the port flips its centre before building corners (effect_billboard::battle_billboard_centre). Without it a spark rising past an actor's head draws as far below its feet. - Blend. The packet's prim code
0x2Eis a semi-transparent textured quad, so every effect child blends, and the page's own ABR bits pick how: pages(320,0)/(448,0)are additive (B + F), page(384,0)isB + 0.25F- why walk dust reads as almost nothing over a bright floor. Every effect the Rim Elm spar fires resolves to the(384,0)page under a dark warm-grey CLUT row, so it is faint by data, not by a bug; the bright additive pages belong to effect ids that fight never requests. The atlas stores its page in one byte, so the blend enable has nowhere to come from except the code byte; a port that copies the page alone draws the flames as opaque tan blobs.
Each host also carries a tinted billboard outline builder - a flat rectangle around each quad, faded by age. Retail draws no such rectangle; it is a diagnostic, off by default on both hosts (LEGAIA_DIAG_FX=1 on the native window, set_battle_fx_outline(true) on the play page).
The other effect primitives: render mode 4
The pool's quads are not the only way an effect actor draws. The per-actor render dispatcher's mode 4 picks one of three emitters off a selector halfword at actor+0x9E, and the move VM writes both the mode and the selector:
| Selector | Emitter | Set by move op | Where it appears |
|---|---|---|---|
| neither bit | Default emitter FUN_80028158 - the ring / disc / fan family | 0x13 | Most render-mode-4 nodes in captured battles; the bulk of the summons' procedural geometry |
0x4000 | Sprite arm FUN_8002A5A4 - one textured quad | 0x23 | Captured in battle states alongside the default |
0x2000 | Random-walk ribbon FUN_801CFA48 - lightning bolts, beams, whips | 0x42 | Only in slot-B cast / summon images (PROT 0923, 0934, 0957, 0964) |
All three arms carry shipped content. An earlier census concluded the default arm was the only reachable one; it swept for the stores in the wrong form and missed the move VM's own. Both hosts draw every live kind-4 node of the summon, move-FX, effect-script and field ambient parts through World::active_effect_kind4_draws, composed like a mesh part.
None of the three emitters draws anything itself. Each writes a small Legaia TMD object into a scratch buffer, and the render dispatcher then draws it as the actor's own model through the ordinary TMD path.
The ribbon
Per step the ribbon writes six vertices and six packets joining them to the next step: a core strip stored twice in the core colour, two inner flares fading core to flare colour, and two outer fringes fading flare to black. Every packet samples one fixed 2×2 texel patch, so the colour words carry the look. In play the ribbon is Gilium's summon; the other carriers are not staged as scenes by the engine.
The sprite arm
The sprite arm builds one textured quad and falls into the ordinary model draw: scaled by the part's render scale +0x72 / 0x1000, turned by the rotation banks, and handed to the prim dispatcher with the colour word +0x74 and level +0x78. The scale is seated by the spawn call, not the record. Nearly every seater passes 0x1000, and the engine seats a staged part at that value unless a cast module reports the call with its own scale - PROT 0911’s group-extent calls, PROT 0913’s depth-scaled ones and the stager arms that forward a parent’s scale (SummonScene::push_parts_scaled).
Its builder is main-executable code, so unlike the ribbon it draws in every mode; Baka Fighter's impact flash uses the same quad kernel.
A colour word with mode bit 0x20 sends a gouraud quad through the prim dispatcher’s third handler bank, which draws it as sixteen small triangles. Slippery’s storm clouds are such quads: huge sheets close to the eye, whose two halves the GPU would drop for their size where the sixteen pieces survive. Drawn whole, Deadly Rain’s sky read a third as bright as retail’s. The pieces are gouraud triangles with one colour on every corner, and the emulator dithers them like any gouraud draw: cut to five bits without the dither, eight stacked additive sheets lost a third of their light and broke into flat bands.
The default arm
The default builder takes a packed count / phase argument and a parameter block (inner / outer colours, a UV rectangle, page and CLUT, inner / outer radius and height, two in-plane scales). Besides move-VM op 0x13 it is called directly by the battle ground shadow, the field battle-intro ring and Baka Fighter's floor disc. Its mode word splits three ways:
| Bits | Selects |
|---|---|
mode & 3 | The plane the three builder axes land in; mode 1 lays a ring on the ground |
(mode >> 3) & 0xF | The shape: 0 a ring or open arc of count columns (a disc when the inner radius is zero), 1..3 multi-quad and fan variants, 4..7 jittered, shifted and pivoted rings |
mode >> 8 | The texture mode, read for shape 0 only: the ribbon's fixed patch, or the parameter block's rectangle mapped polar, as-is or turned; a page word carrying 0x4000 samples the display page itself |
The captured battle states use shapes 0..3 and none of 4..7. The port's effect_default_arm transliterates the builder onto a byte buffer and is held byte-for-byte to the scratch blocks retail captures left behind, including every ground-shadow build.
Draws that take the game’s random stream
Several effect builders draw rand() words while they build, from the game’s one random stream, so how many they take decides every later draw - an enemy’s pick, the next deal. The port meets each the same way: on the pass retail draws, it keeps the stream’s word as the part’s seed and steps the stream past its draws, and the draw replays the build from that seed.
- The jittered rings (default-arm shapes
4and5) draw three words a column every time the render pass reaches the node; a watch of the seed word through Ozma’s closing arms counts thousands an arm. - The streak ribbon
FUN_801E1D98, a separate routine from the move-VM ribbon above, is called by a cast module’s arm inside its own tick. It draws seven words for its bottom segment and four for each chained one - unless its top-edge test drops the ribbon. The test reads corners projected at a fixed half-width, so an anchor too near the eye (or behind it, where the divide saturates) draws nothing and leaves the stream alone. A frame’s count therefore climbs as the camera pulls back. The port projects each call through the battle frame’s own integer GTE view so the same calls drop. - A homing flight’s bolts are the same ribbon calls from the flight’s slot loop, in flight and once landed, dealt the same way. Drawn off a private per-frame seed instead, a bolt pass took a tenth of retail’s draws.
Battle effect parts morph through vdf.dat
The effect-script table form stages move-VM parts like any other scene graph, and a part with op 0x0A lanes draws morphed: the lanes index the battle VDF pack vdf.dat (PROT 0872), whose sub-entry table battle init rebuilds from index 0. The Spirit charge is the visible case - two small rest meshes that VDF entry 12 grows into the aura cone.
- Waits drain at retail's per-frame rate. The part tick subtracts the frame factor times the rate byte from the wait each frame, so a wait of
vholdsvframes. - Lanes grow from the frame after the spawn. The ramp envelope runs from the part tick's tail, which the spawn's own first VM step does not reach. Captures read the lane at exactly
0x66per elapsed frame, so the cone takes0x28frames to open. - The model draw applies the part's scale and colour word. The aura's op
0x0Cwrites an additive colour word at full level; op0x0D's level rate takes the level to 0 over0x20frames and back to full over the last0x0B.
Battle effects die with the battle
Retail never tears effects down one by one. The mode initialiser calls the per-stage init FUN_8001E1B4 on every mode switch, which re-seeds the 143-slot actor free stack and re-pops the seven actor-list sentinels, unlinking every effect actor still on a list with no walk. The efect.dat pool needs no reset on the way out: its walker is battle-overlay code behind the pool-ready byte, and the battle loader re-initialises it on the way in.
The port's World outlives every mode switch and both hosts draw effect state with no mode test, so it drops the same state by name: World::teardown_battle_effects runs at battle entry, at battle exit and at every scene load, resetting the pool and every battle-scoped cast, move-FX and effect-script scene graph. A staged cast module's scene is retired earlier, when the action state machine opens the next action, because the engine runs its records as data and nothing else would halt their infinite loops.
Textures and models
Effect sprites sample two texture pools. The flame atlas etim.dat is uploaded at battle entry only (its VRAM columns hold town textures on the field), while the character texture band stays resident from the field through battle - the Gimard summon flame samples the latter. A separate 30-entry model library, etmd.dat, gives 3D effects their meshes, and effect parts morph through vdf.dat.
The player summon (Gimard’s Burning Attack, and friends) is not an effect-pool child at all: a live trace shows it posed like an enemy body, the battle per-actor draw FUN_80048A08 running in lockstep with the rigid-keyframe decoder FUN_8004998C, one call per live actor per frame. Its flame animation is geometric, not palette cycling. The summon stager images in slot B hold real move-VM part records, run by the executable’s stager, and the engine drives them as a scene graph beside the body. The enemy Gimard’s Tail Fire is a different move again: a single move-VM part actor from the move-FX module PROT 0900.
The floating damage numeral is not a pool child either; it samples the third etim TIM through its own sub-palette. Read from retail's own display list, the digit run keeps its horizontal centre while each cell grows toward its 1:1 24-pixel size and the run rises to a fixed screen row; the quads are unmodulated, so the numeral never fades. Placing it is a host job - the seat is the struck actor's projected screen position - and both hosts emit the cells through one builder, battle_numerals.
History: superseded readings
- The atlas value
0x7680is the entry's CLUT (row 474), not a page-(0,0) 8bpp texture page - the+4/+6fields are CLUT then page. - “Flame flicker is CLUT cycling” is falsified: two animation-distinct frames carry a byte-identical CLUT band while the framebuffer differs.
- “The summon stagers hold no move-VM records” was a wrong-link-base artifact; they recover under base
0x801F69D8. - “Only the default render-mode-4 emitter is reachable” is falsified (above).
More on do-not-re-walk.
Pool layout
| Master field | Meaning |
|---|---|
+0 child_count | Total spawn records; 0 = free slot |
+1 flags | Bit 0 = randomised offsets at spawn |
+2 spawn_cursor | Records consumed so far |
+3 wait | 5.3 countdown |
+4 angle | 12-bit spawn facing |
+8..+0x10 origin | World x / y / z, 16.8 fixed |
+0x18 script_cursor | Next 14-byte spawn record |
The head holds the motion scale (0x1000) and the sprite scale (0xA00) the init writes. A child slot mirrors the master's shape: frame count (doubling as the active flag), mirror bits, frame cursor, wait, i16 velocity, 16.8 position and an animation cursor.
Side-band streaming: summon.dat and readef.DAT
A separate loader case streams one of two runtime-only files into 0x10800-byte slots: per-special-attack CLUT rows and 4bpp texture pages, summon-creature actor records, and the player art-animation archives. The loader consumes a raw TOC index rather than the path string, which is why the names map to extraction entries 893 / 894. Full format: summon-readef.
| File | Raw TOC index | Extraction entry | Selected when |
|---|---|---|---|
data\battle\summon.dat | 0x37F | 893 (103 slots) | _DAT_8007BD24[0x26B] & 0x80 set |
data\battle\readef.DAT | 0x380 | 894 (78 slots) | otherwise |
Spawn sources
The effect catalog (efect.dat) loads at scene entry and stays resident across field / battle transitions. Four producers feed the spawn API:
| Producer | Retail | Port |
|---|---|---|
| Animation effect arm | FUN_8004998C, spawning at the actor's position and facing | BattleActionHost::ui_element |
| Per-action effect-script walk | FUN_801DEA50 | World::route_battle_effect_spawns |
| Move-power effect-id lists | FUN_801E09F8 | World::spawn_move_fx |
Move-power launch run (+0x0E), at the script's terminator | FUN_801DEA50 | World::spawn_launch_run |
| Per-move effect-list spawner | FUN_801E22C8, called by the battle effect driver FUN_800402F4 | battle_cue_group::expand_cue_group |
- A spawn is seated at an actor, never the world origin: the caller copies the actor's world position, offsets it by the effect's planar legs rotated through its facing, and passes the facing as the spawn angle.
- The effect-script walk has two record forms. Its
0x80-flagged records route into this pool; its table-form records stage a small move-VM scene graph instead (see vdf.dat morphs). - The last two producers share the bit-7 multiplex (effect format).
- HUD screen-element raises do not spawn here: the HUD spawner calls no effect routine.
Naming an effect id
Effect ids are anonymous - no string table says “fireball”. To name an id, trace the call sites of the spawn API: each caller passes a literal byte, which correlates with the action that triggered it (a Tactical Art, an item use, a spell cast).
In the port
| Retail | Port |
|---|---|
Init FUN_801DE914 | Pool::init_head |
Spawn FUN_801DFDF0 | Pool::spawn, Pool::spawn_by_ui_id |
| Walker pass 1 | Pool::tick_retail, with the frame-skip catch-up factor |
| Walker pass 2 | Pool::child_billboards: envelope, atlas, size, mirror order |
rand() and the summon side call | EffectHost::next_random, is_summon_effect / handle_summon |
World::tick_effects runs one walker sweep per retail logic frame, and World::active_effect_sprites maps pass 2 one-for-one into billboards, which both hosts draw as camera-facing textured quads sampling the scene VRAM. It is the engine’s only per-frame effect path.
A script spawn is walked on the frame it fires
Retail spawns and walks in one frame, in that order: the per-actor anim tick steps the action effect script, whose direct arm calls the spawn API, and the walker runs afterwards from the draw tick. The engine walks the pool inside its tick and routes queued script and homing spawns in the frame tail, after the walk - so each direct spawn is caught up by the sweeps that frame’s walker gave everything else (Pool::catch_up_master: that master and the children it seeds, nothing more). Without it an effect’s first batch landed one sweep late.
- In battle the walker sweeps from the draw preamble, as retail’s draw tick does: behind the frame driver’s discarded
rand()word and the state machines’ and module’s draws, ahead of the burning bodies’ puffs. Swept at the head of the tick, its mirror draws came off the stream ahead of the pass; a watch of the seed word on Viguro’s film reads driver, ribbons, the walker’s mirrors, then the puffs. - Two port-side deltas, both invisible to retail behaviour: the retirement overrun is cut at retirement, and a dead master lane is reused as an age counter for render fades.
- Debug spawns (the
Ekey inplay-window) live outside the pool, so the walker never sees them. - A faint effect is usually faint by data (see how a child is drawn). Differencing two frames with the billboard draw suppressed (
LEGAIA_DIAG_NOFX=1) shows the sprites really are drawn;LEGAIA_DIAG_FX=1logs each live sprite’s position, size, page, CLUT and VRAM residency.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Init / pack fix-up | 0x801DE914 | Pool head scalars; called from FUN_800520F0 case 0xE with (0x1000, 0xA00) | overlay_battle_801de914.txt |
| Spawn API | 0x801DFDF0 | (effect_id, world_pos*, angle); master seeding; every jal on the disc names this entry | overlay_battle_801dfdf8.txt (prologue word) |
| Per-frame walker | 0x801E0080 | Pass 1 timers / spawn, pass 2 quads; ready flag; catch-up factor DAT_1F800393 | overlay_battle_801e0088.txt (prologue word) |
| Spawn caller | FUN_8004998C effect arm | Spawns at the actor's position and facing | funcs/8004998c.txt |
| Producers | FUN_801DEA50, FUN_801E09F8, FUN_801E22C8 (via FUN_800402F4) | The script walk, move-power lists and per-move list spawner | battle overlay dumps |
| Render dispatcher | FUN_8001ADA4 case 4 | The three emitters on the +0x9E selector | funcs/8001ada4.txt |
| Per-stage init | FUN_8001E1B4 | Every mode switch re-seeds the actor free stack and list sentinels | funcs/8001e1b4.txt |
| Streaming handler | 0x801F17F8 via FUN_800558FC | Raw-TOC-index load of summon / readef; byte-verified RAM↔disc in a mid-cast state | - |
| Sprite projector | FUN_800195A8 | Corners offset after the centre transform | funcs/800195a8.txt |
Full write-up: docs/subsystems/effect-vm.md.