acdream/docs/research/2026-07-02-inbound-motion-deviation-map.md
Erik a2f8104cbf feat(L.2g-S5): delete player pace-inference layer; close #39 (DEV-2)
S0 wire probe (live capture, retail actor via ACE) refuted the premise
the #39 machinery was built on: walk<->run Shift toggles arrive as
EXPLICIT UMs (0x0005 <-> 0x0007@runRate) because retail's default-
difference packing baselines forward_command against Ready — W-held is
always packed, and ACE re-emits it unconditionally with the holdKey
upgrade (MovementData.cs:104-119). The frequent flags=0 autonomous UMs
are genuine keys-released / heading-only states; retail applies them as
full stops (InterpretedMotionState ctor 0x0051e8d0 defaults Ready +
move_to_interpreted_state 0x005289c0 flat copy).

Retail has NO pace->animation adaptation anywhere in its inbound
pipeline (two decomp dives + ACE cross-check, deviation map DEV-2), and
the refinement layer's 0.2s-grace re-promotion after legitimate Ready
UMs was itself the observed Ready<->Run thrash / rubber-band component.

Deleted: ApplyPlayerLocomotionRefinement, UmGraceSeconds,
PlayerRunPromoteSpeed/PlayerRunDemoteSpeed, RemoteMotion.LastUMTime,
the synth-player refinement call in OnLivePositionUpdated. Player-remote
cycles are now UM-driven only, exactly like retail. NPC PlanFromVelocity
path untouched (S6 unifies).

S0 findings + S1 live validation (0 false UM_STALE drops across 280 UMs)
recorded in docs/research/2026-07-02-inbound-motion-deviation-map.md.

fix #39: closed — root-cause narrative corrected, machinery removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:10:28 +02:00

22 KiB
Raw Blame History

Inbound motion deviation map — remote-entity animation + position vs retail

Date: 2026-07-02 Mode: /investigate report — no code changes made. This doc + the four mapper reports under 2026-07-02-inbound-motion-maps/ are the only writes (uncommitted). Branch: claude/vigorous-joliot-f0c3ad. Follows the handoff 2026-07-02-inbound-motion-verbatim-port-handoff.md.

