acdream/src/AcDream.Core/Physics/Motion/TargetManager.cs
Erik 3d89446d98 feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)
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>
2026-07-03 19:34:49 +02:00

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 &gt; 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);
}
}