diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 794a37b..40e3c14 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -124,7 +124,120 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g. - 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) +## #41 — Residual sub-decimeter blips on observed player remotes (M3 baseline) + +**Status:** OPEN +**Severity:** LOW (within retail's own DesiredDistance / MinDistance tolerances; visible only on close inspection) +**Filed:** 2026-05-05 +**Component:** physics / motion / animation (per-tick remote prediction) + +**Description:** With the L.3 M3 path live (queue catch-up + animation +root motion fallback), observed player remotes chase server position +smoothly with NO staircase on slopes and NO per-UP rubber-band. However +small position blips remain — sub-decimeter amplitude, periodic with +the server's UP cadence (~1 Hz). User report 2026-05-05: "I get very +small blips now. Running works, walking works, strafing works." + +The blips fall well within retail's own tolerances: + +- `DesiredDistance` (queue head reach radius) = 0.05 m +- `MinDistanceToReachPosition` (primary stall threshold) = 0.20 m + +So they are NOT a stall trigger and NOT a correctness bug. They're a +visible artifact of the velocity-synthesis residual: anim root motion +(`AnimationSequencer.CurrentVelocity = RunAnimSpeed × adjustedSpeed`) +slightly overshoots server pace between UPs, then queue catch-up walks +the body back toward the server position on the next UP — a small +rubber-band that's smaller than M2's pre-fix version but still +perceptible. + +**Root cause hypothesis (untested):** + +The L.3 handoff explicitly flagged this. From `06-acdream-audit.md` § 9 +and `05-position-manager-and-partarray.md` § 7: + +> Our `CurrentVelocity` carries only the steady-state component of the +> cycle's intent; the per-frame stride wobble is gone… For Humanoid +> the dat ships `MotionData.Velocity = 0` so the multiply is a no-op +> anyway — but the synth uses `RunAnimSpeed × adjustedSpeed` directly. + +ACE's wire `ForwardSpeed` for a running player is the **server runRate** +(~2.94 for skill 200), not a unit multiplier. Our synth multiplies +`RunAnimSpeed` (4.0) by `adjustedSpeed` (~2.94) = ~11.76 m/s, which +the queue catch-up clamps via `min(catchUp × dt, dist)` but the anim +fallback applies in full when the queue is idle. If the actual +server-broadcast pace is closer to 4.0 m/s (RunAnimSpeed alone, with +runRate as a *frame-rate* multiplier rather than a velocity scalar), +our fallback overshoots by ~3× and the queue walks it back every UP. + +Per the handoff: **don't normalize at the wire boundary** (prior +session tried this, called it a hack). The right fix is porting +retail's actual behavior in `add_motion @ 0x005224b0` and +`apply_run_to_command` to determine the correct `CSequence::velocity` +magnitude. + +**Files:** + +- `src/AcDream.Core/Physics/AnimationSequencer.cs` — `CurrentVelocity` + synthesis at L614–679 (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, + SidestepAnimSpeed=1.25 × adjustedSpeed) +- `src/AcDream.Core/Physics/PositionManager.cs` — `ComputeOffset` + applies `seqVel × dt × orientation` as fallback when queue is idle + +**Research:** + +- `docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md` § 5–7 +- `docs/research/2026-05-04-l3-port/06-acdream-audit.md` § 9 (AnimationSequencer) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` line 298437 + (`add_motion @ 0x005224b0`) — `CSequence::velocity = style_speed × MotionData.velocity` + +**Fix path (research first, then port):** + +1. cdb-trace retail to capture `CSequence::velocity` and + `MotionData::velocity` for a Humanoid running cycle. Compare against + our synth (4.0 × 2.94 = 11.76 m/s) to determine the actual retail + magnitude. +2. Port `add_motion`'s `style_speed × MotionData.velocity` chain + verbatim. For Humanoid where `MotionData.Velocity = 0`, port the + fallback retail uses (likely a separate code path through + `apply_run_to_command` that derives velocity from the cycle's + framerate, not a constant). +3. Remove the `RunAnimSpeed × adjustedSpeed` synth in + `AnimationSequencer.SetCycle`. + +**Acceptance:** + +- Visual blips disappear on flat-ground steady-state running. +- Side-by-side acdream-as-observer vs retail-as-observer of the same + server-controlled toon: indistinguishable body trajectory. + +--- + +## #40 — [DONE 2026-05-05 · 40d88b9] ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips) + +**Status:** DONE — closed by L.3 M2 (`feat(motion): L.3 M2 — queue-only chase for grounded player remotes`, commit 40d88b9) + +**Resolution:** The env-var gate was retired entirely. Both +`OnLivePositionUpdated` and `TickAnimations` now use +`IsPlayerGuid(serverGuid)` to route player-remote UPs through the +retail-faithful queue path (formerly the env-var path, but with two +key fixes per the L.3 spec): + +1. `PositionManager.ComputeOffset` is the per-tick translation source + (REPLACE semantics: queue catch-up overrides anim root motion when + active, anim stands when queue is idle / head reached). Mirrors + retail `UpdatePositionInternal @ 0x00512c30`. +2. `ResolveWithTransition` is **not** called for grounded player + remotes — server already collision-resolved the broadcast position, + and sweeping per-tick on tiny queue catch-up deltas amplified + micro-bounces into visible blips. This was the staircase + blip + regression. Trade-off documented in audit § 6. + +User-verified 2026-05-05: smooth body chase, no staircase on slopes, +no per-UP rubber-band on flat ground. Residual sub-decimeter blips +filed separately as #41 (velocity-synthesis magnitude). + +**Filed-original-context (for archive):** **Status:** OPEN (do-not-enable; pending L.3 follow-up rebuild) **Severity:** N/A (gated; default behavior unaffected) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a081995..0c1c15e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6071,10 +6071,12 @@ public sealed class GameWindow : IDisposable { if (IsPlayerGuid(serverGuid) && !rm.Airborne) { - // ── L.3 M2 (2026-05-05): queue-only chase for grounded player remotes ── + // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ── // // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + - // 04-interp-manager.md): + // 04-interp-manager.md + + // 05-position-manager-and-partarray.md): + // // - For a grounded REMOTE player, m_velocityVector stays at 0. // - apply_current_movement is NEVER called per tick on remotes // (it's the local-player-only velocity feed). @@ -6082,15 +6084,23 @@ public sealed class GameWindow : IDisposable // velocity² > 0, so it's a no-op when body.Velocity = 0. // - ResolveWithTransition is NOT called — the server already // collision-resolved the broadcast position. - // - Per-tick body translation comes ENTIRELY from - // InterpolationManager::adjust_offset's queue catch-up. - // When the queue is empty (head reached, between UPs), the - // body stays put. M3 will add animation root motion to fill - // the gap so legs match body pace; for M2 the body chases - // the server position without anim contribution. + // - Per-tick body translation per retail UpdatePositionInternal: + // 1. CPartArray::Update writes anim root motion (body-local + // seqVel × dt) into the local frame. + // 2. PositionManager::adjust_offset OVERWRITES the local + // frame's origin with the queue catch-up vector when + // the queue is active and the head is not yet reached + // — REPLACE, not additive. + // 3. Frame::combine composes the local frame with the + // body's world pose. + // Net: catch-up replaces anim during the chase phase, anim + // stands when the queue is empty / head reached. PositionManager. + // ComputeOffset implements this exact REPLACE dichotomy. // // Airborne player remotes (rm.Airborne) and NPCs fall through to // the legacy path below — unchanged from main per the M2 plan. + System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; @@ -6115,21 +6125,28 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // Step 2 (M2): queue-only translation. Direct call to - // InterpolationManager.AdjustOffset — no PositionManager - // mixing, no animation root motion. The InterpolationManager - // returns: - // - Vector3.Zero when the queue is empty OR the head is - // within DesiredDistance (0.05 m) — body stays still. - // - Direction × min(catchUpSpeed × dt, dist) — body chases - // the head waypoint at up to 2× motion-table max speed. - // - tail − body when fail_count > 3 (stall blip; queue - // cleared as a side effect). + // Step 2 (M3): queue + anim translation via PositionManager. + // ComputeOffset returns: + // - Vector3.Zero when queue is empty AND seqVel is zero + // (idle remote between UPs after head reached) — body + // stays still. + // - Direction × min(catchUpSpeed × dt, dist) when the + // queue is active and head is not reached — body chases + // the head waypoint at up to 2× motion-table max speed + // (REPLACES anim for this frame). + // - Anim root motion (seqVel × dt rotated into world) when + // the queue is empty OR head is within DesiredDistance — + // body advances with the locomotion cycle's baked + // velocity, keeping legs and body pace synchronized. + // - Blip-to-tail (tail − body) when fail_count > 3. float maxSpeed = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3 offset = rm.Interp.AdjustOffset( + System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, - maxSpeedFromMinterp: maxSpeed); + seqVel: seqVel, + ori: rm.Body.Orientation, + interp: rm.Interp, + maxSpeed: maxSpeed); rm.Body.Position += offset; // Step 2.5: angular velocity → body orientation. Prefer