At a glance

In the game
Every named monster special attack's damage roll, approach motion, hit reaction, sound cue and effect bursts
Lives in
Battle-action overlay, PROT entry 0898: table at runtime VA 0x801F4F5C (file 0x26744); id→index map at 0x801F4E63 (file 0x2664B)
Size / stride
44 records × 26 bytes; map is 128 bytes (move ids 0x00..=0x7F)
Indexed by
table[map[move_id]] - the move id is the same id space as the spell-name table
Parser
legaia_asset::move_power; CLI asset move-power; engine engine-core::move_power::MovePowerCatalog
Confidence
Confirmed - the raw PROT bytes byte-match the in-RAM table across unrelated battle states, and every field read is pinned in the instruction stream (how we know)
Used by
Battle formulas, Battle action SM, Move VM, Randomizer (move-power slider)

Record layout (26 bytes)

+00 +02 +04 +06 08 09 0a 0b 0c 0d +0e +12 +16 power (i16) strike Y off move counter phase length effect-list head (4) contact effects (4) launch effects (4) homing tracking impact trail page tag sound cue dashed = no runtime reader · 26 bytes per record, 44 records
One record, offsets in hex. Three overlay functions read it; only the damage kernel touches +00.

The damage kernel reads only +0x00. The action setup computes the record address once and caches it in the battle context; the per-frame tick reads the rest off that pointer. No consumer ever touches +0x0c.

OffsetSizeFieldMeaningConfidence
+0x002 (i16)powerDamage magnitude. A signed halfword - a byte-wide reading truncates most of the table. Four derived shifts feed the roll (details).Confirmed
+0x022 (u16)strike Y offsetSubtracted from the per-arm Y lane when the hit point is seeded from the target.Inferred (read confirmed)
+0x042 (u16)move counterWhole-move timing counter, decremented each frame.Confirmed
+0x062 (u16)phase durationPer-arm phase length written at the strike / re-arm transitions.Inferred (read confirmed)
+0x081homing speedScales the per-frame XY step toward the target; 0x40 - speed reseeds the approach counter.Inferred (read confirmed)
+0x091tracking flagNon-zero: the live XY is copied into the spawned effect each frame, so the effect follows the strike.Confirmed (read); semantic Inferred
+0x0a1impact-effect selectorEnum 1..5 (0 = none) into a 5-entry packed-config table; 3/4/5 branch to extra status-proc rolls.Confirmed (read); naming Inferred
+0x0b1trail texpageAfterimage sprite-page id; becomes the GP0 texpage word 0x7700 + id.Confirmed
+0x0c1designer tag'C'/'E'/'G'/0 annotation on the internal-tier records only; no runtime reader (the sweep).Unknown (no reader)
+0x0d1sound cue idHanded to the cue dispatcher, which queues the SFX ring.Confirmed
+0x0e1effect-list head0xFF broadcasts the trail to all four arms; otherwise the head of the effect-id list (two consumers frame it differently).Confirmed (read)
+0x124on-contact effectsEffect-id list dispatched on the hit branch, terminator-ended.Confirmed
+0x164launch effectsEffect-id list dispatched at the initial strike; same dispatch as +0x12.Confirmed

Worked example. Record 3 (move id 0x06, the third internal-tier attack) decodes as a homing physical strike: power 1500, approach speed 0x20, a 480-frame strike phase, Y offset 250, impact effect 1, cue 0x4d, designer tag C, one effect list on launch and a different one on contact.

What indexes it - table[map[move_id]]

The record index is not the move id. Setup reads the actor's move id (actor[+0x1df]), looks it up in the 128-byte map, and indexes the table with the result; a map byte of 0x00 or 0xFF means “no record”. Joining the map with the spell table labels every record:

  • records 0x10..=0x2b (move ids 0x25..=0x74) - the named monster special attacks;
  • records 0x01..=0x0f (move ids 0x04..=0x1f) - the spell table's unnamed internal enemy-attack tiers (escalating-power triplets);
  • record 0 - an all-zero unused slot.

Special-attack-only

The map covers exactly 44 ids. The basic-attack and Tactical-Art bands (0x08..=0x11, 0x16..=0x18) are unmapped - a queued Somersault or a Gobu Gobu basic attack both resolve to record 0. Damage sources split cleanly:

AttackPower source
Enemy special attackThis table, +0x00
Party Tactical ArtArt-record power byte at record0 +0x24 (art data)
Basic physical attackGeneric physical path (battle formulas)
Seru summonCaster / summon state - the kernel's summon arm never reads the table

Of the 29 mapped named ids, 28 are attacks enemies cast. The table is a subset of the enemy roster's attack ids: the 18 ids missing from it are the magic / elemental casts, whose damage is caster-derived. The one mapped record with no caster (move id 0x2C) is the unused Freeze Thunder: power 37, cue 0x4A, empty effect lists; forcing it crashes on a missing asset (roster numbers).

Effect-id lists (+0x12 / +0x16)

Both lists hold up to four ids and are dispatched identically - the only difference is when they fire (contact vs launch). Each byte multiplexes two id spaces by bit 7:

EntryMeaning
0x00Terminator
0x01..=0x63Move-FX space: spawn the 3D prototype record the effect-prototype table points at, and copy the effect's CLUT row from the sibling table when non-zero
0x64 (100)Fixed screen flash, no table lookup
bit 7 set, != 0xFF2D-sprite space: id & 0x7F into the efect.dat pack1 billboard pool
0xFF, unused 0x65..=0x7FNo effect, scan continues

Both lists index the same two side tables, which sit after the power table in the same overlay (parser move_power::EffectAuxTables):

VAFileTableContents
0x801f63240x27B0Ceffect-prototype pointers61 × u32 overlay VAs, each a variable-length move-VM scene-graph record; packed, not a fixed stride
0x801f64180x27C00per-effect CLUT source x61 × u8, 0 = no palette copy. Not a sound id
0x801F64700x27C58cue-group table13 records × 5 bytes, indexed by group id (details)

A 0x01..=0x63 byte spawns a record that is byte-identical to a summon part record (i16 model_sel, u16 reserved, then move-VM bytecode) through the same stager and move VM the summon renderer uses (spawn path). The join from effect back to triggering move is disc-derivable: asset move-power --effect-index emits it.

Engine wiring

  • MovePowerCatalog pairs the table with the map on World::tables.move_power and resolves a move id to a full MoveFx descriptor: every behavioural field, the impact-config join, each list byte classified as Spawn (3D), AltEffect (2D pool) or Flash.
  • The monster special-attack damage path rolls +0x00 through the arts/physical kernel (battle formulas).
  • World::spawn_move_fx stages a move's spawn records as a SummonScene at the fixed library base and drives each part through the ported move VM; play-window H cycles the spawnable moves. The shared cast path requests the spawn for any non-summon move with a spawnable entry.
  • The trail texpage (+0x0b) feeds the ported afterimage quad (afterimage::build_afterimage_quad); the sound cue (+0x0d) goes through the ported cue decode into the SFX ring.
  • Player Seru-magic ids 0x81..=0x8b route to the summon-creature path; plain physical attacks carry no move id.

Open: the exact per-part transform composition of the spawned scene-graph (the shared piece the summon renderer also waits on), and the cue-group dispatch from the damage-application primitive is not wired in the port.

How we know

FunctionAddressWhat it provesDump
Damage kernelFUN_801dd0acReads +0x00 only (lhu, sign-extended); stride built as 13a << 1; map base materialised at 0x801dd1a0 and 0x801dd36c; summon arm (bne a1,0x7 at 0x801dd104) skips the table.funcs/801dd0ac.txt
Action setupFUN_801dea50Computes table[map[actor+0x1df]] (map base at 0x801df27c) and caches it at ctx+0x1014 (sw at 0x801df284); walks the effect list from +0x0e.funcs/801dea50.txt
Per-frame tickFUN_801e09f8Dereferences the cached pointer ~25 times for every field except +0x00 / +0x0c; dispatches both effect lists; calls the streak draw FUN_801e1ab0 and the cue dispatcher FUN_8004fcc8.funcs/801e09f8.txt
Streak drawFUN_801e1ab0+0x0b becomes the texpage word 0x7700 + id at 0x801e1d54.funcs/801e1ab0.txt
Spawn allocator + stagerFUN_80050ed4 → FUN_80021B04Move-FX spawn chain, pinned live by an exec breakpoint with the prototype base and list id in registers.funcs/80050ed4.txt, 80021b04.txt
Cue expanderFUN_801E22C8Sole reader of the cue-group table (addiu v1, v0, 0x6470 at 0x801E2374); called from FUN_800402F4.funcs/801e22c8.txt
Table extent-0x801F4F5C + 44*26 = 0x801F53D4, exactly where the impact-config table begins; the record count is pinned by the next referenced address.overlay image
Byte identity-The 0x801F4F5C..0x801F69D8 window is byte-identical across unrelated battle saves and matches the raw PROT 0898 bytes at file 0x26744; the overlay loads at slot-A base 0x801CE818 (file 0x25178).battle save states
Engine oracles-move_power_map_is_special_attack_only, move_fx_records_vm_exec_disc (all 54 unique prototype records execute through the ported move VM), model_library_base_tracks_party_size.disc-gated tests

