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":
- Load the bytecode for move X.
- Decode the ATK payload opcode (
0x40002) forpower/range/speed/dir_mask. - 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 initLuxMoveSystem_TickMove @ 0x140367EE0— per-frame plain tickLuxMoveSystem_TickMoveAndAutoAdvance @ 0x14031C740— tick + auto-transitionLuxMoveVM_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 intoPlayMove/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. HoldswCategoryFlags,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/flRangeEndgates which sub-frame samples this record applies to.-
FLuxMoveLane— 1128-byte per-lane VM state (was namedLuxMoveLaneState). Three lane blocks live inline in every chara at fixed offsets:Offset Lane Role chara+0x444F0Lane 0 primary active move (most strikes) chara+0x44958Lane 1 secondary lane (mirror / queued script) chara+0x44DC0Lane 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 +0x00i16LaneIndex(0/1/2)+0x02i16PackedMoveAddr((bank<<12) \| slot;-1= idle)+0x04i32TickCounter(raw ticks since move start)+0x08floatCurrentAnimFrame— THE frame counter, advanced as+0x08 += time_dilation * +0x30each tick. Truncate for "current frame N".+0x0CfloatPrevAnimFrame(tick-start snapshot)+0x10floatAnimLengthFrames(from bank cell+0x34)+0x1Au16AtEndFlag(1at final frame)+0x1Ci16FrameDeltaThisTick+0x24u16FrameStepFinished(1once end reached)+0x26u16InTransitionFlag(1duringTransitionToMove)+0x30floatPlaybackSpeedCurrent(ramps to target)+0x34floatPlaybackSpeedTarget+0x458i32TotalTickCounter(monotonic across advances)+0x460u32AnimVariantIndex(0..5bank variant)For a "current move frame N / M" HUD overlay, read
Lane0+0x08andLane0+0x10every tick — pure pointer arithmetic, no hooks required.chara+0x1350is theMoveStartCounter, bumped on eachTransitionToMovecall (handy for "did the move change?" checks without diffingPackedMoveAddr).chara+0x1360is theLastHitAnimFramemirror, written byProcessHitfor HUD consumers.Static native-code verification also fixes the containing object view: the three
FLuxMoveLanerecords form one contiguousFLuxMoveLane[3]at+0x444F0and end exactly at the fiveFLuxMotionPlaybackSlotrecords at+0x45228. An older Ghidra overlay beginning at+0x44958called lane 1's+0x08/+0x10floats "reaction heights"; those bytes are actuallyflAnimFrameCurrentandflAnimLength. -
LuxMoveEventRbTreeNodeis a distinct 0x30-byte runtime node used by the KH11 event lookup. Its payload isdwPackedMoveIdat+0x20andFLuxMoveBankEventRecord* pEventRecordat+0x28. It must not reuse the similarly shaped fighter-registry node type; doing so falsely renders+0x28aspChara. FLuxBattleVMFreezeRecord— 64-byte slow-motion / VM-freeze blend state. Three candidate alphas, two countdowns, blended output. TheflAlphaCandidate3_SlowMoslot 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
shortis the native two's-complement sign; if the short is negative, the decoder takesabs()and ORs0x80000000into the FP32 result. - bits 14..10 = 5-bit FP16 exponent (bias 15); rebiased to FP32 bias 127
by
+ 0x38000000in FP32-bit-pattern space. - bits 9..0 = 10-bit FP16 mantissa; aligned to FP32 by
<< 13. - the literal
0x0000is a short-circuit for0.0f(otherwise the decoder would emit0x38000000≈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]in0x60028..0x60037(16 entries, stride 2 bytes) →g_LuxCharaAttrTable_Byte_0x181cStride @ 0x14470FCD80x60054..→ a second larger table atg_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 two0x44C-byte grids of terrain entries that the VM traces against.g_abLuxBattleFrameTransformContextA @ 0x144844170/g_abLuxBattleFrameTransformContextB @ 0x144845220— matched0x10AC-byte contexts; each bounds grid is embedded at+0xC60.g_LuxBattle_TerrainProbeUp @ 0x1440FBC38/g_LuxBattle_TerrainProbeDown @ 0x1440F7688— two 16-byte vec4 scratch slots. Primed bySampleTerrainAtXZ_Implas vertical probes at ±100 units, reused byIntersectSegmentWithTerrainTriangleas 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 atchara+0x19F0..+0x1A64. - Moves that exit a stance (e.g.
Afrom Amy's stance) query those bits via IFclass 0x60001,subject 0x60016..0x6001c, thengotointo 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:
- Per ATK cell:
(power, range, speed, dir_mask), wherespeedis the attack's startup/active window. - 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.
- 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 statechara+0x19A4 == 2/3. - Walks a circular yarare-history ring at
chara+0x2188(0x140 entries, stride0xC, payload at+0x2190) to detect reaction transitions. - Probes
LuxBattle_CheckStepApproachClearanceagainst a per-chara-kind scratch at&DAT_14470E2E0 + chara+0x23C * 0x18. - Updates a height-delta accumulator at
vmCtx+0x2AF8 + idx*4based on the opponent's active-cell+0x32flag bits (different masks → different per-tick deltas usingg_LuxMoveVM_FrameDivisor60/g_LuxCmToUEScale). - Lazy-mallocs a 0x444-byte per-hit-intensity reaction-param block at
DAT_14470E018and 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 to6.id in 1..0x1A && (opp+0x19A4 in {0xD,0xF} || opp+0x1982 == 0x50)→ probabilistic remap to{0x21, 0x35, 0x36}based on velocity² gateDAT_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_14470E330stride0x180, indexed by CharaKindByte. Holds 32 entries of{u32 pad, s16 oppMoveStateId, s16 reachOffset, u32 pad}. Modelled asFLuxBattleCharaReachModBlock/FLuxBattleCharaReachModEntry.DAT_144711F88stride0xC0E, indexed by CharaKindByte. Per-character hit-intensity attribute. Read by sub-handlers (0x42, 0x32, 0x1F).
Plus the lazy-allocated per-intensity scratch block:
DAT_14470E018size0x444.+0x310 int32[7]primary knockback distance per intensity,+0x32C int32[7]secondary, blended byflRngRoll. Modelled asFLuxBattleYarareReactionParamBlock.
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 likeCheckYarareGate_StepRange,CheckYarareGate_AerialRange,CheckYarareGate_SelfDistanceBucket. - Per-chara reach-mod gates (
0x2F..0x38) — readDAT_14470E310 + CharaKindByte*8and clamp byDAT_144711F88per-char hit intensity. - Quadrant / direction probes (
0x3E..0x49) —LuxMoveVM_ClassifyRangeBucketreturns0=front / 1=back / 2=left / 3=right, gate matches against case0x3E..0x49for 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'sCurrentNotifTokenmid-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 atg_LuxBattle_CharaKindStatureTable + chara[+0x23C]*0xF0 + varIdx*2varIdx 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¶
- A move-start or per-tick script runs
CALLCOND 0x07/0x05/0x08 (TransitionAuthor), pushing a target move-id (literal or viaLOAD_VARfrom a stack/local var).DecodeVariadicStreamArgswritespLane->nNextMoveID_5Aand a threshold frame atpLane->flTransitionThreshold_68. - On each subsequent tick,
LuxMoveVM_ExecuteOpStreamcallsLuxMoveVM_CheckMoveTransitionTiming @ 0x1402FDD70, which checks ifpLane->flTransitionThreshold_68 <= other_lane.CurrentAnimFrame. When the threshold is met, it callsLuxMoveVM_TransitionToMove(chara, laneIdx, nNextMoveID_5A, ...). - The deferred path (via
OpcodeIf_15wrapping a sub-script that contains a TransitionAuthor) writespLane->nDeferredMoveID_B4instead, and is drained on the next tick byOpcodeIf_16callingTransitionToMovedirectly.
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-mask0x0100000000000000) is the wind-up that always plays first. - Slot 0x013D (cellBoneIndex
[81, 82, -1, ...]→ real attack cells with mask0x0300000006000010) 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:
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¶
Recommended path (fastest to get to a website)¶
- Dump the UE4 pak chunks. These are Unreal Engine 4.17.2 cooked paks; use
UnrealPak or FModel. The
DA_MoveListTable_<StyleId>.uassetfiles 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. - 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). - Write a bytecode parser that implements the opcode dispatch above. Emit one JSON blob per move with:
inputs: decoded from BTN+TIME cellsatks: list of{power, range_m, speed, dir_mask}atb_yarare_ids: the ids that resolve on hit / CH / blockstance_entry_flag_bits: IF-subject bits in0x60007..0x60058that the move toggles- Join each yarare id against the per-chara attribute row in
g_LuxCharaAttrTable_Byte_0x181cStride. That gives opponent stun frames — subtract own recovery to geton_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.