From 40d88b92ed75fa92fc210f08e6eef3e2ed0f079f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 14:57:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(motion):=20L.3=20M2=20=E2=80=94=20queue-on?= =?UTF-8?q?ly=20chase=20for=20grounded=20player=20remotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the M1 InterpolationManager into the per-tick + UP-receipt paths in GameWindow for player remote entities. Visual-verified against a retail-controlled remote: smooth body chase, no per-UP rubber-band, no staircase on slopes. OnLivePositionUpdated: - Gate changed from `ACDREAM_INTERP_MANAGER == "1"` to `IsPlayerGuid(update.Guid)`. NPCs continue through the legacy synth-velocity branch (ServerVelocity / ServerMoveTo) below — their motion model is correct as-is. - Within-bubble enqueue passes `currentBodyPosition` so the M1 far- branch detection (>100 m from body) can pre-arm an immediate blip. - Three branches (airborne no-op, near-enqueue, far-snap) now sync `entity.Position = rmState.Body.Position` before returning. This overrides the unconditional `entity.Position = worldPos` snap at the top of the function. Without this sync the entity teleports forward to server truth on UP receipt and TickAnimations yanks it back to the queue-driven body next frame — visible 0.5–1 m rubber- band per UP. TickAnimations: - Gate changed from `ACDREAM_INTERP_MANAGER == "1"` to `IsPlayerGuid(serverGuid) && !rm.Airborne`. Airborne player remotes fall through to the legacy path so K-fix15 landing + gravity sweep still fire on the jump arc. - Step 2 (per-frame translation) replaced. Was `rm.Position.ComputeOffset(...)` (mixed queue catch-up + animation root motion); now direct `rm.Interp.AdjustOffset(...)` (queue-only, no anim contribution). M3 will layer anim root motion on top so legs match body pace; for M2 the body chases server position smoothly without any anim-driven translation. - Step 4b (ResolveWithTransition collision sweep) REMOVED for player remotes. Server already collision-resolved the broadcast position; running the sweep on tiny per-frame queue catch-up deltas amplified micro-bounces into the ISSUES.md #40 staircase + flat-ground blips. - Step 5 (LastServerZ landing fallback) REMOVED — unreachable in the `!rm.Airborne` branch. Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + 04-interp-manager.md): m_velocityVector stays 0 for grounded remotes, apply_current_movement is local-player-only, and per-tick translation comes entirely from InterpolationManager queue catch-up. Behavior for player remotes: | Scenario | Path | Translation source | |-----------------------|--------|------------------------------| | Grounded near (≤96m) | M2 | Queue catch-up (2× max-speed)| | Grounded far (>96m) | M2 | Hard-snap to worldPos | | Far enqueue (>100m) | M2 | Pre-armed blip-to-tail | | Airborne (mid-jump) | Legacy | Gravity arc + sweep | | Landing | M2 | Hard-snap, queue cleared | NPCs: legacy path unchanged (synth velocity, ServerMoveTo, etc.). Closes the regression observed in 9b0f4f2 ("modern, not retail-faithful") and the L.3 attempts on 91bf1e0 / e94e791. Replaces the env-var path (ACDREAM_INTERP_MANAGER=1) which was marked DO-NOT-ENABLE in ISSUES.md #40 — the env-var no longer toggles anything for player remotes; this IS the path now. Build green, dotnet test green (8 pre-existing failures unchanged on this baseline; verified via stash on a3f53c2). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 307 +++++++----------------- 1 file changed, 81 insertions(+), 226 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fdb71a9..a081995 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3436,11 +3436,19 @@ public sealed class GameWindow : IDisposable 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") + // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for + // player remotes. Mirrors CPhysicsObj::MoveOrTeleport + // (acclient @ 0x00516330) — airborne no-op, far-snap, near + // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through + // the legacy synth-velocity branch below; their motion comes + // from ServerVelocity / ServerMoveTo which the legacy path + // already handles correctly. + // + // Was previously gated on ACDREAM_INTERP_MANAGER=1; the env-var + // path's per-tick TickAnimations counterpart is regressed + // (issue #40). M2 keeps the OnLivePositionUpdated half (which + // is correct) and rewrites the per-tick half — see TickAnimations. + if (IsPlayerGuid(update.Guid)) { // Orientation always snaps on receipt — InterpolationManager walks // position only; heading would otherwise lag the queue. @@ -3499,7 +3507,15 @@ public sealed class GameWindow : IDisposable // integrating gravity via per-frame UpdatePhysicsInternal. Server is // authoritative for the arc; we don't predict it locally. if (!update.IsGrounded) + { + // Undo the unconditional entity hard-snap at the top of the + // function (entity.Position = worldPos): the body is mid-arc + // and TickAnimations will write entity = body next frame + // anyway. Setting entity = body now prevents a 1-frame + // teleport-to-server-then-yank-back rubber-band. + entity.Position = rmState.Body.Position; return; + } // ── LANDING TRANSITION ──────────────────────────────────────── // First IsGrounded=true UP after rmState.Airborne signals landed. @@ -3547,12 +3563,26 @@ public sealed class GameWindow : IDisposable 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. + // The per-frame TickAnimations player-remote path drives the + // actual body advancement via InterpolationManager.AdjustOffset. + // Pass body's current position so the InterpolationManager can + // detect a far-distance enqueue (>100 m from body) and pre-arm + // an immediate blip — avoids body drifting visibly toward a + // far waypoint instead of teleporting to it. float headingFromQuat = ExtractYawFromQuaternion(rot); - rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); + rmState.Interp.Enqueue( + worldPos, + headingFromQuat, + isMovingTo: false, + currentBodyPosition: rmState.Body.Position); } + // Sync the visible entity to the body — overrides the unconditional + // entity.Position = worldPos snap at the top of this function. + // For the far-snap branch this is a no-op (body == worldPos); for + // the near-enqueue branch this prevents a 1-frame teleport-then- + // yank-back rubber-band as TickAnimations chases worldPos via the + // queue. + entity.Position = rmState.Body.Position; return; } @@ -6039,76 +6069,28 @@ public sealed class GameWindow : IDisposable && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") + if (IsPlayerGuid(serverGuid) && !rm.Airborne) { - // ⚠️ REGRESSED 2026-05-03 — DO NOT ENABLE — see docs/ISSUES.md #40 ⚠️ + // ── L.3 M2 (2026-05-05): queue-only chase for grounded player remotes ── // - // 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. + // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + + // 04-interp-manager.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). + // - UpdatePhysicsInternal's translation step is gated on + // 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. // - // 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) - // - // Per retail's CPhysicsObj::UpdateObjectInternal (0x005156b0) - // → UpdatePositionInternal (0x00512c30) → CSequence::update - // chain (decomp investigation 2026-05-03): - // - // 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; + // Airborne player remotes (rm.Airborne) and NPCs fall through to + // the legacy path below — unchanged from main per the M2 plan. System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; @@ -6133,26 +6115,21 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // 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. - // - // 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; + // 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). float maxSpeed = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3 offset = rm.Position.ComputeOffset( + System.Numerics.Vector3 offset = rm.Interp.AdjustOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, - seqVel: seqVel, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeed); + maxSpeedFromMinterp: maxSpeed); rm.Body.Position += offset; // Step 2.5: angular velocity → body orientation. Prefer @@ -6209,140 +6186,18 @@ 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. + // Step 4b INTENTIONALLY OMITTED in M2: + // ResolveWithTransition is NOT called — the server has + // already collision-resolved the broadcast position, and + // running our sweep on tiny per-frame queue catch-up deltas + // amplifies micro-bounces into visible position blips + // (issue #40 staircase + flat-ground blips). Per retail + // spec the per-tick body advance for a remote is purely + // the queue catch-up; collision is the sender's problem. // - // 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 - // 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); - - // 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 5 (landing fallback) is unreachable in this branch — + // we're gated on !rm.Airborne. Airborne player remotes fall + // through to the legacy path below where K-fix15 still fires. // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). // Track the maximum sequencer velocity magnitude seen since