Skip to content

Move System (command-script bytecode VM)

How SC6 stores and runs per-move data. It splits into two layers: a UE4 DataTable for UI text, and a native command-script bytecode for gameplay.

At a glance

Two-layer data model

Layer Holds Storage Extract via
Display (UE4 DataTable) Per-move TextIDs: CommandTextID, NameTextID, ReadingTextID, NoteTextID, MainMovesTextID, RethalHitTextID, AttributeTag, EffectTag. No frame data. /Game/Style/<StyleId>/DA_MoveListTable_<StyleId>.uasset unpack pak → parse DataTable with FLuxBattleMoveListTableRow schema
Gameplay (native command-script) Per-move bytecode: inputs, ATB/ATK payload, IF/goto/calc/RAND opcodes, on-hit/CH yarare ids, stance toggles. Per-style binary blob in the battle-manager move-provider; runs in the VM at 0x140365900 parse via opcode dispatch table; join yarare ids against g_LuxCharaAttrTable_*

To produce "i13 mid for 20 damage, +2 on hit / +6 on CH":

  1. Load the bytecode for move X.
  2. Decode the ATK payload opcode (0x40002) for power / range / speed / dir_mask.
  3. Cross-reference the resolved hit-reaction ("yarare") id against the character's attribute table to get opponent stun frames; subtract own recovery.

UI docs for DA_MoveListTable_* are in Character Data.

Key VM globals

Symbol Address Stride What
g_LuxMoveVM_CommandPlayerArray 0x14470F390 0xC0E Per-chara VM state (slot indexed by chara+0x23C CharaKindByte).
g_LuxMoveSystem_DataTableA 0x144710060 0x3038 Per-chara move-definition data table.
g_LuxMoveSystem_SelfCharaPerSlot 0x14470F398 0x607 u64 Self chara back-ref.
g_LuxMoveSystem_OppCharaPerSlot 0x14470F3A0 0x607 u64 Mirror of chara+0x973E8.
g_LuxCharaAttrTable_Byte_0x181cStride 0x14470FCD8 0x181C Per-chara byte table (yarare frames etc.).
g_LuxCharaAttrTable_Int_0x3038Stride 0x1447123BC 0x3038 Per-chara int table.
g_LuxMoveStateTable 0x1440F4750 0x14 (0x29 rows) Move-state ids.

Key VM entry points

Function RVA Role
LuxMoveVM_ExecuteAndDumpOpcode 0x365900 The VM executor + disassembler. Walks opcodes, mutates state, emits debug-trace string at vmCtx+0x2A28.
LuxMoveVM_TickDriver 0x3656B0 Per-tick entry; gates the executor on VM phase.
LuxMoveSystem_StartMoveForChara 0x31C610 Full fresh-move init.
LuxMoveSystem_TickMove 0x367EE0 Plain per-frame tick.
LuxMoveSystem_TickMoveAndAutoAdvance 0x31C740 Tick + auto-advance to next move.
LuxMoveVM_TickAgainstReactingOpp 0x31C8B0 Tick variant when opponent is in hit-reaction.
LuxMoveVM_PostATKDelayGate 0x365520 Inclusive 5..20-tick randomised post-ATK delay.
LuxBattle_DispatchYarareReaction 0x3521B0 Yarare dispatcher — 80-case switch on hit-reaction id.

Opcode quick reference

op >> 16 Mnemonic Cells Payload
0x1xxxx BTN+TIME 2 button mask + time
0x40001 ATB variable combo_id + yarare_id stream
0x40002 ATK 5 power / range / speed / dir_mask
0x40004 RESERVE_NOWAIT 1 sets vmCtx+0x2A10 = 1
0x40005 RESERVE_WAIT 1 sets vmCtx+0x2A10 = 0
0x50001 start! 1 zero condition-flag ring
0x50003 goto 2 jump delta
0x50004 calc 5 dst = a OP b OP c
0x50005 flag-set 1 sets vmCtx+0x2698 = 1
0x50006 RAND 3 threshold + jump delta
0x50007 _REVERSE 1 sets vmCtx+0x269C = 1
0x50008 IF / notif 5 op + class + subject + value + delta
0x80000 raw command no 1 debug echo

Full opcode semantics: Opcode dispatch.


Display-layer struct

FLuxBattleMoveListTableRow (0x88 bytes)

Reflected via Z_Construct_UScriptStruct_FLuxBattleMoveListTableRow @ 0x14094a910.

Offset Type Name Notes
+0x00 FTableRowBase (base) inherited UE4 data-table row header
+0x08 FString CommandTextID input notation (6B, AGA, etc.)
+0x18 FString NameTextID localised move name
+0x28 FString ReadingTextID localised reading/phonetic
+0x38 FString NoteTextID tooltip / frame-data blurb
+0x48 FString MainMovesTextID category tag
+0x58 FString RethalHitTextID Lethal-Hit text id (typo in ROM: "Rethal")
+0x68 FString AttributeTag high/mid/low/throw attribute tag id
+0x78 FString EffectTag GuardImpact / Unblockable / BreakAttack / SpecialStance / LethalHit / SoulCharge / …

The AttributeTag and EffectTag strings are the join keys into ELuxAttackTouchLevel and ELuxBattleMoveEffectType respectively. They are human-readable tags, not bit-packed values, so the display layer is descriptive rather than authoritative.


Gameplay-layer runtime

Actor class: ALuxBattleMoveCommandPlayer

Registered by Z_Construct_UClass_ALuxBattleMoveCommandPlayer @ 0x140953780.

Reflected UFunctions (all wrap *_Impl methods):

UFunction _Impl Purpose
GetMovePlayParam(MoveListId, Level, Idx, out) @ 0x1404235C0 fetch one FLuxBattleMovePlayParam from the 3-level table[MoveListId].levels[Level].entries[Idx] array
IsPlaying(SideFilter) @ 0x140426610 read player-side at this+0x3C0: 0=none, 1=P1, 2=P2
PlayMove — queue a move by MoveListId
PlayMoveDirect — queue a move by raw FMoveDef*
StopMove — cancel current queued move

Reflected fields on the class (+0x390 .. +0x3D0):

Offset Type Name
+0x390 FStructProperty PlayData
+0x3A0 FEnumProperty (u32) Request
+0x3A8 FStructProperty RequestInfo
+0x3C0 FEnumProperty (u32) PlayState
+0x3C8 FStructProperty PlayStateInfo

On top of the reflected fields, the VM uses non-reflected scratch state at higher offsets (documented inline on LuxBattleMoveCommandPlayer_DebugDumpCommand at 0x140365900 — copy-paste reproduced below).

Runtime payload: FLuxBattleMovePlayParam (~0x10 bytes)

A small struct (just 2 × 8-byte slots, zero-initialised by FLuxBattleMovePlayParam_DefaultCtor @ 0x140406F40). It is the "handle" copied into a UFunction out-param so Blueprint can reference a queued move without owning its backing buffer.


VM runtime layout — global static arrays (2026-04-20)

ALuxBattleMoveCommandPlayer is NOT a heap-allocated UObject instance on this build. It is a fixed global static array indexed by the chara's CharaKindByte (chara+0x23C). This was discovered by following the xrefs into LuxMoveVM_TickDriver.

Base address Name Stride Purpose
0x14470F390 g_LuxMoveVM_CommandPlayerArray 0xC0E bytes Per-chara VM state (opcode scratch, cursor, parser state at +0x9A3, etc.)
0x14470F398 g_LuxMoveSystem_SelfCharaPerSlot 0x607 (as u64 elements) Self ALuxBattleChara* alongside the VM slot
0x14470F3A0 g_LuxMoveSystem_OppCharaPerSlot 0x607 Opponent pointer mirror (= chara->+0x973E8)
0x144710060 g_LuxMoveSystem_DataTableA 0x3038 bytes Per-chara move-definition data table (another parallel array)

Within the 0x3038-stride DataTableA, key fields (offset from the base global, DataTableA addresses pre-resolve to g_LuxMoveSystem_DataTableA + CharaKindByte * 0x3038):

Global Purpose
g_LuxMoveSystem_MoveDefArrayPerSlot @ 0x144710070 FMoveDef* array pointer; entries[i].cells @ +2+i*0x10
g_LuxMoveSystem_ScratchB_PerSlot @ 0x144710D1C cleared to 0 on each StartMove
g_LuxMoveSystem_VMActiveFlagPerSlot @ 0x144710D18 int32 — raised (=1) while LuxMoveVM_TickDriver is ticking
g_LuxMoveSystem_MoveCountPerSlot @ 0x144711A14 uint32 — number of moves in the slot's move table
g_LuxMoveSystem_CurrentMoveIdxPerSlot @ 0x144711A18 uint32 — currently-executing move index
g_LuxMoveSystem_ScratchA_PerSlot @ 0x144711A28 cleared on each StartMove
g_LuxMoveSystem_SpecialStateFlagPerSlot @ 0x144711A48 uint32 — raised when chara+0x16DB set
g_LuxMoveSystem_CellCursorPerSlot @ 0x144711DB4 uint32 — current cell cursor into the move's opcode stream
g_LuxMoveSystem_ScratchC_PerSlot @ 0x144711E38 4 u64 slots cleared on each StartMove

Entry points that mutate these slots:

  • LuxMoveSystem_StartMoveForChara @ 0x14031C610 — full fresh-move init
  • LuxMoveSystem_TickMove @ 0x140367EE0 — per-frame plain tick
  • LuxMoveSystem_TickMoveAndAutoAdvance @ 0x14031C740 — tick + auto-transition
  • LuxMoveVM_TickAgainstReactingOpp @ 0x14031C8B0 — tick when opp is in hit-reaction

Auxiliary scratch / queue structs the VM maintains alongside the per-slot state:

  • FLuxMoveSchedState — 96-byte dual-slot scheduler that lets the next move be queued while the current one is still ticking. pMoveIdSlot[2], pPrevMoveId[2], pMoveChangedCounter[2], pExtraParam0/1Slot[2] are the per-slot arrays.
  • FLuxMoveStartRequest — 108-byte "queue this move" request struct passed into PlayMove / PlayMoveDirect. Holds its own internal state machine (dwStateMachine) plus two candidate move-id slots (nCandidateMoveIdA/B) for transition-window resolution.
  • FLuxMoveBankCell — 112-byte categorised per-character "move bank" row. Holds wCategoryFlags, wAttackTypeTier, nBlockHeightMin/Max, nYarareIdOrHitType, nMoveExtraId, plus a deliberately packed unaligned-qword tail that lets a 16-byte SSE move slap two adjacent rows of metadata in one instruction.
  • FLuxMoveSubFrameRecord — 72-byte 60→120 Hz sub-frame interpolation record. flRangeStart/flRangeEnd gates which sub-frame samples this record applies to.
  • FLuxMoveLane — 1128-byte per-lane VM state (was named LuxMoveLaneState). Three lane blocks live inline in every chara at fixed offsets:

    Offset Lane Role
    chara+0x444F0 Lane 0 primary active move (most strikes)
    chara+0x44958 Lane 1 secondary lane (mirror / queued script)
    chara+0x44DC0 Lane 2 stance / yarare / hit-reaction

    Stride between lanes = 0x468 bytes. chara+0x44068 (ActiveLaneStateCursorPtr) points at whichever lane is currently driving the move. Per-lane fields most useful for instrumentation:

    Lane offset Type Name
    +0x00 i16 LaneIndex (0/1/2)
    +0x02 i16 PackedMoveAddr ((bank<<12) \| slot; -1 = idle)
    +0x04 i32 TickCounter (raw ticks since move start)
    +0x08 float CurrentAnimFrame — THE frame counter, advanced as +0x08 += time_dilation * +0x30 each tick. Truncate for "current frame N".
    +0x0C float PrevAnimFrame (tick-start snapshot)
    +0x10 float AnimLengthFrames (from bank cell +0x34)
    +0x1A u16 AtEndFlag (1 at final frame)
    +0x1C i16 FrameDeltaThisTick
    +0x24 u16 FrameStepFinished (1 once end reached)
    +0x26 u16 InTransitionFlag (1 during TransitionToMove)
    +0x30 float PlaybackSpeedCurrent (ramps to target)
    +0x34 float PlaybackSpeedTarget
    +0x458 i32 TotalTickCounter (monotonic across advances)
    +0x460 u32 AnimVariantIndex (0..5 bank variant)

    For a "current move frame N / M" HUD overlay, read Lane0+0x08 and Lane0+0x10 every tick — pure pointer arithmetic, no hooks required. chara+0x1350 is the MoveStartCounter, bumped on each TransitionToMove call (handy for "did the move change?" checks without diffing PackedMoveAddr). chara+0x1360 is the LastHitAnimFrame mirror, written by ProcessHit for HUD consumers.

    Static native-code verification also fixes the containing object view: the three FLuxMoveLane records form one contiguous FLuxMoveLane[3] at +0x444F0 and end exactly at the five FLuxMotionPlaybackSlot records at +0x45228. An older Ghidra overlay beginning at +0x44958 called lane 1's +0x08/+0x10 floats "reaction heights"; those bytes are actually flAnimFrameCurrent and flAnimLength.

  • LuxMoveEventRbTreeNode is a distinct 0x30-byte runtime node used by the KH11 event lookup. Its payload is dwPackedMoveId at +0x20 and FLuxMoveBankEventRecord* pEventRecord at +0x28. It must not reuse the similarly shaped fighter-registry node type; doing so falsely renders +0x28 as pChara.

  • FLuxBattleVMFreezeRecord — 64-byte slow-motion / VM-freeze blend state. Three candidate alphas, two countdowns, blended output. The flAlphaCandidate3_SlowMo slot is the slow-mo source (e.g. the dramatic finishing-blow camera).

VM opcode scratch layout (offsets on g_LuxMoveVM_CommandPlayerArray[slot])

These offsets are filled in by LuxMoveVM_ExecuteAndDumpOpcode as each opcode fires. The slot itself is typed in Ghidra as FLuxMoveCommandPlayer (12 332 bytes); the per-opcode scratch view (+0x26AC..+0x26E4) is also typed independently as FLuxMoveVM_OpcodeScratch (96 bytes) so it can be passed by reference into helper functions. The four ATK fields that get the most traffic are also wrapped as FLuxMoveVM_ATKPayload (16 bytes) when the VM needs to copy them out as a tuple.

Offset Field Written by opcode
+0x26AC BTN_Mask 0x1xxxx BTN+TIME
+0x26B0 BTN_Time (float) 0x1xxxx
+0x26B4 ATB_ComboId 0x40001 ATB
+0x26B8 ATB_YarareId_0 0x40001
+0x26BC IF_Op 0x50008 IF
+0x26C0 IF_Delta 0x50008
+0x26C4 IF_Subject 0x50008
+0x26C8 IF_Value 0x50008
+0x26CC Goto_Delta 0x50003 goto
+0x26D0 Rand_Threshold 0x50006 RAND
+0x26D4 Rand_JumpDelta 0x50006
+0x26D8 ATK_Power 0x40002 ATK
+0x26DC ATK_RangeRaw 0x40002
+0x26E0 ATK_Speed 0x40002
+0x26E4 ATK_DirectionMask 0x40002

The ATK payload fields are the authoritative "current attack hitbox metadata". Hit detection for non-weapon attacks (kicks, throws) must read these during the active frame window. As of 2026-04-20 the specific reader is still TBD. LuxBattle_DispatchYarareReaction @ 0x1403521B0 is the downstream dispatcher that fires once a hit has been resolved (its 80-case switch maps each yarare id to a per-reaction handler). Following xrefs upward from that function is the shortest remaining path to the hit-test primitive.

For the post-hit pipeline (pick → dispatch → per-id Tick advance), the yarare-id taxonomy, the 134 gate codes, and the knockdown camera, see the dedicated Reaction System page.

VM tick call graph

<battle tick>
  → LuxMoveSystem_TickMove / TickMoveAndAutoAdvance / TickAgainstReactingOpp
      → LuxMoveVM_TickDriver
          [phase==2] → LuxMoveVM_PostATKDelayGate   (inclusive 5..20 tick random delay)
          [phase==1] → LuxMoveVM_RefreshConditionFlagRing
                      → LuxMoveVM_ExecuteAndDumpOpcode   (executes opcode)
                          switch on opcode >> 16:
                            0x1xxxx  BTN+TIME   — writes +0x26AC/+0x26B0
                            0x40001  ATB        — writes +0x26B4..+0x26B8
                            0x40002  ATK        — writes +0x26D8..+0x26E4
                            0x40004  RESERVE_NOWAIT
                            0x40005  RESERVE_WAIT
                            0x50001  start!     — zeroes condition ring
                            0x50003  goto
                            0x50004  calc
                            0x50005  end!       — sets move_ended=1
                            0x50006  RAND
                            0x50007  _REVERSE
                            0x50008  IF         — LuxMoveVM_EvaluateIfOpcode

Parallel, concurrent per-tick paths (not under TickDriver):

<battle tick>
  → LuxMoveVM_TickPickAndDispatchReaction   (picks yarare id from context)
      → LuxBattle_DispatchYarareReaction
          switch on yarareId (0x01..0x4F):
            per-reaction setup (`LuxBattle_InitYarare_0x1F`,
            `LuxBattle_InitYarare_0x22`, ...)
            writes active state to +0xACC (current reaction),
                   +0xACD (frame counter), +0xAB6 (duration)

Hit detection sits between these two paths: the VM executor writes ATK state, something (still TBD) consumes it and decides whether to fire a yarare reaction, and DispatchYarareReaction is the terminal step.


Command-script format

Source of truth: LuxMoveVM_ExecuteAndDumpOpcode @ 0x140365900 (formerly LuxBattleMoveCommandPlayer_DebugDumpCommand). It is both the VM executor and the disassembler — it walks one opcode at a time, mutates VM state, and also writes a human-readable line to this+0x2A28. Every opcode and every field documented here is visible in that function's string literals.

Opcode dispatch (upper 16 bits of each uint32 cell)

op >> 16 Mnemonic Cells Payload
0x1xxxx BTN+TIME 2 cell 0 = button mask, cell 1 = time (float reinterpret)
0x40001 ATB variable combo_id (low 16 of first cell); reads until next cell has 0x4xxxx prefix; accumulates yarare id
0x40002 ATK 5 op, power, range_raw, speed, direction_mask
0x40004 RESERVE_NOWAIT 1 sets this+0x2A10 = 1
0x40005 RESERVE_WAIT 1 sets this+0x2A10 = 0
0x50001 start! 1 zero-inits the condition-flag ring at chara+0x19F0..+0x1A64
0x50002 (pad) 1 falls through to IF decode
0x50003 goto 2 cell 1 = jump delta added to cursor
0x50004 calc 5 dst = a OP b OP c (4 operand cells)
0x50005 flag-set 1 sets this+0x2698 = 1
0x50006 RAND 3 threshold, jump-delta
0x50007 _REVERSE 1 sets this+0x269C = 1
0x50008 notif / IF 5 op, class (0x6xxxx), subject, value, delta
0x80000 raw command no 1 "command no = %08x" then loop
other ERROR COMMAND! — bail

ATK payload (the "attack data" every move has at least one of)

5 cells, high half of cell 0 = 0x40002:

Slot Field Stored at Display format
0 (opcode) — "ATK:"
1 power this+0x26D8 "power=%x"
2 range_raw this+0x26DC "range=%7.3fm" — range_raw / g_LuxMoveVM_AtkRangeDivisor (float at 0x143E8A504)
3 speed this+0x26E0 "speed=%x"
4 direction_mask this+0x26E4 bits below

direction_mask bit → mnemonic (the "dir=" field the debug dumper emits):

Bit Char Meaning
0x0001 0 8-way dir 0 (neutral)
0x0002..0x0080 1..7 8-way dirs 1..7
0x0100 S Sit / crouch
0x0200 U Up (jump)
0x0400 M Mid
0x0800 L Low
0x1000 D Down
0x2000 G Guard (+G sim-press)

A single ATK cell is one hitbox pulse, not the whole move. A multi-hit string is encoded as ATK, timegap, ATK, timegap, …, where the BTN+TIME pair after an ATK is its active-phase window. That is where the frame data hides: startup / active / recovery is the sum of the BTN+TIME cells between ATKs.

Literal encoding (packed float args)

Float literals embedded in an opcode arg cell are stored as a signed 16-bit half-float with two's-complement sign, not the standard IEEE-754 FP16 format. LuxMoveVM_DecodeLiteralArg @ 0x1402FC560 does the decode:

  • bit 15 of the short is the native two's-complement sign; if the short is negative, the decoder takes abs() and ORs 0x80000000 into the FP32 result.
  • bits 14..10 = 5-bit FP16 exponent (bias 15); rebiased to FP32 bias 127 by + 0x38000000 in FP32-bit-pattern space.
  • bits 9..0 = 10-bit FP16 mantissa; aligned to FP32 by << 13.
  • the literal 0x0000 is a short-circuit for 0.0f (otherwise the decoder would emit 0x38000000 ≈ 7.63e-6f).

Denormals, Inf, and NaN are not handled specially — the shipping bytecode only uses finite values. When authoring new bytecode, to emit a float literal x from a packing tool, pack pack_fp16(abs(x)) and then negate the resulting short if x < 0.

BTN+TIME pair (the "hold this input for N frames" primitive)

First cell: op_high = 0x1xxxx. Low 16 bits encode the button-mask using numpad-style direction notation + the three attack buttons + Guard:

Bit Mask Token Meaning
0 0x0001 _W Walking (held-direction modifier)
1 0x0002 _1 Down-back
2 0x0004 _2 Down (crouch)
3 0x0008 _3 Down-forward
4 0x0010 _4 Back
5 0x0020 _5 Neutral (no direction)
6 0x0040 _6 Forward
7 0x0080 _7 Up-back
8 0x0100 _8 Up
9 0x0200 _9 Up-forward
10 0x0400 _A A (horizontal slash)
11 0x0800 _B B (vertical slash)
12 0x1000 _K K (kick)
13 0x2000 _G G (guard)
14 0x4000 _NOGUARD Unblockable-attack marker
15 0x8000 _C Charge / Critical Edge

So a BTN+TIME mask of 0x0840 = _6 | _B = numpad notation 6B (Mitsurugi's canonical mid). 0x2400 = _A | _G = AG (Guard Impact attempt).

Source: emitting code at LuxMoveVM_ExecuteAndDumpOpcode @ 0x140365900 (the strncat_s sequence verifies each bit's mnemonic).

Second cell: time value (reinterpreted as float; stored at this+0x26B0).

The unit is game frames — 1.0f = 1 frame. (It is scaled by g_LuxMoveVM_UnitsPerInt @ 0x143E89FD4 where int comparisons happen in the IF subject branch.)

ATB (hit-reaction chain)

Format: 0x40001 <combo_id> then a variable number of yarare-id cells that belong to the same ATB. The ATB terminates when the next cell has a 0x4xxxx upper half that isn't 0x40001.

Each yarare cell is an id into the per-character yarare table. The yarare id tells the engine which hit-reaction animation the opponent plays, and that is exactly what determines on-hit and on-counter-hit frame advantage. In SC6, frame advantage is not stored on the attacker's move — it is stored on the defender's hit-reaction, so ATK.yarare_onhit → Yarare[id].Recovery - Attacker.Recovery. For a website, either export the computed delta per yarare id or expose both raw numbers.

IF / notif

5 cells: op, class, subject, value, delta. class is one of 0x60001..0x60006 (six test families). subject is the specific register, and the conditional jumps to cursor += delta when the test passes.

subject values above 0x60028 directly index a per-character table:

  • [subject - 0x60028] in 0x60028..0x60037 (16 entries, stride 2 bytes) → g_LuxCharaAttrTable_Byte_0x181cStride @ 0x14470FCD8
  • 0x60054.. → a second larger table at g_LuxCharaAttrTable_Int_0x3038Stride @ 0x1447123BC

Both tables are row-major, indexed by chara+0x23C (character kind byte). Per-row size is 0x181C and 0x3038 bytes respectively. With ≈44 styles this puts each table in the ~270 KB / ~540 KB range — these are the real per-character tuning tables.

Condition-flag ring (on the chara, not the command player)

Each IF subject in 0x60007..0x60058 reads one uint32 / int32 / float slot inside chara+0x19F0 .. chara+0x1A64. The full subject → chara-offset map is in the plate comment on 0x140365900. The start! opcode of every move zeroes the ring, so these are per-move transient flags (e.g. "opponent has been hit by this move's first attack", "we are in a stance").

IF predicate families — the six test kinds

The class cell in an IF opcode selects one of six native predicate functions. The first three are near-identical state-id lookups in g_LuxMoveStateTable @ 0x1440F4750; the last three pull real game state — range, geometry, move-class pair — from the chara and its opponent.

Class Predicate RVA Shape
A (family 0xF) LuxMoveVM_CheckCharaStateEqualsU16 0x140364FC0 chara->MoveStateId == 0xF && chara->+0x1982 == value
B (family 0x7) LuxMoveVM_CheckNotifFamilyB 0x140365040 same shape, different state-id
C (family 0x1C) LuxMoveVM_CheckNotifFamilyC 0x1403650C0 same shape, different state-id
D (range) LuxMoveVM_CheckRangeOrDistance 0x140365140 terrain/reach sample param units along the opponent direction
E (angle/geom) LuxMoveVM_CheckAngleOrGeometry 0x1403652E0 range + angle rotation + terrain-type filter
F (class-name) LuxMoveVM_CompareMoveClassName 0x140394E30 reads move-class pair vs one of 18 symbolic tokens

All three state predicates look up their state id in g_LuxMoveStateTable (a fixed 0x29-entry table, stride 0x14 bytes per row = 5 × uint32) and match the chara's own MoveStateId at chara+0x2B4A4 against the row's key field.

MSVC inlined per-predicate fast paths that probe one specific row directly before falling back to the linear scan:

Predicate Fast-path row Row addr State-id checked
family A row 15 0x1440F487C 0x0F
family B row 7 0x1440F47DC 0x07
family C row 28 0x1440F4980 0x1C

If neither the fast-path row nor the linear scan finds the state id, the predicate substitutes the sentinel state-id 0x2A (the reserved "not found" marker) so the chara-state compare cannot accidentally match. Modders repointing state-table entries should preserve row order to keep the fast paths working.

Move-class pair descriptor (IF class F)

LuxMoveVM_BuildMoveClassPair @ 0x140394AC0 packs self's and opponent's current moves into a 7-uint32 descriptor that subsequent IF-F predicates compare against:

outPair[0]  self.MoveSubclass (chara+0x19FE)
outPair[1]  self.MoveClassB   (chara+0x6E)
outPair[2]  self.MoveClassA   (chara+0x6C) (low 16)
outPair[3]  self bucket       (see rule below)
outPair[4]  (self.MoveFlags at chara+0x70 != 0) ? 1 : 0
outPair[5]  low16 = opp bucket, mid16 = opp.MoveClassA
outPair[6]  ClassifyMovePairKind(selfMoveClassA, oppMoveClassA)

Only MoveClassA values in [5..12] are meaningful; anything outside that range makes BuildMoveClassPair return 0 without writing the pair. The bucket rule (same for self and opp):

MoveClassA Bucket SC6 category
6, 7 0 horizontals (H-A / H-B)
8 1 kicks
9..12 2 throw / special / lethal
5 — (in-range but bucket stays 0; it's the "mutual-horizontal" probe key)

ClassifyMovePairKind @ 0x140394BB0 maps the (self, opp) MoveClass pair to a small integer kind (0..7) used for yarare / frame-data lookups — horizontal vs horizontal → 7, kick vs throw → 2, etc. When invoked in nMode == 2 (combat resolution) it additionally refines the kind by bCounterHit (a CH inverts certain kind-7 pairings to 0/6). The full map is in the Ghidra plate comment on that function.

IF class-F token cases (LuxMoveVM_CompareMoveClassName)

LuxMoveVM_CompareMoveClassName @ 0x140394E30 takes the 7-u32 pair descriptor plus a class-name token (from bytecode, in [1..18]) and dispatches on token - 1:

Token Checks
1 self bucket == 0 AND pair kind < 2
2 self bucket == 0 AND opp bucket == 2 AND pair kind in
3 if opp.MoveSubclassAlt (opp+0x250) in {100, 0x69} fall into token 11 (opp.MoveClassA == 7)
4 self.MoveSubclass == 2
5 self bucket == 0 AND pair kind == 0
6 self.MoveClassB == 2 AND self.MoveClassA == opp.MoveClassA AND self bucket == 0
7 self has active MoveFlags (arPair[4] != 0)
8 self has no active MoveFlags
9..15 opp.MoveClassA == 5, 6, 7, 8, 0xB, 0xC, 10 (exact class compare)
16 per-chara attribute-table lookup == 0x47 → true, else == 0x4E → result
17 same table lookup == 0x46
18 (reserved / false in the shipping table)

The per-chara attribute table for tokens 16/17 is g_LuxMoveVM_ClassNameAttrTable @ 0x144711EC0 (stride 0x3038, indexed by chara+0x23C).

Opponent & self position access (runtime-verified chara offsets)

Reverse-engineering LuxMoveVM_CheckRangeOrDistance at 0x140365140 confirmed several chara-class field locations that were not previously documented:

Chara offset Type Purpose
+0x18 uint8 VMEnabled (script-active flag, set by opcode 0x9000)
+0x6C int16 MoveClassA (5..12 — SC6 move category)
+0x6E uint16 MoveClassB (subclass)
+0x70 int32 MoveFlags (flag bits)
+0x94 float BodyFacingAngle (used by move-vector opcode 0x4, fed into sinf/cosf)
+0xA0 float SelfPos.X (world-space, VM-predicate reference frame)
+0xA8 float SelfPos.Z (lateral world-space)
+0xC0 float StepPos.X (step-plane / terrain-sample reference frame — DIFFERENT from +0xA0)
+0xC8 float StepPos.Z
+0x140 float MoveVelocity.X (written by step ops 0x4..0x9)
+0x144 float MoveVelocity.Y
+0x148 float MoveVelocity.Z
+0x159C float LeanForward (per-frame lean-forward accumulator, gates reach helpers)
+0x16D3 uint8 opponent "uninterruptible" flag (read as opp+0x16D3 == 1 by gate helpers)
+0x16D4 uint8 TerrainGateFlag (CheckOpponentFrontTerrainMatch early-exit)
+0x16E9 uint8 TerrainGateFlagAlt
+0x1354 uint8 VMMode (clamped 0..3, opcode 0x14)
+0x1370 int32 VMCounterAcc (opcode 0x12 accumulator)
+0x1731 uint8 VMAllowExtraOps (gate for late-loaded opcode families)
+0x1982 uint16 CurrentNotifToken (read by IF families A/B/C)
+0x1994 int16 state word — (value - 1) in [0..2] required by CheckOpponentFrontTerrainMatch
+0x19FE uint16 MoveSubclass (read by BuildMoveClassPair)
+0x1C6A uint16 LastVMOpcode (debug trace of last executed VM opcode)
+0x23C uint8 CharaKindByte (row index into g_LuxCharaAttrTable_* and VFX slot selector)
+0x23E uint8 secondary side/slot byte
+0x250 uint16 MoveSubclassAlt (checked ==100 / 0x69 by IF-F)
+0x252 uint16 VFX state check (== 0x23 patches effect id 0xff → 0xfd)
+0x19AE uint16 secondary VFX state check (== 1)
+0x2B4A4 int32 MoveStateId (key into g_LuxMoveStateTable)
+0x2084 float per-arm param A (VM opcode effect)
+0x2088 float per-arm param B
+0x3510 float scalar+frame+mode triple base (opcode 0x13A8)
+0x3540 float saved-pos backup X (opcode 0x13A3)
+0x3548 float saved-pos backup Y
+0x3550 float saved-pos backup Z
+0x44058 void* optional sub-entity ptr (has int16 @ +0x38 height override)
+0x455A0 FLuxCharaBody* body struct (+0x08 = groundY, +0x10 = topY, +0xD8..+0xE8 = hit parts[])
+0x95770 void* sub-controller A (action-stack)
+0x95788 void* sub-controller B
+0x973E8 ALuxBattleChara* Opponent — direct pointer, NOT chara+0x390 as an earlier pass of these docs claimed
+0x9683C uint32 one of four parallel slot-fields cleared together (opcode 0xB)
+0x968FC uint32 "
+0x969BC uint32 "
+0x96A7C uint32 "
+0x1D90 + N*0x20 FLuxVMSlot[6] per-slot 6-entry ring buffer (opcode 0x13D9)

The opponent pointer at chara+0x973E8 is the critical correction. Earlier versions of this page and of structures.md described chara+0x390 as the opponent pointer, on the basis of Ghidra annotations that turned out to be wrong for this build. Runtime class-name introspection shows chara+0x390 is actually WeaponMesh0 (a USkeletalMeshComponent*); LuxMoveVM_CheckRangeOrDistance dereferences chara+0x973E8 → +0xA0/+0xA8 to read the opponent's world-space position for range checks.

Spatial acceleration chain (call graph underneath predicates D and E)

Predicates D (CheckRangeOrDistance) and E (CheckAngleOrGeometry) are the only two families that actually touch the arena geometry. Both fan out through the same helpers into a single shared acceleration grid:

LuxMoveVM_CheckRangeOrDistance                (0x140365140)
 └─> LuxBattle_TryTraceSegmentAgainstBounds   (0x140314BC0)
      └─> LuxBattle_TraceSegmentThroughFrameBoundsGrid (0x1403149E0)
           └─> LuxBattle_TestFrameBoundsCell  (0x1403916E0)
                └─> LuxBattle_IntersectSegmentWithTerrainTriangle (0x140390A90)

LuxMoveVM_CheckAngleOrGeometry                (0x1403652E0)
 ├─> LuxBattle_TryTraceSegmentAgainstBounds   (0x140314BC0)      — same reach test
 └─> LuxBattle_SampleTerrainAtWorldXZ         (0x1403915A0)
      └─> LuxBattle_SampleTerrainAtXZ_Impl    (0x140391350)
           ├─> LuxBattle_FindTerrainRowBucketByZ        (0x140390E90)
           ├─> LuxBattle_IsTerrainProbeInsideTriangleXZ (0x140391270)
           └─> LuxBattle_IsTerrainEntryActive           (0x140391270 path)

Key globals on the shared layer (see structures.md § "Stage / frame spatial acceleration"):

  • g_LuxBattle_FrameContextUseB @ 0x14470DEDC — byte flag; selects A vs B variants.
  • g_abLuxBattleFrameBoundsGridA @ 0x144844DD0 / g_abLuxBattleFrameBoundsGridB @ 0x144845E80 — the two 0x44C-byte grids of terrain entries that the VM traces against.
  • g_abLuxBattleFrameTransformContextA @ 0x144844170 / g_abLuxBattleFrameTransformContextB @ 0x144845220 — matched 0x10AC-byte contexts; each bounds grid is embedded at +0xC60.
  • g_LuxBattle_TerrainProbeUp @ 0x1440FBC38 / g_LuxBattle_TerrainProbeDown @ 0x1440F7688 — two 16-byte vec4 scratch slots. Primed by SampleTerrainAtXZ_Impl as vertical probes at ±100 units, reused by IntersectSegmentWithTerrainTriangle as edge-cross-product scratch for the point-in-triangle step. NOT thread safe.

The triangle entries the grid holds are arena frame walls and floor tris, not character hitboxes. That is why these two predicates answer questions like "is the chara close enough to the ring-out edge to use this ring-throw" or "is there a wall behind the opponent for this wall-splat". They do not resolve move hit detection.

If you are instrumenting the geometry chain, the cheapest single-detour point is LuxBattle_TryTraceSegmentAgainstBounds (0x140314BC0) — both predicates call it, and its parameters are the fully-baked segment plus filter tags.


Stances

Stances are not a separate type. In SC6 they are just moves tagged with ELuxBattleMoveCategory::SpecialStance and/or ELuxBattleMoveEffectType::SpecialStance, whose command-script does not terminate after the animation. While the stance move is queued, the command player keeps its condition-flag ring alive, and follow-up inputs dispatch into different moves via the IF branches.

Concretely:

  • The Training-Mode "move list" exposes a stance as a single row with EffectTag = SpecialStance.
  • The gameplay layer encodes the stance as a move whose bytecode ends in a goto -1 (loop) gated by IF tests on per-chara flag bits. Flag bits flipped by enter/exit moves are at chara+0x19F0..+0x1A64.
  • Moves that exit a stance (e.g. A from Amy's stance) query those bits via IF class 0x60001, subject 0x60016..0x6001c, then goto into a different command region.

Any frontend wanting to render stance trees needs to walk IF-goto edges from each stance's entry move — the "tree" is implicit in the bytecode.


On-hit / on-counter-hit / on-block

Crucial point for a frame-data website: SC6 does not store {on_hit, on_ch, on_block} as three integers on the move. It stores:

  1. Per ATK cell: (power, range, speed, dir_mask), where speed is the attack's startup/active window.
  2. Per ATB cell: a set of yarare ids — which reaction the opponent plays. These include separate ids for on-hit vs counter-hit vs low-parry etc.
  3. In the character-attribute tables (g_LuxCharaAttrTable_*): the actual frame counts (stagger/hit-stun/block-stun) per yarare id.

To produce +2 on hit / +6 on CH you need to compute:

on_hit       = YarareTable[atb.yarare_onhit].opponent_stun
             - MoveRecovery(attacker_move)

on_counter   = YarareTable[atb.yarare_oncounter].opponent_stun
             - MoveRecovery(attacker_move)

on_block     = YarareTable[atb.yarare_onblock].opponent_stun
             - MoveRecovery(attacker_move)

MoveRecovery is the sum of BTN+TIME cells from the final ATK to the end of the bytecode.


Yarare reaction system

The "yarare" (やられ — defender's hit-reaction) pipeline runs on the defender side, parallel to but distinct from the attacker's move-VM. It decides which hit-reaction animation the defender plays, when to chain into a follow-up reaction, and when to commit damage.

Pipeline

<per-tick>
  → LuxMoveVM_TickPickAndDispatchReaction  @ 0x1402DEF50
      (picks a yarare id from the opp's yarare table; prob-rolled fallback)
       → LuxBattle_DispatchYarareReaction  @ 0x1403521B0
            (80-case switch; per-yarare setup; commits to vmCtx)
       → LuxMoveVM_TickActiveYarareReaction  @ 0x14035EF30
            (advances an in-progress reaction one tick)
            → LuxMoveVM_TickYarare_<id>      (per-id ticker, ~37 functions)

LuxMoveVM_TickPickAndDispatchReaction @ 0x1402DEF50

Per-tick reaction picker. Reads the opponent's yarare table at opp+0x10 + idx*0x12 (entries 0x12 bytes apart) and walks it for the first entry whose LuxBattle_CheckYarareReactionGate @ 0x140362E70 returns non-zero. If no gate matches, it falls through to a probabilistic roll between yarare 0x21 (default) and yarare 0x42 (back-breaker / crit, ~1/3). The output is vmCtx->dwPostEffectYarareId + dwPostEffectBodyPart; it then calls the dispatcher.

Side effects beyond the obvious:

  • Maintains 3 reaction-timer slots at vmCtx + 0x2B40 / 0x2B44 / 0x2B48 — advanced when the chara is in reaction state chara+0x19A4 == 2/3.
  • Walks a circular yarare-history ring at chara+0x2188 (0x140 entries, stride 0xC, payload at +0x2190) to detect reaction transitions.
  • Probes LuxBattle_CheckStepApproachClearance against a per-chara-kind scratch at &DAT_14470E2E0 + chara+0x23C * 0x18.
  • Updates a height-delta accumulator at vmCtx+0x2AF8 + idx*4 based on the opponent's active-cell +0x32 flag bits (different masks → different per-tick deltas using g_LuxMoveVM_FrameDivisor60 / g_LuxCmToUEScale).
  • Lazy-mallocs a 0x444-byte per-hit-intensity reaction-param block at DAT_14470E018 and reads its +0x38 float[7] modifier table for intensity-keyed weight scaling.

LuxBattle_DispatchYarareReaction @ 0x1403521B0

Initiates a yarare. Signature: void(FLuxMoveCommandPlayer* vmCtx, uint yarareId, short bodyPart)

Pre-switch normalisations applied to yarareId:

  • id == 3 && opp+0x19C0 == 0x25 → remap to 6.
  • id in 1..0x1A && (opp+0x19A4 in {0xD,0xF} || opp+0x1982 == 0x50) → probabilistic remap to {0x21, 0x35, 0x36} based on velocity² gate DAT_143E8A360.

Then dispatches via 80-case switch. Categories:

Range Category Handler
0x01..0x1A Generic light hit-reactions / staggers inline (bitmask + sentinel)
0x03 Crumple-fall (intensity-scaled hang) inline + nPostDispatchHang
0x1B / 0x20 / 0x26 / 0x27 / 0x2B / 0x3A Cosine-randomised stagger / knock inline
0x1C Reset-to-idle (reach=60.0f) inline
0x1E / 0x50 / 0x43 Knockdown (reach=30.0f) inline
0x44 / 0x45 Quick-rise tech (reach=3.0f) inline
0x28 / 0x2A Null / pure state-swap inline
0x29 Hit-vs-block branch ClassifyStepApproachDirection
0x1D Special evil reaction InitYarare_0x32
0x1F / 0x21 / 0x38 Standard launch / poke InitYarare_0x1F
0x22..0x25 Medium reactions InitYarare_0x22..0x26 (note 0x25 → handler 0x26)
0x2C / 0x2D Launch / air-carry InitYarare_0x2C(id)
0x2E..0x31 Ring-out fall variants SetupRingoutFallVariant(id)
0x32 Pick ring-out reaction PickRingoutReactionYarare
0x33..0x37, 0x39 Heavy reactions InitYarare_0x33..0x37, 0x39
0x35 / 0x36 Aerial-forced-launch (remap target) InitYarare_0x35 / 0x36
0x3B Back-breaker InitYarare_0x3B
0x3C / 0x3D Mirrored handlers (note id↔handler swap) InitYarare_0x3D / 0x3C
0x3E..0x41 Heavy mid/special InitYarare_0x3E..0x41
0x42 Critical-edge / back-breaker special InitYarare_0x42
0x46..0x4E Get-up / parry-break InitYarare_0x4F(id) (return → vmCtx[1].vtable)
0x4F Parry/recovery InitYarare_0x50

Output side effects (vmCtx writes):

Offset Field Notes
+0x2B30 dwActiveYarareId echoes the resolved id
+0x2B34 dwReactionTimer reset to 0
+0x2B38 dwActiveYarareAce reset to 0
+0x2B24/+0x2B28/+0x2B2C prior triple snapshot for transition diff
+0x2AD0 dwReactionKindBitmask 1 << bucket via LuxBattle_YarareIdToReactionBitmask
+0x2AD4 yarareId echo
+0x2AD8 flRingout_EdgeReach
+0x2C34 nScaledKnockbackReach
+0x2C4C nPostDispatchHang
+0x3008 dwBodyPartStash param 3 stashed here
+0x2B68 / +0x2B74 / +0x2B8C / +0x2BC0 / +0x2BD0 / +0x2C44 / +0x2C5C per-handler sub-mode flags values 4/8/9 etc.
+0x2BD0..+0x2BD8 → +0x2BE8..+0x2BF0 epilogue mirror

Per-character variation enters via chara+0x23C (CharaKindByte) only, not chara+0x250. Two per-character tables drive most tuning:

  • DAT_14470E330 stride 0x180, indexed by CharaKindByte. Holds 32 entries of {u32 pad, s16 oppMoveStateId, s16 reachOffset, u32 pad}. Modelled as FLuxBattleCharaReachModBlock / FLuxBattleCharaReachModEntry.
  • DAT_144711F88 stride 0xC0E, indexed by CharaKindByte. Per-character hit-intensity attribute. Read by sub-handlers (0x42, 0x32, 0x1F).

Plus the lazy-allocated per-intensity scratch block:

  • DAT_14470E018 size 0x444. +0x310 int32[7] primary knockback distance per intensity, +0x32C int32[7] secondary, blended by flRngRoll. Modelled as FLuxBattleYarareReactionParamBlock.

LuxBattle_CheckYarareReactionGate @ 0x140362E70

The gate function. Takes (ctx, self chara, gate id 1..0x87) and returns 1 if the candidate yarare entry is valid for the current battle context. There are some 135 cases, but they group roughly as:

  • Direct state checks (cases 1..2, 0x27..0x2C, 0x73..0x82, 0x85..0x86) — read a single chara state byte/short.
  • Range / position predicates (0x05..0x14, 0x17..0x26, 0x6E..0x71) — call helpers like CheckYarareGate_StepRange, CheckYarareGate_AerialRange, CheckYarareGate_SelfDistanceBucket.
  • Per-chara reach-mod gates (0x2F..0x38) — read DAT_14470E310 + CharaKindByte*8 and clamp by DAT_144711F88 per-char hit intensity.
  • Quadrant / direction probes (0x3E..0x49) — LuxMoveVM_ClassifyRangeBucket returns 0=front / 1=back / 2=left / 3=right, gate matches against case 0x3E..0x49 for that direction.
  • Move-state / category gates (0x3..0x4, 0x12..0x16, 0x42..0x45, 0x6A..0x6D, 0x83..0x84) — terrain/category/perfect-guard wiring.
  • Battle-phase / round gates (0x75..0x76).

Modders adding new yarare entries author them as {yarareId, weight, bodyPart} triples at chara+0x10 + idx*0x12, then pick a gate id from 1..0x87 to control when the entry applies. The first match wins.

LuxMoveVM_TickActiveYarareReaction @ 0x14035EF30

Per-tick advancer for already-active reactions. Reads vmCtx->dwActiveYarareId and dispatches to one of ~37 per-id ticker functions named LuxMoveVM_TickYarare_<id>. Returns 0 (continue) or non-zero (signal pick-and-dispatch to abort and re-evaluate next tick).

The per-id tickers handle stagger windups, launch-arc trajectories, ring-out fall, wall-bounce, get-up, parry recovery, and so on — each is a short state machine that mutates lane state and ring-history. They share two helper tickers:

  • LuxMoveVM_TickWithAirStageTracking — for reactions that need airdance / juggle stage tracking.
  • LuxMoveVM_TickWithNotifTokenSwitch — for reactions that branch on the chara's CurrentNotifToken mid-flight.

Per-character move bank layout

Every chara has a single binary blob of bytecode, cell, and event data hanging off chara+0x455C0. It is resolved by LuxMoveVM_ResolveBankSlot @ 0x1402FC400.

FLuxMoveBank (header, 48 bytes at chara+0x455C0)

Offset Size Type Field Notes
+0x00 8 qword header magic / unread unread by hit pipeline
+0x08 4 dword unknown
+0x0C 2 u16 unknown
+0x0E 2 u16 wEventRecordCount count of records in event-record table
+0x10 4 u32 dwAttackCellArrayOffset offset to AttackCell array (bank + bank[+0x10] + cellBone * 0x70)
+0x14 4 u32 dwNonAttackDescTableOffset offset to non-attack 6-byte descr table (bank + bank[+0x14] + (subIdx & ~0x10000) * 6)
+0x18 4 u32 dwEventRecordTableOffset offset to event-record table (stride 0x30, count = wEventRecordCount)
+0x1C 4 FLuxMoveBankBucket bucket0 (StartIdx, Count) ResolveBankSlot uses (bank>>12)&3 to pick a bucket
+0x20 4 FLuxMoveBankBucket bucket1
+0x24 4 FLuxMoveBankBucket bucket2
+0x28 4 FLuxMoveBankBucket bucket3
+0x2C 4 u32 reserved / padding
+0x30 … array slot table starts stride 0x48; (BucketN.StartIdx + slotInBucket) * 0x48

FLuxMoveBankSlotView (slot record view, 0x48 bytes; offset 0x30 into the actual slot)

ResolveBankSlot returns bank + (StartIdx + slot) * 0x48 + 0x30. The +0x30 is intentional: the structure straddles two adjacent slot records so the engine can read related fields with one dereference. All offsets below are relative to the returned pointer, not to the absolute slot start.

Offset Size Type Field Notes
+0x00 2 i16 psVar22 base motion-init-loop base in TransitionToMove
+0x04 2 i16 wField_04 motion arg
+0x10 4 u32 dwSubTableOffset_10 per-slot offset into a sub-table
+0x14 4 u32 dwSubTableOffset_14 per-slot offset into a sub-table
+0x1C 4 u32 bytecode offset (alt path) added to bank base if != -1
+0x20 8 u64 qwInputMask_20 copied to lane+0x448 at TransitionToMove
+0x28 8 u64 qwInputMask_28 copied to lane+0x450
+0x30 4 f32 flAnimLength_30 move's anim length (60Hz frames)
+0x34 2 i16 wAnimLengthFlag_34 -2 = use computed length from playback speed
+0x36 2 i16 wHitWindowStart hit-window start frame (cell.MasterWindowStart for primary cell)
+0x38 2/4 union hit-window-end short OR bytecode-offset dword TransitionToMove reads a short here, ExecuteBankSlotScript reads a dword (uses high bits as offset). UNION layout.
+0x3C..+0x46 12 i16[6] nCellBoneIndexPerVariant[6] Variant index → AttackCell index. -1 = no attack on this variant. The lane's +0x460 AnimVariantIndex (default 0) selects which entry to use.

The variant table at +0x3C is the per-variant cell-bone-id picker: calling LuxMoveVM_SetActiveMoveSlot(chara, variantIdx) from script-side reflection re-resolves chara+0x44058 to a different AttackCell. See Cell lifetime for why this is rarely exercised at runtime.

Sub-table inventory

Sub-table Located by Stride Purpose
Slot table bank+0x30 0x48 walked by ResolveBankSlot to find a slot
AttackCell array bank + bank[+0x10] 0x70 FLuxMoveBankCell rows; one cell per move-bone-id
Non-attack descriptor table bank + bank[+0x14] 6 (i16 DamageMultiplier, i16 PassthroughTag, i16 Duration60ths) — non-attack movedef payloads (see Non-attack ALT-path)
Event record table bank + bank[+0x18] 0x30 LuxMoveVM_CollectHitEventsForFrame walks this per-frame for sound/VFX/hit events; built into a Red-Black BST at chara+0x455C8 by LuxMoveVM_BuildMoveBankEventRecordTree (event-setup)

Bank readers

Function RVA Reads
LuxMoveVM_ResolveBankSlot 0x2FC400 bucket headers bank+0x1C..+0x2B; returns slot+0x30 view
LuxMoveVM_TransitionToMove 0x2FE350 full slot view: +0x00, +0x04, +0x1C, +0x20, +0x28, +0x30..+0x46; bank+0x10 (cell array) and bank+0x14 (alt-path)
LuxMoveVM_SetActiveMoveSlot 0x300C70 slot+0x3C nCellBoneIndexPerVariant; bank+0x10
LuxBattle_TickHitResolutionAndBodyCollision 0x33CCA0 bank+0x10 cell array
LuxBattle_ComputeHitContactVFXCode 0x30FC20 bank+0x10
LuxChara_ResolveBankSlotEntry 0x309D40 bank+0x10
LuxMoveVM_BuildMoveBankEventRecordTree (event setup) 0x2F8310 bank+0x18 + bank+0x0E

Inner stack-based predicate VM

Distinct from the outer move-script VM at LuxMoveVM_ExecuteAndDumpOpcode, SC6 also has a tiny stack-based interpreter at LuxMoveVM_ExecuteBytecode @ 0x1402E5A30. Despite the historic "predicate VM" label, it is the authoring engine for per-move scripts: it evaluates predicates AND fires side effects — it registers effect ops onto the lane's effect-op table, reads and writes per-player and per-script variable banks, calls nested bank-slot scripts, and schedules move-to-move transitions by writing lane[+0x5A]. The "predicate" label fits only the read-only sub-opcodes inside CALLCOND funcIdx=0x00/0x01; the dispatch table is much wider.

Three call sites

The interpreter is wrapped by LuxMoveVM_RunBytecodeScript @ 0x1402E67B0, which in turn is called from three concrete places:

Caller Address When
LuxMoveVM_TransitionToMove 0x1402FE350 Once at move start (the slot's dwBytecodeOffset_38 script — registers effect ops, sets up state, may schedule transitions)
LuxMoveVM_ExecuteOpStream 0x1402FDEA0 Per-tick on each lane (re-runs the same dwBytecodeOffset_38); also dispatches the effect-op table at lane+500
LuxMoveVM_RunSecondaryLaneScript 0x1402FE1C0 When swapping the secondary lane during a primary-lane transition

Plus an indirect call site:

  • LuxMoveVM_ExecuteBankSlotScript @ 0x1402FCC30 — invoked via CALLCOND funcIdx=0x0D from inside another script; lets one script chain into another bank slot's bytecode. The single most common opcode in shipping bytecode (Cervantes 3B's slot 0x13C alone calls 8 sub-scripts through it).

Encoding

1-byte opcodes with optional 2-byte operands. Setting bit 0x80 on the opcode also pushes ACC (the result of the just-executed op) onto the stack after it runs. The function returns only on opcodes 0x02 / 0x05 / 0x06 / 0x07 / 0x08 — the high bit alone does not exit.

VM globals (all int16, per-player or per-bytecode)

Address Name
0x14470D5C0 per-player global var bank base — set per-call by RunBytecodeScript to &g_LuxBattle_CharaKindStatureTable + chara[+0x23C]*0xF0
0x14470D5D0 current ACC value (also "current stack top" used by stack reads)
0x14470D5D2 stack pointer (ring index)
0x14470D5D4 stack mask (e.g. 0x0F for 16-entry ring)
0x14470D5D8 stack buffer base
0x14470DDE0 break/interrupt flag (set by 0x07 RETBRK / 0x08 BRK)
0x14470DE68 g_LuxMoveVM_DeferredTransitionScheduleFrame (set by OpcodeIf_15)
0x14470DE6C g_LuxMoveVM_DeferredTransitionScheduleFlag (set by OpcodeIf_15)
0x14470DEC0 g_LuxMoveVM_DeferredTransitionCommitFlag
0x14470DEA0..0xDEB8 per-call save/restore window for VM context fields (used so that DecodeVariadicStreamArgs can reach the active lane without clobbering the outer caller's context)

Variable addressing

  • varIdx 0x00..0xEF → per-player globals at g_LuxBattle_CharaKindStatureTable + chara[+0x23C]*0xF0 + varIdx*2
  • varIdx 0xF0..0xFF → local frame (16 int16 slots, copied from caller args via memcpy in RunBytecodeScript)
  • varIdx 0x100..0x10F+ → stack-relative ring (ring[(idx - 0x100 + ACC_idx) & mask]) — these are persistent across CALLCONDs and the primary slot helpers use to pass computed move-ids between scripts (see Cervantes 3B example below)

Opcode reference (op & 0x7F)

Op Mnemonic Operand Effect
0x00 NOP / pad — no-op
0x01 PUSH_IMM u16 push 16-bit literal (only if non-zero)
0x02 RET2 — return ACC (also routes to caseD_2 epilog)
0x05/06 RET — pop and return stack top
0x07 RETBRK — pop+return, set break flag = -1
0x08 BRK — set break flag = -1
0x09 SET_ACC_U16 u16 ACC = u16 (variant)
0x0A LOAD u16 varIdx load var → ACC
0x0B SET_ACC_U16 u16 ACC = u16 (variant; most-used authoring op — pairs with +0x80 push to push immediates onto the stack before a CALLCOND)
0x0C..0x10 ADD/SUB/MUL/DIV/MOD — top two stack values
0x11 NEG — -stack[sp]
0x12/0x13 POSTINC / POSTDEC u16 varIdx var ± 1; ACC = old
0x14..0x18 AND/OR/LNOT/SHL/SAR — bitwise / logical
0x19 STORE u16 varIdx pop → var
0x1A..0x1E ADD/SUB/MUL/DIV/MOD_ASSIGN u16 varIdx var op= top
0x1F..0x24 EQ/NE/LT/LE/GT/GE — int comparisons
0x25 CALLCOND u8 funcIdx, u8 argc pop argc shorts into local_68[argc-1..0] (REVERSE order: local_68[0] = oldest pushed, local_68[argc-1] = top of stack); call g_LuxMoveVM_OpcodeIfDispatchTable[funcIdx](pVM, argc, local_68); ACC = return
0x26/0x27 PUSH_ACC / POP_ACC — accumulator/stack swap
0x28/0x29 JNZ / JZ u16 PC conditional branch (pops top)
0x2A JMP u16 PC unconditional jump

CALLCOND dispatch table (g_LuxMoveVM_OpcodeIfDispatchTable @ 143E83A90)

A 38-entry array of native function pointers, indexed by the funcIdx byte of the CALLCOND opcode. This is where the inner VM "escapes" into native code to read state, schedule transitions, register effect ops, and call nested scripts.

funcIdx Function Address Role
0x00, 0x01 LuxMoveVM_EvaluateIfOpcode 0x1403732F0 Master predicate dispatcher (~250 sub-opcodes — local_68[0] is the sub-op id, [1..] are args). Read-only. See Predicate sub-opcodes below.
0x02, 0x03 LuxMoveVM_DispatchEffectOp 0x140376B20 Outer effect VM (~60 opcodes — VFX spawn, palette swap, anchor probes).
0x04, 0x19, 0x1A LuxMoveVM_OpcodeIf_RegisterEffectOpDedup_04 0x1402FD4A0 Inserts (triggerFrame, funcId, args[]) into the active lane's 16-slot aEffectOpTable_1F4; deduplicates duplicates.
0x05, 0x06, 0x07, 0x08 LuxMoveVM_DecodeVariadicStreamArgs (TransitionAuthor) 0x1402FC930 The lane-transition writer. Writes lane[+0x5A] = NextMoveID (immediate path) or lane[+0xB4] (deferred path when OpcodeIf_15 has set the schedule flag). First popped arg = move id; second = timing index; third = threshold index; rest = motion args.
0x09 LuxMoveVM_OpcodeIf_09_LatchCharaStateFlag 0x1402FD720 Latches a chara state flag (paired with OpcodeIf_0A).
0x0A LuxMoveVM_OpcodeIf_0A 0x1402FD7D0 Companion to 0x09.
0x0C LuxMoveVM_OpcodeIf_0C 0x1402FDA50 (TBD)
0x0D LuxMoveVM_ExecuteBankSlotScript 0x1402FCC30 Nested-script call. First popped arg = bank slot id (packed (bank<<12)\|slot); remaining args become the called script's LOAD_VAR 0xF0..0xFF local frame. Used pervasively by character scripts to chain into shared bank-3 helpers.
0x0E/0x0F/0x10/0x11/0x14 LuxMoveVM_OpcodeIf_* 0x1402FD3A0 etc small accessor helpers
0x15 LuxMoveVM_OpcodeIf_15_ScheduleTransitionScript 0x1402FCD30 Deferred-transition wrapper. Sets g_LuxMoveVM_DeferredTransitionScheduleFlag = 1, then calls ExecuteBankSlotScript; any TransitionAuthor inside writes lane[+0xB4] (deferred) instead of lane[+0x5A] (immediate). Drained by OpcodeIf_16 next tick.
0x16 LuxMoveVM_OpcodeIf_16_DrainPendingTransition 0x1402FCDE0 Drains lane[+0xB4] and calls LuxMoveVM_TransitionToMove (clears chara[+0x16EB] multi-hit lockout as a side-effect).
0x17/0x18 LuxMoveVM_OpcodeIf_17/18 0x1402FCF10/0x1402FCF60 Save/restore deferred-transition staging.
0x1A..0x1E LuxMoveVM_OpcodeIf_1A..1E 0x1402FCDC0..1402FD480 Misc lane-state mutators.
0x25 LuxMoveVM_EvaluateIfOpcodeWithHeader 0x1402E5830 Calls EvaluateIfOpcode with sub-op 0x0008 prepended (i.e. invokes the "frame-window match" predicate of family 8 with the script's argc as the rest).

CALLCOND predicate sub-opcodes (funcIdx 0x00/0x01)

Inside LuxMoveVM_EvaluateIfOpcode, local_68[0] selects which of the ~250 sub-opcodes runs. Highlights for instrumentation:

Sub-op Reads Args Returns
0x05 chara->dwGuardControlWord + motion-history ring at chara+0x2190 [1]=motion mask, [2]=min match count, [3]=scan depth 1 if motion sequence matched in window
0x06 chara+0x2192 motion ring (OR-fold) [1]=motion mask, [2]=ring depth flag-check result
0x08 lane+0x1C (signed frame counter), LuxMoveVM_CheckFrameInTimingWindow [1..]=window samples 1 if any sample matches
0x09 *((*chara->field_0x455a0)+0x24) (active-move scratch) none raw u16
0x0A walks +0xD8/+0xE0 prior-attack history slots none "did my last attack land?"
0x0B/0x0C (&chara->field_0x16d0)[(short)args[1]] (chara byte-flag table indexed by +0x16D0..+0x170F) [1]=flag idx byte (0x0C is the negation)
0x0E *(ushort*)(lane+2) [1]=move id opcodeArgs[1] == lane.moveId ("am I in move X?")
0x0F lane+0 (low 16) [1]=class/2 move bank/category match
0x10 *((*chara->field_0x455a0)+0x26) none raw u16 (active-move flag)
0x11 chara->field_0x24c [1] self stance/sub-state == arg
0x14 chara->flLeanForward (chara+0x159C, the cached XZ self↔opp distance) [1]=MIN, [2]=MAX, sentinel 0x7FFF disables a side 1 when MIN ≤ distance ≤ MAX (after dividing through g_LuxMoveVM_AtkRangeDivisor) — the horizontal range-band check used by auto-extension scripts
0x18 chara->bCharaKindByte [1] character-id match
0x1E calls LuxBattleChara_QueryHeightAboveTerrain [1] "am I far enough above floor"
0x35 pOppChara->nDefenseModeAtLastHit [1..N] OR of equality checks (not move id)
0x54 *((*chara->field_0x455a0)+0x28) none raw u16 (active-move flag)
0x9C chara->flAngleScratch15A0 [1]=MIN, [2]=MAX, 0x7FFF sentinel vertical Y-axis variant of 0x14
0x13B3 chara->field_0x444F2 + opcodeArgs[1]*0x468 [1]=lane idx 0..2 per-opponent ushort flag
0x13B8/0x13BA chara->dwAtkRangeRaw1/2 * AtkRangeDivisor none raw range value
0x13BD pOppChara->field_0x43E08 [1]=threshold scaled-compare
0x13CF generic 5-arg comparator (selects one of 5 chara floats, with comparator op + threshold) [1]=field, [2]=unit-flag, [3]=cmp-op, [4]=threshold result of compare

LuxMoveVM_EvaluateIfOpcode itself never writes chara->dwCurrentMoveID or lane[+0x5A], and never calls TransitionToMove — it is purely a predicate evaluator. Side effects (transitions, effect-op registration, nested-script call) come from the OTHER funcIdx slots in the dispatch table.

How transitions actually fire

  1. A move-start or per-tick script runs CALLCOND 0x07/0x05/0x08 (TransitionAuthor), pushing a target move-id (literal or via LOAD_VAR from a stack/local var). DecodeVariadicStreamArgs writes pLane->nNextMoveID_5A and a threshold frame at pLane->flTransitionThreshold_68.
  2. On each subsequent tick, LuxMoveVM_ExecuteOpStream calls LuxMoveVM_CheckMoveTransitionTiming @ 0x1402FDD70, which checks if pLane->flTransitionThreshold_68 <= other_lane.CurrentAnimFrame. When the threshold is met, it calls LuxMoveVM_TransitionToMove(chara, laneIdx, nNextMoveID_5A, ...).
  3. The deferred path (via OpcodeIf_15 wrapping a sub-script that contains a TransitionAuthor) writes pLane->nDeferredMoveID_B4 instead, and is drained on the next tick by OpcodeIf_16 calling TransitionToMove directly.

The lane[+0x5A] override chain (priority high → low, all in FLuxMoveLane):

Field Override condition
nMoveIDOverrideMode2_60 chara+0x973E8 + 0x132C > 1 (mode-2 gate)
nMoveIDOverride16EB_5E chara+0x16EB != 0 (multi-hit lockout)
nMoveIDOverride16FE_5C chara+0x16FE != 0
nNextMoveID_5A default — set by DecodeVariadicStreamArgs

Indirect transitions (move id from a variable)

Move ids passed to TransitionAuthor do not have to be literals. The bytecode pattern LOAD_VAR 0x010N + CALLCOND 0x07 argc=N reads a 16-bit move id from the stack-relative variable bank (vars 0x0100.. map to ring slots relative to the current stack pointer) and uses it as the transition target. This is the workhorse pattern for auto-extension moves: a helper script authors a move id into STACK[0x0100] based on some predicate result, then an ancestor or sibling script reads STACK[0x0100] back and transitions to it.

In a 1 MB shipping move-bank file, expect ~1200 TransitionAuthor calls, of which ~57 are indirect (LOAD_VAR-driven) and the rest are literals.

Cervantes 3B (move 0x013C → 0x013D auto-extension) — worked example

The 3B move slot pair is a textbook case of the indirect-transition pattern:

  • Slot 0x013C (cellBoneIndex [0,0,0,0,0,0] → cell 0 with the non-attack-mask 0x0100000000000000) is the wind-up that always plays first.
  • Slot 0x013D (cellBoneIndex [81, 82, -1, ...] → real attack cells with mask 0x0300000006000010) is the extended version with the actual damage windows.

Slots 0x13C and 0x13D share bytecode: 0x13D's dwBytecodeOffset_38 is exactly 0x9B bytes after 0x13C's, and 0x13C's bytecode terminates with a RET2 at offset +0x9A — so the second half of 0x13C's bytecode IS slot 0x13D's per-tick script. Only the first 0x9B bytes — slot 0x13C's "extra" intro — distinguish the two.

In that intro, slot 0x13C runs:

CALLCOND ExecBankSlotScript(0x305C, args=(0xFFFE, 0x7FFF, 0xFFFF, 0,0,0,0))

Helper slot 0x305C (file +0xC991B) at offset +0x0231 contains:

SET_ACC 0x0014   ; sub-opcode 0x14 (HORIZONTAL XZ band check)
SET_ACC 0x7FFF   ; MAX = sentinel "no upper bound"
SET_ACC_9 0x01F4 ; MIN = 500
CALLCOND EvalIf argc=3

This is the range gate. When chara->flLeanForward >= 500, the script ORs 1 into a boolean and writes the caller's args F4-F6 (the long-range move-id triple) into STACK vars 0x0100/0x0101/0x0102. When it fails, F0-F2 (the close-range fallback 0xFFFE, 0x7FFF, 0xFFFF) are written instead.

A subsequent ancestor script reads STACK[0x0100] via LOAD_VAR 0x0100 and issues CALLCOND 0x07 TransitionAuthor argc=N, which writes pLane->nNextMoveID_5A = 0x013D. CheckMoveTransitionTiming fires the transition once the wind-up animation reaches the threshold, and the user sees the move id flip from 0x13C to 0x13D mid-animation.

The literal 0x013D never appears in any TransitionAuthor push throughout the entire bank — only as STORE var 0x0104 writes (file +0xC765D in slot 0x304E) and as EvalIfWithHeader predicate args. Grepping for 8B 01 3D + CALLCOND TransitionAuthor returns 0 hits. Always trace through the indirect LOAD_VAR path when investigating "why does this move morph".


Move-VM helper functions (labelled)

Address Name Role
0x140363C10 LuxMoveVM_DebugTextAppendF snprintf-style debug-line formatter used by the dumper
0x140364FC0 LuxMoveVM_CheckCharaStateEqualsU16 IF predicate: chara's u16 @ +0x1982 equals value
0x140365040 LuxMoveVM_CheckNotifFamilyB IF predicate family B (notif tokens 0x00, 0x20, 0x23, 0x25)
0x1403650C0 LuxMoveVM_CheckNotifFamilyC IF predicate family C (tokens 0x8D..0x90)
0x140365140 LuxMoveVM_CheckRangeOrDistance IF range/distance-to-opponent predicate
0x1403652E0 LuxMoveVM_CheckAngleOrGeometry IF angle/geometry predicate
0x140394AC0 LuxMoveVM_BuildMoveClassPair populates a 7×u32 output describing self vs opponent move-class (for 0x60055)
0x140394BB0 LuxMoveVM_ClassifyMovePairKind classifies a (self, opp) move-class pair into an integer kind
0x140394E30 LuxMoveVM_CompareMoveClassName equality test on the pair built above
0x140365900 LuxMoveVM_ExecuteAndDumpOpcode (was LuxBattleMoveCommandPlayer_DebugDumpCommand) the VM executor + disassembler combined — mutates VM state per opcode AND emits debug trace. Not "just a dump".
0x1403656B0 LuxMoveVM_TickDriver per-tick entry point; gates executor on VM phase (idle/normal/post-ATK), calls ConditionFlagRing refresh + ExecuteAndDumpOpcode
0x140365520 LuxMoveVM_PostATKDelayGate Inclusive 5..20-tick randomized delay after ATK opcode; native selector dispatch uses RandomInteger(16) + 5
0x140364D10 LuxMoveVM_RefreshConditionFlagRing refreshes the vmCtx+0x19F0..+0x1A64 predicate-flag ring each tick so IF opcodes see current chara state
0x1403732F0 LuxMoveVM_EvaluateIfOpcode ~120-arm switch evaluating a single IF-opcode subject token; NOT hit detection
0x140307BD0 LuxMoveVM_ResolveRangeAndAngleOffset decodes 3-short (range, angle, angle) into a world-space offset at chara+0x130; reads opponent position for direction-snap path
0x140344FC0 LuxMoveVM_ApplyMoveOffsetToChara wrapper around ResolveRangeAndAngleOffset that pulls overrides from opp+0x1D90 ring + applies per-axis scales
0x1402E5A30 LuxMoveVM_ExecuteBytecode inner stack-based interpreter — see Inner stack-based predicate VM
0x1402E67B0 LuxMoveVM_RunBytecodeScript wrapper that selects per-player var bank + copies args to the local frame, then calls ExecuteBytecode
0x1402E5830 LuxMoveVM_EvaluateIfOpcodeWithHeader dispatch slot 0x25 — calls EvaluateIfOpcode with sub-op 0x0008 prepended
0x1402FC400 LuxMoveVM_ResolveBankSlot resolves a packed (bank<<12)\|slot address into an FLuxMoveBankSlotView*
0x1402FC930 LuxMoveVM_DecodeVariadicStreamArgs dispatch slots 0x05/06/07/08 — lane[+0x5A] writer (immediate path) / lane[+0xB4] writer (deferred path); the canonical "TransitionAuthor"
0x1402FCC30 LuxMoveVM_ExecuteBankSlotScript dispatch slot 0x0D — nested-script call (resolves a bank slot + runs its bytecode with caller args). Most-used CALLCOND.
0x1402FCD30 LuxMoveVM_OpcodeIf_15_ScheduleTransitionScript dispatch slot 0x15 — wraps a sub-script in deferred-transition mode (any TransitionAuthor inside writes lane[+0xB4])
0x1402FCDE0 LuxMoveVM_OpcodeIf_16_DrainPendingTransition dispatch slot 0x16 — drains lane[+0xB4], calls TransitionToMove (clears chara+0x16EB multi-hit lockout)
0x1402FD4A0 LuxMoveVM_OpcodeIf_RegisterEffectOpDedup_04 dispatch slots 0x04/0x19/0x1A — inserts an effect-op tuple into lane->aEffectOpTable_1F4[16]
0x1402FDD70 LuxMoveVM_CheckMoveTransitionTiming per-tick gate that fires TransitionToMove when lane->flTransitionThreshold_68 <= other_lane.CurrentAnimFrame
0x1402FDEA0 LuxMoveVM_ExecuteOpStream per-tick lane driver — runs CheckMoveTransitionTiming, walks the effect-op table, calls RunBytecodeScript, advances the lane frame
0x1402FE1C0 LuxMoveVM_RunSecondaryLaneScript runs the secondary lane's bytecode when the primary lane transitions
0x1402FCC10 LuxMoveVM_OpcodeIf_07_TransitionAuthor thin wrapper → DecodeVariadicStreamArgs (dispatch slot 0x07)
0x1402FCC20 LuxMoveVM_OpcodeIf_08_TransitionAuthor thin wrapper → DecodeVariadicStreamArgs (dispatch slot 0x08)
0x1402FCB80 LuxMoveVM_OpcodeIf_05_TransitionAuthor thin wrapper → DecodeVariadicStreamArgs (dispatch slot 0x05)
0x1402FCB90 LuxMoveVM_OpcodeIf_06_TransitionAuthor thin wrapper → DecodeVariadicStreamArgs (dispatch slot 0x06)
0x14031C610 LuxMoveSystem_StartMoveForChara seeds the chara's global VM slot with a new move + ticks once to fire the first opcode
0x14031C740 LuxMoveSystem_TickMoveAndAutoAdvance per-frame tick with auto-advance to the next-queued move on move-end
0x140367EE0 LuxMoveSystem_TickMove plain per-frame tick wrapper (no auto-advance)
0x14031C8B0 LuxMoveVM_TickAgainstReactingOpp tick variant when opp+0x16D3 is set (opp in hit-reaction); plays follow-up move
0x1403531F0 LuxMoveVM_TickWithNotifTokenSwitch complex tick branching on chara+0x1982 CurrentNotifToken
0x1403598D0 LuxMoveVM_TickWithAirStageTracking tick variant that maintains airdance / juggle state
0x1402DEF50 LuxMoveVM_TickPickAndDispatchReaction per-tick yarare-id picker; rolls prob distribution + calls DispatchYarareReaction
0x1403521B0 LuxBattle_DispatchYarareReaction the yarare dispatcher — 80-case switch routing each hit-reaction id to its setup handler. Follow xrefs backward to find the hit-test.
0x140376B20 LuxMoveVM_DispatchEffectOp master VFX/effect opcode dispatcher (~60 opcodes; see cluster table below)
0x1402FC560 LuxMoveVM_DecodeLiteralArg decodes a packed short opcode arg as IEEE-754 half-precision (FP16) → FP32 (see "Literal encoding" below)
0x14038BAE0 LuxMoveVM_ExpandTokenToAnchorIndex token → chara-anchor index via DAT_144100820 table
0x140307310 LuxBattle_ResolveCharaAnchorWorldXYZ anchor index → world-space transform (48-byte struct)
0x1402D7FB0 LuxBattle_GetTerrainTagFromSampleResult (entry.flagsB >> 12) & 0xF with sub-kind remapping
0x140365140 LuxMoveVM_CheckRangeOrDistance IF range/distance-to-opponent predicate (family D)
0x1403652E0 LuxMoveVM_CheckAngleOrGeometry IF angle/geometry predicate (family E)
0x140314BC0 LuxBattle_TryTraceSegmentAgainstBounds shared segment-vs-arena trace (2 alt-frames + default)
0x140314D80 LuxBattle_TraceSegmentAndApplyFrameTagFixup wrapper that applies per-tag world-space fixup vec4
0x1403149E0 LuxBattle_TraceSegmentThroughFrameBoundsGrid ray-march segment across the cell grid
0x1403916E0 LuxBattle_TestFrameBoundsCell per-cell narrow-phase (binary-search + ray-test)
0x140390A90 LuxBattle_IntersectSegmentWithTerrainTriangle segment-vs-triangle intersection
0x140391270 LuxBattle_IsTerrainProbeInsideTriangleXZ 2D point-in-triangle XZ test (scratch in g_LuxBattle_TerrainProbeUp)
0x140390E90 LuxBattle_FindTerrainRowBucketByZ binary search for Z in a cell row's bucket list
0x1403915A0 LuxBattle_SampleTerrainAtWorldXZ entry point for terrain queries by world (X, Z)
0x140391350 LuxBattle_SampleTerrainAtXZ_Impl core implementation (filter + tag resolution)
0x14032FB20 LuxBattle_ProjectSegmentOntoTerrainOrGround project segment to terrain plane or Y=0 fallback
0x1403619C0 LuxBattle_CheckCategoryTerrainGate move-category (0x15 wall / 0x16 ringout) terrain gate
0x140361C20 LuxBattle_CheckPrevCategoryTerrainGate sibling for categories 0x13 / 0x14
0x140361E60 LuxBattle_CheckOpponentFrontTerrainMatch checks terrain tag in front of opponent (0x3A / 0x3B)
0x140362830 LuxBattle_CheckStepApproachClearance 2-sided "can self and opp step toward each other" probe
0x140353A00 LuxBattle_PickRingoutReactionYarare ringout-edge yarare picker (consumes CheckAngleOrGeometry; the Ghidra project has since renamed this from the older LuxBattle_ResolveRingoutReaction label)
0x140314480 LuxBattle_RefreshFrameTerrainCache per-tick A/B frame grid refresh
0x1403133E0 LuxBattle_GetActiveFrameBoundsGrid A/B selector for the bounds grid
0x140313400 LuxBattle_GetActiveFrameTransform A/B selector for the frame transform

Tuning constants:

Address Name Role
0x143E8A504 g_LuxMoveVM_AtkRangeDivisor ATK range_raw → metres divisor
0x143E89FD4 g_LuxMoveVM_UnitsPerInt int-to-float scale for IF threshold comparisons
0x14470FCD8 g_LuxCharaAttrTable_Byte_0x181cStride char-attr byte table, row = chara kind, row-stride 0x181C
0x1447123BC g_LuxCharaAttrTable_Int_0x3038Stride char-attr int table, row-stride 0x3038
0x144711EC0 g_LuxMoveVM_ClassNameAttrTable IF-F token 16/17 lookup, row-stride 0x3038, indexed by chara+0x23C
0x144100820 g_LuxMoveVM_AnchorBitsetTable 0x14-byte rows of 97-bit anchor bitsets; row index = (token - 0x58)
0x1440F4750 g_LuxMoveStateTable 0x29 rows × 0x14 stride; state-id at row+0
0x1440F3C98 g_LuxMoveStateTable_NotFoundSentinel address compared against the scan-result pointer; miss remaps to state-id 0x2A
0x143E83A90 g_LuxMoveVM_OpcodeIfDispatchTable 38 × 8-byte function pointers — the inner-VM CALLCOND dispatch table (full map in Inner stack VM)
0x143E8A504 g_LuxMoveVM_AtkRangeDivisor divides ATK range_raw to metres; also divides EvalIf sub-opcode 0x14 / 0x9C MIN/MAX inputs
0x14470DEA0 g_LuxMoveVM_DecodeStreamSavedActiveLane DecodeVariadicStreamArgs save/restore window for the active-lane pointer
0x14470DE6C g_LuxMoveVM_DeferredTransitionScheduleFlag set by OpcodeIf_15_ScheduleTransitionScript; routes the next TransitionAuthor write to lane[+0xB4] instead of lane[+0x5A]
0x14470DE68 g_LuxMoveVM_DeferredTransitionScheduleFrame scheduled frame for the deferred transition
0x14470DEC0 g_LuxMoveVM_DeferredTransitionCommitFlag "commit immediately" flag for OpcodeIf_15 (set when arg[2] == -2)
0x14470DDE0 g_LuxMoveVM_TransitionThresholdNowFlag set by DecodeVariadicStreamArgs when threshold == current frame; triggers a re-check of CheckMoveTransitionTiming in the same tick

Effect-dispatcher opcode clusters (LuxMoveVM_DispatchEffectOp)

The effect dispatcher is a ~60-opcode switch, consumed once per move tick after the main VM loop decides to emit VFX or system-level effects. The opcodes group into six clusters; anything outside these ranges falls through to the generic default path.

Cluster Opcode range Role
Core control 0x02..0x50 movement / action-stack / counter / hitpart wiring (pairs with the base VM's ATK flow)
Per-slot VFX params 0x3E8..0x3F9 writes into the 6-slot per-move VFX/beam param array (g_LuxMoveVM_SlotParamArray* + 0x08..0x24)
VFX spawn / edit 0x13A1..0x13E3 main VFX spawn / rebind / ring-buffer push (0x13A8 = scalar+frame+mode triple at chara+0x3510; 0x13D9 = 6-entry ring at chara+0x1D90 + N*0x20)
Terrain-sampled spawn 0x1781 / 0x1782 / 0x1786 / 0x1789 spawn anchored to a terrain-probe sample point (feeds through the CheckAngleOrGeometry sampler chain)
Engine / system ops 0x2328..0x2337 palette-variant rebind, aux-palette index swap, system-busy gate
Two-stage / action-stack 0x2AF8 / 0x2AF9 / 0x271A / 0x271D / 0x07E7 two-phase spawn (stage param, then commit) and action-stack push / pop

The dispatcher's relevant globals (useful hooks for VFX / palette mods):

Address Name Role
g_pLuxVfxDispatcher — singleton VFX dispatch actor; opcode 0x13A1.. route through here
g_LuxEffectSystemInstance — engine-layer effect system ptr
g_LuxMoveVM_EffectParamBuffer — scratch param struct copied into spawn calls
g_LuxMoveVM_SlotParamArray (+ _Off08..Off24) — 6-slot per-move param array (opcode 0x3E8..0x3F9)
g_LuxMoveVM_OpcodeExpansionTable — per-opcode arg-count / stride table
g_LuxMoveVM_PaletteVariantSlots — palette-rebind targets (engine-cluster)
g_LuxMoveVM_AuxPaletteIndexMap — aux palette swap table
g_LuxMoveVM_SpawnParamDefaults — spawn-param fallback block
g_LuxMoveVM_TimerConfig / _TimerHalfFrames / _FrameDivisor30 / _FrameDivisor60 — frame-timing conversion constants (30/60 Hz divisors used by effect timers)
g_LuxBattle_FallbackChara — pointer used when the dispatcher can't resolve a chara argument
g_LuxBattle_BlockInteractiveOps — byte flag; non-zero disables interactive-tier opcodes
g_LuxBattle_SystemBusy — byte flag gating 0x2328..0x2337 engine ops

The dispatcher is the canonical entry point for adding custom VFX: hook LuxMoveVM_DispatchEffectOp @ 0x140376B20 on opcode entry to intercept (chara, opcode, args) before the spawn happens, or add a new opcode by extending g_LuxMoveVM_OpcodeExpansionTable and the inner switch.


Enums

ELuxBattleMoveCategory (0..10)

MainMoves, ReversalEdgeMoves, SoulGaugeMoves, HorizontalAttacks, VerticalAttacks, Kicks, SimultaneousPressMoves, EightWayRunMoves, Throws, SpecialStance, LethalHitMoves.

ELuxBattleMoveEffectType (0..10)

Throw, UnblockableAttack, BreakAttack, GuardImpact, SpecialStance, LethalHit, SoulCharge, SoulGaugeFull, SoulGaugeHalf, SoulGaugeQuarter, ReversalEdge.

ELuxAttackTouchLevel (0..6)

E_ATL_HIGH, E_ATL_MIDDLE, E_ATL_LOW, E_ATL_SMIDDLE, E_ATL_SLOW, E_ATL_OTHER, E_ATL_NULL.

ELuxBattleDamage (0..4)

EBD_Unknown, EBD_Slash, EBD_Blow, EBD_Throw, EBD_RingOut.

FLuxAttackTouchParam (0x20 bytes)

Emitted when a hit registers.

Offset Type Name
+0x00 uint8 PlayerIndex
+0x04 FVector2D Position
+0x10 uint8 HitType
+0x14 uint8 AttackType (ELuxWeaponAttackType)
+0x18 uint8 Level (ELuxAttackTouchLevel)
+0x1C bool bCanDownHit

FLuxDamageInfo (0x14 bytes)

HUD/network hit-event struct.

Offset Type Name
+0x00 uint8 player_side (the side that took damage)
+0x04 uint8 damage_side (the side that dealt it)
+0x08 int32 damage (this hit)
+0x0C int32 total_damage (cumulative)
+0x10 bool is_critical
+0x11 bool is_limited (combo-scaling cap reached)

Extraction strategy for a frame-data website

  1. Dump the UE4 pak chunks. These are Unreal Engine 4.17.2 cooked paks; use UnrealPak or FModel. The DA_MoveListTable_<StyleId>.uasset files give you the Training-Mode display text for every move (CommandTextID → localised move name → AttributeTag/EffectTag). That alone is enough to stand up a browsable move list with categories.
  2. Dump the command-script blob. It ships either as an additional uasset per style or as a flat binary inside the battle-data pak (needs confirming — follow the loader at ALuxBattleManager_PlayMove_Impl @ 0x140429840 → provider fetch).
  3. Write a bytecode parser that implements the opcode dispatch above. Emit one JSON blob per move with:
  4. inputs: decoded from BTN+TIME cells
  5. atks: list of {power, range_m, speed, dir_mask}
  6. atb_yarare_ids: the ids that resolve on hit / CH / block
  7. stance_entry_flag_bits: IF-subject bits in 0x60007..0x60058 that the move toggles
  8. Join each yarare id against the per-chara attribute row in g_LuxCharaAttrTable_Byte_0x181cStride. That gives opponent stun frames — subtract own recovery to get on_hit/on_ch/on_block.

Minimum viable v0 (display-only)

Just step 1 plus localisation strings. This gets you a "move list per character" site with no frame data. It is still useful (no one has a clean SC6 notation reference online) and requires zero reverse-engineering beyond reading FLuxBattleMoveListTableRow.

Risk / anti-tamper

The move-list DataTables are not gated — they load at startup with no signature check. Editing them on disk requires pak injection, but reading them does not. The command-script blob loader has not been audited for checksums yet; read-only export should be safe.

Source trail: LuxBattleMoveCommandPlayer_DebugDumpCommand @ 0x140365900, Z_Construct_UClass_ALuxBattleMoveCommandPlayer @ 0x140953780, Z_Construct_UScriptStruct_FLuxBattleMoveListTableRow @ 0x14094A910, Z_Construct_UScriptStruct_FLuxAttackTouchParam_RegisterProps @ 0x14098F950, Z_Construct_UScriptStruct_FLuxDamageInfo_RegisterProps @ 0x140A4A3B0.