using System; using System.Collections.Generic; using System.Linq; using System.Numerics; namespace AcDream.Core.Physics.Motion; /// /// R5 — port of retail's TargetManager (acclient.h:31024, struct #3484; /// decomp 0x0051a370-0x0051ad90, r5-targetmanager-decomp.md). A /// peer-to-peer voyeur subscription system with two roles per object: /// /// /// Watcher (): /// registers this object as a voyeur ON a target; the target's per-tick /// pushes position updates back here via /// , which fans them to the owning host's /// MoveToManager + PositionManager (sticky) through /// . /// Watched (): other objects' /// subscribe to THIS object; each tick /// sends a /// dead-reckoned update to any subscriber the object has drifted past the /// subscriber's radius from. /// /// /// This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow /// polling the entity table). It is a faithful superset: the same /// move-to tracking (distance > radius → HandleUpdateTarget(Ok)) plus the /// correct sticky, 10 s timeout, and exit/teleport event handling. /// /// Owned 1:1 by an (retail /// CPhysicsObj::target_manager, lazily created on first /// set_target/add_voyeur). The two throttle constants /// (=0.5, =10) are /// ACE's, verified against the retail x87 mush — port-plan §2d. /// public sealed class TargetManager { /// Retail HandleTargetting per-tick throttle (ACE: 0.5s) — /// the voyeur sweep runs at most this often. public const double ThrottleSeconds = 0.5; /// Retail target-info staleness timeout (ACE: 10.0s) — an /// Undefined-status target with no update for this long is marked /// TimedOut. public const double StalenessSeconds = 10.0; private readonly IPhysicsObjHost _host; private TargetInfo? _targetInfo; // retail target_info (watcher role) private Dictionary? _voyeurTable; // retail voyeur_table (watched role) private double _lastUpdateTime; // retail last_update_time (throttle base) public TargetManager(IPhysicsObjHost host) => _host = host ?? throw new ArgumentNullException(nameof(host)); /// The current watched-target info, or null when tracking /// nothing. public TargetInfo? TargetInfo => _targetInfo; /// The subscriber table (null until the first /// ). public IReadOnlyDictionary? VoyeurTable => _voyeurTable; /// Retail get_target_quantum — the current target's /// quantum, 0 when tracking nothing. public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0; // ── watcher role ─────────────────────────────────────────────────────── /// /// Retail TargetManager::SetTarget (0x0051ac30). Tear down any /// existing target, then: if is 0, synthesize a /// TimedOut clear-update to the host and leave /// null; otherwise create a fresh (status /// Undefined) and subscribe as a voyeur ON the target /// (target.add_voyeur(self.id, radius, quantum)). The target's live /// position arrives asynchronously via . /// public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { ClearTarget(); if (objectId == 0) { // Clear/cancel: report a TimedOut update carrying the context, // leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.) var cleared = new TargetInfo( ObjectId: 0, Status: TargetStatus.TimedOut, TargetPosition: default, InterpolatedPosition: default, ContextId: contextId); _host.HandleUpdateTarget(cleared); return; } _targetInfo = new TargetInfo( ObjectId: objectId, Status: TargetStatus.Undefined, TargetPosition: default, InterpolatedPosition: default, ContextId: contextId, Radius: radius, Quantum: quantum, LastUpdateTime: _host.CurTime); var target = _host.GetObjectA(objectId); target?.AddVoyeur(_host.Id, radius, quantum); } /// /// Retail TargetManager::SetTargetQuantum (0x0051a4a0). Update the /// current target's resend interval and re-register the voyeur subscription /// on the target with the new quantum. /// public void SetTargetQuantum(double quantum) { if (_targetInfo is not { } ti) return; _targetInfo = ti with { Quantum = quantum }; var target = _host.GetObjectA(ti.ObjectId); target?.AddVoyeur(_host.Id, ti.Radius, quantum); } /// /// Retail TargetManager::ClearTarget (0x0051a7e0). Unsubscribe from /// the current target's voyeur table and drop . /// public void ClearTarget() { if (_targetInfo is not { } ti) return; var target = _host.GetObjectA(ti.ObjectId); target?.RemoveVoyeur(_host.Id); _targetInfo = null; } /// /// Retail TargetManager::ReceiveUpdate (0x0051a930). The inbound /// handler when a target we watch sends us its position (via /// SendVoyeurUpdatereceive_target_update). Ignores updates /// for anything but our current target. Copies the payload, stamps receipt /// time, recomputes the self→target interpolated heading (falls back to +Z /// when degenerate), fans the snapshot to the host, and drops the /// subscription on an ExitWorld status. /// public void ReceiveUpdate(TargetInfo update) { if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId) return; // Copy radius/quantum/positions/velocity/status from the wire; keep our // object_id; stamp receipt time. Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin - _host.Position.Frame.Origin; if (MoveToMath.NormalizeCheckSmall(ref interpHeading)) interpHeading = Vector3.UnitZ; var updated = ti with { Radius = update.Radius, Quantum = update.Quantum, TargetPosition = update.TargetPosition, InterpolatedPosition = update.InterpolatedPosition, Velocity = update.Velocity, Status = update.Status, InterpolatedHeading = interpHeading, LastUpdateTime = _host.CurTime, }; _targetInfo = updated; _host.HandleUpdateTarget(updated); if (update.Status == TargetStatus.ExitWorld) ClearTarget(); } // ── watched role ─────────────────────────────────────────────────────── /// /// Retail TargetManager::AddVoyeur (0x0051a830). A subscriber /// registers to watch this object. If already subscribed, updates its /// radius/quantum in place (no immediate send); otherwise creates the entry /// and pushes an immediate initial snapshot (Ok). /// public void AddVoyeur(uint watcherId, float radius, double quantum) { _voyeurTable ??= new Dictionary(); if (_voyeurTable.TryGetValue(watcherId, out var existing)) { existing.Radius = radius; existing.Quantum = quantum; return; } var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum); _voyeurTable[watcherId] = voyeur; SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok); } /// Retail TargetManager::RemoveVoyeur (0x0051ad90). public bool RemoveVoyeur(uint watcherId) => _voyeurTable?.Remove(watcherId) ?? false; /// /// Retail TargetManager::HandleTargetting (0x0051aa90). THE per-tick /// driver (no separate UseTime): self-throttled to /// , promotes a stale target to TimedOut after /// , then sweeps every subscriber through /// . /// public void HandleTargetting() { if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds) return; if (_targetInfo is { } ti) { if (ti.Status == TargetStatus.Undefined && ti.LastUpdateTime + StalenessSeconds < _host.CurTime) { var timedOut = ti with { Status = TargetStatus.TimedOut }; _targetInfo = timedOut; _host.HandleUpdateTarget(timedOut); } } if (_voyeurTable != null) { foreach (var voyeur in _voyeurTable.Values.ToList()) CheckAndUpdateVoyeur(voyeur); } _lastUpdateTime = _host.PhysicsTimerTime; } /// /// Retail TargetManager::CheckAndUpdateVoyeur (0x0051a650). Push an /// update to only if this object's dead-reckoned /// position has drifted more than the voyeur's radius since the last send. /// public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur) { Position newPos = GetInterpolatedPosition(voyeur.Quantum); float drift = Vector3.Distance( newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin); if (drift > voyeur.Radius) SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok); } /// /// Retail TargetManager::GetInterpolatedPosition (0x0051a5e0). /// Dead-reckon this object's position forward by /// seconds using its current velocity. /// public Position GetInterpolatedPosition(double quantum) { var pos = _host.Position; Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum; return new Position(pos.ObjCellId, origin, pos.Frame.Orientation); } /// /// Retail TargetManager::SendVoyeurUpdate (0x0051a4f0). Record the /// sent position on the voyeur, build a carrying /// this object's CURRENT authoritative position + the extrapolated /// + velocity + status, and deliver it to the /// subscriber's (via its host). /// public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status) { voyeur.LastSentPosition = pos; var info = new TargetInfo( ObjectId: _host.Id, Status: status, TargetPosition: _host.Position, // current authoritative InterpolatedPosition: pos, // the extrapolated position ContextId: 0, Radius: voyeur.Radius, Quantum: voyeur.Quantum, Velocity: _host.Velocity); var voyeurObj = _host.GetObjectA(voyeur.ObjectId); voyeurObj?.ReceiveTargetUpdate(info); } /// /// Retail TargetManager::NotifyVoyeurOfEvent (0x0051a6f0). Broadcast /// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber /// with this object's CURRENT position — no distance gate. /// public void NotifyVoyeurOfEvent(TargetStatus status) { if (_voyeurTable == null) return; foreach (var voyeur in _voyeurTable.Values.ToList()) SendVoyeurUpdate(voyeur, _host.Position, status); } }