diff --git a/docs/research/2026-07-30-movement-parity-audit.md b/docs/research/2026-07-30-movement-parity-audit.md new file mode 100644 index 00000000..602519e7 --- /dev/null +++ b/docs/research/2026-07-30-movement-parity-audit.md @@ -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.