diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 24a5d55c..781d88d8 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
+## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -103,6 +103,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
+| AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` |
---
@@ -255,7 +256,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
-| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
+| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs
index 9c6c3191..310a3e2d 100644
--- a/src/AcDream.App/Net/LiveSessionResetManifest.cs
+++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs
@@ -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; }
}
///
@@ -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),
]);
}
}
diff --git a/src/AcDream.App/Physics/EntityPhysicsHost.cs b/src/AcDream.App/Physics/EntityPhysicsHost.cs
new file mode 100644
index 00000000..3b8a89e9
--- /dev/null
+++ b/src/AcDream.App/Physics/EntityPhysicsHost.cs
@@ -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;
+
+///
+/// R5-V2 — the App-side per entity: acdream's
+/// stand-in for retail's CPhysicsObj as the movement managers see it.
+/// One is built per entity (a remote RemoteMotion or the local player)
+/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow
+/// and stored on the exact LiveEntityRecord, so
+/// can resolve OTHER entities' hosts — the
+/// cross-entity delivery path the voyeur system
+/// needs.
+///
+/// Owns a (retail
+/// CPhysicsObj::target_manager). Its set_target/clear_target/
+/// add_voyeur/remove_voyeur/receive_target_update 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
+/// fan-out are injected by GameWindow so this
+/// class stays free of GameWindow's internals (code-structure rule #1).
+///
+/// R5-V3: owns a too (retail
+/// CPhysicsObj::position_manager — retail creates it lazily via
+/// get_position_manager; acdream constructs it eagerly, which is
+/// behaviorally identical because the empty facade no-ops until its first
+/// StickTo/ConstrainTo). fans
+/// deliveries to the injected MoveToManager fan FIRST, then the
+/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order
+/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
+/// @0x00512c1a).
+///
+public sealed class EntityPhysicsHost : IPhysicsObjHost
+{
+ private Func _getPosition;
+ private Func _getVelocity;
+ private Func _getRadius;
+ private Func _inContact;
+ private Func _minterpMaxSpeed;
+ private Func _curTime;
+ private Func _physicsTimerTime;
+ private Func _getObjectA;
+ private Action _handleUpdateTarget;
+ private Action _interruptCurrentMovement;
+ private readonly TargetManager _targetManager;
+
+ public EntityPhysicsHost(
+ uint id,
+ Func getPosition,
+ Func getVelocity,
+ Func getRadius,
+ Func inContact,
+ Func minterpMaxSpeed,
+ Func curTime,
+ Func physicsTimerTime,
+ Func getObjectA,
+ Action 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ private void Rebind(
+ Func getPosition,
+ Func getVelocity,
+ Func getRadius,
+ Func inContact,
+ Func minterpMaxSpeed,
+ Func curTime,
+ Func physicsTimerTime,
+ Func getObjectA,
+ Action 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;
+ }
+
+ ///
+ /// Applies a freshly composed delegate configuration while retaining this
+ /// host's exact identity and manager instances.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ internal static EntityPhysicsHost CreateMinimal(
+ LiveEntityRecord record,
+ Func resolve,
+ Func 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();
+
+ /// The owned voyeur manager (retail
+ /// CPhysicsObj::target_manager).
+ public TargetManager TargetManager => _targetManager;
+
+ /// R5-V3 — the owned facade (retail
+ /// CPhysicsObj::position_manager): sticky follow + (unarmed)
+ /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick,
+ /// MotionInterpreter.UnstickFromObject, the per-tick
+ /// AdjustOffset/UseTime drivers.
+ 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) ─────────────────
+
+ /// Retail TargetManager::HandleTargetting — the per-tick
+ /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
+ /// for every entity in UpdateObjectInternal, BEFORE the movement
+ /// managers' UseTime.
+ public void HandleTargetting() => _targetManager.HandleTargetting();
+
+ /// Retail CPhysicsObj::exit_world's
+ /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — 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.
+ public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
+
+ ///
+ /// A Hidden object remains logically alive but is removed from the cell
+ /// lookup/interaction surface. Retail's DetectionManager sends
+ /// LeftDetection from CObjCell::hide_object; 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.
+ ///
+ public void NotifyHidden() =>
+ _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
+
+ /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail
+ /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget
+ /// (drop this entity's OWN subscription) then
+ /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one
+ /// drops its stick/moveto — StickyManager::HandleUpdateTarget's
+ /// non-Ok teardown path). Called after a teleport placement.
+ public void NotifyTeleported()
+ {
+ _targetManager.ClearTarget();
+ _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
+ }
+}
diff --git a/src/AcDream.App/Physics/RemoteMotion.cs b/src/AcDream.App/Physics/RemoteMotion.cs
index 7b35d0bd..e29ee21c 100644
--- a/src/AcDream.App/Physics/RemoteMotion.cs
+++ b/src/AcDream.App/Physics/RemoteMotion.cs
@@ -1,4 +1,3 @@
-using AcDream.App.Rendering;
using AcDream.App.World;
namespace AcDream.App.Physics;
@@ -22,7 +21,8 @@ namespace AcDream.App.Physics;
///
///
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? _physicsHostReader;
+ private bool _fullPhysicsHostBound;
+ public EntityPhysicsHost? Host => _fullPhysicsHostBound
+ ? _physicsHostReader?.Invoke() as EntityPhysicsHost
+ : null;
///
/// 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 readPhysicsHost,
+ Func readCell,
+ Action 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 read, Action 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;
}
}
diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs
deleted file mode 100644
index 3aa1d2e7..00000000
--- a/src/AcDream.App/Rendering/EntityPhysicsHost.cs
+++ /dev/null
@@ -1,162 +0,0 @@
-using System;
-using System.Numerics;
-using AcDream.Core.Physics;
-using AcDream.Core.Physics.Motion;
-
-namespace AcDream.App.Rendering;
-
-///
-/// R5-V2 — the App-side per entity: acdream's
-/// stand-in for retail's CPhysicsObj as the movement managers see it.
-/// One is built per entity (a remote RemoteMotion or the local player)
-/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow
-/// and registered in GameWindow._physicsHosts (guid → host), so
-/// can resolve OTHER entities' hosts — the
-/// cross-entity delivery path the voyeur system
-/// needs.
-///
-/// Owns a (retail
-/// CPhysicsObj::target_manager). Its set_target/clear_target/
-/// add_voyeur/remove_voyeur/receive_target_update 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
-/// fan-out are injected by GameWindow so this
-/// class stays free of GameWindow's internals (code-structure rule #1).
-///
-/// R5-V3: owns a too (retail
-/// CPhysicsObj::position_manager — retail creates it lazily via
-/// get_position_manager; acdream constructs it eagerly, which is
-/// behaviorally identical because the empty facade no-ops until its first
-/// StickTo/ConstrainTo). fans
-/// deliveries to the injected MoveToManager fan FIRST, then the
-/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order
-/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
-/// @0x00512c1a).
-///
-public sealed class EntityPhysicsHost : IPhysicsObjHost
-{
- private readonly Func _getPosition;
- private readonly Func _getVelocity;
- private readonly Func _getRadius;
- private readonly Func _inContact;
- private readonly Func _minterpMaxSpeed;
- private readonly Func _curTime;
- private readonly Func _physicsTimerTime;
- private readonly Func _getObjectA;
- private readonly Action _handleUpdateTarget;
- private readonly Action _interruptCurrentMovement;
- private readonly TargetManager _targetManager;
-
- public EntityPhysicsHost(
- uint id,
- Func getPosition,
- Func getVelocity,
- Func getRadius,
- Func inContact,
- Func minterpMaxSpeed,
- Func curTime,
- Func physicsTimerTime,
- Func getObjectA,
- Action 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();
-
- /// The owned voyeur manager (retail
- /// CPhysicsObj::target_manager).
- public TargetManager TargetManager => _targetManager;
-
- /// R5-V3 — the owned facade (retail
- /// CPhysicsObj::position_manager): sticky follow + (unarmed)
- /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick,
- /// MotionInterpreter.UnstickFromObject, the per-tick
- /// AdjustOffset/UseTime drivers.
- 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) ─────────────────
-
- /// Retail TargetManager::HandleTargetting — the per-tick
- /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
- /// for every entity in UpdateObjectInternal, BEFORE the movement
- /// managers' UseTime.
- public void HandleTargetting() => _targetManager.HandleTargetting();
-
- /// Retail CPhysicsObj::exit_world's
- /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — 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.
- public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
-
- ///
- /// A Hidden object remains logically alive but is removed from the cell
- /// lookup/interaction surface. Retail's DetectionManager sends
- /// LeftDetection from CObjCell::hide_object; 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.
- ///
- public void NotifyHidden() =>
- _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
-
- /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail
- /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget
- /// (drop this entity's OWN subscription) then
- /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one
- /// drops its stick/moveto — StickyManager::HandleUpdateTarget's
- /// non-Ok teardown path). Called after a teleport placement.
- public void NotifyTeleported()
- {
- _targetManager.ClearTarget();
- _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
- }
-}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index dcd6ba55..dada8342 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -777,7 +777,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private IReadOnlyDictionary 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 _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
///
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 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
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index 7b064a2a..c82d8655 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -24,6 +24,29 @@ public interface ILiveEntityRemoteMotionRuntime
PhysicsBody Body { get; }
}
+///
+/// Optional seam for a remote-motion component that reads the exact
+/// incarnation's canonical movement-manager host from
+/// .
+///
+public interface ILiveEntityPhysicsHostConsumer
+{
+ void BindPhysicsHost(Func read);
+}
+
+///
+/// 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.
+///
+public interface ILiveEntityCanonicalRuntimeConsumer
+{
+ void BindCanonicalRuntime(
+ Func readPhysicsHost,
+ Func readCell,
+ Action writeCell);
+}
+
///
/// Optional seam for a remote-motion component that consumes the canonical
/// full-cell identity owned by . 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 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 readCell = () => incarnation.FullCellId;
+ Action 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;
diff --git a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
index 85b4eb88..d5081b34 100644
--- a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
+++ b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
@@ -16,9 +16,9 @@ namespace AcDream.Core.Physics.Motion;
/// RemoteMotion or the local player), wiring the accessors to the live
/// and the /
/// / it owns.
-/// is backed by the App's live entity table
-/// (_entitiesByServerGuid), giving the voyeur round-trip its
-/// cross-entity delivery path.
+/// is backed by the App's canonical
+/// LiveEntityRuntime records, giving the voyeur round-trip its
+/// cross-entity delivery path without a second GUID index.
///
public interface IPhysicsObjHost
{
@@ -57,11 +57,21 @@ public interface IPhysicsObjHost
double PhysicsTimerTime { get; }
/// Retail CObjectMaint::GetObjectA(id) — resolve another
- /// physics object by guid, or null 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.
IPhysicsObjHost? GetObjectA(uint id);
+ ///
+ /// 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.
+ ///
+ IPhysicsObjHost? GetRelationshipTarget(uint objectId);
+
/// Retail CPhysicsObj::HandleUpdateTarget — fans a
/// to this host's
/// (move-to steering) AND (sticky follow).
@@ -86,15 +96,19 @@ public interface IPhysicsObjHost
/// Retail CPhysicsObj::receive_target_update →
/// . The inbound side a SENDER's
/// SendVoyeurUpdate tail-calls on the watcher.
- void ReceiveTargetUpdate(TargetInfo info);
+ void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender);
/// Retail CPhysicsObj::add_voyeur(id, radius, quantum) →
/// (lazily creating the
- /// TargetManager). Called on the TARGET when a watcher subscribes.
- 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.
+ void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum);
/// Retail CPhysicsObj::remove_voyeur(id) →
/// . Called on the TARGET when a
- /// watcher unsubscribes.
- 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.
+ void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher);
}
diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs
index 1646ce97..1ca0308b 100644
--- a/src/AcDream.Core/Physics/Motion/StickyManager.cs
+++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs
@@ -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.
diff --git a/src/AcDream.Core/Physics/Motion/TargetManager.cs b/src/AcDream.Core/Physics/Motion/TargetManager.cs
index 7ca908a9..a26bb6ce 100644
--- a/src/AcDream.Core/Physics/Motion/TargetManager.cs
+++ b/src/AcDream.Core/Physics/Motion/TargetManager.cs
@@ -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? _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.
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
+ /// The pointer-like target identity captured by SetTarget.
+ public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
+ _targetInfo is { } info && info.ObjectId == objectId
+ ? _targetHost
+ : null;
+
// ── watcher role ───────────────────────────────────────────────────────
///
@@ -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);
}
///
@@ -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);
}
///
@@ -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;
}
///
@@ -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.
///
- 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 (Ok).
///
- 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();
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);
}
- /// Retail TargetManager::RemoveVoyeur (0x0051ad90).
- public bool RemoveVoyeur(uint watcherId)
- => _voyeurTable?.Remove(watcherId) ?? false;
+ ///
+ /// Retail TargetManager::RemoveVoyeur (0x0051ad90). Removes a
+ /// relationship only when it still belongs to the exact watcher
+ /// incarnation that established it in the App lifetime model.
+ ///
+ 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);
+ }
///
/// Retail TargetManager::HandleTargetting (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);
}
///
diff --git a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
index 10631681..c4502114 100644
--- a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
+++ b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
@@ -11,6 +11,14 @@ namespace AcDream.Core.Physics.Motion;
///
public sealed class TargettedVoyeurInfo
{
+ ///
+ /// 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.
+ ///
+ internal IPhysicsObjHost WatcherHost { get; }
+
/// +0x00 retail object_id — the subscriber's guid.
public uint ObjectId { get; }
@@ -27,10 +35,23 @@ public sealed class TargettedVoyeurInfo
/// delivered to this subscriber (updated by SendVoyeurUpdate).
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;
}
}
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
index e55baf92..cce541e2 100644
--- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
@@ -242,7 +242,6 @@ public sealed class LiveSessionResetPlanTests
AnimationHookFrames = Stage("animation hook frames"),
LivePresentation = Stage("live presentation"),
RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
- PhysicsHostIndex = Stage("physics host index"),
});
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
@@ -255,7 +254,6 @@ public sealed class LiveSessionResetPlanTests
.Select(static error => error.StageName));
Assert.Equal(playerA, sessionIdentity);
Assert.Equal(1, live.PendingTeardownCount);
- Assert.False(dirty["physics host index"]);
Assert.Contains(staticEntity, spatial.Entities);
Assert.Equal(1, spatial.PersistentGuidCount);
@@ -319,7 +317,6 @@ public sealed class LiveSessionResetPlanTests
"animation hook frames",
"live presentation",
"remote movement diagnostics",
- "physics host index",
];
private static WorldEntity Entity(uint id, uint guid) => new()
diff --git a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
index 354830da..4db98835 100644
--- a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
+++ b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Input;
+using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
index 2bc55d79..f0fd81a4 100644
--- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
+++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
@@ -1202,7 +1202,10 @@ public sealed class ProjectileControllerTests
fixture.Live.SetRemoteMotionRuntime(Guid, replacement);
remote.CellId = CellB;
Assert.Equal(CellA, record.FullCellId);
- Assert.Equal(0u, remote.CellId);
+ // A displaced component retains read-only access to its exact
+ // incarnation through exit_world; its guarded write cannot mutate the
+ // current canonical owner.
+ Assert.Equal(CellA, remote.CellId);
replacement.CellId = CellB;
Assert.Equal(CellB, record.FullCellId);
}
diff --git a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs
index cee150f4..0d78b7a5 100644
--- a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs
@@ -1,5 +1,5 @@
using System.Numerics;
-using AcDream.App.Rendering;
+using AcDream.App.Physics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs
new file mode 100644
index 00000000..c2a35f44
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs
@@ -0,0 +1,745 @@
+using System.Numerics;
+using AcDream.App.Physics;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+
+namespace AcDream.App.Tests.World;
+
+public sealed class LiveEntityPhysicsHostOwnershipTests
+{
+ [Fact]
+ public void Host_IsOwnedByExactRecordAndStaleRemoteReaderCannotSeeReplacement()
+ {
+ const uint guid = 0x70000010u;
+ var runtime = Runtime();
+ LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var firstMotion = new HostConsumerMotion();
+ runtime.SetRemoteMotionRuntime(guid, firstMotion);
+ EntityPhysicsHost firstHost = Host(guid);
+
+ runtime.InstallPhysicsHost(first, firstHost);
+ Assert.Same(firstHost, firstMotion.Host);
+ Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
+ Assert.Same(firstHost, resolved);
+
+ LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
+ var secondMotion = new HostConsumerMotion();
+ runtime.SetRemoteMotionRuntime(guid, secondMotion);
+ EntityPhysicsHost replacement = Host(guid);
+ runtime.InstallPhysicsHost(second, replacement);
+
+ Assert.Null(firstMotion.Host);
+ Assert.Same(replacement, secondMotion.Host);
+ Assert.Same(replacement, second.PhysicsHost);
+ }
+
+ [Fact]
+ public void Install_RejectsWrongGuidDuplicateAndStaleRecord()
+ {
+ const uint guid = 0x70000011u;
+ var runtime = Runtime();
+ LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ EntityPhysicsHost original = Host(guid);
+ runtime.InstallPhysicsHost(first, original);
+
+ Assert.Throws(() =>
+ runtime.InstallPhysicsHost(first, Host(guid + 1)));
+ Assert.Throws(() =>
+ runtime.InstallPhysicsHost(first, Host(guid)));
+
+ LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
+ Assert.Throws(() =>
+ runtime.InstallPhysicsHost(first, Host(guid)));
+ Assert.Null(second.PhysicsHost);
+ }
+
+ [Fact]
+ public void InstallOrRebind_PreservesHostManagersAndLiveTrackingState()
+ {
+ const uint watcherGuid = 0x70000020u;
+ const uint targetGuid = 0x70000021u;
+ var runtime = Runtime();
+ LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
+ LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
+ IPhysicsObjHost? Resolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
+ ? host
+ : null;
+
+ EntityPhysicsHost watcher = Host(
+ watcherGuid,
+ resolve: Resolve,
+ position: new Vector3(1f, 0f, 0f));
+ EntityPhysicsHost target = Host(
+ targetGuid,
+ resolve: Resolve,
+ position: new Vector3(4f, 0f, 0f));
+ runtime.InstallPhysicsHost(watcherRecord, watcher);
+ runtime.InstallPhysicsHost(targetRecord, target);
+
+ watcher.PositionManager.StickTo(targetGuid, 0.5f, 1f);
+ watcher.PositionManager.ConstrainTo(
+ new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
+ 1f,
+ 5f);
+ TargetManager targetManager = watcher.TargetManager;
+ PositionManager positionManager = watcher.PositionManager;
+ StickyManager sticky = Assert.IsType(positionManager.Sticky);
+ ConstraintManager constraint = Assert.IsType(positionManager.Constraint);
+ Assert.Equal(targetGuid, sticky.TargetId);
+ Assert.NotNull(watcher.TargetManager.TargetInfo);
+ Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
+
+ EntityPhysicsHost configured = Host(
+ watcherGuid,
+ resolve: Resolve,
+ position: new Vector3(9f, 0f, 0f));
+ EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind(
+ runtime,
+ watcherRecord,
+ configured);
+
+ Assert.Same(watcher, rebound);
+ Assert.Same(targetManager, rebound.TargetManager);
+ Assert.Same(positionManager, rebound.PositionManager);
+ Assert.Same(sticky, rebound.PositionManager.Sticky);
+ Assert.Same(constraint, rebound.PositionManager.Constraint);
+ Assert.Equal(targetGuid, rebound.PositionManager.GetStickyObjectId());
+ Assert.Equal(9f, rebound.Position.Frame.Origin.X);
+ Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
+
+ EntityPhysicsHost secondConfiguration = Host(
+ watcherGuid,
+ resolve: Resolve,
+ position: new Vector3(12f, 0f, 0f));
+ Assert.Same(
+ watcher,
+ EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
+ Assert.Same(targetManager, watcher.TargetManager);
+ Assert.Same(positionManager, watcher.PositionManager);
+ Assert.Equal(12f, watcher.Position.Frame.Origin.X);
+ }
+
+ [Fact]
+ public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal()
+ {
+ const uint guid = 0x70000022u;
+ var runtime = Runtime();
+ LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ int firstUpdates = 0;
+ int firstInterrupts = 0;
+ var firstHost = CallbackHost(
+ guid,
+ _ => firstUpdates++,
+ () => firstInterrupts++);
+ runtime.InstallPhysicsHost(first, firstHost);
+
+ LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
+ int replacementUpdates = 0;
+ int replacementInterrupts = 0;
+ var replacementHost = CallbackHost(
+ guid,
+ _ => replacementUpdates++,
+ () => replacementInterrupts++);
+ runtime.InstallPhysicsHost(replacement, replacementHost);
+
+ firstHost.HandleUpdateTarget(default);
+ firstHost.InterruptCurrentMovement();
+
+ Assert.Equal(1, firstUpdates);
+ Assert.Equal(1, firstInterrupts);
+ Assert.Equal(0, replacementUpdates);
+ Assert.Equal(0, replacementInterrupts);
+ }
+
+ [Fact]
+ public void RemoteMotion_FullHostGateUsesCanonicalStableHost()
+ {
+ const uint guid = 0x70000030u;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var motion = new RemoteMotion(new PhysicsBody());
+ runtime.SetRemoteMotionRuntime(guid, motion);
+ EntityPhysicsHost minimal = Host(guid);
+ runtime.InstallPhysicsHost(record, minimal);
+
+ Assert.Null(motion.Host);
+ motion.MarkFullPhysicsHostBound();
+ Assert.Same(minimal, motion.Host);
+
+ EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f));
+ Assert.Same(
+ minimal,
+ EntityPhysicsHost.InstallOrRebind(runtime, record, configured));
+ Assert.Same(minimal, motion.Host);
+ Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X);
+
+ runtime.SetRemoteMotionRuntime(guid, motion);
+ Assert.Same(minimal, motion.Host);
+ }
+
+ [Fact]
+ public void RemoteMotionBindingFailure_DoesNotPublishPartialCanonicalState()
+ {
+ const uint guid = 0x70000031u;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var failing = new ThrowingCellConsumerMotion();
+
+ Assert.Throws(() =>
+ runtime.SetRemoteMotionRuntime(guid, failing));
+ Assert.Null(record.RemoteMotionRuntime);
+ Assert.Null(record.PhysicsBody);
+ Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
+
+ runtime.SetRemoteMotionRuntime(guid, failing);
+ Assert.Same(failing, record.RemoteMotionRuntime);
+ Assert.Same(failing.Body, record.PhysicsBody);
+ }
+
+ [Fact]
+ public void AlreadyBoundRemoteFromOldGeneration_CannotPoisonReplacement()
+ {
+ const uint guid = 0x70000032u;
+ var runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(guid, 1));
+ var stale = new RemoteMotion(new PhysicsBody());
+ runtime.SetRemoteMotionRuntime(guid, stale);
+
+ LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
+ Assert.Throws(() =>
+ runtime.SetRemoteMotionRuntime(guid, stale));
+ Assert.Null(replacement.RemoteMotionRuntime);
+ Assert.Null(replacement.PhysicsBody);
+ Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
+
+ var fresh = new RemoteMotion(new PhysicsBody());
+ runtime.SetRemoteMotionRuntime(guid, fresh);
+ Assert.Same(fresh, replacement.RemoteMotionRuntime);
+ }
+
+ [Fact]
+ public void SameRemoteRuntime_CannotChangeCanonicalBodyOnIdempotentBind()
+ {
+ const uint guid = 0x70000033u;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var firstBody = new PhysicsBody();
+ var secondBody = new PhysicsBody();
+ var alternating = new AlternatingBodyMotion(firstBody, secondBody);
+
+ runtime.SetRemoteMotionRuntime(guid, alternating);
+ Assert.Same(firstBody, record.PhysicsBody);
+
+ Assert.Throws(() =>
+ runtime.SetRemoteMotionRuntime(guid, alternating));
+ Assert.Same(firstBody, record.PhysicsBody);
+ Assert.Same(alternating, record.RemoteMotionRuntime);
+ Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var indexed));
+ Assert.Same(alternating, indexed);
+ }
+
+ [Fact]
+ public void IdempotentRemoteBind_ReadsBodyOnceAndMutatesOnlyCanonicalBody()
+ {
+ const uint guid = 0x70000036u;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var canonical = new PhysicsBody();
+ var unrelated = new PhysicsBody();
+ PhysicsStateFlags unrelatedInitialState = unrelated.State;
+ var scripted = new SequenceBodyMotion(canonical, canonical, unrelated);
+
+ runtime.SetRemoteMotionRuntime(guid, scripted);
+ runtime.SetRemoteMotionRuntime(guid, scripted);
+
+ Assert.Same(canonical, record.PhysicsBody);
+ Assert.Equal(record.FinalPhysicsState, canonical.State);
+ Assert.Equal(unrelatedInitialState, unrelated.State);
+ Assert.Equal(2, scripted.ReadCount);
+ }
+
+ [Fact]
+ public void ReentrantBind_SameGuidReplacementPreventsOuterPublication()
+ {
+ const uint guid = 0x70000034u;
+ var runtime = Runtime();
+ LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var motion = new CallbackCanonicalMotion(() =>
+ runtime.RegisterLiveEntity(Spawn(guid, 2)));
+
+ Assert.Throws(() =>
+ runtime.SetRemoteMotionRuntime(guid, motion));
+
+ Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
+ Assert.Equal((ushort)2, replacement.Generation);
+ Assert.Null(replacement.RemoteMotionRuntime);
+ Assert.Null(replacement.PhysicsBody);
+ Assert.Null(retired.RemoteMotionRuntime);
+ Assert.Null(retired.PhysicsBody);
+ Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
+ }
+
+ [Fact]
+ public void ReentrantBind_SessionClearPreventsOuterPublication()
+ {
+ const uint guid = 0x70000035u;
+ var runtime = Runtime();
+ LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ var motion = new CallbackCanonicalMotion(runtime.Clear);
+
+ Assert.Throws(() =>
+ runtime.SetRemoteMotionRuntime(guid, motion));
+
+ Assert.Equal(0, runtime.Count);
+ Assert.Equal(0, runtime.PendingTeardownCount);
+ Assert.Null(retired.RemoteMotionRuntime);
+ Assert.Null(retired.PhysicsBody);
+ Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
+ }
+
+ [Fact]
+ public void Delete_TeardownHostsRemainPeerResolvableUntilExitWorldConverges()
+ {
+ const uint leftGuid = 0x70000040u;
+ const uint rightGuid = 0x70000041u;
+ LiveEntityRuntime runtime = null!;
+ runtime = Runtime(record => CleanPhysicsHost(record));
+ LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
+ LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
+ IPhysicsObjHost? Resolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
+ ? host
+ : null;
+ EntityPhysicsHost left = Host(leftGuid, Resolve, new Vector3(0f, 0f, 0f));
+ EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
+ runtime.InstallPhysicsHost(leftRecord, left);
+ runtime.InstallPhysicsHost(rightRecord, right);
+ left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
+ right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
+
+ Assert.True(runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(leftGuid, InstanceSequence: 1),
+ isLocalPlayer: false));
+
+ Assert.Null(leftRecord.PhysicsHost);
+ Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
+ Assert.Null(right.TargetManager.TargetInfo);
+ Assert.False(right.TargetManager.VoyeurTable?.ContainsKey(leftGuid) ?? false);
+ Assert.False(runtime.TryGetPhysicsHost(leftGuid, out _));
+ }
+
+ [Fact]
+ public void SessionClear_TeardownHostsResolveAcrossTombstones()
+ {
+ const uint leftGuid = 0x70000042u;
+ const uint rightGuid = 0x70000043u;
+ LiveEntityRuntime runtime = null!;
+ runtime = Runtime(record => CleanPhysicsHost(record));
+ LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
+ LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
+ IPhysicsObjHost? Resolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
+ ? host
+ : null;
+ EntityPhysicsHost left = Host(leftGuid, Resolve, Vector3.Zero);
+ EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
+ runtime.InstallPhysicsHost(leftRecord, left);
+ runtime.InstallPhysicsHost(rightRecord, right);
+ left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
+ right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
+
+ runtime.Clear();
+
+ Assert.Equal(0, runtime.PendingTeardownCount);
+ Assert.Null(leftRecord.PhysicsHost);
+ Assert.Null(rightRecord.PhysicsHost);
+ Assert.Equal(0u, left.PositionManager.GetStickyObjectId());
+ Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
+ }
+
+ [Fact]
+ public void FailedTeardown_RetryCannotMutateReplacementRelationship()
+ {
+ const uint guid = 0x70000044u;
+ const uint targetGuid = 0x70000045u;
+ bool failOnce = true;
+ EntityPhysicsHost? replacementHost = null;
+ LiveEntityRecord? replacementRecord = null;
+ LiveEntityRuntime runtime = null!;
+ runtime = Runtime(record =>
+ {
+ if (failOnce)
+ {
+ failOnce = false;
+ replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
+ IPhysicsObjHost? Resolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
+ ? host
+ : null;
+ replacementHost = Host(guid, Resolve);
+ runtime.InstallPhysicsHost(replacementRecord, replacementHost);
+ replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
+ throw new InvalidOperationException("fixture failure before old host cleanup");
+ }
+
+ CleanPhysicsHost(record);
+ });
+ LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
+ IPhysicsObjHost? InitialResolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
+ ? host
+ : null;
+ EntityPhysicsHost oldHost = Host(guid, InitialResolve);
+ EntityPhysicsHost targetHost = Host(targetGuid, InitialResolve, new Vector3(2f, 0f, 0f));
+ runtime.InstallPhysicsHost(oldRecord, oldHost);
+ runtime.InstallPhysicsHost(targetRecord, targetHost);
+ oldHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
+
+ Assert.Throws(() => runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(guid, InstanceSequence: 1),
+ isLocalPlayer: false));
+ Assert.Same(oldHost, oldRecord.PhysicsHost);
+ Assert.Same(replacementHost, replacementRecord!.PhysicsHost);
+ Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId());
+ Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
+
+ Assert.Equal(1, runtime.RetryPendingTeardowns());
+ Assert.Null(oldRecord.PhysicsHost);
+ Assert.Same(replacementHost, replacementRecord.PhysicsHost);
+ Assert.Equal(targetGuid, replacementHost.PositionManager.GetStickyObjectId());
+ Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
+ }
+
+ [Fact]
+ public void FailedTeardown_TombstoneCannotAcceptNewRelationshipsOutsideTeardown()
+ {
+ const uint retiredGuid = 0x70000046u;
+ const uint watcherGuid = 0x70000047u;
+ bool fail = true;
+ LiveEntityRuntime runtime = null!;
+ runtime = Runtime(record =>
+ {
+ CleanPhysicsHost(record);
+ if (fail)
+ {
+ fail = false;
+ throw new InvalidOperationException("fixture post-exit failure");
+ }
+ });
+ LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!;
+ LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
+ IPhysicsObjHost? Resolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
+ ? host
+ : null;
+ EntityPhysicsHost retired = Host(retiredGuid, Resolve);
+ EntityPhysicsHost watcher = Host(watcherGuid, Resolve);
+ runtime.InstallPhysicsHost(retiredRecord, retired);
+ runtime.InstallPhysicsHost(watcherRecord, watcher);
+
+ Assert.Throws(() => runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(retiredGuid, InstanceSequence: 1),
+ isLocalPlayer: false));
+ watcher.SetTarget(0, retiredGuid, 0.5f, 0.5);
+
+ Assert.Null(retired.TargetManager.VoyeurTable);
+ Assert.Equal(TargetStatus.Undefined, watcher.TargetManager.TargetInfo!.Value.Status);
+ Assert.False(runtime.TryGetPhysicsHost(retiredGuid, out _));
+ Assert.Equal(1, runtime.RetryPendingTeardowns());
+ }
+
+ [Fact]
+ public void MinimalHost_ExitPositionRetainsExactRecordFrame()
+ {
+ const uint guid = 0x70000048u;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ record.FullCellId = 0x01010123u;
+ record.WorldEntity = new AcDream.Core.World.WorldEntity
+ {
+ Id = 1_000_123u,
+ ServerGuid = guid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = new Vector3(12f, 34f, 56f),
+ Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f),
+ MeshRefs = Array.Empty(),
+ };
+ EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
+
+ Assert.Equal(record.FullCellId, host.Position.ObjCellId);
+ Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin);
+ Assert.Equal(record.WorldEntity.Rotation, host.Position.Frame.Orientation);
+ }
+
+ [Fact]
+ public void FullHost_ExitSnapshotRetainsOldFrameAcrossSameGuidReplacement()
+ {
+ const uint departingGuid = 0x70000049u;
+ const uint watcherGuid = 0x7000004Au;
+ var delivered = new List();
+ LiveEntityRuntime runtime = null!;
+ runtime = Runtime(record =>
+ {
+ if (record.ServerGuid == departingGuid)
+ {
+ LiveEntityRecord replacement = runtime.RegisterLiveEntity(
+ Spawn(departingGuid, 2)).Record!;
+ replacement.FullCellId = 0x02020202u;
+ replacement.WorldEntity = Entity(
+ departingGuid,
+ new Vector3(999f, 999f, 999f),
+ Quaternion.Identity);
+ runtime.InstallPhysicsHost(replacement, Host(departingGuid));
+ }
+ CleanPhysicsHost(record);
+ });
+ LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!;
+ LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
+ departing.FullCellId = 0x01010123u;
+ Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
+ departing.WorldEntity = Entity(
+ departingGuid,
+ new Vector3(12f, 34f, 56f),
+ departingRotation);
+ watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity);
+ IPhysicsObjHost? Resolve(uint id) =>
+ runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null;
+ var body = new PhysicsBody
+ {
+ Position = departing.WorldEntity.Position,
+ Orientation = departingRotation,
+ };
+ var remote = new RemoteMotion(body);
+ runtime.SetRemoteMotionRuntime(departingGuid, remote);
+ var departingHost = new EntityPhysicsHost(
+ departingGuid,
+ getPosition: () => new Position(
+ departing.FullCellId,
+ departing.WorldEntity?.Position ?? body.Position,
+ body.Orientation),
+ getVelocity: () => body.Velocity,
+ getRadius: () => 0.5f,
+ inContact: () => true,
+ minterpMaxSpeed: () => 1f,
+ curTime: () => 0,
+ physicsTimerTime: () => 0,
+ getObjectA: Resolve,
+ handleUpdateTarget: _ => { },
+ interruptCurrentMovement: () => { });
+ var watcher = new EntityPhysicsHost(
+ watcherGuid,
+ getPosition: () => new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
+ getVelocity: () => Vector3.Zero,
+ getRadius: () => 0.5f,
+ inContact: () => true,
+ minterpMaxSpeed: () => 1f,
+ curTime: () => 0,
+ physicsTimerTime: () => 0,
+ getObjectA: Resolve,
+ handleUpdateTarget: delivered.Add,
+ interruptCurrentMovement: () => { });
+ runtime.InstallPhysicsHost(departing, departingHost);
+ runtime.InstallPhysicsHost(watcherRecord, watcher);
+ watcher.SetTarget(0, departingGuid, 0.5f, 0.5);
+ delivered.Clear();
+
+ Assert.True(runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(departingGuid, InstanceSequence: 1),
+ isLocalPlayer: false));
+
+ TargetInfo exit = Assert.Single(delivered);
+ Assert.Equal(TargetStatus.ExitWorld, exit.Status);
+ Assert.Equal(0x01010123u, exit.TargetPosition.ObjCellId);
+ Assert.Equal(new Vector3(12f, 34f, 56f), exit.TargetPosition.Frame.Origin);
+ Assert.Equal(departingRotation, exit.TargetPosition.Frame.Orientation);
+ Assert.Equal(0x01010123u, remote.CellId);
+ }
+
+ [Fact]
+ public void ActiveNonVisibleRecord_CanOwnResolvablePhysicsHost()
+ {
+ const uint guid = 0x7000004Bu;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
+ runtime.InstallPhysicsHost(record, host);
+
+ Assert.Empty(runtime.WorldEntities);
+ Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
+ Assert.Same(host, resolved);
+ }
+
+ private static void CleanPhysicsHost(LiveEntityRecord record)
+ {
+ if (record.PhysicsHost is not EntityPhysicsHost host)
+ return;
+ host.PositionManager.UnStick();
+ host.ClearTarget();
+ host.NotifyExitWorld();
+ }
+
+ private static LiveEntityRuntime Runtime(Action? teardown = null) =>
+ teardown is null
+ ? new LiveEntityRuntime(
+ new GpuWorldState(),
+ new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }))
+ : new LiveEntityRuntime(
+ new GpuWorldState(),
+ new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
+ teardown);
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) =>
+ new(
+ Guid: guid,
+ Position: null,
+ SetupTableId: null,
+ AnimPartChanges: Array.Empty(),
+ TextureChanges: Array.Empty(),
+ SubPalettes: Array.Empty(),
+ BasePaletteId: null,
+ ObjScale: null,
+ Name: "fixture",
+ ItemType: null,
+ MotionState: null,
+ MotionTableId: null,
+ InstanceSequence: instance);
+
+ private static AcDream.Core.World.WorldEntity Entity(
+ uint guid,
+ Vector3 position,
+ Quaternion rotation) =>
+ new()
+ {
+ Id = 1_000_000u + (guid & 0xFFFFu),
+ ServerGuid = guid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = position,
+ Rotation = rotation,
+ MeshRefs = Array.Empty(),
+ };
+
+ private static EntityPhysicsHost Host(
+ uint guid,
+ Func? resolve = null,
+ Vector3? position = null) =>
+ new(
+ guid,
+ getPosition: () => new Position(
+ 0x01010001u,
+ position ?? Vector3.Zero,
+ Quaternion.Identity),
+ getVelocity: () => Vector3.Zero,
+ getRadius: () => 0.5f,
+ inContact: () => true,
+ minterpMaxSpeed: () => 1f,
+ curTime: () => 0,
+ physicsTimerTime: () => 0,
+ getObjectA: resolve ?? (_ => null),
+ handleUpdateTarget: _ => { },
+ interruptCurrentMovement: () => { });
+
+ private static EntityPhysicsHost CallbackHost(
+ uint guid,
+ Action handleUpdateTarget,
+ Action interruptCurrentMovement) =>
+ new(
+ guid,
+ getPosition: () => new Position(
+ 0x01010001u,
+ Vector3.Zero,
+ Quaternion.Identity),
+ getVelocity: () => Vector3.Zero,
+ getRadius: () => 0.5f,
+ inContact: () => true,
+ minterpMaxSpeed: () => 1f,
+ curTime: () => 0,
+ physicsTimerTime: () => 0,
+ getObjectA: _ => null,
+ handleUpdateTarget,
+ interruptCurrentMovement);
+
+ private sealed class HostConsumerMotion :
+ ILiveEntityRemoteMotionRuntime,
+ ILiveEntityPhysicsHostConsumer
+ {
+ private Func? _read;
+ public PhysicsBody Body { get; } = new();
+ public IPhysicsObjHost? Host => _read?.Invoke();
+ public void BindPhysicsHost(Func read)
+ {
+ if (_read is not null)
+ throw new InvalidOperationException("already bound");
+ _read = read;
+ }
+ }
+
+ private sealed class ThrowingCellConsumerMotion :
+ ILiveEntityRemoteMotionRuntime,
+ ILiveEntityCanonicalRuntimeConsumer
+ {
+ private bool _failOnce = true;
+ public PhysicsBody Body { get; } = new();
+ public void BindCanonicalRuntime(
+ Func readPhysicsHost,
+ Func readCell,
+ Action writeCell)
+ {
+ ArgumentNullException.ThrowIfNull(readPhysicsHost);
+ ArgumentNullException.ThrowIfNull(readCell);
+ ArgumentNullException.ThrowIfNull(writeCell);
+ if (_failOnce)
+ {
+ _failOnce = false;
+ throw new InvalidOperationException("fixture context bind failure");
+ }
+ }
+ }
+
+ private sealed class AlternatingBodyMotion(
+ PhysicsBody first,
+ PhysicsBody second) : ILiveEntityRemoteMotionRuntime
+ {
+ private int _reads;
+ public PhysicsBody Body => _reads++ == 0 ? first : second;
+ }
+
+ private sealed class SequenceBodyMotion(params PhysicsBody[] bodies) :
+ ILiveEntityRemoteMotionRuntime
+ {
+ public int ReadCount { get; private set; }
+
+ public PhysicsBody Body
+ {
+ get
+ {
+ int index = Math.Min(ReadCount, bodies.Length - 1);
+ ReadCount++;
+ return bodies[index];
+ }
+ }
+ }
+
+ private sealed class CallbackCanonicalMotion(Action callback) :
+ ILiveEntityRemoteMotionRuntime,
+ ILiveEntityCanonicalRuntimeConsumer
+ {
+ public PhysicsBody Body { get; } = new();
+
+ public void BindCanonicalRuntime(
+ Func readPhysicsHost,
+ Func readCell,
+ Action writeCell)
+ {
+ ArgumentNullException.ThrowIfNull(readPhysicsHost);
+ ArgumentNullException.ThrowIfNull(readCell);
+ ArgumentNullException.ThrowIfNull(writeCell);
+ callback();
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
index 0202c92b..04436ad3 100644
--- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
+++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.Input;
+using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs
index 55701c73..6dd2acbe 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs
@@ -62,6 +62,9 @@ internal sealed class R5Host : IPhysicsObjHost
public IPhysicsObjHost? GetObjectA(uint id)
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
+ public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
+ _targetManager?.GetRelationshipTarget(objectId);
+
public void HandleUpdateTarget(TargetInfo info)
{
HandleUpdateTargetCalls.Add(info);
@@ -79,12 +82,14 @@ internal sealed class R5Host : IPhysicsObjHost
public void ClearTarget() => _targetManager?.ClearTarget();
- public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info);
+ public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
+ _targetManager?.ReceiveUpdate(info, sender);
- public void AddVoyeur(uint watcherId, float radius, double quantum)
- => TargetManager.AddVoyeur(watcherId, radius, quantum);
+ public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
+ => TargetManager.AddVoyeur(watcher, radius, quantum);
- public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId);
+ public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
+ _targetManager?.RemoveVoyeur(watcherId, expectedWatcher);
// ── test helpers ───────────────────────────────────────────────────────
public void SetOrigin(Vector3 origin)
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
index cfbfb27e..f75e054d 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
@@ -352,6 +352,8 @@ internal sealed class RemoteChaseHarness
public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id)
=> id == PlayerGuid ? _h._playerHost : null;
+ public IPhysicsObjHost? GetRelationshipTarget(uint objectId)
+ => GetObjectA(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
@@ -373,9 +375,9 @@ internal sealed class RemoteChaseHarness
}
public void ClearTarget() => _h._targetArmed = false;
- public void ReceiveTargetUpdate(TargetInfo info) { }
- public void AddVoyeur(uint watcherId, float radius, double quantum) { }
- public void RemoveVoyeur(uint watcherId) { }
+ public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
+ public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
+ public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
}
private sealed class TargetHost : IPhysicsObjHost
@@ -391,13 +393,14 @@ internal sealed class RemoteChaseHarness
public double CurTime => _h.Now;
public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id) => null;
+ public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => null;
public void HandleUpdateTarget(TargetInfo info) { }
public void InterruptCurrentMovement() { }
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { }
public void ClearTarget() { }
- public void ReceiveTargetUpdate(TargetInfo info) { }
- public void AddVoyeur(uint watcherId, float radius, double quantum) { }
- public void RemoveVoyeur(uint watcherId) { }
+ public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
+ public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
+ public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
}
// ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs
index 744785df..4c8bd59f 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs
@@ -218,11 +218,11 @@ public sealed class StickyManagerTests
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
var sticky = new StickyManager(self);
+ target.Resolvable = false;
sticky.StickTo(target.Id, 0.5f, 1.0f);
- // Cache a position via HandleUpdateTarget, then make the target vanish.
+ // Cache a position without ever establishing an exact target token.
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
- target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs
index 0fd2130b..549e9b04 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs
@@ -76,7 +76,9 @@ public sealed class TargetManagerTests
int before = self.HandleUpdateTargetCalls.Count;
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
- self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p));
+ self.TargetManager.ReceiveUpdate(
+ new TargetInfo(999u, TargetStatus.Ok, p, p),
+ target);
Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
}
@@ -88,7 +90,9 @@ public sealed class TargetManagerTests
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
- self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p));
+ self.TargetManager.ReceiveUpdate(
+ new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p),
+ target);
Assert.Null(self.TargetManager.TargetInfo); // cleared
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
@@ -102,7 +106,9 @@ public sealed class TargetManagerTests
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
- self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
+ self.TargetManager.ReceiveUpdate(
+ new TargetInfo(target.Id, TargetStatus.Ok, tp, tp),
+ target);
// self→target interp position (0,5,0) normalized = +Y.
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
@@ -110,6 +116,46 @@ public sealed class TargetManagerTests
Assert.Equal(1f, heading.Y, 3);
}
+ [Fact]
+ public void ReceiveUpdate_SameGuidReplacementSender_IsIgnored()
+ {
+ var (self, originalTarget, world) = TwoHosts();
+ self.TargetManager.SetTarget(0, originalTarget.Id, 1.0f, 0.0);
+ self.HandleUpdateTargetCalls.Clear();
+ var replacement = new R5Host(originalTarget.Id, world);
+ var p = new Position(1u, new Vector3(99f, 0f, 0f), Quaternion.Identity);
+
+ self.ReceiveTargetUpdate(
+ new TargetInfo(originalTarget.Id, TargetStatus.ExitWorld, p, p),
+ replacement);
+
+ Assert.Empty(self.HandleUpdateTargetCalls);
+ Assert.NotNull(self.TargetManager.TargetInfo);
+ Assert.Same(
+ originalTarget,
+ self.TargetManager.GetRelationshipTarget(originalTarget.Id));
+ }
+
+ [Fact]
+ public void StickyAdjustOffset_UsesExactTargetInsteadOfSameGuidReplacement()
+ {
+ var (self, originalTarget, world) = TwoHosts();
+ self.SetOrigin(Vector3.Zero);
+ originalTarget.SetOrigin(new Vector3(5f, 0f, 0f));
+ self.PositionManager.StickTo(originalTarget.Id, 0.5f, 1f);
+ var replacement = new R5Host(originalTarget.Id, world);
+ replacement.SetOrigin(new Vector3(10f, 0f, 0f));
+ originalTarget.SetOrigin(new Vector3(-5f, 0f, 0f));
+ var delta = new MotionDeltaFrame();
+
+ self.PositionManager.AdjustOffset(delta, 0.1);
+
+ Assert.True(delta.Origin.X < 0f);
+ Assert.Same(
+ originalTarget,
+ self.TargetManager.GetRelationshipTarget(originalTarget.Id));
+ }
+
[Fact]
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
{
@@ -133,9 +179,8 @@ public sealed class TargetManagerTests
public void HandleTargetting_Throttles_Within500ms()
{
var (self, target, _) = TwoHosts();
- var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0);
// seed a voyeur directly + advance so a sweep WOULD send if not throttled.
- target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0);
+ target.TargetManager.AddVoyeur(self, 0.1f, 0.0);
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
target.PhysicsTimerTime = 0.3; // < 0.5 throttle
@@ -186,12 +231,12 @@ public sealed class TargetManagerTests
{
var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero);
- target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0);
+ target.TargetManager.AddVoyeur(self, radius: 1.0f, quantum: 0.0);
var voyeur = target.TargetManager.VoyeurTable![self.Id];
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
target.SetOrigin(new Vector3(5f, 0f, 0f));
- target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only
+ target.TargetManager.AddVoyeur(self, radius: 2.5f, quantum: 0.3); // existing → update only
Assert.Equal(2.5f, voyeur.Radius);
Assert.Equal(0.3, voyeur.Quantum);
@@ -202,10 +247,27 @@ public sealed class TargetManagerTests
public void RemoveVoyeur_RemovesEntry()
{
var (self, target, _) = TwoHosts();
- target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0);
- Assert.True(target.TargetManager.RemoveVoyeur(self.Id));
+ target.TargetManager.AddVoyeur(self, 1.0f, 0.0);
+ Assert.True(target.TargetManager.RemoveVoyeur(self.Id, self));
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
- Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone
+ Assert.False(target.TargetManager.RemoveVoyeur(self.Id, self)); // already gone
+ }
+
+ [Fact]
+ public void HiddenWatcher_GuidReuseCannotRedirectOrRemoveExactSubscription()
+ {
+ var (watcher, target, world) = TwoHosts();
+ watcher.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
+ watcher.HandleUpdateTargetCalls.Clear();
+ watcher.Resolvable = false;
+ var replacement = new R5Host(watcher.Id, world);
+
+ target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
+
+ Assert.Single(watcher.HandleUpdateTargetCalls);
+ Assert.Empty(replacement.HandleUpdateTargetCalls);
+ Assert.False(target.TargetManager.RemoveVoyeur(watcher.Id, replacement));
+ Assert.True(target.TargetManager.RemoveVoyeur(watcher.Id, watcher));
}
[Fact]