On-disk battle data files¶
If you have unpacked SC6's pak and dumped the /Game/Battle/ tree to disk,
the per-character data lives in dump/Battle/{hdr,mot,cpu,hit,AssetPaths}/.
Each file family is one-to-one with an in-memory engine struct that is
already documented elsewhere; this page just maps file → struct.
File map per character¶
| Path | Style id | Engine struct | Documented in |
|---|---|---|---|
hdr/hdr<XXX>.khd |
XXX |
FLuxMoveBank |
move bank — full reference below |
mot/chr<XXX>.mot |
XXX |
offset-table of HgMotion frame data | Move System / motion |
cpu/cpuai<XXX>.dtp |
XXX |
CPU personality table — 9 sections, always | AI/CPU System |
hit/atkhit<XXX>.dat |
XXX |
i16-tagged KHitBase* stream — attacker side |
Hitbox System |
hit/bodyhit<XXX>.dat |
XXX |
i16-tagged KHitBase* stream — body / pushbox |
Hitbox System |
hit/yararehit<XXX>.dat |
XXX |
i16-tagged KHitBase* stream — reaction |
Reaction System |
AssetPaths/CP_<XXX>.uasset |
XXX |
UE4 wrapper pointing at the above by name | — |
Three pseudo-style ids exist outside the normal 001..066 range:
000— only shipschr000.mot(shared motion).0FF— only shipschr0FF.mot(783 motions, ~0% empty — "common motion").013— only shipscpuai013.dtp(AI placeholder slot).
.khd = on-disk FLuxMoveBank¶
The .khd file IS the engine's FLuxMoveBank (a 48-byte header, located at
chara+0x455C0 once loaded). On disk:
| Offset | Field | Value |
|---|---|---|
+0x00 |
magic | ASCII "KH11" (0x31314B48 LE) |
+0x10 |
dwAttackCellArrayOffset |
start of Section A (attack cells) |
+0x14 |
dwNonAttackDescTableOffset |
start of Section B |
+0x18 |
dwEventRecordTableOffset |
start of Section C (event records) |
After that header, the file is a contiguous blob of three sub-arrays.
Full field map: FLuxMoveBank.
Section A — LuxBattleAttackCell[] (stride 0x70)¶
THE attack-property array. Every authored hitbox-cell of every move
lives here: damage, frame windows, blockstun, hitstun, reaction IDs,
ranges. Full reference: FLuxMoveBankCell
(same on-disk layout, different historical name).
A cell with wHitboxGroupBitfield == 0xFFFF (+0x5E) is a cleared
sentinel — the engine treats that slot as inactive. Roughly 2-8 such sentinels
appear per character.
Empirical counts across all 24 shipped characters:
- 150-452 cells per
.khd(avg ~290). - Damage range typically 0..130; Geralt is the lowest (0..40), Xianghua the highest (0..130).
- Per-cell attack class is not reliably decodable from
wAttackFlags: the block-level bits are blockability flags, set near-uniformly across all classes (see hitbox-system.md: Attack flags). A move's authoritative per-hit class isDA_MoveListTable.AttributeTag— see Character Data.
Section B — LuxBattleNonAttackMoveDescr[] (stride 6)¶
A small per-character table (9-61 rows) of non-attack descriptors. Used by
non-damaging supers, GI, parry, and stance-transition records, addressed as
bank + bank[+0x14] + (subIdx & ~0x10000) * 6 (see
hitbox-system.md / Non-attack ALT-path).
struct LuxBattleNonAttackMoveDescr { // 6 bytes
int16 nSDamageMultiplier; // +0x00 — observed 10..65
int16 nSPassthroughTag; // +0x02 — usually 0xFFFD (default reaction)
int16 nSDuration60ths; // +0x04 — duration in 60ths of a second
};
Section C — event records + per-slot stack-VM bytecode¶
wEventRecordCount at header +0x0E, base at header +0x18, stride 0x30.
The first 3-94 records of Section C form a typed-header-record
prefix: each is [u8 typeTag][u8 subtype][u16 0][u32 index][24 bytes
payload], with typeTag drawn from the FLuxMoveDataEntry enum
(0x00..0x1E plus the 0xD6 count-marker).
The rest of Section C is per-slot stack-VM bytecode, reachable via
each slot's dwBytecodeOffset_38. See the Slot table
section below.
Slot table (move-state records)¶
Right after the 48-byte FLuxMoveBank header, at bank+0x30, sits a contiguous
array of FLuxMoveBankSlotView records (72 bytes / 0x48 each). Each slot
is one move-state (idle, attack startup, attack active, transition, etc.).
Total slot count = sum(bucket.Count) over all 4 buckets at bank+0x1C..+0x2B.
Mitsurugi has 2899 slots; the per-character count scales with moveset
complexity.
| Offset | Type | Field | Notes |
|---|---|---|---|
| +0x00 | u16 | wAnimationIndex_00 |
HgMotion motion id |
| +0x02 | u16 | wMotionPlaybackParam_02 |
per-slot motion init param |
| +0x04 | i16 | nField_04 |
motion arg |
| +0x06 | u16 | wMotionFlags_06 |
motion flags |
| +0x10 | u32 | dwSubTableOffset_10 |
per-slot sub-table A offset (-1 = none) |
| +0x14 | u32 | dwSubTableOffset_14 |
sub-table B offset |
| +0x1C | u32 | dwAltBytecodeOffset_1C |
alt-path bytecode (typically unused in 2.31) |
| +0x20 | u64 | qwInputMask_20 |
input mask -> lane+0x448 at TransitionToMove |
| +0x28 | u64 | qwInputMask_28 |
input mask -> lane+0x450 |
| +0x30 | f32 | flAnimLength_30 |
animation length (60Hz frames) |
| +0x34 | i16 | nAnimLengthFlag_34 |
-2 = use computed length from playback speed |
| +0x36 | i16 | nHitWindowStart_36 |
hit-window start frame |
| +0x38 | u32 | dwBytecodeOffset_38 |
bank-relative byte offset to stack-VM bytecode — the main per-slot script. Engine resolves as (byte*)bank + dwBytecodeOffset_38; -1 means "no bytecode" |
| +0x3C | i16[6] | nCellBoneIndexPerVariant |
variant index -> AttackCell index (-1 = no attack) |
Stack-VM bytecode (per slot)¶
Each slot's dwBytecodeOffset_38 points at a byte-packed bytecode stream
interpreted by LuxMoveVM_ExecuteBytecode @ 0x1402E5A30. The stream is NOT
u32-aligned; instructions are 1 or 3 bytes each. Each opcode byte holds a
low-7-bit opcode plus a 0x80 push-flag (push ACC afterward).
| Opcode | Size | Mnemonic | Notes |
|---|---|---|---|
| 0x00 | 1 | NOP | |
| 0x01 | 3 | PUSH_IMM <BE u16> |
sets dwLastImmediate = u16; pushes to stack only if u16 != 0 (PC always +3) |
| 0x02 | 1 | RET2 |
return |
| 0x03/0x04 | 3 | SET_ACC_U16 <BE u16> |
ACC = imm (no push) |
| 0x05/0x06 | 1 | RET |
pop top, return value |
| 0x07 | 1 | RETBRK |
return + set interrupt flag |
| 0x08 | 1 | BRK |
set interrupt flag |
| 0x09/0x0B | 3 | SET_ACC_U16 <BE u16> |
bare ACC set (+0x80 = also push) |
| 0x0A | 3 | LOAD_VAR <BE u16 varid> |
ACC = var |
| 0x0C..0x18 | 1 | ADD/SUB/MUL/DIV/MOD/NEG/POSTINC/POSTDEC/AND/OR/LNOT/SHL/SAR | arithmetic |
| 0x19 | 3 | STORE_VAR <varid> |
var = pop |
| 0x1A..0x1E | 3 | ADD_VAR / SUB_VAR / MUL_VAR / DIV_VAR / MOD_VAR <varid> |
op-assign |
| 0x1F..0x24 | 1 | EQ/NE/LT/LE/GT/GE | comparison |
| 0x25 | 3 | CALLCOND <u8 fnIdx> <u8 argc> |
dispatch table call |
| 0x26/0x27 | 1 | PUSH_ACC / POP_ACC | |
| 0x28/0x29 | 3 | JNZ / JZ <BE u16 dest> |
pop top, jump if (non)zero. dest is an absolute byte offset within this slot's bytecode buffer (i.e., dwPC = dest directly, NOT a delta) |
| 0x2A | 3 | JMP <BE u16 dest> |
unconditional jump. dest is an absolute byte offset within this slot's bytecode buffer (dwPC = dest, NOT a delta) |
| 0x2B..0x3C | 1 | NOP | reserved / unused |
varid addressing:
varid < 0xF0 -> GLOBAL[varid] (per-character 240-entry i16 array
at &g_LuxBattle_CharaKindStatureTable
+ chara[+0x23C] * 0xF0)
0xF0..0xFF -> LOCAL[varid-F0] (16 i16 args copied from caller)
0x100+ -> STACK[varid-100] (stack ring; persists across CALLCONDs)
CALLCOND dispatch table (g_LuxMoveVM_OpcodeIfDispatchTable @ 0x143E83A90)¶
38 entries × 8-byte function pointers:
| Idx | Function | Notes |
|---|---|---|
| 0x00, 0x01 | LuxMoveVM_EvaluateIfOpcode |
IF-predicate evaluator (most common call) |
| 0x02, 0x03 | LuxMoveVM_DispatchEffectOp |
Effect-system opcode dispatcher (movement / VFX / audio) |
| 0x04, 0x19 | RegisterEffectOpDedup |
|
| 0x05..0x08 | TransitionAuthor_05..08 |
Writes lane[+0x5A] = MoveID — main "transition to next move" path |
| 0x09 | LatchCharaStateFlag |
|
| 0x0A | OpcodeIf_0A |
|
| 0x0B, 0x12, 0x13, 0x24 | NullStub |
no-op |
| 0x0C | OpcodeIf_0C |
|
| 0x0D | ExecuteBankSlotScript |
NESTED CALL into another slot's bytecode |
| 0x0E..0x11 | OpcodeIf_0E..11 |
|
| 0x14 | OpcodeIf_14 |
|
| 0x15 | ScheduleTransitionScript |
writes lane[+0xB4] (deferred transition) |
| 0x16 | OpcodeIf_16 |
drain lane[+0xB4] |
| 0x17/0x18 | OpcodeIf_17/18 |
save/restore staged transition |
| 0x1A | OpcodeIf_1A |
clear lane[+0xB4] |
| 0x1B..0x1E (+ aliases 0x1F..0x22) | OpcodeIf_1B..1E |
|
| 0x23 | GetRandWeightedIndex |
weighted random index |
| 0x25 | EvaluateIfOpcodeWithHeader |
header-aware IF |
Empirical cross-slot stats (Mitsurugi, 2899 slots)¶
Average bytecode length: 73 instructions (range 3..1547 across slots). Aggregate CALLCOND distribution:
ExecuteBankSlotScript 13,936 (move sub-graph navigation)
EvaluateIfOpcode 9,580 (predicates)
DispatchEffectOp 8,410 (effect-system ops)
EvaluateIfOpcodeWithHeader 6,871 (header IF)
OpcodeIf_14 948
TransitionAuthor_07 687 (transition to next move)
The bytecode is the canonical move-state graph: every move-state-machine
transition fires through a TransitionAuthor_* CALLCOND in some slot's
script. A complete tool would walk the bytecode to reconstruct the
per-character move-transition DAG.
.mot motion files¶
+0x00 u32 nMotionCount (628..2046 motions / char)
+0x04 u32 reserved (zero in shipped banks)
+0x08 u32[N] motion_offsets (absolute file offsets)
@offset[i] HgMotion frame data (private layout)
There is no guaranteed N+1 end sentinel. Adjacent motion ids sharing the same
offset are aliases and resolve to the same payload in native code. An offline
tool can bound a payload by the next greater distinct offset; EOF is only an
upper bound for the final distinct payload because the native decoder uses the
clip's internal regions rather than an outer section size.
The bank split, compressed-frame decoder, Blender interchange contract, and
pose-fidelity limits are covered by
Export SC6 .mot animations to Blender.
.dtp CPU AI files¶
Same nCount + u32[N+1] offsets container as .mot. Every shipped
file has exactly 9 sections, several of them at fixed sizes:
| Section | Size (all chars) | Role |
|---|---|---|
| 0 | 27184-73424 (varies) | Decision-tree data (u16 weight/value pairs) |
| 1 | 4400-10064 (varies) | Personality custom table — u16 slot count at +0x08, per-slot 18-byte rows |
| 2 | always 2320 | One weight-slot block |
| 4 | 83200 or 89600 | Big lookup table (only 2 distinct sizes across all chars) |
| 5 | always 34880 | 15 × 2320-byte weight blocks (u32 count=15 + offset table + entries) |
| 6 | always 0 | Alternate weights (patched in at runtime) |
| 7 | always 1488, starts with "PSNL" |
Personality vtable trigger |
| 8 | 2192-5024 (varies) | Per-character override / scratch |
Runtime loader: LuxBattle_InitCpuPersonalityData @ 0x140364950.
.dat hit-volume files (atkhit / bodyhit / yararehit)¶
Each uses the same i16-tagged stream format the runtime walks via
Lux_KHitChk_DeserializeLinkedList @ 0x14030C940:
+0x00 i16 tag (0 = Sphere, 1 = Area, 2 = FixArea, <0 = end)
+0x02 u16 slot (defender bone slot 0..63; PerAttackerBit = 1 << (slot & 0x3F))
+0x04 u32 node_flags (authored; copied to in-memory KHit+0x10)
... variant tail ...
| Tag | Stride | Tail |
|---|---|---|
| 0 (Sphere) | 0x20 |
float[4] (x, y, z, radius), u32 id, u32 reserved |
| 1 (Area) | 0x28 |
swept capsule — see hitbox-system.md / KHit node layout |
| 2 (FixArea) | 0x30 |
3-point OBB — same reference |
Stream ends on the first tag < 0 (typical sentinel: 0xFFFF = -1).
Files always end with a 2-byte FF FF trailer.
Cross-character invariants:
bodyhit*.dat— always exactly 17 sphere records, 0 area, 0 fix.yararehit*.dat— always 18-19 sphere + exactly 1 area, 0 fix.atkhit*.dat— varies, 30-72 records, sphere + area mix.
Name-string obfuscation¶
Any string referenced from inside an FLuxMoveBank buffer is descrambled
in-place on first load by subtracting 0x40 from each byte up to the NUL
terminator (*p -= 0x40). The +0x08 bDecoded byte in the buffer guards
against a second descramble pass. Tools that read .khd files directly must
apply this transform when reading any embedded name.
Editing safety¶
.khd / .mot / .dtp / .dat files are pak-loaded, so disk edits need
a pak-injection workflow. Runtime patching via native hooks is
simpler: the loaded FLuxMoveBank sits at chara+0x455C0, and the
attack-cell array can be edited in place between matches without
invalidating the engine's cached pointers (each cell pointer is set
once per move by LuxMoveVM_TransitionToMove).
For pure stat tweaks, LuxBattleAttackCell::nBaseDamage,
nBlockstunFrames, and nHitstun* are independent fields with no
cross-checks against header-level data — a single u16 is safe to edit.
.vtb and .lpd files (visual-tag and pose-delta data) are loaded through
chara-init params from elsewhere in the pak. Their headers — magic "vtb\0"
version 0x1002, magic "lpb\0" version 0x201 — are handled in
LuxMoveVM_LoadVTBFile @ 0x14038EFA0 and
LuxBattle_BindLPDMotionData @ 0x1402F7670.