362 lines
15 KiB
C#
362 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.Core.Physics.Motion;
|
|
|
|
/// <summary>
|
|
/// R5 — port of retail's <c>TargetManager</c> (acclient.h:31024, struct #3484;
|
|
/// decomp 0x0051a370-0x0051ad90, <c>r5-targetmanager-decomp.md</c>). A
|
|
/// peer-to-peer <b>voyeur subscription</b> system with two roles per object:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><b>Watcher</b> (<see cref="TargetInfo"/>): <see cref="SetTarget"/>
|
|
/// registers this object as a voyeur ON a target; the target's per-tick
|
|
/// <see cref="HandleTargetting"/> pushes position updates back here via
|
|
/// <see cref="ReceiveUpdate"/>, which fans them to the owning host's
|
|
/// MoveToManager + PositionManager (sticky) through
|
|
/// <see cref="IPhysicsObjHost.HandleUpdateTarget"/>.</item>
|
|
/// <item><b>Watched</b> (<see cref="VoyeurTable"/>): other objects'
|
|
/// <see cref="AddVoyeur"/> subscribe to THIS object; each tick
|
|
/// <see cref="HandleTargetting"/> → <see cref="CheckAndUpdateVoyeur"/> sends a
|
|
/// dead-reckoned update to any subscriber the object has drifted past the
|
|
/// subscriber's radius from.</item>
|
|
/// </list>
|
|
///
|
|
/// <para>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.</para>
|
|
///
|
|
/// <para>Owned 1:1 by an <see cref="IPhysicsObjHost"/> (retail
|
|
/// <c>CPhysicsObj::target_manager</c>, lazily created on first
|
|
/// <c>set_target</c>/<c>add_voyeur</c>). The two throttle constants
|
|
/// (<see cref="ThrottleSeconds"/>=0.5, <see cref="StalenessSeconds"/>=10) are
|
|
/// ACE's, verified against the retail x87 mush — port-plan §2d.</para>
|
|
/// </summary>
|
|
public sealed class TargetManager
|
|
{
|
|
/// <summary>Retail <c>HandleTargetting</c> per-tick throttle (ACE: 0.5s) —
|
|
/// the voyeur sweep runs at most this often.</summary>
|
|
public const double ThrottleSeconds = 0.5;
|
|
|
|
/// <summary>Retail target-info staleness timeout (ACE: 10.0s) — an
|
|
/// Undefined-status target with no update for this long is marked
|
|
/// TimedOut.</summary>
|
|
public const double StalenessSeconds = 10.0;
|
|
|
|
private readonly IPhysicsObjHost _host;
|
|
|
|
private TargetInfo? _targetInfo; // retail target_info (watcher role)
|
|
private IPhysicsObjHost? _targetHost; // exact App incarnation token
|
|
private Dictionary<uint, TargettedVoyeurInfo>? _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));
|
|
|
|
/// <summary>The current watched-target info, or null when tracking
|
|
/// nothing.</summary>
|
|
public TargetInfo? TargetInfo => _targetInfo;
|
|
|
|
/// <summary>The subscriber table (null until the first
|
|
/// <see cref="AddVoyeur"/>).</summary>
|
|
public IReadOnlyDictionary<uint, TargettedVoyeurInfo>? VoyeurTable => _voyeurTable;
|
|
|
|
/// <summary>Retail <c>get_target_quantum</c> — the current target's
|
|
/// quantum, 0 when tracking nothing.</summary>
|
|
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
|
|
|
|
/// <summary>The pointer-like target identity captured by SetTarget.</summary>
|
|
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
|
|
_targetInfo is { } info && info.ObjectId == objectId
|
|
? _targetHost
|
|
: null;
|
|
|
|
// ── watcher role ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::SetTarget</c> (0x0051ac30). Tear down any
|
|
/// existing target, then: if <paramref name="objectId"/> is 0, synthesize a
|
|
/// TimedOut clear-update to the host and leave <see cref="TargetInfo"/>
|
|
/// null; otherwise create a fresh <see cref="TargetInfo"/> (status
|
|
/// Undefined) and subscribe as a voyeur ON the target
|
|
/// (<c>target.add_voyeur(self.id, radius, quantum)</c>). The target's live
|
|
/// position arrives asynchronously via <see cref="ReceiveUpdate"/>.
|
|
/// </summary>
|
|
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);
|
|
_targetHost = target;
|
|
target?.AddVoyeur(_host, radius, quantum);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::SetTargetQuantum</c> (0x0051a4a0). Update the
|
|
/// current target's resend interval and re-register the voyeur subscription
|
|
/// on the target with the new quantum.
|
|
/// </summary>
|
|
public void SetTargetQuantum(double quantum)
|
|
{
|
|
if (_targetInfo is not { } ti)
|
|
return;
|
|
|
|
_targetInfo = ti with { Quantum = quantum };
|
|
var target = _targetHost ?? _host.GetObjectA(ti.ObjectId);
|
|
_targetHost = target;
|
|
target?.AddVoyeur(_host, ti.Radius, quantum);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::ClearTarget</c> (0x0051a7e0). Unsubscribe from
|
|
/// the current target's voyeur table and drop <see cref="TargetInfo"/>.
|
|
/// </summary>
|
|
public void ClearTarget()
|
|
{
|
|
if (_targetInfo is not { } ti)
|
|
return;
|
|
|
|
var target = _targetHost ?? _host.GetObjectA(ti.ObjectId);
|
|
target?.RemoveVoyeur(_host.Id, _host);
|
|
_targetInfo = null;
|
|
_targetHost = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::ReceiveUpdate</c> (0x0051a930). The inbound
|
|
/// handler when a target we watch sends us its position (via
|
|
/// <c>SendVoyeurUpdate</c> → <c>receive_target_update</c>). 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.
|
|
/// Receives the update only from the exact target incarnation that
|
|
/// established this relationship. The sender token stays outside the
|
|
/// retail TargetInfo payload.
|
|
/// </summary>
|
|
public void ReceiveUpdate(TargetInfo update, IPhysicsObjHost sender)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(sender);
|
|
if (_targetInfo is not { } ti
|
|
|| ti.ObjectId != update.ObjectId
|
|
|| _targetHost is null
|
|
|| !ReferenceEquals(_targetHost, sender))
|
|
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 ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::AddVoyeur</c> (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 (<c>Ok</c>).
|
|
/// </summary>
|
|
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(watcher);
|
|
uint watcherId = watcher.Id;
|
|
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
|
|
|
|
if (_voyeurTable.TryGetValue(watcherId, out var existing))
|
|
{
|
|
if (ReferenceEquals(existing.WatcherHost, watcher))
|
|
{
|
|
existing.Radius = radius;
|
|
existing.Quantum = quantum;
|
|
return;
|
|
}
|
|
|
|
// A later INSTANCE_TS reused the GUID. Replace the pointer-like
|
|
// subscription; the retiring watcher can no longer remove it via
|
|
// its exact identity token.
|
|
_voyeurTable.Remove(watcherId);
|
|
}
|
|
|
|
var voyeur = new TargettedVoyeurInfo(
|
|
watcherId,
|
|
radius,
|
|
quantum,
|
|
watcher);
|
|
_voyeurTable[watcherId] = voyeur;
|
|
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90). Removes a
|
|
/// relationship only when it still belongs to the exact watcher
|
|
/// incarnation that established it in the App lifetime model.
|
|
/// </summary>
|
|
public bool RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedWatcher);
|
|
if (_voyeurTable is null
|
|
|| !_voyeurTable.TryGetValue(watcherId, out var existing)
|
|
|| !ReferenceEquals(existing.WatcherHost, expectedWatcher))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return _voyeurTable.Remove(watcherId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
|
|
/// driver (no separate <c>UseTime</c>): self-throttled to
|
|
/// <see cref="ThrottleSeconds"/>, promotes a stale target to TimedOut after
|
|
/// <see cref="StalenessSeconds"/>, then sweeps every subscriber through
|
|
/// <see cref="CheckAndUpdateVoyeur"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::CheckAndUpdateVoyeur</c> (0x0051a650). Push an
|
|
/// update to <paramref name="voyeur"/> only if this object's dead-reckoned
|
|
/// position has drifted more than the voyeur's radius since the last send.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::GetInterpolatedPosition</c> (0x0051a5e0).
|
|
/// Dead-reckon this object's position forward by <paramref name="quantum"/>
|
|
/// seconds using its current velocity.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::SendVoyeurUpdate</c> (0x0051a4f0). Record the
|
|
/// sent position on the voyeur, build a <see cref="TargetInfo"/> carrying
|
|
/// this object's CURRENT authoritative position + the extrapolated
|
|
/// <paramref name="pos"/> + velocity + status, and deliver it to the
|
|
/// subscriber's <see cref="ReceiveUpdate"/> (via its host).
|
|
/// </summary>
|
|
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);
|
|
|
|
voyeur.WatcherHost.ReceiveTargetUpdate(info, _host);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>TargetManager::NotifyVoyeurOfEvent</c> (0x0051a6f0). Broadcast
|
|
/// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber
|
|
/// with this object's CURRENT position — no distance gate.
|
|
/// </summary>
|
|
public void NotifyVoyeurOfEvent(TargetStatus status)
|
|
{
|
|
if (_voyeurTable == null)
|
|
return;
|
|
|
|
foreach (var voyeur in _voyeurTable.Values.ToList())
|
|
SendVoyeurUpdate(voyeur, _host.Position, status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Broadcasts a terminal availability event and drops the watched-role
|
|
/// subscriptions after delivery. Retail normally removes the object from
|
|
/// cell lookup before its watchers process the event; clearing the local
|
|
/// table explicitly preserves that lifecycle boundary when the App uses
|
|
/// the target fan-out as the temporary bridge for an unavailable object.
|
|
/// The object's own watcher role (<see cref="TargetInfo"/>) is untouched.
|
|
/// </summary>
|
|
public void NotifyVoyeurOfEventAndClear(TargetStatus status)
|
|
{
|
|
if (_voyeurTable == null)
|
|
return;
|
|
|
|
foreach (var voyeur in _voyeurTable.Values.ToList())
|
|
SendVoyeurUpdate(voyeur, _host.Position, status);
|
|
_voyeurTable.Clear();
|
|
}
|
|
}
|