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

@ -39,7 +39,6 @@ internal sealed class LiveSessionResetBindings
public required Action AnimationHookFrames { get; init; }
public required Action LivePresentation { get; init; }
public required Action RemoteMovementDiagnostics { get; init; }
public required Action PhysicsHostIndex { get; init; }
}
/// <summary>
@ -92,7 +91,6 @@ internal static class LiveSessionResetManifest
new("animation hook frames", bindings.AnimationHookFrames),
new("live presentation", bindings.LivePresentation),
new("remote movement diagnostics", bindings.RemoteMovementDiagnostics),
new("physics host index", bindings.PhysicsHostIndex),
]);
}
}

View file

@ -0,0 +1,314 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.App.World;
namespace AcDream.App.Physics;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
/// and stored on the exact <c>LiveEntityRecord</c>, so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private Func<Position> _getPosition;
private Func<Vector3> _getVelocity;
private Func<float> _getRadius;
private Func<bool> _inContact;
private Func<float?> _minterpMaxSpeed;
private Func<double> _curTime;
private Func<double> _physicsTimerTime;
private Func<uint, IPhysicsObjHost?> _getObjectA;
private Action<TargetInfo> _handleUpdateTarget;
private Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = null!;
_getVelocity = null!;
_getRadius = null!;
_inContact = null!;
_minterpMaxSpeed = null!;
_curTime = null!;
_physicsTimerTime = null!;
_getObjectA = null!;
_handleUpdateTarget = null!;
_interruptCurrentMovement = null!;
Rebind(
getPosition,
getVelocity,
getRadius,
inContact,
minterpMaxSpeed,
curTime,
physicsTimerTime,
getObjectA,
handleUpdateTarget,
interruptCurrentMovement);
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
/// <summary>
/// Rebinds the changing CPhysicsObj access seams without replacing this
/// object's retail-lifetime managers. A static/minimal live object can gain
/// a MovementManager later, and the local player controller can be rebuilt,
/// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager
/// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and
/// constraint state therefore survives the upgrade.
/// </summary>
private void Rebind(
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
ArgumentNullException.ThrowIfNull(getPosition);
ArgumentNullException.ThrowIfNull(getVelocity);
ArgumentNullException.ThrowIfNull(getRadius);
ArgumentNullException.ThrowIfNull(inContact);
ArgumentNullException.ThrowIfNull(minterpMaxSpeed);
ArgumentNullException.ThrowIfNull(curTime);
ArgumentNullException.ThrowIfNull(physicsTimerTime);
ArgumentNullException.ThrowIfNull(getObjectA);
ArgumentNullException.ThrowIfNull(handleUpdateTarget);
ArgumentNullException.ThrowIfNull(interruptCurrentMovement);
_getPosition = getPosition;
_getVelocity = getVelocity;
_getRadius = getRadius;
_inContact = inContact;
_minterpMaxSpeed = minterpMaxSpeed;
_curTime = curTime;
_physicsTimerTime = physicsTimerTime;
_getObjectA = getObjectA;
_handleUpdateTarget = handleUpdateTarget;
_interruptCurrentMovement = interruptCurrentMovement;
}
/// <summary>
/// Applies a freshly composed delegate configuration while retaining this
/// host's exact identity and manager instances.
/// </summary>
private void RebindFrom(EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration.Id != Id)
throw new ArgumentException(
"A physics-host configuration must match the existing host GUID.",
nameof(configuration));
Rebind(
configuration._getPosition,
configuration._getVelocity,
configuration._getRadius,
configuration._inContact,
configuration._minterpMaxSpeed,
configuration._curTime,
configuration._physicsTimerTime,
configuration._getObjectA,
configuration._handleUpdateTarget,
configuration._interruptCurrentMovement);
}
/// <summary>
/// Publishes the first host for an exact live-object incarnation, or
/// enriches that incarnation's existing minimal host in place. This is the
/// shared remote/player composition seam; it deliberately never replaces
/// TargetManager or PositionManager ownership.
/// </summary>
internal static EntityPhysicsHost InstallOrRebind(
LiveEntityRuntime runtime,
LiveEntityRecord expectedRecord,
EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(configuration);
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, expectedRecord))
{
throw new InvalidOperationException(
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition.");
}
if (current.PhysicsHost is null)
{
runtime.InstallPhysicsHost(current, configuration);
return configuration;
}
if (current.PhysicsHost is not EntityPhysicsHost existing)
{
throw new InvalidOperationException(
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation.");
}
existing.RebindFrom(configuration);
return existing;
}
/// <summary>
/// Builds the position-only CPhysicsObj facade used before an animated or
/// player movement owner exists. The delegates capture the exact live
/// record rather than the visible-world projection, so exit_world
/// notifications retain the object's last cell and frame after the picker
/// view has already withdrawn it.
/// </summary>
internal static EntityPhysicsHost CreateMinimal(
LiveEntityRecord record,
Func<uint, IPhysicsObjHost?> resolve,
Func<double> now)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(resolve);
ArgumentNullException.ThrowIfNull(now);
return new EntityPhysicsHost(
record.ServerGuid,
getPosition: () => new Position(
record.FullCellId,
record.WorldEntity?.Position ?? Vector3.Zero,
record.WorldEntity?.Rotation ?? Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: now,
physicsTimerTime: now,
getObjectA: resolve,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetManager.GetRelationshipTarget(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
_targetManager.ReceiveUpdate(info, sender);
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
=> _targetManager.AddVoyeur(watcher, radius, quantum);
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the App bridges the same unavailability edge through
/// the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

View file

@ -1,4 +1,3 @@
using AcDream.App.Rendering;
using AcDream.App.World;
namespace AcDream.App.Physics;
@ -22,7 +21,8 @@ namespace AcDream.App.Physics;
/// </para>
/// </summary>
internal sealed class RemoteMotion :
AcDream.App.World.ILiveEntityRemotePlacementRuntime
AcDream.App.World.ILiveEntityRemotePlacementRuntime,
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer
{
public AcDream.Core.Physics.PhysicsBody Body { get; }
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
@ -82,8 +82,13 @@ internal sealed class RemoteMotion :
// voyeur system (retail CPhysicsObj::target_manager). Replaces the
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
// clear_target/quantum seams route here, HandleTargetting ticks per
// frame, and OTHER entities resolve this one via GameWindow._physicsHosts.
public EntityPhysicsHost? Host;
// frame, and OTHER entities resolve this one through LiveEntityRuntime's
// exact-incarnation PhysicsHost slot.
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
private bool _fullPhysicsHostBound;
public EntityPhysicsHost? Host => _fullPhysicsHostBound
? _physicsHostReader?.Invoke() as EntityPhysicsHost
: null;
/// <summary>
/// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager
@ -299,9 +304,42 @@ internal sealed class RemoteMotion :
});
}
public void BindCanonicalRuntime(
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
if (_physicsHostReader is not null
|| _canonicalCellReader is not null
|| _canonicalCellWriter is not null)
{
throw new InvalidOperationException("The remote canonical runtime context is already bound.");
}
// All validation precedes publication. Nothing below can throw, so a
// rejected context never leaves a half-bound reusable component.
_physicsHostReader = readPhysicsHost;
_canonicalCellReader = readCell;
_canonicalCellWriter = writeCell;
}
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{
_canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read));
_canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write));
ArgumentNullException.ThrowIfNull(read);
ArgumentNullException.ThrowIfNull(write);
if (_canonicalCellReader is not null || _canonicalCellWriter is not null)
throw new InvalidOperationException("The remote canonical cell source is already bound.");
_canonicalCellReader = read;
_canonicalCellWriter = write;
}
public void MarkFullPhysicsHostBound()
{
if (_physicsHostReader is null)
throw new InvalidOperationException("The canonical physics-host source is not bound.");
_fullPhysicsHostBound = true;
}
}

