feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
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>
This commit is contained in:
parent
a3f53c2644
commit
de129bc164
18 changed files with 10721 additions and 190 deletions
722
docs/research/2026-05-04-l3-port/14-local-player-audit.md
Normal file
722
docs/research/2026-05-04-l3-port/14-local-player-audit.md
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
# 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue