From 08cb7f96141968a6a772bed920f37abbd6403169 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 18:12:18 +0200 Subject: [PATCH 01/32] =?UTF-8?q?docs(spec):=20Phase=20L.3=20=E2=80=94=20R?= =?UTF-8?q?emote=20Entity=20Motion=20Conformance=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port retail's InterpolationManager + MoveOrTeleport routing into acdream so remote players, creatures, and NPCs stop popping at every server position update and instead glide smoothly between sparse authoritative updates the way retail does. Three sub-lanes (incremental, each visually verifiable): - L.3.1 — InterpolationManager core + routing + Omega + soft-snap teardown - L.3.2 — PositionManager (root-motion + interp-offset combiner) - L.3.3 — MoveToManager (server-controlled creature MoveTo) This commit specs L.3.1 in detail and sketches L.3.2/L.3.3. Research baseline (cdb live-trace + named-decomp dive 2026-05-02) captured in docs/research/2026-05-02-remote-entity-motion/ resolved-via-cdb.md. All key constants confirmed from binary, not guessed: MAX_PHYSICS_DISTANCE=96, MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5, MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05, queue cap 20, stall window 5/30%/3. Rollout: ACDREAM_INTERP_MANAGER=1 env-var gate during development (dual-path), single cleanup commit after visual verification removes the flag + old hard-snap path + dead RemoteMotion soft-snap fields. Test plan: ~15 unit tests against the InterpolationManager class (pure-data, no game/window deps). Visual verification primary — parallel retail observer of +Acdream walking/running/strafing/ jumping/turning, all should glide. Co-Authored-By: Claude Opus 4.7 --- .../resolved-via-cdb.md | 192 +++++++++ ...26-05-02-l3-remote-entity-motion-design.md | 403 ++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md create mode 100644 docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md diff --git a/docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md b/docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md new file mode 100644 index 00000000..10afe05a --- /dev/null +++ b/docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md @@ -0,0 +1,192 @@ +# Remote-entity motion — open questions resolved via cdb live trace + +**Date:** 2026-05-02 +**Companion to:** the three agent research reports (paste them in alongside if needed; they live in worktree `adoring-torvalds-d796cf`, `sleepy-grothendieck-9d7483`, `gracious-wright-7af984`). + +The three reports converged on the same overall pipeline (queued +position-chase via `InterpolationManager`) but diverged on three +specifics. We resolved all three with a focused cdb attach to the live +retail acclient.exe v11.4186. + +## Resolution method + +1. Static decomp dive into `MoveOrTeleport`, `InterpolateTo`, + `InterpolationManager::adjust_offset`, `set_velocity`, + `GetAutonomyBlipDistance`, `set_local_velocity`. +2. Constant-value lookup for every named distance/velocity referenced + by those functions (`.formats poi(acclient!NAME)`). +3. Live trace with bps on the routing functions while the user did a + ~30-second mixed-motion scenario in retail (walk, strafe, jump, + run-jump). Captured per-bp hit counts + `set_velocity` caller + return addresses. + +Scripts: `interp_discovery.cdb`, `interp_constants.cdb`, +`interp_const2.cdb`, `interp_trace.cdb`. Logs in worktree. + +## Question 1 — distance threshold value(s) + +**Answer:** the agents weren't disagreeing — **they were describing two +different thresholds doing two different jobs.** + +| Threshold | Constant | Value | Where | Decision | +|---|---|---:|---|---| +| Routing gate | `MAX_PHYSICS_DISTANCE` | **96 m** | `CPhysicsObj::MoveOrTeleport` | within → InterpolateTo (queue); beyond → SetPositionSimple (slide-snap) | +| Enqueue blip | `GetAutonomyBlipDistance()` | 100 m outdoor / 20 m indoor (creature)
100 m outdoor / 25 m indoor (player) | `CPhysicsObj::InterpolateTo` | beyond → enqueue InterpolationNode; within → simpler path (set_heading + StopInterpolating) | +| Reach / duplicate-prune | `DESIRED_DISTANCE` | 0.05 m | `InterpolateTo` + `adjust_offset` | node "reached"; tail-prune duplicates | + +The agents who said "96" cited `MAX_PHYSICS_DISTANCE` in MoveOrTeleport +correctly. The one who said "100/25" cited `GetAutonomyBlipDistance` +correctly but had the indoor-creature value off by 5 (it's 20, not 25 — +**25 is the PLAYER indoor distance**, used for self-correction not for +remote entities). + +`GetAutonomyBlipDistance` decoded: + +```c +float CPhysicsObj::GetAutonomyBlipDistance(this) { + bool isPlayer = (this == CPhysicsObj::player_object); + bool isIndoor = (this->cell_id & 0xFFFF) >= 0x100; // 2-byte cell IDs are dungeon/inside + if (isPlayer) { + return isIndoor ? PLAYER_INSIDE_BLIP_DISTANCE // 25 + : PLAYER_OUTSIDE_BLIP_DISTANCE; // 100 + } else { + return isIndoor ? CREATURE_INSIDE_BLIP_DISTANCE // 20 + : CREATURE_OUTSIDE_BLIP_DISTANCE; // 100 + } +} +``` + +## Question 2 — polarity of the 96 m branch + +**Answer: within 96 m → queue (InterpolateTo). Beyond 96 m → snap +(SetPositionSimple).** Decoded from the disasm AND confirmed by trace. + +Disasm of `CPhysicsObj::MoveOrTeleport` at +0x60: + +``` +fld [esi+20h] ; this->distance_to_player +fcomp MAX_PHYSICS_DISTANCE ; vs 96.0 +fnstsw ax +test ah, 5 +jp +0x91 ; JP fires when distance > 96 → snap branch + +; FALL-THROUGH (distance ≤ 96): queue path +call CPhysicsObj::InterpolateTo + ++0x91: ; (distance > 96): snap branch +call PositionManager::StopInterpolating +call CPhysicsObj::SetPositionSimple +``` + +Trace results (~30 sec mixed motion in Holtburg, all entities visible +within ~30 m of camera): + +| BP | Hits | Notes | +|---|---:|---| +| `MoveOrTeleport` | 207 | every inbound UpdatePosition | +| `InterpolateTo` | 207 | **100 % routed to queue** | +| `SetPositionSimple` | **0** | no slide-snaps | +| `SetPosition` | 0 | no teleports/no-cell | + +Confirmed: every routing decision in the test went to the queue. +SetPositionSimple is the rare exception, only used when the entity is +beyond camera range of significance. + +## Question 3 — does walking-remote use UpdatePosition velocity (`set_velocity`)? + +**Answer: NO.** R3 was correct. Walking remote entities never call +`set_velocity`; their `m_velocityVector` stays at zero (or whatever +prior). Position progress is achieved entirely through `adjust_offset` +walking the body toward queued waypoints. + +Trace evidence: + +| BP | Hits | Notes | +|---|---:|---| +| `set_velocity` | 7 | All 7 had **caller = `0x00511534`** | +| `HandleVectorUpdate` (inbound 0xF74E) | 4 | jumps | + +Resolved `0x00511534` → it's inside `CPhysicsObj::set_local_velocity` +(starts at `0x005114d0`). And `set_local_velocity` is what retail's +local-jump path (CMotionInterp::LeaveGround) uses to stuff the launch +velocity into the body. So: + +- Local player jumps 4 times → `LeaveGround → set_local_velocity → + set_velocity` fires repeatedly (7 hits across 4 jumps; charge-frames + + release). +- Inbound 0xF74E packets arrive (4) — these did NOT cause additional + `set_velocity` hits on remote physobjs in our window. Either retail + gates inside `DoVectorUpdate` based on entity type, or the velocity + field got applied via a different path that doesn't trip our bp. +- 207 walking-remote `UpdatePosition`s → **zero `set_velocity` hits**. + +So **for walking remotes, `m_velocityVector` is zero and the +`UpdatePhysicsInternal` Euler integration `position += velocity*dt` +contributes nothing.** All visible motion is from `adjust_offset` +walking the body toward queue head. + +## Bonus — the rest of the constants we needed + +Resolved while we were in there: + +| Constant | Value | Where used | +|---|---:|---| +| `MAX_INTERPOLATED_VELOCITY_MOD` | **2.0** | `adjust_offset` — multiplier on `minterp->get_adjusted_max_speed()` (the catch-up gain) | +| `MAX_INTERPOLATED_VELOCITY` | 7.5 m/s | `adjust_offset` fallback when minterp is unavailable | +| `MIN_DISTANCE_TO_REACH_POSITION` | 0.20 m | `adjust_offset` per-5-frame progress threshold | +| `max_velocity` | 50 m/s | `set_velocity` magnitude clamp | +| `BIG_DISTANCE` | 999999 m | sentinel | +| `CAMERA_MAP_DISTANCE` | 450 m | unrelated; map render only | + +Constraint distances (used by the constraint sub-system, not the queue): + +| Constant | Value | +|---|---:| +| `PLAYER_OUTSIDE_CONSTRAINT_DISTANCE_START` | 10 | +| `PLAYER_OUTSIDE_CONSTRAINT_DISTANCE_MAX` | 50 | +| `PLAYER_INSIDE_CONSTRAINT_DISTANCE_START` | 5 | +| `PLAYER_INSIDE_CONSTRAINT_DISTANCE_MAX` | 20 | +| `CREATURE_OUTSIDE_CONSTRAINT_DISTANCE_START` | 10 | +| `CREATURE_OUTSIDE_CONSTRAINT_DISTANCE_MAX` | 50 | +| `CREATURE_INSIDE_CONSTRAINT_DISTANCE_START` | 5 | +| `CREATURE_INSIDE_CONSTRAINT_DISTANCE_MAX` | 20 | + +## Where this leaves the implementation plan + +The picture is now fully drawn. To match retail, acdream needs to: + +1. **Implement `InterpolationManager`** — FIFO queue (cap 20), + `InterpolateTo(targetPosition, isMovingTo)` enqueues with + GetAutonomyBlipDistance + DESIRED_DISTANCE prune, `adjust_offset(dt)` + per-tick walks toward head at `min(minterp.get_adjusted_max_speed() × + 2, MAX_INTERPOLATED_VELOCITY_FALLBACK 7.5) × dt`, + `NodeCompleted` pops on arrival within `DESIRED_DISTANCE 0.05`, + `UseTime` periodic stall detection (every 5 frames; if progress < 30 % + of expected → fail counter; > 3 fails → blip-to head). +2. **Implement `MoveOrTeleport` routing** in the inbound UpdatePosition + handler. Replace acdream's current hard-snap with: + - Stale-sequence (instance/position) → ignore. + - Teleport-sequence newer or no-cell → SetPosition (hard-snap). + - has_contact false → no-op. + - has_contact true && distance ≤ 96 → InterpolateTo. + - has_contact true && distance > 96 → SetPositionSimple slide-snap. +3. **Drop velocity-based dead-reckoning for walking remotes.** Remote + `m_velocityVector` should stay at zero unless an inbound 0xF74E + VectorUpdate sets it. The body's progress comes from `adjust_offset`, + not from Euler integration of state-derived velocity. +4. **Apply VectorUpdate.Omega.** Currently parsed but not applied — fix + to make jumping/turning remote arcs match. + +acdream code that goes away when this lands: +- `RemoteMotion.SnapResidualDecayRate` and the soft-snap residual blend. +- The locally-recomputed velocity drive between UpdatePosition packets + (`apply_current_movement → get_state_velocity → Euler` path on remote + entities). + +## Files + +- `interp_discovery.cdb` / `.log` — symbol resolution + prologues +- `interp_constants.cdb` / `.log` — first constant lookup +- `interp_const2.cdb` / `.log` — remaining constant lookup +- `interp_trace.cdb` / `.log` — live routing distribution + set_velocity callers +- This doc consolidates the answers diff --git a/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md b/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md new file mode 100644 index 00000000..29051cd2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md @@ -0,0 +1,403 @@ +# Phase L.3 — Remote Entity Motion Conformance — Design Spec + +Port retail's `InterpolationManager` + `MoveOrTeleport` routing into +acdream so remote players, creatures, and NPCs stop popping at every +server position update and instead glide smoothly between sparse +authoritative updates the way retail does. + +**Methodology:** the named retail decomp at `docs/research/named-retail/` +is ground truth. Live cdb traces against retail acclient.exe v11.4186 +have already resolved the open questions about constants and routing +polarity (see *Research baseline* below). ACE and holtburger are +secondary. + +--- + +## Problem Statement + +Remote-entity motion in acdream is choppy. Compared with retail +observers, our remote-rendered players, creatures, and NPCs: + +- **Pop visibly on every UpdatePosition** (~1 Hz for players, ~5 Hz + for moving creatures). Each inbound 0xF748 hard-snaps `Body.Position` + via `OnLivePositionUpdated` (`GameWindow.cs:3151`), then the local + client extrapolates forward via `apply_current_movement` + Euler + integration until the next server update arrives. Direction error + compounds during the gap because acdream's locally-computed velocity + may diverge from the server's authoritative state. +- **Apply VectorUpdate.Omega = nothing.** `0xF74E` is parsed but the + body's omega field is never written, so jumping/turning observers + show flat arcs instead of curved ones. +- **Run two parallel motion systems that fight each other.** + `RemoteMotion` (in `GameWindow.cs:224`) carries + `SnapResidualDecayRate` + a soft-snap residual blend that's an + acdream-original heuristic. Retail does none of this; it has a + single deterministic pipeline. +- **Server-controlled creature MoveTo is MVP.** `RemoteMoveToDriver` + uses a fixed turn rate, no retracking, no sticky-to-target, no + fail-distance progress — chasing creatures and patrol-walking NPCs + look approximate. + +Result: the world feels jittery; remote characters teleport-then-glide +in place of moving smoothly; jumps look wrong from observers. + +--- + +## Research baseline + +Resolved 2026-05-02 via cdb live-trace + named-decomp dive: + +| Source | Path | +|---|---| +| **Resolution doc (canonical answers)** | `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` | +| Original three agent reports (in worktrees) | `adoring-torvalds-d796cf`, `sleepy-grothendieck-9d7483`, `gracious-wright-7af984` | +| cdb scripts + logs | `interp_discovery.cdb/log`, `interp_constants.cdb/log`, `interp_const2.cdb/log`, `interp_trace.cdb/log` (in worktree) | + +**Key facts established by that work:** + +- Retail does NOT velocity-dead-reckon walking remotes. `m_velocityVector` + stays at zero; only `set_local_velocity` (called from `LeaveGround` = + outbound jump) and `DoVectorUpdate` (inbound 0xF74E) ever touch it. +- All visible motion comes from `InterpolationManager::adjust_offset` + walking the body toward the head node of a FIFO position-waypoint + queue at `2 × motion_max_speed × dt`. +- `CPhysicsObj::MoveOrTeleport` is the routing decision: stale-seq → + ignore; teleport-seq newer or no-cell → `SetPosition` hard-snap; + has_contact && distance ≤ 96 → `InterpolateTo` (queue); + has_contact && distance > 96 → `SetPositionSimple` slide-snap. + +**Constants** (all confirmed by reading the binary's named constant +addresses — not guesses): + +| Constant | Value | Use | +|---|---:|---| +| `MAX_PHYSICS_DISTANCE` | 96 m | MoveOrTeleport router gate | +| `CREATURE_OUTSIDE_BLIP_DISTANCE` | 100 m | InterpolateTo enqueue gate (outdoor) | +| `CREATURE_INSIDE_BLIP_DISTANCE` | 20 m | InterpolateTo enqueue gate (indoor) | +| `MAX_INTERPOLATED_VELOCITY_MOD` | 2.0 | `adjust_offset` catch-up gain × motion max | +| `MAX_INTERPOLATED_VELOCITY` | 7.5 m/s | `adjust_offset` fallback when minterp unavailable | +| `MIN_DISTANCE_TO_REACH_POSITION` | 0.20 m | per-5-frame stall progress threshold | +| `DESIRED_DISTANCE` | 0.05 m | reach + duplicate-prune | +| `max_velocity` | 50 m/s | `set_velocity` magnitude clamp | +| Queue cap | 20 | `InterpolateTo` | +| Stall window | 5 frames | `adjust_offset` periodic check | +| Stall fail trigger | 3 fails / 30 % progress | `UseTime` blip-to-tail | + +--- + +## Phase identity + +**Phase L.3 — Remote Entity Motion Conformance.** Slots into the L = +movement category alongside L.1 (animation) and L.2 (collision). + +Three sub-lanes, each independently shippable + visually verifiable: + +| Sub-lane | Title | Ships | +|---|---|---| +| **L.3.1** | InterpolationManager core + routing | New `InterpolationManager` class, `MoveOrTeleport` routing replacing the hard-snap in `OnLivePositionUpdated`, `VectorUpdate.Omega` application, deletion of `RemoteMotion` soft-snap residual | +| **L.3.2** | PositionManager (root-motion + interpolation-offset combiner) | New `PositionManager` class that combines per-frame animation root-motion offset with the InterpolationManager's catch-up offset before writing the body's frame | +| **L.3.3** | MoveToManager (server-controlled creature MoveTo) | Replaces `RemoteMoveToDriver` MVP with a faithful port: retracking, sticky-to-target, fail-distance progress checks, sphere-cylinder distance variants | + +L.3.2 and L.3.3 get their own brainstorm + spec when L.3.1 lands. +**This document specifies L.3.1 in detail; L.3.2 and L.3.3 are +sketches** (above) so the phase shape is on record. + +--- + +## L.3.1 architecture + +### New file — `src/AcDream.Core/Physics/InterpolationManager.cs` + +Pure-data class. No game/window dependencies. Composed into +`RemoteMotion` (one instance per remote entity). + +```csharp +public sealed class InterpolationManager +{ + // Public API + void Enqueue(Position target, float ownerHeading, bool isMovingTo); + Vector3 AdjustOffset(double dt, float maxSpeedFromMinterp); // returns body-space delta to add this frame + bool IsActive { get; } // queue non-empty + void Clear(); // StopInterpolating equivalent + + // Internals + private readonly LinkedList _queue; // cap 20 + private int _failFrameCounter; + private float _failDistanceLastCheck; + private int _failCount; + + // Constants (all from retail named symbols) + public const int QueueCap = 20; + public const float MaxInterpolatedVelocityMod = 2.0f; + public const float MaxInterpolatedVelocity = 7.5f; + public const float MinDistanceToReachPosition = 0.20f; + public const float DesiredDistance = 0.05f; + public const int StallCheckFrameInterval = 5; + public const float StallProgressMinFraction = 0.30f; + public const int StallFailCountForBlip = 3; +} + +internal sealed class InterpolationNode +{ + public Position Target; + public float Heading; + public bool IsHeadingValid; +} +``` + +`AdjustOffset` algorithm (mirrors `acclient!InterpolationManager::adjust_offset`): + +```text +1. If queue empty → return Vector3.Zero +2. headTarget = queue.First +3. dist = (headTarget.Position - currentBodyPosition).Magnitude +4. If dist < DesiredDistance: + queue.RemoveFirst(); return Vector3.Zero (NodeCompleted) +5. catchUpSpeed = clamp(maxSpeedFromMinterp * MaxInterpolatedVelocityMod, + floor=F_EPSILON, + else MaxInterpolatedVelocity fallback) +6. step = catchUpSpeed * dt (clamped to dist so we don't overshoot) +7. delta = (headTarget.Position - currentBodyPosition).Normalized * step +8. _failFrameCounter++; if (_failFrameCounter >= StallCheckFrameInterval): + progress = _failDistanceLastCheck - dist + if (progress < StallProgressMinFraction * (catchUpSpeed * dt * StallCheckFrameInterval)): + _failCount++ + if _failCount > StallFailCountForBlip: + // blip: hard-snap and clear queue. + // OPEN PRECISION ITEM: retail's UseTime (acclient!00555f20) decides + // head-vs-tail snap; the agent reports disagreed (R1 implies head, R2 says + // tail). Verify by reading the UseTime disasm before implementing this + // branch. Default for the initial port: snap to HEAD (next intended + // waypoint) which matches the more common pattern. + body.Position = headTarget.Position + Clear() + return Vector3.Zero + else: + _failCount = 0 + _failDistanceLastCheck = dist; _failFrameCounter = 0 +9. return delta +``` + +`Enqueue` algorithm (mirrors `acclient!CPhysicsObj::InterpolateTo`): + +```text +1. If queue tail exists and Position.distance(target, tail.Target) < DesiredDistance: + // Duplicate-prune + return +2. If queue.Count >= QueueCap: queue.RemoveLast() (drop oldest) +3. node = new InterpolationNode { Target=target, Heading=ownerHeading, IsHeadingValid=isMovingTo } +4. queue.AddLast(node) +``` + +### Modified — `RemoteMotion` (in `GameWindow.cs:224`) + +Add: `public InterpolationManager Interp { get; } = new();` + +Delete (in cleanup commit, after visual verification): +`SnapResidualDecayRate` constant + soft-snap residual fields +(`_snapResidual*`, etc). + +### Modified — `OnLivePositionUpdated` (`GameWindow.cs:3151`) + +Replace the unconditional hard-snap with retail-faithful routing. +Wrap in `ACDREAM_INTERP_MANAGER=1` env-var gate so we can toggle old +vs new during development. + +```csharp +void OnLivePositionUpdated(EntityPositionUpdate update) +{ + var rm = GetOrCreateRemoteMotion(update.Guid); + + if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + { + // Retail-faithful routing (CPhysicsObj::MoveOrTeleport). + // IsStaleSequence: wrap-aware uint16 compare on the four sequence + // counters (instance, position, teleport, force-position) the + // server stamps on every UpdatePosition. Wrap window = 0x7FFF. + // See acclient!CPhysicsObj::newer_event @ 0x00451B10. + if (IsStaleSequence(update, rm)) return; + + if (update.TeleportSequenceNewer || rm.Body.NoCell) + { + rm.Body.Position = targetPosition; // SetPosition hard-snap + rm.Interp.Clear(); + return; + } + + if (!update.HasContact) return; // no-op + + // Distance source: retail uses this->[+0x20] which is the entity's + // distance to the local player. acdream computes the equivalent on + // demand here — local player position is _playerController.Position. + float dist = Vector3.Distance(targetPosition, _playerController.Position); + if (dist > 96f) { + rm.Interp.Clear(); // StopInterpolating + rm.Body.Position = targetPosition; // SetPositionSimple slide-snap + } else { + // headingFromQuat: extract Z-axis heading from the wire quaternion. + // Use existing acdream Quat→Yaw helper (mirrors GameWindow's + // YawToAcQuaternion in reverse). isMovingTo gates whether the heading + // is preserved across InterpolateTo's "same target" path. + rm.Interp.Enqueue(targetPosition, headingFromQuat, isMovingTo: rm.IsMovingTo); + } + } + else + { + // Existing hard-snap path (unchanged) — kept until cleanup commit + rm.Body.Position = targetPosition; + rm.SnapResidualDecayRate = ...; + } +} +``` + +### Modified — per-frame remote tick (`OnLiveRemoteTick` in `GameWindow`) + +When flag on: ask the InterpolationManager for its catch-up offset and +add it to the body's position. When flag off: existing +apply_current_movement + Euler path. + +```csharp +if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +{ + float maxSpeed = rm.Motion.GetMaxSpeed(); // see MotionInterpreter change below + Vector3 delta = rm.Interp.AdjustOffset(dt, maxSpeed); + rm.Body.Position += delta; +} +else +{ + // Existing apply_current_movement + Euler (unchanged) — kept until cleanup commit +} +``` + +### Modified — `OnLiveVectorUpdated` (`GameWindow.cs:3064`) + +Apply `update.Omega` to the body. Currently parsed-but-ignored. ~3 lines: + +```csharp +if (update.Velocity is { } v) rm.Body.Velocity = v; +if (update.Omega is { } w) rm.Body.AngularVel = w; // NEW +``` + +### Modified — `MotionInterpreter` + +Add `public float GetMaxSpeed()` — port of retail +`CMotionInterp::get_max_speed` and `get_adjusted_max_speed`. Returns +the motion-table-derived max speed for the current InterpretedState. +Used by InterpolationManager via the caller (RemoteMotion's tick). + +Public method, ~10 lines, no new file. + +### Cleanup commit (after visual verification) + +One commit titled `chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead soft-snap path`: + +- Delete the `if/else` env-var gate in `OnLivePositionUpdated` and + `OnLiveRemoteTick`. Keep only the new path. +- Delete `RemoteMotion.SnapResidualDecayRate` field + soft-snap + residual fields. +- Delete the apply_current_movement + Euler dead-reckoning code in + the per-frame remote tick (the OLD branch). + +Net diff after cleanup: ~50 lines deletion, code shrinks. + +--- + +## L.3.1 unit tests + +New test file `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs`. ~10-15 tests covering pure-data behavior: + +| Group | Tests | +|---|---| +| Queue mechanics | `Enqueue_AddsNode`; `Enqueue_DropsOldestAtCap20`; `Enqueue_PrunesDuplicateWithinDesiredDistance`; `Clear_EmptiesQueue` | +| AdjustOffset math | `AdjustOffset_EmptyQueue_ReturnsZero`; `AdjustOffset_ReachesNodeWithinDesiredDistance_PopsHead`; `AdjustOffset_ClampedToCatchUpSpeed`; `AdjustOffset_FallbackSpeedWhenMinterpZero`; `AdjustOffset_OvershootProtection` | +| Stall detection | `AdjustOffset_StallCounterFiresEvery5Frames`; `AdjustOffset_NoProgressMarksFail`; `AdjustOffset_3FailsTriggersBlipToHead`; `AdjustOffset_GoodProgressResetsFailCount` | +| Routing helpers | (in `MoveOrTeleportRoutingTests.cs` — separate file) `Routing_StaleSequence_Skips`; `Routing_TeleportSeqNewer_HardSnaps`; `Routing_NoContact_NoOp`; `Routing_Within96_Enqueues`; `Routing_Beyond96_SlideSnaps` | + +All tests run against a stub `Body` and stub motion-max-speed value — +no game/window/loader needed. + +--- + +## Acceptance criteria + +L.3.1 is shippable when: + +1. `dotnet build` green; existing 91 unit tests + new ~13 InterpolationManager + ~5 routing tests all pass. +2. **Visual primary:** parallel retail observer of `+Acdream` standing still, walking, running, strafing, jumping, turning — **all motion glides smoothly**, no 1-Hz popping. +3. **Visual regression check:** `+Acdream`-from-retail-observer behaviors fixed in commit `17a9ff1` (backward jump direction, strafe-run animation, walk-back broadcast direction) all still work. +4. **Visual jump arc:** remote retail toon jumping shows a curved arc as observed from acdream (`Omega` applied), not a flat path. +5. After visual confirmation: cleanup commit lands removing `ACDREAM_INTERP_MANAGER` flag + old hard-snap path + dead `RemoteMotion` fields. + +--- + +## Risks + mitigations + +| Risk | Mitigation | +|---|---| +| New routing interacts badly with `OnLiveVectorUpdated` (jump path runs in parallel) | env-var flag lets us A/B in seconds; visual jump test in acceptance | +| `MotionInterpreter.GetMaxSpeed()` returns wrong value for non-locomotion states | add to unit tests; fall back to `MAX_INTERPOLATED_VELOCITY = 7.5` if returns ≤ epsilon | +| `_cameraPosition` for the 96 m gate is the local player's pos (retail uses `this->[+0x20]` = entity-to-local-player distance) — same thing in our setup | document the assumption inline; revisit in L.3.2 if PositionManager wants a different definition | +| `ACDREAM_INTERP_MANAGER=1` flag forgotten in cleanup commit | acceptance criterion #5 makes the cleanup commit a gate item | +| Queue grows unbounded if NodeCompleted check is buggy | cap-at-20 in Enqueue is hard limit; unit test exercises drop-oldest | + +--- + +## Files + +### New + +- `src/AcDream.Core/Physics/InterpolationManager.cs` — the manager class +- `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` — manager tests +- `tests/AcDream.Core.Tests/Physics/MoveOrTeleportRoutingTests.cs` — routing tests (or merged into above) + +### Modified + +- `src/AcDream.App/Rendering/GameWindow.cs`: + - `RemoteMotion` (~line 224): add `Interp` field + - `OnLivePositionUpdated` (~line 3151): new routing behind env-var + - `OnLiveVectorUpdated` (~line 3064): apply Omega + - `OnLiveRemoteTick` (per-frame): new offset-add behind env-var +- `src/AcDream.Core/Physics/MotionInterpreter.cs`: add `GetMaxSpeed()` +- `docs/plans/2026-04-11-roadmap.md`: insert Phase L.3 entry between L.2 and M +- `docs/ISSUES.md`: close any motion-related open issues this fixes (none currently filed) + +### Cleanup commit (after verification) + +Same files as Modified above, with the env-var dual paths collapsed +to single retail-faithful path, and `RemoteMotion` soft-snap fields +deleted. + +--- + +## Out of scope (deferred to L.3.2 / L.3.3) + +- `PositionManager` (combines anim root-motion + interpolation offset before writing body.Frame) — L.3.2 +- Server-controlled MoveTo creature behavior (retracking, sticky, fail-distance) — L.3.3 +- Replacing `RemoteMoveToDriver.cs` — L.3.3 +- VectorUpdate.Omega for other entity types (projectiles, dropped items) — defer; current spec applies only to player/creature/NPC paths + +--- + +## Implementation order (L.3.1) + +1. Add `InterpolationManager.cs` + unit tests. Build green. +2. Add `MotionInterpreter.GetMaxSpeed()`. Build green. +3. Modify `RemoteMotion` to compose `Interp`. Build green. +4. Add env-var gated routing in `OnLivePositionUpdated`. Build green; flag off → existing behavior. +5. Add env-var gated tick in `OnLiveRemoteTick`. Build green; flag off → existing behavior. +6. Apply `OnLiveVectorUpdated.Omega`. Build green. +7. Visual verification (flag on) — confirm acceptance criteria. +8. Cleanup commit: delete env-var, dead paths, dead RemoteMotion fields. +9. Update roadmap. + +Each step is a single commit. Direct-to-main per CLAUDE.md. + +--- + +## Cross-references + +- Research: `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` +- Findings (prior session): `docs/research/2026-05-01-retail-motion-trace/findings.md` +- Memory: `memory/project_retail_motion_outbound.md`, `memory/project_retail_debugger.md` +- Roadmap: insert as Phase L.3 in `docs/plans/2026-04-11-roadmap.md` +- Related fixed issues: `17a9ff1 fix(motion)` (backward/strafe wire + jump direction) — L.3.1 verifies this still works From f28240ad199bdce5a69778e89566c67d86e3e85a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 18:26:02 +0200 Subject: [PATCH 02/32] =?UTF-8?q?docs(plan):=20Phase=20L.3.1=20=E2=80=94?= =?UTF-8?q?=20InterpolationManager=20core=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10-task incremental plan with explicit subagent dispatch points: - Tasks 0+1+2 dispatched in parallel (3 concurrent Sonnet subagents): Task 0 = decomp dive to settle UseTime head-vs-tail blip ambiguity Task 1 = InterpolationManager class + ~13 unit tests Task 2 = MotionInterpreter.GetMaxSpeed() + ~3 unit tests - Tasks 3-6 sequential GameWindow edits (env-var gated, dual-path): Task 3 = RemoteMotion gains Interp field Task 4 = OnLivePositionUpdated MoveOrTeleport routing Task 5 = per-frame remote tick Interp.AdjustOffset add Task 6 = OnLiveVectorUpdated.Omega application - Task 7 = USER GATE (visual verification) - Tasks 8+9 dispatched in parallel after sign-off (2 subagents): Task 8 = cleanup commit (delete env-var, dead paths, soft-snap residual) Task 9 = roadmap update (insert Phase L.3 entry) Each task has TDD-style steps with exact file paths, code blocks, build/test commands, and commit messages. Plan honors CLAUDE.md direct-to-main + commit-after-each-step + visual-verify-on-motion. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-02-l3-1-interpolation-manager.md | 815 ++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-l3-1-interpolation-manager.md diff --git a/docs/superpowers/plans/2026-05-02-l3-1-interpolation-manager.md b/docs/superpowers/plans/2026-05-02-l3-1-interpolation-manager.md new file mode 100644 index 00000000..959dd33b --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-l3-1-interpolation-manager.md @@ -0,0 +1,815 @@ +# Phase L.3.1 — InterpolationManager Core + MoveOrTeleport Routing — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Heavy subagent use is the user's explicit request.** Tasks marked **`[PARALLEL-A]`**, **`[PARALLEL-B]`**, etc. can be dispatched simultaneously as concurrent Sonnet subagents and the parent reviews each return before integrating. The plan flags every parallelization opportunity. + +**Goal:** Replace acdream's hard-snap-then-Euler-extrapolate remote-entity motion with retail's queued position-waypoint pipeline (`InterpolationManager` + `MoveOrTeleport` routing). Apply parsed-but-ignored `VectorUpdate.Omega`. Tear out the now-redundant `RemoteMotion` soft-snap residual code. Ship behind `ACDREAM_INTERP_MANAGER=1` env-var gate, then collapse the dual paths after visual verification. + +**Architecture:** New pure-data `InterpolationManager` class (FIFO queue cap 20 + `AdjustOffset(dt, maxSpeed) → Vector3` per-frame catch-up) composed into the existing per-remote `RemoteMotion` container. Inbound `0xF748` UpdatePosition handler (`OnLivePositionUpdated`) replaced by retail-faithful router (stale-seq → ignore; teleport-seq newer → snap; within 96 m → enqueue; beyond 96 m → slide-snap). Per-frame remote tick adds `Interp.AdjustOffset(dt) → body.Position`. Single-keyword env-var rollback during dev; cleanup commit after sign-off. + +**Tech Stack:** C# / .NET 10 / xUnit. Edits in `AcDream.App` + `AcDream.Core`. No new NuGet deps. Tests at `tests/AcDream.Core.Tests/Physics/*Tests.cs`. + +**Spec:** [`docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md`](../specs/2026-05-02-l3-remote-entity-motion-design.md) (committed 08cb7f9). + +**Research baseline:** [`docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`](../../research/2026-05-02-remote-entity-motion/resolved-via-cdb.md). + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/AcDream.Core/Physics/InterpolationManager.cs` | **CREATE** | Pure-data FIFO position-queue + adjust_offset math. No game/window deps. Composed into RemoteMotion. | +| `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` | **CREATE** | ~13 unit tests covering queue mechanics, AdjustOffset math, stall detection. | +| `src/AcDream.Core/Physics/MotionInterpreter.cs` | **MODIFY** | Add public `GetMaxSpeed()` returning motion-table-derived max for current InterpretedState. | +| `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs` | **MODIFY** | Add ~3 tests covering GetMaxSpeed for Walk/Run/Idle. | +| `src/AcDream.App/Rendering/GameWindow.cs` | **MODIFY** | (a) RemoteMotion class gains `Interp` field. (b) `OnLivePositionUpdated` env-var gated routing. (c) Per-frame remote tick env-var gated `Interp.AdjustOffset` add. (d) `OnLiveVectorUpdated` applies `Omega` to body. | +| `docs/plans/2026-04-11-roadmap.md` | **MODIFY** | Insert Phase L.3 entry between L.2 and M. | +| (cleanup commit) `src/AcDream.App/Rendering/GameWindow.cs` | **MODIFY** | Delete env-var dual-path branches; delete old hard-snap path; delete RemoteMotion soft-snap residual fields. | + +--- + +## Open Precision Item + +The spec flags one ambiguity: **does retail's `InterpolationManager::UseTime` (acclient @ 0x00555F20) blip-to-HEAD or blip-to-TAIL on stall?** The two agent reports disagreed. Default for the initial port = HEAD. Task 0 below resolves this via a 30-second cdb static decomp dive (no live attach needed). + +--- + +## Task Decomposition Overview + +``` + ┌──────────────────────────────┐ + │ Task 0 [PARALLEL-A] │ + │ Resolve UseTime head/tail │ + │ (decomp read, ~5 min) │ + └──────────────────────────────┘ + ┌──────────────────────────────┐ + │ Task 1 [PARALLEL-B] │ + ┌─ DISPATCH 3 SUBAGENTS IN PARALLEL ──────────────►│ InterpolationManager + tests│ + │ └──────────────────────────────┘ + │ ┌──────────────────────────────┐ + │ │ Task 2 [PARALLEL-C] │ + │ │ MotionInterpreter.GetMaxSpeed│ + │ └──────────────────────────────┘ + + ┌─ AFTER 0+1+2 LAND ────────────────────────────────────────────────────────────────────┐ + │ │ + │ Task 3 — RemoteMotion.Interp field (sequential, single edit) │ + │ ↓ │ + │ Task 4 — OnLivePositionUpdated env-var routing (sequential) │ + │ ↓ │ + │ Task 5 — Per-frame remote tick env-var Interp.AdjustOffset (sequential) │ + │ ↓ │ + │ Task 6 — OnLiveVectorUpdated.Omega (sequential, 3 lines) │ + │ │ + │ Task 7 — Visual verification (USER GATE) │ + │ ↓ user signs off │ + │ │ + │ ┌─ DISPATCH 2 SUBAGENTS IN PARALLEL ──────┐ │ + │ │ Task 8: Cleanup commit │ │ + │ │ Task 9: Roadmap update │ │ + │ └──────────────────────────────────────────┘ │ + └────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Task 0 — [PARALLEL-A] Resolve `UseTime` head-vs-tail via static decomp + +**Owner:** Sonnet subagent (general-purpose). Read-only; no code changes. + +**Files:** +- Read: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (search for `InterpolationManager::UseTime` near line ~352261-353375) + +**Subagent dispatch prompt** (use `general-purpose` agent type, Sonnet): + +> Read the named retail decomp at `docs/research/named-retail/acclient_2013_pseudo_c.txt`. Find `InterpolationManager::UseTime` (search by exact string `InterpolationManager::UseTime`). It should appear around line 352261-353375. Read the body of that function (~100 lines). +> +> The function decides what to do when the per-5-frame stall counter shows the entity isn't catching up to its queued waypoints (`node_fail_counter > 3`). The two prior research agents disagreed on whether the resulting "blip" snaps the body to the HEAD of the queue (the next intended waypoint) or to the TAIL (the most recent server-sent position). +> +> Report under 200 words: which is it (HEAD or TAIL), with the line range from the decomp that proves it. If the decompile is ambiguous (e.g. comparison polarity artifact), flag that and recommend a default. No code edits. + +**Steps:** + +- [ ] **Step 0.1: Dispatch the subagent** + +Use the Agent tool with `subagent_type=general-purpose`, model `sonnet`, prompt above. + +- [ ] **Step 0.2: Read subagent report; record decision in implementation note** + +Append a one-line note to the InterpolationManager source comment (created in Task 1) recording the resolution. + +--- + +## Task 1 — [PARALLEL-B] InterpolationManager class + ~13 unit tests + +**Owner:** Sonnet subagent (general-purpose). Independent of Tasks 0 + 2. + +**Files:** +- Create: `src/AcDream.Core/Physics/InterpolationManager.cs` +- Create: `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` + +**Subagent dispatch prompt** (use `general-purpose` agent type, Sonnet): + +> You are implementing Phase L.3.1 Task 1. Read the spec at `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md` sections "L.3.1 architecture" → "New file" through the unit-test list. Read the research at `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` for the constants table. +> +> Create the file `src/AcDream.Core/Physics/InterpolationManager.cs` matching the spec's API: +> ```csharp +> public sealed class InterpolationManager { +> void Enqueue(Vector3 targetPosition, float ownerHeading, bool isMovingTo); +> Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp); +> bool IsActive { get; } +> void Clear(); +> // constants from spec +> } +> ``` +> The spec uses retail's `Position` type in the signature, but acdream's PhysicsBody uses `Vector3 Position` separately from `uint CellId`. So: +> - `Enqueue(Vector3 targetPosition, float ownerHeading, bool isMovingTo)` — caller is responsible for resolving cell deltas +> - `AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)` returns the world-space delta to add to body.Position this frame +> +> Implement the spec's `AdjustOffset` algorithm exactly (steps 1-9 as written). For the stall-blip branch, use HEAD as the default (Task 0 may override this; Task 0's report should be available — if it says TAIL, use TAIL). Use `LinkedList` for the queue. +> +> Create `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` with the 13 tests listed in the spec under "L.3.1 unit tests" → "Queue mechanics", "AdjustOffset math", "Stall detection". Use xUnit. Match the test-file pattern of existing files (e.g. `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs`): top-level `using` block, `namespace AcDream.Core.Tests.Physics;`, then test methods. Use `file sealed class` for any test-only helpers. +> +> Build with `cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug --nologo` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build --nologo --filter "FullyQualifiedName~InterpolationManager"`. Both must be green. +> +> Commit with `feat(physics): InterpolationManager core (L.3.1 Task 1)` and Co-Authored-By Claude Opus 4.7. Direct-to-main per CLAUDE.md. +> +> Report under 300 words: what you built, test results, any deviations from the spec (if you had to deviate, justify). + +**Steps:** + +- [ ] **Step 1.1: Dispatch the subagent in parallel with Tasks 0 and 2** + +Use the Agent tool with `subagent_type=general-purpose`, `model=sonnet`. Send all 3 dispatch calls in a single message so they run concurrently. + +- [ ] **Step 1.2: Verify subagent's commit** + +```bash +git log -1 --stat src/AcDream.Core/Physics/InterpolationManager.cs +``` + +Expected: commit message starts with `feat(physics): InterpolationManager core (L.3.1 Task 1)`. Files changed include `InterpolationManager.cs` + `InterpolationManagerTests.cs`. + +- [ ] **Step 1.3: Re-run tests in parent session to confirm green** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build --nologo --filter "FullyQualifiedName~InterpolationManager" +``` + +Expected: all ~13 tests pass. + +- [ ] **Step 1.4: Spot-check the implementation file** + +Read the created file. Verify: API surface matches spec exactly; constants are public consts with the spec's values; `AdjustOffset` algorithm follows spec steps 1-9; stall-blip uses HEAD (or TAIL per Task 0 outcome). + +If anything diverges materially from the spec without justification in the subagent's report, dispatch a fix subagent. If the deviation is minor and harmless, accept it. + +--- + +## Task 2 — [PARALLEL-C] `MotionInterpreter.GetMaxSpeed()` + ~3 unit tests + +**Owner:** Sonnet subagent (general-purpose). Independent of Tasks 0 + 1. + +**Files:** +- Modify: `src/AcDream.Core/Physics/MotionInterpreter.cs` (add one public method, ~10-15 lines) +- Modify: `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs` (add ~3 tests) + +**Subagent dispatch prompt** (use `general-purpose` agent type, Sonnet): + +> You are implementing Phase L.3.1 Task 2. Read the spec at `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md` section "L.3.1 architecture" → "Modified — `MotionInterpreter`". +> +> Add a public method `GetMaxSpeed()` to `src/AcDream.Core/Physics/MotionInterpreter.cs`. It must port retail's `CMotionInterp::get_max_speed` semantics: return the motion-table-derived max speed for the current `InterpretedState.ForwardCommand`. Acdream's `MotionInterpreter` already knows the constants `RunAnimSpeed = 4.0f` and `WalkAnimSpeed = 3.12f` (search the file for these). Approximate retail logic: +> ```csharp +> public float GetMaxSpeed() { +> return InterpretedState.ForwardCommand switch { +> MotionCommand.RunForward => RunAnimSpeed * (WeenieObj?.InqRunRate(out var r) == true ? r : MyRunRate), +> MotionCommand.WalkForward => WalkAnimSpeed, +> MotionCommand.WalkBackward => WalkAnimSpeed * 0.65f, // BackwardsFactor +> _ => 0f, // idle / non-locomotion +> }; +> } +> ``` +> If retail decomp suggests a different formula, prefer that — search the named decomp at `docs/research/named-retail/acclient_2013_pseudo_c.txt` for `CMotionInterp::get_max_speed` (around line 305235-305280). Report what you found. +> +> Add ~3 unit tests to `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs`: +> - `GetMaxSpeed_RunForward_ReturnsRunAnimSpeedTimesRunRate` +> - `GetMaxSpeed_WalkForward_ReturnsWalkAnimSpeed` +> - `GetMaxSpeed_Idle_ReturnsZero` +> +> Use the existing `FakeWeenie` test helper from MotionInterpreterTests.cs. +> +> Build + test: +> ```bash +> cd C:/Users/erikn/source/repos/acdream +> dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug --nologo +> dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build --nologo --filter "FullyQualifiedName~MotionInterpreter" +> ``` +> Both green. +> +> Commit with `feat(physics): MotionInterpreter.GetMaxSpeed for InterpolationManager (L.3.1 Task 2)` and Co-Authored-By Claude Opus 4.7. Direct-to-main. +> +> Report under 200 words: what the formula is, decomp reference if found, test results. + +**Steps:** + +- [ ] **Step 2.1: Dispatch in parallel with Tasks 0 and 1** (single message, three concurrent Agent tool calls) + +- [ ] **Step 2.2: Verify subagent's commit** + +```bash +git log -1 --stat src/AcDream.Core/Physics/MotionInterpreter.cs +``` + +Expected: commit message `feat(physics): MotionInterpreter.GetMaxSpeed (L.3.1 Task 2)`. + +- [ ] **Step 2.3: Re-run tests** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build --nologo --filter "FullyQualifiedName~MotionInterpreter" +``` + +Expected: existing tests + new ~3 tests all pass. + +--- + +## Task 3 — Add `Interp` field to `RemoteMotion` class + +**Owner:** Parent (you). Tiny mechanical edit; not worth a subagent. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~224 — `RemoteMotion` class) + +**Steps:** + +- [ ] **Step 3.1: Read the RemoteMotion class definition** + +```bash +grep -n "private sealed class RemoteMotion" "C:/Users/erikn/source/repos/acdream/src/AcDream.App/Rendering/GameWindow.cs" +``` + +Then Read tool from the line returned, ~120 lines. + +- [ ] **Step 3.2: Add `Interp` field** + +In the `RemoteMotion` class body (after the existing field declarations, before the constructor `public RemoteMotion()`), add: + +```csharp + /// + /// Per-remote position-waypoint queue + catch-up math (retail's + /// InterpolationManager). Replaces the hard-snap-then-Euler-extrapolate + /// path when ACDREAM_INTERP_MANAGER=1 — see L.3.1 spec. + /// + public AcDream.Core.Physics.InterpolationManager Interp { get; } = + new AcDream.Core.Physics.InterpolationManager(); +``` + +- [ ] **Step 3.3: Build and verify** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +``` + +Expected: 0 warnings, 0 errors, "Build succeeded." + +- [ ] **Step 3.4: Run all tests** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet test --no-build --nologo +``` + +Expected: existing tests still pass (no behavior change yet). + +- [ ] **Step 3.5: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(motion): RemoteMotion gains InterpolationManager field (L.3.1 Task 3) + +Composes the new InterpolationManager (Task 1) into the per-remote +container. Field exists but is not consumed yet — Tasks 4 and 5 wire +it into the routing + per-frame tick. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 4 — Env-var gated routing in `OnLivePositionUpdated` + +**Owner:** Parent. Manual edit because the surrounding handler is complex (~70 lines) and we need to wrap it without disrupting the legacy path. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~3151 — `OnLivePositionUpdated`) + +**Steps:** + +- [ ] **Step 4.1: Read the entire OnLivePositionUpdated method to understand current structure** + +```bash +grep -n "OnLivePositionUpdated\b" "C:/Users/erikn/source/repos/acdream/src/AcDream.App/Rendering/GameWindow.cs" +``` + +Then Read tool from `OnLivePositionUpdated` start, ~100 lines (until the next method declaration). + +Note: the method currently does (a) lazy-create RemoteMotion if not in dict, (b) hard-snap `body.Position` and `body.Orientation`, (c) update RemoteMotion.SnapResidualDecayRate / soft-snap residual fields, (d) clear airborne / set Z-fields if has-velocity changed. + +- [ ] **Step 4.2: Locate the specific point where the hard-snap happens** + +Look for `rm.Body.Position = ...` (or `body.Position = ...`) inside this handler. Mark its surrounding context. + +- [ ] **Step 4.3: Wrap the snap block in env-var conditional** + +Pseudocode of the change: + +```csharp +private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) +{ + // ... existing lazy-create + parse + identification (unchanged) ... + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) { + rm = new RemoteMotion(); + _remoteDeadReckon[update.Guid] = rm; + } + var targetPos = ...; // existing extraction (Vector3) + var targetOri = ...; // existing extraction (Quaternion) + + // NEW: env-var gated retail-faithful routing (L.3.1) + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + { + // CPhysicsObj::MoveOrTeleport router (acclient @ 0x00516330): + // - stale-seq: ignore (TODO: implement IsStaleSequence wrapping uint16 compare on the four sequence counters; for now allow all to land) + // - teleport-seq newer or no-cell: SetPosition (hard-snap) + // - has_contact false: no-op + // - has_contact true && distance ≤ 96: Interp.Enqueue + // - has_contact true && distance > 96: SetPositionSimple (slide-snap) + + // Distance source: retail uses entity->[+0x20] (entity-to-local-player). + // Acdream computes equivalent via local player position. + Vector3 localPlayerPos = _playerController?.Position ?? Vector3.Zero; + float dist = Vector3.Distance(targetPos, localPlayerPos); + + bool teleportFlag = false; // TODO: source from update sequence comparison once IsStaleSequence is in + bool hasContact = update.Position.HasContact; // verify field name in CreateObject.ServerPosition + + if (teleportFlag) { + rm.Body.Position = targetPos; + rm.Body.Orientation = targetOri; + rm.Interp.Clear(); + } + else if (!hasContact) { + // no-op + } + else if (dist > 96f) { + rm.Interp.Clear(); + rm.Body.Position = targetPos; + rm.Body.Orientation = targetOri; + } + else { + float headingFromQuat = ExtractYawFromQuaternion(targetOri); // see helper below + rm.Interp.Enqueue(targetPos, headingFromQuat, isMovingTo: false); + } + return; + } + + // EXISTING hard-snap path (unchanged) — kept until cleanup commit (Task 8) + rm.Body.Position = targetPos; + rm.Body.Orientation = targetOri; + // ... rest of existing soft-snap + residual fields ... +} + +// Helper (place near OnLivePositionUpdated): +private static float ExtractYawFromQuaternion(Quaternion q) +{ + // Inverse of YawToAcQuaternion: extract Z-axis rotation angle. + // Acdream's player Yaw convention: Yaw=0 faces +X. + return MathF.Atan2(2f * (q.W * q.Z + q.X * q.Y), 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); +} +``` + +If `update.Position.HasContact` doesn't exist, look for the equivalent on `CreateObject.ServerPosition` — likely `update.Position.IsGrounded` or similar based on parsed PositionPack flags. Use whatever's there; acceptable to file a TODO to plumb it through if it's not currently parsed. + +- [ ] **Step 4.4: Build** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +``` + +Expected: 0 errors, 0 warnings. + +- [ ] **Step 4.5: Run tests** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet test --no-build --nologo +``` + +Expected: all existing tests pass (env-var off by default → existing behavior unchanged). + +- [ ] **Step 4.6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(motion): MoveOrTeleport routing in OnLivePositionUpdated (L.3.1 Task 4) + +Wraps the hard-snap path in ACDREAM_INTERP_MANAGER=1 env-var guard. +When set, runs retail-faithful routing (acclient!CPhysicsObj:: +MoveOrTeleport @ 0x00516330): teleport-seq → SetPosition; within 96m +→ Interp.Enqueue; beyond 96m → SetPositionSimple slide-snap. + +Existing hard-snap behavior preserved when flag is unset (default). +Old path will be removed in cleanup commit after visual verification. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 5 — Env-var gated per-frame `Interp.AdjustOffset` add + +**Owner:** Parent. Touches the per-frame remote tick (~line 5680-5760). + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (per-frame remote tick block) + +**Steps:** + +- [ ] **Step 5.1: Locate the remote tick block** + +```bash +grep -n "_remoteDeadReckon.TryGetValue.*serverGuid" "C:/Users/erikn/source/repos/acdream/src/AcDream.App/Rendering/GameWindow.cs" +``` + +Look for the line ~5689 entry; read 80 lines forward to see the whole tick block (where `apply_current_movement` and `body.UpdatePhysicsInternal` are called). + +- [ ] **Step 5.2: Wrap the legacy tick body in an if/else against the env-var** + +Pseudocode of the change: + +```csharp +if (ae.Sequencer is not null + && serverGuid != 0 + && serverGuid != _playerServerGuid + && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) +{ + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + { + // NEW PATH: queued position-chase via InterpolationManager. + // Walking remotes have m_velocityVector == 0 in retail; all visible + // motion comes from adjust_offset walking the body toward queue head + // at 2 × motion_max_speed × dt. + if (rm.Interp.IsActive) + { + float maxSpeed = rm.Motion.GetMaxSpeed(); // Task 2 method + Vector3 delta = rm.Interp.AdjustOffset((float)dt, rm.Body.Position, maxSpeed); + rm.Body.Position += delta; + } + // For airborne remotes, OnLiveVectorUpdated has set body.Velocity; + // body.UpdatePhysicsInternal below applies gravity. No queue + // adjustment competes with the arc. + rm.Body.UpdatePhysicsInternal((float)dt); + } + else + { + // EXISTING hard-snap + Euler path (unchanged) — kept until cleanup + if (!rm.Airborne) { + // ... existing apply_current_movement, force-OnWalkable, etc. + } + rm.Body.UpdatePhysicsInternal((float)dt); + // ... existing post-physics processing ... + } +} +``` + +The exact shape depends on the existing code — preserve everything in the `else` branch verbatim. The `if` branch is the new one. + +- [ ] **Step 5.3: Build** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +``` + +Expected: 0 errors. + +- [ ] **Step 5.4: Run tests** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet test --no-build --nologo +``` + +Expected: all pass (flag off → existing behavior). + +- [ ] **Step 5.5: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(motion): per-frame Interp.AdjustOffset in remote tick (L.3.1 Task 5) + +When ACDREAM_INTERP_MANAGER=1, the per-frame remote tick uses +InterpolationManager.AdjustOffset to walk body.Position toward the +queue head at 2 × motion-max-speed × dt (retail's +acclient!InterpolationManager::adjust_offset @ 0x00555D30). + +Legacy apply_current_movement + Euler dead-reckoning preserved when +flag is unset. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 6 — Apply `VectorUpdate.Omega` in `OnLiveVectorUpdated` + +**Owner:** Parent. Tiny edit, no env-var gate (this is a strict bug-fix that improves both old and new paths). + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~3064 — `OnLiveVectorUpdated`) + +**Steps:** + +- [ ] **Step 6.1: Read the existing handler** + +```bash +grep -n "OnLiveVectorUpdated" "C:/Users/erikn/source/repos/acdream/src/AcDream.App/Rendering/GameWindow.cs" +``` + +Read from line returned, ~30 lines. + +- [ ] **Step 6.2: Find the velocity-application line and add omega next to it** + +Find: +```csharp +if (update.Velocity is { } v) + rm.Body.Velocity = v; +``` + +Add immediately after: +```csharp +if (update.Omega is { } w) + rm.Body.Omega = w; +``` + +Verify the field name on `VectorUpdate.Parsed` — it might be `Omega` or `AngularVelocity`. If it's not present at all, that's a parser gap — file as a follow-up issue and skip this task. Most likely it's already parsed because the spec confirmed "currently parsed-but-ignored". + +- [ ] **Step 6.3: Build + test** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo && dotnet test --no-build --nologo +``` + +Expected: green. + +- [ ] **Step 6.4: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +fix(motion): apply VectorUpdate.Omega to remote body (L.3.1 Task 6) + +VectorUpdate.Omega was parsed by WorldSession but never written to +the remote body's Omega field, leaving remote jumping/turning +arcs flat. Apply it alongside the existing Velocity assignment. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 7 — Visual verification (USER GATE) + +**Owner:** User. Cannot be automated. + +**Steps:** + +- [ ] **Step 7.1: Kill any running acdream** + +```powershell +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 6 +``` + +- [ ] **Step 7.2: Launch acdream with ACDREAM_INTERP_MANAGER=1** + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_INTERP_MANAGER = "1" +dotnet run --project C:\Users\erikn\source\repos\acdream\src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "C:\Users\erikn\source\repos\acdream\.claude\worktrees\jovial-blackburn-773942\launch.log" +``` + +- [ ] **Step 7.3: User performs the visual test matrix** + +Have a parallel retail observer toon watching `+Acdream`. On the retail observer side: + +1. Walk forward 5 sec +2. Walk backward 5 sec +3. Strafe left/right 5 sec each +4. Stop +5. Run forward 5 sec +6. Jump from standstill 2-3x +7. Jump while running 2-3x +8. Turn quickly while running + +For each, verify (against the acceptance criteria in the spec): +- Walking remotes glide smoothly (no 1-Hz popping) +- Backward / strafe / turn behaviors from commit 17a9ff1 still work +- Jump arcs are curved (Omega applied) + +- [ ] **Step 7.4: User signs off OR files a regression** + +If anything regresses, file the specifics and either fix forward (parent dispatches a focused-fix subagent) or revert the env-var to legacy mode while debugging. + +If everything looks right, proceed to Tasks 8 + 9 (parallel cleanup + roadmap). + +--- + +## Task 8 — [PARALLEL-D] Cleanup commit + +**Owner:** Sonnet subagent (general-purpose). Independent of Task 9. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (delete env-var dual paths in 4 + 5; delete RemoteMotion soft-snap residual fields) + +**Subagent dispatch prompt** (use `general-purpose` agent type, Sonnet): + +> You are implementing Phase L.3.1 Task 8: cleanup. The user has visually verified that ACDREAM_INTERP_MANAGER=1 works correctly. Now collapse the dual-path scaffolding into a single retail-faithful path. +> +> In `src/AcDream.App/Rendering/GameWindow.cs`: +> +> 1. **In `OnLivePositionUpdated`** (added in Task 4): delete the `if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")` wrapper. Keep ONLY the routing block inside it (the new path). Delete the legacy hard-snap fall-through. +> +> 2. **In the per-frame remote tick block** (modified in Task 5): same — delete the `if/else` env-var gate. Keep ONLY the new path (Interp.AdjustOffset). Delete the legacy `apply_current_movement` + force-OnWalkable + Euler-extrapolate code in the `else` branch. +> +> 3. **In the `RemoteMotion` class** (~line 224): delete `SnapResidualDecayRate` and any soft-snap residual fields (search for `_snapResidual`, `SnapResidualDecayRate`, `SoftSnap`, etc.). Also delete any related code in `OnLivePositionUpdated` and the per-frame tick that touched those fields (it should already be gone if Task 4/5 wrapped them in the env-var gate, but double-check). +> +> 4. **Search for any remaining `ACDREAM_INTERP_MANAGER` references** in the codebase and confirm zero remain. +> +> Build: `cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo`. 0 warnings, 0 errors. +> Test: `dotnet test --no-build --nologo`. All green. +> +> Commit: +> ``` +> chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead soft-snap path (L.3.1 Task 8) +> +> User has visually verified the new InterpolationManager-based remote +> motion (commits f2 + f5 + f6 from L.3.1). Collapses the env-var +> dual-path: deletes legacy hard-snap + Euler-extrapolate code from +> OnLivePositionUpdated and the per-frame remote tick, deletes the +> SnapResidualDecayRate + soft-snap residual fields from RemoteMotion. +> +> Net diff: ~50 lines deletion. Single retail-faithful path remains. +> +> Co-Authored-By: Claude Opus 4.7 +> ``` +> +> Report under 200 words: line counts deleted, files touched, test results. + +**Steps:** + +- [ ] **Step 8.1: Dispatch the subagent in parallel with Task 9** + +Use Agent tool, `general-purpose`, `sonnet`. Send simultaneously with Task 9's dispatch. + +- [ ] **Step 8.2: Verify the commit landed and the diff is sensible** + +```bash +git log -1 --stat +git show HEAD -- src/AcDream.App/Rendering/GameWindow.cs | head -100 +``` + +Expected: ~50 lines deleted, no `ACDREAM_INTERP_MANAGER` in the diff (only its removal). + +- [ ] **Step 8.3: Re-run all tests in parent session** + +```bash +cd C:/Users/erikn/source/repos/acdream && dotnet test --no-build --nologo +``` + +Expected: green. + +- [ ] **Step 8.4: Confirm zero env-var references remain** + +```bash +grep -rn "ACDREAM_INTERP_MANAGER" "C:/Users/erikn/source/repos/acdream/src/" 2>&1 +``` + +Expected: no output. + +--- + +## Task 9 — [PARALLEL-D] Roadmap update + +**Owner:** Sonnet subagent (general-purpose). Independent of Task 8. + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` + +**Subagent dispatch prompt** (use `general-purpose` agent type, Sonnet): + +> You are implementing Phase L.3.1 Task 9: add the Phase L.3 entry to the roadmap. +> +> Read `docs/plans/2026-04-11-roadmap.md` to understand the existing format. Find the spot between `### Phase L.2 — Movement & Collision Conformance` and `### Phase M — Network Stack Conformance` (search for those exact headings). +> +> Insert a new Phase L.3 entry with this content: +> ```markdown +> ### Phase L.3 — Remote Entity Motion Conformance +> +> **Status:** L.3.1 IN PROGRESS / SHIPPED (depending on whether cleanup commit has landed when you read this). +> +> **Goal:** Replace acdream's hard-snap-then-Euler-extrapolate remote-entity motion with retail's queued position-waypoint pipeline (`InterpolationManager` + `MoveOrTeleport` routing). Apply parsed-but-ignored `VectorUpdate.Omega`. Drop the parallel soft-snap residual scaffolding `RemoteMotion` was carrying. +> +> **Why now:** Live cdb traces (2026-05-02) confirmed retail uses a per-physobj FIFO position queue with `adjust_offset(dt)` walking the body at 2× motion-table-max-speed toward the head, NOT velocity-based dead-reckoning. acdream's chop comes from the wrong algorithm category, not just bad parameters. +> +> **Spec:** [`docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md`](../superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md). +> +> **Plan (L.3.1):** [`docs/superpowers/plans/2026-05-02-l3-1-interpolation-manager.md`](../superpowers/plans/2026-05-02-l3-1-interpolation-manager.md). +> +> **Sub-lanes:** +> +> - **L.3.1 — InterpolationManager core + routing.** New `InterpolationManager` class, `MoveOrTeleport` routing replacing the hard-snap, `VectorUpdate.Omega` application, deletion of `RemoteMotion` soft-snap residual. +> - **L.3.2 — PositionManager.** Combines per-frame animation root-motion offset with the InterpolationManager's catch-up offset before writing the body's frame. Mirrors retail `CPhysicsObj::UpdateObjectInternal`. Spec to be drafted after L.3.1 ships. +> - **L.3.3 — MoveToManager.** Replaces `RemoteMoveToDriver` MVP with full retail-faithful port: retracking, sticky-to-target, fail-distance progress checks, sphere-cylinder distance variants. Spec to be drafted after L.3.2 ships. +> ``` +> +> Also update the file's top-line `**Status:** Living document. Updated YYYY-MM-DD for ...` line — change the date to today (2026-05-02) and the trailing reason to `for Phase L.3 remote-entity motion planning`. +> +> Build (no code changed but verify nothing broke): +> ```bash +> cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +> ``` +> +> Commit: +> ``` +> docs(roadmap): Phase L.3 — Remote Entity Motion Conformance (L.3.1 Task 9) +> +> Adds the Phase L.3 entry between L.2 (collision) and M (network). +> Lists the three sub-lanes (L.3.1 in progress, L.3.2 + L.3.3 sketched). +> Cross-references the design spec and the L.3.1 implementation plan. +> +> Co-Authored-By: Claude Opus 4.7 +> ``` +> +> Report under 100 words: that the entry is inserted, location, build green. + +**Steps:** + +- [ ] **Step 9.1: Dispatch in parallel with Task 8** (single message) + +- [ ] **Step 9.2: Verify the commit** + +```bash +git log -1 --stat docs/plans/2026-04-11-roadmap.md +``` + +Expected: commit message `docs(roadmap): Phase L.3 — Remote Entity Motion Conformance`. File diff shows the new section in the right location. + +- [ ] **Step 9.3: Optional final push** + +```bash +cd C:/Users/erikn/source/repos/acdream && git push origin main +``` + +(Per CLAUDE.md, ask user before pushing.) + +--- + +## Verification Plan + +End-to-end smoke test after L.3.1 fully lands (post-Task 9): + +```bash +cd C:/Users/erikn/source/repos/acdream +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo # green +dotnet test --no-build --nologo # all green (~110 tests) +git log --oneline -10 # see L.3.1 commits in order +grep -rn "ACDREAM_INTERP_MANAGER" src/ # zero hits (cleanup confirmed) +grep -rn "SnapResidualDecayRate" src/ # zero hits (deleted) +``` + +Then user re-runs the visual test matrix from Task 7.3 with no env-var set (default behavior is now the new path). + +If everything's green: L.3.1 done. Brainstorm L.3.2 next (PositionManager). + +--- + +## Self-Review Notes (for the executor) + +- **Task 4's `IsStaleSequence`** is intentionally deferred — the legacy code doesn't check sequences either. Filing a follow-up TODO is acceptable; not a blocker for L.3.1. +- **Task 4's `update.Position.HasContact`** field name is a guess — verify against `CreateObject.ServerPosition` definition. If absent, file a parser-gap follow-up; for L.3.1 default to `hasContact = true` (allow all to enqueue). +- **Task 5's `dt` source** — the existing per-frame block already has `dt` from the render loop or computes it from `nowSec - lastTime`. Reuse whatever's there. +- **Task 6's `update.Omega`** field — verify against `VectorUpdate.Parsed`. If named `AngularVelocity` use that. +- **Subagent failure handling:** if a subagent reports a deviation that breaks the spec contract, dispatch a fix subagent or take it over manually. Don't let a confused subagent leave broken code in main. From f43f168916cda49b9f9471d37b8a5e492c9b27fb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:00:17 +0200 Subject: [PATCH 03/32] feat(physics): InterpolationManager core (L.3.1 Task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-data class + 13 unit tests. Ports retail's CPhysicsObj::InterpolateTo (acclient @ 0x005104F0) and InterpolationManager::adjust_offset (@ 0x00555D30) — FIFO position- waypoint queue (cap 20) + per-frame catch-up math walking the body toward the head node at 2 × motion-table-max-speed (clamped, with 7.5 m/s fallback). Reach @ 0.05m. Duplicate-prune @ 0.05m. Stall detection: every 5 frames; if progress < 30% of expected, increment fail counter; > 3 fails → blip-to-TAIL (resolved via decomp dive of UseTime @ 0x00555F20: tail_ is the snap target, not head_). Constants verified from binary at named addresses (not guesses): MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5, MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05. Composed into RemoteMotion in subsequent task; not yet used. Co-Authored-By: Claude Opus 4.7 --- .../Physics/InterpolationManager.cs | 255 +++++++++++++++ .../Physics/InterpolationManagerTests.cs | 306 ++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 src/AcDream.Core/Physics/InterpolationManager.cs create mode 100644 tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs new file mode 100644 index 00000000..e3a12677 --- /dev/null +++ b/src/AcDream.Core/Physics/InterpolationManager.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// InterpolationManager — retail CPhysicsObj interpolation queue. +// +// Ports: +// CPhysicsObj::InterpolateTo (acclient @ 0x005104F0) +// InterpolationManager::adjust_offset (acclient @ 0x00555D30) +// InterpolationManager::UseTime (acclient @ 0x00555F20) — stall/blip +// +// FIFO position-waypoint queue (cap 20). On each physics tick the caller +// passes current body position + max-speed from the motion table; we return +// the delta vector to apply to the body for this frame. +// +// Queue semantics: +// - Head = next target. Body walks toward head at catch-up speed. +// - Tail = most-recent server position. On stall we blip directly to tail +// (retail UseTime @ 0x00555F20: copies tail_ position, calls +// CPhysicsObj::SetPositionSimple, then StopInterpolating). +// +// Constants verified from named binary at the addresses cited above (not +// guesses): +// MAX_INTERPOLATED_VELOCITY_MOD = 2.0 +// MAX_INTERPOLATED_VELOCITY = 7.5 +// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (unused here — kept for reference) +// DESIRED_DISTANCE = 0.05 +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Waypoint used internally by . +/// +internal sealed class InterpolationNode +{ + public Vector3 TargetPosition; + public float Heading; + public bool IsHeadingValid; +} + +/// +/// Per-remote-entity position interpolation queue. Caller enqueues server +/// position updates and calls once per physics +/// tick to get the per-frame correction delta. +/// +public sealed class InterpolationManager +{ + // ── public constants (retail binary values) ─────────────────────────────── + + /// Maximum waypoints held before oldest is dropped. + public const int QueueCap = 20; + + /// + /// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier. + /// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30). + /// + public const float MaxInterpolatedVelocityMod = 2.0f; + + /// + /// Fallback catch-up speed (m/s) when motion-table max speed is + /// unavailable (zero/tiny). + /// Retail MAX_INTERPOLATED_VELOCITY (@ 0x00555D30). + /// + public const float MaxInterpolatedVelocity = 7.5f; + + /// + /// Per-5-frame stall progress threshold (meters). Body must advance at + /// least this far in frames or + /// it counts as a stall tick. + /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555D30). + /// + public const float MinDistanceToReachPosition = 0.20f; + + /// + /// Reach + duplicate-prune radius (meters). Node is popped when + /// distance to its target falls below this value; new enqueues within + /// this distance of the tail are ignored. + /// Retail DESIRED_DISTANCE (@ 0x00555D30). + /// + public const float DesiredDistance = 0.05f; + + /// + /// Number of ticks between stall progress checks. + /// Retail StallCheckFrameInterval (@ 0x00555D30). + /// + public const int StallCheckFrameInterval = 5; + + /// + /// Minimum fraction of the expected advance that counts as "real + /// progress" in a stall check window. Below this fraction the + /// fail counter increments. + /// Retail StallProgressMinFraction (@ 0x00555D30). + /// + public const float StallProgressMinFraction = 0.30f; + + /// + /// Number of consecutive stall-check failures before the body is + /// blipped to the tail of the queue. + /// Retail StallFailCountForBlip (@ 0x00555D30). + /// + public const int StallFailCountForBlip = 3; + + // ── internals ───────────────────────────────────────────────────────────── + + private readonly LinkedList _queue = new(); + + private int _failFrameCounter = 0; + private float _failDistanceLastCheck = 0f; + private int _failCount = 0; + + // ── public API ──────────────────────────────────────────────────────────── + + /// True when the queue holds at least one waypoint. + public bool IsActive => _queue.Count > 0; + + /// + /// Stop interpolating: clear the queue and reset all stall counters. + /// Retail StopInterpolating / destructor cleanup. + /// + public void Clear() + { + _queue.Clear(); + _failFrameCounter = 0; + _failDistanceLastCheck = 0f; + _failCount = 0; + } + + /// + /// Enqueue a new server-authoritative position waypoint. + /// + /// + /// Step 1: Duplicate-prune — if the new target is within + /// of the current tail, ignore it.
+ /// Step 2: Cap — if the queue is already at , + /// drop the oldest (head) entry.
+ /// Step 3/4: Append a new . + ///
+ /// + /// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0). + ///
+ /// Server-reported world position. + /// Server-reported heading (radians, AC convention). + /// True when the body is in motion — gates heading validity. + public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo) + { + // Step 1: duplicate-prune + if (_queue.Last is { } last) + { + if (Vector3.Distance(targetPosition, last.Value.TargetPosition) < DesiredDistance) + return; + } + + // Step 2: enforce cap + if (_queue.Count >= QueueCap) + _queue.RemoveFirst(); + + // Steps 3+4: add node + var node = new InterpolationNode + { + TargetPosition = targetPosition, + Heading = heading, + IsHeadingValid = isMovingTo, + }; + _queue.AddLast(node); + } + + /// + /// Compute the per-frame position correction delta. + /// + /// + /// Returns when the queue is empty or when + /// the head node has been reached. Returns a snap delta (tail − + /// currentBodyPosition) after + /// consecutive stall failures, then clears the queue. + /// + /// + /// Retail InterpolationManager::adjust_offset (@ 0x00555D30) + + /// UseTime stall/blip (@ 0x00555F20). + /// + /// Frame delta time (seconds). + /// Current world-space body position. + /// + /// Max motion-table speed for this entity's current cycle (m/s), as + /// reported by MotionInterpreter. Pass 0 if unavailable; the fallback + /// will be used. + /// + /// World-space delta to apply to the body this frame. + public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp) + { + // Step 1: empty queue → no correction + if (_queue.First is null) + return Vector3.Zero; + + // Step 2: peek head + var headNode = _queue.First.Value; + + // Step 3: distance to head target + float dist = (headNode.TargetPosition - currentBodyPosition).Length(); + + // Step 4: reached node + if (dist < DesiredDistance) + { + _queue.RemoveFirst(); + return Vector3.Zero; + } + + // Step 5: compute catch-up speed + float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod; + float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity; + + // Step 6: step magnitude (no overshoot) + float step = catchUpSpeed * (float)dt; + if (step > dist) + step = dist; + + // Step 7: direction × step + Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step; + + // Step 8: stall detection + _failFrameCounter++; + if (_failFrameCounter >= StallCheckFrameInterval) + { + float progress = _failDistanceLastCheck - dist; + float expected = catchUpSpeed * (float)dt * StallCheckFrameInterval; + + if (progress < StallProgressMinFraction * expected) + { + _failCount++; + if (_failCount > StallFailCountForBlip) + { + // Blip-to-tail: retail UseTime (@ 0x00555F20) reads + // position_queue.tail_, copies its position to a local, + // calls CPhysicsObj::SetPositionSimple, then + // StopInterpolating. Snap target is the TAIL (the most + // recent server position), not the head. + Vector3 tailPos = _queue.Last!.Value.TargetPosition; + Clear(); + return tailPos - currentBodyPosition; + } + } + else + { + _failCount = 0; + } + + _failDistanceLastCheck = dist; + _failFrameCounter = 0; + } + + // Step 9: return per-frame delta + return delta; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs new file mode 100644 index 00000000..eb8654ec --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs @@ -0,0 +1,306 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// InterpolationManagerTests — covers the retail CPhysicsObj interpolation +// queue port (L.3.1 Task 1). +// +// Source addresses tested: +// CPhysicsObj::InterpolateTo acclient @ 0x005104F0 (Enqueue) +// InterpolationManager::adjust_offset acclient @ 0x00555D30 (AdjustOffset) +// InterpolationManager::UseTime acclient @ 0x00555F20 (blip-to-tail) +// ───────────────────────────────────────────────────────────────────────────── + +public sealed class InterpolationManagerTests +{ + // ── helpers ─────────────────────────────────────────────────────────────── + + /// Origin used as the "body is here" position in most tests. + private static readonly Vector3 BodyOrigin = Vector3.Zero; + + /// A position clearly outside DesiredDistance (= 0.05 m). + private static readonly Vector3 FarTarget = new Vector3(10f, 0f, 0f); + + private static InterpolationManager Make() => new InterpolationManager(); + + // ========================================================================= + // Queue mechanics + // ========================================================================= + + [Fact] + public void Enqueue_AddsNode_QueueBecomesActive() + { + var mgr = Make(); + Assert.False(mgr.IsActive); + + mgr.Enqueue(FarTarget, heading: 0f, isMovingTo: true); + + Assert.True(mgr.IsActive); + } + + [Fact] + public void Enqueue_DropsOldestWhenAtCap20() + { + var mgr = Make(); + + // Fill the queue to cap with distinct positions spaced far enough + // apart to avoid the duplicate-prune threshold (DesiredDistance = 0.05). + for (int i = 0; i < InterpolationManager.QueueCap; i++) + { + mgr.Enqueue(new Vector3(i * 1f, 0f, 0f), heading: 0f, isMovingTo: true); + } + + // The next enqueue must NOT reject the entry; instead it drops the oldest. + // After the insert the queue count must still be QueueCap (not QueueCap+1). + mgr.Enqueue(new Vector3(100f, 0f, 0f), heading: 0f, isMovingTo: true); + + // We can't query Count directly (it's internal), but IsActive must remain + // true, and we verify the cap behaviour indirectly by confirming the call + // did not throw (the queue is bounded) and the manager is still active. + Assert.True(mgr.IsActive); + + // Drive the body toward the head until the queue empties, counting pops. + // If the cap was honoured (count stayed at QueueCap after the 21st push) + // the head should be position x=1 (the 2nd element) rather than x=0 (the + // original first, which was dropped). + // + // We verify this by snapping the body right onto the FarTarget step and + // counting how many AdjustOffset calls return zero after reaching a node. + // + // Simpler: just confirm the queue can be cleared completely. + mgr.Clear(); + Assert.False(mgr.IsActive); + } + + [Fact] + public void Enqueue_PrunesDuplicateWithinDesiredDistance() + { + var mgr = Make(); + var basePos = new Vector3(5f, 0f, 0f); + + mgr.Enqueue(basePos, heading: 0f, isMovingTo: true); + + // Within DesiredDistance (0.05) — must be ignored. + var nearDuplicate = basePos + new Vector3(0.01f, 0f, 0f); + mgr.Enqueue(nearDuplicate, heading: 0f, isMovingTo: true); + + // Confirm duplicate was not added: driving the body to basePos should + // exhaust the queue in one pop, leaving it empty. + // Position body exactly AT the target so AdjustOffset pops the head node. + var result = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: basePos, maxSpeedFromMinterp: 10f); + + Assert.Equal(Vector3.Zero, result); // reached → pop + Assert.False(mgr.IsActive); // only one node existed + } + + [Fact] + public void Clear_EmptiesQueueAndResetsCounters() + { + var mgr = Make(); + mgr.Enqueue(FarTarget, heading: 0f, isMovingTo: true); + Assert.True(mgr.IsActive); + + mgr.Clear(); + + Assert.False(mgr.IsActive); + + // After Clear, AdjustOffset must return zero (no stale state). + var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + Assert.Equal(Vector3.Zero, delta); + } + + // ========================================================================= + // AdjustOffset math + // ========================================================================= + + [Fact] + public void AdjustOffset_EmptyQueue_ReturnsZero() + { + var mgr = Make(); + var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + + Assert.Equal(Vector3.Zero, delta); + } + + [Fact] + public void AdjustOffset_ReachesNodeWithinDesiredDistance_PopsHead() + { + var mgr = Make(); + var target = new Vector3(0.02f, 0f, 0f); // within DesiredDistance (0.05) + + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + // Body is at origin; distance = 0.02 < 0.05 → should pop and return zero. + var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + + Assert.Equal(Vector3.Zero, delta); + Assert.False(mgr.IsActive, "Head node should have been popped after being reached"); + } + + [Fact] + public void AdjustOffset_ClampedToCatchUpSpeed_2xMotionMax() + { + var mgr = Make(); + float maxSpeed = 4.0f; // motion-table max speed + double dt = 0.5; // large dt to make the math clear + // target is far enough that there's no overshoot clamping + var target = new Vector3(100f, 0f, 0f); + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + var delta = mgr.AdjustOffset(dt, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: maxSpeed); + + // Expected step = catchUpSpeed * dt = (maxSpeed * 2.0) * dt = 4.0 + float expectedStep = maxSpeed * InterpolationManager.MaxInterpolatedVelocityMod * (float)dt; + Assert.Equal(expectedStep, delta.Length(), precision: 4); + } + + [Fact] + public void AdjustOffset_FallbackSpeed_WhenMinterpZero() + { + var mgr = Make(); + double dt = 0.5; + var target = new Vector3(100f, 0f, 0f); + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + // maxSpeedFromMinterp = 0 → fallback to MaxInterpolatedVelocity (7.5) + var delta = mgr.AdjustOffset(dt, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 0f); + + float expectedStep = InterpolationManager.MaxInterpolatedVelocity * (float)dt; + Assert.Equal(expectedStep, delta.Length(), precision: 4); + } + + [Fact] + public void AdjustOffset_OvershootProtection_StepClampedToDistance() + { + var mgr = Make(); + float maxSpeed = 10f; + double dt = 1.0; // step = 2*10*1.0 = 20 >> actual distance + + // Place target just 0.5 m away — inside the step distance. + var target = new Vector3(0.5f, 0f, 0f); + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + var delta = mgr.AdjustOffset(dt, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: maxSpeed); + + // Step should be clamped to dist (0.5), not the unclamped 20. + Assert.Equal(0.5f, delta.Length(), precision: 4); + } + + // ========================================================================= + // Stall detection + // ========================================================================= + + [Fact] + public void AdjustOffset_StallCounterIncrementsEachFrame() + { + // Run 4 frames (< StallCheckFrameInterval = 5) with a body that does + // not move — the queue should still be active (no blip yet). + var mgr = Make(); + var target = new Vector3(10f, 0f, 0f); + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + // Body does NOT move — we pass the same fixed position each frame. + for (int i = 0; i < 4; i++) + { + mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + } + + // After 4 frames (<5) the stall check hasn't fired yet, queue intact. + Assert.True(mgr.IsActive); + } + + [Fact] + public void AdjustOffset_NoProgressMarksFail_AfterFiveFrames() + { + // Body stays at origin every frame — zero real progress. + // After 5 frames the stall check fires and _failCount increments (to 1). + // Queue must still be alive (blip only at > StallFailCountForBlip = 3). + var mgr = Make(); + var target = new Vector3(50f, 0f, 0f); + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++) + { + mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + } + + // 1 fail < StallFailCountForBlip (3), so queue is still active. + Assert.True(mgr.IsActive); + } + + [Fact] + public void AdjustOffset_GoodProgressResetsFailCount() + { + // Simulate: body truly advances toward target each frame. + // After each check-interval the fail counter should reset to 0 + // (because progress ≥ 30% of expected). + var mgr = Make(); + var origin = Vector3.Zero; + var target = new Vector3(50f, 0f, 0f); + float maxSpd = 4f; + double dt = 0.016; + mgr.Enqueue(target, heading: 0f, isMovingTo: true); + + // Run 5 frames, advancing the body by the actual delta returned each time. + Vector3 bodyPos = origin; + for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++) + { + var delta = mgr.AdjustOffset(dt, currentBodyPosition: bodyPos, maxSpeedFromMinterp: maxSpd); + bodyPos += delta; // body truly moves + } + + // After 5 frames of genuine progress, queue must still be active + // (no blip) and _failCount should have been reset to 0 (no way to read + // it directly, but we verify indirectly: we'd need 3×5=15 more frames + // of stalling to blip — a further 5-frame no-progress window at this + // point should only bring _failCount to 1, not trigger a blip). + Assert.True(mgr.IsActive); + } + + [Fact] + public void AdjustOffset_3FailsTriggersBlipToTail() + { + // Need > StallFailCountForBlip (3) failures. + // Each failure requires one stall-check window (5 frames of no progress). + // So we need 4 × 5 = 20 frames with the body frozen at origin. + // + // Also enqueue a SECOND node (the tail) different from the first, so we + // can verify the snap is to the tail, not the head. + var mgr = Make(); + var head = new Vector3(10f, 0f, 0f); + var tail = new Vector3(30f, 0f, 0f); + + mgr.Enqueue(head, heading: 0f, isMovingTo: true); + mgr.Enqueue(tail, heading: 0f, isMovingTo: true); + + // 4 stall-check windows × 5 frames each = 20 frames, body never moves. + Vector3? blipDelta = null; + const int totalFrames = (InterpolationManager.StallFailCountForBlip + 1) + * InterpolationManager.StallCheckFrameInterval; + + for (int i = 0; i < totalFrames; i++) + { + var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + if (delta.Length() > 1f) // blip delta will be >> normal per-frame step + { + blipDelta = delta; + break; + } + } + + // Blip must have fired. + Assert.NotNull(blipDelta); + + // Blip delta = tailPos − currentBodyPosition = (30,0,0) − (0,0,0) + Assert.Equal(tail.X, blipDelta!.Value.X, precision: 4); + Assert.Equal(tail.Y, blipDelta!.Value.Y, precision: 4); + Assert.Equal(tail.Z, blipDelta!.Value.Z, precision: 4); + + // Queue must be cleared after blip (retail StopInterpolating). + Assert.False(mgr.IsActive); + } +} From 927636ec7725189607eadf97222ba08aaa6a11e5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:10:23 +0200 Subject: [PATCH 04/32] fix(physics): InterpolationManager review findings (L.3.1 Task 1 polish) Addresses code-quality review findings on commit f43f168: C-1: Stall detection re-implemented to match retail (acclient lines 353071-353275). Tracks _progressQuantum (sum of step values per window) + _distanceAtWindowStart (set at window start). Primary check: cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20m absolute). Secondary check: cumulative_progress / _progressQuantum < 0.30. Either failing increments fail counter; blip-to-tail at >3 consecutive fails (already correct). C-2: Renamed StallFailCountForBlip -> StallFailCountThreshold with clearer XML doc explaining the > vs >= semantics (blip fires when fail count EXCEEDS the threshold, i.e. on the 4th consecutive failed window). I-1: _haveBaselineDistance sentinel prevents first-window false positive that was triggering spurious fails on every new motion sequence (old code defaulted _distanceAtWindowStart to 0, making cumulative progress always negative on frame 5). I-3: dt <= 0 || NaN guard at AdjustOffset entry prevents NaN propagation into PhysicsBody.Position. I-4: Internal field renames for clarity: _failFrameCounter -> _framesSinceLastStallCheck _failDistanceLastCheck -> merged into _distanceAtWindowStart I-5: Added internal Count property + InternalsVisibleTo (via AssemblyAttribute in .csproj) so Enqueue_DropsOldestWhenAtCap20 actually verifies cap enforcement. Added assertion that head is the second-enqueued position after overflow. 3 new tests (AdjustOffset_FirstWindow_DoesNotFalseFail, AdjustOffset_DtZeroOrNegative_ReturnsZero, Enqueue_AtCap20_HeadIsSecondOriginal), 16 total. All green. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.Core/AcDream.Core.csproj | 5 + .../Physics/InterpolationManager.cs | 144 +++++++++++++----- .../Physics/InterpolationManagerTests.cs | 120 ++++++++++++--- 3 files changed, 213 insertions(+), 56 deletions(-) diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 2472687d..6155c02b 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -11,6 +11,11 @@ + + + <_Parameter1>AcDream.Core.Tests + + diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs index e3a12677..1a6ff53c 100644 --- a/src/AcDream.Core/Physics/InterpolationManager.cs +++ b/src/AcDream.Core/Physics/InterpolationManager.cs @@ -26,7 +26,7 @@ namespace AcDream.Core.Physics; // guesses): // MAX_INTERPOLATED_VELOCITY_MOD = 2.0 // MAX_INTERPOLATED_VELOCITY = 7.5 -// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (unused here — kept for reference) +// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters) // DESIRED_DISTANCE = 0.05 // ───────────────────────────────────────────────────────────────────────────── @@ -68,8 +68,8 @@ public sealed class InterpolationManager /// /// Per-5-frame stall progress threshold (meters). Body must advance at /// least this far in frames or - /// it counts as a stall tick. - /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555D30). + /// the window counts as a stall. + /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42). /// public const float MinDistanceToReachPosition = 0.20f; @@ -83,38 +83,69 @@ public sealed class InterpolationManager /// /// Number of ticks between stall progress checks. - /// Retail StallCheckFrameInterval (@ 0x00555D30). + /// Retail frame_counter threshold (@ 0x00555E14). /// public const int StallCheckFrameInterval = 5; /// - /// Minimum fraction of the expected advance that counts as "real - /// progress" in a stall check window. Below this fraction the - /// fail counter increments. - /// Retail StallProgressMinFraction (@ 0x00555D30). + /// Minimum fraction of cumulative progress_quantum that counts as "real + /// progress" in a stall check window. Below this fraction the window + /// counts as a stall (secondary check, applies when progress_quantum > 0). + /// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73). /// public const float StallProgressMinFraction = 0.30f; /// - /// Number of consecutive stall-check failures before the body is - /// blipped to the tail of the queue. - /// Retail StallFailCountForBlip (@ 0x00555D30). + /// Stall-fail counter threshold. The body is blipped to the tail of the + /// queue when node_fail_counter EXCEEDS this value (i.e., on the + /// 4th consecutive failed window, not the 3rd). + /// Retail: node_fail_counter > 3 (@ 0x00555F39). /// - public const int StallFailCountForBlip = 3; + public const int StallFailCountThreshold = 3; // ── internals ───────────────────────────────────────────────────────────── private readonly LinkedList _queue = new(); - private int _failFrameCounter = 0; - private float _failDistanceLastCheck = 0f; - private int _failCount = 0; + /// Frames elapsed since the last 5-frame stall-check window fired. + private int _framesSinceLastStallCheck = 0; + + /// + /// Cumulative sum of per-frame step magnitudes within the current + /// 5-frame window. Retail progress_quantum. + /// + private float _progressQuantum = 0f; + + /// + /// Distance to the head node recorded at the START of the current + /// 5-frame window. Retail original_distance. + /// + private float _distanceAtWindowStart = 0f; + + /// + /// True once the first valid distance sample has been taken and + /// _distanceAtWindowStart is populated. Guards against the + /// first-window false-positive that occurs when the field defaults to 0. + /// + private bool _haveBaselineDistance = false; + + /// + /// Number of consecutive 5-frame windows that failed both the absolute + /// and ratio progress checks. Retail node_fail_counter. + /// Blip fires when this EXCEEDS . + /// + private int _failCount = 0; // ── public API ──────────────────────────────────────────────────────────── /// True when the queue holds at least one waypoint. public bool IsActive => _queue.Count > 0; + /// + /// Current waypoint count (visible to the test assembly for cap verification). + /// + internal int Count => _queue.Count; + /// /// Stop interpolating: clear the queue and reset all stall counters. /// Retail StopInterpolating / destructor cleanup. @@ -122,9 +153,11 @@ public sealed class InterpolationManager public void Clear() { _queue.Clear(); - _failFrameCounter = 0; - _failDistanceLastCheck = 0f; - _failCount = 0; + _framesSinceLastStallCheck = 0; + _progressQuantum = 0f; + _distanceAtWindowStart = 0f; + _haveBaselineDistance = false; + _failCount = 0; } /// @@ -172,8 +205,9 @@ public sealed class InterpolationManager /// /// Returns when the queue is empty or when /// the head node has been reached. Returns a snap delta (tail − - /// currentBodyPosition) after - /// consecutive stall failures, then clears the queue. + /// currentBodyPosition) after + /// consecutive stall failures (i.e., fail count EXCEEDS the threshold), + /// then clears the queue. /// /// /// Retail InterpolationManager::adjust_offset (@ 0x00555D30) + @@ -189,6 +223,9 @@ public sealed class InterpolationManager /// World-space delta to apply to the body this frame. public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp) { + // Guard: bad dt → skip entirely to prevent NaN poisoning PhysicsBody.Position. + if (dt <= 0 || double.IsNaN(dt)) return Vector3.Zero; + // Step 1: empty queue → no correction if (_queue.First is null) return Vector3.Zero; @@ -218,23 +255,58 @@ public sealed class InterpolationManager // Step 7: direction × step Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step; - // Step 8: stall detection - _failFrameCounter++; - if (_failFrameCounter >= StallCheckFrameInterval) - { - float progress = _failDistanceLastCheck - dist; - float expected = catchUpSpeed * (float)dt * StallCheckFrameInterval; + // Step 8: stall detection (retail adjust_offset @ 0x00555E08-0x00555E92) + // + // Retail tracks two quantities across each 5-frame window: + // progress_quantum — cumulative sum of per-frame step magnitudes + // original_distance — distance to head at the START of the window + // + // At window end (frame_counter >= 5): + // cumulative_progress = original_distance - currentDist + // + // Primary check (@ 0x00555E42): + // cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20 m) + // → window is a stall; increment node_fail_counter. + // + // Secondary check (@ 0x00555E73, only when progress_quantum > 0): + // cumulative_progress / progress_quantum < CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30) + // → window is a stall; increment node_fail_counter. + // + // Both checks operate with sticky_object_id == 0 (we never have one). + // Either check failing counts the window as a stall. + // + // Blip fires when node_fail_counter > 3 (retail UseTime @ 0x00555F39). + // Window always resets (frame_counter=0, progress_quantum=0, + // original_distance=currentDist) after the check. - if (progress < StallProgressMinFraction * expected) + // Initialise window baseline on first call after Clear / new motion. + if (!_haveBaselineDistance) + { + _distanceAtWindowStart = dist; + _haveBaselineDistance = true; + } + + _progressQuantum += step; + _framesSinceLastStallCheck++; + + if (_framesSinceLastStallCheck >= StallCheckFrameInterval) + { + float cumulativeProgress = _distanceAtWindowStart - dist; + + bool primaryFail = cumulativeProgress < MinDistanceToReachPosition; + bool secondaryFail = _progressQuantum > 0f && + (cumulativeProgress / _progressQuantum) < StallProgressMinFraction; + + if (primaryFail || secondaryFail) { _failCount++; - if (_failCount > StallFailCountForBlip) + // Blip-to-tail: retail UseTime (@ 0x00555F20) reads + // position_queue.tail_, copies its position to a local, + // calls CPhysicsObj::SetPositionSimple, then + // StopInterpolating. Snap target is the TAIL (the most + // recent server position), not the head. + if (_failCount > StallFailCountThreshold) { - // Blip-to-tail: retail UseTime (@ 0x00555F20) reads - // position_queue.tail_, copies its position to a local, - // calls CPhysicsObj::SetPositionSimple, then - // StopInterpolating. Snap target is the TAIL (the most - // recent server position), not the head. Vector3 tailPos = _queue.Last!.Value.TargetPosition; Clear(); return tailPos - currentBodyPosition; @@ -245,8 +317,10 @@ public sealed class InterpolationManager _failCount = 0; } - _failDistanceLastCheck = dist; - _failFrameCounter = 0; + // Reset the 5-frame window regardless of pass/fail. + _framesSinceLastStallCheck = 0; + _progressQuantum = 0f; + _distanceAtWindowStart = dist; } // Step 9: return per-frame delta diff --git a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs index eb8654ec..fd239315 100644 --- a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs @@ -54,26 +54,50 @@ public sealed class InterpolationManagerTests mgr.Enqueue(new Vector3(i * 1f, 0f, 0f), heading: 0f, isMovingTo: true); } - // The next enqueue must NOT reject the entry; instead it drops the oldest. - // After the insert the queue count must still be QueueCap (not QueueCap+1). + // Sanity: queue is at cap before the 21st enqueue. + Assert.Equal(InterpolationManager.QueueCap, mgr.Count); + + // The 21st enqueue must drop the oldest (x=0) and keep the count at cap. mgr.Enqueue(new Vector3(100f, 0f, 0f), heading: 0f, isMovingTo: true); - // We can't query Count directly (it's internal), but IsActive must remain - // true, and we verify the cap behaviour indirectly by confirming the call - // did not throw (the queue is bounded) and the manager is still active. - Assert.True(mgr.IsActive); + // Count must still be QueueCap — not QueueCap+1. + Assert.Equal(InterpolationManager.QueueCap, mgr.Count); - // Drive the body toward the head until the queue empties, counting pops. - // If the cap was honoured (count stayed at QueueCap after the 21st push) - // the head should be position x=1 (the 2nd element) rather than x=0 (the - // original first, which was dropped). - // - // We verify this by snapping the body right onto the FarTarget step and - // counting how many AdjustOffset calls return zero after reaching a node. - // - // Simpler: just confirm the queue can be cleared completely. - mgr.Clear(); - Assert.False(mgr.IsActive); + // The head (oldest surviving node) must now be x=1 (the second-original + // position), not x=0 (which was dropped). Verify by driving the body + // to exactly x=1 — AdjustOffset must pop that node (distance < DesiredDistance) + // and return zero, confirming x=1 is the head. + var bodyAtSecondOriginal = new Vector3(1f, 0f, 0f); + var result = mgr.AdjustOffset( + dt: 0.016, + currentBodyPosition: bodyAtSecondOriginal, + maxSpeedFromMinterp: 10f); + + // Reached head (dist ≈ 0) → zero delta + node popped. + Assert.Equal(Vector3.Zero, result); + // One node was consumed; count must now be QueueCap - 1. + Assert.Equal(InterpolationManager.QueueCap - 1, mgr.Count); + } + + [Fact] + public void Enqueue_AtCap20_HeadIsSecondOriginal() + { + // Complementary test for the cap overflow: after 21 enqueues the + // second-enqueued position (x=1) must be at the head, not x=0. + var mgr = Make(); + for (int i = 0; i < InterpolationManager.QueueCap; i++) + { + mgr.Enqueue(new Vector3(i * 1f, 0f, 0f), heading: 0f, isMovingTo: true); + } + mgr.Enqueue(new Vector3(100f, 0f, 0f), heading: 0f, isMovingTo: true); + + // Place the body far away from x=0 but RIGHT on x=1. If x=0 were the + // head the result would be non-zero (body is 1 m away from x=0). + // If x=1 is the head the distance is 0 → pop → zero return. + var bodyAtX1 = new Vector3(1f, 0f, 0f); + var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: bodyAtX1, maxSpeedFromMinterp: 10f); + + Assert.Equal(Vector3.Zero, delta); } [Fact] @@ -218,7 +242,7 @@ public sealed class InterpolationManagerTests { // Body stays at origin every frame — zero real progress. // After 5 frames the stall check fires and _failCount increments (to 1). - // Queue must still be alive (blip only at > StallFailCountForBlip = 3). + // Queue must still be alive (blip only at > StallFailCountThreshold = 3). var mgr = Make(); var target = new Vector3(50f, 0f, 0f); mgr.Enqueue(target, heading: 0f, isMovingTo: true); @@ -228,7 +252,7 @@ public sealed class InterpolationManagerTests mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); } - // 1 fail < StallFailCountForBlip (3), so queue is still active. + // 1 fail < StallFailCountThreshold (3), so queue is still active. Assert.True(mgr.IsActive); } @@ -264,7 +288,7 @@ public sealed class InterpolationManagerTests [Fact] public void AdjustOffset_3FailsTriggersBlipToTail() { - // Need > StallFailCountForBlip (3) failures. + // Need > StallFailCountThreshold (3) failures. // Each failure requires one stall-check window (5 frames of no progress). // So we need 4 × 5 = 20 frames with the body frozen at origin. // @@ -279,7 +303,7 @@ public sealed class InterpolationManagerTests // 4 stall-check windows × 5 frames each = 20 frames, body never moves. Vector3? blipDelta = null; - const int totalFrames = (InterpolationManager.StallFailCountForBlip + 1) + const int totalFrames = (InterpolationManager.StallFailCountThreshold + 1) * InterpolationManager.StallCheckFrameInterval; for (int i = 0; i < totalFrames; i++) @@ -303,4 +327,58 @@ public sealed class InterpolationManagerTests // Queue must be cleared after blip (retail StopInterpolating). Assert.False(mgr.IsActive); } + + // ========================================================================= + // New tests: I-1 first-window false-positive guard, I-3 dt guard, I-5 cap + // ========================================================================= + + [Fact] + public void AdjustOffset_FirstWindow_DoesNotFalseFail() + { + // Before the fix, _distanceAtWindowStart defaulted to 0, so on the + // first 5-frame window cumulative_progress = 0 - dist = -dist < 0.20 + // → every new motion sequence triggered a spurious stall fail. + // + // After the fix, the baseline is seeded from the first call, so + // cumulative_progress = dist(frame0) - dist(frame4) which for a body + // that hasn't moved yet is ≈ 0. That is still < MIN (0.20), but the + // _failCount starts at 0 and must be > 3 (not == 1) to blip. The key + // assertion is that after exactly ONE stall window the queue is still + // alive (fail count == 1, blip requires > 3). + var mgr = Make(); + mgr.Enqueue(new Vector3(50f, 0f, 0f), heading: 0f, isMovingTo: true); + + // Run exactly one check-window (5 frames) with the body frozen. + for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++) + { + mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + } + + // One window fail → _failCount == 1, far below StallFailCountThreshold (3). + // Queue must still be active; no spurious blip on first window. + Assert.True(mgr.IsActive, + "First stall window must NOT trigger a blip (would require > 3 consecutive failures)."); + } + + [Fact] + public void AdjustOffset_DtZeroOrNegative_ReturnsZero() + { + var mgr = Make(); + mgr.Enqueue(FarTarget, heading: 0f, isMovingTo: true); + + // dt == 0 → guard fires, return zero, no side-effects. + var deltaZero = mgr.AdjustOffset(dt: 0.0, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + Assert.Equal(Vector3.Zero, deltaZero); + + // dt < 0 → guard fires, return zero. + var deltaNeg = mgr.AdjustOffset(dt: -1.0, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + Assert.Equal(Vector3.Zero, deltaNeg); + + // dt = NaN → guard fires, return zero. + var deltaNaN = mgr.AdjustOffset(dt: double.NaN, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); + Assert.Equal(Vector3.Zero, deltaNaN); + + // Queue must still be intact (guards did not consume or corrupt state). + Assert.True(mgr.IsActive); + } } From 9c5634af17de959098028e6c2ec3b0181e416957 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:16:38 +0200 Subject: [PATCH 05/32] feat(physics): MotionInterpreter.GetMaxSpeed for InterpolationManager (L.3.1 Task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports retail's CMotionInterp::get_max_speed (0x00527cb0). Returns motion-table-derived max speed (m/s) for InterpretedState.ForwardCommand: - RunForward: RunAnimSpeed (4.0) × (InqRunRate ?? MyRunRate) - WalkForward: WalkAnimSpeed (3.12) - WalkBackward: WalkAnimSpeed × 0.65 (BackwardsFactor from adjust_motion @ 0x00528010) - otherwise: 0 Decomp note: Binary Ninja emits a spurious void return for x87 FPU-returning functions; the actual float return is confirmed by both callers (StickyManager::adjust_offset @ 0x00555430, InterpolationManager::AdjustOffset @ 0x00555d52) which multiply the result by 2.0 to produce a catch-up speed in m/s. The per-command switch is consistent with get_state_velocity (0x00527d50) which uses the same constants. Used by InterpolationManager.AdjustOffset in Task 5 as 2 × GetMaxSpeed(). Until Task 5 wires it, the method is unused — covered by 4 unit tests. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.Core/Physics/MotionInterpreter.cs | 50 +++++++++++++++++ .../Physics/MotionInterpreterTests.cs | 56 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 038f675a..1930b446 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -932,6 +932,56 @@ public sealed class MotionInterpreter apply_current_movement(cancelMoveTo: false, allowJump: true); } + // ── CMotionInterp::get_max_speed (0x00527cb0) ───────────────────────────── + + /// + /// Return the motion-table-derived max speed (m/s) for the current + /// . + /// + /// + /// Retail reference (named-retail, 0x00527cb0): + /// CMotionInterp::get_max_speed fetches the run rate via + /// InqRunRate (or falls back to my_run_rate) and returns + /// the result as a float from the x87 FPU stack (ST0). The Binary Ninja + /// decompiler emits a spurious void return type for x87-returning + /// functions — the actual return value is confirmed by the two callers: + /// StickyManager::adjust_offset (0x00555430) and + /// InterpolationManager::AdjustOffset (0x00555d52), both of which + /// multiply the result by 2.0 to produce a catch-up speed in m/s. With a + /// run rate of ~1.0 the catch-up would be 2.0 m/s — far too slow — so the + /// function must return the actual velocity (m/s), not a bare rate. + /// + /// + /// + /// The per-command switch mirrors get_state_velocity + /// (0x00527d50), which uses the same constants and the same + /// RunForward / WalkForward / WalkBackward branches, and the + /// adjust_motion (0x00527c0e) BackwardsFactor = 0.65 + /// scaling confirmed at address 0x00528010. + /// + /// + /// + /// Used by InterpolationManager.AdjustOffset in L.3 Task 5 + /// as 2 × GetMaxSpeed() catch-up speed. + /// + /// + public float GetMaxSpeed() + { + // Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate. + // Mirrors the InqRunRate query at the top of CMotionInterp::get_max_speed. + float rate = MyRunRate; + if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried)) + rate = queried; + + return InterpretedState.ForwardCommand switch + { + MotionCommand.RunForward => RunAnimSpeed * rate, + MotionCommand.WalkForward => WalkAnimSpeed, + MotionCommand.WalkBackward => WalkAnimSpeed * 0.65f, // BackwardsFactor @ adjust_motion 0x00528010 + _ => 0f, // idle / non-locomotion + }; + } + // ── private helper ──────────────────────────────────────────────────────── /// diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index 18926116..addc9fa7 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -817,4 +817,60 @@ public sealed class MotionInterpreterTests var vel = mi.get_state_velocity(); Assert.Equal(4.0f * 2.375f, vel.Y, precision: 2); } + + // ========================================================================= + // GetMaxSpeed (CMotionInterp::get_max_speed @ 0x00527cb0) + // L.3.1 Task 2 — InterpolationManager catch-up speed source + // ========================================================================= + + [Fact] + public void GetMaxSpeed_RunForward_ReturnsRunAnimSpeedTimesRunRate() + { + // Retail: get_max_speed returns run rate from InqRunRate; callers + // multiply by 2 to get catch-up speed. For RunForward the per-m/s + // speed is RunAnimSpeed × rate = 4.0 × 1.5 = 6.0. + var weenie = new FakeWeenie { RunRate = 1.5f }; + var interp = MakeInterp(weenie: weenie); + interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; + + float speed = interp.GetMaxSpeed(); + + Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.5f, speed, precision: 4); // 6.0 + } + + [Fact] + public void GetMaxSpeed_WalkForward_ReturnsWalkAnimSpeed() + { + // WalkForward max speed is always WalkAnimSpeed (3.12) — no run-rate scaling. + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + + float speed = interp.GetMaxSpeed(); + + Assert.Equal(MotionInterpreter.WalkAnimSpeed, speed, precision: 4); + } + + [Fact] + public void GetMaxSpeed_WalkBackward_ReturnsWalkAnimSpeedTimesBackwardsFactor() + { + // BackwardsFactor = 0.65, from adjust_motion @ 0x00528010 in the named retail decomp. + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.WalkBackward; + + float speed = interp.GetMaxSpeed(); + + Assert.Equal(MotionInterpreter.WalkAnimSpeed * 0.65f, speed, precision: 4); + } + + [Fact] + public void GetMaxSpeed_Idle_ReturnsZero() + { + // Ready / non-locomotion commands → 0 (no movement speed). + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.Ready; + + float speed = interp.GetMaxSpeed(); + + Assert.Equal(0f, speed, precision: 4); + } } From 5b26d28b082756fdc2309a16eba804e22085e685 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:20:39 +0200 Subject: [PATCH 06/32] test(physics): MyRunRate fallback test for GetMaxSpeed (L.3.1 Task 2 polish) Code-quality review on commit 9c5634a flagged that the existing 4 GetMaxSpeed tests didn't cover the case where WeenieObj is null and RunForward must fall back to MyRunRate. Without this test, a regression that hardcoded the fallback to 1.0f would silently pass. Co-Authored-By: Claude Opus 4.7 --- .../Physics/MotionInterpreterTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index addc9fa7..1b7d05ea 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -873,4 +873,19 @@ public sealed class MotionInterpreterTests Assert.Equal(0f, speed, precision: 4); } + + [Fact] + public void GetMaxSpeed_RunForward_NoWeenie_FallsBackToMyRunRate() + { + // WeenieObj is null (MakeInterp with no weenie argument); MyRunRate + // is set explicitly. GetMaxSpeed must use MyRunRate as the run-rate + // source when InqRunRate is unavailable. + var interp = MakeInterp(); + interp.MyRunRate = 1.75f; + interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; + + float speed = interp.GetMaxSpeed(); + + Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4); + } } From 517a3ce89c3379c96e0dd45694fde02e160d22ed Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:21:44 +0200 Subject: [PATCH 07/32] feat(motion): RemoteMotion gains InterpolationManager field (L.3.1 Task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the InterpolationManager (Task 1+2) into the per-remote RemoteMotion container in GameWindow. Field exists but is not yet consumed — Tasks 4 and 5 wire it into the routing + per-frame tick. No behavior change. Build + 105 tests still green. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e03217b7..75857de8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -331,6 +331,17 @@ public sealed class GameWindow : IDisposable /// public bool Airborne; + /// + /// Per-remote position-waypoint queue + catch-up math (retail's + /// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset). + /// Replaces the hard-snap-then-Euler-extrapolate path when + /// ACDREAM_INTERP_MANAGER=1 — see Phase L.3.1 spec at + /// docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md. + /// Field exists from Task 3 onwards; consumed in Tasks 4 + 5. + /// + public AcDream.Core.Physics.InterpolationManager Interp { get; } = + new AcDream.Core.Physics.InterpolationManager(); + public RemoteMotion() { Body = new AcDream.Core.Physics.PhysicsBody From 062e19f4639ed7bfbd16ec4b5a2eab04369d2ff3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:24:57 +0200 Subject: [PATCH 08/32] feat(motion): MoveOrTeleport routing in OnLivePositionUpdated (L.3.1 Task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the legacy hard-snap path in ACDREAM_INTERP_MANAGER=1 env-var guard. When set, runs retail-faithful routing (acclient!CPhysicsObj:: MoveOrTeleport @ 0x00516330): - distance > 96m → hard-snap (SetPositionSimple equivalent) - distance ≤ 96m → Interp.Enqueue (queue for adjust_offset to walk to) - teleport flag → hard-snap (default false until sequence plumbing) - has_contact false → no-op (default true until parser plumbing) Existing hard-snap behavior preserved when flag unset (default). Old path will be removed in cleanup commit (Task 8) after visual verification. Helper: ExtractYawFromQuaternion (inverse of GameWindow.YawToAcQuaternion). TODO followups (filed as plan known-limitations): - IsStaleSequence (uint16 wrap-aware compare on 4 sequence counters) - HasContact wire field (CreateObject.ServerPosition gap) - Teleport-sequence comparison Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 75857de8..af851407 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3241,6 +3241,56 @@ public sealed class GameWindow : IDisposable // slerp doesn't visibly rotate from Identity to truth. rmState.Body.Orientation = rot; } + + // L.3.1 Task 4: env-var gated retail-faithful MoveOrTeleport routing. + // Mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). + // Enabled only when ACDREAM_INTERP_MANAGER=1 to keep default behavior + // identical to before this commit. Legacy hard-snap path remains below. + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + { + // CPhysicsObj::MoveOrTeleport router (acclient @ 0x00516330): + // - stale instance/position seq → ignore (TODO: IsStaleSequence not yet plumbed) + // - teleport-seq newer or no-cell → SetPosition (hard-snap) + // - has_contact false → no-op (TODO: HasContact not on wire — default true for L.3.1) + // - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue) + // - has_contact && distance > 96 → SetPositionSimple (slide-snap) + + const float MaxPhysicsDistance = 96f; + System.Numerics.Vector3 localPlayerPos = + _playerController?.Position ?? System.Numerics.Vector3.Zero; + float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); + + // Default-false: teleport flag not plumbed until sequence comparison lands (Task 5+). + bool teleportFlag = false; + // Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap). + // bool hasContact = true; (implicit — only the teleport and distance branches below) + + if (teleportFlag) + { + // SetPosition equivalent: hard-snap position + orientation, clear interp queue. + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + rmState.Interp.Clear(); + } + else if (dist > MaxPhysicsDistance) + { + // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + } + else + { + // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. + // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. + float headingFromQuat = ExtractYawFromQuaternion(rot); + rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); + } + + // Skip the legacy hard-snap path below. + return; + } + double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds; System.Numerics.Vector3? serverVelocity = update.Velocity; if (serverVelocity is null @@ -5131,6 +5181,20 @@ public sealed class GameWindow : IDisposable return new System.Numerics.Quaternion(0f, 0f, z, w); } + /// + /// Inverse of : extracts the local yaw (rotation + /// about the Z axis, in radians) from an AC wire quaternion. + /// Yaw=0 faces +X (East). Used by the L.3.1 InterpolationManager routing to + /// convert server orientation into the heading expected by InterpolationManager.Enqueue. + /// Standard formula: atan2( 2(wz + xy), 1 − 2(y² + z²) ). + /// + private static float ExtractYawFromQuaternion(System.Numerics.Quaternion q) + { + return MathF.Atan2( + 2f * (q.W * q.Z + q.X * q.Y), + 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); + } + private void OnCameraModeChanged(bool _modeBool) { if (_input is null) return; From ae79e34a6d2db713ace657c7a5505c289236d248 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:31:03 +0200 Subject: [PATCH 09/32] feat(motion): per-frame Interp.AdjustOffset in remote tick (L.3.1 Task 5) Wraps the existing legacy per-frame remote tick (apply_current_movement + force-OnWalkable + Euler-extrapolate) in ACDREAM_INTERP_MANAGER=1 env-var guard. When set: - if Interp.IsActive: rm.Body.Position += Interp.AdjustOffset(dt, pos, maxSpeed) - still call body.UpdatePhysicsInternal so airborne arcs (gravity) continue to integrate via the OnLiveVectorUpdated-set velocity. When env-var unset (default), legacy path runs unchanged. Mirrors retail's per-tick CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730) which calls InterpolationManager::adjust_offset (@ 0x00555D30) every frame. Old legacy path will be removed in Task 8 cleanup commit after visual verification. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 624 +++++++++++++----------- 1 file changed, 333 insertions(+), 291 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index af851407..0d4210ad 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5763,320 +5763,362 @@ public sealed class GameWindow : IDisposable && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - // Stop detection is handled explicitly on packet receipt: - // - UpdateMotion with ForwardCommand flag CLEARED → Ready. - // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. - // Both map to retail's "flag-absent = Invalid = reset to - // default" semantics (FUN_0051F260 bulk-copy). No timer-based - // inference needed — the server sends the right signal every - // time a remote stops. - - // Retail per-tick motion pipeline applied to every remote. - // Mirrors retail FUN_00515020 update_object → FUN_00513730 - // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: - // - // 1. apply_current_movement (FUN_00529210) — recomputes - // body.Velocity from InterpretedState via get_state_velocity. - // 2. Pull omega from the sequencer (baked MotionData.Omega - // for TurnRight / TurnLeft cycles, scaled by speedMod). - // 3. body.update_object(now) — Euler-integrates - // position += Velocity × dt + 0.5 × Accel × dt² AND - // orientation += omega × dt. - // - // On UpdatePosition receipt we hard-snap body.Position and - // body.Orientation — if integration matched server physics, - // each snap is small/invisible. - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - - // Step 1: re-apply current motion commands → body.Velocity. - // Forces OnWalkable + Contact so the gate in apply_current_movement - // always succeeds (remotes are server-authoritative; we don't - // simulate airborne physics for them). - // - // K-fix9 (2026-04-26): SKIP this when the remote is airborne. - // Otherwise the force-OnWalkable + apply_current_movement - // path stomps the +Z velocity we set in OnLiveVectorUpdated, - // and gravity never gets to integrate the arc. The airborne - // body keeps the launch velocity from the VectorUpdate; - // UpdatePhysicsInternal below applies gravity each tick; - // the next UpdatePosition snaps to the new ground location - // and re-grounds. - if (!rm.Airborne) + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable - | AcDream.Core.Physics.TransientStateFlags.Active; - if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) + // ── NEW PATH: queued position-chase via InterpolationManager ── + // (L.3.1 Task 5 — ACDREAM_INTERP_MANAGER=1 gates this path) + // + // Walking remotes have m_velocityVector == 0 in retail; all + // visible horizontal motion comes from + // InterpolationManager::adjust_offset (acclient @ 0x00555D30) + // walking the body toward the head of the waypoint queue at + // 2 × motion_max_speed × dt (clamped, 7.5 m/s fallback). + // + // Mirrors retail CPhysicsObj::UpdateObjectInternal + // (acclient @ 0x00513730) which calls adjust_offset every frame + // before UpdatePhysicsInternal integrates gravity. + // + // For airborne remotes, OnLiveVectorUpdated has set + // body.Velocity (launch arc); we still call + // UpdatePhysicsInternal below so gravity applies each frame and + // produces the parabolic arc. The IsActive gate prevents + // AdjustOffset from pulling against an in-flight arc when no + // waypoints are queued for a jumping remote. + if (rm.Interp.IsActive) { - double velocityAge = nowSec - rm.LastServerPosTime; - if (velocityAge > ServerControlledVelocityStaleSeconds) - { - rm.ServerVelocity = System.Numerics.Vector3.Zero; - rm.HasServerVelocity = false; - rm.Body.Velocity = System.Numerics.Vector3.Zero; - ApplyServerControlledVelocityCycle( - serverGuid, - ae, - rm, - System.Numerics.Vector3.Zero); - } - else - { - rm.Body.Velocity = rm.ServerVelocity; - } + float maxSpeed = rm.Motion.GetMaxSpeed(); + System.Numerics.Vector3 delta = rm.Interp.AdjustOffset((double)dt, rm.Body.Position, maxSpeed); + rm.Body.Position += delta; } - else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive - && rm.HasMoveToDestination) + + // Gravity integration: retail's UpdatePhysicsInternal still + // fires every frame regardless of the interpolation path. + // For grounded remotes body.Velocity == 0 so this is a no-op; + // for airborne remotes it applies gravity to the arc. + rm.Body.UpdatePhysicsInternal(dt); + + ae.Entity.Position = rm.Body.Position; + ae.Entity.Rotation = rm.Body.Orientation; + } + else + { + // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ── + // + // Stop detection is handled explicitly on packet receipt: + // - UpdateMotion with ForwardCommand flag CLEARED → Ready. + // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. + // Both map to retail's "flag-absent = Invalid = reset to + // default" semantics (FUN_0051F260 bulk-copy). No timer-based + // inference needed — the server sends the right signal every + // time a remote stops. + + // Retail per-tick motion pipeline applied to every remote. + // Mirrors retail FUN_00515020 update_object → FUN_00513730 + // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: + // + // 1. apply_current_movement (FUN_00529210) — recomputes + // body.Velocity from InterpretedState via get_state_velocity. + // 2. Pull omega from the sequencer (baked MotionData.Omega + // for TurnRight / TurnLeft cycles, scaled by speedMod). + // 3. body.update_object(now) — Euler-integrates + // position += Velocity × dt + 0.5 × Accel × dt² AND + // orientation += omega × dt. + // + // On UpdatePosition receipt we hard-snap body.Position and + // body.Orientation — if integration matched server physics, + // each snap is small/invisible. + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + + // Step 1: re-apply current motion commands → body.Velocity. + // Forces OnWalkable + Contact so the gate in apply_current_movement + // always succeeds (remotes are server-authoritative; we don't + // simulate airborne physics for them). + // + // K-fix9 (2026-04-26): SKIP this when the remote is airborne. + // Otherwise the force-OnWalkable + apply_current_movement + // path stomps the +Z velocity we set in OnLiveVectorUpdated, + // and gravity never gets to integrate the arc. The airborne + // body keeps the launch velocity from the VectorUpdate; + // UpdatePhysicsInternal below applies gravity each tick; + // the next UpdatePosition snaps to the new ground location + // and re-grounds. + if (!rm.Airborne) { - // Phase L.1c port of retail MoveToManager per-tick - // steering (HandleMoveToPosition @ 0x00529d80). - // Steer body orientation toward the latest - // server-supplied destination, then let - // apply_current_movement set Velocity from the - // RunForward cycle through the now-correct heading. - - // Stale-destination guard (2026-04-28): if no - // MoveTo packet has refreshed the destination - // recently, the entity has likely left our - // streaming view or the server cancelled the - // move without us seeing the cancel UM. Continuing - // to steer toward a stale point produces the - // "monster runs in place after popping back into - // view" symptom. Clear and stand down. - double moveToAge = nowSec - rm.LastMoveToPacketTime; - if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active; + if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) { - rm.HasMoveToDestination = false; - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - else - { - var driveResult = AcDream.Core.Physics.RemoteMoveToDriver - .Drive( - rm.Body.Position, - rm.Body.Orientation, - rm.MoveToDestinationWorld, - rm.MoveToMinDistance, - rm.MoveToDistanceToObject, - (float)dt, - rm.MoveToMoveTowards, - out var steeredOrientation); - rm.Body.Orientation = steeredOrientation; - - if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver - .DriveResult.Arrived) + double velocityAge = nowSec - rm.LastServerPosTime; + if (velocityAge > ServerControlledVelocityStaleSeconds) { - // Within arrival window — zero velocity until the - // next MoveTo packet refreshes the destination - // (or the server explicitly stops us with an - // interpreted-motion UM cmd=Ready). + rm.ServerVelocity = System.Numerics.Vector3.Zero; + rm.HasServerVelocity = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + ApplyServerControlledVelocityCycle( + serverGuid, + ae, + rm, + System.Numerics.Vector3.Zero); + } + else + { + rm.Body.Velocity = rm.ServerVelocity; + } + } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive + && rm.HasMoveToDestination) + { + // Phase L.1c port of retail MoveToManager per-tick + // steering (HandleMoveToPosition @ 0x00529d80). + // Steer body orientation toward the latest + // server-supplied destination, then let + // apply_current_movement set Velocity from the + // RunForward cycle through the now-correct heading. + + // Stale-destination guard (2026-04-28): if no + // MoveTo packet has refreshed the destination + // recently, the entity has likely left our + // streaming view or the server cancelled the + // move without us seeing the cancel UM. Continuing + // to steer toward a stale point produces the + // "monster runs in place after popping back into + // view" symptom. Clear and stand down. + double moveToAge = nowSec - rm.LastMoveToPacketTime; + if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) + { + rm.HasMoveToDestination = false; rm.Body.Velocity = System.Numerics.Vector3.Zero; } else { - // Steering active — apply_current_movement reads - // InterpretedState.ForwardCommand=RunForward (set - // when the MoveTo packet arrived) and emits - // velocity along +Y in body local space. Our - // updated orientation rotates that into the right - // world direction toward the target. - rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); - - // Clamp horizontal velocity so we don't overshoot - // the arrival threshold during the final tick of - // approach. Without this, a 4 m/s body advances - // ~6 cm/tick and visibly runs slightly through - // the target before the swing UM lands. - float arrivalThreshold = rm.MoveToMoveTowards - ? rm.MoveToDistanceToObject - : rm.MoveToMinDistance; - rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver - .ClampApproachVelocity( + var driveResult = AcDream.Core.Physics.RemoteMoveToDriver + .Drive( rm.Body.Position, - rm.Body.Velocity, + rm.Body.Orientation, rm.MoveToDestinationWorld, - arrivalThreshold, + rm.MoveToMinDistance, + rm.MoveToDistanceToObject, (float)dt, - rm.MoveToMoveTowards); + rm.MoveToMoveTowards, + out var steeredOrientation); + rm.Body.Orientation = steeredOrientation; + + if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver + .DriveResult.Arrived) + { + // Within arrival window — zero velocity until the + // next MoveTo packet refreshes the destination + // (or the server explicitly stops us with an + // interpreted-motion UM cmd=Ready). + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + // Steering active — apply_current_movement reads + // InterpretedState.ForwardCommand=RunForward (set + // when the MoveTo packet arrived) and emits + // velocity along +Y in body local space. Our + // updated orientation rotates that into the right + // world direction toward the target. + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + + // Clamp horizontal velocity so we don't overshoot + // the arrival threshold during the final tick of + // approach. Without this, a 4 m/s body advances + // ~6 cm/tick and visibly runs slightly through + // the target before the swing UM lands. + float arrivalThreshold = rm.MoveToMoveTowards + ? rm.MoveToDistanceToObject + : rm.MoveToMinDistance; + rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver + .ClampApproachVelocity( + rm.Body.Position, + rm.Body.Velocity, + rm.MoveToDestinationWorld, + arrivalThreshold, + (float)dt, + rm.MoveToMoveTowards); + } } } - } - else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) - { - // MoveTo flag set but we haven't seen a path payload - // yet (e.g. truncated packet, or a brand-new entity - // whose first cycle UM is still in flight). Hold - // velocity at zero — same conservative stance as the - // 882a07c stabilizer for incomplete state. - rm.Body.Velocity = System.Numerics.Vector3.Zero; + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) + { + // MoveTo flag set but we haven't seen a path payload + // yet (e.g. truncated packet, or a brand-new entity + // whose first cycle UM is still in flight). Hold + // velocity at zero — same conservative stance as the + // 882a07c stabilizer for incomplete state. + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } } else { - rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + // Airborne — keep Active flag (so UpdatePhysicsInternal + // doesn't early-return) but DON'T set Contact / OnWalkable. + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - } - else - { - // Airborne — keep Active flag (so UpdatePhysicsInternal - // doesn't early-return) but DON'T set Contact / OnWalkable. - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; - } - // Step 2: integrate rotation manually per tick. We can't - // rely on PhysicsBody.update_object here — its MinQuantum - // gate (1/30 s) causes it to SKIP integration when our - // 60fps render dt (~0.016s) is below the quantum, meaning - // rotation never advances. Measured snap per UP was ~129° - // = the full expected 1s × 2.24 rad/s, confirming zero - // between-tick rotation. - // - // Manual integration matches retail's FUN_005256b0 - // apply_physics (Orientation *= quat(ω × dt)). Use - // ObservedOmega derived from server UP rotation deltas so - // the rate exactly matches server physics — hard-snap on - // next UP becomes invisible by construction. - rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object - if (rm.ObservedOmega.LengthSquared() > 1e-8f) - { - float omegaMag = rm.ObservedOmega.Length(); - var axis = rm.ObservedOmega / omegaMag; - float angle = omegaMag * dt; - var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); - rm.Body.Orientation = System.Numerics.Quaternion.Normalize( - System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); - } - - // Step 3: integrate physics — retail FUN_005111D0 - // UpdatePhysicsInternal. Pure Euler: - // position += velocity × dt + 0.5 × accel × dt² - // - // Call UpdatePhysicsInternal DIRECTLY rather than via - // PhysicsBody.update_object (FUN_00515020). update_object gates - // on MinQuantum = 1/30s: at our 60fps render tick (~16ms), - // deltaTime < MinQuantum → early return AND LastUpdateTime is - // NOT advanced. Net effect: position never integrates between - // UpdatePositions and the only Body.Position changes come - // from the UP hard-snap, producing a visible teleport-stride - // on slopes (the "staircase" the user reported). - // - // PlayerMovementController.cs:358 calls UpdatePhysicsInternal - // directly for the same reason. Remote motion mirrors that. - // Omega is already integrated manually above, so we zero it - // here to prevent UpdatePhysicsInternal's own omega pass from - // double-integrating. - var preIntegratePos = rm.Body.Position; - rm.Body.calc_acceleration(); - rm.Body.UpdatePhysicsInternal(dt); - var postIntegratePos = rm.Body.Position; - - // Step 4: collision sweep — retail FUN_00514B90 → - // FUN_005148A0 → Transition::FindTransitionalPosition. - // Projects the sphere from preIntegratePos to postIntegratePos - // through the BSP + terrain, resolving: - // - terrain Z snap along the slope (fixes the "staircase" where - // horizontal Euler motion up a slope sinks into rising ground - // until the next UP pops it up) - // - indoor BSP walls (via the 6-path dispatcher in BSPQuery) - // - object collisions via ShadowObjectRegistry - // - step-up / step-down against walkable ledges - // ResolveWithTransition is the same call PlayerMovementController - // uses for the local player; remotes now get the full retail - // treatment between UpdatePositions instead of pure kinematics. - // - // Skipped when rm.CellId == 0 (no UP landed yet — can't build - // a SpherePath without a starting cell). One-frame grace until - // the first UP arrives; harmless because the entity is - // server-freshly-spawned at a valid Z anyway. - if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) - { - // Sphere dims match local-player defaults (human Setup - // bounds — ~0.48m radius, ~1.2m height). Good enough for - // grounded humanoid remotes; can be setup-derived later - // if creatures of wildly different sizes need different - // collision profiles. - var resolveResult = _physicsEngine.ResolveWithTransition( - preIntegratePos, postIntegratePos, rm.CellId, - sphereRadius: 0.48f, - sphereHeight: 1.2f, - stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f - stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f - // K-fix9 (2026-04-26): mirror the K-fix7 gate — - // airborne remotes must NOT pre-seed the - // ContactPlane, otherwise AdjustOffset's snap-to-plane - // branch zeroes the +Z offset every step (same bug - // we hit on the local jump). - isOnGround: !rm.Airborne, - body: rm.Body, // persist ContactPlane across frames for slope tracking - // Retail default physics state includes EdgeSlide. - // Remote dead-reckoning should exercise the same - // edge/cliff branch as local movement. - moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); - - rm.Body.Position = resolveResult.Position; - if (resolveResult.CellId != 0) - rm.CellId = resolveResult.CellId; - - // K-fix15 (2026-04-26): post-resolve landing - // detection for airborne remotes. Mirrors - // PlayerMovementController's local-player landing - // path: when the resolver says we're on ground AND - // velocity is no longer pointing up, transition - // back to grounded — clear Airborne, restore - // Contact + OnWalkable, remove Gravity, zero any - // residual downward velocity, and trigger - // HitGround so the sequencer can swap from - // Falling → idle/locomotion. Without this, an - // airborne remote falls through the floor (gravity - // keeps building Velocity.Z negative until the - // sphere-sweep clamps each frame, but Airborne - // stays true forever). - if (rm.Airborne - && resolveResult.IsOnGround - && rm.Body.Velocity.Z <= 0f) + // Step 2: integrate rotation manually per tick. We can't + // rely on PhysicsBody.update_object here — its MinQuantum + // gate (1/30 s) causes it to SKIP integration when our + // 60fps render dt (~0.016s) is below the quantum, meaning + // rotation never advances. Measured snap per UP was ~129° + // = the full expected 1s × 2.24 rad/s, confirming zero + // between-tick rotation. + // + // Manual integration matches retail's FUN_005256b0 + // apply_physics (Orientation *= quat(ω × dt)). Use + // ObservedOmega derived from server UP rotation deltas so + // the rate exactly matches server physics — hard-snap on + // next UP becomes invisible by construction. + rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object + if (rm.ObservedOmega.LengthSquared() > 1e-8f) { - rm.Airborne = false; - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - rm.Body.Velocity = new System.Numerics.Vector3( - rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); - rm.Motion.HitGround(); - - // K-fix17 (2026-04-26): reset the sequencer cycle - // from Falling back to whatever the interpreted - // motion state says they should be doing now. - // Without this, the remote stays in the Falling - // pose forever (legs folded) until the next - // server-sent UpdateMotion arrives. Use the - // sequencer's current style (preserved across - // jump) and pick the cycle from - // InterpretedState.ForwardCommand: Ready - // (idle), WalkForward, RunForward, WalkBackward. - // SideStep / Turn aren't strict locomotion - // priorities — the next UM the server sends will - // refine the cycle if the player is mid-strafe - // when they land; this just gets the legs out - // of Falling immediately. - if (ae.Sequencer is not null) - { - uint style = ae.Sequencer.CurrentStyle != 0 - ? ae.Sequencer.CurrentStyle - : 0x8000003Du; - uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; - if (landingCmd == 0) - landingCmd = AcDream.Core.Physics.MotionCommand.Ready; - float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; - if (landingSpeed <= 0f) landingSpeed = 1f; - ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); - } - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + float omegaMag = rm.ObservedOmega.Length(); + var axis = rm.ObservedOmega / omegaMag; + float angle = omegaMag * dt; + var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); + rm.Body.Orientation = System.Numerics.Quaternion.Normalize( + System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); } - } - ae.Entity.Position = rm.Body.Position; - ae.Entity.Rotation = rm.Body.Orientation; + // Step 3: integrate physics — retail FUN_005111D0 + // UpdatePhysicsInternal. Pure Euler: + // position += velocity × dt + 0.5 × accel × dt² + // + // Call UpdatePhysicsInternal DIRECTLY rather than via + // PhysicsBody.update_object (FUN_00515020). update_object gates + // on MinQuantum = 1/30s: at our 60fps render tick (~16ms), + // deltaTime < MinQuantum → early return AND LastUpdateTime is + // NOT advanced. Net effect: position never integrates between + // UpdatePositions and the only Body.Position changes come + // from the UP hard-snap, producing a visible teleport-stride + // on slopes (the "staircase" the user reported). + // + // PlayerMovementController.cs:358 calls UpdatePhysicsInternal + // directly for the same reason. Remote motion mirrors that. + // Omega is already integrated manually above, so we zero it + // here to prevent UpdatePhysicsInternal's own omega pass from + // double-integrating. + var preIntegratePos = rm.Body.Position; + rm.Body.calc_acceleration(); + rm.Body.UpdatePhysicsInternal(dt); + var postIntegratePos = rm.Body.Position; + + // Step 4: collision sweep — retail FUN_00514B90 → + // FUN_005148A0 → Transition::FindTransitionalPosition. + // Projects the sphere from preIntegratePos to postIntegratePos + // through the BSP + terrain, resolving: + // - terrain Z snap along the slope (fixes the "staircase" where + // horizontal Euler motion up a slope sinks into rising ground + // until the next UP pops it up) + // - indoor BSP walls (via the 6-path dispatcher in BSPQuery) + // - object collisions via ShadowObjectRegistry + // - step-up / step-down against walkable ledges + // ResolveWithTransition is the same call PlayerMovementController + // uses for the local player; remotes now get the full retail + // treatment between UpdatePositions instead of pure kinematics. + // + // Skipped when rm.CellId == 0 (no UP landed yet — can't build + // a SpherePath without a starting cell). One-frame grace until + // the first UP arrives; harmless because the entity is + // server-freshly-spawned at a valid Z anyway. + if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) + { + // Sphere dims match local-player defaults (human Setup + // bounds — ~0.48m radius, ~1.2m height). Good enough for + // grounded humanoid remotes; can be setup-derived later + // if creatures of wildly different sizes need different + // collision profiles. + var resolveResult = _physicsEngine.ResolveWithTransition( + preIntegratePos, postIntegratePos, rm.CellId, + sphereRadius: 0.48f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f + stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f + // K-fix9 (2026-04-26): mirror the K-fix7 gate — + // airborne remotes must NOT pre-seed the + // ContactPlane, otherwise AdjustOffset's snap-to-plane + // branch zeroes the +Z offset every step (same bug + // we hit on the local jump). + isOnGround: !rm.Airborne, + body: rm.Body, // persist ContactPlane across frames for slope tracking + // Retail default physics state includes EdgeSlide. + // Remote dead-reckoning should exercise the same + // edge/cliff branch as local movement. + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); + + rm.Body.Position = resolveResult.Position; + if (resolveResult.CellId != 0) + rm.CellId = resolveResult.CellId; + + // K-fix15 (2026-04-26): post-resolve landing + // detection for airborne remotes. Mirrors + // PlayerMovementController's local-player landing + // path: when the resolver says we're on ground AND + // velocity is no longer pointing up, transition + // back to grounded — clear Airborne, restore + // Contact + OnWalkable, remove Gravity, zero any + // residual downward velocity, and trigger + // HitGround so the sequencer can swap from + // Falling → idle/locomotion. Without this, an + // airborne remote falls through the floor (gravity + // keeps building Velocity.Z negative until the + // sphere-sweep clamps each frame, but Airborne + // stays true forever). + if (rm.Airborne + && resolveResult.IsOnGround + && rm.Body.Velocity.Z <= 0f) + { + rm.Airborne = false; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.Velocity = new System.Numerics.Vector3( + rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); + rm.Motion.HitGround(); + + // K-fix17 (2026-04-26): reset the sequencer cycle + // from Falling back to whatever the interpreted + // motion state says they should be doing now. + // Without this, the remote stays in the Falling + // pose forever (legs folded) until the next + // server-sent UpdateMotion arrives. Use the + // sequencer's current style (preserved across + // jump) and pick the cycle from + // InterpretedState.ForwardCommand: Ready + // (idle), WalkForward, RunForward, WalkBackward. + // SideStep / Turn aren't strict locomotion + // priorities — the next UM the server sends will + // refine the cycle if the player is mid-strafe + // when they land; this just gets the legs out + // of Falling immediately. + if (ae.Sequencer is not null) + { + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + } + + ae.Entity.Position = rm.Body.Position; + ae.Entity.Rotation = rm.Body.Orientation; + } } // ── Get per-part (origin, orientation) from either sequencer or legacy ── From e08accf7c2086653b5c5c9a53b5868246fa51ce3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:34:19 +0200 Subject: [PATCH 10/32] fix(motion): apply VectorUpdate.Omega to remote body (L.3.1 Task 6) VectorUpdate.Omega was parsed by WorldSession but never written to the remote body's Omega field, leaving remote jumping/turning arcs flat. Apply it alongside the existing Velocity assignment. Mirrors retail SmartBox::DoVectorUpdate (acclient @ 0x004521C0) which calls both CPhysicsObj::set_velocity AND CPhysicsObj::set_omega. Same 4 pre-existing test failures, no regression. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0d4210ad..0866e847 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3081,6 +3081,12 @@ public sealed class GameWindow : IDisposable // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt². rm.Body.Velocity = update.Velocity; + // L.3.1 Task 6: apply Omega too. Was parsed but ignored, leaving + // remote jumping/turning arcs flat. Mirrors retail SmartBox:: + // DoVectorUpdate (acclient @ 0x004521C0) which calls both + // CPhysicsObj::set_velocity AND CPhysicsObj::set_omega. + rm.Body.Omega = update.Omega; + // Mark airborne when the launch has meaningful +Z. Threshold // 0.5 m/s rejects noise / horizontal-only updates (server might // also use VectorUpdate for non-jump events). The per-tick From 5154a3eae1ca1bac571cf239c0d1a834d9e85f78 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 08:08:23 +0200 Subject: [PATCH 11/32] fix(motion): heading + jump bugs in InterpolationManager path (L.3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual verification (Task 7) revealed two bugs in the new env-var gated path: 1. Heading locked at login direction. Cause: AdjustOffset returns position delta only; the dist≤96 enqueue branch never updated body.Orientation. Fix: apply orientation unconditionally on every UpdatePosition (snap-on-receipt). Position lerps via queue. 2. Endless jumping. Cause: (a) body.Velocity persisted forever after arc landed because apply_current_movement no longer ran; (b) UpdatePositions during the arc were enqueued, fighting the gravity sim. Fix: skip enqueue when rm.Airborne (mirrors retail MoveOrTeleport has_contact=false → no-op); zero non-airborne body.Velocity each tick (mirrors legacy apply_current_movement); detect landed when receiving UpdatePosition while airborne with no/zero velocity. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 60 +++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0866e847..2beecb9c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3261,6 +3261,48 @@ public sealed class GameWindow : IDisposable // - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue) // - has_contact && distance > 96 → SetPositionSimple (slide-snap) + // Bug 1 fix (L.3.1 visual verification): apply orientation unconditionally + // on every UpdatePosition, regardless of the routing branch below. + // InterpolationManager.AdjustOffset returns a position delta only — it + // never updates Orientation. Without this, the dist≤96 enqueue branch + // never touched Body.Orientation, so remote heading was locked at whatever + // it was at login. Position lerps via the queue; heading snaps on receipt, + // which is both perceptually correct and mirrors retail's set_frame behavior + // (FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment). + rmState.Body.Orientation = rot; + + // Bug 2b fix (L.3.1 visual verification): if the remote is currently + // airborne (body.Velocity set by VectorUpdate, gravity integrating), skip + // enqueueing position waypoints. The queue and the gravity sim would + // double-step position. Mirrors retail MoveOrTeleport returning false when + // has_contact == false (acclient @ 0x00516330). The landing UpdatePosition + // (received after arc completes with no/zero velocity) will arrive with + // rmState.Airborne == false and proceed normally. + // + // Bug 2c fix: detect "just landed" — if Airborne was true but this + // UpdatePosition carries no non-trivial velocity, treat it as ground + // contact: clear Airborne, zero body.Velocity, restore contact flags. + // This is the signal ACE uses (VectorUpdate only fires on jump start; + // no corresponding "landed" packet — the next plain UpdatePosition is it). + if (rmState.Airborne) + { + bool velocityIsNegligible = update.Velocity is null + || update.Velocity.Value.LengthSquared() < 0.04f; + if (velocityIsNegligible) + { + // Landed: snap to server position, re-ground the body. + rmState.Airborne = false; + rmState.Body.Velocity = System.Numerics.Vector3.Zero; + rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rmState.Body.Position = worldPos; + rmState.Interp.Clear(); + } + // Still airborne: don't enqueue — let gravity arc continue. + return; + } + const float MaxPhysicsDistance = 96f; System.Numerics.Vector3 localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; @@ -3273,22 +3315,23 @@ public sealed class GameWindow : IDisposable if (teleportFlag) { - // SetPosition equivalent: hard-snap position + orientation, clear interp queue. + // SetPosition equivalent: hard-snap position, clear interp queue. + // Orientation already applied unconditionally above. rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; rmState.Interp.Clear(); } else if (dist > MaxPhysicsDistance) { // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). + // Orientation already applied unconditionally above. rmState.Interp.Clear(); rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; } else { // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. + // Orientation already applied unconditionally above. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); } @@ -5797,6 +5840,17 @@ public sealed class GameWindow : IDisposable rm.Body.Position += delta; } + // Bug 2a fix (L.3.1 visual verification): grounded remotes must keep + // body.Velocity == 0 so it doesn't fight the queue. In the legacy path + // apply_current_movement achieved this by recomputing velocity from + // InterpretedState each tick; the new path skips apply_current_movement, + // so we explicitly clamp. Airborne remotes keep their VectorUpdate-set + // velocity for gravity arc integration (UpdatePhysicsInternal below). + if (!rm.Airborne) + { + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + // Gravity integration: retail's UpdatePhysicsInternal still // fires every frame regardless of the interpolation path. // For grounded remotes body.Velocity == 0 so this is a no-op; From f199a6a0754570d1cd4954ca3e79b6b67eb090dc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 09:38:49 +0200 Subject: [PATCH 12/32] fix(motion): airborne hard-snap + velocity-extrapolation (L.3.1) Round 2 fix for two visual bugs that survived commit 5154a3e: Bug 1 (chop at 1 Hz UP cadence): Round 1 zeroed body.Velocity each tick on grounded remotes, leaving AdjustOffset as the sole motion source. AdjustOffset catches up in ~150 ms then sits idle until the next UP at 1 Hz, producing visible "updates every 1 second" stepping. Root cause: retail achieves smoothness via animation root motion + AdjustOffset *corrections*; we only ported corrections (root motion is Phase L.3.2 / PositionManager). Workaround for L.3.1: seed body.Velocity from update.Velocity on every grounded UP so UpdatePhysicsInternal integrates position += vel*dt between UPs, with the queue providing corrective patches via AdjustOffset. Bug 2 (endless jump): Round 1 tried to detect landing via "UP arrives during airborne with no velocity" but ACE keeps sending non-zero velocity through the arc, so the detector never fired. Fix: stop maintaining a local "predicted arc". Server is authoritative for airborne position too -- hard-snap from each UP during airborne; body.Velocity (set by OnLiveVectorUpdated) integrates between UPs for smoothing. Landing detected via reported-Z-near-body-Z + falling/ settled velocity heuristic (more reliable than the velocity-zero test). Per-frame tick: removed the !rm.Airborne velocity clamp from Round 1. OnLivePositionUpdated now owns velocity policy; per-tick just integrates whatever is set. Both deviations from retail decomp are documented in source comments and slated for L.3.2 (PositionManager) cleanup. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 88 ++++++++++++++++--------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2beecb9c..8cb23519 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3284,13 +3284,30 @@ public sealed class GameWindow : IDisposable // contact: clear Airborne, zero body.Velocity, restore contact flags. // This is the signal ACE uses (VectorUpdate only fires on jump start; // no corresponding "landed" packet — the next plain UpdatePosition is it). + // ── AIRBORNE ──────────────────────────────────────────────────────────── + // Server is authoritative for the arc. Hard-snap position from every UP + // while airborne; body.Velocity (set by OnLiveVectorUpdated at jump start, + // or unchanged) continues to integrate via UpdatePhysicsInternal/gravity + // between UPs. Don't enqueue — the queue is for grounded motion only. + // + // Landing heuristic (L.3.1): ACE doesn't send an explicit "landed" packet. + // Instead we detect landing by two conditions simultaneously: + // 1. The server-reported Z is within 0.5m of the body's current Z + // (server has snapped to ground level — close to where we are). + // 2. Body's vertical velocity is falling or settled (vz <= 0.5 m/s). + // Both together mean the arc is complete. We do NOT use "velocity == 0" + // because ACE sends non-zero velocity through the entire arc (Bug 2 root + // cause in Round 1). if (rmState.Airborne) { - bool velocityIsNegligible = update.Velocity is null - || update.Velocity.Value.LengthSquared() < 0.04f; - if (velocityIsNegligible) + bool reportedNearBodyZ = + MathF.Abs(worldPos.Z - rmState.Body.Position.Z) < 0.5f; + bool velocityFallingOrSettled = + rmState.Body.Velocity.Z <= 0.5f; + + if (reportedNearBodyZ && velocityFallingOrSettled) { - // Landed: snap to server position, re-ground the body. + // LANDED: snap to ground, re-ground the body. rmState.Airborne = false; rmState.Body.Velocity = System.Numerics.Vector3.Zero; rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; @@ -3298,11 +3315,18 @@ public sealed class GameWindow : IDisposable | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Body.Position = worldPos; rmState.Interp.Clear(); + return; } - // Still airborne: don't enqueue — let gravity arc continue. + + // Still airborne: hard-snap so server is authoritative for the arc. + // body.Velocity preserved from VectorUpdate; UpdatePhysicsInternal + // integrates gravity between UPs. + rmState.Body.Position = worldPos; return; } + // ── GROUNDED ──────────────────────────────────────────────────────────── + // Routing mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). const float MaxPhysicsDistance = 96f; System.Numerics.Vector3 localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; @@ -3313,27 +3337,37 @@ public sealed class GameWindow : IDisposable // Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap). // bool hasContact = true; (implicit — only the teleport and distance branches below) - if (teleportFlag) + if (teleportFlag || dist > MaxPhysicsDistance) { - // SetPosition equivalent: hard-snap position, clear interp queue. - // Orientation already applied unconditionally above. - rmState.Body.Position = worldPos; - rmState.Interp.Clear(); - } - else if (dist > MaxPhysicsDistance) - { - // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). + // SetPosition / SetPositionSimple equivalent: hard-snap, clear queue. // Orientation already applied unconditionally above. + // Zero velocity so UpdatePhysicsInternal doesn't extrapolate from + // a prior walk-direction after a teleport or distant slide-snap. rmState.Interp.Clear(); rmState.Body.Position = worldPos; + rmState.Body.Velocity = System.Numerics.Vector3.Zero; } else { - // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. - // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. + // InterpolationManager.Enqueue equivalent: queue for AdjustOffset to walk to. + // NOTE: do NOT touch rmState.Body.Position here — AdjustOffset owns it. // Orientation already applied unconditionally above. + // + // L.3.1 WORKAROUND — velocity-extrapolation between UPs: + // Retail achieves smooth 60 fps motion via animation root motion feeding + // PositionManager (Phase L.3.2 / PositionManager port). Until that lands, + // AdjustOffset alone catches up in ~150 ms after each 1-Hz UP then sits + // idle the remaining 850 ms — visible as "updates every 1 second" stepping. + // Workaround: seed body.Velocity from the UP's velocity field so + // UpdatePhysicsInternal integrates position += vel*dt between UPs; + // AdjustOffset provides corrective patches when drift accumulates. + // When update.Velocity is null the entity is stationary on this UP → + // zero velocity → only queue-walking applies. This deviates from the + // retail decomp finding that walking remotes have m_velocityVector == 0, + // but is the best approximation available without root motion. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); + rmState.Body.Velocity = update.Velocity ?? System.Numerics.Vector3.Zero; } // Skip the legacy hard-snap path below. @@ -5840,21 +5874,13 @@ public sealed class GameWindow : IDisposable rm.Body.Position += delta; } - // Bug 2a fix (L.3.1 visual verification): grounded remotes must keep - // body.Velocity == 0 so it doesn't fight the queue. In the legacy path - // apply_current_movement achieved this by recomputing velocity from - // InterpretedState each tick; the new path skips apply_current_movement, - // so we explicitly clamp. Airborne remotes keep their VectorUpdate-set - // velocity for gravity arc integration (UpdatePhysicsInternal below). - if (!rm.Airborne) - { - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - - // Gravity integration: retail's UpdatePhysicsInternal still - // fires every frame regardless of the interpolation path. - // For grounded remotes body.Velocity == 0 so this is a no-op; - // for airborne remotes it applies gravity to the arc. + // Velocity policy is owned by OnLivePositionUpdated (grounded) and + // OnLiveVectorUpdated (airborne jump start). Do NOT clamp body.Velocity + // here — doing so stomped the velocity-extrapolation workaround seeded + // on grounded UPs (Bug 1 regression from Round 1). UpdatePhysicsInternal + // integrates whatever velocity is set: zero for stationary remotes, + // update.Velocity for moving remotes (L.3.1 workaround), or the launch + // arc velocity for airborne remotes. Gravity is applied by the same call. rm.Body.UpdatePhysicsInternal(dt); ae.Entity.Position = rm.Body.Position; From 1641d6ea1b536fc7894c144ec4664a21ed0bc048 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 09:51:26 +0200 Subject: [PATCH 13/32] revert: L.3.1 band-aid fixes (5154a3e + f199a6a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 (5154a3e) tried to fix: - heading locked → orientation snap-on-receipt (good idea) - endless jump → landing detector via UP-with-zero-velocity (didn't work; ACE sends non-zero velocity through arc) Round 2 (f199a6a) tried to fix: - chop at 1 Hz → seed body.Velocity from update.Velocity for between-UP extrapolation (didn't help) - endless jump → reported-Z-near-body-Z + falling-velocity heuristic (didn't catch reliably) The actual problem was scoping: L.3.1's "InterpolationManager only" cannot produce smooth motion. Retail combines animation root motion (L.3.2 / PositionManager) + InterpolationManager corrections. Both halves are required for "remotes look smooth". Reverting to e08accf (Task 6 — VectorUpdate.Omega). The next commits will properly port PositionManager + plumb IsGrounded through the wire parser, replacing L.3.1-only with L.3.1+L.3.2 combined per the revised spec. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 112 ++++-------------------- 1 file changed, 16 insertions(+), 96 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8cb23519..0866e847 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3261,72 +3261,6 @@ public sealed class GameWindow : IDisposable // - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue) // - has_contact && distance > 96 → SetPositionSimple (slide-snap) - // Bug 1 fix (L.3.1 visual verification): apply orientation unconditionally - // on every UpdatePosition, regardless of the routing branch below. - // InterpolationManager.AdjustOffset returns a position delta only — it - // never updates Orientation. Without this, the dist≤96 enqueue branch - // never touched Body.Orientation, so remote heading was locked at whatever - // it was at login. Position lerps via the queue; heading snaps on receipt, - // which is both perceptually correct and mirrors retail's set_frame behavior - // (FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment). - rmState.Body.Orientation = rot; - - // Bug 2b fix (L.3.1 visual verification): if the remote is currently - // airborne (body.Velocity set by VectorUpdate, gravity integrating), skip - // enqueueing position waypoints. The queue and the gravity sim would - // double-step position. Mirrors retail MoveOrTeleport returning false when - // has_contact == false (acclient @ 0x00516330). The landing UpdatePosition - // (received after arc completes with no/zero velocity) will arrive with - // rmState.Airborne == false and proceed normally. - // - // Bug 2c fix: detect "just landed" — if Airborne was true but this - // UpdatePosition carries no non-trivial velocity, treat it as ground - // contact: clear Airborne, zero body.Velocity, restore contact flags. - // This is the signal ACE uses (VectorUpdate only fires on jump start; - // no corresponding "landed" packet — the next plain UpdatePosition is it). - // ── AIRBORNE ──────────────────────────────────────────────────────────── - // Server is authoritative for the arc. Hard-snap position from every UP - // while airborne; body.Velocity (set by OnLiveVectorUpdated at jump start, - // or unchanged) continues to integrate via UpdatePhysicsInternal/gravity - // between UPs. Don't enqueue — the queue is for grounded motion only. - // - // Landing heuristic (L.3.1): ACE doesn't send an explicit "landed" packet. - // Instead we detect landing by two conditions simultaneously: - // 1. The server-reported Z is within 0.5m of the body's current Z - // (server has snapped to ground level — close to where we are). - // 2. Body's vertical velocity is falling or settled (vz <= 0.5 m/s). - // Both together mean the arc is complete. We do NOT use "velocity == 0" - // because ACE sends non-zero velocity through the entire arc (Bug 2 root - // cause in Round 1). - if (rmState.Airborne) - { - bool reportedNearBodyZ = - MathF.Abs(worldPos.Z - rmState.Body.Position.Z) < 0.5f; - bool velocityFallingOrSettled = - rmState.Body.Velocity.Z <= 0.5f; - - if (reportedNearBodyZ && velocityFallingOrSettled) - { - // LANDED: snap to ground, re-ground the body. - rmState.Airborne = false; - rmState.Body.Velocity = System.Numerics.Vector3.Zero; - rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rmState.Body.Position = worldPos; - rmState.Interp.Clear(); - return; - } - - // Still airborne: hard-snap so server is authoritative for the arc. - // body.Velocity preserved from VectorUpdate; UpdatePhysicsInternal - // integrates gravity between UPs. - rmState.Body.Position = worldPos; - return; - } - - // ── GROUNDED ──────────────────────────────────────────────────────────── - // Routing mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). const float MaxPhysicsDistance = 96f; System.Numerics.Vector3 localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; @@ -3337,37 +3271,26 @@ public sealed class GameWindow : IDisposable // Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap). // bool hasContact = true; (implicit — only the teleport and distance branches below) - if (teleportFlag || dist > MaxPhysicsDistance) + if (teleportFlag) { - // SetPosition / SetPositionSimple equivalent: hard-snap, clear queue. - // Orientation already applied unconditionally above. - // Zero velocity so UpdatePhysicsInternal doesn't extrapolate from - // a prior walk-direction after a teleport or distant slide-snap. + // SetPosition equivalent: hard-snap position + orientation, clear interp queue. + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + rmState.Interp.Clear(); + } + else if (dist > MaxPhysicsDistance) + { + // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). rmState.Interp.Clear(); rmState.Body.Position = worldPos; - rmState.Body.Velocity = System.Numerics.Vector3.Zero; + rmState.Body.Orientation = rot; } else { - // InterpolationManager.Enqueue equivalent: queue for AdjustOffset to walk to. - // NOTE: do NOT touch rmState.Body.Position here — AdjustOffset owns it. - // Orientation already applied unconditionally above. - // - // L.3.1 WORKAROUND — velocity-extrapolation between UPs: - // Retail achieves smooth 60 fps motion via animation root motion feeding - // PositionManager (Phase L.3.2 / PositionManager port). Until that lands, - // AdjustOffset alone catches up in ~150 ms after each 1-Hz UP then sits - // idle the remaining 850 ms — visible as "updates every 1 second" stepping. - // Workaround: seed body.Velocity from the UP's velocity field so - // UpdatePhysicsInternal integrates position += vel*dt between UPs; - // AdjustOffset provides corrective patches when drift accumulates. - // When update.Velocity is null the entity is stationary on this UP → - // zero velocity → only queue-walking applies. This deviates from the - // retail decomp finding that walking remotes have m_velocityVector == 0, - // but is the best approximation available without root motion. + // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. + // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); - rmState.Body.Velocity = update.Velocity ?? System.Numerics.Vector3.Zero; } // Skip the legacy hard-snap path below. @@ -5874,13 +5797,10 @@ public sealed class GameWindow : IDisposable rm.Body.Position += delta; } - // Velocity policy is owned by OnLivePositionUpdated (grounded) and - // OnLiveVectorUpdated (airborne jump start). Do NOT clamp body.Velocity - // here — doing so stomped the velocity-extrapolation workaround seeded - // on grounded UPs (Bug 1 regression from Round 1). UpdatePhysicsInternal - // integrates whatever velocity is set: zero for stationary remotes, - // update.Velocity for moving remotes (L.3.1 workaround), or the launch - // arc velocity for airborne remotes. Gravity is applied by the same call. + // Gravity integration: retail's UpdatePhysicsInternal still + // fires every frame regardless of the interpolation path. + // For grounded remotes body.Velocity == 0 so this is a no-op; + // for airborne remotes it applies gravity to the arc. rm.Body.UpdatePhysicsInternal(dt); ae.Entity.Position = rm.Body.Position; From c4446e76fbc2a4d98b5b2864d3b0093c0eadbe14 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:03:09 +0200 Subject: [PATCH 14/32] =?UTF-8?q?docs(spec):=20Phase=20L.3=20scope=20revis?= =?UTF-8?q?ion=20=E2=80=94=20combine=20L.3.1+L.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual verification of L.3.1-as-originally-scoped (commit ae79e34 through e08accf) revealed that InterpolationManager corrections alone cannot produce smooth motion — retail also relies on animation root motion (the L.3.2 PositionManager work, originally deferred). The two halves are functionally inseparable. Spec changes: - L.3.1 sub-lane absorbs L.3.2's PositionManager - New section: PositionManager architecture (pure-function ComputeOffset returning Vector3 delta; combines body-local seqVel * dt rotated to world + InterpolationManager.AdjustOffset correction) - New section: IsGrounded plumbing through EntityPositionUpdate (the PositionFlags.IsGrounded=0x04 is already parsed; just expose it) - New section: retail-faithful jump pipeline (airborne → no-op per MoveOrTeleport's has_contact=0 semantics; landing detected via first IsGrounded=true UP after airborne) - Acceptance criteria updated for combined scope - Implementation order: 6 commits remaining (after the revert at 1641d6e) - Stall-blip TAIL annotation (Task 0 resolution) folded in L.3.3 (MoveToManager) stays a separate sub-lane. Co-Authored-By: Claude Opus 4.7 --- ...26-05-02-l3-remote-entity-motion-design.md | 268 ++++++++++++++---- 1 file changed, 219 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md b/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md index 29051cd2..21ce779f 100644 --- a/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md +++ b/docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md @@ -90,17 +90,39 @@ addresses — not guesses): **Phase L.3 — Remote Entity Motion Conformance.** Slots into the L = movement category alongside L.1 (animation) and L.2 (collision). -Three sub-lanes, each independently shippable + visually verifiable: +**Scope revision 2026-05-02 (after Task 7 visual verification):** +L.3.1 was originally scoped as "InterpolationManager only", with L.3.2 +("PositionManager") deferred. Visual verification proved L.3.1 alone +**cannot produce smooth motion** — retail combines animation root motion ++ InterpolationManager corrections, and only the second half ships in +L.3.1-as-originally-scoped. The two halves are functionally inseparable. + +**L.3.1 and L.3.2 are now combined into a single sub-lane** ("L.3.1+L.3.2 +combined"). L.3.3 remains a separate sub-lane. | Sub-lane | Title | Ships | |---|---|---| -| **L.3.1** | InterpolationManager core + routing | New `InterpolationManager` class, `MoveOrTeleport` routing replacing the hard-snap in `OnLivePositionUpdated`, `VectorUpdate.Omega` application, deletion of `RemoteMotion` soft-snap residual | -| **L.3.2** | PositionManager (root-motion + interpolation-offset combiner) | New `PositionManager` class that combines per-frame animation root-motion offset with the InterpolationManager's catch-up offset before writing the body's frame | +| **L.3.1 + L.3.2 combined** | InterpolationManager + PositionManager + retail-faithful jump | (1) `InterpolationManager` (FIFO queue + AdjustOffset), (2) `MotionInterpreter.GetMaxSpeed`, (3) `PositionManager` class combining animation root motion + Interp correction per frame, (4) `IsGrounded` plumbed through `EntityPositionUpdate`, (5) `OnLivePositionUpdated` retail-faithful routing (airborne no-op + landing transition + grounded routing), (6) per-frame `TickAnimations` calls `PositionManager.ComputeOffset` + `UpdatePhysicsInternal`, (7) `VectorUpdate.Omega` application | | **L.3.3** | MoveToManager (server-controlled creature MoveTo) | Replaces `RemoteMoveToDriver` MVP with a faithful port: retracking, sticky-to-target, fail-distance progress checks, sphere-cylinder distance variants | -L.3.2 and L.3.3 get their own brainstorm + spec when L.3.1 lands. -**This document specifies L.3.1 in detail; L.3.2 and L.3.3 are -sketches** (above) so the phase shape is on record. +L.3.3 gets its own brainstorm + spec when L.3.1+L.3.2 ships. +**This document specifies L.3.1+L.3.2 in detail; L.3.3 is a sketch** +(above) so the phase shape is on record. + +### What changed since original spec + +- **L.3.2 PositionManager** is now part of L.3.1, not a separate phase. +- **`IsGrounded` plumbing** added — verified to already exist as + `PositionFlags.IsGrounded = 0x04` in `UpdatePosition.cs:48`, parsed but + not exposed through `EntityPositionUpdate`. Now exposed. +- **Jump pipeline** rewritten to match retail's `MoveOrTeleport` + has_contact=false → no-op semantics. Local arc prediction (the source + of the "endless jump" bug) eliminated. Server is authoritative; landing + detected via the first `IsGrounded=true` UP after airborne. +- **Stall-blip → TAIL** (resolved Task 0 via decomp dive of + `acclient!InterpolationManager::UseTime` @ 0x00555F20). +- **Reverted band-aid commits** `5154a3e` + `f199a6a` (commit `1641d6e`) + before re-implementing properly. --- @@ -163,13 +185,14 @@ internal sealed class InterpolationNode if (progress < StallProgressMinFraction * (catchUpSpeed * dt * StallCheckFrameInterval)): _failCount++ if _failCount > StallFailCountForBlip: - // blip: hard-snap and clear queue. - // OPEN PRECISION ITEM: retail's UseTime (acclient!00555f20) decides - // head-vs-tail snap; the agent reports disagreed (R1 implies head, R2 says - // tail). Verify by reading the UseTime disasm before implementing this - // branch. Default for the initial port: snap to HEAD (next intended - // waypoint) which matches the more common pattern. - body.Position = headTarget.Position + // blip: snap to TAIL (most recent server-sent waypoint) and clear queue. + // RESOLVED 2026-05-02 via decomp dive of acclient!InterpolationManager:: + // UseTime @ 0x00555F20: lines 353273-353333 read this->position_queue.tail_, + // copy tail.Position into local var, call CPhysicsObj::SetPositionSimple + // on it, then StopInterpolating. Semantic: "warp to where the server + // LAST SAID you are", not "where you were trying to get to next." + tail = queue.Last + body.Position = tail.TargetPosition // SetPositionSimple equivalent Clear() return Vector3.Zero else: @@ -292,13 +315,155 @@ Public method, ~10 lines, no new file. One commit titled `chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead soft-snap path`: - Delete the `if/else` env-var gate in `OnLivePositionUpdated` and - `OnLiveRemoteTick`. Keep only the new path. + `TickAnimations` per-frame remote tick. Keep only the new path. - Delete `RemoteMotion.SnapResidualDecayRate` field + soft-snap residual fields. - Delete the apply_current_movement + Euler dead-reckoning code in the per-frame remote tick (the OLD branch). -Net diff after cleanup: ~50 lines deletion, code shrinks. +Net diff after cleanup: ~80 lines deletion, code shrinks. + +--- + +## L.3.2 architecture (PositionManager — combined into L.3.1) + +### New file — `src/AcDream.Core/Physics/PositionManager.cs` + +Pure-data class, no game/window deps. Pure function: takes (animation +root motion + body orientation + InterpolationManager + maxSpeed) and +returns the per-frame world-space delta to add to `body.Position`. +Composed into `RemoteMotion` alongside the `Interp` field. + +**API:** + +```csharp +public sealed class PositionManager +{ + /// + /// Per-frame combiner: animation root motion + InterpolationManager + /// correction. Mirrors retail CPhysicsObj::UpdateObjectInternal + /// (acclient @ 0x00513730): + /// rootOffset = CPartArray::Update(dt) // animation + /// PositionManager::adjust_offset(rootOffset) // adds correction + /// frame.origin += rootOffset + /// + public Vector3 ComputeOffset( + double dt, + Vector3 currentBodyPosition, + Vector3 seqVel, // body-local velocity from active animation cycle + Quaternion ori, // body orientation (for local→world rotation) + InterpolationManager interp, + float maxSpeed) + { + // Step 1: animation root motion (body-local → world). + Vector3 rootMotionLocal = seqVel * (float)dt; + Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); + + // Step 2: interpolation correction (world-space already). + Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); + + // Step 3: combined. + return rootMotionWorld + correction; + } +} +``` + +### Composition + +`RemoteMotion` (in `GameWindow.cs:224`) gains a second field: + +```csharp +public AcDream.Core.Physics.PositionManager Position { get; } = + new AcDream.Core.Physics.PositionManager(); +``` + +(Already has `public InterpolationManager Interp` from Task 3.) + +### Per-frame `TickAnimations` (env-var-on branch) + +```csharp +if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +{ + // Always-run-all-steps per retail UpdateObjectInternal (0x00513730). + Vector3 seqVel = ae.Sequencer?.CurrentVelocity ?? Vector3.Zero; + float maxSpeed = rm.Motion.GetMaxSpeed(); + Vector3 offset = rm.Position.ComputeOffset( + dt, rm.Body.Position, seqVel, rm.Body.Orientation, rm.Interp, maxSpeed); + rm.Body.Position += offset; + rm.Body.UpdatePhysicsInternal(dt); // gravity for airborne; no-op for grounded +} +else { /* legacy path (kept until cleanup commit) */ } +``` + +Replaces the Task 5 commit's `if (rm.Interp.IsActive) { ... AdjustOffset ... }` +block. PositionManager calls AdjustOffset internally. + +### IsGrounded plumbing — `EntityPositionUpdate` + +`PositionFlags.IsGrounded = 0x04` is already parsed in +`UpdatePosition.cs:48`. Add a `bool IsGrounded` field to +`EntityPositionUpdate` record, populate at the parse site, consume in +`OnLivePositionUpdated`. ~3 lines. + +### Retail-faithful jump pipeline + +Rewrites the `OnLivePositionUpdated` env-var-on branch to match retail +`MoveOrTeleport` (acclient @ 0x00516330) — `has_contact=false → return`: + +```csharp +if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +{ + rmState.Body.Orientation = rot; // orientation always snaps + + if (!update.IsGrounded) // airborne: no-op + return; + + if (rmState.Airborne) // landing transition + { + rmState.Airborne = false; + rmState.Body.Velocity = Vector3.Zero; + rmState.Body.State &= ~PhysicsStateFlags.Gravity; + rmState.Body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; // hard-snap to landing + return; + } + + // Grounded routing (CPhysicsObj::MoveOrTeleport): + const float MaxPhysicsDistance = 96f; + var localPlayerPos = _playerController?.Position ?? Vector3.Zero; + float dist = Vector3.Distance(worldPos, localPlayerPos); + + if (dist > MaxPhysicsDistance) + { + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; + } + else + { + float headingFromQuat = ExtractYawFromQuaternion(rot); + rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); + } + return; +} +``` + +`OnLiveVectorUpdated` is **unchanged** — already sets velocity, marks +airborne, enables Gravity, applies Omega (Task 6). + +### L.3.2 unit tests + +New test file `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs`, +~6 tests against the pure `ComputeOffset` function: + +| Test | Verifies | +|---|---| +| `ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion` | seqVel=0, queue empty → returns Vector3.Zero | +| `ComputeOffset_AnimationOnly_Forward_BodyAdvances` | seqVel=(0,4,0), identity orientation → returns (0, 4*dt, 0) | +| `ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth` | seqVel=(0,4,0), orientation faces -Y → returns (0,-4*dt,0) | +| `ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue` | seqVel=0, queue active → returns Interp's delta | +| `ComputeOffset_BothActive_Combined` | both nonzero → returns sum | +| `ComputeOffset_LocalToWorldRotation_Yaw90` | seqVel=(0,1,0), yaw=π/2 → returns (sin(π/2), cos(π/2)·1, 0) verifying rotation | --- @@ -320,13 +485,13 @@ no game/window/loader needed. ## Acceptance criteria -L.3.1 is shippable when: +L.3.1+L.3.2 (combined) is shippable when: -1. `dotnet build` green; existing 91 unit tests + new ~13 InterpolationManager + ~5 routing tests all pass. -2. **Visual primary:** parallel retail observer of `+Acdream` standing still, walking, running, strafing, jumping, turning — **all motion glides smoothly**, no 1-Hz popping. -3. **Visual regression check:** `+Acdream`-from-retail-observer behaviors fixed in commit `17a9ff1` (backward jump direction, strafe-run animation, walk-back broadcast direction) all still work. -4. **Visual jump arc:** remote retail toon jumping shows a curved arc as observed from acdream (`Omega` applied), not a flat path. -5. After visual confirmation: cleanup commit lands removing `ACDREAM_INTERP_MANAGER` flag + old hard-snap path + dead `RemoteMotion` fields. +1. `dotnet build` green; existing 105 unit tests + 16 InterpolationManager + 5 GetMaxSpeed + ~6 PositionManager tests all pass. +2. **Visual primary:** parallel retail observer of `+Acdream` standing still, walking, running, strafing, turning — **all motion glides smoothly**, no 1-Hz popping. (PositionManager's animation-root-motion is what eliminates the chop.) +3. **Visual jump:** retail toon jumping shows a curved arc that LANDS correctly (no endless rise). Server-authoritative airborne (`IsGrounded=false → no-op`). +4. **Visual regression check:** behaviors fixed in commit `17a9ff1` (backward jump direction, strafe-run animation, walk-back broadcast direction) all still work. +5. After visual confirmation: cleanup commit lands removing `ACDREAM_INTERP_MANAGER` flag + old hard-snap path + dead `RemoteMotion` soft-snap fields. --- @@ -344,53 +509,58 @@ L.3.1 is shippable when: ## Files -### New +### Already shipped (L.3.1 original scope) -- `src/AcDream.Core/Physics/InterpolationManager.cs` — the manager class -- `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` — manager tests -- `tests/AcDream.Core.Tests/Physics/MoveOrTeleportRoutingTests.cs` — routing tests (or merged into above) +- `src/AcDream.Core/Physics/InterpolationManager.cs` (commits `f43f168` + `927636e`) +- `tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs` (16 tests) +- `src/AcDream.Core/Physics/MotionInterpreter.cs` `GetMaxSpeed()` (commits `9c5634a` + `5b26d28`) +- `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs` (5 GetMaxSpeed tests added) +- `RemoteMotion.Interp` field (commit `517a3ce`) +- `OnLivePositionUpdated` env-var routing v1 (commit `062e19f`) +- Per-frame `Interp.AdjustOffset` v1 (commit `ae79e34`) +- `OnLiveVectorUpdated.Omega` application (commit `e08accf`) +- Reverted band-aid commits (commit `1641d6e`) -### Modified +### To ship (L.3.2 added scope) +**New:** +- `src/AcDream.Core/Physics/PositionManager.cs` — pure-data combiner class +- `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs` — ~6 tests + +**Modified:** +- `src/AcDream.Core.Net/WorldSession.cs` — add `IsGrounded` field to `EntityPositionUpdate` record + populate at parse site (~3 lines) - `src/AcDream.App/Rendering/GameWindow.cs`: - - `RemoteMotion` (~line 224): add `Interp` field - - `OnLivePositionUpdated` (~line 3151): new routing behind env-var - - `OnLiveVectorUpdated` (~line 3064): apply Omega - - `OnLiveRemoteTick` (per-frame): new offset-add behind env-var -- `src/AcDream.Core/Physics/MotionInterpreter.cs`: add `GetMaxSpeed()` -- `docs/plans/2026-04-11-roadmap.md`: insert Phase L.3 entry between L.2 and M -- `docs/ISSUES.md`: close any motion-related open issues this fixes (none currently filed) + - `RemoteMotion` (~line 224): add `Position` field (alongside existing `Interp`) + - `OnLivePositionUpdated` env-var branch: rewritten — airborne no-op + landing transition + grounded routing (replaces the existing v1 routing) + - `TickAnimations` env-var branch: rewritten — `PositionManager.ComputeOffset` + `UpdatePhysicsInternal` (replaces the existing v1 Interp.AdjustOffset call) ### Cleanup commit (after verification) -Same files as Modified above, with the env-var dual paths collapsed -to single retail-faithful path, and `RemoteMotion` soft-snap fields -deleted. +Single commit: collapses env-var dual paths to retail-faithful path, +deletes `RemoteMotion` soft-snap residual fields. ~80 lines deletion. --- -## Out of scope (deferred to L.3.2 / L.3.3) +## Out of scope (deferred to L.3.3) -- `PositionManager` (combines anim root-motion + interpolation offset before writing body.Frame) — L.3.2 - Server-controlled MoveTo creature behavior (retracking, sticky, fail-distance) — L.3.3 - Replacing `RemoteMoveToDriver.cs` — L.3.3 - VectorUpdate.Omega for other entity types (projectiles, dropped items) — defer; current spec applies only to player/creature/NPC paths --- -## Implementation order (L.3.1) +## Implementation order (L.3.1+L.3.2 combined — remaining work) -1. Add `InterpolationManager.cs` + unit tests. Build green. -2. Add `MotionInterpreter.GetMaxSpeed()`. Build green. -3. Modify `RemoteMotion` to compose `Interp`. Build green. -4. Add env-var gated routing in `OnLivePositionUpdated`. Build green; flag off → existing behavior. -5. Add env-var gated tick in `OnLiveRemoteTick`. Build green; flag off → existing behavior. -6. Apply `OnLiveVectorUpdated.Omega`. Build green. -7. Visual verification (flag on) — confirm acceptance criteria. -8. Cleanup commit: delete env-var, dead paths, dead RemoteMotion fields. -9. Update roadmap. +Original L.3.1 commits 1-6 already shipped. The two band-aid commits (`5154a3e`, `f199a6a`) reverted in `1641d6e`. Remaining: -Each step is a single commit. Direct-to-main per CLAUDE.md. +1. **`feat(physics): PositionManager class + 6 unit tests`** — subagent-implemented. Pure-data class + tests against stub Interp. +2. **`feat(net): plumb IsGrounded through EntityPositionUpdate`** — parent edit, 3 lines. +3. **`feat(motion): retail-faithful per-frame remote tick (PositionManager + IsGrounded routing)`** — subagent. Adds `RemoteMotion.Position` field + rewrites both env-var-on branches (`OnLivePositionUpdated` and the per-frame tick). Single commit because changes are tightly coupled. +4. **USER GATE — visual verification** with retail observer of `+Acdream` performing the test matrix. +5. **`chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead legacy paths`** — subagent. Cleanup commit. +6. **`docs(roadmap+spec): L.3.1+L.3.2 combined; L.3.3 still separate`** — parent. Roadmap entry update + spec status mark. + +Each step is one commit. Direct-to-main per CLAUDE.md. --- From d063ac884d3e7085e5469eb25a932528ef4361c8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:10:16 +0200 Subject: [PATCH 15/32] docs(plan): Phase L.3.1+L.3.2 PositionManager + retail-faithful jump plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-task plan with subagent dispatch on Tasks 1, 3, 5: - Task 1: PositionManager class + 6 unit tests (subagent) - Task 2: Plumb IsGrounded through EntityPositionUpdate (parent, ~5 lines) - Task 3: Retail-faithful per-frame remote tick (subagent — biggest: RemoteMotion.Position field + OnLivePositionUpdated rewrite [airborne no-op + landing transition + grounded routing] + TickAnimations rewrite [PositionManager.ComputeOffset + UpdatePhysicsInternal]) - Task 4: USER GATE (visual verification with retail observer) - Task 5: Cleanup commit (subagent, parallel with 6) - Task 6: Roadmap + spec status update (parent, parallel with 5) Each task has TDD-style steps with exact file paths, code blocks, and commit messages. Spec at c4446e7 lists L.3.1's already-shipped 6 commits; this plan picks up from the revert at 1641d6e. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-02-l3-positionmanager-jump.md | 785 ++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-l3-positionmanager-jump.md diff --git a/docs/superpowers/plans/2026-05-02-l3-positionmanager-jump.md b/docs/superpowers/plans/2026-05-02-l3-positionmanager-jump.md new file mode 100644 index 00000000..2091058d --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-l3-positionmanager-jump.md @@ -0,0 +1,785 @@ +# Phase L.3.1+L.3.2 Combined — PositionManager + Retail-Faithful Remote Tick + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the PositionManager combiner (animation root motion + InterpolationManager corrections) that was originally deferred to L.3.2, plumb `IsGrounded` through `EntityPositionUpdate`, and rewrite the per-frame remote tick + `OnLivePositionUpdated` env-var-on branches to match retail's `MoveOrTeleport` semantics. This eliminates the 1-Hz chop and endless-jump bugs surfaced during Task 7 visual verification. + +**Architecture:** Pure-data `PositionManager.ComputeOffset(dt, body.Position, seqVel, ori, interp, maxSpeed) → Vector3` returns the per-frame world-space delta to add to body.Position. Combines (a) animation root motion = `seqVel * dt` rotated by body orientation with (b) `InterpolationManager.AdjustOffset` correction. Per-frame tick always runs all steps (matches retail `UpdateObjectInternal`). `OnLivePositionUpdated` routes per `MoveOrTeleport`: airborne → no-op; landing transition → snap + clear flags; grounded → enqueue or slide-snap. Server is authoritative for airborne arcs (no local prediction fights gravity). + +**Tech Stack:** C# / .NET 10 / xUnit. No new NuGet deps. Tests at `tests/AcDream.Core.Tests/Physics/*Tests.cs`. + +**Spec:** [`docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md`](../specs/2026-05-02-l3-remote-entity-motion-design.md) (committed `c4446e7`). + +**Already shipped (do NOT rebuild):** +- `f43f168` + `927636e` Task 1 — InterpolationManager +- `9c5634a` + `5b26d28` Task 2 — MotionInterpreter.GetMaxSpeed +- `517a3ce` Task 3 — RemoteMotion.Interp field +- `062e19f` Task 4 — OnLivePositionUpdated env-var routing v1 +- `ae79e34` Task 5 — Per-frame Interp.AdjustOffset v1 +- `e08accf` Task 6 — VectorUpdate.Omega +- `1641d6e` revert of band-aids +- `c4446e7` spec revision + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/AcDream.Core/Physics/PositionManager.cs` | **CREATE** | Pure-function combiner: animation root motion + Interp correction. ~50 lines including XML docs. | +| `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs` | **CREATE** | 6 unit tests against pure `ComputeOffset`. | +| `src/AcDream.Core.Net/Messages/UpdatePosition.cs` | **MODIFY** | Add `IsGrounded` to `Parsed` record, populate from `flags & PositionFlags.IsGrounded`. ~3 lines. | +| `src/AcDream.Core.Net/WorldSession.cs` | **MODIFY** | Add `IsGrounded` to `EntityPositionUpdate` record, pass through in PositionUpdated invoke. ~2 lines. | +| `src/AcDream.App/Rendering/GameWindow.cs` | **MODIFY** | (a) `RemoteMotion` gains `Position` field; (b) rewrite `OnLivePositionUpdated` env-var-on branch (airborne no-op + landing transition + grounded routing); (c) rewrite `TickAnimations` env-var-on branch (`PositionManager.ComputeOffset` + `UpdatePhysicsInternal`). | +| (cleanup commit) `src/AcDream.App/Rendering/GameWindow.cs` | **MODIFY** | Delete env-var dual paths; delete `RemoteMotion` soft-snap residual fields. | +| `docs/plans/2026-04-11-roadmap.md` | **MODIFY** (cleanup phase) | Update Phase L.3 entry to reflect L.3.1+L.3.2 combined. | +| `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md` | **MODIFY** (cleanup phase) | Mark L.3.1+L.3.2 as SHIPPED. | + +--- + +## Task Decomposition Overview + +``` + Task 1 — PositionManager class + 6 tests (subagent) + ↓ + Task 2 — Plumb IsGrounded through EntityPositionUpdate (parent, 2 files, ~5 lines) + ↓ + Task 3 — Retail-faithful per-frame remote tick (subagent — biggest change) + ↓ + Task 4 — USER GATE: visual verification with retail observer + ↓ (after sign-off) + ┌─ DISPATCH IN PARALLEL ──────────────────┐ + │ Task 5: Cleanup commit (subagent) │ + │ Task 6: Roadmap + spec status (parent) │ + └──────────────────────────────────────────┘ +``` + +--- + +## Task 1 — PositionManager class + 6 unit tests + +**Owner:** Sonnet subagent (general-purpose). + +**Files:** +- Create: `src/AcDream.Core/Physics/PositionManager.cs` +- Create: `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs` + +**Subagent dispatch prompt** (use `general-purpose` agent type, Sonnet): + +> You are implementing Task 1 of Phase L.3.1+L.3.2 in the acdream codebase. Read the spec at `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md` section "L.3.2 architecture" → "New file — `src/AcDream.Core/Physics/PositionManager.cs`". +> +> **What to build:** +> +> Create `src/AcDream.Core/Physics/PositionManager.cs`: +> +> ```csharp +> using System.Numerics; +> +> namespace AcDream.Core.Physics; +> +> /// +> /// Per-frame combiner for remote-entity motion: animation root motion +> /// + InterpolationManager catch-up correction. Pure function — no +> /// side effects, no hidden state. +> /// +> /// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730): +> /// rootOffset = CPartArray::Update(dt) // animation +> /// PositionManager::adjust_offset(rootOffset) // adds correction +> /// frame.origin += rootOffset +> /// +> /// In acdream the animation root motion is sourced from +> /// AnimationSequencer.CurrentVelocity (body-local velocity from the +> /// active locomotion cycle). We rotate that by the body's orientation +> /// to get a world-space delta, then add the InterpolationManager's +> /// world-space correction. +> /// +> public sealed class PositionManager +> { +> /// +> /// Compute the per-frame world-space delta to add to body.Position. +> /// +> /// Per-frame delta time, seconds. +> /// Body's current world-space position. +> /// +> /// Body-local velocity from the active animation cycle +> /// (from AnimationSequencer.CurrentVelocity); pass +> /// Vector3.Zero if the entity has no sequencer or is on a +> /// non-locomotion cycle. +> /// +> /// Body orientation; used to rotate seqVel from body-local to world. +> /// The remote's InterpolationManager (for AdjustOffset call). +> /// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp. +> public Vector3 ComputeOffset( +> double dt, +> Vector3 currentBodyPosition, +> Vector3 seqVel, +> Quaternion ori, +> InterpolationManager interp, +> float maxSpeed) +> { +> // Step 1: animation root motion (body-local → world). +> Vector3 rootMotionLocal = seqVel * (float)dt; +> Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); +> +> // Step 2: interpolation correction (world-space already). +> Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); +> +> // Step 3: combined delta. +> return rootMotionWorld + correction; +> } +> } +> ``` +> +> Create `tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs` with EXACTLY these 6 test names (these are the contract): +> +> 1. `ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion` +> - seqVel = Vector3.Zero, no enqueued nodes in interp +> - Assert: returned offset == Vector3.Zero +> +> 2. `ComputeOffset_AnimationOnly_Forward_BodyAdvances` +> - seqVel = (0, 4, 0) (4 m/s forward), ori = Quaternion.Identity, dt = 0.1 +> - Assert: returned offset == (0, 0.4, 0) (forward 0.4m) +> +> 3. `ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth` +> - seqVel = (0, 4, 0), ori = quaternion rotating +Y → -Y (180° around Z), dt = 0.1 +> - Assert: returned offset.Y ≈ -0.4 (south) +> +> 4. `ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue` +> - seqVel = Vector3.Zero, interp has 1 enqueued node 1m ahead, dt = 0.1, maxSpeed = 4f +> - Expected: AdjustOffset returns the catch-up step (≤ 1m, clamped); ComputeOffset returns same +> +> 5. `ComputeOffset_BothActive_Combined` +> - seqVel = (0, 4, 0) — root motion (0, 0.4, 0) +> - interp has node 1m ahead — AdjustOffset returns ~Vector3.UnitY * step +> - Assert: returned offset == rootMotion + correction +> +> 6. `ComputeOffset_LocalToWorldRotation_Yaw90` +> - seqVel = (0, 1, 0) (forward 1 m/s in body frame) +> - ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f) (yaw +90°) +> - dt = 1 +> - Verify the rotation is applied correctly. With yaw +90° around Z, body-local +Y rotates to world... compute the expected and assert with precision: 4. +> +> Use xUnit, `namespace AcDream.Core.Tests.Physics;`, file-private fakes via `file sealed class` if needed. Read `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs` for the existing pattern. +> +> Note: Tests #4 and #5 need a real `InterpolationManager` (not a fake) because PositionManager calls AdjustOffset directly. Construct one inline in each test, Enqueue what you need, and call ComputeOffset. +> +> **Build + test:** +> +> ```bash +> cd C:/Users/erikn/source/repos/acdream +> dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug --nologo +> dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build --nologo --filter "FullyQualifiedName~PositionManager" +> ``` +> +> Both green. 6 tests pass. +> +> **Commit:** +> +> ```bash +> git add src/AcDream.Core/Physics/PositionManager.cs tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs +> git commit -m "$(cat <<'EOF' +> feat(physics): PositionManager combiner class + 6 unit tests (L.3.2) +> +> Pure-function ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed) → +> Vector3. Combines animation root motion (seqVel × dt rotated by body +> orientation) with InterpolationManager.AdjustOffset world-space +> correction. Mirrors retail CPhysicsObj::UpdateObjectInternal +> (acclient @ 0x00513730). +> +> Composed into RemoteMotion in subsequent task (L.3.1+L.3.2 Task 3); +> not yet consumed. +> +> Co-Authored-By: Claude Opus 4.7 +> EOF +> )" +> ``` +> +> **Self-review checklist:** +> - [ ] `PositionManager` is public sealed class +> - [ ] `ComputeOffset` is the only public method (no other API) +> - [ ] All 6 tests have the exact names listed +> - [ ] Tests #4 and #5 use a real `InterpolationManager` +> - [ ] No game/window/sequencer dependencies — only `System.Numerics` + `AcDream.Core.Physics.InterpolationManager` +> - [ ] Build clean, all 6 tests pass +> - [ ] Commit references "L.3.2" +> +> **Report:** +> - Status: DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT +> - What you built (1-2 sentences) +> - Test results (count, any deviations) +> - Files changed +> - Concerns (if any) + +**Steps for the parent (controller):** + +- [ ] **Step 1.1: Dispatch the implementer subagent** using the prompt above. +- [ ] **Step 1.2: Verify the commit landed** + ```bash + cd C:/Users/erikn/source/repos/acdream && git log -1 --stat src/AcDream.Core/Physics/PositionManager.cs + ``` + Expected: commit message starts with `feat(physics): PositionManager combiner class`. +- [ ] **Step 1.3: Re-run tests in parent** + ```bash + cd C:/Users/erikn/source/repos/acdream && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build --nologo --filter "FullyQualifiedName~PositionManager" + ``` + Expected: 6 tests pass. +- [ ] **Step 1.4: Dispatch spec compliance reviewer** (use `general-purpose`, Sonnet). Verify the 6 tests have the EXACT names listed and verify `ComputeOffset` algorithm matches the spec's pseudocode. +- [ ] **Step 1.5: Dispatch code quality reviewer** (use `superpowers:code-reviewer`). Check for: API surface (only ComputeOffset public), test quality, no superfluous deps. +- [ ] **Step 1.6: Address review issues if any.** If issues found, dispatch fix subagent. Re-review. + +--- + +## Task 2 — Plumb `IsGrounded` through `EntityPositionUpdate` + +**Owner:** Parent. Mechanical edit, ~5 lines across 2 files. + +**Files:** +- Modify: `src/AcDream.Core.Net/Messages/UpdatePosition.cs:62-69` (add `IsGrounded` to `Parsed` record) +- Modify: `src/AcDream.Core.Net/Messages/UpdatePosition.cs:166` (populate `IsGrounded` in the constructor call) +- Modify: `src/AcDream.Core.Net/WorldSession.cs:110-113` (add `IsGrounded` to `EntityPositionUpdate` record) +- Modify: `src/AcDream.Core.Net/WorldSession.cs:711-714` (pass `posUpdate.Value.IsGrounded` through) + +**Steps:** + +- [ ] **Step 2.1: Read existing `UpdatePosition.Parsed` record + TryParse return** + ```bash + grep -n "public readonly record struct Parsed\|return new Parsed" "C:/Users/erikn/source/repos/acdream/src/AcDream.Core.Net/Messages/UpdatePosition.cs" + ``` + +- [ ] **Step 2.2: Add `IsGrounded` field to `UpdatePosition.Parsed`** + + Edit `src/AcDream.Core.Net/Messages/UpdatePosition.cs` (~line 62): + + Change: + ```csharp + public readonly record struct Parsed( + uint Guid, + CreateObject.ServerPosition Position, + System.Numerics.Vector3? Velocity, + uint? PlacementId, + ushort InstanceSequence = 0, + ushort TeleportSequence = 0, + ushort ForcePositionSequence = 0); + ``` + To: + ```csharp + public readonly record struct Parsed( + uint Guid, + CreateObject.ServerPosition Position, + System.Numerics.Vector3? Velocity, + uint? PlacementId, + bool IsGrounded, + ushort InstanceSequence = 0, + ushort TeleportSequence = 0, + ushort ForcePositionSequence = 0); + ``` + +- [ ] **Step 2.3: Populate `IsGrounded` in the `Parsed` constructor call (~line 166)** + + Find the line `return new Parsed(guid, serverPos, velocity, placementId,` (~line 166) and change to pass `(flags & PositionFlags.IsGrounded) != 0` as the new IsGrounded argument. Looks roughly like: + + ```csharp + return new Parsed(guid, serverPos, velocity, placementId, + (flags & PositionFlags.IsGrounded) != 0, + instSeq, teleSeq, forceSeq); + ``` + + (Verify the trailing-arg layout against what's actually there; preserve any existing trailing arguments.) + +- [ ] **Step 2.4: Add `IsGrounded` field to `WorldSession.EntityPositionUpdate`** + + Edit `src/AcDream.Core.Net/WorldSession.cs:110`: + + Change: + ```csharp + public readonly record struct EntityPositionUpdate( + uint Guid, + CreateObject.ServerPosition Position, + System.Numerics.Vector3? Velocity); + ``` + To: + ```csharp + public readonly record struct EntityPositionUpdate( + uint Guid, + CreateObject.ServerPosition Position, + System.Numerics.Vector3? Velocity, + bool IsGrounded); + ``` + +- [ ] **Step 2.5: Pass `IsGrounded` through in PositionUpdated invoke (~line 711)** + + Change: + ```csharp + PositionUpdated?.Invoke(new EntityPositionUpdate( + posUpdate.Value.Guid, + posUpdate.Value.Position, + posUpdate.Value.Velocity)); + ``` + To: + ```csharp + PositionUpdated?.Invoke(new EntityPositionUpdate( + posUpdate.Value.Guid, + posUpdate.Value.Position, + posUpdate.Value.Velocity, + posUpdate.Value.IsGrounded)); + ``` + +- [ ] **Step 2.6: Build + test** + ```bash + cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo + dotnet test --no-build --nologo 2>&1 | tail -6 + ``` + Expected: 0 build errors. Same 4 pre-existing test failures, no new failures. + +- [ ] **Step 2.7: Commit** + ```bash + git add src/AcDream.Core.Net/Messages/UpdatePosition.cs src/AcDream.Core.Net/WorldSession.cs + git commit -m "$(cat <<'EOF' + feat(net): plumb IsGrounded through EntityPositionUpdate (L.3.2) + + PositionFlags.IsGrounded (0x04) was already parsed by UpdatePosition + but not exposed through Parsed record or EntityPositionUpdate. + Adds the bool field to both records so OnLivePositionUpdated can + consume it for retail-faithful MoveOrTeleport routing + (acclient @ 0x00516330: has_contact=false → no-op during airborne arc). + + Consumed in subsequent task (L.3.1+L.3.2 Task 3). + + Co-Authored-By: Claude Opus 4.7 + EOF + )" + ``` + +--- + +## Task 3 — Retail-faithful per-frame remote tick + +**Owner:** Sonnet subagent (general-purpose). Largest task — touches 3 distinct sites in `GameWindow.cs`. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (RemoteMotion class line ~224 + OnLivePositionUpdated env-var branch + TickAnimations env-var branch) + +**Subagent dispatch prompt:** + +> You are implementing Task 3 of Phase L.3.1+L.3.2 in the acdream codebase. This task rewrites two env-var-gated branches in `src/AcDream.App/Rendering/GameWindow.cs` to consume the new PositionManager (Task 1) and IsGrounded plumbing (Task 2). +> +> **Repo:** `C:/Users/erikn/source/repos/acdream` — main branch — direct-to-main per CLAUDE.md. +> +> **Spec:** `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md` "L.3.2 architecture" sections. +> +> **Three changes in `GameWindow.cs`:** +> +> ### Change 1: `RemoteMotion` class gains `Position` field +> +> Find the existing `Interp` field (added in commit `517a3ce`). Right after it, add: +> +> ```csharp +> /// +> /// Per-frame combiner for animation root motion + InterpolationManager +> /// correction (Phase L.3.2). Consumed in TickAnimations to compute the +> /// per-frame body.Position delta. +> /// +> public AcDream.Core.Physics.PositionManager Position { get; } = +> new AcDream.Core.Physics.PositionManager(); +> ``` +> +> ### Change 2: Rewrite `OnLivePositionUpdated` env-var-on branch +> +> Find the existing env-var-on block in `OnLivePositionUpdated` (was added at commit `062e19f`). It currently looks roughly like: +> ```csharp +> if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +> { +> rmState.Body.Orientation = rot; +> // teleport check, dist check, etc. +> return; +> } +> ``` +> +> Replace the env-var-on body with this new logic: +> +> ```csharp +> if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +> { +> // Orientation always snaps on receipt — the InterpolationManager +> // walks position only; heading would otherwise lag the queue. +> rmState.Body.Orientation = rot; +> +> // ── AIRBORNE NO-OP ──────────────────────────────────────────── +> // Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): +> // when has_contact==0, return false (don't touch body, don't queue). +> // body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps +> // integrating gravity via per-frame UpdatePhysicsInternal. Server is +> // authoritative for the arc; we don't predict it locally. +> if (!update.IsGrounded) +> return; +> +> // ── LANDING TRANSITION ───────────────────────────────────────── +> // First IsGrounded=true UP after rmState.Airborne signals landed. +> // Clear airborne flags, hard-snap to authoritative landing position, +> // clear interpolation queue (any pre-jump waypoints are stale). +> if (rmState.Airborne) +> { +> rmState.Airborne = false; +> rmState.Body.Velocity = System.Numerics.Vector3.Zero; +> rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; +> rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact +> | AcDream.Core.Physics.TransientStateFlags.OnWalkable; +> rmState.Interp.Clear(); +> rmState.Body.Position = worldPos; +> return; +> } +> +> // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── +> const float MaxPhysicsDistance = 96f; +> var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; +> float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); +> +> if (dist > MaxPhysicsDistance) +> { +> // Beyond view bubble: SetPositionSimple slide-snap. Clear queue. +> rmState.Interp.Clear(); +> rmState.Body.Position = worldPos; +> } +> else +> { +> // Within view bubble: enqueue waypoint for adjust_offset to walk to. +> // PositionManager (called per-frame in TickAnimations) handles the +> // actual body advancement — mix of animation root motion + queue +> // correction. +> float headingFromQuat = ExtractYawFromQuaternion(rot); +> rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); +> } +> return; +> } +> ``` +> +> The legacy `else` branch (env-var unset) STAYS UNCHANGED. +> +> If `ExtractYawFromQuaternion` doesn't exist anymore (it might have been removed in the revert), re-add it near the original location (search for it in commit `062e19f`'s diff). The body is: +> ```csharp +> private static float ExtractYawFromQuaternion(System.Numerics.Quaternion q) +> { +> // Standard z-up yaw extraction: atan2(2(wz + xy), 1 - 2(y² + z²)) +> return MathF.Atan2(2f * (q.W * q.Z + q.X * q.Y), +> 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); +> } +> ``` +> +> ### Change 3: Rewrite `TickAnimations` env-var-on branch +> +> Find the existing env-var-on block in the per-frame remote tick (added at commit `ae79e34`). It currently looks roughly like: +> ```csharp +> if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +> { +> if (rm.Interp.IsActive) { +> float maxSpeed = rm.Motion.GetMaxSpeed(); +> Vector3 delta = rm.Interp.AdjustOffset((double)dt, rm.Body.Position, maxSpeed); +> rm.Body.Position += delta; +> } +> rm.Body.UpdatePhysicsInternal(dt); +> // entity write-back +> } +> ``` +> +> Replace with PositionManager call: +> +> ```csharp +> if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") +> { +> // Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal +> // (acclient @ 0x00513730): +> // 1+2. animation root motion + interpolation correction (combined) +> // 3. physics integration (gravity for airborne; no-op for grounded) +> System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity +> ?? System.Numerics.Vector3.Zero; +> float maxSpeed = rm.Motion.GetMaxSpeed(); +> System.Numerics.Vector3 offset = rm.Position.ComputeOffset( +> dt: (double)dt, +> currentBodyPosition: rm.Body.Position, +> seqVel: seqVel, +> ori: rm.Body.Orientation, +> interp: rm.Interp, +> maxSpeed: maxSpeed); +> rm.Body.Position += offset; +> rm.Body.UpdatePhysicsInternal(dt); +> // KEEP whatever entity write-back lines were here (ae.Entity.Position = ..., etc.) +> } +> else +> { +> // EXISTING legacy path UNCHANGED +> } +> ``` +> +> The `else` branch (legacy path) stays UNCHANGED. +> +> **Build + test:** +> +> ```bash +> cd C:/Users/erikn/source/repos/acdream +> dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +> dotnet test --no-build --nologo 2>&1 | tail -6 +> ``` +> +> Expected: 0 build errors. Same 4 pre-existing failures (`DispatcherToMovementIntegrationTests` + `BSPStepUpTests` — these are not related to L.3 work). No NEW failures. +> +> **Commit:** +> +> ```bash +> git add src/AcDream.App/Rendering/GameWindow.cs +> git commit -m "$(cat <<'EOF' +> feat(motion): retail-faithful per-frame remote tick (L.3.1+L.3.2) +> +> Combines PositionManager (Task 1) + IsGrounded plumbing (Task 2) into +> the per-frame remote motion path. Three changes in GameWindow.cs, +> all gated behind ACDREAM_INTERP_MANAGER=1: +> +> 1. RemoteMotion gains Position field (PositionManager instance). +> +> 2. OnLivePositionUpdated env-var branch rewritten to mirror retail +> CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): +> - orientation snap-on-receipt (PositionManager handles position only) +> - airborne (!IsGrounded) → no-op (server is authoritative for arc; +> body.Velocity from VectorUpdate integrates gravity locally) +> - landing transition (first IsGrounded=true after Airborne) → +> clear airborne flags, hard-snap to landing pos, clear queue +> - grounded routing: dist > 96m → slide-snap; dist ≤ 96m → enqueue +> +> 3. TickAnimations env-var branch rewritten to use PositionManager: +> body.Position += PositionManager.ComputeOffset(dt, pos, seqVel, +> ori, interp, maxSpeed); body.UpdatePhysicsInternal(dt) for gravity. +> +> Replaces the L.3.1-only AdjustOffset-only path. Legacy (env-var off) +> path unchanged. +> +> Cleanup commit (next sub-task) deletes the env-var dual paths after +> visual verification. +> +> Co-Authored-By: Claude Opus 4.7 +> EOF +> )" +> ``` +> +> **Self-review checklist:** +> - [ ] `RemoteMotion.Position` field added (alongside existing `Interp`) +> - [ ] `OnLivePositionUpdated` env-var branch has 3 sub-branches: airborne return, landing transition, grounded routing (snap or enqueue) +> - [ ] `OnLivePositionUpdated` legacy `else` branch UNCHANGED +> - [ ] `TickAnimations` env-var branch uses `PositionManager.ComputeOffset` exclusively (no direct `AdjustOffset` call) +> - [ ] `TickAnimations` legacy `else` branch UNCHANGED +> - [ ] `ExtractYawFromQuaternion` helper present (re-add if missing) +> - [ ] `OnLiveVectorUpdated` UNTOUCHED (it already does the right thing) +> - [ ] Build clean, same 4 pre-existing failures +> +> **Report:** +> - Status: DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT +> - Lines changed (with file:line refs) +> - Test count +> - Concerns (if any) +> +> If the existing legacy `else` path is so tangled that you can't safely rewrite the env-var branch without disturbing it, REPORT BLOCKED with specifics. + +**Steps for the parent:** + +- [ ] **Step 3.1: Dispatch the implementer subagent** using the prompt above. +- [ ] **Step 3.2: Verify the commit landed** + ```bash + cd C:/Users/erikn/source/repos/acdream && git log -1 --stat src/AcDream.App/Rendering/GameWindow.cs + ``` +- [ ] **Step 3.3: Build + test in parent** + ```bash + cd C:/Users/erikn/source/repos/acdream && dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo && dotnet test --no-build --nologo 2>&1 | tail -6 + ``` + Expected: 0 build errors. Same 4 pre-existing failures. +- [ ] **Step 3.4: Spec compliance review** (general-purpose subagent). Verify the rewrite matches the spec's pseudocode exactly. Verify legacy `else` paths are byte-for-byte unchanged. +- [ ] **Step 3.5: Code quality review** (`superpowers:code-reviewer`). Specifically check: orientation snap is in ALL routing paths; airborne no-op is the FIRST gate; landing transition resets all the right flags; ExtractYawFromQuaternion is correct. +- [ ] **Step 3.6: Address review issues if any.** Fix subagent + re-review. + +--- + +## Task 4 — USER GATE: visual verification + +**Owner:** User. Cannot be automated. + +**Steps:** + +- [ ] **Step 4.1: Kill any running acdream** + ```powershell + Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Sleep -Seconds 8 + ``` + +- [ ] **Step 4.2: Launch acdream with `ACDREAM_INTERP_MANAGER=1`** + ```powershell + $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" + $env:ACDREAM_LIVE = "1" + $env:ACDREAM_TEST_HOST = "127.0.0.1" + $env:ACDREAM_TEST_PORT = "9000" + $env:ACDREAM_TEST_USER = "testaccount" + $env:ACDREAM_TEST_PASS = "testpassword" + $env:ACDREAM_INTERP_MANAGER = "1" + dotnet run --project C:\Users\erikn\source\repos\acdream\src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "C:\Users\erikn\source\repos\acdream\.claude\worktrees\jovial-blackburn-773942\launch.log" + ``` + +- [ ] **Step 4.3: Visual test matrix** with parallel retail observer of `+Acdream`. On the retail side, walk + run + jump + turn the toon and verify: + + | Scenario | Expected | + |---|---| + | Walk forward 5 sec | acdream observer sees smooth glide, NO 1-Hz popping | + | Walk backward 5 sec | smooth glide backward (regression check vs commit `17a9ff1`) | + | Strafe left/right 5 sec each | smooth glide sideways | + | Stop, then run forward 5 sec | smooth glide at run speed | + | Jump from standstill 2-3× | curved arc, lands cleanly, NO endless rise | + | Jump while running 2-3× | arc preserves forward motion, lands cleanly | + | Turn quickly while running | heading tracks smoothly (not stuck at login direction) | + +- [ ] **Step 4.4: User signs off OR files a regression** + - If smooth + jumps land + turning works → proceed to Tasks 5+6. + - If anything regresses → describe the symptom; parent dispatches a fix subagent or unsets the env-var for instant rollback. + +--- + +## Task 5 — Cleanup commit (parallel with Task 6) + +**Owner:** Sonnet subagent (general-purpose). Independent of Task 6. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (delete env-var dual paths + soft-snap fields) + +**Subagent dispatch prompt:** + +> You are implementing Task 5 of Phase L.3.1+L.3.2: cleanup. The user has visually verified that `ACDREAM_INTERP_MANAGER=1` works correctly. Now collapse the dual-path scaffolding. +> +> **Repo:** `C:/Users/erikn/source/repos/acdream` — main — direct-to-main per CLAUDE.md. +> +> **What to do in `src/AcDream.App/Rendering/GameWindow.cs`:** +> +> 1. **In `OnLivePositionUpdated`**: delete the `if (Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { ... return; }` wrapper. Keep ONLY the new logic inside it. Delete the legacy hard-snap path that came after. +> +> 2. **In `TickAnimations` (per-frame remote tick)**: delete the `if/else` env-var gate. Keep ONLY the new path (`PositionManager.ComputeOffset` + `UpdatePhysicsInternal`). Delete the legacy `apply_current_movement` + `force-OnWalkable` + Euler-extrapolate code in the `else` branch. +> +> 3. **In the `RemoteMotion` class** (~line 224): delete `SnapResidualDecayRate` and any soft-snap residual fields. Search for `_snapResidual`, `SnapResidualDecayRate`, `SoftSnap`. Also delete any related code in the call sites. +> +> 4. **Search for any remaining `ACDREAM_INTERP_MANAGER` references** in the codebase and confirm zero remain: +> ```bash +> grep -rn "ACDREAM_INTERP_MANAGER" "C:/Users/erikn/source/repos/acdream/src/" 2>&1 +> ``` +> Expected: no output. +> +> **Build + test:** +> ```bash +> cd C:/Users/erikn/source/repos/acdream +> dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +> dotnet test --no-build --nologo 2>&1 | tail -6 +> ``` +> Expected: 0 build errors. Same 4 pre-existing failures, no new ones. +> +> **Commit:** +> ```bash +> git add src/AcDream.App/Rendering/GameWindow.cs +> git commit -m "$(cat <<'EOF' +> chore(motion): remove ACDREAM_INTERP_MANAGER flag + dead legacy paths (L.3.1+L.3.2 cleanup) +> +> User has visually verified the new PositionManager + IsGrounded +> routing path works correctly. Collapses the env-var dual-path: +> deletes legacy hard-snap + apply_current_movement + Euler-extrapolate +> code from OnLivePositionUpdated and the per-frame remote tick. +> Deletes SnapResidualDecayRate + soft-snap residual fields from +> RemoteMotion. +> +> Single retail-faithful path remains. ~80 lines net deletion. +> +> Co-Authored-By: Claude Opus 4.7 +> EOF +> )" +> ``` +> +> **Report:** +> - Status, line counts deleted, files touched, test results. + +**Steps for the parent:** + +- [ ] **Step 5.1: Dispatch the cleanup subagent in parallel with Task 6** (one message, two Agent tool calls). +- [ ] **Step 5.2: Verify the commit landed** + ```bash + cd C:/Users/erikn/source/repos/acdream && git log -1 --stat + ``` +- [ ] **Step 5.3: Confirm zero env-var references remain** + ```bash + grep -rn "ACDREAM_INTERP_MANAGER" "C:/Users/erikn/source/repos/acdream/src/" 2>&1 + ``` + Expected: no output. +- [ ] **Step 5.4: Re-run all tests in parent** + ```bash + cd C:/Users/erikn/source/repos/acdream && dotnet test --no-build --nologo 2>&1 | tail -6 + ``` + Expected: same baseline. + +--- + +## Task 6 — Roadmap + spec status update (parallel with Task 5) + +**Owner:** Parent. Mechanical doc updates. + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` (Phase L.3 entry — mark L.3.1+L.3.2 SHIPPED) +- Modify: `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md` (add SHIPPED status banner) + +**Steps:** + +- [ ] **Step 6.1: Find the Phase L.3 entry in the roadmap** + ```bash + grep -n "Phase L.3\|L.3.1\|L.3.2\|L.3.3" "C:/Users/erikn/source/repos/acdream/docs/plans/2026-04-11-roadmap.md" + ``` + If the roadmap doesn't yet have a Phase L.3 entry, add one between L.2 and M with the L.3.1+L.3.2 combined status = SHIPPED, L.3.3 status = PLANNED. + +- [ ] **Step 6.2: Update the spec doc's status** + + In `docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md`, near the top (after the title / methodology), add or update a status line: + + ```markdown + **Status:** L.3.1+L.3.2 SHIPPED 2026-05-02. L.3.3 PLANNED. + ``` + +- [ ] **Step 6.3: Commit (combined doc update)** + ```bash + git add docs/plans/2026-04-11-roadmap.md docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md + git commit -m "$(cat <<'EOF' + docs(roadmap+spec): Phase L.3.1+L.3.2 shipped (L.3.3 pending) + + Roadmap Phase L.3 entry updated. Spec status banner reflects the + combined L.3.1+L.3.2 deliverable as shipped after visual verification. + L.3.3 (MoveToManager) remains a separate sub-lane to be specced and + scheduled. + + Co-Authored-By: Claude Opus 4.7 + EOF + )" + ``` + +--- + +## Verification Plan + +End-to-end smoke test after Task 6: + +```bash +cd C:/Users/erikn/source/repos/acdream +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo # green +dotnet test --no-build --nologo # 4 pre-existing failures only +git log --oneline -10 # see commits in order +grep -rn "ACDREAM_INTERP_MANAGER" src/ # zero hits (cleanup confirmed) +grep -rn "SnapResidualDecayRate" src/ # zero hits (deleted) +``` + +User can re-run the visual test matrix WITHOUT setting `ACDREAM_INTERP_MANAGER` (default behavior is now the new path) and confirm parity. + +If everything's green → Phase L.3.1+L.3.2 done; brainstorm L.3.3 (MoveToManager) as the next sub-lane. + +--- + +## Self-Review Notes + +- **Spec coverage:** every section of the spec maps to a task here. PositionManager → Task 1; IsGrounded plumbing → Task 2; per-frame tick rewrite + RemoteMotion field + OnLivePositionUpdated rewrite → Task 3; cleanup → Task 5; doc updates → Task 6. +- **Already-shipped commits NOT rebuilt.** L.3.1's first 6 commits (f43f168 → e08accf) already provide InterpolationManager + GetMaxSpeed + Interp field + v1 routing + v1 tick + Omega. +- **Reverted commits** (5154a3e + f199a6a) were band-aids; their replacements live in Task 3. +- **Subagent failure handling:** if a subagent reports BLOCKED on Task 3 (the largest), break it into smaller pieces (3a: RemoteMotion field; 3b: OnLivePositionUpdated rewrite; 3c: TickAnimations rewrite) and dispatch sequentially. Don't let a confused subagent leave broken code in main. +- **Task 4's visual verification is the gate.** Tasks 5+6 only fire after user sign-off. If visual fails, dispatch a fix subagent before Tasks 5+6. From 08fbbef3c40166499b2a5f7b8be3a27fd6060d6d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:13:02 +0200 Subject: [PATCH 16/32] feat(physics): PositionManager combiner class + 6 unit tests (L.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed) → Vector3. Combines animation root motion (seqVel × dt rotated by body orientation) with InterpolationManager.AdjustOffset world-space correction. Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730). Composed into RemoteMotion in subsequent task (L.3.1+L.3.2 Task 3); not yet consumed. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.Core/Physics/PositionManager.cs | 55 ++++++ .../Physics/PositionManagerTests.cs | 179 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/AcDream.Core/Physics/PositionManager.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs new file mode 100644 index 00000000..aa352abd --- /dev/null +++ b/src/AcDream.Core/Physics/PositionManager.cs @@ -0,0 +1,55 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Per-frame combiner for remote-entity motion: animation root motion +/// + InterpolationManager catch-up correction. Pure function — no +/// side effects, no hidden state. +/// +/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730): +/// rootOffset = CPartArray::Update(dt) // animation +/// PositionManager::adjust_offset(rootOffset) // adds correction +/// frame.origin += rootOffset +/// +/// In acdream the animation root motion is sourced from +/// AnimationSequencer.CurrentVelocity (body-local velocity from the +/// active locomotion cycle). We rotate that by the body's orientation +/// to get a world-space delta, then add the InterpolationManager's +/// world-space correction. +/// +public sealed class PositionManager +{ + /// + /// Compute the per-frame world-space delta to add to body.Position. + /// + /// Per-frame delta time, seconds. + /// Body's current world-space position. + /// + /// Body-local velocity from the active animation cycle + /// (from AnimationSequencer.CurrentVelocity); pass + /// Vector3.Zero if the entity has no sequencer or is on a + /// non-locomotion cycle. + /// + /// Body orientation; used to rotate seqVel from body-local to world. + /// The remote's InterpolationManager (for AdjustOffset call). + /// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp. + public Vector3 ComputeOffset( + double dt, + Vector3 currentBodyPosition, + Vector3 seqVel, + Quaternion ori, + InterpolationManager interp, + float maxSpeed) + { + // Step 1: animation root motion (body-local → world). + Vector3 rootMotionLocal = seqVel * (float)dt; + Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); + + // Step 2: interpolation correction (world-space already). + Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); + + // Step 3: combined delta. + return rootMotionWorld + correction; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs new file mode 100644 index 00000000..7839bc60 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// PositionManagerTests — 6 tests covering ComputeOffset. +// +// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730). +// Pure-function combiner: animation root motion (seqVel × dt, rotated by +// body orientation) + InterpolationManager.AdjustOffset correction. +// ───────────────────────────────────────────────────────────────────────────── + +public sealed class PositionManagerTests +{ + // ── helpers ─────────────────────────────────────────────────────────────── + + private static PositionManager Make() => new(); + + private static InterpolationManager EmptyInterp() => new(); + + // ========================================================================= + // Test 1: stationary remote — both sources zero, no motion + // ========================================================================= + + [Fact] + public void ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion() + { + var pm = Make(); + var interp = EmptyInterp(); + + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: Vector3.Zero, + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 4f); + + Assert.Equal(Vector3.Zero, offset); + } + + // ========================================================================= + // Test 2: animation only, identity orientation, forward velocity + // ========================================================================= + + [Fact] + public void ComputeOffset_AnimationOnly_Forward_BodyAdvances() + { + var pm = Make(); + var interp = EmptyInterp(); + + // seqVel = (0, 4, 0), dt = 0.1 → rootMotion = (0, 0.4, 0) + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 0f); + + Assert.Equal(0f, offset.X, precision: 4); + Assert.Equal(0.4f, offset.Y, precision: 4); + Assert.Equal(0f, offset.Z, precision: 4); + } + + // ========================================================================= + // Test 3: animation only, 180° yaw around Z — body moves south (-Y) + // ========================================================================= + + [Fact] + public void ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth() + { + var pm = Make(); + var interp = EmptyInterp(); + + // 180° around Z flips +Y → -Y + Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI); + + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: ori, + interp: interp, + maxSpeed: 0f); + + Assert.Equal(0f, offset.X, precision: 4); + Assert.Equal(-0.4f, offset.Y, precision: 4); + } + + // ========================================================================= + // Test 4: interp only, no animation — body chases queue + // ========================================================================= + + [Fact] + public void ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue() + { + var pm = Make(); + var interp = new InterpolationManager(); + + // Enqueue target 1m ahead on +X; body starts at origin + interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false); + + // Expected catch-up: catchUpSpeed = maxSpeed × 2 = 4 × 2 = 8 m/s + // step = 8 × 0.1 = 0.8m (< dist = 1m so no overshoot clamp) + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: Vector3.Zero, + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 4f); + + Assert.Equal(0.8f, offset.X, precision: 3); + Assert.Equal(0f, offset.Y, precision: 3); + Assert.Equal(0f, offset.Z, precision: 3); + } + + // ========================================================================= + // Test 5: both sources active — combined delta + // ========================================================================= + + [Fact] + public void ComputeOffset_BothActive_Combined() + { + var pm = Make(); + var interp = new InterpolationManager(); + + // Enqueue target 1m ahead on +X + interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false); + + // rootMotion = (0, 4, 0) × 0.1 = (0, 0.4, 0) + // correction ≈ (0.8, 0, 0) + // combined ≈ (0.8, 0.4, 0) + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 4f); + + Assert.Equal(0.8f, offset.X, precision: 3); + Assert.Equal(0.4f, offset.Y, precision: 3); + Assert.Equal(0f, offset.Z, precision: 3); + } + + // ========================================================================= + // Test 6: local-to-world rotation — +90° yaw around Z + // ========================================================================= + + [Fact] + public void ComputeOffset_LocalToWorldRotation_Yaw90() + { + var pm = Make(); + var interp = EmptyInterp(); + + // +90° CCW around Z in right-handed coordinates: + // body-local +Y → world -X + Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); + + // seqVel = (0, 1, 0), dt = 1 → rootMotionLocal = (0, 1, 0) + // after Transform by ori → (-1, 0, 0) approximately + Vector3 offset = pm.ComputeOffset( + dt: 1.0, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 1f, 0f), + ori: ori, + interp: interp, + maxSpeed: 0f); + + Assert.Equal(-1f, offset.X, precision: 4); + Assert.Equal(0f, offset.Y, precision: 4); + Assert.Equal(0f, offset.Z, precision: 4); + } +} From 5d717312cc055dc49e4b7f3c3f292fbad2c54133 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:15:02 +0200 Subject: [PATCH 17/32] feat(net): plumb IsGrounded through EntityPositionUpdate (L.3.2 Task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PositionFlags.IsGrounded (0x04) was already parsed by UpdatePosition but not exposed through the Parsed record or EntityPositionUpdate. Adds the bool field to both records so OnLivePositionUpdated can consume it for retail-faithful MoveOrTeleport routing (acclient @ 0x00516330: has_contact=false → no-op during airborne arc). Consumed in subsequent task (L.3.1+L.3.2 Task 3). Co-Authored-By: Claude Opus 4.7 --- src/AcDream.Core.Net/Messages/UpdatePosition.cs | 2 ++ src/AcDream.Core.Net/WorldSession.cs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/AcDream.Core.Net/Messages/UpdatePosition.cs b/src/AcDream.Core.Net/Messages/UpdatePosition.cs index cccfe4ca..e4978cf7 100644 --- a/src/AcDream.Core.Net/Messages/UpdatePosition.cs +++ b/src/AcDream.Core.Net/Messages/UpdatePosition.cs @@ -64,6 +64,7 @@ public static class UpdatePosition CreateObject.ServerPosition Position, System.Numerics.Vector3? Velocity, uint? PlacementId, + bool IsGrounded, ushort InstanceSequence = 0, ushort TeleportSequence = 0, ushort ForcePositionSequence = 0); @@ -164,6 +165,7 @@ public static class UpdatePosition RotationW: rw, RotationX: rx, RotationY: ry, RotationZ: rz); return new Parsed(guid, serverPos, velocity, placementId, + IsGrounded: (flags & PositionFlags.IsGrounded) != 0, instSeq, teleSeq, forceSeq); } catch diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 47ef10bc..3e76509c 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -110,7 +110,8 @@ public sealed class WorldSession : IDisposable public readonly record struct EntityPositionUpdate( uint Guid, CreateObject.ServerPosition Position, - System.Numerics.Vector3? Velocity); + System.Numerics.Vector3? Velocity, + bool IsGrounded); /// /// Fires when the session parses a 0xF748 UpdatePosition game message. @@ -711,7 +712,8 @@ public sealed class WorldSession : IDisposable PositionUpdated?.Invoke(new EntityPositionUpdate( posUpdate.Value.Guid, posUpdate.Value.Position, - posUpdate.Value.Velocity)); + posUpdate.Value.Velocity, + posUpdate.Value.IsGrounded)); } } else if (op == VectorUpdate.Opcode) From e94e7913fb60ba4c2461570420852dca10827576 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:18:24 +0200 Subject: [PATCH 18/32] feat(motion): retail-faithful per-frame remote tick (L.3.1+L.3.2 Task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines PositionManager (Task 1, commit 08fbbef) + IsGrounded plumbing (Task 2, commit 5d71731) into the per-frame remote motion path. Three changes in GameWindow.cs, all gated behind ACDREAM_INTERP_MANAGER=1: 1. RemoteMotion gains Position field (PositionManager instance). 2. OnLivePositionUpdated env-var branch rewritten to mirror retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): - orientation snap-on-receipt (PositionManager handles position only) - airborne (!IsGrounded) → no-op (server is authoritative for arc; body.Velocity from VectorUpdate integrates gravity locally) - landing transition (first IsGrounded=true after Airborne) → clear airborne flags, hard-snap to landing pos, clear queue - grounded routing: dist > 96m → slide-snap; dist ≤ 96m → enqueue 3. TickAnimations env-var branch rewritten to use PositionManager: body.Position += PositionManager.ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed); body.UpdatePhysicsInternal(dt) for gravity. Replaces the L.3.1-only AdjustOffset-only path. Legacy (env-var off) path unchanged. Cleanup commit (next sub-task) deletes the env-var dual paths after visual verification. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 117 ++++++++++++------------ 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0866e847..59a2a28b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -342,6 +342,14 @@ public sealed class GameWindow : IDisposable public AcDream.Core.Physics.InterpolationManager Interp { get; } = new AcDream.Core.Physics.InterpolationManager(); + /// + /// Per-frame combiner for animation root motion + InterpolationManager + /// correction (Phase L.3.2). Consumed in TickAnimations to compute the + /// per-frame body.Position delta. + /// + public AcDream.Core.Physics.PositionManager Position { get; } = + new AcDream.Core.Physics.PositionManager(); + public RemoteMotion() { Body = new AcDream.Core.Physics.PhysicsBody @@ -3254,46 +3262,55 @@ public sealed class GameWindow : IDisposable // identical to before this commit. Legacy hard-snap path remains below. if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { - // CPhysicsObj::MoveOrTeleport router (acclient @ 0x00516330): - // - stale instance/position seq → ignore (TODO: IsStaleSequence not yet plumbed) - // - teleport-seq newer or no-cell → SetPosition (hard-snap) - // - has_contact false → no-op (TODO: HasContact not on wire — default true for L.3.1) - // - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue) - // - has_contact && distance > 96 → SetPositionSimple (slide-snap) + // Orientation always snaps on receipt — InterpolationManager walks + // position only; heading would otherwise lag the queue. + rmState.Body.Orientation = rot; + // ── AIRBORNE NO-OP ──────────────────────────────────────────── + // Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): + // when has_contact==0, return false (don't touch body, don't queue). + // body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps + // integrating gravity via per-frame UpdatePhysicsInternal. Server is + // authoritative for the arc; we don't predict it locally. + if (!update.IsGrounded) + return; + + // ── LANDING TRANSITION ──────────────────────────────────────── + // First IsGrounded=true UP after rmState.Airborne signals landed. + // Clear airborne flags, hard-snap to authoritative landing position, + // clear interpolation queue (any pre-jump waypoints are stale). + if (rmState.Airborne) + { + rmState.Airborne = false; + rmState.Body.Velocity = System.Numerics.Vector3.Zero; + rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; + return; + } + + // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── const float MaxPhysicsDistance = 96f; - System.Numerics.Vector3 localPlayerPos = - _playerController?.Position ?? System.Numerics.Vector3.Zero; + var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); - // Default-false: teleport flag not plumbed until sequence comparison lands (Task 5+). - bool teleportFlag = false; - // Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap). - // bool hasContact = true; (implicit — only the teleport and distance branches below) - - if (teleportFlag) + if (dist > MaxPhysicsDistance) { - // SetPosition equivalent: hard-snap position + orientation, clear interp queue. - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - rmState.Interp.Clear(); - } - else if (dist > MaxPhysicsDistance) - { - // SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap). + // Beyond view bubble: SetPositionSimple slide-snap. Clear queue. rmState.Interp.Clear(); rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; } else { - // InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to. - // NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it. + // Within view bubble: enqueue waypoint for adjust_offset to walk to. + // PositionManager (called per-frame in TickAnimations) handles the + // actual body advancement — mix of animation root motion + queue + // correction. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); } - - // Skip the legacy hard-snap path below. return; } @@ -5771,36 +5788,24 @@ public sealed class GameWindow : IDisposable { if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { - // ── NEW PATH: queued position-chase via InterpolationManager ── - // (L.3.1 Task 5 — ACDREAM_INTERP_MANAGER=1 gates this path) + // ── NEW PATH: PositionManager (animation root motion + InterpolationManager) ── + // (L.3.1+L.3.2 Task 3 — ACDREAM_INTERP_MANAGER=1 gates this path) // - // Walking remotes have m_velocityVector == 0 in retail; all - // visible horizontal motion comes from - // InterpolationManager::adjust_offset (acclient @ 0x00555D30) - // walking the body toward the head of the waypoint queue at - // 2 × motion_max_speed × dt (clamped, 7.5 m/s fallback). - // - // Mirrors retail CPhysicsObj::UpdateObjectInternal - // (acclient @ 0x00513730) which calls adjust_offset every frame - // before UpdatePhysicsInternal integrates gravity. - // - // For airborne remotes, OnLiveVectorUpdated has set - // body.Velocity (launch arc); we still call - // UpdatePhysicsInternal below so gravity applies each frame and - // produces the parabolic arc. The IsActive gate prevents - // AdjustOffset from pulling against an in-flight arc when no - // waypoints are queued for a jumping remote. - if (rm.Interp.IsActive) - { - float maxSpeed = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3 delta = rm.Interp.AdjustOffset((double)dt, rm.Body.Position, maxSpeed); - rm.Body.Position += delta; - } - - // Gravity integration: retail's UpdatePhysicsInternal still - // fires every frame regardless of the interpolation path. - // For grounded remotes body.Velocity == 0 so this is a no-op; - // for airborne remotes it applies gravity to the arc. + // Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal + // (acclient @ 0x00513730): + // 1+2. animation root motion + interpolation correction (combined) + // 3. physics integration (gravity for airborne; no-op for grounded) + System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; + float maxSpeed = rm.Motion.GetMaxSpeed(); + System.Numerics.Vector3 offset = rm.Position.ComputeOffset( + dt: (double)dt, + currentBodyPosition: rm.Body.Position, + seqVel: seqVel, + ori: rm.Body.Orientation, + interp: rm.Interp, + maxSpeed: maxSpeed); + rm.Body.Position += offset; rm.Body.UpdatePhysicsInternal(dt); ae.Entity.Position = rm.Body.Position; From c1bfd64834200bf3a354fe8233728a37878dccda Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:32:42 +0200 Subject: [PATCH 19/32] fix(motion): port calc_acceleration + sequencer omega to retail tick (L.3.1+L.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual verification (Task 4) revealed two missing pieces from the retail per-frame tick port (acclient!CPhysicsObj::update_object @ 0x00513730): 1. body.calc_acceleration() must run BEFORE UpdatePhysicsInternal so gravity (set via PhysicsStateFlags.Gravity in OnLiveVectorUpdated) actually decays jump velocity. Without it body.Acceleration stays stale or zero → endless rise on jumps. 2. sequencer.CurrentOmega must be applied to body.Orientation per frame. Retail's TurnRight/TurnLeft cycles bake angular velocity that drives smooth rotation between UPs; we were only snapping orientation on UP receipt (~1 Hz), producing visible chop on turning remotes. Both fixes are part of the retail tick we already started porting in PositionManager — just missing pieces. Speed-overshoot bug (sequencer.CurrentVelocity > server's actual broadcast pace) is still being investigated in a follow-up. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 35 +++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 59a2a28b..d87df04d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5789,14 +5789,23 @@ public sealed class GameWindow : IDisposable if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { // ── NEW PATH: PositionManager (animation root motion + InterpolationManager) ── - // (L.3.1+L.3.2 Task 3 — ACDREAM_INTERP_MANAGER=1 gates this path) + // (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path) // // Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal // (acclient @ 0x00513730): // 1+2. animation root motion + interpolation correction (combined) - // 3. physics integration (gravity for airborne; no-op for grounded) + // 2.5 sequencer omega → body orientation (TurnRight/TurnLeft angular velocity) + // 3. calc_acceleration (gravity flag → body.Acceleration) + // 4. physics integration (gravity for airborne; no-op for grounded) + + // Sequencer-driven motion sources: linear velocity (root motion) + // AND angular velocity (turn-cycle omega). System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity ?? System.Numerics.Vector3.Zero; + System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega + ?? System.Numerics.Vector3.Zero; + + // Step 1+2: animation root motion + Interp correction (combined via PositionManager). float maxSpeed = rm.Motion.GetMaxSpeed(); System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, @@ -5806,6 +5815,28 @@ public sealed class GameWindow : IDisposable interp: rm.Interp, maxSpeed: maxSpeed); rm.Body.Position += offset; + + // Step 2.5: animation-driven rotation. Retail's sequencer bakes Omega + // for TurnRight/TurnLeft cycles; we apply it as a per-frame quaternion + // rotation. seqOmega is body-local angular velocity (axis-angle: axis + // is omega.Normalized, magnitude is rad/sec). For Z-axis turns it's + // (0, 0, ±π/2 × turnSpeed) typically. + if (seqOmega.LengthSquared() > 1e-9f) + { + float angleDelta = seqOmega.Length() * (float)dt; + System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(seqOmega); + var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta); + rm.Body.Orientation = System.Numerics.Quaternion.Normalize( + System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot)); + } + + // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag + // (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called + // per-frame in update_object). Without this, body.Acceleration stays stale + // or zero → gravity never decays jump velocity → endless rise on jumps. + rm.Body.calc_acceleration(); + + // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); ae.Entity.Position = rm.Body.Position; From 0997f9607824a149a55c05136a0e28228d389bdc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:48:10 +0200 Subject: [PATCH 20/32] fix(motion): landing fallback + TurnLeft omega sign + vel diagnostic (L.3.2) Three Option-A patches addressing visual issues from the L.3.1+L.3.2 remote-entity motion path (gated by ACDREAM_INTERP_MANAGER=1): 1. Landing fallback. ACE doesn't always send IsGrounded=true on the landing frame, so airborne remotes kept falling under gravity and visually "disappeared into the ground" until the next non-stop UP forced a re-snap. Track the most recent server-broadcast Z on every UP (including mid-arc airborne ones) and, in TickAnimations, snap the body back up + clear airborne when its predicted Z drops more than 0.5 m below that floor. 2. TurnLeft omega sign. The synthesize-omega fallback in AnimationSequencer (used when MotionData ships without HasOmega) had case 0x0E using zomega = +(pi/2) * adjustedSpeed, but adjust_motion above already remapped 0x0E to 0x0D with adjustedSpeed = -speedMod. The double-negate produced -Z (clockwise = right) for both turn directions, matching the reported "turning left animates as turning right". Use the same -(pi/2) * adjustedSpeed formula as case 0x0D so the negation lands the result on +Z (CCW). 3. Velocity diagnostic. New env var ACDREAM_REMOTE_VEL_DIAG=1 prints one line per moving remote per ~2 seconds comparing the sequencer's CurrentVelocity to the server's effective broadcast pace ((LastServerPos - PrevServerPos) / dt). Lets us measure the speed-overshoot ratio that produces the residual 1-Hz blippiness before tuning a fix. Refs Phase L.3.1+L.3.2 spec at docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 105 ++++++++++++++++++ .../Physics/AnimationSequencer.cs | 13 ++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d87df04d..6d0c297b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -350,6 +350,33 @@ public sealed class GameWindow : IDisposable public AcDream.Core.Physics.PositionManager Position { get; } = new AcDream.Core.Physics.PositionManager(); + /// + /// Most recent server-broadcast Z coordinate from any UpdatePosition + /// (including mid-arc airborne UPs). Used by the + /// ACDREAM_INTERP_MANAGER=1 per-tick path as a landing-fallback + /// floor: if gravity drags the body's Z below this value while + /// is still set, force-land locally because + /// the server has effectively told us where the ground is even if + /// it never sent an IsGrounded=true UP. Initialized to NaN so the + /// fallback is a no-op until the first UP arrives. + /// + public float LastServerZ = float.NaN; + + /// + /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the + /// previous UpdatePosition's world position + timestamp. The per-tick + /// path computes (serverPos - prevServerPos) / dt and compares + /// it to the sequencer's CurrentVelocity. The ratio tells us + /// whether the local-prediction speed (animation root motion) is + /// outrunning the server's actual broadcast pace, which would cause + /// the InterpolationManager queue to walk back the body each UP and + /// produce visible 1-Hz blips. Read in TickAnimations and throttled + /// to one log line per remote per ~2 seconds. + /// + public System.Numerics.Vector3 PrevServerPos; + public double PrevServerPosTime; + public double LastVelDiagLogTime; + public RemoteMotion() { Body = new AcDream.Core.Physics.PhysicsBody @@ -3266,6 +3293,25 @@ public sealed class GameWindow : IDisposable // position only; heading would otherwise lag the queue. rmState.Body.Orientation = rot; + // Track the most recent server-broadcast Z on EVERY UP — including + // mid-arc airborne ones. Read by the per-tick landing-fallback in + // TickAnimations: if gravity drags the body below this floor while + // still airborne, we force-land locally even when the server never + // sent an IsGrounded=true UP for the actual landing frame. + rmState.LastServerZ = worldPos.Z; + + // Diagnostic-only (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous + // server-pos snapshot forward so the per-tick comparison has a + // delta to work with. Cheap (struct copy + double write); not + // gated here because the read side gates the actual print. + { + double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + rmState.PrevServerPos = rmState.LastServerPos; + rmState.PrevServerPosTime = rmState.LastServerPosTime; + rmState.LastServerPos = worldPos; + rmState.LastServerPosTime = nowSecDiag; + } + // ── AIRBORNE NO-OP ──────────────────────────────────────────── // Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): // when has_contact==0, return false (don't touch body, don't queue). @@ -5839,6 +5885,65 @@ public sealed class GameWindow : IDisposable // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); + // Step 5: landing fallback. The retail-faithful path leaves + // the landing transition to OnLivePositionUpdated when ACE + // sends IsGrounded=true. In practice ACE doesn't always + // broadcast that flag promptly — the body keeps falling + // under gravity and visibly disappears into the ground until + // the next non-stop UP arrives (e.g. when the player turns). + // The remote's most recent server-reported Z is an + // authoritative ground floor: if our predicted body has + // sunk below it by more than half a meter, snap up to it + // and clear airborne, mirroring the OnLivePositionUpdated + // landing-transition branch. Threshold matches retail's + // MIN_DISTANCE_TO_REACH_POSITION-style tolerance. + if (rm.Airborne + && !float.IsNaN(rm.LastServerZ) + && rm.Body.Position.Z < rm.LastServerZ - 0.5f) + { + rm.Airborne = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Interp.Clear(); + rm.Body.Position = new System.Numerics.Vector3( + rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ); + } + + // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). + // Compare the sequencer's body-local CurrentVelocity (root motion + // we're applying per tick) against the server's effective + // broadcast pace ((LastServerPos - PrevServerPos) / Δt). If + // |seqVel| significantly exceeds |serverVel|, the body + // overshoots between UPs and the InterpolationManager has to + // walk it backward each waypoint — visible as 1-Hz blips. + // The ratio prints once per remote per ~2 seconds so a moving + // remote shows up without flooding the console. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && rm.PrevServerPosTime > 0.0 + && rm.LastServerPosTime > rm.PrevServerPosTime) + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - rm.LastVelDiagLogTime > 2.0) + { + double dtServer = rm.LastServerPosTime - rm.PrevServerPosTime; + var serverDelta = rm.LastServerPos - rm.PrevServerPos; + float serverSpeed = (float)(serverDelta.Length() / dtServer); + float seqSpeed = seqVel.Length(); + // Only log when the entity is actually moving — skip + // idle remotes where both speeds are ~0. + if (serverSpeed > 0.1f || seqSpeed > 0.1f) + { + System.Console.WriteLine( + $"[VEL_DIAG] guid={serverGuid:X8} seqSpeed={seqSpeed:F3} m/s " + + $"serverSpeed={serverSpeed:F3} m/s " + + $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}"); + rm.LastVelDiagLogTime = nowSec; + } + } + } + ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation; } diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index a6d57ba9..d270f359 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -567,10 +567,15 @@ public sealed class AnimationSequencer case 0x0D: // TurnRight — clockwise from above = -Z in right-handed. zomega = -(MathF.PI / 2f) * adjustedSpeed; break; - case 0x0E: // TurnLeft — counter-clockwise = +Z. adjust_motion - // may have remapped 0x0E → 0x0D with negated speed; - // in that case the negation preserves correct sign. - zomega = (MathF.PI / 2f) * adjustedSpeed; + case 0x0E: // TurnLeft — counter-clockwise = +Z. + // adjust_motion above ALREADY remapped 0x0E → 0x0D + // with adjustedSpeed = -speedMod, so the same + // formula as 0x0D applied to the negated speed + // produces the correct +Z (CCW) result. Using a + // different sign here would double-negate and + // animate a left turn as a right turn — that was + // the bug observed before this fix (commit follows). + zomega = -(MathF.PI / 2f) * adjustedSpeed; break; } if (zomega != 0f) From 9960ce3bce1eaf961b8e127a37b586d6aa739650 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 13:01:43 +0200 Subject: [PATCH 21/32] fix(motion): preserve signed TurnSpeed for remote turn animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-arrival animCycle picker in OnLiveMotionUpdated was passing MathF.Abs(turnSpeed) to the sequencer, stripping the sign that ACE uses to encode TurnLeft. Confirmed via live wire trace 2026-05-03: TurnLeft input from a retail-driven character arrives as turnCmd16=0x000D (TurnRight), TurnSpeed=-1.500 — mirroring retail's adjust_motion convention on the wire. With Abs, both directions collapsed onto motion=TurnRight + speedMod=+1.5, and the synthesize- omega path computed -2.25 (CW = right) for both. Visible symptom: TurnLeft animated as TurnRight then blipped to the correct facing on the next UpdatePosition. Pass the signed speed through unchanged. The sequencer's negative- speed path (EnqueueMotionData multiplies MotionData.Omega by speedMod; the synthesize-omega fallback uses -(pi/2)*adjustedSpeed) produces the correct CCW omega for TurnLeft now that the sign survives. Also adds a TURN_WIRE diagnostic gated on ACDREAM_REMOTE_VEL_DIAG=1 that prints every wire-arrived TurnCommand with reconstructed enum and signed speed, plus splits the OMEGA_DIAG throttle off LastVelDiagLogTime onto its own LastOmegaDiagLogTime so the two diagnostics don't starve each other. Verified with the same trace: TURN_WIRE speed=-1.500 -> OMEGA_DIAG Z=+2.250 (CCW = TurnLeft), TURN_WIRE speed=+1.500 -> OMEGA_DIAG Z=-2.250 (CW = TurnRight). Both directions now have correct sign. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 38 ++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6d0c297b..bc6e49f1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -376,6 +376,7 @@ public sealed class GameWindow : IDisposable public System.Numerics.Vector3 PrevServerPos; public double PrevServerPosTime; public double LastVelDiagLogTime; + public double LastOmegaDiagLogTime; public RemoteMotion() { @@ -2862,7 +2863,17 @@ public sealed class GameWindow : IDisposable .ReconstructFullCommand(turnForAnim); if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim; animCycle = turnFullForAnim; - animSpeed = MathF.Abs(update.MotionState.TurnSpeed ?? 1f); + // SIGNED — do NOT MathF.Abs. ACE encodes TurnLeft on the + // wire as (TurnCommand=TurnRight, TurnSpeed=NEGATIVE), + // mirroring retail's adjust_motion convention. The + // sequencer's negative-speed path (EnqueueMotionData + // multiplies MotionData.Omega by speedMod, the + // synthesize-omega fallback flips zomega via + // -(pi/2)*adjustedSpeed) only produces the correct + // CCW rotation when the sign is preserved here. + // Confirmed by live wire trace 2026-05-03: TurnLeft + // input arrives as turnCmd16=0x000D, speed=-1.500. + animSpeed = update.MotionState.TurnSpeed ?? 1f; } } // K-fix17 (2026-04-26): preserve the Falling cycle while @@ -2993,6 +3004,14 @@ public sealed class GameWindow : IDisposable .ReconstructFullCommand(turnCmd16); if (turnFull == 0) turnFull = 0x65000000u | turnCmd16; float turnSpd = update.MotionState.TurnSpeed ?? 1f; + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} " + + $"turnFull=0x{turnFull:X8} low=0x{turnFull & 0xFFu:X2} " + + $"({(((turnFull & 0xFFu) == 0x0D) ? "TurnRight" : ((turnFull & 0xFFu) == 0x0E) ? "TurnLeft" : "OTHER")}) " + + $"speed={turnSpd:F3}"); + } remoteMot.Motion.DoInterpretedMotion( turnFull, turnSpd, modifyInterpretedState: true); // Seed ObservedOmega with formula so rotation starts @@ -5874,6 +5893,23 @@ public sealed class GameWindow : IDisposable var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta); rm.Body.Orientation = System.Numerics.Quaternion.Normalize( System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot)); + + // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction + // once per remote per ~1 second so we can confirm whether the omega + // sign actually being applied matches the retail-observed turn + // direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - rm.LastOmegaDiagLogTime > 0.5) + { + uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; + System.Console.WriteLine( + $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} " + + $"seqOmega.Z={seqOmega.Z:F3} (Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)"); + rm.LastOmegaDiagLogTime = nowSec; + } + } } // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag From 842dfcd092c3cc5555bb05163c5f501204c484d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 15:24:24 +0200 Subject: [PATCH 22/32] fix(motion): retail-faithful per-frame remote tick (L.3.2 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-bug fix for the env-var-gated retail-faithful remote tick path (ACDREAM_INTERP_MANAGER=1). Combines four previously-stacked defects into one coherent rewrite: 1. PositionManager.ComputeOffset was additive (rootMotion + correction). Retail's PositionManager::adjust_offset (acclient @ 0x00555190 → InterpolationManager::adjust_offset @ 0x00555d30) REPLACES the offset frame via Frame::operator=(arg2, &__return) when catch-up engages — it does NOT add to the rootOffset that CPartArray::Update wrote. Switched to "correction overrides root motion" semantics. 2. MotionInterpreter.GetMaxSpeed was returning RunAnimSpeed × rate (~11.7 m/s for run skill 200). The retail decomp at acclient_2013_pseudo_c.txt:305127 shows get_max_speed returns the bare run rate (~2.94) — the function's float return rides the x87 FPU stack, which Binary Ninja shows as void. Caller multiplies by 2.0 to get the catch-up speed. With the wrong return our catch-up was 23.5 m/s instead of retail's 5.88 m/s — the queue would walk the body 4× too aggressively. 3. The env-var TickAnimations branch was DOUBLE-COUNTING forward translation: it applied seqVel × dt via PositionManager.ComputeOffset AND let UpdatePhysicsInternal advance body.Position += body.Velocity × dt. Both were ~11.7 m/s for run, so body raced at 23.4 m/s — "way too fast" per the user. Pass seqVel=Vector3.Zero to ComputeOffset; let body.Velocity (refreshed per tick by apply_current_movement) drive the bulk translation alone. 4. Body orientation only applied sequencer.CurrentOmega per tick. For the running-in-circles case ACE broadcasts ForwardCommand=RunForward AND TurnCommand=TurnLeft on the same UpdateMotion; the sequencer picks the RunForward cycle whose synthesized CurrentOmega is zero, so body never rotated between UPs and body.Velocity stayed in an out-of-date world direction — the visible "rectangle when running circles" effect. Prefer ObservedOmega (set explicitly in OnLiveMotionUpdated from the wire's TurnCommand + signed TurnSpeed) when present; fall back to seqOmega for standalone turn cycles. Also adds: - Sequencer-reset call in the env-var landing-fallback so the legs un-fold from Falling on land (mirrors the legacy K-fix17 path). - LastServerZ now only updates on IsGrounded UPs, so the per-tick landing-fallback floor doesn't drift up to the player's airborne peak Z and force-land mid-arc — fixes the user-reported "small landing in the air before landing on the ground" when jumping while moving. - VEL_DIAG now samples at UP arrival with overlapping windows, plus TURN_WIRE / OMEGA_DIAG / FWD_WIRE diagnostics gated on ACDREAM_REMOTE_VEL_DIAG=1 used to trace these bugs to ground truth. Verified via live retail-driven character observation 2026-05-03: turn-left now rotates left (was animating right with snap), running in circles is much smoother, jumping lands on ground (no mid-air pause). Residual ~20% steady-state overshoot for walk remains — WalkAnimSpeed=3.12 (decompiled retail constant) doesn't match ACE's actual broadcast walk pace (~2.6 m/s). Tracked separately. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 233 +++++++++++++----- src/AcDream.Core/Physics/MotionInterpreter.cs | 59 +++-- src/AcDream.Core/Physics/PositionManager.cs | 35 ++- 3 files changed, 228 insertions(+), 99 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bc6e49f1..3b76a52d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -375,8 +375,14 @@ public sealed class GameWindow : IDisposable /// public System.Numerics.Vector3 PrevServerPos; public double PrevServerPosTime; - public double LastVelDiagLogTime; public double LastOmegaDiagLogTime; + /// + /// Diagnostic-only: max |sequencer.CurrentVelocity| observed across + /// all per-tick samples since the last UpdatePosition arrival. The + /// next UP compares this against (LastServerPos - PrevServerPos) / + /// dtServer to compute the overshoot ratio. Reset on each UP. + /// + public float MaxSeqSpeedSinceLastUP; public RemoteMotion() { @@ -2782,6 +2788,15 @@ public sealed class GameWindow : IDisposable // to the Attack/Twitch/etc command, and // get_state_velocity returns 0 because the gate is // RunForward||WalkForward — body stops moving forward. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion) + { + System.Console.WriteLine( + $"[FWD_WIRE] guid={update.Guid:X8} " + + $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} " + + $"newCmd=0x{fullMotion:X8} " + + $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}"); + } remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; // Pass speedMod through verbatim — preserve sign so retail's // adjust_motion'd backward walk (cmd=WalkForward, spd<0) @@ -3312,19 +3327,46 @@ public sealed class GameWindow : IDisposable // position only; heading would otherwise lag the queue. rmState.Body.Orientation = rot; - // Track the most recent server-broadcast Z on EVERY UP — including - // mid-arc airborne ones. Read by the per-tick landing-fallback in - // TickAnimations: if gravity drags the body below this floor while - // still airborne, we force-land locally even when the server never + // Track the most recent GROUNDED server-broadcast Z. Read by + // the per-tick landing-fallback in TickAnimations: if gravity + // drags the body more than 0.5 m below this floor while still + // airborne, we force-land locally even when the server never // sent an IsGrounded=true UP for the actual landing frame. - rmState.LastServerZ = worldPos.Z; + // + // Only updated for grounded UPs — mid-arc airborne UPs would + // raise this value to the player's peak Z, then the body's + // descent would cross (peak - 0.5) and trigger a force-land + // mid-air, producing the user-reported "small landing in the + // air before landing on the ground" when jumping while moving. + if (update.IsGrounded) + rmState.LastServerZ = worldPos.Z; - // Diagnostic-only (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous - // server-pos snapshot forward so the per-tick comparison has a - // delta to work with. Cheap (struct copy + double write); not - // gated here because the read side gates the actual print. + // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous + // server-pos snapshot forward AND print the per-UP comparison + // between the max sequencer speed observed since last UP and + // the actual server broadcast pace. Both sides are now sampled + // over the same window so the ratio reflects real overshoot. { double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && rmState.LastServerPosTime > 0.0) + { + double dtServer = nowSecDiag - rmState.LastServerPosTime; + if (dtServer > 0.001) + { + var serverDelta = worldPos - rmState.LastServerPos; + float serverSpeed = (float)(serverDelta.Length() / dtServer); + float seqSpeed = rmState.MaxSeqSpeedSinceLastUP; + if (serverSpeed > 0.1f || seqSpeed > 0.1f) + { + System.Console.WriteLine( + $"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s " + + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s " + + $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}"); + } + } + } + rmState.MaxSeqSpeedSinceLastUP = 0f; rmState.PrevServerPos = rmState.LastServerPos; rmState.PrevServerPosTime = rmState.LastServerPosTime; rmState.LastServerPos = worldPos; @@ -3353,6 +3395,22 @@ public sealed class GameWindow : IDisposable | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Interp.Clear(); rmState.Body.Position = worldPos; + + // Reset the sequencer out of Falling — see matching block in + // TickAnimations Step 5 (env-var path) for rationale. + if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) + && aeForLand.Sequencer is not null) + { + uint style = aeForLand.Sequencer.CurrentStyle != 0 + ? aeForLand.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rmState.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rmState.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + aeForLand.Sequencer.SetCycle(style, landingCmd, landingSpeed); + } return; } @@ -5853,43 +5911,85 @@ public sealed class GameWindow : IDisposable { if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { - // ── NEW PATH: PositionManager (animation root motion + InterpolationManager) ── + // ── NEW PATH: retail-faithful per-frame remote tick ── // (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path) // - // Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal - // (acclient @ 0x00513730): - // 1+2. animation root motion + interpolation correction (combined) - // 2.5 sequencer omega → body orientation (TurnRight/TurnLeft angular velocity) - // 3. calc_acceleration (gravity flag → body.Acceleration) - // 4. physics integration (gravity for airborne; no-op for grounded) + // Mirrors retail CPhysicsObj::UpdateObjectInternal + // (acclient @ 0x005156b0) → UpdatePositionInternal (@ 0x00512c30): + // + // 1. Force grounded transient flags (matches the legacy path + // and the gate inside MotionInterpreter.apply_current_movement + // which only writes velocity when OnWalkable is set). + // 2. apply_current_movement → body.set_local_velocity(get_state_velocity()) + // Refreshes body.Velocity from the current InterpretedState + // every tick. Matches the legacy path that has been working + // for player remotes since pre-L.3. + // 3. PositionManager.ComputeOffset returns ONLY the + // InterpolationManager catch-up correction (with seqVel=0). + // Retail's CPartArray::Update writes a tiny per-anim-frame + // stride into the offset frame; PositionManager::adjust_offset + // either lets it through or REPLACES it with catch-up. Our + // AnimationSequencer.CurrentVelocity is the SYNTHESIZED + // RunAnimSpeed × speedMod (matches body.Velocity), NOT a + // per-anim-frame stride — passing it as root motion + // double-counts the bulk translation that body.Velocity + // already provides via UpdatePhysicsInternal. Pass zero + // so only the queue-correction reaches the body. + // 4. Apply correction to body.Position. + // 5. Sequencer omega → body orientation (turn cycles). + // 6. calc_acceleration + UpdatePhysicsInternal — Euler- + // integrates body.Position += body.Velocity × dt. - // Sequencer-driven motion sources: linear velocity (root motion) - // AND angular velocity (turn-cycle omega). - System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity - ?? System.Numerics.Vector3.Zero; System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; - // Step 1+2: animation root motion + Interp correction (combined via PositionManager). + // Step 1: grounded flags so apply_current_movement writes velocity. + if (!rm.Airborne) + { + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active; + + // Step 2: refresh body.Velocity from current motion state. + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } + else + { + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; + } + + // Step 3+4: queue catch-up correction only (no double-count of seqVel). float maxSpeed = rm.Motion.GetMaxSpeed(); System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, - seqVel: seqVel, + seqVel: System.Numerics.Vector3.Zero, ori: rm.Body.Orientation, interp: rm.Interp, maxSpeed: maxSpeed); rm.Body.Position += offset; - // Step 2.5: animation-driven rotation. Retail's sequencer bakes Omega - // for TurnRight/TurnLeft cycles; we apply it as a per-frame quaternion - // rotation. seqOmega is body-local angular velocity (axis-angle: axis - // is omega.Normalized, magnitude is rad/sec). For Z-axis turns it's - // (0, 0, ±π/2 × turnSpeed) typically. - if (seqOmega.LengthSquared() > 1e-9f) + // Step 2.5: angular velocity → body orientation. Prefer + // ObservedOmega (set explicitly in OnLiveMotionUpdated from + // the wire's TurnCommand + signed TurnSpeed) over the + // sequencer's synthesized omega: when the player runs in + // a circle ACE broadcasts ForwardCommand=RunForward AND + // TurnCommand=TurnLeft on the same UpdateMotion. The + // sequencer's animCycle picker chooses RunForward (legs + // running), whose synthesized CurrentOmega is zero. Body + // would not rotate between UPs and body.Velocity stays in + // an out-of-date world direction, producing the + // user-reported "rectangle when running circles" effect. + // ObservedOmega has the correct turn rate even when the + // visible cycle is RunForward. + System.Numerics.Vector3 omegaToApply = + rm.ObservedOmega.LengthSquared() > 1e-9f + ? rm.ObservedOmega + : seqOmega; + if (omegaToApply.LengthSquared() > 1e-9f) { - float angleDelta = seqOmega.Length() * (float)dt; - System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(seqOmega); + float angleDelta = omegaToApply.Length() * (float)dt; + System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply); var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta); rm.Body.Orientation = System.Numerics.Quaternion.Normalize( System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot)); @@ -5906,7 +6006,9 @@ public sealed class GameWindow : IDisposable uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; System.Console.WriteLine( $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} " - + $"seqOmega.Z={seqOmega.Z:F3} (Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)"); + + $"omegaApplied.Z={omegaToApply.Z:F3} " + + $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) " + + $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)"); rm.LastOmegaDiagLogTime = nowSec; } } @@ -5945,39 +6047,48 @@ public sealed class GameWindow : IDisposable rm.Interp.Clear(); rm.Body.Position = new System.Numerics.Vector3( rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ); + + // Swap the sequencer out of Falling — without this the + // legs stay folded in the airborne pose forever even + // though the body is now planted on the ground. Mirrors + // the legacy K-fix17 path at the bottom of TickAnimations + // (line ~6284): pick the cycle from the last-known + // InterpretedState.ForwardCommand, falling back to Ready + // when nothing is held. The next UpdateMotion the server + // sends will refine if the player was strafing/turning + // mid-jump; this just gets them out of Falling now. + if (ae.Sequencer is not null) + { + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); + } } // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). - // Compare the sequencer's body-local CurrentVelocity (root motion - // we're applying per tick) against the server's effective - // broadcast pace ((LastServerPos - PrevServerPos) / Δt). If - // |seqVel| significantly exceeds |serverVel|, the body - // overshoots between UPs and the InterpolationManager has to - // walk it backward each waypoint — visible as 1-Hz blips. - // The ratio prints once per remote per ~2 seconds so a moving - // remote shows up without flooding the console. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && rm.PrevServerPosTime > 0.0 - && rm.LastServerPosTime > rm.PrevServerPosTime) + // Track the maximum sequencer velocity magnitude seen since + // the last UpdatePosition arrival (carried on the + // RemoteMotion struct), then on each UP arrival the + // OnLivePositionUpdated path prints the comparison against + // the server's actual broadcast pace + // ((LastServerPos - PrevServerPos) / Δt). This guarantees + // both sides are sampled during the same window and the + // ratio reflects the real overshoot. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSec - rm.LastVelDiagLogTime > 2.0) - { - double dtServer = rm.LastServerPosTime - rm.PrevServerPosTime; - var serverDelta = rm.LastServerPos - rm.PrevServerPos; - float serverSpeed = (float)(serverDelta.Length() / dtServer); - float seqSpeed = seqVel.Length(); - // Only log when the entity is actually moving — skip - // idle remotes where both speeds are ~0. - if (serverSpeed > 0.1f || seqSpeed > 0.1f) - { - System.Console.WriteLine( - $"[VEL_DIAG] guid={serverGuid:X8} seqSpeed={seqSpeed:F3} m/s " - + $"serverSpeed={serverSpeed:F3} m/s " - + $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}"); - rm.LastVelDiagLogTime = nowSec; - } - } + // body.Velocity is now the source of bulk translation + // (set above by apply_current_movement). Track its + // magnitude so VEL_DIAG can compare against the actual + // server broadcast pace. + float seqSpeedNow = rm.Body.Velocity.Length(); + if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP) + rm.MaxSeqSpeedSinceLastUP = seqSpeedNow; } ae.Entity.Position = rm.Body.Position; diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 1930b446..f82c1d36 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -935,34 +935,38 @@ public sealed class MotionInterpreter // ── CMotionInterp::get_max_speed (0x00527cb0) ───────────────────────────── /// - /// Return the motion-table-derived max speed (m/s) for the current - /// . + /// Return the run rate. Mirrors retail + /// CMotionInterp::get_max_speed at 0x00527cb0. /// /// - /// Retail reference (named-retail, 0x00527cb0): - /// CMotionInterp::get_max_speed fetches the run rate via - /// InqRunRate (or falls back to my_run_rate) and returns - /// the result as a float from the x87 FPU stack (ST0). The Binary Ninja - /// decompiler emits a spurious void return type for x87-returning - /// functions — the actual return value is confirmed by the two callers: - /// StickyManager::adjust_offset (0x00555430) and - /// InterpolationManager::AdjustOffset (0x00555d52), both of which - /// multiply the result by 2.0 to produce a catch-up speed in m/s. With a - /// run rate of ~1.0 the catch-up would be 2.0 m/s — far too slow — so the - /// function must return the actual velocity (m/s), not a bare rate. + /// Decomp (named-retail/acclient_2013_pseudo_c.txt:305127): + /// + /// void get_max_speed(this) { + /// weenie_obj = this->weenie_obj; + /// this_1 = nullptr; + /// if (weenie_obj == 0) return; + /// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return; + /// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack + /// } + /// + /// Binary Ninja shows the return type as void because the float + /// return rides the x87 FPU stack rather than EAX. Both branches + /// emit an fld of either this_1 (the InqRunRate + /// out-param value) or my_run_rate, leaving the run rate on + /// ST0 as the return value. /// /// /// - /// The per-command switch mirrors get_state_velocity - /// (0x00527d50), which uses the same constants and the same - /// RunForward / WalkForward / WalkBackward branches, and the - /// adjust_motion (0x00527c0e) BackwardsFactor = 0.65 - /// scaling confirmed at address 0x00528010. - /// - /// - /// - /// Used by InterpolationManager.AdjustOffset in L.3 Task 5 - /// as 2 × GetMaxSpeed() catch-up speed. + /// Critical: this returns the BARE run rate (typically 1.0 to + /// ~3.0), NOT a velocity in m/s. We previously multiplied by + /// RunAnimSpeed to get a m/s value, reasoning that + /// 2 × bare_rate would be too slow a catch-up speed for the + /// caller (InterpolationManager::adjust_offset). That was a + /// misread of the decomp — retail's catch-up IS that slow on purpose. + /// The multi-second 1-Hz blip the user reported when observing retail + /// remotes from acdream traced to body racing at the wrong (overshot) + /// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s + /// for a run-skill-200 char). /// /// public float GetMaxSpeed() @@ -972,14 +976,7 @@ public sealed class MotionInterpreter float rate = MyRunRate; if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried)) rate = queried; - - return InterpretedState.ForwardCommand switch - { - MotionCommand.RunForward => RunAnimSpeed * rate, - MotionCommand.WalkForward => WalkAnimSpeed, - MotionCommand.WalkBackward => WalkAnimSpeed * 0.65f, // BackwardsFactor @ adjust_motion 0x00528010 - _ => 0f, // idle / non-locomotion - }; + return rate; } // ── private helper ──────────────────────────────────────────────────────── diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs index aa352abd..be3dbc0d 100644 --- a/src/AcDream.Core/Physics/PositionManager.cs +++ b/src/AcDream.Core/Physics/PositionManager.cs @@ -42,14 +42,35 @@ public sealed class PositionManager InterpolationManager interp, float maxSpeed) { - // Step 1: animation root motion (body-local → world). - Vector3 rootMotionLocal = seqVel * (float)dt; - Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); - - // Step 2: interpolation correction (world-space already). + // Retail-faithful per-frame combiner. Mirrors + // CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) + + // InterpolationManager::adjust_offset (@ 0x00555d30): + // + // 1. CPartArray::Update writes rootOffset (animation root motion) + // into the per-tick Frame. + // 2. PositionManager::adjust_offset → InterpolationManager::adjust_offset + // either: + // a) RETURNS EARLY when distance(body, head) < 0.05m + // (NodeCompleted; arg2 unmodified) — body uses root motion. + // b) OVERWRITES arg2 with `direction × min(catchUpSpeed × dt, + // distance)` when body is far from head — catch-up REPLACES + // root motion for this frame. + // + // It is NOT additive. Our prior port added rootMotion + correction + // every frame, which stacked the animation push (≈ RunAnimSpeed × + // speedMod, ≈ 11.7 m/s) on top of the queue catch-up (capped at + // ≈ 23.5 m/s) so the body advanced at up to ~3× the server's + // broadcast pace and the head-behind-body case produced a backward + // correction every UP — the visible 1-Hz blip the user reported. + // + // AdjustOffset returns Vector3.Zero in two cases mapped to retail's + // early-return: empty queue OR distance < DesiredDistance (0.05m). + // In both, body falls back to animation root motion. Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); + if (correction.LengthSquared() > 0f) + return correction; - // Step 3: combined delta. - return rootMotionWorld + correction; + Vector3 rootMotionLocal = seqVel * (float)dt; + return Vector3.Transform(rootMotionLocal, ori); } } From a45c21ee51ee809cb8a40663f55c5f3fcceb38e8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 16:23:57 +0200 Subject: [PATCH 23/32] =?UTF-8?q?fix(motion):=20retail-faithful=20remote?= =?UTF-8?q?=20tick=20=E2=80=94=20clear=20body.Velocity,=20drive=20via=20se?= =?UTF-8?q?qVel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hybrid that double-counted forward translation: predicted body.Velocity (set per-tick by apply_current_movement) + the seqVel-derived offset both pushed the remote body forward at ~11.7 m/s × dt for run, summing to ~23.4 m/s × dt — the user's "way too fast" + 1-Hz blip. Per the named-retail decomp investigation 2026-05-03 (research agent report dispatched against acclient_2013_pseudo_c.txt for CSequence::update + UpdatePositionInternal + UpdateObjectInternal + adjust_offset, line citations in the env-var path comments): CPhysicsObj::UpdateObjectInternal (0x005156b0) → UpdatePositionInternal (0x00512c30) → CPartArray::Update (writes anim root motion into the offset frame) → PositionManager::adjust_offset (REPLACES the offset with catch-up when the body is far from the queue head; otherwise leaves the anim root motion alone — Frame::operator=(arg2, &__return) semantics, NOT additive) → Frame::combine (out = m_position + offset) → UpdatePhysicsInternal (out += body.Velocity × dt + 0.5·accel·dt²) For a remote in steady-state RunForward where the server hasn't pushed an explicit velocity, m_velocityVector ≈ 0 and ALL per-tick translation comes from the animation root motion (CSequence::update_internal + Frame::combine of crossed pos_frames keyframes). Our port doesn't extract per-keyframe pos_frames from the .anm assets; instead AnimationSequencer.CurrentVelocity is the synthesized equivalent (RunAnimSpeed × ForwardSpeed averaged), passed through PositionManager.ComputeOffset. Concrete changes in the env-var (ACDREAM_INTERP_MANAGER=1) path: * Pass seqVel = ae.Sequencer.CurrentVelocity to ComputeOffset (was Vector3.Zero — that disabled the animation-root-motion source and left only the queue catch-up to drive translation, which lagged server pace). * Clear rm.Body.Velocity to Vector3.Zero for grounded remotes each tick. Mirrors retail's m_velocityVector ≈ 0 for remotes; prevents UpdatePhysicsInternal from adding a second 11.7 m/s × dt on top of the seqVel-driven translation. * Stop calling apply_current_movement per tick. Retail only calls it on motion-state changes (per cdb traces from the L.5 investigation), not per physics tick. body.Velocity-based translation is now the AIRBORNE-only path (gravity integration during jumps). Also reverts an unacceptable "scaling hack" (per-tick body.Velocity scaled by observed serverSpeed/predictedSpeed) the user explicitly rejected as patching over an unsolved structural problem. GetMaxSpeed reverted to RunAnimSpeed × rate (matches ACE MotionInterp.cs:670-678; the earlier "return bare rate" change came from a misread of an x87-decompiled get_max_speed where Binary Ninja showed the return type as void). AnimationSequencer.SetCycle now ALWAYS overwrites CurrentVelocity for known locomotion cycles (Walk/WalkBackward/Run/SideStepRight/ SideStepLeft) instead of gating on `CurrentVelocity.LengthSquared() < 1e-9f`. The gate was correct for non-locomotion entities with dat-baked HasVelocity, but for Humanoid where the dat is silent and the only thing that could set CurrentVelocity before synthesis was a transition link's HasVelocity flag, the gate would silently leave the body advancing at the link's velocity instead of the cycle's intended steady-state. Adds wire-arrival diagnostics gated on ACDREAM_REMOTE_VEL_DIAG=1 (SETCYCLE, FWD_WIRE) used to trace the bug to ground truth. User-confirmed improvements vs prior state: - Steady-state run no longer "way too fast" - Run-in-circles smoother (rectangle effect gone) - Jump landing in correct location - Turn-left visibly turns left Outstanding (not addressed by this commit, deferred for next investigation): walk↔run direct transitions don't visibly switch the animation cycle until the next motion event fires. Both legacy and new paths exhibit the same behavior, so the bug lives in the SetCycle queue manipulation pipeline shared by both — not in the per-tick translation path that this commit revises. Wire trace confirms ACE delivers the WalkForward → RunForward transition correctly and SetCycle does fire for it. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 120 ++++++++++++------ .../Physics/AnimationSequencer.cs | 36 ++++-- src/AcDream.Core/Physics/MotionInterpreter.cs | 9 +- 3 files changed, 114 insertions(+), 51 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3b76a52d..e7b658b9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2788,14 +2788,23 @@ public sealed class GameWindow : IDisposable // to the Attack/Twitch/etc command, and // get_state_velocity returns 0 because the gate is // RunForward||WalkForward — body stops moving forward. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion) + if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion) { - System.Console.WriteLine( - $"[FWD_WIRE] guid={update.Guid:X8} " - + $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} " - + $"newCmd=0x{fullMotion:X8} " - + $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}"); + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[FWD_WIRE] guid={update.Guid:X8} " + + $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} " + + $"newCmd=0x{fullMotion:X8} " + + $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}"); + } + // Motion command changed — invalidate observed-velocity + // history so the per-tick scaling in TickAnimations + // doesn't reuse a stale ratio derived from the OLD + // motion (e.g. carrying run-pace serverSpeed into the + // first walk frame, which would briefly accelerate + // walk to run pace before settling). + remoteMot.PrevServerPosTime = 0.0; } remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; // Pass speedMod through verbatim — preserve sign so retail's @@ -2958,7 +2967,18 @@ public sealed class GameWindow : IDisposable } } if (cycleToPlay != 0) + { + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && (ae.Sequencer.CurrentMotion != cycleToPlay + || MathF.Abs(ae.Sequencer.CurrentSpeedMod - animSpeed) > 1e-3f)) + { + System.Console.WriteLine( + $"[SETCYCLE] guid={update.Guid:X8} " + + $"old=(motion=0x{ae.Sequencer.CurrentMotion:X8} speed={ae.Sequencer.CurrentSpeedMod:F3}) " + + $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})"); + } ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed); + } } // Retail runs the full MotionInterp state machine on every @@ -5914,56 +5934,84 @@ public sealed class GameWindow : IDisposable // ── NEW PATH: retail-faithful per-frame remote tick ── // (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path) // - // Mirrors retail CPhysicsObj::UpdateObjectInternal - // (acclient @ 0x005156b0) → UpdatePositionInternal (@ 0x00512c30): + // Per retail's CPhysicsObj::UpdateObjectInternal (0x005156b0) + // → UpdatePositionInternal (0x00512c30) → CSequence::update + // chain (decomp investigation 2026-05-03): // - // 1. Force grounded transient flags (matches the legacy path - // and the gate inside MotionInterpreter.apply_current_movement - // which only writes velocity when OnWalkable is set). - // 2. apply_current_movement → body.set_local_velocity(get_state_velocity()) - // Refreshes body.Velocity from the current InterpretedState - // every tick. Matches the legacy path that has been working - // for player remotes since pre-L.3. - // 3. PositionManager.ComputeOffset returns ONLY the - // InterpolationManager catch-up correction (with seqVel=0). - // Retail's CPartArray::Update writes a tiny per-anim-frame - // stride into the offset frame; PositionManager::adjust_offset - // either lets it through or REPLACES it with catch-up. Our - // AnimationSequencer.CurrentVelocity is the SYNTHESIZED - // RunAnimSpeed × speedMod (matches body.Velocity), NOT a - // per-anim-frame stride — passing it as root motion - // double-counts the bulk translation that body.Velocity - // already provides via UpdatePhysicsInternal. Pass zero - // so only the queue-correction reaches the body. - // 4. Apply correction to body.Position. - // 5. Sequencer omega → body orientation (turn cycles). - // 6. calc_acceleration + UpdatePhysicsInternal — Euler- - // integrates body.Position += body.Velocity × dt. + // For a REMOTE entity (not local player), per physics tick + // the world-position advance is the sum of: + // A) animation root motion accumulated by + // update_internal (Frame::combine of crossed + // per-keyframe pos_frames deltas) OR replaced by + // InterpolationManager::adjust_offset's catch-up + // when the body is far from the queue head. + // B) body.Velocity × dt + 0.5 × accel × dt² + // (UpdatePhysicsInternal). For remotes, retail does + // NOT call apply_current_movement per tick — body. + // Velocity stays at whatever the last + // InterpolationManager type-3 ("set velocity") node + // set it to (typically zero unless the server is + // explicitly pushing velocity via VectorUpdate). + // + // So for normal grounded run/walk/strafe with no server- + // pushed velocity, ALL per-tick translation comes from (A). + // + // Acdream port mapping: + // - We don't extract per-keyframe pos_frames from the .anm + // assets. Our AnimationSequencer.CurrentVelocity is the + // synthesized equivalent (RunAnimSpeed × ForwardSpeed) + // which averages to the same effective body translation. + // - Pass it as seqVel to ComputeOffset so the + // animation-root-motion path drives body translation. + // - DO NOT call apply_current_movement per tick — that + // would set body.Velocity to RunAnimSpeed × ForwardSpeed, + // and UpdatePhysicsInternal would then add ANOTHER + // 11.7 m/s × dt on top of the seqVel motion already + // applied by ComputeOffset, producing 2× server pace + // (the user-reported "way too fast" + 1-Hz blip from + // the catch-up walking back the overshoot). + // - body.Velocity stays at 0 for grounded remotes; non- + // zero only when OnLiveVectorUpdated set it (jump + // start) — UpdatePhysicsInternal then integrates + // gravity for the airborne arc. + System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; - // Step 1: grounded flags so apply_current_movement writes velocity. + // Step 1: transient flags (Contact + OnWalkable for grounded; + // Active always so UpdatePhysicsInternal doesn't early-return). if (!rm.Airborne) { rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active; - // Step 2: refresh body.Velocity from current motion state. - rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + // For grounded remotes the body should not be carrying + // velocity — retail's m_velocityVector for a remote is + // 0 unless the server explicitly pushed one. Clear any + // stale velocity from a prior airborne arc so + // UpdatePhysicsInternal doesn't double-apply it on top + // of the seqVel-driven ComputeOffset translation below. + rm.Body.Velocity = System.Numerics.Vector3.Zero; } else { rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // Step 3+4: queue catch-up correction only (no double-count of seqVel). + // Step 2: per-frame body translation. ComputeOffset returns + // either the queue catch-up (when active) or the animation + // root motion (seqVel × dt rotated to world). REPLACE + // semantics — retail's PositionManager::adjust_offset + // overwrites the offset frame with the catch-up direction, + // not adding to it. float maxSpeed = rm.Motion.GetMaxSpeed(); System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, - seqVel: System.Numerics.Vector3.Zero, + seqVel: seqVel, ori: rm.Body.Orientation, interp: rm.Interp, maxSpeed: maxSpeed); diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index d270f359..fb33c0f1 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -510,42 +510,52 @@ public sealed class AnimationSequencer // decompiled from _DAT_007c96e0/e4/e8. The velocity is body-local // (+Y = forward, +X = right); consumers rotate into world space via // the owning entity's orientation. - if (CurrentVelocity.LengthSquared() < 1e-9f) + // For known locomotion cycles, ALWAYS overwrite CurrentVelocity with + // the synthesized value — even if the transition link set + // CurrentVelocity from its own HasVelocity flag. The link's velocity + // is for the brief transition (e.g. small stride into run-pose); the + // cycle's intended steady-state velocity is what consumers (remote + // body translation in GameWindow.TickAnimations env-var path) need. + // Without this, walking-to-running transitions left CurrentVelocity + // at the link's slow pace, and the user reported "it just blips + // forward walking" until another motion command (turn, etc) forced + // a re-synth. The gate that previously read + // `if (CurrentVelocity.LengthSquared() < 1e-9f)` allowed dat-baked + // velocity to win over synthesis — which is correct for non- + // locomotion (e.g. flying creatures with HasVelocity) but wrong for + // Humanoid run/walk/strafe where the dat is silent and the link + // velocity is the only thing setting it. { float yvel = 0f; float xvel = 0f; - // Low byte of the ORIGINAL (non-adjusted) motion tells us which - // intent the caller signalled. adjust_motion may have remapped - // TurnLeft → TurnRight / SideStepLeft → SideStepRight / - // WalkBackward → WalkForward, encoding the sign into adjustedSpeed. - // The speed sign is preserved in adjustedSpeed so we multiply by - // it rather than re-deriving per-case. uint low = motion & 0xFFu; + bool isLocomotion = false; switch (low) { case 0x05: // WalkForward yvel = WalkAnimSpeed * adjustedSpeed; + isLocomotion = true; break; case 0x06: // WalkBackward — adjust_motion remapped to WalkForward - // with speedMod *= -0.65f, so adjustedSpeed already - // carries the factor. But the motion arg we see - // here is the original (pre-adjust) 0x06, so we - // still use WalkAnimSpeed — the negative sign of - // adjustedSpeed flips the direction correctly. + // with speedMod *= -0.65f. yvel = WalkAnimSpeed * adjustedSpeed; + isLocomotion = true; break; case 0x07: // RunForward yvel = RunAnimSpeed * adjustedSpeed; + isLocomotion = true; break; case 0x0F: // SideStepRight xvel = SidestepAnimSpeed * adjustedSpeed; + isLocomotion = true; break; case 0x10: // SideStepLeft — remapped to SideStepRight with // negated speed; same handling as backward walk. xvel = SidestepAnimSpeed * adjustedSpeed; + isLocomotion = true; break; } - if (yvel != 0f || xvel != 0f) + if (isLocomotion) CurrentVelocity = new Vector3(xvel, yvel, 0f); } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index f82c1d36..c82ce2b9 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -972,11 +972,16 @@ public sealed class MotionInterpreter public float GetMaxSpeed() { // Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate. - // Mirrors the InqRunRate query at the top of CMotionInterp::get_max_speed. + // Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678 + // which is verified against retail (the ACE MotionInterp file is a + // line-by-line port). Returns the maximum world-space velocity in m/s + // — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by + // InterpolationManager.AdjustOffset to compute the catch-up speed + // (= 2 × maxSpeed). float rate = MyRunRate; if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried)) rate = queried; - return rate; + return RunAnimSpeed * rate; } // ── private helper ──────────────────────────────────────────────────────── From b1d8e122edb3644f3b78d93cc41d7abe6acaf98a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 16:54:34 +0200 Subject: [PATCH 24/32] research(motion): cdb live trace of retail walk-to-run transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live cdb trace of retail acclient.exe (v11.4186, PDB-matched) capturing the exact function call sequence for a direct walk-to-run motion transition where the user holds shift+W (walk) then releases SHIFT while still holding W (transition to run). Trace bps on: - CPhysicsObj::DoInterpretedMotion (0x0050EA70) - CPartArray::DoInterpretedMotion (0x00518750) - MotionTableManager::PerformMovement (0x0051C0B0) - MotionTableManager::add_to_queue (0x0051BFE0) - MotionTableManager::truncate_animation_list (0x0051BCA0) - CMotionTable::DoObjectMotion (0x00523E90) - CMotionTable::StopObjectMotion (0x00523EC0) Captured trace at tools/cdb-scripts/walk_run_motion_trace.log shows the precise walk-to-run sequence: [79] CPhysicsObj::DoInterpretedMotion: motion=45000005 walk start [82] CMotionTable::DoObjectMotion: motion=45000005 [83] MotionTableManager::add_to_queue: arg1=45000005 arg2=00000001 [89] CPhysicsObj::DoInterpretedMotion: motion=44000007 run start [92] CMotionTable::DoObjectMotion: motion=44000007 [93] MotionTableManager::add_to_queue: arg1=44000007 arg2=00000001 [104] CMotionTable::StopObjectMotion: motion=44000007 run end Critical structural finding for #L.4-walk-run: Retail does NOT call truncate_animation_list during the walk→run transition. truncate_animation_list never fires in the entire 200-hit trace. Retail also does NOT call StopObjectMotion(WalkForward) before add_to_queue(RunForward). Retail just appends the new motion to the queue and lets MotionTableManager (and its CheckForCompletedMotions / remove_redundant_links per-tick cleanup, not yet traced) handle the natural progression. acdream's AnimationSequencer.SetCycle aggressively calls ClearCyclicTail() at line 430 BEFORE enqueuing the new cycle, which destroys the in-flight walk cycle's frames. The new run cycle is enqueued but _currNode is left in a state that doesn't smoothly continue — visible to the user as "it just blips forward walking, AS SOON as press another key like turning, its starts running" (the next motion event re-fires SetCycle which finally aligns state). Fix is a structural refactor of SetCycle to mirror retail's "additive queue with auto-cleanup" semantics. Out of scope for this research commit; filed as #L.4 in the next ISSUES.md entry. Co-Authored-By: Claude Opus 4.7 --- tools/cdb-scripts/run_walk_run_trace.ps1 | 16 ++ tools/cdb-scripts/walk_run_motion_trace.log | 175 ++++++++++++++++++ .../walk_run_motion_trace.log.console | 0 3 files changed, 191 insertions(+) create mode 100644 tools/cdb-scripts/run_walk_run_trace.ps1 create mode 100644 tools/cdb-scripts/walk_run_motion_trace.log create mode 100644 tools/cdb-scripts/walk_run_motion_trace.log.console diff --git a/tools/cdb-scripts/run_walk_run_trace.ps1 b/tools/cdb-scripts/run_walk_run_trace.ps1 new file mode 100644 index 00000000..3fecbe4f --- /dev/null +++ b/tools/cdb-scripts/run_walk_run_trace.ps1 @@ -0,0 +1,16 @@ +$cdb = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" +$script = "C:\Users\erikn\source\repos\acdream\tools\cdb-scripts\walk_run_motion_trace.cdb" +$log = "C:\Users\erikn\source\repos\acdream\tools\cdb-scripts\walk_run_motion_trace.log" + +if (Test-Path $log) { Remove-Item $log } + +Write-Host "Attaching cdb to acclient.exe..." +Write-Host "Once attached, do this in retail:" +Write-Host " 1. Stand still 2s" +Write-Host " 2. Hold shift+W (walk) 4s" +Write-Host " 3. Release SHIFT only, keep W, 4s (this is what we want to capture)" +Write-Host " 4. Release W" +Write-Host " 5. Wait for cdb to detach (or Ctrl+C this PS to detach manually)" +Write-Host "" + +& $cdb -pn acclient.exe -cf $script *>&1 | Tee-Object -FilePath "$log.console" diff --git a/tools/cdb-scripts/walk_run_motion_trace.log b/tools/cdb-scripts/walk_run_motion_trace.log new file mode 100644 index 00000000..39a793e6 --- /dev/null +++ b/tools/cdb-scripts/walk_run_motion_trace.log @@ -0,0 +1,175 @@ +Opened log file 'C:\Users\erikn\source\repos\acdream\tools\cdb-scripts\walk_run_motion_trace.log' +0:017> .sympath C:\Users\erikn\source\repos\acdream\refs +Symbol search path is: C:\Users\erikn\source\repos\acdream\refs +Expanded Symbol search path is: c:\users\erikn\source\repos\acdream\refs + +************* Path validation summary ************** +Response Time (ms) Location +OK C:\Users\erikn\source\repos\acdream\refs +0:017> .symopt+ 0x40 +Symbol options are 0xB0367: + 0x00000001 - SYMOPT_CASE_INSENSITIVE + 0x00000002 - SYMOPT_UNDNAME + 0x00000004 - SYMOPT_DEFERRED_LOADS + 0x00000020 - SYMOPT_OMAP_FIND_NEAREST + 0x00000040 - SYMOPT_LOAD_ANYTHING + 0x00000100 - SYMOPT_NO_UNQUALIFIED_LOADS + 0x00000200 - SYMOPT_FAIL_CRITICAL_ERRORS + 0x00010000 - SYMOPT_AUTO_PUBLICS + 0x00020000 - SYMOPT_NO_IMAGE_SEARCH + 0x00080000 - SYMOPT_NO_PROMPTS +0:017> .reload /f acclient.exe +0:017> +0:017> r $t0 = 0 +0:017> +0:017> bp acclient!CPhysicsObj::DoInterpretedMotion ".printf \"\\n[%d] CPhysicsObj::DoInterpretedMotion: motion=%08x speedBits=%08x\", @$t0, poi(esp+4), poi(esp+8); r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 0 redefined +0:017> bp acclient!CPartArray::DoInterpretedMotion ".printf \"\\n[%d] CPartArray::DoInterpretedMotion: motion=%08x speedBits=%08x\", @$t0, poi(esp+4), poi(esp+8); r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 1 redefined +0:017> bp acclient!MotionTableManager::PerformMovement ".printf \"\\n[%d] MotionTableManager::PerformMovement: motion=%08x speedBits=%08x holdkey=%08x\", @$t0, poi(esp+4), poi(esp+8), poi(esp+0xc); r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 2 redefined +0:017> bp acclient!MotionTableManager::add_to_queue ".printf \"\\n[%d] MotionTableManager::add_to_queue: arg1=%08x arg2=%08x\", @$t0, poi(esp+4), poi(esp+8); r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 3 redefined +0:017> bp acclient!MotionTableManager::truncate_animation_list ".printf \"\\n[%d] MotionTableManager::truncate_animation_list\", @$t0; r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 4 redefined +0:017> bp acclient!CMotionTable::DoObjectMotion ".printf \"\\n[%d] CMotionTable::DoObjectMotion: motion=%08x\", @$t0, poi(esp+4); r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 5 redefined +0:017> bp acclient!CMotionTable::StopObjectMotion ".printf \"\\n[%d] CMotionTable::StopObjectMotion: motion=%08x\", @$t0, poi(esp+4); r $t0 = @$t0 + 1; .if (@$t0 >= 200) { .detach } .else { gc }" +0:017> +breakpoint 6 redefined +0:017> g + +[0] MotionTableManager::PerformMovement: motion=001afc60 speedBits=16f5e1f8 holdkey=16fa5c38 +[1] CMotionTable::StopObjectMotion: motion=6500000d +[2] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[3] MotionTableManager::PerformMovement: motion=001afc2c speedBits=16f5e1f8 holdkey=16fa5c38 +[4] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[5] MotionTableManager::PerformMovement: motion=001afbe4 speedBits=16f5e1f8 holdkey=16fa5c38 +[6] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[7] CPhysicsObj::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[8] CPartArray::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[9] MotionTableManager::PerformMovement: motion=001afc14 speedBits=16f5e1f8 holdkey=16fa5c38 +[10] CMotionTable::DoObjectMotion: motion=6500000d +[11] MotionTableManager::add_to_queue: arg1=6500000d arg2=00000000 +[12] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[13] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[14] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[15] CMotionTable::DoObjectMotion: motion=8000003d +[16] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[17] CPhysicsObj::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[18] CPartArray::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[19] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[20] CMotionTable::DoObjectMotion: motion=10000054 +[21] MotionTableManager::add_to_queue: arg1=10000054 arg2=00000001 +[22] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[23] CMotionTable::StopObjectMotion: motion=6500000f +[24] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e4f8 holdkey=16fa5218 +[25] CMotionTable::StopObjectMotion: motion=6500000d +[26] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[27] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[28] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[29] CMotionTable::DoObjectMotion: motion=8000003d +[30] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[31] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[32] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[33] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[34] CMotionTable::DoObjectMotion: motion=41000003 +[35] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[36] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[37] CMotionTable::StopObjectMotion: motion=6500000f +[38] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e4f8 holdkey=16fa5218 +[39] CMotionTable::StopObjectMotion: motion=6500000d +[40] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001af5e8 +[41] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001af5e8 +[42] MotionTableManager::PerformMovement: motion=001af558 speedBits=15f356a0 holdkey=13cf1420 +[43] CMotionTable::DoObjectMotion: motion=8000003d +[44] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[45] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001af5e8 +[46] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001af5e8 +[47] MotionTableManager::PerformMovement: motion=001af558 speedBits=15f356a0 holdkey=13cf1420 +[48] CMotionTable::DoObjectMotion: motion=41000003 +[49] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[50] MotionTableManager::PerformMovement: motion=001af558 speedBits=15f356a0 holdkey=13cf1420 +[51] CMotionTable::StopObjectMotion: motion=6500000f +[52] MotionTableManager::PerformMovement: motion=001af574 speedBits=15f356a0 holdkey=13cf1420 +[53] CMotionTable::StopObjectMotion: motion=6500000d +[54] MotionTableManager::PerformMovement: motion=001afc60 speedBits=16f5e1f8 holdkey=16fa5c38 +[55] CMotionTable::StopObjectMotion: motion=6500000d +[56] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[57] MotionTableManager::PerformMovement: motion=001afc2c speedBits=16f5e1f8 holdkey=16fa5c38 +[58] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[59] MotionTableManager::PerformMovement: motion=001afbe4 speedBits=16f5e1f8 holdkey=16fa5c38 +[60] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[61] CPhysicsObj::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[62] CPartArray::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[63] MotionTableManager::PerformMovement: motion=001afc14 speedBits=16f5e1f8 holdkey=16fa5c38 +[64] CMotionTable::DoObjectMotion: motion=6500000d +[65] MotionTableManager::add_to_queue: arg1=6500000d arg2=00000000 +[66] MotionTableManager::PerformMovement: motion=001af408 speedBits=15f356a0 holdkey=13cf1420 +[67] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[68] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001af33c +[69] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001af33c +[70] MotionTableManager::PerformMovement: motion=001af2a0 speedBits=15f356a0 holdkey=13cf1420 +[71] CMotionTable::DoObjectMotion: motion=41000003 +[72] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[73] MotionTableManager::PerformMovement: motion=001af2a0 speedBits=15f356a0 holdkey=13cf1420 +[74] CMotionTable::StopObjectMotion: motion=6500000f +[75] MotionTableManager::PerformMovement: motion=001af2a0 speedBits=15f356a0 holdkey=13cf1420 +[76] CMotionTable::StopObjectMotion: motion=6500000d +[77] MotionTableManager::PerformMovement: motion=001af2a0 speedBits=15f356a0 holdkey=13cf1420 +[78] CMotionTable::StopObjectMotion: motion=6500000f +[79] CPhysicsObj::DoInterpretedMotion: motion=45000005 speedBits=001af348 +[80] CPartArray::DoInterpretedMotion: motion=45000005 speedBits=001af348 +[81] MotionTableManager::PerformMovement: motion=001af2ac speedBits=15f356a0 holdkey=13cf1420 +[82] CMotionTable::DoObjectMotion: motion=45000005 +[83] MotionTableManager::add_to_queue: arg1=45000005 arg2=00000001 +[84] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001af5e0 +[85] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001af5e0 +[86] MotionTableManager::PerformMovement: motion=001af550 speedBits=15f356a0 holdkey=13cf1420 +[87] CMotionTable::DoObjectMotion: motion=8000003d +[88] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[89] CPhysicsObj::DoInterpretedMotion: motion=44000007 speedBits=001af5e0 +[90] CPartArray::DoInterpretedMotion: motion=44000007 speedBits=001af5e0 +[91] MotionTableManager::PerformMovement: motion=001af550 speedBits=15f356a0 holdkey=13cf1420 +[92] CMotionTable::DoObjectMotion: motion=44000007 +[93] MotionTableManager::add_to_queue: arg1=44000007 arg2=00000001 +[94] MotionTableManager::PerformMovement: motion=001af550 speedBits=15f356a0 holdkey=13cf1420 +[95] CMotionTable::StopObjectMotion: motion=6500000f +[96] MotionTableManager::PerformMovement: motion=001af56c speedBits=15f356a0 holdkey=13cf1420 +[97] CMotionTable::StopObjectMotion: motion=6500000d +[98] MotionTableManager::PerformMovement: motion=001afcfc speedBits=16f5e1f8 holdkey=16fa5c38 +[99] CMotionTable::StopObjectMotion: motion=6500000d +[100] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[101] MotionTableManager::PerformMovement: motion=001afc84 speedBits=16f5e1f8 holdkey=16fa5c38 +[102] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[103] MotionTableManager::PerformMovement: motion=001af2c0 speedBits=15f356a0 holdkey=13cf1420 +[104] CMotionTable::StopObjectMotion: motion=44000007 +[105] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000001 +[106] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001af4bc +[107] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001af4bc +[108] MotionTableManager::PerformMovement: motion=001af420 speedBits=15f356a0 holdkey=13cf1420 +[109] CMotionTable::DoObjectMotion: motion=41000003 +[110] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[111] MotionTableManager::PerformMovement: motion=001af420 speedBits=15f356a0 holdkey=13cf1420 +[112] CMotionTable::StopObjectMotion: motion=6500000f +[113] MotionTableManager::PerformMovement: motion=001af420 speedBits=15f356a0 holdkey=13cf1420 +[114] CMotionTable::StopObjectMotion: motion=6500000d +[115] MotionTableManager::PerformMovement: motion=001af420 speedBits=15f356a0 holdkey=13cf1420 +[116] CMotionTable::StopObjectMotion: motion=6500000f +[117] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001af47c +[118] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001af47c +[119] MotionTableManager::PerformMovement: motion=001af3e0 speedBits=15f356a0 holdkey=13cf1420 +[120] CMotionTable::DoObjectMotion: motion=41000003 +[121] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[122] MotionTableManager::PerformMovement: motion=001af3e0 speedBits=15f356a0 holdkey=13cf1420 +[123] CMotionTable::StopObjectMotion: motion=6500000f +[124] MotionTableManager::PerformMovement: motion=001af3e0 speedBits=15f356a0 holdkey=13cf1420 +[125] CMotionTable::StopObjectMotion: motion=6500000d +[126] MotionTableManager::PerformMovement: motion=001af3e0 speedBits=15f356a0 holdkey=13cf1420 +[127] CMotionTable::StopObjectMotion: motion=6500000f \ No newline at end of file diff --git a/tools/cdb-scripts/walk_run_motion_trace.log.console b/tools/cdb-scripts/walk_run_motion_trace.log.console new file mode 100644 index 00000000..e69de29b From c06b6c51e116022245ea2945d94ed7f1c3387366 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 17:00:55 +0200 Subject: [PATCH 25/32] fix(motion): full queue reset on locomotion-cycle direct transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AnimationSequencer.SetCycle transitions between forward-locomotion cycles (Walk↔Run, Walk↔WalkBackward, etc.) — i.e. when both old and new motion's low byte is in {0x05 WalkForward, 0x06 WalkBackward, 0x07 RunForward} — do a full queue drain + _currNode/_firstCyclic reset (matching the existing skipTransitionLink branch) instead of just ClearCyclicTail. Without this, _currNode is left pointing into the previous cycle's non-cyclic head (link frames from the prior Ready→walk transition), and the visible legs continue playing those head frames before reaching the new run cycle. Investigation findings (cdb live trace of retail at tools/cdb-scripts/walk_run_motion_trace.log): Retail's actual approach is "additive add_to_queue with no truncate" — MotionTableManager handles the natural progression via per-tick CheckForCompletedMotions / remove_redundant_links cleanup. Acdream doesn't have that machinery, so this fix is the closest viable emulation: force the queue back to a clean state and rebuild from scratch on the locomotion-cycle transition. User-reported symptom this addresses (walk→run direct transition, release shift while W held): visible animation cycle did not switch until next motion event. Verified via FWD_WIRE + SETCYCLE diags that both ACE and our SetCycle are firing correctly on the transition. Co-Authored-By: Claude Opus 4.7 --- .../Physics/AnimationSequencer.cs | 24 +- tools/cdb-scripts/walk_run_motion_trace.log | 386 +++++++++++++++++- .../walk_run_motion_trace.log.console | Bin 0 -> 99876 bytes 3 files changed, 408 insertions(+), 2 deletions(-) diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index fb33c0f1..76de86a9 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -434,7 +434,29 @@ public sealed class AnimationSequencer // cycle. Without this, the old RunForward → ??? link would // continue draining for ~100 ms before the new Falling cycle // starts, defeating the "skip the link" intent. - if (skipTransitionLink) + // + // 2026-05-03: ALSO do a full drain when transitioning between + // FORWARD-LOCOMOTION cycles (Walk↔Run, Walk↔WalkBackward, etc.) + // — i.e. when both old and new motion's low byte is in the + // {0x05 WalkForward, 0x06 WalkBackward, 0x07 RunForward} set. + // ClearCyclicTail alone leaves _currNode in the previous cycle's + // non-cyclic head (link frames from a Ready→walk transition), + // and the visible legs continue playing those head frames before + // reaching the new run cycle. The user-reported symptom: walk→run + // direct transition (release shift while W held) did not visibly + // switch the leg cycle — body advanced at walk pace until the + // next motion event (turn / stop) re-fired SetCycle and finally + // aligned the queue. Live cdb trace of retail acclient.exe + // 2026-05-03 (tools/cdb-scripts/walk_run_motion_trace.log) shows + // retail uses an additive add_to_queue with no truncate — the + // MotionTableManager's per-tick CheckForCompletedMotions handles + // the natural progression. We don't have that machinery, so we + // emulate via a hard reset on the locomotion-cycle transition. + uint oldLow = CurrentMotion & 0xFFu; + uint newLow = motion & 0xFFu; + bool oldIsForwardLoc = oldLow == 0x05u || oldLow == 0x06u || oldLow == 0x07u; + bool newIsForwardLoc = newLow == 0x05u || newLow == 0x06u || newLow == 0x07u; + if (skipTransitionLink || (oldIsForwardLoc && newIsForwardLoc)) { _queue.Clear(); _currNode = null; diff --git a/tools/cdb-scripts/walk_run_motion_trace.log b/tools/cdb-scripts/walk_run_motion_trace.log index 39a793e6..41880c5f 100644 --- a/tools/cdb-scripts/walk_run_motion_trace.log +++ b/tools/cdb-scripts/walk_run_motion_trace.log @@ -172,4 +172,388 @@ breakpoint 6 redefined [124] MotionTableManager::PerformMovement: motion=001af3e0 speedBits=15f356a0 holdkey=13cf1420 [125] CMotionTable::StopObjectMotion: motion=6500000d [126] MotionTableManager::PerformMovement: motion=001af3e0 speedBits=15f356a0 holdkey=13cf1420 -[127] CMotionTable::StopObjectMotion: motion=6500000f \ No newline at end of file +[127] CMotionTable::StopObjectMotion: motion=6500000f +[128] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[129] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[130] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[131] CMotionTable::DoObjectMotion: motion=8000003d +[132] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[133] CPhysicsObj::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[134] CPartArray::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[135] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[136] CMotionTable::DoObjectMotion: motion=10000053 +[137] MotionTableManager::add_to_queue: arg1=10000053 arg2=00000003 +[138] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[139] CMotionTable::StopObjectMotion: motion=6500000f +[140] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[141] CMotionTable::StopObjectMotion: motion=6500000d +[142] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[143] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[144] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[145] CMotionTable::DoObjectMotion: motion=8000003d +[146] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[147] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[148] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[149] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[150] CMotionTable::DoObjectMotion: motion=41000003 +[151] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[152] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[153] CMotionTable::StopObjectMotion: motion=6500000f +[154] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[155] CMotionTable::StopObjectMotion: motion=6500000d +[156] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[157] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[158] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[159] CMotionTable::DoObjectMotion: motion=8000003d +[160] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[161] CPhysicsObj::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[162] CPartArray::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[163] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[164] CMotionTable::DoObjectMotion: motion=10000053 +[165] MotionTableManager::add_to_queue: arg1=10000053 arg2=00000003 +[166] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[167] CMotionTable::StopObjectMotion: motion=6500000f +[168] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[169] CMotionTable::StopObjectMotion: motion=6500000d +[170] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[171] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[172] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[173] CMotionTable::DoObjectMotion: motion=8000003d +[174] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[175] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[176] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[177] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[178] CMotionTable::DoObjectMotion: motion=41000003 +[179] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[180] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[181] CMotionTable::StopObjectMotion: motion=6500000f +[182] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[183] CMotionTable::StopObjectMotion: motion=6500000d +[184] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[185] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[186] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[187] CMotionTable::DoObjectMotion: motion=8000003d +[188] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[189] CPhysicsObj::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[190] CPartArray::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[191] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[192] CMotionTable::DoObjectMotion: motion=10000054 +[193] MotionTableManager::add_to_queue: arg1=10000054 arg2=00000001 +[194] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[195] CMotionTable::StopObjectMotion: motion=6500000f +[196] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[197] CMotionTable::StopObjectMotion: motion=6500000d +[198] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[199] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[200] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[201] CMotionTable::DoObjectMotion: motion=8000003d +[202] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[203] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[204] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[205] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[206] CMotionTable::DoObjectMotion: motion=41000003 +[207] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[208] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[209] CMotionTable::StopObjectMotion: motion=6500000f +[210] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[211] CMotionTable::StopObjectMotion: motion=6500000d +[212] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[213] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[214] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[215] CMotionTable::DoObjectMotion: motion=8000003d +[216] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[217] CPhysicsObj::DoInterpretedMotion: motion=10000052 speedBits=001afcb4 +[218] CPartArray::DoInterpretedMotion: motion=10000052 speedBits=001afcb4 +[219] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[220] CMotionTable::DoObjectMotion: motion=10000052 +[221] MotionTableManager::add_to_queue: arg1=10000052 arg2=00000001 +[222] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[223] CMotionTable::StopObjectMotion: motion=6500000f +[224] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[225] CMotionTable::StopObjectMotion: motion=6500000d +[226] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[227] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[228] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[229] CMotionTable::DoObjectMotion: motion=8000003d +[230] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[231] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[232] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[233] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[234] CMotionTable::DoObjectMotion: motion=41000003 +[235] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[236] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[237] CMotionTable::StopObjectMotion: motion=6500000f +[238] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[239] CMotionTable::StopObjectMotion: motion=6500000d +[240] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[241] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[242] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[243] CMotionTable::DoObjectMotion: motion=8000003d +[244] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[245] CPhysicsObj::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[246] CPartArray::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[247] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[248] CMotionTable::DoObjectMotion: motion=10000053 +[249] MotionTableManager::add_to_queue: arg1=10000053 arg2=00000003 +[250] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[251] CMotionTable::StopObjectMotion: motion=6500000f +[252] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[253] CMotionTable::StopObjectMotion: motion=6500000d +[254] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[255] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[256] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[257] CMotionTable::DoObjectMotion: motion=8000003d +[258] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[259] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[260] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[261] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[262] CMotionTable::DoObjectMotion: motion=41000003 +[263] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[264] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[265] CMotionTable::StopObjectMotion: motion=6500000f +[266] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[267] CMotionTable::StopObjectMotion: motion=6500000d +[268] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[269] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[270] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[271] CMotionTable::DoObjectMotion: motion=8000003d +[272] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[273] CPhysicsObj::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[274] CPartArray::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[275] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[276] CMotionTable::DoObjectMotion: motion=10000054 +[277] MotionTableManager::add_to_queue: arg1=10000054 arg2=00000001 +[278] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[279] CMotionTable::StopObjectMotion: motion=6500000f +[280] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e978 holdkey=16fa3328 +[281] CMotionTable::StopObjectMotion: motion=6500000d +[282] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[283] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[284] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[285] CMotionTable::DoObjectMotion: motion=8000003d +[286] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[287] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[288] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[289] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[290] CMotionTable::DoObjectMotion: motion=41000003 +[291] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[292] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[293] CMotionTable::StopObjectMotion: motion=6500000f +[294] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e978 holdkey=16fa3328 +[295] CMotionTable::StopObjectMotion: motion=6500000d +[296] MotionTableManager::PerformMovement: motion=001afbe4 speedBits=16f5e1f8 holdkey=16fa5c38 +[297] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[298] CPhysicsObj::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[299] CPartArray::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[300] MotionTableManager::PerformMovement: motion=001afc14 speedBits=16f5e1f8 holdkey=16fa5c38 +[301] CMotionTable::DoObjectMotion: motion=6500000d +[302] MotionTableManager::add_to_queue: arg1=6500000d arg2=00000000 +[303] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[304] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[305] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[306] CMotionTable::DoObjectMotion: motion=8000003d +[307] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[308] CPhysicsObj::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[309] CPartArray::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[310] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[311] CMotionTable::DoObjectMotion: motion=10000054 +[312] MotionTableManager::add_to_queue: arg1=10000054 arg2=00000001 +[313] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[314] CMotionTable::StopObjectMotion: motion=6500000f +[315] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e978 holdkey=16fa3328 +[316] CMotionTable::StopObjectMotion: motion=6500000d +[317] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[318] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[319] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[320] CMotionTable::DoObjectMotion: motion=8000003d +[321] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[322] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[323] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[324] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[325] CMotionTable::DoObjectMotion: motion=41000003 +[326] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[327] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[328] CMotionTable::StopObjectMotion: motion=6500000f +[329] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e978 holdkey=16fa3328 +[330] CMotionTable::StopObjectMotion: motion=6500000d +[331] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[332] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[333] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5ec78 holdkey=16fa4498 +[334] CMotionTable::DoObjectMotion: motion=8000003d +[335] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[336] CPhysicsObj::DoInterpretedMotion: motion=10000052 speedBits=001afcb4 +[337] CPartArray::DoInterpretedMotion: motion=10000052 speedBits=001afcb4 +[338] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5ec78 holdkey=16fa4498 +[339] CMotionTable::DoObjectMotion: motion=10000052 +[340] MotionTableManager::add_to_queue: arg1=10000052 arg2=00000001 +[341] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5ec78 holdkey=16fa4498 +[342] CMotionTable::StopObjectMotion: motion=6500000f +[343] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5ec78 holdkey=16fa4498 +[344] CMotionTable::StopObjectMotion: motion=6500000d +[345] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[346] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[347] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5ec78 holdkey=16fa4498 +[348] CMotionTable::DoObjectMotion: motion=8000003d +[349] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[350] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[351] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[352] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5ec78 holdkey=16fa4498 +[353] CMotionTable::DoObjectMotion: motion=41000003 +[354] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[355] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5ec78 holdkey=16fa4498 +[356] CMotionTable::StopObjectMotion: motion=6500000f +[357] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5ec78 holdkey=16fa4498 +[358] CMotionTable::StopObjectMotion: motion=6500000d +[359] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[360] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[361] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[362] CMotionTable::DoObjectMotion: motion=8000003d +[363] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[364] CPhysicsObj::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[365] CPartArray::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[366] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[367] CMotionTable::DoObjectMotion: motion=10000054 +[368] MotionTableManager::add_to_queue: arg1=10000054 arg2=00000001 +[369] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[370] CMotionTable::StopObjectMotion: motion=6500000f +[371] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[372] CMotionTable::StopObjectMotion: motion=6500000d +[373] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[374] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[375] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[376] CMotionTable::DoObjectMotion: motion=8000003d +[377] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[378] CPhysicsObj::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[379] CPartArray::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[380] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[381] CMotionTable::DoObjectMotion: motion=10000053 +[382] MotionTableManager::add_to_queue: arg1=10000053 arg2=00000003 +[383] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[384] CMotionTable::StopObjectMotion: motion=6500000f +[385] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[386] CMotionTable::StopObjectMotion: motion=6500000d +[387] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[388] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[389] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[390] CMotionTable::DoObjectMotion: motion=8000003d +[391] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[392] CPhysicsObj::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[393] CPartArray::DoInterpretedMotion: motion=10000054 speedBits=001afcb4 +[394] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[395] CMotionTable::DoObjectMotion: motion=10000054 +[396] MotionTableManager::add_to_queue: arg1=10000054 arg2=00000001 +[397] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[398] CMotionTable::StopObjectMotion: motion=6500000f +[399] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e978 holdkey=16fa3328 +[400] CMotionTable::StopObjectMotion: motion=6500000d +[401] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[402] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[403] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[404] CMotionTable::DoObjectMotion: motion=8000003d +[405] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[406] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[407] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[408] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[409] CMotionTable::DoObjectMotion: motion=41000003 +[410] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[411] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[412] CMotionTable::StopObjectMotion: motion=6500000f +[413] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[414] CMotionTable::StopObjectMotion: motion=6500000d +[415] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[416] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[417] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[418] CMotionTable::DoObjectMotion: motion=8000003d +[419] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[420] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[421] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[422] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[423] CMotionTable::DoObjectMotion: motion=41000003 +[424] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[425] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e978 holdkey=16fa3328 +[426] CMotionTable::StopObjectMotion: motion=6500000f +[427] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e978 holdkey=16fa3328 +[428] CMotionTable::StopObjectMotion: motion=6500000d +[429] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[430] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[431] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[432] CMotionTable::DoObjectMotion: motion=8000003d +[433] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[434] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[435] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[436] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[437] CMotionTable::DoObjectMotion: motion=41000003 +[438] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[439] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e9f8 holdkey=16fa6538 +[440] CMotionTable::StopObjectMotion: motion=6500000f +[441] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e9f8 holdkey=16fa6538 +[442] CMotionTable::StopObjectMotion: motion=6500000d +[443] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[444] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[445] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[446] CMotionTable::DoObjectMotion: motion=8000003d +[447] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[448] CPhysicsObj::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[449] CPartArray::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[450] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[451] CMotionTable::DoObjectMotion: motion=10000053 +[452] MotionTableManager::add_to_queue: arg1=10000053 arg2=00000003 +[453] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[454] CMotionTable::StopObjectMotion: motion=6500000f +[455] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[456] CMotionTable::StopObjectMotion: motion=6500000d +[457] MotionTableManager::PerformMovement: motion=001afc60 speedBits=16f5e1f8 holdkey=16fa5c38 +[458] CMotionTable::StopObjectMotion: motion=6500000d +[459] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[460] MotionTableManager::PerformMovement: motion=001afc2c speedBits=16f5e1f8 holdkey=16fa5c38 +[461] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[462] MotionTableManager::PerformMovement: motion=001afbe4 speedBits=16f5e1f8 holdkey=16fa5c38 +[463] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[464] CPhysicsObj::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[465] CPartArray::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[466] MotionTableManager::PerformMovement: motion=001afc14 speedBits=16f5e1f8 holdkey=16fa5c38 +[467] CMotionTable::DoObjectMotion: motion=6500000d +[468] MotionTableManager::add_to_queue: arg1=6500000d arg2=00000000 +[469] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[470] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[471] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[472] CMotionTable::DoObjectMotion: motion=8000003d +[473] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[474] CPhysicsObj::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[475] CPartArray::DoInterpretedMotion: motion=41000003 speedBits=001afcb4 +[476] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[477] CMotionTable::DoObjectMotion: motion=41000003 +[478] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[479] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5eb78 holdkey=16fa6a48 +[480] CMotionTable::StopObjectMotion: motion=6500000f +[481] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5eb78 holdkey=16fa6a48 +[482] CMotionTable::StopObjectMotion: motion=6500000d +[483] MotionTableManager::PerformMovement: motion=001afc60 speedBits=16f5e1f8 holdkey=16fa5c38 +[484] CMotionTable::StopObjectMotion: motion=6500000d +[485] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[486] MotionTableManager::PerformMovement: motion=001afc2c speedBits=16f5e1f8 holdkey=16fa5c38 +[487] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[488] MotionTableManager::PerformMovement: motion=001afbe4 speedBits=16f5e1f8 holdkey=16fa5c38 +[489] MotionTableManager::add_to_queue: arg1=41000003 arg2=00000000 +[490] CPhysicsObj::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[491] CPartArray::DoInterpretedMotion: motion=6500000d speedBits=001afcc0 +[492] MotionTableManager::PerformMovement: motion=001afc14 speedBits=16f5e1f8 holdkey=16fa5c38 +[493] CMotionTable::DoObjectMotion: motion=6500000d +[494] MotionTableManager::add_to_queue: arg1=6500000d arg2=00000000 +[495] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[496] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[497] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[498] CMotionTable::DoObjectMotion: motion=8000003d +[499] MotionTableManager::add_to_queue: arg1=8000003d arg2=00000000 +[500] CPhysicsObj::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[501] CPartArray::DoInterpretedMotion: motion=10000053 speedBits=001afcb4 +[502] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[503] CMotionTable::DoObjectMotion: motion=10000053 +[504] MotionTableManager::add_to_queue: arg1=10000053 arg2=00000003 +[505] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218 +[506] CMotionTable::StopObjectMotion: motion=6500000f +[507] MotionTableManager::PerformMovement: motion=001afc40 speedBits=16f5e4f8 holdkey=16fa5218 +[508] CMotionTable::StopObjectMotion: motion=6500000d +[509] CPhysicsObj::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[510] CPartArray::DoInterpretedMotion: motion=8000003d speedBits=001afcb4 +[511] MotionTableManager::PerformMovement: motion=001afc24 speedBits=16f5e4f8 holdkey=16fa5218Detached diff --git a/tools/cdb-scripts/walk_run_motion_trace.log.console b/tools/cdb-scripts/walk_run_motion_trace.log.console index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3b916dc19129430d021ec509be6b0a3e99ed10ba 100644 GIT binary patch literal 99876 zcmeI5eRCW)a>n=XROLJHu97<6C8t;v#TTc16afPv!rY^81clIb6J#@4S+CT)DgW zQNGRh-pcp*)?2yyh1~tMy!gyd@_&{1ee;=j&Ara#Z+x;})88y;$yW2~N_i~bzmPj2 zRqu$LPvqyG=zSrw&lX3`-@;v<$)`^mjV~I_&Xz4i+t20NOS#wkM%&LDEgv*(J&~(V z#6nmMD6=j8$D0u&aWEJTaQ4_YJ91#;rkky+=bWg&b&O@_`BZM+z{kQJg7aMergsyZoC;HaclAM;+p*M z+G=os@12YN-!9{#AB(mYUrQ8elq+HmMMN+QtUABCEB%SS148ucp_rKS&y>3Qtbb#1 zJ!$=EVw=bZy^a>&$-j(Xj6k&Dz4+l>)9*fx5Ms=}U82wS;$Rt{_a#D(8jO1@I-iQh zTZ@kbb$%_szm~rd&5S>^sykADCGEYGzbP_ri6^v2Tw81}_OE)zHMzRKxFIsP7XKpe zb{pBZ<*pYpj>8vT3FfNYqegB@Cq94KpeN&mjRn`_o;pUt2kcJh3f6+pm(6H*Ed7)S z=SO?Omb6KGAMfae-^#Zb!)dW0Hj)^{j^wYa-%s@?jRd{Z5ArQ~pN`RHpSKbfZ{)>j z2ac!iV%Lxd`oC>j&3H;b_P(Bq4sb5K`bO@7U+rZhh*=Yf`1n+QE*lFTFF$MF8TR{a z;}1ogCt`s&G7^6;c2e}jDp(pnV0qr1iO!7V?QbdrIucvr8y(B@cc69mMcQ%09$w0C z?hAJBTt%wlIR73-^=Z)O=*%t3K zmTmX4odzEcH?s9B={0AK&t%c^ZT;~R~{Ed+@8oaBvHx4`hn=LE%!-& zENt_(;OkTI-TF4~ie+G4!Ma-Gm9jwlkgJw+G#NVqN{F=m_ zW~Owmk<(>Ytgvba@csIRcF=iQd5?P;#XV@VDZ=ADIQ7L9>g8L0xa`}8^T*O>Hj6$u z>Pb;=2USc)z?q|^F21OLC~@%4mJZ;z`v$plJ!SmG_gI>A0j6k1kHg zWXA9?WAaI6?*3Y2lcUW^ekoGge31x8pUvV-d)5=-4-e(_rAWUczW=IsPRz{6BLEYVs$&6R+0z6ghByb@o(5*DksswK_cM<7JOuZ}oU?a!L^y@~FAj`ktyN zXgpeRf)OFCbF}s7W~^e2B5V6lWdBj@f?d~h%rN@WFUgj1<>nGI+p;&a!Q%oOkQa_; zH(WW8R|#Ik{RSIsOU&dgVEsd-U;(|7p2_+w@aJZf0Poj(L3?kTgXxu!y>5FU9|3oo z2zb_H@pF=yVT2e^){Fm*BlslisuG-=JuN4~MZkvj`5f9x4kdU%ubhe&CBBd2pM4O2 z5)8U%aOkwa_B%^S*86rO9ygP&b38DyESo`F4fklR%{#6aMt&TxLJ#i+^vf=dyaR{?jGm1)X-pGhLnX{SWkK zSiXHM(Q(j>0Qt5bNpFwm2>VU{-NgGU{UB>`q?*0e?_Al`;;Z=RC3yfOg|QAV=#|p7s~CO1lQ_N<+&K}Hx+eXJ z^;)yZ`X0#2&GnU$m@C^74;GniuXHgeWFLr`lh$lc%_G;*kF!pHXeS$wieA+7N z>C#U=ZJ4>C5qmHGBil1r!Lrdndj=TUH zm;c?6PcerAYw1a3$Dc1gllw4NVjjLL|JS`uii@v>BeCv*W_gahEB6cIk$L2$+}qf* zFB;}$ms>JQ-jV%d53Ve+Zk~-lm{*4g;R^XAyXLb5dq|$jX#J>Y1rUa27>x$T70D^7 zM(d@=Xhkf%lIU9BD?r4MJu!^DA$`>54L+%#$vAGvv|h5t-6|^~63MHTv^PeAdBvUf z<}+E5`L3uBvcle84N|@yuKB#s-ktuVAOYwAqUfFncAl}v2z*%YBi+$uk)Y8*pEwrW z*~CX$2k=xda=lzy4@*`V))&YSS=P~K5B8Z+8v@o|G&$B&X*)hTk=Z0GmERR;biC|| zrV-qJGbVq#k$NKN#CXk07JZfd+beKq4e8!l`ZmDK+3@hLHR9NR+nKqr&z9bLE*v2znK&~k+G?%@*1o>garCk3 zgvEalURtu6P9HN&3DQHBVZHP?{@9fS&OVe`(_Q(=p)As^mznf1XX6U5((Dm?lBif= zO&_MoXvhAMBk_-}K-!-B_4!q_3T&Nmhbx=1fj>!HvC{rteiI8+pQQJ_tDIE?MsgU1 zefDTe*7c;E>^KftlQsYNWZx3(XGdy2`a^agL;$<5^Zl+{-$2HI3Ja|}D6zKn_#iv1 zEc#kZmIiLF`&jbrUfltV?-^}vp0Fw#{$OjjsZ2?{BNWEBUIU^kpc}2%EBY^1S=C^* zvZRZ6Se}Y7YH)LWTV=Gz9=#H9&?lSfU9gUNsFLamvg77=YxkxRcSpur_KTJ50uD1i zwJ$m2Q|V!bHS1d4jrDsXlRd~G1F??LD#x-`tG_k(ks9yg5w4q?qt*EsV`sTB8I`G5 z(owz|V_liLLPwT1Ket8B>fDwq$3bLchnOl=>Fh6Ua>7}&VkbRxxuEkHKPcx@x^kjcXPCA(-i)Px*o-M9eUtg7; zLp5gc;Q6t%J6|^ia}2xrk*-&6B&B;-M>v(?U1Nt(xffW(MGa;rHvfHerRy<5vxsBdq`m5&5HIH`a& z8|sVE@w~aquN#Je%pVt@HqpRNn=qy_kF>v7tneE>j+(!~J7|Md8uAyij-NKyO z+Q^o~@kMiwGx?X#Zb=1$cO6gF?`3vFOhjPylV~@lVL>YDF*jYW8`w<(<5LE~rGc(RZ?%!s#gI@|)c%)^lFS zJFtWPWO`zWhgZ51$y-T0Gj*Eakr=?BIOGJD_?)zLaz8r}vxLiA!e zMuzgb>@hT;zMD3li|^EbKAn!5TJ87J9-?yd=xkdB%UET8_xJyW%KwM57ct;K?RR6u zNsLn3J3y&-i@%e$d@OOqDASE<{fwTy*T zu6|$8hFtdx>5p1*{zCXNteN@>xHG$asP{mV^!>Jz_tV{h59F@w5$gXoqhePZva+Ac z&trMrm9gW6%pe}hz1Vr!cQ3eO>R#;GW#;oj?x@`Tg|v;@H1192weOBP&UdvlU0%pN z9*7m6$_$4)f6;R%%@(F~dmycP(d?%BXZiP*wDMk~Wm%h*EvN4Lx!miX+zr1zmUnm& z4|7GW`cST?Y}w9ic3ZCXeCW&y#b=Gax0ClVCZG0+wwwK9x5R39<$gpQ9=R>H?E1r) z`MSGNt%0wJXKh*B%RoijnXt9^2N^f$Kk$t7Nf)c%N2}Ebw^mkJsDG2L)3kzcDB>~7N6K!{8D%XyOWd| z(Yohy?YX?p7vC)YR>q`%lWRleFjr{vm`~--WTSqR>o8*G(#H}T>;{5o+F#LyGk*T* z%4WQWU7g7c0Y-Wu$j4kHb$ws1|D$|9wz%DEt3l1{&1cxpLfb!DO4<@Gh<`LDIY;I{ z<%d}!b|eB|B>&Z1BSIK`^)s*JZ~Wz#O-yz@dng*|oJm`PkB9?3#fSUD+gQsyPTXN# zB!KJhq(^X?4X0v^69=11`Tr=;83z439R-ZDM8~F4(C*t$B?k1gp!@QUnV_CHNmM)% z8J7}g>>+p~_{W)loN1%02&Je{N7|-Qu_Zl_+0?PjmQEzv>!C&pP2fbF*Y)8b3LI;S zSR#vcETb{A+ZVEv^1XP2pW0E>Cx`d}lU~UTKE!}s+iqR^%Nb3`$4udS&GwO5VvbHl z97v-pMImPaQ_@Uc8QUnu6`%Cj`yWnEX1+7Tn^gRV98wq;j2|d#BtQCY<&&j7c+>m$ zBY*6x6e7d>J{%w39MOlmkNe_^{g30voQ%IDU+xzA@>Rp%VSC>Qzh@qm?uTA}*0nWR z8cuqnZjxGo^sI^#OQ>@@k5hi~4z&xZkC1*Txg}8ffp_U+Ppd2QWmV1DCHhkFz7ez}j&})-;E! zWbyjgS+`%rmQ=5CYJ~^S$|5?Jl}A-hFVOZ5%UO2Y9WuS(J1d|mJZQ%>EAnO?MSFT$ z&8QxxlMls*#5F7LX?q3y+{v;9>ihy*Q1q`iD~N4ro*M(HV_79}Jr-7nbrn7RB<-iZ zxE=?shv_sOUe}LnRs!ncb!<{#yz}$MDBK_DLfc?N8 zE0uBEZY=5g0&59GQ2V8r%^p;Xpdst=U2faWUX;f*ReGjXO>R3(rzx{5wYe=PpQuIq zb78(YTWm{8$G*$3GM1IcN>1&v2>N+Bz-kNy#$7Y6yJLwbu9a`6 zQMu-7*f?=*nU%EuxmEQ%$FsQhWlPH9y6d+bx25WhxK6ir?%v`&up9~v9oZ}oz_W0s zvXs__rTW~acuf`8*p@O5z{xAqSZa**a8PABOZgc1cqlLxW*sovtU|#I?_jk7ZD8wtj;BGvuc|7y=5x~rNggl%UL!|FIEXqi?unbt6!rpva}Um@pI(HzeD zW)QJ0m1%LcK03s*@-v9t$+9iX!Kpe=Mfaxsff~a4R`l~#&YHD;`18)gc+Rla+xveNr=D#Hi9gQ`{_T&6)LT(X{@Xj4I*WDEw=l^Pzd^ zsIn@npL5zSAI|Ex2&&7Lx*7D;=pW*mthKE>?q4DJ&%G;iRVQC2X7llv{8E1n$EI}8kZ;hWhH^#sC?&oq`|mq5RzHmgqLMDeLCrMsv-wkjLnx>=k)959B2CR5=y zPejk}r1RF4at7vHEa}7c&5?qYt=i0vNH7lHKL+}-A|9J`97LKgyHdLc*hq*bmMN*d z4%F?B>*p?mbJ#f@l|VC=2xF(?}_sH#g?J}G_e&hy>6Z?MxiurGPlma%4R1x&9y)^zje z>*qB*^E#ZA!4tE_7;A=U<*fnT9Jkn+n$LqAWKPQfQQBu~h1${n*z(P5Vtd+HWA&hE zIo(mYY~~(ID;aCpg=CRbH^P~_*-M{G;)(E3-&uF&K2$4T#$|pHo@eU52#@U(_zC z_6!m3%e0g+;81sj8@|iLCDfofae5Pe}XD(ys5Bw3H$MB)+3n71Cb)WTX znc|Vd#}7h9=bZ8f-ullWu~o9Z;EgS-8?86HioI;;$(c0s;0IO#T0g?Ob3M;YeXqsL zYck-WTKTf(^NVoH%B1n1xu}l;$My^n?#r~4@q<`eKR*ch1Nxw+ug%S8`x9i$L;k?3 zI@9qne}1sPTrW+xphmVO@Pql(gs$#Cm`hb?td%coK6X#^u3hafvoig$#h@Tb=9m?G_2UIagz8toqQQT1{7JRKkG05njJWG`6SlgK?@vV`=^TAk@Z8 zS0`GQKk)l({QZf6A52#(dL(nC`uh*2tQNg4*lB#bHhRV*hcAB+DnaLyKVT>MyqLPL zC!&9TZ%Yk+fTS@hLc1}QtF1*7TivW3;oZ4jdHw*stU{=6gb&rqmo=Xk1IDVhURsL* z$M#fyfFxh0Wvu)Gn#^m}(~nBV;QM_xG-Gc3fEu0YFuXoLK>s|354D!%50Ey;{fX!m zP`zgMwL8LiFr9OA=ak9w_C$6dp#RwG3;55MX(>a`uJ`i%;QBIWQkx&F&h@rkI{~p! zVCFR=e5h8wtohvd0i$Kz2#@V)`~VMnu%Il$V`=&P0kxn$E8P-q152mXI9V35$ zuk#pQj~`eyX#LgME|z;n;(0y?bZ$=q_40#IHQL`3>*No-m7rzl*^Q|@KL|CQwfR9j zv-D)hcqVdS<~8}jP_5>*KhYcEp=z{Ngva(Ye!x@s>hptGT0cLq{Rc#nkBiTZA58gd zP^>jwwq=a`!IaMjg-aRVu8p2uEcfsO`iag1>SkYuYBevW?%SV;xARQB4nN>T!}{pi z^kCs>gO>0EzqRrhKGa%aeZi_h z=axSRRi1Uxvpd3gSc_GImSV!(&@)txrd#0O-5uN0&~u!pgvQeP(KF1hLQQ9Fe$buk zd1A>bKlvWk5ype*Rt-9rp2%L-b>|;&b|7ma zhH0T{G~EI}ddBt)l6;w#GW3k4m7r&+>8#BUR_A(s^Qv_H0_$hv>`xr3l`m^P_E_VO zaLdZnityN;A;Nu`ma+(srS(U+)h`f9(`C#{@&|sOO}Rf24Dn<(heUV@Kk!;>ifYin z53Cw=Zu}rrdDfj>bw?NvKd@@hH1>4o3`1gTjGh>(M$;{*#}D?MYocZ7+4Ww2eIe9z z*5(J@xt=eUEE8Tg!iQ=#xBZDe1{|tJYejf$PvZv%qwG(NrS7ES{>9+?uH`?$F=%$D~o&&$MEDs{fEQMkXefmLG@E z#8y6QGp#P~D`#5hHBKgGs8%0xCI+k4&cwv_3=uvChR4#%B77_i@8*Sk5k3xv57UZo zPnC(OKU-$sdTk7k?P(EiRif#!qRZgRX3Md({s^~RBW<4B{&Ua#5B&}6)3#*X9lHC* z<`PJo4#VruCD1>Q;X|!uxkjWZj?XLCh+YBJYwqOejxZif=WLk|89nP)ipKUd^c<&B zG?vzno;H_aEjH8?)t%#YXPll`qUwTJn$un-tmWH-KCf&#`seq(_;%~ZUJeniIGrBT zHsuGgw6X~Iaq;>2%&OIr4x_%pCtugokvpmI(bPH-&EARtGVneCA=-I_` z4|>wmO|SXz1AA_0+B0=yt7LX{)a+B}e^v_>N1T>tm{z}1H1=|k8p{UB6YP8SQxB0QGXAK@W? z0H^S1GGxRX1C_$_MANpcG2(^%fz^AaWp({F#9Af%z>DElMLOonZQuvvRf=}8+`|vX zsuUfn)x4OxKeqBr-8Z|6?HSP1muV?O&sbVNdWQUgRf^W<2i>`zC&EJoXx%m9p<4M? zK8K*^7-J33)YExI8vp(LAhxGPxK)X!<#flHvIvi*l|{IZi_gzz<7dnRKd^ewQojwc z))@T2sz|5d2jf+WcCp-po?}&t4%KR2Ox;&!&rI5~GU?voN6*-vhMwb8ipJ7P(9+^%vxnAFXsP-PRL&@g^3t1+-Ef|;RoIbx95qDX|0ey7_U;ai{&1EFjl4L zP_5?0)O{yWlF`mH^*Z^3y{qhbX)i28&#w1!wNA9zawfHR;I3l1?<_U+vP}4x{J^Uf z7M@eC+8f~|m7=}~kG*XCz^X*+MtCf(pC8y+YMdQ9xBZEyqEr8>C;RBpI#_bb{fSoZ zS?aeTY^sDG_#-@z;q~?>j#nw##c~fnuxEwV-n}(cD__=pNNknNu6%X&oZ_3`+u}pd z*q+ws$Eg&JrS+p{SYNP8(YfUhLIvoU{J@*ln6grIoCvq9Od9|F8M4(8JLG@7Rib4K zIF{BQ;URzEt@oTCDj9?C_u0@9FXRvGDWPdub+avH{J>M~!5#A$KGa&4KS0_X_a~y4 zWy0&CXLp1tXDw(oFQ)FxO`(6Dsn_5KNb+S`%Fwgxy7VHq*B$Z3KqW@eT+PYQ5ijHqu&{^M4aty|@dF=*Pf-mT_<>b} z&W#_0D$hCb1FHs|OHcIXrb5+dx&?mp?BfSJGufX=92wuP%@5l1baq^&Gl|UOuglZg zP8UauP`{ujQz$CdjqqVwSv6=nKBgaQyb&I%M$;|uM|f;cW5C{LhL-VzSXw_nu>FZ( z(sUW~5`N(KS$@^C9zU?Bgr?)8ZiJWc1HZM#&$a}9Fl99;HH&Es-SjMW`i7x@+TS&D zQ*CSc_9vEM!X|SDtN%>5x^9$j%g9#SZ{BB$j%mrMGU{VIjR3~y8y&{9q~UyRCe}SU zKDSmGo<@L$Y|mm{CN@+n#rY|EaQ&wdVAa~0n%JJk#O!&arIsp-@K{%tdewY zv*}PFS~tSu*O^rDY6xGfQedrc{&7 zEji9?df)kkjPWcPYDwoco3_f++WQ{5^RXO1Y|rtn3U&OrJCfC&zN*j7^O&JpHQfUL zx<+hIV`AP4)UpVVrIj$T@YK=T5#F8o^%J>zAwMV0`{R|<;6IU7fj9EWcZ)Om_k8iG+*NnA^RIWs9Id9x#2<;IJcj!s z9Q~F3uVc@?2#@WleF;fp>}`*w^<%iLO{0mA!*9;_#`4+ttF5+o4Qow@;U%+ae}v~T weCSHPt+rw Date: Sun, 3 May 2026 17:35:55 +0200 Subject: [PATCH 26/32] revert: AnimationSequencer locomotion-cycle full-reset and link-skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both changes were too aggressive: 1. Full queue reset on locomotion-locomotion transitions (c06b6c5) — turned out the user's tests went through Ready (no direct walk↔run transitions in the wire), so the fix never fired and didn't address the actual bug. 2. Unconditional skip of every transition link — killed ALL transition animations across the board (jump landing, run-to-stop, sit-down, lie-down, etc.) for every entity, not just the locomotion-locomotion case. User correctly identified this as a much bigger regression. Sequencer is back to pre-c06b6c5 baseline: ClearCyclicTail-only on motion change, transition link enqueued normally. The walk↔run-direct-transition issue (and the broader remote-only-doesn't-update issue) remains open and requires a different approach. Confirmed regression isolation: local +Acdream's transitions in acdream client work (visible legs switch correctly), and acdream chars observed from a parallel retail client also have working transitions. The bug is specifically when acdream observes a RETAIL-driven character — somewhere in the inbound UpdateMotion → animCycle picker → SetCycle path, the visible cycle update is being lost. Filed for separate investigation. Adds an env-var-gated HASCYCLE diagnostic in OnLiveMotionUpdated that confirmed cycle resolution succeeds (HasCycle=True for both RunForward 0x44000007 and WalkForward 0x45000005 on style 0x8000003D), so the bug isn't in MotionTable cycle lookup. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 8 +++++++ .../Physics/AnimationSequencer.cs | 24 +------------------ 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e7b658b9..43ba1c1f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2936,6 +2936,14 @@ public sealed class GameWindow : IDisposable // gets the wire's (or seeded) ForwardCommand verbatim // so apply_current_movement produces correct velocity. uint cycleToPlay = animCycle; + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && (animCycle & 0xFFu) is 0x05u or 0x07u) + { + bool hc = ae.Sequencer.HasCycle(fullStyle, cycleToPlay); + System.Console.WriteLine( + $"[HASCYCLE] guid={update.Guid:X8} style=0x{fullStyle:X8} " + + $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}"); + } if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay)) { uint requested = cycleToPlay; diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 76de86a9..fb33c0f1 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -434,29 +434,7 @@ public sealed class AnimationSequencer // cycle. Without this, the old RunForward → ??? link would // continue draining for ~100 ms before the new Falling cycle // starts, defeating the "skip the link" intent. - // - // 2026-05-03: ALSO do a full drain when transitioning between - // FORWARD-LOCOMOTION cycles (Walk↔Run, Walk↔WalkBackward, etc.) - // — i.e. when both old and new motion's low byte is in the - // {0x05 WalkForward, 0x06 WalkBackward, 0x07 RunForward} set. - // ClearCyclicTail alone leaves _currNode in the previous cycle's - // non-cyclic head (link frames from a Ready→walk transition), - // and the visible legs continue playing those head frames before - // reaching the new run cycle. The user-reported symptom: walk→run - // direct transition (release shift while W held) did not visibly - // switch the leg cycle — body advanced at walk pace until the - // next motion event (turn / stop) re-fired SetCycle and finally - // aligned the queue. Live cdb trace of retail acclient.exe - // 2026-05-03 (tools/cdb-scripts/walk_run_motion_trace.log) shows - // retail uses an additive add_to_queue with no truncate — the - // MotionTableManager's per-tick CheckForCompletedMotions handles - // the natural progression. We don't have that machinery, so we - // emulate via a hard reset on the locomotion-cycle transition. - uint oldLow = CurrentMotion & 0xFFu; - uint newLow = motion & 0xFFu; - bool oldIsForwardLoc = oldLow == 0x05u || oldLow == 0x06u || oldLow == 0x07u; - bool newIsForwardLoc = newLow == 0x05u || newLow == 0x06u || newLow == 0x07u; - if (skipTransitionLink || (oldIsForwardLoc && newIsForwardLoc)) + if (skipTransitionLink) { _queue.Clear(); _currNode = null; From 357dcc0547c4ed1cb52cd8a56b1b2d39fde49207 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 19:54:54 +0200 Subject: [PATCH 27/32] fix(motion): SetCycle forces _currNode onto first newly-enqueued node; skip SubState commands in UM Commands list iteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the "remote-driven character animation cycle does not visibly switch" bug: 1. AnimationSequencer.SetCycle now snapshots _queue.Last BEFORE appending the new link/cycle nodes, then forces _currNode onto preEnqueueTail.Next (= first newly-added node). Without this, _currNode could stay pointing into stale non-cyclic head frames left over from the previous cycle (typically a Walk_link or Ready_link's tail), and the visible animation continues playing those stale frames before the queue advances naturally to the new cycle. Local player avoided the bug because PlayerMovementController fires SetCycle in a tight per-input loop that keeps the queue clean; remote player accumulates stale link drains across many bundled UMs. 2. OnLiveMotionUpdated's UM Commands list iteration now skips SubState class commands (high byte 0x40-0x4F like Ready 0x41000003). The router's SetCycle call for those would silently override the animCycle picker's own SetCycle a few lines above in the same UM packet — verified via SETCYCLE diag captures showing run/walk being immediately re-cycled to Ready. Only Action / Modifier / ChatEmote class commands (overlays that interleave with the cycle) belong in this list iteration. This fixed the landing-from-jump animation issue (user-confirmed: "landing now works"). Walk↔run direct transitions still don't visibly switch the leg cycle for observed retail-driven characters even though ae.Sequencer.CurrentMotion correctly transitions (per-tick SEQSTATE diag added — proves the sequencer's logical state holds the right motion). Bug is somewhere downstream of SetCycle's queue/state setup, possibly in Advance/BuildBlendedFrame or in how seqFrames are applied to MeshRefs for remote entities. Filed for next investigation. Adds env-var-gated diagnostics (ACDREAM_REMOTE_VEL_DIAG=1): CMD_LIST — what's in the UM's Commands list at receive time HASCYCLE — whether the requested cycle exists in the dat SEQSTATE — per-tick sequencer.CurrentMotion + CurrentSpeedMod for the observed retail char (1Hz throttled) Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 55 +++++++++++++++++++ .../Physics/AnimationSequencer.cs | 31 ++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 43ba1c1f..8c7bc94d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3113,10 +3113,46 @@ public sealed class GameWindow : IDisposable // InterpretedMotionState.Commands[]; the router reconstructs the // class byte and chooses PlayAction for actions/modifiers/emotes // or SetCycle for persistent substates. + // + // 2026-05-03: SKIP SubState class commands (high-byte 0x40-0x4F). + // The animCycle picker above already chose the correct SubState + // cycle based on Forward/Sidestep/Turn command priority and just + // called SetCycle for it. Letting the Commands list also call + // SetCycle(SubState) would OVERRIDE our chosen cycle — e.g. ACE + // bundles Ready (0x41000003) into the Commands list of a + // RunForward UpdateMotion (cdb trace 2026-05-03 confirmed retail + // does the same), and our router would silently re-cycle the + // sequencer back to Ready right after we set RunForward. That's + // why observed retail-driven characters never visibly switched + // their leg cycle even though SETCYCLE diags fired correctly: + // a second SetCycle call wiped the first within the same UM + // packet processing. Only Actions/Modifiers/ChatEmotes (overlays + // that interleave with the cycle) belong in the list iteration. if (update.MotionState.Commands is { Count: > 0 } cmds) { + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + var sb = new System.Text.StringBuilder(); + sb.Append($"[CMD_LIST] guid={update.Guid:X8} fwd=0x{fullMotion:X8} cmds=["); + for (int i = 0; i < cmds.Count; i++) + { + if (i > 0) sb.Append(", "); + uint fc = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(cmds[i].Command); + var rt = AcDream.Core.Physics.AnimationCommandRouter.Classify(fc); + sb.Append($"0x{fc:X8}({rt})"); + } + sb.Append("]"); + System.Console.WriteLine(sb.ToString()); + } foreach (var item in cmds) { + uint fullItemCommand = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(item.Command); + var itemRoute = AcDream.Core.Physics.AnimationCommandRouter + .Classify(fullItemCommand); + if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState) + continue; AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( ae.Sequencer, fullStyle, @@ -6475,6 +6511,25 @@ public sealed class GameWindow : IDisposable IReadOnlyList? seqFrames = null; if (ae.Sequencer is not null) { + // Per-tick sequencer-state diag: prove whether the sequencer + // for the observed retail char actually holds the latest + // motion (= SetCycle landed) OR is stuck on an old motion + // (= something elsewhere is reverting). Throttled to once + // per second per remote. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && serverGuid != 0 + && serverGuid != _playerServerGuid) + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag) + && nowSec - rmDiag.LastOmegaDiagLogTime > 1.0) + { + System.Console.WriteLine( + $"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} " + + $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}"); + rmDiag.LastOmegaDiagLogTime = nowSec; + } + } seqFrames = ae.Sequencer.Advance(dt); // Phase E.1: drain animation hooks (footstep sounds, attack diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index fb33c0f1..7fc7e683 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -447,6 +447,21 @@ public sealed class AnimationSequencer // add_motion chain (MotionTable.cs L100-L101, L152-L153). ClearPhysics(); + // Snapshot the queue tail BEFORE appending new motion data so we + // can locate the first newly-added node afterward and force + // _currNode onto it. Without this, _currNode can stay pointing + // into stale non-cyclic head frames left over from the previous + // cycle (typically a Walk_link or Ready_link's tail), and the + // visible animation continues playing those stale frames before + // the queue advances naturally to the new cycle. For remote + // entities receiving many bundled UMs over time, this stale-head + // build-up was the root cause of "transitions between cycles + // don't visibly switch the leg pose" even though SetCycle's + // CurrentMotion/CurrentSpeedMod were updated correctly. Local + // player avoided the bug because PlayerMovementController fires + // SetCycle in a tight per-input loop that keeps the queue clean. + var preEnqueueTail = _queue.Last; + // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); @@ -478,9 +493,21 @@ public sealed class AnimationSequencer } } - // If we have no current anim, start at the beginning of the queue. - if (_currNode == null) + // Force _currNode onto the FIRST NEWLY-ENQUEUED node so the + // visible animation switches to the new cycle/link immediately + // instead of finishing whatever stale head frames were sitting + // at the front of the queue. preEnqueueTail.Next is the first + // newly-added node; if preEnqueueTail was null (queue was empty + // before enqueue), the first new node is _queue.First. + var firstNew = preEnqueueTail is null ? _queue.First : preEnqueueTail.Next; + if (firstNew is not null) { + _currNode = firstNew; + _framePosition = _currNode.Value.GetStartFramePosition(); + } + else if (_currNode == null) + { + // Defensive fallback: nothing newly added AND no current node. _currNode = _queue.First; _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; } From 7f1bd1809a9c9e001b9d8892e0d34f08220c139f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 19:59:22 +0200 Subject: [PATCH 28/32] docs(research): investigation prompt for remote-anim-cycle bug Hand-off briefing for the remaining "observed retail char's leg cycle doesn't visibly switch in acdream" bug. Captures everything we learned today including: - All 8 commits shipped today (turn-sign, observed-velocity revert, retail-faithful tick, Commands-list SubState skip, currNode reset) - Confirmed wins: body translation, run-in-circles, jump landing position + animation, turn-left direction - Confirmed remaining bug: walk/run/idle leg cycle on observed remotes + residual steady-state blippiness - Diagnostic infrastructure (FWD_WIRE, CMD_LIST, HASCYCLE, SETCYCLE, SEQSTATE, TURN_WIRE, OMEGA_DIAG, VEL_DIAG) and how to reproduce - cdb live trace findings (retail uses additive add_to_queue with no truncate; we have ClearCyclicTail + rebuild) - Six concrete next-step hypotheses - A self-contained prompt for the next research agent - Notes on rejected approaches (link-skip, full-reset, scaling hack) Co-Authored-By: Claude Opus 4.7 --- .../investigation-prompt.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md diff --git a/docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md b/docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md new file mode 100644 index 00000000..fa2b619f --- /dev/null +++ b/docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md @@ -0,0 +1,253 @@ +# Remote-entity animation-cycle bug — investigation prompt + +**Hand-off date:** 2026-05-03 +**Status:** open. Multiple shipped fixes today reduced the remote-entity motion problem to a single residual symptom — the **leg-cycle on observed remotes does not visibly switch between Walk / Run / Ready** even though every signal says it should. Plus minor blippiness in steady motion. + +This document is a self-contained briefing for an agent (or fresh session) picking this up. + +--- + +## What problem are we trying to solve? + +When acdream observes another player driven by a parallel **retail** acclient.exe (connected to the same local ACE server), the remote character's **leg animation cycle** does not visibly change when that retail player switches between Run / Walk / Idle. The remote's **body** moves at the right speed (translation works), but the **legs keep playing whatever cycle was active before**. + +User test: drive `+Acdream` (or any retail char) through `Press W (run) → release → Press shift+W (walk) → release` while observing in acdream's window. The body moves correctly but the leg cycle stays in idle pose / walk pose / whatever it was. + +User-confirmed working perspectives: +- Local +Acdream's transitions in acdream **work** ✓ +- +Acdream observed FROM a parallel retail client **work** ✓ (proves our outbound is fine) + +So the bug is **specifically** in how acdream renders the visual cycle for an observed remote-driven character. + +--- + +## What we shipped today (commits in chronological order) + +``` +0997f96 fix(motion): landing fallback + TurnLeft omega sign + vel diagnostic (L.3.2) +9960ce3 fix(motion): preserve signed TurnSpeed for remote turn animations +842dfcd fix(motion): retail-faithful per-frame remote tick (L.3.2 follow-up) +b1d8e12 research(motion): cdb live trace of retail walk-to-run transition +a45c21e fix(motion): retail-faithful remote tick — clear body.Velocity, drive via seqVel +c06b6c5 fix(motion): full queue reset on locomotion-cycle direct transitions [partly reverted] +a2ae2ae revert: AnimationSequencer locomotion-cycle full-reset and link-skip +357dcc0 fix(motion): SetCycle forces _currNode onto first newly-enqueued node; + skip SubState commands in UM Commands list iteration +``` + +**User-confirmed wins from the above:** +- Body translation no longer races (was 2× server pace; now matches) +- Run-in-circles smooth (rectangle-effect gone — body rotates properly between UPs) +- Jump landing position correct (no mid-air force-land) +- Jump landing animation works (Falling → Ready visible) +- Turn-left visibly turns left (was animating right with snap-back) +- Signed TurnSpeed preserved (ACE encodes TurnLeft as `TurnCommand=TurnRight, Speed=negative`) + +**User-confirmed remaining bugs:** +1. **Walk↔Run leg cycle on observed remotes does not visibly switch.** Body advances at correct new speed but legs continue playing previous cycle. +2. **Residual small "blip" corrections during steady-state motion** (run, walk, strafe). User describes this as a periodic micro-jitter — small but visible. +3. **(Possible)** ~20% steady-state walk overshoot (`maxSeqSpeed=3.120, serverSpeed≈2.6`) per VEL_DIAG measurements — not yet root-caused. May or may not be related to (2). + +--- + +## What we proved about bug 1 (the cycle-doesn't-switch) + +Per the diagnostic infrastructure built today: + +| Signal | Result | +|---|---| +| `[FWD_WIRE]` — wire-arrival ForwardCommand transitions | ✅ ACE delivers `WalkForward → RunForward` (and direct walk↔run) correctly | +| `[CMD_LIST]` — Commands list at receive time | Empty for walk/run UMs; contains Ready/Action class for some others | +| `[HASCYCLE]` — does the dat have the requested cycle | ✅ True for both `0x44000007` (Run) and `0x45000005` (Walk) on style `0x8000003D` (NonCombat Humanoid) | +| `[SETCYCLE]` — animCycle picker calls into AnimationSequencer.SetCycle | ✅ Fires with correct (style, motion, speed) | +| `[SEQSTATE]` — per-tick `ae.Sequencer.CurrentMotion` for the observed remote | ✅ Holds the new motion correctly (e.g. shows `0x44000007 speed=2.939` after Run press, then `0x41000003 speed=1.000` after release) | + +So: +- ACE wire data is correct. +- Our parser updates `InterpretedState` correctly. +- `OnLiveMotionUpdated` calls `SetCycle` with correct args. +- `SetCycle` updates the sequencer's `CurrentMotion` correctly. +- The cycle data the sequencer would play exists in the dat. + +**But the visible leg cycle does NOT update.** Therefore the bug is **downstream of `ae.Sequencer.CurrentMotion`** — somewhere between the sequencer's internal state and the rendered MeshRefs: +- `AnimationSequencer.Advance(dt)` returning frames from the wrong node +- `BuildBlendedFrame()` reading from a stale `_currNode` +- `_currNode` advancing through stale link/head frames before reaching the new cycle +- Or how the per-part transforms returned by Advance get applied to the entity's `MeshRefs` for remote entities + +We attempted a fix in `357dcc0` that forces `_currNode` onto the first newly-enqueued node in SetCycle — user reports **no visible change** after this fix. + +--- + +## What's different between local (works) and remote (doesn't) + +Both call **the same `AnimationSequencer.SetCycle` method** in `src/AcDream.Core/Physics/AnimationSequencer.cs:360`. So the sequencer code itself is shared. + +Local +Acdream path: +- `PlayerMovementController` → `UpdatePlayerAnimation` (in `GameWindow.cs:6664`) → resolves cycle → `ae.Sequencer.SetCycle(...)` +- Fast-path early-return when cmd+speed unchanged (line 6713-6714) +- `OnLiveMotionUpdated` skips wire-echo SetCycle for the local player guid (line 2707) + +Remote (observed retail char) path: +- Wire arrives → `OnLiveMotionUpdated` (`GameWindow.cs:3203`) +- "animCycle picker" at line 2842-2867 chooses the cycle based on Forward / Sidestep / Turn priority +- HasCycle fallback chain at line 2939 +- `ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)` at line 2988 +- Then iterates `update.MotionState.Commands` and routes each through `AnimationCommandRouter` (357dcc0 made this skip SubState class) +- Then ALSO updates `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` for body.Velocity computation +- Then ALSO calls `remoteMot.Motion.DoInterpretedMotion(...)` for sidestep/turn axes + +**Hypotheses to investigate:** + +A) After `SetCycle` fires, some other call in `OnLiveMotionUpdated` re-cycles the sequencer back. We've eliminated the `Commands` list (357dcc0 skip-SubState). Other candidates: `PlayAction` calls inside `RouteWireCommand`, the spawn-time SetCycle at line 2313, or something in `ApplyServerControlledVelocityCycle` (line 3238). + +B) `_currNode` actually IS in the right place after SetCycle but `Advance(dt)` doesn't read from it correctly. Maybe a thread-safety issue (SetCycle on net thread, Advance on render thread, partial state visible). + +C) `Advance` returns the right frames but `seqFrames` are not applied to the entity's `MeshRefs` for the remote entity specifically. Look at `GameWindow.cs:6510-6589` — the per-part transform application loop. There's no obvious local-vs-remote branch but worth tracing. + +D) The MeshRefs themselves get rebuilt each frame and the rebuild reads from a different source for remotes. The `newMeshRefs` list is built per-frame at line 6567. + +E) Local player's `ae.Sequencer.SetCycle` is called at a higher rate than remote's (per-input vs per-UM). Maybe the queue stays cleaner with frequent calls, and the bug is exposed only when SetCycle is sparse. + +F) **Most likely** based on what we've seen: `Advance` plays through stale link frames before reaching the cycle. Our 357dcc0 fix forces `_currNode` onto the first newly-enqueued node — but for `Ready→Run`, the newly-enqueued sequence is `[Ready→Run link, Run cycle]`. `_currNode` lands on the **link**, the link plays for ~0.5–1 second, then the run cycle starts. User perceives the link's "transition pose" as "still walking / still idle." + +--- + +## Diagnostic infrastructure available + +All env-var gated on `ACDREAM_REMOTE_VEL_DIAG=1`: + +| Diag | Where | What it shows | +|---|---|---| +| `[FWD_WIRE]` | `GameWindow.cs:2793-2800` | Each ForwardCommand transition received per remote | +| `[CMD_LIST]` | `GameWindow.cs:3119-3133` | Commands list contents at UM receive time | +| `[HASCYCLE]` | `GameWindow.cs:2939-2947` | HasCycle result for the requested cycle | +| `[SETCYCLE]` | `GameWindow.cs:2972-2986` | Each animCycle picker → SetCycle call | +| `[SEQSTATE]` | `GameWindow.cs:6520-6532` | Per-tick `ae.Sequencer.CurrentMotion` (1Hz throttled) | +| `[TURN_WIRE]` | `GameWindow.cs:3050-3057` | TurnCommand wire arrivals with signed speed | +| `[OMEGA_DIAG]` | `GameWindow.cs:5901-5912` | Per-tick omega being applied to body | +| `[VEL_DIAG]` | `GameWindow.cs:3327-3343` | Server-broadcast speed vs maxSeqSpeed per UP | + +Also gated on `ACDREAM_INTERP_MANAGER=1` is the entire retail-faithful per-tick remote motion path. Set both env vars when reproducing. + +The repo has `tools/cdb-scripts/` set up for live tracing of retail acclient.exe via cdb.exe. Two trace scripts already proven working: +- `walk_run_motion_trace.cdb` + `walk_run_motion_trace.log` — captured the exact retail walk→run sequence and proved retail uses `MotionTableManager::add_to_queue` without `truncate_animation_list`. + +To launch retail tracing: have user start retail and connect, then in PowerShell: +``` +& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" ` + -pn acclient.exe -cf "tools\cdb-scripts\walk_run_motion_trace.cdb" *>&1 | + Tee-Object -FilePath "tools\cdb-scripts\walk_run_motion_trace.log.console" +``` +Auto-detaches at 200 hits via `.detach` (do NOT use `qd` per CLAUDE.md gotcha — silently ignored). NEVER `Stop-Process` cdb — takes retail down with it. + +--- + +## What retail actually does (from cdb live trace) + +For a walk→run direct transition retail's call sequence is: + +``` +[79] CPhysicsObj::DoInterpretedMotion: motion=45000005 walk start (shift+W) +[82] CMotionTable::DoObjectMotion: motion=45000005 +[83] MotionTableManager::add_to_queue: arg1=45000005 arg2=00000001 ← walk added looping + +[89] CPhysicsObj::DoInterpretedMotion: motion=44000007 run start (release shift) +[92] CMotionTable::DoObjectMotion: motion=44000007 +[93] MotionTableManager::add_to_queue: arg1=44000007 arg2=00000001 ← run added looping + +[104] CMotionTable::StopObjectMotion: motion=44000007 run end (release W) +``` + +`MotionTableManager::truncate_animation_list` was on bp the entire trace and **never fired**. Retail just appends new motions to the queue and lets `MotionTableManager::CheckForCompletedMotions` (`0x0051BE00`) and `MotionTableManager::remove_redundant_links` (`0x0051BF20`) handle the natural progression — neither of which we have ported. + +This suggests our `AnimationSequencer.SetCycle` rebuild semantics (ClearCyclicTail + enqueue link + enqueue cycle) is fundamentally different from retail's "append-only" `MotionTableManager`. May not matter for visual output as long as our queue manipulations land in the same end state, but it's a structural mismatch worth exploring if the tactical fixes don't pan out. + +--- + +## File locations + +- **`src/AcDream.Core/Physics/AnimationSequencer.cs`** — SetCycle (line 360), Advance (690), BuildBlendedFrame (1254), ClearCyclicTail (1117), AdvanceToNextAnimation (1150), EnqueueMotionData (1101), LoadAnimNode (1037) +- **`src/AcDream.App/Rendering/GameWindow.cs`** — OnLiveMotionUpdated (3203), TickAnimations (5851), animCycle picker block (2842-2988), the seqFrames-to-MeshRefs application loop (6510-6635), UpdatePlayerAnimation (6664) +- **`src/AcDream.Core/Physics/AnimationCommandRouter.cs`** — RouteWireCommand (53), Classify (29) +- **`src/AcDream.Core/Physics/MotionInterpreter.cs`** — get_state_velocity (587), GetMaxSpeed (968), apply_current_movement (653), HitGround (924) +- **`src/AcDream.Core/Physics/PositionManager.cs`** — ComputeOffset (37) (the per-tick combiner) +- **`src/AcDream.Core/Physics/InterpolationManager.cs`** — Enqueue, AdjustOffset (224), stall detection +- **Reference decomp:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (1.4M-line pseudo-C with full PDB names) +- **Symbols index:** `docs/research/named-retail/symbols.json` (greppable name → address) +- **Verbatim retail headers:** `docs/research/named-retail/acclient.h` (struct field offsets) + +--- + +## Concrete next steps for the bug + +1. **Add a per-tick diag that prints `_currNode.Anim.Id` + `_framePosition` for the observed remote.** This will conclusively answer whether `_currNode` is on the new cycle, on a stale link, or somewhere else. Implement near the existing SEQSTATE diag in `GameWindow.cs:6520`. Ask user to do the precise test sequence (W only, then shift+W only, no turns/no mouse) and read the log. + +2. **Add a diag that prints `seqFrames[0].Origin` and `seqFrames[0].Orientation`** (the result of Advance) before applying to MeshRefs. If the values change meaningfully between cycles → bug is in MeshRefs application. If they're stuck → bug is in Advance/BuildBlendedFrame. + +3. **Compare the call ORDER of SetCycle for local vs remote.** Maybe local's UpdatePlayerAnimation calls SetCycle then immediately also re-resolves cycle data and passes it through. Or local has frame-resolution state we lack for remotes. + +4. **Try the retail-faithful additive `add_to_queue` semantics:** modify SetCycle to skip ClearCyclicTail and just append new motion data. The `MotionTableManager::CheckForCompletedMotions` cleanup we don't port might be needed — but a primitive version (drop nodes whose `IsLooping=true` count exceeds 1, keeping the newest) might suffice as a starting point. + +5. **Trace retail's CSequence::update / update_internal calls live** with cdb to see what frames ARE returned per tick for a remote running and transitioning. We have the cdb toolchain set up; pattern existing scripts in `tools/cdb-scripts/`. + +6. **If all else fails, dispatch a research agent** with the prompt below. + +--- + +## For the next research agent — exact assignment + +> Read this entire document. +> +> Read `src/AcDream.Core/Physics/AnimationSequencer.cs` end-to-end, focusing on: +> - `SetCycle` (line 360-560) — what state it mutates and in what order +> - `Advance` (line 690-784) — how it consumes the queue and what it returns +> - `BuildBlendedFrame` (line 1254-1313) — how the visible per-part transforms are computed +> - `ClearCyclicTail` (line 1117-1140) and `AdvanceToNextAnimation` (line 1150-1166) — node lifecycle in the queue +> +> Then read `src/AcDream.App/Rendering/GameWindow.cs:5851-6635` — the `TickAnimations` method including the dead-reckoning blocks, sequencer Advance call, and the seqFrames-to-MeshRefs application loop. +> +> Answer: +> +> 1. After `SetCycle` is called for `RunForward` (with `linkData != null` and `cycleData != null`), what is the precise queue state, the value of `_currNode`, and the value of `_framePosition` immediately after SetCycle returns? Trace step by step including ClearCyclicTail's effect on `_currNode`. Cite line numbers. +> +> 2. On the next render tick when `Advance(dt=0.0167)` is called, what does it do? Specifically, does it advance through the link frames first, or skip them, or play them and stop at the cycle? What pose does `BuildBlendedFrame` return at the end? +> +> 3. Is there any code path between `SetCycle` returning and the next `Advance` call that could RESET `_currNode` back to a stale node? List every SetCycle call site (there are ~12 in GameWindow.cs) and identify any that fire on the per-tick path (not just on UM receive). +> +> 4. Is there any difference in how `seqFrames` is consumed for the local player vs a remote-observed entity in the loop at lines 6566-6635? Both use `if (seqFrames is not null) { origin = seqFrames[i].Origin; ... }`. Find any conditional branch that bypasses seqFrames for remotes. +> +> Output: a concise (<800 word) report with line citations and a clear hypothesis for the root cause of the visible-cycle-doesn't-switch bug. Do NOT modify any code. + +--- + +## Quick reproduction recipe + +1. Start local ACE server (user has this running on `127.0.0.1:9000`). +2. Start a parallel **retail** acclient.exe and connect with a different character (NOT `+Acdream`). +3. Build acdream: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +4. Launch acdream from the main repo dir with both env vars: + ```powershell + $env:ACDREAM_INTERP_MANAGER = "1" + $env:ACDREAM_REMOTE_VEL_DIAG = "1" + $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" + $env:ACDREAM_LIVE = "1" + $env:ACDREAM_TEST_HOST = "127.0.0.1" + $env:ACDREAM_TEST_PORT = "9000" + $env:ACDREAM_TEST_USER = "testaccount" + $env:ACDREAM_TEST_PASS = "testpassword" + dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath launch.log + ``` +5. From the retail client, drive the test character: stand 2s, press W (run) 4s, release, press shift+W (walk) 4s, release. +6. Observe the test character in the acdream window. Bug: leg cycle does NOT visibly switch between idle / run / walk poses. +7. Read diags from `launch.log` (UTF-16 — use `Get-Content -Encoding Unicode`). + +--- + +## Notes on what NOT to do + +- **Do not pass `skipTransitionLink: true` unconditionally to SetCycle** — tried in commit `c06b6c5` (link skip), broke landing-from-jump, sit-down, and every other transition that needs its dat link to play. Reverted in `a2ae2ae`. +- **Do not full-reset the queue on every motion change** — same commit, also reverted. Side effect: removed end-animations everywhere. +- **Do not "scale body.Velocity by observed serverSpeed/predictedSpeed"** — tried during the day, user explicitly rejected as a hack. Always use predicted velocity from `get_state_velocity` (= `RunAnimSpeed × ForwardSpeed`). +- **Do not `Stop-Process cdb`** while it's attached to retail — takes retail down with it (CLAUDE.md). Use `.detach` inside bp actions for graceful exit. From 23004a479115d5dfe02d1bcf3b2b9edf9d6c1114 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 20:38:47 +0200 Subject: [PATCH 29/32] =?UTF-8?q?diag(motion):=20instrumentation=20for=20r?= =?UTF-8?q?emote=20walk=E2=86=94run=20leg-cycle=20bug=20(Commit=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five diagnostics, no behavior changes. All gated on existing ACDREAM_REMOTE_VEL_DIAG=1 env var. Plan at ~/.claude/plans/yes-make-a-plan-parsed-axolotl.md. Five hypotheses surviving from the four-agent investigation (docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md): H1 SEQSTATE silently swallowed by OMEGA_DIAG sharing throttle clock H2 ApplyServerControlledVelocityCycle races UM-driven SetCycle per UP H3 SetCycle fast-path returns without updating _currNode H4 GetLink/GetCycle null → defensive fallback lands on stale head H5 PartTemplate.Count diverges from anim PartFrames.Count → silent identity-quat freeze Diagnostics added (all log lines are grep-prefixed): D1 Split LastSeqStateLogTime field for SEQSTATE — own throttle. Foundational: every other diag depends on SEQSTATE telling truth. D2 [UPCYCLE] inside ApplyServerControlledVelocityCycle, + [UPCYCLE_SRC] at the call site (wire vs synth velocity). D3 [SCFAST] in fast-path return, [SCFULL] at full-rebuild end. D4 [SCNULLFALLBACK] in the null-data defensive fallback. D5 [PARTSDIAG] with pt.Count / seqFrames.Count / setup.Parts.Count / anim.PartFrames[0].Frames.Count + sum-of-components hash. Repro recipe: $env:ACDREAM_INTERP_MANAGER = "1" $env:ACDREAM_REMOTE_VEL_DIAG = "1" dotnet run … 2>&1 | Tee-Object tools/diag-logs/walkrun-.log Then watch a retail-driven character through acdream and exercise: idle → W run → release → shift+W walk → release → demote → promote → run+turn (this last one is the H1 trap). Decision matrix in the plan file maps each [TAG] signature to a specific Commit B fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 98 ++++++++++++++++++- .../Physics/AnimationSequencer.cs | 64 ++++++++++++ tools/diag-logs/.gitignore | 1 + 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tools/diag-logs/.gitignore diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8c7bc94d..b19a973f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -377,6 +377,23 @@ public sealed class GameWindow : IDisposable public double PrevServerPosTime; public double LastOmegaDiagLogTime; /// + /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own + /// throttle clock for the SEQSTATE log line in TickAnimations. + /// Previously SEQSTATE shared with + /// the OMEGA_DIAG block, which fires at 0.5s and resets the clock — + /// any remote that turned during a transition silently swallowed + /// SEQSTATE for 0.5–1.5s, masking the bug we're trying to diagnose + /// (walk↔run leg-cycle sticking on observed retail chars). Split + /// 2026-05-03 (Commit A). + /// + public double LastSeqStateLogTime; + /// + /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own + /// throttle clock for the PARTSDIAG log line in TickAnimations + /// (D5). One log per remote per ~1s. + /// + public double LastPartsDiagLogTime; + /// /// Diagnostic-only: max |sequencer.CurrentVelocity| observed across /// all per-tick samples since the last UpdatePosition arrival. The /// next UP compares this against (LastServerPos - PrevServerPos) / @@ -3295,6 +3312,23 @@ public sealed class GameWindow : IDisposable uint style = ae.Sequencer.CurrentStyle != 0 ? ae.Sequencer.CurrentStyle : 0x8000003Du; + + // D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether + // ApplyServerControlledVelocityCycle is racing UpdateMotion-driven + // SetCycle for player-driven remotes. If this fires every ~100-200ms + // during a Walk→Run press with `motion` flipping between buckets, + // H2 (UP-vs-UM race) is confirmed. UPs (5-10 Hz) would then + // perpetually overwrite the cycle the UM just set. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[UPCYCLE] guid={serverGuid:X8} " + + $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) " + + $"|v|={velocity.Length():F2} " + + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} " + + $"prev=0x{currentMotion:X8} " + + $"airborne={rm.Airborne} moveTo={rm.ServerMoveToActive}"); + } ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); } @@ -3607,6 +3641,17 @@ public sealed class GameWindow : IDisposable && rmState.HasServerVelocity && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) { + // D2 (Commit A 2026-05-03): tag whether the velocity feeding + // ApplyServerControlledVelocityCycle is wire-explicit (rare for + // player remotes — ACE almost never sets HasVelocity on player + // UPs) or synthesized from position deltas (the common case). + // Pairs with the [UPCYCLE] line printed inside the call. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + string velSrc = update.Velocity is null ? "synth" : "wire"; + System.Console.WriteLine( + $"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}"); + } ApplyServerControlledVelocityCycle( update.Guid, aeForVelocity, @@ -6522,12 +6567,16 @@ public sealed class GameWindow : IDisposable { double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag) - && nowSec - rmDiag.LastOmegaDiagLogTime > 1.0) + && nowSec - rmDiag.LastSeqStateLogTime > 1.0) { + // D1 (2026-05-03): SEQSTATE has its own throttle clock + // (LastSeqStateLogTime) so it isn't silently swallowed by + // OMEGA_DIAG resetting LastOmegaDiagLogTime when the + // observed remote happens to be turning. System.Console.WriteLine( $"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} " + $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}"); - rmDiag.LastOmegaDiagLogTime = nowSec; + rmDiag.LastSeqStateLogTime = nowSec; } } seqFrames = ae.Sequencer.Advance(dt); @@ -6564,6 +6613,51 @@ public sealed class GameWindow : IDisposable } int partCount = ae.PartTemplate.Count; + + // D5 (Commit A 2026-05-03): PARTSDIAG — proves whether + // PartTemplate.Count diverges from seqFrames.Count (silent + // identity-quat fallback freezes parts → H5) and whether the + // per-part frames returned by Advance actually change between + // Walk and Run cycles. The seqFrames hash is a sum-of-components + // proxy: cheap, unitless, monotonically distinct between cycles + // for any non-degenerate animation. If [PARTSDIAG] shows the + // hash unchanged across a Walk→Run transition while [SEQSTATE] + // shows CurrentMotion flipping, the sequencer is serving stale + // frames despite the cycle being correct. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && serverGuid != 0 + && serverGuid != _playerServerGuid + && _remoteDeadReckon.TryGetValue(serverGuid, out var rmParts)) + { + double nowSecParts = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSecParts - rmParts.LastPartsDiagLogTime > 1.0) + { + int seqCount = seqFrames?.Count ?? -1; + int setupParts = ae.Setup.Parts.Count; + int animFrame0Parts = ae.Animation.PartFrames.Count > 0 + ? ae.Animation.PartFrames[0].Frames.Count + : -1; + double seqHash = 0.0; + if (seqFrames is not null) + { + for (int hi = 0; hi < seqFrames.Count; hi++) + { + var f = seqFrames[hi]; + seqHash += f.Origin.X + f.Origin.Y + f.Origin.Z + + f.Orientation.X + f.Orientation.Y + + f.Orientation.Z + f.Orientation.W; + } + } + System.Console.WriteLine( + $"[PARTSDIAG] guid={serverGuid:X8} " + + $"pt.Count={partCount} seqFrames.Count={seqCount} " + + $"setup.Parts.Count={setupParts} " + + $"anim.PartFrames[0].Frames.Count={animFrame0Parts} " + + $"seqHash={seqHash:F4}"); + rmParts.LastPartsDiagLogTime = nowSecParts; + } + } + var newMeshRefs = new List(partCount); var scaleMat = ae.Scale == 1.0f ? System.Numerics.Matrix4x4.Identity diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 7fc7e683..bbdd66fb 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -282,6 +282,15 @@ public sealed class AnimationSequencer private const double FrameEpsilon = 1e-5; private const double RateEpsilon = 1e-6; + // ── Diagnostics (Commit A 2026-05-03) ─────────────────────────────────── + // Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines + // emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads + // the env var inline rather than caching so a launch can be re-toggled + // without restarting. Throttled to one log per ~0.5s per sequencer + // instance; the log line itself carries the motion ID so multiple cycles + // alternating fast still get distinguished in the log. + private double _lastSetCycleDiagTime; + // ── Constructor ────────────────────────────────────────────────────────── /// @@ -406,6 +415,25 @@ public sealed class AnimationSequencer MultiplyCyclicFramerate(speedMod / CurrentSpeedMod); CurrentSpeedMod = speedMod; } + + // D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path + // is firing instead of the full rebuild. If [SCFAST] dominates + // during a Walk→Run press AND the leg-cycle hash from D5 doesn't + // change, H3 (fast-path leaves _currNode on a stale link) is the + // bug. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - _lastSetCycleDiagTime > 0.5) + { + System.Console.WriteLine( + $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " + + $"oldSpeedMod={CurrentSpeedMod:F3} " + + $"qCount={_queue.Count} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); + _lastSetCycleDiagTime = nowSec; + } + } return; } @@ -510,6 +538,42 @@ public sealed class AnimationSequencer // Defensive fallback: nothing newly added AND no current node. _currNode = _queue.First; _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; + + // D4 (Commit A 2026-05-03): SCNULLFALLBACK — proves whether the + // null-data fallback is being hit. If this fires during a + // Walk→Run transition for the watched remote, H4 (MotionTable + // GetLink/GetCycle returns null for the remote's setup) is the + // bug. linkData/cycleData null almost certainly means a + // MotionTable lookup gap for that style+motion combo. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[SCNULLFALLBACK] motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + + $"linkNull={(linkData is null)} cycleNull={(cycleData is null)} " + + $"qCount={_queue.Count}"); + } + } + + // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on + // the full-rebuild SetCycle path. Together they tell us the + // fast-path-vs-rebuild ratio. If the visible cycle is wrong despite + // SCFULL firing with a non-null _currNode and firstNew set, the bug + // is downstream (H5 PartTemplate divergence, or sequencer-internal + // serving stale frames despite correct CurrentMotion). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - _lastSetCycleDiagTime > 0.5) + { + System.Console.WriteLine( + $"[SCFULL] motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + + $"speedMod={speedMod:F3} " + + $"qCount={_queue.Count} " + + $"firstNewNull={(firstNew is null)} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " + + $"firstCyclicNull={(_firstCyclic is null)}"); + _lastSetCycleDiagTime = nowSec; + } } CurrentStyle = style; diff --git a/tools/diag-logs/.gitignore b/tools/diag-logs/.gitignore new file mode 100644 index 00000000..397b4a76 --- /dev/null +++ b/tools/diag-logs/.gitignore @@ -0,0 +1 @@ +*.log From eaa8fc5c6728ed68d8474293613e6ded82b81265 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 20:51:14 +0200 Subject: [PATCH 30/32] =?UTF-8?q?diag(motion):=20A.1=20=E2=80=94=20unthrot?= =?UTF-8?q?tled=20SCFAST/SCFULL=20+=20UM=5FRAW=20(Commit=20A.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit A's log refuted H2 (UPCYCLE never fires for player guids — gated by IsPlayerGuid), H4 (SCNULLFALLBACK count = 0), H5 (PartTemplate counts always match anim PartFrames). The remaining puzzle: SCFULL Ready=23 dominates (all motions: 41 total) SETCYCLE picker logged: only 9 transitions to Ready Difference (≥14 extra Ready full-rebuilds) suggests a non-picker source, OR many UMs arriving with no ForwardCommand bit being routed through the picker's `else if (!command.HasValue) { fullMotion = Ready; }` at GameWindow.cs:2671-2673, knocking the cycle back to Ready mid-Walk/Run. This commit removes the 0.5s throttle on SCFAST and SCFULL (every call now logs) and adds [UM_RAW] at OnLiveMotionUpdated entry to print: - stance / fwd / fwdSpd / side / turn / movementType / isMoveTo - sequencer.CurrentMotion at call time per UM, gated on ACDREAM_REMOTE_VEL_DIAG=1. Combined: one repro pass tells us (a) UM arrival rate per remote, (b) which UMs lack ForwardCommand, (c) whether the picker is the source of the 14+ extra Ready calls. Commit B is then a one-line fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 21 +++++++ .../Physics/AnimationSequencer.cs | 62 +++++++------------ 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b19a973f..655aee7b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2595,6 +2595,27 @@ public sealed class GameWindow : IDisposable ushort stance = update.MotionState.Stance; ushort? command = update.MotionState.ForwardCommand; + // A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line, + // gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells + // us the actual UM arrival rate per remote and which fields are set + // on each. The bug-suspect is "ACE sends UMs without ForwardCommand + // bit during running, our picker resolves to Ready, SetCycle(Ready) + // resets the cycle". This diag lets us count how often that happens. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && update.Guid != _playerServerGuid) + { + string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null"; + string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null"; + string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null"; + string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null"; + uint seqMot = ae.Sequencer?.CurrentMotion ?? 0; + System.Console.WriteLine( + $"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} " + + $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} " + + $"isMoveTo={update.MotionState.IsServerControlledMoveTo} " + + $"seq.CurrentMotion=0x{seqMot:X8}"); + } + // Diagnostic: dump every inbound UpdateMotion so we can trace why // remote chars don't transition off RunForward when they stop. // Enable with ACDREAM_DUMP_MOTION=1. diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index bbdd66fb..fa7b23d5 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -283,13 +283,9 @@ public sealed class AnimationSequencer private const double RateEpsilon = 1e-6; // ── Diagnostics (Commit A 2026-05-03) ─────────────────────────────────── - // Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines - // emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads - // the env var inline rather than caching so a launch can be re-toggled - // without restarting. Throttled to one log per ~0.5s per sequencer - // instance; the log line itself carries the motion ID so multiple cycles - // alternating fast still get distinguished in the log. - private double _lastSetCycleDiagTime; + // Removed throttle in A.1 (2026-05-03) — every SCFAST/SCFULL call logs + // unthrottled (still gated on ACDREAM_REMOTE_VEL_DIAG=1) so we can read + // exact call rate and Run→Ready transitions one tick at a time. // ── Constructor ────────────────────────────────────────────────────────── @@ -417,22 +413,17 @@ public sealed class AnimationSequencer } // D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path - // is firing instead of the full rebuild. If [SCFAST] dominates - // during a Walk→Run press AND the leg-cycle hash from D5 doesn't - // change, H3 (fast-path leaves _currNode on a stale link) is the - // bug. + // is firing instead of the full rebuild. + // A.1 (2026-05-03): unthrottled — we need actual call rate, not + // 0.5s-bucketed sample. Keeps cost low (just a Console.WriteLine + // per SetCycle call, all gated on env var). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSec - _lastSetCycleDiagTime > 0.5) - { - System.Console.WriteLine( - $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " - + $"oldSpeedMod={CurrentSpeedMod:F3} " - + $"qCount={_queue.Count} " - + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); - _lastSetCycleDiagTime = nowSec; - } + System.Console.WriteLine( + $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " + + $"oldSpeedMod={CurrentSpeedMod:F3} " + + $"qCount={_queue.Count} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); } return; } @@ -555,25 +546,20 @@ public sealed class AnimationSequencer } // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on - // the full-rebuild SetCycle path. Together they tell us the - // fast-path-vs-rebuild ratio. If the visible cycle is wrong despite - // SCFULL firing with a non-null _currNode and firstNew set, the bug - // is downstream (H5 PartTemplate divergence, or sequencer-internal - // serving stale frames despite correct CurrentMotion). + // the full-rebuild SetCycle path. + // A.1 (2026-05-03): unthrottled — see SCFAST comment. We also print + // the previous CurrentMotion so the log directly shows the cycle + // transition (e.g. "Run → Ready" indicates the visible cycle just + // got reset back to Ready, mid-Run). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSec - _lastSetCycleDiagTime > 0.5) - { - System.Console.WriteLine( - $"[SCFULL] motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " - + $"speedMod={speedMod:F3} " - + $"qCount={_queue.Count} " - + $"firstNewNull={(firstNew is null)} " - + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " - + $"firstCyclicNull={(_firstCyclic is null)}"); - _lastSetCycleDiagTime = nowSec; - } + System.Console.WriteLine( + $"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + + $"speedMod={speedMod:F3} " + + $"qCount={_queue.Count} " + + $"firstNewNull={(firstNew is null)} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " + + $"firstCyclicNull={(_firstCyclic is null)}"); } CurrentStyle = style; From 039149a9d04a1e553c5eae78b036f04b1038040d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 4 May 2026 08:10:55 +0200 Subject: [PATCH 31/32] fix(motion): port ResolveWithTransition into env-var per-tick path (Commit B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores per-frame collision/terrain sweep that was DROPPED by e94e791 (L.3.1+L.3.2 Task 3) when the ACDREAM_INTERP_MANAGER=1 path replaced the per-tick logic with a stripped-down version intended to mirror retail's CPhysicsObj::MoveOrTeleport. That was a category error: MoveOrTeleport (acclient @ 0x00516330) is the *network packet handler* entry point — minimal work. The per-frame physics tick is retail's update_object (FUN_00515020) — full chain including FUN_005148A0 Transition::FindTransitionalPosition (the collision sweep). The legacy (env-var off) path mirrors update_object correctly; the env-var path was missing this single step. Symptoms that map directly to the missing sweep: - "Staircase" Z drift on slopes (horizontal Euler motion sinks into rising ground until the next UP pops it up). User-confirmed for BOTH retail-driven AND acdream-driven remotes when observed from acdream. - Position blips during steady-state motion (predicted XY drifts unconstrained between UPs, then UP hard-snaps). Fix: copy the legacy path's "Step 4: collision sweep" block (lines ~6483-6569) into the env-var per-frame branch, between UpdatePhysicsInternal and the existing landing fallback. Includes post-resolve landing detection (K-fix15 + K-fix17 mirror) so airborne remotes correctly transition back to grounded after the sweep clamps them to a walkable surface. Sphere dims match the legacy path verbatim (0.48m radius, 1.2m height, 0.4m step-up/down, EdgeSlide moverFlags) — retail human-scale, already proven via the legacy path before the e94e791 regression. Does NOT address the separate Run↔Walk cycle bug (different root cause: missing velocity-derived cycle inference for player remotes). That's a follow-up commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 655aee7b..e44a975e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6117,6 +6117,12 @@ public sealed class GameWindow : IDisposable // semantics — retail's PositionManager::adjust_offset // overwrites the offset frame with the catch-up direction, // not adding to it. + // + // 2026-05-03 (Commit B fix for staircase regression): capture + // the pre-translation position so the collision sweep below + // (Step 4b) can resolve the full per-tick movement through + // BSP + terrain. + var preIntegratePos = rm.Body.Position; float maxSpeed = rm.Motion.GetMaxSpeed(); System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, @@ -6181,6 +6187,93 @@ public sealed class GameWindow : IDisposable // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); + // Step 4b (Commit B fix 2026-05-03): collision sweep — port of + // retail update_object's FUN_005148A0 Transition::FindTransitionalPosition. + // This was MISSING in the env-var path introduced by e94e791 + // (L.3.1+L.3.2 Task 3). The legacy (env-var off) path at the + // bottom of this function has it (line ~6483 "Step 4: collision + // sweep"); we just need the same call here. + // + // Without this: + // - Body Z drifts on slopes (visible "staircase" — horizontal + // Euler motion up a slope sinks into rising ground until + // the next UP pops it up). + // - Body slides through walls / objects between UPs. + // - Step-up / step-down doesn't engage on ledges. + // - Edge-slide doesn't engage on cliff edges. + // + // The env-var path was originally designed to mirror retail + // CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330) — a network + // packet handler entry point that does minimal work. But + // TickAnimations is the per-frame physics tick (mirrors retail + // FUN_00515020 update_object), which DOES include the collision + // sweep. Adding the sweep here makes the env-var path retail- + // faithful for the per-frame tick (matching the legacy path, + // which had it). + var postIntegratePos = rm.Body.Position; + if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) + { + // Sphere dims match local-player + legacy-path defaults + // (~0.48m radius, ~1.2m height humanoid). Step-up/down 0.4m + // matches L.2.3a retail human-scale. EdgeSlide is the retail + // default mover-flags state. + var resolveResult = _physicsEngine.ResolveWithTransition( + preIntegratePos, postIntegratePos, rm.CellId, + sphereRadius: 0.48f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + // Airborne remotes must NOT pre-seed the ContactPlane — + // mirrors K-fix9 in the legacy path; otherwise + // AdjustOffset's snap-to-plane branch zeroes the +Z + // offset every step on a jump arc. + isOnGround: !rm.Airborne, + body: rm.Body, + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); + + rm.Body.Position = resolveResult.Position; + if (resolveResult.CellId != 0) + rm.CellId = resolveResult.CellId; + + // Post-resolve landing detection — mirrors K-fix15 in the + // legacy path. When the resolver says we're on ground AND + // velocity is no longer pointing up, transition back to + // grounded. Without this, gravity keeps building negative Z + // velocity until the sphere-sweep clamps each frame, but + // Airborne stays true forever. + if (rm.Airborne + && resolveResult.IsOnGround + && rm.Body.Velocity.Z <= 0f) + { + rm.Airborne = false; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.Velocity = new System.Numerics.Vector3( + rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); + rm.Motion.HitGround(); + + // Reset sequencer cycle from Falling back to whatever + // InterpretedState says. Mirrors K-fix17 in the legacy + // path. + if (ae.Sequencer is not null) + { + uint landStyle = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(landStyle, landingCmd, landingSpeed); + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + } + // Step 5: landing fallback. The retail-faithful path leaves // the landing transition to OnLivePositionUpdated when ACE // sends IsGrounded=true. In practice ACE doesn't always From a3f53c2644264cb981e4576b60b7559bf45d4720 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 4 May 2026 10:10:10 +0200 Subject: [PATCH 32/32] =?UTF-8?q?docs+cleanup:=20env-var=20regression=20+?= =?UTF-8?q?=20Run=E2=86=94Walk=20cycle=20bug=20filed;=20re-throttle=20diag?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-of-session cleanup of the 2026-05-03 remote-motion debug session. Documentation: - CLAUDE.md: add ⚠️ DO-NOT-ENABLE warning for ACDREAM_INTERP_MANAGER=1 in the diagnostic env-vars list. Add an "Outbound motion wire format" section documenting acdream's WalkForward+HoldKey.Run encoding (which ACE auto-upgrades to RunForward on relay) so future sessions don't re-derive it. - docs/ISSUES.md: file two new issues: * #39 — Run↔Walk cycle transition not visible on observed retail-driven player remotes when watched from acdream. Root cause located: ApplyServerControlledVelocityCycle is gated by IsPlayerGuid, excluding the exact case where ACE doesn't broadcast a UM (shift toggle while direction key held). Fix sketch ~10 lines, separate commit. Cross-references the four-agent investigation prompt. * #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed. Documents why (e94e791 conflated MoveOrTeleport with update_object), the visible symptoms (staircase Z, position blips), and why Commit B (039149a)'s ResolveWithTransition port was insufficient (env-var path also clears body.Velocity → no horizontal Euler motion → sweep input is queue catch-up only, which stair-steps). Fix path = separate L.3 follow-up to re-integrate PositionManager additively on top of the legacy chain. Code: - GameWindow.cs:6042: prepend a ⚠️ REGRESSED warning block at the top of the env-var per-frame branch so anyone reading the code is immediately aware not to enable it. Cross-references ISSUES.md #40. - AnimationSequencer.cs: re-throttle [SCFAST]/[SCFULL] diagnostics to 0.5s per instance (rolled back from A.1's unthrottled experiment). Already served its purpose; throttled is enough for steady-state diagnostics. Restore _lastSetCycleDiagTime field. No behavior change for any current launch (env-var unset = legacy path unchanged). Build green; baseline test failures (8) unchanged from prior commit, none introduced by this session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 35 +++++ docs/ISSUES.md | 148 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 22 +++ .../Physics/AnimationSequencer.cs | 57 ++++--- 4 files changed, 238 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75ff1fe1..0e02b6d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -548,6 +548,41 @@ via `PlayerMovementController.ApplyServerRunRate`) or from (default 2 = 5×5). - `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver- broken setups. +- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion + diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`, + `[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`, + `[VEL_DIAG]`, `[UPCYCLE]`). Heavy. +- ⚠️ `ACDREAM_INTERP_MANAGER=1` — **DO NOT ENABLE.** This was an + experimental rewrite (e94e791) of the per-tick remote motion path. + It's regressed: the env-var path drops the per-tick collision sweep + (`ResolveWithTransition`) that the default path retains, causing a + visible "staircase" pattern when remotes run up/down slopes (body + Z stays flat between UPs, snaps at each one) plus position blips + during steady-state motion. Default (env-var unset) uses the + working retail-port chain. The PositionManager class itself is + fine and retail-faithful; only the integration into per-tick was + wrong. To be re-done in a future L.3 follow-up phase as additive + refinement on top of the working chain. + +### Outbound motion wire format (acdream → ACE) + +Important quirk for cross-checking observed remote behavior. acdream's +`PlayerMovementController` + `MoveToState` builder encode motion as: + +| Local input | Wire `ForwardCommand` | Wire `HoldKey` | Wire `ForwardSpeed` | +|---|---|---|---| +| W (run) | `WalkForward` (0x05) | `Run` (2) | server runRate (~2.4–2.94) | +| W + Shift (walk) | `WalkForward` (0x05) | `None` (1) | 1.0 | + +ACE auto-upgrades `WalkForward + HoldKey.Run` → `RunForward (0x07)` +when relaying to remote observers. So our INBOUND parser sees +`fwd=0x07` for "remote is running." This matches retail's encoding. + +When the local player toggles Shift while keeping W held (Run↔Walk +demote/promote), acdream sends a fresh `MoveToState` with the new +HoldKey + ForwardSpeed. Retail's outbound likely does the same, but +ACE's behavior on relay is uncertain — see `#L.X` in ISSUES.md for +the open Run↔Walk cycle bug on observed retail-driven remotes. ### Visual verification workflow diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c95ec3bd..794a37bc 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,154 @@ Copy this block when adding a new issue: # Active issues +## #39 — Run↔Walk cycle transition not visible on observed player remotes (acdream-as-observer) + +**Status:** OPEN +**Severity:** MEDIUM (visible animation desync; not a correctness/wire bug) +**Filed:** 2026-05-03 +**Component:** physics / motion / animation + +**Description:** When observing a remote-driven player character through +acdream and the actor toggles Shift while keeping a direction key held +(Run↔Walk demote/promote), the visible leg cycle does NOT update on the +observer side. Body position eventually corrects via UpdatePosition +hard-snaps (causing visible position blips), but the animation cycle +stays at whatever it was last set to (Run sticks; Walk sticks). + +Observation matrix: + +| Observer | Actor | Cycle Run↔Walk | Z on slopes | +|---|---|---|---| +| Retail | Retail | ✓ | ✓ | +| Retail | Acdream | ✓ | ✓ | +| Acdream | Acdream | ✓ | ✗ (only with env-var path) | +| Acdream | Retail | ✗ | ✗ | + +**Root cause / status:** + +ACE only broadcasts a fresh `UpdateMotion` (UM) when the wire's +`ForwardCommand` byte changes — i.e. on direction-key state changes +(W press, W release). Toggling Shift while W is held changes +`ForwardSpeed` and `HoldKey` but NOT `ForwardCommand`, so ACE does +NOT broadcast a UM for the demote/promote. The speed change DOES +propagate via `UpdatePosition` (position-delta velocity changes +between Run-pace and Walk-pace), confirmed via `[VEL_DIAG]` +serverSpeed varying ~2.5 m/s (walk) ↔ ~9 m/s (run). + +Retail's inbound code uses UP-derived velocity to refine the visible +cycle when no UM tells it. Acdream has the equivalent function — +`ApplyServerControlledVelocityCycle` in `GameWindow.cs:3274` — but +it's gated `if (IsPlayerGuid(serverGuid)) return;` for player +remotes, exactly the case where the gap matters. + +(Earlier hypothesized as H2 in the 2026-05-03 four-agent investigation +but marked refuted because the [UPCYCLE] diag never fired — that +was BECAUSE of the gate; un-gating reveals it firing per UP, which +is the correct behavior.) + +**Fix sketch (~10 lines):** un-gate `ApplyServerControlledVelocityCycle` +for player remotes when `currentMotion` is a locomotion cycle +(Run/Walk/Sidestep/Backward). UMs still drive direction-key changes +authoritatively; UP-derived velocity refines the speed bucket within +the same direction. Add a `LastUMUpdateTime` grace window (e.g. +500ms) so UMs win when fresh. + +**Files:** + +- `src/AcDream.App/Rendering/GameWindow.cs:3274` — `ApplyServerControlledVelocityCycle` + (the gate `if (IsPlayerGuid(serverGuid)) return;` to remove with conditions) +- `src/AcDream.App/Rendering/GameWindow.cs:3640-3660` — call site (already + passes through with HasServerVelocity from synthesized UP-deltas) +- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs:54-76` — + `PlanFromVelocity` thresholds (may need re-tuning if banding is observed) + +**Research:** + +- `docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md` — + full background of the four-agent investigation +- This session's diagnostic logs at `tools/diag-logs/walkrun-A1b-*.log` + (UM_RAW, FWD_WIRE, SETCYCLE traces) confirming ACE's wire pattern + +**Acceptance:** + +- Observer in acdream watching a retail-driven character toggle Shift + while holding W: visible leg cycle switches Run↔Walk within ~200ms + of the wire change. +- No regression on the working cases (acdream-on-acdream, retail + observers, idle↔Run, idle↔Walk). +- No spurious cycle thrashing during turning while running (ObservedOmega + doesn't trigger velocity-bucket changes). + +## #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips) + +**Status:** OPEN (do-not-enable; pending L.3 follow-up rebuild) +**Severity:** N/A (gated; default behavior unaffected) +**Filed:** 2026-05-03 +**Component:** physics / motion (per-tick remote prediction) + +**Description:** The `ACDREAM_INTERP_MANAGER=1` per-frame remote tick +introduced by commit `e94e791` (L.3.1+L.3.2 Task 3) is a regression and +should not be enabled. Two visible symptoms: + +1. **Z staircase on slopes:** observed remotes running up/down hills + sink into rising terrain or float over receding terrain, then snap + to correct Z at each `UpdatePosition` arrival. Body never follows + the terrain mesh between UPs. + +2. **Position blips during steady-state motion:** XY drifts + unconstrained between UPs, then UP hard-snaps cause visible jumps. + +Both symptoms ABSENT when env-var unset (default legacy path). + +**Root cause:** the env-var path was designed to mirror retail +`CPhysicsObj::MoveOrTeleport` (acclient @ 0x00516330). MoveOrTeleport +is retail's network-packet entry point — minimal work. The per-frame +physics tick is retail's `update_object` (FUN_00515020) — full chain +including `apply_current_movement` → `UpdatePhysicsInternal` → +`Transition::FindTransitionalPosition` (collision sweep). The legacy +path mirrors `update_object` correctly. The env-var path stripped the +collision sweep on a wrong assumption that this was "more retail- +faithful" — it was the opposite. + +Commit B (039149a, 2026-05-03) ported `ResolveWithTransition` into the +env-var path, but the symptom persisted because the env-var path also +clears `body.Velocity` for grounded remotes (no Euler integration of +horizontal motion → sweep input is the catch-up offset only, which +itself stair-steps because UPs are sampled at ~1 Hz). + +**Files:** + +- `src/AcDream.App/Rendering/GameWindow.cs:6042-6260` — env-var per-frame branch +- `src/AcDream.App/Rendering/GameWindow.cs:6260+` — legacy per-frame branch (works) +- `src/AcDream.Core/Physics/PositionManager.cs` — class itself is retail-faithful + (port of CPositionManager::adjust_offset), only the integration was wrong + +**Research:** + +- This session's `2026-05-03` chronological commit log + visual verification +- `docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md` + for the four-agent investigation that traced this + +**Fix path (separate L.3 follow-up phase, NOT this session):** + +The PositionManager class is correct retail-port. Re-integrate it as +ADDITIVE refinement on top of the working legacy chain (small +correction toward queued server positions, applied AFTER +`apply_current_movement` + `UpdatePhysicsInternal` + collision sweep) +— not as a REPLACEMENT for them. Match retail's actual `update_object` +chain ordering: `position_manager::adjust_offset` runs after the +primary motion + collision resolution. + +**Acceptance:** + +- New per-tick path enabled via env-var (or default after stabilization) + produces the same smooth slope motion + zero blips as the legacy path. +- Inbound `UpdatePosition` queue catch-up nudges body toward server + authoritative position without overriding terrain Z snap or causing + position blips. +- Verification: side-by-side vs legacy default in 2-client setup, + identical visible behavior. + ## #38 — Chase camera + player feel "30 fps" since L.5 physics-tick gate **Status:** OPEN diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e44a975e..fdb71a92 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6041,6 +6041,28 @@ public sealed class GameWindow : IDisposable { if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { + // ⚠️ REGRESSED 2026-05-03 — DO NOT ENABLE — see docs/ISSUES.md #40 ⚠️ + // + // Introduced by e94e791 (L.3.1+L.3.2 Task 3) intending to + // mirror retail CPhysicsObj::MoveOrTeleport (network-packet + // entry point — minimal work). Wrong retail function for the + // per-frame tick — the actual per-frame chain is retail's + // update_object (FUN_00515020), which the LEGACY path below + // correctly mirrors (apply_current_movement → + // UpdatePhysicsInternal → ResolveWithTransition collision + // sweep). This env-var path strips the collision sweep AND + // clears body.Velocity, leaving only PositionManager queue + // catch-up — which stair-steps with the 1 Hz UP cadence on + // slopes and produces visible position blips on flat ground. + // + // Commit B (039149a, 2026-05-03) ported ResolveWithTransition + // here but symptom persists because body.Velocity=0 means + // pre/postIntegrate sweep input is just the queue catch-up, + // which itself snaps in steps. Fix requires re-integrating + // PositionManager as ADDITIVE adjust_offset on top of the + // legacy chain — separate L.3 follow-up phase. + // + // Until that lands, stay on the legacy path (env-var unset). // ── NEW PATH: retail-faithful per-frame remote tick ── // (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path) // diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index fa7b23d5..9687feac 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -283,9 +283,12 @@ public sealed class AnimationSequencer private const double RateEpsilon = 1e-6; // ── Diagnostics (Commit A 2026-05-03) ─────────────────────────────────── - // Removed throttle in A.1 (2026-05-03) — every SCFAST/SCFULL call logs - // unthrottled (still gated on ACDREAM_REMOTE_VEL_DIAG=1) so we can read - // exact call rate and Run→Ready transitions one tick at a time. + // Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines + // emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads + // the env var inline rather than caching so a launch can be re-toggled + // without restarting. 0.5s per sequencer instance keeps logs readable + // while still capturing meaningful state changes. + private double _lastSetCycleDiagTime; // ── Constructor ────────────────────────────────────────────────────────── @@ -413,17 +416,20 @@ public sealed class AnimationSequencer } // D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path - // is firing instead of the full rebuild. - // A.1 (2026-05-03): unthrottled — we need actual call rate, not - // 0.5s-bucketed sample. Keeps cost low (just a Console.WriteLine - // per SetCycle call, all gated on env var). + // is firing instead of the full rebuild. Throttled to 0.5s per + // instance (re-throttled after A.1 unthrottled experiment). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { - System.Console.WriteLine( - $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " - + $"oldSpeedMod={CurrentSpeedMod:F3} " - + $"qCount={_queue.Count} " - + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - _lastSetCycleDiagTime > 0.5) + { + System.Console.WriteLine( + $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " + + $"oldSpeedMod={CurrentSpeedMod:F3} " + + $"qCount={_queue.Count} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); + _lastSetCycleDiagTime = nowSec; + } } return; } @@ -546,20 +552,23 @@ public sealed class AnimationSequencer } // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on - // the full-rebuild SetCycle path. - // A.1 (2026-05-03): unthrottled — see SCFAST comment. We also print - // the previous CurrentMotion so the log directly shows the cycle - // transition (e.g. "Run → Ready" indicates the visible cycle just - // got reset back to Ready, mid-Run). + // the full-rebuild SetCycle path. Throttled to 0.5s per instance. + // Logs prev CurrentMotion so the line shows the transition directly + // (e.g. "Run → Ready" = cycle just got reset). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { - System.Console.WriteLine( - $"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " - + $"speedMod={speedMod:F3} " - + $"qCount={_queue.Count} " - + $"firstNewNull={(firstNew is null)} " - + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " - + $"firstCyclicNull={(_firstCyclic is null)}"); + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - _lastSetCycleDiagTime > 0.5) + { + System.Console.WriteLine( + $"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + + $"speedMod={speedMod:F3} " + + $"qCount={_queue.Count} " + + $"firstNewNull={(firstNew is null)} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " + + $"firstCyclicNull={(_firstCyclic is null)}"); + _lastSetCycleDiagTime = nowSec; + } } CurrentStyle = style;