docs(L.2g): inbound motion deviation map + campaign registration

/investigate deliverable for the inbound (remote-entity) animation+position
retail-parity effort. 10 deviations (DEV-1..10) mapped and adversarially
verified against the named retail decomp + ACE port + current code (9
confirmed, 1 refuted-and-corrected).

Headline: the #39-era UP-pace->cycle inference layer's premise ('wire goes
silent on Shift toggle') is refuted at both oracles — retail sends a fresh
MoveToState on HoldRun toggle while moving (0x006b37a8) and ACE rebroadcasts
every MoveToState unconditionally (GameActionMoveToState.cs:36); retail has
NO pace->animation adaptation anywhere (position error is absorbed solely by
the InterpolationManager chase, already ported verbatim in L.3).

Registers sub-lane L.2g in the roadmap: port the CMotionInterp inbound funnel
verbatim for all remote entity classes, slices S0-S6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 15:20:46 +02:00
parent b60b9b4a21
commit fb3ee0544a
6 changed files with 2174 additions and 0 deletions

View file

@ -697,6 +697,18 @@ diagnostic scaffolding, not yet the final collision system.
tests to real-world fixtures and verify local acdream view plus retail
observer view. ACE accepting a position is a compatibility check, not proof
of fine-grained retail collision parity.
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
remote entities (players, NPCs, monsters — one funnel, user-approved):
`move_to_interpreted_state` (0x005289c0) + `apply_interpreted_movement`
(0x00528600) + `StopInterpretedMotion` stop path + `MotionTableManager`
bookkeeping (`remove_redundant_links` / `CheckForCompletedMotions` /
`MotionDone`), delete the non-retail UP-pace→cycle inference layer, restore
the walk↔run link pose. Slice order S0S6 + full deviation map (DEV-1..10,
adversarially verified): `docs/research/2026-07-02-inbound-motion-deviation-map.md`.
Acceptance: walk↔run toggle on an observed remote reacts at wire latency with
no compounding animation/position desync; no stop-slide; user visual
side-by-side vs retail observer.
**Shipped (2026-06-30) — L.2b outbound wire parity (decomp-verbatim, tests-first):**
- **D1**`MoveToState` now packs `RawMotionState` by retail default-difference

View file

