using System; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.App.World; namespace AcDream.App.Physics; /// /// R5-V2 — the App-side per entity: acdream's /// stand-in for retail's CPhysicsObj as the movement managers see it. /// One is built per entity (a remote RemoteMotion or the local player) /// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow /// and stored on the exact LiveEntityRecord, so /// can resolve OTHER entities' hosts — the /// cross-entity delivery path the voyeur system /// needs. /// /// Owns a (retail /// CPhysicsObj::target_manager). Its set_target/clear_target/ /// add_voyeur/remove_voyeur/receive_target_update seams /// forward to it exactly as retail's CPhysicsObj does; the movement managers' /// target seams are repointed here, replacing the AP-79 poll adapter. The /// per-entity accessors (position/velocity/radius/contact/clocks) and the /// fan-out are injected by GameWindow so this /// class stays free of GameWindow's internals (code-structure rule #1). /// /// R5-V3: owns a too (retail /// CPhysicsObj::position_manager — retail creates it lazily via /// get_position_manager; acdream constructs it eagerly, which is /// behaviorally identical because the empty facade no-ops until its first /// StickTo/ConstrainTo). fans /// deliveries to the injected MoveToManager fan FIRST, then the /// PositionManager — retail CPhysicsObj::HandleUpdateTarget order /// (0x00512bc0: MovementManager @0x00512bf0, PositionManager /// @0x00512c1a). /// public sealed class EntityPhysicsHost : IPhysicsObjHost { private Func _getPosition; private Func _getVelocity; private Func _getRadius; private Func _inContact; private Func _minterpMaxSpeed; private Func _curTime; private Func _physicsTimerTime; private Func _getObjectA; private Action _handleUpdateTarget; private Action _interruptCurrentMovement; private readonly TargetManager _targetManager; public EntityPhysicsHost( uint id, Func getPosition, Func getVelocity, Func getRadius, Func inContact, Func minterpMaxSpeed, Func curTime, Func physicsTimerTime, Func getObjectA, Action handleUpdateTarget, Action interruptCurrentMovement) { Id = id; _getPosition = null!; _getVelocity = null!; _getRadius = null!; _inContact = null!; _minterpMaxSpeed = null!; _curTime = null!; _physicsTimerTime = null!; _getObjectA = null!; _handleUpdateTarget = null!; _interruptCurrentMovement = null!; Rebind( getPosition, getVelocity, getRadius, inContact, minterpMaxSpeed, curTime, physicsTimerTime, getObjectA, handleUpdateTarget, interruptCurrentMovement); _targetManager = new TargetManager(this); PositionManager = new PositionManager(this); } /// /// Rebinds the changing CPhysicsObj access seams without replacing this /// object's retail-lifetime managers. A static/minimal live object can gain /// a MovementManager later, and the local player controller can be rebuilt, /// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager /// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and /// constraint state therefore survives the upgrade. /// private void Rebind( Func getPosition, Func getVelocity, Func getRadius, Func inContact, Func minterpMaxSpeed, Func curTime, Func physicsTimerTime, Func getObjectA, Action handleUpdateTarget, Action interruptCurrentMovement) { ArgumentNullException.ThrowIfNull(getPosition); ArgumentNullException.ThrowIfNull(getVelocity); ArgumentNullException.ThrowIfNull(getRadius); ArgumentNullException.ThrowIfNull(inContact); ArgumentNullException.ThrowIfNull(minterpMaxSpeed); ArgumentNullException.ThrowIfNull(curTime); ArgumentNullException.ThrowIfNull(physicsTimerTime); ArgumentNullException.ThrowIfNull(getObjectA); ArgumentNullException.ThrowIfNull(handleUpdateTarget); ArgumentNullException.ThrowIfNull(interruptCurrentMovement); _getPosition = getPosition; _getVelocity = getVelocity; _getRadius = getRadius; _inContact = inContact; _minterpMaxSpeed = minterpMaxSpeed; _curTime = curTime; _physicsTimerTime = physicsTimerTime; _getObjectA = getObjectA; _handleUpdateTarget = handleUpdateTarget; _interruptCurrentMovement = interruptCurrentMovement; } /// /// Applies a freshly composed delegate configuration while retaining this /// host's exact identity and manager instances. /// private void RebindFrom(EntityPhysicsHost configuration) { ArgumentNullException.ThrowIfNull(configuration); if (configuration.Id != Id) throw new ArgumentException( "A physics-host configuration must match the existing host GUID.", nameof(configuration)); Rebind( configuration._getPosition, configuration._getVelocity, configuration._getRadius, configuration._inContact, configuration._minterpMaxSpeed, configuration._curTime, configuration._physicsTimerTime, configuration._getObjectA, configuration._handleUpdateTarget, configuration._interruptCurrentMovement); } /// /// Publishes the first host for an exact live-object incarnation, or /// enriches that incarnation's existing minimal host in place. This is the /// shared remote/player composition seam; it deliberately never replaces /// TargetManager or PositionManager ownership. /// internal static EntityPhysicsHost InstallOrRebind( LiveEntityRuntime runtime, LiveEntityRecord expectedRecord, EntityPhysicsHost configuration) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(expectedRecord); ArgumentNullException.ThrowIfNull(configuration); if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current) || !ReferenceEquals(current, expectedRecord)) { throw new InvalidOperationException( $"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition."); } if (current.PhysicsHost is null) { runtime.InstallPhysicsHost(current, configuration); return configuration; } if (current.PhysicsHost is not EntityPhysicsHost existing) { throw new InvalidOperationException( $"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."); } existing.RebindFrom(configuration); return existing; } /// /// Validates an exact incarnation and returns the manager-stable host that /// a later will publish, without changing any /// live delegates. Player-mode construction uses this preparation edge so /// DAT/physics/camera failures cannot expose a half-rebound host. /// internal static EntityPhysicsHost SelectStableHostWithoutRebind( LiveEntityRuntime runtime, LiveEntityRecord expectedRecord, EntityPhysicsHost configuration) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(expectedRecord); ArgumentNullException.ThrowIfNull(configuration); if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current) || !ReferenceEquals(current, expectedRecord)) { throw new InvalidOperationException( $"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation."); } return current.PhysicsHost switch { null => configuration, EntityPhysicsHost existing => existing, _ => throw new InvalidOperationException( $"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."), }; } /// /// Builds the position-only CPhysicsObj facade used before an animated or /// player movement owner exists. The delegates capture the exact live /// record rather than the visible-world projection, so exit_world /// notifications retain the object's last cell and frame after the picker /// view has already withdrawn it. /// internal static EntityPhysicsHost CreateMinimal( LiveEntityRecord record, Func resolve, Func now) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(resolve); ArgumentNullException.ThrowIfNull(now); return new EntityPhysicsHost( record.ServerGuid, getPosition: () => new Position( record.FullCellId, record.WorldEntity?.Position ?? Vector3.Zero, record.WorldEntity?.Rotation ?? Quaternion.Identity), getVelocity: () => Vector3.Zero, getRadius: () => 0f, inContact: () => true, minterpMaxSpeed: () => null, curTime: now, physicsTimerTime: now, getObjectA: resolve, handleUpdateTarget: _ => { }, interruptCurrentMovement: () => { }); } // ── IPhysicsObjHost accessors ────────────────────────────────────────── public uint Id { get; } public Position Position => _getPosition(); public Vector3 Velocity => _getVelocity(); public float Radius => _getRadius(); public bool InContact => _inContact(); public float? MinterpMaxSpeed => _minterpMaxSpeed(); public double CurTime => _curTime(); public double PhysicsTimerTime => _physicsTimerTime(); /// The owned voyeur manager (retail /// CPhysicsObj::target_manager). public TargetManager TargetManager => _targetManager; /// R5-V3 — the owned facade (retail /// CPhysicsObj::position_manager): sticky follow + (unarmed) /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, /// MotionInterpreter.UnstickFromObject, the per-tick /// AdjustOffset/UseTime drivers. public PositionManager PositionManager { get; } // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => _targetManager.GetRelationshipTarget(objectId); public void HandleUpdateTarget(TargetInfo info) { // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: // MovementManager (the injected MoveToManager fan) first, then // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). _handleUpdateTarget(info); PositionManager.HandleUpdateTarget(info); } public void InterruptCurrentMovement() => _interruptCurrentMovement(); public void SetTarget(uint contextId, uint objectId, float radius, double quantum) => _targetManager.SetTarget(contextId, objectId, radius, quantum); public void ClearTarget() => _targetManager.ClearTarget(); public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) => _targetManager.ReceiveUpdate(info, sender); public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) => _targetManager.AddVoyeur(watcher, radius, quantum); public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) => _targetManager.RemoveVoyeur(watcherId, expectedWatcher); // ── per-tick driver + lifecycle (called by GameWindow) ───────────────── /// Retail TargetManager::HandleTargetting — the per-tick /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally /// for every entity in UpdateObjectInternal, BEFORE the movement /// managers' UseTime. public void HandleTargetting() => _targetManager.HandleTargetting(); /// Retail CPhysicsObj::exit_world's /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every /// watcher of this entity that it left the world (they drop the /// stick/moveto). Called on despawn before the host is removed from the /// registry. public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); /// /// A Hidden object remains logically alive but is removed from the cell /// lookup/interaction surface. Retail's DetectionManager sends /// LeftDetection from CObjCell::hide_object; that manager is /// not ported yet, so the App bridges the same unavailability edge through /// the existing target fan-out. The non-Ok update tears down watcher /// MoveTo/Sticky consumers, and the watched-role table is cleared so an /// UnHide re-subscription receives a fresh initial snapshot. The object's /// own watcher role is deliberately preserved. /// public void NotifyHidden() => _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld); /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget /// (drop this entity's OWN subscription) then /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one /// drops its stick/moveto — StickyManager::HandleUpdateTarget's /// non-Ok teardown path). Called after a teleport placement. public void NotifyTeleported() { _targetManager.ClearTarget(); _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); } }