diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index ae0c01ac..f182d51a 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -200,7 +200,6 @@ accepted-divergence entries (#96, #49, #50).
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
-| TS-22 | `adjust_motion` not ported — backward (×−0.65) / strafe (×−1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
| TS-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), but the outbound caller builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91` | Discrete client-initiated motions (D2) not wired yet; packer-ready, runtime emission is Phase 2+ | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
@@ -212,10 +211,11 @@ accepted-divergence entries (#96, #49, #50).
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 |
+| TS-34 | `adjust_motion`'s creature guard is a no-op — retail returns early for a non-creature weenie (`!IsCreature()`), but acdream's `IWeenieObject` has no `IsCreature()`, so the guard always proceeds | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`adjust_motion`) | Correct today: the only caller of the local `adjust_motion`/`apply_raw_movement` path is the player (a creature); no non-creature drives it | If a non-creature weenie is ever routed through `adjust_motion` (e.g. a missile with a weenie), its motion would be wrongly normalized instead of skipped | `CMotionInterp::adjust_motion` 0x00528010 creature guard; add `IsCreature` to IWeenieObject |
---
-## 5. Unclear (UN) — 5 rows
+## 5. Unclear (UN) — 4 rows
These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded
@@ -226,7 +226,6 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
-| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
@@ -242,17 +241,15 @@ WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
-4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
-5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
-6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
-7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
-8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
-9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
-10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
-11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
-12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
-13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
-14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
+4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
+5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
+6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
+7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
+8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
+9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
+10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
+11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
+12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 1c47dd23..f5f21abb 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -616,7 +616,17 @@ change must keep both phase plans in sync.
`AceModernCommandCatalog` runtime default + full-extraction
`Retail2013CommandCatalog` conformance; deleted the blind 0x016E–0x0197
override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug
- as ISSUES #159. Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)
+ as ISSUES #159. Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)*
+ *(D6.2a, shipped 2026-07-01:* ported `CMotionInterp::adjust_motion` /
+ `apply_run_to_command` / `apply_raw_movement` into `MotionInterpreter` and
+ unified the local velocity + keyboard turn + jump onto one input-built
+ `RawMotionState` → `get_state_velocity` — backward/strafe-left no longer zero,
+ strafe is now retail-exact (`~1.56×runRate`, clamped ≤3.75), turn omega from
+ interpreted `turn_speed`, hand-mirrored formulas deleted. Retired register
+ TS-22 + UN-5; added TS-34 (IsCreature guard no-op). `WalkAnimSpeed` unified to
+ the retail-exact 3.11999989f. Pseudocode:
+ `docs/research/2026-07-01-d6-motion-interp-pseudocode.md`. NEXT D6.2b: the
+ echo-gated wire `forward_speed=1.0` question.)
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
style transitions, attack swings by stance/power/height, hit reactions,
evades/blocks/parries, and death handoff.
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index b6923fb9..b4df15b1 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -875,6 +875,40 @@ public sealed class PlayerMovementController
TurnSpeed: null);
}
+ // ── D6.2: normalize input into the interpreted motion state ───────────
+ // ONE RawMotionState from MovementInput drives the local velocity + turn
+ // (retail CMotionInterp::apply_raw_movement 0x005287e0). RAW speeds are
+ // 1.0 — apply_run_to_command applies the run rate, so passing a pre-scaled
+ // speed would double-scale. adjust_motion normalizes backward →
+ // WalkForward (×-0.65) and strafe-left → SideStepRight (×-1×1.248), so
+ // get_state_velocity below is correct for ALL directions (retires the
+ // hand-mirrored formulas; register TS-22). Skipped during server
+ // auto-walk, which drives the body itself.
+ if (!autoWalkConsumedMotion)
+ {
+ var axisHold = input.Run ? HoldKey.Run : HoldKey.None;
+ var raw = new RawMotionState
+ {
+ CurrentHoldKey = axisHold,
+ ForwardCommand = input.Forward ? MotionCommand.WalkForward
+ : input.Backward ? MotionCommand.WalkBackward
+ : MotionCommand.Ready,
+ ForwardHoldKey = (input.Forward || input.Backward) ? axisHold : HoldKey.Invalid,
+ ForwardSpeed = 1.0f,
+ SidestepCommand = input.StrafeRight ? MotionCommand.SideStepRight
+ : input.StrafeLeft ? MotionCommand.SideStepLeft
+ : 0u,
+ SidestepHoldKey = (input.StrafeRight || input.StrafeLeft) ? axisHold : HoldKey.Invalid,
+ SidestepSpeed = 1.0f,
+ TurnCommand = input.TurnRight ? MotionCommand.TurnRight
+ : input.TurnLeft ? MotionCommand.TurnLeft
+ : 0u,
+ TurnHoldKey = (input.TurnRight || input.TurnLeft) ? axisHold : HoldKey.Invalid,
+ TurnSpeed = 1.0f,
+ };
+ _motion.apply_raw_movement(raw);
+ }
+
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
@@ -886,11 +920,21 @@ public sealed class PlayerMovementController
// Effective: walking ≈ 90°/s, running ≈ 135°/s.
// Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally
// close to retail walking but no run differentiation.
- float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run);
- if (input.TurnRight)
- Yaw -= keyboardTurnRate * dt;
- if (input.TurnLeft)
- Yaw += keyboardTurnRate * dt;
+ // D6.2: local keyboard turn rate now comes from the interpreted turn
+ // state (adjust_motion normalized TurnLeft→TurnRight with sign +
+ // apply_run_to_command ×RunTurnFactor when running). omega.Z =
+ // BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
+ // path uses (GameWindow ObservedOmega seed). Numerically identical to the
+ // former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
+ // Skipped during auto-walk (server drives the turn). Mouse turn stays a
+ // direct Yaw delta (deliberately generates no turn command — avoids
+ // MoveToState spam from mouse jitter).
+ if (!autoWalkConsumedMotion
+ && _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
+ {
+ Yaw -= RemoteMoveToDriver.BaseTurnRateRadPerSec
+ * _motion.InterpretedState.TurnSpeed * dt;
+ }
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
@@ -910,53 +954,10 @@ public sealed class PlayerMovementController
// (Ready/Stand) velocity, freezing the body.
if (!autoWalkConsumedMotion)
{
- // Determine the dominant forward/backward command and speed.
- uint forwardCmd;
- float forwardCmdSpeed;
- if (input.Forward)
- {
- forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
- // When running, use the PlayerWeenie's RunRate as ForwardSpeed.
- // The retail server computes this from Run skill + encumbrance and
- // broadcasts it in UpdateMotion, but it doesn't echo to the sender.
- // We compute locally using the same formula.
- if (input.Run && _weenie.InqRunRate(out float runRate))
- forwardCmdSpeed = runRate;
- else
- forwardCmdSpeed = 1.0f;
- }
- else if (input.Backward)
- {
- forwardCmd = MotionCommand.WalkBackward;
- // K-fix3 (2026-04-26): backward also honors Run. Without
- // this, holding X with Run=true (default) still produced
- // walk-tier backward speed because forwardCmdSpeed was
- // hardcoded to 1.0. Now scale by runRate the same way
- // RunForward does.
- if (input.Run && _weenie.InqRunRate(out float runRateBack))
- forwardCmdSpeed = runRateBack;
- else
- forwardCmdSpeed = 1.0f;
- }
- else
- {
- forwardCmd = MotionCommand.Ready;
- forwardCmdSpeed = 1.0f;
- }
-
- // Update interpreted motion state (needed for animation + server messages).
- _motion.DoMotion(forwardCmd, forwardCmdSpeed);
-
- // Sidestep.
- if (input.StrafeRight)
- _motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true);
- else if (input.StrafeLeft)
- _motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true);
- else
- {
- _motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true);
- _motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true);
- }
+ // D6.2: the forward/sidestep command determination + DoMotion +
+ // DoInterpretedMotion moved into the apply_raw_movement call above, so
+ // the interpreted state (and thus get_state_velocity) is already
+ // populated + normalized for all directions.
// Only replace velocity with motion interpreter output when grounded.
// While airborne, the physics body's integrated velocity (from LeaveGround)
@@ -965,37 +966,13 @@ public sealed class PlayerMovementController
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
+ // D6.2: velocity for ALL directions comes from the normalized
+ // interpreted state — backward (WalkForward ×-0.65) and strafe-left
+ // (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
+ // that adjust_motion ran above. The hand-mirrored formulas are gone
+ // (register TS-22 retired).
var stateVel = _motion.get_state_velocity();
-
- float localY = 0f;
- float localX = 0f;
-
- // K-fix3 (2026-04-26): unified run-multiplier for backward
- // + strafe. Forward already scales correctly because it uses
- // stateVel.Y (which the motion state machine fed runRate
- // into via DoMotion). Backward + strafe bypass the state
- // machine and hardcoded speed; previously they capped at
- // walk speed regardless of Run, which made the ~2.4×
- // forward-vs-back/strafe ratio feel wrong. Now both scale
- // with the same runRate the forward branch uses.
- float runMul = 1.0f;
- if (input.Run && _weenie.InqRunRate(out float vrr))
- runMul = vrr;
-
- if (input.Forward)
- localY = stateVel.Y;
- else if (input.Backward)
- localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul);
-
- // Strafe scales with the same runMul so sidestep matches
- // the forward pace at run speed (retail uses speed=1.0 for
- // SideStep + the same hold-key-driven run/walk multiplier).
- if (input.StrafeRight)
- localX = MotionInterpreter.SidestepAnimSpeed * runMul;
- else if (input.StrafeLeft)
- localX = -MotionInterpreter.SidestepAnimSpeed * runMul;
-
- _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
+ _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz));
}
} // end of `if (!autoWalkConsumedMotion)` — section 2
@@ -1029,56 +1006,13 @@ public sealed class PlayerMovementController
float jumpVz = _motion.get_jump_v_z();
_motion.LeaveGround();
outJumpExtent = _jumpExtent;
- // BODY-LOCAL jump-launch velocity, computed directly from input.
- //
- // Why not read _body.Velocity? Because _motion.LeaveGround()
- // routes through get_leave_ground_velocity → get_state_velocity,
- // which is a faithful port of retail's FUN_00528960. Retail's
- // version only handles WalkForward (0x45000005) / RunForward
- // (0x44000007) / SideStepRight (0x6500000F); WalkBackwards
- // and SideStepLeft return zero. Retail papers over this in
- // adjust_motion (FUN_00528010) by translating
- // WalkBackwards → WalkForward + speed × -0.65
- // SideStepLeft → SideStepRight + speed × -1
- // before they reach InterpretedState — but we don't yet port
- // adjust_motion, so InterpretedState holds the un-translated
- // command and get_state_velocity returns (0,0,0) for it.
- // LeaveGround then writes (0,0,jumpZ) to the body, wiping the
- // correct strafe/backward velocity the controller had just set
- // a few lines up. Result: backward/strafe jumps go straight up.
- //
- // Until adjust_motion is ported, we mirror the grounded-velocity
- // computation from the block above and stuff the result into
- // outJumpVelocity directly. Local frame: +Y forward, +X right,
- // +Z up — matches retail's body-frame convention. Server
- // rotates body→world on receive, so observers see the jump
- // in the correct world direction.
- float jumpRunMul = 1.0f;
- if (input.Run && _weenie.InqRunRate(out float jvrr))
- jumpRunMul = jvrr;
-
- // Forward uses get_state_velocity (which knows Walk vs Run vs
- // animation-cycle pacing). Backward / Strafe use the same
- // hardcoded scaled formulas the grounded-velocity block above
- // uses (lines 397-408).
- float localY = 0f;
- if (input.Forward)
- {
- var stateVel = _motion.get_state_velocity();
- localY = stateVel.Y;
- }
- else if (input.Backward)
- {
- localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul);
- }
-
- float localX = 0f;
- if (input.StrafeRight)
- localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
- else if (input.StrafeLeft)
- localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
-
- outJumpVelocity = new Vector3(localX, localY, jumpVz);
+ // D6.2: get_state_velocity() is now correct for all directions
+ // (apply_raw_movement normalized backward/strafe above), so the
+ // jump-launch velocity is get_state_velocity() + the vertical jump
+ // component. Retires the duplicated hand-mirrored formulas that
+ // existed only until adjust_motion was ported (register TS-22).
+ var jumpVel = _motion.get_state_velocity();
+ outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, jumpVz);
// Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ)
// to the body for backward/strafe-left (same get_state_velocity
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index cffb09fb..ddb278e7 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -251,8 +251,10 @@ public interface IWeenieObject
public sealed class MotionInterpreter
{
// ── animation speed constants (from ACE / confirmed by decompile globals) ─
- /// Walk animation base speed (_DAT_007c96e4 family).
- public const float WalkAnimSpeed = 3.12f;
+ /// Walk animation base speed. Retail-exact (.rdata 0x007c891c =
+ /// 3.11999989f); unified for both and
+ /// 's sidestep scale in D6.2.
+ public const float WalkAnimSpeed = 3.11999989f;
/// Run animation base speed (_DAT_007c96e0 family).
public const float RunAnimSpeed = 4.0f;
/// Sidestep animation base speed (_DAT_007c96e8 family).
@@ -264,6 +266,34 @@ public sealed class MotionInterpreter
/// Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).
public const float MaxJumpExtent = 1.0f;
+ ///
+ /// Backward-speed multiplier applied by when
+ /// remapping WalkBackward → WalkForward (retail .rdata 0x007c8910).
+ /// Retail-exact value; do not round to 0.65f.
+ ///
+ public const float BackwardsFactor = 0.649999976f;
+
+ ///
+ /// Turn speed multiplier applied by for
+ /// TurnRight when the hold key is Run (retail apply_run_to_command, no
+ /// run-rate scaling and no clamp — just a flat ×1.5).
+ ///
+ public const float RunTurnFactor = 1.5f;
+
+ ///
+ /// Symmetric clamp on sidestep interpreted speed applied by
+ /// for SideStepRight when running
+ /// (retail apply_run_to_command SideStepRight clamp, ±3.0).
+ ///
+ public const float MaxSidestepAnimRate = 3.0f;
+
+ ///
+ /// Retail-exact sidestep scale factor used by
+ /// when remapping to SideStepRight (retail SidestepFactor = 0.5).
+ ///
+ public const float SidestepFactor = 0.5f;
+
+
// ── fields (matching struct layout from acclient_function_map.md) ─────────
/// The physics body this interpreter controls (struct offset +0x08).
@@ -284,6 +314,16 @@ public sealed class MotionInterpreter
/// Stored run rate from last successful InqRunRate call (offset +0x7C).
public float MyRunRate = 1.0f;
+ ///
+ /// The physics object's currently-held movement key (retail
+ /// this->raw_state.current_holdkey).
+ /// falls back to this field when the per-channel holdKey argument passed
+ /// in is . D6.2 (the raw-state orchestrator)
+ /// is responsible for keeping this in sync with the raw motion state;
+ /// default is a safe no-op until then.
+ ///
+ public HoldKey CurrentHoldKey = HoldKey.None;
+
/// True when crouching-in-place for a standing long jump (offset +0x70).
public bool StandingLongJump;
@@ -647,6 +687,191 @@ public sealed class MotionInterpreter
return velocity;
}
+ // ── FUN_00528010 — adjust_motion ──────────────────────────────────────────
+
+ ///
+ /// Normalize one raw motion channel (forward, sidestep, or turn) into its
+ /// canonical interpreted form: fold the "negative" commands (WalkBackward,
+ /// TurnLeft, SideStepLeft) into their positive counterpart with a negated/
+ /// scaled speed, then apply the Run hold-key promotion.
+ ///
+ ///
+ /// Decompiled logic (FUN_00528010, docs/research/named-retail/
+ /// acclient_2013_pseudo_c.txt:305343-305400):
+ ///
+ /// if weenie != null and !weenie.IsCreature(): return
+ /// switch cmd:
+ /// RunForward: return // no scale, NO holdkey path
+ /// WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor
+ /// TurnLeft: cmd = TurnRight; speed *= -1
+ /// SideStepLeft: cmd = SideStepRight; speed *= -1
+ /// if cmd == SideStepRight:
+ /// speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // ≈ 1.24799995
+ /// if holdKey == Invalid: holdKey = raw.current_holdkey
+ /// if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
+ ///
+ ///
+ ///
+ ///
+ /// GOTCHA: RunForward early-returns before the sidestep scale AND before
+ /// the hold-key path — a running forward command is already normalized
+ /// and must not be touched again. The sidestep negate (SideStepLeft →
+ /// SideStepRight, ×-1) happens BEFORE the ×1.248 anim-rate scale, so the
+ /// net multiplier for SideStepLeft is -1.248×speed, not -1×(1.248×speed)
+ /// applied out of order (same result here, but the ORDER matters once
+ /// apply_run_to_command's sign-dependent clamp is layered on top).
+ ///
+ ///
+ ///
+ /// Creature guard (retail weenie.IsCreature()) is UNWIRED.
+ /// does not currently expose an
+ /// IsCreature query, so this port always proceeds (equivalent to
+ /// "no weenie" or "is a creature" — true for the player, which is the
+ /// only caller as of D6.1). Flagged for D6.2 / a future IWeenieObject
+ /// extension; do not fabricate a guess at the query here.
+ ///
+ ///
+ /// Raw motion command, normalized in place.
+ /// Raw motion speed, normalized in place.
+ ///
+ /// The hold key for THIS channel (forward/sidestep/turn each carry their
+ /// own hold key in the retail raw state). Pass
+ /// to fall back to .
+ ///
+ public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
+ {
+ // Creature guard unwired — IWeenieObject has no IsCreature(); always proceed.
+
+ switch (motion)
+ {
+ case MotionCommand.RunForward:
+ // Already normalized; no scale, no hold-key path.
+ return;
+ case MotionCommand.WalkBackward:
+ motion = MotionCommand.WalkForward;
+ speed *= -BackwardsFactor;
+ break;
+ case MotionCommand.TurnLeft:
+ motion = MotionCommand.TurnRight;
+ speed *= -1f;
+ break;
+ case MotionCommand.SideStepLeft:
+ motion = MotionCommand.SideStepRight;
+ speed *= -1f;
+ break;
+ }
+
+ if (motion == MotionCommand.SideStepRight)
+ speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed);
+
+ if (holdKey == HoldKey.Invalid)
+ holdKey = CurrentHoldKey;
+
+ if (holdKey == HoldKey.Run)
+ apply_run_to_command(ref motion, ref speed);
+ }
+
+ // ── FUN_00527be0 — apply_run_to_command ───────────────────────────────────
+
+ ///
+ /// Apply the Run hold-key promotion/scale to an already-normalized
+ /// (positive-form) motion command.
+ ///
+ ///
+ /// Decompiled logic (FUN_00527be0, docs/research/named-retail/
+ /// acclient_2013_pseudo_c.txt:305062-305123):
+ ///
+ /// 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
+ /// speed *= speedMod // UNCONDITIONAL — applies to backward too
+ /// TurnRight:
+ /// speed *= RunTurnFactor // ×1.5, no run-rate, no clamp
+ /// SideStepRight:
+ /// speed *= speedMod
+ /// if abs(speed) > MaxSidestepAnimRate: speed = speed > 0 ? Max : -Max
+ ///
+ ///
+ ///
+ ///
+ /// GOTCHA: the WalkForward speed *= speedMod is unconditional —
+ /// it applies even when speed is negative (backward), even though the
+ /// command is NOT promoted to RunForward in that case (the promotion is
+ /// sign-gated, the scale is not).
+ ///
+ ///
+ public void apply_run_to_command(ref uint motion, ref float speed)
+ {
+ float speedMod = 1.0f;
+ if (WeenieObj is not null)
+ speedMod = WeenieObj.InqRunRate(out float rate) ? rate : MyRunRate;
+
+ switch (motion)
+ {
+ case MotionCommand.WalkForward:
+ if (speed > 0f)
+ motion = MotionCommand.RunForward;
+ speed *= speedMod;
+ break;
+ case MotionCommand.TurnRight:
+ speed *= RunTurnFactor;
+ break;
+ case MotionCommand.SideStepRight:
+ speed *= speedMod;
+ if (MathF.Abs(speed) > MaxSidestepAnimRate)
+ speed = speed > 0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate;
+ break;
+ }
+ }
+
+ // ── 0x005287e0 — apply_raw_movement ───────────────────────────────────────
+
+ ///
+ /// Retail CMotionInterp::apply_raw_movement orchestrator
+ /// (0x005287e0, docs/research/named-retail/acclient_2013_pseudo_c.txt:305817-305834).
+ /// Copies the raw motion state into the interpreted state, then normalizes
+ /// each of the three channels (forward / sidestep / turn) IN PLACE via
+ /// using that channel's own hold key.
+ ///
+ ///
+ /// After this call, yields correct velocity
+ /// for ALL directions — backward (WalkBackward→WalkForward, ×-0.65) and
+ /// strafe-left (SideStepLeft→SideStepRight, ×-1×1.248) are normalized here,
+ /// which is the D6 fix that retires the hand-mirrored controller formulas
+ /// (register TS-22). Callers must pass RAW speeds (forward_speed = 1.0), NOT
+ /// pre-run-scaled speeds — applies the run
+ /// rate, so a pre-scaled input would double-scale.
+ ///
+ ///
+ public void apply_raw_movement(RawMotionState raw)
+ {
+ CurrentHoldKey = raw.CurrentHoldKey;
+
+ // Copy raw -> interpreted. Retail copies 7 fields; our
+ // InterpretedMotionState omits current_style (velocity doesn't use it).
+ InterpretedState.ForwardCommand = raw.ForwardCommand;
+ InterpretedState.ForwardSpeed = raw.ForwardSpeed;
+ InterpretedState.SideStepCommand = raw.SidestepCommand;
+ InterpretedState.SideStepSpeed = raw.SidestepSpeed;
+ InterpretedState.TurnCommand = raw.TurnCommand;
+ InterpretedState.TurnSpeed = raw.TurnSpeed;
+
+ // Normalize each channel with its OWN per-channel hold key.
+ uint fCmd = InterpretedState.ForwardCommand; float fSpd = InterpretedState.ForwardSpeed;
+ adjust_motion(ref fCmd, ref fSpd, raw.ForwardHoldKey);
+ InterpretedState.ForwardCommand = fCmd; InterpretedState.ForwardSpeed = fSpd;
+
+ uint sCmd = InterpretedState.SideStepCommand; float sSpd = InterpretedState.SideStepSpeed;
+ adjust_motion(ref sCmd, ref sSpd, raw.SidestepHoldKey);
+ InterpretedState.SideStepCommand = sCmd; InterpretedState.SideStepSpeed = sSpd;
+
+ uint tCmd = InterpretedState.TurnCommand; float tSpd = InterpretedState.TurnSpeed;
+ adjust_motion(ref tCmd, ref tSpd, raw.TurnHoldKey);
+ InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd;
+ }
+
// ── FUN_00529210 — apply_current_movement ─────────────────────────────────
///
diff --git a/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
new file mode 100644
index 00000000..0887c844
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
@@ -0,0 +1,299 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MotionNormalizationTests — Phase D6.1: adjust_motion + apply_run_to_command.
+//
+// Source addresses tested (docs/research/2026-07-01-d6-motion-interp-pseudocode.md):
+// FUN_00528010 (0x00528010) CMotionInterp::adjust_motion
+// FUN_00527be0 (0x00527be0) CMotionInterp::apply_run_to_command
+//
+// Golden constants (retail, verified against ACE MotionInterp.cs byte-for-byte):
+// BackwardsFactor = 0.649999976 (0x007c8910)
+// WalkAnimSpeed = 3.11999989 (0x007c891c, retail-exact — NOT 3.12f)
+// SidestepAnimSpeed = 1.25
+// SidestepFactor = 0.5
+// RunTurnFactor = 1.5
+// MaxSidestepAnimRate = 3.0
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// Fake WeenieObject for injecting a known run rate into apply_run_to_command /
+/// adjust_motion without a real weenie.
+///
+file sealed class FakeRunRateWeenie : IWeenieObject
+{
+ public float RunRate;
+ public bool InqRunRateResult = true;
+
+ public bool InqJumpVelocity(float extent, out float vz)
+ {
+ vz = 0f;
+ return false;
+ }
+
+ public bool InqRunRate(out float rate)
+ {
+ rate = RunRate;
+ return InqRunRateResult;
+ }
+
+ public bool CanJump(float extent) => true;
+}
+
+public sealed class MotionNormalizationTests
+{
+ // Retail-exact sidestep scale: SidestepFactor(0.5) * (3.11999989 / 1.25) ≈ 1.24799995...
+ private const float SidestepScale = 0.5f * (3.11999989f / MotionInterpreter.SidestepAnimSpeed);
+
+ private static MotionInterpreter MakeInterp(IWeenieObject? weenie = null)
+ {
+ return new MotionInterpreter { WeenieObj = weenie };
+ }
+
+ // ── adjust_motion: per-command remap + scale ─────────────────────────────
+
+ [Fact]
+ public void AdjustMotion_WalkBackward_RemapsToWalkForward_NegatesAndScalesSpeed()
+ {
+ // 0x00528010: WalkBackwards -> cmd = WalkForward; speed *= -BackwardsFactor
+ var interp = MakeInterp();
+ uint motion = MotionCommand.WalkBackward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.None);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, 5);
+ Assert.Equal(-0.649999976f, speed, 5);
+ }
+
+ [Fact]
+ public void AdjustMotion_TurnLeft_RemapsToTurnRight_NegatesSpeed()
+ {
+ var interp = MakeInterp();
+ uint motion = MotionCommand.TurnLeft;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.None);
+
+ Assert.Equal(MotionCommand.TurnRight, motion);
+ Assert.Equal(-1.0f, speed, 5);
+ }
+
+ [Fact]
+ public void AdjustMotion_SideStepLeft_RemapsToSideStepRight_NegatesBeforeScale()
+ {
+ // Negate happens BEFORE the ×1.248 sidestep scale, so the net factor is -1.248.
+ var interp = MakeInterp();
+ uint motion = MotionCommand.SideStepLeft;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.None);
+
+ Assert.Equal(MotionCommand.SideStepRight, motion);
+ Assert.Equal(-SidestepScale, speed, 5);
+ Assert.Equal(-1.24799995f, speed, 4);
+ }
+
+ [Fact]
+ public void AdjustMotion_SideStepRight_ScalesSpeed_NoNegate()
+ {
+ var interp = MakeInterp();
+ uint motion = MotionCommand.SideStepRight;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.None);
+
+ Assert.Equal(MotionCommand.SideStepRight, motion);
+ Assert.Equal(SidestepScale, speed, 5);
+ Assert.Equal(1.24799995f, speed, 4);
+ }
+
+ [Fact]
+ public void AdjustMotion_RunForward_EarlyReturns_NoScaleNoHoldKeyPath()
+ {
+ // GOTCHA: RunForward returns immediately -- no scale AND no holdkey promotion,
+ // even when holdKey == Run (there's nothing left to promote it to, but the
+ // early-return also means speed is untouched).
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
+ uint motion = MotionCommand.RunForward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.Run);
+
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(1.0f, speed, 5);
+ }
+
+ [Fact]
+ public void AdjustMotion_NonSidestepNonTurnNonForward_Unchanged_ExceptHoldKeyPath()
+ {
+ // Ready is not one of the switch cases and not SideStepRight after remap,
+ // so command/speed pass through unchanged when holdKey doesn't promote it.
+ var interp = MakeInterp();
+ uint motion = MotionCommand.Ready;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.None);
+
+ Assert.Equal(MotionCommand.Ready, motion);
+ Assert.Equal(1.0f, speed, 5);
+ }
+
+ // ── adjust_motion: holdKey inheritance + Run promotion ───────────────────
+
+ [Fact]
+ public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun()
+ {
+ var interp = MakeInterp();
+ interp.CurrentHoldKey = HoldKey.Run;
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
+
+ // apply_run_to_command: WalkForward + speed>0 -> RunForward; speed *= speedMod (1.0, no weenie).
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(1.0f, speed, 5);
+ }
+
+ [Fact]
+ public void AdjustMotion_HoldKeyNone_DoesNotPromote()
+ {
+ var interp = MakeInterp();
+ interp.CurrentHoldKey = HoldKey.Run;
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.None);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.Equal(1.0f, speed, 5);
+ }
+
+ [Fact]
+ public void AdjustMotion_HoldKeyRunPassedDirectly_PromotesWalkForward()
+ {
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.75f });
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.Run);
+
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(2.75f, speed, 5);
+ }
+
+ // ── apply_run_to_command ──────────────────────────────────────────────────
+
+ [Fact]
+ public void ApplyRunToCommand_WalkForward_PositiveSpeed_PromotesToRunForward_ScalesByRunRate()
+ {
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(2.94f, speed, 5);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_WalkForward_NegativeSpeed_StaysWalkForward_ScalesUnconditionally()
+ {
+ // GOTCHA: speed *= speedMod is UNCONDITIONAL -- backward (negative speed)
+ // still gets run-scaled even though it is NOT promoted to RunForward.
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
+ uint motion = MotionCommand.WalkForward;
+ float speed = -0.649999976f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.Equal(-0.649999976f * 2.94f, speed, 4);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_TurnRight_ScalesByRunTurnFactor_NoRunRateNoClamp()
+ {
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
+ uint motion = MotionCommand.TurnRight;
+ float speed = 1.0f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.TurnRight, motion);
+ Assert.Equal(1.5f, speed, 5);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_SideStepRight_BelowClamp_ScalesByRunRate()
+ {
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.0f });
+ uint motion = MotionCommand.SideStepRight;
+ float speed = 1.0f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.SideStepRight, motion);
+ Assert.Equal(2.0f, speed, 5);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_SideStepRight_AbovePositiveClamp_ClampsToPositiveMax()
+ {
+ // runRate 2.94 * speed 1.248 (post adjust_motion sidestep scale) ≈ 3.669 > 3.0
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
+ uint motion = MotionCommand.SideStepRight;
+ float speed = 1.24799995f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.SideStepRight, motion);
+ Assert.Equal(3.0f, speed, 5);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_SideStepRight_AboveNegativeClamp_ClampsToNegativeMax()
+ {
+ var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
+ uint motion = MotionCommand.SideStepRight;
+ float speed = -1.24799995f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.SideStepRight, motion);
+ Assert.Equal(-3.0f, speed, 5);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_UsesMyRunRate_WhenInqRunRateFails()
+ {
+ var weenie = new FakeRunRateWeenie { RunRate = 999f, InqRunRateResult = false };
+ var interp = MakeInterp(weenie);
+ interp.MyRunRate = 1.5f;
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(1.5f, speed, 5);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_NoWeenie_SpeedModDefaultsToOne()
+ {
+ var interp = MakeInterp(weenie: null);
+ uint motion = MotionCommand.SideStepRight;
+ float speed = 1.0f;
+
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(1.0f, speed, 5);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs
new file mode 100644
index 00000000..45c71889
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs
@@ -0,0 +1,187 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MotionVelocityPipelineTests — Phase D6.2: the full raw→interpreted→velocity
+// pipeline (apply_raw_movement 0x005287e0 → get_state_velocity 0x00527d50).
+//
+// These pin the retail-faithful LOCAL velocity for every direction — the exact
+// behavior the D6.2 controller integration now routes through, replacing the
+// hand-mirrored backward/strafe formulas (register TS-22). Golden values are
+// derived from the pseudocode doc
+// (docs/research/2026-07-01-d6-motion-interp-pseudocode.md) and the retail
+// constants on MotionInterpreter.
+//
+// Notable retail-faithful change vs the old hand-mirror: strafe is now
+// 1.25 × (0.5·WalkAnimSpeed/SidestepAnimSpeed) × runRate = ~1.56 × runRate,
+// clamped via SideStepSpeed ≤ 3.0 (so |v.X| ≤ 3.75). The old code used
+// 1.25 × runRate with no sidestep-scale and no clamp.
+// ─────────────────────────────────────────────────────────────────────────────
+
+file sealed class FakeRunRateWeenie : IWeenieObject
+{
+ public float RunRate;
+ public bool InqRunRateResult = true;
+ public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; }
+ public bool InqRunRate(out float rate) { rate = RunRate; return InqRunRateResult; }
+ public bool CanJump(float extent) => true;
+}
+
+public sealed class MotionVelocityPipelineTests
+{
+ private const float RunRate = 2.75f; // ≈ the +Acdream test char's run rate
+
+ private const float WalkAnim = MotionInterpreter.WalkAnimSpeed; // 3.11999989
+ private const float RunAnim = MotionInterpreter.RunAnimSpeed; // 4.0
+ private const float SideAnim = MotionInterpreter.SidestepAnimSpeed; // 1.25
+ private const float BackFac = MotionInterpreter.BackwardsFactor; // 0.649999976
+ private const float MaxSide = MotionInterpreter.MaxSidestepAnimRate;// 3.0
+ // adjust_motion sidestep scale: 0.5 * (WalkAnim / SideAnim) ≈ 1.24799995
+ private const float SideScale = MotionInterpreter.SidestepFactor * (WalkAnim / SideAnim);
+
+ private static MotionInterpreter MakeInterp(float runRate)
+ => new() { WeenieObj = new FakeRunRateWeenie { RunRate = runRate } };
+
+ private static RawMotionState Raw(
+ uint forward = MotionCommand.Ready, uint sidestep = 0u, uint turn = 0u,
+ HoldKey hold = HoldKey.None)
+ => new()
+ {
+ CurrentHoldKey = hold,
+ ForwardCommand = forward,
+ ForwardHoldKey = forward != MotionCommand.Ready ? hold : HoldKey.Invalid,
+ ForwardSpeed = 1.0f,
+ SidestepCommand = sidestep,
+ SidestepHoldKey = sidestep != 0u ? hold : HoldKey.Invalid,
+ SidestepSpeed = 1.0f,
+ TurnCommand = turn,
+ TurnHoldKey = turn != 0u ? hold : HoldKey.Invalid,
+ TurnSpeed = 1.0f,
+ };
+
+ // ── forward ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RunForward_VelocityIsRunAnimSpeedTimesRunRate()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.Run));
+
+ // WalkForward + Run → RunForward @ runRate; v.Y = RunAnim * runRate (== maxSpeed, no clamp).
+ var v = interp.get_state_velocity();
+ Assert.Equal(RunAnim * RunRate, v.Y, 3);
+ Assert.Equal(0f, v.X, 5);
+ }
+
+ [Fact]
+ public void WalkForward_NoRun_VelocityIsWalkAnimSpeed()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.None));
+
+ var v = interp.get_state_velocity();
+ Assert.Equal(WalkAnim * 1.0f, v.Y, 3); // no run scaling; WalkForward @ 1.0
+ }
+
+ // ── backward (the TS-22 fix: no longer zero) ─────────────────────────────
+
+ [Fact]
+ public void RunBackward_VelocityIsNegativeWalkTimesBackwardFactorTimesRunRate()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(forward: MotionCommand.WalkBackward, hold: HoldKey.Run));
+
+ // WalkBackward → WalkForward, speed *= -0.65; Run → *= runRate (unconditional,
+ // promotion sign-gated so it stays WalkForward). v.Y = WalkAnim * (-0.65 * runRate).
+ var v = interp.get_state_velocity();
+ Assert.Equal(-(WalkAnim * BackFac * RunRate), v.Y, 3);
+ Assert.True(v.Y < 0f, "backward velocity must be negative");
+ // Matches the OLD hand-mirror (WalkAnim * 0.65 * runMul) — backward is unchanged.
+ }
+
+ // ── strafe (retail-faithful magnitude + ±3.0 clamp) ──────────────────────
+
+ [Fact]
+ public void RunStrafeRight_ClampsSideStepSpeedToThree_VelocityIs3p75()
+ {
+ var interp = MakeInterp(RunRate); // 1.248*2.75 = 3.432 > 3.0 → clamps
+ interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run));
+
+ var v = interp.get_state_velocity();
+ Assert.Equal(SideAnim * MaxSide, v.X, 3); // 1.25 * 3.0 = 3.75
+ Assert.Equal(3.75f, v.X, 3);
+ Assert.Equal(0f, v.Y, 5);
+ }
+
+ [Fact]
+ public void RunStrafeRight_BelowClamp_VelocityIsScaledSidestep()
+ {
+ const float lowRun = 2.0f; // 1.248*2.0 = 2.496 < 3.0 → no clamp
+ var interp = MakeInterp(lowRun);
+ interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run));
+
+ var v = interp.get_state_velocity();
+ // v.X = SideAnim * (SideScale * lowRun) = 1.56 * lowRun ≈ 3.12
+ Assert.Equal(SideAnim * SideScale * lowRun, v.X, 3);
+ }
+
+ [Fact]
+ public void RunStrafeLeft_NegatedAndClamped_VelocityIsNeg3p75()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepLeft, hold: HoldKey.Run));
+
+ // SideStepLeft → SideStepRight, negated → -1.248; *runRate = -3.432; clamp → -3.0.
+ var v = interp.get_state_velocity();
+ Assert.Equal(-(SideAnim * MaxSide), v.X, 3); // -3.75
+ Assert.True(v.X < 0f, "strafe-left velocity must be negative");
+ }
+
+ // ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ───────────────
+
+ [Fact]
+ public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.Run));
+
+ Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand);
+ Assert.Equal(MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // +1.5
+ }
+
+ [Fact]
+ public void RunTurnLeft_RemapsToTurnRightWithNegativeRunTurnFactor()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(turn: MotionCommand.TurnLeft, hold: HoldKey.Run));
+
+ // TurnLeft → TurnRight, speed *= -1; Run → *= 1.5 → -1.5.
+ Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand);
+ Assert.Equal(-MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // -1.5
+ }
+
+ [Fact]
+ public void WalkTurn_NoRun_TurnSpeedIsUnity()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.None));
+
+ Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand);
+ Assert.Equal(1.0f, interp.InterpretedState.TurnSpeed, 5); // no run factor
+ }
+
+ // ── idle ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Idle_ReadyState_ZeroVelocity()
+ {
+ var interp = MakeInterp(RunRate);
+ interp.apply_raw_movement(Raw()); // Ready, no sidestep/turn
+
+ var v = interp.get_state_velocity();
+ Assert.Equal(0f, v.X, 5);
+ Assert.Equal(0f, v.Y, 5);
+ }
+}