diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 8258839f..dce12142 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -171,7 +171,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` |
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
-| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.OnLiveEntityPruned` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` |
+| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` |
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
diff --git a/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs b/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs
new file mode 100644
index 00000000..bbfca18f
--- /dev/null
+++ b/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs
@@ -0,0 +1,27 @@
+using System.Numerics;
+
+namespace AcDream.App.Physics;
+
+///
+/// Session-scoped observation timestamps used to infer when a remote
+/// locomotion cycle has stopped. Canonical live identity remains in
+/// LiveEntityRuntime; this owner contains only derived observer state.
+///
+internal sealed class RemoteMovementObservationTracker
+{
+ private readonly Dictionary _lastMove = new();
+
+ internal (Vector3 Pos, DateTime Time) this[uint serverGuid]
+ {
+ set => _lastMove[serverGuid] = value;
+ }
+
+ internal bool TryGetValue(
+ uint serverGuid,
+ out (Vector3 Pos, DateTime Time) observation) =>
+ _lastMove.TryGetValue(serverGuid, out observation);
+
+ internal bool Remove(uint serverGuid) => _lastMove.Remove(serverGuid);
+
+ internal void Clear() => _lastMove.Clear();
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 7bc131b8..5a088d91 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -372,8 +372,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// ACE Player_Tick.cs line 368: the client never sends "released forward"
// MoveToState, so the server never broadcasts an explicit stop. Observer
// must infer it from position deltas.
- private readonly Dictionary
- _remoteLastMove = new();
+ private readonly AcDream.App.Physics.RemoteMovementObservationTracker
+ _remoteMovementObservations = new();
///
/// Per-remote-entity movement state for smoothing between server
@@ -397,8 +397,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// keyed by server guid. Retail keeps these stamps on CPhysicsObj
/// (update_times); seeded from CreateObject's PhysicsDesc timestamp
/// block during , consulted at the
- /// top of , dropped with the entity in
- /// .
+ /// top of , and dropped by the live
+ /// entity teardown owner.
///
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
@@ -2064,6 +2064,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// N.5 mandatory path: spawn adapters + dispatcher always construct.
// _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are
// all guaranteed non-null here (startup throws above if any are missing).
+ var liveEntityComponentLifecycle =
+ new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
{
var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
// Sequencer factory: look up Setup + MotionTable from dats and build
@@ -2212,8 +2214,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_worldState,
AdvanceLandblockPresentationRetirement);
- var liveEntityComponentLifecycle =
- new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
@@ -2224,9 +2224,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
- liveEntityComponentLifecycle.Bind(
- new AcDream.App.World.DelegateLiveEntityRuntimeComponentLifecycle(
- TearDownLiveEntityRuntimeComponents));
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
@@ -2237,10 +2234,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
- _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
- _liveEntities,
- () => _playerServerGuid,
- OnLiveEntityPruned);
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
@@ -2566,6 +2559,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() => _playerServerGuid,
IsSealedDungeonCell,
Console.WriteLine);
+ var liveEntityTeardown =
+ new AcDream.App.World.LiveEntityRuntimeTeardownController(
+ _liveEntities,
+ _liveEntityPresentation!,
+ _entityEffects!,
+ _remoteTeleportController!,
+ _selectionInteractions,
+ _selection,
+ _animatedEntities,
+ _remoteMovementObservations,
+ _translucencyFades,
+ _liveEntityProjectionWithdrawal!,
+ _equippedChildRenderer!,
+ _physicsEngine.ShadowObjects,
+ _liveEntityLights!,
+ _classificationCache,
+ () => _playerServerGuid);
+ liveEntityComponentLifecycle.Bind(liveEntityTeardown);
_liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController(
_liveEntities,
Objects,
@@ -2579,9 +2590,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveEntityPresentation!),
originCoordinator,
networkUpdateBridge,
+ liveEntityTeardown,
PublishLocalPhysicsTimestamps,
() => _playerServerGuid,
_options.DumpLiveSpawns ? Console.WriteLine : null);
+ _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
+ _liveEntities,
+ () => _playerServerGuid,
+ candidate => _liveEntityHydration.OnPrune(candidate));
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(
new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink(
@@ -2691,7 +2707,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
NetworkEffects = () => _entityEffects?.ClearNetworkState(),
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
- RemoteMovementDiagnostics = _remoteLastMove.Clear,
+ RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
});
private void ResetSessionMouseCapture()
@@ -2831,7 +2847,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
spawn,
static (hydration, value) => hydration.OnCreate(value)),
Deleted: deletion => _inboundEntityEvents.Run(
- this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
+ _liveEntityHydration!, deletion,
+ static (hydration, value) => hydration.OnDelete(value)),
PickedUp: pickup => _inboundEntityEvents.Run(
_liveEntityHydration!, pickup,
static (hydration, value) => hydration.OnPickup(value)),
@@ -3015,41 +3032,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
block.Height, heightTable, landblockX, landblockY, localX, localY);
}
- private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
- {
- // A Delete can arrive before CreateObject, in which case no instance
- // timestamp owner exists and its mixed pending effect FIFO must be
- // discarded directly. For a known record, cleanup belongs after the
- // generation gate: a stale Delete must not cancel the current owner.
- if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true)
- _entityEffects?.ForgetUnknownOwner(delete.Guid);
- bool removed = _liveEntities!.UnregisterLiveEntity(
- delete,
- isLocalPlayer: delete.Guid == _playerServerGuid,
- beforeTeardown: () =>
- {
- AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
- });
-
- if (removed
- && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
- {
- Console.WriteLine(
- $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
- }
- }
-
- private void OnLiveEntityPruned(AcDream.App.World.LiveEntityPruneCandidate candidate)
- {
- // ACE retains KnownObjects across normal teleports and consequently
- // does not issue an F747 when an old region leaves client visibility.
- // Route the 25-second client liveness expiry through the exact same
- // generation gate and symmetric teardown as a wire ObjectDelete.
- OnLiveEntityDeleted(new AcDream.Core.Net.Messages.DeleteObject.Parsed(
- candidate.ServerGuid,
- candidate.Generation));
- }
-
///
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
@@ -3556,105 +3538,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
hiddenEntityHost.NotifyHidden();
}
- private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
- {
- record.RuntimeComponentTeardownPlan ??= CreateLiveEntityRuntimeTeardownPlan(record);
- record.RuntimeComponentTeardownPlan.Advance();
- }
-
- private AcDream.App.World.LiveEntityTeardownPlan CreateLiveEntityRuntimeTeardownPlan(
- LiveEntityRecord record)
- {
- uint serverGuid = record.ServerGuid;
- var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup(
- record,
- guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true
- ? current
- : null);
- var cleanups = new List
- {
- () => _liveEntityPresentation?.Forget(record),
- () => _entityEffects?.OnLiveEntityUnregistered(record),
- () => _remoteTeleportController?.Forget(record),
- () =>
- {
- bool replacementExists =
- _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true
- && !ReferenceEquals(current, record);
- if (_selectionInteractions is { } interactions)
- {
- interactions.OnEntityRemoved(record, replacementExists);
- }
- else if (!replacementExists && _selection.SelectedObjectId == serverGuid)
- {
- _selection.Clear(
- AcDream.Core.Selection.SelectionChangeSource.System,
- AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
- }
- },
- };
-
- if (record.WorldEntity is not { } existingEntity)
- {
- return new AcDream.App.World.LiveEntityTeardownPlan(cleanups);
- }
-
- // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
- // independently (r3-port-plan §4): the manager's (each pending
- // animation fires MotionDone(success:false) → the bound interp pops
- // in step) THEN the interp's own (flushes any remainder —
- // MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
- // relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
- if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
- cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld());
- RemoteMotion? rmGone = record.RemoteMotionRuntime as RemoteMotion;
- if (rmGone is not null)
- cleanups.Add(rmGone.Movement.HandleExitWorld);
- // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
- // entity that it left the world (TargetManager::NotifyVoyeurOfEvent
- // ExitWorld) — a watcher moving-to/stuck-to this entity drops the
- // 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 = record.PhysicsHost as EntityPhysicsHost;
- if (ephGone is not null)
- {
- // R5-V3 (#171): retail exit_world (0x00514e60) order — the
- // departing entity first tears down its OWN stick
- // (PositionManager::UnStick @0x00514e88 — drops its voyeur
- // subscription on whatever it was stuck to) and its own target
- // subscription (TargetManager::ClearTarget @0x00514e97 — same
- // for a plain mid-chase moveto), THEN NotifyVoyeurOfEvent
- // (ExitWorld) tells the entities watching IT. Without the first
- // two, a despawning attacker leaves a dead voyeur entry on its
- // target until send-failure pruning.
- cleanups.Add(ephGone.PositionManager.UnStick);
- cleanups.Add(ephGone.ClearTarget);
- cleanups.Add(ephGone.NotifyExitWorld);
- }
- cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
- cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
-
- // Dead-reckon state is keyed by SERVER guid (not local id) so we
- // clear using the same guid the next spawn/update would use.
- // LiveEntityRuntime removes the canonical remote-motion index by
- // captured reference after this App teardown plan converges. Never use
- // the GUID-only dictionary view here: it may already expose a newer
- // incarnation.
- cleanups.Add(() => incarnation.RunIfNoReplacement(
- () => _remoteLastMove.Remove(serverGuid)));
- cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
- cleanups.Add(() => _liveEntityProjectionWithdrawal!.LeaveWorld(
- record,
- _playerServerGuid));
- cleanups.Add(() => _equippedChildRenderer?.OnLogicalTeardown(record));
- // Logical teardown ends the retained shadow payload too. The weaker
- // pickup/parent path stops after Suspend so re-entry can restore it.
- cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id));
- cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id));
- return new AcDream.App.World.LiveEntityTeardownPlan(cleanups);
- }
-
///
/// R4-V5: shared retail unpack_movement type-6..9 routing
/// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes
@@ -4149,9 +4032,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
- // stamp the _remoteLastMove timestamp to "now". Without this,
+ // stamp the remote observation timestamp to "now". Without this,
// the stop-detection loop in TickAnimations sees the previous
- // _remoteLastMove timestamp (set by the last UpdatePosition,
+ // observation timestamp (set by the last UpdatePosition,
// often >300ms ago during idle) and fires the stop signal
// IMMEDIATELY — flipping the sequencer straight back to Ready.
// The visible symptom was "remote char never animates; just
@@ -4172,8 +4055,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// window from this transition. Without this, the entity
// starts its run animation and is instantly interrupted.
var refreshedTime = System.DateTime.UtcNow;
- if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
- _remoteLastMove[update.Guid] = (prev.Pos, refreshedTime);
+ if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
+ _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime);
if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
}
@@ -4936,16 +4819,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (update.Guid != _playerServerGuid)
{
var now = System.DateTime.UtcNow;
- if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
+ if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
{
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
if (moveDist > 0.05f)
- _remoteLastMove[update.Guid] = (worldPos, now);
+ _remoteMovementObservations[update.Guid] = (worldPos, now);
// else: leave old entry so "Time" = last real movement time
}
else
{
- _remoteLastMove[update.Guid] = (worldPos, now);
+ _remoteMovementObservations[update.Guid] = (worldPos, now);
}
// Retail-faithful hard-snap on UpdatePosition.
@@ -7519,7 +7402,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// after that callback stack has unwound on the same update thread.
try
{
- _liveEntities?.RetryPendingTeardowns();
+ _liveEntityHydration?.RetryPendingTeardowns();
}
catch (AggregateException error)
{
diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs
index a1d06811..1c20b5b7 100644
--- a/src/AcDream.App/World/LiveEntityHydrationController.cs
+++ b/src/AcDream.App/World/LiveEntityHydrationController.cs
@@ -146,6 +146,7 @@ internal sealed class LiveEntityHydrationController
private readonly ILiveEntityReadyPublisher _ready;
private readonly ILiveEntityWorldOriginCoordinator _origin;
private readonly ILiveEntityNetworkUpdateSink _networkUpdates;
+ private readonly ILiveEntityTeardownCoordinator _teardown;
private readonly Action _publishTimestamps;
private readonly Func _playerGuid;
private readonly Action? _diagnostic;
@@ -159,6 +160,7 @@ internal sealed class LiveEntityHydrationController
ILiveEntityReadyPublisher ready,
ILiveEntityWorldOriginCoordinator origin,
ILiveEntityNetworkUpdateSink networkUpdates,
+ ILiveEntityTeardownCoordinator teardown,
Action publishTimestamps,
Func playerGuid,
Action? diagnostic = null)
@@ -171,6 +173,7 @@ internal sealed class LiveEntityHydrationController
_ready = ready ?? throw new ArgumentNullException(nameof(ready));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_networkUpdates = networkUpdates ?? throw new ArgumentNullException(nameof(networkUpdates));
+ _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_publishTimestamps = publishTimestamps
?? throw new ArgumentNullException(nameof(publishTimestamps));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@@ -339,6 +342,47 @@ internal sealed class LiveEntityHydrationController
return accepted;
}
+ ///
+ /// Retail SmartBox::HandleDeleteObject @ 0x00451EA0 rejects the
+ /// player, requires the exact Instance timestamp, then performs one
+ /// symmetric logical teardown. An unknown owner only loses queued
+ /// pre-create effect packets.
+ ///
+ public bool OnDelete(DeleteObject.Parsed delete)
+ {
+ // SmartBox::HandleDeleteObject rejects the player before resolving an
+ // object or touching any queued owner state. A pre-Create player
+ // Delete must therefore be side-effect-free too.
+ if (delete.Guid == _playerGuid())
+ return false;
+
+ if (!_runtime.TryGetRecord(delete.Guid, out _))
+ _teardown.ForgetUnknownOwner(delete.Guid);
+
+ bool removed = _runtime.UnregisterLiveEntity(
+ delete,
+ isLocalPlayer: false,
+ beforeTeardown: () =>
+ ObjectTableWiring.ApplyEntityDelete(_objects, delete));
+ if (removed)
+ {
+ _diagnostic?.Invoke(
+ $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
+ }
+ return removed;
+ }
+
+ ///
+ /// Retail's 25-second visibility expiry converges through the same exact
+ /// generation gate and teardown transaction as an authoritative F747.
+ ///
+ public bool OnPrune(LiveEntityPruneCandidate candidate) =>
+ OnDelete(new DeleteObject.Parsed(
+ candidate.ServerGuid,
+ candidate.Generation));
+
+ public int RetryPendingTeardowns() => _runtime.RetryPendingTeardowns();
+
///
/// Parent events may precede either CreateObject. The relationship owner
/// retains wire order and invokes
diff --git a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs
new file mode 100644
index 00000000..48bd8cb3
--- /dev/null
+++ b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs
@@ -0,0 +1,167 @@
+using AcDream.App.Interaction;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Physics;
+using AcDream.Core.Rendering;
+using AcDream.Core.Selection;
+
+namespace AcDream.App.World;
+
+internal interface ILiveEntityTeardownCoordinator
+ : ILiveEntityRuntimeComponentLifecycle
+{
+ void ForgetUnknownOwner(uint serverGuid);
+}
+
+///
+/// Owns the retryable App-component half of retail
+/// CObjectMaint::DeleteObject @ 0x00508460. Each successful cleanup is
+/// committed on the exact record tombstone, so a later retry never repeats an
+/// ExitWorld notification or removes a same-GUID replacement.
+///
+internal sealed class LiveEntityRuntimeTeardownController
+ : ILiveEntityTeardownCoordinator
+{
+ private readonly LiveEntityRuntime? _runtime;
+ private readonly LiveEntityPresentationController? _presentation;
+ private readonly EntityEffectController? _effects;
+ private readonly RemoteTeleportController? _remoteTeleport;
+ private readonly SelectionInteractionController? _interactions;
+ private readonly SelectionState? _selection;
+ private readonly LiveEntityAnimationRuntimeView? _animations;
+ private readonly RemoteMovementObservationTracker? _remoteMovementObservations;
+ private readonly TranslucencyFadeManager? _translucencyFades;
+ private readonly LiveEntityProjectionWithdrawalController? _projectionWithdrawal;
+ private readonly EquippedChildRenderController? _children;
+ private readonly ShadowObjectRegistry? _shadows;
+ private readonly LiveEntityLightController? _lights;
+ private readonly EntityClassificationCache? _classification;
+ private readonly Func? _playerGuid;
+ private readonly Func _createPlan;
+ private readonly Action _forgetUnknownOwner;
+
+ public LiveEntityRuntimeTeardownController(
+ LiveEntityRuntime runtime,
+ LiveEntityPresentationController presentation,
+ EntityEffectController effects,
+ RemoteTeleportController remoteTeleport,
+ SelectionInteractionController? interactions,
+ SelectionState selection,
+ LiveEntityAnimationRuntimeView animations,
+ RemoteMovementObservationTracker remoteMovementObservations,
+ TranslucencyFadeManager translucencyFades,
+ LiveEntityProjectionWithdrawalController projectionWithdrawal,
+ EquippedChildRenderController children,
+ ShadowObjectRegistry shadows,
+ LiveEntityLightController lights,
+ EntityClassificationCache classification,
+ Func playerGuid)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
+ _effects = effects ?? throw new ArgumentNullException(nameof(effects));
+ _remoteTeleport = remoteTeleport ?? throw new ArgumentNullException(nameof(remoteTeleport));
+ _interactions = interactions;
+ _selection = selection ?? throw new ArgumentNullException(nameof(selection));
+ _animations = animations ?? throw new ArgumentNullException(nameof(animations));
+ _remoteMovementObservations = remoteMovementObservations
+ ?? throw new ArgumentNullException(nameof(remoteMovementObservations));
+ _translucencyFades = translucencyFades
+ ?? throw new ArgumentNullException(nameof(translucencyFades));
+ _projectionWithdrawal = projectionWithdrawal
+ ?? throw new ArgumentNullException(nameof(projectionWithdrawal));
+ _children = children ?? throw new ArgumentNullException(nameof(children));
+ _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
+ _lights = lights ?? throw new ArgumentNullException(nameof(lights));
+ _classification = classification
+ ?? throw new ArgumentNullException(nameof(classification));
+ _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
+ _createPlan = CreatePlan;
+ _forgetUnknownOwner = effects.ForgetUnknownOwner;
+ }
+
+ internal LiveEntityRuntimeTeardownController(
+ Func createPlan,
+ Action forgetUnknownOwner)
+ {
+ _createPlan = createPlan ?? throw new ArgumentNullException(nameof(createPlan));
+ _forgetUnknownOwner = forgetUnknownOwner
+ ?? throw new ArgumentNullException(nameof(forgetUnknownOwner));
+ }
+
+ public void TearDown(LiveEntityRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ record.RuntimeComponentTeardownPlan ??= _createPlan(record);
+ record.RuntimeComponentTeardownPlan.Advance();
+ }
+
+ public void ForgetUnknownOwner(uint serverGuid) =>
+ _forgetUnknownOwner(serverGuid);
+
+ private LiveEntityTeardownPlan CreatePlan(LiveEntityRecord record)
+ {
+ LiveEntityRuntime runtime = _runtime!;
+ uint serverGuid = record.ServerGuid;
+ var incarnation = new LiveEntityIncarnationCleanup(
+ record,
+ guid => runtime.TryGetRecord(guid, out LiveEntityRecord current)
+ ? current
+ : null);
+ var cleanups = new List
+ {
+ () => _presentation!.Forget(record),
+ () => _effects!.OnLiveEntityUnregistered(record),
+ () => _remoteTeleport!.Forget(record),
+ () =>
+ {
+ bool replacementExists =
+ runtime.TryGetRecord(serverGuid, out LiveEntityRecord current)
+ && !ReferenceEquals(current, record);
+ if (_interactions is { } interactions)
+ {
+ interactions.OnEntityRemoved(record, replacementExists);
+ }
+ else if (!replacementExists
+ && _selection!.SelectedObjectId == serverGuid)
+ {
+ _selection.Clear(
+ SelectionChangeSource.System,
+ SelectionChangeReason.SelectedObjectRemoved);
+ }
+ },
+ };
+
+ if (record.WorldEntity is not { } existingEntity)
+ return new LiveEntityTeardownPlan(cleanups);
+
+ // CPhysicsObj::exit_world @ 0x00514E60 drains the PartArray and
+ // MovementManager before unsticking/clearing the object's target and
+ // notifying its voyeurs. Preserve the two independent motion drains.
+ if (_animations!.TryGetValue(existingEntity.Id, out LiveEntityAnimationState animation))
+ cleanups.Add(() => animation.Sequencer?.Manager.HandleExitWorld());
+ if (record.RemoteMotionRuntime is RemoteMotion remoteMotion)
+ cleanups.Add(remoteMotion.Movement.HandleExitWorld);
+ if (record.PhysicsHost is EntityPhysicsHost physicsHost)
+ {
+ cleanups.Add(physicsHost.PositionManager.UnStick);
+ cleanups.Add(physicsHost.ClearTarget);
+ cleanups.Add(physicsHost.NotifyExitWorld);
+ }
+
+ cleanups.Add(() => _animations.Remove(existingEntity.Id));
+ cleanups.Add(() => _classification!.InvalidateEntity(existingEntity.Id));
+ cleanups.Add(() => incarnation.RunIfNoReplacement(
+ () => _remoteMovementObservations!.Remove(serverGuid)));
+ cleanups.Add(() => _translucencyFades!.ClearEntity(existingEntity.Id));
+ cleanups.Add(() => _projectionWithdrawal!.LeaveWorld(
+ record,
+ _playerGuid!()));
+ cleanups.Add(() => _children!.OnLogicalTeardown(record));
+ cleanups.Add(() => _shadows!.Deregister(existingEntity.Id));
+ cleanups.Add(() => _lights!.Forget(existingEntity.Id));
+ return new LiveEntityTeardownPlan(cleanups);
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index d16db0a7..96503f07 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -26,6 +26,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("OnLiveEntityPickedUp")]
[InlineData("OnLiveParentUpdated")]
[InlineData("TryAcceptParentForRender")]
+ [InlineData("OnLiveEntityDeleted")]
+ [InlineData("OnLiveEntityPruned")]
+ [InlineData("TearDownLiveEntityRuntimeComponents")]
+ [InlineData("CreateLiveEntityRuntimeTeardownPlan")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
@@ -47,6 +51,7 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(LiveEntityAppearanceBinding))]
[InlineData(typeof(LiveEntityHydrationController))]
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
+ [InlineData(typeof(LiveEntityRuntimeTeardownController))]
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
{
foreach (FieldInfo field in helperType.GetFields(
diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
index 0ff1c3a5..ba308732 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
@@ -550,6 +550,152 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
}
+ [Fact]
+ public void UnknownDelete_ForgetsOnlyPendingOwnerState()
+ {
+ using var fixture = new Fixture(originKnown: true);
+
+ Assert.False(fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
+
+ Assert.Equal([Guid], fixture.Teardown.ForgottenUnknownOwners);
+ Assert.Equal(0, fixture.Teardown.TearDownCount);
+ Assert.Equal(0, fixture.Runtime.Count);
+ }
+
+ [Fact]
+ public void StaleDelete_DoesNotEndRemoteLiveIncarnation()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
+ LiveEntityRecord current = fixture.Record;
+
+ Assert.False(fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
+
+ Assert.Same(current, fixture.Record);
+ Assert.NotNull(fixture.Objects.Get(Guid));
+ Assert.Equal(0, fixture.Teardown.TearDownCount);
+ Assert.Empty(fixture.Teardown.ForgottenUnknownOwners);
+ }
+
+ [Fact]
+ public void LocalPlayerDelete_IsSideEffectFreeBeforeAndAfterCreate()
+ {
+ using var fixture = new Fixture(originKnown: true, playerGuid: Guid);
+
+ Assert.False(fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
+ Assert.Empty(fixture.Teardown.ForgottenUnknownOwners);
+
+ fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
+ LiveEntityRecord current = fixture.Record;
+
+ Assert.False(fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 2)));
+
+ Assert.Same(current, fixture.Record);
+ Assert.NotNull(fixture.Objects.Get(Guid));
+ Assert.Equal(0, fixture.Teardown.TearDownCount);
+ Assert.Empty(fixture.Teardown.ForgottenUnknownOwners);
+ }
+
+ [Fact]
+ public void ExactDelete_RemovesObjectAndTearsDownOnce()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+
+ Assert.True(fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
+
+ Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
+ Assert.Null(fixture.Objects.Get(Guid));
+ Assert.Equal(1, fixture.Teardown.TearDownCount);
+ Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
+ }
+
+ [Fact]
+ public void Prune_UsesTheSameExactDeleteTransaction()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+
+ Assert.True(fixture.Controller.OnPrune(
+ new LiveEntityPruneCandidate(Guid, Generation: 1)));
+
+ Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
+ Assert.Null(fixture.Objects.Get(Guid));
+ Assert.Equal(1, fixture.Teardown.TearDownCount);
+ }
+
+ [Fact]
+ public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ int objectRemovedCalls = 0;
+ fixture.Objects.ObjectRemoved += _ =>
+ {
+ objectRemovedCalls++;
+ throw new InvalidOperationException("fixture object-table observer failure");
+ };
+ bool failTeardown = true;
+ fixture.Teardown.TearDownAction = _ =>
+ {
+ if (!failTeardown)
+ return;
+ failTeardown = false;
+ throw new InvalidOperationException("fixture component teardown failure");
+ };
+
+ AggregateException error = Assert.Throws(() =>
+ fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
+
+ Assert.Contains(error.Flatten().InnerExceptions,
+ exception => exception.Message.Contains("object-table observer", StringComparison.Ordinal));
+ Assert.Contains(error.Flatten().InnerExceptions,
+ exception => exception.Message.Contains("component teardown", StringComparison.Ordinal));
+ Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
+ Assert.Null(fixture.Objects.Get(Guid));
+ Assert.Equal(1, objectRemovedCalls);
+ Assert.Equal(1, fixture.Runtime.PendingTeardownCount);
+
+ Assert.Equal(1, fixture.Controller.RetryPendingTeardowns());
+ Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
+ Assert.Equal(1, objectRemovedCalls);
+ Assert.Equal(2, fixture.Teardown.TearDownCount);
+ }
+
+ [Fact]
+ public void DeleteCallback_CanInstallNewerGenerationWithoutOldCleanupRemovingIt()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord old = fixture.Record;
+ bool replaced = false;
+ fixture.Teardown.TearDownAction = record =>
+ {
+ Assert.Same(old, record);
+ if (replaced)
+ return;
+ replaced = true;
+ LiveEntityRegistrationResult result = fixture.Runtime.RegisterLiveEntity(
+ Spawn(Generation: 2, PositionSequence: 2, Name: "replacement"));
+ Assert.True(result.LogicalRegistrationCreated);
+ };
+
+ Assert.True(fixture.Controller.OnDelete(
+ new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
+
+ Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord replacement));
+ Assert.NotSame(old, replacement);
+ Assert.Equal((ushort)2, replacement.Generation);
+ Assert.Equal("replacement", replacement.Snapshot.Name);
+ Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
+ }
+
[Fact]
public void PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration()
{
@@ -1447,6 +1593,7 @@ public sealed class LiveEntityHydrationControllerTests
public readonly RecordingReadyPublisher Ready;
public readonly RecordingOrigin Origin;
public readonly RecordingNetworkSink Network = new();
+ public readonly RecordingTeardownCoordinator Teardown = new();
public readonly LiveEntityHydrationController Controller;
public Action? PublishTimestampsAction { get; set; }
@@ -1465,7 +1612,7 @@ public sealed class LiveEntityHydrationControllerTests
Runtime = new LiveEntityRuntime(
spatial,
Resources,
- new DelegateLiveEntityRuntimeComponentLifecycle(_ => { }));
+ Teardown);
Materializer = new RecordingMaterializer(Runtime, Operations);
Relationships = new RecordingRelationships(Operations);
Ready = new RecordingReadyPublisher(Operations);
@@ -1493,6 +1640,7 @@ public sealed class LiveEntityHydrationControllerTests
Ready,
Origin,
Network,
+ Teardown,
(guid, timestamps) =>
{
Operations.Add($"timestamps:{timestamps.Instance}");
@@ -1743,6 +1891,23 @@ public sealed class LiveEntityHydrationControllerTests
}
}
+ private sealed class RecordingTeardownCoordinator
+ : ILiveEntityTeardownCoordinator
+ {
+ public int TearDownCount { get; private set; }
+ public List ForgottenUnknownOwners { get; } = [];
+ public Action? TearDownAction { get; set; }
+
+ public void TearDown(LiveEntityRecord record)
+ {
+ TearDownCount++;
+ TearDownAction?.Invoke(record);
+ }
+
+ public void ForgetUnknownOwner(uint serverGuid) =>
+ ForgottenUnknownOwners.Add(serverGuid);
+ }
+
private static WorldSession.EntitySpawn Spawn(
ushort Generation,
ushort PositionSequence,
diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs
new file mode 100644
index 00000000..c03639bf
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs
@@ -0,0 +1,115 @@
+using AcDream.App.World;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.World;
+
+public sealed class LiveEntityRuntimeTeardownControllerTests
+{
+ [Fact]
+ public void Retry_ReusesPlanAndDoesNotReplayCompletedOwners()
+ {
+ LiveEntityRecord record = new(Spawn());
+ int factoryCalls = 0;
+ int completedOwnerCalls = 0;
+ int retryingOwnerCalls = 0;
+ bool failOnce = true;
+ var controller = new LiveEntityRuntimeTeardownController(
+ _ =>
+ {
+ factoryCalls++;
+ return new LiveEntityTeardownPlan([
+ () => completedOwnerCalls++,
+ () =>
+ {
+ retryingOwnerCalls++;
+ if (failOnce)
+ {
+ failOnce = false;
+ throw new InvalidOperationException("fixture failure");
+ }
+ },
+ ]);
+ },
+ _ => { });
+
+ Assert.Throws(() => controller.TearDown(record));
+ controller.TearDown(record);
+
+ Assert.Equal(1, factoryCalls);
+ Assert.Equal(1, completedOwnerCalls);
+ Assert.Equal(2, retryingOwnerCalls);
+ Assert.True(record.RuntimeComponentTeardownPlan!.IsComplete);
+ }
+
+ [Fact]
+ public void UnknownOwner_IsForwardedWithoutCreatingATeardownPlan()
+ {
+ uint forgotten = 0u;
+ int factoryCalls = 0;
+ var controller = new LiveEntityRuntimeTeardownController(
+ _ =>
+ {
+ factoryCalls++;
+ return new LiveEntityTeardownPlan([]);
+ },
+ guid => forgotten = guid);
+
+ controller.ForgetUnknownOwner(Guid);
+
+ Assert.Equal(Guid, forgotten);
+ Assert.Equal(0, factoryCalls);
+ }
+
+ private const uint Guid = 0x70000001u;
+ private const uint Cell = 0x01010001u;
+
+ private static WorldSession.EntitySpawn Spawn()
+ {
+ var position = new CreateObject.ServerPosition(
+ Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
+ var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)PhysicsStateFlags.ReportCollisions,
+ Position: position,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: 0x02000001u,
+ MotionTableId: 0x09000001u,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: null,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ Guid,
+ position,
+ 0x02000001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ "fixture",
+ (uint)ItemType.Creature,
+ null,
+ 0x09000001u,
+ PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+}