The retail movement-manager family the R4 MoveToManager port left as do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's PositionManager facade + StickyManager + ConstraintManager + the TargetManager voyeur system, with full conformance tests. NO wiring yet — purely additive, no behavior change. Wiring (retiring TS-39 sticky + AP-79 target adapter) is R5-V2/V3. New Core classes (src/AcDream.Core/Physics/Motion/): - StickyManager (0x00555400): follow-a-target steering. adjust_offset's dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0, follow speed ×5 / fallback 15) — speed-clamped signed-distance steer + bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown. - ConstraintManager (0x00556090): the server-position rubber-band leash. 90% IsFullyConstrained jump gate + grounded linear brake taper. Structural only — acdream never ARMS it (retail arms from SmartBox::HandleReceivedPosition, which acdream lacks, with two x87 constants BN elided). IsFullyConstrained stays false = TS-35 behavior; leash-arming + the unknown constants are a deferred issue. - PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out. - TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer voyeur subscription system (0.5 s throttle, 10 s staleness, send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A faithful superset of the AP-79 adapter — SetTarget subscribes ON the target; the target's HandleTargetting pushes updates back. - IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/ radius/contact/GetObjectA + target-tracking fan-out) the App wires per entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator. Supporting: - TargetInfo extended to the full retail 10-field struct (additive defaults keep the R4 4-arg call sites compiling). - MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall, GlobalToLocalVec. - Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion Combiner, freeing the name and removing the ambiguity that breaks every file importing both Physics + Physics.Motion (GameWindow will in V2/V3). Tests: 42 new conformance cases (Sticky/Constraint/Position facade + TargetManager incl. the full cross-entity voyeur round-trip). Full suite 4006 green (+2 skipped), no regressions. Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
300 lines
12 KiB
C#
300 lines
12 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 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;
|
|
|
|
// ── 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);
|
|
target?.AddVoyeur(_host.Id, 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 = _host.GetObjectA(ti.ObjectId);
|
|
target?.AddVoyeur(_host.Id, 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 = _host.GetObjectA(ti.ObjectId);
|
|
target?.RemoveVoyeur(_host.Id);
|
|
_targetInfo = 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.
|
|
/// </summary>
|
|
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 ───────────────────────────────────────────────────────
|
|
|
|
/// <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(uint watcherId, float radius, double quantum)
|
|
{
|
|
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
|
|
public bool RemoveVoyeur(uint watcherId)
|
|
=> _voyeurTable?.Remove(watcherId) ?? false;
|
|
|
|
/// <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);
|
|
|
|
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
|
|
voyeurObj?.ReceiveTargetUpdate(info);
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|