41 KiB
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:
- Line citation is stale. Line 1110 of
PlayerMovementController.cstoday is insideAttachAnimationRootMotionSource's parameter list — unrelated code. The actual epsilon logic lives atPlayerMovementController.cs:2234-2263(ApproxFrameEqual/ApproxPlaneEqual). - The epsilon claim is factually wrong about retail. Read directly
from the named decomp:
Frame::is_equal(0x00424c30, line 38461-38468) callsVector3Math::AreEqual(origin, origin, 0.000199999995f)andFrame::is_quaternion_equal(0x00424c70, line 38472-38505), which compares all four quaternion components against the same0.000199999995f(0.0002) epsilon — not an exact bit compare. LikewisePlane::operator==(0x006b3dd0, line 699713-699743) comparesN.x/N.y/N.z/dagainst the identical0.000199999995fepsilon.ApproxFrameEqual/ApproxPlaneEqualin current acdream code (lines 2234-2263) use exactly0.000199999995funiformly 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 ("RetailFrame::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: returnsinterpreted_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 establishedget_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:174src/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, retail0x00529d80line 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/HandleTurnToHeadingepsilon-snap logic (MoveToManager.cs:819-873,:1155-1203) cites retail addresses0x00529b90/0x0052a0c0directly; the code's own comments flag two retail quirks as deliberately preserved:FailProgressCountis write-only in retail (no give-up threshold exists — do not invent one), andHandleMoveToPositionhas noset_headingcall (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 viaCommandInterpreter::HandleKeyboardCommand(0x006b3690, line 699262-699289) on keyboard command0x90000c7: reads an optional trailing float from the same keybind's argument stream asautorun_speed(defaults to 1.0 if the keybind carries no extra argument — confirmed this is the stock case: the retail keymap'sMovementRunLock [ "" [ 0 DIK_Q ] ]entry carries no such argument). - Default key:
Q— confirmed identical indocs/research/named-retail/retail-default.keymap.txt:105and acdream'sKeyBindings.RetailDefaults():174. - What "on" actually sends:
CommandInterpreter::ApplyCurrentMovement(0x006b3430, line 699146-699183): whenauto_run != 0, retail unconditionally callsMovePlayer(WalkForward(0x45000005), 1, autorun_speed, SetHoldKey=1, HoldKeyToApply=1(Run))— autorun ALWAYS forcesHoldKey.Run, hard-coded, independent of any live walk/run toggle state. Since stockautorun_speeddefaults 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:
CommandInterpreter::HandleNewForwardMovement(0x006b3d60, line 699672-699676): any fresh Forward key press cancels autorun (SetAutoRun(0, 1)).CInputManager::ActivateActionKey(0x00432650, line 699243-699258 region, specifically 699496-699502): on a genuine key-down edge (not a repeat) for action IDs0x29/0x2a/0x2b, callsCInputManager::TurnOffRunLock(0x004325e0, line 699424-699442), which removes theMovementRunLockaction state and fires its release-equivalent listener callback. (The exact identity of actions0x29-0x2bas rawCInputManageraction-ID ordinals was not resolved from static text this session — INFERENCE that they are Backward/StrafeLeft/StrafeRight, based on process-of-elimination againstHandleNewForwardMovement's separate, explicit Forward-only handling.)- Also unconditionally cleared on
LoseControlToServer,PlayerTeleported,PlayerIsDead-detectedMovePlayercalls, andHandleKeyboardCommand's ownLoseKeyboardFocus/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 theForwardboolean true;Run: !walking(:71) is evaluated live, every poll, from whateverInputAction.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, unconditionalDoInterpretedMotioncalls. - Turning while moving backward: confirmed no interaction — the
backward
-0.65×scale (adjust_motion's0x45000006→WalkForwardcanonicalization) only touches the forward channel; the turn channel's ownadjust_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_factorcatch-up math) all operate on the pre-integration speed scalar; dt-integration happens downstream in physics, not insideadjust_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 theJumpPackconstructor 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 noPositionfield at all — insteadextent,velocity, 4× sequence, thenobject_guid: Guidandspell_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 — justExtent,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
- [HIGH] Interpolation catch-up cap is 4× too fast for any non-running remote (§2a).
MotionInterpreternever portedget_adjusted_max_speed(0x00527d00) orcurrent_speed_factor; every catch-up-cap call site (RuntimeRemotePhysicsUpdater.cs:254,291,685,LiveEntityMotionRuntimeController.cs:174,PlayerModeController.cs:328) uses the always-×4GetMaxSpeed()instead of the conditional accessor retail's ownfUseAdjustedSpeed_=1static 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. - [MEDIUM] Autorun always inherits the live walk/run toggle instead of always forcing Run (§4).
DispatcherMovementInputSource.cs:71computesRun: !walkingevery poll, unconditioned onAutoRunActive; retail'sApplyCurrentMovementhard-forcesHoldKey.Runfor 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). - [MEDIUM] Autorun does not cancel on a fresh Forward (W) press (§4).
HandlePressedAction's cancel set omitsInputAction.MovementForward; retail'sHandleNewForwardMovementexplicitly 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. - [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. - [LOW] Indoor
AutonomyBlipDistanceuses 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. - [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
MoveOrTeleport96 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 (outsideInterpolationManager.csitself). - [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.
- [INFO, no action] Two of three wire-format oracles have a wrong Jump packet model (§6).
holtburger and Chorizite both omit
Positionfrom 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.mddocs/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 (InterpolationManagercatch-up dispatch), 353261-353344 region, 284887-284967 (JumpPack::Pack/ctor), 698940-699850 (autorun/CommandInterpreterfamily), 699560-699830 (UseTime,ToggleAutoRun,HandleNewForwardMovement,Plane::operator==,CommandInterpreterctor), 699120-699220 (ApplyCurrentMovement,ApplyListHeadMovement), 55424-55520 (CInputManager::TurnOffRunLock/ActivateActionKey), 700233-700420 (ShouldSendPositionEvent,SendMovementEvent,SendPositionEvent,SetAutoRun); plus targeted greps forJumpPack,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.