Export SC6 .mot animations to Blender¶
Goal: turn one clip from an extracted SC6 chrXXX.mot bank into a baked
Blender Action on the matching character armature, without confusing the raw
authored motion with the game's final IK- and overlay-adjusted pose.
Requires: a legally extracted SC6 installation, the matching character skeletal mesh and skeleton, Blender, a UE4 skeletal-mesh exporter, and either an HgMotion decoder or a runtime matrix-capture hook.
There is not yet a one-click SC6 MOT importer
.mot is Lux/HgMotion data, not an Unreal AnimSequence and not the
unrelated .mot formats used by other games. FModel, UE Viewer, and a
generic Blender .mot add-on do not decode it. The practical tool boundary
is a small SC6 converter plus an optional Blender front end; the difficult
part is the converter, not Blender's file-picker UI.
The HorseMod repository contains the current motion-bank parser and reference Huffman/root decoder. They are evidence-backed starting points, not a complete skeleton solver or a Blender importer.
Choose what to export¶
The word "animation" can mean three different outputs in SC6. Pick one before writing code because they require different inputs.
| Target | What it contains | Required source | Recommended use |
|---|---|---|---|
| Authored MOT sample | The local transforms produced by the selected HgMotion clip | .mot, matching merged reference-pose data, logical-channel mapping, and body-profile scaling |
Editing and studying authored motion |
| Final gameplay pose | The pose after reference values, hierarchy, IK, retracking, overlays, and later skeleton work | Runtime capture, or a reimplementation of the entire native pose pipeline | Highest-fidelity Blender playback |
| Root curve only | Selector 0x16 translation/facing output |
.mot and the known Huffman/channel decoder |
Movement plots and an early converter milestone |
For a first useful exporter, capture the final gameplay matrices at runtime and
bake them. In parallel, use that capture as the oracle for the offline .mot
decoder. A .mot-only result must be labelled authored MOT sample, not
"the exact in-game animation."
Why .mot alone is insufficient¶
LuxBattleChara_SolveBonePose @ 0x1402EDB90 does much more than sample a clip.
It combines up to four ordered motion slots with persistent decoded state,
reference transforms, authored defaults, controller/retracking values, scale,
spine solving, two-bone IK, and signed parent mappings. It also obtains its
reference values from a merged, layered FLuxMoveDataEntry bank rather than
from the .mot file.
The initialization path confirms the dependency:
LuxBattleChara_LoadMovesetEntries_BonesAndJapaneseVtb @ 0x140312040
merges base and PARTS/NMD3 move-data layers
LuxBattleChara_InitBoneRefPositions @ 0x1402EA810
builds reference vectors and FK metadata from the merged 0x70-byte entries
LuxBattleChara_RescaleBoneSizePositions @ 0x1402E9F90
applies body-profile upper/lower scale and recomputes IK lengths
LuxBattleChara_SolveBonePose @ 0x1402EDB90
samples MOT slots, applies reference values, hierarchy, spine solving, and IK
LuxMoveVM_EvaluateBonePose @ 0x1402F0F20
applies the active clip and its nine signed logical-transform mappings
LuxSkeleton_UpdateBoneTransforms @ 0x14034B8D0
performs later blending, auxiliary constraints, and spring/collision work
Therefore, copying one .nmd file beside one .mot is not a complete offline
input set. Either reproduce the merge and rescale path or capture the already
resolved runtime matrices.
Export the matching model and armature¶
SC6 is an Unreal Engine 4.17.2 game. For character 001, the principal model
and skeleton packages are:
Replace 001 with the character/style id being decoded. Export the
SkeletalMesh with a CUE4Parse-based tool such as
FModel. FModel can save
skeletal models as PSK; current releases also expose newer mesh formats. For
PSK, install the maintained
PSK/PSA Blender extension,
then use File -> Import -> Unreal PSK.
Preserve these properties:
- all bones, including apparently non-deforming or controller bones;
- original bone names and parent relationships;
- the imported bind pose;
- the model import's axis and unit conversion.
Do not rebuild or auto-reorient the armature after importing it. PSK has no declared unit system, so record any scale selected during import. The animation converter must apply the same conversion.
FModel's animation tab does not solve this step: SC6's .mot clips are raw
battle files and are not AnimSequence exports.
Extract and split the motion bank¶
The character bank normally appears at:
Shared banks also exist, including chr000.mot and chr0FF.mot. The packed
motion id used by LuxMoveVM_InitMotionPlayback @ 0x140300400 selects a bank
with (id >> 12) & 0xF and a clip with id & 0x7FF; do not assume every move
uses the character-local bank.
The outer bank is little-endian:
+0x00 u32 motion_count
+0x04 u32 reserved; zero in shipped banks
+0x08 u32[count] absolute file offsets
There is no guaranteed count + 1 end offset. Repeated offsets mean multiple
clip ids resolve to the same payload. For every non-final distinct offset, the
next greater offset is a safe outer bound. EOF is only an upper bound for the
last distinct offset because native code derives the clip's internal regions
without an outer size.
This minimal splitter preserves aliases:
from pathlib import Path
import struct
def read_mot_bank(path: Path) -> tuple[bytes, list[int]]:
raw = path.read_bytes()
if len(raw) < 8:
raise ValueError("MOT is smaller than its header")
count, reserved = struct.unpack_from("<II", raw, 0)
if reserved != 0:
raise ValueError(f"unexpected reserved dword: 0x{reserved:X}")
table_end = 8 + count * 4
if table_end > len(raw):
raise ValueError("MOT offset table exceeds the file")
offsets = list(struct.unpack_from(f"<{count}I", raw, 8))
if offsets != sorted(offsets):
raise ValueError("MOT offsets are not monotonic")
if any(offset < table_end or offset >= len(raw) for offset in offsets):
raise ValueError("MOT offset is outside the data area")
return raw, offsets
def clip_upper_bound(raw: bytes, offsets: list[int], clip_id: int) -> bytes:
start = offsets[clip_id]
end = next((value for value in offsets[clip_id + 1:] if value > start), len(raw))
return raw[start:end]
Use the move bank's animation id when available instead of choosing an offset by eye. Repeated offsets are aliases; treating the earlier entries as zero-byte clips changes the bank's semantics.
Implement the HgMotion decoder¶
The following stages are required for an offline authored-pose export.
1. Parse the clip header¶
The native sampler consumes at least these fields:
| Clip offset | Field | Use |
|---|---|---|
+0x00 |
u16 frame_count |
Logical duration |
+0x02 |
encoded decoded-word count | Native code shifts it right once |
+0x04 |
u32 flags |
Sampling rate, table selection, precision, and other behavior |
+0x08 |
channel-presence descriptor | Controls which selector-stream entries consume words |
+0x1C |
frame-group size/offset data | Locates compressed eight-frame groups |
LuxMotion_SampleKeyframeTransforms @ 0x1402E7780 applies half- or
quarter-rate flags before selecting encoded keyframes. Bake the result at the
logical 60 Hz timeline; do not mistake fewer encoded keyframes for a shorter
animation.
2. Decode the frame words¶
LuxMotion_DecodeHuffmanKeyframeData @ 0x1402E71E0 works in eight-frame
groups. Each group begins from signed 16-bit base words. Later frames apply
Huffman-coded, second-order accumulated deltas. A pose sampled between encoded
frames needs both the current and next word arrays.
Match the native 16-bit wrapping behavior. Reject a stream that reads outside the clip's established internal region; the game itself assumes trusted data and does not provide the bounds checks an exporter needs.
3. Walk the selector stream and interpolate¶
LuxMotion_BlendKeyframeTransforms @ 0x1402E79C0 walks a fixed selector stream
and the clip's presence mask. Flag bit 0x8000 chooses the alternate stream.
The default shipped stream addresses 32 logical transforms; the alternate
stream addresses 22.
Important selector roles confirmed in the native switch include:
| Selector | Confirmed role |
|---|---|
0x02, 0x19 |
quaternion rotation forms |
0x03, 0x1A |
translation forms |
0x13 |
scale |
0x16 |
logical root transform plus a facing side channel |
0x15 |
marker/control selector; it does not advance like a normal bone transform |
0x14, 0x17, 0x18 |
auxiliary scratch vectors consumed by later pose/IK work |
Do not turn every selector into a Blender bone. In particular, the auxiliary selectors publish solver inputs, and the root/control selectors have special cursor behavior. Blend rotations as quaternions and use the same current/next fraction that the native sampler computes.
4. Supply defaults and solve the logical hierarchy¶
Inactive channels retain reference/default data. The native decoded scratch area also persists between ordered motion-slot samples, so clearing every channel before every layer is not native-equivalent.
For an authored-pose export, reproduce:
- the merged base plus PARTS/NMD3 reference entries;
- the character body-profile rescaling;
- default rotation, translation, and scale for absent channels;
- the default or alternate logical-channel mapping;
- parent composition into model-space matrices.
Stop here only if the result is intentionally an authored sample. Spine IK, analytic limb IK, retracking/controllers, overlays, and later skeleton updates are separate fidelity stages.
Normalize matrices for Blender¶
Do not send the game's raw 16 floats directly to mathutils.Matrix. First
normalize the native row/column convention, then transfer the deformation onto
the imported Blender bind pose. This avoids depending on the visual direction
of Blender's edit-bone tails.
For column-vector matrices, the model-space deformation is:
C is the exact axis/unit conversion used for the model import. SC6's battle
space uses X/Z as the ground plane and Y as vertical. The confirmed Lux-to-UE
position conversion is:
If the Blender model remains in UE centimeters, preserve that scale. If the
model was converted to meters, fold the same factor into C. Test a known
forward-moving root clip before applying a guessed sign flip.
For the interchange step below, output Blender armature-space, column-vector matrices after this normalization. Store the 16 values as four JSON rows.
Write the neutral animation file¶
A small JSON boundary is easier to debug than a Blender add-on and keeps the HgMotion decoder independent of Blender:
{
"schema": "sc6-armature-matrices-v1",
"action": "chr001_clip_0123",
"fps": 60,
"matrix_space": "blender_armature",
"frames": [
{
"frame": 0,
"bones": {
"root": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
}
}
]
}
Requirements for this contract:
- each frame contains the same set of exported bone names;
- every matrix is already in Blender armature space;
- matrices contain scale; do not silently discard it;
- samples use logical 60 Hz frames, including the final frame;
- bone names, not array order alone, bind samples to the armature.
Bake the JSON into a Blender Action¶
Select the imported armature, open Blender's Scripting workspace, paste the
following script, set JSON_PATH, and run it. The core bake was smoke-tested
headlessly with Blender 5.1.
from pathlib import Path
import json
import bpy
from mathutils import Matrix
JSON_PATH = Path(r"C:\sc6-export\chr001_clip_0123.json")
def matrix_from_rows(values):
if len(values) != 16:
raise ValueError(f"expected 16 matrix values, got {len(values)}")
return Matrix(tuple(values[row * 4:(row + 1) * 4] for row in range(4)))
armature = bpy.context.object
if armature is None or armature.type != "ARMATURE":
raise RuntimeError("select the target SC6 armature before running the script")
payload = json.loads(JSON_PATH.read_text(encoding="utf-8"))
if payload.get("schema") != "sc6-armature-matrices-v1":
raise ValueError("unsupported animation schema")
if payload.get("matrix_space") != "blender_armature":
raise ValueError("matrices must already be in Blender armature space")
samples = payload["frames"]
if not samples:
raise ValueError("animation contains no frames")
keyed_names = set(samples[0]["bones"])
for sample in samples:
if set(sample["bones"]) != keyed_names:
raise ValueError("every frame must contain the same exported bones")
missing = sorted(keyed_names - set(armature.pose.bones.keys()))
if missing:
raise KeyError(f"bones missing from selected armature: {missing}")
depth = {}
for bone in armature.data.bones:
value = 0
parent = bone.parent
while parent is not None:
value += 1
parent = parent.parent
depth[bone.name] = value
pose_bones = sorted(armature.pose.bones, key=lambda bone: depth[bone.name])
scene = bpy.context.scene
scene.render.fps = int(payload["fps"])
armature.animation_data_create()
action = bpy.data.actions.new(payload["action"])
armature.animation_data.action = action
for pose_bone in pose_bones:
pose_bone.rotation_mode = "QUATERNION"
for sample in samples:
frame = int(sample["frame"])
scene.frame_set(frame)
# Non-exported bones remain at rest and still follow animated parents.
for pose_bone in pose_bones:
pose_bone.matrix_basis.identity()
# Parent-first assignment because the JSON matrices are armature-space.
for pose_bone in pose_bones:
values = sample["bones"].get(pose_bone.name)
if values is not None:
pose_bone.matrix = matrix_from_rows(values)
for name in sorted(keyed_names):
pose_bone = armature.pose.bones[name]
pose_bone.keyframe_insert("location", frame=frame, group=name)
pose_bone.keyframe_insert("rotation_quaternion", frame=frame, group=name)
pose_bone.keyframe_insert("scale", frame=frame, group=name)
scene.frame_start = min(int(sample["frame"]) for sample in samples)
scene.frame_end = max(int(sample["frame"]) for sample in samples)
scene.frame_set(scene.frame_start)
print(f"Baked {action.name}: {len(samples)} frames, {len(keyed_names)} bones")
The script keys every integer frame, which preserves the sampled pose at 60 Hz. If a later tool emits sparse keys, explicitly choose linear translation/scale interpolation and quaternion-safe rotation interpolation rather than accepting Bezier defaults.
Runtime-capture route¶
Runtime capture is both the shortest high-fidelity exporter and the validation oracle for an offline decoder.
Choose the hook point based on the target:
| Capture point | What it proves |
|---|---|
Immediately after LuxMotion_BlendKeyframeTransforms |
Raw selector output; useful while debugging the decoder |
After LuxBattleChara_SolveBonePose / LuxMoveVM_EvaluateBonePose |
Core resolved battle pose, hierarchy, and IK state at that stage |
After LuxSkeleton_UpdateBoneTransforms |
Later visible/collision skeleton state including additional constraints and secondary work |
Capture the matching rest matrices once, then record each desired bone's model-space pose matrix at one sample per battle tick. Include the UE skeleton bone index and resolved bone name in the capture. The game publishes a collision-visible matrix bank, but array position alone is not a portable Blender binding.
For deterministic capture, freeze or replay a known input sequence, record the selected packed motion id and playback frame, and avoid mixing samples from different ring-buffer generations.
Verify the export¶
Use native runtime matrices as the acceptance test, not visual similarity.
- Confirm the bank count, reserved dword, and every offset before decoding.
- Confirm that aliased clip ids resolve to identical payload starts.
- Decode frame 0, a non-zero frame inside an eight-frame group, a group boundary, and the final logical frame.
- Check every quaternion is finite and close to unit length after decoding.
- Check absent channels reproduce the correct merged and rescaled reference values instead of zero transforms.
- Compare root, pelvis, one hand, one foot, and one auxiliary/weapon bone against captured model-space matrices at the same integer playback frames.
- Compare deformation matrices after the deliberate axis/unit conversion; do not hide a transpose or scale error with a loose positional tolerance.
- In Blender, scrub the first and final frame and ensure neither snaps to an unkeyed rest pose.
An offline MOT sample is acceptable when the selector output and intended reference/hierarchy stages match. It is not a final-pose match if the remaining error comes from deliberately omitted IK, retracking, overlays, or spring work.
Current decoder coverage¶
A local audit of 31 extracted chr*.mot banks found 33,717 table slots. After
collapsing repeated offsets to range-bearing payloads, 16,157 distinct ranges
were tested. The current reference decoder accepted 16,028 clip headers and
decoded first/last compressed word streams for 15,821 of them.
This is not 15,821 complete Blender animations. It only demonstrates broad coverage of the outer header and Huffman word stage. The remaining failures include long-duration headers rejected by conservative heuristics, unresolved group boundaries, and streams that exceed the currently inferred internal region. Full-pose selector mapping, merged defaults, hierarchy, and runtime comparison remain required.
All accepted clips in this corpus selected the default channel table; the native bit-15 alternate path still has to be implemented and tested because it exists in code even when the sampled files do not exercise it.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Every clip begins with nonsense | Offset table read from +0x04 |
Skip the reserved dword and begin at +0x08. |
| One of several repeated offsets becomes empty | Immediate-next offset used as its end | Bound all aliases by the next greater distinct offset. |
Model imports but .mot is invisible to FModel |
.mot is not a UE AnimSequence |
Extract it as a raw file and use the SC6 HgMotion decoder. |
| Animation is 100 times too large or small | Model and motion used different unit conversions | Reuse the model import's exact scale in matrix conversion C. |
| Ground plane and height are swapped | Lux axes were treated as UE/Blender axes | Apply UE(X,Y,Z) = Lux(X,Z,Y) * 100, then the model import's UE-to-Blender conversion. |
| Limbs bend correctly but hands/feet drift | IK/retracking/reference stages were omitted | Label it authored-only, or capture/reimplement the later native stages. |
| Bones twist although positions look close | Raw game matrices were assigned to Blender bones | Transfer the deformation through game and Blender bind matrices. |
| Root moves but the body stays in rest pose | Only selector 0x16 is decoded |
Complete the selector stream, defaults, mapping, and hierarchy. |
| Final pose differs during hair/cloth motion | Capture happened before later skeleton updates | Capture after the stage whose result is actually needed. |
Native evidence¶
| Function | Evidence used by this recipe |
|---|---|
LuxMoveVM_InitMotionPlayback @ 0x140300400 |
Bank/clip packed-id split and the +0x08 motion-offset table |
LuxMotion_DecodeHuffmanKeyframeData @ 0x1402E71E0 |
Eight-frame groups and accumulated Huffman deltas |
LuxMotion_SampleKeyframeTransforms @ 0x1402E7780 |
Frame-rate flags, current/next decode, clamping, and interpolation |
LuxMotion_BlendKeyframeTransforms @ 0x1402E79C0 |
Selector streams, channel masks, special selectors, and local-transform output |
LuxBattleChara_SolveBonePose @ 0x1402EDB90 |
Ordered MOT layers, reference/default data, hierarchy, spine solve, and IK |
LuxMoveVM_EvaluateBonePose @ 0x1402F0F20 |
Active-motion application and nine signed logical-transform mappings |
LuxBattleChara_InitBoneRefPositions @ 0x1402EA810 |
Reference vectors come from merged move-data entries, not .mot |
LuxBattleChara_RescaleBoneSizePositions @ 0x1402E9F90 |
Body-profile scaling changes reference positions and IK lengths |
LuxSkeleton_UpdateBoneTransforms @ 0x14034B8D0 |
Later blending, constraints, spring/collision work, and published skeleton state |
Do we need a Blender add-on?¶
No for the first version. Keep the implementation in three testable layers:
SC6 bank/clip parser + pose solver
-> named armature-space matrices
-> JSON or glTF
-> Blender bake/import
Once the decoder passes runtime-matrix comparisons, wrapping it as a Blender extension is modest UI work. Building the add-on first does not reduce the reverse-engineering work and makes automated decoder tests harder to run.