View file

@ -1,162 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Rendering;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private readonly Func<Position> _getPosition;
private readonly Func<Vector3> _getVelocity;
private readonly Func<float> _getRadius;
private readonly Func<bool> _inContact;
private readonly Func<float?> _minterpMaxSpeed;
private readonly Func<double> _curTime;
private readonly Func<double> _physicsTimerTime;
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
private readonly Action<TargetInfo> _handleUpdateTarget;
private readonly Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition));
_getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity));
_getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius));
_inContact = inContact ?? throw new ArgumentNullException(nameof(inContact));
_minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed));
_curTime = curTime ?? throw new ArgumentNullException(nameof(curTime));
_physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime));
_getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA));
_handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget));
_interruptCurrentMovement = interruptCurrentMovement
?? throw new ArgumentNullException(nameof(interruptCurrentMovement));
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> _targetManager.AddVoyeur(watcherId, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the App bridges the same unavailability edge through
/// the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

View file

@ -777,7 +777,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, registered in _physicsHosts so remote
// TargetManager voyeur system, stored on the exact live record so remote
// entities chasing the player resolve it. Replaces the AP-79
// _playerMoveToTarget* poll fields.
private EntityPhysicsHost? _playerHost;
@ -787,7 +787,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// giving the TargetManager voyeur round-trip its cross-entity delivery
// path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
// (player); pruned only by logical LiveEntityRuntime teardown.
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
@ -2644,7 +2643,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteLastMove.Clear,
PhysicsHostIndex = _physicsHosts.Clear,
});
private void ResetSessionMouseCapture()
@ -4714,7 +4712,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// live object has no PartArray sink, so build the manager once anyway.
if (rm.Host is not null)
return rm.Sink;
if (_liveEntities is not { } liveEntities
|| !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
|| !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
{
return rm.Sink;
}
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
// wiring (the conformance-tested reference). Positions are WORLD
// space on both sides (getPosition + the MovementStruct positions
@ -4799,12 +4802,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// HandleUpdateTarget fans deliveries to this entity's MoveToManager
// and (R5-V3, inside the host) its PositionManager — retail's
// CPhysicsObj::HandleUpdateTarget order.
host = new EntityPhysicsHost(
var configuredHost = new EntityPhysicsHost(
serverGuid,
getPosition: () => new AcDream.Core.Physics.Position(
rmT.CellId,
_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt)
? selfEnt.Position : mtBody.Position,
hostRecord.FullCellId,
hostRecord.WorldEntity?.Position ?? mtBody.Position,
mtBody.Orientation),
getVelocity: () => mtBody.Velocity,
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
@ -4820,8 +4822,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled));
rm.Host = host;
_physicsHosts[serverGuid] = host;
host = EntityPhysicsHost.InstallOrRebind(
liveEntities,
hostRecord,
configuredHost);
rm.MarkFullPhysicsHostBound();
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// MoveToManager via the factory above (host now exists for the
@ -4851,42 +4856,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// </summary>
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
{
// CObjCell::hide_object removes Hidden objects from the lookup surface
// used to establish new target/voyeur relationships. Existing
// subscriptions are cleared by LiveEntityPresentationController.
if (!_visibleEntitiesByServerGuid.ContainsKey(id))
if (_liveEntities is not { } liveEntities)
return null;
bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
if (!isActive)
return null;
// TS-49: CObjCell::hide_object removes Hidden objects from the
// relationship surface until DetectionManager is ported. Pending,
// attached, and otherwise non-render-visible active objects remain in
// CObjectMaint's object table and must still resolve here.
if (liveEntities.IsHidden(id))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(
$"[autowalk-host-miss] object=0x{id:X8} "
+ $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
+ $"registered={_physicsHosts.ContainsKey(id)} "
+ $"hidden={_liveEntities?.IsHidden(id) == true}");
+ $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
+ $"hidden={liveEntities.IsHidden(id)}");
}
return null;
}
if (_physicsHosts.TryGetValue(id, out var existing))
if (liveEntities.TryGetPhysicsHost(id, out var existing))
return existing;
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
var minimal = new EntityPhysicsHost(
id,
getPosition: () => new AcDream.Core.Physics.Position(
0u,
_visibleEntitiesByServerGuid.TryGetValue(id, out var e)
? e.Position : System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity),
getVelocity: () => System.Numerics.Vector3.Zero, // static target
getRadius: () => 0f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: NowSeconds,
physicsTimerTime: NowSeconds,
getObjectA: ResolvePhysicsHost,
handleUpdateTarget: _ => { }, // not a watcher
interruptCurrentMovement: () => { });
_physicsHosts[id] = minimal;
var minimal = EntityPhysicsHost.CreateMinimal(
activeRecord,
ResolvePhysicsHost,
NowSeconds);
liveEntities.InstallPhysicsHost(activeRecord, minimal);
return minimal;
}
@ -4973,7 +4974,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.Cleared);
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
hiddenEntityHost.NotifyHidden();
}
@ -5038,15 +5039,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// moveto/stick. This is the ONLY clean-up for a watcher whose target
// already sent an Ok (once status != Undefined the 10 s staleness
// timeout never fires), so it must run before the host is pruned.
EntityPhysicsHost? ephGone = rmGone?.Host;
if (ephGone is null)
{
incarnation.RunIfNoReplacement(() =>
{
if (_physicsHosts.TryGetValue(serverGuid, out var hostGone))
ephGone = hostGone as EntityPhysicsHost;
});
}
EntityPhysicsHost? ephGone = record.PhysicsHost as EntityPhysicsHost;
if (ephGone is not null)
{
// R5-V3 (#171): retail exit_world (0x00514e60) order — the
@ -5061,8 +5054,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
cleanups.Add(ephGone.PositionManager.UnStick);
cleanups.Add(ephGone.ClearTarget);
cleanups.Add(ephGone.NotifyExitWorld);
EntityPhysicsHost capturedHost = ephGone;
cleanups.Add(() => incarnation.RemoveCaptured(_physicsHosts, capturedHost));
}
cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
@ -5259,7 +5250,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
bool targetVisible = turnPath.TargetGuid is { } visibleGuid
&& _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
bool targetHost = turnPath.TargetGuid is { } hostGuid
&& _physicsHosts.ContainsKey(hostGuid);
&& _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
var moveTo = movement.MoveTo;
Console.WriteLine(
$"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
@ -10922,9 +10913,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Func<bool> isCurrent)
{
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered)
? registered as EntityPhysicsHost
: remote?.Host;
EntityPhysicsHost? host =
_liveEntities?.TryGetPhysicsHost(serverGuid, out var registered) == true
? registered as EntityPhysicsHost
: null;
return AcDream.App.Physics.RemoteTeleportHook.Execute(
new AcDream.App.Physics.RemoteTeleportHookActions(
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
@ -13139,18 +13131,19 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
return false;
}
LiveEntityRecord? playerRecord = null;
if (_liveEntities?.TryGetRecord(
if (_liveEntities is not { } playerLiveEntities
|| !playerLiveEntities.TryGetRecord(
_playerServerGuid,
out LiveEntityRecord foundPlayerRecord) == true)
out LiveEntityRecord playerRecord))
{
playerRecord = foundPlayerRecord;
Console.WriteLine(
$"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet");
return false;
}
_playerController = new AcDream.App.Input.PlayerMovementController(
_physicsEngine,
playerRecord?.ObjectClock);
if (playerRecord is not null)
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
playerRecord.ObjectClock);
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// R4-V5: the local player's verbatim MoveToManager — same seam
// wiring shape as EnsureRemoteMotionBindings, with three
@ -13232,15 +13225,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is
// the player's WORLD position (WorldEntity.Position — what the AP-79
// poll used), so an NPC watching the player receives the identical
// position. Registered in _physicsHosts so those NPCs' GetObjectA
// position. Stored on the exact live record so those NPCs' GetObjectA
// resolves the player; HandleTargetting is ticked in the player
// pre-Update block.
playerHost = new EntityPhysicsHost(
var exactPlayerMovement = pcMoveTo.Movement;
var configuredPlayerHost = new EntityPhysicsHost(
_playerServerGuid,
getPosition: () => new AcDream.Core.Physics.Position(
pcMoveTo.CellId,
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf)
? pSelf.Position : pcMoveTo.Position,
playerRecord.FullCellId,
playerRecord.WorldEntity?.Position ?? pcMoveTo.Position,
pcMoveTo.BodyOrientation),
getVelocity: () => pcMoveTo.BodyVelocity,
getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
@ -13263,12 +13256,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
+ $"{info.TargetPosition.Frame.Origin.Y:F2},"
+ $"{info.TargetPosition.Frame.Origin.Z:F2})");
}
_playerController?.Movement.HandleUpdateTarget(info);
exactPlayerMovement.HandleUpdateTarget(info);
},
interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo(
interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled));
playerHost = EntityPhysicsHost.InstallOrRebind(
playerLiveEntities,
playerRecord,
configuredPlayerHost);
_playerHost = playerHost;
_physicsHosts[_playerServerGuid] = playerHost;
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// player MoveToManager via the factory above (playerHost now exists

