diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bc6e49f..3b76a52 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 1930b44..f82c1d3 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 aa352ab..be3dbc0 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); } }