docs(research): movement parity audit - catch-up cap 4x gap (HIGH), autorun divergences, AP-30 stale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 14:54:32 +02:00
parent 4031a01881
commit 2123b44e8c

View file

@ -0,0 +1,618 @@
# 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.