Port retail's InterpolationManager + MoveOrTeleport routing into acdream so remote players, creatures, and NPCs stop popping at every server position update and instead glide smoothly between sparse authoritative updates the way retail does. Three sub-lanes (incremental, each visually verifiable): - L.3.1 — InterpolationManager core + routing + Omega + soft-snap teardown - L.3.2 — PositionManager (root-motion + interp-offset combiner) - L.3.3 — MoveToManager (server-controlled creature MoveTo) This commit specs L.3.1 in detail and sketches L.3.2/L.3.3. Research baseline (cdb live-trace + named-decomp dive 2026-05-02) captured in docs/research/2026-05-02-remote-entity-motion/ resolved-via-cdb.md. All key constants confirmed from binary, not guessed: MAX_PHYSICS_DISTANCE=96, MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5, MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05, queue cap 20, stall window 5/30%/3. Rollout: ACDREAM_INTERP_MANAGER=1 env-var gate during development (dual-path), single cleanup commit after visual verification removes the flag + old hard-snap path + dead RemoteMotion soft-snap fields. Test plan: ~15 unit tests against the InterpolationManager class (pure-data, no game/window deps). Visual verification primary — parallel retail observer of +Acdream walking/running/strafing/ jumping/turning, all should glide. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
18 KiB
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.PositionviaOnLivePositionUpdated(GameWindow.cs:3151), then the local client extrapolates forward viaapply_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.
0xF74Eis 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(inGameWindow.cs:224) carriesSnapResidualDecayRate+ 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.
RemoteMoveToDriveruses 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_velocityVectorstays at zero; onlyset_local_velocity(called fromLeaveGround= outbound jump) andDoVectorUpdate(inbound 0xF74E) ever touch it. - All visible motion comes from
InterpolationManager::adjust_offsetwalking the body toward the head node of a FIFO position-waypoint queue at2 × motion_max_speed × dt. CPhysicsObj::MoveOrTeleportis the routing decision: stale-seq → ignore; teleport-seq newer or no-cell →SetPositionhard-snap; has_contact && distance ≤ 96 →InterpolateTo(queue); has_contact && distance > 96 →SetPositionSimpleslide-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).
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: 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):
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/elseenv-var gate inOnLivePositionUpdatedandOnLiveRemoteTick. Keep only the new path. - Delete
RemoteMotion.SnapResidualDecayRatefield + 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:
dotnet buildgreen; existing 91 unit tests + new ~13 InterpolationManager + ~5 routing tests all pass.- Visual primary: parallel retail observer of
+Acdreamstanding still, walking, running, strafing, jumping, turning — all motion glides smoothly, no 1-Hz popping. - Visual regression check:
+Acdream-from-retail-observer behaviors fixed in commit17a9ff1(backward jump direction, strafe-run animation, walk-back broadcast direction) all still work. - Visual jump arc: remote retail toon jumping shows a curved arc as observed from acdream (
Omegaapplied), not a flat path. - After visual confirmation: cleanup commit lands removing
ACDREAM_INTERP_MANAGERflag + old hard-snap path + deadRemoteMotionfields.
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 classtests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs— manager teststests/AcDream.Core.Tests/Physics/MoveOrTeleportRoutingTests.cs— routing tests (or merged into above)
Modified
src/AcDream.App/Rendering/GameWindow.cs:RemoteMotion(~line 224): addInterpfieldOnLivePositionUpdated(~line 3151): new routing behind env-varOnLiveVectorUpdated(~line 3064): apply OmegaOnLiveRemoteTick(per-frame): new offset-add behind env-var
src/AcDream.Core/Physics/MotionInterpreter.cs: addGetMaxSpeed()docs/plans/2026-04-11-roadmap.md: insert Phase L.3 entry between L.2 and Mdocs/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)
- Add
InterpolationManager.cs+ unit tests. Build green. - Add
MotionInterpreter.GetMaxSpeed(). Build green. - Modify
RemoteMotionto composeInterp. Build green. - Add env-var gated routing in
OnLivePositionUpdated. Build green; flag off → existing behavior. - Add env-var gated tick in
OnLiveRemoteTick. Build green; flag off → existing behavior. - Apply
OnLiveVectorUpdated.Omega. Build green. - Visual verification (flag on) — confirm acceptance criteria.
- Cleanup commit: delete env-var, dead paths, dead RemoteMotion fields.
- 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