618 lines
41 KiB
Markdown
618 lines
41 KiB
Markdown
# 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.
|