acdream/docs/research/2026-07-01-d6-motion-interp-pseudocode.md
Erik 4ed278369b docs(D6): CMotionInterp motion-normalization pseudocode + unification design
Understand-phase output for D6: verbatim decomp + faithful pseudocode for
adjust_motion (0x00528010), apply_run_to_command (0x00527be0),
get_state_velocity (0x00527d50), apply_raw_movement (0x005287e0), all
cross-checked against the retail decomp (ACE not checked out in this worktree;
its constants match the decomp). Includes the acdream integration map (the
hand-mirrored backward/strafe bypass D6 replaces), the retail-faithful behavior
changes (strafe ~20% faster + 3.0 clamp), and the locked design decisions:
full RawMotionState unification (wire+velocity+turn), forward_speed=1.0 on run
verified via smoke echo, turn ported to interpreted omega (feel unchanged,
AP-9 base rate stays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:30:25 +02:00

13 KiB
Raw Blame History

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_motion0x00528010, lines 305343-305400
  • CMotionInterp::apply_run_to_command0x00527be0, lines 305062-305123
  • CMotionInterp::get_state_velocity0x00527d50, lines 305160-305204
  • CMotionInterp::apply_raw_movement0x005287e0, lines 305817-305834 Secondary oracle: references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs (constants L26-32; adjust_motion L394-428; apply_run_to_command L525-562; get_state_velocity L678-700; get_leave_ground_velocity L654-663; apply_raw_movement L506-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 AMotionInterpreter.get_state_velocity (MotionInterpreter.cs:599-648) is faithful but only handles WalkForward/RunForward/SideStepRight; returns 0 for WalkBackward + SideStepLeft because adjust_motion is 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 — forward localY=stateVel.Y (:986), backward localY=-(WalkAnimSpeed*0.65*runMul) (:988), strafe localX=±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)

  1. Strafe ~20% faster. Current 1.25×runRate; retail 1.25 × 1.248 (adjust_motion sidestep scale) × runRate = 1.56×runRate, then clamped via SideStepSpeed ≤ 3.0 (so v.X ≤ 3.75). acdream is currently missing the 1.248 scale + the 3.0 clamp.
  2. Sidestep ±3.0 clamp becomes active at high run rates.
  3. Backward unchanged for typical run rates (already retail-faithful: 3.12×0.65×runRate), now sourced from the real pipeline.
  4. 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_velocity does 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_motion on 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_movement full 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)

  1. Full unification. ONE AcDream.Core.Physics.RawMotionState (the L.2b type) is built from MovementInput and drives BOTH the outbound wire packing AND the local velocity/turn pipeline (apply_raw_movementget_state_velocity + turn omega). The separate GameWindow wire-construction is retired; MovementResult carries the raw state, GameWindow packs it.
  2. forward_speed = 1.0 on run (decomp-forced). The raw forward_speed must be 1.0 when running, because apply_run_to_command multiplies by run-rate — carrying runRate in the raw state would double-scale to runRate². So the wire now sends forward_speed=1.0 (omitted by default-difference) + HoldKey.Run, NOT the current runRate. This is the retail-faithful encoding (observer/server derives the run speed). Acceptance = the smoke echo: send 1.0, confirm the UM echo 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).
  3. Turn ported to interpreted, feel unchanged. Local turn is driven from the interpreted turn_speed (adjust_motion: TurnLeft→TurnRight ×-1, ×1.5 run) via omega.Z = ±(π/2) × turn_speed — the SAME formula the remote path already uses (GameWindow.cs:4845). Numerically identical to today's TurnRateFor for the local player (both π/2 × RunTurnFactor), so no turn-feel change; the fixed TurnRateFor direct-Yaw is replaced by the pipeline. AP-9 stays (the π/2 base rate is still an approximation of the per-creature TurnSpeed; wiring that is a separate follow-up).
  4. References caveat. Only references/WorldBuilder is 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_command into MotionInterpreter + conformance tests, tests-first, against this doc.
  • D6.2 (lead, delicate): apply_raw_movement orchestrator + build the unified RawMotionState in PlayerMovementController from MovementInput, route grounded + jump velocity through get_state_velocity, drive turn omega from interpreted turn_speed, thread the raw state through MovementResult to the GameWindow packer, delete the hand-mirrors (:988/:994/:996 + jump :1072/:1076-1079) and the TurnRateFor direct-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.