CPU / AI System¶
How the CPU opponent decides what to do, and how training-mode dummies are
driven. The short version: the AI emits frame inputs — not direct MoveVM
commands — so it sits one layer above the move VM and feeds the same input
buffer the human controller writes to. Difficulty and personality select which
HgCpuDirect* SubVM module ticks. Tutorial drills use the same framework
as live AI, only with scripted Tick bodies.
All addresses absolute (image base 0x140000000).
TL;DR¶
| Question | Answer |
|---|---|
| Where does AI tick? | LuxMoveVM_SelectMoveCommandContinuation @ 0x1402E5470 inside LuxBattle_PerFrameTick |
| What does AI output? | A 32-bit input-command word written to the per-slot sched state — the same field a human controller writes through LuxBattle_TickCharaInput |
| Does AI tick during replay viewing? | Yes — but the replay decoder overwrites chara+0x2150 before consumption, so the AI's choice is silently discarded |
| Does the WorldTickGate freeze gate AI? | Yes, automatically — Site 9 (LuxBattle_PerFrameTick) is the parent of the entire AI tick chain |
| Are tutorials a separate AI? | No — same HgCpuDirect* SubVM framework, just with scripted Tick bodies instead of personality-picked ones |
| Can a mod drive the training dummy? | Yes — 11 UFunctions on the training-mode actor, all with native bodies |
Per-frame call chain¶
LuxBattle_PerFrameTick @ 0x1402DBC60 (gated by WorldTickGate Site 9)
└── LuxMoveVM_ScheduleCharaMoveForTick @ 0x1402E52D0 (per-chara, before input read)
├── (moveId == 2) LuxMoveVM_TickPickAndDispatchReaction @ 0x1402DEF50
│ (yarare / hit-reaction classifier — NOT strategic AI)
│
└── (otherwise) LuxMoveVM_SelectMoveCommandContinuation @ 0x1402E5470
└── pSubVM->vtable[+0x10]() = HgCpuDirectXXX::Tick
(chooses input → writes pSubVM+0x08)
then vtable[+0x28] OnTickPost
then vtable[+0x30] OnTickFinalize
then vtable[+0x38] OnTickMoveChange (if move changed)
source: function plates at
0x1402DBC60,0x1402E52D0,0x1402E5470, landmark plate at0x1402F7370(LuxBattleChara_GetAIStateTableField).
AI output = frame input¶
Each SubVM Tick writes a 32-bit input command word to pSubVM+0x08. That word
propagates into the per-slot sched state at &DAT_144715408 + slot*0x60; the
DEFAULT branch of LuxBattle_TickCharaInput @ 0x140312510 then reads it and
writes chara+0x2150 / +0x2158 as that frame's raw input — exactly where a
human controller writes.
For special moves (cmd id 0x69), the AI bypasses the input layer: it writes
the move id to &DAT_144715430 + slot*0x18 + cmd and two uint16 params to
&DAT_144715448, then commits via
LuxMoveVM_SchedState_CommitCommandSlot @ 0x1402E5660.
Replay determinism
Because the AI emits frame inputs, replays are bit-deterministic from the
input stream alone — no AI snapshot needed. During replay viewing the AI
keeps ticking, but the replay decoder overwrites chara+0x2150 before
consumption, so the AI's output is silently discarded. See
replay-system.md.
Difficulty / personality¶
The chara's BattleAISetting.Personality FName keys a row in the chara-record
data table at chara+0x97250. The row either carries a "PSNL" 4-cc tag
(custom personality data) or falls back to the 32-byte default block at
_DAT_143E84F10.
| Step | Where | What |
|---|---|---|
| Base weights | chara+0x948..+0x964 |
32-byte weight block — written by LuxBattle_InitCpuPersonalityData @ 0x140364950 |
| Live bank | chara+0x968..+0x984 |
Mirrored copy for per-frame compute. Refreshed by LuxBattle_RefreshCpuPersonalityData @ 0x140364BC0 |
| Condition vector | chara+0x220 |
15 × uint16 per-frame combat conditions (HP fraction, distance bucket, opp move state, ring proximity, etc.) |
| Per-slot rows | chara+0x230 (stride 0x12) |
One row for each of the 8 personality slots |
| Compute | — | LuxBattle_ComputePersonalityActionWeights @ 0x140394FE0 — weight * condition_vec[col] summed into an 81-entry action-id histogram; top 8 kept |
| Pick array | chara+0x988..+0xBA0 |
The 8 chosen action ids per slot. Per-frame Tick samples one via the VM RNG |
| Per-round refresh | — | LuxBattle_ComputeAllPersonalitySlots @ 0x140395320 |
enum class ELuxBattleAIDifficulty : uint8 {
Tutorial = 0, Easy = 1, Normal = 2, Hard = 3,
VeryHard = 4, Legendary = 5, ENUM_MAX = 6,
};
enum class ELuxBattleAITendency : uint8 {
Standard = 0, Offense = 1, Defense = 2,
GuardBreak = 3, Run = 4, ENUM_MAX = 5,
};
source: RTTI strings at
0x1433A3B20..0x1433A3D70(difficulty),0x1433A30F0..0x1433A3230(tendency).
HgCpuDirect SubVM framework¶
Behavior modules are 0x70-byte CCpuDirectCommand-derived objects. There are
100+ derived classes, one per behavior pattern (standing guard, ring-edge
attack, revenge counter, AllGuardCount tracking, and so on). Base layout:
| Offset | Field | Purpose |
|---|---|---|
+0x00 |
vftable |
Polymorphic Tick / OnTickPost / Finalize / OnTickMoveChange dispatch |
+0x08 |
dwInputCmd |
The AI output — read by LuxBattle_TickCharaInput |
+0x10, +0x18 |
pChara, pOpp |
P1 and P2 chara pointers |
+0x30..+0x58 |
counters | Timers and state counters |
+0x58 |
flReactionTimer |
-1.0f sentinel = no pending reaction |
+0x60 |
pSchedState |
Back-pointer to the owning FLuxMoveSchedState |
+0x68..+0x74 |
per-derived state | Class-specific storage |
The factory that picks which derived class to install per chara is
LuxMoveVM_CreateCpuDirectState @ 0x1402E26A0 — it switches on personality +
condition vector and constructs the appropriate SubVM.
Tutorial scripts use the same framework¶
The 16 HgCpuDirectTutorial*_Init functions at 0x1402E4CF0..0x1402E51D0
construct scripted CCpuDirectCommand-derived SubVMs, one per drill
(ApproachBG, BB_Guard, Stun, ReversalEdgeMax, 66AB, 4AorAG, 4KB,
3B, AirControl, GuardImpact3B, 1AReverseImpact, and so on). Their Tick
bodies emit fixed input sequences instead of consulting personality or condition.
Implication: there is no "tutorial input replayer" separate from the AI. To record a custom CPU behavior, attaching a scripted SubVM through this framework is the lowest-friction path.
Practical recipe: scripted CPU drill boundaries¶
Use a scripted HgCpuDirect*-style SubVM only when the dummy needs native,
frame-perfect behavior that is more than a pre-recorded input stream: reacting
to distance, move state, ring position, or a drill-specific success/fail flag.
For simple canned sequences, prefer the training-mode UFunctions below. For
external bot logic that owns every frame, prefer the LuxBattle_TickCharaInput
override recipe below.
What the boundary guarantees¶
| Boundary | Practical meaning |
|---|---|
| SubVM output | A scripted drill should emit the same 32-bit frame-input command at pSubVM+0x08 that stock AI emits. The downstream MoveVM still decides whether that input starts a move. |
| MoveVM boundary | Do not treat a CPU drill as a direct move-lane editor. Move selection, transition timing, hit reactions, and active lane state belong to the Move System. |
| Replay boundary | A scripted SubVM can tick during replay viewing, but replay input can still replace the final raw input before consumption. See Replay System. |
| Native-code boundary | Installing or replacing a SubVM is native-code work. Training UFunctions are useful native-call bridge points; custom SubVM construction needs a DLL detour path. |
Conservative implementation checklist¶
- Mirror the tutorial pattern first: construct a
CCpuDirectCommand-derived object, initialize the known base fields, and let the existing dispatcher callTick,OnTickPost,OnTickFinalize, andOnTickMoveChange. - Keep custom drill state inside verified per-derived storage or an external native side table keyed by the owning chara/sched-state pointer. Do not assume a wider class layout just because one tutorial class has spare-looking bytes.
- Emit ordinary frame-input commands unless you have explicitly chosen the
special-move route. The special route uses command id
0x69and the scheduler commit path documented above, so its params need per-character runtime validation. - Preserve the stock chara/opponent/sched-state pointers. If a drill swaps the
SubVM after
LuxMoveVM_CreateCpuDirectState, verify those back-pointers before the first tick. - Add an escape path that restores the stock factory choice or clears the drill when the match restarts. Battle objects are rebuilt across rematch and menu transitions, so stale SubVM pointers are not reusable.
- Test under pause, frame-step, training playback, and replay viewing. The scripted SubVM may tick while another source wins the final input, and that is expected behavior rather than proof that the SubVM failed.
Use the simpler layer when it fits¶
| Goal | Better layer |
|---|---|
| "Block after this recorded string" | Training recording/playback UFunctions |
| "Press this command every frame while a condition is true" | Native frame-input override |
| "Run a tutorial-style drill with counters and branchy native state" | Scripted HgCpuDirect* SubVM |
| "Force a move for presentation or move-list tooling" | BattleManager / MoveCommandPlayer UFunctions; see battle-manager.md and move-system.md |
Training-mode UFunctions¶
The training-mode actor exposes 11 UFunctions, all with live native bodies. They are the easiest native-call surface for any "drive the dummy programmatically" mod (record-and-replay, frame-trap setup, combo-trial scripts):
| UFunction | Address | What |
|---|---|---|
StartRecording |
0x1402D3C70 |
Begin recording the human player's inputs |
StartPlayback |
0x1402D3D20 |
Begin playback of the last recorded stream |
StopAndReset |
0x1402D3D00 |
Stop recording or playback and clear the stream |
GetMode |
0x1402D3DA0 |
Returns ELuxTrainingDummyMode |
QueueCommand_Slot0 |
0x1402D3E20 |
Queue a raw input command on slot 0 |
QueueCommand_AtSlot |
0x1402D3E50 |
Queue a raw input command on arbitrary slot |
GetCurrentCommand_Slot0 |
0x1402D3E30 |
Peek the current queued command on slot 0 |
GetCurrentCommand_AtSlot |
0x1402D3F20 |
Peek the current queued command on arbitrary slot |
QueueSpecialMove1Param |
0x1402D3F90 |
Queue a special-move (id 0x69) command with one parameter |
QueueSpecialMove2Param |
0x1402D4000 |
Queue a special-move command with two parameters |
ResetGlobalState |
0x1402DA520 |
Reset the training-mode globals at &DAT_144715430 / &DAT_144715448 queues |
enum class ELuxTrainingDummyMode : uint32 {
OFF = 0,
RECORDING = 1,
PLAYBACK = 2,
};
enum class ELuxTrainingScrubMode : uint32 {
OFF = 0,
FORWARD = 1,
REWIND = 2,
};
Practical recipe: drive the training dummy¶
Use the training-mode UFunction surface when the goal is a programmable dummy, not a new CPU personality. This keeps the mod above the native AI SubVM layer and uses the same training queues the game already knows how to record, play back, and clear. Call these from a native bridge that re-resolves the live training-mode actor after transitions and validates parameter metadata before relying on a portable call shape.
Pick the smallest control surface¶
| Need | Prefer | Notes |
|---|---|---|
| Replay a human-recorded setup | StartRecording / StartPlayback / StopAndReset |
Best for quick training-lab automation because the game captures the same frame-input stream it later consumes. |
| Feed a known raw input command | QueueCommand_Slot0 or QueueCommand_AtSlot |
Use after you have validated the 32-bit command word at runtime. This page does not define the bit packing. |
| Force a special-move command | QueueSpecialMove1Param / QueueSpecialMove2Param |
Native route for command id 0x69; parameters are move-specific and should be treated as runtime-validated data, not portable constants. |
| Clear a wedged queue | StopAndReset, then ResetGlobalState |
ResetGlobalState clears the special-move globals as well as the normal training queue state. |
Safe call order¶
- Enter a live Training match and re-resolve the live training-mode actor after every rematch, character change, or return from replay/menu flow. The BattleManager's training subsystem slots are mapped in battle-manager.md.
- Call
StopAndResetbefore switching from recording to queued commands, or from special-move commands back to normal input playback. - Use
GetModeas the state check afterStartRecording,StartPlayback, andStopAndReset; do not infer the mode only from your mod's last call. - For command queues, queue a minimal known action first, then confirm the same
value appears through
GetCurrentCommand_*before building a longer script. - For special moves, validate both the visible move transition and the queued params in one training session before assuming they survive character, stance, or weapon changes.
- If the dummy does not move, check whether playback/recording won the same
frame over the CPU output. The training playback path can discard live AI
choices in
LuxBattle_TickCharaInput, exactly like replay input does.
No portable call snippet here
The UFunctions are callable, but this page intentionally does not provide hard-coded object lookups or raw command words. Resolve the live UObject and parameter metadata in your runtime environment, then call through your native bridge.
Replay behavior summary¶
| Scenario | AI ticks? | Effect |
|---|---|---|
| Live play vs CPU | Yes | AI's input written to chara+0x2150, consumed as that frame's input |
| Replay viewing (match replay) | Yes | AI ticks, but replay decoder overwrites chara+0x2150 first — AI's choice silently discarded |
| Training mode (dummy in PLAYBACK) | Yes | Recorded inputs win over AI in LuxBattle_TickCharaInput; AI's choice discarded |
| WorldTickGate freeze | No | Site 9 on LuxBattle_PerFrameTick is upstream of the entire AI tick chain — no separate gate needed |
Practical recipe: override frame input¶
Use this when the mod needs a hard per-frame input override, such as a bot that
reacts to the opponent in real time, a deterministic punish trainer, or a test
harness that should ignore the stock CPU choice. This is a native hook job:
LuxBattle_TickCharaInput @ 0x140312510 is not a UFunction, so reflected
UFunction hooks are the wrong tool. Use a native DLL detour or equivalent code
hook at the documented function boundary.
Override checklist¶
- Decide whether this is a soft override or a hard override. A soft override
can use training-mode queues above. A hard override writes the final
chara+0x2150frame-input value after the game has selected the frame's source input. - Instrument one side first. Log the value produced by the AI SubVM
(
pSubVM+0x08), the scheduler copy, and the finalchara+0x2150value for a few frames before writing anything. - Place the write late enough that the source picker has already run. In live CPU play that source is usually the AI path; in replay viewing and training playback the recorded-input path can overwrite the AI output before the move system consumes it. The replay overwrite is the same determinism rule described in replay-system.md.
- Keep the override to one frame at a time. Recompute or re-copy the command every tick instead of assuming the previous value will be held for you.
- Treat
chara+0x2158as part of the raw-input pair until its exact role for your scenario is verified. If your override only changes+0x2150, log+0x2158across held inputs, releases, and simultaneous button presses so edge/hold behavior is not accidentally broken. - Verify the downstream move result through the MoveVM rather than only the input value. The move system page documents the scheduler and active move lanes that are useful for "did this input become the expected move?" checks: move-system.md.
Priority traps¶
| Symptom | Likely cause | Check |
|---|---|---|
| Value is written, but the dummy follows the recording | Training playback wins later in LuxBattle_TickCharaInput |
Disable playback or move the native write after the playback source write. |
| Value is written during replay, but the character follows the replay file | Replay decoder overwrote the live AI/override value | Confirm whether the session is replay viewing; see Replay System. |
| First frame works, held direction/button does not | Hold/edge state is split across the raw-input pair | Compare +0x2150 and +0x2158 while holding and releasing a known input. |
| Move starts one frame late | Hook is before the final source write or after the move consumer | Add frame counters around LuxBattle_TickCharaInput and the MoveVM scheduler path. |
Where to hook for AI mods¶
| Goal | Hook |
|---|---|
| Drive training dummy programmatically | Call the 11 UFunctions above from a native bridge |
| Override an AI's input each frame | Hook LuxBattle_TickCharaInput @ 0x140312510 and write your own value to chara+0x2150 before it returns (replay decoder uses the same primitive) |
| Replace a CPU behavior wholesale | Patch the SubVM at the per-slot sched state — either patch LuxMoveVM_CreateCpuDirectState to construct your derived class, or swap the vftable pointer at pSubVM+0x00 post-construct |
| Add a scripted CPU drill | Mirror the HgCpuDirectTutorial*_Init pattern: construct a 0x70-byte CCpuDirectCommand-derived object with your Tick body and install at the sched-state SubVM slot |
| Freeze AI during a custom pause | None needed — gate LuxBattle_PerFrameTick (see replay-system.md); AI tick is downstream |
Runtime verification checklist¶
Before calling a dummy/CPU mod "working", verify the layer you think is in control is actually the final input source for the frame.
| Check | What to verify |
|---|---|
| Live object resolution | Re-find the BattleManager, charas, and training actor after rematch/menu transitions. Do not reuse stale UObject pointers across rematch or menu flow. |
| Mode state | GetMode agrees with the intended OFF / RECORDING / PLAYBACK state before and after the test action. |
| Input source priority | In one instrumented run, compare the SubVM output, scheduler copy, and final chara+0x2150 value for the same side/frame. This catches replay and training playback overwrites. |
| Raw-input pair behavior | For held inputs and releases, compare both chara+0x2150 and chara+0x2158; do not assume a single dword covers every edge/hold case. |
| Move-system effect | Confirm the expected move or state change in the MoveVM layer, not only the raw input write. Use the active-lane and scheduler notes in move-system.md. |
| Replay/training separation | Repeat the same test in live training, dummy playback, and replay viewing if the mod supports all three. The final input owner changes by mode; this is expected. |
| Pause/frame-step behavior | If the mod interacts with pause or stepping, test both SetBattlePause and native tick gates. BattleManager pause semantics and replay freeze gates are documented in battle-manager.md and replay-system.md. |
| Reset path | Stop playback, clear queued commands, restart the round, and confirm no stale SubVM pointer, queued command, or special-move param survives unintentionally. |
Good evidence is a short trace that ties one frame together: selected source input -> final raw input -> MoveVM-visible move/state result. If any link in that chain is inferred rather than observed, label the mod behavior as runtime-validated only for the scenarios you actually tested.
Function quick reference¶
| Symbol | Address | Page |
|---|---|---|
LuxBattleChara_GetAIStateTableField (landmark) |
0x1402F7370 |
This page |
LuxMoveVM_ScheduleCharaMoveForTick |
0x1402E52D0 |
This page (tick chain) |
LuxMoveVM_SelectMoveCommandContinuation |
0x1402E5470 |
This page (tick chain) |
LuxBattle_TickCharaInput |
0x140312510 |
This page (frame-input target) |
LuxMoveVM_SchedState_CommitCommandSlot |
0x1402E5660 |
This page (special-move bypass) |
LuxMoveVM_CreateCpuDirectState |
0x1402E26A0 |
This page (SubVM factory) |
LuxBattle_InitCpuPersonalityData |
0x140364950 |
This page (personality) |
LuxBattle_RefreshCpuPersonalityData |
0x140364BC0 |
This page (personality) |
LuxBattle_ComputeAllPersonalitySlots |
0x140395320 |
This page (personality) |
LuxBattle_ComputePersonalityActionWeights |
0x140394FE0 |
This page (personality) |
LuxTrainingDummy_StartRecording |
0x1402D3C70 |
This page (training mode) |
LuxTrainingDummy_StartPlayback |
0x1402D3D20 |
This page (training mode) |
LuxTrainingDummy_QueueCommand_AtSlot |
0x1402D3E50 |
This page (training mode) |
LuxTrainingDummy_QueueSpecialMove1Param |
0x1402D3F90 |
This page (training mode) |
LuxMoveVM_TickPickAndDispatchReaction |
0x1402DEF50 |
move-system.md (yarare classifier) |