acdream/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md
Erik c4446e76fb docs(spec): Phase L.3 scope revision — combine L.3.1+L.3.2
Visual verification of L.3.1-as-originally-scoped (commit ae79e34
through e08accf) revealed that InterpolationManager corrections alone
cannot produce smooth motion — retail also relies on animation root
motion (the L.3.2 PositionManager work, originally deferred). The two
halves are functionally inseparable.

Spec changes:
- L.3.1 sub-lane absorbs L.3.2's PositionManager
- New section: PositionManager architecture (pure-function ComputeOffset
  returning Vector3 delta; combines body-local seqVel * dt rotated to
  world + InterpolationManager.AdjustOffset correction)
- New section: IsGrounded plumbing through EntityPositionUpdate (the
  PositionFlags.IsGrounded=0x04 is already parsed; just expose it)
- New section: retail-faithful jump pipeline (airborne → no-op per
  MoveOrTeleport's has_contact=0 semantics; landing detected via first
  IsGrounded=true UP after airborne)
- Acceptance criteria updated for combined scope
- Implementation order: 6 commits remaining (after the revert at 1641d6e)
- Stall-blip TAIL annotation (Task 0 resolution) folded in

L.3.3 (MoveToManager) stays a separate sub-lane.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:03:09 +02:00

573 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).
**Scope revision 2026-05-02 (after Task 7 visual verification):**
L.3.1 was originally scoped as "InterpolationManager only", with L.3.2
("PositionManager") deferred. Visual verification proved L.3.1 alone
**cannot produce smooth motion** — retail combines animation root motion
+ InterpolationManager corrections, and only the second half ships in
L.3.1-as-originally-scoped. The two halves are functionally inseparable.
**L.3.1 and L.3.2 are now combined into a single sub-lane** ("L.3.1+L.3.2
combined"). L.3.3 remains a separate sub-lane.
| Sub-lane | Title | Ships |
|---|---|---|
| **L.3.1 + L.3.2 combined** | InterpolationManager + PositionManager + retail-faithful jump | (1) `InterpolationManager` (FIFO queue + AdjustOffset), (2) `MotionInterpreter.GetMaxSpeed`, (3) `PositionManager` class combining animation root motion + Interp correction per frame, (4) `IsGrounded` plumbed through `EntityPositionUpdate`, (5) `OnLivePositionUpdated` retail-faithful routing (airborne no-op + landing transition + grounded routing), (6) per-frame `TickAnimations` calls `PositionManager.ComputeOffset` + `UpdatePhysicsInternal`, (7) `VectorUpdate.Omega` application |
| **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.3 gets its own brainstorm + spec when L.3.1+L.3.2 ships.
**This document specifies L.3.1+L.3.2 in detail; L.3.3 is a sketch**
(above) so the phase shape is on record.
### What changed since original spec
- **L.3.2 PositionManager** is now part of L.3.1, not a separate phase.
- **`IsGrounded` plumbing** added — verified to already exist as
`PositionFlags.IsGrounded = 0x04` in `UpdatePosition.cs:48`, parsed but
not exposed through `EntityPositionUpdate`. Now exposed.
- **Jump pipeline** rewritten to match retail's `MoveOrTeleport`
has_contact=false → no-op semantics. Local arc prediction (the source
of the "endless jump" bug) eliminated. Server is authoritative; landing
detected via the first `IsGrounded=true` UP after airborne.
- **Stall-blip → TAIL** (resolved Task 0 via decomp dive of
`acclient!InterpolationManager::UseTime` @ 0x00555F20).
- **Reverted band-aid commits** `5154a3e` + `f199a6a` (commit `1641d6e`)
before re-implementing properly.
---
## 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<InterpolationNode> _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: snap to TAIL (most recent server-sent waypoint) and clear queue.
// RESOLVED 2026-05-02 via decomp dive of acclient!InterpolationManager::
// UseTime @ 0x00555F20: lines 353273-353333 read this->position_queue.tail_,
// copy tail.Position into local var, call CPhysicsObj::SetPositionSimple
// on it, then StopInterpolating. Semantic: "warp to where the server
// LAST SAID you are", not "where you were trying to get to next."
tail = queue.Last
body.Position = tail.TargetPosition // SetPositionSimple equivalent
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
`TickAnimations` per-frame remote tick. 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: ~80 lines deletion, code shrinks.
---
## L.3.2 architecture (PositionManager — combined into L.3.1)
### New file — `src/AcDream.Core/Physics/PositionManager.cs`
Pure-data class, no game/window deps. Pure function: takes (animation
root motion + body orientation + InterpolationManager + maxSpeed) and
returns the per-frame world-space delta to add to `body.Position`.
Composed into `RemoteMotion` alongside the `Interp` field.
**API:**
```csharp
public sealed class PositionManager
{
/// <summary>
/// Per-frame combiner: animation root motion + InterpolationManager
/// correction. Mirrors retail CPhysicsObj::UpdateObjectInternal
/// (acclient @ 0x00513730):
/// rootOffset = CPartArray::Update(dt) // animation
/// PositionManager::adjust_offset(rootOffset) // adds correction
/// frame.origin += rootOffset
/// </summary>
public Vector3 ComputeOffset(
double dt,
Vector3 currentBodyPosition,
Vector3 seqVel, // body-local velocity from active animation cycle
Quaternion ori, // body orientation (for local→world rotation)
InterpolationManager interp,
float maxSpeed)
{
// Step 1: animation root motion (body-local → world).
Vector3 rootMotionLocal = seqVel * (float)dt;
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
// Step 2: interpolation correction (world-space already).
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
// Step 3: combined.
return rootMotionWorld + correction;
}
}
```
### Composition
`RemoteMotion` (in `GameWindow.cs:224`) gains a second field:
```csharp
public AcDream.Core.Physics.PositionManager Position { get; } =
new AcDream.Core.Physics.PositionManager();
```
(Already has `public InterpolationManager Interp` from Task 3.)
### Per-frame `TickAnimations` (env-var-on branch)
```csharp
if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
{
// Always-run-all-steps per retail UpdateObjectInternal (0x00513730).
Vector3 seqVel = ae.Sequencer?.CurrentVelocity ?? Vector3.Zero;
float maxSpeed = rm.Motion.GetMaxSpeed();
Vector3 offset = rm.Position.ComputeOffset(
dt, rm.Body.Position, seqVel, rm.Body.Orientation, rm.Interp, maxSpeed);
rm.Body.Position += offset;
rm.Body.UpdatePhysicsInternal(dt); // gravity for airborne; no-op for grounded
}
else { /* legacy path (kept until cleanup commit) */ }
```
Replaces the Task 5 commit's `if (rm.Interp.IsActive) { ... AdjustOffset ... }`
block. PositionManager calls AdjustOffset internally.
### IsGrounded plumbing — `EntityPositionUpdate`
`PositionFlags.IsGrounded = 0x04` is already parsed in
`UpdatePosition.cs:48`. Add a `bool IsGrounded` field to
`EntityPositionUpdate` record, populate at the parse site, consume in
`OnLivePositionUpdated`. ~3 lines.
### Retail-faithful jump pipeline
Rewrites the `OnLivePositionUpdated` env-var-on branch to match retail
`MoveOrTeleport` (acclient @ 0x00516330) — `has_contact=false → return`:
```csharp
if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
{
rmState.Body.Orientation = rot; // orientation always snaps
if (!update.IsGrounded) // airborne: no-op
return;
if (rmState.Airborne) // landing transition
{
rmState.Airborne = false;
rmState.Body.Velocity = Vector3.Zero;
rmState.Body.State &= ~PhysicsStateFlags.Gravity;
rmState.Body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
rmState.Interp.Clear();
rmState.Body.Position = worldPos; // hard-snap to landing
return;
}
// Grounded routing (CPhysicsObj::MoveOrTeleport):
const float MaxPhysicsDistance = 96f;
var localPlayerPos = _playerController?.Position ?? Vector3.Zero;
float dist = Vector3.Distance(worldPos, localPlayerPos);
if (dist > MaxPhysicsDistance)
{
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
}
else
{
float headingFromQuat = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false);
}
return;
}
```
`OnLiveVectorUpdated` is **unchanged** — already sets velocity, marks
airborne, enables Gravity, applies Omega (Task 6).
### L.3.2 unit tests
New test file `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs`,
~6 tests against the pure `ComputeOffset` function:
| Test | Verifies |
|---|---|
| `ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion` | seqVel=0, queue empty → returns Vector3.Zero |
| `ComputeOffset_AnimationOnly_Forward_BodyAdvances` | seqVel=(0,4,0), identity orientation → returns (0, 4*dt, 0) |
| `ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth` | seqVel=(0,4,0), orientation faces -Y → returns (0,-4*dt,0) |
| `ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue` | seqVel=0, queue active → returns Interp's delta |
| `ComputeOffset_BothActive_Combined` | both nonzero → returns sum |
| `ComputeOffset_LocalToWorldRotation_Yaw90` | seqVel=(0,1,0), yaw=π/2 → returns (sin(π/2), cos(π/2)·1, 0) verifying rotation |
---
## 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+L.3.2 (combined) is shippable when:
1. `dotnet build` green; existing 105 unit tests + 16 InterpolationManager + 5 GetMaxSpeed + ~6 PositionManager tests all pass.
2. **Visual primary:** parallel retail observer of `+Acdream` standing still, walking, running, strafing, turning — **all motion glides smoothly**, no 1-Hz popping. (PositionManager's animation-root-motion is what eliminates the chop.)
3. **Visual jump:** retail toon jumping shows a curved arc that LANDS correctly (no endless rise). Server-authoritative airborne (`IsGrounded=false → no-op`).
4. **Visual regression check:** behaviors fixed in commit `17a9ff1` (backward jump direction, strafe-run animation, walk-back broadcast direction) all still work.
5. After visual confirmation: cleanup commit lands removing `ACDREAM_INTERP_MANAGER` flag + old hard-snap path + dead `RemoteMotion` soft-snap 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
### Already shipped (L.3.1 original scope)
- `src/AcDream.Core/Physics/InterpolationManager.cs` (commits `f43f168` + `927636e`)
- `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` (16 tests)
- `src/AcDream.Core/Physics/MotionInterpreter.cs` `GetMaxSpeed()` (commits `9c5634a` + `5b26d28`)
- `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs` (5 GetMaxSpeed tests added)
- `RemoteMotion.Interp` field (commit `517a3ce`)
- `OnLivePositionUpdated` env-var routing v1 (commit `062e19f`)
- Per-frame `Interp.AdjustOffset` v1 (commit `ae79e34`)
- `OnLiveVectorUpdated.Omega` application (commit `e08accf`)
- Reverted band-aid commits (commit `1641d6e`)
### To ship (L.3.2 added scope)
**New:**
- `src/AcDream.Core/Physics/PositionManager.cs` — pure-data combiner class
- `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs` — ~6 tests
**Modified:**
- `src/AcDream.Core.Net/WorldSession.cs` — add `IsGrounded` field to `EntityPositionUpdate` record + populate at parse site (~3 lines)
- `src/AcDream.App/Rendering/GameWindow.cs`:
- `RemoteMotion` (~line 224): add `Position` field (alongside existing `Interp`)
- `OnLivePositionUpdated` env-var branch: rewritten — airborne no-op + landing transition + grounded routing (replaces the existing v1 routing)
- `TickAnimations` env-var branch: rewritten — `PositionManager.ComputeOffset` + `UpdatePhysicsInternal` (replaces the existing v1 Interp.AdjustOffset call)
### Cleanup commit (after verification)
Single commit: collapses env-var dual paths to retail-faithful path,
deletes `RemoteMotion` soft-snap residual fields. ~80 lines deletion.
---
## Out of scope (deferred to L.3.3)
- 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+L.3.2 combined — remaining work)
Original L.3.1 commits 1-6 already shipped. The two band-aid commits (`5154a3e`, `f199a6a`) reverted in `1641d6e`. Remaining:
1. **`feat(physics): PositionManager class + 6 unit tests`** — subagent-implemented. Pure-data class + tests against stub Interp.
2. **`feat(net): plumb IsGrounded through EntityPositionUpdate`** — parent edit, 3 lines.
3. **`feat(motion): retail-faithful per-frame remote tick (PositionManager + IsGrounded routing)`** — subagent. Adds `RemoteMotion.Position` field + rewrites both env-var-on branches (`OnLivePositionUpdated` and the per-frame tick). Single commit because changes are tightly coupled.
4. **USER GATE — visual verification** with retail observer of `+Acdream` performing the test matrix.
5. **`chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead legacy paths`** — subagent. Cleanup commit.
6. **`docs(roadmap+spec): L.3.1+L.3.2 combined; L.3.3 still separate`** — parent. Roadmap entry update + spec status mark.
Each step is one 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