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

221 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.