View file

@ -24,6 +24,29 @@ public interface ILiveEntityRemoteMotionRuntime
PhysicsBody Body { get; }
}
/// <summary>
/// Optional seam for a remote-motion component that reads the exact
/// incarnation's canonical movement-manager host from
/// <see cref="LiveEntityRecord"/>.
/// </summary>
public interface ILiveEntityPhysicsHostConsumer
{
void BindPhysicsHost(Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> read);
}
/// <summary>
/// Atomic composition seam for a component that consumes both canonical host
/// and cell identity. Implementations validate every delegate before
/// publishing any of them, so a failed bind leaves the component reusable.
/// </summary>
public interface ILiveEntityCanonicalRuntimeConsumer
{
void BindCanonicalRuntime(
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell);
}
/// <summary>
/// Optional seam for a remote-motion component that consumes the canonical
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
@ -216,6 +239,8 @@ public sealed class LiveEntityRecord
internal bool PhysicsBodyAcquisitionInProgress { get; set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RemoteMotionBindingInProgress { get; set; }
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
internal bool RequiresRemotePlacementRuntime { get; set; }
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
@ -1201,10 +1226,30 @@ public sealed class LiveEntityRuntime
ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
if (record.RemoteMotionBindingInProgress)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress.");
PhysicsBody candidateBody = runtime.Body
?? throw new InvalidOperationException("A remote-motion runtime returned no physics body.");
if (ReferenceEquals(record.RemoteMotionRuntime, runtime))
{
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body.");
}
// Idempotent publication is important when motion and vector
// packets converge on the same runtime in one update turn. The
// component seams are incarnation-bound and must never be rebound.
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
_remoteMotionByGuid[serverGuid] = runtime;
RefreshSpatialRuntimeIndexes(record);
return;
}
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{
@ -1217,38 +1262,134 @@ public sealed class LiveEntityRuntime
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
}
record.RequiresRemotePlacementRuntime |=
runtime is ILiveEntityRemotePlacementRuntime;
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
// Bind every possibly-throwing exact-incarnation seam before
// publishing any canonical record/index state. An already-bound
// component from an older GUID generation must fail without poisoning
// the replacement record. The callback is arbitrary App code, so the
// exact record, authority epoch, and expected owners are revalidated
// before commit just like resource/physics-body acquisition.
LiveEntityRecord incarnation = record;
ulong sessionVersion = _sessionLifetimeVersion;
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
PhysicsBody? expectedBody = record.PhysicsBody;
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime;
bool expectedPlacementContract = record.RequiresRemotePlacementRuntime;
bool expectedIndexPresent = _remoteMotionByGuid.TryGetValue(
serverGuid,
out ILiveEntityRemoteMotionRuntime? expectedIndexedRuntime);
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost = () =>
_recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
? current.PhysicsHost
: null;
// Reads remain bound to the exact CPhysicsObj incarnation through its
// exit_world notifications. Only writes require active ownership.
Func<uint> readCell = () => incarnation.FullCellId;
Action<uint> writeCell = cellId =>
{
// Capture the incarnation, not only its GUID. A callback retained
// by an old MovementManager must never mutate a replacement that
// later reuses the same server GUID.
LiveEntityRecord incarnation = record;
cellConsumer.BindCanonicalCell(
read: () =>
_recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
? current.FullCellId
: 0u,
write: cellId =>
if (cellId != 0
&& _recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
{
RebucketLiveEntity(serverGuid, cellId);
}
};
record.RemoteMotionBindingInProgress = true;
try
{
if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer)
{
canonicalConsumer.BindCanonicalRuntime(
readPhysicsHost,
readCell,
writeCell);
}
else
{
bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer;
bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer;
if (consumesHost && consumesCell)
{
if (cellId != 0
&& _recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
{
RebucketLiveEntity(serverGuid, cellId);
}
});
throw new InvalidOperationException(
"A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
}
if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer)
hostConsumer.BindPhysicsHost(readPhysicsHost);
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
cellConsumer.BindCanonicalCell(readCell, writeCell);
}
bool indexUnchanged = _remoteMotionByGuid.TryGetValue(
serverGuid,
out ILiveEntityRemoteMotionRuntime? currentIndexedRuntime)
== expectedIndexPresent
&& (!expectedIndexPresent
|| ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime));
if (_sessionLifetimeVersion != sessionVersion
|| CurrentLifetimeMutation(serverGuid) != lifetimeVersion
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|| !ReferenceEquals(current, incarnation)
|| !ReferenceEquals(current.PhysicsBody, expectedBody)
|| !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime)
|| current.RequiresRemotePlacementRuntime != expectedPlacementContract
|| !indexUnchanged)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding.");
}
record.RequiresRemotePlacementRuntime |=
runtime is ILiveEntityRemotePlacementRuntime;
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
_remoteMotionByGuid[serverGuid] = runtime;
RefreshSpatialRuntimeIndexes(record);
}
_remoteMotionByGuid[serverGuid] = runtime;
RefreshSpatialRuntimeIndexes(record);
finally
{
record.RemoteMotionBindingInProgress = false;
}
}
public void InstallPhysicsHost(
LiveEntityRecord expectedRecord,
AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(host);
uint serverGuid = expectedRecord.ServerGuid;
if (host.Id != serverGuid)
throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host));
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| !ReferenceEquals(record, expectedRecord))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation.");
}
if (record.PhysicsHost is not null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host.");
record.PhysicsHost = host;
}
public bool TryGetPhysicsHost(
uint serverGuid,
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.PhysicsHost is { } existing)
{
host = existing;
return true;
}
host = null!;
return false;
}
public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) =>
@ -2374,6 +2515,7 @@ public sealed class LiveEntityRuntime
}
record.AnimationRuntime = null;
record.RemoteMotionRuntime = null;
record.PhysicsHost = null;
record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null;
record.ProjectileRuntime = null;

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;
}
}