using AcDream.App.World; namespace AcDream.App.Physics; /// /// Per-remote-entity physics + motion stack — verbatim application of /// retail's client-side motion pipeline to every remote. Mirrors retail /// CPhysicsObj::update_object (0x00515D10) → /// CPhysicsObj::UpdateObjectInternal (0x005156B0) → /// CPhysicsObj::UpdatePositionInternal (0x00512C30) → /// CPhysicsObj::UpdatePhysicsInternal (0x00510700), and ACE's /// PhysicsObj.cs port. /// /// /// Retail has NO special "interpolator" for remote entities — it runs /// the full motion state machine on every entity, local or remote, /// and reconciles via hard-snap on UpdatePosition. This class simply /// pairs a with its /// so each /// remote gets the same treatment as the local player. /// /// internal sealed class RemoteMotion : AcDream.App.World.ILiveEntityRemotePlacementRuntime, AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer { public AcDream.Core.Physics.PhysicsBody Body { get; } AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; /// R5-V5: retail CPhysicsObj::movement_manager — the /// ONE per-entity owner of the interp + moveto pair (acclient.h /// /* 3463 */). Constructed here with the interp; the /// MoveToManager side arrives via MoveToFactory + /// MakeMoveToManager() in EnsureRemoteMotionBindings. /// / below are views of its /// children (retail get_minterp-style access), kept so the /// dozens of existing call sites read unchanged. public AcDream.Core.Physics.Motion.MovementManager Movement; public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp; /// R3-W4: the persistent per-remote dispatch sink (created /// once by EnsureRemoteMotionBindings; also bound as /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch /// cycles through the motion-table backend). public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink; /// Last UpdatePosition timestamp — drives body.update_object sub-stepping. public double LastServerPosTime; /// Last known server position — kept for diagnostics / HUD. public System.Numerics.Vector3 LastServerPos; /// /// #184: the body position at which this remote's collision SHADOW was /// last (re-)registered. The per-tick shadow-follows-resolved sync /// (SyncRemoteShadowToBody) skips re-flooding when the body has /// not moved > ε since this — a resting/idle-town crowd doesn't churn /// the registry, while a moving/de-overlapping crowd re-registers every /// tick. Sentinel Zero forces the first sync. /// public System.Numerics.Vector3 LastShadowSyncPos; /// /// Complete root orientation paired with . /// Multipart and off-centre Setup collision geometry must be re-flooded /// when the object turns in place, not only when its origin translates. /// The default zero quaternion is the first-sync sentinel. /// public System.Numerics.Quaternion LastShadowSyncOrientation; /// /// Latest server-authoritative velocity for NPC/monster smoothing. /// Prefer the HasVelocity vector from UpdatePosition; when ACE omits /// it for a server-controlled creature, derive it from consecutive /// authoritative positions instead of guessing from player RUM state. /// public System.Numerics.Vector3 ServerVelocity; public bool HasServerVelocity; /// R4-V4: the entity's verbatim retail MoveToManager /// (server-directed movement), constructed + seam-bound by /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5: /// owned by ; this is the child view. public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo; // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager // voyeur system (retail CPhysicsObj::target_manager). Replaces the // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ // clear_target/quantum seams route here, HandleTargetting ticks per // frame, and OTHER entities resolve this one through LiveEntityRuntime's // exact-incarnation PhysicsHost slot. private Func? _physicsHostReader; private bool _fullPhysicsHostBound; public EntityPhysicsHost? Host => _fullPhysicsHostBound ? _physicsHostReader?.Invoke() as EntityPhysicsHost : null; /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager /// and CMotionInterp; the per-tick remote driver consults this to /// decide whether to feed body steering through /// instead of /// the InterpretedMotionState path. /// public bool ServerMoveToActive; /// /// True once a MoveTo packet's full path payload (Origin + thresholds) /// has been parsed and the world-converted destination is stored on /// . Cleared on arrival or when /// the next non-MoveTo UpdateMotion replaces the locomotion source. /// Phase L.1c (2026-04-28). /// public bool HasMoveToDestination; /// /// World-space destination from the most recent MoveTo packet's /// Origin field, converted via the same landblock-grid /// arithmetic OnLivePositionUpdated uses. /// public System.Numerics.Vector3 MoveToDestinationWorld; /// /// min_distance from the MoveTo packet's MovementParameters. /// Used by as /// the chase-arrival threshold per retail /// MoveToManager::HandleMoveToPosition. /// public float MoveToMinDistance; /// /// distance_to_object from the MoveTo packet. Reserved for /// the flee branch (move_away); chase uses /// . /// public float MoveToDistanceToObject; /// /// True if MovementParameters bit 9 (move_towards, mask /// 0x200) is set on the active packet — i.e. this is a /// chase. False = flee (move_away) or static target. /// public bool MoveToMoveTowards; /// /// Seconds-since-epoch timestamp of the most recent MoveTo packet /// for this entity. Used by the per-tick driver to give up /// steering when no refresh has arrived for /// /// — typically because the entity left our streaming view and /// the server stopped broadcasting its MoveTo updates. /// public double LastMoveToPacketTime; /// /// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits /// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001- /// 0x0040, indoor 0x0100+). Fed into /// /// so the retail sphere-sweep can look up the right terrain/EnvCell /// polygons for each remote's per-frame motion. Zero until the first /// UP lands, which disables the transition step for that frame /// (Euler alone, matching pre-wire behavior). /// private uint _cellId; private Func? _canonicalCellReader; private Action? _canonicalCellWriter; /// /// Canonical full cell for this one live CPhysicsObj. Ordinary remotes /// retain the local backing value. A MovementManager attached to an /// existing projectile delegates reads and writes to /// , so projectile prediction, /// Movement packets, and spatial rebucketing cannot develop competing /// cell identities. /// public uint CellId { get => _canonicalCellReader?.Invoke() ?? _cellId; set { _cellId = value; _canonicalCellWriter?.Invoke(value); } } System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition { get => LastServerPos; set => LastServerPos = value; } double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime { get => LastServerPosTime; set => LastServerPosTime = value; } System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition { get => LastShadowSyncPos; set => LastShadowSyncPos = value; } System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation { get => LastShadowSyncOrientation; set => LastShadowSyncOrientation = value; } bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne { get => Airborne; set => Airborne = value; } void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() => Movement.HitGround(); void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() => Motion.LeaveGround(); /// /// K-fix9 (2026-04-26): true while the remote is airborne (jump /// arc in flight). Set when a 0xF74E VectorUpdate arrives with /// non-trivial +Z velocity; cleared when the next UpdatePosition /// snaps to a new ground location. While true, the per-tick /// remote update SKIPS the "force OnWalkable + apply_current_movement" /// step that would otherwise stomp the body's Z velocity each /// frame, AND enables gravity so the parabolic arc actually plays /// out between server snaps. /// public bool Airborne; /// /// Per-remote position-waypoint queue + catch-up math (retail's /// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset). /// Drives per-tick body translation for grounded player remotes /// via . /// public AcDream.Core.Physics.InterpolationManager Interp { get; } = new AcDream.Core.Physics.InterpolationManager(); /// /// Per-frame combiner for animation root motion + InterpolationManager /// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30: /// queue catch-up REPLACES anim when active; anim stands when queue /// is idle. /// public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } = new AcDream.Core.Physics.RemoteMotionCombiner(); /// Reusable complete delta frame shared by interpolation, /// Sticky/Constraint, and the final body composition. public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the /// previous UpdatePosition's world position + timestamp. The per-tick /// path compares the server's broadcast pace with the literal root /// displacement emitted by CSequence::update. The old inferred /// walk/run velocity was the source of the periodic overshoot this /// diagnostic was introduced to find. /// public System.Numerics.Vector3 PrevServerPos; public double PrevServerPosTime; public double LastOmegaDiagLogTime; /// /// Diagnostic-only: maximum scaled CSequence root-motion speed /// observed since the last UpdatePosition arrival. The next UP /// compares it with the server's observed pace. Reset on each UP. /// public float MaxRootMotionSpeedSinceLastUP; public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null) { Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody { // Remotes don't simulate gravity — server owns Z. Force // Contact + OnWalkable + Active so apply_current_movement // writes velocity through every tick (the gate in // MotionInterpreter.apply_current_movement is // PhysicsObj.OnWalkable). State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions, TransientState = AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active, // R4-V5 door-swing fix: a RemoteMotion exists only for a // WORLD entity — retail's physics_obj->cell is non-null the // moment the object is placed. Without this, the interp's // detached-object guard stripped every dispatched // transition link (see PhysicsBody.InWorld). InWorld = true, }; // R5-V5: the interp is owned by the MovementManager facade // (retail CPhysicsObj::movement_manager -> motion_interpreter). // acdream constructs it eagerly here — retail's lazy-create // window is never observable (see MovementManager's class doc). Movement = new AcDream.Core.Physics.Motion.MovementManager( new AcDream.Core.Physics.MotionInterpreter(Body) { // R4-V5 #160 fix: retail remotes carry a weenie whose // InqRunRate FAILS, landing apply_run_to_command on // my_run_rate (the M13 wire feed). A null weenie took the // degenerate 1.0 branch — observer-side run movetos played // and moved in slow motion. See RemoteWeenie. WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), }); } public void BindCanonicalRuntime( Func readPhysicsHost, Func readCell, Action writeCell) { ArgumentNullException.ThrowIfNull(readPhysicsHost); ArgumentNullException.ThrowIfNull(readCell); ArgumentNullException.ThrowIfNull(writeCell); if (_physicsHostReader is not null || _canonicalCellReader is not null || _canonicalCellWriter is not null) { throw new InvalidOperationException("The remote canonical runtime context is already bound."); } // All validation precedes publication. Nothing below can throw, so a // rejected context never leaves a half-bound reusable component. _physicsHostReader = readPhysicsHost; _canonicalCellReader = readCell; _canonicalCellWriter = writeCell; } public void BindCanonicalCell(Func read, Action write) { ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(write); if (_canonicalCellReader is not null || _canonicalCellWriter is not null) throw new InvalidOperationException("The remote canonical cell source is already bound."); _canonicalCellReader = read; _canonicalCellWriter = write; } public void MarkFullPhysicsHostBound() { if (_physicsHostReader is null) throw new InvalidOperationException("The canonical physics-host source is not bound."); _fullPhysicsHostBound = true; } }