The outbound MoveToState now sends the RAW forward_speed 1.0 (omitted by
default-difference packing) instead of the pre-computed runRate, matching what
retail's client sends.
Settled the open question with a live echo-test: acdream sends forward_speed=1.0,
ACE broadcasts back RunForward @ runRate (not 1.0), and a retail observer saw
+Acdream run at full pace. So ACE RECOMPUTES the broadcast run speed from the
character's run skill and auto-upgrades WalkForward+HoldKey.Run -> RunForward for
observers. The earlier PlayerMovementController comment citing ACE
MovementData.cs ("ACE relays the speed, so the wire must send run_rate") was
wrong; corrected.
Changes:
- PlayerMovementController section 6: forward wire command is WalkForward @ 1.0
(+ HoldKey.Run when running); LocalAnimationCommand still carries RunForward for
the local cycle. Backward already 1.0. The MotionStateChanged detection now
keys the walk<->run toggle off HoldKey + LocalAnimationCommand (forward_speed is
constant 1.0); the forward_speed comparison is retained but never fires.
- GameWindow: reverted the D6.2b echo-test one-liner; the wire RawMotionState
takes forward_speed from result (now 1.0).
The wire and the D6.2a local velocity are now both raw-1.0. Threading them onto
one shared RawMotionState (removing section-6's duplicate build) is a
behavior-neutral cleanup follow-up.
Full suite green (3255). Docs: roadmap + pseudocode doc updated with the
echo-test finding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
D6 — CMotionInterp motion-normalization port: pseudocode + integration map
Date: 2026-07-01
Phase: L.1 / L.2b follow-up (D6) — port retail's raw→interpreted motion
normalization so local velocity for backward/strafe-left comes from the retail
source instead of hand-mirrored controller code.
Standard: decomp-verbatim. Retail decomp is the oracle; ACE
MotionInterp.cs is the secondary oracle and was confirmed to match retail
byte-for-byte on every constant and branch (understand-phase workflow,
2026-07-01).
Oracle anchors (all in docs/research/named-retail/acclient_2013_pseudo_c.txt):
CMotionInterp::adjust_motion—0x00528010, lines 305343-305400CMotionInterp::apply_run_to_command—0x00527be0, lines 305062-305123CMotionInterp::get_state_velocity—0x00527d50, lines 305160-305204CMotionInterp::apply_raw_movement—0x005287e0, lines 305817-305834 Secondary oracle:references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs(constants L26-32;adjust_motionL394-428;apply_run_to_commandL525-562;get_state_velocityL678-700;get_leave_ground_velocityL654-663;apply_raw_movementL506-523).
Constants (retail = ACE, verified)
| name | value | retail anchor |
|---|---|---|
| BackwardsFactor | 0.649999976 |
007c8910; WalkBackwards speed *= -BackwardsFactor |
| WalkAnimSpeed | 3.11999989 |
007c891c; forward v.Y = WalkAnimSpeed * fwdSpeed |
| RunAnimSpeed | 4.0 |
RunForward v.Y; also maxSpeed = RunAnimSpeed * rate |
| SidestepAnimSpeed | 1.25 |
sidestep v.X; adjust_motion divisor |
| SidestepFactor | 0.5 |
adjust_motion sidestep scale |
| RunTurnFactor | 1.5 |
apply_run_to_command TurnRight |
| MaxSidestepAnimRate | 3.0 |
apply_run_to_command SideStepRight clamp |
MotionCommand hex: 0x45000005=WalkForward, 0x45000006=WalkBackwards,
0x44000007=RunForward, 0x6500000d=TurnRight, 0x6500000e=TurnLeft,
0x6500000f=SideStepRight, 0x65000010=SideStepLeft.
The four functions (faithful pseudocode)
apply_raw_movement (orchestrator) — 0x005287e0
if physics_obj == null: return
// copy 7 raw fields into interpreted
interp.current_style = raw.current_style
interp.forward_command = raw.forward_command ; interp.forward_speed = raw.forward_speed
interp.sidestep_command = raw.sidestep_command; interp.sidestep_speed = raw.sidestep_speed
interp.turn_command = raw.turn_command ; interp.turn_speed = raw.turn_speed
// normalize EACH channel with ITS OWN per-channel hold key
adjust_motion(ref interp.forward_command, ref interp.forward_speed, raw.forward_holdkey)
adjust_motion(ref interp.sidestep_command, ref interp.sidestep_speed, raw.sidestep_holdkey)
adjust_motion(ref interp.turn_command, ref interp.turn_speed, raw.turn_holdkey)
apply_interpreted_movement(arg2, arg3) // acdream: apply velocity via get_state_velocity
adjust_motion(ref cmd, ref speed, holdKey) — 0x00528010
if weenie != null and !weenie.IsCreature(): return // non-creature: no adjust
switch cmd:
RunForward: return // already normalized — NO holdkey path
WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor // -0.649999976
TurnLeft: cmd = TurnRight; speed *= -1
SideStepLeft: cmd = SideStepRight; speed *= -1
// after remap, sidestep anim-rate scale:
if cmd == SideStepRight:
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // (3.12/1.25)*0.5 ≈ 1.24799995
// hold-key (runs for everything EXCEPT the RunForward early-return):
if holdKey == Invalid: holdKey = raw.current_holdkey
if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
GOTCHA: RunForward early-returns and does NOT run the hold-key path. The sidestep negate happens BEFORE the ×1.248 scale (so SideStepLeft = -1.248*speed).
apply_run_to_command(ref cmd, ref speed) — 0x00527be0
speedMod = 1.0
if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate
switch cmd:
WalkForward:
if speed > 0: cmd = RunForward // promote ONLY when moving forward (sign gate)
speed *= speedMod // UNCONDITIONAL — applies to backward too (negative speed)
TurnRight:
speed *= RunTurnFactor // *1.5
SideStepRight:
speed *= speedMod
if abs(speed) > MaxSidestepAnimRate: // clamp to ±3.0 (ACE resolves the {test ah,0x5} polarity)
speed = speed > 0 ? MaxSidestepAnimRate : -MaxSidestepAnimRate
GOTCHA: WalkForward speed *= speedMod is unconditional — so backward
DOES get run-scaled (resolves the UN-5 "backward cites nothing" doubt in
acdream's favor). The walk→run promotion is sign-gated: backward keeps a
negative speed so it stays WalkForward (not promoted).
get_state_velocity() -> local Vector3 — 0x00527d50
v = (0,0,0)
if interp.sidestep_command == SideStepRight: v.X = SidestepAnimSpeed * interp.sidestep_speed // 1.25*s
if interp.forward_command == WalkForward: v.Y = WalkAnimSpeed * interp.forward_speed // 3.12*s
else if interp.forward_command == RunForward: v.Y = RunAnimSpeed * interp.forward_speed // 4.0*s
v.Z = 0
rate = MyRunRate; if weenie != null: weenie.InqRunRate(ref rate)
maxSpeed = RunAnimSpeed * rate // 4.0 * rate
if v.Length() > maxSpeed: v = normalize(v) * maxSpeed // PORT ACE'S FORM — decomp operand
// names are wrong (x87 register aliasing)
GOTCHA: backward/strafe-left produce velocity here ONLY because adjust_motion
already converted them to WalkForward/SideStepRight with negated speed. Z (jump)
is added by the caller get_leave_ground_velocity, AFTER this clamp.
acdream current state (what D6 replaces)
Two divergent velocity paths (verified in source):
- PATH A —
MotionInterpreter.get_state_velocity(MotionInterpreter.cs:599-648) is faithful but only handles WalkForward/RunForward/SideStepRight; returns 0 for WalkBackward + SideStepLeft becauseadjust_motionis not ported (the InterpretedState holds the un-normalized command). - PATH B — the controller bypass (
PlayerMovementController.cs): grounded velocity block builds body-local velocity by hand — forwardlocalY=stateVel.Y(:986), backwardlocalY=-(WalkAnimSpeed*0.65*runMul)(:988), strafelocalX=±SidestepAnimSpeed*runMul(:994/:996);runMul = InqRunRate if Run else 1.0(:981-983). The same formulas are re-copied in the jump block (:1072,:1076-1079) because LeaveGround→get_state_velocity would zero backward/strafe. This duplication is register rows TS-22 + UN-5.
Integration seams:
- Input enters at
PlayerMovementController.Update(dt, MovementInput)(:838); section 2 (:911-1000) maps input →_motion.DoMotion/DoInterpretedMotion- the hand-built velocity.
- Velocity is consumed by
_body.set_local_velocity(:998, jump:1090) → physics integration (:1096-1130) →ResolveWithTransition(:1136-1160). - The outbound wire (section 6,
:1329-1399→ MovementResult → GameWindow:8264-8388) is a SEPARATE construction (the L.2b RawMotionState); it sends the RAW commands (WalkForward+HoldKey.Run, backward/strafe @1.0) and ACE/the observer runs adjust_motion. D6 must NOT change these wire bytes.
Behavior changes D6 introduces (retail-faithful; flag for smoke test)
- Strafe ~20% faster. Current
1.25×runRate; retail1.25 × 1.248 (adjust_motion sidestep scale) × runRate = 1.56×runRate, then clamped viaSideStepSpeed ≤ 3.0(so v.X ≤ 3.75). acdream is currently missing the 1.248 scale + the 3.0 clamp. - Sidestep ±3.0 clamp becomes active at high run rates.
- Backward unchanged for typical run rates (already retail-faithful:
3.12×0.65×runRate), now sourced from the real pipeline. - Backward/strafe-left no longer zero out of
get_state_velocity(the TS-22 bug class is retired at the source, not hand-patched).
Out of scope for D6 (follow-ups)
- Turn:
get_state_velocitydoes not produce turn velocity (turn is angular). acdream drives Yaw directly (RemoteMoveToDriver.TurnRateFor, ~90°/135° per sec). D6 keeps direct-Yaw turn; routing turn through interpreted angular velocity is a separate slice.adjust_motionon the turn channel is still ported (for completeness + the wire), but it does not change turn feel. - RawMotionState unification: retail uses ONE raw state for both the wire and the velocity pipeline. D6 keeps the L.2b wire construction separate and builds/normalizes a velocity-side raw state; unifying the two is a later slice.
apply_current_movementfull sequence (style/forward/sidestep/turn/action ordering,CMotionTable::GetObjectSequence) — the understand-phase extractor for this one hit the structured-output cap; re-extract when the animation sequencing slice needs it. Not required for the velocity port.
Design decisions (locked with the user 2026-07-01)
- Full unification. ONE
AcDream.Core.Physics.RawMotionState(the L.2b type) is built fromMovementInputand drives BOTH the outbound wire packing AND the local velocity/turn pipeline (apply_raw_movement→get_state_velocity+ turn omega). The separate GameWindow wire-construction is retired;MovementResultcarries the raw state, GameWindow packs it. forward_speed = 1.0on run — CONFIRMED viable (echo-test 2026-07-01). The echo-test settled it: acdream sendsforward_speed=1.0, ACE broadcasts backRunForward @ runRate(not1.0), and a retail observer saw +Acdream run at full pace — so ACE recomputes the broadcast run speed from run skill (theMovementData.cs-citing "ACE relays" assumption was wrong). The wire now sends the retail-faithful raw1.0(D6.2b). Original reasoning below stands: The rawforward_speedmust be1.0when running, becauseapply_run_to_commandmultiplies by run-rate — carryingrunRatein the raw state would double-scale torunRate². So the wire now sendsforward_speed=1.0(omitted by default-difference) +HoldKey.Run, NOT the currentrunRate. This is the retail-faithful encoding (observer/server derives the run speed). Acceptance = the smoke echo: send1.0, confirm theUMecho still shows the player at~runRate(⇒ ACE recomputes from run skill ⇒ correct). If the echo shows~1.0, ACE relays literally and the wire half reverts. The L.2b golden tests update to the faithful encoding (forward_speed omitted on run).- Turn ported to interpreted, feel unchanged. Local turn is driven from the
interpreted
turn_speed(adjust_motion: TurnLeft→TurnRight×-1,×1.5run) viaomega.Z = ±(π/2) × turn_speed— the SAME formula the remote path already uses (GameWindow.cs:4845). Numerically identical to today'sTurnRateForfor the local player (bothπ/2 × RunTurnFactor), so no turn-feel change; the fixedTurnRateFordirect-Yaw is replaced by the pipeline. AP-9 stays (theπ/2base rate is still an approximation of the per-creature TurnSpeed; wiring that is a separate follow-up). - References caveat. Only
references/WorldBuilderis checked out in this worktree; ACE/holtburger are not. The retail decomp (docs/research/named-retail/) is the sole in-repo oracle and was verified directly for all four functions. ACE values quoted here are training-memory cross-checks that happen to match the decomp — do not cite ACE file:lines.
Retires / touches: register TS-22 deleted (adjust_motion ported;
get_state_velocity no longer returns 0 for backward/strafe-left). UN-5
resolved (run-scaling backward IS retail — apply_run_to_command unconditional
*speedMod). AP-9 stays (turn base rate). A wire-encoding change row is
added if the echo test confirms forward_speed=1.0.
Implementation slices:
- D6.1 (delegated, bounded): port
adjust_motion+apply_run_to_commandintoMotionInterpreter+ conformance tests, tests-first, against this doc. - D6.2 (lead, delicate):
apply_raw_movementorchestrator + build the unifiedRawMotionStateinPlayerMovementControllerfromMovementInput, route grounded + jump velocity throughget_state_velocity, drive turn omega from interpretedturn_speed, thread the raw state throughMovementResultto the GameWindow packer, delete the hand-mirrors (:988/:994/:996+ jump:1072/:1076-1079) and theTurnRateFordirect-Yaw, update the L.2b golden tests. Then smoke (echo + strafe/backward/turn/jump feel).
Conformance-test targets
adjust_motion: each of the 7 command transforms + the sidestep 1.248 scale + the backward -0.65 + the negate-before-scale order + the RunForward no-op + the non-creature early return + per-channel holdKey inheritance.apply_run_to_command: WalkForward promote-when-speed>0 + unconditional*speedMod; TurnRight ×1.5; SideStepRight ×speedMod + ±3.0 clamp (test the boundary at runRate that pushes |speed| past 3.0).get_state_velocity: backward + strafe-left now non-zero (the TS-22 fix); the maxSpeed normalize clamp; strafe = 1.56×runRate clamped to ≤3.75.apply_raw_movement: 3-channel copy + per-channel adjust + the run-promotion of a running-forward raw state.