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 four siblings, but it has no opcode table at all - it is a pool of slots driven by countdown timers.
At a glance
- Where
- Battle overlay (PROT 0898); pool at
_DAT_8007BD30, 5008 bytes - Input
efect.dat(extraction 0873) - a 2-pack: 14 sprite-animation records + 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) + the field-resident character texture band (0874 §2) - Engine
engine-vm::effect_vm(Pool::tick_retail,Pool::child_billboards); live viaWorld::tick_effects- Confidence
- Confirmed - walker and spawn traced instruction-for-instruction
- Used by
- Battle action SM, Move VM effect lists, item and spirit appliers
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 “after N frames, start sprite animation K at this offset”. A master slot walks the record list; each record it reaches claims a child slot, which then walks its own animation frames and drifts along a velocity until its last frame retires it.
Pool layout
| Offset | Size | Contents |
|---|---|---|
+0x000 | 16 B | table head set by init: motion scale (0x1000) and sprite scale (0xA00, ×10 texel size) |
+0x010 | 4096 B | 128 × 32-byte child slots - one per live sprite |
+0x1010 | 896 B | 32 × 28-byte master slots - one per live effect |
| 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 fixed-point countdown (frames << 3, minus 8 per frame) |
+4 angle | 12-bit spawn facing |
+8..+0x10 origin | world x/y/z, 16.8 fixed |
+0x18 script_cursor | next 14-byte spawn record |
A child slot mirrors the shape: frame count (doubles as the active flag), random UV-mirror bits, frame cursor, wait, i16 velocity, 16.8 position and an animation cursor. Every wait in the system is the same 5.3 countdown, so effect time tracks wall-clock under frame skip.
Why there is no opcode table
- The “state” bytes are wait counters. The only data consumed are spawn records (14 bytes each) and animation frames (6 bytes each).
- Zero-delay records spawn as one burst; zero-delay frames advance as one burst. Both loops repeat while the freshly loaded wait is zero.
- On pool exhaustion a record is still consumed with no child - effects degrade rather than stall.
- Delays are stored in a byte after the
<< 3, so a delay of 32 frames or more wraps modulo 32.
Details: pass 2 render maths and the blend rule
- Brightness envelope. With
n = frame_count >> 3, modulation ramps in over the first eighth of the animation and back out over the rest, clamped at0x80(neutral), written asr = g = b. - Size. atlas
w/h × sprite_scale >> 8, centre projected through the sprite projector, corners offset in view space, inserted into the ordering table by depth. - UVs. base and extent from the 8-byte atlas entry; corner order swapped per the child’s random mirror bits; CLUT from atlas
+4, texture page from atlas+6. - Blend. The packet uses prim code
0x2E- textured quad, semi-transparent - so every effect child blends, and the page’s own ABR bits pick the formula. Pages(320,0)/(448,0)are additive (B + F); page(384,0)isB + 0.25F, which is why walk-dust reads as almost nothing over a bright floor. - Retirement quirk. Retiring zeroes the active flag and the wait, but the frame loop tests only the wait, so retail keeps stepping the dead slot until it meets a non-zero delay byte. Harmless - the next seed rewrites every field.
Textures, models and the engine port
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.
| Retail piece | Engine mirror |
|---|---|
| init / spawn / walker pass 1 | Pool::init, Pool::spawn, Pool::tick_retail (one sweep per retail frame from World::tick_effects) |
| walker pass 2 | Pool::child_billboards → World::active_effect_sprites; each host draws a camera-facing textured quad |
| spawn at the acting actor | BattleActionHost::ui_element spawns at the actor’s battle seat with its facing angle |
| effect catalog | EffectCatalog::from_efect_dat_bytes, resident on World::effect_catalog across field / battle |
| flame atlas / texture band | scene::upload_flame_atlas_into_vram (battle entry) / scene::upload_effect_textures_into_vram (scene entry) |
model library etmd.dat | scene::seed_effect_model_library_from_etmd → World::global_tmd_pool[3..=32]; Gimard Tail Fire = index 26 |
The player summon (Gimard’s Burning Attack, and friends) is not an effect-pool child at all: it is posed like an enemy body through the battle per-actor draw and the rigid-keyframe decoder, and its flame animation is geometric, not palette cycling. The per-summon stager overlays are dispatched by the battle action SM.
Details: host traps a port hits
- Half-extents are view-space. Retail offsets the quad corners after transforming the centre, so the battle camera’s 4× base scale never touches them. A port offsetting in world space draws every sprite four times too large. Correction:
engine-vm::effect_billboard::world_half_extents. - The blend enable comes from the prim code, not the atlas. The atlas stores its page in one byte, so a billboard builder that copies the page into a TSB word never sets the semi-transparent bit and the whole layer rasterises opaque - pale tan blobs instead of glow. Port:
effect_sprite_tsb. - “Spawns fire, nothing appears” is usually the data. The Rim Elm spar’s effects all use the
B + 0.25Fpage; the bright additive pages belong to effect ids the spar never requests.LEGAIA_DIAG_FX=1logs per-sprite position, page, brightness and texel residency. - The tinted outline is a diagnostic, off by default on both hosts (native env
LEGAIA_DIAG_FX=1; browserset_battle_fx_outline(true)). Retail draws no such rectangle. - The floating damage numeral is not a pool child; it samples the third
etimTIM at page(448,0)through the sub-palette at(48, 476), and rises to screen row 32 while its cells grow to 24 px. Port:engine-vm::battle_value_readout.
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.
More on do-not-re-walk.
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 ignores the path string and consumes a raw TOC index, 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 |
Naming an effect id
Effect ids are anonymous - no string table says “fireball”. Two producers feed the spawn wrapper: the move-power record’s effect-id lists, and the per-move cue-group list walked by the battle effect driver, both through the same bit-7 multiplex (effect format). To name an id, correlate the literal byte a caller passes with the action that triggered it.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| init / pack fix-up | 0x801DE914 | pool head scalars; called from FUN_800520F0 case 0xE with (id=0x1000, param=0xA00) | overlay_battle_801de914.txt |
| spawn API | 0x801DFDF8 | (effect_id, world_pos*, angle); master seeding | overlay_battle_801dfdf8.txt |
| per-frame walker | 0x801E0088 | pass 1 timers/spawn, pass 2 quads; ready flag DAT_8007BD71 == 0xFF; catch-up factor DAT_1F800393 | overlay_battle_801e0088.txt |
| spawn caller | FUN_8004998C effect arm | spawns at the actor’s position (actor+0x34) and facing (actor+0x46) | funcs/8004998c.txt |
| list dispatch / cue groups | FUN_801E09F8, FUN_801E22C8, FUN_800402F4 | the two producers of the spawn wrapper FUN_801DFDF0 | battle overlay dumps |
| streaming handler | 0x801F17F8 via FUN_800558FC | raw-TOC-index load of summon / readef; byte-verified RAM↔disc in a mid-cast state | - |
| summon render path | FUN_80048A08 → FUN_8004998C | live trace: the two run in lockstep, once per live actor per frame; move VM near-zero | PCSX-Redux probe |
| sprite projector | FUN_800195A8 | corners offset after the centre transform | funcs/800195a8.txt |