# 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