@ -0,0 +1,283 @@
# 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-365``GameMessageUpdateMotion`
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::HandleReceivedPosition``MoveOrTeleport`
`InterpolationManager` 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::Update`
`CSequence::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).
## Recommended port campaign (for approval — no code written yet)
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.
## 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.

View file

@ -0,0 +1,208 @@
# acdream CURRENT inbound remote-entity motion/animation pipeline — as-is map
Repo: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
Scope: REPORT-ONLY. Describes what the code does TODAY, not what it should do.
---
## 0. File inventory (all line numbers verified against current worktree)
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (333 lines) — inbound 0xF74C parser.
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs` (177 lines) — inbound 0xF748 parser.
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (108 lines) — **OUTBOUND ONLY** (0xF61C, client→server). Not part of the inbound path; included by the task's starting-point list but has no inbound role.
- `src/AcDream.App/Rendering/GameWindow.cs` (13,759 lines) — `OnLiveMotionUpdated` (42304967), `OnLivePositionUpdated` (5334~5773), `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` (51125333), `TickAnimations` (9673~10267+), `RemoteMotion` class (401~592).
- `src/AcDream.Core/Physics/AnimationSequencer.cs` (1583 lines) — `SetCycle` (401788), `Advance` (862957), helpers.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` (1255 lines) — `DoInterpretedMotion`/`StopInterpretedMotion`/`StopCompletely`/`get_state_velocity`/`adjust_motion`/`apply_raw_movement`/`apply_current_movement`/`GetMaxSpeed`.
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines) — per-tick steering for MoveToObject/MoveToPosition (movementType 6/7) remotes.
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines) — velocity→cycle classifier for non-player remotes (`PlanFromVelocity`) and MoveTo-start seed (`PlanMoveToStart`).
- `src/AcDream.Core/Physics/InterpolationManager.cs` (389 lines) — retail `CPhysicsObj` position-waypoint queue port (FIFO cap 20, catch-up, stall detection, blip-to-tail).
- `src/AcDream.Core/Physics/PositionManager.cs` (108 lines) — per-frame REPLACE combiner (queue-catchup vs anim-root-motion), used only for grounded, non-airborne PLAYER remotes.
**No `pending_motions` / `MotionDone` lifecycle exists anywhere in the codebase.** `grep -rn "pending_motion|MotionDone|PendingMotion" src/` returns zero matches. Confirmed as a genuine gap (matches the animation-sequencer-deep-dive research doc's "missing pending_motions HIGH" finding).
---
## Q1 — INBOUND ENTRY: wire → motion interpreter, for a REMOTE object
1. **Wire parse.** `AcDream.Core.Net.Messages.UpdateMotion.TryParse` (`UpdateMotion.cs:70-253`) decodes opcode `0xF74C` into `Parsed(uint Guid, CreateObject.ServerMotionState MotionState)`. Extracts: `Guid`, `currentStyle` (Stance), and — when `movementType == 0` (InterpretedMotionState) — optional `ForwardCommand`/`ForwardSpeed`/`SideStepCommand`/`SideStepSpeed`/`TurnCommand`/`TurnSpeed` plus a `Commands` list (actions/emotes), gated by a 7-bit flags dword (`UpdateMotion.cs:145-226`). When `movementType is 6 or 7` (MoveToObject/MoveToPosition), it instead parses a `MoveToPathData` payload via `TryParseMoveToPayload` (`UpdateMotion.cs:255-331`): target guid (type 6 only), Origin cell+xyz, `MovementParameters` dword, distance/min/fail distances, speed, walkRunThreshold, desiredHeading, runRate.
- **Fields parsed then discarded / not used for pose**: `instanceSequence` (u16, `UpdateMotion.cs:85-87`, "tracked but not used for pose" — actually not even stored, just skipped), `movementSequence`+`serverControlSequence` (u16+u16 inside the 6-byte MovementData header, `UpdateMotion.cs:89-106`, skipped wholesale via `pos += 6`), `isAutonomous` (u8, same 6-byte skip — never separately read), `_motionFlags` (u8, read into a local but only used for the diagnostic dump at `UpdateMotion.cs:111,115-122`, otherwise discarded), `distanceToObject`/`failDistance`/`walkRunThreshold`/`desiredHeading` inside `TryParseMoveToPayload` (parsed at `UpdateMotion.cs:305-306,309,313,315` into locals, never placed on `MoveToPathData` — only `distanceToObject`(as `DistanceToObject`), `minDistance`, `failDistance`(discarded — not passed to the record), `walkRunThreshold` and `desiredHeading` ARE passed to `MoveToPathData` ctor at `UpdateMotion.cs:319-329` but `MoveToPathData` doesn't expose `FailDistance` in the earlier local list — check record fields separately if exact retention matters).
- Similarly, `UpdatePosition.TryParse` (`UpdatePosition.cs:77-175`) decodes 0xF748 into guid, `ServerPosition` (cellId+xyz+quat), optional `Velocity`, optional `PlacementId`, `IsGrounded` flag, and three of four trailing u16 sequence numbers (`instSeq`, `teleSeq`, `forceSeq` — the second one, "positionSequence", is explicitly skipped: `UpdatePosition.cs:156` comment "not tracked by movement"). None of `instSeq`/`teleSeq`/`forceSeq` are consulted anywhere downstream for freshness/ordering (comment at `UpdatePosition.cs:29-31`: "We don't currently check these for freshness but we must consume them to walk the buffer correctly").
2. **Session dispatch → GameWindow event.** (Not directly inspected this pass, but referenced via `_liveSession.MotionUpdated += OnLiveMotionUpdated;` at `GameWindow.cs:2633`.) The parsed `UpdateMotion.Parsed` becomes an `AcDream.Core.Net.WorldSession.EntityMotionUpdate` and fires the `MotionUpdated` event.
3. **`GameWindow.OnLiveMotionUpdated`** (`GameWindow.cs:4230`) is the sole consumer.
- Early-outs: `_dats is null` (4232), entity not found by `update.Guid` in `_entitiesByServerGuid` (4233), or no `AnimatedEntity` for it in `_animatedEntities` (4234).
- Stamps `rmStateForUm.LastUMTime` on the `_remoteDeadReckon[update.Guid]` entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 / `UmGraceSeconds`).
- Reads `stance = update.MotionState.Stance` and `command = update.MotionState.ForwardCommand` (nullable) (4259-4260).
- **Sequencer path** (the only path exercised in practice — `ae.Sequencer is not null` at 4327) reconstructs a full 32-bit `fullMotion` command from the wire's 16-bit `ForwardCommand` via `MotionCommandResolver.ReconstructFullCommand` (4372-4378), OR seeds a MoveTo-derived motion via `ServerControlledLocomotion.PlanMoveToStart` when `IsServerControlledMoveTo` and no forward command (4356-4363), OR falls back to `0x41000003` (Ready) when the command is null/0 and not a MoveTo (4364-4367 — "retail stop signal").
- **Branches on `update.Guid == _playerServerGuid`**: the LOCAL player's own echoed UM is NOT applied to the sequencer (comment 4401-4416: "UpdatePlayerAnimation is the authoritative driver ... skip the wire-echo SetCycle"). Everything from here on (Q2-Q7) describes the REMOTE (`else` branch at 4493) path.
- For a genuine remote: bulk-copies `fullMotion`/`speedMod` into `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` UNCONDITIONALLY (4572-4598) — this is the direct feed into `MotionInterpreter.get_state_velocity()` (Q5). Then classifies via `AnimationCommandRouter.Classify(fullMotion)` into `forwardIsOverlay` (Action/Modifier/ChatEmote — routed via `RouteFullCommand`, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637 `else` block — this is Q2/Q3/Q4's territory).
---
## Q2 — TRANSITION: walk↔run while already moving (no stop)
Frame-by-frame, for the SubState (non-overlay) branch at `GameWindow.cs:4637-4859`:
1. **Cycle selection** (4648-4683): `animCycle = fullMotion`, `animSpeed = speedMod` by default. If the forward command is NOT run/walk/walkback (`fwdIsRunWalk` false), fall back to sidestep or turn cycle. **For a plain walk↔run toggle, `fullMotion` IS run/walk, so this stays the forward cycle** — no fallback triggered.
2. **Airborne guard** (4696): if `remoteIsAirborne`, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle.
3. **Cycle-existence fallback chain** (4728-4757): if the MotionTable lacks `(fullStyle, cycleToPlay)`, falls back RunForward→WalkForward→Ready→(no-op, leave sequencer alone). Only matters if the creature's table is missing the target cycle; for the player Humanoid table both Walk and Run exist.
4. **`ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)`** is called (4769) — this is the entry into `AnimationSequencer.SetCycle` (`AnimationSequencer.cs:401`).
### Inside `SetCycle` (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk:
- `adjust_motion` remap (407-423): TurnLeft/SideStepLeft/WalkBackward get mirrored to their positive counterpart with negated speed. RunForward(0x07)/WalkForward(0x05) pass through unchanged (`adjustedMotion = motion`).
- **Fast-path check** (440-468): fires only if `CurrentStyle == style && CurrentMotion == motion` (same exact motion command) AND sign of speedMod matches. **On an actual Walk→Run toggle, `CurrentMotion` (0x45000005 WalkForward) != `motion` (0x44000007 RunForward), so the fast path does NOT fire** — this is a FULL REBUILD, not a framerate-only rescale. (The fast path only fires when the SAME command re-arrives with a different speed, e.g. a run-rate echo while already running.)
- **Full-rebuild path** (470-788):
- `GetLink(style, CurrentMotion=WalkForward, CurrentSpeedMod, adjustedMotion=RunForward, adjustedSpeed)` looks up a transition-link MotionData from the MotionTable's `Links` dict (470-480, method at 1159-1203). Both speeds positive here, so it uses the forward-direction lookup: `Links[(style<<16)|WalkForward][RunForward]`.
- `ClearCyclicTail()` (511) — removes the OLD cycle's looping node(s) from the queue; non-cyclic (link) frames not yet played are left alone.
- `ClearPhysics()` (529) — zeroes `CurrentVelocity`/`CurrentOmega` before rebuild.
- Enqueues the link's MotionData (non-looping) THEN the new cycle's MotionData (looping) via `EnqueueMotionData` (547-554).
- **Direct cyclic→cyclic transition special-case** (585-635, "Fix B" 2026-05-06): if BOTH the previous motion (WalkForward) AND the new motion (RunForward) are recognized locomotion low-bytes (`IsLocomotionCycleLowByte`, checks against 0x05/0x06/0x07/0x0F/0x10 — `AnimationSequencer.cs:1509-1513`), the code DROPS the just-enqueued transition link from the queue entirely (629-632) and forces `_currNode = _firstCyclic` directly onto the new RunForward cycle (633-634), skipping the link animation and its frame-position start entirely. This is explicitly a divergence from the general link-transition mechanism, justified by a live cdb trace (609-618 comment) showing retail's `add_to_queue` sequence for a Walk→Run direct transition never fires `truncate_animation_list` and just appends the new cycle directly — i.e., **acdream's current behavior for walk↔run is: NO visible link/blend animation, instant hard-cut cycle swap to the new cycle's start frame** (`_framePosition = _firstCyclic.Value.GetStartFramePosition()`, 634).
- If NOT both locomotion (e.g. Ready→WalkForward), the link IS played (`_currNode = firstNew` at 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run.
- `CurrentStyle`/`CurrentMotion`/`CurrentSpeedMod` updated (682-684).
- **Velocity synthesis** (686-754): because retail's Humanoid MotionData ships `Flags=0x00` (no `HasVelocity`) for locomotion cycles, `CurrentVelocity` is unconditionally re-synthesized here from hardcoded constants (`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f` — 793-795) times `adjustedSpeed`, based on the NEW motion's low byte. **This happens regardless of whether the link or the cycle is what's currently playing** — i.e. `CurrentVelocity` snaps to the new cycle's steady-state speed the instant `SetCycle` returns, even in the general (non-locomotion-direct) case where a link animation is still visually playing.
- Omega synthesis (756-787) similarly, only for turn commands (not relevant to walk/run).
### Frame-by-frame summary for Walk→Run toggle while moving:
- **Frame N (UM arrives)**: `OnLiveMotionUpdated` fires → `SetCycle(style, RunForward, speedMod)` called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped, `_currNode` snapped directly to Run cycle's start frame, `CurrentVelocity` synthesized to `RunAnimSpeed × speedMod` — ALL of this happens on the SAME UM-processing call, before the next `Advance()`/tick.
- **Frame N (same tick, later in loop)**: `TickAnimations` (`GameWindow.cs:9673`) runs later that frame (or next frame depending on event/tick ordering) and calls `Advance(dt)` on the sequencer, which now plays from the Run cycle's first frame — no partial-frame blend from the old Walk pose.
- **Is there blending?** NO cross-fade/blend for the walk↔run case specifically — only the (skipped) link animation would have provided a transitional pose, and it's explicitly dropped for this case.
- **Is the new motion appended/queued, or does it replace?** It REPLACES: `ClearCyclicTail()` always removes the old cycle before the new one is enqueued (511), and for the walk↔run case the link itself is also stripped from the queue (629-632) so only the new cycle remains.
- **Speed**: `CurrentVelocity`/animation framerate both change immediately and atomically inside `SetCycle` — no interpolation of speed either (the fast-path's `MultiplyCyclicFramerate` smooth-rescale only applies when `CurrentMotion` is unchanged, i.e. NOT for an actual Walk↔Run motion-command swap, only for a same-command speed update).
---
## Q3 — PENDING/DONE (pending_motions + MotionDone lifecycle)
**NONE.** There is no `pending_motions` queue and no `MotionDone` callback/event anywhere in the codebase (confirmed via `grep -rn "pending_motion|MotionDone|PendingMotion" src/` — zero hits).
What DOES exist that's adjacent:
- `AnimationSequencer._queue` (a `LinkedList<AnimNode>`, `AnimationSequencer.cs:297`) holds link-then-cycle nodes, but it is not a "pending motion command" queue in the retail sense — it's purely an animation-frame playback queue (link frames + one looping tail node), rebuilt wholesale on every `SetCycle` call. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") — `SetCycle` always either fast-paths (same command) or fully rebuilds (different command) synchronously the instant the wire event arrives.
- `AnimationSequencer.ConsumePendingHooks()` (964-972) exists and drains `_pendingHooks`, a `List<AnimationHook>` accumulated during `Advance()` when integer frame boundaries are crossed (`Advance`, 862-957, calls `ExecuteHooks` at 913/939). This is a HOOK dispatch mechanism (audio/VFX/damage triggers baked into animation frames), NOT a motion-command completion signal.
- `AnimationDoneSentinel` (`AnimationSequencer.cs:1128-1129`, a static `AnimationHookDir.Both` hook) is added to `_pendingHooks` when a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, inside `Advance`). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME `_pendingHooks` list as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail's `pending_motions`/`MotionDone` state machine would (e.g. to know when to pop the next queued attack, or to notify the wire layer that a swing animation finished so the next command can be sent).
- **Consequence for callers**: nobody appends anything to a "next motion" queue and nobody pops one on completion. Every UM (or per-tick refinement call) calls `SetCycle`/`DoInterpretedMotion` directly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification in `OnLiveMotionUpdated` (4626 vs 4637) and (b) `SetCycle`'s own state (`CurrentMotion`/`CurrentStyle` comparison) — there's no notion of "motion command A is still pending completion, defer motion command B."
---
## Q4 — CYCLE SWAP mechanics: does frame index carry over?
Governed entirely by `AnimationSequencer.SetCycle` (see Q2 for the walk→run specific path) and `AdvanceToNextAnimation` (`AnimationSequencer.cs:1344-1364`):
- **General case (non-locomotion-direct, e.g. Ready→WalkForward)**: `_currNode` is set to the FIRST NEWLY-ENQUEUED node (`firstNew`, 583, 636-640) — i.e. the link animation, if one exists, plays from ITS `GetStartFramePosition()` (583+639: `_framePosition = _currNode.Value.GetStartFramePosition()`), which for positive framerate is `StartFrame` (frame 0 of the link) — **frame index does NOT carry over from the old cycle; it restarts at the link's (or cycle's, if no link) start frame.** When the link naturally exhausts during subsequent `Advance()` calls, `AdvanceToNextAnimation()` (1344) walks to `_currNode.Next`, or if null, wraps to `_firstCyclic` — again restarting `_framePosition` at `GetStartFramePosition()` of that new node (1362-1363).
- **Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case, `AnimationSequencer.cs:619-635`)**: `_currNode` is forced DIRECTLY onto `_firstCyclic` (the new cycle's looping node), with `_framePosition = _firstCyclic.Value.GetStartFramePosition()` (634) — i.e. **frame index is reset to 0 (or `EndFrame` for negative-speed cycles) of the NEW cycle, NOT carried over from the OLD cycle's current frame position.** There is no "keep the current phase, just swap animation data" — every `SetCycle` transition, link or no link, hard-resets `_framePosition` to a start boundary of whichever node becomes current.
- **No "link animation between the two loops" mechanism for locomotion-direct swaps** — the whole point of the "Fix B" branch is to SKIP the link (i.e. skip the transitional pose entirely) for cyclic→cyclic transitions, per the cdb-trace-derived retail-matching behavior documented in the comment (609-618).
- Net: cycle swap between two looping cycles is an **instant hard-cut with no phase continuity and (for locomotion-direct transitions) no transitional/link animation.** This differs from a "true" retail per-limb blend if such a thing exists in the more complete engine — acdream's sequencer is a single active `AnimNode` playback engine, not a blend-tree.
---
## Q5 — POSITION DRIVE between inbound position packets
Two structurally DIFFERENT paths exist, selected in `TickAnimations` (`GameWindow.cs:9717-9722`) by:
```csharp
if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
{
if (IsPlayerGuid(serverGuid) && !rm.Airborne) // ← Path A: grounded PLAYER remotes
else // ← Path B: NPCs + airborne player remotes (legacy)
}
```
### Path A — grounded player remotes (`GameWindow.cs:9722-9920`)
Uses `PositionManager.ComputeOffset` (`PositionManager.cs:51-107`), which is explicitly a **REPLACE, not additive** combiner:
1. `InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed)` (called at `PositionManager.cs:84`) is tried FIRST.
- If the queue has waypoints AND the body is farther than `DesiredDistance` (0.05 m, `InterpolationManager.cs:79`) from the head waypoint, it returns a non-zero "catch-up" delta: `direction × min(catchUpSpeed × dt, dist)`, where `catchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0)` or a `MaxInterpolatedVelocity` (7.5 m/s) fallback (`InterpolationManager.cs:262-362`). **This REPLACES animation root motion entirely for that frame** (`PositionManager.cs:84-86`: `if (correction.LengthSquared() > 0f) return correction;`).
- If the queue is empty OR the head has been reached (dist ≤ 0.05m, which pops the head via `NodeCompleted`), `AdjustOffset` returns `Vector3.Zero`, and `PositionManager.ComputeOffset` falls through to animation root motion: `seqVel × dt`, rotated by body orientation, optionally slope-projected (`PositionManager.cs:88-106`).
2. `seqVel` is `ae.Sequencer.CurrentVelocity` (body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOT `MotionInterpreter.get_state_velocity()`.
3. `omegaToApply` is `rm.ObservedOmega` (seeded formulaically from wire TurnCommand+TurnSpeed in `OnLiveMotionUpdated`, `GameWindow.cs:4841-4847`) preferred over `seqOmega` (`GameWindow.cs:9842-9845`).
4. `rm.Body.calc_acceleration()` + `rm.Body.UpdatePhysicsInternal(dt)` still run (9878, 9881) for gravity/vertical integration, but `ResolveWithTransition` (collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position").
5. **Dominance**: for grounded player remotes, the InterpolationManager queue DOMINATES whenever it's non-empty and the body hasn't caught up; the sequencer's `CurrentVelocity` (animation root motion) only drives position when the queue is idle/caught-up.
### Path B — NPCs and airborne player remotes (legacy path, `GameWindow.cs:9933-10267+`)
This is the ORIGINAL / "unchanged until Task 8 cleanup" path (comment 9923):
1. `rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false)` (10074, the general branch) — calls `MotionInterpreter.get_state_velocity()` (`MotionInterpreter.cs:639-688`), which reads `InterpretedState.ForwardCommand`/`ForwardSpeed` (bulk-copied from the wire in `OnLiveMotionUpdated`, Q1/Q2) and returns `WalkAnimSpeed/RunAnimSpeed × ForwardSpeed` UNLESS the sequencer's `CurrentVelocity.Y` is non-zero (`GetCycleVelocity` accessor, wired — see below), in which case it prefers that (`MotionInterpreter.cs:653-668`, "Option B — MotionData-sourced forward velocity"). Result is written directly to `PhysicsBody.set_local_velocity` via `apply_current_movement` (`MotionInterpreter.cs:905-909`, gated on `PhysicsObj.OnWalkable`).
2. Alternative sub-branches at this same call site (`GameWindow.cs:9963-10076`) select DIFFERENT velocity sources depending on state:
- If `rm.HasServerVelocity` and NOT a player guid (9968): uses `rm.ServerVelocity` (synthesized from consecutive UP position deltas, or wire `Velocity` field when present) directly as `rm.Body.Velocity` — BYPASSING `apply_current_movement`/`get_state_velocity` entirely — UNLESS the velocity is stale (`velocityAge > ServerControlledVelocityStaleSeconds = 0.60s`, 926), in which case it's zeroed and `ApplyServerControlledVelocityCycle` is invoked to reset the cycle to Ready.
- If `rm.ServerMoveToActive && rm.HasMoveToDestination` (9987): uses `RemoteMoveToDriver.Drive` to steer orientation, then `apply_current_movement` for velocity magnitude (10042), clamped via `RemoteMoveToDriver.ClampApproachVelocity` (10052-10059) to avoid overshoot.
- Else (general fallback, 10074): `apply_current_movement` as described in step 1.
3. Manual per-tick rotation integration (`GameWindow.cs:10097-10106`) using `rm.ObservedOmega`.
4. `rm.Body.UpdatePhysicsInternal(dt)` (10128) — Euler integration.
5. **Collision sweep IS run for Path B** (`_physicsEngine.ResolveWithTransition`, 10156-10181) — unlike Path A, NPCs/airborne players DO get a client-side sweep between server updates (with a fixed sphere radius/height, step-up/down heights).
### Which dominates?
- Path A (player remotes, grounded): InterpolationManager queue dominates when active; sequencer's synthesized `CurrentVelocity` (animation root motion) is the fallback when the queue is idle.
- Path B (NPCs / airborne players): `MotionInterpreter.get_state_velocity()` (fed by `InterpretedState.ForwardCommand/ForwardSpeed` set from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer's `CurrentVelocity.Y` is non-zero, `get_state_velocity` PREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (`MotionInterpreter.cs:646-668` "Option B"). Wire-synthesized `ServerVelocity` from position deltas takes priority over both when present and fresh.
- **Neither `apply_raw_movement` nor the raw `adjust_motion`(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path** (confirmed via `grep -rn "apply_raw_movement|get_state_velocity()"` across `src/` outside `MotionInterpreter.cs` — only `src/AcDream.App/Input/PlayerMovementController.cs` calls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2 `apply_raw_movement`/`adjust_motion` port (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead get `InterpretedState.ForwardCommand`/`ForwardSpeed` bulk-copied directly (mirroring retail's `copy_movement_from`, per the comment at `GameWindow.cs:4536-4544,4552-4558`), bypassing `adjust_motion`'s left→right/backward-forward normalization and run-hold-key promotion — that normalization already happened SERVER-SIDE (ACE) before the wire command arrived, per the code comments.
---
## Q6 — CORRECTION (how inbound UpdatePosition corrects accumulated error)
Handled in `OnLivePositionUpdated` (`GameWindow.cs:5334-~5773`). The correction strategy differs by whether the remote is a PLAYER or not, and whether it's airborne:
### Player remotes (`IsPlayerGuid(update.Guid)` branch, `GameWindow.cs:5460-5635`)
Ports retail's `CPhysicsObj::MoveOrTeleport` (0x00516330):
1. **Airborne no-op** (5512-5521): if `!update.IsGrounded`, body position/velocity are NOT touched by the UP at all — gravity integration continues locally; only the render entity is synced to the current body position (undoing the unconditional top-of-function hard-snap).
2. **Landing transition** (5527-5553): first grounded UP after airborne clears `Airborne`, zeroes velocity, hard-snaps `rmState.Body.Position = worldPos` directly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whatever `InterpretedState.ForwardCommand` implies.
3. **Grounded routing** (5555-5581): distance check against the LOCAL player's position (`MaxPhysicsDistance = 96f`, 5556):
- `dist > 96`: hard `SetPositionSimple`-style snap — `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` (no smoothing at all).
- `dist <= 96`: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo:false, currentBodyPosition:rmState.Body.Position)` — feeds `InterpolationManager.Enqueue` (`InterpolationManager.cs:170-231`), which itself has three sub-branches:
- **Far branch** (dist from last-tail/body-position to new target > `AutonomyBlipDistance = 100m`): enqueues AND pre-arms an immediate blip (`_failCount = StallFailCountThreshold + 1 = 4`) so the very next `AdjustOffset` call snaps straight to the tail.
- **Already-close branch** (body-to-target ≤ `DesiredDistance = 0.05m`): wipes the queue entirely, no enqueue (`Clear()`).
- **Near/not-close branch**: tail-prune loop collapses consecutive stale entries within 0.05m of the new target, caps queue at 20 (drops head if full), then appends.
- Every-tick catch-up happens later in `TickAnimations` Path A via `PositionManager.ComputeOffset``InterpolationManager.AdjustOffset` (Q5). There is a secondary "stall" detector inside `AdjustOffset` (`InterpolationManager.cs:292-337`): every 5 frames it checks cumulative progress against `MinDistanceToReachPosition = 0.20m` and a secondary ratio check (`cumulative/progressQuantum/dt >= 0.30`); repeated stall failures (`_failCount > StallFailCountThreshold = 3`) trigger a hard SNAP to the tail waypoint (`InterpolationManager.cs:345-350`) and clear the queue.
4. **Velocity-fallback cycle refinement** (5582-5625): synthesizes velocity from consecutive server positions (`(worldPos - PrevServerPos) / dtPos`) and feeds `ApplyServerControlledVelocityCycle``ApplyPlayerLocomotionRefinement` (Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle.
5. `entity.SetPosition(rmState.Body.Position)` at the end (5633) — the render entity always mirrors the body, never `worldPos` directly, for player remotes within the near-branch (queue chase still in progress).
### Non-player remotes (`GameWindow.cs:5637-5767`)
NO InterpolationManager/PositionManager usage at all — this is the older "legacy" correction:
1. `serverVelocity` sourced from wire `update.Velocity` if present, else synthesized from position delta if `rmState.LastServerPosTime > 0` (5638-5646).
2. `rmState.Body.Position = worldPos`**UNCONDITIONAL HARD SNAP, every single UpdatePosition, no soft-lerp, no distance check** (5657). Comment at 5679-5682 confirms: "Retail authoritatively hard-snaps cell membership here too."
3. `rmState.Body.Orientation = rot` — also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seeded `ObservedOmega`, not from UP deltas — an earlier attempt to derive omega from UP deltas caused a "halved observed rate" bug on the first UP after a turn).
4. `rmState.Body.Velocity = svel` if wire `Velocity` present (5711-5713); **`svel.LengthSquared() < 0.04f` is treated as an explicit stop signal** — triggers `rmState.Motion.StopCompletely()` (Q7) PLUS a direct `SetCycle` to Ready (5714-5731). This is the ONLY place `StopCompletely()` is called anywhere in the inbound path.
5. Between UPs (Path B in `TickAnimations`), NPCs get a client-side `ResolveWithTransition` collision sweep — see Q5.
### Named thresholds used for correction
| Constant | Value | File:line | Purpose |
|---|---|---|---|
| `SnapHardSnapThreshold` | 20.0 m | `GameWindow.cs:614` | (declared, comment references retail `GetAutonomyBlipDistance`; NOT directly read in the reviewed OnLivePositionUpdated flow for non-player remotes — hard snap there is unconditional every UP) |
| `MaxPhysicsDistance` | 96 m | `GameWindow.cs:5556` | Player-remote far-vs-near routing (SetPositionSimple vs Interp.Enqueue) |
| `InterpolationManager.AutonomyBlipDistance` | 100 m | `InterpolationManager.cs:105` | Far-branch pre-arm blip in `Enqueue` |
| `InterpolationManager.DesiredDistance` | 0.05 m | `InterpolationManager.cs:79` | "Reached"/"already close" radius |
| `InterpolationManager.MinDistanceToReachPosition` | 0.20 m | `InterpolationManager.cs:73` | 5-frame stall-check primary threshold |
| `InterpolationManager.StallFailCountThreshold` | 3 (fires on >3, i.e. 4th fail) | `InterpolationManager.cs:98` | Snap-to-tail trigger |
| `ServerControlledVelocityStaleSeconds` | 0.60 s | `GameWindow.cs:926` | NPC server-velocity staleness → zero + Ready |
| `RemoteMoveToDriver.StaleDestinationSeconds` | 1.5 s | `RemoteMoveToDriver.cs:136` | MoveTo destination staleness → stand down |
---
## Q7 — STOP (motion → Ready/stand)
There are **THREE distinct, independently-triggered stop paths** in the current code, none of which are unified:
### 1. UM-driven stop (`OnLiveMotionUpdated`, the "retail stop signal")
When the wire's `ForwardCommand` flag is absent or explicitly 0 AND it's not a MoveTo packet (`GameWindow.cs:4347,4364-4367`): `fullMotion = 0x41000003u` (Ready) is used directly as the SetCycle target. This flows through the normal `SetCycle` full-rebuild path (Q2/Q4) — Ready is NOT flagged as a "locomotion" low-byte (`IsLocomotionCycleLowByte` only covers 0x05/0x06/0x07/0x0F/0x10, `AnimationSequencer.cs:1509-1513`), so a Run→Ready transition DOES get the link animation this time (the "Fix B" cyclic-direct bypass only applies when BOTH old and new are locomotion). `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` are ALSO bulk-copied to `fullMotion`/`speedMod` = Ready/1.0 UNCONDITIONALLY at the same time (4590,4598) — so `MotionInterpreter.get_state_velocity()` will subsequently return `Vector3.Zero` (neither WalkForward nor RunForward matches Ready) for Path B (NPC) velocity. **No explicit `StopCompletely()`/`StopInterpretedMotion()` call happens in this path** — it's purely a state overwrite via the same "InterpretedState = wire's forward/speed" bulk-copy used for every other UM.
### 2. UP-driven stop for non-player remotes (near-zero wire velocity)
`GameWindow.cs:5711-5731`: only fires when the wire's `UpdatePosition.Velocity` field is explicitly present AND its length² < 0.04 (i.e. |v| < 0.2 m/s). Calls `rmState.Motion.StopCompletely()` (`MotionInterpreter.cs:562-587`) which resets BOTH `RawState` and `InterpretedState` to Ready/1.0/0/0/0/1.0 across all three axes (forward/sidestep/turn) and calls `PhysicsObj.set_velocity(Vector3.Zero)` directly this IS an explicit velocity zero. Additionally forces `ae.Sequencer.SetCycle(curStyle, readyCmd)` (5729) so the visible cycle snaps to Ready too, independent of whatever UM might arrive later. Comment (5700-5710) notes this UP-absent-velocity path was previously a bug source ("fired StopCompletely every UP intermittent run") and is now scoped ONLY to the explicit-near-zero case, NOT absent-velocity.
### 3. Player-remote UM stop (no distinct path — same as #1, but note the render/position split)
For player remotes, `OnLiveMotionUpdated`'s player branch (4417-4492) explicitly SKIPS the sequencer entirely for the LOCAL player's OWN echoed UM. For OTHER players (genuine remotes), the same #1 mechanism applies. **Position, however, is decoupled**: even after a Ready UM zeroes `InterpretedState`/switches the sequencer to Ready, the `InterpolationManager` queue (Path A, Q5) may still hold unconsumed waypoints from the last few UPs sent WHILE the player was still moving (the network is asynchronous — UM and UP for the same "I stopped" event may arrive in either order or with a gap). Since `PositionManager.ComputeOffset` prefers the queue's catch-up delta over `seqVel` whenever the queue is non-empty (`PositionManager.cs:84-86`), **the body can continue sliding toward the last few queued waypoints via `InterpolationManager.AdjustOffset`'s catch-up motion EVEN AFTER the sequencer has already switched to a zero-velocity Ready cycle** — because the queue-driven correction is independent of `CurrentVelocity`. This is a structural potential for residual sliding: the stop signal (UM→Ready) zeroes ANIMATION velocity but does NOT clear `rmState.Interp`'s queue; the queue only self-empties via normal `NodeCompleted` head-pop as the body reaches each remaining waypoint (`InterpolationManager.cs:278-282`), or by explicit `Clear()` calls which only happen at: (a) far-branch enqueue is NOT a clear, (b) already-close enqueue (0.05m gate), (c) `AdjustOffset`'s stall-blip clear, (d) the landing-transition block (5534, airborne case only). **There is no explicit `rm.Interp.Clear()` call anywhere in the UM-driven stop path (#1) or in the near-zero-UP stop path (#2)** — confirmed by reading `GameWindow.cs:4230-4967` (OnLiveMotionUpdated, no `Interp.Clear` call) and `GameWindow.cs:5711-5731` (the UP near-zero-velocity StopCompletely block, no `Interp.Clear` call there either — only the airborne-landing block at 5534 calls `rmState.Interp.Clear()`).
### Residual-sliding mechanisms explicitly present elsewhere (not stop-specific)
- `SnapResidualDecayRate = 8.0f` (1/sec, `GameWindow.cs:597`) is DECLARED with a comment describing an exponential decay of "soft-snap residual" (100ms half-life) — but this constant is not exercised in any of the code paths read in this pass (`OnLivePositionUpdated`/`OnLiveMotionUpdated`/`TickAnimations` Path A/B); it may be vestigial or used in a renderer-only smoothing pass not covered by this trace. Flagging as unconfirmed — did not find a live call site using this constant during this investigation.
### Summary answers
- (a) **Where remote DR velocity comes from**: for grounded PLAYER remotes, `AnimationSequencer.CurrentVelocity` (synthesized constants × speedMod, set inside `SetCycle`, NOT from `MotionInterpreter.get_state_velocity()`), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes from `InterpolationManager.AdjustOffset`'s catch-up formula (`maxSpeedFromMinterp × 2.0`, itself sourced from `MotionInterpreter.GetMaxSpeed()` = `RunAnimSpeed(4.0) × runRate`). For NPCs (and airborne players), DR velocity comes from `MotionInterpreter.get_state_velocity()` (which itself prefers the sequencer's `CurrentVelocity.Y` when non-zero, else falls back to `WalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed`), OR directly from `rm.ServerVelocity` (wire `Velocity` field or position-delta synthesis) when `HasServerVelocity` is true and fresh.
- (b) **When DR velocity changes after a new UM**: SAME FRAME / same synchronous call — `OnLiveMotionUpdated` bulk-copies `InterpretedState.ForwardCommand/ForwardSpeed` immediately (`GameWindow.cs:4590,4598`) and calls `SetCycle` immediately (4769), which synthesizes `CurrentVelocity` immediately inside the same call (`AnimationSequencer.cs:722-754`). There is no queueing/deferral — the very next `TickAnimations`/`Advance()` call (next frame) will observe the new velocity.
- (c) **Can animation phase and DR velocity disagree, for how long**: YES, structurally, in at least two ways: (1) For player remotes, `PositionManager` can be driving position via the InterpolationManager QUEUE (catch-up toward stale waypoints) while the SEQUENCER has already advanced to a new cycle/phase from a fresher UM — these two systems are not coupled; the queue drains independently at its own catch-up rate (up to `GetMaxSpeed()×2.0` or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) The `SetCycle` cyclic-direct hard-cut (Q2/Q4) resets `_framePosition` to a start boundary instantly on every walk↔run toggle, so the visible LEG PHASE has zero continuity with the body's actual accumulated forward distance at the moment of the toggle — there's no phase-matching/re-sync logic. Duration of disagreement is unbounded in principle (bounded in practice only by how quickly the InterpolationManager queue catches up, or by the next UM/UP hard-snap).
- (d) **How stop zeroes velocity, what can leave residual sliding**: `StopCompletely()` zeroes `PhysicsObj.set_velocity(Vector3.Zero)` directly on the body AND resets `InterpretedState`, but this ONLY fires from the UP near-zero-velocity path (#2 above) — the far more common UM-driven Ready transition (#1) only zeroes the ANIMATION's synthesized `CurrentVelocity` (via `SetCycle`'s Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all — `CurrentVelocity` simply stays whatever it was set to by `ClearPhysics()` at the top of `SetCycle`, i.e. `Vector3.Zero`, UNLESS a link with `HasVelocity` overrides it — Ready transitions DO get a link per Q2, and if that link's MotionData has `HasVelocity` set, `CurrentVelocity` would be non-zero for the DURATION of that link before wrapping to the (velocity-less, non-locomotion) Ready cycle). Meanwhile, for player remotes specifically, **NEITHER of the two stop paths clears `rmState.Interp`'s waypoint queue** — so if the queue still holds unreached waypoints from before the stop signal, `PositionManager.ComputeOffset`/`InterpolationManager.AdjustOffset` will continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames / `_failCount` exceeding 3), producing exactly the "residual sliding after stop" symptom the task is asking about. This is a genuine, code-confirmed gap: stop-signal handling and interpolation-queue clearing are two independent systems that are not wired together outside of the airborne-landing special case.
---
## Cross-cutting notes for synthesis
- **Two entirely separate motion-drive architectures coexist**: the newer InterpolationManager/PositionManager "queue + REPLACE" model (grounded player remotes only) vs. the older `MotionInterpreter.apply_current_movement`/`get_state_velocity` "continuous velocity from InterpretedState" model (NPCs + airborne remotes). They share almost no code and have different stop/correction semantics.
- **`MotionInterpreter`'s D6.2 `adjust_motion`/`apply_raw_movement` port is entirely unused by the inbound remote path** — remotes get server-pre-normalized commands bulk-copied directly into `InterpretedState`, bypassing the retail-faithful raw→interpreted normalization pipeline that exists in the codebase but is wired to the LOCAL player only (`PlayerMovementController.cs`).
- **No unified "stop" concept**: three independent triggers (UM Ready, UP near-zero-velocity StopCompletely, airborne-landing zero) each partially zero different pieces of state (animation velocity only / full body+interpreted-state velocity / position+velocity+cycle), and none of them touch the InterpolationManager queue except the airborne-landing case.
- **`SetCycle`'s cyclic-locomotion-direct special case (Q2/Q4) is itself a documented, intentional divergence from the general link-transition mechanism**, justified in-code by a cdb trace of retail's `add_to_queue` behavior — i.e. per CLAUDE.md's divergence-register discipline, this specific behavior claims retail-fidelity (not an acknowledged approximation), though this investigation did not independently verify the cdb trace's conclusiveness.

View file

@ -0,0 +1,610 @@
# Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics`
Key files (all confirmed to exist):
- `Animation/MotionInterp.cs` — retail `CMotionInterp` (per-object motion-command interpreter; owns `PendingMotions`, `RawState`, `InterpretedState`)
- `Animation/Sequence.cs` — retail `CSequence` (the actual animation cycle/frame player; owns `AnimList`, `CurrAnim`, `FrameNumber`, `Velocity`/`Omega`)
- `Animation/MotionTable.cs` — retail motion-table graph walker (`GetObjectSequence` = the core cycle-swap / link-animation logic)
- `Managers/MotionTableManager.cs` — retail per-object `AnimationCounter`/`PendingAnimations` bookkeeping + redundant-link truncation
- `Managers/MovementManager.cs` — top dispatcher owning `MotionInterpreter` + `MoveToManager`
- `Managers/InterpolationManager.cs` — remote/dead-reckoning position correction (network snap-to-position blending)
- `Managers/PositionManager.cs` — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from `PhysicsObj.UpdatePositionInternal`
- `PartArray.cs` — glue between `PhysicsObj` and `Sequence`/`MotionTableManager` (`DoInterpretedMotion`, `Update`, `StopInterpretedMotion`)
- `PhysicsObj.cs` — top-level per-entity physics object; `update_object`/`UpdateObjectInternal`/`UpdatePositionInternal` drive the whole thing every tick
- `Animation/AnimSequenceNode.cs`, `Animation/MotionState.cs`, `Animation/RawMotionState.cs`, `Animation/InterpretedMotionState.cs`, `Animation/MotionNode.cs`, `Animation/AnimNode.cs`, `Animation/MovementParameters.cs`
IMPORTANT CAVEAT: ACE is the **server**. There is no "remote entity rendering" concept in ACE — every `PhysicsObj` on the server is locally-simulated and authoritative; ACE broadcasts `UpdatePosition`/`UpdateMotion` wire messages *outward* to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving":
1. **The locally-controlled-by-network-input path**: `Player_Tick.OnMoveToState_ClientMethod` / `OnMoveToState_ServerMethod` — this is literally the server *acting as* the retail client's `CMotionInterp` would for the connected player, driven by inbound `MoveToState` (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter."
2. **The dead-reckoning/position-correction path** for *other* entities as seen by a given observer would be `PhysicsObj.MoveOrTeleport` + `PositionManager.InterpolateTo` + `InterpolationManager` — this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters' `UpdatePosition` broadcasts, and structurally mirrors what a retail *client* does when it receives another player's `UpdatePosition`).
Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2).
---
## Q1 — INBOUND ENTRY: wire message → motion interpreter
### Path A: motion-command message (`MoveToState`, retail wire opcode family 0xF61C) → MotionInterp
1. `ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13` `Handle(ClientMessage, Session)` — the game-action handler for `[GameAction(GameActionType.MoveToState)]`. Parses payload into a `MoveToState` struct (`Network/Structure/MoveToState.cs`, not read in detail here) and stores `session.Player.CurrentMoveToState = moveToState;` (line 20).
2. Line 29: `session.Player.OnMoveToState(moveToState);` — this is the entry into the physics/animation layer.
3. Line 36: `session.Player.BroadcastMovement(moveToState);` (`WorldObjects/Player_Networking.cs:309`) — re-broadcasts the motion as `GameMessageUpdateMotion` to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above).
4. `WorldObjects/Player_Tick.cs:176` `OnMoveToState(MoveToState moveToState)`:
- Guards on `FastTick` (line 178).
- Line 187-188: if not already moving/animating, resets `PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime` (this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum).
- Line 190-193: branches on `client_movement_formula` property + `StandingLongJump`:
- `OnMoveToState_ServerMethod` (retail-authoritative single-shot apply)
- `OnMoveToState_ClientMethod` (retail client-style edge-triggered DoMotion/StopMotion calls)
5. **`OnMoveToState_ServerMethod`** (`Player_Tick.cs:272-288`) — the retail-CMotionInterp-faithful path:
```csharp
var minterp = PhysicsObj.get_minterp();
minterp.RawState.SetState(moveToState.RawMotionState);
if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; }
var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None;
minterp.apply_raw_movement(true, allowJump);
```
This copies the ENTIRE wire-level `RawMotionState` (`Network/Structure/RawMotionState`) into `MotionInterp.RawState` in one shot via `RawMotionState.SetState` (`Animation/RawMotionState.cs:117-140`), then calls `MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`), which re-derives `InterpretedState` from `RawState` field-by-field and re-issues `DoInterpretedMotion` calls (see Q2 below). **This is the retail `CMotionInterp::apply_raw_movement` — full re-derivation every call, not an incremental diff.**
6. **`OnMoveToState_ClientMethod`** (`Player_Tick.cs:199-270`) — an edge-triggered alternative (a `client_movement_formula`-flagged, non-default variant) that diffs `rawState` against `prevState = LastMoveToState?.RawMotionState` and calls `PhysicsObj.DoMotion` / `PhysicsObj.StopMotion` only on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail *client itself* processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method.
### Path B: `PhysicsObj.DoMotion` (entry used by both client-edge method and any direct-call site)
`PhysicsObj.cs:340-346`:
```csharp
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
{
LastMoveWasAutonomous = true;
if (MovementManager == null) return WeenieError.NoAnimationTable;
var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams);
return MovementManager.PerformMovement(mvs);
}
```
`MovementManager.PerformMovement` (`Managers/MovementManager.cs:124-157`) lazily constructs `MotionInterpreter` if null, then dispatches `MovementType.RawCommand` to `MotionInterpreter.PerformMovement(mvs)` (`Animation/MotionInterp.cs:236-262`), which for `RawCommand` calls `DoMotion(mvs.Motion, mvs.Params)` (`MotionInterp.cs:112-158`).
`MotionInterp.DoMotion` (line 112):
- Builds a local `currentParams` copy (`CopySome`, MotionInterp.cs:119).
- `adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)` (line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2).
- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while `CurrentStyle != NonCombat`.
- Action-count gating (line 147-151): `GetNumActions() >= 6``TooManyActions`.
- Calls `DoInterpretedMotion(currentMotion, currentParams)` (line 152) — the actual interpreter entry (shared with Path C below).
- If success and `movementParams.ModifyRawState`, calls `RawState.ApplyMotion(motion, movementParams)` (line 155) to record the **original, un-adjusted** motion into RawState (so Run-promotion doesn't get baked into RawState).
### Path C: `MotionInterp.DoInterpretedMotion` — the true funnel point (both paths above and the direct-interpreted-command callers converge here)
`Animation/MotionInterp.cs:51-110`:
1. `contact_allows_move(motion)` gate (line 57, see below) — blocks ground-locked motions while airborne.
2. `StandingLongJump` special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates `InterpretedState` without touching the physics animation (charge-jump doesn't reanimate).
3. Otherwise (line 64-90):
- `Dead` motion → `PhysicsObj.RemoveLinkAnimations()` first (line 66-67).
- `result = PhysicsObj.DoInterpretedMotion(motion, movementParams)` (line 69) → `PartArray.DoInterpretedMotion` (`PartArray.cs:130-136`) → `MotionTableManager.PerformMovement(mvs, Sequence)` (`Managers/MotionTableManager.cs:116-145`) → `Table.DoObjectMotion``GetObjectSequence` (`Animation/MotionTable.cs:55-257`, the actual cycle-graph logic, see Q2/Q4).
- On success: computes `jump_error_code` (lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), then `add_to_queue(movementParams.ContextID, motion, jump_error_code)` (line 85) — pushes a `MotionNode` onto `MotionInterp.PendingMotions` (see Q3).
- If `movementParams.ModifyInterpretedState`, calls `InterpretedState.ApplyMotion(motion, movementParams)` (line 88) to record the new forward/sidestep/turn/style/action state.
4. If not contact-allowed (airborne): Action commands fail with `YouCantJumpWhileInTheAir` (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with `ModifyInterpretedState` (line 99-100).
5. Line 106-107: if `PhysicsObj.CurCell == null`, `RemoveLinkAnimations()` — an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations.
`contact_allows_move` (`MotionInterp.cs:584-602`):
```
Dead / Falling / TurnRight..TurnLeft range → always true
non-creature WeenieObj → always true
!PhysicsState.Gravity → true
!TransientState.Contact → false // airborne, no ground contact
TransientState.OnWalkable → true // standing on walkable floor
else → false
```
---
## Q2 — TRANSITION: walk↔run while already moving (no full stop)
### Frame 0: the wire triggers `apply_raw_movement` (server method) or a direct `DoMotion(RunForward, ...)` (client-edge method / direct caller)
`MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`):
```csharp
InterpretedState.CurrentStyle = RawState.CurrentStyle;
InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005)
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
InterpretedState.SideStepCommand = RawState.SideStepCommand;
...
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
apply_interpreted_movement(cancelMoveTo, allowJump);
```
`adjust_motion` (`MotionInterp.cs:394-428`) is where **Walk→Run promotion actually happens**:
```csharp
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
switch (motion)
{
case RunForward: return; // already run, no-op
case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65
case TurnLeft: motion = TurnRight; speed *= -1.0f; break;
case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break;
}
if (motion == SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25)
if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey;
if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed);
}
```
`apply_run_to_command` (`MotionInterp.cs:525-562`):
```csharp
case WalkForward:
if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id RunForward substate id
speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate)
break;
case TurnRight: speed *= RunTurnFactor; break; // 1.5
case SideStepRight:
speed *= speedMod;
if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0
speed = Math.Sign(speed) * MaxSidestepAnimRate;
break;
```
**So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the *interpreter* (not the network layer) decides to swap the motion id from `WalkForward` (0x45000005) to `RunForward` (0x44000007) before it ever reaches the animation graph.** This matches the CLAUDE.md-documented wire quirk: acdream sends `WalkForward + HoldKey.Run`, and ACE relays it as `RunForward` to observers — that relay behavior mirrors exactly this `adjust_motion`/`apply_run_to_command` swap.
### Frame 1: `apply_interpreted_movement` re-issues `DoInterpretedMotion` for the (possibly swapped) ForwardCommand
`MotionInterp.apply_interpreted_movement` (`MotionInterp.cs:440-504`):
```csharp
if (InterpretedState.ForwardCommand == RunForward)
MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance
if (contact_allows_move(InterpretedState.ForwardCommand))
{
if (!StandingLongJump)
{
movementParams.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed)
...sidestep, turn similarly...
```
### Frame 2: `DoInterpretedMotion(RunForward, ...)` reaches `MotionTable.GetObjectSequence` — THIS is where the cycle actually swaps
`GetObjectSequence` (`Animation/MotionTable.cs:60-257`). WalkForward (0x45000005) and RunForward (0x44000007) both have the `CommandMask.SubState` bit (0x40000000) set, so both hit the **SubState branch** (line 121 onward), NOT the "same substate, just re-speed" fast path — because `motion` (RunForward id) != `currState.Substate` (currently WalkForward id). The relevant excerpt:
```csharp
if ((motion & CommandMask.SubState) != 0)
{
var motionID = motion & 0xFFFFFF;
Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData
...
if (is_allowed(motion, motionData, currState))
{
if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod))
{
// FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run,
// since WalkForward != RunForward numerically)
change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod);
subtract_motion(sequence, motionData, currState.SubstateMod);
combine_motion(sequence, motionData, speedMod);
currState.SubstateMod = speedMod;
return true;
}
// GENERAL PATH — actually taken for Walk->Run:
if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers();
var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod);
// link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict,
// keyed by (style<<16 | fromSubstate) -> Dictionary<toSubstate, MotionData>
if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod))
{
// fallback: go through the style's default substate as an intermediate hop
uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion);
link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f);
motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod);
}
sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero
sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4
if (motionData_ != null)
{
add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim
add_motion(sequence, motionData_, speedMod); // append default->run link anim
}
else
{
add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case)
}
add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever)
currState.SubstateMod = speedMod;
currState.Substate = motion; // RunForward is now the tracked substate
re_modify(sequence, currState); // re-apply any active modifiers on top
numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1;
return true;
}
}
```
**Answer to Q2: the new motion is APPENDED to the sequence's `AnimList`, not a hard replace.** `sequence.remove_cyclic_anims()` first prunes any *already-cyclic* (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then `add_motion` calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of `AnimList` (`Sequence.append_animation`, `Sequence.cs:203-216`). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once `Sequence.update_internal` walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). **There IS a blend/link animation**`get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed)` looks up a purpose-built transition clip from `MotionTable.Links[(style<<16)|fromSubstate][toSubstate]`; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. **Speed change is NOT instantaneous/immediate** — the running cycle only takes effect once the sequence actually plays through to it; only `Sequence.Velocity`/`Sequence.Omega` (the linear/angular displacement-per-quantum baked into the currently active `MotionData`) change per node, and those are set fresh by each `add_motion` call via `sequence.SetVelocity(motionData.Velocity * speed)` (`MotionTable.cs:362`) which OVERWRITES (not blends) `Sequence.Velocity` — but that overwrite only takes effect for the currently-active node once the sequence has walked into it.
One exception worth flagging: `SetVelocity`/`SetOmega` inside `add_motion` (line 362-363) is called once per `add_motion(sequence, motionData, speed)` invocation and each call **overwrites** `sequence.Velocity`/`sequence.Omega` — so by the time `GetObjectSequence` returns, `Sequence.Velocity` already reflects the LAST `add_motion` call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means **the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle** — a subtlety worth testing for in acdream's port (verify our `Sequence.Update` doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this).
---
## Q3 — PENDING/DONE lifecycle: `pending_motions` + `MotionDone`
Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate:
### Layer 1: `MotionInterp.PendingMotions` (`List<MotionNode>`, `MotionInterp.cs:24`)
- **Type**: `LinkedList<MotionNode>`. `MotionNode` (`Animation/MotionNode.cs`): `ContextID` (int), `Motion` (uint), `JumpErrorCode` (WeenieError).
- **Appended by**: `MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)` (`MotionInterp.cs:388-392`):
```csharp
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
PhysicsObj.IsAnimating = true;
```
Called from `DoInterpretedMotion` (line 85), `StopCompletely` (line 321), `StopInterpretedMotion` (line 348), and `apply_interpreted_movement`'s turn-release branch (line 495).
- **Popped by**: `MotionInterp.MotionDone(bool success)` (`MotionInterp.cs:210-234`):
```csharp
var motionData = PendingMotions.First;
if (motionData != null)
{
var pendingMotion = motionData.Value;
if ((pendingMotion.Motion & CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy)
if (motionData != null)
{
PendingMotions.Remove(motionData);
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
}
}
```
This is called ONLY from `PhysicsObj.MotionDone(motion, success)``MovementManager.MotionDone(motion, success)``MotionInterpreter.MotionDone(success)` (note: the `motion` parameter is dropped/ignored at this layer — it always pops `PendingMotions.First`, positionally, not by matching motion id).
- **Who calls `PhysicsObj.MotionDone`**: `MotionTableManager.AnimationDone` (see Layer 2 below) and `MotionTableManager.CheckForCompletedMotions`.
- **`motions_pending()` / `IsAnimating`**: `MotionInterp.motions_pending()` (line 784-787) just checks `PendingMotions.Count > 0`; the faster `PhysicsObj.IsAnimating` bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer `IsAnimating` for perf.
- **`HandleExitWorld`** (`MotionInterp.cs:160-173`): drains `PendingMotions` entirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain).
### Layer 2: `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`, `Managers/MotionTableManager.cs:13`)
- **Type**: `AnimNode` (`Animation/AnimNode.cs`): `Motion` (uint), `NumAnims` (uint) — the count of individual `Animation` sub-clips (as opposed to the higher-level `MotionCommand`) that must finish playing before this queue entry is considered done.
- **Appended by**: `MotionTableManager.add_to_queue(motion, num_anims, sequence)` (`MotionTableManager.cs:163-167`), called from `PerformMovement` (line 127/134/139) with `counter` = the `numAnims` computed by `MotionTable.GetObjectSequence`/`StopSequenceMotion`/`StopObjectCompletely` (i.e. the total sub-clip count of link+cycle animations just appended to `Sequence.AnimList` for this motion). Also called by `initialize_state` (line 176, entry into default Ready state) with the numAnims from `SetDefaultState`.
- Immediately after append: `remove_redundant_links(sequence)` (line 166) — walks `PendingAnimations` from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls `trancuate_animation_list` to zero out the redundant entries' `NumAnims` and physically remove the corresponding still-unplayed link animations from `Sequence.AnimList` via `sequence.remove_link_animations(totalAnims)` (`Sequence.cs:324-340`). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling).
- **Popped/completed by TWO different drivers**:
1. **`AnimationDone(bool success)`** (`MotionTableManager.cs:28-61`) — driven by actual animation-hook completion signals (see `Sequence.update_internal`'s `AnimDoneHook`, Q4). Increments `AnimationCounter`, then while the FRONT node's `NumAnims <= AnimationCounter`: subtracts `entry.NumAnims` back out of the counter, removes an Action-head from `MotionState` if the motion has the Action bit, calls `PhysicsObj.MotionDone(motionID, success)` (→ pops Layer-1 `MotionInterp.PendingMotions`, see above), removes the front `PendingAnimations` node, and fires `PhysicsObj.WeenieObj.OnMotionDone(motionID, success)` (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).
2. **`CheckForCompletedMotions()`** (`MotionTableManager.cs:63-85`) — a POLLING variant, called from `PhysicsObj.CheckForCompletedMotions` (`PhysicsObj.cs:296-300`) → `PartArray.CheckForCompletedMotions` (`PartArray.cs:72-76`), itself invoked once per `MotionInterp.PerformMovement` call (`MotionInterp.cs:260`, right after `PhysicsObj.CheckForCompletedMotions()` unconditionally at the end of every `PerformMovement`). It loops while `PendingAnimations.First.Value.NumAnims == 0` (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior `remove_redundant_links`/`trancuate_animation_list` call), popping them exactly like `AnimationDone` does (same Action-head removal, `PhysicsObj.MotionDone`, `OnMotionDone`).
- **Actual per-sub-animation completion signal**: `Sequence.update_internal` (`Animation/Sequence.cs:351-443`), when `animDone` is set true (a frame index walked off the end of the current `AnimSequenceNode`'s HighFrame/LowFrame), fires:
```csharp
if (HookObj != null)
{
var node = AnimList.First;
if (!node.Equals(FirstCyclic))
HookObj.add_anim_hook(AnimationHook.AnimDoneHook);
}
```
i.e. **only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node** — the looping cycle node itself never signals "done" this way (it loops forever via `advance_to_next_animation`'s wraparound to `FirstCyclic`, see Q4). `HookObj.add_anim_hook` (defined on `PhysicsObj`, not read in detail — queues an `FPHook`/animation hook for later dispatch) is what eventually drives `MotionTableManager.AnimationDone(true)` on the consuming side.
### Summary of the 3-tier queue relationship
```
Sequence.AnimList (LinkedList<AnimSequenceNode>) — the actual playable clips, link+cyclic
↑ pushed onto tail by add_motion() during GetObjectSequence
MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — motion-id + sub-clip COUNT bookkeeping
↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling
MotionInterp.PendingMotions (LinkedList<MotionNode>) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call)
↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations
entry at Layer 2 fully completes (positionally FIFO, NOT id-matched)
```
---
## Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link?
Answer: **transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.**
### `Sequence.append_animation` (`Sequence.cs:203-216`)
```csharp
public void append_animation(AnimData animData)
{
var node = new AnimSequenceNode(animData);
if (!node.has_anim()) return;
AnimList.AddLast(node);
FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node
if (CurrAnim == null)
{
CurrAnim = AnimList.First;
FrameNumber = CurrAnim.Value.get_starting_frame();
}
}
```
Each `add_motion` call in `GetObjectSequence` does one `append_animation` PER sub-`Anim` in the `MotionData.Anims` list (`MotionTable.cs:358-370`, `add_motion`). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). `FirstCyclic` ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, `advance_to_next_animation` wraps back to `FirstCyclic`, not to `AnimList.First`.
### `Sequence.remove_cyclic_anims` (`Sequence.cs:303-322`) — called BEFORE the new link+cycle are appended
```csharp
public void remove_cyclic_anims()
{
var node = FirstCyclic;
while (node != null)
{
if (CurrAnim.Equals(node))
{
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
}
```
This walks from the OLD `FirstCyclic` (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (`CurrAnim`) happens to BE the old cyclic loop node being removed, `CurrAnim` is rewound to `node.Previous` with `FrameNumber` snapped to that previous node's `get_ending_frame()` — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. **In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, `HighFrame + 1 - EPSILON`, `AnimSequenceNode.cs:31-37`), THEN the link animation begins from ITS OWN starting frame (`LowFrame` for forward playback, `AnimSequenceNode.cs:72-78`).** There is no frame-phase carry-over from Walk into the link or from the link into Run — every `AnimSequenceNode` transition resets `frameNum` to that node's `get_starting_frame()` inside `advance_to_next_animation` (`Sequence.cs:145-201`, specifically line 165 `frameNum = currAnim.get_starting_frame();` for forward playback, or line 191 `frameNum = currAnim.get_ending_frame();` for the reverse-playback branch).
### `advance_to_next_animation` (`Sequence.cs:145-201`) — the actual node-to-node walk
Forward-playback branch (`timeElapsed >= 0.0f`):
```csharp
// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution)
if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail
currAnim = animNode.Value;
frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame never carries phase
if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
```
So the answer is explicit: **frame index restarts per-node** (each `AnimSequenceNode` — link clip or cycle clip — always begins at its own `LowFrame`/`HighFrame` boundary), and the only "carry-over" concept is which NODE plays next, driven by the `AnimList` linked-list order that `GetObjectSequence`/`add_motion` built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated **link animation** (`MotionTable.get_link`, `MotionTable.cs:395-426`) between them — never a numeric blend/crossfade of frame data. `Sequence.apply_physics` (Sequence.cs:221-230) applies `Velocity`/`Omega` translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in `Sequence`/`MotionTable` code.
### `apricot()` (`Sequence.cs:232-243`) — garbage-collects consumed-but-still-present nodes
```csharp
public void apricot()
{
var node = AnimList.First;
while (!node.Equals(CurrAnim))
{
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
}
```
Called every `Sequence.Update` tick (`Sequence.cs:132-143`, right after `update_internal`) — trims fully-played-past nodes off the FRONT of `AnimList` up to (but never past) `FirstCyclic`, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is `apricot` in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.)
---
## Q5 — POSITION DRIVE between inbound packets: what advances position?
**Both, combined additively in one offset frame, gated by ground contact.** Sequence order (`PhysicsObj.UpdatePositionInternal`, `PhysicsObj.cs:1862-1879`):
```csharp
public void UpdatePositionInternal(double quantum, ref AFrame newFrame)
{
var offsetFrame = new AFrame();
if (!State.HasFlag(PhysicsState.Hidden))
{
if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
offsetFrame.Origin *= Scale; // 2. scale only applied while grounded
else
offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT
}
if (PositionManager != null)
PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top
newFrame = AFrame.Combine(Position.Frame, offsetFrame);
...
}
```
**Step 1 — `PartArray.Update``Sequence.Update`** (`PartArray.cs:589-592`, `Sequence.cs:132-143`):
```csharp
public void Update(float quantum, ref AFrame offsetFrame)
{
if (AnimList.First != null)
{
update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame);
apricot();
}
else if (offsetFrame != null)
apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration
}
```
Inside `update_internal` (`Sequence.cs:351-443`), for every whole frame boundary crossed this tick:
```csharp
if (currAnim.Anim.PosFrames != null)
frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas")
if (Math.Abs(framerate) > PhysicsGlobals.EPSILON)
apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution)
```
So **within a single `Sequence.Update` call, BOTH sources are combined**: (a) any authored `PosFrames` deltas baked directly into the `.anim` asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via `AFrame.Combine`, AND (b) `Sequence.apply_physics` (`Sequence.cs:221-230`) which does `frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum);` where `Velocity`/`Omega` are the CURRENT node's `MotionData.Velocity`/`.Omega` (set by `add_motion`'s `sequence.SetVelocity(motionData.Velocity * speed)` — i.e. this IS "velocity from the interpreted motion state", since `speed` traces back to `InterpretedState.ForwardSpeed`/`get_state_velocity()`-derived values). **Which dominates depends entirely on the specific `.anim` asset** — most locomotion cycles (walk/run) in retail author their forward displacement via `Velocity` (constant per-cycle linear speed) rather than per-frame `PosFrames`, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the `.anim` DAT asset defines gets applied.
**Step 3 is critical**: `offsetFrame.Origin *= 0.0f` when NOT `OnWalkable`**while airborne, animation-driven translation is completely discarded**; only `PositionManager.AdjustOffset` (network correction) and gravity/velocity integration elsewhere (`UpdatePhysicsInternal`, `PhysicsObj.cs:1832-1860`, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: **ground movement speed is whatever the `.anim` asset's baked `Velocity` says for that cycle at that `speed` multiplier, not a free-form physics velocity vector.**
**Step 4 — `PositionManager.AdjustOffset`** (`PositionManager.cs:20-28`) chains `InterpolationManager.adjust_offset` + `StickyManager.adjust_offset` + `ConstraintManager.adjust_offset` onto the SAME `offsetFrame` that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior.
---
## Q6 — CORRECTION: how do inbound position updates fix accumulated error?
Entry point for "another entity's authoritative position arrived": `PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)` (`PhysicsObj.cs:905-934`):
```csharp
public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)
{
... staleness/sequence-number check against UpdateTimes[4] ...
if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp))
{
teleport_hook(true);
SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all
return true;
}
else
{
if (!contact) return false;
if (PlayerDistance < 96.0f)
InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path
else
{
PositionManager?.StopInterpolating();
SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely)
}
}
return true;
}
```
`96.0f` (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate.
`PositionManager.InterpolateTo` (`PositionManager.cs:55-61`) lazily creates an `InterpolationManager` and calls `InterpolationManager.InterpolateTo(position, keepHeading)`.
### `InterpolationManager.InterpolateTo` (`InterpolationManager.cs:36-84`)
```csharp
var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position;
var dist = dest.Distance(position);
if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip"
{
if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position
{
// dedupe: drop trailing queued nodes that are already close (<0.05) to the new target
while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast();
while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20
enqueue new PositionType node (optionally overriding heading to current heading if keepHeading)
}
else
{
if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true);
StopInterpolating(); // close enough already — snap heading only, clear queue
}
}
else
{
// error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4
enqueue new PositionType node; NodeFailCounter = 4;
}
```
### Per-tick blending: `InterpolationManager.adjust_offset(AFrame frame, double quantum)` (`InterpolationManager.cs:199-258`) — called from `PositionManager.AdjustOffset` every `UpdatePositionInternal` tick
```csharp
if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded
var first = queue.First.Value;
if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here
var dist = PhysicsObj.Position.Distance(first.Position);
if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units
var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed
if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f
var delta = OriginalDistance - dist;
ProgressQuantum += quantum; FrameCounter++;
if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f)))
{
// "making good enough progress" — keep smoothing
if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks
var offset = first.Position.Subtract(PhysicsObj.Position);
var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance
var distance = offset.Origin.Length();
if (distance <= 0.05f) NodeCompleted(true);
if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2
if (KeepHeading) offset.set_heading(0.0f);
frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal added on top of animation displacement
return;
}
NodeFailCounter++;
NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick
```
Constants (verbatim, `InterpolationManager.cs:18-19`):
```
LargeDistance = 999999.0f
MaxInterpolatedVelocity = 7.5f
UseAdjustedSpeed = true (static, defaults on)
```
Snap/completion threshold: **`dist < 0.05f`** appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in `InterpolateTo`) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from `PhysicsGlobals.EPSILON` (0.0002f) which is the general floating-point/animation epsilon.
Progress-rate abandonment rule: if less than **30%** of the max-possible correction speed's worth of distance was closed over the last 5-tick window (`delta / ProgressQuantum / maxSpeed >= 0.3f` failing), `NodeFailCounter` increments; once `NodeFailCounter > 3` the `UseTime()` method (`InterpolationManager.cs:142-197`) takes over and does a **hard `SetPositionSimple` snap** (with fallback velocity-carry logic scanning back through the queue for the last `PositionType` node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up.
`NodeCompleted(bool success)` (`InterpolationManager.cs:91-126`) pops the front queue node, resets `FrameCounter`/`ProgressQuantum`, and recomputes `OriginalDistance` against the NEXT queued node (or `LargeDistance` if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node.
**No InterpolationNode "Velocity" playback happens inside `adjust_offset`** — `VelocityType`/`JumpType` queue nodes are explicitly skipped there (`InterpolationManager.cs:205`) and are instead consumed synchronously and immediately inside `UseTime()` (`InterpolationManager.cs:185-196`, `case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;`), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set.
---
## Q7 — STOP: motion → Ready/stand
### Two distinct stop mechanisms in `MotionInterp`
**(a) `StopInterpretedMotion(uint motion, MovementParameters)`** (`MotionInterp.cs:329-365`) — stop ONE specific ongoing motion (e.g. release the forward key while still turning):
```csharp
if (contact_allows_move(motion))
{
if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight})
InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change
else
{
result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand)
if (result == WeenieError.None)
{
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id
if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion);
}
}
}
else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
```
`MotionTableManager.PerformMovement`'s `StopInterpretedCommand` case (`MotionTableManager.cs:130-135`):
```csharp
if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData;
add_to_queue((uint)MotionCommand.Ready, counter, seq);
```
`MotionTable.StopObjectMotion``StopSequenceMotion` (`MotionTable.cs:315-356`):
```csharp
if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion)
{
uint style; StyleDefaults.TryGetValue(currState.Style, out style);
GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready)
return true;
}
if ((motion & CommandMask.Modifier) == 0) return false;
// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers
```
**So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting `Ready`/idle instead of another locomotion cycle.** This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same `get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f)` call inside the re-entered `GetObjectSequence`), not an instant cut to a standing pose.
**(b) `StopCompletely()`** (`MotionInterp.cs:301-327`) — full stop, e.g. server-forced idle:
```csharp
PhysicsObj.cancel_moveto();
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0;
PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely)
add_to_queue(0, (uint)MotionCommand.Ready, jump);
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
```
`MotionTable.StopObjectCompletely` (`MotionTable.cs:293-313`):
```csharp
// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states)
while (currState.Modifiers.First != null)
StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims);
// then stop the current Substate (Walk/Run/etc.) itself
StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims);
```
### Velocity zeroing
`Velocity`/`Omega` on the `Sequence` are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time `add_motion` runs during the `GetObjectSequence(style, ..., true)` re-entry inside `StopSequenceMotion` (line 327): `sequence.SetVelocity(motionData.Velocity * speed)` where `motionData` is now the Ready/idle cycle's `MotionData` (`MotionTable.cs:362`, inside `add_motion`), and Ready/idle cycles are authored with zero linear `Velocity` in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against `docs/research/named-retail/` if exactness matters). Additionally, `sequence.clear_physics()` (`Sequence.cs:256-260`, sets `Velocity = Omega = Vector3.Zero`) is called unconditionally at the TOP of every `GetObjectSequence` SubState-branch invocation (`MotionTable.cs:152` inside the branch reached from `StopSequenceMotion`'s re-entry) — **so `Sequence.Velocity`/`Omega` ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent `add_motion` calls for the link+Ready-cycle**, meaning during the STOP LINK ANIMATION itself, whatever `Velocity`/`Omega` that specific link clip's `MotionData` carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below).
### Residual-slide prevention
There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through `get_link(...)` to a dedicated stop/deceleration animation clip whose `MotionData.Velocity` is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's `MotionData.Velocity` should be `Vector3.Zero` so continued looping produces zero displacement per Q5's `apply_physics(frame, quantum, quantum)` math. Physical/gravity velocity (`PhysicsObj.Velocity`, used only by the free-flight `UpdatePhysicsInternal` path, `PhysicsObj.cs:1832-1860`) is separately damped via `calc_friction(quantum, velocity_mag2)` (not read in detail; referenced at `PhysicsObj.cs:1849`) plus a hard clamp:
```csharp
if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero;
```
`SmallVelocity = 0.25f` (`PhysicsGlobals.cs:34`), `SmallVelocitySquared = 0.0625f` (line 36), `EPSILON = 0.0002f` (line 9) — i.e. once ground-friction has decayed `PhysicsObj.Velocity` to within `sqrt(0.0625 + 0.0002) ≈ 0.2504` units/sec of zero (this branch is really checking `velocity_mag2 <= SmallVelocitySquared + EPSILON`), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop.
### `RemoveLinkAnimations` on cell-exit
Both `StopInterpretedMotion` and `StopCompletely` end with `if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();` (`PhysicsObj.cs:992-996``PartArray.HandleEnterWorld``MotionTableManager.HandleEnterWorld` (`MotionTableManager.cs:103-108`) → `sequence.remove_all_link_animations()` (`Sequence.cs:289-301`) + drains `PendingAnimations` via repeated `AnimationDone(false)` calls). `remove_all_link_animations` differs from `remove_cyclic_anims` (Q4) — it strips every node BEFORE `FirstCyclic` (i.e. the non-looping link/transition segment), snapping `CurrAnim` straight to `FirstCyclic` (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when `CurCell == null`, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of `DoInterpretedMotion`/`StopInterpretedMotion` (Q1/Q3).

View file

@ -0,0 +1,92 @@
# Claim map: inbound motion/animation research vs current code
Date: 2026-07-02. Repo (worktree): `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
(branch `claude/vigorous-joliot-f0c3ad`, HEAD `b60b9b4a`).
**Path note:** `docs/research/2026-06-04-animation-sequencer-deep-dive.md` does NOT
exist in this worktree or in git history at all (it is an *untracked* file only
in the main checkout `C:/Users/erikn/source/repos/acdream/docs/research/...`).
It was never committed. Read directly from that absolute path for this audit.
The other two docs (`2026-06-26-movement-animation-retail-parity-audit.md`,
`2026-07-01-d6-motion-interp-pseudocode.md`) exist and are committed in this
worktree.
---
## Doc 1: 2026-06-04-animation-sequencer-deep-dive.md (8 ranked divergences)
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|---|---|---|---|---|
| 1 | Missing `pending_motions`/`MotionDone` chain (MotionInterp, HIGH). `add_to_queue` + `CheckForCompletedMotions` unimplemented. Affects multi-step action sequences, combo attacks, `UpdateMotion Commands[]` action chains. (doc anchor: "Divergences ranked" #1, L1242-1247) | Grepped `MotionInterpreter.cs`/`AnimationSequencer.cs` for `pending_motions`/`MotionDone`/`CheckForCompletedMotions`/`add_to_queue` — only comment references (`FUN_00510900 — CheckForCompletedMotions (animation flush, not simulated here)`, `FUN_00528790(…) — add_to_queue`). No queue data structure, no `MotionDone` event. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:399,412,558` (comments only, no impl) |
| 2 | `contact_allows_move` level mismatch (MotionInterp, HIGH). acdream blocks Fallen/Dead/Crouch-range inside `contact_allows_move` itself; retail does this at `DoMotion` level with distinct error codes. (doc anchor: "Divergences ranked" #2, L1249-1253) | Read `contact_allows_move` body: still explicitly checks `fwd == MotionCommand.Fallen \|\| fwd == MotionCommand.Dead` and the crouch/sit/sleep range, returning `false` (blocked) directly from this function — exactly the divergence described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (see lines 1092-1099 for the Fallen/Dead/Crouch block) |
| 3 | `re_modify` not implemented after cycle rebuild (CSequence, MEDIUM). Stacked Modifier motions (combat stance overlays) lose velocity/omega contribution on cycle switch. (doc anchor: "Divergences ranked" #3, L1255-1260) | Grepped `AnimationSequencer.cs` for `re_modify`/`ReModify`/`ModifierList`/`_activeModifiers` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/AnimationSequencer.cs` (no match found for the term at all) |
| 4 | `TransparentHook` fires as instant snap vs smooth lerp (anim-hooks, MEDIUM). No `FPHook` time-interpolation equivalent. (doc anchor: "Divergences ranked" #4, L1262-1266) | Not directly re-verified this session (out of scope for walk/run + stop-slide focus — this is a fade/translucency concern, not motion/position). | **COULD-NOT-VERIFY** (not checked; low relevance to task's (a)/(b)/(c) scope beyond "(c) inbound animation machinery generally") | n/a |
| 5 | `direction_` default `0xFFFFFFFE` vs `AnimationHookDir.Both` (anim-hooks, LOW). Dat-baked hooks with missing/zero direction may fire when retail would never fire them. (doc anchor: "Divergences ranked" #5, L1268-1272) | Not re-verified this session — orthogonal to motion/position, low priority. | **COULD-NOT-VERIFY** | n/a |
| 6 | Legacy entity path has no hook dispatch (anim-hooks, LOW-MEDIUM), at old `GameWindow.cs:8563-8573`. (doc anchor: "Divergences ranked" #6, L1274-1277) | Not re-verified — line numbers are 2026-06-04-vintage and GameWindow.cs has grown/shifted substantially (now 13,759 lines vs whatever it was then); the cited range is very likely stale. | **STALE-ANCHOR** (line numbers unverified/likely shifted; concept not re-checked) | n/a |
| 7 | `enter_default_state` not called on spawn (MotionInterp, LOW). No `InitializeMotionTables`/pending_motions Ready-node seeding at spawn. (doc anchor: "Divergences ranked" #7, L1279-1282) | Grepped `MotionInterpreter.cs` for `enter_default_state`/`EnterDefaultState`/`InitializeMotionTables` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs` (no match) |
| 8 | `StandingLongJump` path not called per-tick (MotionInterp, LOW). The standing-long-jump pre-jump pose sub-state exists as a flag but `apply_interpreted_movement`'s per-tick branch (Ready + StopSideStep) is not wired. (doc anchor: "Divergences ranked" #8, L1284-1287) | `StandingLongJump` field exists and is set/cleared in a few places (`MotionInterpreter.cs:328,941,1119,1146`) but no per-tick `apply_interpreted_movement`-style dispatch was found calling `DoInterpretedMotion(Ready)` + `StopInterpretedMotion(SideStepRight)` from the flag. Also confirmed D3 in doc 2 (MoveToState longjump bit) is now WIRED on the *outbound* wire side (`standingLongjump` param exists in `MoveToState.Build`), but that's the wire byte, not the per-tick animation branch this claim is about — those are different things. | **STILL-TRUE** (the per-tick animation branch is still not called; note the wire-side `standingLongjump` param now exists as an input but nothing sets it to true from a live per-tick standing-longjump detection) | `src/AcDream.Core/Physics/MotionInterpreter.cs:328,941,1119,1146` (flag only, no per-tick apply path); cf. `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70` (wire param exists but is a pass-through, always `false` by default) |
| 9 (supporting) | Remote-entity path dispatches via direct `SetCycle` call from `GameWindow.OnLiveMotionUpdated`, NOT via `move_to_interpreted_state` (executive summary + divergence #5/#9 in the MotionInterp section, L303-317). | Confirmed: `move_to_interpreted_state`/`MoveToInterpretedState` still does not exist anywhere except one comment reference. `OnLiveMotionUpdated` still directly manipulates `ae.Sequencer` (`SetCycle`) and does a raw bulk-copy into `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` — bypassing `adjust_motion` for remotes entirely. This is THE central mechanism relevant to the walk<->run-lag symptom: the D6-ported `adjust_motion`/`apply_raw_movement`/`get_state_velocity` triad is **local-player-only** (see doc-3 cross-check below). | **STILL-TRUE** (confirmed, and directly load-bearing for the task's (a)/(c) focus) | `src/AcDream.App/Rendering/GameWindow.cs:4517` (comment only ref to move_to_interpreted_state); `GameWindow.cs:4590,4598` (direct bulk-copy into InterpretedState, bypassing adjust_motion); `GameWindow.cs:4327-4492` (SetCycle-direct dispatch) |
| 10 (supporting) | acdream's remote velocity path bypasses `apply_current_movement` gating differences: acdream calls `PhysicsObj.OnWalkable` gate directly rather than the full retail contact/gravity flow (MotionInterp section divergence #3, L305). | `apply_current_movement` (used by BOTH local and remote via `rm.Motion.apply_current_movement` calls) still gates on `PhysicsObj.OnWalkable` directly and calls `get_state_velocity()` — same shape as described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:890-910`; called for remotes at `GameWindow.cs:10042,10074` |
---
## Doc 2: 2026-06-26-movement-animation-retail-parity-audit.md (D1-D12)
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|---|---|---|---|---|
| D1 | MoveToState raw flags not retail — presence-based instead of default-difference packing; overs-sends default values. (doc L118-130) | `MoveToState.cs` doc header now explicitly says "default-difference flags dword + conditional fields + actions" and `RawMotionState.Default` exists with retail defaults (`CurrentHoldKey=None`, `ForwardCommand=0x41000003`, `ForwardSpeed=1.0`, etc. matching doc's retail defaults table L60-72). Shipped as part of L.2b (`78e163a4`, 2026-06-30). | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/MoveToState.cs:22`; `src/AcDream.Core/Physics/RawMotionState.cs:44-58` |
| D2 | RawMotionState action-list and style packing incomplete — no full action-list packing, current-style scenarios. (doc L132-136) | `RawMotionState` now has `Actions` (IReadOnlyList<RawMotionAction>) with a doc comment describing retail's `num_actions` bits 11-15 packing. Appears substantially addressed as part of L.2b, though full conformance vs the bitfield layout (11 one-bit flags + num_actions:5) was not independently re-verified byte-for-byte this session. | **CLOSED-SINCE** (structure now present; exact bit-packing not re-verified) | `src/AcDream.Core/Physics/RawMotionState.cs:57-63` |
| D3 | MoveToState longjump bit not modeled — only contact 0/1 passed, `standing_longjump` not wired as named state. (doc L138-147) | `MoveToState.Build` now has a `standingLongjump` parameter and packs `trailing = (byte)((standingLongjump ? 0x02 : 0) \| (contact ? 0x01 : 0))` — matches doc's described retail byte layout. NOTE: the parameter is wired as an INPUT but this session did not verify any call site sets it to `true` from a live per-tick standing-longjump detection (see doc-1 item #8 above — the per-tick animation branch driving this flag is still missing). So the WIRE FORMAT is fixed; whether it's ever populated correctly is a separate, still-open question. | **CLOSED-SINCE** (wire format only; upstream detection still absent) | `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70,100-101` |
| D4 | JumpAction packet layout retail-incompatible — missing full Position, extra objectGuid/spellId fields, wrong order. (doc L149-161) | `JumpAction.cs` doc header now cites `Position::Pack (0x005a9640)` and explicitly notes the fix: "the pre-slice code had it backwards (two spurious trailing zero u32s, no Position at all)" — confirms this was found and fixed as part of L.2b. | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/JumpAction.cs:21-31` |
| D5 | Position heartbeat close but not fully proven — `NotePositionSent` stamps all three (time/position/contact-plane) on BOTH MTS and AP paths, but retail's `SendMovementEvent` (MTS) only stamps `last_sent_position_time`; only `SendPositionEvent` (AP) stamps all three. (doc L163-169) | Confirmed STILL live and explicitly re-audited: a 2026-06-30 dated comment at the MTS send site says verbatim "this is a real, audit-confirmed divergence from retail... left UNCHANGED; reported to the lead engineer instead of fixed here." This is the most concrete, freshest confirmation in the whole audit — it was re-checked via Ghidra decompile-by-address and deliberately NOT fixed. | **STILL-TRUE** (explicitly re-confirmed and deliberately deferred, not a stale claim) | `src/AcDream.App/Rendering/GameWindow.cs:8320-8339` |
| D6 | MotionInterpreter lacks canonical retail raw→interpreted normalization (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement` not ported); SideStepLeft/WalkBackward produce zero velocity; jump lateral velocity hand-patched. (doc L171-182) | **This is exactly what Doc 3 (D6 pseudocode doc) documents as SHIPPED** for the local player. Confirmed present: `adjust_motion` (`MotionInterpreter.cs:741`), `apply_run_to_command` (`:805`), `apply_raw_movement` (`:848`), `get_state_velocity` (`:639`) all exist and are called from `PlayerMovementController.cs:909,974,1014`. | **CLOSED-SINCE** (for LOCAL player only — see D7 below, this does NOT extend to remotes) | `src/AcDream.Core/Physics/MotionInterpreter.cs:639,741,805,848`; `src/AcDream.App/Input/PlayerMovementController.cs:880-1014` |
| D7 | Animation application split from retail motion flow — `AnimationSequencer` has useful pieces but `MotionInterpreter.apply_current_movement` is velocity-oriented and doesn't drive animation state through retail's order (style/forward/sidestep/turn/actions sequencing). (doc L184-188) | **THE central claim for the task's (a)/(c) focus.** Confirmed still true and now MORE precisely characterized: for remotes, `OnLiveMotionUpdated` bulk-copies `ForwardCommand`/`ForwardSpeed` directly into `InterpretedState` (bypassing `adjust_motion`), AND separately calls `ae.Sequencer.SetCycle(...)` directly for the visual cycle — two parallel, only loosely-coupled paths (one drives position/velocity via `apply_current_movement``get_state_velocity`, the other drives the visual cycle via `SetCycle`). Neither goes through `apply_interpreted_movement`'s full style/forward/sidestep/turn sequencing. Additionally there is a SEPARATE `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` mechanism (UP-velocity-derived, with a 200ms UM grace window + 4.5/5.5 m/s hysteresis bucketing) that ALSO writes `SetCycle` for the local-player-observed-remotely case — this is a THIRD parallel path, not documented in either research doc, that is very likely a major contributor to "walk<->run reacts too slowly" (its grace window + hysteresis band means a walk<->run toggle needs velocity to cross a threshold via UpdatePosition samples before the visual cycle re-buckets, since a UM may not arrive for HoldKey-only toggles per the code's own comments). | **STILL-TRUE** (confirmed and refined — root mechanism now identified precisely, more detailed than the original doc knew) | `src/AcDream.App/Rendering/GameWindow.cs:4590,4598` (bulk-copy bypass); `GameWindow.cs:5112-5176` (`ApplyServerControlledVelocityCycle`); `GameWindow.cs:5178-5300+` (`ApplyPlayerLocomotionRefinement`, grace/hysteresis constants at `:5095,5104,5110`) |
| D8 | Force-walk and MoveTo are approximations — `ServerControlledLocomotion`, `RemoteMoveToDriver`, `PlayerMovementController.BeginServerAutoWalk` approximate steering/cycle selection vs retail's full `MoveToManager` queue (pre-turn, move, aux turn, final heading, sticky targeting, progress failure, `MovementParameters::get_command`). (doc L190-196) | `ServerControlledLocomotion.cs` is still only 87 lines (a `PlanMoveToStart`/`PlanFromVelocity` helper, not a queued state machine). No pre-turn/aux-turn/sticky-target/progress-failure machinery found. | **STILL-TRUE** | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines total); `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines, still a simplified steering helper) |
| D9 | Inbound movement types 8 (TurnToObject) and 9 (TurnToHeading) dropped — `UpdateMotion` handles interpreted state and MoveTo 6/7 but not 8/9. (doc L198-207) | Confirmed: `UpdateMotion.TryParse` only branches on `movementType == 0` and `movementType is 6 or 7`; no `8`/`9` case anywhere. Grep for `TurnToObject`/`TurnToHeading` across `UpdateMotion.cs` and `GameWindow.cs` returns zero hits. The 2026-07-02 handoff doc explicitly reconfirms this live: "CONFIRMED live: mt=0x09 arrives and just SetCycle(Ready) instead of turning." | **STILL-TRUE** (independently re-confirmed via grep + corroborated by a live capture cited in the handoff doc) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:136-238` (no case 8/9) |
| D10 | Spawn-time movement state weaker than live — `CreateObject` detects movement types/flags but doesn't preserve full MoveTo target/origin/threshold data the way `UpdateMotion` does. (doc L209-213) | Confirmed with exact detail: `CreateObject.TryParseMoveToPayload` (private, 3-out-param overload: `movementParameters`, `speed`, `runRate`) SKIPS Origin/distanceToObject/minDistance/failDistance/walkRunThreshold/desiredHeading via `pos +=` advances with no output — so `CreateObject`'s returned `ServerMotionState.MoveToPath` is always `null`. By contrast `UpdateMotion.TryParseMoveToPayload` (a DIFFERENT, more complete overload) DOES populate `out CreateObject.MoveToPathData? path` with Origin/thresholds. Two parsers of the same wire shape, one materially weaker. | **STILL-TRUE** (confirmed with precise mechanism: two divergent parser overloads for the same MoveTo payload) | `src/AcDream.Core.Net/Messages/CreateObject.cs:1194-1226` (weak 3-field overload, spawn path) vs `src/AcDream.Core.Net/Messages/UpdateMotion.cs:255-331` (full path-populating overload, live-update path) |
| D11 | Sequence/autonomy data parsed then discarded — retail carries movement sequence, server-control sequence, autonomous state, motion flags, position sequence into movement application; acdream exposes only a subset. (doc L215-219) | Confirmed: `UpdateMotion.TryParse` still does a blind `pos += 6` skip over `movementSequence`(u16) + `serverControlSequence`(u16) + `isAutonomous`(u8) + align — none of these three fields are captured into the `Parsed` record or `ServerMotionState`. (Note: `WorldSession.cs` DOES track `_serverControlSequence`/`_instanceSequence`/etc., but those are the OUTBOUND sequence counters sourced from `UpdatePosition`, not the INBOUND per-entity `UpdateMotion` fields this claim is about — different mechanism entirely, do not conflate.) `_motionFlags` (byte) is read into a local var and never used beyond that line either. | **STILL-TRUE** (confirmed, and clarified that the WorldSession sequence counters some might assume closes this are unrelated) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:89-106` (skip), `:111` (`_motionFlags` read, unused past declaration) |
| D12 | Jump/falling/contact gates simplified — retail allows specific movement while falling/dead, has separate jump checks for posture/stamina/constraints/pending-motion/contact/leave-hit-ground reapplication; acdream blocks or simplifies several. (doc L221-225) | Not exhaustively re-verified line-by-line this session (large surface). Spot-checked `contact_allows_move`/`motion_allows_jump`-equivalent: the Fallen/Dead/Crouch block still lives inside `contact_allows_move` (see doc-1 item #2), which is itself evidence the gates remain simplified/misplaced relative to retail's `DoMotion`-level error codes. No `pending_motions`-driven jump eligibility check found (consistent with item #1). | **STILL-TRUE** (partially re-verified via the contact_allows_move overlap with doc-1 #2; full jump/posture/stamina gate audit not redone) | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (same evidence as doc-1 #2) |
---
## Doc 3: 2026-07-01-d6-motion-interp-pseudocode.md — cross-check of its own claims
| Claim | Current-code check | Status |
|---|---|---|
| "D6 ships `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/`get_state_velocity` in `MotionInterpreter.cs`, unifying LOCAL player velocity+turn+jump+wire onto one `RawMotionState` at `forward_speed=1.0`" | Confirmed via git log (`0f099bb6` D6.2a, `d34721fa` D6.2b) and via direct code read — all four functions exist and are wired into `PlayerMovementController.cs` only. | **STILL-TRUE (as scoped)** |
| "Out of scope for D6: RawMotionState unification — retail uses ONE raw state for both the wire and the velocity pipeline; D6 keeps the L.2b wire construction separate... unifying is a later slice" (doc L153-156) | This scoping statement is itself accurate and still holds: the wire-side `RawMotionState`/`MoveToState.Build` path and the local velocity-side `apply_raw_movement` input are DIFFERENT `RawMotionState` construction sites (confirmed by design decision #1 in the doc itself, "ONE `RawMotionState`... is built from `MovementInput`" — but this unification was the D6.2 GOAL for local-only; there is still no unification with the REMOTE/inbound path at all). | **STILL-TRUE (scoping honored, and the doc's own "later slice" is exactly the gap this task's handoff doc targets)** |
| "Out of scope for D6: turn ... acdream keeps direct-Yaw turn is replaced" / actually turn WAS ported per design decision #3 ("Turn ported to interpreted, feel unchanged... omega.Z = ±(π/2) × turn_speed... the fixed TurnRateFor direct-Yaw is replaced by the pipeline") — LOCAL only. | For remotes, `RemoteMoveToDriver.TurnRateFor` is still the mechanism (per doc's own "AP-9 stays" note) — confirmed no remote-side `adjust_motion`-sourced turn omega found; `RemoteMoveToDriver.cs` (340 lines) still exists with its own turn-rate logic, separate from `MotionInterpreter`. | **STILL-TRUE (remote turn is still the old direct-rate approximation; local turn was ported per D6 but that's a different code path)** |
---
## Synthesis: what this means for the walk<->run lag + stop-slide symptom (task focus a/b)
1. **D6 (the shipped port) does not touch the remote/inbound path at all.** Every function it
ported (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, `get_state_velocity`)
is called exclusively from `PlayerMovementController.cs` (local player). Zero call sites
from `GameWindow.cs`'s remote-entity code (`OnLiveMotionUpdated`, `_remoteDeadReckon`,
`ApplyServerControlledVelocityCycle`) invoke these D6-ported functions.
2. **The remote path bulk-copies wire commands directly into `InterpretedState`**
(`GameWindow.cs:4590,4598`), bypassing `adjust_motion` — so backward/sidestep-left
normalization and run-rate scaling never happen for remotes the retail-faithful way.
This matches the retail `copy_movement_from` bulk-copy semantics for THAT ONE STEP, but the
surrounding `pending_motions`/queue/`move_to_interpreted_state` state machine around it
(doc-1 items #1, #9) is still entirely absent.
3. **A newly-identified (not named in either doc) mechanism likely explains "reacts too
slowly":** `ApplyPlayerLocomotionRefinement` (`GameWindow.cs:5178+`) only kicks in after a
`UmGraceSeconds = 0.2` (200ms) window since the last UM, and then applies **hysteresis**
bands (`PlayerRunPromoteSpeed = 5.5f`, `PlayerRunDemoteSpeed = 4.5f`) before re-bucketing
walk↔run from UpdatePosition-derived velocity — this is architecturally exactly the kind of
mechanism that would produce a visible lag on a walk↔run toggle, especially combined with
the code's own comment that retail may not broadcast a fresh MoveToState for HoldKey-only
toggles (Shift while W held), forcing reliance on this slower velocity-inference path.
4. **D9 (types 8/9 dropped)** means any server-driven turn command mid-locomotion is silently
turned into a `SetCycle(Ready)`, which would visibly interrupt/desync locomotion — a
plausible contributor to "compounding error... running around + turning."
5. **D11 (sequence numbers discarded)** means acdream has no way to detect/reject
out-of-order or duplicate `UpdateMotion` packets, which is a plausible contributor to the
"position errors once the entity stops" (a late/duplicate packet could re-apply a stale
state after a newer one, with no sequence check to catch it).
None of the above is a fix recommendation — this is a raw claim/verification map only, per
the report-only investigation mode.

View file

@ -0,0 +1,969 @@
# Retail decomp map: INBOUND remote-entity motion pipeline
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (66 MB, Sept 2013
EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that
file (not addresses), from a Sept-2026 checkout at
`C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`.
Addresses (0x0051xxxx etc.) are also given per function for cross-reference with
`symbols.json` / Ghidra.
---
## Q1 — INBOUND ENTRY: wire message -> motion interpreter
Full call chain, outermost (network) to innermost (motion-table state machine):
```
ACSmartBox::DispatchSmartBoxEvent(NetBlob*) line 357117 (0x005595d0)
switch (opcode) {
case 0xf619: // "Movement" — the live/current movement update
SmartBox::UnpackPositionEvent(...) line 357142
if result == NETBLOB_PROCESSED_OK:
CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid)
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357154 (call), def @271370 (0x00509690)
if nonzero: cmdinterp->LoseControlToServer()
case 0xf74c: // position+movement combo, has an extra u16 seq check first
is_newer(obj->update_times[8], seq) gate line 357224
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357232
}
```
`CPhysics::SetObjectMovement` (2-arg overload used for 0xf619/0xf74c dispatch,
`__stdcall`, line 271370 / addr 0x00509690):
```
int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen,
u16 seqA, u16 seqB, bool isAutonomous)
{
isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer();
// 16-bit wraparound-aware "is this sequence number newer" compare,
// done TWICE against two independent counters obj->update_times[1]
// and obj->update_times[5]:
diff = |seqA - obj->update_times[1]| (mod 0x10000)
newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA) // wraparound rule
if not newer: return 0 // STALE PACKET, DROPPED
obj->update_times[1] = seqA
diff2 = |obj->update_times[5] - seqB|
newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old)
if not newer2: return 0 // STALE, DROPPED
obj->update_times[5] = seqB
if (!isAutonomous || !isPlayer) { // remote entity ALWAYS
// takes this branch;
// local player only
// takes it when NOT
// self-driving (server
// override / rubber-band)
obj->last_move_was_autonomous = isAutonomous
CPhysicsObj::unpack_movement(obj, &buf, bufLen) line 271423 (0x00509742)
if isPlayer: return 1 // signals caller to call LoseControlToServer
}
return 0
}
```
`CPhysicsObj::unpack_movement` (line 280179, addr 0x00512040):
```
void unpack_movement(CPhysicsObj* this, buf**, bufLen)
{
if (this->movement_manager == null)
this->movement_manager = MovementManager::Create(this, this->weenie_obj)
MovementManager::unpack_movement(this->movement_manager, buf, bufLen) line 280202
}
```
`MovementManager::unpack_movement` (line 300563, addr 0x00524440) — deserializes
the wire struct and dispatches to ONE of 10 sub-cases (`command_ids[ecx_4]` type
tag read from the first u16 in the buffer, case 0..9 via jump table @300707):
```
switch (type_tag) {
case 0: // InterpretedMotionState (RawMotionState command wrapper) -- THIS
// is the walk/run/turn/sidestep command path used for remote
// players AND monsters
InterpretedMotionState::UnPack(&ims, buf, bufLen) line 300606
// optional trailing u32 = "sticky" target object id
MovementManager::move_to_interpreted_state(this, &ims) line 300618 (0x0052457c)
if sticky_id != 0: CPhysicsObj::stick_to_object(...)
motion_interpreter->standing_longjump = (type_tag & 0x200)
return 1
case 6: // MoveToObject
Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject)
motion_interpreter->my_run_rate = <wire float>
CPhysicsObj::MoveToObject(physics_obj, target_guid, &params) line 300644
case 7: // MoveToPosition
similar; CPhysicsObj->my_run_rate set from wire; then
MoveToManager::MoveToPosition(...) line 300659
case 8: // TurnToObject
case 9: // TurnToHeading
-> MoveToManager::TurnToHeading / handled via MoveToManager
}
```
**For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0
(`InterpretedMotionState::UnPack` + `move_to_interpreted_state`) is the relevant
path.** This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape
as the client's own `RawMotionState`/interp state, containing
`current_style`, `forward_command`, `forward_speed`, `sidestep_command/speed`,
`turn_command/speed`, plus a list of pending server "actions"
(`context_id`/`action_stamp` pairs used for jump-charge/attack acknowledgement,
NOT used for normal walk/run).
`MovementManager::move_to_interpreted_state` (line 300259, addr 0x00524170):
```
void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims)
{
if (motion_interpreter == null) {
motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj)
CMotionInterp::enter_default_state(motion_interpreter)
}
CMotionInterp::move_to_interpreted_state(motion_interpreter, ims) line 300272
}
```
`CMotionInterp::move_to_interpreted_state` (line 305936, addr 0x005289c0) — THE
entry point that turns a wire InterpretedMotionState into an actual motion-table
transition:
```
int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims)
{
if (physics_obj == null) return 0
this->raw_state.current_style = ims->current_style
CPhysicsObj::interrupt_current_movement(physics_obj)
bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command)
InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims) // <-- OVERWRITES
// forward/side/turn
// command+speed wholesale,
// line 293301
CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...)) line 305949
// then replay any queued server "actions" (jump charge etc.) whose
// action_stamp is newer than server_action_stamp — sequence-wraparound
// compare identical in shape to the SetObjectMovement 0x7fff test above
for (action in ims->actions) {
if (newer(action.stamp, this->server_action_stamp)) {
this->server_action_stamp = action.stamp
CMotionInterp::DoInterpretedMotion(this, action.motion, &params)
}
}
return 1
}
```
**KEY: `copy_movement_from` is a flat field-by-field OVERWRITE of the
InterpretedMotionState (forward_command, forward_speed, sidestep_*, turn_*,
current_style) — there is no "diff the old vs new command" step here.** The
actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN,
inside `CMotionTable::GetObjectSequence`, when `apply_current_movement` ->
`apply_interpreted_movement` -> `DoInterpretedMotion` is called with the new
`forward_command`/`forward_speed`.
---
## Q2 — TRANSITION: walk<->run while already moving
`CMotionInterp::apply_current_movement` (line 305838, addr 0x00528870):
```
void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag)
{
if (physics_obj == null || !initted) return
isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer()
if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj))
return apply_raw_movement(this, forceFlag, jumpFlag) // LOCAL player path
return apply_interpreted_movement(this, forceFlag, jumpFlag) // REMOTE entity path
}
```
`movement_is_autonomous` just returns `physics_obj->last_move_was_autonomous`
(set by `SetObjectMovement` above — for a genuinely-remote object this flag is
always false relative to the LOCAL viewer, so remote players/monsters always
take the `apply_interpreted_movement` branch.)
`CMotionInterp::apply_interpreted_movement` (line 305713, addr 0x00528600) — the
per-command dispatcher that turns the bookkeeping InterpretedMotionState fields
back into individual `DoInterpretedMotion` calls:
```
void apply_interpreted_movement(CMotionInterp* this, int a, int b)
{
if physics_obj == null: return
if interpreted_state.forward_command == RUN_FORWARD (0x44000007):
this->my_run_rate = interpreted_state.forward_speed // caches server-echoed run rate
DoInterpretedMotion(this, interpreted_state.current_style, {}) // style (stance) first
if (!contact_allows_move(this, interpreted_state.forward_command)) {
DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {})
} else if (standing_longjump) {
DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {})
StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {})
} else {
DoInterpretedMotion(this, interpreted_state.forward_command, {}) // <-- WALK/RUN COMMAND
if interpreted_state.sidestep_command == 0:
StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {})
else:
DoInterpretedMotion(this, interpreted_state.sidestep_command, {})
}
if interpreted_state.turn_command != 0:
DoInterpretedMotion(this, interpreted_state.turn_command, {})
return // early return — no idle-stop check runs this frame
if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0)
add_to_queue(this, ctx=0, READY_STANCE, tickCount)
}
```
So a wire "run instead of walk" update decays into exactly one
`CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed})`
call (or `WALK_FORWARD`) — a single call with the SAME semantics as the local
input path, not a special "speed-changed" fast path at this layer.
`CMotionInterp::DoInterpretedMotion` (line 305575, addr 0x00528360):
```
uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p)
{
if physics_obj == null: return 8
if (contact_allows_move(this, motion)) {
if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to
ApplyMotion-only path)
if motion == 0x40000011 /* some "cancel" motion */:
CPhysicsObj::RemoveLinkAnimations(physics_obj) // <-- flush queued link anims
result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p) // -> CPartArray -> MotionTableManager
if result == 0:
jumpAllowed = ...
add_to_queue(this, p->context_id, motion, jumpAllowed)
if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p)
} else if (motion & 0x10000000) == 0:
label_528440:
if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...)
result = 0
else:
result = 0x24 // motion rejected (e.g. mid-air command not allowed)
if (physics_obj != null && physics_obj->cell == 0)
CPhysicsObj::RemoveLinkAnimations(physics_obj) // detached-from-world guard
return result
}
```
`CPhysicsObj::DoInterpretedMotion` -> `CPartArray::DoInterpretedMotion` -> packs
a `MovementStruct{type=InterpretedCommand}` and calls
`MotionTableManager::PerformMovement`, which for `InterpretedCommand` calls:
```
if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks))
MotionTableManager::add_to_queue(this, motion, outTicks, sequence) // queues in
// pending_animations
// (DIFFERENT list
// from CMotionInterp's
// pending_motions!)
```
`CMotionTable::DoObjectMotion` is a thin wrapper for
`CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0)`.
### `CMotionTable::GetObjectSequence` — THE cycle-swap decision (line 298636, addr 0x00522860)
This is the true state machine that decides append-vs-replace-vs-fast-path. Given
`new_substate = motion & 0xffffff` bucketed by which high bit is set on `motion`:
**Bit `0x40000000` set — a normal "cycle" motion (this is what WALK/RUN commands
carry, e.g. `0x44000007` RunForward, `0x45000005` WalkForward):**
```
cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff))
if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state):
// *** THE SAME-CYCLE FAST PATH ***
if (new_substate == state->substate // SAME logical
&& same_sign(new_speed, state->substate_mod) // command (walk OR run
&& CSequence::has_anims(sequence)) { // stays walk, or run stays
// run — direction unchanged)
// AND a cycle is already
// playing
change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed) // rescale playback rate
subtract_motion(sequence, cycleData, state->substate_mod) // remove OLD velocity contribution
combine_motion(sequence, cycleData, new_speed) // add NEW velocity contribution
state->substate_mod = new_speed
return 1 // <-- NO new CSequence nodes appended. Same AnimSequenceNode
// keeps playing; only its playback-rate + the CSequence's
// cached velocity/omega vectors change.
}
// *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT
// substate id, not same-sign-same-substate) — LINK TRANSITION PATH ***
linkAnim = CMotionTable::get_link(table, state->style, state->substate,
state->substate_mod, new_substate, new_speed)
if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) {
// no direct link authored, OR direction reversed: route through the
// style's registered "default"/rest substate as an intermediate hop
defaultSubstate = style_defaults[state->style]
linkAnim = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0)
linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed)
}
CSequence::clear_physics(sequence) // zero cached velocity/omega — see Q6/Q7
CSequence::remove_cyclic_anims(sequence) // drop any still-looping cycle node(s)
add_motion(sequence, linkAnim, 1.0-or-substate_mod) // append the transition ("link") anim node(s)
add_motion(sequence, linkAnim2, new_speed) // (if double-hop via default state)
add_motion(sequence, cycleData, new_speed) // append the NEW cyclic anim, marked cyclic
state->substate = new_substate
state->substate_mod = new_speed
CMotionTable::re_modify(table, sequence, state) // re-apply any active modifiers (e.g. sidestep)
// on top of the new chain
*outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1
return 1
```
**Answering the prompt's explicit sub-question (a):** DoInterpretedMotion on a
speed change does **NOT always reuse the same cycle**. It depends on whether the
new command maps to the SAME `substate` id as the currently-playing one:
- Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 ->
WalkForward speed 1.0, or RunForward at any two different `forward_speed`
values) hits the **fast path**: `change_cycle_speed` + `subtract_motion`/
`combine_motion` — same `AnimSequenceNode` object, just re-timed and
re-weighted. No new node, no restart.
- **Walk<->Run is a substate CHANGE (`0x45000005` WalkForward vs
`0x44000007` RunForward are different substate ids)**, so it does NOT hit
the fast path. It goes through the **link-transition path**: `get_link`
looks up an authored transition animation (a short blend clip, e.g.
walk-to-run or run-to-walk) between the two substates; that link node(s)
are appended to the sequence via `add_motion`, followed by the new cyclic
node. The OLD cyclic node is dropped (`remove_cyclic_anims`). Playback then
proceeds: link anim plays first (non-cyclic, finite frames), and once it
completes the `CSequence::update_internal` advance mechanism moves
`curr_anim` forward in the `anim_list` to the next node — the new cyclic
walk/run anim — automatically (see Q4).
**Sub-question (b): the CSequence node list.** `CSequence::anim_list` is a
doubly-linked list (`DLListBase` of `AnimSequenceNode`), NOT a single
"QueuedAnimations" array. `add_motion` -> `CSequence::append_animation` (line
301777) creates one new `AnimSequenceNode` per `MotionData::anims[i]` entry and
`DLListBase::InsertAfter`s it at the tail. `this->first_cyclic` marks where the
cyclic (looping) portion of the list begins; `remove_cyclic_anims` trims
everything from `first_cyclic` onward when a new transition starts (so
`clear_physics` + `remove_cyclic_anims` together mean: "keep any link anim
that's mid-playback [it's before first_cyclic], but throw away the old loop").
`curr_anim` points at the node currently being played; `CSequence::update`
advances `frame_number` within `curr_anim` and, in `apricot()`, walks
`curr_anim` forward through the list once frames are exhausted for a node,
discarding fully-consumed non-cyclic nodes from the front of the list up to
`first_cyclic`.
**Sub-question: is there blending?** No cross-fade/blend in the graphics sense.
It's sequential: `link_anim -> cyclic_anim`, back-to-back play, and the
crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this
codebase means the `CSequence.velocity`/`omega` accumulators (float vectors)
are algebraically combined (`combine_motion`/`subtract_motion`/`add_motion`
add or subtract scaled contributions) — that's a physics-level blend of
velocity, not a skeletal pose blend.
**Sub-question: is there an immediate speed change?** Only in the same-substate
fast path (`change_cycle_speed`+`subtract_motion`+`combine_motion` all happen
synchronously inside `GetObjectSequence`, i.e., on the SAME frame the wire
message is processed — no interpolation of speed itself). For walk<->run
(different substate), the VISIBLE speed change is gated behind the link anim's
playback duration — velocity is whatever `CSequence.velocity` currently holds
(the link anim's own authored velocity/omega, added via `add_motion`), and only
once the cyclic node becomes current does the full run/walk cyclic velocity
apply.
### `same_sign` (line 298253, addr 0x00522260) — verbatim
```
int same_sign(float a, float b) {
// true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
// this is the "is direction unchanged" test used to gate the same-cycle
// fast path and to decide whether get_link needs a sign-aware lookup.
return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches)
}
```
### `change_cycle_speed` (line 298276, addr 0x00522290) — verbatim constant
```
void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002
CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate
else if (fabs(newSpeed) >= 0.000199999995f)
CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0)
// else: both ~0, no-op
}
```
This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied
to every node from `sequence->first_cyclic` onward
(`AnimSequenceNode::multiply_framerate`, line 302425) — so a walk<->walk speed
change (same substate) scales animation playback speed proportionally to the
commanded speed ratio, and ALSO swaps `low_frame`/`high_frame` if the new
multiplier is negative (playing the cycle backward).
---
## Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle
**There are TWO distinct pending-queues, easy to conflate:**
1. **`CMotionInterp::pending_motions`** (singly-linked `LListData`, fields:
`[next, context_id, motion, jumpAllowedFlag]`). Owner: `CMotionInterp`.
Appended by `CMotionInterp::add_to_queue` (line 305032, addr 0x00527b80) —
called from `DoInterpretedMotion` (line 305607), `StopInterpretedMotion`
(line 305657), `apply_interpreted_movement`'s idle-stop path (line 305775),
and `StopCompletely` (line 305227). Popped ONLY by
`CMotionInterp::MotionDone` (line 305238, addr 0x00527ec0):
```
void MotionDone(CMotionInterp* this, int arg2) {
if (physics_obj == null) return
head = pending_motions.head_
if (head != null) {
if (head->motion & 0x10000000) { // this queued motion carried
// a server "action" (jump
// charge etc.)
CPhysicsObj::unstick_from_object(physics_obj)
InterpretedMotionState::RemoveAction(&interpreted_state)
RawMotionState::RemoveAction(&raw_state)
}
pop head off pending_motions (delete node)
}
}
```
`CMotionInterp::motions_pending()` (line 305322) == `pending_motions.head_ != null`.
**`context_id`/`action_stamp` on this queue is used for jump-charge and
other server-acknowledged "actions", NOT for ordinary walk/run — ordinary
`DoInterpretedMotion` calls still push a node here (so `motions_pending()`
reflects "any interpreted motion is mid-flight"), but nothing about
walk<->run reads the `context_id`/action semantics.**
2. **`MotionTableManager::pending_animations`** (doubly-linked `DLListBase`,
fields: `[motion_id, tickCount]`), plus `MotionTableManager::animation_counter`
(running decrement counter). Owner: `MotionTableManager` (one per
`CPartArray`, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame
-level completion tracker, distinct from #1's motion-command-level tracker).
Appended by `MotionTableManager::add_to_queue` (line 290854, addr
0x0051bfe0) every time `GetObjectSequence` succeeds, storing the `outTicks`
value it returned (how many more discrete animation "steps"/frames worth of
non-cyclic content remain before this motion is fully consumed). Also
immediately calls `remove_redundant_links` (line 290771) to prune
already-queued-but-superseded link-transition entries (see Q2 note on
walk<->run spam).
**Consumption / popping — TWO drivers:**
- **Per-tick poll:** `MotionTableManager::CheckForCompletedMotions` (line
290645, addr 0x0051be00), called every physics tick via
`CPartArray::HandleMovement` -> `MotionTableManager::UseTime` (alias for
`CheckForCompletedMotions`, line 290845) from
`CPhysicsObj::UpdateObjectInternal` (line 283748). Walks
`pending_animations` from the head while `tickCount == 0`, firing
`CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)` for each and
removing action-heads (`MotionState::remove_action_head`) if the
`0x10000000` bit is set.
- **Anim-hook driven:** `CPhysicsObj::Hook_AnimDone` (line 277845, addr
0x0050fda0) — registered as a `CAnimHook` fired by
`CSequence::execute_hooks` (line 300780) when a specific animation FRAME
carries a hook whose `direction_` matches playback direction. Calls
`CPartArray::AnimationDone(1)` -> `MotionTableManager::AnimationDone(1)`
(line 290558, addr 0x0051bce0), which increments `animation_counter` and
pops every `pending_animations` entry whose `tickCount <= animation_counter`
(decrementing the counter by each popped entry's `tickCount`, i.e. a
running-total consumption model, not a strict per-frame countdown).
- **Synchronous, post-dispatch:** `CMotionInterp::PerformMovement` (line
306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw
input path (`MovementManager::PerformMovement` cases 0-4) — calls
`CPhysicsObj::CheckForCompletedMotions` immediately after every
`DoMotion`/`DoInterpretedMotion`/`StopMotion`/`StopInterpretedMotion`/
`StopCompletely` dispatch (line 306234/241/248/255/262), so a
zero-duration motion completes in the SAME frame it was issued rather
than waiting for the next tick. This path is NOT used by the wire/remote
entry (`CMotionInterp::apply_interpreted_movement` calls
`DoInterpretedMotion`/`StopInterpretedMotion` directly without a following
`CheckForCompletedMotions` — the remote entity therefore only gets its
completions serviced by the per-tick poll and the anim-hook path, not the
synchronous one).
`CPhysicsObj::MotionDone(physics_obj, motion_id, arg3)` (line 277856, addr
0x0050fdb0) -> `MovementManager::MotionDone` (line 300396) ->
`CMotionInterp::MotionDone` (line 305238, described in #1 above). **This is
the bridge between the two queues**: a `MotionTableManager`-level
animation-frame completion cascades UP into popping the
`CMotionInterp`-level command queue.
**Callback / state change on completion:** popping `pending_motions` only (a)
optionally clears the "stuck to object" state + removes a pending server action
if the popped node had the `0x10000000` "carries an action" bit, and (b) frees
the node. It does NOT itself touch velocity, `substate`, or the `CSequence`
node list — those were already mutated synchronously back when
`GetObjectSequence` ran (at command-ISSUE time, not command-COMPLETE time).
**The animation-frame-level completion (`AnimationDone`/`CheckForCompletedMotions`)
is what actually matters for gameplay feel: it's what lets a queued
non-cyclic link anim naturally hand off to the next queued node (see Q4) and
what lets a "jump" or other single-shot server action be acknowledged as
finished.**
---
## Q4 — CYCLE SWAP: frame index carryover vs restart vs link
Per `CSequence::append_animation` (line 301777, addr 0x00525510):
```
void append_animation(CSequence* this, AnimData* animData) {
node = new AnimSequenceNode(animData)
if (!node->has_anim()) { delete node immediately; return } // degenerate/empty motion, skip
DLListBase::InsertAfter(&anim_list, node, anim_list.tail_) // always appended at TAIL
this->first_cyclic = node // *** every appended node
// becomes the new
// first_cyclic marker
// until superseded ***
if (curr_anim == null) { // sequence was idle/empty
curr_anim = anim_list.head_
frame_number = curr_anim->get_starting_frame()
}
// if curr_anim was already non-null (something mid-playback), it is
// left untouched — the newly appended node just waits at the tail.
}
```
So **appending never resets `frame_number` for whatever's currently playing.**
The frame index of the CURRENTLY playing node (the link anim, or the old cycle
if it's still `curr_anim`) is untouched.
`CSequence::update` (line 302402, addr 0x00525b80):
```
void update(CSequence* this, double dt, Frame* outDelta) {
if (anim_list.head_ != null) {
CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta)
CSequence::apricot(this) // list-trim housekeeping (below)
} else if (outDelta != null) {
CSequence::apply_physics(this, outDelta, dt, dt) // PURE velocity integration,
// no animation nodes at all
}
}
```
`update_internal` (line 301839, addr 0x005255d0) is heavily x87-obfuscated in
this decompile (unresolvable float compares/branches show as raw
`/* unimplemented */` FPU op comments) — the BN decompiler could not fully
recover its control flow. What IS recoverable: it advances `frame_number`
within `curr_anim` by `dt * anim->framerate`-derived amount, and once a node's
frames are exhausted it walks `curr_anim` to `AnimSequenceNode::GetNext(...)`
(confirmed indirectly via `apricot`'s cleanup logic below and via
`CSequence::execute_hooks`/`multiply_cyclic_animation_fr` operating on
"`first_cyclic` onward" — i.e., cyclic nodes loop in place by wrapping
`frame_number`, non-cyclic/link nodes advance `curr_anim` to the next list
node when frames are exhausted).
`CSequence::apricot` (line 300978, addr 0x00524b40) — the list-trim called every
`update()`:
```
void apricot(CSequence* this) {
i = (anim_list.head_ != null) ? adjustedHead : null
if (i != curr_anim) {
while (i != first_cyclic) {
// unlink node i from anim_list (both directions), delete it,
// then advance i to the new head
... unlink + delete ...
i = new head
if (i == curr_anim) break
}
}
}
```
i.e., **once `curr_anim` has moved past the head of the list (a node finished
playing), `apricot` deletes every now-stale node from `anim_list.head_` up to
(but not including) `first_cyclic`.** This is standard "consume finished
one-shot link anims off the front of the queue" behavior.
**Direct answer:** the frame index does **NOT carry over between the OLD cycle
and the NEW cycle** — they are different `AnimSequenceNode` objects wrapping
different `CAnimation` data with independently-tracked start frames
(`AnimSequenceNode::get_starting_frame()`). What DOES carry over/continue
smoothly is:
- the **link animation plays out fully first** (its own authored frame range,
from `get_starting_frame()` to its end), because it was appended to the
tail and `curr_anim` only advances once the current node's frames are
exhausted (via `update_internal`'s internal advance, not `apricot`, which
is just cleanup).
- once the link anim's frames are exhausted, playback naturally proceeds to
the next node in `anim_list` (the newly appended cyclic walk/run node),
which starts fresh at ITS `get_starting_frame()`.
- So the transition literally IS the link animation: **walk -> run uses an
authored transition clip in between**; there is no cross-fade of the walk
cycle's frame position into the run cycle's frame position. The retail art
pipeline authors these link/transition clips specifically so this hard
swap looks continuous.
- If `get_link` found NO authored link for this style/substate pair (the
`linkAnim == null` branch in `GetObjectSequence`), the code instead hops
through the style's "default" (idle/ready) substate as an intermediate —
two link anims chained — rather than doing a raw cut.
- `change_cycle_speed`'s `multiply_cyclic_animation_fr` (called ONLY on the
same-substate fast path) operates on `this->first_cyclic` onward, i.e., it
re-times whatever is the CURRENT cyclic node in place — it does not touch
frame_number's absolute position within that node, only its rate of
advance, so a walk-speed-change (not walk<->run) preserves the current
frame's phase, just plays faster/slower/backward from there.
---
## Q5 — POSITION DRIVE between inbound packets
Confirmed by tracing `CPhysicsObj::update_object` -> `UpdateObjectInternal` ->
`UpdatePositionInternal` -> `CPartArray::Update` -> `CSequence::update`:
```
CPhysicsObj::update_object(CPhysicsObj* this) line 283950, addr 0x00515d10
{
... skip if parented/no-cell/frozen ...
dt = Timer::cur_time - this->update_time
if dt < 0.000199999995f: return // EPSILON, same constant as change_cycle_speed
if dt < 2.0:
UpdatePositionInternal-chain for the whole dt in one call
else:
// clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0,
// then one final UpdateObjectInternal(remainder) call — prevents a
// huge single-frame teleport after e.g. a stall/loading hitch
while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 }
UpdateObjectInternal(this, remaining)
}
CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt) line 283611, addr 0x005156b0
{
... early-outs for ethereal/off-world states, still runs particle/script update ...
if (this->cell != 0) {
var deltaFrame = {identity}
UpdatePositionInternal(this, dt, &deltaFrame) // <-- computes the candidate move
if (has spheres / real collision geometry) {
if (deltaFrame == zero-delta) {
set_frame(this, &deltaFrame); cached_velocity = 0
} else {
heading update (velocity-derived or state-flag-derived)
CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0) // <-- FULL
// COLLISION
// SWEEP,
// same
// machinery
// as local
// player
// movement
if (result == null) {
set_frame(this, &deltaFrame) // blocked entirely -> stays put, but frame still applied??
// (this branch means find_valid_position failed to
// produce a transition object; effectively a no-collision
// passthrough for objects without real spheres)
cached_velocity = 0
} else {
cached_velocity = (result->sphere_path.curr_pos - m_position) / dt // ACTUAL POST-COLLISION
// velocity, NOT the
// raw commanded one
SetPositionInternal(this, result)
}
}
} else {
// no collision spheres on this part array: apply frame directly, no sweep
set_frame(this, &deltaFrame); cached_velocity = 0
}
DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement
(== MotionTableManager::UseTime
== CheckForCompletedMotions) /
PositionManager::UseTime
}
}
CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta) line 280817, addr 0x00512c30
{
if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta) // <-- FILLS outDelta
// via CSequence::update
if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt)
// (server position-correction blend, see Q6)
Frame::combine(outDelta, &this->m_position.frame, outDelta) // outDelta = currentFrame (+) outDelta
if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta) // gravity/step physics on top
CPhysicsObj::process_hooks(this) // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position
}
CPartArray::Update(CPartArray* this, float dt, Frame* outDelta) line 285883, addr 0x00517db0
{
CSequence::update(&this->sequence, dt, outDelta) // exactly the branch described in Q4:
// animation-node-consumption path OR
// pure apply_physics(velocity*dt) fallback
}
```
**Direct answer: BOTH, and they are the SAME code path, not two competing
sources.** `CSequence::update` chooses between:
(a) **animation-node consumption** (`update_internal`) when `anim_list` is
non-empty — this advances frames AND, per-node, the per-frame position
delta baked into the `AnimFrame` data (`get_pos_frame`/`get_part_frame`)
contributes to the produced `outDelta` Frame (the x87-obscured part of
`update_internal`, but its role is confirmed by `AnimSequenceNode::get_pos_frame`
/ `get_part_frame` existing specifically to fetch per-frame authored
pose+position data), and
(b) **`apply_physics`** (pure `outDelta.origin += dt * this->velocity;
outDelta.rotate(dt * this->omega)`) when `anim_list` is EMPTY (i.e. a
pure-interpreted-velocity idle/moving state with no queued transition
animations left) — this is the steady-state "walking/running in a
straight line between server packets" case for a LOOPING cyclic anim once
its own list bookkeeping considers it "done producing new nodes" — but
note `has_anims()` / `anim_list.head_ != null` is true whenever there's
ANY node (including the still-looping cyclic one), so in practice, for a
normal walk/run cycle, path (a) is what's active essentially always;
path (b) is the true-idle / "no motion data at all, just raw velocity"
fallback (e.g. after `StopCompletely` clears everything, or for
objects that were never given a motion table).
Either way, the output Frame delta is what feeds `Frame::combine` against the
CURRENT position, and the combined candidate then goes through the FULL
`CPhysicsObj::transition` collision sweep — remote entities are
collision-checked every tick exactly like the local player, they are not
simply "teleported" along a straight line. `cached_velocity` (used for e.g.
UI/physics queries, NOT for driving the next tick's move — the next tick
re-derives everything from `CSequence` state) is the ACTUAL post-collision
displacement/dt, which can differ from the commanded interpreted velocity if
a wall was hit.
---
## Q6 — CORRECTION: reconciling inbound position updates
Two independent correction paths were located; both are called from
`UpdatePositionInternal`/its callers, gated by whether the wire message
carried a full `Position` update or just a motion-command update:
1. **`PositionManager::adjust_offset`** (called every tick from
`UpdatePositionInternal`, line 280857) — blends a stored "we're behind
where we should be" offset into the per-tick delta over time, i.e. a
position-manager-owned soft-correction/interpolation smoothing layer
(`PositionManager::UnStick`/`StopInterpolating`/`IsInterpolating`/
`IsFullyConstrained`/`GetStickyObjectID` are its other exposed operations —
all wrapped 1:1 through `CPhysicsObj::unstick_from_object`,
`StopInterpolating`, `IsInterpolating`, `IsFullyConstrained`,
`get_sticky_object_id`). The named-retail decompile does not expose
`PositionManager::adjust_offset`'s internal body in this file (its class
implementation lives outside the traced call chain reached in this pass);
what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates
the same `Frame* outDelta` that `CPartArray::Update`/`CSequence::update`
just wrote, i.e. it's a correction applied ON TOP OF the
animation/velocity-driven delta, before that delta is combined with
current position and swept for collision. This is the retail equivalent of
"dead-reckoning error absorbed gradually into the next frame's move" rather
than a hard position snap.
2. **Full snap path**: when `0xf74c`/`0xf619` carries not just a motion
command but also a fresh authoritative `Position` (the position+movement
combo case, or `MoveToObject`/`MoveToPosition` in `unpack_movement`'s cases
6/7), the code calls `CPhysicsObj::SetPositionInternal` (line 283892, addr
0x00515bd0) via the `MoveToManager`/`CPhysicsObj::MoveToObject`/
`SetScatterPositionInternal` machinery — this is a direct authoritative
`Position` set (through `AdjustPosition` + `CheckPositionInternal` +
`handle_all_collisions`), i.e. a hard reposition/snap when the server
sends a full position rather than only a motion-state delta. `unpack_movement`
case 0 (the plain `InterpretedMotionState`, used for ordinary walk<->run)
does NOT carry a `Position` at all — it only ever updates the motion
command/speed and lets local dead-reckoning (`CSequence`-driven `update` +
collision sweep, per Q5) carry the position forward until the next
authoritative position or motion packet arrives. There is no visible
"snap-if-error-exceeds-threshold" constant found in the traced functions
in this pass — the correction is structurally continuous
(`adjust_offset` blended every tick) rather than threshold-triggered,
based on what's directly observable in this file.
---
## Q7 — STOP: motion -> ready/stand
Stopping is **not special-cased outside the normal `GetObjectSequence`
machinery** — it is routed through the exact same link-transition logic as any
other substate change, targeting the style's registered idle/rest substate.
Entry points, both eventually reaching `CMotionTable::StopSequenceMotion`
(line 298954, addr 0x00522fc0):
```
CMotionInterp::StopInterpretedMotion(this, motion, params) line 305635, addr 0x00528470
-> if contact_allows_move fails OR standing_longjump-with-jump-motion:
just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no
physical stop is even attempted (e.g. can't "stop turning" mid-air the same way)
-> else:
CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params)
-> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand)
-> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks)
-> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks)
if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result)
InterpretedMotionState::RemoveMotion(&interpreted_state, motion) // clears forward_command
// back to 0x41000003 READY
```
`CMotionTable::StopSequenceMotion` (line 298954, addr 0x00522fc0):
```
int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) {
*outTicks = 0
if ((motion & 0x40000000) != 0 && motion == state->substate) {
// stopping the MAIN cycle (forward walk/run, not a modifier like
// sidestep): look up the style's default (idle/ready) substate and
// re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition
// to idle", full link-anim machinery applies (Q2/Q4)
defaultSubstate = style_defaults[state->style]
return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1)
}
if ((motion & 0x20000000) != 0) {
// stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of
// the base cycle rather than replacing it): find the modifier's
// MotionData and directly SUBTRACT its velocity/omega contribution
for (m in state->modifier_head-list) {
if (m.motion == motion) {
modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion)
if (modData != null) {
subtract_motion(seq, modData, m.speed_mod) // <-- direct velocity/omega
// subtraction, NO link anim,
// NO node changes — this IS
// how e.g. releasing sidestep
// while still running removes
// just the sideways component
MotionState::remove_modifier(state, m, prev)
return 1
}
break
}
}
}
return 0
}
```
**Velocity zeroing:** happens in TWO places depending on stop type:
- Main-cycle stop (walk/run -> ready): via `GetObjectSequence`'s
link-transition branch, which unconditionally calls
`CSequence::clear_physics(sequence)` BEFORE appending the new link+cycle
chain — `clear_physics` (line 301194, addr 0x00524d50) zeroes
`sequence->velocity` and `sequence->omega` outright, then the new link
anim's OWN authored velocity/omega (if any) is added back via
`add_motion`. So there IS a hard zero, immediately followed by
re-population from the transition-to-idle clip's own baked
velocity/omega (typically ~0 for a stand/ready clip, hence "stop").
- Modifier stop (sidestep/turn release): `subtract_motion` directly removes
exactly that modifier's contribution (scaled by its `speed_mod`) from the
still-nonzero base-cycle velocity — no full zero, no `clear_physics` call,
because the base cycle (e.g. still running) keeps its own velocity intact.
**Stop/link animation:** YES — the idle-entry is itself an authored `get_link`
transition clip from the current substate to the style's default substate,
exactly like any other substate-to-substate transition (Q2/Q4). There is no
"instant freeze frame"; retail plays a deceleration/stop clip.
**Residual-sliding prevention:** because `clear_physics` zeroes
`sequence->velocity`/`omega` at the moment the stop-transition is initiated
(not merely when the stop ANIMATION finishes), the `apply_physics`/animation
per-frame delta stops contributing translation from THAT frame onward except
whatever the stop-link-clip's own authored motion data supplies via
`add_motion(seq, linkAnim, ...)` immediately after the clear. So there's no
"physics keeps sliding while the stop anim plays" bug window — the only motion
during the stop-link clip is whatever the clip's OWN keyframed velocity says
(typically small/decelerating by design), and once the link clip's frames are
exhausted and playback reaches the (typically near-static) idle cyclic node,
velocity is whatever that idle cycle's own `add_motion(..., cycleData, speed)`
contributed (near zero for a proper "Ready"/idle motion).
Additionally: `CPhysicsObj::RemoveLinkAnimations` (-> `CPartArray::HandleEnterWorld`
which is really "flush the motion table manager's queued link anims", called
from multiple guard points: whenever `physics_obj->cell == 0` inside both
`DoInterpretedMotion` and `StopInterpretedMotion`'s tail, from `HitGround`,
`LeaveGround`, and from `move_to_interpreted_state`'s caller context indirectly)
provides a hard-reset safety valve: if the object leaves the world/cell
mid-transition, any queued link-transition chain is discarded outright rather
than left dangling.
---
## Verbatim float constants collected in this pass
| Constant | Where | Meaning |
|---|---|---|
| `0.000199999995f` (~0.0002) | `change_cycle_speed` (298276), `CPhysicsObj::update_object` dt-epsilon (283950 area), `CPhysicsObj::SetTranslucency` (279489) | Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. |
| `2.0` (dt seconds) | `CPhysicsObj::update_object` (~284009) | Large-dt chunking threshold: any single `update_object` gap >= 2.0s is stepped in 1.0s `UpdateObjectInternal` slices to avoid one huge teleport-y integration step. |
| `1.0` (dt seconds) | same function | Per-slice step size used while chunking large dt. |
| `1.25f` | `CMotionInterp::get_state_velocity` (305160) | Sidestep speed multiplier when computing "logical state velocity" (`sidestep_speed * 1.25f`) — used for e.g. UI/AI queries, not the actual CSequence velocity. |
| `1.5f` | `CMotionInterp::apply_run_to_command` (305062), motion `0x6500000d` (TURN) case | Speed multiplier applied to turn commands. |
| `3f` / `-1f*3f` | `CMotionInterp::apply_run_to_command`, motion `0x6500000f` (SIDESTEP) case | Sidestep speed is clamped/scaled to exactly `+-3.0` depending on sign of the run-rate-scaled input (with sign preserved via the `x87_r7 < 0` branch). |
| `3.11999989f` (~3.12) | `CMotionInterp::get_state_velocity` (305176) | Walk-forward (`0x45000005`) logical-velocity multiplier. |
| `4f` | `CMotionInterp::get_state_velocity` (305180) | Run-forward (`0x44000007`) logical-velocity multiplier. |
| `96f` | `CPhysicsObj::update_object` (283974, player_distance gate) | Distance (world units, ~yards? — needs unit confirmation) beyond which a different `set_active` path is taken for a non-player-object relative to the player. |
| `0.100000001f` (0.1) | `CPhysicsObj::set_elasticity` (277817) | Elasticity clamp floor — unrelated to motion but shares the file region. |
---
## Function/line index (quick lookup for a synthesis pass)
| Symbol | Line | Addr | Role |
|---|---|---|---|
| `ACSmartBox::DispatchSmartBoxEvent` | 357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) |
| `CPhysics::SetObjectMovement` (stdcall) | 271370 | 0x00509690 | Sequence-number staleness gate, dispatch |
| `CPhysicsObj::unpack_movement` | 280179 | 0x00512040 | Lazily creates MovementManager, forwards |
| `MovementManager::unpack_movement` | 300563 | 0x00524440 | Deserializes wire struct, 10-way type switch |
| `MovementManager::move_to_interpreted_state` | 300259 | 0x00524170 | Lazy-create CMotionInterp, forward |
| `CMotionInterp::move_to_interpreted_state` | 305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions |
| `InterpretedMotionState::copy_movement_from` | 293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) |
| `CMotionInterp::apply_current_movement` | 305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path |
| `CMotionInterp::apply_interpreted_movement` | 305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot |
| `CMotionInterp::DoInterpretedMotion` | 305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue |
| `CPhysicsObj::DoInterpretedMotion` | 276348 | 0x0050ea70 | Thin forward to CPartArray |
| `CPartArray::DoInterpretedMotion` | 286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager |
| `MotionTableManager::PerformMovement` | 290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely |
| `CMotionTable::DoObjectMotion` | 300045 | 0x00523e90 | -> GetObjectSequence(force=0) |
| `CMotionTable::GetObjectSequence` | 298636 | 0x00522860 | **THE cycle-swap/append/fast-path decision** |
| `same_sign` | 298253 | 0x00522260 | Direction-unchanged test |
| `change_cycle_speed` | 298276 | 0x00522290 | Same-cycle playback-rate rescale |
| `CMotionTable::get_link` | 298552 | 0x00522710 | Authored transition-anim lookup |
| `add_motion` / `combine_motion` / `subtract_motion` | 298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out |
| `CSequence::append_animation` | 301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump |
| `CSequence::clear_physics` | 301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega |
| `CSequence::remove_cyclic_anims` | referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition |
| `CSequence::update` | 302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) |
| `CSequence::update_internal` | 301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) |
| `CSequence::apply_physics` | 300955 | 0x00524ab0 | outDelta.origin += dt*velocity; rotate(dt*omega) |
| `CSequence::apricot` | 300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic |
| `CPartArray::Update` | 285883 | 0x00517db0 | == CSequence::update |
| `CPhysicsObj::UpdatePositionInternal` | 280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks |
| `CPhysicsObj::UpdateObjectInternal` | 283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls |
| `CPhysicsObj::update_object` | 283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking |
| `CPhysicsObj::transition` | 280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position |
| `MotionTableManager::add_to_queue` | 290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links |
| `MotionTableManager::remove_redundant_links` | 290771 | 0x0051bf20 | Collapse superseded queued link transitions |
| `MotionTableManager::CheckForCompletedMotions` | 290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone |
| `MotionTableManager::AnimationDone` | 290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter |
| `CPhysicsObj::Hook_AnimDone` | 277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) |
| `CPhysicsObj::MotionDone` | 277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue |
| `MovementManager::MotionDone` | 300396 | 0x005242d0 | Forward |
| `CMotionInterp::MotionDone` | 305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged |
| `CMotionInterp::add_to_queue` | 305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions |
| `CMotionInterp::motions_pending` | 305322 | 0x00527fe0 | pending_motions.head_ != null |
| `CMotionInterp::StopInterpretedMotion` | 305635 | 0x00528470 | Entry for stopping a command |
| `CMotionTable::StopObjectMotion` | 300053 | 0x00523ec0 | -> StopSequenceMotion |
| `CMotionTable::StopSequenceMotion` | 298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) |
| `CMotionTable::StopObjectCompletely` | 300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each |
| `InterpretedMotionState::ApplyMotion` | 293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields |
| `InterpretedMotionState::RemoveMotion` | 293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults |
| `CPhysicsObj::RemoveLinkAnimations` | 277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims |
| `CMotionInterp::contact_allows_move` | 305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions |
| `CMotionInterp::PerformMovement` | 306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) |
---
## Notes on scope / what was NOT fully resolved
- `CSequence::update_internal`'s exact per-frame arithmetic (how `frame_number`
advances, exact interpolation between `low_frame`/`high_frame`, and the
precise mechanism by which a per-frame authored position delta from
`AnimFrame`/`get_pos_frame` gets folded into the output `Frame*`) is
x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU
compare/branch sequences show as `/* unimplemented {fcomp ...} */` rather
than resolved C. This matches the documented project-wide limitation (see
`memory/feedback_bn_decomp_field_names.md` and the CLAUDE.md cdb-toolchain
section) that some floating-point-heavy retail functions don't fully
decompile via Binary Ninja and may need a cdb live-trace or manual
disassembly pass to pin exact behavior. What IS certain from the
surrounding code (append_animation/get_pos_frame/get_part_frame/apricot)
is the STRUCTURE: node-list advance + per-node authored frame data feeding
the output Frame.
- `PositionManager::adjust_offset`'s body (the Q6 soft-correction blend) was
not located inside this pseudo-C excerpt in this pass — only its call site
and sibling API surface (`UnStick`, `StopInterpolating`, `IsInterpolating`,
`IsFullyConstrained`, `GetStickyObjectID`) were confirmed. A follow-up grep
for `PositionManager::` method bodies (likely a different source file /
address range not covered by the anchors given) would be needed to get its
exact blend formula and any snap-threshold constant.
- No explicit "snap if error > threshold" constant was found for position
correction in the portions traced; the retail design as observed here is a
continuous per-tick blend (`adjust_offset`) plus occasional authoritative
hard `SetPositionInternal` when the wire message actually carries a
`Position` (MoveToObject/MoveToPosition/PositionAndMovement paths), not a
distance-threshold-triggered snap layered on top of ordinary motion-command
packets.