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

41 KiB
Raw Blame History

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-305400FACT, 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-305122FACT 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-305100FACT, 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 YesHandleNewForwardMovement explicitly cancels autorun on every new W press (0x006b3d60) NoMovementForward 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.