diff --git a/docs/research/2026-07-01-d6-motion-interp-pseudocode.md b/docs/research/2026-07-01-d6-motion-interp-pseudocode.md new file mode 100644 index 00000000..52ff40a7 --- /dev/null +++ b/docs/research/2026-07-01-d6-motion-interp-pseudocode.md @@ -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.