refactor(world): canonicalize live physics host ownership

This commit is contained in:
Erik 2026-07-21 14:05:34 +02:00
parent 5882b308c1
commit fcb66198fc
21 changed files with 1546 additions and 323 deletions

View file

@ -16,9 +16,9 @@ namespace AcDream.Core.Physics.Motion;
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
/// <see cref="GetObjectA"/> is backed by the App's live entity table
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
/// cross-entity delivery path.</para>
/// <see cref="GetObjectA"/> is backed by the App's canonical
/// <c>LiveEntityRuntime</c> records, giving the voyeur round-trip its
/// cross-entity delivery path without a second GUID index.</para>
/// </summary>
public interface IPhysicsObjHost
{
@ -57,11 +57,21 @@ public interface IPhysicsObjHost
double PhysicsTimerTime { get; }
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
/// physics object by guid, or <c>null</c> if not currently known/visible.
/// The cross-entity seam for the voyeur round-trip and sticky live-target
/// physics object by guid from the object table. The App additionally
/// withholds Hidden objects from ordinary relationship creation through
/// its registered TS-49 DetectionManager adaptation. This is the
/// cross-entity seam for the voyeur round-trip and sticky live-target
/// resolve.</summary>
IPhysicsObjHost? GetObjectA(uint id);
/// <summary>
/// Returns the exact target incarnation captured by this host's current
/// TargetManager relationship. App teardown can overlap a newer
/// INSTANCE_TS with the same GUID, so StickyManager must not re-resolve the
/// relationship through the active GUID table.
/// </summary>
IPhysicsObjHost? GetRelationshipTarget(uint objectId);
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
@ -86,15 +96,19 @@ public interface IPhysicsObjHost
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
void ReceiveTargetUpdate(TargetInfo info);
void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender);
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
void AddVoyeur(uint watcherId, float radius, double quantum);
/// TargetManager). Called on the TARGET when a watcher subscribes. The
/// exact watcher host preserves retail's unique object-table identity
/// while App teardown overlaps a newer same-GUID generation.</summary>
void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum);
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
/// watcher unsubscribes.</summary>
void RemoveVoyeur(uint watcherId);
/// watcher unsubscribes. The expected watcher identity prevents a retiring
/// same-GUID incarnation from removing a newer incarnation's subscription
/// while App teardown callbacks converge.</summary>
void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher);
}

View file

@ -210,7 +210,7 @@ public sealed class StickyManager
return;
var self = _host.Position;
var target = _host.GetObjectA(TargetId);
var target = _host.GetRelationshipTarget(TargetId);
var targetPos = target != null ? target.Position : TargetPosition;
// offset = local-frame, Z-flattened vector from self to target.

View file

@ -49,6 +49,7 @@ public sealed class TargetManager
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)
@ -67,6 +68,12 @@ public sealed class TargetManager
/// 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>
@ -101,7 +108,8 @@ public sealed class TargetManager
LastUpdateTime: _host.CurTime);
var target = _host.GetObjectA(objectId);
target?.AddVoyeur(_host.Id, radius, quantum);
_targetHost = target;
target?.AddVoyeur(_host, radius, quantum);
}
/// <summary>
@ -115,8 +123,9 @@ public sealed class TargetManager
return;
_targetInfo = ti with { Quantum = quantum };
var target = _host.GetObjectA(ti.ObjectId);
target?.AddVoyeur(_host.Id, ti.Radius, quantum);
var target = _targetHost ?? _host.GetObjectA(ti.ObjectId);
_targetHost = target;
target?.AddVoyeur(_host, ti.Radius, quantum);
}
/// <summary>
@ -128,9 +137,10 @@ public sealed class TargetManager
if (_targetInfo is not { } ti)
return;
var target = _host.GetObjectA(ti.ObjectId);
target?.RemoveVoyeur(_host.Id);
var target = _targetHost ?? _host.GetObjectA(ti.ObjectId);
target?.RemoveVoyeur(_host.Id, _host);
_targetInfo = null;
_targetHost = null;
}
/// <summary>
@ -141,10 +151,17 @@ public sealed class TargetManager
/// 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)
public void ReceiveUpdate(TargetInfo update, IPhysicsObjHost sender)
{
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId)
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
@ -181,25 +198,53 @@ public sealed class TargetManager
/// 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)
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))
{
existing.Radius = radius;
existing.Quantum = quantum;
return;
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);
var voyeur = new TargettedVoyeurInfo(
watcherId,
radius,
quantum,
watcher);
_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::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
@ -280,8 +325,7 @@ public sealed class TargetManager
Quantum: voyeur.Quantum,
Velocity: _host.Velocity);
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
voyeurObj?.ReceiveTargetUpdate(info);
voyeur.WatcherHost.ReceiveTargetUpdate(info, _host);
}
/// <summary>

View file

@ -11,6 +11,14 @@ namespace AcDream.Core.Physics.Motion;
/// </summary>
public sealed class TargettedVoyeurInfo
{
/// <summary>
/// Exact watcher incarnation captured when this subscription is created.
/// Retail object IDs are process-lifetime object-table identities; the App
/// preserves that pointer-like identity explicitly while an old
/// INSTANCE_TS teardown may overlap a newer record with the same GUID.
/// </summary>
internal IPhysicsObjHost WatcherHost { get; }
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
public uint ObjectId { get; }
@ -27,10 +35,23 @@ public sealed class TargettedVoyeurInfo
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
public Position LastSentPosition { get; set; }
public TargettedVoyeurInfo(uint objectId, float radius, double quantum)
public TargettedVoyeurInfo(
uint objectId,
float radius,
double quantum,
IPhysicsObjHost watcherHost)
{
ArgumentNullException.ThrowIfNull(watcherHost);
if (watcherHost.Id != objectId)
{
throw new ArgumentException(
"Watcher identity must match the voyeur object ID.",
nameof(watcherHost));
}
ObjectId = objectId;
Radius = radius;
Quantum = quantum;
WatcherHost = watcherHost;
}
}