acdream/docs/research/2026-07-30-movement-parity-audit.md
2026-07-30 14:54:32 +02:00

618 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Movement Parity Audit — Retail vs acdream (2026-07-30)
**Status: COMPLETE — report-only investigation, no code changes made.**
Scope: input → intent → wire → presentation for player + remote movement.
Explicitly OUT of scope (closed by Campaign P, physics/collision proper):
stat chain, friction, sphere lists, leash, PK flags. See
`docs/plans/2026-07-29-physics-parity-campaign.md`'s closeout. This audit
picks up the *movement wire/presentation* seam Campaign P did not touch.
Legend: **FACT** = confirmed against named-retail decomp byte/pseudo-C
(cited address + `acclient_2013_pseudo_c.txt` line) or cross-referenced
against a second independent client (holtburger / Chorizite). **INFERENCE**
= plausible reading where the decompiler dropped x87 detail (a known
Binary Ninja artifact class, see `claude-memory/feedback_bn_decomp_field_names.md`)
and could not be fully disambiguated in this pass.
---
## 1. Outbound semantics table
Retail's outbound tree (from `claude-memory/project_retail_motion_outbound.md`,
re-verified this session):
```
WASD keypress → CommandInterpreter::SendMovementEvent (0x006B4680, per-frame)
→ MoveToStatePack → SendMoveToStateEvent → 0xF61C
at-rest heartbeat → CommandInterpreter::ShouldSendPositionEvent (0x006B45E0)
→ SendPositionEvent (0x006B4770) → AutonomousPositionPack → 0xF753
```
| Intent | Retail send decision | acdream send decision | Verdict |
|---|---|---|---|
| W (run) | `SendMovementEvent` fires on any command-list head edge; wire carries `WalkForward`, `HoldKey.Run`, raw `forward_speed` (pre-scale). ACE auto-upgrades to `RunForward` for observers. | `PlayerMovementController` line 2106-2225: `outForwardCmd=WalkForward`, `outForwardSpeed=1.0f` (raw), `IsRunning=input.Run`; `changed` fires on cmd/hold/speed edges. `RawMotionStatePacker` D1 default-diff omits unchanged fields. | **Parity** (D6.2b/D1 shipped, verified 2026-07-01) |
| W+Shift (walk) | Same tree, `HoldKey.None`, `forward_speed=1.0` (not run-scaled — ACE/observer scaling is a display-time concern, not sender concern). | Same — `axisHoldKey = movement.IsRunning ? Run : None` in `LocalPlayerOutboundController.BuildRawMotionState` (:234-244). | **Parity** |
| Backward (X) | `WalkBackward` tag, own independent forward-channel entry; `adjust_motion` applies a flat **-0.65×** speed multiplier for the walk-forward↔backward pair (`apply_run_to_command`/`adjust_motion` 0x00528010, `acclient_2013_pseudo_c.txt:305343-305400`**FACT**, spot-read confirms the `0x45000006→WalkForward, speed*=-0.65` canonicalization). | `outForwardCmd=WalkBackward`, `outForwardSpeed=1.0f` (PlayerMovementController :2111-2115). The **-0.65× backward scale lives in `MotionInterpreter.cs:543-546`** ("Retail-exact value; do not round to 0.65f") and is applied on the *interpreted* (local-animation) side, not re-derived on the wire (wire stays raw 1.0, matching D6.2b's "ACE recomputes" model). | **Parity** — same separation-of-concerns retail uses (raw wire, scaled interpretation) |
| Strafe (Z/C) | `SideStepRight`/`SideStepLeft`; `adjust_motion` applies a flat **×1.248** (`(3.12/1.25)*0.5`) animation-rate scale, THEN `apply_run_to_command`'s SideStepRight branch (if Run) multiplies by `runRate` and clamps magnitude to **3.0** (`0x00527be0:305102-305122`**FACT** for the 3.0 constant and the runRate scale; **INFERENCE** on the exact snap-vs-clamp branch polarity, x87 flag test unresolved by BN). | `MotionInterpreter.cs:558-564` cites the retail `±3.0` clamp and the 1.248 sidestep scale explicitly; `_activeInputSidestepCommand`/`SidestepUsesRunHold` in `PlayerMovementController.cs:2129-2133` carry the channel through to the wire. | **Parity** (ported; the one open item is the same x87-ambiguous branch retail's own disassembly leaves fuzzy — not an acdream gap) |
| Turn (A/D) keyboard | `adjust_motion` canonicalizes Left→Right (`speed *= -1`), then `apply_run_to_command`'s TurnRight branch multiplies by a flat **1.5×** when hold key is Run (`0x00527be0:305096-305100`**FACT**, byte-confirmed this session). Turn is a channel fully independent of forward/sidestep. | `MotionInterpreter.cs:554` `RunTurnFactor = 1.5f`, applied inside the ported `apply_run_to_command` (:1355+). Turn channel (`_activeInputTurnCommand`/`_activeInputTurnSpeed`) is independent of forward/sidestep in `PlayerMovementController.cs:2139-2143`. | **Parity** |
| Autorun (Q) | See §4 below — separate section, real divergence found. | | **Divergent** |
| Mouse-look turn (MMB) | `CameraSet::ToggleMouseLook`/`Rotate` (0x00457490/0x00458310) drive ordinary `TurnLeft`/`TurnRight` `MovePlayer` calls, always `HoldKey.Run`; speed = 2×filtered horizontal delta, dead-zone 0.02, cap 1.5. `MoveToState` sent on start/stop and every 0.5 s while active. | `MouseTurnDeadZone=0.02f`, `MouseTurnSpeedScale=2.0f`, `MouseTurnMaximumSpeed=1.5f`, `MouseMovementEventInterval=0.5f` (`PlayerMovementController.cs:376-380`) — exact match. | **Parity** (previously verified 2026-07-15, re-confirmed this session) |
| Mouse-move-to (click-to-move) | Not part of the CommandInterpreter WASD tree; routes through `MoveToManager`/`MoveToPosition` (§3). | Same split in acdream (`MoveToManager.cs`, separate from `PlayerMovementController`'s per-frame channel). | **Parity** (architectural match) |
| Stop (S key / all keys released) | `CommandInterpreter::UseTime` gates `ShouldSendPositionEvent` first, then falls through; a full command-list-empty state issues `MovePlayer(Ready, ...)` idle re-sync via `ApplyCurrentMovement`. | `PlayerMovementController` idle path falls to `_motion.RawState.ForwardCommand` staying at `Ready` default (0x41000003), consistent with retail's ctor default. | **Parity** |
### TS-33 residual (exact current-code read)
Register row (`docs/architecture/retail-divergence-register.md:264`, re-read
this session): **"NARROWED 2026-07-15 — full AP tracker semantics are
ported... Residual: acdream's single update path snapshots the AP predicate,
emits a same-update MTS first when input changed, then AP. Retail proves
`UseTime` performs Should→AP, but MTS originates in separate input
callbacks; their relative same-tick callback/wire order is not yet
traced."** This is confirmed still accurate: `PlayerMovementController.cs`'s
per-frame method computes `MovementResult` (lines 2080-2226, the MTS side)
and `LocalPlayerOutboundController.SendPreNetworkActions`/
`SendPostNetworkPosition` (its own file, :50-144) split MTS-before-inbound
vs AP-after-inbound exactly as retail's `UseTime` (0x006B3BF0, decomp
699564-699583) does: `ShouldSendPositionEvent()→SendPositionEvent()` FIRST,
then (separately, from input callbacks, not shown in `UseTime` itself)
`SendMovementEvent`. TS-33's residual is real but narrow: it's an *ordering*
question (does retail's per-frame input callback that calls
`SendMovementEvent` run before or after that frame's `UseTime` AP check?),
not a values/cadence question. Unchanged this session — still needs a cdb
trace to close, not a code fix.
### AP-30 — STALE register row (found this session)
**FACT.** The register (`retail-divergence-register.md:153`) currently
reads: *"AutonomousPosition diff cadence compares with epsilons (1 mm pos,
1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float
compare... `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1110`."*
Both halves of this row are now wrong relative to current code:
1. **Line citation is stale.** Line 1110 of `PlayerMovementController.cs`
today is inside `AttachAnimationRootMotionSource`'s parameter list — unrelated
code. The actual epsilon logic lives at `PlayerMovementController.cs:2234-2263`
(`ApproxFrameEqual`/`ApproxPlaneEqual`).
2. **The epsilon claim is factually wrong about retail.** Read directly
from the named decomp: `Frame::is_equal` (`0x00424c30`, line 38461-38468)
calls `Vector3Math::AreEqual(origin, origin, 0.000199999995f)` and
`Frame::is_quaternion_equal` (0x00424c70, line 38472-38505), which
compares all four quaternion components against the **same
`0.000199999995f` (0.0002) epsilon** — not an exact bit compare.
Likewise `Plane::operator==` (`0x006b3dd0`, line 699713-699743) compares
`N.x`/`N.y`/`N.z`/`d` against the identical `0.000199999995f` epsilon.
`ApproxFrameEqual`/`ApproxPlaneEqual` in current acdream code (lines
2234-2263) use exactly `0.000199999995f` uniformly for both — i.e.
**acdream's current code already matches retail's real (epsilon, not
exact) comparison byte-for-byte**, and the code's own doc-comment says so
correctly ("Retail `Frame::is_equal` ... compares ... with a 0.0002-unit
epsilon"). The register row documents a bug that no longer exists.
**Recommendation:** retire/correct AP-30 in the register (delete the row,
or rewrite it to note the epsilon match is intentional retail parity, not a
divergence) as a small housekeeping fix — no runtime behavior change
needed, since the code is already correct.
---
## 2. Inbound presentation
### 2a. Interpolation catch-up rate — **DIVERGENT, high-severity** (found this session, per coordinator's byte-decode addendum)
**FACT (P-review byte decode, 2026-07-30).** Retail's
`InterpolationManager::adjust_offset`/`UseTime` (0x00555d30/0x00555f20)
gates its catch-up-speed source on a **static flag**,
`InterpolationManager::fUseAdjustedSpeed_` (`.data` at `0x0081f418`,
initialized to `0x1` — confirmed directly, line 1102675):
```
if (fUseAdjustedSpeed_ == 0) catchUpBase = get_max_speed(); // DEAD by default
else catchUpBase = get_adjusted_max_speed(); // the LIVE path
catchUp = catchUpBase * 2.0f; // MaxInterpolatedVelocityMod
```
(confirmed directly, `acclient_2013_pseudo_c.txt:353104-353123`).
`CMotionInterp::get_adjusted_max_speed` (`0x00527d00`, line 305145-305156,
read directly — BN drops the x87 return values into dead-looking
statements, the same artifact class as `get_max_speed`'s ×4 dropout that
UN-2 already resolved by disassembly) is **conditional on the entity's
current interpreted forward command**:
- `forward_command != RunForward (0x44000007)` (i.e. standing, walking,
turning, sidestepping, backing up — anything but an actual run cycle):
returns the **bare run rate** (`InqRunRate`/`my_run_rate`), **no ×4**.
- `forward_command == RunForward`: returns
`interpreted_state.forward_speed ÷ current_speed_factor`, **× 4.0**
(`RunAnimSpeed`, `0x007c8918`) — per the coordinator's disassembly-level
decode (the BN pseudo-C alone drops this trailing multiply, matching the
established `get_max_speed`/UN-2 artifact pattern).
**acdream's current code does not port `get_adjusted_max_speed` at all**
there is no `CurrentSpeedFactor`/`current_speed_factor` field anywhere in
`MotionInterpreter.cs` (confirmed by grep, zero hits). Every call site that
feeds the interpolation catch-up cap instead calls the **unconditional**
`MotionInterpreter.GetMaxSpeed()` (`MotionInterpreter.cs:2632-2642`, itself
a faithful, byte-verified port of retail's `get_max_speed` alone — always
`runRate × RunAnimSpeed(4.0)`, regardless of forward_command):
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:254`,
`:291`, `:685` (remote NPC/player catch-up)
- `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:174`
- `src/AcDream.App/Input/PlayerModeController.cs:328`
**Consequence:** for any remote entity that is standing, walking, turning,
sidestepping, or backing up (i.e. every state except actively running
forward), acdream's catch-up cap is **exactly 4× retail's** (both use the
×2.0 `MaxInterpolatedVelocityMod`, but acdream also always applies the ×4.0
`RunAnimSpeed` that retail reserves for the RunForward-only branch). For a
run-rate-2.94 character standing still: retail caps catch-up at
2×2.94 ≈ **5.9 m/s**; acdream currently caps it at 2×2.94×4 ≈ **23.5 m/s**
— a 4× overshoot. Only while the remote is genuinely in a `RunForward`
cycle does acdream's flat ×4 approach retail's own (still not identical,
since retail additionally normalizes by `current_speed_factor`, an
unported field).
This existed underneath a prior investigation (UN-2, resolved 2026-06-12,
cited directly in `MotionInterpreter.cs:2601-2630`) that correctly
byte-verified the ×4.0 constant *inside* `get_max_speed`, but did not catch
that `get_max_speed` itself is the **dead default branch** — retail's real
call site always takes `get_adjusted_max_speed`, which only applies that
×4 conditionally. This is a strong root-cause candidate for the "remote
catch-up feels too fast/twitchy for non-running remotes" symptom family
(#41 blips, #165 wall-penetration-before-stop) that the same doc-comment
explicitly says to look elsewhere for — this audit's finding redirects that
search back to this exact seam.
**Recommendation (report-only — no fix applied):** port
`CMotionInterp::get_adjusted_max_speed` as a new `MotionInterpreter` method
(needs `current_speed_factor`, currently absent — a new tracked field,
citing `0x00527d00`/line 305145), and switch every catch-up-cap call site
above from `GetMaxSpeed()` to the new adjusted accessor, gated by the
(retail-fixed-true) `fUseAdjustedSpeed_` semantics — i.e. always call the
adjusted variant, since retail's own flag is always on. This is the
single highest-value fix candidate in this audit.
### 2b. Snap / teleport thresholds — two distinct constants, both present
**FACT.** Retail has two separate thresholds, and acdream has ported both
correctly:
| Constant | Retail value | Retail site | acdream value | acdream site |
|---|---|---|---|---|
| Hard routing snap (give up on interpolation entirely, `MoveOrTeleport`) | **96.0 m** | `CPhysicsObj::MoveOrTeleport` 0x00516330, line 284342-284361 | Not separately named in `InterpolationManager.cs` — this gate lives upstream, at the physics dispatch layer (`RuntimeRemotePhysicsUpdater`/teleport handling), not audited line-by-line this session; flagged as **needs a follow-up grep to confirm the 96 m constant is present at the equivalent acdream call site** (not found during this pass — see gap catalog). | — |
| Enqueue-time "far jump, pre-arm blip" (`AutonomyBlipDistance`) | **100 m outdoor / 20 m indoor** per prior cdb live-attach (project's own 2026-05-0x capture) — the *decomp* constant itself (`GetAutonomyBlipDistance`, 0x0050eb70) is BN-garbled and not independently re-derivable from static text alone this session (**INFERENCE**, cdb-sourced not decomp-sourced) | `CPhysicsObj::GetAutonomyBlipDistance` 0x0050eb70 | `AutonomyBlipDistance = 100.0f` (`InterpolationManager.cs:99`), comment explicitly notes "indoor is 20 m" as a known-but-unported distinction | `InterpolationManager.cs:99` |
**Verdict: Parity** for the enqueue-time 100 m outdoor constant (matches
the project's own prior cdb finding); the indoor-20m variant is
**Divergent/incomplete** — acdream uses a flat 100 m regardless of
indoor/outdoor, an existing known gap already flagged in the code's own
comment (not a new finding, confirmed still present).
### 2c. Position-history queue depth — Parity
**FACT.** Retail: 20 entries (`0x14`), head-evicted on overflow, confirmed
directly at `InterpolateTo` line 353004-353021. acdream:
`QueueCap = 20` (`InterpolationManager.cs:49`), enforced identically
(`Enqueue`, :254-256, `RemoveFirst()` on cap). **Parity.**
### 2d. Stall/give-up mechanics — Parity
**FACT**, all four constants cross-checked directly against the decomp
this session and via the subagent's independent read of
`InterpolationManager.cs`:
| Constant | Retail (line) | acdream (`InterpolationManager.cs`) |
|---|---|---|
| Stall check window | 5 frames (353146) | `StallCheckFrameInterval = 5` (:79) |
| Min progress distance | 0.20 m (353185-353190) | `MinDistanceToReachPosition = 0.20f` (:67) |
| Min progress fraction | 0.30 (353172-353177) | `StallProgressMinFraction = 0.30f` (:86) |
| Fail-count blip threshold | `> 3` (353270) | `StallFailCountThreshold = 3` (:92), fires at 4+ |
**Verdict: Parity.**
### 2e. TS-44 sticky-gated enqueue suppression
Not inside `InterpolationManager.cs` itself — lives in the consumer,
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1637-1643`.
Suppresses a raw `UpdatePosition`-driven snap for an NPC currently
sticky-attached to a target (`PositionManager.GetStickyObjectId() != 0`),
bounded by the ~1 s sticky lease; register TS-44 is **narrowed, not
retired** (per `docs/ISSUES.md` 2026-07-07 pass). User-visible effect:
while a monster is sticky-melee-locked onto a target, an incoming server
position correction that would otherwise snap the NPC is suppressed and
the sticky steering keeps driving it instead — server truth reasserts on
the first UpdatePosition after the lease expires. This is a deliberate,
already-registered adaptation, not a newly found gap.
### 2f. MoveToRunRate consumption
**FACT.** Wire-parsed in `CreateObject.cs:269-296` (`ServerMotionState`
field) and consumed at `LiveEntityNetworkUpdateController.cs:323,433` and
`LiveEntityMotionRuntimeController.cs:394`. A pre-existing, separately
tracked gap (M13 plan doc `docs/research/2026-07-03-r4-moveto/r4-port-plan.md:87`)
notes `MoveToRunRate` feeds the PlanMoveToStart seed but not
`MotionInterpreter.MyRunRate` directly during a live moveto, so
`apply_run_to_command`'s speed scale can use a stale rate mid-MoveTo. Not
re-litigated further this session — flagged as a known, already-filed item
in the gap catalog below (not new).
### 2g. Walk↔Run mid-hold promote/demote render fidelity
**Issue #39** — "Run↔Walk cycle transition not visible on observed player
remotes." **Status confirmed directly this session: CLOSED 2026-07-02**
(`docs/ISSUES.md:7107`). The original 2026-05-06 root-cause ("ACE goes
silent on HoldKey-only toggle") was refuted by a 2026-07-02
three-oracle-plus-live-capture re-investigation
(`docs/research/2026-07-02-inbound-motion-deviation-map.md`, §S0): retail
DOES send a fresh MoveToState on HoldRun toggle while moving, and ACE DOES
rebroadcast it; the refinement machinery #39 built to compensate for the
(non-existent) gap was deleted (commit S5) after it caused spurious
Ready↔Run animation thrash. **CLAUDE.md's phrasing that this is an open
uncertainty ("ACE's behavior on relay is uncertain") is stale relative to
the ISSUES.md record** — worth a small doc correction, not a code gap.
---
## 3. MoveTo/TurnTo parameters
Retail's `MovementParameters` ctor (`0x00524380`, decomp line 300510-300534,
struct verbatim at `acclient.h:31453-31465`) vs acdream's
`MovementParameters.cs` (already cites the same address):
| Field | Retail default | acdream default | Cite | Verdict |
|---|---|---|---|---|
| `MinDistance` | 0.0 | 0f (`:164`) | 300510-300534 | Parity |
| `DistanceToObject` | 0.6 m | 0.6f (`:161`) | same | Parity |
| `FailDistance` | FLT_MAX (3.40282347e+38) | `float.MaxValue` (`:173`) | same | Parity |
| `Speed` | 1.0 | 1f (`:170`) | same | Parity |
| `WalkRunThreshhold` | **15.0 m** | 15f (`:179`) | same | Parity — and acdream's own comment explicitly flags the ACE-divergence trap (ACE uses 1.0) and refuses to copy it |
| `CanCharge` (bitfield 0x10) | **clear (false)** | `false` (`:102`) | same | Parity — same explicit ACE-divergence-trap comment (ACE sets it true by default) |
| `HoldKeyToApply` | `HoldKey_Invalid` | `HoldKey.Invalid` (`:185`) | same | Parity |
**Verdict: full parity.** acdream's `MovementParameters.cs` is a verbatim,
already-well-cited port with correct, explicit call-outs of two known
ACE-vs-retail divergence traps (`CanCharge`, `WalkRunThreshhold`) that it
deliberately does NOT copy from ACE. No gap found here.
### Turn/arrival thresholds beyond the ctor
- `HandleMoveToPosition`'s aux-turn deadband: **20°/340°**
(`MoveToManager.cs:1057`, retail `0x00529d80` line 307187-307438) —
matches the retail function it cites; not independently re-derived from
raw bytes this session (**INFERENCE** on the exact retail constant, but
the citation chain is pre-existing and was not contradicted by anything
found this session).
- `BeginTurnToHeading`/`HandleTurnToHeading` epsilon-snap logic
(`MoveToManager.cs:819-873`, `:1155-1203`) cites retail addresses
`0x00529b90`/`0x0052a0c0` directly; the code's own comments flag two
retail quirks as deliberately preserved: `FailProgressCount` is
write-only in retail (no give-up threshold exists — do not invent one),
and `HandleMoveToPosition` has **no** `set_heading` call (ACE's
"sync for server tickrate" addition is explicitly NOT ported). Both
read as correct, deliberate non-divergences.
### MinDistance vs FailDistance semantics
**FACT** (both retail and acdream, per direct decomp read this session and
independent MoveToManager.cs read): both `MoveToObject` and
`MoveToPosition` share one `movement_params`/`Params` struct and one
handler. `MinDistance`/`DistanceToObject` gates *arrival* (current distance
to target this tick); `FailDistance` gates *give-up* against **total
distance traveled since the move began**, defaulting to FLT_MAX so it is
effectively inert unless a caller tightens it. No object-vs-position
asymmetry exists in either client. **Parity.**
### AP-23 — pickup/use-radius heuristic (current scope)
Register row (`retail-divergence-register.md:148`): an invented per-type
radius bucket (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m
rest) for close-range gating. **Narrowed 2026-07-25 (R5-V3):** the
speculative install now threads the target's real Setup
radius/height (`GetSetupCylinder`) and the player's real radius; only the
bucket bounds remain invented, and Use itself was retired from the
speculative-moveto seam entirely (sends immediately now). Located at
`src/AcDream.App/Interaction/WorldSelectionQuery.cs:78-83` (constants),
`:490-498` (`GetUseRadius`). **One live consumer still cites this seam as
its root mechanism**: `docs/ISSUES.md` issue at line 3825-3826 (2026-07-05,
door-Use-swallowed, HIGH severity), whose resolution is explicitly folded
into Campaign P's physics-parity visual matrix scenario 8
(`docs/plans/2026-07-30-physics-parity-visual-matrix.md`) — i.e. this is
already tracked and pending the user's visual gate, not a newly found gap.
---
## 4. Autorun + mouse semantics
### Retail mechanics (all FACT, byte-read this session)
- **Toggle entry point:** `CommandInterpreter::ToggleAutoRun` (`0x006b3cc0`,
line 699625-699631): `SetAutoRun(auto_run==0, 1)`. Bound via
`CommandInterpreter::HandleKeyboardCommand` (`0x006b3690`, line
699262-699289) on keyboard command `0x90000c7`: reads an **optional
trailing float from the same keybind's argument stream** as
`autorun_speed` (defaults to **1.0** if the keybind carries no extra
argument — confirmed this is the stock case: the retail keymap's
`MovementRunLock [ "" [ 0 DIK_Q ] ]` entry carries no such argument).
- **Default key:** `Q` — confirmed identical in
`docs/research/named-retail/retail-default.keymap.txt:105` and
acdream's `KeyBindings.RetailDefaults():174`.
- **What "on" actually sends:** `CommandInterpreter::ApplyCurrentMovement`
(`0x006b3430`, line 699146-699183): when `auto_run != 0`, retail
unconditionally calls
`MovePlayer(WalkForward(0x45000005), 1, autorun_speed, SetHoldKey=1, HoldKeyToApply=1(Run))`
**autorun ALWAYS forces `HoldKey.Run`**, hard-coded, independent of any
live walk/run toggle state. Since stock `autorun_speed` defaults to 1.0
and the hold key is forced Run, **retail's default autorun always runs**
(WalkForward+HoldKey.Run, which ACE/observers see as RunForward), never
walks, regardless of whether the player has Shift/walk-mode held at
toggle time or afterward.
- **Cancel conditions, two independent mechanisms:**
1. `CommandInterpreter::HandleNewForwardMovement` (`0x006b3d60`, line
699672-699676): **any fresh Forward key press cancels autorun**
(`SetAutoRun(0, 1)`).
2. `CInputManager::ActivateActionKey` (`0x00432650`, line 699243-699258
region, specifically 699496-699502): on a genuine key-down edge
(not a repeat) for action IDs `0x29`/`0x2a`/`0x2b`, calls
`CInputManager::TurnOffRunLock` (`0x004325e0`, line 699424-699442),
which removes the `MovementRunLock` action state and fires its
release-equivalent listener callback. (The exact identity of actions
`0x29`-`0x2b` as raw `CInputManager` action-ID ordinals was not
resolved from static text this session — **INFERENCE** that they are
Backward/StrafeLeft/StrafeRight, based on process-of-elimination
against `HandleNewForwardMovement`'s separate, explicit Forward-only
handling.)
3. Also unconditionally cleared on `LoseControlToServer`,
`PlayerTeleported`, `PlayerIsDead`-detected `MovePlayer` calls, and
`HandleKeyboardCommand`'s own `LoseKeyboardFocus`/death paths.
### acdream mechanics
`RuntimeLocalPlayerMovementState.cs` (`Execute(ToggleRunLock)`, :116-119;
`CancelAutoRun()`, :148-156) + `DispatcherMovementInputSource.cs` (:48-96):
- Default key: **Q** — matches (`KeyBindings.cs:174`).
- **On:** `Forward: forward || AutoRunActive` (:65) — autorun simply forces
the `Forward` boolean true; `Run: !walking` (:71) is evaluated **live,
every poll**, from whatever `InputAction.MovementWalkMode` (Shift) is
currently held — **independent of autorun state**.
- **Cancel set:** `HandlePressedAction` (:80-96) cancels autorun on Press
of `{MovementBackup, MovementStop, MovementStrafeLeft, MovementStrafeRight}`
only.
### Verdicts
| Behavior | Retail | acdream | Verdict |
|---|---|---|---|
| Default key | Q | Q | Parity |
| Pace while autorunning | **Always Run** (hard-forced `HoldKey.Run`, independent of walk-mode toggle) | **Follows the live `MovementWalkMode` toggle** — if the user has Shift/walk-mode held (or toggled) while or after engaging autorun, autorun walks instead of runs | **Divergent.** Confirmed by direct read of `DispatcherMovementInputSource.cs:71` (`Run: !walking`, unconditioned on `AutoRunActive`) against retail's `ApplyCurrentMovement` autorun branch (`SetHoldKey=1, HoldKeyToApply=1` hard-coded, `0x006b3486`). |
| Cancel on Backward/Strafe | Yes (input-layer `TurnOffRunLock`, **INFERENCE** on exact action IDs) | Yes, explicit (`MovementBackup`, `MovementStrafeLeft`, `MovementStrafeRight`) | Parity (functional match) |
| Cancel on Stop key | Not separately identified in retail's cancel set this session (no explicit S/Stop-key cancel site found; likely folds through `auto_run`/`transient_state` reset elsewhere) | Yes, explicit (`MovementStop`) | Likely parity, low-confidence on the retail side |
| **Cancel on fresh Forward press** | **Yes**`HandleNewForwardMovement` explicitly cancels autorun on every new W press (`0x006b3d60`) | **No**`MovementForward` is absent from `HandlePressedAction`'s cancel list (:86-91); architecturally reachable (the same Press edge already drives `CombatAttackInputFrameAdapter.HandleMovementInput`'s abort-for-movement check, `GameplayInputFrameController.cs:24-39`) but not wired to `CancelAutoRun()` | **Divergent, confirmed gap.** In acdream, pressing W while autorunning is currently a no-op (autorun stays latched, `Forward` was already true); in retail, the same press explicitly drops autorun and hands control back to the held key. |
| Mouse-look interaction with autorun | No evidence found of a direct interaction; mouse-look drives its own `TurnLeft`/`TurnRight` channel independent of `auto_run` | Same — mouse-look turn channel (`_activeInputTurnCommand`) is independent of `AutoRunActive` | Parity (no interaction expected on either side) |
| Both-mouse-buttons-run | No evidence found of a distinct "both mouse buttons = run forward" binding in the decompiled `CommandInterpreter`/`CInputManager` text searched this session | Not implemented (no such binding in `KeyBindings.RetailDefaults()`) | **Ruled out as a feature** — this session found no retail mechanism for it, so acdream's absence is not a gap. (If the user recalls this from live retail play, it would warrant a targeted cdb trace on `IInputActionCallback`/mouse-button handlers; not found in the static decomp searched here.) |
---
## 5. Turn-rate composition
**FACT**, direct decomp read this session, `CMotionInterp::apply_raw_movement`
(`0x005287e0`, line 305817-305834) → three independent
`adjust_motion(forward)`, `adjust_motion(sidestep)`, `adjust_motion(turn)`
calls → `apply_interpreted_movement` (`0x00528600`, line 305713-305788)
dispatches `DoInterpretedMotion` **separately** per axis.
- **No cross-axis normalization exists in retail.** Forward, sidestep, and
turn are fully independent scalar channels; there is no diagonal-movement
magnitude clamp (no "moving diagonally isn't faster than moving straight"
logic anywhere in this pipeline) — retail "naively adds commands," to use
the literal reading of `apply_interpreted_movement`'s three sequential,
unconditional `DoInterpretedMotion` calls.
- **Turning while moving backward:** confirmed **no interaction** — the
backward `-0.65×` scale (`adjust_motion`'s `0x45000006→WalkForward`
canonicalization) only touches the forward channel; the turn channel's
own `adjust_motion(turn)` call and its 1.5× run-turn factor are
processed independently with zero shared state.
- **Order of operations relative to dt:** the 1.5× turn multiplier (and
the 1.248× sidestep scale, and the ×4.0/`current_speed_factor` catch-up
math) all operate on the pre-integration **speed scalar**; dt-integration
happens downstream in physics, not inside `adjust_motion`/
`apply_run_to_command`. So "run-turn factor before or after dt scaling"
is moot — it's applied to the same speed value physics later multiplies
by dt, in both clients.
**acdream's port** (`MotionInterpreter.cs`, `adjust_motion` :1290-1321,
`apply_raw_movement`-equivalent :1386-1424) mirrors this structure exactly:
three independent `adjust_motion` calls per axis (:1416, :1420, :1424), no
cross-axis clamp anywhere in the surrounding code, and the code's own
comment (:1268-1271) explicitly documents the same ordering subtlety
retail has (sign-flip on canonicalization happens BEFORE the 1.248
sidestep scale, so the net multiplier for SideStepLeft is `-1.248×speed`,
not `-1×(1.248×speed)` — same value algebraically, but the comment shows
the port tracked retail's actual operation order, not just its result).
**Verdict: full parity.** No combined-input normalization gap found on
either side (neither client has one) — this is a "ruled out" item, not an
open question. Backward+turn and strafe+turn combinations have no special
case in retail and none in acdream, matching.
---
## 6. Wire-format cross-check: holtburger + Chorizite
### RawMotionState / MoveToState / AutonomousPosition bit layout — three-way parity
**FACT.** `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs:45-61`
(`RawMotionFlags` bitflags) is bit-for-bit identical to acdream's
`RawMotionStatePacker.cs:44-55` flag constants (0x001 CurrentHoldKey through
0x400 TurnSpeed, `num_actions` in bits 11+ via
`packed_flags >> 11` matching acdream's `NumActionsShift = 11`), and both
match the named-retail `RawMotionState::Pack` (0x0051ed10) bitfield this
project already ported. `MoveToStateActionData`
(`.../movement/actions.rs:9-18`) field order (raw_motion_state, position,
4× u16 sequence, one trailing byte) matches acdream's `MoveToState.Build`
call shape exactly, including the trailing
`(standingLongjump?2:0)|(contact?1:0)` byte (holtburger's
`contact_long_jump: u8`, same slot). `AutonomousPositionActionData`
(:141-149) matches `AutonomousPosition.Build` field-for-field.
Independently, holtburger's own `AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL`
(`crates/holtburger-core/src/client/movement/common.rs:22`) is
**`Duration::from_secs(1)`** — a third independent confirmation (after
retail's decomp ctor default `0x3ff00000`=1.0 at line 699783, and acdream's
`HeartbeatInterval = 1.0f`) that the AP heartbeat is exactly 1 second across
all three. **Parity, three-way confirmed.**
### Jump packet — acdream matches retail; BOTH holtburger and Chorizite are wrong here
**FACT, byte-verified this session.** Retail's `JumpPack::Pack`
(`0x00516d10`, decomp line 284915-284967, read directly) writes, in exact
order: `extent` (f32) → `velocity.x/y/z` (f32×3) →
**`this->position.vtable->Pack(...)`** (a full `Position` pack: objcell_id
+ frame origin + quaternion) → `instance_timestamp`/`server_control_timestamp`/
`teleport_timestamp`/`force_position_ts` (u16×4) → 4-byte align. This
matches the `JumpPack` **constructor** signature
(`0x00516c70`, line 284887: `float, Vector3 const*, Position const*, u16×4`)
exactly — Position genuinely is part of the wire bytes, not just a
constructor-time convenience.
acdream's `JumpAction.Build(gameActionSequence, extent, velocity, cellId,
position, rotation, instanceSequence, serverControlSequence,
teleportSequence, forcePositionSequence)` (called from
`LocalPlayerOutboundController.cs:73-84`) matches this exactly — this was
already the subject of a correction (memory: "D4 `JumpAction` = retail
`JumpPack` (extent·velocity·Position·4 ts); spurious objectGuid/spellId
removed, Position now packed").
By contrast:
- **holtburger's `JumpActionData`** (`.../movement/actions.rs:73-82`) has
**no `Position` field at all** — instead `extent`, `velocity`, 4×
sequence, then `object_guid: Guid` and `spell_id: u32`. This is the
*pre-correction* shape acdream itself used to have before the D4 fix
(per the same memory note) — i.e. holtburger's Jump model reproduces the
same historical mistake acdream already found and fixed via the named
decomp.
- **Chorizite's `JumpPack.generated.cs`** (`Types/JumpPack.generated.cs:22-83`)
has **neither Position nor object_guid/spell_id** — just `Extent`,
`Velocity`, and the 4 sequence ushorts, then straight to 4-byte
alignment. Also missing the Position bytes.
**Conclusion: acdream's Jump packet is the retail-correct one; do not use
holtburger's or Chorizite's Jump models as a tiebreaker for this specific
packet** — both diverge from the byte-verified retail shape in the same
direction (omitting Position), and holtburger additionally invents
object_guid/spell_id fields that do not exist on the wire. This is a
genuine finding worth remembering for future cross-reference work on this
one packet (not something to act on in acdream — acdream is already
correct), and is exactly the kind of case the project's reference-hierarchy
rule anticipates ("the intersection of the relevant references is almost
always the truth... a single reference can be misleading") — here the
*retail decomp itself*, not the intersection, was the tiebreaker, since two
of three references independently share the same divergence.
---
## 7. Ranked gap catalog
1. **[HIGH] Interpolation catch-up cap is 4× too fast for any non-running remote (§2a).**
`MotionInterpreter` never ported `get_adjusted_max_speed`
(`0x00527d00`) or `current_speed_factor`; every catch-up-cap call site
(`RuntimeRemotePhysicsUpdater.cs:254,291,685`,
`LiveEntityMotionRuntimeController.cs:174`, `PlayerModeController.cs:328`)
uses the always-×4 `GetMaxSpeed()` instead of the conditional accessor
retail's own `fUseAdjustedSpeed_=1` static makes the *only* live path.
Root-cause candidate for observed remote catch-up feeling too
fast/twitchy outside full sprint. **Recommended fix order: first**,
since it's concrete, well-cited, and plausibly explains existing
symptom reports (#41/#165 family) the project has been chasing under
other theories.
2. **[MEDIUM] Autorun always inherits the live walk/run toggle instead of always forcing Run (§4).**
`DispatcherMovementInputSource.cs:71` computes `Run: !walking` every
poll, unconditioned on `AutoRunActive`; retail's `ApplyCurrentMovement`
hard-forces `HoldKey.Run` for the entire duration of an autorun latch
regardless of walk-mode state. User-visible: toggling walk-mode while
autorunning in acdream can make it walk; retail autorun never walks
(absent a custom keybind speed argument, which the stock keymap doesn't
carry).
3. **[MEDIUM] Autorun does not cancel on a fresh Forward (W) press (§4).**
`HandlePressedAction`'s cancel set omits `InputAction.MovementForward`;
retail's `HandleNewForwardMovement` explicitly cancels on every new W
edge. Currently a silent no-op difference (autorun stays latched) that
is architecturally trivial to close — the same Press edge is already
routed through the pipeline for the unrelated combat-abort check.
4. **[LOW, doc-only] AP-30 register row is stale (§1).** Both its file:line
citation and its epsilon claim about retail no longer match reality —
the code already matches retail's real (epsilon-based, not exact)
`Frame::is_equal`/`Plane::operator==` comparison. Recommend
retiring/correcting the row; zero runtime risk either way.
5. **[LOW] Indoor `AutonomyBlipDistance` uses a flat 100 m regardless of indoor/outdoor (§2b).**
Already flagged in the code's own comment as a known simplification (cdb
sourced 20 m indoor vs 100 m outdoor); not a new finding, but grouped
here since it's the one open item in an otherwise clean interpolation
audit.
6. **[LOW, needs follow-up not fix] Confirm the 96 m hard-teleport-snap threshold's acdream equivalent (§2b).**
This session did not locate the acdream call site that mirrors retail's
`MoveOrTeleport` 96 m routing gate (`0x00516330`) — flagged as an
unresolved research gap, not a confirmed divergence. Worth a follow-up
grep for wherever acdream decides "too far to interpolate, snap
instead" at the physics-dispatch layer (outside `InterpolationManager.cs`
itself).
7. **[INFO, no action] CLAUDE.md's "ACE's Run↔Walk relay behavior is uncertain" phrasing is stale (§2g).**
Issue #39 closed 2026-07-02 with the opposite finding (retail does send
a fresh MoveToState on HoldRun toggle; ACE does relay it). Small doc
correction candidate, zero code impact.
8. **[INFO, no action] Two of three wire-format oracles have a wrong Jump packet model (§6).**
holtburger and Chorizite both omit `Position` from their Jump packet
type; acdream's is byte-verified correct. No action needed on acdream's
side — recorded so a future cross-reference pass doesn't get misled by
holtburger/Chorizite's shared mistake on this one packet.
---
## Sources consulted
- `docs/plans/2026-07-29-physics-parity-campaign.md` (scope boundary —
what Campaign P already closed)
- `docs/architecture/retail-divergence-register.md` (rows TS-33, TS-28,
AP-30, AD-57, and the full IA/AD/TS header banners for context)
- `docs/ISSUES.md` (#235, #262, #39, the AP-23 door-Use item at :3825-3826)
- `claude-memory/project_retail_motion_outbound.md`,
`claude-memory/project_input_pipeline.md`,
`claude-memory/project_physics_collision_digest.md` (Campaign P summary
section only)
- `claude-memory/feedback_autowalk_cancharge_bit.md`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — direct reads at
lines 38445-38505 (`Frame::is_equal`/`is_quaternion_equal`), 305062-305156
(`apply_run_to_command`, `get_adjusted_max_speed`), 305160-305199
(`get_state_velocity`), 353095-353135 (`InterpolationManager` catch-up
dispatch), 353261-353344 region, 284887-284967 (`JumpPack::Pack`/ctor),
698940-699850 (autorun/`CommandInterpreter` family), 699560-699830
(`UseTime`, `ToggleAutoRun`, `HandleNewForwardMovement`, `Plane::operator==`,
`CommandInterpreter` ctor), 699120-699220 (`ApplyCurrentMovement`,
`ApplyListHeadMovement`), 55424-55520 (`CInputManager::TurnOffRunLock`/
`ActivateActionKey`), 700233-700420 (`ShouldSendPositionEvent`,
`SendMovementEvent`, `SendPositionEvent`, `SetAutoRun`); plus targeted
greps for `JumpPack`, `apply_run_to_command`, `auto_run`, `Plane::operator==`,
`Frame::is_equal`, `0x45000005`.
- `docs/research/named-retail/retail-default.keymap.txt` (Q=MovementRunLock,
S=Stop confirmed)
- acdream source: `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`,
`LocalPlayerOutboundController.cs`, `RuntimeLocalPlayerMovementState.cs`;
`src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`;
`src/AcDream.Core/Physics/RawMotionState.cs`, `MotionInterpreter.cs`,
`InterpolationManager.cs`, `Motion/MoveToManager.cs`,
`Motion/MovementParameters.cs`; `src/AcDream.App/Input/DispatcherMovementInputSource.cs`,
`GameplayInputFrameController.cs`; `src/AcDream.UI.Abstractions/Input/KeyBindings.cs`,
`InputAction.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs`;
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs`,
`LiveEntityMotionRuntimeController.cs`; `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs`,
`actions.rs`; `references/holtburger/crates/holtburger-core/src/client/movement/{system.rs,common.rs}`
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/JumpPack.generated.cs`,
`Messages/C2S/Actions/Movement_Jump.generated.cs`
- Two Sonnet research subagents (retail-decomp MoveTo/interpolation/turn
research; acdream MoveToManager/InterpolationManager code research) —
their findings were spot-checked directly against the named decomp and
source files in this pass (per `feedback_verify_subagent_claims_against_source.md`);
all spot-checks (MovementParameters ctor defaults, `apply_run_to_command`,
issue #39 status) matched their reports.