Details

The four power shifts in the damage kernel

The kernel reads +0x00 at three load sites and derives four shifts. They are not a full / half / quarter ladder: two bound a random roll, two are summed straight into the damage accumulator.

LoadShiftSiteRole
0x801dd1c0>> 20x801dd1ccroll modulus, div at 0x801dd1d4
0x801dd1c0>> 00x801dd240additive damage term
0x801dd38c>> 10x801dd39cadditive threshold term
0x801dd3cc>> 30x801dd3d8roll modulus, div at 0x801dd3e0
0x801dd3cc>> 10x801dd448additive damage term

Both moduli are used as rand % (x + 1), so power < 4 rolls a constant 0 on the >> 2 branch and < 8 on >> 3. The half and eighth scales appear only in the retry arm, which re-floors a too-low attacker score. The roll is then scaled by the element-affinity matrix FUN_801dd864, a sibling table in the same overlay.

Indexing in instructions - the odd map base and the delay-slot lui

The map lookup in the tick materialises 0x801F4E64 and reads lbu a0,-0x1(v1); the 0x801F4E63 base is that addiu 0x4e64 / lbu -0x1 pair, so the constant 0x4e63 appears nowhere in the code. The stride is built as 13a << 1, never a literal, so a search for 0x1a finds nothing either.

Of the three sites that materialise the base, the one at 0x801dd36c defeats a linear lui+addiu matcher: its lui a0,0x801f sits in the branch delay slot at 0x801dd2f8 and the addiu is the branch target, 0x74 bytes later with a0 clobbered in between. A sweep folding pairs must follow branch targets, not adjacency.

Per-field load sites: +0x02 at 0x801e0dc4, 0x801e13b8; +0x04 at 0x801df288; +0x06 at 0x801e0d70, 0x801e1360; +0x08 at 0x801e1018, 0x801e1074, 0x801e1274; +0x09 at 0x801e10d4; +0x0a stored at actor+0x21f, indexing 0x801f53d4 ((v-1)*4, packed u32 words with 0x3FF-masked lanes, not pointers) into actor+0x04; +0x0b at 0x801e0ca0, 0x801e0cd0; +0x0d at 0x801e184c; +0x0e at 0x801e0c54, 0x801df408, 0x801df4fc; +0x12 at 0x801e0d00, 0x801e114c, 0x801e1250, 0x801e12ac; +0x16 at 0x801e0ddc, 0x801e0f54, 0x801e13d0, 0x801e1550, 0x801e1800.

The +0x0c no-reader sweep - what the negative covers

Swept over bytes, not dumps: SCUS_942.54 plus every overlay image in static-overlays.toml, every 4-byte word decoded independently, for every valid R3000 load / store and j / jal target (not COP2, which cannot address a record). Access shapes modelled: absolute constants (lui+addiu/ori pairs folded and tested against [table, table + 44*26)), base-folded displacements, ×26-indexed registers, pointer-relative reads through lw rD, 0x1014(rB), straddling loads, and data-resident pointers. No reference lands at a shifted offset, no data word holds an in-table address, and PROT 0898's five byte-width loads at literal displacement 0xc all sit on unrelated RAM structs (the image has 134 memory operations at that displacement; the other 38 byte-width ones are stores). The one form an absolute scan cannot see is impossible here: $gp is 0x8007B318, so a signed-16 displacement off it reaches 0x80073318..0x80083317 and the table sits about 1.5 MB outside that window. PROT 0965 and 0971 are now in the overlay map and inside the sweep; PROT 0896 is closed by bytes, producing zero hits in either base-independent encoding. +0x0d, by contrast, is read - once, as a byte, at 0x801E184C, feeding the cue dispatcher.

