From 08cb7f96141968a6a772bed920f37abbd6403169 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 18:12:18 +0200 Subject: [PATCH] =?UTF-8?q?docs(spec):=20Phase=20L.3=20=E2=80=94=20Remote?= =?UTF-8?q?=20Entity=20Motion=20Conformance=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port retail's InterpolationManager + MoveOrTeleport routing into acdream so remote players, creatures, and NPCs stop popping at every server position update and instead glide smoothly between sparse authoritative updates the way retail does. Three sub-lanes (incremental, each visually verifiable): - L.3.1 — InterpolationManager core + routing + Omega + soft-snap teardown - L.3.2 — PositionManager (root-motion + interp-offset combiner) - L.3.3 — MoveToManager (server-controlled creature MoveTo) This commit specs L.3.1 in detail and sketches L.3.2/L.3.3. Research baseline (cdb live-trace + named-decomp dive 2026-05-02) captured in docs/research/2026-05-02-remote-entity-motion/ resolved-via-cdb.md. All key constants confirmed from binary, not guessed: MAX_PHYSICS_DISTANCE=96, MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5, MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05, queue cap 20, stall window 5/30%/3. Rollout: ACDREAM_INTERP_MANAGER=1 env-var gate during development (dual-path), single cleanup commit after visual verification removes the flag + old hard-snap path + dead RemoteMotion soft-snap fields. Test plan: ~15 unit tests against the InterpolationManager class (pure-data, no game/window deps). Visual verification primary — parallel retail observer of +Acdream walking/running/strafing/ jumping/turning, all should glide. Co-Authored-By: Claude Opus 4.7 --- .../resolved-via-cdb.md | 192 +++++++++ ...26-05-02-l3-remote-entity-motion-design.md | 403 ++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md create mode 100644 docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md diff --git a/docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md b/docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md new file mode 100644 index 0000000..10afe05 --- /dev/null +++ b/docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md @@ -0,0 +1,192 @@ +# Remote-entity motion — open questions resolved via cdb live trace + +**Date:** 2026-05-02 +**Companion to:** the three agent research reports (paste them in alongside if needed; they live in worktree `adoring-torvalds-d796cf`, `sleepy-grothendieck-9d7483`, `gracious-wright-7af984`). + +The three reports converged on the same overall pipeline (queued +position-chase via `InterpolationManager`) but diverged on three +specifics. We resolved all three with a focused cdb attach to the live +retail acclient.exe v11.4186. + +## Resolution method + +1. Static decomp dive into `MoveOrTeleport`, `InterpolateTo`, + `InterpolationManager::adjust_offset`, `set_velocity`, + `GetAutonomyBlipDistance`, `set_local_velocity`. +2. Constant-value lookup for every named distance/velocity referenced + by those functions (`.formats poi(acclient!NAME)`). +3. Live trace with bps on the routing functions while the user did a + ~30-second mixed-motion scenario in retail (walk, strafe, jump, + run-jump). Captured per-bp hit counts + `set_velocity` caller + return addresses. + +Scripts: `interp_discovery.cdb`, `interp_constants.cdb`, +`interp_const2.cdb`, `interp_trace.cdb`. Logs in worktree. + +## Question 1 — distance threshold value(s) + +**Answer:** the agents weren't disagreeing — **they were describing two +different thresholds doing two different jobs.** + +| Threshold | Constant | Value | Where | Decision | +|---|---|---:|---|---| +| Routing gate | `MAX_PHYSICS_DISTANCE` | **96 m** | `CPhysicsObj::MoveOrTeleport` | within → InterpolateTo (queue); beyond → SetPositionSimple (slide-snap) | +| Enqueue blip | `GetAutonomyBlipDistance()` | 100 m outdoor / 20 m indoor (creature)
100 m outdoor / 25 m indoor (player) | `CPhysicsObj::InterpolateTo` | beyond → enqueue InterpolationNode; within → simpler path (set_heading + StopInterpolating) | +| Reach / duplicate-prune | `DESIRED_DISTANCE` | 0.05 m | `InterpolateTo` + `adjust_offset` | node "reached"; tail-prune duplicates | + +The agents who said "96" cited `MAX_PHYSICS_DISTANCE` in MoveOrTeleport +correctly. The one who said "100/25" cited `GetAutonomyBlipDistance` +correctly but had the indoor-creature value off by 5 (it's 20, not 25 — +**25 is the PLAYER indoor distance**, used for self-correction not for +remote entities). + +`GetAutonomyBlipDistance` decoded: + +```c +float CPhysicsObj::GetAutonomyBlipDistance(this) { + bool isPlayer = (this == CPhysicsObj::player_object); + bool isIndoor = (this->cell_id & 0xFFFF) >= 0x100; // 2-byte cell IDs are dungeon/inside + if (isPlayer) { + return isIndoor ? PLAYER_INSIDE_BLIP_DISTANCE // 25 + : PLAYER_OUTSIDE_BLIP_DISTANCE; // 100 + } else { + return isIndoor ? CREATURE_INSIDE_BLIP_DISTANCE // 20 + : CREATURE_OUTSIDE_BLIP_DISTANCE; // 100 + } +} +``` + +## Question 2 — polarity of the 96 m branch + +**Answer: within 96 m → queue (InterpolateTo). Beyond 96 m → snap +(SetPositionSimple).** Decoded from the disasm AND confirmed by trace. + +Disasm of `CPhysicsObj::MoveOrTeleport` at +0x60: + +``` +fld [esi+20h] ; this->distance_to_player +fcomp MAX_PHYSICS_DISTANCE ; vs 96.0 +fnstsw ax +test ah, 5 +jp +0x91 ; JP fires when distance > 96 → snap branch + +; FALL-THROUGH (distance ≤ 96): queue path +call CPhysicsObj::InterpolateTo + ++0x91: ; (distance > 96): snap branch +call PositionManager::StopInterpolating +call CPhysicsObj::SetPositionSimple +``` + +Trace results (~30 sec mixed motion in Holtburg, all entities visible +within ~30 m of camera): + +| BP | Hits | Notes | +|---|---:|---| +| `MoveOrTeleport` | 207 | every inbound UpdatePosition | +| `InterpolateTo` | 207 | **100 % routed to queue** | +| `SetPositionSimple` | **0** | no slide-snaps | +| `SetPosition` | 0 | no teleports/no-cell | + +Confirmed: every routing decision in the test went to the queue. +SetPositionSimple is the rare exception, only used when the entity is +beyond camera range of significance. + +## Question 3 — does walking-remote use UpdatePosition velocity (`set_velocity`)? + +**Answer: NO.** R3 was correct. Walking remote entities never call +`set_velocity`; their `m_velocityVector` stays at zero (or whatever +prior). Position progress is achieved entirely through `adjust_offset` +walking the body toward queued waypoints. + +Trace evidence: + +| BP | Hits | Notes | +|---|---:|---| +| `set_velocity` | 7 | All 7 had **caller = `0x00511534`** | +| `HandleVectorUpdate` (inbound 0xF74E) | 4 | jumps | + +Resolved `0x00511534` → it's inside `CPhysicsObj::set_local_velocity` +(starts at `0x005114d0`). And `set_local_velocity` is what retail's +local-jump path (CMotionInterp::LeaveGround) uses to stuff the launch +velocity into the body. So: + +- Local player jumps 4 times → `LeaveGround → set_local_velocity → + set_velocity` fires repeatedly (7 hits across 4 jumps; charge-frames + + release). +- Inbound 0xF74E packets arrive (4) — these did NOT cause additional + `set_velocity` hits on remote physobjs in our window. Either retail + gates inside `DoVectorUpdate` based on entity type, or the velocity + field got applied via a different path that doesn't trip our bp. +- 207 walking-remote `UpdatePosition`s → **zero `set_velocity` hits**. + +So **for walking remotes, `m_velocityVector` is zero and the +`UpdatePhysicsInternal` Euler integration `position += velocity*dt` +contributes nothing.** All visible motion is from `adjust_offset` +walking the body toward queue head. + +## Bonus — the rest of the constants we needed + +Resolved while we were in there: + +| Constant | Value | Where used | +|---|---:|---| +| `MAX_INTERPOLATED_VELOCITY_MOD` | **2.0** | `adjust_offset` — multiplier on `minterp->get_adjusted_max_speed()` (the catch-up gain) | +| `MAX_INTERPOLATED_VELOCITY` | 7.5 m/s | `adjust_offset` fallback when minterp is unavailable | +| `MIN_DISTANCE_TO_REACH_POSITION` | 0.20 m | `adjust_offset` per-5-frame progress threshold | +| `max_velocity` | 50 m/s | `set_velocity` magnitude clamp | +| `BIG_DISTANCE` | 999999 m | sentinel | +| `CAMERA_MAP_DISTANCE` | 450 m | unrelated; map render only | + +Constraint distances (used by the constraint sub-system, not the queue): + +| Constant | Value | +|---|---:| +| `PLAYER_OUTSIDE_CONSTRAINT_DISTANCE_START` | 10 | +| `PLAYER_OUTSIDE_CONSTRAINT_DISTANCE_MAX` | 50 | +| `PLAYER_INSIDE_CONSTRAINT_DISTANCE_START` | 5 | +| `PLAYER_INSIDE_CONSTRAINT_DISTANCE_MAX` | 20 | +| `CREATURE_OUTSIDE_CONSTRAINT_DISTANCE_START` | 10 | +| `CREATURE_OUTSIDE_CONSTRAINT_DISTANCE_MAX` | 50 | +| `CREATURE_INSIDE_CONSTRAINT_DISTANCE_START` | 5 | +| `CREATURE_INSIDE_CONSTRAINT_DISTANCE_MAX` | 20 | + +## Where this leaves the implementation plan + +The picture is now fully drawn. To match retail, acdream needs to: + +1. **Implement `InterpolationManager`** — FIFO queue (cap 20), + `InterpolateTo(targetPosition, isMovingTo)` enqueues with + GetAutonomyBlipDistance + DESIRED_DISTANCE prune, `adjust_offset(dt)` + per-tick walks toward head at `min(minterp.get_adjusted_max_speed() × + 2, MAX_INTERPOLATED_VELOCITY_FALLBACK 7.5) × dt`, + `NodeCompleted` pops on arrival within `DESIRED_DISTANCE 0.05`, + `UseTime` periodic stall detection (every 5 frames; if progress < 30 % + of expected → fail counter; > 3 fails → blip-to head). +2. **Implement `MoveOrTeleport` routing** in the inbound UpdatePosition + handler. Replace acdream's current hard-snap with: + - Stale-sequence (instance/position) → ignore. + - Teleport-sequence newer or no-cell → SetPosition (hard-snap). + - has_contact false → no-op. + - has_contact true && distance ≤ 96 → InterpolateTo. + - has_contact true && distance > 96 → SetPositionSimple slide-snap. +3. **Drop velocity-based dead-reckoning for walking remotes.** Remote + `m_velocityVector` should stay at zero unless an inbound 0xF74E + VectorUpdate sets it. The body's progress comes from `adjust_offset`, + not from Euler integration of state-derived velocity. +4. **Apply VectorUpdate.Omega.** Currently parsed but not applied — fix + to make jumping/turning remote arcs match. + +acdream code that goes away when this lands: +- `RemoteMotion.SnapResidualDecayRate` and the soft-snap residual blend. +- The locally-recomputed velocity drive between UpdatePosition packets + (`apply_current_movement → get_state_velocity → Euler` path on remote + entities). + +## Files + +- `interp_discovery.cdb` / `.log` — symbol resolution + prologues +- `interp_constants.cdb` / `.log` — first constant lookup +- `interp_const2.cdb` / `.log` — remaining constant lookup +- `interp_trace.cdb` / `.log` — live routing distribution + set_velocity callers +- This doc consolidates the answers diff --git a/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md b/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md new file mode 100644 index 0000000..29051cd --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md @@ -0,0 +1,403 @@ +# Phase L.3 — Remote Entity Motion Conformance — Design Spec + +Port retail's `InterpolationManager` + `MoveOrTeleport` routing into +acdream so remote players, creatures, and NPCs stop popping at every +server position update and instead glide smoothly between sparse +authoritative updates the way retail does. + +**Methodology:** the named retail decomp at `docs/research/named-retail/` +is ground truth. Live cdb traces against retail acclient.exe v11.4186 +have already resolved the open questions about constants and routing +polarity (see *Research baseline* below). ACE and holtburger are +secondary. + +--- + +## Problem Statement + +Remote-entity motion in acdream is choppy. Compared with retail +observers, our remote-rendered players, creatures, and NPCs: + +- **Pop visibly on every UpdatePosition** (~1 Hz for players, ~5 Hz + for moving creatures). Each inbound 0xF748 hard-snaps `Body.Position` + via `OnLivePositionUpdated` (`GameWindow.cs:3151`), then the local + client extrapolates forward via `apply_current_movement` + Euler + integration until the next server update arrives. Direction error + compounds during the gap because acdream's locally-computed velocity + may diverge from the server's authoritative state. +- **Apply VectorUpdate.Omega = nothing.** `0xF74E` is parsed but the + body's omega field is never written, so jumping/turning observers + show flat arcs instead of curved ones. +- **Run two parallel motion systems that fight each other.** + `RemoteMotion` (in `GameWindow.cs:224`) carries + `SnapResidualDecayRate` + a soft-snap residual blend that's an + acdream-original heuristic. Retail does none of this; it has a + single deterministic pipeline. +- **Server-controlled creature MoveTo is MVP.** `RemoteMoveToDriver` + uses a fixed turn rate, no retracking, no sticky-to-target, no + fail-distance progress — chasing creatures and patrol-walking NPCs + look approximate. + +Result: the world feels jittery; remote characters teleport-then-glide +in place of moving smoothly; jumps look wrong from observers. + +--- + +## Research baseline + +Resolved 2026-05-02 via cdb live-trace + named-decomp dive: + +| Source | Path | +|---|---| +| **Resolution doc (canonical answers)** | `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` | +| Original three agent reports (in worktrees) | `adoring-torvalds-d796cf`, `sleepy-grothendieck-9d7483`, `gracious-wright-7af984` | +| cdb scripts + logs | `interp_discovery.cdb/log`, `interp_constants.cdb/log`, `interp_const2.cdb/log`, `interp_trace.cdb/log` (in worktree) | + +**Key facts established by that work:** + +- Retail does NOT velocity-dead-reckon walking remotes. `m_velocityVector` + stays at zero; only `set_local_velocity` (called from `LeaveGround` = + outbound jump) and `DoVectorUpdate` (inbound 0xF74E) ever touch it. +- All visible motion comes from `InterpolationManager::adjust_offset` + walking the body toward the head node of a FIFO position-waypoint + queue at `2 × motion_max_speed × dt`. +- `CPhysicsObj::MoveOrTeleport` is the routing decision: stale-seq → + ignore; teleport-seq newer or no-cell → `SetPosition` hard-snap; + has_contact && distance ≤ 96 → `InterpolateTo` (queue); + has_contact && distance > 96 → `SetPositionSimple` slide-snap. + +**Constants** (all confirmed by reading the binary's named constant +addresses — not guesses): + +| Constant | Value | Use | +|---|---:|---| +| `MAX_PHYSICS_DISTANCE` | 96 m | MoveOrTeleport router gate | +| `CREATURE_OUTSIDE_BLIP_DISTANCE` | 100 m | InterpolateTo enqueue gate (outdoor) | +| `CREATURE_INSIDE_BLIP_DISTANCE` | 20 m | InterpolateTo enqueue gate (indoor) | +| `MAX_INTERPOLATED_VELOCITY_MOD` | 2.0 | `adjust_offset` catch-up gain × motion max | +| `MAX_INTERPOLATED_VELOCITY` | 7.5 m/s | `adjust_offset` fallback when minterp unavailable | +| `MIN_DISTANCE_TO_REACH_POSITION` | 0.20 m | per-5-frame stall progress threshold | +| `DESIRED_DISTANCE` | 0.05 m | reach + duplicate-prune | +| `max_velocity` | 50 m/s | `set_velocity` magnitude clamp | +| Queue cap | 20 | `InterpolateTo` | +| Stall window | 5 frames | `adjust_offset` periodic check | +| Stall fail trigger | 3 fails / 30 % progress | `UseTime` blip-to-tail | + +--- + +## Phase identity + +**Phase L.3 — Remote Entity Motion Conformance.** Slots into the L = +movement category alongside L.1 (animation) and L.2 (collision). + +Three sub-lanes, each independently shippable + visually verifiable: + +| Sub-lane | Title | Ships | +|---|---|---| +| **L.3.1** | InterpolationManager core + routing | New `InterpolationManager` class, `MoveOrTeleport` routing replacing the hard-snap in `OnLivePositionUpdated`, `VectorUpdate.Omega` application, deletion of `RemoteMotion` soft-snap residual | +| **L.3.2** | PositionManager (root-motion + interpolation-offset combiner) | New `PositionManager` class that combines per-frame animation root-motion offset with the InterpolationManager's catch-up offset before writing the body's frame | +| **L.3.3** | MoveToManager (server-controlled creature MoveTo) | Replaces `RemoteMoveToDriver` MVP with a faithful port: retracking, sticky-to-target, fail-distance progress checks, sphere-cylinder distance variants | + +L.3.2 and L.3.3 get their own brainstorm + spec when L.3.1 lands. +**This document specifies L.3.1 in detail; L.3.2 and L.3.3 are +sketches** (above) so the phase shape is on record. + +--- + +## L.3.1 architecture + +### New file — `src/AcDream.Core/Physics/InterpolationManager.cs` + +Pure-data class. No game/window dependencies. Composed into +`RemoteMotion` (one instance per remote entity). + +```csharp +public sealed class InterpolationManager +{ + // Public API + void Enqueue(Position target, float ownerHeading, bool isMovingTo); + Vector3 AdjustOffset(double dt, float maxSpeedFromMinterp); // returns body-space delta to add this frame + bool IsActive { get; } // queue non-empty + void Clear(); // StopInterpolating equivalent + + // Internals + private readonly LinkedList _queue; // cap 20 + private int _failFrameCounter; + private float _failDistanceLastCheck; + private int _failCount; + + // Constants (all from retail named symbols) + public const int QueueCap = 20; + public const float MaxInterpolatedVelocityMod = 2.0f; + public const float MaxInterpolatedVelocity = 7.5f; + public const float MinDistanceToReachPosition = 0.20f; + public const float DesiredDistance = 0.05f; + public const int StallCheckFrameInterval = 5; + public const float StallProgressMinFraction = 0.30f; + public const int StallFailCountForBlip = 3; +} + +internal sealed class InterpolationNode +{ + public Position Target; + public float Heading; + public bool IsHeadingValid; +} +``` + +`AdjustOffset` algorithm (mirrors `acclient!InterpolationManager::adjust_offset`): + +```text +1. If queue empty → return Vector3.Zero +2. headTarget = queue.First +3. dist = (headTarget.Position - currentBodyPosition).Magnitude +4. If dist < DesiredDistance: + queue.RemoveFirst(); return Vector3.Zero (NodeCompleted) +5. catchUpSpeed = clamp(maxSpeedFromMinterp * MaxInterpolatedVelocityMod, + floor=F_EPSILON, + else MaxInterpolatedVelocity fallback) +6. step = catchUpSpeed * dt (clamped to dist so we don't overshoot) +7. delta = (headTarget.Position - currentBodyPosition).Normalized * step +8. _failFrameCounter++; if (_failFrameCounter >= StallCheckFrameInterval): + progress = _failDistanceLastCheck - dist + if (progress < StallProgressMinFraction * (catchUpSpeed * dt * StallCheckFrameInterval)): + _failCount++ + if _failCount > StallFailCountForBlip: + // blip: hard-snap and clear queue. + // OPEN PRECISION ITEM: retail's UseTime (acclient!00555f20) decides + // head-vs-tail snap; the agent reports disagreed (R1 implies head, R2 says + // tail). Verify by reading the UseTime disasm before implementing this + // branch. Default for the initial port: snap to HEAD (next intended + // waypoint) which matches the more common pattern. + body.Position = headTarget.Position + Clear() + return Vector3.Zero + else: + _failCount = 0 + _failDistanceLastCheck = dist; _failFrameCounter = 0 +9. return delta +``` + +`Enqueue` algorithm (mirrors `acclient!CPhysicsObj::InterpolateTo`): + +```text +1. If queue tail exists and Position.distance(target, tail.Target) < DesiredDistance: + // Duplicate-prune + return +2. If queue.Count >= QueueCap: queue.RemoveLast() (drop oldest) +3. node = new InterpolationNode { Target=target, Heading=ownerHeading, IsHeadingValid=isMovingTo } +4. queue.AddLast(node) +``` + +### Modified — `RemoteMotion` (in `GameWindow.cs:224`) + +Add: `public InterpolationManager Interp { get; } = new();` + +Delete (in cleanup commit, after visual verification): +`SnapResidualDecayRate` constant + soft-snap residual fields +(`_snapResidual*`, etc). + +### Modified — `OnLivePositionUpdated` (`GameWindow.cs:3151`) + +Replace the unconditional hard-snap with retail-faithful routing. +Wrap in `ACDREAM_INTERP_MANAGER=1` env-var gate so we can toggle old +vs new during development. + +```csharp +void OnLivePositionUpdated(EntityPositionUpdate update) +{ + var rm = GetOrCreateRemoteMotion(update.Guid); + + if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + { + // Retail-faithful routing (CPhysicsObj::MoveOrTeleport). + // IsStaleSequence: wrap-aware uint16 compare on the four sequence + // counters (instance, position, teleport, force-position) the + // server stamps on every UpdatePosition. Wrap window = 0x7FFF. + // See acclient!CPhysicsObj::newer_event @ 0x00451B10. + if (IsStaleSequence(update, rm)) return; + + if (update.TeleportSequenceNewer || rm.Body.NoCell) + { + rm.Body.Position = targetPosition; // SetPosition hard-snap + rm.Interp.Clear(); + return; + } + + if (!update.HasContact) return; // no-op + + // Distance source: retail uses this->[+0x20] which is the entity's + // distance to the local player. acdream computes the equivalent on + // demand here — local player position is _playerController.Position. + float dist = Vector3.Distance(targetPosition, _playerController.Position); + if (dist > 96f) { + rm.Interp.Clear(); // StopInterpolating + rm.Body.Position = targetPosition; // SetPositionSimple slide-snap + } else { + // headingFromQuat: extract Z-axis heading from the wire quaternion. + // Use existing acdream Quat→Yaw helper (mirrors GameWindow's + // YawToAcQuaternion in reverse). isMovingTo gates whether the heading + // is preserved across InterpolateTo's "same target" path. + rm.Interp.Enqueue(targetPosition, headingFromQuat, isMovingTo: rm.IsMovingTo); + } + } + else + { + // Existing hard-snap path (unchanged) — kept until cleanup commit + rm.Body.Position = targetPosition; + rm.SnapResidualDecayRate = ...; + } +} +``` + +### Modified — per-frame remote tick (`OnLiveRemoteTick` in `GameWindow`) + +When flag on: ask the InterpolationManager for its catch-up offset and +add it to the body's position. When flag off: existing +apply_current_movement + Euler path. + +```csharp +if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +{ + float maxSpeed = rm.Motion.GetMaxSpeed(); // see MotionInterpreter change below + Vector3 delta = rm.Interp.AdjustOffset(dt, maxSpeed); + rm.Body.Position += delta; +} +else +{ + // Existing apply_current_movement + Euler (unchanged) — kept until cleanup commit +} +``` + +### Modified — `OnLiveVectorUpdated` (`GameWindow.cs:3064`) + +Apply `update.Omega` to the body. Currently parsed-but-ignored. ~3 lines: + +```csharp +if (update.Velocity is { } v) rm.Body.Velocity = v; +if (update.Omega is { } w) rm.Body.AngularVel = w; // NEW +``` + +### Modified — `MotionInterpreter` + +Add `public float GetMaxSpeed()` — port of retail +`CMotionInterp::get_max_speed` and `get_adjusted_max_speed`. Returns +the motion-table-derived max speed for the current InterpretedState. +Used by InterpolationManager via the caller (RemoteMotion's tick). + +Public method, ~10 lines, no new file. + +### Cleanup commit (after visual verification) + +One commit titled `chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead soft-snap path`: + +- Delete the `if/else` env-var gate in `OnLivePositionUpdated` and + `OnLiveRemoteTick`. Keep only the new path. +- Delete `RemoteMotion.SnapResidualDecayRate` field + soft-snap + residual fields. +- Delete the apply_current_movement + Euler dead-reckoning code in + the per-frame remote tick (the OLD branch). + +Net diff after cleanup: ~50 lines deletion, code shrinks. + +--- + +## L.3.1 unit tests + +New test file `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs`. ~10-15 tests covering pure-data behavior: + +| Group | Tests | +|---|---| +| Queue mechanics | `Enqueue_AddsNode`; `Enqueue_DropsOldestAtCap20`; `Enqueue_PrunesDuplicateWithinDesiredDistance`; `Clear_EmptiesQueue` | +| AdjustOffset math | `AdjustOffset_EmptyQueue_ReturnsZero`; `AdjustOffset_ReachesNodeWithinDesiredDistance_PopsHead`; `AdjustOffset_ClampedToCatchUpSpeed`; `AdjustOffset_FallbackSpeedWhenMinterpZero`; `AdjustOffset_OvershootProtection` | +| Stall detection | `AdjustOffset_StallCounterFiresEvery5Frames`; `AdjustOffset_NoProgressMarksFail`; `AdjustOffset_3FailsTriggersBlipToHead`; `AdjustOffset_GoodProgressResetsFailCount` | +| Routing helpers | (in `MoveOrTeleportRoutingTests.cs` — separate file) `Routing_StaleSequence_Skips`; `Routing_TeleportSeqNewer_HardSnaps`; `Routing_NoContact_NoOp`; `Routing_Within96_Enqueues`; `Routing_Beyond96_SlideSnaps` | + +All tests run against a stub `Body` and stub motion-max-speed value — +no game/window/loader needed. + +--- + +## Acceptance criteria + +L.3.1 is shippable when: + +1. `dotnet build` green; existing 91 unit tests + new ~13 InterpolationManager + ~5 routing tests all pass. +2. **Visual primary:** parallel retail observer of `+Acdream` standing still, walking, running, strafing, jumping, turning — **all motion glides smoothly**, no 1-Hz popping. +3. **Visual regression check:** `+Acdream`-from-retail-observer behaviors fixed in commit `17a9ff1` (backward jump direction, strafe-run animation, walk-back broadcast direction) all still work. +4. **Visual jump arc:** remote retail toon jumping shows a curved arc as observed from acdream (`Omega` applied), not a flat path. +5. After visual confirmation: cleanup commit lands removing `ACDREAM_INTERP_MANAGER` flag + old hard-snap path + dead `RemoteMotion` fields. + +--- + +## Risks + mitigations + +| Risk | Mitigation | +|---|---| +| New routing interacts badly with `OnLiveVectorUpdated` (jump path runs in parallel) | env-var flag lets us A/B in seconds; visual jump test in acceptance | +| `MotionInterpreter.GetMaxSpeed()` returns wrong value for non-locomotion states | add to unit tests; fall back to `MAX_INTERPOLATED_VELOCITY = 7.5` if returns ≤ epsilon | +| `_cameraPosition` for the 96 m gate is the local player's pos (retail uses `this->[+0x20]` = entity-to-local-player distance) — same thing in our setup | document the assumption inline; revisit in L.3.2 if PositionManager wants a different definition | +| `ACDREAM_INTERP_MANAGER=1` flag forgotten in cleanup commit | acceptance criterion #5 makes the cleanup commit a gate item | +| Queue grows unbounded if NodeCompleted check is buggy | cap-at-20 in Enqueue is hard limit; unit test exercises drop-oldest | + +--- + +## Files + +### New + +- `src/AcDream.Core/Physics/InterpolationManager.cs` — the manager class +- `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` — manager tests +- `tests/AcDream.Core.Tests/Physics/MoveOrTeleportRoutingTests.cs` — routing tests (or merged into above) + +### Modified + +- `src/AcDream.App/Rendering/GameWindow.cs`: + - `RemoteMotion` (~line 224): add `Interp` field + - `OnLivePositionUpdated` (~line 3151): new routing behind env-var + - `OnLiveVectorUpdated` (~line 3064): apply Omega + - `OnLiveRemoteTick` (per-frame): new offset-add behind env-var +- `src/AcDream.Core/Physics/MotionInterpreter.cs`: add `GetMaxSpeed()` +- `docs/plans/2026-04-11-roadmap.md`: insert Phase L.3 entry between L.2 and M +- `docs/ISSUES.md`: close any motion-related open issues this fixes (none currently filed) + +### Cleanup commit (after verification) + +Same files as Modified above, with the env-var dual paths collapsed +to single retail-faithful path, and `RemoteMotion` soft-snap fields +deleted. + +--- + +## Out of scope (deferred to L.3.2 / L.3.3) + +- `PositionManager` (combines anim root-motion + interpolation offset before writing body.Frame) — L.3.2 +- Server-controlled MoveTo creature behavior (retracking, sticky, fail-distance) — L.3.3 +- Replacing `RemoteMoveToDriver.cs` — L.3.3 +- VectorUpdate.Omega for other entity types (projectiles, dropped items) — defer; current spec applies only to player/creature/NPC paths + +--- + +## Implementation order (L.3.1) + +1. Add `InterpolationManager.cs` + unit tests. Build green. +2. Add `MotionInterpreter.GetMaxSpeed()`. Build green. +3. Modify `RemoteMotion` to compose `Interp`. Build green. +4. Add env-var gated routing in `OnLivePositionUpdated`. Build green; flag off → existing behavior. +5. Add env-var gated tick in `OnLiveRemoteTick`. Build green; flag off → existing behavior. +6. Apply `OnLiveVectorUpdated.Omega`. Build green. +7. Visual verification (flag on) — confirm acceptance criteria. +8. Cleanup commit: delete env-var, dead paths, dead RemoteMotion fields. +9. Update roadmap. + +Each step is a single commit. Direct-to-main per CLAUDE.md. + +--- + +## Cross-references + +- Research: `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` +- Findings (prior session): `docs/research/2026-05-01-retail-motion-trace/findings.md` +- Memory: `memory/project_retail_motion_outbound.md`, `memory/project_retail_debugger.md` +- Roadmap: insert as Phase L.3 in `docs/plans/2026-04-11-roadmap.md` +- Related fixed issues: `17a9ff1 fix(motion)` (backward/strafe wire + jump direction) — L.3.1 verifies this still works