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