Symptom (acceptance oracle — user's live observation, axiom)

Watching a remote player in acdream: walk↔run transitions WITHOUT stopping react too slowly in the motion interpreter, throwing animation + position off afterward and compounding through continued running/turning; plus general sliding + position errors on stop.

Headline findings

  1. The premise behind acdream's current walk↔run mechanism is refuted. The #39-era UP-velocity refinement layer (ApplyPlayerLocomotionRefinement, 0.2 s UM-grace + 510 Hz UP cadence + 5.5/4.5 m/s hysteresis) was built on the 2026-05-06 finding "the wire goes silent on Shift-toggle." Both oracles now contradict that finding:
    • Retail sends. CommandInterpreter's HoldRun command handler (0x85000001, pseudo-C acclient_2013_pseudo_c.txt:699322-699328, addr 0x006b37a8→0x006b3852) calls SetHoldRun then SendMovementEvent() whenever the player is not standing still — i.e. a Shift toggle while W is held emits a fresh MoveToState. (Same for HoldSidestep 0x85000002.)
    • ACE relays. GameActionMoveToState.cs:36 calls BroadcastMovement(moveToState) unconditionally for every inbound MoveToState (Player_Networking.cs:364-365GameMessageUpdateMotion broadcast, with the WalkForward+HoldKey.Run→RunForward upgrade and run-skill speed recompute). So an observer should receive a UM with ForwardCommand flipping Walk↔Run on every Shift toggle. Why the 2026-05-06 clean test saw only Ready↔Run [FWD_WIRE] transitions is the one open empirical question — settle it with probe S0 below before touching the refinement layer.
  2. Retail never adapts animation from observed pace. Three independent decomp dives + the ACE port cross-check all converge: the inbound position path (0xF748/0xF619 → SmartBox::HandleReceivedPositionMoveOrTeleportInterpolationManager chase) has zero writes into CMotionInterp — it only reads get_adjusted_max_speed() (×2.0) as the chase-speed cap. The wire's velocity field is a dead parameter in MoveOrTeleport (pseudo-C:284304 — passed, never dereferenced). Animation changes come only from motion events (0xF74C → MovementManager::unpack_movement). acdream's pace→cycle inference layer has no retail equivalent (DEV-2) and is an unregistered deviation.
  3. acdream's instant DR-velocity snap on walk↔run is retail-correct; the pose hard-cut is the real deviation. Adversarial verification refuted the initial "retail paces velocity through the link clip" claim: in GetObjectSequence's link-transition branch, add_motion(new cycle) is the LAST synchronous call and CSequence::set_velocity is a hard overwrite — the sequence velocity snaps to the new cycle's value the same tick (pseudo-C:298437-298468, 300798-300814; ACE MotionTable.cs:144-168, Sequence.cs:127-130). What acdream's Fix B loses is only the link pose (plus its authored root-motion frames): retail plays the authored walk↔run link clip to completion before the new cycle's animation starts.
  4. Retail's remote pipeline is one atomic funnel; acdream's is three loosely coupled writes. That structural split (DEV-1) is what lets every gap (DEV-2/3/5/6) desync pose from velocity with nothing to reconcile them.

The retail inbound model (what "verbatim" means here)

Two decoupled inbound paths, meeting only at the per-tick frame combiner:

Motion events (0xF74C / 0xF619-embedded)ACSmartBox::DispatchSmartBoxEvent (pseudo-C:357117) → CPhysics::SetObjectMovement (271370, staleness-gated on update_times[8]) → MovementManager::unpack_movement (300563, 10-way jump table: case 0 = InterpretedMotionState, 6/7 = MoveTo, 8/9 = TurnToObject/TurnToHeading) → CMotionInterp::move_to_interpreted_state (305936: flat copy_movement_from overwrite of interpreted_state, caches my_run_rate from RunForward's forward_speed) → apply_interpreted_movement (305713: per-slot DoInterpretedMotion/StopInterpretedMotion dispatch in retail order — style, forward, sidestep-or-stop, turn-or-stop, idle-stop enqueue) → CMotionTable::GetObjectSequence (298636), which decides:

  • same substate + same speed sign → fast path: change_cycle_speed (playback rate × newSpeed/oldSpeed) + subtract_motion/combine_motion velocity swap — same node keeps playing, no restart (acdream's SCFAST fast-path is the equivalent);
  • substate change (walk↔run!) → link path: get_link (double-hop through the style default when no direct link / sign flip), clear_physics + remove_cyclic_anims (keeps a mid-playback link, drops the old loop), add_motion(link) + add_motion(cycle), re_modify re-applies active modifiers. Velocity = the cycle's (last overwrite, instant); pose = link first, then cycle. Bookkeeping: MotionTableManager::add_to_queue/remove_redundant_links/ CheckForCompletedMotions (290645-290854) + CMotionInterp::pending_motions/ MotionDone (305032/305238) reconcile completed commands at anim boundaries.

Position events (0xF748/0xF619)SmartBox::HandleReceivedPosition (92896): for remotes, MoveOrTeleport (284304) only — staleness-gated (POSITION_TS/TELEPORT_TS), >96 units from player → plain snap, else InterpolationManager::InterpolateTo node queue (cap 20). Per tick, adjust_offset (353071) moves the full remaining distance capped at 2 × get_adjusted_max_speed() (fallback 7.5 m/s), stall detection (<30% progress per 5 frames → fail counter >3 → hard snap). acdream's InterpolationManager is already a verbatim port of this (L.3).

Per tick (UpdateObjectInternal 283611): CPartArray::UpdateCSequence::update fills the frame delta from the animation frames' baked per-frame deltas (anim-node path) or velocity×dt (apply_physics fallback); PositionManager::adjust_offset overwrites it when a chase node is active (last-writer-wins, NOT additive — matches acdream's ComputeOffset REPLACE dichotomy); then the full collision sweep (transition) runs for remotes too (acdream deliberately skips it for grounded player remotes — DEV-10).

Stop — one path for every stop: CMotionTable::StopSequenceMotion (298954) re-enters GetObjectSequence targeting the style default with force=1: clear_physics zeroes velocity/omega at transition start, the idle-link's authored deceleration plays, add_to_queue(READY) + RemoveMotion bookkeeping. Modifier stop = subtract_motion only. No pace-derived stop inference.

Ranked deviation map

All 10 deviations were adversarially verified through two lenses (refute vs current acdream code / refute vs retail decomp+ACE). 9 CONFIRMED, 1 REFUTED (DEV-4's velocity half — corrected below). Full verify evidence: …/tasks/waqdgyk0m.output (session temp) — the load-bearing citations were additionally re-read first-hand in this session.

# Sev Deviation Symptom link
DEV-1 HIGH Remote inbound bypasses the CMotionInterp funnel: bulk-copy + direct SetCycle instead of move_to_interpreted_state/apply_interpreted_movement Structural root: pose + DR velocity are separate writes; every gap desyncs them with no reconciliation
DEV-2 HIGH Non-retail grace-window + hysteresis pace→cycle inference for player remotes (UmGraceSeconds=0.2, promote 5.5 / demote 4.5 m/s) The direct "reacts too slowly": ≥0.2 s + UP cadence + hysteresis-crossing before the gait flips; backlog builds meanwhile. Unregistered deviation; premise refuted (headline 1)
DEV-3 HIGH Stop is three unwired mechanisms (UM-Ready overwrite / dead UP-velocity StopCompletely / landing); no StopSequenceMotion path; nothing zeroes body velocity or reconciles the queue Direct cause of stop-slide: pose goes Ready while the body drains pre-stop waypoints at up to 2× maxSpeed
DEV-4 MED (corrected) Fix B drops the authored walk↔run link pose (hard visual cut + lost link root motion). Velocity snap — REFUTED: retail snaps velocity instantly too Visual-only hard cut at each toggle; NOT a position-error source
DEV-5 HIGH Inbound movement types 8/9 (TurnToObject/TurnToHeading) dropped → misapplied as SetCycle(Ready) Mid-locomotion turn command becomes a spurious full stop — error injected exactly during "compounding through running/turning"
DEV-6 MED Sequence-number staleness gate missing (movementSequence/serverControlSequence/isAutonomous parsed-then-discarded; retail gates on update_times[8]) A reordered stale UM re-applies the old gait or un-stops a stop (extends register TS-26 to UM)
DEV-7 MED pending_motions/pending_animations/MotionDone/remove_redundant_links lifecycle absent Enabler: missing remove_redundant_links is what made rapid toggles restart links forever, forcing Fix B; without MotionDone nothing clears per-transition bookkeeping
DEV-8 MED Remote DR velocity synthesized from hardcoded gait constants at SetCycle time vs retail's MotionData-accumulated CSequence velocity (+ baked per-frame anim deltas) Any per-gait speed mismatch vs ACE's integration = constant-rate error that fills the interp backlog → discharges as slide/snap
DEV-9 MED Modifier layer missing: no re_modify, single-cycle selection, omega formula-seeded instead of combine/subtract Turn contribution approximated independently of the motion table → heading-rate error while running+turning
DEV-10 LOW Bifurcated position-drive paths; grounded player remotes skip the per-tick collision sweep (retail sweeps every entity) None for this symptom directly; parity + pass-through-geometry hazard

Key anchors per deviation

  • DEV-1 — retail: pseudo-C 305936 (move_to_interpreted_state 0x005289c0), 305713 (apply_interpreted_movement 0x00528600), 298636 (GetObjectSequence 0x00522860), 293301 (copy_movement_from 0x0051e750). acdream: GameWindow.cs:4590/4598 (bulk-copy), :4769 (separate SetCycle), :4517 (comment-only funnel ref); D6.2 raw-input funnel is local-player-only (PlayerMovementController.cs:761-1014). Nuance (verify correction): the sidestep/turn axes DO route through DoInterpretedMotion/StopInterpretedMotion (GameWindow.cs:4794-4857); the forward axis — the crux — is the bulk-copy.
  • DEV-2 — acdream: GameWindow.cs:5095/5104/5110 (constants), :5112-5300 (both methods), :5128-5134 (the unconfirmed-premise comment). Retail: no equivalent exists (searched: InterpolationManager/PositionManager ranges 352000-353400 — zero CMotionInterp writes; MoveOrTeleport velocity param dead at 284304).
  • DEV-3 — retail: 298954 (StopSequenceMotion 0x00522fc0), 305635 (StopInterpretedMotion 0x00528470), 301194 (clear_physics 0x00524d50). acdream: GameWindow.cs:4364-4367 (UM-Ready), :5711-5731 (dead UP-velocity stop — ACE player UPs carry velocity=null per :5700-5710), :5527-5553 (landing = only Interp.Clear site); plus the acdream-invented 300 ms stop-detection window (GameWindow.cs:4862-4890 + TickAnimations) — also retail-less, part of this cluster.
  • DEV-4 — retail: 298636 link branch; 298552 (get_link); 301777 (append_animation); velocity-overwrite refutation: 298437-298468 (add_motion 0x005224b0) + 300798-300814 (set_velocity/set_omega overwrites), ACE MotionTable.cs:144-168 + Sequence.cs:127-130/221-230. acdream: AnimationSequencer.cs:585-635 (Fix B), :686-754 (velocity synthesis — retail-equivalent in timing).
  • DEV-5 — retail: 300563/300707 (jump table cases 8/9 → MoveToManager::TurnToObject/TurnToHeading). acdream: UpdateMotion.cs:136-238 (no case 8/9), GameWindow.cs:4364-4367 (null-command → Ready misfire). Confirmed live earlier: mt=0x09 arrives, acdream plays Ready (2026-06-26 audit D9).
  • DEV-6 — retail: 271370 (SetObjectMovement staleness gate), 357224 (update_times[8] compare). acdream: UpdateMotion.cs:89-106, UpdatePosition.cs:29-31/156 (parsed-then-discarded).
  • DEV-7 — retail: 305238 (MotionDone 0x00527ec0), 290854 (MotionTableManager::add_to_queue 0x0051bfe0), 290771 (remove_redundant_links 0x0051bf20), 290645 (CheckForCompletedMotions). acdream: AnimationSequencer.cs:401 (always-synchronous SetCycle), :1128-1129 (AnimationDoneSentinel exists, unconsumed by any motion bookkeeping).
  • DEV-8 — retail: 298437-298492 (add_motion/combine_motion/ subtract_motion), 302402 (CSequence::update — anim-frame deltas are the primary drive), 305160 (get_state_velocity constants 3.12/4.0 — the logical values acdream synthesizes). acdream: AnimationSequencer.cs:686-754.
  • DEV-9 — retail: 298636 (re_modify at rebuild tail), 298954 (modifier subtract_motion branch), 305713 (per-slot dispatch). acdream: GameWindow.cs:4648-4683 (single-cycle pick), :4841-4847 (ObservedOmega formula seed), :10097-10106 (manual omega integration).
  • DEV-10 — retail: 280817/283611 (sweep for all entities). acdream: GameWindow.cs:9717-9722 (fork), :9883-9890 (sweep skipped, player-remote path), :10156-10181 (sweep present, NPC path), :5657 (NPC UP hard-snap).

Ordered so each slice is independently landable, tests-first, minimal-surgery (no sequencer rewrite; SetCycle internals stay). Every slice updates docs/architecture/retail-divergence-register.md in the same commit.

  • S0 — the wire probe (no code). Live capture, acdream as observer, actor driven from retail: ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1, structured protocol with the walk↔run Shift toggle as the centerpiece; optionally WireMCP on loopback :9000 for the raw S→C bytes. Expected per the oracles: a UM with ForwardCommand flipping Walk↔Run on every toggle ([UM_RAW] is the arbiter — the 2026-05-06 test read [FWD_WIRE], which only fires on interpreted-command change after acdream's own resolution). Outcome decides DEV-2's disposition. (Needs the user driving the retail client — this is the one stop-and-wait step.)
  • S1 — DEV-6 staleness gate. ~30 isolated lines: capture the two u16s into UpdateMotion.Parsed, retail wraparound-newer compare per guid, drop stale. Unit-testable against the 0x7fff wraparound rule. Safe first commit.
  • S2 — DEV-1 funnel (the centerpiece), absorbing DEV-3 + DEV-9 dispatch. Port move_to_interpreted_state + apply_interpreted_movement into MotionInterpreter, with DoInterpretedMotion routing to the existing AnimationSequencer.SetCycle as the GetObjectSequence backend. OnLiveMotionUpdated's remote SubState branch collapses to one move_to_interpreted_state(ims) call; stop flows through StopInterpretedMotion (no Interp.Clear() band-aid — the queue's final waypoints ARE the true stop position); sidestep/turn become per-slot retail dispatch. Parity test first: replay a captured UM stream through old + new paths, assert identical SetCycle sequence + InterpretedState trace; then a stop-slide acceptance case.
  • S3 — DEV-7 bookkeeping. MotionTableManager companion (pending_animations, add_to_queue, remove_redundant_links, CheckForCompletedMotions) wiring the existing AnimationDoneSentinel; pending_motions/MotionDone in MotionInterpreter second. Additive, decomp-semantics unit tests first.
  • S4 — DEV-4 corrected: retire Fix B. Restore the link-pose enqueue for locomotion↔locomotion now that S3's remove_redundant_links removes Fix B's original justification (link-restart spam). Velocity stays instant (retail-correct). Acceptance: user visual side-by-side.
  • S5 — DEV-2 disposition per S0. If UMs arrive: delete the refinement layer (dead weight + latency + feedback loop) and let the S2 funnel drive. If the wire is genuinely silent: keep a minimal layer as an explicitly registered ACE-adaptation row (a retail client on the same wire would show the stale gait too — matching retail display means NOT inferring), and file the gap against ACE. Either way DEV-2's machinery stops being an unregistered deviation.
  • S6 — remainder. DEV-5 (parse types 8/9 → heading target; cross-check field order vs Chorizite + holtburger first), DEV-8 (evidence first: dump Humanoid MotionTable Walk/Run MotionData.Velocity+Flags from the dat, diff synthesized magnitudes vs ACE's integration), DEV-10 deferred (fold-in after S2; NPC hard-snap replacement needs explicit user approval per the reverted-campaign precedent).

What we've ruled out

  • "acdream lacks any transition machinery" — false; SetCycle resolves authored links (GetLink) and has the retail same-substate fast path (SCFAST ≈ change_cycle_speed).
  • "Retail paces the walk↔run velocity change through the link clip" — refuted (headline 3); instant velocity snap is retail-correct.
  • "Retail refines the remote's cycle from UP-derived velocity" (#39's premise) — refuted; no such mechanism exists in the decomp (two independent dives + ACE cross-check).
  • The InterpolationManager chase itself — already a verbatim port (L.3); constants re-verified this session (2× adjusted max speed, 0.05 desired distance, 30%/5-frame stall, fail>3 blip, 20-node cap, 96-unit gate upstream).

What this is NOT

This is NOT a "port the whole CSequence/CPartArray stack" campaign — the sequencer's node/link/queue mechanics are close enough that the funnel (DEV-1), bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically; and it is NOT a dead-reckoning-tuning problem — the chase is already retail, it's the inputs (cycle + velocity source + stop signal) that deviate.

S0 wire-probe RESULTS (2026-07-02, post-S1 live capture)

Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured protocol incl. Shift toggles. 280 UMs captured (launch-s0-wireprobe.log).

  1. The wire is NOT silent — DEV-2's premise is dead. Walk↔run toggles arrive explicitly: fwd=0x0005 fwdSpd=null(=1.0)fwd=0x0007 fwdSpd=2.85. Root cause of the 2026-05-06 misreading: retail's default-difference packing baselines forward_command against Ready (RawMotionState.Default, our own D6.2b verbatim port) — W-held is non-default and always packed, so ACE (MovementData.cs:104-119) always re-emits it with the holdKey upgrade + run-skill speed.
  2. flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE — they are genuine keys-released / heading-only MoveToState relays (all-default raw state packs nothing; ACE RawMotionState.Read leaves absent = Invalid; MovementData emits nothing). Retail semantics (verified: InterpretedMotionState ctor 0x0051e8d0 defaults forward_command=Ready; MovementManager::unpack_movement case 0 UnPacks into a default-constructed state; CMotionInterp::move_to_interpreted_state 0x005289c0 does a FLAT copy_movement_from + apply_current_movement): an empty UM = a real full stop. acdream already maps it to Ready — but the DEV-2 refinement then re-promotes the cycle from UP pace after the 0.2 s grace, producing a Ready↔Run thrash against legitimate stop signals. That thrash is the observed flicker/rubber-band component.
  3. S1 gate validated live: 0 [UM_STALE] drops across 280 UMs; movementSeq advances +1 per UM; byte-level layout confirmed (instanceSeq|movementSeq|serverControlSeq|isAutonomous exactly where the parser reads them).
  4. Also confirmed in unpack_movement (0x00524440) for the S2 port: the outer style u16 routes through command_ids[] and applies via DoMotion ONLY when it differs from the current style; motionFlags carries sticky-object (0x100 → stick_to_object) and standing_longjump (0x200); the actions list is consumed under a 15-bit server_action_stamp wraparound compare (move_to_interpreted_state 0x005289c0, lines 305953-305989).

Disposition: S5 = delete the player pace-inference layer (executed in the same session; NPC PlanFromVelocity path untouched — its unification is S6).

Provenance

  • Workflow wf_b92a5670-283: 4 read-only mappers (retail decomp / ACE port / acdream code / prior-research claims — full reports preserved in 2026-07-02-inbound-motion-maps/) → synthesis → 2-lens adversarial verify per deviation (25 agents, ~3.0 M tokens). The ACE-port mapper crashed on structured output AFTER writing its report file; synthesis read all four files. One verify lens (DEV-4 retail side) delivered the REFUTED verdict incorporated above.
  • Targeted agents: retail remote-anim adaptation dive (+2 children: InterpolationManager decomp dump, ACE InterpolationManager cross-check).
  • First-hand session reads: OnLiveMotionUpdated (GameWindow 4230-4930), DR tick path (9700-9920), AnimationSequencer.SetCycle (340-790), ApplyServerControlledVelocityCycle/ApplyPlayerLocomotionRefinement (5077-5300), ServerControlledLocomotion, PositionManager, InterpolationManager, UpdatePosition parser, ACE Player_Tick.cs / Player_Networking.cs / GameActionMoveToState.cs, decomp SendMovementEvent/HoldRun sites (699274-700312), 2026-06-04 deep-dive divergence list, ISSUES #39, divergence register scan.
  • Corrections applied to the raw workflow output: DEV-1 anchor fix (MotionInterpreter.cs:399/412/558 reference different retail functions — the only funnel comment-refs are GameWindow.cs:4517 + PlayerMovementController.cs:593); DEV-4 rewritten per its refutation.