Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive, Count) so PositionManager + GameWindow callers continue to compile; internals are full retail spec. Bug fixes vs prior port (audit 04-interp-manager.md § 7): #1 progress_quantum accumulates dt (sum of frame deltas), not step magnitude. Retail line 353140; the prior port's `+= step` made the secondary stall ratio meaningless. #3 Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets _failCount = StallFailCountThreshold + 1 = 4, so the next AdjustOffset call's post-stall check fires an immediate blip-to- tail snap. Retail line 352944. Prior port silently drifted toward far targets at catch-up speed instead of teleporting. #4 Secondary stall test ports the retail formula verbatim: cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE. Audit notes the units are 1/sec (likely Turbine bug or x87 FPU misread by Binary Ninja) — mirrored byte-for-byte regardless. #5 Tail-prune is a tail-walking loop, not a single-tail compare. Multiple consecutive stale tail entries within DesiredDistance (0.05 m) of the new target collapse together. Retail line 352977. #6 Cap-eviction at the HEAD when count reaches 20 (already correct in the prior port; verified). New API: Enqueue gains an optional `currentBodyPosition` parameter so the far-branch detection can reference the body when the queue is empty. Backward-compatible (default null = pre-far-branch behavior). UseTime collapsed into AdjustOffset's tail (post-stall blip check) since acdream has no per-tick UseTime call separate from adjust_offset; identical semantic outcome. State fields renamed to retail names with sentinel values: _frameCounter, _progressQuantum, _originalDistance (init = 999999f sentinel per retail line 0x00555D30 ctor), _failCount. Tests: - 17/17 InterpolationManagerTests green. - New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset pins the bug #3 fix: enqueueing 150 m away triggers a same-tick blip (delta length ≈ 150 m), and the queue clears. Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/. 00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick, 03-up-routing, 04-interp-manager, 05-position-manager-and-partarray, 06-acdream-audit, 14-local-player-audit are the L.3 spec used by this commit and the M2 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
722 lines
32 KiB
Markdown
722 lines
32 KiB
Markdown
# 14 — Acdream audit: LOCAL player motion (the actor side)
|
||
|
||
Date: 2026-05-04. Scope: every file that touches our own `+Acdream`
|
||
character's motion — the simulator that reads keyboard input, drives
|
||
`PhysicsBody` + `MotionInterpreter`, animates the player entity, and
|
||
broadcasts MoveToState / AutonomousPosition / JumpAction over the wire.
|
||
Counterpart to `06-acdream-audit.md` (remote / observed motion).
|
||
|
||
Inputs read in detail:
|
||
|
||
- `src/AcDream.App/Input/PlayerMovementController.cs` (885 LOC)
|
||
- `src/AcDream.App/Input/PlayerModeAutoEntry.cs`
|
||
- `src/AcDream.Core/Physics/PlayerWeenie.cs` (81 LOC)
|
||
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — local-player call
|
||
sites (`apply_current_movement`, `DoMotion`, `DoInterpretedMotion`,
|
||
`StopInterpretedMotion`, `LeaveGround`, `HitGround`, `jump`,
|
||
`get_jump_v_z`, `get_state_velocity`).
|
||
- `src/AcDream.Core.Net/WorldSession.cs` — outbound sequence counters,
|
||
`NextGameActionSequence`, `SendGameAction` plumbing.
|
||
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (165 LOC) — 0xF61C
|
||
packet builder.
|
||
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` (89 LOC) —
|
||
0xF753 packet builder.
|
||
- `src/AcDream.Core.Net/Messages/JumpAction.cs` — 0x... jump packet.
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — local-player wiring at
|
||
L5182–5337 (per-frame Update + outbound), L6956–7103
|
||
(`UpdatePlayerAnimation`), L2638–2656 (server RunRate echo path),
|
||
L7997–8062 (player-controller construction at world-entry).
|
||
|
||
Verdict labels: **PORT** (faithful retail port), **HACK**
|
||
(acdream-original logic, not in retail), **BROKEN** (regressed/wrong
|
||
vs the named-retail spec), **DIAG** (instrumentation only).
|
||
|
||
Retail counterparts cited from
|
||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||
|
||
---
|
||
|
||
## 1. `PlayerMovementController.cs` — top-level architecture
|
||
|
||
### 1.1 Structure (L89–230)
|
||
|
||
| Field | Verdict | Notes |
|
||
|---|---|---|
|
||
| `PhysicsBody _body` | PORT | Constructed with `Gravity \| ReportCollisions` — **NOTE** retail's local player also has Contact+OnWalkable on the transient side after first SetPosition. |
|
||
| `MotionInterpreter _motion` | PORT | Wires Body+Weenie. |
|
||
| `PlayerWeenie _weenie` | PORT-with-issue | RunSkill/JumpSkill from env vars (default 200 / 300). See §4. |
|
||
| `PhysicsEngine _physics` | PORT | Used for `ResolveWithTransition` collision sweep. |
|
||
| `Yaw`, `MouseTurnSensitivity` | HACK | Per-controller yaw float; retail stores body Orientation directly. Acdream maintains Yaw separately and rebuilds Orientation each frame (L322). |
|
||
| `StepUpHeight` / `StepDownHeight` | PORT | 0.4 m default; updated from `Setup.StepUpHeight` at world-entry (L8021–8035). Retail-faithful values. |
|
||
| `_jumpCharging` / `_jumpExtent` / `JumpChargeRate` | PORT-with-twist | 2.0/s charge rate (retail divisor unrecovered from PDB; comment at L150–155 acknowledges this is a feel-tune). The vz formula is byte-exact to retail. |
|
||
| `_wasAirborneLastFrame` | PORT | Used for justLanded edge detection. |
|
||
| `_prev*` previous-frame command/speed snapshots | PORT-ish | Used to detect `MotionStateChanged`. Retail's `CommandInterpreter::SendMovementEvent` similarly diff-gates outbound MTS. **NOTE** retail compares the entire `RawMotionState` not just selected fields — see #2.2 below. |
|
||
| `_heartbeatAccum`, `HeartbeatInterval=1.0s` | PORT | Matches retail trace 2026-05-01 + holtburger AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL (1 Hz). **Better than the prior 200 ms guess** in CLAUDE.md. |
|
||
| `_physicsAccum` (L204) | PORT | L.5 retail 30 Hz physics-tick gate. Mirrors `update_object` MinQuantum behavior. Critical for slope/wall-bounce parity. |
|
||
|
||
### 1.2 `ApplyServerRunRate(float)` (L270–274)
|
||
|
||
Called from `OnLiveMotionUpdated` at L2643 when an inbound UM addressed
|
||
to the local player carries `MotionState.ForwardSpeed`.
|
||
|
||
```csharp
|
||
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
|
||
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||
```
|
||
|
||
**Verdict: HACK.** Retail's local-side flow:
|
||
|
||
- Server sends UM with the authoritative ForwardSpeed only when
|
||
ForwardCommand changes (apply_run_to_command echoes the speed back
|
||
via the outbound MTS).
|
||
- Retail does NOT directly stuff `ForwardSpeed` into `InterpretedState`
|
||
on inbound UM — the inbound is purely a bulk-copy into a
|
||
RawMotionState that drives the *animation sequencer*, not the
|
||
velocity feed. The local player's velocity comes from
|
||
`apply_current_movement` reading the user-input-driven InterpretedState.
|
||
|
||
This is acdream's solution to "ACE doesn't tell the client its real
|
||
RunRate at character spawn" — we adopt the first non-zero ForwardSpeed
|
||
ACE relays. Per CLAUDE.md the long-term fix is parsing
|
||
PlayerDescription's RunSkill (issue #7); the env var workaround
|
||
becomes legacy then.
|
||
|
||
**Side effect:** calling `apply_current_movement` here re-evaluates
|
||
`get_state_velocity` and writes `body.Velocity`, which can momentarily
|
||
override an in-flight jump's airborne velocity. The `allowJump:false`
|
||
flag mitigates it but the call is still racing against integration.
|
||
|
||
### 1.3 `SetPosition(pos, cellId)` (L276–287)
|
||
|
||
```csharp
|
||
_body.Position = pos;
|
||
CellId = cellId;
|
||
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||
_body.Velocity = Vector3.Zero;
|
||
_body.LastUpdateTime = 0.0;
|
||
```
|
||
|
||
**Verdict: PORT.** Mirrors retail's `CPhysicsObj::SetPositionInternal`
|
||
post-snap effects (acclient_2013_pseudo_c.txt, FUN_00516330). Used on
|
||
world-entry and PortalSpace exit.
|
||
|
||
### 1.4 `Update(float dt, MovementInput input)` — main 600-line loop
|
||
|
||
Main structure: portal-space gate → turning → motion state machine →
|
||
jump → integrate → collision resolve → bounce → ground/landing →
|
||
outbound commands → motion-change detection → heartbeat → animation.
|
||
|
||
#### 1.4.1 Portal-space gate (L294–307)
|
||
|
||
**Verdict: PORT.** When `State==PortalSpace`, returns zero-movement
|
||
result. Mirrors retail's `CPhysicsObj::set_in_portal_space` early
|
||
return.
|
||
|
||
#### 1.4.2 Turn input (L309–322)
|
||
|
||
```csharp
|
||
if (input.TurnRight) Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
|
||
if (input.TurnLeft) Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
|
||
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
|
||
_body.Orientation = Quaternion.CreateFromAxisAngle(UnitZ, Yaw - PI/2);
|
||
```
|
||
|
||
**Verdict: HACK.** Retail's local turn rate is `(π/2) × TurnSpeed`
|
||
(matches the omega formula in `06-acdream-audit.md` §2). acdream uses
|
||
`WalkAnimSpeed × 0.5 = 1.56 rad/s ≈ 90°/s` — coincidentally close to
|
||
retail's `π/2 ≈ 90°/s` for TurnSpeed=1.0, but the constant is wrong-
|
||
rooted (re-derivation through animation-speed table is a coincidence).
|
||
|
||
The mouse-turn path is entirely acdream-original; retail handles mouse
|
||
look via `CommandInterpreter::IssueAxisCommand` driving turn commands,
|
||
NOT direct yaw mutation.
|
||
|
||
#### 1.4.3 Motion state machine (L324–411) — body velocity per input
|
||
|
||
Determines `forwardCmd` + `forwardCmdSpeed` from input, then:
|
||
|
||
```csharp
|
||
_motion.DoMotion(forwardCmd, forwardCmdSpeed); // → InterpretedState
|
||
if (input.StrafeRight) _motion.DoInterpretedMotion(SideStepRight, 1f, …);
|
||
if (input.StrafeLeft) _motion.DoInterpretedMotion(SideStepLeft, 1f, …);
|
||
…
|
||
if (_body.OnWalkable)
|
||
{
|
||
var stateVel = _motion.get_state_velocity();
|
||
float localY = …, localX = …; // hand-rolled body-local velocity
|
||
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
||
}
|
||
```
|
||
|
||
**Verdict: HACK.** Retail's correct flow is:
|
||
|
||
1. Input → `CMotionInterp::DoMotion` (FUN_00529930) →
|
||
`apply_run_to_command` (FUN_00527be0) →
|
||
`DoInterpretedMotion` (FUN_00528f70) → `apply_current_movement`
|
||
(FUN_00529210) → `set_local_velocity(get_state_velocity())`.
|
||
|
||
2. `get_state_velocity` (FUN_00528960) reads InterpretedState
|
||
directly, returning body-local
|
||
`(SidestepAnimSpeed × SideStepSpeed, RunAnimSpeed × ForwardSpeed, 0)`.
|
||
|
||
acdream calls `DoMotion` (which DOES call `apply_current_movement`
|
||
internally → writes body.Velocity correctly) — and then OVERWRITES
|
||
that body velocity at L410 with a hand-rolled local-frame vector.
|
||
|
||
The hand-rolled block is acdream's workaround for **WalkBackward and
|
||
SideStepLeft** producing zero velocity in `get_state_velocity` because
|
||
the retail port omitted `adjust_motion` (FUN_00528010), which retail
|
||
runs *before* InterpretedState writes:
|
||
|
||
```
|
||
WalkBackwards → WalkForward + speed × -0.65
|
||
SideStepLeft → SideStepRight + speed × -1
|
||
```
|
||
|
||
**Critical impact for the L.3 audit:** the local player's body.Velocity
|
||
**IS** non-zero on every grounded frame (correctly so for the local
|
||
player — opposite of remotes), but it's set by *acdream's hand-rolled
|
||
block at L410 rather than by `MotionInterpreter`*. Per the L.3 spec and
|
||
named-retail source, the local-side velocity should come from
|
||
`get_state_velocity` only, after `adjust_motion` translates the
|
||
backward/strafe-left commands.
|
||
|
||
The **right fix** is to port `adjust_motion` (FUN_00528010) into
|
||
`MotionInterpreter` and remove L378–411 entirely, letting `DoMotion`
|
||
do its thing.
|
||
|
||
#### 1.4.4 Jump path (L413–505)
|
||
|
||
**Verdict: PORT (algorithm) + HACK (workaround for missing adjust_motion).**
|
||
|
||
The jump-charge logic at L420–428 (accumulate while held + on ground,
|
||
fire on release) matches retail `Event_Jump`'s charge-bar pattern.
|
||
|
||
The fire path:
|
||
1. `_motion.jump(extent)` — validates via retail FUN_00529390. PORT.
|
||
2. `_motion.get_jump_v_z()` — reads vz before LeaveGround zeroes
|
||
extent (matches retail FUN_00529710 invocation order). PORT.
|
||
3. `_motion.LeaveGround()` — clears Contact+OnWalkable, sets Gravity,
|
||
calls get_state_velocity into body.Velocity. PORT.
|
||
4. **Then acdream re-writes `outJumpVelocity`** (L466–501) with a
|
||
manually-computed body-local jump velocity that includes
|
||
backward/strafe-left, working around the same adjust_motion gap as
|
||
§1.4.3. Hand-rolled mirror of L378–411 logic.
|
||
|
||
The comment at L443–460 acknowledges this explicitly: "Until
|
||
adjust_motion is ported, we mirror the grounded-velocity computation."
|
||
|
||
#### 1.4.5 Physics integration + 30 Hz gate (L507–535)
|
||
|
||
```csharp
|
||
_physicsAccum += dt;
|
||
if (_physicsAccum > HugeQuantum) _physicsAccum = 0f; // stale
|
||
else if (_physicsAccum >= MinQuantum)
|
||
{
|
||
float tickDt = MathF.Min(_physicsAccum, MaxQuantum);
|
||
_body.calc_acceleration();
|
||
_body.UpdatePhysicsInternal(tickDt);
|
||
_physicsAccum -= tickDt;
|
||
}
|
||
```
|
||
|
||
**Verdict: PORT.** This is the L.5 retail-physics-tick gate from
|
||
2026-04-30, reverse-engineered via cdb attach to retail. Effectively
|
||
clamps physics integration to 30 Hz even at 60+ Hz render.
|
||
**Mirrors retail's `update_object` MinQuantum behavior precisely.**
|
||
|
||
#### 1.4.6 Collision resolve (L538–574)
|
||
|
||
```csharp
|
||
var resolveResult = _physics.ResolveWithTransition(
|
||
preIntegratePos, postIntegratePos, CellId,
|
||
sphereRadius: 0.48f, sphereHeight: 1.2f,
|
||
stepUpHeight, stepDownHeight,
|
||
isOnGround: _body.OnWalkable,
|
||
body: _body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||
```
|
||
|
||
**Verdict: PORT.** Sphere dimensions match retail human Setup;
|
||
`IsPlayer | EdgeSlide` matches retail PhysicsGlobals.DefaultState for
|
||
players.
|
||
|
||
#### 1.4.7 Wall-bounce / velocity reflection (L578–686)
|
||
|
||
Implements retail's `handle_all_collisions` velocity-reflection:
|
||
`v_new = v - (1 + elasticity) × dot(v, n) × n`. Sources cited:
|
||
acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:2656-2721.
|
||
|
||
**Verdict: PORT-with-conservative-rule.** The `applyBounce` gating at
|
||
L638–640 is more restrictive than retail's strict
|
||
`!(prev && now && !sledding)` — acdream additionally suppresses
|
||
bounce on the airborne→grounded landing transition because the
|
||
post-reflection upward Z would defeat acdream's per-frame
|
||
`Velocity.Z<=0` landing-snap gate. Retail tolerates this because
|
||
elasticity=0.05 is visually imperceptible there; acdream's per-frame
|
||
architecture amplifies it. Documented in the comment at L630–637.
|
||
|
||
#### 1.4.8 Ground/landing detection (L688–720)
|
||
|
||
```csharp
|
||
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
|
||
{
|
||
bool wasAirborne = !_body.OnWalkable;
|
||
_body.TransientState |= Contact | OnWalkable;
|
||
if (_body.Velocity.Z < 0f) _body.Velocity.Z = 0f;
|
||
if (wasAirborne) { _motion.HitGround(); justLanded = true; }
|
||
}
|
||
else { _body.TransientState &= ~(Contact | OnWalkable); }
|
||
```
|
||
|
||
**Verdict: PORT.** Mirrors retail `MoveOrTeleport` post-resolution
|
||
landing state machine + `CMotionInterp::HitGround` call on
|
||
airborne→grounded transition.
|
||
|
||
#### 1.4.9 Outbound wire commands (L725–795)
|
||
|
||
Builds `outForwardCmd / outForwardSpeed / outSidestepCmd /
|
||
outTurnCmd / localAnimCmd`:
|
||
|
||
```csharp
|
||
if (input.Forward)
|
||
{
|
||
outForwardCmd = MotionCommand.WalkForward;
|
||
if (input.Run && _weenie.InqRunRate(out var rr))
|
||
{
|
||
outForwardSpeed = rr;
|
||
localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
|
||
}
|
||
else { outForwardSpeed = 1.0f; localAnimCmd = MotionCommand.WalkForward; }
|
||
}
|
||
else if (input.Backward) { outForwardCmd = WalkBackward; outForwardSpeed = 1.0f; … }
|
||
```
|
||
|
||
**Verdict: PORT-with-ACE-quirk.** The `WalkForward + HoldKey.Run`
|
||
encoding (rather than direct `RunForward`) is documented at L26–34 of
|
||
the file: it's a workaround for ACE's `MovementData` only computing
|
||
`interpState.ForwardSpeed` for raw WalkForward/WalkBackward. ACE then
|
||
auto-upgrades to RunForward on broadcast.
|
||
|
||
**Retail wire format: this is correct.** Retail's
|
||
`CommandInterpreter::SendMovementEvent` builds the MoveToState the
|
||
same way — sends WalkForward with HoldKey.Run for run intent,
|
||
RunForward only for explicit run-toggle. Confirmed via 2026-05-01 cdb
|
||
trace.
|
||
|
||
The `localAnimCmd` divergence is acdream-original but **necessary**
|
||
because the local sequencer wants RunForward immediately (for visual
|
||
parity), not the wire's WalkForward.
|
||
|
||
#### 1.4.10 Motion-change detection (L797–831)
|
||
|
||
```csharp
|
||
bool changed = outForwardCmd != _prevForwardCmd
|
||
|| outSidestepCmd != _prevSidestepCmd
|
||
|| outTurnCmd != _prevTurnCmd
|
||
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|
||
|| runHold != _prevRunHold
|
||
|| localAnimCmd != _prevLocalAnimCmd;
|
||
```
|
||
|
||
**Verdict: PORT-ish.** Retail's `CommandInterpreter::SendMovementEvent`
|
||
diffs against the *previously-sent* RawMotionState, sending an MTS
|
||
only when the new state is non-equal. acdream's diff is field-selective
|
||
but covers the load-bearing fields. The `localAnimCmd` field is not in
|
||
retail's RawMotionState — including it forces a fresh outbound on
|
||
Walk↔Run toggle (W held + Shift toggle), which retail also does because
|
||
the toggle changes the wire ForwardSpeed. So the net effect matches.
|
||
|
||
**Subtle issue:** if the user only changes SidestepSpeed or TurnSpeed
|
||
(without changing the corresponding command), no outbound fires. Retail
|
||
likely doesn't either; not a regression.
|
||
|
||
#### 1.4.11 Heartbeat (L833–845)
|
||
|
||
```csharp
|
||
_heartbeatAccum += dt;
|
||
HeartbeatDue = _heartbeatAccum >= 1.0f;
|
||
if (HeartbeatDue) _heartbeatAccum = 0f;
|
||
```
|
||
|
||
**Verdict: PORT.** 1 Hz cadence matches retail trace 2026-05-01 +
|
||
holtburger. Caller (`GameWindow`) reads `HeartbeatDue` and fires
|
||
AutonomousPosition.
|
||
|
||
**OPEN QUESTION** flagged in CLAUDE.md memory: retail's
|
||
`SendPositionEvent` gates the heartbeat on `transient_state` (must
|
||
have Contact+OnWalkable+valid Position) AND on motion. acdream's
|
||
1 Hz at-rest heartbeat is unconditional once in-world. Retail's cdb
|
||
trace showed AutonomousPosition gated on motion — i.e. **acdream
|
||
sends AP heartbeats while standing still, retail does not**. Probable
|
||
mismatch worth investigating. Filed in `project_retail_motion_outbound.md`.
|
||
|
||
---
|
||
|
||
## 2. `MoveToState.cs` (Messages/MoveToState.cs, 165 LOC)
|
||
|
||
**Verdict: PORT.** Wire layout matches holtburger
|
||
`RawMotionState::pack` + `MoveToStateActionData::pack`:
|
||
|
||
| Field | Verdict | Notes |
|
||
|---|---|---|
|
||
| GameAction envelope (0xF7B1, seq, 0xF61C) | PORT | |
|
||
| Flags dword (bits 0–10 fields, bits 11–31 cmd-list-len=0) | PORT | We never send commands. |
|
||
| Conditional fields in flag-bit order | PORT | CurrentHoldKey, ForwardCommand, ForwardHoldKey, ForwardSpeed, SidestepCommand/HoldKey/Speed, TurnCommand/HoldKey/Speed. CurrentStyle (0x2) intentionally not sent — we don't track stance changes here (handled via separate ChangeCombatMode). |
|
||
| WorldPosition: cellId u32, x/y/z f32, rotW/rotX/rotY/rotZ f32 | PORT | Quaternion wire order W,X,Y,Z confirmed. |
|
||
| Sequences: u16 instance/serverControl/teleport/forcePosition | PORT | |
|
||
| Contact byte u8 + 4-byte align | PORT | |
|
||
|
||
**Open question 1:** retail builds the MoveToState with the FULL
|
||
`RawMotionState` from the local CMotionInterp, including
|
||
`AftCommand`/`AftSpeed`/`AftHoldKey` axes for sailing/swimming. We
|
||
omit those flags — they're never set by `PlayerMovementController`.
|
||
For walking that's fine; if we ever ship swimming/sailing this needs
|
||
extending.
|
||
|
||
**Open question 2:** `CurrentStyle` (0x2) — when the player changes
|
||
combat stance (e.g. Sword), does retail emit it as a separate
|
||
ChangeCombatMode + MoveToState pair, or does the MoveToState itself
|
||
carry the new style? Holtburger sends it via RawMotionState. acdream's
|
||
ChangeCombatMode path (`SendChangeCombatMode`) sends a separate
|
||
GameAction. **Cross-check needed**, but not load-bearing for the L.3
|
||
audit.
|
||
|
||
---
|
||
|
||
## 3. `AutonomousPosition.cs` (Messages/AutonomousPosition.cs, 89 LOC)
|
||
|
||
**Verdict: PORT.** Simpler than MoveToState: GameAction envelope +
|
||
WorldPosition + 4 sequences + lastContact byte + align.
|
||
|
||
Wire layout matches holtburger
|
||
`AutonomousPositionActionData::pack`. Used as the 1 Hz heartbeat.
|
||
|
||
---
|
||
|
||
## 4. `PlayerWeenie.cs` (81 LOC)
|
||
|
||
**Verdict: PORT (algorithm) + HACK (data source).**
|
||
|
||
`GetRunRate(burden, runSkill)` and `GetJumpHeight(burden, jumpSkill,
|
||
extent)` formulas are byte-for-byte from decompiled `acclient.exe` +
|
||
ACE `MovementSystem.GetRunRate / GetJumpHeight`:
|
||
|
||
```
|
||
RunRate = (burdenMod × (runSkill / (runSkill+200) × 11) + 4) / 4 (cap 4.5 at skill 800)
|
||
JumpHeight = burdenMod × (jumpSkill/(jumpSkill+1300) × 22.2 + 0.05) × extent
|
||
(clamp to 0.35 m min)
|
||
vz = sqrt(jumpHeight × 19.6)
|
||
```
|
||
|
||
**HACK side:** the constructor reads RunSkill/JumpSkill from env vars
|
||
(default 200 / 300). Per CLAUDE.md these are **NOT synced to the
|
||
server** — ACE has its own canonical RunSkill which it broadcasts in
|
||
`UpdateMotion.ForwardSpeed`. We currently echo via
|
||
`PlayerMovementController.ApplyServerRunRate` (§1.2), which DIRECTLY
|
||
overwrites `InterpretedState.ForwardSpeed` rather than updating the
|
||
weenie's RunSkill. So our local-prediction velocity may diverge from
|
||
the server's authoritative value mid-tick if the server's RunRate
|
||
differs from `(loadMod × runSkill/(runSkill+200) × 11 + 4)/4`.
|
||
|
||
**Long-term fix:** parse `PlayerDescription` (0xF7B0/0x0013), extract
|
||
RunSkill, call `_weenie.SetSkills(serverRun, serverJump)`. Filed
|
||
issue #7 in CLAUDE.md.
|
||
|
||
---
|
||
|
||
## 5. `MotionInterpreter` local-player call sites
|
||
|
||
Local-player call sites of `apply_current_movement`:
|
||
|
||
- `PlayerMovementController.cs:273` (`ApplyServerRunRate`) — see §1.2.
|
||
HACK.
|
||
- `PlayerMovementController.cs` indirectly via `DoMotion` →
|
||
`DoInterpretedMotion` → `apply_current_movement`. PORT.
|
||
- `PlayerMovementController.cs` jump path via `LeaveGround`. PORT.
|
||
- `MotionInterpreter` internally inside `DoInterpretedMotion`,
|
||
`StopInterpretedMotion`, `HitGround`. PORT.
|
||
|
||
For the local player **`body.Velocity` is correctly non-zero per tick**
|
||
— driven by user input. This is the OPPOSITE of the L.3 invariant for
|
||
remotes and matches the named-retail spec.
|
||
|
||
---
|
||
|
||
## 6. `GameWindow.cs` local-player wiring
|
||
|
||
### 6.1 World-entry construction (L7997–8062)
|
||
|
||
```csharp
|
||
_playerController = new PlayerMovementController(_physicsEngine);
|
||
if (_lastSeenRunSkill > 0) _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
|
||
_playerController.StepUpHeight = playerSetup?.StepUpHeight ?? 0.4f;
|
||
_playerController.StepDownHeight = playerSetup?.StepDownHeight ?? 0.4f;
|
||
_playerController.SetPosition(initResult.Position, initResult.CellId);
|
||
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
|
||
_playerController.Yaw = rawYaw + MathF.PI/2;
|
||
```
|
||
|
||
**Verdict: PORT.** StepUp/Down come from Setup; cycle-velocity
|
||
accessor wires the AnimationSequencer into `MotionInterpreter`.
|
||
|
||
### 6.2 Per-frame Update loop (L5182–5337)
|
||
|
||
Reads input → `_playerController.Update(dt, input)` → updates entity
|
||
position+rotation → updates ChaseCamera → builds outbound MoveToState
|
||
(if MotionStateChanged) + AutonomousPosition (if HeartbeatDue) +
|
||
JumpAction (if jump fired) → calls `UpdatePlayerAnimation`.
|
||
|
||
**Verdict: PORT.** Wire-output gating is correct: change-driven MTS,
|
||
heartbeat AP, event-driven Jump. Cell ID composition and world→wire
|
||
conversion match retail.
|
||
|
||
The HoldKey block at L5266–5288 sends per-axis hold keys for every
|
||
active axis (forward/sidestep/turn) using the same value. Per
|
||
holtburger comment at L876: this is correct — retail uses the same
|
||
HoldKey value across all active axes.
|
||
|
||
### 6.3 `UpdatePlayerAnimation` (L6956–7103)
|
||
|
||
Computes the visible animation cycle from MovementResult:
|
||
|
||
```
|
||
Airborne → MotionCommand.Falling
|
||
Forward+Run → RunForward (LocalAnimationCommand)
|
||
Forward → WalkForward
|
||
Backward → WalkBackward
|
||
Sidestep → SideStepLeft/Right
|
||
Turn → TurnLeft/Right
|
||
else → Ready
|
||
```
|
||
|
||
Drives `ae.Sequencer.SetCycle(NonCombatStance, animCommand,
|
||
animSpeed × animScale, skipTransitionLink: airborne)`.
|
||
|
||
**Verdict: PORT.** Mirrors retail `MotionTable::SetState` /
|
||
`Sequence::SetCycle`. The `LocalAnimationSpeed` decoupling
|
||
(forward+run = runRate; backward+run / strafe+run = runRate too even
|
||
though wire ForwardSpeed=1.0) is acdream-original but correct: it
|
||
ensures the visible cycle pace matches the actual movement velocity
|
||
even when the wire format keeps backward/strafe at 1.0 for ACE
|
||
compat.
|
||
|
||
**Skip-link on Falling** (L7091): retail-faithful — without it, the
|
||
local player visibly stood still for ~100 ms at the start of every
|
||
jump while the RunForward→Falling transition link drained.
|
||
|
||
---
|
||
|
||
## 7. Shared-with-remote-player code paths
|
||
|
||
| File / function | Used by local? | Used by remote? | Concern? |
|
||
|---|---|---|---|
|
||
| `PhysicsBody` (full) | YES | YES | NO — same retail port; both sides write Velocity / Orientation correctly per their respective sources. |
|
||
| `MotionInterpreter` (full) | YES | YES | YES — `apply_current_movement` is the converging point. Local writes body.Velocity from user input (correct). Remote writes body.Velocity from `InterpretedState.ForwardCommand+ForwardSpeed` (incorrect per L.3 spec — see audit 06 §8). After L.3 lands, only the local player path will call `apply_current_movement` per tick; remotes will be anim-root-motion driven. |
|
||
| `AnimationSequencer` (full) | YES | YES | NO — both sides drive `SetCycle` from the appropriate input source. |
|
||
| `PhysicsEngine.ResolveWithTransition` | YES | YES | NO — collision sweep with sphere dims + step heights; identical for both. **Both paths must keep this** post-L.3 (audit 06 §6 step 4b). |
|
||
| `AnimatedEntity.Sequencer` | YES | YES | NO — sequencer is per-entity. Local UpdatePlayerAnimation writes its own; remote OnLiveMotionUpdated writes the remote's. Independent. |
|
||
| `PlayerWeenie` | YES | NO | NO — local-only. Remotes don't have a weenie; their MotionInterpreter fall back to default 1.0 RunRate via `if (weenie==null) x87_r7 = 1f` in retail's apply_run_to_command. |
|
||
| `PhysicsEngine.ShadowObjects` | NO | YES | NO — shadow tracking is for cell-list updates, not local-player. |
|
||
| `RemoteMotion` struct + dead-reckon table | NO | YES | NO — remote-only. |
|
||
|
||
**Key convergence point:** `MotionInterpreter.apply_current_movement`.
|
||
Local needs it (per tick, driven by user input). Remote should NOT
|
||
call it per tick (per L.3 spec). The two paths share the function but
|
||
diverge on call frequency.
|
||
|
||
After L.3 lands:
|
||
- Local: `_playerController.Update` → `_motion.DoMotion` →
|
||
`apply_current_movement` (writes body.Velocity from user input).
|
||
**Once per frame.** Per CLAUDE.md ACE wire-format quirks, no
|
||
changes needed.
|
||
- Remote: per-tick reads `sequencer.CurrentVelocity` directly via
|
||
`PositionManager.ComputeOffset`. **Never calls
|
||
`apply_current_movement`.** body.Velocity stays 0 for grounded
|
||
remotes. `apply_current_movement` still fires on `OnLiveMotionUpdated`
|
||
for axis-state setup, but not per tick.
|
||
|
||
---
|
||
|
||
## 8. Specific question answers
|
||
|
||
**(a) Does `PlayerMovementController` mirror retail's pipeline
|
||
(`apply_current_movement → integrate → collision sweep`)?**
|
||
|
||
YES, partially. The pipeline order is correct: input →
|
||
DoMotion (which calls apply_current_movement internally) → integrate
|
||
(`UpdatePhysicsInternal`) → ResolveWithTransition. **But it then
|
||
*overwrites* the velocity with hand-rolled body-local code at L378–411
|
||
to work around the missing `adjust_motion` port** for backward/
|
||
strafe-left. The same workaround appears in the jump path at L466–501.
|
||
The pipeline shape matches retail; the velocity-source-of-truth diverges.
|
||
|
||
**(b) Does it use `m_velocityVector` correctly? (Local DOES integrate
|
||
velocity, unlike remotes.)**
|
||
|
||
YES — local body.Velocity is intentionally non-zero per tick, driven
|
||
by user input via `set_local_velocity`. Then
|
||
`UpdatePhysicsInternal` integrates `Position += Velocity*dt + 0.5*A*dt²`.
|
||
This matches retail's local-player model.
|
||
|
||
**(c) Does the outbound MoveToState packet match retail's wire format?**
|
||
|
||
YES. Wire layout in `Messages/MoveToState.cs` is byte-faithful to
|
||
holtburger's `RawMotionState::pack` + `MoveToStateActionData::pack`,
|
||
which is itself ground-truth from a working Rust client. Per-axis
|
||
HoldKey (forward/sidestep/turn) is sent. CurrentStyle (0x2) is
|
||
omitted intentionally; OK for walking. AftCommand/AftSpeed/AftHoldKey
|
||
not sent — fine until swimming/sailing ships.
|
||
|
||
**(d) Is the local sequencer driven by `UpdatePlayerAnimation`
|
||
matching retail's `MotionTable::SetState`?**
|
||
|
||
YES. The cycle picker (Airborne→Falling > LocalAnim > wire forward >
|
||
sidestep > turn > Ready), `NonCombatStance` overlay, sequencer
|
||
`SetCycle` invocation all mirror retail. Speed decoupling
|
||
(LocalAnimationSpeed vs wire ForwardSpeed) is acdream-original but
|
||
correct for ACE-quirk-driven backward/strafe pacing.
|
||
|
||
**(e) Are there any acdream-specific hacks/workarounds in the local
|
||
player path?**
|
||
|
||
YES. Five distinct hacks, all acknowledged in code comments:
|
||
|
||
1. **Hand-rolled velocity overrides at L378–411 + L466–501** —
|
||
workaround for missing `adjust_motion` port. Both grounded and jump
|
||
paths are affected. **Top priority to fix** alongside the L.3
|
||
refactor; same root cause as remote-side `apply_current_movement`
|
||
issues.
|
||
2. **`ApplyServerRunRate`** (§1.2) — directly stuffs server's
|
||
ForwardSpeed into InterpretedState. Should be replaced with
|
||
PlayerDescription parse → `_weenie.SetSkills(...)`.
|
||
3. **Yaw float + mouse-turn sensitivity** — retail uses turn commands
|
||
from `IssueAxisCommand` for ALL turn input (keyboard + mouse). We
|
||
maintain a separate Yaw and rebuild Orientation each frame.
|
||
4. **`JumpChargeRate = 2.0/s`** — retail divisor unrecovered from
|
||
PDB; tuned for feel. Cited in code comment at L150–155.
|
||
5. **Wall-bounce landing suppression** (L638–640) — more
|
||
conservative than retail's strict rule, justified by acdream's
|
||
per-frame architecture amplifying micro-bounce on landing.
|
||
|
||
**(f) How is the local player's runRate sourced?**
|
||
|
||
Three layers, in priority order:
|
||
|
||
1. `_lastSeenRunSkill` from PlayerDescription parse (issue #7, NOT
|
||
YET WIRED — code at L1565 is dormant).
|
||
2. `ACDREAM_RUN_SKILL` env var (default 200) → constructor → weenie
|
||
formula.
|
||
3. `ApplyServerRunRate(forwardSpeed)` echo from inbound UM —
|
||
overwrites `InterpretedState.ForwardSpeed` directly, bypassing the
|
||
weenie formula.
|
||
|
||
**Retail-faithful?** PARTIALLY. The formula
|
||
`(loadMod × runSkill/(runSkill+200) × 11 + 4)/4` is byte-exact
|
||
retail. But the data source is wrong: retail's local
|
||
`CWeenieObject::InqRunRate` reads the player's actual server-synced
|
||
RunSkill, not an env var. Until issue #7 ships, low-skill characters
|
||
(< 200) and high-skill characters (> 800) will mispredict.
|
||
|
||
**(g) Does the local player have any `IsPlayerGuid`-style gates that
|
||
would also need cleanup?**
|
||
|
||
The local-player path AT THE PER-FRAME UPDATE level is gated only by
|
||
`_playerMode && _playerController != null`, which is appropriate
|
||
(it's the actor side). The IsPlayerGuid gates in audit 06
|
||
(`OnLivePositionUpdated`, `OnLiveMotionUpdated`, etc.) all SKIP
|
||
the local player guid (e.g. `if (update.Guid == _playerServerGuid)
|
||
return;` patterns) — that's correct because the local player's state
|
||
is owned by `_playerController`, not by the dead-reckoning struct.
|
||
|
||
The only place where IsPlayerGuid logic touches local state is
|
||
`ApplyServerRunRate` (§1.2): an inbound UM addressed to the local
|
||
player echoes ForwardSpeed. That's fine; not a gate to remove.
|
||
|
||
**No IsPlayerGuid gates to clean up on the actor side.** All cleanup
|
||
in audit 06 is on the observer/remote side.
|
||
|
||
---
|
||
|
||
# Summary
|
||
|
||
## (a) Is local player motion already retail-faithful?
|
||
|
||
**Mostly yes, with two known divergences.** The pipeline shape
|
||
(input → DoMotion → integrate → collision sweep → outbound MTS) is
|
||
retail-faithful and well-cited. The 30 Hz physics-tick gate, jump
|
||
charge formula, wall-bounce reflection, ground/landing detection,
|
||
HoldKey wire encoding, and 1 Hz heartbeat all match named-retail and
|
||
the 2026-05-01 cdb trace. The outbound MoveToState + AutonomousPosition
|
||
packet builders are byte-faithful to holtburger's reference
|
||
implementation.
|
||
|
||
The two divergences:
|
||
|
||
1. **Hand-rolled velocity overrides** (L378–411, L466–501) work around
|
||
the missing `adjust_motion` (FUN_00528010) port. Backward/
|
||
strafe-left commands are translated to WalkForward/SideStepRight
|
||
with negative speeds in retail before they reach InterpretedState;
|
||
acdream skips the translation and re-derives the velocity manually
|
||
downstream. Result is correct but architecturally diverged.
|
||
|
||
2. **RunSkill data source** is env var (default 200) plus
|
||
ApplyServerRunRate echo, instead of the server's authoritative
|
||
PlayerDescription value. Causes mispredicted local velocity for
|
||
non-default skill characters.
|
||
|
||
Both are pre-existing tech debt, not L.3-specific. The local-player
|
||
audit found NO L.3-introduced regressions analogous to the remote-side
|
||
`apply_current_movement`-per-tick bug.
|
||
|
||
## (b) Top 3 things that need to change
|
||
|
||
1. **Port `adjust_motion` (FUN_00528010) into `MotionInterpreter`.**
|
||
Translates WalkBackwards → WalkForward + speed×-0.65 and
|
||
SideStepLeft → SideStepRight + speed×-1 BEFORE InterpretedState
|
||
write. Once present, `get_state_velocity` returns correct vectors
|
||
for all motion commands and the hand-rolled overrides at L378–411
|
||
+ L466–501 can be deleted. Same fix benefits the jump path.
|
||
|
||
2. **Wire `PlayerDescription` (0xF7B0 / 0x0013) RunSkill+JumpSkill
|
||
→ `_weenie.SetSkills(...)`** (issue #7). Removes the env var
|
||
workaround and `ApplyServerRunRate` becomes a no-op (the weenie's
|
||
formula already produces the correct RunRate from the server's
|
||
skill). Velocity-prediction parity for arbitrary characters.
|
||
|
||
3. **Rationalise the heartbeat gate.** Retail's
|
||
`SendPositionEvent` gates AutonomousPosition on motion state
|
||
(Contact + OnWalkable + active velocity); acdream sends 1 Hz
|
||
unconditionally while in-world. Cdb trace 2026-05-01 confirmed
|
||
retail does not heartbeat at rest. Filing this — wasted
|
||
bandwidth + observer dead-reckon noise. Low-stakes vs (1) and
|
||
(2), but a clean behavioral diff worth fixing.
|
||
|
||
## (c) Shared-with-remote code paths that need to converge
|
||
|
||
`MotionInterpreter.apply_current_movement` is the convergence point.
|
||
Local must call it per tick (correct, retail-faithful); remote must
|
||
NOT (per L.3 spec). The function itself is fine; the call-site
|
||
discipline is what matters.
|
||
|
||
After the L.3 port:
|
||
|
||
- `PhysicsBody`, `AnimationSequencer`, `PhysicsEngine.ResolveWithTransition`,
|
||
`MotionInterpreter` (the type, not its per-tick invocation) all stay
|
||
shared and correct.
|
||
- `apply_current_movement`: local-player call (per tick + on UM echo)
|
||
remains; remote-player per-tick call gets removed (currently at
|
||
GameWindow.cs:6599).
|
||
- The shared `CurrentVelocity` accessor on `AnimationSequencer` (wired
|
||
to local via `AttachCycleVelocityAccessor`) gets a parallel
|
||
`PositionManager.ComputeOffset` consumer for remotes — same field,
|
||
different driver.
|
||
|
||
**No symmetry break required.** Local and remote can share the type
|
||
hierarchy; they diverge only on which functions they invoke per
|
||
tick. The L.3 port doesn't perturb the local-player path at all
|
||
beyond optionally fixing items (1)-(3) above as side improvements.
|
||
|
||
---
|
||
|
||
Path: `docs/research/2026-05-04-l3-port/14-local-player-audit.md`.
|