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; 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 // is OnWalkable; otherwise it clears the displacement. Grounded // remotes below are explicitly OnWalkable and airborne remotes use // their authoritative velocity/gravity arc, so this is the same // branch expressed through our retained runtime state. System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne ? rootMotionLocalFrame.Origin * objectScale : System.Numerics.Vector3.Zero; // 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. As of #184 Slice 2b this grounded model is the SINGLE // remote path (players + NPCs) — retail has no fork. 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; 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. } 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: 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). // 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 = rm.PositionManagerDeltaScratch; pmDelta.Origin = scaledRootMotionLocalOrigin; pmDelta.Orientation = rootMotionLocalFrame.Orientation; float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed(); System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne ? _physics.Engine.SampleTerrainNormal( rm.Body.Position.X, rm.Body.Position.Y) : null; rm.Position.ComposeOffset( dt, rm.Body.Position, rm.Body.Orientation, pmDelta, rm.Interp, maxSpeedNpc, pmDelta, terrainNormalNpc, 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(); System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne ? _physics.Engine.SampleTerrainNormal( rm.Body.Position.X, rm.Body.Position.Y) : null; rm.Position.ComposeOffset( dt, rm.Body.Position, rm.Body.Orientation, pmDelta, rm.Interp, maxSpeedNpc, pmDelta, terrainNormalNpc, 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, // 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 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.) // AD-25 (2026-07-30): retail CPhysicsObj::handle_all_collisions // (0x00514780, pc:282647) runs UNCONDITIONALLY after EVERY // SetPositionInternal — remote objects included; a // VectorUpdate-launched jump arc is ordinary object physics in // retail. #173 (2026-07-05) first mirrored the local player's // reflect math here by hand, but with a narrower gate than // retail's: `shouldReflect = !(prevOnWalkable && nowOnWalkable // && !sledding)` collapses to the two ad-hoc branches this // block used to hand-roll, and got BOTH wrong — the sledding // branch suppressed the bounce exactly when retail's // `!sledding` term forces it UNCONDITIONALLY, and the // non-sledding branch only reflected airborne→airborne where // retail reflects on every transition except grounded→grounded. // PhysicsObjUpdate.HandleAllCollisions is the same verbatim // port the local player and every ordinary body already use // (PhysicsObjUpdate.CommitSetPositionTransition); call it // directly instead of re-deriving the gate. It already // no-ops the reflect step when collisionNormalValid is false, // but — unlike the old wrapper this replaces — still runs the // fsf>1 unconditional velocity-zero "bleed" regardless of // whether this tick found a collision normal, matching // retail's own unconditional call site. AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions( rm.Body, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, previousContact, previousOnWalkable, resolveResult.IsOnGround); // 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. ulong landingStateAuthorityVersion = record.StateAuthorityVersion; rm.Movement.HitGround(); if (!IsCurrentOwner( record, rm, objectClockEpoch, externalOwnerValid)) { return false; } // DR bookkeeping only (partner of the jump-start // `State |= Gravity`): stops the per-tick gravity // integration for the grounded body. if (record.StateAuthorityVersion == landingStateAuthorityVersion) { 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}"); } } // 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."); 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; // [remote-edge] probe (stuck-cast/missing-attack investigation, // 2026-07-30): each ground edge drains the mover's pending // action animations (retail HandleEnterWorld) — one line per // edge correlates eaten attack gestures with contact flickers. Action hitGround = rm.Movement.HitGround; Action leaveGround = rm.Motion.LeaveGround; if (AcDream.Core.Physics.PhysicsDiagnostics.DumpMotionEnabled) { uint edgeGuid = record.ServerGuid; hitGround = () => { Console.WriteLine($"[remote-edge] guid={edgeGuid:X8} HitGround"); rm.Movement.HitGround(); }; leaveGround = () => { Console.WriteLine($"[remote-edge] guid={edgeGuid:X8} LeaveGround"); rm.Motion.LeaveGround(); }; } if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( rm.Body, resolved.InContact, resolved.OnWalkable, resolved.CollisionNormalValid, resolved.CollisionNormal, previousContact, previousOnWalkable, hitGround, 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); } }