Visual verification of L.3.1-as-originally-scoped (commitae79e34throughe08accf) 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 at1641d6e) - 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>
573 lines
26 KiB
Markdown
573 lines
26 KiB
Markdown
# 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
|