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