diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs new file mode 100644 index 00000000..72c0ef55 --- /dev/null +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -0,0 +1,817 @@ +using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; +using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; + +namespace AcDream.App.Physics; + +/// +/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted +/// verbatim from GameWindow.TickAnimations (Code Structure Rule 1: no +/// new feature bodies in the >10k-line GameWindow). One instance is +/// owned by GameWindow and called once per animated remote entity per +/// frame, from inside the same guard the body used to live under +/// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid +/// && rm.LastServerPosTime > 0). +/// +/// Behaviour is byte-for-byte the pre-extraction body: the player/NPC +/// FORK is PRESERVED here unchanged — Path A (grounded PLAYER remotes, +/// IsPlayerGuid(serverGuid) && !rm.Airborne) advances by the interp +/// catch-up and deliberately OMITS the sweep; Path B (NPCs + airborne player +/// remotes) runs ResolveWithTransition + shadow-follows-resolved. Slice +/// 2b collapses the fork inside this class. See +/// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. +/// +/// Shared helpers that GameWindow also calls elsewhere are injected: +/// GetSetupCylinder (a general Setup-dimension helper with ~9 callers, +/// incl. the local player's own cylinder — kept on GameWindow) and +/// ApplyServerControlledVelocityCycle (anim-cycle selection, also called +/// from the UP handler) arrive as delegates. SyncRemoteShadowToBody +/// (remote-physics-specific) moved here and is called back from the UP-branch +/// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other +/// callers and moved here outright. +/// +internal sealed class RemotePhysicsUpdater +{ + // Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller). + private const double ServerControlledVelocityStaleSeconds = 0.60; + + private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; + private readonly System.Func _getSetupCylinder; + private readonly System.Action _applyServerControlledVelocityCycle; + + internal RemotePhysicsUpdater( + AcDream.Core.Physics.PhysicsEngine physicsEngine, + System.Func getSetupCylinder, + System.Action applyServerControlledVelocityCycle) + { + _physicsEngine = physicsEngine; + _getSetupCylinder = getSetupCylinder; + _applyServerControlledVelocityCycle = applyServerControlledVelocityCycle; + } + + // Duplicated one-liner (GameWindow keeps its own copy — many callers there). + private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + + /// + /// #184 Slice 2a — the per-remote DR tick (retail UpdateObjectInternal + /// shape), verbatim from the former GameWindow.TickAnimations guard + /// body. serverGuid + the entity id derive from + /// .Entity; / + /// are passed per-call (they change on streaming recentre — never snapshot + /// them in the constructor). + /// + public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY) + { + uint serverGuid = ae.Entity.ServerGuid; + // R5-V2: retail UpdateObjectInternal ticks TargetManager:: + // HandleTargetting UNCONDITIONALLY per entity, BEFORE the + // movement managers' UseTime. This is where this entity, as a + // watched target, pushes its position to its voyeurs (any entity + // moving-to it), and where its own target-info staleness times + // out. Runs for every remote regardless of the grounded/airborne + // branch below (which drive MoveToManager.UseTime via + // TickRemoteMoveTo). No-op for entities with no target + no voyeurs. + rm.Host?.HandleTargetting(); + + if (IsPlayerGuid(serverGuid) && !rm.Airborne) + { + // ── 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 + + // 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). + // - 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 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; + + // 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; + + // 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; + } + + // R4-V5 glide fix (2026-07-03 user report: a retail + // player using an object GLIDES to it — position moves + // via the UP queue but no walk/run legs): remote + // PLAYERS' MoveToManagers were armed by mt-6 but never + // TICKED — the V4 UseTime slot lived only in the + // NPC/legacy branch below, gated !IsPlayerGuid + // (inherited from the deleted RemoteMoveToDriver's + // NPC-only scope). Retail ticks every entity's + // MovementManager (UpdateObjectInternal has no entity- + // class fork). The manager's dispatches produce the + // locomotion cycle through the funnel sink (the LEGS); + // position stays queue-chased per the L.3 M2 spec, so + // the two compose exactly like an NPC's tick. + TickRemoteMoveTo(rm); + + // 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(); + // Slope-staircase fix (2026-05-05): sample terrain normal + // at the body's current XY so PositionManager can project + // the seqVel-only fallback onto the local slope. Without + // this, the queue-empty interval between UPs left Z flat + // (anim cycles bake Z=0 body-local) — visible ~5 Hz + // staircase when a remote runs up/down hills. The + // projection is a no-op on flat ground. + System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal( + rm.Body.Position.X, rm.Body.Position.Y); + + System.Numerics.Vector3 bodyPosBefore = rm.Body.Position; + System.Numerics.Vector3 offset = rm.Position.ComputeOffset( + dt: (double)dt, + currentBodyPosition: rm.Body.Position, + seqVel: seqVel, + ori: rm.Body.Orientation, + interp: rm.Interp, + maxSpeed: maxSpeed, + terrainNormal: terrainNormal); + // R5-V3 (#171): retail chains Interpolation → Sticky over + // ONE shared delta frame (PositionManager::adjust_offset + // 0x00555190). The combiner's catch-up IS acdream's + // interpolation stage, so its offset SEEDS the frame + // (converted to mover-local) and StickyManager:: + // adjust_offset OVERWRITES it when armed+initialized + // (0x00555430 assigns m_fOrigin rather than accumulating). + // With no stick armed the frame comes back untouched and + // this reduces to the pre-V3 `Position += offset`. + if (rm.Host is { } plHost) + { + var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame + { + Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( + rm.Body.Orientation, offset), + }; + plHost.PositionManager.AdjustOffset(pmDelta, dt); + ApplyPositionManagerDelta(rm.Body, pmDelta); + } + else + { + rm.Body.Position += offset; + } + + // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1. + // Prints per-tick body Z trajectory + queue state + projected + // offset.Z so we can grep before/after the fix and confirm Z + // changes continuously between UPs on slopes (no flat + // intervals followed by snaps). + if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1") + { + bool queueActive = rm.Interp.IsActive; + float nz = terrainNormal?.Z ?? 1.0f; + System.Console.WriteLine( + $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} " + + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) " + + $"queue={queueActive} cpN.Z={nz:F3}"); + } + + // 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 = 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)); + + // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction + // once per remote per ~1 second so we can confirm whether the omega + // sign actually being applied matches the retail-observed turn + // direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - rm.LastOmegaDiagLogTime > 0.5) + { + uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; + System.Console.WriteLine( + $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} " + + $"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; + } + } + } + + // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag + // (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called + // per-frame in update_object). Without this, body.Acceleration stays stale + // or zero → gravity never decays jump velocity → endless rise on jumps. + rm.Body.calc_acceleration(); + + // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). + rm.Body.UpdatePhysicsInternal(dt); + + // 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. + // + // 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 + // 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") + { + // 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.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty + if (rm.CellId != 0) + ae.Entity.ParentCellId = rm.CellId; + ae.Entity.Rotation = rm.Body.Orientation; + } + else + { + // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ── + // + // Stop detection is handled explicitly on packet receipt: + // - UpdateMotion with ForwardCommand flag CLEARED → Ready. + // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. + // Both map to retail's "flag-absent = Invalid = reset to + // default" semantics (FUN_0051F260 bulk-copy). No timer-based + // inference needed — the server sends the right signal every + // time a remote stops. + + // Retail per-tick motion pipeline applied to every remote. + // Mirrors retail FUN_00515020 update_object → FUN_00513730 + // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: + // + // 1. apply_current_movement (FUN_00529210) — recomputes + // body.Velocity from InterpretedState via get_state_velocity. + // 2. Pull omega from the sequencer (baked MotionData.Omega + // for TurnRight / TurnLeft cycles, scaled by speedMod). + // 3. body.update_object(now) — Euler-integrates + // position += Velocity × dt + 0.5 × Accel × dt² AND + // orientation += omega × dt. + // + // On UpdatePosition receipt we hard-snap body.Position and + // body.Orientation — if integration matched server physics, + // each snap is small/invisible. + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + + // Step 1: re-apply current motion commands → body.Velocity. + // Forces OnWalkable + Contact so the gate in apply_current_movement + // always succeeds (remotes are server-authoritative; we don't + // simulate airborne physics for them). + // + // K-fix9 (2026-04-26): SKIP this when the remote is airborne. + // Otherwise the force-OnWalkable + apply_current_movement + // path stomps the +Z velocity we set in OnLiveVectorUpdated, + // and gravity never gets to integrate the arc. The airborne + // body keeps the launch velocity from the VectorUpdate; + // UpdatePhysicsInternal below applies gravity each tick; + // the next UpdatePosition snaps to the new ground location + // and re-grounds. + if (!rm.Airborne) + { + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active; + + // #184 (2026-07-07): a grounded remote carries NO translation + // velocity. Its per-tick movement is the interp CATCH-UP toward + // the MoveOrTeleport-queued server waypoint (computed at the + // sticky-compose site below), which the KEPT ResolveWithTransition + // sweep de-overlaps against neighbours — and the resolved position + // is written back into the SHADOW (below) so the de-overlap + // persists and neighbours collide against the resolved body, not + // the raw server pos. This REPLACES the old synth-velocity model + // (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity): + // retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity + // leg — a remote translates by adjust_offset and the UP is a gentle + // target. Matches Path A's grounded model (:10113); clearing + // velocity mirrors Path A (:10125). + rm.Body.Velocity = System.Numerics.Vector3.Zero; + + // Stale server-velocity → stop the locomotion CYCLE (the legs). + // ANIM ONLY — translation is the catch-up. Kept verbatim (same + // !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch + // so a scripted-path NPC that stops server-side drops out of its + // walk/run cycle; ApplyServerControlledVelocityCycle selects the + // anim from ServerVelocity, independent of Body.Velocity. + bool moveToArmed = rm.MoveTo is + { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }; + bool stickyArmed = + (rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; + if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity + && !moveToArmed && !stickyArmed) + { + double velocityAge = nowSec - rm.LastServerPosTime; + if (velocityAge > ServerControlledVelocityStaleSeconds) + { + rm.ServerVelocity = System.Numerics.Vector3.Zero; + rm.HasServerVelocity = false; + _applyServerControlledVelocityCycle( + serverGuid, + ae, + rm, + System.Numerics.Vector3.Zero); + } + } + + // R4-V4: tick the MoveToManager UNCONDITIONALLY (retail + // MovementManager::UseTime per tick, UpdateObjectInternal call + // @0x00515998) — UseTime runs HandleMoveToPosition / + // HandleTurnToHeading (steering + arrival + fail-distance), + // dispatching its per-node locomotion (turn / RunForward) through + // the sink (the LEGS). Position comes from the catch-up; legs from + // this per-node dispatch + the funnel. The #170-deleted per-frame + // apply_current_movement is NOT reintroduced. + TickRemoteMoveTo(rm); + } + else + { + // Airborne — keep Active flag (so UpdatePhysicsInternal + // doesn't early-return) but DON'T set Contact / OnWalkable. + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; + } + + // Step 2: integrate rotation manually per tick. We can't + // rely on PhysicsBody.update_object here — its MinQuantum + // gate (1/30 s) causes it to SKIP integration when our + // 60fps render dt (~0.016s) is below the quantum, meaning + // rotation never advances. Measured snap per UP was ~129° + // = the full expected 1s × 2.24 rad/s, confirming zero + // between-tick rotation. + // + // Manual integration matches retail's FUN_005256b0 + // apply_physics (Orientation *= quat(ω × dt)). Use + // ObservedOmega derived from server UP rotation deltas so + // the rate exactly matches server physics — hard-snap on + // next UP becomes invisible by construction. + rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object + if (rm.ObservedOmega.LengthSquared() > 1e-8f) + { + float omegaMag = rm.ObservedOmega.Length(); + var axis = rm.ObservedOmega / omegaMag; + float angle = omegaMag * dt; + var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); + rm.Body.Orientation = System.Numerics.Quaternion.Normalize( + System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); + } + + // Step 3: integrate physics — retail FUN_005111D0 + // UpdatePhysicsInternal. Pure Euler: + // position += velocity × dt + 0.5 × accel × dt² + // + // Call UpdatePhysicsInternal DIRECTLY rather than via + // PhysicsBody.update_object (FUN_00515020). update_object gates + // on MinQuantum = 1/30s: at our 60fps render tick (~16ms), + // deltaTime < MinQuantum → early return AND LastUpdateTime is + // NOT advanced. Net effect: position never integrates between + // UpdatePositions and the only Body.Position changes come + // from the UP hard-snap, producing a visible teleport-stride + // on slopes (the "staircase" the user reported). + // + // PlayerMovementController.cs:358 calls UpdatePhysicsInternal + // directly for the same reason. Remote motion mirrors that. + // Omega is already integrated manually above, so we zero it + // here to prevent UpdatePhysicsInternal's own omega pass from + // double-integrating. + var preIntegratePos = rm.Body.Position; + // R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation → + // Sticky over ONE shared delta frame (PositionManager::adjust_offset + // 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition + // sweep so collision resolves whichever movement won (preIntegratePos + // captured first — the sweep covers it). + // • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) — + // the movement source is the adjust_offset walk toward the + // MoveOrTeleport-queued server waypoint, exactly like Path A + // (:10173). StickyManager::adjust_offset then OVERWRITES the + // Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE + // dichotomy), so a stuck monster still steers via #171. + // • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates + // from velocity + gravity, unchanged). + // Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal + // adds no translation on top of the catch-up — no double-move. + if (rm.Host is { } npcHost) + { + AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta; + if (!rm.Airborne) + { + System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; + float maxSpeedNpc = rm.Motion.GetMaxSpeed(); + System.Numerics.Vector3? terrainNormalNpc = + _physicsEngine.SampleTerrainNormal( + rm.Body.Position.X, rm.Body.Position.Y); + System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset( + dt: (double)dt, + currentBodyPosition: rm.Body.Position, + seqVel: seqVelNpc, + ori: rm.Body.Orientation, + interp: rm.Interp, + maxSpeed: maxSpeedNpc, + terrainNormal: terrainNormalNpc); + pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame + { + Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( + rm.Body.Orientation, offsetNpc), + }; + } + else + { + pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); + } + npcHost.PositionManager.AdjustOffset(pmDelta, dt); + ApplyPositionManagerDelta(rm.Body, pmDelta); + } + else if (!rm.Airborne) + { + // No PositionManager host yet (pre-binding): apply the catch-up + // directly, matching Path A's fallback (:10202). + System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; + float maxSpeedNpc = rm.Motion.GetMaxSpeed(); + System.Numerics.Vector3? terrainNormalNpc = + _physicsEngine.SampleTerrainNormal( + rm.Body.Position.X, rm.Body.Position.Y); + rm.Body.Position += rm.Position.ComputeOffset( + dt: (double)dt, + currentBodyPosition: rm.Body.Position, + seqVel: seqVelNpc, + ori: rm.Body.Orientation, + interp: rm.Interp, + maxSpeed: maxSpeedNpc, + terrainNormal: terrainNormalNpc); + } + rm.Body.calc_acceleration(); + rm.Body.UpdatePhysicsInternal(dt); + var postIntegratePos = rm.Body.Position; + + // Step 4: collision sweep — retail FUN_00514B90 → + // FUN_005148A0 → Transition::FindTransitionalPosition. + // Projects the sphere from preIntegratePos to postIntegratePos + // through the BSP + terrain, resolving: + // - terrain Z snap along the slope (fixes the "staircase" where + // horizontal Euler motion up a slope sinks into rising ground + // until the next UP pops it up) + // - indoor BSP walls (via the 6-path dispatcher in BSPQuery) + // - object collisions via ShadowObjectRegistry + // - step-up / step-down against walkable ledges + // ResolveWithTransition is the same call PlayerMovementController + // uses for the local player; remotes now get the full retail + // treatment between UpdatePositions instead of pure kinematics. + // + // Skipped when rm.CellId == 0 (no UP landed yet — can't build + // a SpherePath without a starting cell). One-frame grace until + // the first UP arrives; harmless because the entity is + // server-freshly-spawned at a valid Z anyway. + if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) + { + // #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so + // creatures de-overlap at their TRUE radii (a big monster + // spreads wider, a small one tighter), not the hardcoded + // human 0.48/1.835. GetSetupCylinder returns (setup.Radius, + // setup.Height) × ObjScale — the creature's own dat Setup + // scaled by its wire ObjScale, the same source the local + // player + moveto/sticky use, and consistent with the + // spawn-time shadow registration's entScale. Retail seeds + // the transition from the object's own Setup sphere list × + // m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere; + // ObjScale from set_description 0x00514f40). This narrows + // TS-46 (remotes no longer use human dims); the two-scalar + // API is still a lossy stand-in for retail's full (≤2) + // sphere list, and stepUp/stepDown stay 0.4 (retail derives + // those from the Setup too — an adjacent divergence left as-is). + // Fallback to the human capsule for a shapeless / unresolvable + // Setup (GetSetupCylinder returns (0,0)); a zero radius would + // degenerate the sweep. + var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity); + if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; } + var resolveResult = _physicsEngine.ResolveWithTransition( + preIntegratePos, postIntegratePos, rm.CellId, + sphereRadius: deR, + sphereHeight: deH, + stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f + stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f + // K-fix9 (2026-04-26): mirror the K-fix7 gate — + // airborne remotes must NOT pre-seed the + // ContactPlane, otherwise AdjustOffset's snap-to-plane + // branch zeroes the +Z offset every step (same bug + // we hit on the local jump). + isOnGround: !rm.Airborne, + body: rm.Body, // persist ContactPlane across frames for slope tracking + // Retail default physics state includes EdgeSlide. + // Remote dead-reckoning should exercise the same + // edge/cliff branch as local movement. + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + // Fix #42 (2026-05-05): skip the moving remote's + // own ShadowEntry. _animatedEntities is keyed by + // entity.Id so kv.Key matches the EntityId the + // ShadowObjectRegistry has for this remote. + // Without this, the airborne sweep collides with + // the remote's own cylinder and produces ~1m of + // horizontal drift on the first jump frame + // (validated by [SWEEP-OBJ] traces). + movingEntityId: ae.Entity.Id); + + rm.Body.Position = resolveResult.Position; + if (resolveResult.CellId != 0) + rm.CellId = resolveResult.CellId; + + // #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing + // de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail + // re-registers a moved object's shadow every transition step + // (SetPositionInternal → remove/add_shadows_to_cells, Ghidra + // 0x00515330) so its m_position — the RESOLVED position — is what + // OTHER creatures collide against. acdream's shadow otherwise only + // syncs to the RAW server pos on UpdatePosition, so neighbours would + // de-overlap against each other's OVERLAPPING shadows and any spread + // would be discarded on the next UP (never accumulating), AND the + // player would collide with a shadow offset from where the monster + // renders (the reverted attempt's "stuck on an invisible monster"). + // Syncing the shadow to the resolved body every tick makes the + // de-overlap PERSIST and keeps collision == render. Re-flood is cheap + // MOVEMENT-GATED (#184 review): re-flood only when the resolved + // body moved > ~1 cm since the last shadow registration. This is + // SAFE now that the per-UP :5669 sync is players-only — the NPC + // shadow's only writers are this loop + the UP-branch tail, both to + // the resolved body, so a net-stationary (de-overlapped, sweep- + // blocked) creature keeps its correct shadow and never re-floods, + // while a moving/de-overlapping crowd (which moves every tick) still + // syncs every tick. Bounds the per-tick RegisterMultiPart flood cost + // to actually-moving remotes — the perf risk the review flagged for + // a packed town. (In-place shadow-move + cell-relink-on-change is a + // further optimization if profiling still shows churn.) + if (System.Numerics.Vector3.DistanceSquared( + rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f) + { + SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY); + } + + // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions + // (pc:282699-282715) runs after EVERY SetPositionInternal — + // remote objects included; a VectorUpdate-launched jump arc + // is ordinary object physics in retail. acdream ported the + // velocity reflection for the LOCAL player only (L.3a, + // PlayerMovementController ~:940), so a remote jumping into + // a dungeon ceiling had its POSITION pinned by the sweep + // while its +Z velocity kept integrating — the char hovered + // at the roof until gravity burned the arc off, landing + // late (user report, 0x0007 dungeon). Mirror the local + // site exactly: + // v_new = v − (1 + elasticity)·dot(v, n)·n + // with the AD-25 suppression (bounce only when airborne + // before AND after — corridor slides and landings don't + // reflect; the landing snap below keeps its + // `Velocity.Z <= 0` gate intact). Inelastic movers + // (missiles, later) zero out instead. + if (resolveResult.CollisionNormalValid) + { + bool prevOnWalkable = rm.Body.OnWalkable; + bool nowOnWalkable = resolveResult.IsOnGround; + bool applyBounce = rm.Body.State.HasFlag( + AcDream.Core.Physics.PhysicsStateFlags.Sledding) + ? !(prevOnWalkable && nowOnWalkable) + : (!prevOnWalkable && !nowOnWalkable); + if (applyBounce) + { + if (rm.Body.State.HasFlag( + AcDream.Core.Physics.PhysicsStateFlags.Inelastic)) + { + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + var vRem = rm.Body.Velocity; + var nRem = resolveResult.CollisionNormal; + float dotVN = System.Numerics.Vector3.Dot(vRem, nRem); + if (dotVN < 0f) + { + rm.Body.Velocity = + vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f))); + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine( + $"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}"); + } + } + } + } + + // K-fix15 (2026-04-26): post-resolve landing + // detection for airborne remotes. Mirrors + // PlayerMovementController's local-player landing + // path: when the resolver says we're on ground AND + // velocity is no longer pointing up, transition + // back to grounded — clear Airborne, restore + // Contact + OnWalkable, remove Gravity, zero any + // residual downward velocity, and trigger + // HitGround so the sequencer can swap from + // Falling → idle/locomotion. Without this, an + // airborne remote falls through the floor (gravity + // keeps building Velocity.Z negative 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; + // #184 (2026-07-07): clear the interp queue on landing (mirrors + // the player-remote landing). Airborne UPs hard-snap and never + // Enqueue, so any pre-jump waypoints are stale; without this the + // first grounded catch-up after touchdown chases them backward. + rm.Interp.Clear(); + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Body.Velocity = new System.Numerics.Vector3( + rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); + // #161: HitGround MUST run with the Gravity state + // bit still set — CMotionInterp::HitGround + // (0x00528ac0) gates on state&0x400 (retail never + // clears GRAVITY on landing; it's a persistent + // object property). Clearing it first made this + // re-apply a silent no-op, which is why the + // falling pose never exited. The re-apply + // dispatches the PRESERVED pre-fall forward + // command through the funnel → the motion table + // plays the Falling→X landing link. (The old + // K-fix17 forced SetCycle is deleted: it read the + // then-clobbered InterpretedState.ForwardCommand + // — 0x40000015 — and re-set the very Falling + // cycle it meant to clear.) + // R4-V5 (closes the V4 wiring-contract gap the + // adversarial review caught): retail order — + // minterp first, then moveto (MovementManager:: + // HitGround 0x00524300, §2d — the R5-V5 facade + // relay). Re-arms a moveto suspended by the + // airborne UseTime contact gate; without it a + // chasing NPC that lands stalls until ACE's + // ~1 Hz re-emit. + rm.Movement.HitGround(); + // DR bookkeeping only (partner of the jump-start + // `State |= Gravity`): stops the per-tick gravity + // integration for the grounded body. + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + } + + ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty + if (rm.CellId != 0) + ae.Entity.ParentCellId = rm.CellId; + ae.Entity.Rotation = rm.Body.Orientation; + } + + // R5-V3 (#171): retail UpdateObjectInternal tail — + // PositionManager::UseTime (0x005156b0, call @0x005159b3, + // right after CPartArray::HandleMovement, UNCONDITIONAL for + // every entity in both grounded and airborne branches): the + // sticky 1 s lease watchdog (StickyManager::UseTime + // 0x00555610 — a stick not re-issued by a fresh server arm + // within 1 s tears itself down). No-op while nothing is stuck. + rm.Host?.PositionManager.UseTime(); + } + + /// + /// R4-V5 / R5-V2: the per-tick + /// drive (retail MovementManager::UseTime 0x005242f0 — the moveto + /// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from + /// GameWindow (#184 Slice 2a); the DR tick is its only caller. + /// + private static void TickRemoteMoveTo(RemoteMotion rm) + { + rm.Movement.UseTime(); + } + + /// + /// R5-V3 (#171): apply a + /// written by PositionManager.AdjustOffset onto a body — acdream's + /// stand-in for retail's Frame::combine in + /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine + /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes + /// globaltolocalvec output — 0x00555430), so combining = rotating it + /// out by the body orientation. An untouched (identity) rotation means "no + /// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition + /// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the + /// DR tick is its only caller. + /// + private static void ApplyPositionManagerDelta( + AcDream.Core.Physics.PhysicsBody body, + AcDream.Core.Physics.Motion.MotionDeltaFrame delta) + { + if (delta.Origin != System.Numerics.Vector3.Zero) + body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation); + if (!delta.Orientation.IsIdentity) + body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( + body.Orientation, + AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation) + + delta.GetHeading()); + } + + /// + /// #184 — shadow-follows-resolved. Re-register a remote creature's collision + /// SHADOW at its RESOLVED body position, so OTHER creatures (and the player) + /// de-overlap / collide against where the monster actually IS (== where it + /// renders), not the raw overlapping server position. Retail re-registers a + /// moved object's shadow every accepted transition step (SetPositionInternal + /// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is + /// passed in (/) + /// rather than snapshotted, since it moves on recentre. Updates + /// so callers can movement-gate. + /// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC + /// UP-branch tail. + /// + public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY) + { + int shLbX = (int)((rm.CellId >> 24) & 0xFFu); + int shLbY = (int)((rm.CellId >> 16) & 0xFFu); + float shOffX = (shLbX - liveCenterX) * 192f; + float shOffY = (shLbY - liveCenterY) * 192f; + _physicsEngine.ShadowObjects.UpdatePosition( + entityId, rm.Body.Position, rm.Body.Orientation, + shOffX, shOffY, rm.CellId, seedCellId: rm.CellId); + rm.LastShadowSyncPos = rm.Body.Position; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6d6f2737..f06d9d91 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -151,6 +151,12 @@ public sealed class GameWindow : IDisposable // ConcurrentDictionary inside makes cross-thread access safe. private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new(); + // #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the + // >10k-line TickAnimations (Code Structure Rule 1). Assigned in the ctor + // once the injected shared helpers exist as method groups (GetSetupCylinder, + // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. + private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; + // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -270,7 +276,10 @@ public sealed class GameWindow : IDisposable /// private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new(); - private sealed class AnimatedEntity + // #184 Slice 2a: internal (was private) so the extracted + // RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches + // RemoteMotion's existing internal visibility. + internal sealed class AnimatedEntity { public required AcDream.Core.World.WorldEntity Entity; public required DatReaderWriter.DBObjs.Setup Setup; @@ -1030,7 +1039,8 @@ public sealed class GameWindow : IDisposable private readonly Dictionary _physicsHosts = new(); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; - private const double ServerControlledVelocityStaleSeconds = 0.60; + // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 + // Slice 2a — the DR tick's stale-velocity anim-stop was its only user). private int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -1053,6 +1063,13 @@ public sealed class GameWindow : IDisposable _uiRegistry = uiRegistry; SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); + // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's + // PhysicsEngine; the two helpers it also needs but that have callers + // outside the DR loop (GetSetupCylinder — the player's own cylinder + + // moveto/sticky radii; ApplyServerControlledVelocityCycle — the UP + // handler) stay on GameWindow and are injected as delegates. + _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( + _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); } /// @@ -4591,56 +4608,10 @@ public sealed class GameWindow : IDisposable return (setup.Radius * scale, setup.Height * scale); } - /// - /// R5-V3 (#171): apply a - /// written by PositionManager.AdjustOffset onto a body — acdream's - /// stand-in for retail's Frame::combine in - /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine - /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes - /// globaltolocalvec output — 0x00555430), so combining = rotating it - /// out by the body orientation. The rotation carries a RELATIVE heading; - /// an untouched (identity) rotation means "no turn" — retail distinguishes - /// an unwritten offset rotation from set_heading(0) by the identity - /// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0) - /// makes compass addition the exact frame-combine here. - /// - private static void ApplyPositionManagerDelta( - AcDream.Core.Physics.PhysicsBody body, - AcDream.Core.Physics.Motion.MotionDeltaFrame delta) - { - if (delta.Origin != System.Numerics.Vector3.Zero) - body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation); - if (!delta.Orientation.IsIdentity) - body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( - body.Orientation, - AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation) - + delta.GetHeading()); - } - - /// - /// #184 — shadow-follows-resolved. Re-register a remote creature's collision - /// SHADOW at its RESOLVED body position, so OTHER creatures (and the player) - /// de-overlap / collide against where the monster actually IS (== where it - /// renders), not the raw overlapping server position. Retail re-registers a - /// moved object's shadow every accepted transition step (SetPositionInternal - /// → remove/add_shadows_to_cells, Ghidra 0x00515330); this is the acdream - /// equivalent driven from the remote DR loop. worldOffset is recomputed from - /// .CellId + the streaming centre, exactly as the UP - /// handler derives origin (:5618) — and is DEAD anyway when seedCellId - /// is supplied (it only feeds the short-circuited DeriveOutdoorSeed). Updates - /// so callers can movement-gate. - /// - private void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm) - { - int shLbX = (int)((rm.CellId >> 24) & 0xFFu); - int shLbY = (int)((rm.CellId >> 16) & 0xFFu); - float shOffX = (shLbX - _liveCenterX) * 192f; - float shOffY = (shLbY - _liveCenterY) * 192f; - _physicsEngine.ShadowObjects.UpdatePosition( - entityId, rm.Body.Position, rm.Body.Orientation, - shOffX, shOffY, rm.CellId, seedCellId: rm.CellId); - rm.LastShadowSyncPos = rm.Body.Position; - } + // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to + // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no + // caller outside the DR tick; SyncRemoteShadowToBody is now called back via + // _remotePhysicsUpdater from the NPC UP-branch tail (below). /// /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the @@ -4848,23 +4819,10 @@ public sealed class GameWindow : IDisposable return false; } - /// - /// R4-V5 / R5-V2: the per-tick - /// drive (retail MovementManager::UseTime 0x005242f0 — the moveto - /// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4 - /// TargetTracker POLL is gone (R5-V2): target-position delivery now flows - /// through the - /// voyeur system — the target's own HandleTargetting pushes updates - /// into this entity's HandleUpdateTarget, which is called - /// unconditionally per remote in the per-tick loop BEFORE this drive - /// (retail UpdateObjectInternal order). Safe for any remote: a - /// manager with no armed moveto no-ops, airborne bodies held by - /// UseTime's contact gate. - /// - private void TickRemoteMoveTo(RemoteMotion rm) - { - rm.Movement.UseTime(); - } + // #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime + // 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is + // gone, TargetManager voyeur delivers positions) moved to + // RemotePhysicsUpdater. The DR tick was its only caller. /// /// Phase 6.6: the server says an entity's motion has changed. Look up @@ -6153,7 +6111,8 @@ public sealed class GameWindow : IDisposable // the per-tick loop skips). The per-tick loop keeps it current between // UPs, movement-gated. rmState.CellId is the server cell adopted above. if (rmState.CellId != 0) - SyncRemoteShadowToBody(entity.Id, rmState); + _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, rmState, _liveCenterX, _liveCenterY); entity.SetPosition(rmState.Body.Position); entity.Rotation = rmState.Body.Orientation; @@ -10181,696 +10140,7 @@ public sealed class GameWindow : IDisposable // TickAnimations advance. && rm.LastServerPosTime > 0) { - // R5-V2: retail UpdateObjectInternal ticks TargetManager:: - // HandleTargetting UNCONDITIONALLY per entity, BEFORE the - // movement managers' UseTime. This is where this entity, as a - // watched target, pushes its position to its voyeurs (any entity - // moving-to it), and where its own target-info staleness times - // out. Runs for every remote regardless of the grounded/airborne - // branch below (which drive MoveToManager.UseTime via - // TickRemoteMoveTo). No-op for entities with no target + no voyeurs. - rm.Host?.HandleTargetting(); - - if (IsPlayerGuid(serverGuid) && !rm.Airborne) - { - // ── 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 + - // 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). - // - 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 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; - - // 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; - - // 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; - } - - // R4-V5 glide fix (2026-07-03 user report: a retail - // player using an object GLIDES to it — position moves - // via the UP queue but no walk/run legs): remote - // PLAYERS' MoveToManagers were armed by mt-6 but never - // TICKED — the V4 UseTime slot lived only in the - // NPC/legacy branch below, gated !IsPlayerGuid - // (inherited from the deleted RemoteMoveToDriver's - // NPC-only scope). Retail ticks every entity's - // MovementManager (UpdateObjectInternal has no entity- - // class fork). The manager's dispatches produce the - // locomotion cycle through the funnel sink (the LEGS); - // position stays queue-chased per the L.3 M2 spec, so - // the two compose exactly like an NPC's tick. - TickRemoteMoveTo(rm); - - // 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(); - // Slope-staircase fix (2026-05-05): sample terrain normal - // at the body's current XY so PositionManager can project - // the seqVel-only fallback onto the local slope. Without - // this, the queue-empty interval between UPs left Z flat - // (anim cycles bake Z=0 body-local) — visible ~5 Hz - // staircase when a remote runs up/down hills. The - // projection is a no-op on flat ground. - System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal( - rm.Body.Position.X, rm.Body.Position.Y); - - System.Numerics.Vector3 bodyPosBefore = rm.Body.Position; - System.Numerics.Vector3 offset = rm.Position.ComputeOffset( - dt: (double)dt, - currentBodyPosition: rm.Body.Position, - seqVel: seqVel, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeed, - terrainNormal: terrainNormal); - // R5-V3 (#171): retail chains Interpolation → Sticky over - // ONE shared delta frame (PositionManager::adjust_offset - // 0x00555190). The combiner's catch-up IS acdream's - // interpolation stage, so its offset SEEDS the frame - // (converted to mover-local) and StickyManager:: - // adjust_offset OVERWRITES it when armed+initialized - // (0x00555430 assigns m_fOrigin rather than accumulating). - // With no stick armed the frame comes back untouched and - // this reduces to the pre-V3 `Position += offset`. - if (rm.Host is { } plHost) - { - var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame - { - Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( - rm.Body.Orientation, offset), - }; - plHost.PositionManager.AdjustOffset(pmDelta, dt); - ApplyPositionManagerDelta(rm.Body, pmDelta); - } - else - { - rm.Body.Position += offset; - } - - // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1. - // Prints per-tick body Z trajectory + queue state + projected - // offset.Z so we can grep before/after the fix and confirm Z - // changes continuously between UPs on slopes (no flat - // intervals followed by snaps). - if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1") - { - bool queueActive = rm.Interp.IsActive; - float nz = terrainNormal?.Z ?? 1.0f; - System.Console.WriteLine( - $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} " - + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) " - + $"queue={queueActive} cpN.Z={nz:F3}"); - } - - // 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 = 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)); - - // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction - // once per remote per ~1 second so we can confirm whether the omega - // sign actually being applied matches the retail-observed turn - // direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight). - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSec - rm.LastOmegaDiagLogTime > 0.5) - { - uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; - System.Console.WriteLine( - $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} " - + $"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; - } - } - } - - // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag - // (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called - // per-frame in update_object). Without this, body.Acceleration stays stale - // or zero → gravity never decays jump velocity → endless rise on jumps. - rm.Body.calc_acceleration(); - - // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). - rm.Body.UpdatePhysicsInternal(dt); - - // 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. - // - // 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 - // 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") - { - // 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.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty - if (rm.CellId != 0) - ae.Entity.ParentCellId = rm.CellId; - ae.Entity.Rotation = rm.Body.Orientation; - } - else - { - // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ── - // - // Stop detection is handled explicitly on packet receipt: - // - UpdateMotion with ForwardCommand flag CLEARED → Ready. - // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. - // Both map to retail's "flag-absent = Invalid = reset to - // default" semantics (FUN_0051F260 bulk-copy). No timer-based - // inference needed — the server sends the right signal every - // time a remote stops. - - // Retail per-tick motion pipeline applied to every remote. - // Mirrors retail FUN_00515020 update_object → FUN_00513730 - // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: - // - // 1. apply_current_movement (FUN_00529210) — recomputes - // body.Velocity from InterpretedState via get_state_velocity. - // 2. Pull omega from the sequencer (baked MotionData.Omega - // for TurnRight / TurnLeft cycles, scaled by speedMod). - // 3. body.update_object(now) — Euler-integrates - // position += Velocity × dt + 0.5 × Accel × dt² AND - // orientation += omega × dt. - // - // On UpdatePosition receipt we hard-snap body.Position and - // body.Orientation — if integration matched server physics, - // each snap is small/invisible. - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - - // Step 1: re-apply current motion commands → body.Velocity. - // Forces OnWalkable + Contact so the gate in apply_current_movement - // always succeeds (remotes are server-authoritative; we don't - // simulate airborne physics for them). - // - // K-fix9 (2026-04-26): SKIP this when the remote is airborne. - // Otherwise the force-OnWalkable + apply_current_movement - // path stomps the +Z velocity we set in OnLiveVectorUpdated, - // and gravity never gets to integrate the arc. The airborne - // body keeps the launch velocity from the VectorUpdate; - // UpdatePhysicsInternal below applies gravity each tick; - // the next UpdatePosition snaps to the new ground location - // and re-grounds. - if (!rm.Airborne) - { - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable - | AcDream.Core.Physics.TransientStateFlags.Active; - - // #184 (2026-07-07): a grounded remote carries NO translation - // velocity. Its per-tick movement is the interp CATCH-UP toward - // the MoveOrTeleport-queued server waypoint (computed at the - // sticky-compose site below), which the KEPT ResolveWithTransition - // sweep de-overlaps against neighbours — and the resolved position - // is written back into the SHADOW (below) so the de-overlap - // persists and neighbours collide against the resolved body, not - // the raw server pos. This REPLACES the old synth-velocity model - // (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity): - // retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity - // leg — a remote translates by adjust_offset and the UP is a gentle - // target. Matches Path A's grounded model (:10113); clearing - // velocity mirrors Path A (:10125). - rm.Body.Velocity = System.Numerics.Vector3.Zero; - - // Stale server-velocity → stop the locomotion CYCLE (the legs). - // ANIM ONLY — translation is the catch-up. Kept verbatim (same - // !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch - // so a scripted-path NPC that stops server-side drops out of its - // walk/run cycle; ApplyServerControlledVelocityCycle selects the - // anim from ServerVelocity, independent of Body.Velocity. - bool moveToArmed = rm.MoveTo is - { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }; - bool stickyArmed = - (rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; - if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity - && !moveToArmed && !stickyArmed) - { - double velocityAge = nowSec - rm.LastServerPosTime; - if (velocityAge > ServerControlledVelocityStaleSeconds) - { - rm.ServerVelocity = System.Numerics.Vector3.Zero; - rm.HasServerVelocity = false; - ApplyServerControlledVelocityCycle( - serverGuid, - ae, - rm, - System.Numerics.Vector3.Zero); - } - } - - // R4-V4: tick the MoveToManager UNCONDITIONALLY (retail - // MovementManager::UseTime per tick, UpdateObjectInternal call - // @0x00515998) — UseTime runs HandleMoveToPosition / - // HandleTurnToHeading (steering + arrival + fail-distance), - // dispatching its per-node locomotion (turn / RunForward) through - // the sink (the LEGS). Position comes from the catch-up; legs from - // this per-node dispatch + the funnel. The #170-deleted per-frame - // apply_current_movement is NOT reintroduced. - TickRemoteMoveTo(rm); - } - else - { - // Airborne — keep Active flag (so UpdatePhysicsInternal - // doesn't early-return) but DON'T set Contact / OnWalkable. - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; - } - - // Step 2: integrate rotation manually per tick. We can't - // rely on PhysicsBody.update_object here — its MinQuantum - // gate (1/30 s) causes it to SKIP integration when our - // 60fps render dt (~0.016s) is below the quantum, meaning - // rotation never advances. Measured snap per UP was ~129° - // = the full expected 1s × 2.24 rad/s, confirming zero - // between-tick rotation. - // - // Manual integration matches retail's FUN_005256b0 - // apply_physics (Orientation *= quat(ω × dt)). Use - // ObservedOmega derived from server UP rotation deltas so - // the rate exactly matches server physics — hard-snap on - // next UP becomes invisible by construction. - rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object - if (rm.ObservedOmega.LengthSquared() > 1e-8f) - { - float omegaMag = rm.ObservedOmega.Length(); - var axis = rm.ObservedOmega / omegaMag; - float angle = omegaMag * dt; - var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); - rm.Body.Orientation = System.Numerics.Quaternion.Normalize( - System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); - } - - // Step 3: integrate physics — retail FUN_005111D0 - // UpdatePhysicsInternal. Pure Euler: - // position += velocity × dt + 0.5 × accel × dt² - // - // Call UpdatePhysicsInternal DIRECTLY rather than via - // PhysicsBody.update_object (FUN_00515020). update_object gates - // on MinQuantum = 1/30s: at our 60fps render tick (~16ms), - // deltaTime < MinQuantum → early return AND LastUpdateTime is - // NOT advanced. Net effect: position never integrates between - // UpdatePositions and the only Body.Position changes come - // from the UP hard-snap, producing a visible teleport-stride - // on slopes (the "staircase" the user reported). - // - // PlayerMovementController.cs:358 calls UpdatePhysicsInternal - // directly for the same reason. Remote motion mirrors that. - // Omega is already integrated manually above, so we zero it - // here to prevent UpdatePhysicsInternal's own omega pass from - // double-integrating. - var preIntegratePos = rm.Body.Position; - // R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation → - // Sticky over ONE shared delta frame (PositionManager::adjust_offset - // 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition - // sweep so collision resolves whichever movement won (preIntegratePos - // captured first — the sweep covers it). - // • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) — - // the movement source is the adjust_offset walk toward the - // MoveOrTeleport-queued server waypoint, exactly like Path A - // (:10173). StickyManager::adjust_offset then OVERWRITES the - // Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE - // dichotomy), so a stuck monster still steers via #171. - // • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates - // from velocity + gravity, unchanged). - // Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal - // adds no translation on top of the catch-up — no double-move. - if (rm.Host is { } npcHost) - { - AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta; - if (!rm.Airborne) - { - System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity - ?? System.Numerics.Vector3.Zero; - float maxSpeedNpc = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3? terrainNormalNpc = - _physicsEngine.SampleTerrainNormal( - rm.Body.Position.X, rm.Body.Position.Y); - System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset( - dt: (double)dt, - currentBodyPosition: rm.Body.Position, - seqVel: seqVelNpc, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeedNpc, - terrainNormal: terrainNormalNpc); - pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame - { - Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( - rm.Body.Orientation, offsetNpc), - }; - } - else - { - pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - } - npcHost.PositionManager.AdjustOffset(pmDelta, dt); - ApplyPositionManagerDelta(rm.Body, pmDelta); - } - else if (!rm.Airborne) - { - // No PositionManager host yet (pre-binding): apply the catch-up - // directly, matching Path A's fallback (:10202). - System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity - ?? System.Numerics.Vector3.Zero; - float maxSpeedNpc = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3? terrainNormalNpc = - _physicsEngine.SampleTerrainNormal( - rm.Body.Position.X, rm.Body.Position.Y); - rm.Body.Position += rm.Position.ComputeOffset( - dt: (double)dt, - currentBodyPosition: rm.Body.Position, - seqVel: seqVelNpc, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeedNpc, - terrainNormal: terrainNormalNpc); - } - rm.Body.calc_acceleration(); - rm.Body.UpdatePhysicsInternal(dt); - var postIntegratePos = rm.Body.Position; - - // Step 4: collision sweep — retail FUN_00514B90 → - // FUN_005148A0 → Transition::FindTransitionalPosition. - // Projects the sphere from preIntegratePos to postIntegratePos - // through the BSP + terrain, resolving: - // - terrain Z snap along the slope (fixes the "staircase" where - // horizontal Euler motion up a slope sinks into rising ground - // until the next UP pops it up) - // - indoor BSP walls (via the 6-path dispatcher in BSPQuery) - // - object collisions via ShadowObjectRegistry - // - step-up / step-down against walkable ledges - // ResolveWithTransition is the same call PlayerMovementController - // uses for the local player; remotes now get the full retail - // treatment between UpdatePositions instead of pure kinematics. - // - // Skipped when rm.CellId == 0 (no UP landed yet — can't build - // a SpherePath without a starting cell). One-frame grace until - // the first UP arrives; harmless because the entity is - // server-freshly-spawned at a valid Z anyway. - if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) - { - // #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so - // creatures de-overlap at their TRUE radii (a big monster - // spreads wider, a small one tighter), not the hardcoded - // human 0.48/1.835. GetSetupCylinder returns (setup.Radius, - // setup.Height) × ObjScale — the creature's own dat Setup - // scaled by its wire ObjScale, the same source the local - // player + moveto/sticky use, and consistent with the - // spawn-time shadow registration's entScale. Retail seeds - // the transition from the object's own Setup sphere list × - // m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere; - // ObjScale from set_description 0x00514f40). This narrows - // TS-46 (remotes no longer use human dims); the two-scalar - // API is still a lossy stand-in for retail's full (≤2) - // sphere list, and stepUp/stepDown stay 0.4 (retail derives - // those from the Setup too — an adjacent divergence left as-is). - // Fallback to the human capsule for a shapeless / unresolvable - // Setup (GetSetupCylinder returns (0,0)); a zero radius would - // degenerate the sweep. - var (deR, deH) = GetSetupCylinder(serverGuid, ae.Entity); - if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; } - var resolveResult = _physicsEngine.ResolveWithTransition( - preIntegratePos, postIntegratePos, rm.CellId, - sphereRadius: deR, - sphereHeight: deH, - stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f - stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f - // K-fix9 (2026-04-26): mirror the K-fix7 gate — - // airborne remotes must NOT pre-seed the - // ContactPlane, otherwise AdjustOffset's snap-to-plane - // branch zeroes the +Z offset every step (same bug - // we hit on the local jump). - isOnGround: !rm.Airborne, - body: rm.Body, // persist ContactPlane across frames for slope tracking - // Retail default physics state includes EdgeSlide. - // Remote dead-reckoning should exercise the same - // edge/cliff branch as local movement. - moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide, - // Fix #42 (2026-05-05): skip the moving remote's - // own ShadowEntry. _animatedEntities is keyed by - // entity.Id so kv.Key matches the EntityId the - // ShadowObjectRegistry has for this remote. - // Without this, the airborne sweep collides with - // the remote's own cylinder and produces ~1m of - // horizontal drift on the first jump frame - // (validated by [SWEEP-OBJ] traces). - movingEntityId: kv.Key); - - rm.Body.Position = resolveResult.Position; - if (resolveResult.CellId != 0) - rm.CellId = resolveResult.CellId; - - // #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing - // de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail - // re-registers a moved object's shadow every transition step - // (SetPositionInternal → remove/add_shadows_to_cells, Ghidra - // 0x00515330) so its m_position — the RESOLVED position — is what - // OTHER creatures collide against. acdream's shadow otherwise only - // syncs to the RAW server pos on UpdatePosition, so neighbours would - // de-overlap against each other's OVERLAPPING shadows and any spread - // would be discarded on the next UP (never accumulating), AND the - // player would collide with a shadow offset from where the monster - // renders (the reverted attempt's "stuck on an invisible monster"). - // Syncing the shadow to the resolved body every tick makes the - // de-overlap PERSIST and keeps collision == render. Re-flood is cheap - // MOVEMENT-GATED (#184 review): re-flood only when the resolved - // body moved > ~1 cm since the last shadow registration. This is - // SAFE now that the per-UP :5669 sync is players-only — the NPC - // shadow's only writers are this loop + the UP-branch tail, both to - // the resolved body, so a net-stationary (de-overlapped, sweep- - // blocked) creature keeps its correct shadow and never re-floods, - // while a moving/de-overlapping crowd (which moves every tick) still - // syncs every tick. Bounds the per-tick RegisterMultiPart flood cost - // to actually-moving remotes — the perf risk the review flagged for - // a packed town. (In-place shadow-move + cell-relink-on-change is a - // further optimization if profiling still shows churn.) - if (System.Numerics.Vector3.DistanceSquared( - rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f) - { - SyncRemoteShadowToBody(kv.Key, rm); - } - - // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions - // (pc:282699-282715) runs after EVERY SetPositionInternal — - // remote objects included; a VectorUpdate-launched jump arc - // is ordinary object physics in retail. acdream ported the - // velocity reflection for the LOCAL player only (L.3a, - // PlayerMovementController ~:940), so a remote jumping into - // a dungeon ceiling had its POSITION pinned by the sweep - // while its +Z velocity kept integrating — the char hovered - // at the roof until gravity burned the arc off, landing - // late (user report, 0x0007 dungeon). Mirror the local - // site exactly: - // v_new = v − (1 + elasticity)·dot(v, n)·n - // with the AD-25 suppression (bounce only when airborne - // before AND after — corridor slides and landings don't - // reflect; the landing snap below keeps its - // `Velocity.Z <= 0` gate intact). Inelastic movers - // (missiles, later) zero out instead. - if (resolveResult.CollisionNormalValid) - { - bool prevOnWalkable = rm.Body.OnWalkable; - bool nowOnWalkable = resolveResult.IsOnGround; - bool applyBounce = rm.Body.State.HasFlag( - AcDream.Core.Physics.PhysicsStateFlags.Sledding) - ? !(prevOnWalkable && nowOnWalkable) - : (!prevOnWalkable && !nowOnWalkable); - if (applyBounce) - { - if (rm.Body.State.HasFlag( - AcDream.Core.Physics.PhysicsStateFlags.Inelastic)) - { - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - else - { - var vRem = rm.Body.Velocity; - var nRem = resolveResult.CollisionNormal; - float dotVN = System.Numerics.Vector3.Dot(vRem, nRem); - if (dotVN < 0f) - { - rm.Body.Velocity = - vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f))); - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine( - $"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}"); - } - } - } - } - - // K-fix15 (2026-04-26): post-resolve landing - // detection for airborne remotes. Mirrors - // PlayerMovementController's local-player landing - // path: when the resolver says we're on ground AND - // velocity is no longer pointing up, transition - // back to grounded — clear Airborne, restore - // Contact + OnWalkable, remove Gravity, zero any - // residual downward velocity, and trigger - // HitGround so the sequencer can swap from - // Falling → idle/locomotion. Without this, an - // airborne remote falls through the floor (gravity - // keeps building Velocity.Z negative 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; - // #184 (2026-07-07): clear the interp queue on landing (mirrors - // the player-remote landing). Airborne UPs hard-snap and never - // Enqueue, so any pre-jump waypoints are stale; without this the - // first grounded catch-up after touchdown chases them backward. - rm.Interp.Clear(); - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rm.Body.Velocity = new System.Numerics.Vector3( - rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); - // #161: HitGround MUST run with the Gravity state - // bit still set — CMotionInterp::HitGround - // (0x00528ac0) gates on state&0x400 (retail never - // clears GRAVITY on landing; it's a persistent - // object property). Clearing it first made this - // re-apply a silent no-op, which is why the - // falling pose never exited. The re-apply - // dispatches the PRESERVED pre-fall forward - // command through the funnel → the motion table - // plays the Falling→X landing link. (The old - // K-fix17 forced SetCycle is deleted: it read the - // then-clobbered InterpretedState.ForwardCommand - // — 0x40000015 — and re-set the very Falling - // cycle it meant to clear.) - // R4-V5 (closes the V4 wiring-contract gap the - // adversarial review caught): retail order — - // minterp first, then moveto (MovementManager:: - // HitGround 0x00524300, §2d — the R5-V5 facade - // relay). Re-arms a moveto suspended by the - // airborne UseTime contact gate; without it a - // chasing NPC that lands stalls until ACE's - // ~1 Hz re-emit. - rm.Movement.HitGround(); - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`): stops the per-tick gravity - // integration for the grounded body. - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); - } - } - - ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty - if (rm.CellId != 0) - ae.Entity.ParentCellId = rm.CellId; - ae.Entity.Rotation = rm.Body.Orientation; - } - - // R5-V3 (#171): retail UpdateObjectInternal tail — - // PositionManager::UseTime (0x005156b0, call @0x005159b3, - // right after CPartArray::HandleMovement, UNCONDITIONAL for - // every entity in both grounded and airborne branches): the - // sticky 1 s lease watchdog (StickyManager::UseTime - // 0x00555610 — a stick not re-issued by a fresh server arm - // within 1 s tears itself down). No-op while nothing is stuck. - rm.Host?.PositionManager.UseTime(); + _remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY); } // ── Get per-part (origin, orientation) from either sequencer or legacy ──