using AcDream.Runtime.Entities; namespace AcDream.Runtime.Physics; internal readonly record struct RuntimeRemotePhysicsSnapshot( System.Numerics.Vector3 Position, System.Numerics.Quaternion Orientation, uint FullCellId); /// /// #184 Slice 2a extracted the per-remote dead-reckoning physics tick /// verbatim from the former graphical frame owner. Slice J5.5 moved that /// presentation-free simulation under the per-session Runtime physics owner. /// It is called once per eligible remote entity per retail object quantum, /// preserving the same guard /// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid /// && rm.LastServerPosTime > 0). /// Hidden remotes use a separate live-entity pass because retail keeps their /// PositionManager alive even when the object has no render-animation owner. /// /// Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED /// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the /// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue /// #40 premise) is gone — every remote now runs the SAME catch-up + /// ResolveWithTransition sweep + shadow-follows-resolved, so packed PLAYER /// remotes de-overlap exactly like NPCs (retail UpdateObjectInternal /// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is /// the !IsPlayerGuid-gated stale-velocity animation-cycle stop. See /// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. /// /// Shared policy arrives through focused Physics delegates: /// DAT-derived shape dimensions and animation-cycle projection arrive through /// the App adapter in a graphical host. 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 RuntimeRemotePhysicsUpdater { // Preserved from #184 Slice 2a; the remote tick remains its only caller. private const double ServerControlledVelocityStaleSeconds = 0.60; private readonly RuntimePhysicsState _physics; internal RuntimeRemotePhysicsUpdater( RuntimePhysicsState physics) { _physics = physics ?? throw new ArgumentNullException(nameof(physics)); } // Retail GUID classification used only for collision flags. private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; /// /// Canonical ordinary-object tick. Render animation is optional: retail /// walks CPhysics::object_maint, so a live object with a /// MovementManager or PositionManager must continue even when it has no /// render-animation presentation component. /// internal bool Tick( RuntimeEntityRecord record, RemoteMotion rm, float objectScale, AcDream.Core.Physics.AnimationSequencer? sequencer, float dt, ulong objectClockEpoch, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, float radius, float height, int liveCenterX, int liveCenterY, System.Action? processAnimationHooks = null, System.Action? applyStaleVelocityCycle = null, System.Func? acknowledgeProjection = null, System.Func? externalOwnerValid = null, // TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived // step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape). // Default/empty preserves the pre-TS-46 human-capsule fallback below. System.Collections.Immutable.ImmutableArray sphereList = default, float sphereScale = 1f, float stepUpHeight = 0.4f, float stepDownHeight = 0.4f, // TS-23 (2026-07-30): the remote's own PK/PKLite/Impenetrable // ObjectInfoState bits (LiveEntityMotionRuntimeController's // ClientObjectTable-backed lookup, translated via // EntityCollisionFlagsExt.ToMoverState). Default None is a no-op OR // for every non-PK remote — bit-identical to the pre-P3 value. AcDream.Core.Physics.ObjectInfoState moverPvpState = AcDream.Core.Physics.ObjectInfoState.None) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rm); ArgumentNullException.ThrowIfNull(rootMotionLocalFrame); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } uint serverGuid = record.ServerGuid; // Bug B (2026-08-04): stamp the GUID for any [remote-slide-*] line // emitted from inside this remote's synchronous tick — in particular // blip producer Candidate 2, which fires deep inside // InterpolationManager and has no GUID of its own. TEMPORARY — strip // with the ACDREAM_PROBE_REMOTE_SLIDE family. AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution( serverGuid); uint localEntityId = record.LocalEntityId ?? throw new InvalidOperationException( $"Runtime entity 0x{serverGuid:X8}/{record.Incarnation} has no local identity."); // #184 Slice 2b — the UNIFIED per-remote tick. The former Path A // (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition // sweep OMITTED, per the now-retired issue-#40 "collision is the sender's // problem" premise) is GONE — every remote now runs the SAME catch-up + // sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap // exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO // player/remote fork; only the stale animation-cycle stop below remains // player/NPC-specific. // // Stop detection stays explicit on packet receipt (UpdateMotion // ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared -> // StopCompletely). Mirrors retail update_object -> UpdatePositionInternal // -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0). // The bare block scopes this update's locals (formerly the else body). { double nowSec = _physics.UtcNowSeconds; // Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales // the CSequence root displacement by m_scale only while the body // carries ON_WALKABLE_TS (`if ((transient_state & 2) == 0)` at // 0x00512CA1 multiplies the accumulated root frame by 0f, the else // arm by m_scale). Bug B (2026-08-04): read the transient the // sweep committed, never a separately tracked client bool — the // two disagree exactly on a steep contact, which is the surface // this whole fix is about. bool bodyOnWalkableAtTickStart = rm.Body.OnWalkable; System.Numerics.Vector3 scaledRootMotionLocalOrigin = bodyOnWalkableAtTickStart ? rootMotionLocalFrame.Origin * objectScale : System.Numerics.Vector3.Zero; // Bug B (2026-08-04) capture for the [remote-slide-tick] line // below. These USED to record whether the deleted per-tick // `TransientState |= Contact | OnWalkable` force actually flipped a // clear bit. The force is gone (see the block comment below), so // they now simply report the transient state the body ENTERED this // tick with — the INVERSE of what "forced" implied. The C# names // are kept because the diagnosis doc quotes them, but the LOG keys // were renamed to `entryNoContact=`/`entryNoWalkable=` so a // post-fix capture cannot be read against a pre-fix one; grep the // new keys. // TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family. bool slideForcedContact = !rm.Body.InContact; bool slideForcedWalkable = !rm.Body.OnWalkable; System.Numerics.Vector3 slideVelocityBeforeZero = rm.Body.Velocity; // retail CPhysicsObj::update_object 0x00515D10 -> set_active(1) // @0x00515DC2. ACTIVE is the only transient this tick may assert // on its own; CONTACT and ON_WALKABLE belong to // SetPositionInternal (0x00515330) and are committed from the // sweep's contact plane below. // // Bug B (2026-08-04): the deleted lines were // if (!rm.Airborne) // rm.Body.TransientState |= Contact | OnWalkable | Active; // rm.Body.Velocity = Vector3.Zero; // — a per-tick FORGE of both retail transients plus a discard of // the authoritative velocity ACE delivered. On a 52.4-degree roof // the sweep correctly reported "contact, not walkable" and this // overruled it every tick, so `calc_acceleration` saw // Contact && OnWalkable and returned zero acceleration, friction // never engaged, and the body could not move at all. Retail has no // such write: CONTACT comes from `contact_plane_valid` // (0x00515430) and ON_WALKABLE from `contact_plane.N.z >= floor_z` // (0x00515465-0x0051548E), and `MoveOrTeleport` 0x00516330 never // touches the wire velocity vector for a remote at all. rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; if (!rm.Airborne) { // 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; applyStaleVelocityCycle?.Invoke( 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. } // Step 2: CSequence's complete Frame carries motion-table omega through // the same compose as root translation. PhysicsBody.Omega remains // reserved for the object's physical angular velocity and is // integrated once by UpdatePhysicsInternal below. // 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 calls UpdatePhysicsInternal directly // for the same reason. Remote motion mirrors that. CSequence's // authored orientation has already entered the shared delta Frame; // Body.Omega remains the separate physical angular-velocity source. 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). Retail expresses that // gate as `transient_state & 1` (CONTACT_TS) inside // InterpolationManager::adjust_offset @0x00555D52, which is the // `inContact:` argument below; before Bug B's fix the deleted // per-tick force made that argument permanently true. // Bug B (2026-08-04): the body's own velocity is no longer discarded // each tick, so UpdatePhysicsInternal genuinely integrates whatever // the sweep, gravity, and the authoritative wire vector left on it — // that integration is what a steep-contact slide IS. if (rm.Host is { } npcHost) { AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta = rm.PositionManagerDeltaScratch; pmDelta.Origin = scaledRootMotionLocalOrigin; pmDelta.Orientation = rootMotionLocalFrame.Orientation; float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed(); // AD-10 (retired 2026-08-06): a terrain-only slope projection // used to run here, ahead of the sweep. It was an EXTRA copy of // retail's own per-sub-step projection // (CTransition::adjust_offset 0x0050a370, pc:272271-272393, // ported verbatim in Transition.AdjustOffset and reached by the // ResolveWithTransition call below), taken against a // single-point SampleTerrainNormal(x, y) lookup blind to the // body's Z, its cell, buildings, EnvCells and statics. Retail // has no such pre-sweep step. Measured redundant 2026-08-06: // with it removed the production trajectory down a 31-degree // ramp is bit-identical and the whole Runtime suite is // unchanged. Both fork branches carried this block verbatim // (the AP-22 shape); removing the parameter from ComposeOffset // makes a one-site-only regression fail to compile. rm.Position.ComposeOffset( dt, rm.Body.Position, rm.Body.Orientation, pmDelta, rm.Interp, maxSpeedNpc, pmDelta, inContact: rm.Body.InContact); npcHost.PositionManager.AdjustOffset(pmDelta, dt); // #167 (Campaign P P5): push the read side of TS-35's // jump_is_allowed gate. Retail reads IsFullyConstrained through // CPhysicsObj/PositionManager/ConstraintManager directly; acdream's // MotionInterpreter only has a PhysicsBody, so the per-tick pump // (the single owner of this write, matching the taper call just // above) is the seam that keeps the stub property current. rm.Body.IsFullyConstrained = npcHost.PositionManager.IsFullyConstrained(); ApplyPositionManagerDelta(rm.Body, pmDelta); } else { // No PositionManager host yet (pre-binding): apply the catch-up // directly, matching Path A's fallback (:10202). // Airborne suppresses only CPartArray Origin. Retail keeps the // complete root orientation live across the OnWalkable scale // gate in UpdatePositionInternal (0x00512C30). AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta = rm.PositionManagerDeltaScratch; pmDelta.Origin = scaledRootMotionLocalOrigin; pmDelta.Orientation = rootMotionLocalFrame.Orientation; float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed(); // AD-10 (retired 2026-08-06): a terrain-only slope projection // used to run here, ahead of the sweep. It was an EXTRA copy of // retail's own per-sub-step projection // (CTransition::adjust_offset 0x0050a370, pc:272271-272393, // ported verbatim in Transition.AdjustOffset and reached by the // ResolveWithTransition call below), taken against a // single-point SampleTerrainNormal(x, y) lookup blind to the // body's Z, its cell, buildings, EnvCells and statics. Retail // has no such pre-sweep step. Measured redundant 2026-08-06: // with it removed the production trajectory down a 31-degree // ramp is bit-identical and the whole Runtime suite is // unchanged. Both fork branches carried this block verbatim // (the AP-22 shape); removing the parameter from ComposeOffset // makes a one-site-only regression fail to compile. rm.Position.ComposeOffset( dt, rm.Body.Position, rm.Body.Orientation, pmDelta, rm.Interp, maxSpeedNpc, pmDelta, inContact: rm.Body.InContact); ApplyPositionManagerDelta(rm.Body, pmDelta); } rm.Body.calc_acceleration(); rm.Body.UpdatePhysicsInternal(dt); if (sequencer is { } hookSequencer) processAnimationHooks?.Invoke(localEntityId, hookSequencer); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } var postIntegratePos = rm.Body.Position; uint committedCellId = rm.CellId; // 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 && _physics.Engine.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. TS-46 (2026-07-30) // closed the remaining residual: sphereList/sphereScale below // now carry the Setup's own verbatim sphere list (falling back // to this deR/deH reconstruction only when the Setup has no // sphere rows), and stepUpHeight/stepDownHeight are // Setup-derived rather than a hardcoded 0.4f. // Fallback to the human capsule for a shapeless / unresolvable // Setup (GetSetupCylinder returns (0,0)); a zero radius would // degenerate the sweep. float deR = radius; float deH = height; if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; } // AD-25 (2026-07-30): retail handle_all_collisions (pc:282647) // reads the mover's own transient_state CONTACT_TS/ // ON_WALKABLE_TS bits BEFORE this SetPositionInternal-equivalent // resolve — capture them here, matching TickHidden's identical // pre-resolve capture below. bool previousContact = rm.Body.InContact; bool previousOnWalkable = rm.Body.OnWalkable; var resolveResult = _physics.Engine.ResolveWithTransition( preIntegratePos, postIntegratePos, rm.CellId, sphereRadius: deR, sphereHeight: deH, stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal // TS-46: the Setup's own sphere list, scaled by the // creature's own ObjScale. Empty falls back to the // deR/deH two-scalar reconstruction above. sphereList: sphereList, sphereScale: sphereScale, // With a body present this argument no longer seeds // transition contact at all (retail check_contact // 0x0050F5B0 owns that, see PhysicsEngine); it only decides // whether the retained walkable polygon is handed to the // SpherePath. Bug B (2026-08-04): read the committed // ON_WALKABLE transient, exactly like the local player // (`isOnGround: _body.OnWalkable`) and TickHidden // (`isOnGround: previousOnWalkable`), instead of the client // Airborne bool. isOnGround: previousOnWalkable, body: rm.Body, // persist ContactPlane across frames for slope tracking // Retail default physics state includes EdgeSlide; remote DR // should exercise the same edge/cliff branch as local movement. // #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so // CollisionExemption's PvP block fires exactly as it does for the // LOCAL player (PlayerMovementController :920) — two non-PK players // WALK THROUGH each other (retail sets IsPlayer on every object's // own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100` // from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a // non-PK player pair). Without IsPlayer the mover would de-overlap // two players — MORE solid than retail (you can stand inside another // non-PK player in AC). Players still COLLIDE with monsters (target // not IsPlayer → no exemption) + terrain + walls. TS-23 // (2026-07-30): moverPvpState carries the remote's real // PK/PKLite/Impenetrable bits (default None — a no-op OR // for every non-PK remote, bit-identical to the pre-P3 // value); a PK-vs-PK pair now collides exactly like // retail instead of always walking through. moverFlags: (IsPlayerGuid(serverGuid) ? AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide : AcDream.Core.Physics.ObjectInfoState.EdgeSlide) | moverPvpState, // 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: localEntityId); rm.Body.Position = resolveResult.Position; if (resolveResult.CellId != 0) committedCellId = 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 // POSE/CELL-GATED (#184 review): re-flood only when the resolved // body moved > ~1 cm, rotated, or entered another cell. This is // SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for // players too — every remote's shadow (player + NPC) is written ONLY // by 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.) // ── SetPositionInternal commit (Bug B, 2026-08-04) ─────────── // This block REPLACES a bare `HandleAllCollisions(..., // resolveResult.IsOnGround)` call. That call was the TAIL of // retail SetPositionInternal (0x00515330) without its PREFIX: // the sweep's own `InContact` / `OnWalkable` — the exact retail // classification the engine already computed — were never // committed to the body, and the ground edge was decided from // `resolveResult.IsOnGround`, which is `inContact || …` // (PhysicsEngine) and is therefore TRUE on a steep contact. // A remote that touched a 52.4-degree roof was consequently // declared landed, forced walkable, and stripped of gravity. // // Retail order (0x00515430 → 0x0051548E → 0x005154FE): // CONTACT_TS <- collision_info.contact_plane_valid // calc_acceleration // ON_WALKABLE_TS <- contact_plane.N.z >= floor_z, via // set_on_walkable @0x00511310, which is // the SOLE source of // MovementManager::HitGround / // ::LeaveGround — no ownership, player, or // creature gate anywhere in it // calc_acceleration // handle_all_collisions // // That is PhysicsObjUpdate.CommitSetPositionTransition's // sequence MINUS its velocity-authority check: the helper also // honours an `isVelocityCurrent` delegate and skips // handle_all_collisions when a newer Vector/Movement packet // installed a velocity from inside the ground-edge callback // (PhysicsObjUpdate.cs:101-102). That check is inert here — // this site would pass it as null (the helper's default), the // same as the TickHidden call below, because a per-quantum // simulation step is not a packet apply and opens no window in // which a competing velocity authority could land: the only // callbacks between the contact prefix and // handle_all_collisions are HitGround/LeaveGround and the // ownership re-check. The packet-driven placement paths are // the ones that need it: `RuntimeSetPositionState`'s // canonical commit (which now backs every remote packet- // driven placement, teleport included — C4 route 4b-3 // deleted the `RemoteTeleportPlacement.Apply` caller this // note used to name) makes the same check inline around its // own `HandleAllCollisions`. // // It is spelled out through its own public sub-steps // (CommitSetPositionContactPrefix / the ground edge / // CommitSetPositionPostGround / HandleAllCollisions — the seam // whose doc comment exists for precisely this) for two reasons: // the Bug A landing probes must bracket the exact HitGround // call, and this per-remote per-quantum path must not allocate // an `isCurrent` closure. // // The whole commit is gated on `Ok && candidateMoved`, matching // PlayerMovementController and retail UpdateObjectInternal // (pc:283657): a failed transition is discarded whole and a // zero-move frame never re-derives contact. bool candidateMoved = postIntegratePos != preIntegratePos; if (resolveResult.Ok && candidateMoved) { bool finalOnWalkable = AcDream.Core.Physics.PhysicsObjUpdate .CommitSetPositionContactPrefix( rm.Body, resolveResult.InContact, resolveResult.OnWalkable, previousOnWalkable); if (!previousOnWalkable && finalOnWalkable) { // #161: HitGround MUST run with the Gravity state bit // still set — CMotionInterp::HitGround (0x00528AC0) // gates on state & 0x400. Bug B deleted the clear that // used to follow this call: retail NEVER toggles // GRAVITY_PS on a ground edge (`set_state` @0x00514DD0 // post-processes only lighting/nodraw/hidden), it gates // gravity ACCELERATION on the CONTACT/ON_WALKABLE // transients inside calc_acceleration @0x00510950. // R4-V5: retail order is minterp then moveto // (MovementManager::HitGround 0x00524300). rm.Movement.HitGround(); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } // #184 (2026-07-07): clear the interp queue on the // LANDING edge. An airborne remote's Positions hard-snap // and never Enqueue, so any pre-arc waypoints are stale; // without this the first grounded catch-up after // touchdown chases them backward. Bug B kept the // behaviour and only re-derived the edge — it now hangs // off the same `set_on_walkable(1)` transition retail // fires HitGround from, instead of the hand-rolled // `IsOnGround && Velocity.Z <= 0` test that fired on a // steep contact too. Register row AP-139. rm.Interp.Clear(); if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); } else if (previousOnWalkable && !finalOnWalkable) { // set_on_walkable(0) @0x0051133C — // MovementManager::LeaveGround. A remote that walks off // a ledge or slides off a walkable lip onto a steep face // now takes retail's ground-departure edge instead of // staying nominally grounded forever. rm.Motion.LeaveGround(); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } } AcDream.Core.Physics.PhysicsObjUpdate .CommitSetPositionPostGround(rm.Body); // retail CPhysicsObj::handle_all_collisions (0x00514780, // pc:282647) @0x005154FE — the same verbatim port the local // player and every ordinary body use. `nowOnWalkable` is the // COMMITTED transient, not the contact-derived // `resolveResult.IsOnGround` the old call passed: on a steep // contact those disagree, and passing IsOnGround suppressed // the landing reflect exactly where retail forces it. AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions( rm.Body, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, previousContact, previousOnWalkable, rm.Body.OnWalkable); // Bug B (2026-08-04): Airborne is DERIVED from the committed // ON_WALKABLE transient, never latched by a landing test. // This is the project's ONE definition of the flag — every // writer spells `!Body.OnWalkable`, and there are FOUR of // them: `SettleSpawnedRemoteContact` (the spawn-settle // tail), `RuntimeSetPositionState`'s canonical placement // commit (C4 route 4b-3 retired the fifth writer this // note used to name, `RemoteTeleportPlacement.Apply` in // App — the teleport path's `Airborne` derivation is now // this same canonical commit's), and this file's two // (here and the `TickHidden` resolve). // `PlayerMovementController.IsAirborne` computes the same // predicate for the local player. It stays unchanged here; // only the fact it is derived FROM has moved, from a // hand-rolled `IsOnGround` test to the sweep's own // contact-plane result. // // What this flag then GATES is a separate, still-open // divergence: retail's free-flight predicate for the // interpolate-vs-snap decision is CONTACT, not walkability // (`InterpolationManager::adjust_offset` @0x00555D30 gates // its whole body on `transient_state & 1` @0x00555D52). // Register row AP-140. rm.Airborne = !rm.Body.OnWalkable; } // Bug B (2026-08-04) — [remote-slide-tick]. Emitted here, after // the SetPositionInternal commit above, so it reports the // sweep's own retail classification (rsInContact / rsOnWalkable // / rsCpNz) next to what the body now carries. Before the fix // those two columns disagreed on a steep roof — that // disagreement WAS the bug — and they must now agree on every // committed frame. bodyCpNz vs floorZ settles NOT ESTABLISHED // #2 ("is that roof steep in OUR collision data"). // // Rate limit: ShouldEmitRemoteSlideTick emits immediately on // any change to the signature below (every contact / walkable / // grounded / airborne / gravity / steep / moved transition is // captured at full 30 Hz fidelity) and otherwise throttles to // one line per GUID per 200 ms, so a RESTING remote — the whole // point of the capture — stays at ~5 lines/s instead of 30. // TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family. { bool slideBodyCpValid = rm.Body.ContactPlaneValid; float slideBodyCpNz = rm.Body.ContactPlane.Normal.Z; int slideSignature = (rm.Airborne ? 1 << 0 : 0) | (slideForcedContact ? 1 << 1 : 0) | (slideForcedWalkable ? 1 << 2 : 0) | (resolveResult.InContact ? 1 << 3 : 0) | (resolveResult.OnWalkable ? 1 << 4 : 0) | (resolveResult.IsOnGround ? 1 << 5 : 0) | (rm.Body.InContact ? 1 << 6 : 0) | (rm.Body.OnWalkable ? 1 << 7 : 0) | (rm.Body.HasGravity ? 1 << 8 : 0) | (slideBodyCpValid ? 1 << 9 : 0) | (slideBodyCpValid && slideBodyCpNz < AcDream.Core.Physics.PhysicsGlobals.FloorZ ? 1 << 10 : 0) | (System.Numerics.Vector3.Distance( preIntegratePos, resolveResult.Position) > 0.01f ? 1 << 11 : 0); if (AcDream.Core.Physics.PhysicsDiagnostics .ShouldEmitRemoteSlideTick(serverGuid, slideSignature)) { AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideTick( guid: serverGuid, airborne: rm.Airborne, forcedContact: slideForcedContact, forcedWalkable: slideForcedWalkable, velocityBeforeZero: slideVelocityBeforeZero, resolved: true, resolveInContact: resolveResult.InContact, resolveOnWalkable: resolveResult.OnWalkable, resolveIsOnGround: resolveResult.IsOnGround, resolveContactPlaneValid: resolveResult.InContact, resolveContactPlaneNormalZ: resolveResult.ContactPlane.Normal.Z, bodyContactPlaneValid: slideBodyCpValid, bodyContactPlaneNormalZ: slideBodyCpNz, contact: rm.Body.InContact, onWalkable: rm.Body.OnWalkable, gravity: rm.Body.HasGravity, velocity: rm.Body.Velocity, acceleration: rm.Body.Acceleration, preIntegratePosition: preIntegratePos, postIntegratePosition: postIntegratePos, resolvedPosition: resolveResult.Position); } } } else { // Bug B (2026-08-04): the sweep was SKIPPED this tick (no // starting cell, or no landblocks resident). Reported with // resolved=false and every rs* field default so a silent // stretch in the log cannot be misread as "the probe is not // firing". Same edge-or-throttle admission. TEMPORARY — strip // with the ACDREAM_PROBE_REMOTE_SLIDE family. bool skipBodyCpValid = rm.Body.ContactPlaneValid; float skipBodyCpNz = rm.Body.ContactPlane.Normal.Z; int skipSignature = (rm.Airborne ? 1 << 0 : 0) | (slideForcedContact ? 1 << 1 : 0) | (slideForcedWalkable ? 1 << 2 : 0) | (rm.Body.InContact ? 1 << 6 : 0) | (rm.Body.OnWalkable ? 1 << 7 : 0) | (rm.Body.HasGravity ? 1 << 8 : 0) | (skipBodyCpValid ? 1 << 9 : 0) | (1 << 12); if (AcDream.Core.Physics.PhysicsDiagnostics .ShouldEmitRemoteSlideTick(serverGuid, skipSignature)) { AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideTick( guid: serverGuid, airborne: rm.Airborne, forcedContact: slideForcedContact, forcedWalkable: slideForcedWalkable, velocityBeforeZero: slideVelocityBeforeZero, resolved: false, resolveInContact: false, resolveOnWalkable: false, resolveIsOnGround: false, resolveContactPlaneValid: false, resolveContactPlaneNormalZ: 0f, bodyContactPlaneValid: skipBodyCpValid, bodyContactPlaneNormalZ: skipBodyCpNz, contact: rm.Body.InContact, onWalkable: rm.Body.OnWalkable, gravity: rm.Body.HasGravity, velocity: rm.Body.Velocity, acceleration: rm.Body.Acceleration, preIntegratePosition: preIntegratePos, postIntegratePosition: postIntegratePos, resolvedPosition: rm.Body.Position); } } // SetPositionInternal commits the resolved body/contact result and // root frame as one object before changing cell membership. The // canonical CellId writer can synchronously rebucket loaded to // pending, delete, or replace this GUID, so it is the transaction's // final callback boundary before shadow publication. if (!(acknowledgeProjection?.Invoke( new RuntimeRemotePhysicsSnapshot( rm.Body.Position, rm.Body.Orientation, committedCellId)) ?? true) || !IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } bool cellChanged = committedCellId != 0 && committedCellId != rm.CellId; if (cellChanged) rm.CellId = committedCellId; if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } // #184: shadow follows the resolved body, but only after the // canonical rebucket proves this exact owner is still spatially // resident. A pending destination keeps its retained registration // suspended; a callback-created replacement owns another local ID. if (ShouldSynchronizeShadow( cellChanged, rm.Body.Position, rm.Body.Orientation, rm.LastShadowSyncPos, rm.LastShadowSyncOrientation)) { SyncRemoteShadowToBody( localEntityId, rm, liveCenterX, liveCenterY); } } // 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. AcDream.Core.Physics.RetailObjectManagerTail.Run( rm.Host?.TargetManager, rm.Movement, sequencer?.Manager, rm.Host?.PositionManager); return IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid); } /// /// Retail hidden-object slice of CPhysicsObj::UpdatePositionInternal /// (0x00512C30) plus the manager tail of /// UpdateObjectInternal (0x005156B0). Hidden skips /// CPartArray::Update and UpdatePhysicsInternal, but the /// PositionManager offset is still composed and the target, movement, and /// position managers still consume time. Physics-script and particle owners /// tick later in the shared frame pipeline. /// internal bool TickHidden( RuntimeEntityRecord record, RemoteMotion rm, float dt, ulong objectClockEpoch, float radius, float height, AcDream.Core.Physics.Motion.MotionTableManager? partArrayHandleMovement = null, System.Action? processAnimationHooks = null, AcDream.Core.Physics.AnimationSequencer? sequencer = null, System.Func? acknowledgeProjection = null, System.Func? externalOwnerValid = null, // TS-46/TS-23 (2026-07-30): see the visible Tick's identical parameters. System.Collections.Immutable.ImmutableArray sphereList = default, float sphereScale = 1f, float stepUpHeight = 0.4f, float stepDownHeight = 0.4f, AcDream.Core.Physics.ObjectInfoState moverPvpState = AcDream.Core.Physics.ObjectInfoState.None) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rm); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } uint localEntityId = record.LocalEntityId ?? throw new InvalidOperationException( $"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity."); // Bug B (2026-08-04): hidden remotes run the same ComposeOffset chain, // so the InterpolationManager stall snap can fire from here too and // needs the same GUID attribution. TEMPORARY — strip with the // ACDREAM_PROBE_REMOTE_SLIDE family. AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution( record.ServerGuid); System.Numerics.Vector3 preComposePosition = rm.Body.Position; // The part-array contribution is the identity frame while Hidden. // Interpolation is the first PositionManager stage in retail; acdream // retains it in RemoteMotionCombiner, ahead of Sticky/Constraint. AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta = rm.PositionManagerDeltaScratch; positionDelta.Reset(); rm.Position.ComposeOffset( dt, rm.Body.Position, rm.Body.Orientation, positionDelta, rm.Interp, rm.Motion.GetAdjustedMaxSpeed(), positionDelta, inContact: rm.Body.InContact); rm.Host?.PositionManager.AdjustOffset(positionDelta, dt); // #167 (Campaign P P5): see the identical push in Tick's grounded // npcHost branch — Hidden objects still keep their PositionManager // (and therefore their leash) alive per retail. if (rm.Host is { } hiddenHost) rm.Body.IsFullyConstrained = hiddenHost.PositionManager.IsFullyConstrained(); ApplyPositionManagerDelta(rm.Body, positionDelta); // Hidden suppresses CPartArray::Update, but process_hooks remains the // final UpdatePositionInternal step. Drain any hook already pending on // the retained sequence before transition/manager time, exactly like // the visible path above. if (sequencer is not null) processAnimationHooks?.Invoke(localEntityId, sequencer); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } System.Numerics.Vector3 composedPosition = rm.Body.Position; uint committedCellId = rm.CellId; if (rm.CellId != 0 && composedPosition != preComposePosition && _physics.Engine.LandblockCount > 0) { if (radius < 0.05f) { radius = 0.48f; height = 1.835f; } bool previousContact = rm.Body.InContact; bool previousOnWalkable = rm.Body.OnWalkable; var resolved = _physics.Engine.ResolveWithTransition( preComposePosition, composedPosition, rm.CellId, radius, height, stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal isOnGround: previousOnWalkable, body: rm.Body, // TS-23: moverPvpState is a no-op OR (None) for every // non-PK remote. moverFlags: (IsPlayerGuid(record.ServerGuid) ? AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide : AcDream.Core.Physics.ObjectInfoState.EdgeSlide) | moverPvpState, movingEntityId: localEntityId, // TS-46: the Setup's own sphere list, scaled by ObjScale. // Empty falls back to the radius/height reconstruction above. sphereList: sphereList, sphereScale: sphereScale); rm.Body.Position = resolved.Position; if (resolved.CellId != 0) committedCellId = resolved.CellId; if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( rm.Body, resolved.InContact, resolved.OnWalkable, resolved.CollisionNormalValid, resolved.CollisionNormal, previousContact, previousOnWalkable, rm.Movement.HitGround, rm.Motion.LeaveGround, () => IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid))) { return false; } rm.Airborne = !rm.Body.OnWalkable; } // Hidden suppresses mesh/part updates, not SetPositionInternal. Commit // the resolved root before the canonical cell writer enters the same // re-entrant rebucket boundary as the visible path. if (!(acknowledgeProjection?.Invoke( new RuntimeRemotePhysicsSnapshot( rm.Body.Position, rm.Body.Orientation, committedCellId)) ?? true) || !IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } if (committedCellId != 0 && committedCellId != rm.CellId) rm.CellId = committedCellId; if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } AcDream.Core.Physics.RetailObjectManagerTail.Run( rm.Host?.TargetManager, rm.Movement, partArrayHandleMovement, rm.Host?.PositionManager); return IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid); } private bool IsCurrentOwner( RuntimeEntityRecord record, RemoteMotion remote, ulong objectClockEpoch, System.Func? externalOwnerValid) => _physics.IsSpatialRemote(record, remote) && record.ObjectClockEpoch == objectClockEpoch && ReferenceEquals(record.PhysicsBody, remote.Body) && (externalOwnerValid?.Invoke() ?? true); /// /// 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 rotates it /// out by the body's current orientation and post-multiplies the complete /// relative orientation. The remote 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.FrameOps.SetRotate( body.Position, body.Orientation, body.Orientation * delta.Orientation); } /// /// #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 /// and /// so callers can /// pose-gate. Rotation matters because Setup collision geometry may be /// multipart or offset from the root. /// Called by the remote tick and the authoritative-position tail. /// internal void SyncRemoteShadowToBody( uint entityId, AcDream.Runtime.Physics.IRuntimeRemotePlacement rm, int liveCenterX, int liveCenterY, uint? authoritativeCellId = null) { SyncRemoteShadowToBody( entityId, rm.Body, liveCenterX, liveCenterY, authoritativeCellId ?? rm.CellId); rm.LastShadowSyncPosition = rm.Body.Position; rm.LastShadowSyncOrientation = rm.Body.Orientation; } internal static bool ShouldSynchronizeShadowPose( System.Numerics.Vector3 currentPosition, System.Numerics.Quaternion currentOrientation, System.Numerics.Vector3 lastPosition, System.Numerics.Quaternion lastOrientation) { if (System.Numerics.Vector3.DistanceSquared( currentPosition, lastPosition) > 1e-4f) { return true; } float currentLengthSquared = currentOrientation.LengthSquared(); float lastLengthSquared = lastOrientation.LengthSquared(); if (!float.IsFinite(currentLengthSquared) || !float.IsFinite(lastLengthSquared) || currentLengthSquared < 1e-12f || lastLengthSquared < 1e-12f) { return true; } float normalizedDot = MathF.Abs( System.Numerics.Quaternion.Dot( currentOrientation, lastOrientation) / MathF.Sqrt(currentLengthSquared * lastLengthSquared)); return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f; } internal static bool ShouldSynchronizeShadow( bool cellChanged, System.Numerics.Vector3 currentPosition, System.Numerics.Quaternion currentOrientation, System.Numerics.Vector3 lastPosition, System.Numerics.Quaternion lastOrientation) => cellChanged || ShouldSynchronizeShadowPose( currentPosition, currentOrientation, lastPosition, lastOrientation); internal void SyncRemoteShadowToBody( uint entityId, AcDream.Core.Physics.PhysicsBody body, int liveCenterX, int liveCenterY, uint authoritativeCellId) { ShadowPositionSynchronizer.Sync( _physics.Engine.ShadowObjects, entityId, body.Position, body.Orientation, authoritativeCellId, liveCenterX, liveCenterY); } }