Effect-list framing - the two consumers disagree
  • Setup walks one contiguous run from +0x0E, stopping only on a zero byte. 0xFF is not a terminator there: it has bit 7 set and routes to the 2D path with id 0x7F.
  • Tick reads +0x12 and +0x16 as two separate 4-byte lists and treats +0x0E == 0xFF as a broadcast to all four arms.

Ids 0x0A, 0x2D and 0x2E additionally latch the spawned handle into ctx+0x1028 and seed the per-frame delta triple at ctx+0x1184, which is what makes their effect follow the actor. The 2D path (FUN_801dfdf0 → FUN_801DFDF8) special-cases pack1 0x04 → 0x801F5D90 and 0x13 → 0x801F5CF8, the two burst-arm move-VM trigger programs. The 61-entry space bounds both side tables; the runtime's < 100 spawn guard is a loose safety check.

The cue-group table 0x801F6470

Indexed by group id rather than effect id; the cue expander reads one 5-byte record per call:

OffsetSizeFieldMeaning
+0x001cue count0..=4; zero spawns nothing
+0x014cue idsOnly the first count are read. Bit 0x80 = an actor cue (FUN_801DFDF0(id & 0x7F, pos, yaw), the strike pose); any other id indexes both effect side tables

13 records fill 0x801F6470..0x801F64B5, followed by padding and the summon.DAT path literal; callers never index past 0xC. The table has exactly one consumer - a reference scan of SCUS plus every base-mapped overlay finds the address materialised once - while its two sibling tables are reached from five sites each, always in pairs. The caller is the damage-application primitive FUN_800402F4 (eleven branches, group ids 5..0xC literal or computed). The port has the kernel (legaia_engine_vm::battle_cue_group::expand_cue_group) and both tables but not that dispatch.

Effect-prototype records - the spawn path and model_sel

The tick calls FUN_80050ed4(world_pos, src_pos, 0x801f6324[id], 0x1000), a 0x60-slot allocator that tail-calls the shared stager FUN_80021B04 (the disassembly preserves a0..a3; the C decomp drops them), which reads the record's +0x00 model_sel (< 0 / 0x4000 / 0x4001 are transform-node / render-mode sentinels, else the mesh is DAT_8007C018[model_sel + gp[0x754]]), allocates an actor (FUN_80020de0), stores the record as the actor's move-VM buffer (+0x48), forces the PC to u16-index 2 (bytecode at record+4) and drives it through the move VM (FUN_80023070). move_power::parse_effect_proto_records decodes the whole table (ids 0x27 / 0x28 → 0x801F5BBC / 0x801F5BDC); a live Gimard “Fire Tail” mid-cast holds a part-actor whose +0x48 is 0x801F5484.

gp[0x754] (global 0x8007BA6C) is party_count + 2 in battle: 0 when no effect-model library is resident, 3 for the 1-member training party, 5 for the full party. The two fixed pool slots plus the live party meshes precede the effect-model library (PROT 0871, engine global_tmd_pool[3..=32]), so model_sel is library-relative and there is no per-summon base.

Roster cross-check numbers

Across 186 monsters, 46 distinct attack ids appear in the monster record's special-attack bytes (+0x21..=+0x23); 28 are in this table. The other 18 (0x2E / 0x2F / 0x3C / 0x4A..=0x6E, plus 0xA7 / 0xB8 beyond the map) are the magic / elemental casts. 95 of 186 monsters carry no magic attack at all. Freeze Thunder is record 22.

History: readings that were wrong
  • Dumps labelled overlay_0897_* / overlay_magic_* / overlay_muscle_dome_* carry the battle-overlay bytes at a wrong load base, which produced a claim that 0x801F3990 was a second damage kernel. At the real VA it is the cast audio-cue dispatcher and reads no field of the record. Take addresses from the extracted overlay image.
  • 0x801f6418 is a CLUT source-x, not a per-effect sound id, and each effect list uses both side tables rather than one each. A “~0x20-byte struct” reading of the prototype table was a coincidence of one record's size.

Full entries in do-not-re-walk § battle.

CLI

asset move-power <raw PROT 0898 entry>                  # dump every record (power, counters, effects, ...)
asset move-power <raw PROT 0898 entry> --effect-index   # effect id -> triggering moves

The randomizer's move-power slider rewrites +0x00 in place through the same parser. Full reference: docs/formats/move-power.md.

See also