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_game_tables::move_power; CLI asset move-power; engine engine-battle::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)

Two rows of one 26-byte record; hatched = no runtime reader. Three overlay functions read it, and only the damage kernel touches +0x00.

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.
move idactor+0x1DF id → index map0x801F4E63 record, 26 bytestable 0x801F4F5C record index damage kernelFUN_801DD0AC sound cueFUN_8004FCC8 impact config0x801F53D4 effect-id listsbit 7 picks the space +0x00 power +0x0d cue +0x0a +0x12 / +0x16 3D prototype0x801F6324 → FUN_80021B04 efect.dat billboardpack1 pool bit 7 clear bit 7 set
table[map[move_id]] and what each field feeds. Only the damage kernel reads the power halfword; the per-frame tick reads the rest off the record pointer the setup caches.

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. The +0x12 list is the projectile's flight list, spawned at its head each time its counter runs out; the +0x16 list is its landing list, spawned on the target. Each byte multiplexes two id spaces by bit 7, and the two walks do not read the low range alike:

Entry+0x16 (landing)+0x12 (flight)
0x00TerminatorTerminator
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-zeroThe same
0x64 (100)A screen flash: black to a dim red over 0x78 framesA prototype spawn like any other byte
0x65..=0x7FNothingA prototype spawn, read past the table's end
bit 7 set, != 0xFF2D-sprite space: id & 0x7F into the efect.dat pack1 billboard poolThe same
0xFFNo spawn; one streak ribbon at the slot on each pass of the landed countdownNo spawn; one streak ribbon at the projectile's head on each pass of the flight

No shipped flight list holds a byte in 0x64..=0x7F. Four records carry a flight list of four 0xFF and trail four ribbons from caster to target.

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.

In the port

legaia_game_tables::move_power parses the table, the map and the side tables; engine-battle::move_power::MovePowerCatalog resolves a move id to a full MoveFx descriptor (every behavioural field, the impact-config join, each list byte classified as a 3D spawn, a 2D pool effect or a flash). A cast of a non-summon move stages its spawn records through the same move VM the summon renderer uses (World::spawn_move_fx); the cue groups are placed by engine-battle-vm::battle_cue_group from the battle-action applier. The exact per-part transform composition of a spawned scene-graph is the piece still shared-open with the summon renderer.

How we know

Readers of every field, and the byte-identity check
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

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.

So the kernel takes the whole halfword and does every shift itself; the unshifted word is the biggest term of the attack roll. The power figures on this page (record 3's 1500 from raw 0x1770, Freeze Thunder's 37) and the asset move-power column are the >> 2 modulus base. Fed that quarter instead, the roll shifts twice: Tail Fire (raw 0x1E) on a Spirit-guarding Vahn reads 16 on retail's damage ring, where the quartered roll lands on 6 or 7.

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 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 carries the kernel, both tables and the per-branch dispatch (legaia_engine_battle_vm::battle_cue_group).

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.

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