diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3b76a52..e7b658b 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 d270f35..fb33c0f 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 f82c1d3..c82ce2b 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 ────────────────────────────────────────────────────────