diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0847e5ce..42d7a233 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3108,7 +3108,20 @@ regression on the species that already render correctly. ## #39 — Run↔Walk cycle transition not visible on observed player remotes (acdream-as-observer) -**Status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test) +**Status:** CLOSED 2026-07-02 (superseded by L.2g; refinement machinery DELETED in the S5 commit) +**Closure note:** the root-cause narrative below is WRONG — the 2026-05-06 +"wire goes silent on Shift toggle" finding was refuted at three oracles + a +fresh live capture (`docs/research/2026-07-02-inbound-motion-deviation-map.md`, +S0 section): retail sends a fresh MoveToState on HoldRun toggle while moving +(`CommandInterpreter` 0x006b37a8 → `SendMovementEvent`), ACE rebroadcasts every +MoveToState (`GameActionMoveToState.cs:36`), and the S0 capture shows explicit +`0x0005↔0x0007` UMs on each toggle. Retail has NO pace→cycle adaptation +anywhere (DEV-2). The `ApplyPlayerLocomotionRefinement` layer this issue added +was itself causing Ready↔Run thrash against legitimate flags=0 stop UMs and +was deleted; player-remote cycles are UM-driven only. Remaining transition +polish (funnel, stop path, link pose) is tracked as roadmap L.2g S2–S4. + +**Original status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test) **Severity:** LOW (most cases now visibly correct after the 2026-05-06 fix sequence; remaining unverified cases are direction-flip — believed to work via direct UM but not explicitly exercised) **Filed:** 2026-05-03 **Component:** physics / motion / animation diff --git a/docs/research/2026-07-02-inbound-motion-deviation-map.md b/docs/research/2026-07-02-inbound-motion-deviation-map.md index da9f5c37..01877eae 100644 --- a/docs/research/2026-07-02-inbound-motion-deviation-map.md +++ b/docs/research/2026-07-02-inbound-motion-deviation-map.md @@ -258,6 +258,46 @@ bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically; and it is NOT a dead-reckoning-tuning problem — the chase is already retail, it's the inputs (cycle + velocity source + stop signal) that deviate. +## S0 wire-probe RESULTS (2026-07-02, post-S1 live capture) + +Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured +protocol incl. Shift toggles. 280 UMs captured (`launch-s0-wireprobe.log`). + +1. **The wire is NOT silent — DEV-2's premise is dead.** Walk↔run toggles + arrive explicitly: `fwd=0x0005 fwdSpd=null(=1.0)` ↔ `fwd=0x0007 + fwdSpd=2.85`. Root cause of the 2026-05-06 misreading: retail's + default-difference packing baselines `forward_command` against **Ready** + (`RawMotionState.Default`, our own D6.2b verbatim port) — W-held is + non-default and always packed, so ACE (`MovementData.cs:104-119`) always + re-emits it with the holdKey upgrade + run-skill speed. +2. **flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE** + — they are genuine keys-released / heading-only MoveToState relays + (all-default raw state packs nothing; ACE `RawMotionState.Read` leaves + absent = Invalid; `MovementData` emits nothing). Retail semantics + (verified: `InterpretedMotionState` ctor 0x0051e8d0 defaults + `forward_command=Ready`; `MovementManager::unpack_movement` case 0 + UnPacks into a default-constructed state; + `CMotionInterp::move_to_interpreted_state` 0x005289c0 does a FLAT + `copy_movement_from` + `apply_current_movement`): an empty UM = a real + full stop. acdream already maps it to Ready — but the DEV-2 refinement + then re-promotes the cycle from UP pace after the 0.2 s grace, producing + a Ready↔Run thrash against legitimate stop signals. That thrash is the + observed flicker/rubber-band component. +3. **S1 gate validated live**: 0 `[UM_STALE]` drops across 280 UMs; + movementSeq advances +1 per UM; byte-level layout confirmed + (`instanceSeq|movementSeq|serverControlSeq|isAutonomous` exactly where + the parser reads them). +4. Also confirmed in `unpack_movement` (0x00524440) for the S2 port: the + outer style u16 routes through `command_ids[]` and applies via + `DoMotion` ONLY when it differs from the current style; motionFlags + carries sticky-object (0x100 → `stick_to_object`) and standing_longjump + (0x200); the actions list is consumed under a 15-bit + `server_action_stamp` wraparound compare + (`move_to_interpreted_state` 0x005289c0, lines 305953-305989). + +**Disposition: S5 = delete the player pace-inference layer** (executed in the +same session; NPC `PlanFromVelocity` path untouched — its unification is S6). + ## Provenance - Workflow `wf_b92a5670-283`: 4 read-only mappers (retail decomp / ACE port / diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9b711550..68a5e2ce 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -570,19 +570,6 @@ public sealed class GameWindow : IDisposable /// public float MaxSeqSpeedSinceLastUP; - /// - /// Seconds-since-epoch timestamp of the most recent UpdateMotion (UM) - /// for this remote. Used by the player-remote velocity-fallback cycle - /// refinement to skip refinement while a fresh UM is authoritative — - /// retail's outbound MoveToState gives us direction-explicit cycles - /// on direction-key changes (W press, W release, W↔S flip), and we - /// only want UP-derived velocity to refine the speed bucket within - /// a direction when no UM has arrived recently. Defaults to 0 - /// (epoch) so the first UP after spawn is allowed to refine - /// immediately if velocity already differs from the spawn cycle. - /// - public double LastUMTime; - public RemoteMotion() { Body = new AcDream.Core.Physics.PhysicsBody @@ -4288,19 +4275,6 @@ public sealed class GameWindow : IDisposable return; } - // #39 (2026-05-06): stamp the per-remote LastUMTime so the - // UP-velocity fallback path in ApplyServerControlledVelocityCycle - // can skip refinement while a UM is fresh. UMs are authoritative - // for direction-key changes (W press / release / W↔S flip); - // velocity refinement only helps for HoldKey-only changes (Shift - // toggle while a direction key is held — retail does NOT broadcast - // a fresh MoveToState in that case). - if (_remoteDeadReckon.TryGetValue(update.Guid, out var rmStateForUm)) - { - rmStateForUm.LastUMTime = - (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - } - // Re-resolve using the new stance/command. Keep the setup and // motion-table we already know about — the server's motion // updates override state within the same table, not swap tables. @@ -5135,35 +5109,6 @@ public sealed class GameWindow : IDisposable return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10; } - /// - /// Grace window in seconds after a UM arrives during which UP-derived - /// velocity refinement is suppressed for a player remote. UMs are - /// authoritative; the velocity fallback only fills the gap when retail - /// does not send a fresh MoveToState (Shift toggle while direction key - /// held). 200 ms covers the worst-case UM/UP race — UMs arrive on - /// direction-key events and UPs at 5–10 Hz, so the first UP after a - /// fresh UM lands ~100–200 ms behind. Tightened from 500 ms (commit - /// 8fa04af original) per user observation that the Shift-toggle - /// transition was visibly slower than retail; with 0.2 s the worst-case - /// added latency is just the UP cadence below it. - /// - private const double UmGraceSeconds = 0.2; - - /// - /// Speed (m/s) above which a player-remote currently in WalkForward - /// is promoted to RunForward by velocity refinement. Tuned to player - /// speeds: walk ≈ 3.12 m/s (WalkAnimSpeed × 1.0), run ≈ 8–12 m/s - /// (RunAnimSpeed × runRate ≈ 4.0 × 2.0–3.0). Hysteresis with - /// avoids thrashing at the boundary. - /// - private const float PlayerRunPromoteSpeed = 5.5f; - - /// - /// Speed (m/s) below which a player-remote currently in RunForward - /// is demoted to WalkForward by velocity refinement. - /// - private const float PlayerRunDemoteSpeed = 4.5f; - private void ApplyServerControlledVelocityCycle( uint serverGuid, AnimatedEntity ae, @@ -5180,27 +5125,24 @@ public sealed class GameWindow : IDisposable if (IsPlayerGuid(serverGuid)) { - // #39 (2026-05-06): player-remote forward-direction speed-bucket - // refinement. The bug case: actor toggles Shift while holding W - // (or releases Shift). Retail's outbound apparently does NOT - // broadcast a fresh MoveToState for HoldKey-only changes - // (verified via static analysis of CommandInterpreter::SendMovementEvent - // call sites; needs cdb confirmation). ACE has nothing to - // broadcast → no UM arrives at the observer → cycle stays at - // whichever direction-bucket was last set. Velocity DOES change - // (UP carries new pace), so this code path uses UP-derived - // velocity to refine the speed bucket within the same direction. + // L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO + // pace-derived cycle refinement — retail has no such mechanism + // anywhere in its inbound pipeline (deviation map DEV-2, two + // independent decomp dives + ACE cross-check). The #39-era + // premise ("retail's outbound goes silent on Shift toggle") was + // refuted at all three oracles + the S0 live capture: retail + // sends a fresh MoveToState on HoldRun toggle while moving + // (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE + // rebroadcasts every MoveToState (GameActionMoveToState.cs:36), + // and the wire shows explicit 0x0005↔0x0007 UMs on each toggle + // (launch-s0-wireprobe.log). The refinement layer's re-promote + // after legitimate flags=0 Ready UMs was itself the observed + // Ready↔Run thrash. Cycle changes for player remotes come from + // UpdateMotion ONLY, exactly like retail; position error is the + // InterpolationManager chase's job. // - // Conservative scope: - // - Forward direction only (low byte 0x05 or 0x07). Sidestep - // and backward HoldKey toggles are deferred until the TTD - // trace described in - // docs/research/2026-05-06-locomotion-cycle-transitions/ - // confirms retail's exact algorithm. - // - Hysteresis (4.5 m/s demote / 5.5 m/s promote) prevents - // thrashing at the boundary. - // - 500 ms UM grace window — a fresh UM is always authoritative. - ApplyPlayerLocomotionRefinement(serverGuid, ae, rm, velocity); + // NPC/monster remotes below keep PlanFromVelocity until S6 + // unifies all entity classes onto the CMotionInterp funnel. return; } @@ -5230,162 +5172,6 @@ public sealed class GameWindow : IDisposable ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); } - private void ApplyPlayerLocomotionRefinement( - uint serverGuid, - AnimatedEntity ae, - RemoteMotion rm, - System.Numerics.Vector3 velocity) - { - // UM grace: a fresh UM is authoritative. - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - double sinceUm = nowSec - rm.LastUMTime; - if (sinceUm < UmGraceSeconds) return; - - uint currentMotion = ae.Sequencer!.CurrentMotion; - uint lowByte = currentMotion & 0xFFu; - float currentSign = MathF.Sign(ae.Sequencer.CurrentSpeedMod); - if (currentSign == 0f) currentSign = 1f; - - // Recognised locomotion directions: - // 0x05 (WalkForward) — also encodes WalkBackward via negative speed - // (ACE convention: SidestepCommand= cancel, ForwardCommand= - // WalkForward, ForwardSpeed *= -0.65) - // 0x07 (RunForward) - // 0x0F (SideStepRight) - // 0x10 (SideStepLeft) - // Other motions (Ready, Turn, emotes, attacks) are left to UM-driven SetCycle. - const uint LowWalkForward = 0x05u; - const uint LowRunForward = 0x07u; - const uint LowSideStepRight = 0x0Fu; - const uint LowSideStepLeft = 0x10u; - bool isForwardClass = lowByte == LowWalkForward || lowByte == LowRunForward; - bool isSidestep = lowByte == LowSideStepRight || lowByte == LowSideStepLeft; - if (!isForwardClass && !isSidestep) return; - - float horizSpeed = MathF.Sqrt(velocity.X * velocity.X + velocity.Y * velocity.Y); - - // Hysteresis: stay in current bucket unless we cross the appropriate - // threshold. Below StopSpeed → don't refine (let UM Ready stop signal - // handle the stop transition; we don't want UP momentary 0-velocity - // to drop the cycle to Ready while the actor is mid-stride). - if (horizSpeed < AcDream.Core.Physics.ServerControlledLocomotion.StopSpeed) - return; - - uint targetMotion; - float speedMod; - - if (isSidestep) - { - // Sidestep: motion ID stays the same (SideStepLeft / SideStepRight). - // Retail's wire encoding for sidestep speed buckets uses the same - // motion ID with different SidestepSpeed (slow ≈ 1.25 multiplier, - // fast ≈ 3.0 clamp per ACE MovementData.cs:124-131). On Shift - // toggle while a strafe key is held, retail does NOT broadcast a - // fresh MoveToState (same wire-silence rule as the forward case), - // so observer-side cycle refinement must come from UP-derived - // velocity here. Preserve the sign — SideStepLeft is sometimes - // emitted with negative speedMod by the adjust_motion path. - // - // Magnitude: horizSpeed / SidestepAnimSpeed maps the observed - // speed back to a SideStepSpeed the sequencer can apply as a - // framerate multiplier. Retail's get_state_velocity for - // sidestep cycles is `velocity.X = SidestepAnimSpeed * - // SideStepSpeed` (MotionInterpreter.cs:592 — constant 1.25 - // m/s). Dividing by WalkAnimSpeed (3.12) here was wrong by - // 2.5× and made slow strafe play visibly slower than retail. - float sideMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed; - sideMag = MathF.Min(MathF.Max( - sideMag, - AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), - AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); - targetMotion = currentMotion; - speedMod = sideMag * currentSign; - } - else if (currentSign < 0f) - { - // BACKWARD walk: ACE encodes WalkBackward as `WalkForward` motion - // with NEGATIVE speedMod (MovementData.cs:115 `interpState.ForwardSpeed *= -0.65f`). - // No "RunBackward" motion exists — Shift toggle on backward - // changes only the magnitude of speedMod (slow back ≈ -0.65, - // fast back ≈ -1.91 = -runRate × 0.65). Keep WalkForward motion, - // refine magnitude, preserve negative sign. - // - // Without this branch (the original fix #1), backward refinement - // computed a positive speedMod from horizSpeed and overwrote the - // negative sign, making the legs play forward-walk while the body - // continued moving backward (the rubber-banding the user reported). - float backMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed; - backMag = MathF.Min(MathF.Max( - backMag, - AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), - AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); - targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; - speedMod = -backMag; - } - else if (lowByte == LowRunForward) - { - if (horizSpeed < PlayerRunDemoteSpeed) - { - targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; - speedMod = MathF.Min(MathF.Max( - horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed, - AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), - AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); - } - else - { - targetMotion = AcDream.Core.Physics.MotionCommand.RunForward; - speedMod = MathF.Min(MathF.Max( - horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed, - AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), - AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); - } - } - else - { - // currently WalkForward (0x05) with positive speedMod = walking forward. - if (horizSpeed > PlayerRunPromoteSpeed) - { - targetMotion = AcDream.Core.Physics.MotionCommand.RunForward; - speedMod = MathF.Min(MathF.Max( - horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed, - AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), - AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); - } - else - { - targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; - speedMod = MathF.Min(MathF.Max( - horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed, - AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), - AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); - } - } - - // Skip the SetCycle if neither motion nor speedMod changed - // meaningfully — avoids replaying transition links every UP. - bool motionChanged = currentMotion != targetMotion - && (currentMotion & 0xFFu) != (targetMotion & 0xFFu); - bool speedChanged = MathF.Abs(ae.Sequencer.CurrentSpeedMod - speedMod) > 0.05f; - if (!motionChanged && !speedChanged) - return; - - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - System.Console.WriteLine( - $"[UPCYCLE_PLAYER] guid={serverGuid:X8} " - + $"|v|={horizSpeed:F2} cur=0x{currentMotion:X8} " - + $"-> motion=0x{targetMotion:X8} speedMod={speedMod:F2} " - + $"sinceUM={sinceUm:F2}s " - + $"motionChg={motionChanged} speedChg={speedChanged}"); - } - - uint style = ae.Sequencer.CurrentStyle != 0 - ? ae.Sequencer.CurrentStyle - : 0x8000003Du; - ae.Sequencer.SetCycle(style, targetMotion, speedMod); - } - private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { // Phase A.1 / #135: track the PLAYER's last server-known landblock so the @@ -5634,24 +5420,14 @@ public sealed class GameWindow : IDisposable isMovingTo: false, currentBodyPosition: rmState.Body.Position); } - // #39 fix-3 (2026-05-06): velocity-fallback cycle refinement - // for player remotes. Wire-level evidence (`launch-39-diag2.log`): - // when retail's actor toggles Shift while a direction key - // is held, retail's outbound MoveToState logic does NOT - // emit a fresh packet (only Ready ↔ Run UMs visible in - // `[FWD_WIRE]`, despite a clear walk-pace ↔ run-pace - // velocity transition in `[VEL_DIAG]`). ACE has nothing - // to broadcast → no UM arrives at the observer → cycle - // sticks at whatever the last UM set. Compute the - // synth-velocity here in the player-remote path AND - // call into ApplyServerControlledVelocityCycle, which - // routes through the direction-preserving + UM-grace - // ApplyPlayerLocomotionRefinement helper (added in - // commit 8fa04af). - // - // The legacy non-player block below (3759+) covers NPCs - // and is gated `!IsPlayerGuid`; this block fills the - // matching gap for players. + // Track the UP-derived synth velocity for diagnostics + // ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the + // #39-era cycle-refinement call that used to live here is + // DELETED — retail never adapts a remote's animation from + // observed pace (deviation map DEV-2; premise refuted at + // decomp + ACE source + the S0 live capture, which shows + // explicit 0x0005↔0x0007 UMs on every Shift toggle). + // Player-remote cycles are UM-driven only. if (rmState.PrevServerPosTime > 0.0) { double nowSecVel = rmState.LastServerPosTime; @@ -5661,21 +5437,6 @@ public sealed class GameWindow : IDisposable var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos; rmState.ServerVelocity = synthVel; rmState.HasServerVelocity = true; - - if (_animatedEntities.TryGetValue(entity.Id, out var aeForVel) - && aeForVel.Sequencer is not null) - { - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - System.Console.WriteLine( - $"[UPCYCLE_SRC] guid={update.Guid:X8} src=synth-player"); - } - ApplyServerControlledVelocityCycle( - update.Guid, - aeForVel, - rmState, - synthVel); - } } } @@ -5793,17 +5554,16 @@ public sealed class GameWindow : IDisposable if (rmState.HasServerVelocity && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) { - // #39 (2026-05-06): un-gated for player remotes — the - // function itself routes player remotes into the dedicated - // ApplyPlayerLocomotionRefinement path (forward-direction - // speed bucket only, with UM grace + hysteresis). Non-player - // remotes use the existing PlanFromVelocity path. + // NPC/monster remotes: PlanFromVelocity cycle selection from + // UP-derived velocity (ACE broadcasts NPC motion patterns the + // UM stream alone doesn't cover). Player remotes return early + // inside — their cycles are UM-driven only per retail (L.2g + // S5; DEV-2 deleted). Unification of NPCs onto the + // CMotionInterp funnel is S6. // // 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]/[UPCYCLE_PLAYER] line printed inside. + // ApplyServerControlledVelocityCycle is wire-explicit or + // synthesized from position deltas (the common case). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { string velSrc = update.Velocity is null ? "synth" : "wire";