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

26 KiB
Raw Blame History

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).

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):

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):

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.

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.

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:

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:

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:

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)

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:

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