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>
This commit is contained in:
Erik 2026-07-01 08:30:25 +02:00
parent f271a49e6a
commit 4ed278369b

View file

@ -0,0 +1,221 @@
# 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-305400
- `CMotionInterp::apply_run_to_command``0x00527be0`, lines 305062-305123
- `CMotionInterp::get_state_velocity``0x00527d50`, lines 305160-305204
- `CMotionInterp::apply_raw_movement``0x005287e0`, 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 A**`MotionInterpreter.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_movement`
`get_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.