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>
This commit is contained in:
parent
517bbfdae4
commit
3d89446d98
25 changed files with 7279 additions and 12 deletions
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue