At a glance

Where
Battle overlay (PROT 0898). Spawn API 0x801DFDF0, per-frame walker 0x801E0080 (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 by World::tick_effects - the engine's only per-frame effect path; the render-mode-4 emitters are engine-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.

spawn(effect id) actor position + facing efect.dat scripts + anim records master slot (x32) wait, record cursor, origin child slot (x128) wait, frame, position, velocity one textured quad semi-transparent, faded in / out retire after the last record / frame pass 1: one record = one child pass 2: draw
Pass 1 runs the timers and spawns, repeated once per elapsed vsync so effect time tracks wall-clock under frame skip; pass 2 draws every live child once. No opcode is ever decoded.

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)

+0x00animu8
+0x01delayu8
+0x02offset ai16
+0x04heighti16
+0x06offset bi16
+0x08vel ai16
+0x0Avel yi16
+0x0Cvel bi16

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)

+0atlas indexu8
+1delayu8
+2speedu8
+3unread3 B
  • 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.speed with 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 4 and 0x13 first call the move-VM part spawner 0x80050ED4, 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 at 0x80 (neutral) and written as r = g = b.
  • Size. Atlas w/h × sprite_scale >> 8 (retail scale 0xA00, 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, 0x800 on Theeder’s hits) and their closing arms restore 0xA00, 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 0x2E is 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) is B + 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:

SelectorEmitterSet by move opWhere it appears
neither bitDefault emitter FUN_80028158 - the ring / disc / fan family0x13Most render-mode-4 nodes in captured battles; the bulk of the summons' procedural geometry
0x4000Sprite arm FUN_8002A5A4 - one textured quad0x23Captured in battle states alongside the default
0x2000Random-walk ribbon FUN_801CFA48 - lightning bolts, beams, whips0x42Only 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:

BitsSelects
mode & 3The plane the three builder axes land in; mode 1 lays a ring on the ground
(mode >> 3) & 0xFThe 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 >> 8The 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 4 and 5) 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 v holds v frames.
  • 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 0x66 per elapsed frame, so the cone takes 0x28 frames to open.
  • The model draw applies the part's scale and colour word. The aura's op 0x0C writes an additive colour word at full level; op 0x0D's level rate takes the level to 0 over 0x20 frames and back to full over the last 0x0B.

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 0x7680 is the entry's CLUT (row 474), not a page-(0,0) 8bpp texture page - the +4 / +6 fields 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

+0x000head16 B
+0x010128 child slots × 32 B4096 B
+0x101032 masters × 28 B896 B
Master fieldMeaning
+0 child_countTotal spawn records; 0 = free slot
+1 flagsBit 0 = randomised offsets at spawn
+2 spawn_cursorRecords consumed so far
+3 wait5.3 countdown
+4 angle12-bit spawn facing
+8..+0x10 originWorld x / y / z, 16.8 fixed
+0x18 script_cursorNext 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.

FileRaw TOC indexExtraction entrySelected when
data\battle\summon.dat0x37F893 (103 slots)_DAT_8007BD24[0x26B] & 0x80 set
data\battle\readef.DAT0x380894 (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:

ProducerRetailPort
Animation effect armFUN_8004998C, spawning at the actor's position and facingBattleActionHost::ui_element
Per-action effect-script walkFUN_801DEA50World::route_battle_effect_spawns
Move-power effect-id listsFUN_801E09F8World::spawn_move_fx
Move-power launch run (+0x0E), at the script's terminatorFUN_801DEA50World::spawn_launch_run
Per-move effect-list spawnerFUN_801E22C8, called by the battle effect driver FUN_800402F4battle_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

RetailPort
Init FUN_801DE914Pool::init_head
Spawn FUN_801DFDF0Pool::spawn, Pool::spawn_by_ui_id
Walker pass 1Pool::tick_retail, with the frame-skip catch-up factor
Walker pass 2Pool::child_billboards: envelope, atlas, size, mirror order
rand() and the summon side callEffectHost::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.

retail, one frame Anim tickFUN_80047430 Effect scriptFUN_801DEA50 SpawnFUN_801DFDF0 Walkerfrom the draw tick port, one tick World::tickspawn queued Walkertick_retail Frame tailroute queued spawns catch_up_masterthe missed sweeps
The same effect state at the end of the frame, reached in a different order: the catch-up stands in for the walker retail runs after the spawn.
  • 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 E key in play-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=1 logs each live sprite’s position, size, page, CLUT and VRAM residency.

How we know

FunctionAddressWhat it provesDump
Init / pack fix-up0x801DE914Pool head scalars; called from FUN_800520F0 case 0xE with (0x1000, 0xA00)overlay_battle_801de914.txt
Spawn API0x801DFDF0(effect_id, world_pos*, angle); master seeding; every jal on the disc names this entryoverlay_battle_801dfdf8.txt (prologue word)
Per-frame walker0x801E0080Pass 1 timers / spawn, pass 2 quads; ready flag; catch-up factor DAT_1F800393overlay_battle_801e0088.txt (prologue word)
Spawn callerFUN_8004998C effect armSpawns at the actor's position and facingfuncs/8004998c.txt
ProducersFUN_801DEA50, FUN_801E09F8, FUN_801E22C8 (via FUN_800402F4)The script walk, move-power lists and per-move list spawnerbattle overlay dumps
Render dispatcherFUN_8001ADA4 case 4The three emitters on the +0x9E selectorfuncs/8001ada4.txt
Per-stage initFUN_8001E1B4Every mode switch re-seeds the actor free stack and list sentinelsfuncs/8001e1b4.txt
Streaming handler0x801F17F8 via FUN_800558FCRaw-TOC-index load of summon / readef; byte-verified RAM↔disc in a mid-cast state-
Sprite projectorFUN_800195A8Corners offset after the centre transformfuncs/800195a8.txt

Full write-up: docs/subsystems/effect-vm.md.

See also