Replay System¶
How SC6 records and plays back a match. Critically, a "frozen" replay is not
the same as a frozen training match: replay viewing has the simulation-core
world tick plus seven additional Actor::Tick paths that can keep advancing
chara state, the round timer, recorded-input cursors, and animation. Stopping
LuxBattle_PerFrameTick alone leaves the Actor::Tick paths running.
All addresses absolute (image base 0x140000000).
Replay backends present in the binary¶
SC6 carries UE4's generic DemoNetDriver code, but the current SC6 replay-menu evidence points at the Lux replay path as the authoritative in-game replay system:
| Path | Where observed | What it does |
|---|---|---|
| Lux battle replay | In-game replay saves/viewing, battle replay player, input-log catch-up | Stores SC6 replay data under the REPLAY/ path, restores per-round reset snapshots, and advances deterministic input/frame state through ALuxBattleFrameInputLog and the BattleManager simulation loop. |
UE4 UDemoNetDriver |
Generic UE4 engine code still linked into the binary | Can play/seek UE .demo streams when a real UWorld->DemoNetDriver exists. It is not currently validated as the SC6 replay-menu authority. |
Evidence for the Lux path:
LuxReplayManager_Ctor_WithReplayPath @ 0x140409880constructs theLuxBattleReplaySavefile manager and sets its base path to"REPLAY/".ALuxBattleReplayPlayer_Tick_CopyRoundResetSnapshotAndSetMoveState4 @ 0x140435C20handles replay round navigation, copies a0xC0-byte round-start snapshot intoBattleManager+0x1360, then callsALuxBattleManager_SetMoveState(..., 4).LuxBattleManager_Tick_SimulationLoop_UpdateInputAndRoundState @ 0x1403FE520consumesALuxBattleFrameInputLog+0x3A4 nMasterClockasdeltaand applies recorded input/round-state catch-up.- The Stage 1/2/3 input pipeline below (
FLuxReplayDataBlock, decoded packets, chara-side replay input ring) is native Lux code, not DemoNetDriver packet playback.
Evidence for the UE4 DemoNetDriver path is weaker: the binary contains stock
UE4 strings such as PlayReplay: Attempting to play demo %s,
RecordReplay: failed to create demo net driver!, demo.GotoTimeInSeconds, and
many DemoNetDriver.cpp strings. Those prove the UE4 engine code is present;
they do not by themselves prove the SC6 replay menu routes through it.
Practical detection guidance: for SC6 replay-viewing features, prefer
ALuxBattleReplayPlayer at BM+0x488 and the ALuxBattleFrameInputLog state.
Only use UWorld->DemoNetDriver as a DemoNet signal after verifying it is
non-null in the current session.
Lux input replay opcodes¶
The Lux replay backend stores input as a stream of 3-byte records:
[opcode: uint8][argument: uint16]. The decoder expands that stream into
8-byte per-frame records: {frameId, cursor, p1Input, p2Input}.
| Opcode | Argument | Meaning |
|---|---|---|
1 |
frame id | set the working frame id |
2 |
cursor | set the working replay cursor |
3 |
cursor end | emit decoded records from the working cursor through this cursor |
4 |
P1 input bitmask | update the P1 input mirror used by emitted records |
5 |
P2 input bitmask | update the P2 input mirror used by emitted records |
6 |
ignored | end-of-stream; clears the running flag |
Key functions:
| Function | Address | Role |
|---|---|---|
LuxReplay_DecodeInputPackets_FromFile |
0x1403ED310 |
Stage 1 decoder. Reads 3-byte file records from FLuxReplayDataBlock+0x3C8, writes decoded 8-byte records at +0x3A8. |
LuxReplay_EncodeInputEvents_ToBuffer |
0x1403ED980 |
Inverse encoder. Emits frame/cursor/input records and compresses unchanged cursor runs with opcode 3. |
LuxReplay_WriteThreeByteInputRecord_ToBuffer |
0x1403F62E0 |
Appends one packed opcode record. |
FLuxReplayDataBlock high-traffic offsets:
| Offset | What |
|---|---|
+0x3A8 |
decoded packet buffer ({frame,cursor,p1,p2} records, 8 B each) |
+0x3B0 |
decoded buffer size / limit |
+0x3B8 |
decoded write cursor |
+0x3C0 |
decoded read cursor consumed by Stage 2 |
+0x3C8 |
packed file-opcode buffer |
+0x3D0 |
packed buffer size |
+0x3D8 |
packed buffer write limit |
+0x3E0 |
packed file read cursor |
+0x3F0..+0x3F6 |
working frame, cursor, P1 input, P2 input mirrors |
+0x3FC |
running flag |
For replay tools, treat this as a delta-coded forward stream. Backward scrub
needs a saved decoded stream or a full FLuxReplayDataBlock snapshot restore;
rewinding only the visible cursor is not enough.
Seeking / scrubbing status¶
UE4 4.17.2's DemoNetDriver seek API is present, but it is not validated as the SC6 in-game replay-menu seek path. Treat it as a UE demo utility, not as the Lux battle replay authority:
| Symbol | Address | Role |
|---|---|---|
UDemoNetDriver_GotoTimeInSeconds |
0x141E0ECA0 |
void(UDemoNetDriver* this, float TimeInSeconds, FOnGotoTimeDelegate* del). Queues an FGotoTimeInSecondsTask on NetDriver+0x7E0; drops the request if a goto task is already queued or driver+0x791 is busy. |
RegisterCVar_DemoGotoTimeInSeconds |
0x140255B00 |
Registers the CVar demo.GotoTimeInSeconds. Writing a float reaches the same UE demo task path when a DemoNetDriver is active. |
For SC6 Lux battle replays, the verified native seek-like operation is round navigation, not arbitrary time seek:
ALuxBattleReplayPlayer_Tick_CopyRoundResetSnapshotAndSetMoveState4 @ 0x140435C20watches replay-menu bits 8/9.- It adjusts
ReplayPlayer+0x39C nCurrentRound, selects a0xC0-byte entry fromReplayPlayer+0x3A8 pStateResetRounds, copies that entry intoBattleManager+0x1360, then callsALuxBattleManager_SetMoveState(..., 4). - Frame-accurate backward scrub requires restoring a coherent snapshot of the
Lux replay-data block,
ALuxBattleFrameInputLog, BattleManager replay cursors, and chara replay input overlay state. Rewinding only a visible cursor or writingdemo.GotoTimeInSecondsis not enough for the Lux path.
If you deliberately use the UE demo path, first verify UWorld->DemoNetDriver
is non-null and that the current session is actually a UE .demo playback.
The scrub is asynchronous; don't hold per-actor pointers across it because the
netdriver checkpoint path can tear down and respawn actors.
source:
UDemoNetDriver_GotoTimeInSeconds @ 0x141E0ECA0, task vtable0x1438B9B88,RegisterCVar_DemoGotoTimeInSeconds @ 0x140255B00,ALuxBattleReplayPlayer_Tick_CopyRoundResetSnapshotAndSetMoveState4 @ 0x140435C20,LuxReplayManager_Ctor_WithReplayPath @ 0x140409880.
Do not use DemoNetDriver as the default SC6 replay scrub
The DemoNetDriver API is real engine code, but current SC6 replay-menu
evidence is Lux replay-player/input-log based. A mod that blindly writes
demo.GotoTimeInSeconds may do nothing useful in a normal SC6 replay
viewing session.
At a glance¶
The replay master clock and what advances it¶
ALuxBattleFrameInputLog +0x3A4 is the per-match master clock: a plain
int32 that increments once per UE4 frame in a normal match.
ALuxBattleManager's simulation loop reads it as delta = master - lastApplied
and runs delta iterations of input/round-state catch-up. Two sibling
functions write to it with identical semantics; both must be gated to truly
pin the clock:
| Site | Function | INC at | Dispatcher | What it does |
|---|---|---|---|---|
| R1 | LuxBattleChara_VTable648_TickAndAdvanceReplayClock |
+0x2A |
unconditional | inc dword [rbx+0x3A4] after three vtable sub-tick calls |
| R2 | LuxBattleChara_VTable648_TickAndAdvanceReplayClock_GatedBy4404 |
+0x33 |
cmp byte [rcx+0x4404], 0 then unconditional INC |
live-tick path for the same clock |
The two functions exist as separate copies because they sit on different
vtable slots and fire from different parents. The INC instruction is identical
in both: FF 83 A4 03 00 00 (inc dword [rbx+0x3A4]).
Site 9 plus seven Actor::Tick gates to halt for a frozen replay¶
Verified against HorseMod's gate stack. Site 9 is the simulation-core tick;
the remaining seven rows are separate Actor::Tick chains that drive
chara/replay/round state independently of PerFrameTick:
| Site | Function | RVA | Drives |
|---|---|---|---|
| 9 | LuxBattle_PerFrameTick |
0x1402DBC60 |
The simulation core. Hit detection, MoveVM, frame counter, position integration. |
| 11 | LuxBattleChara_Tick_AdvanceReplayFrame_OrLocal |
0x1403F8410 |
Primary replay-input leak. In replay mode dispatches vtable[0x6A8] (Stage 2/3 input pipeline) and vtable[0x6C8] (chara replay-state writer). Pushes the recorded input into the chara's active slot every UE4 frame. |
| 20 | LuxActor_Tick_CallVtable5f8 (ALuxBattleFrameInputLog::TickActor) |
0x1403FBDF0 |
InputLog actor's tick. Dispatches vtable[0x5F8] → counter advance at InputLog+0x3AC, Stage 2/3 input dispatch, master-clock INC sibling. |
| 21 | LuxBattleManager_Tick_MainStateMachine_At1461 |
0x1403FBF30 |
BM main state machine. Calls LuxBattleManager_Tick_SimulationLoop_UpdateInputAndRoundState (the catch-up loop) and the round-over check. |
| 21b | ALuxBattleManager_Update_Impl |
0x140437590 |
BM tick that drives BattleTime / BattleSystemTime FName timers. The round timer. |
| 22 | ALuxBattleChara_TickActor |
0x1403D0590 |
Chara base TickActor — Maegami hair, weapon mesh anim, SC charge gauge, parent-class actor tick. Long anim montages play out via this path even with sim frozen. |
| 22b | ALuxDemoHumanActor_TickActor |
0x1404865B0 |
Derived class. Inherits ALuxBattleChara. Used for match-replay cinematic playback. Body runs anim-track playback BEFORE tail-calling super, so Site 22 alone doesn't catch it. |
| 22c | APreviewHumanActor_TickActor |
0x140486C60 |
Sibling of 22b (menu chara previews / cinematic paths). |
The TimeDilation bypass — why VMFreezeByte alone fails replay¶
g_LuxBattle_VMFreezeRecord.bVMFreezeByte (the canonical "hitstop" lever
documented in movement.md) is
bypassed by LuxMoveVM_GetTimeDilationScalar @ 0x14030A8C0 for normal-play
characters during replay viewing — see
TimeDilation fall-through paths below.
A complete replay freeze needs an entry-RET on GetTimeDilationScalar that
unconditionally returns 0.0, plus Site 9 and the seven Actor::Tick gates above.
Per-frame chain (replay viewing)¶
Once per UE4 frame, replay-watching state advances along all of these
paths in parallel. The numbers below are labels, not a strict order — UE4
dispatches actor ticks by tick-group and depends-on relationships. What matters
is that each path is reached every frame regardless of PerFrameTick.
UE4 World tick
├── Site 9 — LuxBattle_PerFrameTick (simulation core)
│ └── TickCharaMainSimulation × N charas
│ └── MoveVM, hit detection, integrator, frame counter
│
├── Site 20 — LuxActor_Tick_CallVtable5f8 (FrameInputLog)
│ └── vtable[0x5F8] → counter advance @ InputLog+0x3AC
│ + Stage 2/3 input dispatch
│ + sibling that calls into Site R1/R2
│
├── Site 11 — LuxBattleChara_Tick_AdvanceReplayFrame_OrLocal (per chara)
│ ├── REPLAY mode → vtable[0x6A8] (Stage 2/3 pipeline)
│ │ vtable[0x6C8] (chara replay-state writer)
│ └── LIVE mode → local input dispatch
│
├── Site 22{,b,c} — Chara TickActor variants (per chara)
│ └── anim playback, hair, weapon mesh, SC gauge
│
├── Site 21 — BM_Tick_MainStateMachine_At1461 (BattleManager)
│ ├── LuxBattleManager_Tick_SimulationLoop_UpdateInputAndRoundState
│ │ loop: while (master_clock - last_applied > 0)
│ │ ├── apply recorded input from InputLog ring
│ │ ├── advance round state machine
│ │ └── INC nFrameAdvanceCounter
│ └── round-over check
│
└── Site 21b — ALuxBattleManager_Update_Impl (BM round timer)
└── BattleTime / BattleSystemTime FName timers (TickTimerHandle)
Both master-clock INC functions — R1 (0x1403E1FC0) and R2 (0x1403E2000) —
are reached as sub-ticks under one of the paths above. R2's prologue checks
cmp byte [rcx+0x4404], 0 (the InputLog double-tick guard) before running its
body; R1 is unconditional.
ALuxBattleFrameInputLog field map (replay-relevant)¶
Verified offsets used by the replay tick chain. Full struct in Game Structures.
| Offset | Type | Name | Set/read by |
|---|---|---|---|
+0x39C |
uint32 |
dwPlaybackCursor |
written by Site 20 chain |
+0x3A0 |
int32 |
nLastFrameID |
written by Site 20 chain |
+0x3A4 |
int32 |
nMasterClock |
INC'd by R1 / R2 every UE4 frame. Read as delta = master - lastApplied by SimulationLoop_UpdateInputAndRoundState. |
+0x3A8 |
void* |
pRecordedFrameBuffer |
array of FLuxRecordedFrame (192 B each) |
+0x3AC |
int32 |
(counter) | INC'd via vtable[0x5F8] from Site 20. Sub-tick advance counter. |
+0x3B0 |
int32 |
nTotalRecordedFrames |
|
+0x4404 |
bool |
bDoubleTickGuard |
Per-frame re-entrancy gate. R2 checks this; Site 11 also gates on cmp dword [rcx+0x4400], 0 (a wider guard at +0x4400). |
+0x4410 |
int32 |
nDrainCursor |
The ring at +0x3A8 → FLuxRecordedFrame[] holds ~90 frames
(17 KB / 192 B ≈ 90.7). At 60 fps that is ≈ 1.5 s of recorded input — enough
to bridge a tick-rate hiccup without dropping frames, but not enough to seek
arbitrarily far into the past in a single drain.
SimulationLoop catch-up¶
LuxBattleManager_Tick_SimulationLoop_UpdateInputAndRoundState @ 0x1403FE520
is the consumer side. Sketch:
delta = InputLog.nMasterClock - InputLog.nLastApplied
while (delta-- > 0):
apply one frame of recorded input from the ring buffer
advance round-state machine
INC nFrameAdvanceCounter
drain one InputLog frame entry
This is why a naive freeze — one that pauses PerFrameTick but lets the master
clock advance — produces a fast-forward burst on unfreeze: the master clock
keeps climbing throughout the freeze, delta accumulates, and on unfreeze the
loop runs all delta iterations in a single BM tick.
The HorseMod fix pins nMasterClock itself via the R1/R2 INC gates
(Horse::ReplayClockGate below), so delta
cannot grow during freeze and the catch-up loop stays a no-op.
TimeDilation fall-through paths (bypasses VMFreezeByte)¶
LuxMoveVM_GetTimeDilationScalar @ 0x14030A8C0 is the per-chara time-scale
multiplier consulted by every dt-scaled integrator (position, MoveVM advance,
anim montage, hit detection, etc.). It has four return paths, and only one
of them honours bVMFreezeByte:
float LuxMoveVM_GetTimeDilationScalar(LuxBattleChara* chara) {
byte charaKindByte = chara->CharaKindByte; // +0x23C
// OUTER GUARD — per-opp-mode cell. If 3, skip everything below.
if ((&DAT_14484645c)[charaKindByte ^ 1] != 3) {
if ((int)DAT_144846458 < 0) { // global mode
// ENTRY GATE — only reaches Path A/B if state != 2 OR
// (P2 AND opp.state == 2). For P1 in normal play (state==2),
// condition is FALSE → fall through to chara+0x3500 below.
if ((chara->state_at0x19EC != 2) ||
(charaKindByte != 0 &&
chara->Opp->state_at0x19EC == 2)) {
if (chara->slot_at0x3510 < 0.0f) {
// PATH A — special slow-mo (super-flash, finishing-blow).
// *** BYPASSES VMFreezeByte ***
return flOutBlendW1 * chara->scale_at0x3500;
}
// PATH B — normal play. Honours VMFreezeByte.
float fVar1 = (g_VMFreezeRecord.bVMFreezeByte == 0)
? g_VMFreezeRecord.flBaseAlpha : 0.0f;
return fVar1 * min(chara->scale_at0x3500, perCharaCap);
}
// P1 in state==2 falls through ↓
}
else if (DAT_144846458 != charaKindByte) {
// PATH C — mode mismatch.
return 0.0f;
}
}
// FALL-THROUGH — per-chara base time-scale (typically 1.0).
// *** BYPASSES VMFreezeByte ***
return chara->scale_at0x3500;
}
Why this matters for replay viewing¶
During replay viewing the chara's match-state byte at +0x19EC is 2
(normal play): even though inputs come from a recorded file, the state byte
stays 2 the whole time. For P1 (charaKindByte == 0), the entry gate evaluates
to (2 != 2) || (0 != 0 && ...) = FALSE, so Path A/B is skipped and the
function falls through to chara+0x3500 (normally 1.0f). Setting
bVMFreezeByte = 1 therefore does nothing for P1 in this state — UE4 anim
instances scale by the engine's tick dilation, so the replay keeps advancing.
The HorseMod fix is an entry-RET trampoline that returns 0.0f whenever the
freeze policy slot is 0, regardless of the chara's match-state byte
(Horse::TimeDilationGate).
Why hit-stop / super-freeze still work in stock SC6: the engine writes
chara+0x3510to a negative value during super-flash (Path A), or transitions chara state out of2during cinematics (Path B), so the VMFreezeByte path actually executes. PlainbVMFreezeByte = 1from a mod — with the chara still in state==2 and+0x3510non-negative — never reaches a path that consults the byte.
Stage 2/3 input pipeline (vtable[0x6A8] / [0x6C8])¶
Site 11 (LuxBattleChara_Tick_AdvanceReplayFrame_OrLocal) dispatches
two vtable slots in REPLAY mode:
| Slot | Role | Effect |
|---|---|---|
vtable[0x6A8] |
Stage 2/3 input pipeline | Walks the InputLog frame buffer and pushes the next recorded input bitmask into the chara's input ring |
vtable[0x6C8] |
Chara replay-state writer | Writes recorded chara state into +0x39C/+0x3A0/+0x3A8/etc. (the per-chara replay-state fields) |
LuxBattleChara_ReplayPlayback_PushInputsToActiveSlots @ 0x1403F6600 is the
actual Stage-3 push function. It drains the InputLog ring into chara+0x3C0..
(the active-input slot), one entry per UE4 frame.
This is the path behind the visible "stepping shows held inputs" symptom: if the master clock keeps advancing during freeze, the InputLog ring fills with buffered inputs; then on unfreeze the push function empties the entire ring in one tick — seen on screen as the chara mashing buttons.
HorseMod gate stack summary¶
| Component | Hooks | Role |
|---|---|---|
Horse::WorldTickGate |
Site 9 | Owns the policy slot. RET on PerFrameTick when slot=0; consume one credit when slot>0. |
Horse::ReplayClockGate |
INC at R1+0x2A, INC at R2+0x33 |
Skip the master-clock INC when policy=0. Keeps delta=0 so SimulationLoop's catch-up loop is a no-op during freeze. |
Horse::ActorTickGate |
Sites 11, 20, 21, 21b, 22, 22b, 22c | Entry-RET on each Actor::Tick prologue when policy=0. Halts the seven Actor::Tick chains that aren't covered by Site 9. |
Horse::TimeDilationGate |
LuxMoveVM_GetTimeDilationScalar @ 0x14030A8C0 entry |
Force return 0.0f (XORPS XMM0; RET) when policy=0. Bypasses the function's state==2 fall-through that would otherwise return chara+0x3500 ≈ 1.0. |
The four gates share a single int32_t policy slot:
| Slot value | Meaning |
|---|---|
0 |
Frozen — every gated path bails (bare RET). |
> 0 |
Step credits — gated paths run; PerFrameTick consumes one credit per observed run. |
< 0 |
Treated as "always run" (defensive equivalent of native; sibling gates never write negative). |
Only WorldTickGate decrements credits; the sibling gates read the slot and
never modify it. This avoids tick-order off-by-one bugs — e.g. if a
decrementing sibling ran first and took the slot 1→0, another sibling in the
same UE4 frame would see 0 and bail even though the credit was meant for that
frame.
"Native" mode (mod disabled) is achieved simply by leaving every BytePatch disabled — there is no policy-slot-driven "always run" mode in normal operation.
Code references¶
| Symbol | RVA | Role |
|---|---|---|
LuxBattle_PerFrameTick |
0x1402DBC60 |
Site 9. Simulation core entry. |
LuxBattleChara_Tick_AdvanceReplayFrame_OrLocal |
0x1403F8410 |
Site 11. Per-chara replay-frame advance. |
LuxActor_Tick_CallVtable5f8 |
0x1403FBDF0 |
Site 20. ALuxBattleFrameInputLog::TickActor — dispatches vtable[0x5F8]. |
LuxBattleManager_Tick_MainStateMachine_At1461 |
0x1403FBF30 |
Site 21. BM main state-machine tick + simulation-loop call. |
ALuxBattleManager_Update_Impl |
0x140437590 |
Site 21b. BM round-timer driver. |
ALuxBattleChara_TickActor |
0x1403D0590 |
Site 22. Base chara TickActor. |
ALuxDemoHumanActor_TickActor |
0x1404865B0 |
Site 22b. Replay cinematic chara TickActor (derived). |
APreviewHumanActor_TickActor |
0x140486C60 |
Site 22c. Menu preview chara TickActor (derived). |
LuxBattleChara_VTable648_TickAndAdvanceReplayClock |
0x1403E1FC0 |
Site R1. INC at +0x2A (inc dword [rbx+0x3A4]). |
LuxBattleChara_VTable648_TickAndAdvanceReplayClock_GatedBy4404 |
0x1403E2000 |
Site R2. INC at +0x33, gated by +0x4404. |
LuxBattleManager_Tick_SimulationLoop_UpdateInputAndRoundState |
0x1403FE520 |
Catch-up loop consumer. Reads delta = master - lastApplied. |
LuxBattleChara_ReplayPlayback_PushInputsToActiveSlots |
0x1403F6600 |
Stage-3 push: drains InputLog ring into chara active-input slot. |
LuxMoveVM_GetTimeDilationScalar |
0x14030A8C0 |
Per-chara time scale. Four return paths; only Path B honours bVMFreezeByte. |
g_LuxBattle_VMFreezeRecord |
0x1448462D0 |
64-byte record. bVMFreezeByte at +0x00, flBaseAlpha at +0x04. |
UDemoNetDriver_GotoTimeInSeconds |
0x141E0ECA0 |
UE4 demo seek API present in the binary. Not validated as the SC6 Lux replay-menu scrub path. |
RegisterCVar_DemoGotoTimeInSeconds |
0x140255B00 |
Registers CVar demo.GotoTimeInSeconds for the UE demo task path. |
Practical notes for mods¶
- Freezing a replay is not freezing a training match. A "pause" that
works in training (e.g.
bVMFreezeByte=1alone, orSetBattlePause) will visibly leak in replay viewing — chara anims keep playing, the round timer drains, the replay auto-advances. SetBattlePausekeeps six handlers running by design (seeOnBattleTickWhenPaused). One of those isALuxBattleFrameInputLog @ BM+0x478— i.e. SC6's own pause path intentionally keeps the replay-cursor handler alive.- The seven additional Actor::Tick sites are not exhaustive for every conceivable
freeze use case. They cover the paths that drive gameplay-relevant
state; UMG widget Tick, particle/Niagara render time, audio, and
non-battle actors keep ticking regardless. For full pause semantics
including UI, use UE4's
bGamePaused. - Step credits drain one per
PerFrameTickrun, not per UE4 frame. When a UE4 frame fires multiple gated paths in any order, all of them see the same policy value for the whole frame — no off-by-one drift. This is why the decrement is owned exclusively by Site 9.